/**
 * Standard-image build pipeline (ADR-022 / docs/runtime.md).
 *
 * Every host builds exactly one standard image (`uai-standard:dev`) and
 * caches it. `ensureStandardImage()` is called once at host startup:
 *
 *   1. `docker volume create uai-asdf-data` — the host-wide asdf data volume
 *      that caches lazily-installed runtime versions across tasks (idempotent;
 *      `docker volume create` is a no-op if the volume already exists).
 *   2. `docker image inspect uai-standard:dev` — if it resolves, the image is
 *      present and we skip the build. Otherwise `docker build` it from
 *      `host-agent/images/standard/`.
 *
 * Best-effort: any failure (docker missing, daemon down, build error) is
 * logged and swallowed so the host process still boots and serves tunnels.
 * task-up surfaces the real error to the user if a build is genuinely needed.
 */

import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

/** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
export const STANDARD_IMAGE_TAG = "uai-standard:dev";
export const ASDF_DATA_VOLUME = "uai-asdf-data";

/**
 * Runtimes the standard image's asdf plugins can satisfy, with a sensible set
 * of advertised versions per kind (ADR-021 `runtimes`). These are the
 * versions the cloud's task-creation picker hints; asdf will lazily install
 * any of them on first use (docs/runtime.md). The first entry of each list is
 * the root `/etc/.tool-versions` default baked into the image.
 *
 * KEEP IN SYNC with host-agent/images/standard/ (the asdf plugins and the
 * root tool-versions). Treat that directory as authoritative for the numbers;
 * this list is the advertisement surface.
 */
export const STANDARD_RUNTIMES: ReadonlyArray<{
  kind: string;
  availableVersions: string[];
}> = [
  { kind: "node", availableVersions: ["22.11.0", "20.18.0", "18.20.4"] },
  { kind: "python", availableVersions: ["3.12.4", "3.11.9"] },
  { kind: "go", availableVersions: ["1.23.2", "1.22.8"] },
  { kind: "ruby", availableVersions: ["3.3.5", "3.2.5"] },
  { kind: "rust", availableVersions: ["1.81.0"] },
];

/** A fresh, mutable copy of the advertised runtimes for the capability frame. */
export function standardRuntimes(): Array<{
  kind: string;
  availableVersions: string[];
}> {
  return STANDARD_RUNTIMES.map((r) => ({
    kind: r.kind,
    availableVersions: [...r.availableVersions],
  }));
}

/** Image label carrying the build-context content hash (rebuild trigger). */
const CONTEXT_HASH_LABEL = "com.runuai.context-hash";

/**
 * Content-hash the build context (every file under images/standard, sorted
 * by relative path). Null when the context can't be read — the caller then
 * keeps whatever image exists.
 */
async function hashBuildContext(): Promise<string | null> {
  try {
    const root = standardImageDir();
    const files: string[] = [];
    const walk = async (dir: string, prefix: string): Promise<void> => {
      for (const entry of await readdir(dir, { withFileTypes: true })) {
        const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
        if (entry.isDirectory()) await walk(join(dir, entry.name), rel);
        else files.push(rel);
      }
    };
    await walk(root, "");
    files.sort();
    const hash = createHash("sha256");
    for (const rel of files) {
      hash.update(rel);
      hash.update("\0");
      hash.update(await readFile(join(root, rel)));
      hash.update("\0");
    }
    return hash.digest("hex").slice(0, 32);
  } catch {
    return null;
  }
}

/** Absolute path to the standard image build context. */
function standardImageDir(): string {
  const here = dirname(fileURLToPath(import.meta.url));
  // lib/standard-image.ts -> host-agent/ -> images/standard
  return resolve(here, "..", "images", "standard");
}

interface RunResult {
  code: number | null;
  stdout: string;
  stderr: string;
}

/** Run a command to completion, capturing stdio. Never rejects. */
function run(command: string, args: string[]): Promise<RunResult> {
  return new Promise<RunResult>((resolveRun) => {
    let stdout = "";
    let stderr = "";
    const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
    child.stdout?.setEncoding("utf8");
    child.stderr?.setEncoding("utf8");
    child.stdout?.on("data", (chunk: string) => {
      stdout += chunk;
    });
    child.stderr?.on("data", (chunk: string) => {
      stderr += chunk;
    });
    child.on("error", (err: Error) => {
      resolveRun({ code: null, stdout, stderr: stderr + err.message });
    });
    child.on("close", (code) => {
      resolveRun({ code, stdout, stderr });
    });
  });
}

/**
 * Agent CLIs that MUST exist inside the shared asdf volume. The volume mounts
 * over /opt/asdf-data and shadows the image's baked-in shims, so a volume
 * seeded before a CLI was added (or before a version bump) can be missing it —
 * e.g. a stale volume with `codex` but no `claude`, which makes `claude` exit
 * 127 in every task container. Reconciled on each host start.
 */
const VOLUME_AGENT_CLIS: { bin: string; pkg: string }[] = [
  { bin: "claude", pkg: "@anthropic-ai/claude-code" },
  { bin: "codex", pkg: "@openai/codex" },
];

