#!/usr/bin/env node
// Laptop-side seat launcher. Takes the WRAPPER-CONTRACT flag set, ships the workspace to a
// buildbox over the EXISTING remote-build transport, runs the wrapper inside a capped podman
// container there (seat-run.sh), brings the tree, the commits and the log back, and exits with
// the wrapper's own contract rc.
//
// Contract preservation is the whole point:
//   - same flags in, same rc out (0 / 2 / 3 / 75 / 124)
//   - the raw engine log lands in the caller's canonical log dir, same filename shape
//   - a transport failure is exit 3 (engine down, NOTHING dispatched), never a generic nonzero
//   - a dispatch whose outcome is UNKNOWN is exit 124 (non-completion, retry once) — asserting
//     3 there would let the orchestrator double-dispatch a live seat
import { spawnSync } from "node:child_process";
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { convergeCredentials } from "./seat-creds.mjs";

const HERE = dirname(fileURLToPath(import.meta.url));
const BUNDLED_WRAPPERS = resolve(HERE, "../wrappers");
const HOME = homedir();
const STATE_DIR = join(HOME, ".claude", "run", "local-gate");
const CANONICAL_LOG_DIR = join(HOME, "Projects", "mega-plan-harness", "tmp", "logs");

// exit 3 asserts "nothing dispatched"; exit 124 asserts "may have run, outcome unknown"
// `transport-grace-expired-after-start` is here and the plain `transport-grace-expired` is not:
// the first means the box vanished with the seat already dispatched (a reboot lands exactly here),
// the second means it vanished before anything went out. Calling a rebooted box "nothing
// dispatched" is what would let the orchestrator double-dispatch a seat that really ran.
const UNKNOWN_OUTCOME = /^(start-ambiguous|unexpected-job-state|invalid-job-meta|pull-failed|epoch-mismatch|invalid-epoch|epoch-read-failed|transport-grace-expired-after-start)/;
// remoting is switched OFF by config for this repo/run — not broken. The shim runs the wrapper
// locally instead. A host that is merely unreachable or full is NOT in here: silently moving an
// agent back onto the workstation is the thing this whole seam exists to prevent.
const RUN_LOCALLY = /^(disabled|local_only:)/;
export const RUN_LOCALLY_RC = 70;

function die(code, detail) {
  process.stderr.write(`${JSON.stringify({ ok: false, detail: `remote-seat: ${detail}` })}\n`);
  process.exit(code);
}

function loadTransport() {
  const candidates = [
    process.env.HARNESS_SEAT_TRANSPORT,
    resolve(HERE, "../../workstation/claude/lib/remote-build.mjs"),
    join(HOME, ".claude", "lib", "remote-build.mjs"),
  ].filter(Boolean);
  for (const path of candidates) if (existsSync(path)) return import(`file://${path}`);
  return null;
}

export function seatConfig() {
  const path = process.env.HARNESS_SEAT_CONFIG || join(HERE, "seat-remote.json");
  return JSON.parse(readFileSync(path, "utf8"));
}

export function adapterRemotable(cfg, adapter) {
  if (cfg.enabled !== true) return { ok: false, reason: "seat-remote.json disabled" };
  if (cfg.adapters?.[adapter] !== true) return { ok: false, reason: cfg.blocked?.[adapter] || `adapter ${adapter} not enabled for remote seats` };
  return { ok: true };
}

export const SEAT_CONTRACT_RC = new Set([0, 2, 3, 75, 124]);

// The transport speaks its own codes — 254 for a job whose unit vanished, and a `classification`
// other than remote-rc when it never saw an rc at all. A reboot produces both. Neither is a
// WRAPPER-CONTRACT code, and passing one through tells the orchestrator something false. What is
// true in every one of those cases is "it was dispatched, the outcome is unknown" = 124.
export function contractRc(result, log = () => {}) {
  if (result.classification && result.classification !== "remote-rc") {
    log(`seat outcome unknown: transport classification ${result.classification} (rc ${result.status}) — reporting 124`);
    return 124;
  }
  if (!SEAT_CONTRACT_RC.has(result.status)) {
    log(`seat outcome unknown: transport rc ${result.status} is not a contract code — reporting 124`);
    return 124;
  }
  return result.status;
}

