import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// Capture effect callbacks so we can invoke them manually — same pattern as
// useViewportPopover.test.ts / useTimelineSSE.test.ts.
const effectCleanups: Array<() => void> = [];

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);
      }
    },
  };
});

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

// ─── Helpers ────────────────────────────────────────────────────────────────

/** Build a minimal focusable HTMLElement stub. */
function makeFocusable(focused = false): HTMLElement {
  return {
    focus: vi.fn(),
    tagName: "BUTTON",
    disabled: false,
    tabIndex: 0,
    matches: () => true,
  } as unknown as HTMLElement;
}

interface MockContainer {
  querySelectorAll: ReturnType<typeof vi.fn>;
  addEventListener: ReturnType<typeof vi.fn>;
  removeEventListener: ReturnType<typeof vi.fn>;
  _emit(type: string, event: Partial<KeyboardEvent>): void;
}

/**
 * Build a minimal container element whose querySelectorAll returns the given
 * list of focusable children, and whose addEventListener / removeEventListener
 * can be spied on.
 */
function makeContainer(focusable: HTMLElement[]): MockContainer {
  const listeners = new Map<string, EventListener>();
  return {
    querySelectorAll: vi.fn((_selector: string) => focusable),
    addEventListener: vi.fn((type: string, handler: EventListener) => {
      listeners.set(type, handler);
    }),
    removeEventListener: vi.fn((type: string) => {
      listeners.delete(type);
    }),
    _emit(type: string, event: Partial<KeyboardEvent>) {
      listeners.get(type)?.(event as Event);
    },
  };
}

// ─── Tests ───────────────────────────────────────────────────────────────────

describe("useFocusTrap", () => {
  let rafId = 0;
  let rafCallbacks: Array<() => void>;

  beforeEach(() => {
    vi.clearAllMocks();
    effectCleanups.length = 0;
    rafCallbacks = [];
    rafId = 0;

    // Stub rAF: capture callbacks without executing them immediately so we
    // control when initial focus fires.
    vi.stubGlobal("requestAnimationFrame", (cb: () => void) => {
      rafCallbacks.push(cb);
      return ++rafId;
    });
    vi.stubGlobal("cancelAnimationFrame", vi.fn());
  });

  afterEach(() => {
    // Run all pending cleanups to avoid leaking listeners between tests.
    while (effectCleanups.length > 0) {
      effectCleanups.pop()?.();
    }
    vi.unstubAllGlobals();
  });

  // ── isOpen = false → no rAF, no listener ──────────────────────────────────

  it("does nothing when isOpen is false", () => {
    const first = makeFocusable();
    const container = makeContainer([first]);
    const ref = { current: container as unknown as HTMLElement };

    useFocusTrap(ref, false);

    expect(rafCallbacks).toHaveLength(0);
    expect(container.addEventListener).not.toHaveBeenCalled();
  });

  // ── isOpen = true → deferred focus on first element ──────────────────────

  it("focuses the first focusable element via rAF when isOpen is true", () => {
    const first = makeFocusable();
    const second = makeFocusable();
    const container = makeContainer([first, second]);
    const ref = { current: container as unknown as HTMLElement };

    useFocusTrap(ref, true);

    // rAF should have been queued but not yet fired
    expect(rafCallbacks).toHaveLength(1);
    expect(first.focus).not.toHaveBeenCalled();

    // Fire the rAF callback to simulate browser layout completion
    const rafCb = rafCallbacks[0];
    if (!rafCb) throw new Error("expected rAF callback to be queued");
    rafCb();
    expect(first.focus).toHaveBeenCalledTimes(1);
    expect(second.focus).not.toHaveBeenCalled();
  });

  // ── Tab key wraps forward (last → first) ──────────────────────────────────

  it("wraps focus from last to first on Tab", () => {
    const first = makeFocusable();
    const last = makeFocusable();
    const container = makeContainer([first, last]);
    const ref = { current: container as unknown as HTMLElement };

    useFocusTrap(ref, true);

    // Simulate Tab with document.activeElement on the last element
    vi.stubGlobal("document", { activeElement: last });

    const preventDefault = vi.fn();
    container._emit("keydown", { key: "Tab", shiftKey: false, preventDefault });

    expect(preventDefault).toHaveBeenCalled();
    expect(first.focus).toHaveBeenCalledTimes(1);
  });

  // ── Shift+Tab key wraps backward (first → last) ──────────────────────────

  it("wraps focus from first to last on Shift+Tab", () => {
    const first = makeFocusable();
    const last = makeFocusable();
    const container = makeContainer([first, last]);
    const ref = { current: container as unknown as HTMLElement };

    useFocusTrap(ref, true);

    // Simulate Shift+Tab with document.activeElement on the first element
    vi.stubGlobal("document", { activeElement: first });

    const preventDefault = vi.fn();
    container._emit("keydown", { key: "Tab", shiftKey: true, preventDefault });

    expect(preventDefault).toHaveBeenCalled();
    expect(last.focus).toHaveBeenCalledTimes(1);
  });

  // ── Non-Tab key → ignored ─────────────────────────────────────────────────

  it("ignores non-Tab keys", () => {
    const first = makeFocusable();
    const container = makeContainer([first]);
    const ref = { current: container as unknown as HTMLElement };

    useFocusTrap(ref, true);

    vi.stubGlobal("document", { activeElement: first });

    const preventDefault = vi.fn();
    container._emit("keydown", { key: "Escape", shiftKey: false, preventDefault });

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

  // ── Empty focusable list → preventDefault, no focus attempt ──────────────

  it("calls preventDefault and does not throw when there are no focusable elements", () => {
    const container = makeContainer([]);
    const ref = { current: container as unknown as HTMLElement };

    useFocusTrap(ref, true);

    vi.stubGlobal("document", { activeElement: null });

    const preventDefault = vi.fn();
    expect(() => {
      container._emit("keydown", { key: "Tab", shiftKey: false, preventDefault });
    }).not.toThrow();
    expect(preventDefault).toHaveBeenCalled();
  });

  // ── Cleanup cancels rAF and removes listener ──────────────────────────────

  it("cancels pending rAF and removes keydown listener on cleanup", () => {
    const first = makeFocusable();
    const container = makeContainer([first]);
    const ref = { current: container as unknown as HTMLElement };

    useFocusTrap(ref, true);

    expect(container.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));

    // Run cleanup (simulates component unmount or isOpen → false)
    effectCleanups.pop()?.();

    expect(cancelAnimationFrame).toHaveBeenCalledWith(rafId);
    expect(container.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
  });
});
