// Placement of harness/factory runs onto a buildbox: the laptop submits and attaches,
// the box executes. A run lives in ~/runs/<run-id> (outside the remote-build GC root)
// under a systemd-owned tmux server in agent-run.slice, so detaching the terminal never
// ends the run and the run's processes are bounded by the slice.
import { homeRel, shellHomePath, shellPathAssign } from "./home-path.mjs";
import { execFileSync } from "node:child_process";
import { basename, isAbsolute, resolve as resolvePath } from "node:path";
import { remoteTransportBashS, shq, sshOpts, sshTarget } from "./remote-ssh.mjs";

export const RUN_SLICE = "agent-run.slice";
export const RUN_TMUX_SESSION = "main";
// Distinct from any run's own exit status: the caller detached while the run continues.
export const RUN_DETACHED_EXIT = 98;
// The run wrote no exit code and its unit is gone: systemd killed the cgroup
// (RuntimeMaxSec, OOMPolicy, reboot). Never reportable as success.
export const RUN_KILLED_EXIT = 99;
// Status could not be established; the run may still be live on the box.
export const RUN_WAIT_UNKNOWN_EXIT = 96;
export const RUN_PLACED_ENV = "RUN_REMOTE_PLACED";
export const DEFAULT_RUNTIME_MAX_SEC = 86400;

const RUN_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
const GIT_DIR_NAME_RE = /^[A-Za-z0-9._-]+$/;

export function assertSafeRunId(runId) {
  if (typeof runId !== "string" || !RUN_ID_RE.test(runId)) {
    throw new Error(`unsafe run id: ${JSON.stringify(runId)}`);
  }
  return runId;
}

export function newRunId(prefix = "run", now = new Date()) {
  const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "Z");
  const rand = Math.random().toString(36).slice(2, 8);
  return assertSafeRunId(`${prefix}-${stamp}-${rand}`);
}

export function runUnitName(runId) {
  return `agent-run-${assertSafeRunId(runId)}.service`;
}

export function runDirShellPath(runId) {
  return shellHomePath(homeRel("runs", assertSafeRunId(runId)));
}

export function runRepoShellPath(runId) {
  return shellHomePath(homeRel("runs", assertSafeRunId(runId), "repo"));
}

export function runStateShellPath(runId) {
  return shellHomePath(homeRel("runs", assertSafeRunId(runId), "state"));
}

export function runSocketShellPath(runId) {
  return shellHomePath(homeRel("runs", assertSafeRunId(runId), "tmux.sock"));
}

export function runLogShellPath(runId) {
  return shellHomePath(homeRel("runs", assertSafeRunId(runId), "state", "output.log"));
}

// A killed run takes its tmux server down with it, so capture-pane has nothing left to
// read on exactly the failure that most needs explaining; the piped log is on disk.
export function buildRunLogTailScript(runId, lines = 200) {
  if (!Number.isInteger(lines) || lines < 1) throw new Error(`invalid tail length: ${lines}`);
  return [
    "set -euo pipefail",
    shellPathAssign("log", runLogShellPath(runId)),
    `[ -s "$log" ] || { echo run-log-absent; exit 1; }`,
    `tail -n ${lines} "$log"`,
  ].join("\n");
}

export function resolveGitMirror(repoRoot, deps = {}) {
  const exec = deps.execFileSync || execFileSync;
  let out = "";
  try {
    out = String(exec("git", ["-C", repoRoot, "rev-parse", "--absolute-git-dir", "--git-common-dir"], { encoding: "utf8" }));
  } catch (error) {
    const status = Number(error.status);
    const stderr = String(error.stderr ?? error.message ?? "").toLowerCase();
    if (status === 128 && (stderr.includes("not a git repository") || stderr.includes("not a git work tree"))) {
      return null;
    }
    throw error;
  }
  const lines = out.trim().split(/\r?\n/).filter(Boolean);
  if (lines.length !== 2) {
    throw new Error(`unexpected git output for run-remote mirror: ${JSON.stringify(out)}`);
  }
  const [gitDirRaw, commonDirRaw] = lines;
  const gitDir = isAbsolute(gitDirRaw) ? resolvePath(gitDirRaw) : resolvePath(repoRoot, gitDirRaw);
  const commonDir = isAbsolute(commonDirRaw) ? resolvePath(commonDirRaw) : resolvePath(repoRoot, commonDirRaw);
  return {
    gitDir,
    commonDir,
    gitDirName: basename(gitDir),
  };
}