export function classifyReason(reason) {
  const text = String(reason || "");
  if (RUN_LOCALLY.test(text)) return RUN_LOCALLY_RC;
  return UNKNOWN_OUTCOME.test(text) ? 124 : 3;
}

function parseArgs(argv) {
  const out = { extra: [] };
  const seen = new Set();
  const take = { "--adapter": "adapter", "--wrapper": "wrapper", "--workspace": "workspace", "--trust": "trust", "--task-slug": "taskSlug", "--model": "model", "--timeout": "timeout", "--profile": "profile", "--permission-mode": "permissionMode", "--resume": "resume", "--thread-id": "threadId" };
  for (let i = 0; i < argv.length; i += 1) {
    const flag = argv[i];
    const key = take[flag];
    if (!key) die(2, `unknown arg ${flag}`);
    if (i + 1 >= argv.length || (flag === "--permission-mode" && !argv[i + 1])) die(2, `${flag} requires a value`);
    if (flag === "--permission-mode" && seen.has(flag)) die(2, `${flag} may be specified once`);
    seen.add(flag);
    out[key] = argv[i + 1];
    i += 1;
  }
  if (out.permissionMode !== undefined && out.permissionMode !== "safe") die(2, "--permission-mode safe required");
  if (String(out.taskSlug || "").startsWith("incident-") && out.permissionMode !== "safe") die(2, "incident dispatch requires --permission-mode safe");
  return out;
}

function git(root, args, env) {
  return spawnSync("git", ["-C", root, ...args], { encoding: "utf8", env: env ? { ...process.env, ...env } : process.env });
}

const SEAT_EXCLUDES = ["/seat-prompt-*.txt", ".harness-seat/"];

// info/exclude in the COMMON dir, not the tracked .gitignore: the patterns are this machine's
// dispatch plumbing, they must not land in a diff of the user's repo, and one write covers every
// linked worktree. Idempotent — the same repo dispatching a thousand seats appends once.
export function ensureSeatExcludes(root) {
  const common = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).stdout?.trim();
  if (!common) return;
  const file = join(common, "info", "exclude");
  // an unreadable or unwritable exclude file is not a contract rc — the check-ignore backstop
  // downstream turns it into a legible die(3) instead of an uncaught throw
  try {
    const have = existsSync(file) ? readFileSync(file, "utf8") : "";
    const missing = SEAT_EXCLUDES.filter((p) => !have.split("\n").some((l) => l.trim() === p));
    if (!missing.length) return;
    mkdirSync(dirname(file), { recursive: true });
    writeFileSync(file, `${have}${have && !have.endsWith("\n") ? "\n" : ""}# harness seat dispatch (modules/harness/seat)\n${missing.join("\n")}\n`);
  } catch {}
}

function filesUnder(root, dir = root) {
  return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
    const path = join(dir, entry.name);
    return entry.isDirectory() ? filesUnder(root, path) : [relative(root, path)];
  });
}

// .git is in the transport's pull excludes (a build never produces commits) — a SEAT does.
// Fetch the mirror's branch back and fast-forward, so the orchestrator's HEAD-before/after
// sees exactly the commits the agent made.
function mirrorUrl(transport, root, host) {
  const cfg = { ...transport.loadRemoteConfig(), host };
  return {
    url: `ssh://${cfg.ssh_user}@${cfg.host}:${cfg.port}${cfg.remote_root}/${transport.mirrorName(root)}`,
    env: { GIT_SSH_COMMAND: `ssh ${transport.sshOpts(cfg).join(" ")}` },
  };
}

