#!/usr/bin/env node
// Single source of truth for buildbox host identity, state, access paths and spill order.
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";

const HOME = process.env.HOME || homedir();

export class BuildboxRegistryError extends Error {
  constructor(message) {
    super(message);
    this.name = "BuildboxRegistryError";
  }
}

export function registryPath(env = process.env) {
  return env.BUILDBOX_HOSTS_CONFIG || join(HOME, ".claude", "buildbox-hosts.json");
}

const HOST_KEYS = new Set(["name", "ssh_alias", "state", "machine_id", "roles", "access", "rustdesk", "notes"]);
const ROOT_KEYS = new Set(["schema_version", "hosts", "orders"]);
const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/i;

export const HOST_STATES = Object.freeze(["reachable", "unreachable", "bricked"]);
// SSH doors only. Every address here is a client-side dial target; NEVER use one as a
// bind address for a service — sshd pinned to a per-host tailnet IP is what took
// debian2 and debian3 off the network at cold boot.
export const ACCESS_PATHS = Object.freeze(["lan", "tailscale_ip", "tailscale_ssh"]);
const ACCESS_KEYS = new Set(["host", "port", "user", "identity_file"]);
const ADDRESS_RE = /^[a-z0-9][a-z0-9.:_-]*$/i;

function bad(detail) {
  throw new BuildboxRegistryError(`buildbox registry invalid: ${detail}`);
}

function parseAccess(access, name, source) {
  if (!access || typeof access !== "object" || Array.isArray(access)) bad(`${source}: ${name}.access must be an object`);
  for (const key of Object.keys(access)) if (!ACCESS_PATHS.includes(key)) bad(`${source}: ${name}.access unknown path "${key}"`);
  const parsed = {};
  for (const path of ACCESS_PATHS) {
    if (!(path in access)) bad(`${source}: ${name}.access is missing "${path}" (use null when the path is unknown)`);
    const entry = access[path];
    if (entry === null) { parsed[path] = null; continue; }
    if (typeof entry !== "object" || Array.isArray(entry)) bad(`${source}: ${name}.access.${path} must be an object or null`);
    for (const key of Object.keys(entry)) if (!ACCESS_KEYS.has(key)) bad(`${source}: ${name}.access.${path} unknown key "${key}"`);
    const { host, port, user, identity_file: identityFile } = entry;
    if (typeof host !== "string" || !ADDRESS_RE.test(host)) bad(`${source}: ${name}.access.${path}.host invalid`);
    if (!Number.isInteger(port) || port < 1 || port > 65535) bad(`${source}: ${name}.access.${path}.port invalid`);
    if (typeof user !== "string" || !user) bad(`${source}: ${name}.access.${path}.user must be a non-empty string`);
    if (identityFile !== null && (typeof identityFile !== "string" || !identityFile)) {
      bad(`${source}: ${name}.access.${path}.identity_file must be a non-empty string or null`);
    }
    parsed[path] = Object.freeze({ host, port, user, identity_file: identityFile });
  }
  return Object.freeze(parsed);
}

export function parseRegistry(text, source) {
  let raw;
  try {
    raw = JSON.parse(text);
  } catch (err) {
    throw new BuildboxRegistryError(`buildbox registry unreadable (${source}): ${err.message}`);
  }
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) bad(`${source}: root must be an object`);
  for (const key of Object.keys(raw)) if (!ROOT_KEYS.has(key)) bad(`${source}: unknown key "${key}"`);
  if (raw.schema_version !== 1) bad(`${source}: schema_version must be 1`);
  if (!Array.isArray(raw.hosts) || raw.hosts.length === 0) bad(`${source}: hosts must be a non-empty array`);

  const seenNames = new Set();
  const seenMachineIds = new Map();
  const hosts = raw.hosts.map((host, i) => {
    if (!host || typeof host !== "object" || Array.isArray(host)) bad(`${source}: hosts[${i}] must be an object`);
    for (const key of Object.keys(host)) if (!HOST_KEYS.has(key)) bad(`${source}: hosts[${i}] unknown key "${key}"`);
    const { name, ssh_alias: sshAlias, state, machine_id: machineId, roles, access, rustdesk, notes } = host;
    if (typeof name !== "string" || !NAME_RE.test(name)) bad(`${source}: hosts[${i}].name invalid`);
    if (seenNames.has(name)) bad(`${source}: duplicate host name "${name}"`);
    seenNames.add(name);
    if (typeof sshAlias !== "string" || !NAME_RE.test(sshAlias)) bad(`${source}: ${name}.ssh_alias invalid`);
    if (!HOST_STATES.includes(state)) bad(`${source}: ${name}.state must be one of ${HOST_STATES.join("|")}`);
    if (machineId !== null && (typeof machineId !== "string" || !/^[0-9a-f]{32}$/.test(machineId))) {
      bad(`${source}: ${name}.machine_id must be a 32-char hex string or null`);
    }
    if (machineId !== null) {
      const owner = seenMachineIds.get(machineId);
      if (owner) bad(`${source}: machine_id shared by "${owner}" and "${name}"`);
      seenMachineIds.set(machineId, name);
    }
    if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || !r)) bad(`${source}: ${name}.roles must be a string array`);
    if (typeof notes !== "string" || !notes) bad(`${source}: ${name}.notes must be a non-empty string`);
    if (rustdesk !== null && !/^[0-9]{6,16}$/.test(String(rustdesk ?? ""))) {
      bad(`${source}: ${name}.rustdesk must be a numeric peer id string or null`);
    }
    return Object.freeze({
      name,
      ssh_alias: sshAlias,
      state,
      machine_id: machineId,
      roles: Object.freeze([...roles]),
      access: parseAccess(access, name, source),
      rustdesk,
      notes,
    });
  });
  const stateByName = new Map(hosts.map((h) => [h.name, h.state]));

  if (!raw.orders || typeof raw.orders !== "object" || Array.isArray(raw.orders)) bad(`${source}: orders must be an object`);
  const orders = {};
  for (const [orderName, list] of Object.entries(raw.orders)) {
    if (!NAME_RE.test(orderName)) bad(`${source}: order name "${orderName}" invalid`);
    if (!Array.isArray(list) || list.length === 0) bad(`${source}: orders.${orderName} must be a non-empty array`);
    const seen = new Set();
    for (const entry of list) {
      if (!seenNames.has(entry)) bad(`${source}: orders.${orderName} references undeclared host "${entry}"`);
      if (seen.has(entry)) bad(`${source}: orders.${orderName} repeats "${entry}"`);
      if (stateByName.get(entry) !== "reachable") {
        bad(`${source}: orders.${orderName} lists "${entry}" whose state is "${stateByName.get(entry)}" — only reachable hosts may be ordered`);
      }
      seen.add(entry);
    }
    orders[orderName] = Object.freeze([...list]);
  }
  if (!orders.build) bad(`${source}: orders.build is required`);

  return Object.freeze({ schema_version: 1, hosts: Object.freeze(hosts), orders: Object.freeze(orders), source });
}