/**
 * Self-heal the shared asdf volume: ensure each agent CLI resolves inside it,
 * installing + reshimming any that are missing. Cheap when healthy (one
 * container that just lists absent bins); only pays the npm cost on repair.
 * A fresh/empty volume is seeded from the image by Docker on first mount, so
 * this is a no-op there — it only fixes pre-existing stale volumes.
 */
async function ensureVolumeAgentClis(): Promise<void> {
  const bins = VOLUME_AGENT_CLIS.map((c) => c.bin).join(" ");
  // Sentinel-prefixed output so login-shell init noise can't be misread as a
  // missing bin.
  const check = await run("docker", [
    "run",
    "--rm",
    "-v",
    `${ASDF_DATA_VOLUME}:/opt/asdf-data`,
    STANDARD_IMAGE_TAG,
    "bash",
    "-lc",
    `for b in ${bins}; do command -v "$b" >/dev/null 2>&1 || echo "UAI_MISSING:$b"; done`,
  ]);
  if (check.code !== 0) {
    console.warn(
      `[host-agent] could not check agent CLIs in ${ASDF_DATA_VOLUME} (exit ` +
        `${check.code ?? "spawn"}); continuing. ${check.stderr.trim()}`,
    );
    return;
  }
  const missing = check.stdout
    .split("\n")
    .map((l) => l.trim())
    .filter((l) => l.startsWith("UAI_MISSING:"))
    .map((l) => l.slice("UAI_MISSING:".length));
  if (missing.length === 0) return;

  const pkgs = VOLUME_AGENT_CLIS.filter((c) => missing.includes(c.bin))
    .map((c) => c.pkg)
    .join(" ");
  console.log(
    `[host-agent] repairing ${ASDF_DATA_VOLUME}: agent CLI(s) [${missing.join(", ")}] ` +
      `missing from the shared volume — installing ${pkgs}`,
  );
  const repair = await run("docker", [
    "run",
    "--rm",
    "-u",
    "root",
    // Run from /home/node so asdf resolves the node version from
    // /home/node/.tool-versions. As root HOME is /root (no .tool-versions),
    // and without a version asdf's npm shim aborts ("No version is set for
    // command npm", exit 126). root can still write the volume from here.
    "-w",
    "/home/node",
    "-v",
    `${ASDF_DATA_VOLUME}:/opt/asdf-data`,
    STANDARD_IMAGE_TAG,
    "bash",
    "-lc",
    `npm install -g ${pkgs} && asdf reshim nodejs`,
  ]);
  if (repair.code === 0) {
    console.log(
      `[host-agent] ${ASDF_DATA_VOLUME} repaired — installed [${missing.join(", ")}]`,
    );
  } else {
    console.warn(
      `[host-agent] failed to repair ${ASDF_DATA_VOLUME} (exit ${repair.code ?? "spawn"}); ` +
        `tasks may be missing [${missing.join(", ")}].\n${repair.stderr.trim()}`,
    );
  }
}

/**
 * Upgrade the shared volume's agent CLIs to latest — once per HOST START, not
 * per task. Per-task installs would add npm latency to every task-up, race
 * concurrent starts on the shared volume, and give a broken vendor release a
 * fleet-wide blast radius with no rollback; host restarts are the deliberate
 * update moment (sessions respawn then anyway, and a bad release is one
 * `UAI_AGENT_CLI_AUTOUPDATE=0` + manual pin away from contained). Running
 * sessions keep their already-loaded process; new spawns pick up the new bin.
 * Best-effort: an offline registry just logs and keeps the current versions.
 */
