import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { loadRegistry } from "../../workstation/claude/lib/buildbox-registry.mjs";
import {
  FleetError,
  isPlacementEligible,
  loadFleet,
  parseFleet,
} from "../../../lib/fleet/loader.mjs";

const SHIPPED_REGISTRY = join(
  dirname(fileURLToPath(import.meta.url)),
  "../../workstation/claude/buildbox-hosts.json",
);

const REGISTRY_HOSTS = new Set(["debian1", "debian2", "debian3"]);

const mockRegistry = () => ({
  schema_version: 1,
  hosts: [...REGISTRY_HOSTS].map((name) => ({
    name,
    ssh_alias: name,
    state: "reachable",
    machine_id: null,
    roles: ["builder"],
    access: {
      lan: null,
      tailscale_ip: { host: "100.0.0.1", port: 2222, user: "user", identity_file: null },
      tailscale_ssh: { host: `${name}.example.ts.net`, port: 22, user: "user", identity_file: null },
    },
    rustdesk: null,
    notes: `${name} fixture`,
  })),
  orders: { build: ["debian1", "debian2", "debian3"] },
  source: "fixture",
});

const registryLoader = () => mockRegistry();

const baseFleet = () => ({
  schema_version: 1,
  nodes: {
    workstation: {
      transport: "local",
      roles: ["control", "agent-runtime"],
      profiles: ["shared-agent-tools", "workstation"],
      execution: "last-resort",
    },
    debian1: {
      transport: "ssh",
      host_ref: "debian1",
      roles: ["builder", "agent-runtime"],
      profiles: ["shared-agent-tools", "buildbox", "buildbox-root"],
      execution: "normal",
    },
    debian2: {
      transport: "ssh",
      host_ref: "debian2",
      roles: ["builder", "agent-runtime"],
      profiles: ["shared-agent-tools", "buildbox", "buildbox-root"],
      execution: "normal",
    },
    debian3: {
      transport: "ssh",
      host_ref: "debian3",
      roles: ["builder", "agent-runtime"],
      profiles: ["shared-agent-tools", "buildbox", "buildbox-root"],
      execution: "normal",
    },
  },
  fallback: {
    enabled: true,
    node: "workstation",
    requires_all_unavailable: ["debian1", "debian2", "debian3"],
    max_concurrent_local_jobs: 1,
    activation_windows: 3,
    health_window_sec: 60,
    lease_ttl_sec: 900,
  },
});

const parseFixture = (overrides = {}) => {
  const doc = structuredClone(baseFleet());
  if (overrides.nodes) {
    for (const [name, node] of Object.entries(overrides.nodes)) {
      doc.nodes[name] = { ...(doc.nodes[name] || {}), ...node };
    }
    delete overrides.nodes;
  }
  if (overrides.fallback) {
    doc.fallback = { ...doc.fallback, ...overrides.fallback };
    delete overrides.fallback;
  }
  Object.assign(doc, overrides);
  return parseFleet(JSON.stringify(doc), "fixture", { loadRegistry: registryLoader });
};

const writeFleetFile = (doc) => {
  const dir = mkdtempSync(join(tmpdir(), "fleet-test."));
  const path = join(dir, "fleet.json");
  writeFileSync(path, JSON.stringify(doc));
  return path;
};

test("rejects malformed schema at root", () => {
  assert.throws(() => parseFleet("{ not json", "bad"), FleetError);
  assert.throws(() => parseFleet("[]", "arr"), FleetError);
  assert.throws(
    () => parseFleet(JSON.stringify({ ...baseFleet(), surprise: true }), "extra-root", { loadRegistry: registryLoader }),
    /unknown root key/,
  );
  assert.throws(
    () => parseFleet(JSON.stringify({ ...baseFleet(), schema_version: 2 }), "version", { loadRegistry: registryLoader }),
    /schema_version/,
  );
});

test("rejects unknown node and fallback keys", () => {
  assert.throws(
    () => parseFixture({ nodes: { workstation: { extra: true } } }),
    /nodes\.workstation\.extra/,
  );
  assert.throws(
    () => parseFixture({ fallback: { extra: true } }),
    /fallback\.extra/,
  );
});

test("rejects unknown profile", () => {
  assert.throws(
    () => parseFixture({ nodes: { workstation: { profiles: ["shared-agent-tools", "not-a-profile"] } } }),
    /unknown value/,
  );
});

test("rejects unknown execution value", () => {
  assert.throws(
    () => parseFixture({ nodes: { debian1: { execution: "maybe" } } }),
    /unknown execution value/,
  );
});

test("rejects missing fallback node reference", () => {
  assert.throws(
    () => parseFixture({ fallback: { node: "ghost" } }),
    /references undeclared node/,
  );
});

test("rejects ssh transport without host_ref", () => {
  const doc = structuredClone(baseFleet());
  delete doc.nodes.debian1.host_ref;
  assert.throws(
    () => parseFleet(JSON.stringify(doc), "fixture", { loadRegistry: registryLoader }),
    /host_ref/,
  );
});

