import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import {
  existsSync,
  lstatSync,
  mkdirSync,
  promises as fs,
  readFileSync,
  readlinkSync,
  symlinkSync,
  unlinkSync,
} from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { promisify } from "node:util";
import { canonicalSourceMode } from "./mode.mjs";
import { loadRegistry, resolveAccess } from "../../modules/workstation/claude/lib/buildbox-registry.mjs";
import { loadRemoteConfig } from "../../modules/workstation/claude/lib/remote-build.mjs";
import {
  auditHostSeat,
  provisionHost,
} from "../../modules/workstation/claude/lib/remote-seat-provision.mjs";
import { shq, sshOpts, sshTarget } from "../../modules/workstation/claude/lib/remote-ssh.mjs";
import { SshTransportError } from "./transport-ssh.mjs";

const execFileAsync = promisify(execFile);

const DEFAULT_REPO_ROOT = join(homedir(), "Projects/overdeck");
const DEFAULT_DEPLOY_ROOT = join(homedir(), ".local/share/overdeck/deploy");

function canonicalize(value) {
  if (value === null || typeof value !== "object") {
    return value;
  }
  if (Array.isArray(value)) {
    return value.map((entry) => canonicalize(entry));
  }
  const out = {};
  for (const key of Object.keys(value).sort()) {
    out[key] = canonicalize(value[key]);
  }
  return out;
}

export function computeItemsRevision(items) {
  const structure = items.map((item) => ({
    id: item.id,
    source: item.source,
    destination: item.destination,
    ownership: item.ownership,
    strategy: item.strategy,
    mode: item.mode,
    content_digest: item.content_digest,
    owner: item.owner,
  }));
  return createHash("sha256").update(JSON.stringify(canonicalize(structure))).digest("hex");
}

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

function isConnectionFailure(message) {
  return /ECONNREFUSED|Connection refused|Connection timed out|No route to host|Network is unreachable|Could not resolve hostname|Operation timed out|Connection reset/i.test(
    message,
  );
}

function isUnreachableError(err) {
  if (err instanceof SshTransportError || err?.name === "SshTransportError") {
    return true;
  }
  const message = String(err?.message ?? err ?? "");
  return isConnectionFailure(message);
}

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;
}

function canonicalMode(mode) {
  if (mode === null || mode === undefined) {
    return null;
  }
  if (typeof mode === "number") {
    return (mode & 0o777).toString(8);
  }
  const text = String(mode);
  const numeric = Number(text);
  if (Number.isInteger(numeric) && numeric <= 0o777) {
    return numeric.toString(8);
  }
  if (/^[0-7]{3}$/.test(text)) {
    return text;
  }
  return text;
}

function expectedMode(item, options) {
  if (item.strategy !== "copy" && item.strategy !== "unit") {
    return null;
  }
  const source = resolveSourceAbs(item, options);
  if (!source || !existsSync(source)) {
    return null;
  }
  const st = lstatSync(source);
  if (st.isSymbolicLink() || st.isDirectory()) {
    return null;
  }
  return canonicalSourceMode(st);
}

function expectedLinkTarget(item, options) {
  if (item.strategy !== "symlink") {
    return null;
  }
  const source = resolveSourceAbs(item, options);
  if (!source) {
    return null;
  }
  return source;
}

function expectedUnitState() {
  return "enabled:active";
}

function formatUnitState(state) {
  if (state.enabled && state.active) {
    return "enabled:active";
  }
  return `${state.enabled}:${state.active}`;
}

function splitItems(items) {
  const fleet = [];
  const delegated = [];
  for (const item of items) {
    if (item.owner === "remote-seat-provision") {
      delegated.push(item);
    } else {
      fleet.push(item);
    }
  }
  return { fleet, delegated };
}

function buildSeatCfg(nodeName, options) {
  const registry = options.deps?.loadRegistry?.() ?? loadRegistry();
  const remoteCfg = options.deps?.loadRemoteConfig?.() ?? loadRemoteConfig();
  const access = resolveAccess(registry, nodeName, "tailscale_ip");
  return {
    host: nodeName,
    ssh_user: access.user,
    port: access.port,
    identity_file: access.identity_file,
    remote_root: remoteCfg.remote_root,
    hosts: registry.hosts.map((host) => host.name),
  };
}