// exit-empty/destroy-unattached keep the server and session alive with no client;
// remain-on-exit keeps the finished pane's scrollback readable on a later attach.
const RUN_TMUX_CONF = [
  "set -g exit-empty off",
  "set -g destroy-unattached off",
  "set -g detach-on-destroy on",
  "set -g remain-on-exit on",
  "set -g history-limit 200000",
  "set -g allow-passthrough on",
  "set -g mouse on",
].join("\n");

function heredoc(varPath, body, marker) {
  return [`cat > ${varPath} <<'${marker}'`, body, marker];
}

export function buildRunProvisionScript({ runId }) {
  assertSafeRunId(runId);
  return [
    "set -euo pipefail",
    shellPathAssign("run_dir", runDirShellPath(runId)),
    shellPathAssign("repo", runRepoShellPath(runId)),
    shellPathAssign("state", runStateShellPath(runId)),
    'mkdir -p "$repo" "$state"',
    'chmod 700 "$run_dir"',
    "command -v tmux >/dev/null || { echo run-tmux-missing; exit 1; }",
    "command -v node >/dev/null || { echo run-node-missing; exit 1; }",
    "echo run-provision-ok",
  ].join("\n");
}

export function buildRunLaunchScript({
  runId,
  command,
  slice = RUN_SLICE,
  runtimeMaxSec = DEFAULT_RUNTIME_MAX_SEC,
  env = {},
  cols = 200,
  rows = 50,
}) {
  assertSafeRunId(runId);
  if (typeof command !== "string" || !command.trim()) throw new Error("run command required");
  if (!Number.isInteger(runtimeMaxSec) || runtimeMaxSec < 60) {
    throw new Error(`invalid runtimeMaxSec: ${runtimeMaxSec}`);
  }
  const unit = runUnitName(runId);
  // Children of the coordinator are placed explicitly: a nested `systemd-run --scope`
  // does NOT inherit its caller's slice (measured: it lands in app.slice), so without
  // this the dispatched agents would escape agent-run.slice entirely.
  const exports = Object.entries({ HARNESS_CHILD_SLICE: slice, ...env })
    .map(([k, v]) => `export ${k}=${shq(String(v))}`);
  const entry = [
    "#!/bin/bash",
    shellPathAssign("run_repo", runRepoShellPath(runId)),
    shellPathAssign("run_state", runStateShellPath(runId)),
    'cd "$run_repo" || exit 97',
    'exec > >(tee -a "$run_state/output.log") 2>&1',
    // A non-interactive ssh shell reads no profile, so the deployed workstation shims
    // are absent from PATH unless they are put there here. Appended, never prepended:
    // ~/.claude/bin/git is the land-queue shim, and a placed run must reach /usr/bin/git
    // to push its results back.
    'export PATH="$PATH:$HOME/.claude/bin:$HOME/.local/bin"',
    ...exports,
    "set +e",
    // The subshell is load-bearing: a top-level `exit` in the command would otherwise
    // end this script before the exit code is recorded, and a recorded-nothing run is
    // indistinguishable from one the kernel killed.
    "(",
    command,
    ")",
    "rc=$?",
    'printf "%s" "$rc" > "$run_state/exit-code.tmp"',
    'mv "$run_state/exit-code.tmp" "$run_state/exit-code"',
    'printf "\\n[run-remote] finished rc=%s\\n" "$rc"',
    "exit $rc",
  ].join("\n");
  return [
    "set -euo pipefail",
    `unit=${shq(unit)}`,
    `slice=${shq(slice)}`,
    shellPathAssign("run_dir", runDirShellPath(runId)),
    shellPathAssign("repo", runRepoShellPath(runId)),
    shellPathAssign("state", runStateShellPath(runId)),
    shellPathAssign("sock", runSocketShellPath(runId)),
    '[ -d "$repo" ] || { echo run-repo-missing; exit 1; }',
    'mkdir -p "$state"',
    'if systemctl --user is-active --quiet "$unit"; then echo run-already-active; exit 1; fi',
    'rm -f "$sock" "$state/exit-code"',
    ...heredoc('"$state/tmux.conf"', RUN_TMUX_CONF, "RUN_TMUX_CONF_EOF"),
    ...heredoc('"$state/entry.sh"', entry, "RUN_ENTRY_EOF"),
    'chmod 700 "$state/entry.sh"',
    "systemd-run --user --quiet --collect \\",
    '  --unit="$unit" --slice="$slice" --service-type=forking \\',
    `  -p RuntimeMaxSec=${runtimeMaxSec} -p OOMPolicy=kill -p KillMode=control-group \\`,
    '  -- /usr/bin/tmux -S "$sock" -f "$state/tmux.conf" start-server',
    "for _ in $(seq 1 100); do [ -S \"$sock\" ] && break; sleep 0.1; done",
    '[ -S "$sock" ] || { echo run-tmux-socket-missing; exit 1; }',
    `tmux -S "$sock" new-session -d -s ${shq(RUN_TMUX_SESSION)} -c "$repo" -x ${cols} -y ${rows} ` +
      '"exec bash \\"$state/entry.sh\\""',
    `tmux -S "$sock" has-session -t ${shq(RUN_TMUX_SESSION)} || { echo run-session-missing; exit 1; }`,
    'printf "run-launched unit=%s socket=%s\\n" "$unit" "$sock"',
  ].join("\n");
}

