import { spawnSync } from "node:child_process";
import { homedir } from "node:os";
import { isAbsolute } from "node:path";

import { loadRegistry, reachableHostNames, requireReachable, resolveAccess } from "./buildbox-registry.mjs";

const OPERATIONS = new Set(["probe", "ready", "audit"]);
const DEFAULT_OPERATION_TIMEOUT_MS = 30_000;
const DEFAULT_CHECK_TIMEOUT_MS = 10_000;

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

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

function releaseIdentityScript(releaseRoot, requiredServices) {
  return [
    "set -eu",
    `root=${shellQuote(releaseRoot)}`,
    'target=$(readlink "$root/current")',
    'case "$target" in releases/*/runtime) ;; *) echo "invalid-current-pointer" >&2; exit 1 ;; esac',
    'releaseId=${target#releases/}',
    'releaseId=${releaseId%/runtime}',
    'releaseDir="$root/releases/$releaseId"',
    '[ -d "$releaseDir/runtime" ] && [ ! -L "$releaseDir/runtime" ] || { echo "runtime-missing" >&2; exit 1; }',
    'descriptor="$releaseDir/descriptor.json"',
    '[ -f "$descriptor" ] || { echo "descriptor-missing" >&2; 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"',
    ...requiredServices.map(({ scope, unit }) => scope === "user"
      ? `systemctl --user is-active --quiet ${shellQuote(unit)} || { echo ${shellQuote(`service-inactive=user:${unit}`)} >&2; exit 1; }`
      : `systemctl is-active --quiet ${shellQuote(unit)} || { echo ${shellQuote(`service-inactive=system:${unit}`)} >&2; exit 1; }`),
    'printf "releaseId=%s\\n" "$releaseId"',
  ];
}

const RUNTIME_TREE_AUDIT = String.raw`
const fs = require("node:fs");
const path = require("node:path");
const [archivePath, runtimeRoot] = process.argv.slice(1);
const archive = fs.readFileSync(archivePath);
const expected = new Map();
const fail = () => process.exit(1);
const field = (header, offset, length) => {
  const bytes = header.subarray(offset, offset + length);
  const nul = bytes.indexOf(0);
  return bytes.subarray(0, nul < 0 ? bytes.length : nul).toString("utf8");
};
const octal = (header, offset, length) => {
  const value = field(header, offset, length);
  if (!/^[0-7]+$/.test(value)) fail();
  return Number.parseInt(value, 8);
};
const safeRelative = (relative) => !path.isAbsolute(relative)
  && !relative.split("/").some((part) => !part || part === "." || part === "..");
let offset = 0;
for (; offset + 512 <= archive.length;) {
  const header = archive.subarray(offset, offset + 512);
  if (header.every((byte) => byte === 0)) break;
  if (field(header, 257, 6) !== "ustar" || field(header, 263, 2) !== "00") fail();
  const storedChecksum = Number.parseInt(header.subarray(148, 154).toString("ascii"), 8);
  const actualChecksum = [...header].reduce(
    (sum, byte, index) => sum + (index >= 148 && index < 156 ? 0x20 : byte),
    0,
  );
  if (storedChecksum !== actualChecksum) fail();
  const name = field(header, 0, 100);
  const prefix = field(header, 345, 155);
  const full = prefix ? prefix + "/" + name : name;
  const size = octal(header, 124, 12);
  const mode = octal(header, 100, 8) & 0o777;
  const type = header[156] || 0x30;
  if (!Number.isSafeInteger(size) || !full.startsWith("runtime/")) fail();
  const directory = type === 0x35;
  if (directory !== full.endsWith("/")) fail();
  const relative = full.slice(8, directory ? -1 : undefined);
  if ((!relative && !directory) || (relative && !safeRelative(relative))) fail();
  if (expected.has(relative)) fail();
  offset += 512;
  if (offset + size > archive.length) fail();
  const target = relative ? path.join(runtimeRoot, relative) : runtimeRoot;
  const stat = fs.lstatSync(target);
  if (directory) {
    if (size !== 0 || !stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o777) !== mode) fail();
    expected.set(relative, "directory");
  } else if (type === 0x32) {
    if (size !== 0 || !stat.isSymbolicLink() || fs.readlinkSync(target) !== field(header, 157, 100)) fail();
    expected.set(relative, "symlink");
  } else if (type === 0x30) {
    if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o777) !== mode) fail();
    if (!fs.readFileSync(target).equals(archive.subarray(offset, offset + size))) fail();
    expected.set(relative, "file");
  } else {
    fail();
  }
  offset += Math.ceil(size / 512) * 512;
}
if (archive.length - offset < 1024 || !archive.subarray(offset).every((byte) => byte === 0)) fail();
if (expected.get("") !== "directory") fail();
const walk = (directory, base = "") => {
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
    const relative = base ? base + "/" + entry.name : entry.name;
    const wanted = expected.get(relative);
    if (!wanted) fail();
    if (entry.isDirectory()) {
      if (wanted !== "directory") fail();
      walk(path.join(directory, entry.name), relative);
    } else if (wanted === "directory") {
      fail();
    }
  }
};
walk(runtimeRoot);
`;

