import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { CapabilityService } from "./capability";
import { handleJobReport } from "./server";
import type { ControllerStore } from "./store";

// Mirrors modules/workstation/claude/lib/k3s-remote-build.mjs's k3sConfig(): same env
// override, same config file, same keys. Not imported directly — that module targets
// controller/tsconfig.json's `allowJs: false` boundary (harness .mjs, not a controller
// dependency) — so the gate keys are mirrored here instead.
export const K3S_REMOTE_BUILD_FLAG = "BUILD_REMOTE_K3S";
export const DEFAULT_K3S_KUBECONFIG = join(homedir(), ".kube", "config-buildboxes");
const CONFIG_PATH = process.env.BUILD_REMOTE_CONFIG || join(homedir(), ".claude", "build-remote.json");

// Same label the submitter (k3s-remote-build.mjs buildJobManifest) puts on every pod, and
// the same container name ("build") its exitCodeFromPods doctrine reads from.
const POD_LABEL_SELECTOR = "app.kubernetes.io/name=overdeck-remote-build";
const BUILD_CONTAINER_NAME = "build";
const JOB_NAME_LABEL = "job-name";

// Annotation keys buildJobManifest stamps onto the pod template — the only honest source
// for report fields the k3s Job has no other way to carry (see k3s-remote-build.mjs).
const ANNOTATION_KEY = "overdeck.dev/build-key";
const ANNOTATION_MIRROR = "overdeck.dev/mirror";
const ANNOTATION_REPO = "overdeck.dev/repo";
const ANNOTATION_SNAPSHOT = "overdeck.dev/snapshot";
const ANNOTATION_TIMEOUT_SEC = "overdeck.dev/timeout-sec";

// The container args are always ["exec \"$@\"", "overdeck-remote-build", ...argv] —
// buildJobManifest's fixed wrapper prefix.
const ARGV_WRAPPER_PREFIX_LENGTH = 2;

export interface K3sWatcherConfig {
  enabled: boolean;
  namespace: string;
  kubeconfig: string;
}

function defaultReadConfig(): unknown {
  return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
}

export function k3sWatcherConfig(
  processEnv: NodeJS.ProcessEnv = process.env,
  readConfig: () => unknown = defaultReadConfig,
): K3sWatcherConfig {
  let raw: unknown = null;
  try {
    raw = readConfig();
  } catch {
    // absent/unreadable config = disabled, same fail-closed default as k3sConfig()
  }
  const cfg = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
  const envFlag = processEnv[K3S_REMOTE_BUILD_FLAG];
  const enabled = envFlag === "1" ? true : envFlag === "0" ? false : cfg.k3s_enabled === true;
  const namespace = processEnv.BUILD_REMOTE_K3S_NAMESPACE
    || (typeof cfg.k3s_namespace === "string" ? cfg.k3s_namespace : "default");
  const kubeconfig = processEnv.BUILD_REMOTE_K3S_KUBECONFIG
    || (typeof cfg.k3s_kubeconfig === "string" ? cfg.k3s_kubeconfig : DEFAULT_K3S_KUBECONFIG);
  return { enabled, namespace, kubeconfig };
}

interface KubeClient {
  server: string;
  ca: string;
  cert?: string;
  key?: string;
  token?: string;
}

function decodeBase64(value: string): string {
  return Buffer.from(value, "base64").toString("utf8");
}

