import { SSE_EVENT_TYPES } from "@capakraken/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getTimelineSseResyncKeys } from "./timelineSsePolicy.js";

const invalidateQueries = vi.fn();
const effectCleanups: Array<() => void> = [];

vi.mock("@tanstack/react-query", () => ({
  useQueryClient: () => ({
    invalidateQueries,
  }),
}));

vi.mock("react", async () => {
  const actual = await vi.importActual<typeof import("react")>("react");
  return {
    ...actual,
    useEffect: (callback: () => void | (() => void)) => {
      const cleanup = callback();
      if (typeof cleanup === "function") {
        effectCleanups.push(cleanup);
      }
    },
    useRef: <T,>(value: T) => ({ current: value }),
  };
});

import { useTimelineSSE } from "./useTimelineSSE.js";

class MockEventSource {
  static instances: MockEventSource[] = [];

  onopen: (() => void) | null = null;
  onmessage: ((event: MessageEvent) => void) | null = null;
  onerror: (() => void) | null = null;
  close = vi.fn();

  constructor(public readonly url: string) {
    MockEventSource.instances.push(this);
  }

  emitOpen() {
    this.onopen?.();
  }

  emitMessage(data: string) {
    this.onmessage?.({ data } as MessageEvent);
  }

  emitError() {
    this.onerror?.();
  }
}

type VisibilityHandler = () => void;

class MockVisibilityDocument {
  visibilityState: "visible" | "hidden" = "visible";
  private readonly listeners = new Set<VisibilityHandler>();

  addEventListener(type: string, listener: VisibilityHandler) {
    if (type === "visibilitychange") {
      this.listeners.add(listener);
    }
  }

  removeEventListener(type: string, listener: VisibilityHandler) {
    if (type === "visibilitychange") {
      this.listeners.delete(listener);
    }
  }

  setVisibility(state: "visible" | "hidden") {
    this.visibilityState = state;
    for (const listener of this.listeners) {
      listener();
    }
  }
}