function driftScript(releaseRoot) {
  return [
    ...releaseIdentityScript(releaseRoot, []),
    'actualArtifact=$(sha256sum "$releaseDir/artifact.tar" | cut -d" " -f1)',
    '[ "$actualArtifact" = "$releaseId" ] || { echo "artifact-drift" >&2; exit 1; }',
    `node -e ${shellQuote(RUNTIME_TREE_AUDIT)} "$releaseDir/artifact.tar" "$releaseDir/runtime" || { echo "runtime-tree-drift" >&2; exit 1; }`,
    'node -e \'const fs=require("node:fs"),crypto=require("node:crypto"),path=require("node:path");const [descriptor,runtime]=process.argv.slice(1),d=JSON.parse(fs.readFileSync(descriptor,"utf8"));for(const [rel,want] of Object.entries(d.unitSha256)){if(!rel.startsWith("systemd/")||path.isAbsolute(rel)||rel.split("/").includes(".."))process.exit(1);const got=crypto.createHash("sha256").update(fs.readFileSync(path.join(runtime,rel))).digest("hex");if(got!==want){console.error(`unit-drift=${rel}`);process.exit(1)}}\' "$descriptor" "$releaseDir/runtime"',
    'printf "drift=clean\\n"',
  ].join("\n");
}

function sshArgs(access, remoteCommand, connectTimeoutMs) {
  if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,31}$/.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",
    "-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));
  }
  args.push("--", `${access.user}@${access.host}`, remoteCommand);
  return args;
}

