import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Item, Panel } from "./schema";
import { CollectorState } from "./state";
import { Journal } from "./journal";
import {
  ALERTER_MINING_OUTPUT_ABSENT,
  Alerter,
  buildDigest,
  collectDigestInput,
  createAlerter,
  formatDigestParagraph,
  gdbusNotify,
  isQuietHours,
  readMiningOutput,
  resolveAlerterSettings,
} from "./alerter";

const FIXTURE_TZ = "Asia/Bangkok";
const FIXTURE_MORNING_MS = Date.parse("2026-07-17T08:00:00.000+07:00");

function actItem(id: string, overrides: Partial<Item> = {}): Item {
  return {
    id,
    source: "ghci",
    severity: "act",
    kind: "ci",
    title: `failure ${id}`,
    detail: `detail ${id}`,
    ts: "2026-07-17T08:30:00.000+07:00",
    actions: [],
    ...overrides,
  };
}

function makeState(dir: string, now?: () => number): CollectorState {
  return new CollectorState(new Journal(join(dir, "items.jsonl")), now);
}

describe("resolveAlerterSettings", () => {
  let dir: string;

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

  test("ships disabled with named error when mining output is absent", () => {
    dir = mkdtempSync(join(tmpdir(), "alerter-mining-absent-"));
    const miningPath = join(dir, "notification-mining.toml");
    const result = resolveAlerterSettings(
      { enabled: true, miningPath },
      () => null,
    );
    expect(result.ok).toBe(false);
    if (result.ok) return;
    expect(result.code).toBe(ALERTER_MINING_OUTPUT_ABSENT);
    expect(result.message).toContain(miningPath);
    expect(result.message).toContain("mine-all.prompt");
    expect(result.message).toContain("enabled = true");
  });
});

describe("Alerter cooldown and quiet hours", () => {
  const config = {
    enabled: true as const,
    miningPath: "/tmp/mining.toml",
    cooldownMs: 600_000,
    quietHoursStart: "22:00",
    quietHoursEnd: "07:00",
  };

  test("honors per-item cooldown", async () => {
    const calls: string[] = [];
    const notify = async (summary: string) => {
      calls.push(summary);
      return calls.length;
    };
    const alerter = new Alerter(config, {
      notify,
      now: () => FIXTURE_MORNING_MS,
      timeZone: FIXTURE_TZ,
    });
    const item = actItem("ci-1");

    expect(await alerter.notifyItem(item, FIXTURE_MORNING_MS)).toBe(true);
    expect(await alerter.notifyItem(item, FIXTURE_MORNING_MS + 60_000)).toBe(false);
    expect(await alerter.notifyItem(item, FIXTURE_MORNING_MS + 600_000)).toBe(true);
    expect(calls).toHaveLength(2);
  });

  test("honors quiet hours", async () => {
    const calls: string[] = [];
    const notify = async () => {
      calls.push("notify");
      return 1;
    };
    const alerter = new Alerter(config, {
      notify,
      timeZone: FIXTURE_TZ,
    });
    const item = actItem("ci-quiet");
    const lateNight = Date.parse("2026-07-17T23:30:00.000+07:00");
    const earlyMorning = Date.parse("2026-07-17T06:30:00.000+07:00");
    const daytime = FIXTURE_MORNING_MS;

    expect(isQuietHours(lateNight, "22:00", "07:00", FIXTURE_TZ)).toBe(true);
    expect(isQuietHours(earlyMorning, "22:00", "07:00", FIXTURE_TZ)).toBe(true);
    expect(isQuietHours(daytime, "22:00", "07:00", FIXTURE_TZ)).toBe(false);

    expect(await alerter.notifyItem(item, lateNight)).toBe(false);
    expect(await alerter.notifyItem(item, earlyMorning)).toBe(false);
    expect(await alerter.notifyItem(item, daytime)).toBe(true);
    expect(calls).toHaveLength(1);
  });

  test("ignores non-act severities via createAlerter subscription", async () => {
    const dir = mkdtempSync(join(tmpdir(), "alerter-sub-"));
    const calls: string[] = [];
    const state = makeState(dir, () => FIXTURE_MORNING_MS);
    state.registerAdapter("fixture", 1000);
    const handle = createAlerter({
      state,
      config,
      notify: async (summary) => {
        calls.push(summary);
        return 1;
      },
      now: () => FIXTURE_MORNING_MS,
      timeZone: FIXTURE_TZ,
    });

    state.recordSuccess(
      "fixture",
      FIXTURE_MORNING_MS,
      [
        {
          id: "warn-1",
          source: "fixture",
          severity: "warn",
          kind: "limit",
          title: "limit warn",
          detail: "detail",
          ts: new Date(FIXTURE_MORNING_MS).toISOString(),
          actions: [],
        },
      ],
      [],
    );
    await new Promise((resolve) => setTimeout(resolve, 0));
    expect(calls).toHaveLength(0);

    state.recordSuccess("fixture", FIXTURE_MORNING_MS + 1, [actItem("act-1", { source: "fixture" })], []);
    await new Promise((resolve) => setTimeout(resolve, 0));
    expect(calls).toHaveLength(1);
    handle.stop();
    rmSync(dir, { recursive: true, force: true });
  });
});

