import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import {
  existsSync,
  lstatSync,
  mkdtempSync,
  readFileSync,
  unlinkSync,
  writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { loadRegistry, resolveAccess } from "../../modules/workstation/claude/lib/buildbox-registry.mjs";
import { shq, sshOpts, sshTarget } from "../../modules/workstation/claude/lib/remote-ssh.mjs";
import { auditNode, computeItemsRevision } from "./engine.mjs";
import { canonicalSourceMode } from "./mode.mjs";
import { SshTransportError } from "./transport-ssh.mjs";

const execFileAsync = promisify(execFile);

const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
const DEFAULT_REPO_ROOT = join(homedir(), "Projects/overdeck");
const DEFAULT_DEPLOY_ROOT = join(homedir(), ".local/share/overdeck/deploy");
const DEADMAN_SRC = join(REPO_ROOT, "modules/buildbox/deadman/buildbox-deadman");
const DEFAULT_DEADMAN_SEC = 1200;
const DEFAULT_REBOOT_WAIT_SEC = 900;

export class HardenError extends Error {
  constructor(message, { report = null } = {}) {
    super(message);
    this.name = "HardenError";
    this.report = report;
  }
}

/**
 * @param {string} nodeName
 * @param {import("./types.mjs").Transport} transport
 */
export function validateHardenTarget(nodeName, transport) {
  const name = String(nodeName ?? "").trim();
  if (!name) {
    throw new HardenError("harden requires exactly one buildbox node");
  }
  if (/[\s,]/.test(name)) {
    throw new HardenError(`harden refuses multiple nodes: ${JSON.stringify(nodeName)}`);
  }
  if (name === "*" || name === "all" || name === "__all__") {
    throw new HardenError("harden refuses implicit all-node selection");
  }
  if (name === "workstation") {
    throw new HardenError("harden refuses the workstation node");
  }
  if (transport.name !== "ssh") {
    throw new HardenError(`harden requires ssh transport, got ${transport.name}`);
  }
}

/**
 * @param {import("./types.mjs").DesiredItem[]} items
 */
function selectHardenItems(items) {
  return items.filter((item) => item.mode === "harden");
}

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

function expandTilde(path, home) {
  if (path === "~") return home;
  if (path.startsWith("~/")) return join(home, path.slice(2));
  return path;
}

function resolveRepoRoot(options) {
  return options.repoRoot ?? process.env.OVERDECK_REPO_DIR ?? DEFAULT_REPO_ROOT;
}

function resolveDeployRoot(options) {
  return options.deployRoot ?? process.env.OVERDECK_DEPLOY_DIR ?? DEFAULT_DEPLOY_ROOT;
}

function resolveSourceAbs(item, options) {
  const repoRoot = resolveRepoRoot(options);
  const deployRoot = resolveDeployRoot(options);
  if (item.source.kind === "repo") {
    return join(repoRoot, item.source.path);
  }
  if (item.source.kind === "deploy") {
    return join(deployRoot, item.source.path);
  }
  return null;
}

/**
 * @param {import("./types.mjs").DesiredItem} item
 */
function readSourceBytes(item, options) {
  const source = resolveSourceAbs(item, options);
  if (!source || !existsSync(source)) {
    throw new HardenError(`missing source for ${item.id}: ${item.source.kind}:${item.source.path}`);
  }
  const st = lstatSync(source);
  if (!st.isFile()) {
    throw new HardenError(`source for ${item.id} is not a regular file: ${source}`);
  }
  return readFileSync(source);
}

async function execCommand(argv) {
  try {
    const { stdout, stderr } = await execFileAsync(argv[0], argv.slice(1), { encoding: "utf8" });
    return { stdout: stdout ?? "", stderr: stderr ?? "", code: 0 };
  } catch (err) {
    const execErr = /** @type {NodeJS.ErrnoException & { stdout?: string; stderr?: string }} */ (err);
    return {
      stdout: execErr.stdout ?? "",
      stderr: execErr.stderr ?? "",
      code: typeof execErr.code === "number" ? execErr.code : 1,
    };
  }
}

function buildSshCfg(nodeName, options) {
  const registry = options.deps?.loadRegistry?.() ?? loadRegistry();
  const access = resolveAccess(registry, nodeName, "tailscale_ip");
  return {
    host: nodeName,
    port: access.port,
    ssh_user: access.user,
    identity_file: access.identity_file,
    connect_timeout_sec: 6,
  };
}

/**
 * @param {string} nodeName
 * @param {import("./types.mjs").Transport} transport
 * @param {Record<string, unknown>} options
 */
function createDefaultHardenOps(nodeName, transport, options) {
  const sshCfg = buildSshCfg(nodeName, options);
  const exec = options.sshExec ?? execCommand;
  const deadmanSec = options.deadmanSec ?? DEFAULT_DEADMAN_SEC;

  async function runRemote(remote) {
    const argv = ["ssh", ...sshOpts(sshCfg, { mux: false }), sshTarget(sshCfg), remote];
    const result = await exec(argv);
    if (result.code !== 0) {
      throw new SshTransportError(nodeName, result.stderr.trim() || `remote command failed with exit ${result.code}`);
    }
    return result;
  }

  async function upload(localPath, remotePath) {
    const scpOpts = sshOpts(sshCfg, { mux: false }).map((value) => (value === "-p" ? "-P" : value));
    const argv = [
      "scp",
      ...scpOpts,
      "-q",
      localPath,
      `${sshTarget(sshCfg)}:${remotePath}`,
    ];
    const result = await exec(argv);
    if (result.code !== 0) {
      throw new SshTransportError(nodeName, result.stderr.trim() || `upload failed with exit ${result.code}`);
    }
  }

  return {
    async bootId() {
      const result = await runRemote("cat /proc/sys/kernel/random/boot_id");
      return result.stdout.trim();
    },

    async armDeadman(changeId) {
      if (!existsSync(DEADMAN_SRC)) {
        throw new HardenError(`deadman missing: ${DEADMAN_SRC}`);
      }
      const remoteTmp = `/tmp/buildbox-deadman.${process.pid}`;
      await upload(DEADMAN_SRC, remoteTmp);
      await runRemote(
        `sudo -n install -m 0755 ${shq(remoteTmp)} /usr/local/sbin/buildbox-deadman && rm -f ${shq(remoteTmp)}`,
      );
      await runRemote(
        `sudo -n /usr/local/sbin/buildbox-deadman arm --id ${shq(changeId)} --deadline-sec ${deadmanSec}`,
      );
    },

    async disarmDeadman(changeId) {
      await runRemote(`sudo -n /usr/local/sbin/buildbox-deadman disarm ${shq(changeId)}`);
    },

    async writeRoot(item, bytes) {
      if (bytes.length > 0 && sha256Hex(bytes) !== item.content_digest) {
        throw new HardenError(
          `content digest mismatch for ${item.id}: expected ${item.content_digest}`,
        );
      }
      const dest = item.destination;
      const installMode = canonicalSourceMode(lstatSync(resolveSourceAbs(item, options)));
      const stagingPath = join(
        dirname(dest),
        `.${basename(dest)}.harden-staging-${process.pid}-${createHash("sha256").update(dest).digest("hex").slice(0, 8)}`,
      );
      const localStaging = join(mkdtempSync(join(homedir(), "fleet-harden-upload-")), "payload");
      writeFileSync(localStaging, bytes);
      try {
        await runRemote(`mkdir -p ${shq(dirname(dest))}`);
        await upload(localStaging, stagingPath);
        const remote = [
          `dest=${shq(dest)}`,
          `staging=${shq(stagingPath)}`,
          `expected=${shq(item.content_digest)}`,
          `mode=${shq(installMode)}`,
          'actual=$(sha256sum "$staging" | awk \'{print $1}\')',
          '[ "$actual" = "$expected" ] || { rm -f "$staging"; exit 42; }',
          'sudo -n install -D -m "$mode" -o root -g root "$staging" "$dest"',
          'rm -f "$staging"',
        ].join("; ");
        const argv = ["ssh", ...sshOpts(sshCfg, { mux: false }), sshTarget(sshCfg), remote];
        const result = await exec(argv);
        if (result.code === 42) {
          throw new HardenError(`staged content digest mismatch for ${item.id}`);
        }
        if (result.code !== 0) {
          await runRemote(`rm -f ${shq(stagingPath)}`).catch(() => {});
          throw new SshTransportError(
            nodeName,
            result.stderr.trim() || `root write failed with exit ${result.code}`,
          );
        }
      } finally {
        unlinkSync(localStaging);
      }
    },

    async reboot() {
      await runRemote("sudo -n systemctl reboot").catch(() => {});
    },

    async waitForReboot(bootIdBefore, timeoutSec = DEFAULT_REBOOT_WAIT_SEC) {
      const deadline = Date.now() + timeoutSec * 1000;
      while (Date.now() < deadline) {
        try {
          const current = await this.bootId();
          if (current && current !== bootIdBefore) {
            return current;
          }
        } catch {
          // host may be down during reboot
        }
        await new Promise((resolve) => setTimeout(resolve, 5000));
      }
      return null;
    },

    async restoreLastKnownGood(changeId) {
      const remote = [
        `id=${shq(changeId)}`,
        'state="/var/lib/buildbox-deadman/state.json"',
        'dir="/var/lib/buildbox-deadman"',
        '[ -f "$state" ] || exit 0',
        `python3 -c 'import json,sys; s=json.load(open(sys.argv[1])); sys.exit(0 if s.get("id")==sys.argv[2] else 1)' "$state" "$id" || exit 0`,
        'snap=$(python3 -c \'import json,sys; print(json.load(open(sys.argv[1])).get("snapshot",""))\' "$state")',
        '[ -n "$snap" ] && [ -f "$snap" ] || exit 0',
        'stage="$dir/restore.$$"',
        'rm -rf "$stage"; mkdir -p "$stage"',
        'tar --numeric-owner --acls --xattrs -xf "$snap" -C "$stage"',
        'for p in etc/ssh etc/systemd etc/sysctl.d etc/fstab; do',
        '  new="$stage/$p"; cur="/$p"; old="/$p.deadman-old.$$"',
        '  [ -e "$new" ] || { sudo -n rm -rf "$cur"; continue; }',
        '  if [ -e "$cur" ]; then sudo -n mv "$cur" "$old"; fi',
        '  sudo -n mv "$new" "$cur"',
        '  sudo -n rm -rf "$old"',
        'done',
        'rm -rf "$stage"',
        'sudo -n systemctl daemon-reload || true',
        'sudo -n systemctl restart ssh || true',
        'rm -f "$snap" "$state"',
        'sudo -n systemctl disable --now buildbox-deadman.timer >/dev/null 2>&1 || true',
      ].join("\n");
      await runRemote(remote);
    },
  };
}

/**
 * @param {string} nodeName
 * @param {import("./types.mjs").DesiredItem[]} items
 * @param {import("./types.mjs").Transport} transport
 * @param {Record<string, unknown>} [options]
 */
export async function hardenNode(nodeName, items, transport, options = {}) {
  validateHardenTarget(nodeName, transport);

  const hardenItems = selectHardenItems(items);
  const revision = computeItemsRevision(hardenItems);
  const changeId = revision.slice(0, 16);
  const ops = options.deps?.hardenOps ?? createDefaultHardenOps(nodeName, transport, options);

  const bootIdBefore = await ops.bootId();
  if (!bootIdBefore) {
    throw new HardenError(`${nodeName} is unreachable`);
  }

  const initialAudit = await auditNode(nodeName, hardenItems, transport, options);
  if (initialAudit.unreachable) {
    throw new HardenError(`${nodeName} is unreachable`);
  }

  const driftedIds = new Set(initialAudit.drift.map((entry) => entry.id));
  const changed = [];

  if (driftedIds.size > 0) {
    await ops.armDeadman(changeId);
    for (const item of hardenItems) {
      if (!driftedIds.has(item.id) || item.owner !== "fleet") {
        continue;
      }
      const bytes = readSourceBytes(item, options);
      await ops.writeRoot(item, bytes);
      changed.push(item.id);
    }
  }

  let rebooted = false;
  let bootIdAfter = bootIdBefore;

  if (changed.length > 0) {
    await ops.reboot();
    const timeoutSec = options.rebootTimeoutSec ?? DEFAULT_REBOOT_WAIT_SEC;
    const nextBootId = await ops.waitForReboot(bootIdBefore, timeoutSec);
    if (!nextBootId || nextBootId === bootIdBefore) {
      await ops.restoreLastKnownGood(changeId).catch(() => {});
      throw new HardenError(
        `${nodeName} did not come back under a new boot id within ${timeoutSec}s`,
        {
          report: {
            node: nodeName,
            revision,
            changed,
            rebooted: false,
            bootIdBefore,
            bootIdAfter: bootIdBefore,
            finalAudit: await auditNode(nodeName, hardenItems, transport, options),
          },
        },
      );
    }
    bootIdAfter = nextBootId;
    rebooted = true;
  }

  const finalAudit = await auditNode(nodeName, hardenItems, transport, options);
  if (!finalAudit.ok) {
    await ops.restoreLastKnownGood(changeId);
    throw new HardenError(`post-reboot audit failed on ${nodeName}`, {
      report: {
        node: nodeName,
        revision,
        changed,
        rebooted,
        bootIdBefore,
        bootIdAfter,
        finalAudit,
      },
    });
  }

  if (rebooted) {
    await ops.disarmDeadman(changeId);
  }

  return {
    node: nodeName,
    revision,
    changed,
    rebooted,
    bootIdBefore,
    bootIdAfter,
    finalAudit,
  };
}
