import { spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import { loadRegistry, requireReachable, resolveAccess, resolveHosts } from "./buildbox-registry.mjs";
import {
  buildRuntimeAccountCleanupCommands,
  buildRuntimeAccountCheckCommand,
  buildRuntimeAccountCreateCommand,
  buildRuntimeAccountInstallCommand,
  loadRuntimeAccountSource,
  materializeRuntimeAccountStage,
  parseRuntimeAccountCreateResult,
} from "./runtime-account-stage.mjs";
import { verifyRuntimeRelease } from "./runtime-release.mjs";

const DEFAULT_OPERATION_TIMEOUT_MS = 30_000;
const DEFAULT_CHECK_TIMEOUT_MS = 10_000;
const MAX_BUFFER = 4 * 1024 * 1024;
const SERVICE = /^(system|user):([A-Za-z0-9_.@-]+\.(?:service|socket|timer|path))$/;
const SSH_USER = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,31}$/;
const STAGE = /^\/tmp\/overdeck-runtime-release\.[A-Za-z0-9]{6,64}$/;
const SHA256 = /^[a-f0-9]{64}$/;
const PUBLIC_ACCOUNT_REF = /^sha256:[a-f0-9]{64}$/;
const LEASE_KEYS = ["accountFingerprint", "accountRef", "codingAgentDir", "expectedIdentity", "observedIdentity", "requestedIdentity", "runId", "schemaVersion"];

function shellQuote(value) {
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
}

function positiveInteger(value, name) {
  if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
  return value;
}

function absolute(value, name) {
  if (typeof value !== "string" || !isAbsolute(value) || resolve(value) !== value) {
    throw new Error(`${name} must be an absolute normalized path`);
  }
  return value;
}

function services(values, noRequiredServices = false) {
  if (noRequiredServices !== true && noRequiredServices !== false) {
    throw new Error("noRequiredServices must be boolean");
  }
  const policy = values ?? [];
  if (!Array.isArray(policy)) throw new Error("requiredServices must be an array");
  if (noRequiredServices) {
    if (policy.length > 0) throw new Error("noRequiredServices cannot be combined with requiredServices");
    return [];
  }
  if (policy.length === 0) throw new Error("requiredServices must be non-empty");
  const seen = new Set();
  return policy.map((value) => {
    const match = SERVICE.exec(value);
    if (!match || seen.has(value)) throw new Error(`invalid required service: ${value}`);
    seen.add(value);
    return { scope: match[1], unit: match[2] };
  });
}

function transportArgs(access, connectTimeoutMs, scp = false) {
  if (!SSH_USER.test(access.user)) throw new Error(`invalid registry SSH username: ${access.user}`);
  const args = [
    "-F", "/dev/null",
    "-o", "BatchMode=yes",
    "-o", `ConnectTimeout=${Math.max(1, Math.ceil(connectTimeoutMs / 1000))}`,
    "-o", "ControlMaster=no",
    "-o", "ControlPath=none",
    "-o", "StrictHostKeyChecking=yes",
    "-o", "UpdateHostKeys=no",
    scp ? "-P" : "-p", String(access.port),
  ];
  if (access.identity_file) {
    const home = process.env.HOME || homedir();
    args.push("-o", "IdentitiesOnly=yes", "-i", access.identity_file.replace(/^~(?=\/)/, home));
  }
  return args;
}

function sshSpec(kind, access, remoteCommand, timeoutMs, input = undefined) {
  return {
    kind,
    file: "ssh",
    args: [...transportArgs(access, timeoutMs), "--", `${access.user}@${access.host}`, remoteCommand],
    remoteCommand,
    timeoutMs,
    maxBuffer: MAX_BUFFER,
    ...(input === undefined ? {} : { input }),
  };
}

function scpSpec(access, source, destination, timeoutMs) {
  return {
    kind: "transfer",
    file: "scp",
    args: [...transportArgs(access, timeoutMs, true), "--", source, `${access.user}@${access.host}:${destination}`],
    source,
    destination,
    timeoutMs,
    maxBuffer: MAX_BUFFER,
  };
}