describe("gdbusNotify", () => {
  test("asks the gate first, then follows notifier.py argv shape and parses the id", async () => {
    const seen: string[][] = [];
    const nid = await gdbusNotify(
      "overdeck: ghci",
      "CI failed",
      ["Dismiss"],
      "critical",
      41,
      async (argv) => {
        seen.push(argv);
        if (argv[0] !== "gdbus") return { stdout: "", stderr: "", rc: 0 };
        expect(argv).toContain("overdeck");
        expect(argv).toContain("41");
        expect(argv).toContain("dialog-warning");
        expect(argv.some((arg) => arg.includes("dismiss"))).toBe(true);
        return { stdout: "(uint32 77,)", stderr: "", rc: 0 };
      },
    );
    expect(seen[0]?.[0]).toBe("python3");
    expect(seen[0]?.[1]).toMatch(/notif_gate\.py$/);
    expect(seen[0]?.[2]).toBe("check");
    expect(seen[0]?.[3]).toMatch(/alerter\.ts$/);
    expect(seen[0]).toContain("dbus-gated");
    expect(seen[1]?.[0]).toBe("gdbus");
    expect(nid).toBe(77);
  });

  test("an unapproved source never reaches the session bus", async () => {
    const seen: string[][] = [];
    const nid = await gdbusNotify(
      "overdeck: ghci",
      "CI failed",
      ["Dismiss"],
      "normal",
      0,
      async (argv) => {
        seen.push(argv);
        return { stdout: "", stderr: "", rc: 1 };
      },
    );
    expect(nid).toBe(0);
    expect(seen).toHaveLength(1);
    expect(seen[0]?.[0]).toBe("python3");
  });

  test("a gate that cannot be run denies rather than emitting", async () => {
    const seen: string[][] = [];
    const nid = await gdbusNotify(
      "overdeck: ghci",
      "CI failed",
      ["Dismiss"],
      "normal",
      0,
      async (argv) => {
        seen.push(argv);
        throw new Error("gate missing");
      },
    );
    expect(nid).toBe(0);
    expect(seen).toHaveLength(1);
  });
});

