#!/usr/bin/env bun
/**
 * Host-side continuous-deploy trigger (#1275).
 *
 * This deliberately runs on the relay host: GitHub Actions cannot reach the
 * Tailnet relay. It reuses release.ts's deploy-only path, so all existing
 * interlocks, version verification and double health gates remain the deploy
 * authority. A failed child leaves the ledger failed and never stamps Tasks.
 */
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { errMessage } from "agent-relay-sdk";
import { finishDeployEvent, getDeployEvent, initDb, latestSuccessfulDeploy, stampDeployedTasks, stampVerifiedTasks, tryBeginDeployEvent } from "../src/db.ts";
import { runLiveSpawnSmoke } from "./release-upgrade.ts";
import { CD_WATCHER_INITIATOR, resolveCanonicalRelayDbPath, shouldRunCdWatcherOnThisHost } from "./cd-config.ts";

export { shouldRunCdWatcherOnThisHost } from "./cd-config.ts";

type Command = { code: number; out: string; err: string };
type Run = (cmd: string, args: string[], cwd?: string) => Command;
const run: Run = (cmd, args, cwd) => {
  const result = spawnSync(cmd, args, { cwd, encoding: "utf8" });
  return { code: result.status ?? 1, out: (result.stdout ?? "").trim(), err: (result.stderr ?? "").trim() };
};

export function greenMainSha(runCommand: Run, root: string): string | undefined {
  if (runCommand("git", ["fetch", "origin", "main"], root).code !== 0) throw new Error("git fetch origin main failed");
  const sha = runCommand("git", ["rev-parse", "origin/main"], root).out;
  if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error(`origin/main did not resolve to a SHA: ${sha || "empty"}`);
  // ci.yml is the push-to-main test gate. release.yml is tag-triggered publishing
  // and cannot make ordinary green-main commits eligible for CD.
  const ci = runCommand("gh", ["run", "list", "--workflow", "ci.yml", "--commit", sha, "--limit", "20", "--json", "headSha,headBranch,event,status,conclusion"], root);
  if (ci.code !== 0) throw new Error(`could not query CI for ${sha}: ${ci.err || ci.out}`);
  const rows = JSON.parse(ci.out || "[]") as Array<{ headSha?: string; headBranch?: string; event?: string; status?: string; conclusion?: string }>;
  return rows.some((row) => row.headSha === sha && row.headBranch === "main" && row.event === "push" && row.status === "completed" && row.conclusion === "success") ? sha : undefined;
}

export function commitsInDeployRange(runCommand: Run, root: string, previousSha: string | undefined, sha: string): string[] {
  // With no prior successful deploy, `git rev-list <sha>` means all history and
  // corrupts lead-time metrics. The validated tip is the only safe first range.
  if (!previousSha) return [sha];
  const range = `${previousSha}..${sha}`;
  const result = runCommand("git", ["rev-list", "--reverse", range], root);
  if (result.code !== 0) throw new Error(`could not list commits in ${range}: ${result.err || result.out}`);
  return result.out.split("\n").filter((line) => /^[0-9a-f]{40}$/i.test(line));
}

/** Git artifacts are the CD default; npm remains the explicit emergency fallback. */
export function cdDeployArgs(sha: string, mode = process.env.AGENT_RELAY_CD_ARTIFACT_MODE ?? "git-checkout", deployEventId?: string): string[] {
  if (mode !== "git-checkout" && mode !== "npm-reuse") throw new Error(`invalid CD artifact mode "${mode}"`);
  const args = mode === "git-checkout"
    ? ["run", "scripts/release.ts", "--deploy-only", "--no-smoke", "--artifact", "git-checkout", "--commit", sha]
    : ["run", "scripts/release.ts", "--deploy-only", "--no-smoke", "--artifact", "npm-reuse"];
  return deployEventId ? [...args, "--deploy-event", deployEventId] : args;
}

/** Best-effort `liveIn` label for the deployed SHA — the deployed worktree's own version,
 * falling back to the SHA itself if the checkout is unreadable for any reason. */