test("rejects unresolvable host_ref", () => {
  assert.throws(
    () => parseFixture({ nodes: { debian1: { host_ref: "missing-host" } } }),
    /does not resolve in the buildbox registry/,
  );
});

test("rejects requires_all_unavailable naming a non-builder", () => {
  assert.throws(
    () => parseFixture({ fallback: { requires_all_unavailable: ["debian1", "debian2", "workstation"] } }),
    /must name a declared builder/,
  );
});

test("allows a declared builder absent from requires_all_unavailable", () => {
  const fleet = parseFixture({ fallback: { requires_all_unavailable: ["debian1", "debian2"] } });
  assert.deepEqual(fleet.fallback.requires_all_unavailable, ["debian1", "debian2"]);
});

test("execution absent defaults to none and roles-only node is placement-ineligible", () => {
  const fleet = parseFixture({
    nodes: {
      roles_only: {
        transport: "local",
        roles: ["builder"],
        profiles: ["buildbox"],
      },
    },
  });
  const node = fleet.node("roles_only");
  assert.equal(node.execution, "none");
  assert.equal(isPlacementEligible("roles_only", node), false);
});

test("revision is stable across key reordering and formatting", () => {
  const compact = JSON.stringify(baseFleet());
  const reordered = JSON.stringify({
    fallback: baseFleet().fallback,
    nodes: baseFleet().nodes,
    schema_version: 1,
  });
  const pretty = `${JSON.stringify(baseFleet(), null, 2)}\n`;
  const a = parseFleet(compact, "compact", { loadRegistry: registryLoader });
  const b = parseFleet(reordered, "reordered", { loadRegistry: registryLoader });
  const c = parseFleet(pretty, "pretty", { loadRegistry: registryLoader });
  assert.equal(a.revision, b.revision);
  assert.equal(a.revision, c.revision);
});

test("revision changes when any validated value changes", () => {
  const before = parseFixture();
  const after = parseFixture({ fallback: { lease_ttl_sec: 901 } });
  assert.notEqual(before.revision, after.revision);
});

test("loadFleet on the real declaration yields four nodes with builder parity", () => {
  const fleet = loadFleet(undefined, {
    loadRegistry: () => loadRegistry({ BUILDBOX_HOSTS_CONFIG: SHIPPED_REGISTRY }),
  });
  assert.equal(fleet.schema_version, 1);
  assert.match(fleet.revision, /^[0-9a-f]{64}$/);
  assert.deepEqual(fleet.nodeNames(), ["workstation", "debian1", "debian2", "debian3"]);
  assert.equal(fleet.nodes.size, 4);

  const workstation = fleet.node("workstation");
  assert.equal(workstation.transport, "local");
  assert.equal(workstation.host_ref, null);
  assert.deepEqual(workstation.roles, ["control", "agent-runtime"]);
  assert.deepEqual(workstation.profiles, ["shared-agent-tools", "workstation"]);
  assert.equal(workstation.execution, "last-resort");

  const builders = ["debian1", "debian2", "debian3"].map((name) => fleet.node(name));
  for (const builder of builders) {
    assert.equal(builder.transport, "ssh");
    assert.equal(builder.host_ref, builder.name);
    assert.deepEqual(builder.roles, ["builder", "agent-runtime"]);
    assert.deepEqual(builder.profiles, ["shared-agent-tools", "buildbox", "buildbox-root"]);
    assert.equal(builder.execution, "normal");
  }

  const template = {
    transport: "ssh",
    roles: ["builder", "agent-runtime"],
    profiles: ["shared-agent-tools", "buildbox", "buildbox-root"],
    execution: "normal",
  };
  for (const builder of builders) {
    assert.deepEqual(
      { transport: builder.transport, roles: builder.roles, profiles: builder.profiles, execution: builder.execution },
      template,
    );
  }

  assert.equal(fleet.fallback.enabled, true);
  assert.equal(fleet.fallback.node, "workstation");
  assert.deepEqual(fleet.fallback.requires_all_unavailable, ["debian1", "debian2", "debian3"]);
});

test("query helpers return declaration order and throw on undeclared names", () => {
  const fleet = parseFixture();
  assert.deepEqual(fleet.nodesByRole("builder").map((n) => n.name), ["debian1", "debian2", "debian3"]);
  assert.deepEqual(
    fleet.nodesByProfile("shared-agent-tools").map((n) => n.name),
    ["workstation", "debian1", "debian2", "debian3"],
  );
  assert.throws(() => fleet.node("missing"), FleetError);
});

test("loadFleet reads a custom path", () => {
  const path = writeFleetFile(baseFleet());
  const fleet = loadFleet(path, { loadRegistry: registryLoader });
  assert.equal(fleet.nodeNames().length, 4);
});

test("errors name the offending path and value without dumping the document", () => {
  try {
    parseFixture({ schema_version: 9 });
    assert.fail("expected FleetError");
  } catch (err) {
    assert.equal(err.name, "FleetError");
    assert.match(err.message, /^schema_version:/);
    assert.doesNotMatch(err.message, /"nodes"/);
  }
});