function requiredServicePolicy(operation, values, noRequiredServices = false) {
  if (noRequiredServices !== true && noRequiredServices !== false) {
    throw new Error("noRequiredServices must be boolean");
  }
  if (operation !== "ready") {
    if (noRequiredServices) throw new Error("noRequiredServices is only valid for ready");
    return [];
  }
  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 a non-empty explicit service policy");
  }
  const seen = new Set();
  return policy.map((value) => {
    const match = /^(system|user):([A-Za-z0-9_.@-]+\.(?:service|socket|timer|path))$/.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 hostChecks(operation, access, releaseRoot, requiredServices, checkTimeoutMs) {
  const ssh = (kind, remoteCommand) => ({
    kind,
    file: "ssh",
    args: sshArgs(access, remoteCommand, checkTimeoutMs),
    remoteCommand,
  });
  if (operation === "probe") {
    return [
      { kind: "dns", file: "getent", args: ["ahosts", access.host] },
      ssh("ssh", "true"),
    ];
  }
  if (operation === "ready") {
    return [ssh("ready", releaseIdentityScript(releaseRoot, requiredServices).join("\n"))];
  }
  return [
    ssh("drift", driftScript(releaseRoot)),
    ssh("authentication", "gh auth status --hostname github.com >/dev/null"),
    ssh("external", "curl --fail --silent --show-error --max-time 5 https://api.github.com/rate_limit >/dev/null"),
  ];
}

function selectedHostNames(registry, requestedHosts) {
  return requestedHosts?.length ? requireReachable(registry, requestedHosts) : reachableHostNames(registry);
}

export function diagnosticPlan(operation, options = {}) {
  if (!OPERATIONS.has(operation)) throw new Error(`unknown diagnostic operation: ${operation}`);
  const registry = options.registry ?? loadRegistry(options.env);
  const checkTimeoutMs = assertPositiveInteger(options.checkTimeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS, "checkTimeoutMs");
  const releaseRoot = options.releaseRoot;
  if (operation !== "probe" && (typeof releaseRoot !== "string" || !isAbsolute(releaseRoot))) {
    throw new Error("releaseRoot must be an absolute path for ready and audit");
  }
  const requiredServices = requiredServicePolicy(operation, options.requiredServices, options.noRequiredServices);
  return selectedHostNames(registry, options.hosts).map((host) => {
    const access = resolveAccess(registry, host, options.accessPath ?? "tailscale_ip");
    return { host, checks: hostChecks(operation, access, releaseRoot, requiredServices, checkTimeoutMs) };
  });
}

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

function detailFrom(result) {
  const lines = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim().split("\n").filter(Boolean);
  return lines.at(-1)?.slice(0, 500) ?? "command-failed";
}

function classify(spec, result) {
  if (result.error?.code === "ETIMEDOUT") return { kind: spec.kind, outcome: "could-not-run", reason: "deadline-exceeded" };
  if (result.error || result.signal || result.status === null || result.status === 255) {
    return { kind: spec.kind, outcome: "could-not-run", reason: result.error?.code ?? result.signal ?? "transport-unavailable" };
  }
  if (result.status !== 0) return { kind: spec.kind, outcome: "fail", reason: detailFrom(result) };
  const check = { kind: spec.kind, outcome: "pass" };
  const releaseId = String(result.stdout ?? "").match(/^releaseId=([0-9a-f]{64})$/m)?.[1];
  if (releaseId) check.releaseId = releaseId;
  return check;
}

function hostOutcome(checks) {
  if (checks.some((check) => check.outcome === "fail")) return "fail";
  if (checks.some((check) => check.outcome === "could-not-run")) return "could-not-run";
  return "pass";
}

export function runDiagnostics(operation, options = {}, deps = {}) {
  const now = deps.now ?? Date.now;
  const execute = deps.execute ?? defaultExecute;
  const operationTimeoutMs = assertPositiveInteger(options.operationTimeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS, "operationTimeoutMs");
  const checkTimeoutMs = assertPositiveInteger(options.checkTimeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS, "checkTimeoutMs");
  const deadlineMs = now() + operationTimeoutMs;
  const plan = diagnosticPlan(operation, { ...options, checkTimeoutMs });
  const hosts = [];

  for (const hostPlan of plan) {
    const checks = [];
    for (const spec of hostPlan.checks) {
      const remainingMs = deadlineMs - now();
      if (remainingMs <= 0) {
        checks.push({ kind: spec.kind, outcome: "could-not-run", reason: "operation-deadline-exceeded" });
        continue;
      }
      const timeoutMs = Math.min(checkTimeoutMs, remainingMs);
      let result;
      try {
        result = execute({ ...spec, timeoutMs });
      } catch (error) {
        result = { status: null, stdout: "", stderr: "", error };
      }
      checks.push(classify(spec, result));
    }
    const hostResult = { host: hostPlan.host, outcome: hostOutcome(checks), checks };
    const releaseId = checks.find((check) => check.releaseId)?.releaseId;
    if (releaseId) hostResult.releaseId = releaseId;
    hosts.push(hostResult);
  }

  return { schemaVersion: 1, operation, timeoutMs: operationTimeoutMs, hosts };
}

function parseSeconds(value, flag) {
  if (!/^\d+$/.test(value ?? "")) throw new Error(`${flag} requires a positive integer`);
  return assertPositiveInteger(Number(value) * 1000, flag);
}

function parseCliArgs(operation, argv) {
  const options = { hosts: [], requiredServices: [], noRequiredServices: false };
  const values = new Set(["--release-root", "--service", "--timeout-sec", "--check-timeout-sec", "--access-path"]);
  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index];
    if (arg === "-h" || arg === "--help") return { help: true };
    if (arg === "--no-required-services") {
      if (operation !== "ready") throw new Error("--no-required-services is only valid for ready");
      options.noRequiredServices = true;
      continue;
    }
    if (!arg.startsWith("--")) {
      options.hosts.push(arg);
      continue;
    }
    if (!values.has(arg)) throw new Error(`unknown argument: ${arg}`);
    const value = argv[++index];
    if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`);
    if (arg === "--release-root") options.releaseRoot = value;
    if (arg === "--service") options.requiredServices.push(value);
    if (arg === "--timeout-sec") options.operationTimeoutMs = parseSeconds(value, arg);
    if (arg === "--check-timeout-sec") options.checkTimeoutMs = parseSeconds(value, arg);
    if (arg === "--access-path") options.accessPath = value;
  }
  if (operation !== "probe" && !options.releaseRoot) throw new Error("--release-root is required");
  if (options.noRequiredServices && options.requiredServices.length > 0) {
    throw new Error("--no-required-services cannot be combined with --service");
  }
  return { options };
}

function cliUsage(operation) {
  const release = operation === "probe" ? "" : " --release-root <absolute-path>";
  const services = operation === "ready"
    ? " (--service <system|user:unit> [--service ...] | --no-required-services)"
    : "";
  return `usage: buildbox-${operation}${release}${services} [--timeout-sec <n>] [--check-timeout-sec <n>] [--access-path <path>] [host...]\n`;
}

export function runDiagnosticCli(operation, argv, deps = {}) {
  const writeOut = deps.stdout ?? ((text) => process.stdout.write(text));
  const writeErr = deps.stderr ?? ((text) => process.stderr.write(text));
  try {
    const parsed = parseCliArgs(operation, argv);
    if (parsed.help) {
      writeOut(cliUsage(operation));
      return 0;
    }
    const result = runDiagnostics(operation, {
      ...parsed.options,
      registry: deps.registry,
      env: deps.env,
    }, {
      execute: deps.execute,
      now: deps.now,
    });
    writeOut(`${JSON.stringify(result)}\n`);
    return result.hosts.every((host) => host.outcome === "pass") ? 0 : 1;
  } catch (error) {
    writeErr(`buildbox-${operation}: ${error.message}\n${cliUsage(operation)}`);
    return 2;
  }
}
