import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
  CapabilityService,
  ToolchainManifestSchema,
  type CapabilityProber,
  type CapabilityProbeResult,
} from "./capability";
import { buildControllerStatus, ControllerStatusSchema } from "./status";
import { ControllerStore } from "./store";

const manifest = ToolchainManifestSchema.parse({
  repo: "owner/repo",
  command: "playwright",
  version: "1.52.0",
  writablePaths: ["/var/cache/playwright", "/tmp"],
  minimumDiskBytes: 10_000,
  requiresSystemd: true,
});

function result(overrides: Partial<CapabilityProbeResult> = {}): CapabilityProbeResult {
  return {
    commandPresent: true,
    version: "1.52.0",
    writablePaths: ["/var/cache/playwright", "/tmp"],
    diskFreeBytes: 20_000,
    systemd: true,
    failureReason: null,
    ...overrides,
  };
}

function prober(value: CapabilityProbeResult): CapabilityProber {
  return { probe: async () => value };
}

describe("CapabilityService", () => {
  let dir: string;
  let dbPath: string;
  let store: ControllerStore;

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), "controller-capability-"));
    dbPath = join(dir, "state.sqlite");
    store = new ControllerStore(dbPath);
    store.upsertHost({ hostname: "debian1" });
  });

  afterEach(() => {
    store.close();
    rmSync(dir, { recursive: true, force: true });
  });

  test("admits only when command, version, paths, disk, and systemd satisfy manifest", async () => {
    const service = new CapabilityService(store, prober(result()));

    await expect(service.admit("debian1", manifest)).resolves.toEqual({
      admitted: true,
      missing: [],
      reason: null,
    });
    expect(store.getManifest("owner/repo", "playwright")).toEqual(manifest);

    const missingService = new CapabilityService(store, prober(result({
      commandPresent: false,
      version: "1.51.0",
      writablePaths: ["/tmp"],
      diskFreeBytes: 9_999,
      systemd: false,
    })));
    await expect(missingService.admit("debian1", manifest)).resolves.toEqual({
      admitted: false,
      missing: ["command", "version", "writable:/var/cache/playwright", "disk", "systemd"],
      reason: "unmet: command, version, writable:/var/cache/playwright, disk, systemd",
    });
  });

  test("manifest miss emits one capability-missing event and suppresses probe storms", async () => {
    let probes = 0;
    const service = new CapabilityService(store, {
      probe: async () => {
        probes += 1;
        return result({ commandPresent: false });
      },
    });

    expect((await service.admit("debian1", manifest)).admitted).toBe(false);
    expect((await service.admit("debian1", manifest)).admitted).toBe(false);
    expect(probes).toBe(1);
    expect(store.readEvents().map(({ event }) => event.reason)).toEqual([
      "capability-missing",
    ]);
  });

  test("repeated 126/127 opens a persisted class circuit and emits once", () => {
    const service = new CapabilityService(store, prober(result()));

    expect(service.recordExit("debian1", "playwright", 126).state).toBe("closed");
    expect(service.recordExit("debian1", "playwright", 127).state).toBe("open");
    service.recordExit("debian1", "playwright", 127);
    expect(store.readEvents()).toHaveLength(1);

    store.close();
    store = new ControllerStore(dbPath);
    expect(store.getCapabilityBreaker("debian1", "playwright")?.state).toBe("open");
    expect(store.getHost("debian1")?.quarantinedCommands).toEqual(["playwright"]);
  });

  test("closed breaker ignores unrelated exit codes", () => {
    const service = new CapabilityService(store, prober(result()));
    service.recordExit("debian1", "playwright", 126);
    const after = service.recordExit("debian1", "playwright", 0);
    expect(after.state).toBe("closed");
    expect(after.failureCount).toBe(1);
  });

  test("open breaker stays open for every exit code including success", () => {
    const service = new CapabilityService(store, prober(result()));
    service.quarantine("debian1", "playwright");
    const after = service.recordExit("debian1", "playwright", 0);
    expect(after.state).toBe("open");
  });

  test("half-open success closes through recordExit and emits capability-restored", () => {
    const service = new CapabilityService(store, prober(result()));
    service.quarantine("debian1", "playwright");
    service.halfOpen("debian1", "playwright");

    const closed = service.recordExit("debian1", "playwright", 0);
    expect(closed.state).toBe("closed");
    expect(closed.failureCount).toBe(0);
    expect(store.readEvents().map(({ event }) => event.reason)).toContain("capability-restored");
    expect(store.getHost("debian1")?.quarantinedCommands).toEqual([]);
  });

  test("half-open nonzero re-opens and admit does not close probation", async () => {
    const service = new CapabilityService(store, prober(result()));
    service.quarantine("debian1", "playwright");
    service.halfOpen("debian1", "playwright");

    expect(store.getCapabilityBreaker("debian1", "playwright")?.state).toBe("half-open");
    expect((await service.admit("debian1", manifest)).admitted).toBe(true);
    expect(store.getCapabilityBreaker("debian1", "playwright")?.state).toBe("half-open");

    const reopened = service.recordExit("debian1", "playwright", 127);
    expect(reopened.state).toBe("open");
  });

  test("breaker opening and capability-missing event commit atomically", () => {
    const healthy = new CapabilityService(store, prober(result()));
    healthy.recordExit("debian1", "playwright", 126);
    store.close();
    store = new ControllerStore(dbPath, { eventWriteFails: true });
    const failing = new CapabilityService(store, prober(result()));

    expect(() => failing.recordExit("debian1", "playwright", 127)).toThrow(
      "event write failed",
    );

    store.close();
    store = new ControllerStore(dbPath);
    expect(store.getCapabilityBreaker("debian1", "playwright")).toMatchObject({
      state: "closed",
      failureCount: 1,
    });
    expect(store.readEvents()).toEqual([]);
  });

  test("unrelated host updates preserve half-open breaker state", async () => {
    const service = new CapabilityService(store, prober(result()));
    service.quarantine("debian1", "playwright");
    service.halfOpen("debian1", "playwright");
    const host = store.getHost("debian1");
    if (!host) throw new Error("missing fixture host");

    store.upsertHost({ ...host, runningJobs: 1 });

    expect(store.getCapabilityBreaker("debian1", "playwright")?.state).toBe("half-open");
  });

  test("status projects breaker through existing capability fields", () => {
    const service = new CapabilityService(store, prober(result()));
    service.quarantine("debian1", "playwright");

    const status = buildControllerStatus(store);
    expect(ControllerStatusSchema.parse(status)).toEqual(status);
    expect(status.hosts.debian1?.capability).toMatchObject({
      circuitOpen: true,
      missingCommand: "playwright",
    });
  });

  test("half-open breaker does not project as a missing command", () => {
    const service = new CapabilityService(store, prober(result()));
    service.quarantine("debian1", "playwright");
    service.halfOpen("debian1", "playwright");

    const status = buildControllerStatus(store);
    expect(ControllerStatusSchema.parse(status)).toEqual(status);
    expect(status.hosts.debian1?.capability?.circuitOpen).toBeUndefined();
    expect(status.hosts.debian1?.capability?.missingCommand).toBeUndefined();
  });

  test("probeAdmission is side-effect-free", async () => {
    const before = store.getRevision();
    const service = new CapabilityService(store, prober(result({ commandPresent: false })));
    await expect(service.probeAdmission("debian1", manifest)).resolves.toEqual({
      admitted: false,
      missing: ["command"],
      reason: "unmet: command",
    });
    expect(store.getManifest("owner/repo", "playwright")).toBeNull();
    expect(store.getCapabilityBreaker("debian1", "playwright")).toBeNull();
    expect(store.getRevision()).toBe(before);
  });

  test("a probe harness failure is reported, not misclassified as a missing command", async () => {
    const service = new CapabilityService(store, {
      probe: async () => result({
        commandPresent: false,
        failureReason: "ssh debian1:2222 running `true` exited 255 (ssh transport, auth, or host-key failure)",
      }),
    });

    await expect(service.admit("debian1", manifest)).resolves.toEqual({
      admitted: false,
      missing: ["probe-unavailable"],
      reason: "ssh debian1:2222 running `true` exited 255 (ssh transport, auth, or host-key failure)",
    });
    expect(store.getCapabilityBreaker("debian1", "playwright")).toBeNull();
    expect(store.getHost("debian1")?.capabilityOk).toBe(true);
  });

  test("command breakers do not mutate manifest capability", () => {
    store.upsertHost({ hostname: "debian1", capabilityOk: true });
    const service = new CapabilityService(store, prober(result()));
    service.quarantine("debian1", "playwright");
    service.halfOpen("debian1", "playwright");
    service.recordExit("debian1", "playwright", 0);
    expect(store.getHost("debian1")?.capabilityOk).toBe(true);
  });
});