function defaultExecute(spec) {
  return spawnSync(spec.file, spec.args, {
    encoding: "utf8",
    input: spec.input,
    timeout: spec.timeoutMs,
    maxBuffer: spec.maxBuffer,
  });
}

function commandFailure(spec, result) {
  return result.error?.code === "ETIMEDOUT" ? `${spec.kind} deadline exceeded` : `${spec.kind} failed`;
}

function selectionScript(releaseRoot, expectedDigest = null) {
  return [
    "set -eu",
    `root=${shellQuote(releaseRoot)}`,
    'target=$(readlink "$root/current")',
    ...(expectedDigest ? [`[ "$target" = ${shellQuote(`releases/${expectedDigest}/runtime`)} ] || exit 1`] : []),
    'case "$target" in releases/*/runtime) ;; *) exit 1 ;; esac',
    'releaseId=${target#releases/}',
    'releaseId=${releaseId%/runtime}',
    'releaseDir="$root/releases/$releaseId"',
    '[ -d "$releaseDir/runtime" ] && [ ! -L "$releaseDir/runtime" ] || exit 1',
    '[ -x "$releaseDir/runtime/bin/apply-runtime-release" ] || exit 1',
    'descriptor="$releaseDir/descriptor.json"',
    'artifact="$releaseDir/runtime-release-$releaseId.tar"',
    '[ -f "$descriptor" ] && [ -f "$artifact" ] || exit 1',
    'node -e \'const fs=require("node:fs");const [path,id]=process.argv.slice(1);if(!/^[0-9a-f]{64}$/.test(id))process.exit(1);const d=JSON.parse(fs.readFileSync(path,"utf8"));if(d.artifactSha256!==id)process.exit(1)\' "$descriptor" "$releaseId"',
    'printf "releaseId=%s\\n" "$releaseId"',
  ].join("\n");
}

function serviceScript(requiredServices) {
  return [
    "set -eu",
    ...requiredServices.map(({ scope, unit }) => scope === "user"
      ? `systemctl --user is-active --quiet ${shellQuote(unit)} || exit 1`
      : `systemctl is-active --quiet ${shellQuote(unit)} || exit 1`),
  ].join("\n");
}

function releaseId(output, phase) {
  const match = /^releaseId=([0-9a-f]{64})$/m.exec(String(output ?? ""));
  if (!match) throw new Error(`${phase} failed`);
  return match[1];
}

function traceText(value, maxLength) {
  if (typeof value !== "string" || value.length === 0 || value.length > maxLength
      || Array.from(value).some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) {
    throw new Error("dispatch receipt mismatch");
  }
  return value;
}

function receiptDigest(value) {
  return createHash("sha256").update(value).digest("hex");
}

function accountTrace(value, expected) {
  if (!value || typeof value !== "object" || Array.isArray(value)
      || Object.keys(value).sort().join("|") !== LEASE_KEYS.join("|") || value.schemaVersion !== 1) {
    throw new Error("dispatch receipt mismatch");
  }
  const runId = traceText(value.runId, 128);
  const accountRef = traceText(value.accountRef, 71);
  if (!PUBLIC_ACCOUNT_REF.test(accountRef)) throw new Error("dispatch receipt mismatch");
  const codingAgentDir = traceText(value.codingAgentDir, 4096);
  if (!isAbsolute(codingAgentDir) || resolve(codingAgentDir) !== codingAgentDir) throw new Error("dispatch receipt mismatch");
  const requestedIdentity = traceText(value.requestedIdentity, 256);
  const expectedIdentity = traceText(value.expectedIdentity, 256);
  const observedIdentity = traceText(value.observedIdentity, 256);
  if (typeof value.accountFingerprint !== "string" || !SHA256.test(value.accountFingerprint)) {
    throw new Error("dispatch receipt mismatch");
  }
  const expectedAccountRef = `sha256:${receiptDigest(`account-ref\0${expected.accountRef}`)}`;
  const expectedFingerprint = receiptDigest(`account\0${expected.accountRef}\0${expected.identity}`);
  const expectedRunName = receiptDigest(`account-run\0${expected.runId}\0${expected.accountRef}`);
  if (runId !== expected.runId || accountRef !== expectedAccountRef || value.accountFingerprint !== expectedFingerprint
      || basename(codingAgentDir) !== expectedRunName || basename(dirname(codingAgentDir)) !== "runs") {
    throw new Error("dispatch receipt mismatch");
  }
  return Object.freeze({
    schemaVersion: 1,
    runId,
    accountRef,
    codingAgentDir,
    requestedIdentity,
    expectedIdentity,
    observedIdentity,
    accountFingerprint: value.accountFingerprint,
  });
}

