import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { loadRegistry } from "../../modules/workstation/claude/lib/buildbox-registry.mjs";

const TRANSPORTS = new Set(["local", "ssh"]);
const ROLES = new Set(["control", "agent-runtime", "builder"]);
const PROFILES = new Set(["shared-agent-tools", "workstation", "buildbox", "buildbox-root"]);
const EXECUTION = new Set(["normal", "last-resort", "none"]);

const ROOT_KEYS = new Set(["schema_version", "nodes", "fallback"]);
const NODE_KEYS = new Set(["transport", "host_ref", "roles", "profiles", "execution"]);
const FALLBACK_KEYS = new Set([
  "enabled",
  "node",
  "requires_all_unavailable",
  "max_concurrent_local_jobs",
  "activation_windows",
  "health_window_sec",
  "lease_ttl_sec",
]);

const DEFAULT_FLEET_PATH = join(
  dirname(fileURLToPath(import.meta.url)),
  "../../modules/fleet/fleet.json",
);

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

function fail(path, value, detail) {
  const shown = value === undefined ? "undefined" : JSON.stringify(value);
  throw new FleetError(`${path}: ${detail} (got ${shown})`);
}

function assertObject(value, path) {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    fail(path, value, "must be an object");
  }
}

function assertString(value, path) {
  if (typeof value !== "string" || !value) {
    fail(path, value, "must be a non-empty string");
  }
}

function assertBoolean(value, path) {
  if (typeof value !== "boolean") {
    fail(path, value, "must be a boolean");
  }
}

function assertInteger(value, path, { min } = {}) {
  if (!Number.isInteger(value) || (min !== undefined && value < min)) {
    fail(path, value, min !== undefined ? `must be an integer >= ${min}` : "must be an integer");
  }
}

function assertStringArray(value, path, { enumSet, minItems = 1, unique = true } = {}) {
  if (!Array.isArray(value)) {
    fail(path, value, "must be an array");
  }
  if (value.length < minItems) {
    fail(path, value, `must have at least ${minItems} item(s)`);
  }
  const seen = new Set();
  for (let i = 0; i < value.length; i += 1) {
    const entry = value[i];
    const entryPath = `${path}[${i}]`;
    if (typeof entry !== "string" || !entry) {
      fail(entryPath, entry, "must be a non-empty string");
    }
    if (enumSet && !enumSet.has(entry)) {
      fail(entryPath, entry, `unknown value`);
    }
    if (unique) {
      if (seen.has(entry)) {
        fail(entryPath, entry, "duplicate entry");
      }
      seen.add(entry);
    }
  }
}

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

function computeRevision(structure) {
  const text = JSON.stringify(canonicalize(structure));
  return createHash("sha256").update(text).digest("hex");
}

function resolveRegistryHostNames(loadRegistryFn) {
  const registry = loadRegistryFn();
  return new Set(registry.hosts.map((host) => host.name));
}

/**
 * @param {string} name
 * @returns {boolean}
 */
export function isPlacementEligible(name, node) {
  void name;
  return node.execution !== "none";
}

