import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
  existsSync,
  mkdirSync,
  mkdtempSync,
  readFileSync,
  rmSync,
  writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Server } from "bun";
import {
  createSshCapabilityProber,
  HOST_ENROLLMENT_MANIFEST,
  ToolchainManifestSchema,
  type SpawnExec,
} from "./capability";
import { loadConfigResult } from "./config";
import { startController, type NotifyExec } from "./index";
import { ControllerStore } from "./store";
import { tokenFile } from "./token";

describe("createSshCapabilityProber", () => {
  test("rejects unsafe hostnames and foreign manifests without spawning", async () => {
    let spawned = false;
    const prober = createSshCapabilityProber(() => {
      spawned = true;
      throw new Error("should not spawn");
    });

    const failed = {
      commandPresent: false,
      version: null,
      writablePaths: [],
      diskFreeBytes: 0,
      systemd: false,
    };

    await expect(prober.probe("-evil", HOST_ENROLLMENT_MANIFEST)).resolves.toEqual({
      ...failed,
      failureReason: "not a valid hostname: -evil",
    });
    await expect(prober.probe("debian1", {
      ...HOST_ENROLLMENT_MANIFEST,
      repo: "other",
    })).resolves.toEqual({
      ...failed,
      failureReason: "no prober for manifest other/true",
    });
    expect(spawned).toBe(false);
  });

  test("invokes exact ssh argv transport with 5s timeout and maps outcomes", async () => {
    const calls: Array<{ cmd: string[]; options: { shell: false } }> = [];
    const home = "/fixture/home";
    let resolveExit: ((code: number) => void) | undefined;
    const exec: SpawnExec = (cmd, options) => {
      calls.push({ cmd, options });
      return {
        exited: new Promise<number>((resolve) => {
          resolveExit = resolve;
        }),
        kill() {
          resolveExit?.(143);
        },
      };
    };

    const prober = createSshCapabilityProber(exec, home, 5_000);
    const pending = prober.probe("debian1", HOST_ENROLLMENT_MANIFEST);
    expect(calls).toEqual([{
      cmd: [
        "ssh",
        "-p",
        "2222",
        "-i",
        join(home, ".ssh/id_ed25519_buildbox"),
        "debian1",
        "true",
      ],
      options: { shell: false },
    }]);

    resolveExit?.(0);
    await expect(pending).resolves.toEqual({
      commandPresent: true,
      version: "ssh-exit-0",
      writablePaths: [],
      diskFreeBytes: 0,
      systemd: false,
      failureReason: null,
    });

    const failing = createSshCapabilityProber(
      (cmd, options) => ({
        exited: Promise.resolve(1),
        kill() {},
      }),
      home,
      5_000,
    );
    await expect(failing.probe("debian1", HOST_ENROLLMENT_MANIFEST)).resolves.toMatchObject({
      commandPresent: false,
      version: null,
      failureReason: "ssh debian1:2222 running `true` exited 1 (remote command failed)",
    });

    const timingOut = createSshCapabilityProber(
      () => ({ exited: new Promise<number>(() => {}), kill() {} }),
      home,
      5,
    );
    await expect(timingOut.probe("debian1", HOST_ENROLLMENT_MANIFEST)).resolves.toMatchObject({
      failureReason: "ssh debian1:2222 did not answer within 5ms",
    });
  });
});

const TEST_PORT = 19876;