function validateReceipt(receipt, expected) {
  if (!receipt || receipt.schemaVersion !== 1 || receipt.ok !== true || receipt.exitCode !== 0
      || receipt.runtimeDigest !== expected.runtimeDigest || receipt.host !== expected.host
      || receipt.model !== expected.model) {
    throw new Error("dispatch receipt mismatch");
  }
  const trace = accountTrace(receipt.lease, expected);
  if (trace.requestedIdentity !== expected.identity || trace.expectedIdentity !== expected.identity
      || trace.observedIdentity !== expected.identity) throw new Error("account trace mismatch");
  return trace;
}

function parseReceipt(output) {
  const line = String(output ?? "").trim().split("\n").at(-1);
  try {
    return JSON.parse(line);
  } catch {
    throw new Error("dispatch receipt unreadable");
  }
}

export function deployRuntimeRelease(options, deps = {}) {
  const now = deps.now ?? Date.now;
  const enteredAt = now();
  const descriptorPath = absolute(options.descriptorPath, "descriptorPath");
  const artifactPath = absolute(options.artifactPath, "artifactPath");
  const releaseRoot = absolute(options.releaseRoot, "releaseRoot");
  const dispatchPath = absolute(options.dispatchPath, "dispatchPath");
  const piAuthPath = absolute(options.piAuthPath, "piAuthPath");
  const receiptPath = absolute(options.receiptPath, "receiptPath");
  const requiredServices = services(options.requiredServices, options.noRequiredServices);
  if (typeof options.model !== "string" || !options.model) throw new Error("model is required");
  if (typeof options.expectedAccountIdentity !== "string" || !options.expectedAccountIdentity) {
    throw new Error("expectedAccountIdentity is required");
  }
  const operationTimeoutMs = positiveInteger(options.operationTimeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS, "operationTimeoutMs");
  const checkTimeoutMs = positiveInteger(options.checkTimeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS, "checkTimeoutMs");
  const deadline = enteredAt + operationTimeoutMs;
  const registry = deps.registry ?? loadRegistry(options.env);
  const hosts = options.host
    ? requireReachable(registry, [options.host])
    : resolveHosts(registry, { order: "build", env: options.env, inSandbox: options.inSandbox });
  const host = hosts[0];
  if (!registry.orders.build.includes(host)) throw new Error(`host "${host}" is not in build order`);
  const access = resolveAccess(registry, host, options.accessPath ?? "tailscale_ip");
  const verifyRelease = deps.verifyRelease ?? verifyRuntimeRelease;
  if (deadline - now() <= 0) throw new Error("operation deadline exceeded");
  const descriptor = verifyRelease({ descriptorPath, artifactPath });
  if (deadline - now() <= 0) throw new Error("operation deadline exceeded");
  const loadAccount = deps.loadAccountSource ?? loadRuntimeAccountSource;
  const accountSource = loadAccount({
    dispatchPath,
    piAuthPath,
    model: options.model,
    expectedIdentity: options.expectedAccountIdentity,
  });
  if (accountSource.dispatch.runtimeDigest !== descriptor.artifactSha256
      || accountSource.dispatch.runtimeReleaseRoot !== releaseRoot
      || accountSource.dispatch.runtimeDescriptor !== `${releaseRoot}/releases/${descriptor.artifactSha256}/descriptor.json`) {
    throw new Error("runtime account dispatch release mismatch");
  }
  if (deadline - now() <= 0) throw new Error("operation deadline exceeded");
  const execute = deps.execute ?? defaultExecute;
  const rollbackReserveMs = Math.min(checkTimeoutMs * 4, Math.max(4, Math.floor(operationTimeoutMs / 2)));

  const runBudgeted = (specFactory, { reserveMs = 0, steps = 1 } = {}) => {
    const available = deadline - now() - reserveMs;
    const timeoutMs = Math.min(checkTimeoutMs, Math.floor(available / steps));
    if (timeoutMs <= 0) throw new Error("operation deadline exceeded");
    const spec = specFactory(timeoutMs);
    let result;
    try {
      result = execute(spec);
    } catch {
      throw new Error(`${spec.kind} failed`);
    }
    if (result?.error || result?.signal || result?.status !== 0) throw new Error(commandFailure(spec, result ?? {}));
    return result;
  };
  const run = (specFactory) => runBudgeted(specFactory);
  const runForward = (specFactory) => runBudgeted(specFactory, { reserveMs: rollbackReserveMs });

  const selectedResult = run((timeoutMs) => sshSpec("selection", access, selectionScript(releaseRoot), timeoutMs));
  const priorDigest = releaseId(selectedResult.stdout, "selection");
  const suffix = (deps.randomUUID?.() ?? randomUUID()).replaceAll("-", "");
  if (!/^[A-Za-z0-9]{6,64}$/.test(suffix)) throw new Error("invalid staging suffix");
  const stagingCandidate = `/tmp/overdeck-runtime-release.${suffix}`;
  const stageResult = run((timeoutMs) => sshSpec(
    "stage",
    access,
    `set -eu\numask 077\nmkdir -m 700 -- ${shellQuote(stagingCandidate)}\nmkdir -- ${shellQuote(`${stagingCandidate}/bin`)} ${shellQuote(`${stagingCandidate}/lib`)}\nprintf '%s\\n' ${shellQuote(stagingCandidate)}`,
    timeoutMs,
  ));
  const stagingPath = String(stageResult.stdout ?? "").trim();
  if (!STAGE.test(stagingPath)) throw new Error("stage failed");

  const moduleDir = dirname(fileURLToPath(import.meta.url));
  const payloads = [
    [descriptorPath, `${stagingPath}/descriptor.json`],
    [artifactPath, `${stagingPath}/artifact.tar`],
    [join(moduleDir, "../bin/apply-runtime-release"), `${stagingPath}/bin/apply-runtime-release`],
    [join(moduleDir, "runtime-reconciler.mjs"), `${stagingPath}/lib/runtime-reconciler.mjs`],
    [join(moduleDir, "runtime-release.mjs"), `${stagingPath}/lib/runtime-release.mjs`],
  ];
  for (const [source, destination] of payloads) run((timeoutMs) => scpSpec(access, source, destination, timeoutMs));

  runForward((timeoutMs) => sshSpec(
    "apply",
    access,
    `${stagingPath}/bin/apply-runtime-release --descriptor ${shellQuote(`${stagingPath}/descriptor.json`)} --artifact ${shellQuote(`${stagingPath}/artifact.tar`)} --release-root ${shellQuote(releaseRoot)}`,
    timeoutMs,
  ));

  let phase = "ready";
  let accountStage = null;
  let accountCommands = null;
  let accountDeleted = false;
  let accountDisarmed = false;
  try {
    const readyResult = runForward((timeoutMs) => sshSpec(
      "ready",
      access,
      selectionScript(releaseRoot, descriptor.artifactSha256),
      timeoutMs,
    ));
    if (releaseId(readyResult.stdout, "ready") !== descriptor.artifactSha256) throw new Error("ready failed");
    if (requiredServices.length > 0) {
      phase = "service";
      runForward((timeoutMs) => sshSpec("service", access, serviceScript(requiredServices), timeoutMs));
    }
    phase = "account-create";
    const accountSuffix = (deps.randomUUID?.() ?? randomUUID()).replaceAll("-", "");
    const accountCreate = runForward((timeoutMs) => sshSpec(
      "account-create",
      access,
      buildRuntimeAccountCreateCommand({ suffix: accountSuffix }),
      timeoutMs,
    ));
    accountStage = parseRuntimeAccountCreateResult(accountCreate.stdout, { suffix: accountSuffix });
    accountCommands = buildRuntimeAccountCleanupCommands({ stage: accountStage });
    const materialized = materializeRuntimeAccountStage({ source: accountSource, stagePath: accountStage.stagePath });
    phase = "account-install";
    runForward((timeoutMs) => sshSpec(
      "account-install",
      access,
      buildRuntimeAccountInstallCommand({ source: accountSource, materialized, stage: accountStage }),
      timeoutMs,
      accountSource.auth,
    ));
    phase = "account-check";
    runForward((timeoutMs) => sshSpec(
      "account-check",
      access,
      buildRuntimeAccountCheckCommand({ stage: accountStage }),
      timeoutMs,
    ));
    phase = "dispatch";
    const dispatchResult = runForward((timeoutMs) => sshSpec(
      "dispatch",
      access,
      `${shellQuote(`${releaseRoot}/current/bin/run-runtime-dispatch`)} --dispatch ${shellQuote(`${accountStage.stagePath}/dispatch.json`)} --receipt ${shellQuote(receiptPath)} -- /bin/true && node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"utf8"))' ${shellQuote(receiptPath)}`,
      timeoutMs,
    ));
    phase = "receipt";
    const accountTrace = validateReceipt(parseReceipt(dispatchResult.stdout), {
      runtimeDigest: descriptor.artifactSha256,
      host,
      model: options.model,
      identity: options.expectedAccountIdentity,
      runId: accountSource.dispatch.runId,
      accountRef: accountSource.dispatch.accountRef,
    });
    phase = "account-delete";
    runForward((timeoutMs) => sshSpec("account-delete", access, accountCommands.deleteCommand, timeoutMs));
    accountDeleted = true;
    phase = "account-disarm";
    runForward((timeoutMs) => sshSpec("account-disarm", access, accountCommands.disarmCommand, timeoutMs));
    accountDisarmed = true;
    phase = "cleanup";
    const exactFiles = payloads.map(([, destination]) => shellQuote(destination)).join(" ");
    runForward((timeoutMs) => sshSpec(
      "cleanup",
      access,
      `rm -- ${exactFiles}\nrmdir -- ${shellQuote(`${stagingPath}/bin`)} ${shellQuote(`${stagingPath}/lib`)} ${shellQuote(stagingPath)}`,
      timeoutMs,
    ));
    return { schemaVersion: 1, host, runtimeDigest: descriptor.artifactSha256, stagingPath, ready: true, accountTrace };
  } catch (error) {
    let accountCleanupFailed = false;
    if (accountCommands && !accountDeleted) {
      try {
        runBudgeted((timeoutMs) => sshSpec("account-delete", access, accountCommands.deleteCommand, timeoutMs), { steps: 4 });
        accountDeleted = true;
      } catch {
        accountCleanupFailed = true;
      }
    }
    if (accountCommands && accountDeleted && !accountDisarmed) {
      try {
        runBudgeted((timeoutMs) => sshSpec("account-disarm", access, accountCommands.disarmCommand, timeoutMs), { steps: 3 });
        accountDisarmed = true;
      } catch {
        accountCleanupFailed = true;
      }
    }
    try {
      const priorRelease = `${releaseRoot}/releases/${priorDigest}`;
      runBudgeted((timeoutMs) => sshSpec(
        "rollback",
        access,
        `${shellQuote(`${priorRelease}/runtime/bin/apply-runtime-release`)} --descriptor ${shellQuote(`${priorRelease}/descriptor.json`)} --artifact ${shellQuote(`${priorRelease}/runtime-release-${priorDigest}.tar`)} --release-root ${shellQuote(releaseRoot)}`,
        timeoutMs,
      ), { steps: 2 });
      const restored = runBudgeted(
        (timeoutMs) => sshSpec("rollback-verify", access, selectionScript(releaseRoot, priorDigest), timeoutMs),
        { steps: 1 },
      );
      if (releaseId(restored.stdout, "rollback verification") !== priorDigest) throw new Error("rollback verification failed");
    } catch {
      throw new Error(`rollback failed after ${phase}`);
    }
    if (accountCleanupFailed) throw new Error("account cleanup failed");
    throw error;
  }
}

