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

describe('useAsync', () => {
  describe('basic functionality', () => {
    it('should start in loading state when immediate is true', () => {
      const asyncFn = vi.fn().mockResolvedValue('data');
      const { result } = renderHook(() => useAsync(asyncFn, { immediate: true }));

      expect(result.current.loading).toBe(true);
      expect(result.current.data).toBeNull();
      expect(result.current.error).toBeNull();
    });

    it('should not load immediately when immediate is false', () => {
      const asyncFn = vi.fn().mockResolvedValue('data');
      const { result } = renderHook(() => useAsync(asyncFn, { immediate: false }));

      expect(result.current.loading).toBe(false);
      expect(result.current.data).toBeNull();
      expect(asyncFn).not.toHaveBeenCalled();
    });

    it('should default to immediate true', () => {
      const asyncFn = vi.fn().mockResolvedValue('data');
      const { result } = renderHook(() => useAsync(asyncFn));

      expect(result.current.loading).toBe(true);
      expect(asyncFn).toHaveBeenCalled();
    });

    it('should resolve with data on success', async () => {
      const asyncFn = vi.fn().mockResolvedValue({ id: 1, name: 'Test' });
      const { result } = renderHook(() => useAsync(asyncFn));

      await waitFor(() => {
        expect(result.current.loading).toBe(false);
      });

      expect(result.current.data).toEqual({ id: 1, name: 'Test' });
      expect(result.current.error).toBeNull();
    });

    it('should handle errors', async () => {
      const asyncFn = vi.fn().mockRejectedValue(new Error('Fetch failed'));
      const { result } = renderHook(() => useAsync(asyncFn));

      await waitFor(() => {
        expect(result.current.loading).toBe(false);
      });

      expect(result.current.data).toBeNull();
      expect(result.current.error).toBe('Fetch failed');
    });

    it('should handle non-Error errors', async () => {
      // When a string is thrown, getErrorMessage returns it directly
      const asyncFn = vi.fn().mockRejectedValue('String error');
      const { result } = renderHook(() => useAsync(asyncFn));

      await waitFor(() => {
        expect(result.current.loading).toBe(false);
      });

      expect(result.current.error).toBe('String error');
    });
  });

  describe('execute', () => {
    it('should allow manual execution', async () => {
      const asyncFn = vi.fn().mockResolvedValue('executed');
      const { result } = renderHook(() => useAsync(asyncFn, { immediate: false }));

      expect(asyncFn).not.toHaveBeenCalled();

      await act(async () => {
        await result.current.execute();
      });

      expect(asyncFn).toHaveBeenCalledTimes(1);
      expect(result.current.data).toBe('executed');
    });

    it('should return data on successful execution', async () => {
      const asyncFn = vi.fn().mockResolvedValue('return-value');
      const { result } = renderHook(() => useAsync(asyncFn, { immediate: false }));

      let returnValue: unknown = null;
      await act(async () => {
        returnValue = await result.current.execute();
      });

      expect(returnValue).toBe('return-value');
    });

    it('should return null on error', async () => {
      const asyncFn = vi.fn().mockRejectedValue(new Error('Failed'));
      const { result } = renderHook(() => useAsync(asyncFn, { immediate: false }));

      let returnValue: unknown = 'initial';
      await act(async () => {
        returnValue = await result.current.execute();
      });

      expect(returnValue).toBeNull();
    });

    it('should clear previous error on new execution', async () => {
      const asyncFn = vi
        .fn()
        .mockRejectedValueOnce(new Error('First error'))
        .mockResolvedValueOnce('success');

      const { result } = renderHook(() => useAsync(asyncFn, { immediate: false }));

      // First execution - error
      await act(async () => {
        await result.current.execute();
      });
      expect(result.current.error).toBe('First error');

      // Second execution - success
      await act(async () => {
        await result.current.execute();
      });
      expect(result.current.error).toBeNull();
      expect(result.current.data).toBe('success');
    });
  });

  describe('reset', () => {
    it('should reset state to initial values', async () => {
      const asyncFn = vi.fn().mockResolvedValue('data');
      const { result } = renderHook(() => useAsync(asyncFn));

      await waitFor(() => {
        expect(result.current.data).toBe('data');
      });

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

      expect(result.current.data).toBeNull();
      expect(result.current.loading).toBe(false);
      expect(result.current.error).toBeNull();
    });
  });

  describe('setData', () => {
    it('should allow direct data update', async () => {
      const asyncFn = vi.fn().mockResolvedValue('initial');
      const { result } = renderHook(() => useAsync(asyncFn));

      await waitFor(() => {
        expect(result.current.data).toBe('initial');
      });

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

      expect(result.current.data).toBe('updated');
    });

    it('should allow setting data to null', async () => {
      const asyncFn = vi.fn().mockResolvedValue('initial');
      const { result } = renderHook(() => useAsync(asyncFn));

      await waitFor(() => {
        expect(result.current.data).toBe('initial');
      });

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

      expect(result.current.data).toBeNull();
    });
  });

  describe('deps', () => {
    it('should re-execute when deps change', async () => {
      const asyncFn = vi.fn().mockResolvedValue('data');
      const { result, rerender } = renderHook(({ dep }) => useAsync(asyncFn, { deps: [dep] }), {
        initialProps: { dep: 1 },
      });

      await waitFor(() => {
        expect(result.current.loading).toBe(false);
      });

      expect(asyncFn).toHaveBeenCalledTimes(1);

      rerender({ dep: 2 });

      await waitFor(() => {
        expect(asyncFn).toHaveBeenCalledTimes(2);
      });
    });

    it('should not re-execute when deps stay the same', async () => {
      const asyncFn = vi.fn().mockResolvedValue('data');
      const { result, rerender } = renderHook(({ dep }) => useAsync(asyncFn, { deps: [dep] }), {
        initialProps: { dep: 1 },
      });

      await waitFor(() => {
        expect(result.current.loading).toBe(false);
      });

      expect(asyncFn).toHaveBeenCalledTimes(1);

      rerender({ dep: 1 });

      // Should still be 1
      expect(asyncFn).toHaveBeenCalledTimes(1);
    });
  });
});