function parseFallback(raw, nodeNames, builderNames) {
  assertObject(raw, "fallback");
  for (const key of Object.keys(raw)) {
    if (!FALLBACK_KEYS.has(key)) {
      fail(`fallback.${key}`, raw[key], "unknown key");
    }
  }

  const {
    enabled,
    node,
    requires_all_unavailable: requiresAllUnavailable,
    max_concurrent_local_jobs: maxConcurrentLocalJobs,
    activation_windows: activationWindows,
    health_window_sec: healthWindowSec,
    lease_ttl_sec: leaseTtlSec,
  } = raw;

  assertBoolean(enabled, "fallback.enabled");
  assertString(node, "fallback.node");
  if (!nodeNames.has(node)) {
    fail("fallback.node", node, "references undeclared node");
  }
  assertStringArray(requiresAllUnavailable, "fallback.requires_all_unavailable", { minItems: 0 });
  for (let i = 0; i < requiresAllUnavailable.length; i += 1) {
    const entry = requiresAllUnavailable[i];
    if (!builderNames.has(entry)) {
      fail(`fallback.requires_all_unavailable[${i}]`, entry, "must name a declared builder");
    }
  }
  assertInteger(maxConcurrentLocalJobs, "fallback.max_concurrent_local_jobs", { min: 0 });
  assertInteger(activationWindows, "fallback.activation_windows", { min: 1 });
  assertInteger(healthWindowSec, "fallback.health_window_sec", { min: 1 });
  assertInteger(leaseTtlSec, "fallback.lease_ttl_sec", { min: 1 });

  return Object.freeze({
    enabled,
    node,
    requires_all_unavailable: Object.freeze([...requiresAllUnavailable]),
    max_concurrent_local_jobs: maxConcurrentLocalJobs,
    activation_windows: activationWindows,
    health_window_sec: healthWindowSec,
    lease_ttl_sec: leaseTtlSec,
  });
}

function parseNode(name, raw, registryHostNames) {
  assertObject(raw, `nodes.${name}`);
  for (const key of Object.keys(raw)) {
    if (!NODE_KEYS.has(key)) {
      fail(`nodes.${name}.${key}`, raw[key], "unknown key");
    }
  }

  const { transport, host_ref: hostRef, roles, profiles, execution: executionRaw } = raw;
  if (!TRANSPORTS.has(transport)) {
    fail(`nodes.${name}.transport`, transport, "unknown transport");
  }

  let resolvedHostRef = null;
  if (transport === "ssh") {
    if (hostRef === undefined || hostRef === null || hostRef === "") {
      fail(`nodes.${name}.host_ref`, hostRef, "required when transport is ssh");
    }
    assertString(hostRef, `nodes.${name}.host_ref`);
    if (!registryHostNames.has(hostRef)) {
      fail(`nodes.${name}.host_ref`, hostRef, "does not resolve in the buildbox registry");
    }
    resolvedHostRef = hostRef;
  } else if (hostRef !== undefined && hostRef !== null) {
    fail(`nodes.${name}.host_ref`, hostRef, "must be absent when transport is local");
  }

  assertStringArray(roles, `nodes.${name}.roles`, { enumSet: ROLES });
  assertStringArray(profiles, `nodes.${name}.profiles`, { enumSet: PROFILES });

  let execution = "none";
  if (executionRaw !== undefined) {
    if (!EXECUTION.has(executionRaw)) {
      fail(`nodes.${name}.execution`, executionRaw, "unknown execution value");
    }
    execution = executionRaw;
  }

  return Object.freeze({
    name,
    transport,
    host_ref: resolvedHostRef,
    roles: Object.freeze([...roles]),
    profiles: Object.freeze([...profiles]),
    execution,
  });
}

