import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { CapabilityService } from "./capability";
import { IncidentReducer } from "./incidents";
import {
  createIncidentNotifierAdapter,
  createLaptopNotifierSink,
  createWatchdogWebhookAdapter,
  mapIncidentNotification,
  mapWatchdogNotification,
  WATCHDOG_WEBHOOK_TIMEOUT_MS,
  type ArgvExec,
} from "./notify";
import { handleJobReport } from "./server";
import { ControllerStore } from "./store";

const FIXED_NOW = Date.parse("2026-07-20T12:00:00.000Z");

describe("canonical notification mappings", () => {
  test("maps incident notifications field-for-field", () => {
    const notification = {
      severity: "page" as const,
      incident: {
        key: "repeated-command-not-found:box:playwright",
        firstSeen: "2026-07-20T11:00:00.000Z",
        lastSeen: "2026-07-20T12:00:00.000Z",
        count: 2,
        affectedJobs: ["job-1"],
        remediation: "quarantine command capability on affected host",
        cooldownUntil: "2026-07-20T12:30:00.000Z",
        autoResolveCondition: "half-open capability probe succeeds",
        state: "open" as const,
      },
    };

    expect(mapIncidentNotification(notification)).toEqual({
      title: "repeated-command-not-found:box:playwright",
      detail: JSON.stringify({
        affectedJobs: ["job-1"],
        remediation: "quarantine command capability on affected host",
        count: 2,
        state: "open",
        autoResolveCondition: "half-open capability probe succeeds",
        cooldownUntil: "2026-07-20T12:30:00.000Z",
      }),
      severity: "page",
      ts: "2026-07-20T12:00:00.000Z",
    });
  });

  test("maps watchdog notifications field-for-field with injected clock", () => {
    expect(mapWatchdogNotification({
      target: "controller",
      url: "http://127.0.0.1:18787/heartbeat",
      error: "health request returned 503",
      restartError: "systemctl exited 1",
    }, FIXED_NOW)).toEqual({
      title: "watchdog:controller",
      detail: JSON.stringify({
        url: "http://127.0.0.1:18787/heartbeat",
        error: "health request returned 503",
        restartError: "systemctl exited 1",
      }),
      severity: "page",
      ts: "2026-07-20T12:00:00.000Z",
    });
  });

  test("omits restartError when watchdog notification has none", () => {
    expect(mapWatchdogNotification({
      target: "collector",
      url: "http://127.0.0.1:18138/health",
      error: "fetch failed",
    }, FIXED_NOW).detail).toBe(JSON.stringify({
      url: "http://127.0.0.1:18138/health",
      error: "fetch failed",
    }));
  });
});

describe("laptop notifier sink", () => {
  let journalLines: string[] = [];
  let desktopCalls: Array<{ file: string; argv: readonly string[] }> = [];

  const desktopExec: ArgvExec = (file, argv) => {
    desktopCalls.push({ file, argv });
    return { status: 0 };
  };

  function sink(mode: "journal" | "desktop" | "both") {
    journalLines = [];
    desktopCalls = [];
    return createLaptopNotifierSink({
      mode,
      journalWrite: (line) => journalLines.push(line),
      desktopExec,
    });
  }

  afterEach(() => {
    journalLines = [];
    desktopCalls = [];
  });

  test("info never desktop-pages", () => {
    sink("both").send({
      title: "dead-lease:debian1",
      detail: "{}",
      severity: "info",
      ts: "2026-07-20T12:00:00.000Z",
    });
    expect(journalLines).toHaveLength(1);
    expect(desktopCalls).toHaveLength(0);
  });

  test("page and high desktop locally in both mode", () => {
    const notifier = sink("both");
    notifier.send({
      title: "queue-stalled-while-idle:debian1",
      detail: '{"state":"open"}',
      severity: "page",
      ts: "2026-07-20T12:00:00.000Z",
    });
    notifier.send({
      title: "artifact-cas-mismatch:job-1",
      detail: '{"state":"open"}',
      severity: "high",
      ts: "2026-07-20T12:01:00.000Z",
    });

    expect(journalLines).toHaveLength(2);
    expect(desktopCalls).toEqual([
      {
        file: "/usr/bin/notify-send",
        argv: [
          "--urgency=critical",
          "--app-name=overdeck",
          "queue-stalled-while-idle:debian1",
          '{"state":"open"}',
        ],
      },
      {
        file: "/usr/bin/notify-send",
        argv: [
          "--urgency=critical",
          "--app-name=overdeck",
          "artifact-cas-mismatch:job-1",
          '{"state":"open"}',
        ],
      },
    ]);
  });

  test("broken notify-send is swallowed and journaled", () => {
    const failingExec: ArgvExec = () => ({
      status: null,
      error: { code: "ENOENT", message: "notify-send not found" },
    });
    const lines: string[] = [];
    createLaptopNotifierSink({
      mode: "desktop",
      journalWrite: (line) => lines.push(line),
      desktopExec: failingExec,
    }).send({
      title: "controller-down:laptop",
      detail: '{"error":"down"}',
      severity: "page",
      ts: "2026-07-20T12:00:00.000Z",
    });

    expect(lines).toHaveLength(1);
    expect(JSON.parse(lines[0]!)).toMatchObject({
      type: "desktop-notify-failed",
      title: "controller-down:laptop",
      error: "notify-send not found",
    });
  });
});