async function auditFleetItem(item, transport, options) {
  const drift = [];
  const state = await transport.read(item.destination);

  if (item.strategy === "copy" || item.strategy === "unit") {
    const haveDigest = state.exists ? state.digest : null;
    if (haveDigest !== item.content_digest) {
      drift.push({ id: item.id, field: "digest", want: item.content_digest, have: haveDigest });
    }
  }

  if (item.strategy === "symlink") {
    const wantLink = expectedLinkTarget(item, options);
    const haveLink = state.exists ? state.linkTarget : null;
    if (wantLink !== haveLink) {
      drift.push({ id: item.id, field: "link_target", want: wantLink, have: haveLink });
    }
    const wantDigest = wantLink ? sha256Hex(`L:${wantLink}`) : item.content_digest;
    const haveDigest = state.exists ? state.digest : null;
    if (haveDigest !== wantDigest) {
      drift.push({ id: item.id, field: "digest", want: wantDigest, have: haveDigest });
    }
  }

  const haveOwnership = state.exists ? state.ownership : "user";
  if (haveOwnership !== item.ownership) {
    drift.push({ id: item.id, field: "ownership", want: item.ownership, have: haveOwnership });
  }

  const wantMode = expectedMode(item, options);
  if (wantMode !== null) {
    const haveMode = state.exists ? canonicalMode(state.mode) : null;
    if (haveMode !== wantMode) {
      drift.push({ id: item.id, field: "mode", want: wantMode, have: haveMode });
    }
  }

  if (item.strategy === "unit") {
    const unitName = basename(item.destination);
    const stateUnit = await transport.unitState(unitName);
    const want = expectedUnitState();
    const have = formatUnitState(stateUnit);
    if (have !== want) {
      drift.push({ id: item.id, field: "unit_state", want, have });
    }
  }

  if (item.strategy === "git-checkout") {
    const wantRef = item.source.ref;
    const haveRef = await transport.gitRef(item.destination);
    if (wantRef !== haveRef) {
      drift.push({ id: item.id, field: "git_ref", want: wantRef, have: haveRef });
    }
  }

  return drift;
}

async function auditDelegated(nodeName, delegatedItems, transport, options) {
  const itemIds = delegatedItems.map((item) => item.id);
  if (itemIds.length === 0) {
    return null;
  }

  if (transport.name === "ssh") {
    const cfg = buildSeatCfg(nodeName, options);
    const audit = options.deps?.auditHostSeat ?? auditHostSeat;
    const lines = audit(cfg, options.seatOpts ?? {}, options.deps ?? {});
    const ok = lines.every((line) => line.level !== "FAIL");
    return { owner: "remote-seat-provision", itemIds, ok, lines };
  }

  const drift = [];
  for (const item of delegatedItems) {
    drift.push(...(await auditFleetItem(item, transport, options)));
  }
  return { owner: "remote-seat-provision", itemIds, ok: drift.length === 0, drift };
}