export function buildRunStatusScript(runId) {
  assertSafeRunId(runId);
  const unit = runUnitName(runId);
  return [
    "set -euo pipefail",
    `unit=${shq(unit)}`,
    shellPathAssign("state", runStateShellPath(runId)),
    shellPathAssign("sock", runSocketShellPath(runId)),
    "service=inactive",
    "session=absent",
    "exit_code=none",
    'if systemctl --user is-active --quiet "$unit"; then service=active; fi',
    `if [ -S "$sock" ] && tmux -S "$sock" has-session -t ${shq(RUN_TMUX_SESSION)} 2>/dev/null; then session=present; fi`,
    'if [ -s "$state/exit-code" ]; then exit_code=$(cat "$state/exit-code"); fi',
    'printf "service=%s session=%s exit_code=%s\\n" "$service" "$session" "$exit_code"',
  ].join("\n");
}

export function buildRunStopScript(runId) {
  assertSafeRunId(runId);
  const unit = runUnitName(runId);
  return [
    "set -euo pipefail",
    `unit=${shq(unit)}`,
    shellPathAssign("sock", runSocketShellPath(runId)),
    // Stopping the unit kills the whole control group; the tmux server is the unit's
    // main process, so no process-name matching is ever involved.
    'if systemctl --user is-active --quiet "$unit"; then systemctl --user stop "$unit"; fi',
    "sleep 0.5",
    'if systemctl --user is-active --quiet "$unit"; then echo run-still-active; exit 1; fi',
    'rm -f "$sock"',
    "echo run-stopped",
  ].join("\n");
}

export function buildRunExitCodeScript(runId) {
  assertSafeRunId(runId);
  return [
    "set -euo pipefail",
    shellPathAssign("state", runStateShellPath(runId)),
    'if [ -s "$state/exit-code" ]; then cat "$state/exit-code"; else echo none; fi',
  ].join("\n");
}

// A run executes as the box's normal user, so it attaches with plain tmux — unlike a
// seat, which needs the root-mediated socket because each seat has its own uid.
export function buildRunAttachInvocation(cfg, runId) {
  assertSafeRunId(runId);
  const sock = runSocketShellPath(runId);
  const cmd = `tmux -S ${sock} attach-session -t ${shq(RUN_TMUX_SESSION)}`;
  return { cmd: "ssh", args: [...sshOpts(cfg), "-t", sshTarget(cfg), cmd] };
}

export function attachCommandLine(cfg, runId) {
  const { cmd, args } = buildRunAttachInvocation(cfg, runId);
  return [cmd, ...args].join(" ");
}

export function parseRunStatus(stdout) {
  const line = String(stdout || "").split("\n").find((l) => l.startsWith("service="));
  if (!line) return null;
  return Object.fromEntries(line.trim().split(" ").map((p) => p.split("=")));
}

