import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import useThrottleCallback from './useThrottleCallback';

describe('useThrottleCallback', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.restoreAllMocks();
    vi.useRealTimers();
  });

  describe('default options', () => {
    it('should invoke callback immediately on leading edge', () => {
      const callback = vi.fn();
      const { result } = renderHook(() => useThrottleCallback(callback, 1000));

      expect(callback).toHaveBeenCalledTimes(0);

      act(() => {
        result.current();
      });

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

    it('should throttle multiple rapid calls within wait period', () => {
      const callback = vi.fn();
      const { result } = renderHook(() => useThrottleCallback(callback, 1000));

      expect(callback).toHaveBeenCalledTimes(0);

      act(() => {
        result.current(); // Leading edge
        result.current(); // Throttled
        result.current(); // Throttled
        result.current(); // Throttled
      });

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

    it('should invoke callback on trailing edge after wait period', () => {
      const callback = vi.fn();
      const { result } = renderHook(() => useThrottleCallback(callback, 1000));

      expect(callback).toHaveBeenCalledTimes(0);

      act(() => {
        result.current(); // Leading edge
        result.current(); // Trailing edge
      });

      expect(callback).toHaveBeenCalledTimes(1);

      act(() => {
        vi.advanceTimersByTime(500);
      });

      expect(callback).toHaveBeenCalledTimes(1);

      act(() => {
        vi.advanceTimersByTime(500);
      });

      expect(callback).toHaveBeenCalledTimes(2);
    });

    it('should allow immediate invocation after wait period expires', () => {
      const callback = vi.fn();
      const { result } = renderHook(() => useThrottleCallback(callback, 1000));

      expect(callback).toHaveBeenCalledTimes(0);

      act(() => {
        result.current(); // Leading edge (first call)
      });

      expect(callback).toHaveBeenCalledTimes(1);

      act(() => {
        vi.advanceTimersByTime(1000);
      });

      act(() => {
        result.current(); // Leading edge (second call)
      });

      expect(callback).toHaveBeenCalledTimes(2);
    });
  });

  describe('arguments', () => {
    it('should pass arguments to callback correctly', () => {
      const callback = vi.fn();
      const { result } = renderHook(() => useThrottleCallback(callback, 1000));

      act(() => {
        result.current('arg1', 'arg2', 123);
      });

      expect(callback).toHaveBeenLastCalledWith('arg1', 'arg2', 123);
    });

    it('should use latest arguments for trailing edge call', () => {
      const callback = vi.fn();
      const { result } = renderHook(() => useThrottleCallback(callback, 1000));

      act(() => {
        result.current('first'); // Leading edge
        result.current('second'); // Queued for trailing edge
        result.current('third'); // Replaces queued call
      });

      expect(callback).toHaveBeenLastCalledWith('first');

      act(() => {
        vi.advanceTimersByTime(1000);
      });

      expect(callback).toHaveBeenLastCalledWith('third');
      expect(callback).toHaveBeenCalledTimes(2);
    });

    it('should use the expected arguments across multiple wait periods', () => {
      const callback = vi.fn((arg: string) => arg);
      const { result } = renderHook(() => useThrottleCallback(callback, 1000));

      expect(callback).toHaveBeenCalledTimes(0);

      act(() => {
        result.current('first');
      });

      act(() => {
        vi.advanceTimersByTime(500);
      });

      act(() => {
        result.current('second');
      });

      expect(callback).toHaveBeenCalledTimes(1);
      expect(callback).toHaveBeenLastCalledWith('first');
      expect(callback).lastReturnedWith('first');

      act(() => {
        vi.advanceTimersByTime(500);
      });

      expect(callback).toHaveBeenCalledTimes(2);
      expect(callback).toHaveBeenLastCalledWith('second');
      expect(callback).lastReturnedWith('second');

      act(() => {
        vi.advanceTimersByTime(1000);
        /**
         * Due to a bug in lodash's throttle implementation, a 500ms delay
         * would erroneously allow this test to pass, even though that is not
         * the full 1000ms after the last invocation of the callback!
         *
         * Related:
         * https://github.com/lodash/lodash/issues/3051
         * https://github.com/lodash/lodash/pull/3053
         */
        // vi.advanceTimersByTime(500);
      });

      act(() => {
        result.current('third');
        result.current('fourth');
        result.current('fifth');
      });

      expect(callback).toHaveBeenCalledTimes(3);
      expect(callback).toHaveBeenLastCalledWith('third');
      expect(callback).lastReturnedWith('third');

      act(() => {
        vi.advanceTimersByTime(1000);
      });

      expect(callback).toHaveBeenCalledTimes(4);
      expect(callback).toHaveBeenLastCalledWith('fifth');
      expect(callback).lastReturnedWith('fifth');

      act(() => {
        result.current('sixth');
      });

      expect(callback).toHaveBeenCalledTimes(5);
      expect(callback).toHaveBeenLastCalledWith('sixth');
      expect(callback).lastReturnedWith('sixth');
    });
  });

  describe('custom options', () => {
    it('should respect leading: false option', () => {
      const callback = vi.fn();
      const { result } = renderHook(() =>
        useThrottleCallback(callback, 1000, { leading: false })
      );

      act(() => {
        result.current();
      });

      expect(callback).not.toHaveBeenCalled();

      act(() => {
        vi.advanceTimersByTime(1000);
      });

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

    it('should respect trailing: false option', () => {
      const callback = vi.fn();
      const { result } = renderHook(() =>
        useThrottleCallback(callback, 1000, { trailing: false })
      );

      act(() => {
        result.current(); // Leading edge only
        result.current(); // Should be ignored
      });

      expect(callback).toHaveBeenCalledTimes(1);

      act(() => {
        vi.advanceTimersByTime(1000);
      });

      // Should still be 1, no trailing edge call
      expect(callback).toHaveBeenCalledTimes(1);
    });

    it('should respect both leading: false and trailing: false', () => {
      const callback = vi.fn();
      const { result } = renderHook(() =>
        useThrottleCallback(callback, 1000, { leading: false, trailing: false })
      );

      act(() => {
        result.current();
        result.current();
      });

      expect(callback).not.toHaveBeenCalled();

      act(() => {
        vi.advanceTimersByTime(1000);
      });

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

  it('should handle undefined wait time', () => {
    const callback = vi.fn();
    const { result } = renderHook(() =>
      useThrottleCallback(callback, undefined)
    );

    act(() => {
      result.current();
    });

    expect(callback).toHaveBeenCalled();
  });

  it('should update throttled function when callback changes', () => {
    const callback1 = vi.fn();
    const callback2 = vi.fn();

    const { result, rerender } = renderHook(
      ({ cb }) => useThrottleCallback(cb, 1000),
      { initialProps: { cb: callback1 } }
    );

    act(() => {
      result.current();
    });

    expect(callback1).toHaveBeenCalledTimes(1);
    expect(callback2).not.toHaveBeenCalled();

    // Update the callback
    rerender({ cb: callback2 });

    act(() => {
      vi.advanceTimersByTime(1000);
    });

    act(() => {
      result.current();
    });

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

  it('should maintain separate throttle state for different hook instances', () => {
    const callback1 = vi.fn();
    const callback2 = vi.fn();

    const { result: result1 } = renderHook(() =>
      useThrottleCallback(callback1, 1000)
    );
    const { result: result2 } = renderHook(() =>
      useThrottleCallback(callback2, 1000)
    );

    act(() => {
      result1.current();
      result2.current();
    });

    expect(callback1).toHaveBeenCalledTimes(1);
    expect(callback2).toHaveBeenCalledTimes(1);

    act(() => {
      result1.current();
      result2.current();
    });

    // Both should still be at 1 (throttled)
    expect(callback1).toHaveBeenCalledTimes(1);
    expect(callback2).toHaveBeenCalledTimes(1);

    act(() => {
      vi.advanceTimersByTime(1000);
    });

    // Both should have trailing edge calls
    expect(callback1).toHaveBeenCalledTimes(2);
    expect(callback2).toHaveBeenCalledTimes(2);
  });

  it('should NOT cancel pending invocation when callback changes', () => {
    const callback1 = vi.fn();
    const callback2 = vi.fn();

    const { result, rerender } = renderHook(
      ({ cb }) => useThrottleCallback(cb, 1000),
      { initialProps: { cb: callback1 } }
    );

    act(() => {
      result.current('first-value');
    });

    expect(callback1).toHaveBeenCalledTimes(1);
    expect(callback1).toHaveBeenLastCalledWith('first-value');

    act(() => {
      result.current('second-value');
    });

    expect(callback1).toHaveBeenCalledTimes(1);

    act(() => {
      vi.advanceTimersByTime(500);
    });

    // Change the callback mid-throttle
    rerender({ cb: callback2 });

    expect(callback1).toHaveBeenCalledTimes(1);
    expect(callback2).not.toHaveBeenCalled();

    act(() => {
      vi.advanceTimersByTime(500);
    });

    expect(callback1).toHaveBeenCalledTimes(1);
    expect(callback2).toHaveBeenCalledTimes(1);
    expect(callback2).toHaveBeenLastCalledWith('second-value');
  });

  it('should cancel pending invocation when any options change', () => {
    const callback = vi.fn();

    const { result, rerender } = renderHook(
      ({ opts }) => useThrottleCallback(callback, 1000, opts),
      { initialProps: { opts: { leading: true, trailing: true } } }
    );

    act(() => {
      result.current('first-call');
    });

    expect(callback).toHaveBeenCalledTimes(1);
    expect(callback).toHaveBeenLastCalledWith('first-call');

    act(() => {
      result.current('second-call');
    });

    expect(callback).toHaveBeenCalledTimes(1);

    act(() => {
      vi.advanceTimersByTime(500);
    });

    // Change the options
    rerender({ opts: { leading: false, trailing: true } });

    expect(callback).toHaveBeenCalledTimes(1);

    act(() => {
      vi.advanceTimersByTime(500);
    });

    expect(callback).toHaveBeenCalledTimes(1);

    act(() => {
      result.current('third-call');
    });

    expect(callback).toHaveBeenCalledTimes(1);

    act(() => {
      vi.advanceTimersByTime(1000);
    });

    expect(callback).toHaveBeenCalledTimes(2);
    expect(callback).toHaveBeenLastCalledWith('third-call');
  });

  it('should cancel pending invocation when unmounting', () => {
    const callback = vi.fn();

    const { result, unmount } = renderHook(() =>
      useThrottleCallback(callback, 1000)
    );

    act(() => {
      result.current('first-value');
    });

    expect(callback).toHaveBeenCalledTimes(1);
    expect(callback).toHaveBeenLastCalledWith('first-value');

    act(() => {
      result.current('second-value');
    });

    expect(callback).toHaveBeenCalledTimes(1);

    act(() => {
      vi.advanceTimersByTime(500);
    });

    // Should still be 1
    expect(callback).toHaveBeenCalledTimes(1);

    // Unmount the hook
    unmount();

    act(() => {
      vi.advanceTimersByTime(500);
    });

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