export function resolveDeployedVersion(worktree: string, sha: string): string {
  try {
    const pkg = JSON.parse(readFileSync(join(worktree, "package.json"), "utf8")) as { version?: string };
    return typeof pkg.version === "string" && pkg.version ? pkg.version : sha;
  } catch {
    return sha;
  }
}

export function runCdWatcher(opts: { root: string; dbPath: string; runCommand?: Run } = { root: process.cwd(), dbPath: resolveCanonicalRelayDbPath() }): "skipped" | "deployed" | "failed" {
  const command = opts.runCommand ?? run;
  initDb(opts.dbPath);
  const sha = greenMainSha(command, opts.root);
  if (!sha) return "skipped";
  const previous = latestSuccessfulDeploy();
  if (previous?.commitSha === sha) return "skipped";
  const event = tryBeginDeployEvent({ commitSha: sha, hosts: [], initiatedBy: CD_WATCHER_INITIATOR });
  // A recent same-SHA pending row is self-overlap; any pending row is the fleet
  // mutex. Neither may launch a second full-fleet deploy.
  if (!event) return "skipped";
  const commits = commitsInDeployRange(command, opts.root, previous?.commitSha, sha);
  const worktree = mkdtempSync(join(tmpdir(), "agent-relay-cd-"));
  try {
    const add = command("git", ["worktree", "add", "--detach", worktree, sha], opts.root);
    if (add.code !== 0) throw new Error(`could not prepare deploy worktree: ${add.err || add.out}`);
    // deploy-only is intentional: it is the existing, proven command-bus fan-out.
    // Its git-checkout mode consumes the green SHA directly; npm-reuse is opt-in.
    const deploy = command("bun", cdDeployArgs(sha, undefined, event.id), worktree);
    if (deploy.code !== 0) throw new Error(`fleet deploy failed: ${deploy.err || deploy.out}`);
    // release.ts normally owns this attached row and persists real host statuses.
    // Keep a compatible fallback for a legacy/interrupted deploy child.
    const completed = getDeployEvent(event.id);
    if (completed?.status === "pending") finishDeployEvent({ id: event.id, status: "succeeded", hosts: completed.hosts });
    if (getDeployEvent(event.id)?.status !== "succeeded") throw new Error("fleet deploy did not complete successfully in the deploy ledger");
    stampDeployedTasks({ deployId: event.id, commitShas: commits });
    // Fleet-level proof-of-life (#1102): the deploy fan-out above runs with --no-smoke (deploy
    // and verify are separate concerns), so the smoke runs here, against the deployed worktree,
    // with the deployId already in scope to attribute stage:verified correctly. A failed smoke
    // does not fail the deploy itself (see release-upgrade.ts's runLiveSpawnSmoke) — it just
    // withholds the verified stamp; the fleet already shipped.
    const smokeCode = runLiveSpawnSmoke({
      noSmoke: false,
      verbose: false,
      log: (msg) => console.log(msg),
      sh: (smokeCmd, smokeArgs) => command(smokeCmd, smokeArgs, worktree),
    });
    if (smokeCode === 0) {
      stampVerifiedTasks({ deployId: event.id, commitShas: commits, liveIn: resolveDeployedVersion(worktree, sha) });
    }
    return "deployed";
  } catch (error) {
    const current = getDeployEvent(event.id);
    if (current?.status === "pending") finishDeployEvent({ id: event.id, status: "failed", hosts: [...current.hosts, { id: "fleet", status: "failed", reason: errMessage(error) }] });
    return "failed";
  } finally {
    command("git", ["worktree", "remove", "--force", worktree], opts.root);
    rmSync(worktree, { recursive: true, force: true });
  }
}

if (import.meta.main) {
  // The scheduler must make the same check before scheduling a tick. Keeping it
  // here too makes direct/cron execution fail closed rather than creating a
  // second host-local watcher.
  if (!shouldRunCdWatcherOnThisHost()) {
    console.log("cd-watcher: skipped (this host is not the designated CD control host)");
    process.exit(0);
  }
  const result = runCdWatcher({ root: process.env.AGENT_RELAY_CD_REPO ?? process.cwd(), dbPath: resolveCanonicalRelayDbPath() });
  console.log(`cd-watcher: ${result}`);
  process.exitCode = result === "failed" ? 1 : 0;
}