async function upgradeVolumeAgentClis(): Promise<void> {
  if (process.env.UAI_AGENT_CLI_AUTOUPDATE === "0") {
    console.log("[host-agent] agent CLI auto-update disabled (UAI_AGENT_CLI_AUTOUPDATE=0)");
    return;
  }
  const pkgs = VOLUME_AGENT_CLIS.map((c) => `${c.pkg}@latest`).join(" ");
  const bins = VOLUME_AGENT_CLIS.map((c) => c.bin);
  // npm's in-place upgrade renames the old package dir, which fails with
  // ENOTEMPTY while (or right after) live sessions held it open through the
  // volume. Fallback: remove the scoped dirs and install fresh — safe at host
  // start, when the previous sessions' execs are gone.
  const scopeDirs = "/opt/asdf-data/installs/nodejs/*/lib/node_modules/@anthropic-ai /opt/asdf-data/installs/nodejs/*/lib/node_modules/@openai";
  const upgrade = await run("docker", [
    "run",
    "--rm",
    // The container NAME is the mutex: overlapping boots (or a crash-looping
    // service) must never race two npm installs on the shared volume — that
    // once left it with no `claude` at all (2026-07-13). Docker rejects the
    // duplicate name; we treat that as "already upgrading, skip".
    "--name",
    "uai-cli-upgrade",
    "-u",
    "root",
    "-w",
    "/home/node",
    "-v",
    `${ASDF_DATA_VOLUME}:/opt/asdf-data`,
    STANDARD_IMAGE_TAG,
    "bash",
    "-lc",
    // Reshim is its OWN statement (`;`, not `&&`) so it ALWAYS runs after the
    // install attempts — with `&& reshim`, the `|| { rm -rf; reinstall; }`
    // fallback's exit code could skip the reshim, leaving a stale `codex`/
    // `claude` shim ("No <cli> executable found for nodejs …") even though the
    // bin installed fine (hit codex live 2026-07-16). Reshim is idempotent and
    // cheap, so running it unconditionally is strictly safer.
    `npm install -g ${pkgs} >/dev/null 2>&1 || { rm -rf ${scopeDirs}; npm install -g ${pkgs} >/dev/null 2>&1; }; ` +
      `asdf reshim nodejs >/dev/null 2>&1; ` +
      bins.map((b) => `printf '%s ' "$(${b} --version 2>/dev/null | head -1)"`).join("; "),
  ]);
  if (upgrade.code !== 0 && /already in use/i.test(upgrade.stderr)) {
    console.log("[host-agent] agent CLI upgrade already running — skipped");
    return;
  }
  if (upgrade.code === 0) {
    console.log(
      `[host-agent] agent CLIs current on ${ASDF_DATA_VOLUME}: ${upgrade.stdout.trim()}`,
    );
  } else {
    console.warn(
      `[host-agent] agent CLI upgrade skipped (exit ${upgrade.code ?? "spawn"}) — ` +
        `tasks keep the volume's current versions. ${upgrade.stderr.trim().slice(0, 200)}`,
    );
  }
}

/**
 * Ensure the standard image and the shared asdf data volume exist, and that the
 * agent CLIs are present inside the volume. Builds the image only when `docker
 * image inspect` fails. Best-effort: logs and continues on any error so the
 * host keeps booting.
 */
export async function ensureStandardImage(): Promise<void> {
  // 1. Shared asdf data volume — idempotent.
  const vol = await run("docker", ["volume", "create", ASDF_DATA_VOLUME]);
  if (vol.code !== 0) {
    console.warn(
      `[host-agent] could not create asdf data volume (${ASDF_DATA_VOLUME}); ` +
        `continuing. ${vol.stderr.trim()}`,
    );
    // If docker itself is unavailable, the image step will also fail; bail
    // early so we don't double-log a confusing build error.
    if (vol.code === null) return;
  }

  // 2. Ensure the image is built AND current. "Present" is not enough — a
  //    Dockerfile change used to no-op forever because we only built when
  //    inspect failed (found live 2026-07-08: ADR-053's baked packages never
  //    landed). The build context is content-hashed into an image label;
  //    a mismatch triggers a rebuild (layer cache keeps it cheap).
  let imageReady = false;
  const contextHash = await hashBuildContext();
  const inspect = await run("docker", [
    "image",
    "inspect",
    "-f",
    `{{index .Config.Labels "${CONTEXT_HASH_LABEL}"}}`,
    STANDARD_IMAGE_TAG,
  ]);
  if (inspect.code === null) {
    console.warn(
      "[host-agent] docker unavailable; skipping standard image build. " +
        "Tasks will fail until docker is running.",
    );
    return;
  }
  const labeledHash = inspect.code === 0 ? inspect.stdout.trim() : null;
  if (inspect.code === 0 && contextHash !== null && labeledHash === contextHash) {
    console.log(`[host-agent] standard image ${STANDARD_IMAGE_TAG} current`);
    imageReady = true;
  } else if (inspect.code === 0 && contextHash === null) {
    // Can't hash (packaged install without the context?) — keep the image.
    console.log(`[host-agent] standard image ${STANDARD_IMAGE_TAG} present`);
    imageReady = true;
  } else {
    const context = standardImageDir();
    console.log(
      inspect.code === 0
        ? `[host-agent] standard image ${STANDARD_IMAGE_TAG} stale (context changed) — rebuilding from ${context}`
        : `[host-agent] building standard image ${STANDARD_IMAGE_TAG} from ${context}`,
    );
    const build = await run("docker", [
      "build",
      "-t",
      STANDARD_IMAGE_TAG,
      ...(contextHash !== null
        ? ["--label", `${CONTEXT_HASH_LABEL}=${contextHash}`]
        : []),
      context,
    ]);
    if (build.code === 0) {
      console.log(`[host-agent] built standard image ${STANDARD_IMAGE_TAG}`);
      imageReady = true;
    } else {
      console.warn(
        `[host-agent] standard image build failed (exit ${build.code ?? "spawn"}); ` +
          "continuing. Tasks needing the image will surface this error.\n" +
          build.stderr.trim(),
      );
      // A stale-but-working image is better than none.
      imageReady = labeledHash !== null;
    }
  }

  // 3. Reconcile the agent CLIs into the shared volume (it shadows the image's
  //    shims, so a stale volume can be missing one — the claude-127 bug).
  if (imageReady) {
    await ensureVolumeAgentClis();
    await upgradeVolumeAgentClis();
  }
}