// `remain-on-exit on` keeps the tmux server — and so the unit — active after the
// command finishes, so completion is keyed on the exit-code file and never on
// service state. The inverse case is the dangerous one: no exit code AND no unit
// means the cgroup was killed before the run could record anything.
export function classifyRunWait(status) {
  if (!status) return { state: "unknown" };
  const raw = String(status.exit_code ?? "");
  if (/^\d+$/.test(raw)) return { state: "exited", code: Number.parseInt(raw, 10) };
  if (raw === "none" && status.service === "inactive") {
    return { state: "killed", code: RUN_KILLED_EXIT };
  }
  if (raw === "none" && status.service === "active") return { state: "running" };
  return { state: "unknown" };
}

export function parseLoadAvg(stdout) {
  const first = String(stdout || "").trim().split(/\s+/)[0];
  const value = Number.parseFloat(first);
  return Number.isFinite(value) ? value : null;
}

// Least-loaded reachable host, probed live. Registry order breaks ties, so a total
// probe failure still yields the registry's own preference rather than a hard stop.
export function selectRunHost(candidates, cfgFor, deps = {}) {
  const transport = deps.remoteTransportBashS || remoteTransportBashS;
  const probes = [];
  for (const name of candidates) {
    const result = transport(cfgFor(name), "cat /proc/loadavg", { timeoutSec: 20 }, deps);
    if (result.status !== 0) {
      probes.push({ host: name, load: null, reachable: false });
      continue;
    }
    probes.push({ host: name, load: parseLoadAvg(result.stdout), reachable: true });
  }
  const usable = probes.filter((p) => p.reachable && p.load !== null);
  if (usable.length === 0) {
    const detail = probes.map((p) => `${p.host}:${p.reachable ? "no-load" : "unreachable"}`).join(" ");
    throw new Error(`run-remote: no usable buildbox host (${detail})`);
  }
  usable.sort((a, b) => a.load - b.load || candidates.indexOf(a.host) - candidates.indexOf(b.host));
  return { host: usable[0].host, load: usable[0].load, probes };
}