const CLI_VALUES = new Map([
  ["--descriptor", "descriptorPath"],
  ["--artifact", "artifactPath"],
  ["--release-root", "releaseRoot"],
  ["--dispatch", "dispatchPath"],
  ["--pi-auth", "piAuthPath"],
  ["--receipt", "receiptPath"],
  ["--model", "model"],
  ["--expected-account-identity", "expectedAccountIdentity"],
  ["--host", "host"],
  ["--access-path", "accessPath"],
]);
const CLI_REQUIRED = [
  ["--descriptor", "descriptorPath"],
  ["--artifact", "artifactPath"],
  ["--release-root", "releaseRoot"],
  ["--dispatch", "dispatchPath"],
  ["--pi-auth", "piAuthPath"],
  ["--receipt", "receiptPath"],
  ["--model", "model"],
  ["--expected-account-identity", "expectedAccountIdentity"],
];
const CLI_USAGE = "usage: deploy-runtime-release --descriptor PATH --artifact PATH --release-root PATH --dispatch PATH --pi-auth PATH --receipt PATH --model MODEL --expected-account-identity ID (--service SCOPE:UNIT [--service SCOPE:UNIT ...] | --no-required-services) [--host HOST] [--access-path PATH] [--timeout-sec N] [--check-timeout-sec N]\n";