export function loadRegistry(env = process.env) {
  const path = registryPath(env);
  let text;
  try {
    text = readFileSync(path, "utf8");
  } catch (err) {
    throw new BuildboxRegistryError(`buildbox registry unreadable (${path}): ${err.message}`);
  }
  return parseRegistry(text, path);
}

// Identity comes from the seat/sandbox contract (OVERDECK_SEAT_HOST, injected and
// attested at seat start), falling back to /etc/machine-id for a bare host login.
// A container's hostname is its own, so hostname is never consulted.
const SANDBOX_MARKERS = ["/run/.containerenv", "/.dockerenv"];

export function resolveSelf(registry, opts = {}) {
  const env = opts.env || process.env;
  const readMachineId = opts.readMachineId || (() => {
    try {
      return readFileSync("/etc/machine-id", "utf8").trim();
    } catch {
      return "";
    }
  });
  const inSandbox = opts.inSandbox ?? (
    Boolean(env.OVERDECK_SEAT_ID) || SANDBOX_MARKERS.some((p) => existsSync(p))
  );

  const seatHost = (env.OVERDECK_SEAT_HOST || "").trim();
  if (seatHost) {
    const match = registry.hosts.find((h) => h.name === seatHost || h.ssh_alias === seatHost);
    if (!match) {
      throw new BuildboxRegistryError(
        `self-identity unresolved: OVERDECK_SEAT_HOST="${seatHost}" is not a declared registry host`,
      );
    }
    return { name: match.name, source: "seat-host" };
  }

  const machineId = readMachineId();
  if (machineId) {
    const match = registry.hosts.find((h) => h.machine_id && h.machine_id === machineId);
    if (match) return { name: match.name, source: "machine-id" };
  }

  if (inSandbox) {
    throw new BuildboxRegistryError(
      "self-identity unresolved: running inside a sandbox with no OVERDECK_SEAT_HOST and no registered machine-id",
    );
  }
  return { name: null, source: "workstation" };
}

export function resolveHosts(registry, opts = {}) {
  const orderName = opts.order || "build";
  const order = registry.orders[orderName];
  if (!order) throw new BuildboxRegistryError(`buildbox registry has no order "${orderName}"`);
  const self = resolveSelf(registry, opts);
  const byName = new Map(registry.hosts.map((h) => [h.name, h]));
  const hosts = order.filter((name) => byName.get(name).state === "reachable" && name !== self.name);
  if (hosts.length === 0) {
    throw new BuildboxRegistryError(
      `no eligible buildbox host for order "${orderName}" (self=${self.name ?? "workstation"}); refusing to run locally`,
    );
  }
  return hosts;
}

export function reachableHostNames(registry) {
  return registry.hosts.filter((h) => h.state === "reachable").map((h) => h.name);
}

// The only way to obtain connection details. A host that is not `reachable` has no
// resolvable access, so no caller can dial one by accident.
export function resolveAccess(registry, name, path) {
  if (!ACCESS_PATHS.includes(path)) {
    throw new BuildboxRegistryError(`unknown access path "${path}" (want one of ${ACCESS_PATHS.join("|")})`);
  }
  const host = registry.hosts.find((h) => h.name === name || h.ssh_alias === name);
  if (!host) throw new BuildboxRegistryError(`no such buildbox host "${name}"`);
  if (host.state !== "reachable") {
    throw new BuildboxRegistryError(`host "${host.name}" is ${host.state} and must not be contacted`);
  }
  const entry = host.access[path];
  if (!entry) throw new BuildboxRegistryError(`host "${host.name}" has no "${path}" access path`);
  return entry;
}