describe("watchdog webhook adapter", () => {
  test("POST uses exact ntfy-compatible request and 5s timeout", async () => {
    const requests: Array<{ url: string; init: RequestInit }> = [];
    const stderr: string[] = [];
    const adapter = createWatchdogWebhookAdapter({
      webhookUrl: "https://ntfy.example/topic",
      now: () => FIXED_NOW,
      fetcher: async (url, init) => {
        requests.push({ url, init });
        return new Response("ok", { status: 200 });
      },
      stderrWrite: (line) => stderr.push(line),
    });

    await adapter.notify({
      target: "controller",
      url: "http://127.0.0.1:18787/heartbeat",
      error: "health request returned 503",
    });

    expect(requests).toHaveLength(1);
    expect(requests[0]?.url).toBe("https://ntfy.example/topic");
    expect(requests[0]?.init.method).toBe("POST");
    expect(requests[0]?.init.body).toBe(JSON.stringify({
      url: "http://127.0.0.1:18787/heartbeat",
      error: "health request returned 503",
    }));
    const headers = requests[0]?.init.headers as Record<string, string>;
    expect(headers.Title).toBe("watchdog:controller");
    expect(headers.Priority).toBe("urgent");
    expect(headers.Tags).toBe("warning");
    expect((requests[0]?.init.signal as AbortSignal).constructor.name).toBe("AbortSignal");
    expect(WATCHDOG_WEBHOOK_TIMEOUT_MS).toBe(5_000);
    expect(stderr).toHaveLength(0);
  });

  test("POST failure logs and is swallowed", async () => {
    const stderr: string[] = [];
    const adapter = createWatchdogWebhookAdapter({
      webhookUrl: "https://ntfy.example/topic",
      now: () => FIXED_NOW,
      fetcher: async () => new Response("nope", { status: 500 }),
      stderrWrite: (line) => stderr.push(line),
    });

    await expect(adapter.notify({
      target: "collector",
      url: "http://127.0.0.1:18138/health",
      error: "timeout",
    })).resolves.toBeUndefined();

    expect(stderr).toHaveLength(1);
    expect(JSON.parse(stderr[0]!)).toEqual({
      type: "watchdog-webhook-failed",
      target: "collector",
      error: "webhook returned 500",
    });
  });
});

describe("report-fed paging", () => {
  let dir = "";

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

  test("126 then 127 job reports page through the real laptop sink", async () => {
    dir = mkdtempSync(join(tmpdir(), "controller-notify-report-"));
    const store = new ControllerStore(join(dir, "state.sqlite"), { now: () => FIXED_NOW });
    store.upsertHost({ hostname: "box", state: "available", capabilityOk: true });
    const capability = new CapabilityService(store, undefined, () => FIXED_NOW);
    const journalLines: string[] = [];
    const desktopCalls: Array<{ file: string; argv: readonly string[] }> = [];
    const sink = createLaptopNotifierSink({
      mode: "both",
      journalWrite: (line) => journalLines.push(line),
      desktopExec: (file, argv) => {
        desktopCalls.push({ file, argv });
        return { status: 0 };
      },
    });
    const incidents = new IncidentReducer(
      store,
      createIncidentNotifierAdapter(sink),
      () => FIXED_NOW,
    );

    const finished = (key: string, rc: number) => ({
      source: "remote-build",
      host: "box",
      key,
      mirror: "mirror",
      repo: "owner/repo",
      snapshot: key,
      argv: ["/usr/bin/playwright"],
      attempt: 1,
      stage: "finished",
      rc,
      missingCommand: "playwright",
      startedAt: "2026-07-20T00:00:00.000Z",
      finishedAt: "2026-07-20T00:00:01.000Z",
      timeoutSec: 30,
    });

    const reportRequest = (body: unknown) => new Request("http://controller/jobs/report", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(body),
    });

    expect((await handleJobReport(reportRequest(finished("one", 126)), store, capability)).status).toBe(200);
    incidents.reducePending();
    expect(journalLines).toHaveLength(0);

    expect((await handleJobReport(reportRequest(finished("two", 127)), store, capability)).status).toBe(200);
    incidents.reducePending();

    expect(store.listIncidents()).toHaveLength(1);
    const incident = store.listIncidents()[0]!;
    const canonical = mapIncidentNotification({ severity: "page", incident });
    expect(journalLines).toHaveLength(1);
    expect(JSON.parse(journalLines[0]!)).toEqual(canonical);
    expect(desktopCalls).toEqual([{
      file: "/usr/bin/notify-send",
      argv: [
        "--urgency=critical",
        "--app-name=overdeck",
        incident.key,
        canonical.detail,
      ],
    }]);
    store.close();
  });
});