export function loadKubeClient(
  kubeconfigPath: string,
  readFile: (path: string) => string = (path) => readFileSync(path, "utf8"),
): KubeClient {
  const doc = Bun.YAML.parse(readFile(kubeconfigPath)) as {
    "current-context"?: string;
    contexts?: Array<{ name: string; context: { cluster: string; user: string } }>;
    clusters?: Array<{ name: string; cluster: { server: string; "certificate-authority-data"?: string } }>;
    users?: Array<{ name: string; user: { "client-certificate-data"?: string; "client-key-data"?: string; token?: string } }>;
  };
  const contextName = doc["current-context"];
  const context = doc.contexts?.find((entry) => entry.name === contextName)?.context;
  if (!context) throw new Error(`kubeconfig has no current-context "${contextName}"`);
  const cluster = doc.clusters?.find((entry) => entry.name === context.cluster)?.cluster;
  const user = doc.users?.find((entry) => entry.name === context.user)?.user;
  if (!cluster) throw new Error(`kubeconfig has no cluster "${context.cluster}"`);
  if (!user) throw new Error(`kubeconfig has no user "${context.user}"`);
  const caData = cluster["certificate-authority-data"];
  if (!caData) throw new Error("kubeconfig cluster is missing certificate-authority-data");
  const client: KubeClient = { server: cluster.server, ca: decodeBase64(caData) };
  if (user["client-certificate-data"] && user["client-key-data"]) {
    client.cert = decodeBase64(user["client-certificate-data"]);
    client.key = decodeBase64(user["client-key-data"]);
  } else if (typeof user.token === "string" && user.token.length > 0) {
    client.token = user.token;
  } else {
    throw new Error("kubeconfig user has neither a client certificate nor a token");
  }
  return client;
}

export type K8sFetcher = (url: string, init: Record<string, unknown>) => Promise<Response>;

async function k8sGet(
  client: KubeClient,
  path: string,
  fetcher: K8sFetcher,
  timeoutMs: number,
): Promise<unknown> {
  const headers: Record<string, string> = {};
  if (client.token) headers.authorization = `Bearer ${client.token}`;
  const response = await fetcher(`${client.server}${path}`, {
    headers,
    tls: { ca: client.ca, cert: client.cert, key: client.key },
    signal: AbortSignal.timeout(timeoutMs),
  });
  if (!response.ok) {
    throw new Error(`k8s api ${path} returned ${response.status}`);
  }
  return response.json();
}

async function k8sGetText(
  client: KubeClient,
  path: string,
  fetcher: K8sFetcher,
  timeoutMs: number,
): Promise<string> {
  const headers: Record<string, string> = {};
  if (client.token) headers.authorization = `Bearer ${client.token}`;
  const response = await fetcher(`${client.server}${path}`, {
    headers,
    tls: { ca: client.ca, cert: client.cert, key: client.key },
    signal: AbortSignal.timeout(timeoutMs),
  });
  if (!response.ok) {
    throw new Error(`k8s api ${path} returned ${response.status}`);
  }
  return response.text();
}

interface K8sPod {
  metadata?: {
    name?: string;
    uid?: string;
    creationTimestamp?: string;
    labels?: Record<string, string>;
    annotations?: Record<string, string>;
  };
  spec?: {
    nodeName?: string;
    containers?: Array<{ name?: string; args?: string[] }>;
  };
  status?: {
    containerStatuses?: Array<{
      name?: string;
      state?: { terminated?: { exitCode?: number; startedAt?: string; finishedAt?: string } };
    }>;
  };
}

interface NewestTerminatedPod {
  jobName: string;
  pod: K8sPod;
  exitCode: number;
  startedAt: string;
  finishedAt: string;
}

// Mirrors k3s-remote-build.mjs's exitCodeFromPods: group by the Job's pods, newest
// creationTimestamp wins when several pods exist for the same job-name.
export function newestTerminatedPodsByJob(pods: K8sPod[]): NewestTerminatedPod[] {
  const byJob = new Map<string, { created: number; pod: K8sPod; terminated: { exitCode: number; startedAt: string; finishedAt: string } }>();
  for (const pod of pods) {
    const jobName = pod.metadata?.labels?.[JOB_NAME_LABEL];
    if (!jobName) continue;
    const terminated = pod.status?.containerStatuses?.find((entry) => entry.name === BUILD_CONTAINER_NAME)?.state?.terminated;
    if (!terminated || !Number.isInteger(terminated.exitCode) || !terminated.startedAt || !terminated.finishedAt) continue;
    const created = Date.parse(pod.metadata?.creationTimestamp ?? "") || 0;
    const existing = byJob.get(jobName);
    if (!existing || created > existing.created) {
      byJob.set(jobName, {
        created,
        pod,
        terminated: { exitCode: terminated.exitCode!, startedAt: terminated.startedAt, finishedAt: terminated.finishedAt },
      });
    }
  }
  return [...byJob.entries()].map(([jobName, entry]) => ({
    jobName,
    pod: entry.pod,
    exitCode: entry.terminated.exitCode,
    startedAt: entry.terminated.startedAt,
    finishedAt: entry.terminated.finishedAt,
  }));
}

