import { describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CollectorState, type Delta } from "../src/state";
import { Journal } from "../src/journal";
import { buildScheduler } from "../src/collector";
import type { Item } from "../src/schema";
import { FixtureAdapter } from "./support/fixtureAdapter";
import { ManualClock } from "./support/manualClock";

function tmpJournalPath(): string {
  const dir = mkdtempSync(join(tmpdir(), "overdeck-state-"));
  return join(dir, "items.jsonl");
}

describe("fixture adapter round-trip", () => {
  test("poll result lands in state as items and panels", async () => {
    const journalPath = tmpJournalPath();
    const state = new CollectorState(new Journal(journalPath));
    const adapter = new FixtureAdapter("fixture-a", 1000);
    const clock = new ManualClock();
    const scheduler = buildScheduler([adapter], state, clock, () => 0.5);

    scheduler.start();
    await clock.advance(0);

    expect(state.getItems()).toHaveLength(1);
    expect(state.getItems()[0]?.id).toBe("fixture-a-item-1");
    expect(state.getPanels()).toHaveLength(1);
    expect(state.getPanels()[0]?.id).toBe("fixture-a-panel-1");

    scheduler.stop();
    rmSync(journalPath, { force: true });
  });

  test("delta is emitted to subscribers on poll", async () => {
    const journalPath = tmpJournalPath();
    const state = new CollectorState(new Journal(journalPath));
    const adapter = new FixtureAdapter("fixture-b", 1000);
    const clock = new ManualClock();
    const scheduler = buildScheduler([adapter], state, clock, () => 0.5);

    const deltas: unknown[] = [];
    state.subscribe((d) => deltas.push(d));

    scheduler.start();
    await clock.advance(0);

    expect(deltas.some((d: any) => d.type === "item")).toBe(true);
    expect(deltas.some((d: any) => d.type === "panel")).toBe(true);

    scheduler.stop();
  });

  test("getItems filters by kind", async () => {
    const journalPath = tmpJournalPath();
    const state = new CollectorState(new Journal(journalPath));
    const adapter = new FixtureAdapter("fixture-c", 1000);
    const clock = new ManualClock();
    const scheduler = buildScheduler([adapter], state, clock, () => 0.5);

    scheduler.start();
    await clock.advance(0);

    expect(state.getItems("progress")).toHaveLength(1);
    expect(state.getItems("ci")).toHaveLength(0);

    scheduler.stop();
  });

  test("scheduler forwards parsed completeScopes into scoped reconciliation", async () => {
    const state = new CollectorState(new Journal(tmpJournalPath()));
    const clock = new ManualClock();
    let pollCount = 0;
    const adapter = {
      id: "scoped-fixture",
      interval: 1000,
      async poll() {
        pollCount += 1;
        return {
          items: pollCount === 1
            ? [makeScopedItem("scoped-item", "scoped-fixture", "repo:one")]
            : [],
          panels: [],
          completeScopes: ["repo:one"],
        };
      },
    };
    const scheduler = buildScheduler([adapter], state, clock, () => 0.5);

    scheduler.start();
    await clock.advance(0);
    expect(state.getItems().map((item) => item.id)).toEqual(["scoped-item"]);

    await clock.advance(1000);
    expect(state.getItems()).toEqual([]);
    scheduler.stop();
  });
});

function makeItem(id: string, source: string): Item {
  return {
    id,
    source,
    severity: "act",
    kind: "alert",
    title: "t",
    detail: "d",
    ts: new Date().toISOString(),
    actions: [],
  };
}

function makeScopedItem(id: string, source: string, reconciliationScope: string): Item {
  return { ...makeItem(id, source), reconciliationScope } as Item;
}