// rsync's per-directory filter only reads `.gitignore` files, so it ships paths ignored
// via core.excludesFile or .git/info/exclude — including unreadable overlay mounts whose
// permission error aborts the whole transfer. git knows the full ignore set; rsync does not.
export function ignoredPathsFor(repoRoot, deps = {}) {
  const exec = deps.execFileSync || execFileSync;
  const out = exec(
    "git",
    ["-C", repoRoot, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory"],
    { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
  );
  return String(out)
    .split("\n")
    .map((line) => line.trim())
    .filter(Boolean)
    .map((rel) => `/${rel.replace(/\/+$/, "")}`);
}

export function buildRsyncArgs(cfg, { src, dest, deleteExtra = true, filterGitignore = false, excludeFrom = "" }) {
  const ssh = ["ssh", ...sshOpts(cfg)].join(" ");
  const args = ["-a", "--human-readable", "-e", ssh];
  if (deleteExtra) args.push("--delete");
  if (filterGitignore) args.push("--filter=:- .gitignore", "--exclude=/.git");
  if (excludeFrom) args.push(`--exclude-from=${excludeFrom}`);
  args.push(src, `${sshTarget(cfg)}:${dest}`);
  return args;
}

export function buildGitCommonPushArgs(cfg, { commonDir, runId }) {
  assertSafeRunId(runId);
  if (typeof commonDir !== "string" || !commonDir) {
    throw new Error(`invalid commonDir: ${JSON.stringify(commonDir)}`);
  }
  const ssh = ["ssh", ...sshOpts(cfg)].join(" ");
  return [
    "-a",
    "--human-readable",
    "--delete",
    "--exclude=/worktrees",
    "-e",
    ssh,
    `${commonDir.replace(/\/+$/, "")}/`,
    `${sshTarget(cfg)}:runs/${runId}/git/`,
  ];
}

export function buildGitDirPushArgs(cfg, { gitDir, runId, gitDirName }) {
  assertSafeRunId(runId);
  if (typeof gitDir !== "string" || !gitDir) {
    throw new Error(`invalid gitDir: ${JSON.stringify(gitDir)}`);
  }
  if (!GIT_DIR_NAME_RE.test(String(gitDirName))) {
    throw new Error(`unsafe gitDirName: ${JSON.stringify(gitDirName)}`);
  }
  const gitDirNameSafe = String(gitDirName);
  const ssh = ["ssh", ...sshOpts(cfg)].join(" ");
  return [
    "-a",
    "--human-readable",
    "--delete",
    "-e",
    ssh,
    `${gitDir.replace(/\/+$/, "")}/`,
    `${sshTarget(cfg)}:runs/${runId}/git/worktrees/${gitDirNameSafe}/`,
  ];
}

export function buildGitWorktreesDirScript({ runId, gitDirName }) {
  assertSafeRunId(runId);
  if (!GIT_DIR_NAME_RE.test(String(gitDirName))) {
    throw new Error(`unsafe gitDirName: ${JSON.stringify(gitDirName)}`);
  }
  const target = shellHomePath(homeRel("runs", runId, "git", "worktrees", String(gitDirName)));
  return [
    "set -euo pipefail",
    shellPathAssign("target", target),
    'mkdir -p "$target"',
  ].join("\n");
}

export function buildGitPointerScript({ runId, gitDirName }) {
  assertSafeRunId(runId);
  if (gitDirName == null) {
    const gitCommon = shellHomePath(homeRel("runs", runId, "git"));
    return [
      "set -euo pipefail",
      shellPathAssign("repo", runRepoShellPath(runId)),
      shellPathAssign("gitCommon", gitCommon),
      'printf "gitdir: %s\\n" "$gitCommon" > "$repo/.git"',
      'git -C "$repo" rev-parse --show-toplevel >/dev/null || { echo run-remote-git-pointer-verify-failed; exit 1; }',
    // The mirror is a snapshot of a live checkout, so the repo is only usable if
    // HEAD still resolves to a commit that actually arrived in the object store.
    'git -C "$repo" rev-parse --verify HEAD^{commit} >/dev/null || { echo run-remote-git-head-unresolvable; exit 1; }',
    ].join("\n");
  }
  if (!GIT_DIR_NAME_RE.test(String(gitDirName))) {
    throw new Error(`unsafe gitDirName: ${JSON.stringify(gitDirName)}`);
  }
  const worktreeGitDir = shellHomePath(homeRel("runs", runId, "git", "worktrees", String(gitDirName)));
  return [
    "set -euo pipefail",
    shellPathAssign("repo", runRepoShellPath(runId)),
    shellPathAssign("gitDir", worktreeGitDir),
    'printf "gitdir: %s\\n" "$gitDir" > "$repo/.git"',
    'printf "%s\\n" "$repo/.git" > "$gitDir/gitdir"',
    'printf "%s\\n" "../.." > "$gitDir/commondir"',
    'git -C "$repo" rev-parse --show-toplevel >/dev/null || { echo run-remote-git-pointer-verify-failed; exit 1; }',
    // The mirror is a snapshot of a live checkout, so the repo is only usable if
    // HEAD still resolves to a commit that actually arrived in the object store.
    'git -C "$repo" rev-parse --verify HEAD^{commit} >/dev/null || { echo run-remote-git-head-unresolvable; exit 1; }',
  ].join("\n");
}

export function runLedgerRecord({ runId, host, socketPath, repoPath, command, now = new Date() }) {
  assertSafeRunId(runId);
  return {
    schemaVersion: 1,
    ledgerId: runId,
    runtime: "run-remote",
    startedAt: now.toISOString().replace(/\.\d+Z$/, "Z"),
    host,
    bootId: null,
    cwd: repoPath,
    repoRoot: repoPath,
    project: null,
    branch: null,
    worktree: false,
    tty: null,
    launcherPid: process.pid,
    parentLedgerId: process.env.AGENT_LEDGER_ID || null,
    parent: null,
    launchedBy: "run-remote",
    account: null,
    enrolledBy: "run-remote",
    mux: { kind: "tmux", socket: socketPath, target: RUN_TMUX_SESSION },
    pid: null,
    pidStartTicks: null,
    cpuTicks: null,
    sessionId: null,
    transcriptPath: null,
    command,
    lastActiveAt: null,
    lastHeartbeatAt: null,
    finishedAt: null,
    finishReason: null,
  };
}