// A seat killed by a reboot never committed. The box's reconciler parks its working tree under
// refs/harness-seat/<runId> in the mirror; this brings that ref home. It is deliberately NOT
// merged or checked out — the run did not finish, and silently mutating the caller's tree with a
// half-done agent's edits would be worse than losing them. The ref is the handle to recover from.
export function fetchRescueRef(transport, root, host, runId, log) {
  const ref = `refs/harness-seat/${runId}`;
  const { url, env } = mirrorUrl(transport, root, host);
  const listed = git(root, ["ls-remote", url, ref], env);
  if (listed.status !== 0 || !String(listed.stdout).trim()) return null;
  if (git(root, ["fetch", "-q", url, `+${ref}:${ref}`], env).status !== 0) { log(`seat rescue: fetch of ${ref} failed`); return null; }
  const sha = String(git(root, ["rev-parse", ref]).stdout).trim();
  log(`seat rescue: the buildbox rebooted mid-run; uncommitted seat work is at ${ref} (${sha.slice(0, 12)}) — inspect with: git diff HEAD ${ref}`);
  return ref;
}

export function fetchCommitsBack(transport, root, host, log) {
  const { url, env } = mirrorUrl(transport, root, host);
  const remoteHead = git(root, ["ls-remote", url, "HEAD"], env);
  if (remoteHead.status !== 0) { log(`seat fetch-back skipped: ls-remote failed`); return; }
  const sha = String(remoteHead.stdout).trim().split(/\s+/)[0];
  if (!/^[0-9a-f]{40}$/.test(sha)) { log("seat fetch-back skipped: no remote HEAD"); return; }
  if (String(git(root, ["cat-file", "-e", `${sha}^{commit}`]).status) === "0") return; // already local
  if (git(root, ["fetch", "-q", url, sha], env).status !== 0) { log("seat fetch-back: fetch failed"); return; }
  const local = String(git(root, ["rev-parse", "HEAD"]).stdout).trim();
  if (git(root, ["merge-base", "--is-ancestor", local, sha]).status !== 0) { log(`seat fetch-back: ${sha.slice(0, 12)} does not descend from local HEAD — left in FETCH_HEAD`); return; }
  replayAgentCommits(root, local, sha, log);
}

// The transport pushes a "rb snapshot" commit (author rb <rb@local>) carrying the dirty tree, so
// the mirror's history is snapshot + the agent's own commits. Fast-forwarding would drag the
// snapshot into the caller's history. Replay the AGENT's commits — theirs alone — onto local HEAD.
export function replayAgentCommits(root, local, remoteSha, log) {
  const listed = git(root, ["rev-list", "--reverse", `${local}..${remoteSha}`]);
  if (listed.status !== 0) { log("seat fetch-back: rev-list failed"); return; }
  const commits = String(listed.stdout).trim().split("\n").filter(Boolean);
  let parent = local;
  let replayed = 0;
  for (const commit of commits) {
    const [email, name, date, subject] = ["%ae", "%an", "%aI", "%B"].map((fmt) => String(git(root, ["show", "-s", `--format=${fmt}`, commit]).stdout));
    if (email.trim() === "rb@local") continue;
    const tree = String(git(root, ["rev-parse", `${commit}^{tree}`]).stdout).trim();
    const made = git(root, ["commit-tree", tree, "-p", parent, "-m", subject.trimEnd()], {
      GIT_AUTHOR_NAME: name.trim(), GIT_AUTHOR_EMAIL: email.trim(), GIT_AUTHOR_DATE: date.trim(),
      GIT_COMMITTER_NAME: name.trim(), GIT_COMMITTER_EMAIL: email.trim(), GIT_COMMITTER_DATE: date.trim(),
    });
    if (made.status !== 0) { log(`seat fetch-back: commit-tree failed at ${commit.slice(0, 12)}`); return; }
    parent = String(made.stdout).trim();
    replayed += 1;
  }
  if (!replayed) { log("seat fetch-back: seat produced no commits"); return; }
  const reset = git(root, ["reset", "--mixed", parent]);
  log(reset.status === 0
    ? `seat fetch-back: replayed ${replayed} seat commit(s), HEAD -> ${parent.slice(0, 12)}`
    : "seat fetch-back: reset failed — replayed commits are unreferenced");
}