describe('useAsyncWithCache', () => {
  let mockNow: number;

  beforeEach(() => {
    clearAsyncCache();
    mockNow = 1000000;
    vi.spyOn(Date, 'now').mockImplementation(() => mockNow);
  });

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

  it('should cache results', async () => {
    const asyncFn = vi.fn().mockResolvedValue('cached-data');

    const { result: result1 } = renderHook(() => useAsyncWithCache('test-key', asyncFn));

    await waitFor(() => {
      expect(result1.current.data).toBe('cached-data');
    });

    expect(asyncFn).toHaveBeenCalledTimes(1);

    // Second hook with same key should use cache
    const { result: result2 } = renderHook(() => useAsyncWithCache('test-key', asyncFn));

    await waitFor(() => {
      expect(result2.current.data).toBe('cached-data');
    });

    // Should not call asyncFn again due to cache
    expect(asyncFn).toHaveBeenCalledTimes(1);
  });

  it('should use different cache for different keys', async () => {
    const asyncFn1 = vi.fn().mockResolvedValue('data1');
    const asyncFn2 = vi.fn().mockResolvedValue('data2');

    const { result: result1 } = renderHook(() => useAsyncWithCache('key1', asyncFn1));

    const { result: result2 } = renderHook(() => useAsyncWithCache('key2', asyncFn2));

    await waitFor(() => {
      expect(result1.current.data).toBe('data1');
    });

    await waitFor(() => {
      expect(result2.current.data).toBe('data2');
    });

    expect(asyncFn1).toHaveBeenCalledTimes(1);
    expect(asyncFn2).toHaveBeenCalledTimes(1);
  });

  it('should expire cache after cacheTime', async () => {
    const asyncFn = vi.fn().mockResolvedValue('data');
    const cacheTime = 1000; // 1 second

    const { result } = renderHook(() => useAsyncWithCache('expire-key', asyncFn, { cacheTime }));

    await waitFor(() => {
      expect(result.current.data).toBe('data');
    });

    expect(asyncFn).toHaveBeenCalledTimes(1);

    // Advance time past cache expiry by updating mock
    mockNow += cacheTime + 100;

    // Execute again - should fetch fresh data
    await act(async () => {
      await result.current.execute();
    });

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

  it('should use cache within cacheTime', async () => {
    const asyncFn = vi.fn().mockResolvedValue('data');
    const cacheTime = 5000; // 5 seconds

    const { result } = renderHook(() =>
      useAsyncWithCache('cache-time-key', asyncFn, { cacheTime }),
    );

    await waitFor(() => {
      expect(result.current.data).toBe('data');
    });

    expect(asyncFn).toHaveBeenCalledTimes(1);

    // Advance time but not past cache expiry
    mockNow += 2000;

    // Execute again - should use cache
    await act(async () => {
      await result.current.execute();
    });

    // Should still be 1 because cache is valid
    expect(asyncFn).toHaveBeenCalledTimes(1);
  });

  it('should use default cacheTime of 5 minutes', async () => {
    const asyncFn = vi.fn().mockResolvedValue('data');

    const { result } = renderHook(() => useAsyncWithCache('default-cache', asyncFn));

    await waitFor(() => {
      expect(result.current.data).toBe('data');
    });

    // Advance 4 minutes - should still be cached
    mockNow += 4 * 60 * 1000;

    await act(async () => {
      await result.current.execute();
    });

    expect(asyncFn).toHaveBeenCalledTimes(1);

    // Advance past 5 minutes total (add 2 more minutes)
    mockNow += 2 * 60 * 1000;

    await act(async () => {
      await result.current.execute();
    });

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

describe('clearAsyncCache', () => {
  beforeEach(() => {
    clearAsyncCache();
  });

  it('should clear specific cache key', async () => {
    const asyncFn1 = vi.fn().mockResolvedValue('data1');
    const asyncFn2 = vi.fn().mockResolvedValue('data2');

    const { result: r1 } = renderHook(() => useAsyncWithCache('key1', asyncFn1));
    const { result: r2 } = renderHook(() => useAsyncWithCache('key2', asyncFn2));

    await waitFor(() => {
      expect(r1.current.data).toBe('data1');
      expect(r2.current.data).toBe('data2');
    });

    expect(asyncFn1).toHaveBeenCalledTimes(1);
    expect(asyncFn2).toHaveBeenCalledTimes(1);

    clearAsyncCache('key1');

    // Re-execute key1 - should fetch
    await act(async () => {
      await r1.current.execute();
    });

    // Re-execute key2 - should use cache
    await act(async () => {
      await r2.current.execute();
    });

    expect(asyncFn1).toHaveBeenCalledTimes(2);
    expect(asyncFn2).toHaveBeenCalledTimes(1); // Still cached
  });

  it('should clear all cache when no key provided', async () => {
    const asyncFn1 = vi.fn().mockResolvedValue('data1');
    const asyncFn2 = vi.fn().mockResolvedValue('data2');

    const { result: r1 } = renderHook(() => useAsyncWithCache('key1', asyncFn1));
    const { result: r2 } = renderHook(() => useAsyncWithCache('key2', asyncFn2));

    await waitFor(() => {
      expect(r1.current.data).toBe('data1');
      expect(r2.current.data).toBe('data2');
    });

    clearAsyncCache();

    // Both should require fresh fetch
    await act(async () => {
      await r1.current.execute();
    });
    await act(async () => {
      await r2.current.execute();
    });

    expect(asyncFn1).toHaveBeenCalledTimes(2);
    expect(asyncFn2).toHaveBeenCalledTimes(2);
  });
});