export interface K3sJobReportBody {
  source: "remote-build";
  host: string;
  key: string;
  mirror: string;
  repo: string;
  snapshot: string;
  argv: string[];
  attempt: number;
  stage: "finished";
  rc: number;
  startedAt: string;
  finishedAt: string;
  timeoutSec: number;
}

// Builds the exact emitJobReport() JSON shape from a completed pod. Returns null when the
// pod is missing data the report requires (unannotated pod, no node assignment yet) —
// the caller skips it this poll and retries next tick rather than emitting a bad report.
export function buildReportFromPod(entry: NewestTerminatedPod): K3sJobReportBody | null {
  const { pod } = entry;
  const annotations = pod.metadata?.annotations ?? {};
  const key = annotations[ANNOTATION_KEY];
  const mirror = annotations[ANNOTATION_MIRROR];
  const repo = annotations[ANNOTATION_REPO];
  const snapshot = annotations[ANNOTATION_SNAPSHOT];
  const timeoutSecRaw = annotations[ANNOTATION_TIMEOUT_SEC];
  const host = pod.spec?.nodeName;
  const args = pod.spec?.containers?.find((entry2) => entry2.name === BUILD_CONTAINER_NAME)?.args ?? [];
  const argv = args.slice(ARGV_WRAPPER_PREFIX_LENGTH);
  const timeoutSec = Number(timeoutSecRaw);
  if (!key || !mirror || !repo || !snapshot || !host || argv.length === 0 || !Number.isFinite(timeoutSec) || timeoutSec <= 0) {
    return null;
  }
  return {
    source: "remote-build",
    host,
    key,
    mirror,
    repo,
    snapshot,
    argv,
    attempt: 1,
    stage: "finished",
    rc: entry.exitCode,
    startedAt: entry.startedAt,
    finishedAt: entry.finishedAt,
    timeoutSec,
  };
}

export interface K3sWatcherOptions {
  store: ControllerStore;
  capability: CapabilityService;
  processEnv?: NodeJS.ProcessEnv;
  readConfig?: () => unknown;
  readKubeconfig?: (path: string) => string;
  fetcher?: K8sFetcher;
  requestTimeoutMs?: number;
  pollIntervalMs?: number;
  backoffCapMs?: number;
  log?: (line: string) => void;
  writeLogs?: (jobName: string, text: string) => void;
  scheduleTimer?: (fn: () => void, ms: number) => unknown;
  clearScheduledTimer?: (handle: unknown) => void;
}

export interface K3sJobWatcher {
  /**
   * Runs one poll pass; resolves to the number of reports emitted (0 when disabled or idle
   * with nothing new to report). Rejects when the k3s API/kubeconfig is unreachable so a
   * caller can apply backoff — `run()`'s internal loop does exactly that.
   */
  tick(): Promise<number>;
  stop(): void;
}

const DEFAULT_POLL_INTERVAL_MS = 10_000;
const DEFAULT_BACKOFF_CAP_MS = 300_000;
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;

/**
 * Additive telemetry only: watches k3s pods for the label the submitter stamps and
 * re-emits the same report shape/route real reporters use (handleJobReport, in-process —
 * no HTTP hop, no token needed since it runs inside the controller). A watcher crash or an
 * unreachable API server must never affect any other controller path, so every failure is
 * caught locally and answered with exponential backoff (capped, reset on success) — never
 * a hot loop, never a thrown rejection into the caller.
 */