describe.serial("startController", () => {
  let dir: string;
  let configDir: string;
  let previousConfigDir: string | undefined;
  let previousNotifySocket: string | undefined;
  let servers: Server<undefined>[] = [];

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), "controller-index-"));
    configDir = join(dir, "config");
    mkdirSync(configDir, { recursive: true });
    previousConfigDir = process.env.OVERDECK_CONFIG_DIR;
    previousNotifySocket = process.env.NOTIFY_SOCKET;
    process.env.OVERDECK_CONFIG_DIR = configDir;
    delete process.env.NOTIFY_SOCKET;
  });

  afterEach(async () => {
    for (const server of servers.splice(0)) {
      server.stop(true);
    }
    process.env.OVERDECK_CONFIG_DIR = previousConfigDir;
    if (previousNotifySocket === undefined) {
      delete process.env.NOTIFY_SOCKET;
    } else {
      process.env.NOTIFY_SOCKET = previousNotifySocket;
    }
    rmSync(dir, { recursive: true, force: true });
  });

  test("missing config and token boot with defaults and created token", async () => {
    const handle = await startController({
      port: TEST_PORT,
      capabilityProber: {
        probe: async () => ({
          commandPresent: false,
          version: null,
          writablePaths: [],
          diskFreeBytes: 0,
          systemd: false,
        }),
      },
    });
    servers.push(handle.server);

    expect(handle.configHealth.degraded).toBe(false);
    expect(handle.configHealth.config.bindHost).toBe("127.0.0.1");
    expect(handle.configHealth.config.port).toBe(8787);
    expect(existsSync(tokenFile())).toBe(true);
    const mode = (await Bun.file(tokenFile()).stat()).mode & 0o777;
    expect(mode).toBe(0o600);

    const res = await fetch(`http://127.0.0.1:${TEST_PORT}/health`, {
      headers: { authorization: `Bearer ${readFileSync(tokenFile(), "utf8").trim()}` },
    });
    expect(res.status).toBe(200);
    await handle.shutdown();
  });

  test("invalid config stays live degraded with one incident and loopback bind", async () => {
    writeFileSync(join(configDir, "controller.toml"), "bindHost = \"0.0.0.0\"\n");
    writeFileSync(join(configDir, "token"), "integration-token\n", { mode: 0o600 });

    const handle = await startController({
      port: TEST_PORT,
      token: "integration-token",
      capabilityProber: {
        probe: async () => ({
          commandPresent: false,
          version: null,
          writablePaths: [],
          diskFreeBytes: 0,
          systemd: false,
        }),
      },
    });
    servers.push(handle.server);

    expect(handle.configHealth.degraded).toBe(true);
    const res = await fetch(`http://127.0.0.1:${TEST_PORT}/status`, {
      headers: { authorization: "Bearer integration-token" },
    });
    expect(res.status).toBe(200);
    const body = await res.json() as { reconciler: { healthy: boolean }; dispatch: { state: string } };
    expect(body.reconciler.healthy).toBe(false);
    expect(body.dispatch.state).toBe("paused");
    expect(handle.configHealth.incident?.kind).toBe("config-invalid-shape");
    await handle.shutdown();
  });

  test("empty token file fails with TOKEN_EMPTY", async () => {
    writeFileSync(join(configDir, "token"), "", { mode: 0o600 });
    await expect(startController()).rejects.toMatchObject({ code: "TOKEN_EMPTY" });
  });

  test("projects events initially, on append, and on shutdown flush", async () => {
    writeFileSync(join(configDir, "token"), "projection-token\n", { mode: 0o600 });
    const eventsPath = join(configDir, "controller", "events.jsonl");
    const handle = await startController({
      port: TEST_PORT,
      token: "projection-token",
      projectionIntervalMs: 50,
      capabilityProber: {
        probe: async () => ({
          commandPresent: false,
          version: null,
          writablePaths: [],
          diskFreeBytes: 0,
          systemd: false,
        }),
      },
    });
    servers.push(handle.server);

    expect(readFileSync(eventsPath, "utf8")).toBe("");
    handle.runtime.store.upsertHost({ hostname: "debian1", state: "available", slotsTotal: 4 });

    const statusRes = await fetch(`http://127.0.0.1:${TEST_PORT}/status`, {
      headers: { authorization: "Bearer projection-token" },
    });
    const status = await statusRes.json() as { revision: number };
    await fetch(`http://127.0.0.1:${TEST_PORT}/transition/box-drain`, {
      method: "POST",
      headers: {
        authorization: "Bearer projection-token",
        "content-type": "application/json",
      },
      body: JSON.stringify({
        expectedRevision: status.revision,
        idempotencyKey: "projection-drain-1",
        args: { host: "debian1" },
      }),
    });

    await Bun.sleep(1_100);
    const first = readFileSync(eventsPath, "utf8").trim().split("\n").filter(Boolean);
    expect(first).toHaveLength(1);

    const afterRes = await fetch(`http://127.0.0.1:${TEST_PORT}/status`, {
      headers: { authorization: "Bearer projection-token" },
    });
    const after = await afterRes.json() as { revision: number };
    await fetch(`http://127.0.0.1:${TEST_PORT}/transition/box-restore`, {
      method: "POST",
      headers: {
        authorization: "Bearer projection-token",
        "content-type": "application/json",
      },
      body: JSON.stringify({
        expectedRevision: after.revision,
        idempotencyKey: "projection-restore-1",
        args: { host: "debian1" },
      }),
    });

    await Bun.sleep(1_100);
    const second = readFileSync(eventsPath, "utf8").trim().split("\n").filter(Boolean);
    expect(second.length).toBe(2);

    await handle.shutdown();
    const flushed = readFileSync(eventsPath, "utf8").trim().split("\n").filter(Boolean);
    expect(flushed).toHaveLength(2);
  });

  test("systemd notify emits READY once and WATCHDOG every 10s only with NOTIFY_SOCKET", async () => {
    writeFileSync(join(configDir, "token"), "notify-token\n", { mode: 0o600 });
    process.env.NOTIFY_SOCKET = "/run/systemd/notify";

    const calls: Array<{ file: string; argv: readonly string[] }> = [];
    const notifyExec: NotifyExec = (file, argv) => {
      calls.push({ file, argv });
      return { status: 0 };
    };

    const handle = await startController({
      port: TEST_PORT,
      token: "notify-token",
      notifyExec,
      watchdogIntervalMs: 50,
      capabilityProber: {
        probe: async () => ({
          commandPresent: false,
          version: null,
          writablePaths: [],
          diskFreeBytes: 0,
          systemd: false,
        }),
      },
    });
    servers.push(handle.server);

    expect(calls.filter((call) => call.argv[0] === "READY=1")).toHaveLength(1);
    expect(calls.every((call) => call.file === "/usr/bin/systemd-notify")).toBe(true);

    await Bun.sleep(120);
    expect(calls.filter((call) => call.argv[0] === "WATCHDOG=1").length).toBeGreaterThanOrEqual(1);
    await handle.shutdown();
  });

  test("pending admission recovery completes before bind and READY", async () => {
    writeFileSync(join(configDir, "token"), "recovery-token\n", { mode: 0o600 });
    process.env.NOTIFY_SOCKET = "/run/systemd/notify";
    const dbPath = join(configDir, "controller", "state.sqlite");
    const seeded = new ControllerStore(dbPath);
    seeded.upsertHost({
      hostname: "enrolling", state: "maintenance", enrolling: true, capabilityOk: false,
    });
    seeded.journalIntent({
      verb: "admission-reconcile", idempotencyKey: "pending-admission",
      args: {}, expectedRevision: 0,
    });
    seeded.close();

    const probeStarted = Promise.withResolvers<void>();
    const releaseProbe = Promise.withResolvers<void>();
    const notifications: string[] = [];
    let settled = false;
    const starting = startController({
      port: TEST_PORT,
      token: "recovery-token",
      notifyExec: (_file, argv) => {
        notifications.push(argv[0] ?? "");
        return { status: 0 };
      },
      capabilityProber: {
        probe: async () => {
          probeStarted.resolve();
          await releaseProbe.promise;
          return {
            commandPresent: true, version: "ssh-exit-0", writablePaths: [],
            diskFreeBytes: 0, systemd: false,
          };
        },
      },
    }).then((handle) => {
      settled = true;
      return handle;
    });
    await probeStarted.promise;
    await Bun.sleep(0);
    expect(settled).toBe(false);
    expect(notifications).not.toContain("READY=1");
    releaseProbe.resolve();
    const handle = await starting;
    servers.push(handle.server);
    expect(handle.runtime.store.listPendingJournals()).toEqual([]);
    expect(handle.runtime.store.getHost("enrolling")).toMatchObject({
      state: "available", enrolling: false, capabilityOk: true,
    });
    expect(notifications).toContain("READY=1");
    await handle.shutdown();
  });

  test("missing systemd-notify logs once and keeps server live", async () => {
    writeFileSync(join(configDir, "token"), "notify-missing-token\n", { mode: 0o600 });
    process.env.NOTIFY_SOCKET = "/run/systemd/notify";
    const errors: string[] = [];
    const original = console.error;
    console.error = (...args: unknown[]) => {
      errors.push(args.map(String).join(" "));
    };

    const notifyExec: NotifyExec = () => ({
      status: null,
      error: { code: "ENOENT", message: "not found" },
    });

    const handle = await startController({
      port: TEST_PORT,
      token: "notify-missing-token",
      notifyExec,
      capabilityProber: {
        probe: async () => ({
          commandPresent: false,
          version: null,
          writablePaths: [],
          diskFreeBytes: 0,
          systemd: false,
        }),
      },
    });
    servers.push(handle.server);

    const res = await fetch(`http://127.0.0.1:${TEST_PORT}/health`, {
      headers: { authorization: "Bearer notify-missing-token" },
    });
    expect(res.status).toBe(200);
    expect(errors.filter((line) => line.includes("systemd-notify"))).toHaveLength(1);

    console.error = original;
    await handle.shutdown();
  });

  test("health stays responsive while an in-flight probe is pending", async () => {
    writeFileSync(join(configDir, "token"), "probe-latency-token\n", { mode: 0o600 });
    let releaseProbe: (() => void) | undefined;
    const probeStarted = Promise.withResolvers<void>();
    const manifest = ToolchainManifestSchema.parse({
      repo: "owner/repo",
      command: "playwright",
      version: "1.52.0",
      writablePaths: ["/tmp"],
      minimumDiskBytes: 0,
      requiresSystemd: false,
    });

    const handle = await startController({
      port: TEST_PORT,
      token: "probe-latency-token",
      capabilityProber: {
        probe: () => new Promise((resolve) => {
          probeStarted.resolve();
          releaseProbe = () => resolve({
            commandPresent: false,
            version: null,
            writablePaths: [],
            diskFreeBytes: 0,
            systemd: false,
          });
        }),
      },
    });
    servers.push(handle.server);
    handle.runtime.store.upsertHost({ hostname: "debian1" });

    const pendingAdmit = handle.runtime.capability.admit("debian1", manifest);
    await probeStarted.promise;

    const started = performance.now();
    const res = await fetch(`http://127.0.0.1:${TEST_PORT}/heartbeat`, {
      headers: { authorization: "Bearer probe-latency-token" },
    });
    const elapsed = performance.now() - started;
    expect(res.status).toBe(200);
    expect(elapsed).toBeLessThan(1_000);

    releaseProbe?.();
    await pendingAdmit;
    await handle.shutdown();
  });

  test("shutdown closes the single store", async () => {
    writeFileSync(join(configDir, "token"), "shutdown-token\n", { mode: 0o600 });
    const handle = await startController({
      port: TEST_PORT,
      token: "shutdown-token",
      capabilityProber: {
        probe: async () => ({
          commandPresent: false,
          version: null,
          writablePaths: [],
          diskFreeBytes: 0,
          systemd: false,
        }),
      },
    });

    await handle.shutdown();
    expect(() => handle.server.stop(true)).not.toThrow();
  });
});

describe("loadConfigResult bindHost literal", () => {
  let dir: string;

  beforeEach(() => {
    dir = mkdtempSync(join(tmpdir(), "controller-config-bind-"));
  });

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

  test("rejects non-loopback bindHost as invalid shape", () => {
    const path = join(dir, "controller.toml");
    writeFileSync(path, 'bindHost = "0.0.0.0"\n');
    const result = loadConfigResult(path);
    expect(result.degraded).toBe(true);
    expect(result.incident?.kind).toBe("config-invalid-shape");
    expect(result.config.bindHost).toBe("127.0.0.1");
  });
});