function harvestLogs(logDirAbs, runId, log) {
  if (!existsSync(logDirAbs)) return null;
  let status = null;
  let identity = null;
  mkdirSync(CANONICAL_LOG_DIR, { recursive: true });
  for (const name of readdirSync(logDirAbs)) {
    const src = join(logDirAbs, name);
    if (!statSync(src).isFile()) continue;
    if (name === "status.json") { status = readFileSync(src, "utf8").trim(); continue; }
    if (name === "identity.json") { identity = readFileSync(src, "utf8").trim(); continue; }
    // engine logs already carry a timestamp+pid; seat-run.stderr is fixed-name and would be
    // overwritten by every concurrent seat, so it takes the run id.
    const dest = name === "seat-run.stderr" ? `${runId}-${name}` : name;
    try { copyFileSync(src, join(CANONICAL_LOG_DIR, dest)); } catch (err) { log(`seat log copy failed ${name}: ${err.message}`); }
  }
  writeHarvestedIdentity(identity);
  return status;
}

function writeHarvestedIdentity(identityText) {
  const dest = process.env.HARNESS_IDENTITY_FILE;
  if (!dest || !identityText) return;
  try {
    const parsed = JSON.parse(identityText);
    if (parsed?.kind !== "wrapper.identity") return;
    if (typeof parsed.account !== "string" || !parsed.account) return;
    const line = `${JSON.stringify({ kind: "wrapper.identity", account: parsed.account })}\n`;
    const tmp = `${dest}.tmp`;
    writeFileSync(tmp, line);
    renameSync(tmp, dest);
  } catch {
    // invalid/absent => write nothing
  }
}