export function createK3sJobWatcher(options: K3sWatcherOptions): K3sJobWatcher {
  const {
    store,
    capability,
    processEnv = process.env,
    readConfig,
    readKubeconfig,
    fetcher = (fetch as unknown) as K8sFetcher,
    requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
    pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
    backoffCapMs = DEFAULT_BACKOFF_CAP_MS,
    log = (line: string) => process.stdout.write(`${line}\n`),
    writeLogs = (jobName: string, text: string) => process.stdout.write(`k3s-watcher: logs job=${jobName}\n${text}`),
    scheduleTimer = (fn: () => void, ms: number) => setTimeout(fn, ms),
    clearScheduledTimer = (handle: unknown) => clearTimeout(handle as Parameters<typeof clearTimeout>[0]),
  } = options;

  let reportedJobs = new Set<string>();
  let backoffMs = pollIntervalMs;
  let stopped = false;
  let timer: unknown;

  const reportOne = async (entry: NewestTerminatedPod): Promise<boolean> => {
    const body = buildReportFromPod(entry);
    if (!body) {
      log(`k3s-watcher: pod for job=${entry.jobName} missing report annotations — skipping this poll`);
      return false;
    }
    const request = new Request("http://controller.internal/jobs/report", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(body),
    });
    const response = await handleJobReport(request, store, capability);
    if (!response.ok) {
      log(`k3s-watcher: report rejected job=${entry.jobName} status=${response.status}`);
      return false;
    }
    log(`k3s-watcher: report accepted job=${entry.jobName} rc=${body.rc}`);
    return true;
  };

  const tick = async (): Promise<number> => {
    const cfg = k3sWatcherConfig(processEnv, readConfig);
    if (!cfg.enabled) return 0;

    // Kubeconfig-load and pod-list failures are the "k3s unreachable" case: rethrow so the
    // caller (loop, below) applies exponential backoff. Per-pod issues below are handled
    // without throwing — one bad report must not stall the whole poll.
    const client = loadKubeClient(cfg.kubeconfig, readKubeconfig);
    const podList = await k8sGet(
      client,
      `/api/v1/namespaces/${encodeURIComponent(cfg.namespace)}/pods?labelSelector=${encodeURIComponent(POD_LABEL_SELECTOR)}`,
      fetcher,
      requestTimeoutMs,
    );

    const pods = Array.isArray((podList as { items?: unknown })?.items) ? ((podList as { items: K8sPod[] }).items) : [];
    const newest = newestTerminatedPodsByJob(pods);
    const liveJobNames = new Set(newest.map((entry) => entry.jobName));
    reportedJobs = new Set([...reportedJobs].filter((jobName) => liveJobNames.has(jobName)));

    let emitted = 0;
    for (const entry of newest) {
      if (reportedJobs.has(entry.jobName)) continue;
      const ok = await reportOne(entry);
      if (!ok) continue;
      reportedJobs.add(entry.jobName);
      emitted += 1;
      const podName = entry.pod.metadata?.name;
      if (podName) {
        try {
          const logs = await k8sGetText(
            client,
            `/api/v1/namespaces/${encodeURIComponent(cfg.namespace)}/pods/${encodeURIComponent(podName)}/log?container=${encodeURIComponent(BUILD_CONTAINER_NAME)}`,
            fetcher,
            requestTimeoutMs,
          );
          writeLogs(entry.jobName, logs);
        } catch (error) {
          log(`k3s-watcher: log fetch failed job=${entry.jobName} (${(error as Error).message})`);
        }
      }
    }
    return emitted;
  };

  const scheduleNext = (delayMs: number) => {
    if (stopped) return;
    timer = scheduleTimer(loop, delayMs);
  };

  const loop = async () => {
    if (stopped) return;
    try {
      await tick();
      backoffMs = pollIntervalMs;
    } catch (error) {
      log(`k3s-watcher: tick threw (${(error as Error).message}) — backing off`);
      backoffMs = Math.min(backoffMs * 2, backoffCapMs);
    }
    scheduleNext(backoffMs);
  };

  scheduleNext(0);

  return {
    tick,
    stop() {
      stopped = true;
      if (timer !== undefined) clearScheduledTimer(timer);
    },
  };
}