describe("morning digest", () => {
  test("digest string is exact on fixture day", () => {
    const paragraph = formatDigestParagraph({
      overnightRuns: [
        { title: "opt-data-integrity", status: "gated" },
        { title: "grok-account-parity", status: "failed" },
      ],
      scoreboardDeltaWorks: 3,
      scoreboardRepoCount: 2,
      newFailures: [{ title: "PR Gate failed on multideal" }],
      waitingDecisions: [
        { title: "Choose deploy window" },
        { title: "HALT: missing fixture" },
      ],
    });
    expect(paragraph).toBe(
      "Good morning. Overnight, 2 harness runs updated (opt-data-integrity (gated) and grok-account-parity (failed)). scoreboard gained 3 works across 2 repos. 1 new CI failure: PR Gate failed on multideal. 2 decisions waiting: Choose deploy window and HALT: missing fixture.",
    );
  });

  test("buildDigest assembles fixture state for the fixture morning", () => {
    const dir = mkdtempSync(join(tmpdir(), "alerter-digest-"));
    const state = makeState(dir, () => FIXTURE_MORNING_MS);
    state.registerAdapter("harness", 60_000);
    state.registerAdapter("golive", 60_000);
    state.registerAdapter("ghci", 60_000);
    const plansPanel: Panel = {
      id: "plans",
      ts: new Date(FIXTURE_MORNING_MS).toISOString(),
      data: {
        runs: [
          {
            title: "opt-data-integrity",
            status: "gated",
            updatedAt: "2026-07-17T06:15:00.000+07:00",
          },
          {
            title: "grok-account-parity",
            status: "failed",
            updatedAt: "2026-07-17T05:00:00.000+07:00",
          },
          {
            title: "stale-run",
            status: "succeeded",
            updatedAt: "2026-07-16T20:00:00.000+07:00",
          },
        ],
      },
    };
    const scoreboardPanel: Panel = {
      id: "scoreboard",
      ts: new Date(FIXTURE_MORNING_MS).toISOString(),
      data: {
        repos: [
          { repo: "multideal", trend7d: 2 },
          { repo: "zync.is", trend7d: 1 },
        ],
      },
    };
    state.recordSuccess("harness", FIXTURE_MORNING_MS, [], [plansPanel]);
    state.recordSuccess("golive", FIXTURE_MORNING_MS, [], [scoreboardPanel]);
    state.recordSuccess(
      "ghci",
      FIXTURE_MORNING_MS,
      [
        actItem("ci-new", {
          title: "PR Gate failed on multideal",
          ts: "2026-07-17T07:10:00.000+07:00",
        }),
        {
          id: "decision-1",
          source: "harness",
          severity: "warn",
          kind: "decision",
          title: "Choose deploy window",
          detail: "",
          ts: "2026-07-17T04:00:00.000+07:00",
          actions: [],
        },
        {
          id: "halt-1",
          source: "harness",
          severity: "act",
          kind: "halt",
          title: "HALT: missing fixture",
          detail: "",
          ts: "2026-07-17T03:00:00.000+07:00",
          actions: [],
        },
      ],
      [],
    );

    const input = collectDigestInput(state, FIXTURE_MORNING_MS, FIXTURE_TZ);
    expect(input.overnightRuns).toHaveLength(2);
    expect(input.scoreboardDeltaWorks).toBe(3);
    expect(input.newFailures).toHaveLength(1);
    expect(input.waitingDecisions).toHaveLength(2);

    const digest = buildDigest(state, () => FIXTURE_MORNING_MS, FIXTURE_TZ);
    expect(digest.day).toBe("2026-07-17");
    expect(digest.paragraph).toBe(
      "Good morning. Overnight, 2 harness runs updated (opt-data-integrity (gated) and grok-account-parity (failed)). scoreboard gained 3 works across 2 repos. 1 new CI failure: PR Gate failed on multideal. 2 decisions waiting: Choose deploy window and HALT: missing fixture.",
    );

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

describe("mining fixture seeding", () => {
  test("reads thresholds from mining output without inventing defaults", () => {
    const dir = mkdtempSync(join(tmpdir(), "alerter-mining-read-"));
    const miningPath = join(dir, "notification-mining.toml");
    writeFileSync(
      miningPath,
      "cooldownMs = 1800000\nquietHoursStart = \"22:00\"\nquietHoursEnd = \"07:00\"\n",
    );
    const result = resolveAlerterSettings({ enabled: true, miningPath }, (path) =>
      readMiningOutput(path),
    );
    expect(result.ok).toBe(true);
    if (!result.ok) return;
    expect(result.config.cooldownMs).toBe(1_800_000);
    expect(result.config.quietHoursStart).toBe("22:00");
    expect(result.config.quietHoursEnd).toBe("07:00");
    rmSync(dir, { recursive: true, force: true });
  });
});