export async function runRemoteSeat(args) {
  const transportMod = await loadTransport();
  if (!transportMod) die(3, "remote-build transport not found");
  const t = transportMod;
  const cfg = seatConfig();
  const gate = adapterRemotable(cfg, args.adapter);
  if (!gate.ok) die(3, gate.reason);

  // Decided before anything is written or checked: with remoting switched off in
  // build-remote.json nothing about this repo needs to satisfy the seat's preconditions.
  if (!t.loadRemoteConfig()) {
    process.stderr.write("seat-remote: local dispatch — remote build disabled in build-remote.json\n");
    process.exit(RUN_LOCALLY_RC);
  }

  const workspace = resolve(args.workspace);
  if (!existsSync(workspace)) die(2, "--workspace missing or not a dir");
  const root = t.projectRoot(workspace);
  if (!root) die(3, "workspace is not inside a git repo — the seat transport mirrors repos");
  const wsRel = relative(root, workspace) || ".";

  const runId = `${new Date().toISOString().replace(/[^0-9]/g, "").slice(0, 14)}-${process.pid}`;
  const slug = String(args.taskSlug || "").replace(/[^a-zA-Z0-9_-]/g, "");
  if (!slug) die(2, "--task-slug required");
  if (!args.trust) die(2, "--trust (prompt) required");

  const promptRel = `seat-prompt-${runId}.txt`;
  const runtimeRel = join(".harness-seat", runId, "runtime");
  const runtimeAbs = join(root, runtimeRel);
  const runnerRel = join(runtimeRel, "seat", "seat-run.sh");
  const runnerAbs = join(root, runnerRel);
  const logRel = join(".harness-seat", runId, "logs");
  // The prompt and the seat logs ride the transport's IGNORED-file overlay
  // (`ls-files -o -i --exclude-standard`), so they must be ignored in every repo a seat runs in —
  // not only the ones whose tracked .gitignore happens to carry the patterns.
  ensureSeatExcludes(root);
  const log = (m) => process.stderr.write(`${m}\n`);

  let result;
  try {
    cpSync(HERE, join(runtimeAbs, "seat"), { recursive: true });
    cpSync(BUNDLED_WRAPPERS, join(runtimeAbs, "wrappers"), { recursive: true });
    if (git(root, ["check-ignore", "-q", runnerRel]).status !== 0) {
      throw new Error(`${basename(root)} does not ignore the seat dispatch overlay`);
    }

    // Every eligible host, not just the one the transport picks: the transport chooses after this
    // point, and a box that is skipped today is dispatched to tomorrow. A host already holding the
    // current digests costs nothing — no connection is opened.
    try { convergeCredentials({ transport: t, stateDir: STATE_DIR, log }); }
    catch (err) { log(`seat creds: convergence skipped — ${err?.message || err}`); }

    const limits = cfg.limits || {};
    const argv = [
      "bash", join(root, runnerRel),
      "--run-id", runId,
      "--wrapper", basename(args.wrapper),
      "--repo-root", root,
      "--workspace-rel", wsRel,
      "--prompt-file", promptRel,
      "--log-rel", logRel,
      "--task-slug", slug,
      "--image", cfg.image,
      "--memory", String(limits.memory ?? "4g"),
      "--cpus", String(limits.cpus ?? "2"),
      "--pids", String(limits.pids ?? 512),
      "--tmpfs-size", String(limits.tmpfsSize ?? "1g"),
      "--max-seats", String(limits.maxConcurrentPerHost ?? 3),
    ];
    if (limits.cgroupParent) argv.push("--cgroup-parent", String(limits.cgroupParent));
    const gitName = String(git(root, ["config", "user.name"]).stdout || "").trim();
    const gitEmail = String(git(root, ["config", "user.email"]).stdout || "").trim();
    if (gitName) argv.push("--git-name", gitName);
    if (gitEmail) argv.push("--git-email", gitEmail);
    for (const [flag, value] of [["--model", args.model], ["--timeout", args.timeout], ["--profile", args.profile], ["--permission-mode", args.permissionMode], ["--resume", args.resume], ["--thread-id", args.threadId]]) {
      if (value) argv.push(flag, String(value));
    }

    // The gitignored runtime is an explicit per-run transport overlay.
    const overlayFiles = filesUnder(root, runtimeAbs);
    const loadRemoteConfig = () => {
      const base = t.loadRemoteConfig();
      return base ? { ...base, ship_ignored: [...(base.ship_ignored || []), ...overlayFiles] } : base;
    };
    result = t.tryRemoteBuild({
      key: `seat-${runId}`,
      argv,
      cwd: root,
      stateDir: STATE_DIR,
      log,
      privateInput: args.trust,
      cleanupRemote: [runtimeRel],
    }, { loadRemoteConfig });
  } finally {
    try { rmSync(runtimeAbs, { recursive: true, force: true }); } catch {}
  }

  if (!result.ran) {
    const code = classifyReason(result.reason);
    if (code === RUN_LOCALLY_RC) {
      process.stderr.write(`seat-remote: local dispatch — ${result.reason}\n`);
      process.exit(RUN_LOCALLY_RC);
    }
    die(code, `remote seat not run: ${result.reason}`);
  }

  let host = "";
  try { host = readFileSync(join(STATE_DIR, `${t.mirrorName(root)}.host`), "utf8").trim(); } catch {}
  if (host) {
    fetchCommitsBack(t, root, host, log);
    if (!SEAT_CONTRACT_RC.has(result.status) || result.status === 124) fetchRescueRef(t, root, host, runId, log);
  }

  const logDirAbs = join(root, logRel);
  const status = harvestLogs(logDirAbs, runId, log);
  try { rmSync(join(root, ".harness-seat", runId), { recursive: true, force: true }); } catch {}
  if (status) process.stdout.write(`${status}\n`);
  return contractRc(result, log);
}

const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
  const raw = process.argv.slice(2);
  if (raw[0] === "--check") {
    // 0 = this adapter runs remotely, 1 = run it locally. The wrapper shim branches on this.
    if (process.env.HARNESS_SEAT_CONTAINER === "1") process.exit(1);
    let verdict;
    try { verdict = adapterRemotable(seatConfig(), raw[1] || ""); } catch (err) { verdict = { ok: false, reason: `seat config unreadable: ${err.message}` }; }
    if (!verdict.ok) process.stderr.write(`seat-remote: local dispatch — ${verdict.reason}\n`);
    process.exit(verdict.ok ? 0 : 1);
  }
  const args = parseArgs(raw);
  if (!args.adapter || !args.wrapper) die(2, "--adapter and --wrapper required");
  if (!args.workspace || !existsSync(resolve(args.workspace))) die(2, "--workspace missing or not a dir");
  if (!args.trust) die(2, "--trust (prompt) required");
  if (!String(args.taskSlug || "").replace(/[^a-zA-Z0-9_-]/g, "")) die(2, "--task-slug required");
  runRemoteSeat(args).then((code) => process.exit(code), (err) => die(3, `unexpected: ${err?.message || err}`));
}
