import { describe, expect, test } from "bun:test";

import {
  shouldRefreshBoardForEvent,
  subscribeToBoardEvents,
} from "./board-events";
import {
  createCrossTabSseCoordinator,
  type BroadcastChannelLike,
} from "./cross-tab-sse";
import { queryKeys } from "./query-keys";
import type { EventSourceLike, SseKey } from "./sse";

type MessageHandler = ((event: { data: string }) => void) | null;

class FakeEventSource {
  onmessage: MessageHandler = null;
  closed = false;

  emit(payload: unknown) {
    this.onmessage?.({ data: JSON.stringify(payload) });
  }

  close() {
    this.closed = true;
  }
}

class FakeBroadcastChannel implements BroadcastChannelLike {
  onmessage: ((event: { data: unknown }) => void) | null = null;

  postMessage() {}

  close() {}
}

describe("shouldRefreshBoardForEvent", () => {
  test("refreshes board for run and interview status changes only", () => {
    expect(shouldRefreshBoardForEvent("run.running")).toBe(true);
    expect(shouldRefreshBoardForEvent("run.blocked")).toBe(true);
    expect(shouldRefreshBoardForEvent("interview.completed")).toBe(true);
    expect(shouldRefreshBoardForEvent("checkpoint.completed")).toBe(false);
  });
});

describe("subscribeToBoardEvents", () => {
  test("coordinated mode shares one global source and invalidates the board runs key", async () => {
    const source = new FakeEventSource();
    const created: string[] = [];
    const keys: SseKey[] = [];
    const coordinator = createCoordinator((url) => {
      created.push(url);
      return source;
    });
    const mutate = (key: SseKey) => {
      keys.push(key);
      return Promise.resolve();
    };

    const firstCleanup = subscribeToBoardEvents(mutate, () => {
      throw new Error("source should be created by coordinator");
    }, { debounceMs: 0, coordinator });
    const secondCleanup = subscribeToBoardEvents(mutate, () => {
      throw new Error("source should be reused");
    }, { debounceMs: 0, coordinator });

    await waitFor(() => created.length === 1);
    keys.length = 0;

    source.emit({ event: "run.running" });

    expect(created).toEqual(["/api/v1/attach"]);
    expect(keys).toEqual([queryKeys.boards.runs()]);

    firstCleanup();
    expect(source.closed).toBe(false);
    secondCleanup();
    expect(source.closed).toBe(true);
    coordinator.close();
  });

  test("fallback mode preserves the existing shared board EventSource", () => {
    const source = new FakeEventSource();
    const created: string[] = [];
    const keys: SseKey[] = [];
    const coordinator = createFallbackCoordinator();
    const mutate = (key: SseKey) => {
      keys.push(key);
      return Promise.resolve();
    };

    const firstCleanup = subscribeToBoardEvents(mutate, (url) => {
      created.push(url);
      return source;
    }, { debounceMs: 0, coordinator });
    const secondCleanup = subscribeToBoardEvents(mutate, () => {
      throw new Error("source should be reused");
    }, { debounceMs: 0, coordinator });

    source.emit({ event: "run.running" });

    expect(created).toEqual(["/api/v1/attach"]);
    expect(keys).toEqual([queryKeys.boards.runs()]);

    firstCleanup();
    expect(source.closed).toBe(false);
    secondCleanup();
    expect(source.closed).toBe(true);
    coordinator.close();
  });
});

function createCoordinator(eventSourceFactory: (url: string) => EventSourceLike) {
  return createCrossTabSseCoordinator({
    tabId: "board-test",
    channelFactory: () => new FakeBroadcastChannel(),
    eventSourceFactory,
    addVisibilityChangeListener: () => () => {},
    addPagehideListener: () => () => {},
    timing: {
      heartbeatMs: 10,
      leaderStaleMs: 50,
      electionJitterMs: 0,
    },
  });
}

function createFallbackCoordinator() {
  return createCrossTabSseCoordinator({
    channelFactory: () => {
      throw new Error("BroadcastChannel unavailable");
    },
  });
}

async function waitFor(condition: () => boolean, timeoutMs = 200) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if (condition()) return;
    await new Promise((resolve) => setTimeout(resolve, 2));
  }
  throw new Error("condition did not become true before timeout");
}
