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

// Capture effect callbacks so we can run them manually (same pattern as 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);
      }
    },
    useRef: <T,>(value: T) => ({ current: value }),
    useState: <T,>(initializer: T | (() => T)) => {
      const value = typeof initializer === "function" ? (initializer as () => T)() : initializer;
      return [value, vi.fn()] as [T, ReturnType<typeof vi.fn>];
    },
  };
});

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

// ---- Minimal window event emitter used in tests ----

type AnyHandler = (event: Event) => void;

class MockWindow {
  private readonly listeners = new Map<string, Set<AnyHandler>>();

  addEventListener(type: string, handler: AnyHandler) {
    if (!this.listeners.has(type)) {
      this.listeners.set(type, new Set());
    }
    this.listeners.get(type)!.add(handler);
  }

  removeEventListener(type: string, handler: AnyHandler) {
    this.listeners.get(type)?.delete(handler);
  }

  emit(type: string, event: Partial<Event> = {}) {
    for (const handler of this.listeners.get(type) ?? []) {
      handler(event as Event);
    }
  }

  readonly innerWidth = 1280;
  readonly innerHeight = 800;
  readonly visualViewport = null;
}

// In the node test environment `Node` is not defined. Provide a stub class so that
// `scrollTarget instanceof Node` works as expected in handleScroll.
class MockNode {
  // subclasses can override to signal membership
}

/**
 * Creates a pair of mock Node-like objects where `container.contains(inner)`
 * returns true but `container.contains(outside)` returns false.
 * This satisfies the `r.current.contains(scrollTarget)` check in handleScroll
 * without needing a real DOM.
 */
function makeMockNodes() {
  const inner = new MockNode() as unknown as Node;
  const outside = new MockNode() as unknown as Node;
  const container = Object.assign(new MockNode(), {
    contains(node: Node | null) {
      return node === inner;
    },
  }) as unknown as HTMLElement;
  return { container, inner, outside };
}

describe("useViewportPopover", () => {
  let mockWindow: MockWindow;
  let onClose: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    vi.clearAllMocks();
    effectCleanups.length = 0;

    mockWindow = new MockWindow();
    onClose = vi.fn();

    // Stub globals needed by useViewportPopover effects
    vi.stubGlobal("window", mockWindow);
    vi.stubGlobal("document", {
      addEventListener: vi.fn(),
      removeEventListener: vi.fn(),
    });
    // `Node` is unavailable in the node test environment; stub it so that
    // `scrollTarget instanceof Node` works correctly in handleScroll.
    vi.stubGlobal("Node", MockNode);
    vi.stubGlobal("ResizeObserver", class {
      observe() {}
      disconnect() {}
    });
    vi.stubGlobal("requestAnimationFrame", (cb: () => void) => {
      cb();
      return 1;
    });
    vi.stubGlobal("cancelAnimationFrame", () => {});
  });

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

  // Test 1: anchor "point" → onClose called on window scroll
  it("calls onClose when window scroll fires for a point anchor", () => {
    useViewportPopover({
      anchor: { kind: "point", x: 100, y: 50 },
      width: 300,
      estimatedHeight: 200,
      onClose,
    });

    // Fire a scroll event with a non-Node target so the ignore-check is irrelevant
    mockWindow.emit("scroll", { target: null });

    expect(onClose).toHaveBeenCalledTimes(1);
  });

  // Test 2: anchor "point" + ignoreScrollContainers → onClose NOT called when scroll target is inside the container
  it("does NOT call onClose when scroll target is inside an ignored scroll container", () => {
    const { container, inner } = makeMockNodes();
    const containerRef = { current: container };

    useViewportPopover({
      anchor: { kind: "point", x: 100, y: 50 },
      width: 300,
      estimatedHeight: 200,
      onClose,
      ignoreScrollContainers: [containerRef],
    });

    // Scroll event whose target is inside the ignored container
    mockWindow.emit("scroll", { target: inner });

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

  // Test 3: anchor "point" + ignoreScrollContainers → onClose IS called when scroll target is outside container
  it("calls onClose when scroll target is outside the ignored scroll container", () => {
    const { container, outside } = makeMockNodes();
    const containerRef = { current: container };

    useViewportPopover({
      anchor: { kind: "point", x: 100, y: 50 },
      width: 300,
      estimatedHeight: 200,
      onClose,
      ignoreScrollContainers: [containerRef],
    });

    // Scroll event whose target is NOT inside the container
    mockWindow.emit("scroll", { target: outside });

    expect(onClose).toHaveBeenCalledTimes(1);
  });

  // Test 4: anchor "point" → onClose called on window resize
  it("calls onClose when window resize fires for a point anchor", () => {
    useViewportPopover({
      anchor: { kind: "point", x: 100, y: 50 },
      width: 300,
      estimatedHeight: 200,
      onClose,
    });

    mockWindow.emit("resize", {});

    expect(onClose).toHaveBeenCalledTimes(1);
  });

  // Test 5: initial style for anchor "point" with x=100, y=50 → position fixed, left/top set
  it("computes fixed position style with left and top for a point anchor", () => {
    const result = useViewportPopover({
      anchor: { kind: "point", x: 100, y: 50 },
      width: 300,
      estimatedHeight: 200,
      onClose,
      viewportPadding: 16,
    });

    const style = result.style as import("react").CSSProperties;

    expect(style.position).toBe("fixed");
    // anchor.x = 100, align = "start" → raw left = 100
    // clamped: min(max(100, 16), max(16, 1280-300-16)) = min(100, 964) = 100
    expect(style.left).toBe(100);
    // anchor.y = 50, offset = 8 → raw top = 58
    // clamped: min(max(58, 16), max(16, 800-200-16)) = min(58, 584) = 58
    expect(style.top).toBe(58);
  });

  // Test 6: bounds clamping — if anchor x + width > viewport width, left is clamped
  it("clamps left so the popover stays within viewport bounds", () => {
    // anchor at x=1250, width=300, innerWidth=1280, padding=16
    // raw left = 1250, maxLeft = max(16, 1280 - 300 - 16) = 964 → clamped to 964
    const result = useViewportPopover({
      anchor: { kind: "point", x: 1250, y: 50 },
      width: 300,
      estimatedHeight: 200,
      onClose,
      viewportPadding: 16,
    });

    const style = result.style as import("react").CSSProperties;
    expect(style.position).toBe("fixed");
    expect(style.left as number).toBeLessThanOrEqual(1280 - 300 - 16);
    // Should be clamped to 964
    expect(style.left).toBe(964);
  });
});