function cliSeconds(value, flag) {
  if (!/^[1-9][0-9]*$/.test(value ?? "")) throw new Error(`${flag} requires a positive integer`);
  return positiveInteger(Number(value) * 1000, flag);
}

function parseCli(argv) {
  const options = { requiredServices: [], noRequiredServices: false };
  for (let index = 0; index < argv.length; index += 1) {
    const flag = argv[index];
    if (flag === "--no-required-services") {
      options.noRequiredServices = true;
      continue;
    }
    const value = argv[index + 1];
    if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`);
    index += 1;
    if (CLI_VALUES.has(flag)) options[CLI_VALUES.get(flag)] = value;
    else if (flag === "--service") options.requiredServices.push(value);
    else if (flag === "--timeout-sec") options.operationTimeoutMs = cliSeconds(value, flag);
    else if (flag === "--check-timeout-sec") options.checkTimeoutMs = cliSeconds(value, flag);
    else throw new Error(`unknown argument: ${flag}`);
  }
  for (const [flag, key] of CLI_REQUIRED) if (!options[key]) throw new Error(`${flag} is required`);
  if (options.noRequiredServices && options.requiredServices.length > 0) {
    throw new Error("--no-required-services cannot be combined with --service");
  }
  if (!options.noRequiredServices && options.requiredServices.length === 0) {
    throw new Error("--service or --no-required-services is required");
  }
  return options;
}

export function runDeployCli(argv, deps = {}) {
  const writeOut = deps.stdout ?? ((text) => process.stdout.write(text));
  const writeErr = deps.stderr ?? ((text) => process.stderr.write(text));
  try {
    const options = parseCli(argv);
    const result = (deps.deploy ?? deployRuntimeRelease)(options, deps);
    writeOut(`${JSON.stringify(result)}\n`);
    return 0;
  } catch (error) {
    writeErr(`deploy-runtime-release: ${error.message}\n${CLI_USAGE}`);
    return 2;
  }
}