describe("useTimelineSSE", () => {
  let mockDocument: MockVisibilityDocument;

  beforeEach(() => {
    vi.useFakeTimers();
    vi.clearAllMocks();
    effectCleanups.length = 0;
    MockEventSource.instances.length = 0;
    mockDocument = new MockVisibilityDocument();
    vi.stubGlobal("EventSource", MockEventSource);
    vi.stubGlobal("document", mockDocument);
  });

  afterEach(() => {
    while (effectCleanups.length > 0) {
      effectCleanups.pop()?.();
    }
    vi.unstubAllGlobals();
    vi.useRealTimers();
  });

  it("ignores malformed SSE payloads without invalidating queries", () => {
    useTimelineSSE();

    expect(MockEventSource.instances).toHaveLength(1);

    MockEventSource.instances[0]?.emitMessage("{not-json");

    expect(invalidateQueries).not.toHaveBeenCalled();
  });

  it("does not invalidate queries for ping messages", () => {
    useTimelineSSE();

    expect(MockEventSource.instances).toHaveLength(1);

    MockEventSource.instances[0]?.emitOpen();

    MockEventSource.instances[0]?.emitMessage(JSON.stringify({ type: SSE_EVENT_TYPES.PING }));

    expect(invalidateQueries).not.toHaveBeenCalled();
  });

  it("does not resync on the initial successful SSE connection", () => {
    useTimelineSSE();

    expect(MockEventSource.instances).toHaveLength(1);

    MockEventSource.instances[0]?.emitOpen();

    expect(invalidateQueries).not.toHaveBeenCalled();
  });

  it("resets reconnect backoff after ping and reconnects only once per pending timer", () => {
    useTimelineSSE();

    const firstConnection = MockEventSource.instances[0];
    expect(firstConnection).toBeDefined();

    firstConnection?.emitError();
    firstConnection?.emitError();
    expect(firstConnection?.close).toHaveBeenCalledTimes(1);

    vi.advanceTimersByTime(1999);
    expect(MockEventSource.instances).toHaveLength(1);

    vi.advanceTimersByTime(1);
    expect(MockEventSource.instances).toHaveLength(2);

    const secondConnection = MockEventSource.instances[1];
    secondConnection?.emitMessage(JSON.stringify({ type: SSE_EVENT_TYPES.PING }));
    secondConnection?.emitError();

    vi.advanceTimersByTime(1999);
    expect(MockEventSource.instances).toHaveLength(2);

    vi.advanceTimersByTime(1);
    expect(MockEventSource.instances).toHaveLength(3);

    expect(invalidateQueries).not.toHaveBeenCalled();
  });

  it("resyncs timeline queries exactly once after a successful reconnect", () => {
    useTimelineSSE();

    const firstConnection = MockEventSource.instances[0];
    expect(firstConnection).toBeDefined();

    firstConnection?.emitOpen();
    firstConnection?.emitError();

    vi.advanceTimersByTime(2_000);

    expect(MockEventSource.instances).toHaveLength(2);

    const secondConnection = MockEventSource.instances[1];
    expect(secondConnection).toBeDefined();

    secondConnection?.emitOpen();
    secondConnection?.emitOpen();

    expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
    expect(invalidateQueries.mock.calls).toEqual(
      getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
    );
  });

  it("clears a pending reconnect when the hook is disposed", () => {
    useTimelineSSE();

    const firstConnection = MockEventSource.instances[0];
    expect(firstConnection).toBeDefined();

    firstConnection?.emitError();

    while (effectCleanups.length > 0) {
      effectCleanups.pop()?.();
    }

    vi.advanceTimersByTime(30_000);

    expect(MockEventSource.instances).toHaveLength(1);
    expect(firstConnection?.close).toHaveBeenCalled();
  });

  it("does not connect while the page is hidden on mount, then connects when visible", () => {
    mockDocument.visibilityState = "hidden";

    useTimelineSSE();

    expect(MockEventSource.instances).toHaveLength(0);

    mockDocument.setVisibility("visible");

    expect(MockEventSource.instances).toHaveLength(1);
    expect(invalidateQueries).not.toHaveBeenCalled();
  });

  it("closes the active connection while hidden and resyncs once when visible again", () => {
    useTimelineSSE();

    const firstConnection = MockEventSource.instances[0];
    expect(firstConnection).toBeDefined();

    firstConnection?.emitOpen();
    mockDocument.setVisibility("hidden");

    expect(firstConnection?.close).toHaveBeenCalledTimes(1);
    expect(MockEventSource.instances).toHaveLength(1);

    mockDocument.setVisibility("visible");

    expect(MockEventSource.instances).toHaveLength(2);

    const secondConnection = MockEventSource.instances[1];
    secondConnection?.emitOpen();

    expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
    expect(invalidateQueries.mock.calls).toEqual(
      getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
    );
  });

  it("hides during pending reconnect timer — clears timer and resyncs on next open", () => {
    useTimelineSSE();

    const firstConnection = MockEventSource.instances[0];
    expect(firstConnection).toBeDefined();

    // Establish connection, then break it — schedules a 2s reconnect timer
    firstConnection?.emitOpen();
    firstConnection?.emitError(); // shouldResyncOnOpen = true, timer scheduled

    // Tab hides while timer is still pending (no active `es`)
    mockDocument.setVisibility("hidden"); // clears timer, shouldResyncOnOpen stays true

    // No timer should fire — still only one EventSource
    vi.advanceTimersByTime(5_000);
    expect(MockEventSource.instances).toHaveLength(1);

    // Tab becomes visible — should connect fresh
    mockDocument.setVisibility("visible");
    expect(MockEventSource.instances).toHaveLength(2);

    // Successful open on the new connection → resync
    const secondConnection = MockEventSource.instances[1];
    secondConnection?.emitOpen();

    expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
    expect(invalidateQueries.mock.calls).toEqual(
      getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
    );
  });

  it("resyncs after the first-ever connection attempt fails before any open", () => {
    useTimelineSSE();

    const firstConnection = MockEventSource.instances[0];
    expect(firstConnection).toBeDefined();

    // Error fires without any preceding open — shouldResyncOnOpen becomes true
    firstConnection?.emitError();

    // Advance past the 2s backoff
    vi.advanceTimersByTime(2_000);
    expect(MockEventSource.instances).toHaveLength(2);

    // Retry succeeds
    const secondConnection = MockEventSource.instances[1];
    secondConnection?.emitOpen();

    expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
    expect(invalidateQueries.mock.calls).toEqual(
      getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
    );
  });
});
