import { renderHook } from '@testing-library/react';
import useWheelNavigation from '../useWheelNavigation';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Build a minimal containerRef backed by a div with mocked event methods. */
const makeContainerRef = () => {
  const el = document.createElement('div');
  el.addEventListener    = jest.fn();
  el.removeEventListener = jest.fn();
  return { current: el };
};

/** Extract the wheel handler that was registered via the mocked addEventListener. */
const getWheelHandler = (
  containerRef: ReturnType<typeof makeContainerRef>,
): ((e: Partial<WheelEvent>) => void) => {
  const calls = (containerRef.current.addEventListener as jest.Mock).mock.calls;
  const call  = calls.find((c) => c[0] === 'wheel');
  if (!call) throw new Error('No wheel listener was registered');
  return call[1] as (e: Partial<WheelEvent>) => void;
};

/** Create a fake WheelEvent-like object. */
const makeWheelEvent = (deltaY: number): Partial<WheelEvent> => ({
  deltaY,
  preventDefault: jest.fn(),
});

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('useWheelNavigation', () => {
  let onScrollDown: jest.Mock;
  let onScrollUp: jest.Mock;

  beforeEach(() => {
    onScrollDown = jest.fn();
    onScrollUp   = jest.fn();
    jest.restoreAllMocks();
  });

  // ── Registration ──────────────────────────────────────────────────────────

  it('registers a wheel listener on the container when enabled', () => {
    const containerRef = makeContainerRef();

    renderHook(() =>
      useWheelNavigation({ containerRef, onScrollDown, onScrollUp }),
    );

    expect(containerRef.current.addEventListener).toHaveBeenCalledWith(
      'wheel',
      expect.any(Function),
      { passive: false },
    );
  });

  it('does NOT register a wheel listener when disabled', () => {
    const containerRef = makeContainerRef();

    renderHook(() =>
      useWheelNavigation({ containerRef, onScrollDown, onScrollUp, disabled: true }),
    );

    const calls = (containerRef.current.addEventListener as jest.Mock).mock.calls;
    expect(calls.find((c) => c[0] === 'wheel')).toBeUndefined();
  });

  it('removes the wheel listener on cleanup', () => {
    const containerRef = makeContainerRef();

    const { unmount } = renderHook(() =>
      useWheelNavigation({ containerRef, onScrollDown, onScrollUp }),
    );

    unmount();

    expect(containerRef.current.removeEventListener).toHaveBeenCalledWith(
      'wheel',
      expect.any(Function),
    );
  });

  // ── Scroll-down navigation ─────────────────────────────────────────────────

  it('calls onScrollDown when accumulated deltaY reaches the threshold', () => {
    const containerRef = makeContainerRef();

    renderHook(() =>
      useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30 }),
    );

    const handler = getWheelHandler(containerRef);

    // Single event just above threshold
    handler(makeWheelEvent(35));

    expect(onScrollDown).toHaveBeenCalledTimes(1);
    expect(onScrollUp).not.toHaveBeenCalled();
  });

  it('calls onScrollDown only after accumulation exceeds the threshold across multiple events', () => {
    const containerRef = makeContainerRef();

    renderHook(() =>
      useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30, cooldownMs: 0 }),
    );

    const handler = getWheelHandler(containerRef);

    handler(makeWheelEvent(10)); // 10 – below threshold
    handler(makeWheelEvent(10)); // 20 – still below
    expect(onScrollDown).not.toHaveBeenCalled();

    handler(makeWheelEvent(15)); // 35 – over threshold, fires
    expect(onScrollDown).toHaveBeenCalledTimes(1);
  });

  it('calls preventDefault on the wheel event', () => {
    const containerRef = makeContainerRef();

    renderHook(() =>
      useWheelNavigation({ containerRef, onScrollDown, onScrollUp }),
    );

    const handler = getWheelHandler(containerRef);
    const event = makeWheelEvent(50);
    handler(event);

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

  // ── Scroll-up navigation ───────────────────────────────────────────────────

  it('calls onScrollUp when accumulated deltaY is negative and meets the threshold', () => {
    const containerRef = makeContainerRef();

    renderHook(() =>
      useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30 }),
    );

    const handler = getWheelHandler(containerRef);
    handler(makeWheelEvent(-40));

    expect(onScrollUp).toHaveBeenCalledTimes(1);
    expect(onScrollDown).not.toHaveBeenCalled();
  });

  // ── Threshold boundary ─────────────────────────────────────────────────────

  it('does NOT fire if the accumulated deltaY stays below the threshold', () => {
    const containerRef = makeContainerRef();

    renderHook(() =>
      useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30 }),
    );

    const handler = getWheelHandler(containerRef);
    handler(makeWheelEvent(20));
    handler(makeWheelEvent(5));

    expect(onScrollDown).not.toHaveBeenCalled();
    expect(onScrollUp).not.toHaveBeenCalled();
  });

  // ── Accumulator reset after firing ────────────────────────────────────────

  it('resets the accumulator after firing so subsequent scrolls still need to reach the threshold', () => {
    const containerRef = makeContainerRef();
    const nowSpy = jest.spyOn(Date, 'now');

    renderHook(() =>
      useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30, cooldownMs: 0 }),
    );

    const handler = getWheelHandler(containerRef);

    // First fire
    nowSpy.mockReturnValue(0);
    handler(makeWheelEvent(40));
    expect(onScrollDown).toHaveBeenCalledTimes(1);

    // Immediately after (cooldown = 0): small scroll should not fire again
    nowSpy.mockReturnValue(1);
    handler(makeWheelEvent(10));
    expect(onScrollDown).toHaveBeenCalledTimes(1); // still 1

    // One more event that pushes over threshold
    nowSpy.mockReturnValue(2);
    handler(makeWheelEvent(25));
    expect(onScrollDown).toHaveBeenCalledTimes(2);
  });

  // ── Cooldown ───────────────────────────────────────────────────────────────

  it('ignores events during the cooldown period and resets the accumulator', () => {
    const containerRef = makeContainerRef();
    const nowSpy = jest.spyOn(Date, 'now');
    const cooldownMs = 600;

    renderHook(() =>
      useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30, cooldownMs }),
    );

    const handler = getWheelHandler(containerRef);

    // Fire once at t=1000 (> cooldownMs so lastFired=0 doesn't block the first event)
    nowSpy.mockReturnValue(1000);
    handler(makeWheelEvent(40));
    expect(onScrollDown).toHaveBeenCalledTimes(1);

    // t=1100ms — still in cooldown (only 100ms have passed), must not fire
    nowSpy.mockReturnValue(1100);
    handler(makeWheelEvent(500));
    expect(onScrollDown).toHaveBeenCalledTimes(1);

    // t=1700ms — cooldown expired; accumulator was reset, need fresh threshold
    nowSpy.mockReturnValue(1700);
    handler(makeWheelEvent(40)); // fresh accumulation, above threshold → fires again
    expect(onScrollDown).toHaveBeenCalledTimes(2);
  });

  it('does not fire during cooldown even when accumulated delta would exceed threshold', () => {
    const containerRef = makeContainerRef();
    const nowSpy = jest.spyOn(Date, 'now');
    const cooldownMs = 600;

    renderHook(() =>
      useWheelNavigation({ containerRef, onScrollDown, onScrollUp, threshold: 30, cooldownMs }),
    );

    const handler = getWheelHandler(containerRef);

    // Start above cooldownMs so lastFired=0 doesn't block the first event
    nowSpy.mockReturnValue(1000);
    handler(makeWheelEvent(40));
    expect(onScrollDown).toHaveBeenCalledTimes(1);

    // Multiple large events within cooldown window
    nowSpy.mockReturnValue(1200);
    handler(makeWheelEvent(200));
    nowSpy.mockReturnValue(1400);
    handler(makeWheelEvent(200));

    // Still only the original single call
    expect(onScrollDown).toHaveBeenCalledTimes(1);
  });

  // ── Stable callback refs ───────────────────────────────────────────────────

  it('uses the latest onScrollDown/onScrollUp callbacks without re-registering the listener', () => {
    const containerRef = makeContainerRef();
    const onScrollDown2 = jest.fn();
    const onScrollUp2 = jest.fn();
    let flip = false;

    const { rerender } = renderHook(() =>
      useWheelNavigation({
        containerRef,
        onScrollDown: flip ? onScrollDown2 : onScrollDown,
        onScrollUp:   flip ? onScrollUp2   : onScrollUp,
        threshold: 30,
        cooldownMs: 0,
      }),
    );

    const registrationCountBefore = (containerRef.current.addEventListener as jest.Mock).mock.calls.length;

    // Swap callbacks
    flip = true;
    rerender();

    const registrationCountAfter = (containerRef.current.addEventListener as jest.Mock).mock.calls.length;
    // The effect that attaches the listener should NOT have re-run (stable deps)
    expect(registrationCountAfter).toBe(registrationCountBefore);

    // Firing the handler should now invoke the latest callbacks
    const handler = getWheelHandler(containerRef);
    jest.spyOn(Date, 'now').mockReturnValue(9999); // ensure no cooldown
    handler(makeWheelEvent(40));

    expect(onScrollDown2).toHaveBeenCalledTimes(1);
    expect(onScrollDown).not.toHaveBeenCalled();
  });
});