// Caller-supplied host names (env/flag overrides) must pass through here before use,
// so an override cannot reach a host the registry says must not be contacted.
export function requireReachable(registry, names) {
  if (names.length === 0) throw new BuildboxRegistryError("no buildbox host names given");
  return names.map((name) => {
    const host = registry.hosts.find((h) => h.name === name || h.ssh_alias === name);
    if (!host) throw new BuildboxRegistryError(`no such buildbox host "${name}"`);
    if (host.state !== "reachable") {
      throw new BuildboxRegistryError(`host "${host.name}" is ${host.state} and must not be contacted`);
    }
    return host.name;
  });
}

const USAGE = "usage: buildbox-registry.mjs hosts [--order <name>|--all] | self | check <host[,host...]> | access <host> --path <lan|tailscale_ip|tailscale_ssh> | rustdesk <host>\n";

// Exit 4 = identity resolved and it is not a registry host, distinct from exit 3 =
// identity unresolvable. Callers that gate on being a buildbox need both to deny.
const EXIT_NOT_A_HOST = 4;

function main(argv) {
  const [command, ...rest] = argv;
  try {
    if (command === "hosts") {
      let order = "build";
      let all = false;
      for (let i = 0; i < rest.length; i += 1) {
        if (rest[i] === "--order") { order = rest[i + 1]; i += 1; continue; }
        if (rest[i] === "--all") { all = true; continue; }
        process.stderr.write(`buildbox-registry: unknown arg: ${rest[i]}\n`);
        return 2;
      }
      if (!order) { process.stderr.write("buildbox-registry: --order needs a value\n"); return 2; }
      const registry = loadRegistry();
      const names = all ? reachableHostNames(registry) : resolveHosts(registry, { order });
      if (names.length === 0) throw new BuildboxRegistryError("no reachable buildbox host");
      process.stdout.write(`${names.join(",")}\n`);
      return 0;
    }
    if (command === "self") {
      if (rest.length > 0) { process.stderr.write(USAGE); return 2; }
      const registry = loadRegistry();
      const self = resolveSelf(registry);
      if (!self.name) {
        process.stderr.write("buildbox-registry: this host is not a declared registry host\n");
        return EXIT_NOT_A_HOST;
      }
      const host = registry.hosts.find((h) => h.name === self.name);
      if (!host.machine_id) {
        throw new BuildboxRegistryError(`host "${host.name}" has no recorded machine_id`);
      }
      process.stdout.write(`${host.name} ${host.machine_id} ${self.source}\n`);
      return 0;
    }
    if (command === "rustdesk") {
      const [name, ...extra] = rest;
      if (!name || extra.length > 0) { process.stderr.write(USAGE); return 2; }
      const registry = loadRegistry();
      const host = registry.hosts.find((h) => h.name === name || h.ssh_alias === name);
      if (!host) throw new BuildboxRegistryError(`no such buildbox host "${name}"`);
      if (!host.rustdesk) throw new BuildboxRegistryError(`host "${host.name}" has no recorded rustdesk peer id`);
      process.stdout.write(`${host.rustdesk}\n`);
      return 0;
    }
    if (command === "check") {
      const [csv, ...extra] = rest;
      if (!csv || extra.length > 0) { process.stderr.write(USAGE); return 2; }
      const names = csv.split(",").map((s) => s.trim()).filter(Boolean);
      process.stdout.write(`${requireReachable(loadRegistry(), names).join(",")}\n`);
      return 0;
    }
    if (command === "access") {
      const [name, ...flags] = rest;
      let path = "";
      for (let i = 0; i < flags.length; i += 1) {
        if (flags[i] === "--path") { path = flags[i + 1]; i += 1; continue; }
        process.stderr.write(`buildbox-registry: unknown arg: ${flags[i]}\n`);
        return 2;
      }
      if (!name || !path) { process.stderr.write(USAGE); return 2; }
      const a = resolveAccess(loadRegistry(), name, path);
      process.stdout.write(`${a.user}@${a.host} ${a.port} ${a.identity_file ?? "-"}\n`);
      return 0;
    }
    process.stderr.write(USAGE);
    return 2;
  } catch (err) {
    process.stderr.write(`buildbox-registry: ${err.message}\n`);
    return 3;
  }
}

// Deployed callers invoke this through a symlink, where argv[1] is the link and
// import.meta.url is the target. Comparing them unresolved makes every deployed
// invocation a no-op that exits 0 with empty stdout.
if (process.argv[1]) {
  let invoked = "";
  try {
    invoked = realpathSync(process.argv[1]);
  } catch {
    invoked = process.argv[1];
  }
  if (invoked === fileURLToPath(import.meta.url)) process.exit(main(process.argv.slice(2)));
}
