import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Cache } from './cache.decorator';

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

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

  describe('Cache', () => {
    class DataService {
      callCount = 0;

      @Cache({
        ttl: 1000, // 1 second cache
        keyGenerator: (query: string) => query
      })
      async search(query: string): Promise<string[]> {
        this.callCount++;
        await new Promise(resolve => setTimeout(resolve, 100));
        return [`result-${query}-${this.callCount}`];
      }

      @Cache({
        ttl: 500
      })
      async fetchWithExpiry(id: number): Promise<string> {
        this.callCount++;
        await new Promise(resolve => setTimeout(resolve, 50));
        return `data-${id}-${this.callCount}`;
      }

      @Cache()
      async fetchWithNoExpiry(value: string): Promise<string> {
        this.callCount++;
        return `permanent-${value}-${this.callCount}`;
      }
    }

    it('should cache results for specified time', async () => {
      const service = new DataService();

      // First call
      const promise1 = service.search('test');
      await vi.runAllTimersAsync();
      const result1 = await promise1;
      expect(result1).toEqual(['result-test-1']);
      expect(service.callCount).toBe(1);

      // Second call within cache time - should return cached result
      const result2 = await service.search('test');
      expect(result2).toEqual(['result-test-1']);
      expect(service.callCount).toBe(1); // No new call

      // Advance time past cache expiry
      vi.advanceTimersByTime(1100);

      // Third call after cache expiry - should make new call
      const promise3 = service.search('test');
      await vi.runAllTimersAsync();
      const result3 = await promise3;
      expect(result3).toEqual(['result-test-2']);
      expect(service.callCount).toBe(2);
    });

    it('should cache without expiry when no ttl specified', async () => {
      const service = new DataService();

      // First call
      const result1 = await service.fetchWithNoExpiry('test');
      expect(result1).toBe('permanent-test-1');
      expect(service.callCount).toBe(1);

      // Advance time significantly
      vi.advanceTimersByTime(10000);

      // Second call should still use cache
      const result2 = await service.fetchWithNoExpiry('test');
      expect(result2).toBe('permanent-test-1');
      expect(service.callCount).toBe(1);
    });

    it('should cache different keys separately', async () => {
      const service = new DataService();

      // First call
      const promise1 = service.search('query1');
      await vi.runAllTimersAsync();
      const result1 = await promise1;
      expect(result1).toEqual(['result-query1-1']);
      expect(service.callCount).toBe(1);

      // Second call with different key
      const promise2 = service.search('query2');
      await vi.runAllTimersAsync();
      const result2 = await promise2;
      expect(result2).toEqual(['result-query2-2']);
      expect(service.callCount).toBe(2);

      // Subsequent calls should use cache
      const result3 = await service.search('query1');
      const result4 = await service.search('query2');
      expect(result3).toEqual(['result-query1-1']);
      expect(result4).toEqual(['result-query2-2']);
      expect(service.callCount).toBe(2); // No new calls
    });

    it('should clean up expired cache entries', async () => {
      const service = new DataService();

      // Make a call
      const promise1 = service.fetchWithExpiry(1);
      await vi.runAllTimersAsync();
      await promise1;

      // Advance time past cache expiry
      vi.advanceTimersByTime(600);

      // Make another call - should not use expired cache
      service.callCount = 0; // Reset for clarity
      const promise2 = service.fetchWithExpiry(1);
      await vi.runAllTimersAsync();
      const result2 = await promise2;
      
      expect(result2).toBe('data-1-1');
      expect(service.callCount).toBe(1); // New call was made
    });

    it('should not cache errors', async () => {
      class ErrorService {
        callCount = 0;

        @Cache({ ttl: 1000 })
        async fetchWithError(): Promise<string> {
          this.callCount++;
          await new Promise(resolve => setTimeout(resolve, 50));
          throw new Error('API Error');
        }
      }

      const service = new ErrorService();

      // First call that errors
      try {
        const promise1 = service.fetchWithError();
        await vi.runAllTimersAsync();
        await promise1;
        expect.fail('Should have thrown an error');
      } catch (error) {
        expect(error).toBeInstanceOf(Error);
        expect((error as Error).message).toBe('API Error');
      }
      expect(service.callCount).toBe(1);

      // Second call should not use cache (errors aren't cached)
      try {
        const promise2 = service.fetchWithError();
        await vi.runAllTimersAsync();
        await promise2;
        expect.fail('Should have thrown an error');
      } catch (error) {
        expect(error).toBeInstanceOf(Error);
        expect((error as Error).message).toBe('API Error');
      }
      expect(service.callCount).toBe(2);
    });

    it('should use JSON.stringify as default key generator', async () => {
      class DefaultKeyService {
        callCount = 0;

        @Cache({ ttl: 1000 })
        async fetch(_param1: string, _param2: number): Promise<string> {
          this.callCount++;
          return `result-${this.callCount}`;
        }
      }

      const service = new DefaultKeyService();

      // First call with specific args
      const result1 = await service.fetch('test', 123);
      expect(result1).toBe('result-1');

      // Same args should use cache
      const result2 = await service.fetch('test', 123);
      expect(result2).toBe('result-1');
      expect(service.callCount).toBe(1);

      // Different args should make new call
      const result3 = await service.fetch('test', 456);
      expect(result3).toBe('result-2');
      expect(service.callCount).toBe(2);
    });
  });

  describe('Sync function caching', () => {
    class MathService {
      callCount = 0;

      @Cache({
        ttl: 1000,
        keyGenerator: (n: number) => n.toString()
      })
      fibonacci(n: number): number {
        this.callCount++;
        if (n <= 1) return n;
        return this.fibonacci(n - 1) + this.fibonacci(n - 2);
      }

      @Cache({
        ttl: 500
      })
      expensiveCalculation(a: number, b: number): number {
        this.callCount++;
        // Simulate expensive calculation
        return a * b + Math.sqrt(a + b);
      }

      @Cache()
      permanentCalculation(value: number): number {
        this.callCount++;
        return value * 2;
      }
    }

    it('should cache sync function results', () => {
      const service = new MathService();

      // First call
      const result1 = service.fibonacci(5);
      expect(result1).toBe(5);
      expect(service.callCount).toBe(6); // fibonacci(5) calls fibonacci(4), fibonacci(3), etc.

      // Second call should use cache
      const result2 = service.fibonacci(5);
      expect(result2).toBe(5);
      expect(service.callCount).toBe(6); // No new calls
    });

    it('should cache sync functions with multiple arguments', () => {
      const service = new MathService();

      // First call
      const result1 = service.expensiveCalculation(10, 20);
      expect(result1).toBe(10 * 20 + Math.sqrt(10 + 20));
      expect(service.callCount).toBe(1);

      // Second call with same arguments should use cache
      const result2 = service.expensiveCalculation(10, 20);
      expect(result2).toBe(result1);
      expect(service.callCount).toBe(1);

      // Call with different arguments should execute
      const result3 = service.expensiveCalculation(5, 10);
      expect(result3).toBe(5 * 10 + Math.sqrt(5 + 10));
      expect(service.callCount).toBe(2);
    });

    it('should handle sync function cache expiry', () => {
      const service = new MathService();

      // First call
      const result1 = service.expensiveCalculation(3, 4);
      expect(result1).toBe(3 * 4 + Math.sqrt(3 + 4));
      expect(service.callCount).toBe(1);

      // Second call within TTL should use cache
      const result2 = service.expensiveCalculation(3, 4);
      expect(result2).toBe(result1);
      expect(service.callCount).toBe(1);

      // Advance time past TTL
      vi.advanceTimersByTime(600);

      // Call after expiry should execute again
      const result3 = service.expensiveCalculation(3, 4);
      expect(result3).toBe(3 * 4 + Math.sqrt(3 + 4));
      expect(service.callCount).toBe(2);
    });

    it('should cache sync functions without expiry', () => {
      const service = new MathService();

      // First call
      const result1 = service.permanentCalculation(42);
      expect(result1).toBe(84);
      expect(service.callCount).toBe(1);

      // Advance time significantly
      vi.advanceTimersByTime(10000);

      // Second call should still use cache
      const result2 = service.permanentCalculation(42);
      expect(result2).toBe(84);
      expect(service.callCount).toBe(1);
    });

    it('should not cache sync function errors', () => {
      class ErrorService {
        callCount = 0;

        @Cache({ ttl: 1000 })
        errorFunction(shouldError: boolean): string {
          this.callCount++;
          if (shouldError) {
            throw new Error('Sync Error');
          }
          return 'success';
        }
      }

      const service = new ErrorService();

      // First call that errors
      expect(() => service.errorFunction(true)).toThrow('Sync Error');
      expect(service.callCount).toBe(1);

      // Second call should not use cache (errors aren't cached)
      expect(() => service.errorFunction(true)).toThrow('Sync Error');
      expect(service.callCount).toBe(2);

      // Call with success should work
      const result = service.errorFunction(false);
      expect(result).toBe('success');
      expect(service.callCount).toBe(3);

      // Subsequent success call should use cache
      const result2 = service.errorFunction(false);
      expect(result2).toBe('success');
      expect(service.callCount).toBe(3);
    });
  });

  describe('Mixed sync/async usage', () => {
    class MixedService {
      callCount = 0;

      @Cache({ ttl: 1000 })
      async asyncMethod(value: string): Promise<string> {
        this.callCount++;
        await new Promise(resolve => setTimeout(resolve, 10));
        return `async-${value}`;
      }

      @Cache({ ttl: 1000 })
      syncMethod(value: string): string {
        this.callCount++;
        return `sync-${value}`;
      }
    }

    it('should handle mixed sync and async methods in same class', async () => {
      const service = new MixedService();

      // Test async method
      const promise1 = service.asyncMethod('test');
      await vi.runAllTimersAsync();
      const result1 = await promise1;
      expect(result1).toBe('async-test');
      expect(service.callCount).toBe(1);

      // Test sync method
      const result2 = service.syncMethod('test');
      expect(result2).toBe('sync-test');
      expect(service.callCount).toBe(2);

      // Test caching for both
      const result3 = await service.asyncMethod('test');
      expect(result3).toBe('async-test');
      expect(service.callCount).toBe(2); // No new call

      const result4 = service.syncMethod('test');
      expect(result4).toBe('sync-test');
      expect(service.callCount).toBe(2); // No new call
    });
  });

});