function parseFleetDocument(text, source, loadRegistryFn) {
  let raw;
  try {
    raw = JSON.parse(text);
  } catch (err) {
    throw new FleetError(`${source}: unreadable JSON (${err.message})`);
  }

  assertObject(raw, source);
  for (const key of Object.keys(raw)) {
    if (!ROOT_KEYS.has(key)) {
      fail(key, raw[key], "unknown root key");
    }
  }

  if (raw.schema_version !== 1) {
    fail("schema_version", raw.schema_version, "must be 1");
  }

  assertObject(raw.nodes, "nodes");
  const nodeEntries = Object.entries(raw.nodes);
  if (nodeEntries.length === 0) {
    fail("nodes", raw.nodes, "must declare at least one node");
  }

  const registryHostNames = resolveRegistryHostNames(loadRegistryFn);
  const nodeNames = new Set(nodeEntries.map(([name]) => name));
  const nodes = new Map();
  const builderNames = new Set();
  const parsedNodes = [];

  for (const [name, nodeRaw] of nodeEntries) {
    assertString(name, `nodes.${name}`);
    const node = parseNode(name, nodeRaw, registryHostNames);
    nodes.set(name, node);
    parsedNodes.push(node);
    if (node.roles.includes("builder")) {
      builderNames.add(name);
    }
  }

  const revisionNodes = parsedNodes
    .map((node) => ({
      name: node.name,
      transport: node.transport,
      host_ref: node.host_ref,
      roles: [...node.roles].sort(),
      profiles: [...node.profiles].sort(),
      execution: node.execution,
    }))
    .sort((a, b) => a.name.localeCompare(b.name));

  const fallback = parseFallback(raw.fallback, nodeNames, builderNames);
  const revisionStructure = {
    schema_version: 1,
    nodes: revisionNodes,
    fallback: {
      enabled: fallback.enabled,
      node: fallback.node,
      requires_all_unavailable: [...fallback.requires_all_unavailable],
      max_concurrent_local_jobs: fallback.max_concurrent_local_jobs,
      activation_windows: fallback.activation_windows,
      health_window_sec: fallback.health_window_sec,
      lease_ttl_sec: fallback.lease_ttl_sec,
    },
  };

  const declarationOrder = nodeEntries.map(([name]) => name);

  return Object.freeze({
    schema_version: 1,
    revision: computeRevision(revisionStructure),
    nodes,
    fallback,
    nodeNames() {
      return [...declarationOrder];
    },
    node(target) {
      const found = nodes.get(target);
      if (!found) {
        throw new FleetError(`node: undeclared name ${JSON.stringify(target)}`);
      }
      return found;
    },
    nodesByRole(role) {
      return declarationOrder.map((name) => nodes.get(name)).filter((node) => node.roles.includes(role));
    },
    nodesByProfile(profile) {
      return declarationOrder.map((name) => nodes.get(name)).filter((node) => node.profiles.includes(profile));
    },
  });
}

function buildTuningPath(env) {
  return env.BUILD_REMOTE_CONFIG || join(env.HOME || homedir(), ".claude", "build-remote.json");
}

// build-remote.json carries per-node transport tuning only. A leftover `hosts` key is a
// second host list and is never silently preferred over the declaration.
export function auditBuildTuning(nodeNames, env = process.env) {
  const path = buildTuningPath(env);
  let text;
  try {
    text = readFileSync(path, "utf8");
  } catch (err) {
    if (err?.code === "ENOENT") {
      return;
    }
    throw new FleetError(`${path}: unreadable (${err.message})`);
  }

  let raw;
  try {
    raw = JSON.parse(text);
  } catch (err) {
    throw new FleetError(`${path}: unreadable JSON (${err.message})`);
  }
  assertObject(raw, path);

  if (Object.hasOwn(raw, "hosts")) {
    fail(`${path}: hosts`, raw.hosts, "leftover host list — the fleet declaration is the only host source");
  }
  if (Object.hasOwn(raw, "host")) {
    fail(`${path}: host`, raw.host, "leftover legacy host — the fleet declaration is the only host source");
  }
  for (const [key, value] of Object.entries(raw)) {
    if (value && typeof value === "object" && !Array.isArray(value) && !nodeNames.has(key)) {
      fail(`${path}: ${key}`, value, "per-node tuning for a node the fleet does not declare");
    }
  }
}

/**
 * @param {string} [path]
 * @param {{ loadRegistry?: typeof loadRegistry }} [opts]
 */
export function loadFleet(path = process.env.DECKCTL_FLEET_FILE || DEFAULT_FLEET_PATH, opts = {}) {
  const loadRegistryFn = opts.loadRegistry || loadRegistry;
  let text;
  try {
    text = readFileSync(path, "utf8");
  } catch (err) {
    throw new FleetError(`${path}: unreadable (${err.message})`);
  }
  return parseFleetDocument(text, path, loadRegistryFn);
}

/**
 * @param {string} text
 * @param {string} source
 * @param {{ loadRegistry?: typeof loadRegistry }} [opts]
 */
export function parseFleet(text, source, opts = {}) {
  const loadRegistryFn = opts.loadRegistry || loadRegistry;
  return parseFleetDocument(text, source, loadRegistryFn);
}