function readSourceBytes(item, options) {
  const source = resolveSourceAbs(item, options);
  if (!source || !existsSync(source)) {
    throw new Error(`missing source for ${item.id}: ${item.source.kind}:${item.source.path}`);
  }
  const st = lstatSync(source);
  if (!st.isFile()) {
    throw new Error(`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,
    };
  }
}

async function runTransportCommand(transport, command, options) {
  if (transport.name === "local") {
    const home = options.home ?? homedir();
    const exec = options.localExec ?? execCommand;
    if (command.argv) {
      return exec(command.argv);
    }
    const dest = expandTilde(command.dest, home);
    const target = expandTilde(command.target, home);
    mkdirSync(dirname(dest), { recursive: true });
    if (existsSync(dest)) {
      unlinkSync(dest);
    }
    symlinkSync(target, dest);
    return { stdout: "", stderr: "", code: 0 };
  }

  const sshCfg = command.sshCfg;
  const remote = command.remote;
  const argv = ["ssh", ...sshOpts(sshCfg, { mux: false }), sshTarget(sshCfg), remote];
  const exec = options.sshExec ?? execCommand;
  return exec(argv);
}

async function applySymlink(item, transport, options) {
  const target = expectedLinkTarget(item, options);
  if (!target) {
    throw new Error(`cannot resolve symlink target for ${item.id}`);
  }
  if (transport.name === "local") {
    const home = options.home ?? homedir();
    await runTransportCommand(
      transport,
      { dest: item.destination, target },
      { ...options, home },
    );
    return;
  }
  const cfg = buildSeatCfg(options.nodeName, options);
  const sshCfg = {
    host: cfg.host,
    port: cfg.port,
    ssh_user: cfg.ssh_user,
    identity_file: cfg.identity_file,
    connect_timeout_sec: 6,
  };
  const dest = item.destination;
  const remote = [
    `dest=${shq(dest)}`,
    `target=${shq(target)}`,
    'mkdir -p "$(dirname "$dest")"',
    'rm -f "$dest"',
    'ln -sfn "$target" "$dest"',
  ].join("; ");
  const result = await runTransportCommand(transport, { remote, sshCfg }, options);
  if (result.code !== 0) {
    throw new SshTransportError(options.nodeName, result.stderr.trim() || `symlink failed with exit ${result.code}`);
  }
}

async function applyGitCheckout(item, transport, options) {
  const dest = item.destination;
  const repo = item.source.path;
  const ref = item.source.ref;
  if (transport.name === "local") {
    const home = options.home ?? homedir();
    const absDest = expandTilde(dest, home);
    mkdirSync(dirname(absDest), { recursive: true });
    if (!existsSync(absDest)) {
      const clone = await execCommand(["git", "clone", "--quiet", repo, absDest]);
      if (clone.code !== 0) {
        throw new Error(`git clone failed for ${item.id}: ${clone.stderr}`);
      }
    } else {
      const fetch = await execCommand(["git", "-C", absDest, "fetch", "--quiet", "origin"]);
      if (fetch.code !== 0) {
        throw new Error(`git fetch failed for ${item.id}: ${fetch.stderr}`);
      }
    }
    const checkout = await execCommand(["git", "-C", absDest, "checkout", "--quiet", "--detach", ref]);
    if (checkout.code !== 0) {
      throw new Error(`git checkout failed for ${item.id}: ${checkout.stderr}`);
    }
    return;
  }
  const cfg = buildSeatCfg(options.nodeName, options);
  const sshCfg = {
    host: cfg.host,
    port: cfg.port,
    ssh_user: cfg.ssh_user,
    identity_file: cfg.identity_file,
    connect_timeout_sec: 6,
  };
  const remote = [
    `dest=${shq(dest)}`,
    `repo=${shq(repo)}`,
    `ref=${shq(ref)}`,
    'if [ ! -d "$dest/.git" ]; then rm -rf "$dest"; mkdir -p "$(dirname "$dest")"; git clone --quiet "$repo" "$dest" || exit 1; else git -C "$dest" fetch --quiet origin || exit 1; fi',
    'git -C "$dest" checkout --quiet --detach "$ref" || exit 1',
  ].join("; ");
  const result = await runTransportCommand(transport, { remote, sshCfg }, options);
  if (result.code !== 0) {
    throw new SshTransportError(options.nodeName, result.stderr.trim() || `git checkout failed with exit ${result.code}`);
  }
}


async function applyFileMode(item, transport, options) {
  const wantMode = expectedMode(item, options);
  if (!wantMode) {
    return;
  }
  if (transport.name === "local") {
    const home = options.home ?? homedir();
    const dest = expandTilde(item.destination, home);
    await fs.chmod(dest, Number.parseInt(wantMode, 8));
    return;
  }
  const cfg = buildSeatCfg(options.nodeName, options);
  const sshCfg = {
    host: cfg.host,
    port: cfg.port,
    ssh_user: cfg.ssh_user,
    identity_file: cfg.identity_file,
    connect_timeout_sec: 6,
  };
  const remote = `dest=${shq(item.destination)}; chmod ${wantMode} "$dest"`;
  const result = await runTransportCommand(transport, { remote, sshCfg }, options);
  if (result.code !== 0) {
    throw new SshTransportError(options.nodeName, result.stderr.trim() || `chmod failed with exit ${result.code}`);
  }
}

async function applyUnit(item, transport, options) {
  const bytes = readSourceBytes(item, options);
  await transport.write(item, bytes);
  const unitName = basename(item.destination);
  if (transport.name === "local") {
    const exec = options.localExec ?? execCommand;
    await exec(["systemctl", "--user", "daemon-reload"]);
    await exec(["systemctl", "--user", "enable", unitName]);
    await exec(["systemctl", "--user", "start", unitName]);
    return;
  }
  const cfg = buildSeatCfg(options.nodeName, options);
  const sshCfg = {
    host: cfg.host,
    port: cfg.port,
    ssh_user: cfg.ssh_user,
    identity_file: cfg.identity_file,
    connect_timeout_sec: 6,
  };
  const remote = [
    `unit=${shq(unitName)}`,
    "systemctl --user daemon-reload",
    'systemctl --user enable "$unit"',
    'systemctl --user start "$unit"',
  ].join("; ");
  const result = await runTransportCommand(transport, { remote, sshCfg }, options);
  if (result.code !== 0) {
    throw new SshTransportError(options.nodeName, result.stderr.trim() || `unit activation failed with exit ${result.code}`);
  }
}

async function convergeFleetItem(item, transport, options) {
  switch (item.strategy) {
    case "copy": {
      const bytes = readSourceBytes(item, options);
      await transport.write(item, bytes);
      await applyFileMode(item, transport, options);
      return;
    }
    case "symlink":
      await applySymlink(item, transport, options);
      return;
    case "git-checkout":
      await applyGitCheckout(item, transport, options);
      return;
    case "unit":
      await applyUnit(item, transport, options);
      await applyFileMode(item, transport, options);
      return;
    default:
      throw new Error(`unsupported strategy for ${item.id}: ${item.strategy}`);
  }
}

async function convergeDelegated(nodeName, delegatedItems, transport, options, delegatedReport) {
  const itemIds = delegatedItems.map((item) => item.id);
  if (itemIds.length === 0) {
    return { changed: [], failed: [] };
  }
  if (transport.name !== "ssh") {
    const drifted = new Set((delegatedReport?.drift ?? []).map((entry) => entry.id));
    const changed = [];
    const failed = [];
    for (const item of delegatedItems) {
      if (!drifted.has(item.id)) {
        continue;
      }
      if (item.mode !== "converge" || item.ownership === "root") {
        continue;
      }
      try {
        await convergeFleetItem(item, transport, { ...options, nodeName });
        changed.push(item.id);
      } catch (err) {
        failed.push({ id: item.id, error: String(err.message ?? err) });
      }
    }
    return { changed, failed };
  }
  const cfg = buildSeatCfg(nodeName, options);
  const provision = options.deps?.provisionHost ?? provisionHost;
  const result = provision(cfg, options.seatOpts ?? {}, options.deps ?? {});
  if (!result.ok) {
    return {
      changed: [],
      failed: [{ id: "remote-seat-provision", error: result.reason ?? "provision failed" }],
    };
  }
  return { changed: itemIds, failed: [] };
}

export function formatAuditReport(report) {
  const lines = [];
  if (report.unreachable) {
    lines.push(`UNREACHABLE ${report.node}`);
    return lines;
  }
  for (const entry of report.drift) {
    lines.push(
      `DRIFT ${report.node} ${entry.id} ${entry.field} want=${JSON.stringify(entry.want)} have=${JSON.stringify(entry.have)}`,
    );
  }
  if (report.delegated && !report.delegated.ok) {
    if (Array.isArray(report.delegated.lines)) {
      for (const line of report.delegated.lines) {
        if (line.level === "FAIL") {
          lines.push(`DRIFT ${report.node} delegated ${line.msg}`);
        }
      }
    } else if (Array.isArray(report.delegated.drift)) {
      for (const entry of report.delegated.drift) {
        lines.push(
          `DRIFT ${report.node} ${entry.id} ${entry.field} want=${JSON.stringify(entry.want)} have=${JSON.stringify(entry.have)}`,
        );
      }
    }
  }
  if (lines.length === 0 && !report.unreachable) {
    if (report.ok) {
      lines.push(`OK ${report.node}`);
    } else {
      lines.push(`ERROR ${report.node} ${report.error ?? "audit failed"}`);
    }
  }
  return lines;
}

export function printAuditReport(report, options = {}) {
  const log = options.log ?? ((line) => console.log(line));
  for (const line of formatAuditReport(report)) {
    log(line);
  }
}

export async function auditNode(nodeName, items, transport, options = {}) {
  const revision = computeItemsRevision(items);
  const { fleet, delegated } = splitItems(items);

  try {
    const drift = [];
    for (const item of fleet) {
      drift.push(...(await auditFleetItem(item, transport, options)));
    }
    const delegatedReport = await auditDelegated(nodeName, delegated, transport, options);
    const delegatedOk = !delegatedReport || delegatedReport.ok;
    return {
      node: nodeName,
      revision,
      ok: drift.length === 0 && delegatedOk,
      drift,
      unreachable: false,
      delegated: delegatedReport,
    };
  } catch (err) {
    if (isUnreachableError(err)) {
      return {
        node: nodeName,
        revision,
        ok: false,
        drift: [],
        unreachable: true,
        delegated: null,
      };
    }
    throw err;
  }
}

export async function convergeNode(nodeName, items, transport, options = {}) {
  const revision = computeItemsRevision(items);
  const { fleet, delegated } = splitItems(items);
  const changed = [];
  const failed = [];

  let initialAudit;
  try {
    initialAudit = await auditNode(nodeName, items, transport, options);
  } catch (err) {
    if (isUnreachableError(err)) {
      return {
        node: nodeName,
        revision,
        changed,
        failed: [{ id: "*", error: String(err.message ?? err) }],
        finalAudit: {
          node: nodeName,
          revision,
          ok: false,
          drift: [],
          unreachable: true,
          delegated: null,
        },
      };
    }
    throw err;
  }

  if (initialAudit.unreachable) {
    return {
      node: nodeName,
      revision,
      changed,
      failed: [{ id: "*", error: "node unreachable" }],
      finalAudit: initialAudit,
    };
  }

  const driftedFleetIds = new Set(initialAudit.drift.map((entry) => entry.id));

  for (const item of fleet) {
    if (!driftedFleetIds.has(item.id)) {
      continue;
    }
    if (item.mode !== "converge" || item.ownership === "root") {
      continue;
    }
    try {
      await convergeFleetItem(item, transport, { ...options, nodeName });
      changed.push(item.id);
    } catch (err) {
      failed.push({ id: item.id, error: String(err.message ?? err) });
    }
  }

  const delegatedResult = await convergeDelegated(
    nodeName,
    delegated,
    transport,
    options,
    initialAudit.delegated,
  );
  changed.push(...delegatedResult.changed);
  failed.push(...delegatedResult.failed);

  const finalAudit = await auditNode(nodeName, items, transport, options);
  return {
    node: nodeName,
    revision,
    changed,
    failed,
    finalAudit,
  };
}

export async function auditNodes(specs, options = {}) {
  const log = options.log ?? ((line) => console.log(line));
  const results = await Promise.all(
    specs.map(async (spec, index) => {
      try {
        const report = await auditNode(spec.nodeName, spec.items, spec.transport, {
          ...options,
          ...spec.options,
        });
        return { index, report };
      } catch (err) {
        return {
          index,
          report: {
            node: spec.nodeName,
            revision: computeItemsRevision(spec.items),
            ok: false,
            drift: [],
            unreachable: false,
            delegated: null,
            error: String(err.message ?? err),
          },
        };
      }
    }),
  );
  results.sort((a, b) => a.index - b.index);
  for (const { report } of results) {
    printAuditReport(report, { log });
  }
  return results.map((entry) => entry.report);
}

export async function convergeNodes(specs, options = {}) {
  const log = options.log ?? ((line) => console.log(line));
  const results = await Promise.all(
    specs.map(async (spec, index) => {
      try {
        const report = await convergeNode(spec.nodeName, spec.items, spec.transport, {
          ...options,
          ...spec.options,
        });
        return { index, report };
      } catch (err) {
        const revision = computeItemsRevision(spec.items);
        return {
          index,
          report: {
            node: spec.nodeName,
            revision,
            changed: [],
            failed: [{ id: "*", error: String(err.message ?? err) }],
            finalAudit: {
              node: spec.nodeName,
              revision,
              ok: false,
              drift: [],
              unreachable: false,
              delegated: null,
              error: String(err.message ?? err),
            },
          },
        };
      }
    }),
  );
  results.sort((a, b) => a.index - b.index);
  for (const { report } of results) {
    printAuditReport(report.finalAudit, { log });
  }
  return results.map((entry) => entry.report);
}

export function aggregateExitCode(reports) {
  for (const report of reports) {
    if ("finalAudit" in report) {
      if (report.failed.length > 0 || !report.finalAudit.ok || report.finalAudit.unreachable) {
        return 1;
      }
      continue;
    }
    if (!report.ok || report.unreachable) {
      return 1;
    }
  }
  return 0;
}