describe("reconciliation on success", () => {
  test("adapter health tracks poll latency and consecutive failures, then resets on success", () => {
    const state = new CollectorState(new Journal(tmpJournalPath()));
    state.registerAdapter("adapter-a", 1000);

    state.recordFailure("adapter-a", 1000, new Error("first"), 80);
    state.recordFailure("adapter-a", 2000, new Error("second"), 120);

    expect(state.adapterStatuses()[0]).toMatchObject({
      lastPollDurationMs: 120,
      consecutiveErrors: 2,
      lastError: "second",
    });

    state.recordSuccess("adapter-a", 3000, [], [], 45);
    expect(state.adapterStatuses()[0]).toMatchObject({
      lastPollDurationMs: 45,
      consecutiveErrors: 0,
      lastSuccess: 3000,
    });
    expect(state.adapterStatuses()[0]?.lastError).toBeUndefined();
  });

  test("an id absent from a successful poll is removed, tombstoned, and delta'd as resolved", () => {
    const journalPath = tmpJournalPath();
    const state = new CollectorState(new Journal(journalPath));
    state.registerAdapter("adapter-a", 1000);

    const deltas: Delta[] = [];
    state.subscribe((d) => deltas.push(d));

    state.recordSuccess("adapter-a", 1000, [makeItem("x", "adapter-a")], []);
    expect(state.getItems().map((i) => i.id)).toEqual(["x"]);

    state.recordSuccess("adapter-a", 2000, [], []); // x is now absent -> resolved
    expect(state.getItems()).toHaveLength(0);
    expect(deltas.some((d) => d.type === "item-resolved" && d.id === "x")).toBe(true);

    // tombstone persisted to disk: a fresh journal replay must not resurrect x
    const reloaded = new Journal(journalPath);
    expect(reloaded.load().find((i) => i.id === "x")).toBeUndefined();
  });

  test("recordFailure retains items — nothing is resolved on a failed poll", () => {
    const journalPath = tmpJournalPath();
    const state = new CollectorState(new Journal(journalPath));
    state.registerAdapter("adapter-a", 1000);

    state.recordSuccess("adapter-a", 1000, [makeItem("x", "adapter-a")], []);
    state.recordFailure("adapter-a", 2000, new Error("boom"));

    expect(state.getItems().map((i) => i.id)).toEqual(["x"]);
  });

  test("ownership isolation: adapter A's poll never resolves adapter B's items", () => {
    const journalPath = tmpJournalPath();
    const state = new CollectorState(new Journal(journalPath));
    state.registerAdapter("adapter-a", 1000);
    state.registerAdapter("adapter-b", 1000);

    state.recordSuccess("adapter-a", 1000, [makeItem("a-item", "adapter-a")], []);
    state.recordSuccess("adapter-b", 1000, [makeItem("b-item", "adapter-b")], []);

    // adapter-a's next poll emits nothing; only a-item (adapter-a's own) may resolve
    state.recordSuccess("adapter-a", 2000, [], []);

    expect(state.getItems().map((i) => i.id)).toEqual(["b-item"]);
  });

  test("items replayed from the journal are reconcilable: a restart does not orphan them", () => {
    const journalPath = tmpJournalPath();
    const before = new CollectorState(new Journal(journalPath));
    before.registerAdapter("adapter-a", 1000);
    before.recordSuccess("adapter-a", 1000, [makeItem("halt-1", "adapter-a")], []);

    // restart: fresh state replays the journal
    const after = new CollectorState(new Journal(journalPath));
    after.registerAdapter("adapter-a", 1000);
    expect(after.getItems().map((i) => i.id)).toEqual(["halt-1"]);

    // the condition cleared while the collector was down — the next snapshot omits it
    after.recordSuccess("adapter-a", 2000, [], []);

    expect(after.getItems()).toHaveLength(0);
  });

  test("named scopes reconcile independently and empty completeScopes resolves none", () => {
    const state = new CollectorState(new Journal(tmpJournalPath()));
    state.registerAdapter("adapter-a", 1000);
    state.registerAdapter("adapter-b", 1000);

    state.recordSuccess("adapter-a", 1000, [
      makeScopedItem("a-one", "adapter-a", "repo:one"),
      makeScopedItem("a-two", "adapter-a", "repo:two"),
    ], []);
    state.recordSuccess("adapter-b", 1000, [
      makeScopedItem("b-one", "adapter-b", "repo:one"),
    ], []);

    state.recordSuccess("adapter-a", 2000, [], [], undefined, []);
    expect(state.getItems().map((item) => item.id).sort()).toEqual(["a-one", "a-two", "b-one"]);

    state.recordSuccess("adapter-a", 3000, [], [], undefined, ["repo:one"]);
    expect(state.getItems().map((item) => item.id).sort()).toEqual(["a-two", "b-one"]);
  });

  test("named scope ownership survives restart and resolves by exact scope only", () => {
    const journalPath = tmpJournalPath();
    const before = new CollectorState(new Journal(journalPath));
    before.registerAdapter("adapter-a", 1000);
    before.recordSuccess("adapter-a", 1000, [
      makeScopedItem("one", "adapter-a", "repo:one"),
      makeScopedItem("two", "adapter-a", "repo:two"),
    ], []);

    const after = new CollectorState(new Journal(journalPath));
    after.registerAdapter("adapter-a", 1000);
    after.recordSuccess("adapter-a", 2000, [], [], undefined, ["repo:two"]);
    expect(after.getItems().map((item) => item.id)).toEqual(["one"]);

    const restarted = new CollectorState(new Journal(journalPath));
    restarted.registerAdapter("adapter-a", 1000);
    restarted.recordSuccess("adapter-a", 3000, [], [], undefined, ["repo:one"]);
    expect(restarted.getItems()).toEqual([]);
  });

  test("upserting an id into a new scope does not let its completed old scope tombstone it", () => {
    const state = new CollectorState(new Journal(tmpJournalPath()));
    state.registerAdapter("adapter-a", 1000);
    state.recordSuccess("adapter-a", 1000, [makeScopedItem("moving", "adapter-a", "repo:old")], []);

    const moved = makeScopedItem("moving", "adapter-a", "repo:new");
    state.recordSuccess("adapter-a", 2000, [moved], [], undefined, ["repo:old"]);

    expect(state.getItems()).toEqual([moved]);
  });

  test("replay migrates only valid unscoped ghci run failures and persists upgraded ownership", () => {
    const journalPath = tmpJournalPath();
    const journal = new Journal(journalPath);
    journal.append({
      ...makeItem("ghci:acme/one:101:1", "ghci"),
      project: "acme/one",
      kind: "ci",
    });
    journal.append({
      ...makeItem("ghci:acme/two:202:3", "ghci"),
      project: "acme/two",
      kind: "ci",
    });
    journal.append({
      ...makeItem("ghci:train:acme/one:7:green-unmerged", "ghci"),
      project: "acme/one",
      kind: "ci",
    });
    journal.append({
      ...makeItem("ghci:acme/one:not-a-run:1", "ghci"),
      project: "acme/one",
      kind: "ci",
    });
    journal.append({
      ...makeItem("other:acme/one:303:1", "other"),
      project: "acme/one",
      kind: "ci",
    });

    const migrated = new CollectorState(new Journal(journalPath));
    migrated.registerAdapter("ghci", 1000);
    expect(migrated.getItems().map((item) => [item.id, item.reconciliationScope])).toEqual([
      ["ghci:acme/one:101:1", "run-failure:acme/one"],
      ["ghci:acme/two:202:3", "run-failure:acme/two"],
      ["ghci:train:acme/one:7:green-unmerged", undefined],
      ["ghci:acme/one:not-a-run:1", undefined],
      ["other:acme/one:303:1", undefined],
    ]);
    migrated.recordSuccess("ghci", 1000, [], [], undefined, ["run-failure:acme/two"]);
    expect(migrated.getItems().map((item) => [item.id, item.reconciliationScope])).toEqual([
      ["ghci:acme/one:101:1", "run-failure:acme/one"],
      ["other:acme/one:303:1", undefined],
    ]);

    const replayed = new CollectorState(new Journal(journalPath));
    replayed.registerAdapter("ghci", 1000);
    replayed.recordSuccess("ghci", 2000, [], [], undefined, ["run-failure:acme/one"]);
    expect(replayed.getItems().map((item) => item.id)).toEqual(["other:acme/one:303:1"]);
  });
});
