import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { Cache } from '$lib/clients/cache';

describe('Cache', () => {
  // Mock time functions
  beforeEach(() => {
    vi.useFakeTimers();
  });

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

  describe('constructor', () => {
    test('expects to create a cache with default values', () => {
      const cache = new Cache<string>();
      expect(cache.size).toBe(0);
    });

    test('expects to throw error if TTL is not positive', () => {
      expect(() => new Cache<string>(0)).toThrow('TTL must be positive');
      expect(() => new Cache<string>(-1)).toThrow('TTL must be positive');
      expect(() => new Cache<string>(-2)).toThrow('TTL must be positive');
    });

    test('expects to throw error if max size is not positive', () => {
      expect(() => new Cache<string>(1000, 0)).toThrow('Max size must be positive');
      expect(() => new Cache<string>(1000, -1)).toThrow('Max size must be positive');
    });
  });

  describe('set and get', () => {
    test('expects to set and get a value', () => {
      const cache = new Cache<string>();
      cache.set('key', 'value');
      expect(cache.get('key')).toBe('value');
    });

    test('expects to return undefined for non-existent key', () => {
      const cache = new Cache<string>();
      expect(cache.get('nonexistent')).toBeUndefined();
    });

    test('expects to throw error for invalid key', () => {
      const cache = new Cache<string>();
      expect(() => cache.set('', 'value')).toThrow('Invalid cache key');
      expect(() => cache.set('a'.repeat(256), 'value')).toThrow('Invalid cache key');
      // @ts-expect-error: Testing invalid input
      expect(() => cache.set(123, 'value')).toThrow('Invalid cache key');
    });

    test('expects to return undefined and delete entry if it is expired', () => {
      const TTL = 1000;
      const cache = new Cache<string>(TTL);

      cache.set('key', 'value');
      expect(cache.get('key')).toBe('value');

      vi.advanceTimersByTime(TTL + 1);
      expect(cache.get('key')).toBeUndefined();
      expect(cache.size).toBe(0);
    });
  });

  describe('key overwriting', () => {
    test('expects to overwrite value for existing key', () => {
      const cache = new Cache<string>();
      cache.set('key', 'value1');
      expect(cache.get('key')).toBe('value1');

      cache.set('key', 'value2');
      expect(cache.get('key')).toBe('value2');
      expect(cache.size).toBe(1); // The size is same.
    });

    test('expects to reset expiration when overwriting key', () => {
      const TTL = 1000;
      const cache = new Cache<string>(TTL);

      vi.setSystemTime(new Date('2023-01-01T00:00:00.000Z'));
      cache.set('key', 'value1');

      // 800 ms elapsed (not expired).
      vi.advanceTimersByTime(800);

      // overwrite the key.
      cache.set('key', 'value2');

      // Another 800 ms elapsed (total 1600 ms, expired if original value).
      vi.advanceTimersByTime(800);

      // Overwritten values have not yet expired.
      expect(cache.get('key')).toBe('value2');
    });
  });

  describe('LRU behavior', () => {
    test('expects to evict least recently accessed item when max size is reached', () => {
      const cache = new Cache<string>(1000, 3);

      // Add three items to the cache.
      cache.set('key1', 'value1');
      cache.set('key2', 'value2');
      cache.set('key3', 'value3');

      // Add a new item to exceed the max size (key1 expected to be evicted).
      cache.set('key4', 'value4');

      // Expect key1 to be evicted, the others to be present.
      expect(cache.get('key1')).toBeUndefined();
      expect(cache.get('key2')).toBe('value2');
      expect(cache.get('key3')).toBe('value3');
      expect(cache.get('key4')).toBe('value4');
    });
  });

  describe('size management', () => {
    test('expects to report correct size', () => {
      const cache = new Cache<string>();
      expect(cache.size).toBe(0);

      cache.set('key1', 'value1');
      expect(cache.size).toBe(1);

      cache.set('key2', 'value2');
      expect(cache.size).toBe(2);

      cache.delete('key1');
      expect(cache.size).toBe(1);

      cache.clear();
      expect(cache.size).toBe(0);
    });

    test('expects to remove oldest entry when max size is reached', () => {
      const cache = new Cache<string>(1000, 2);

      vi.setSystemTime(new Date('2023-01-01T00:00:00.000Z'));
      cache.set('key1', 'value1');

      vi.setSystemTime(new Date('2023-01-01T00:00:01.000Z'));
      cache.set('key2', 'value2');

      vi.setSystemTime(new Date('2023-01-01T00:00:02.000Z'));
      cache.set('key3', 'value3');

      expect(cache.size).toBe(2);
      expect(cache.get('key1')).toBeUndefined();
      expect(cache.get('key2')).toBe('value2');
      expect(cache.get('key3')).toBe('value3');
    });
  });

  describe('edge cases', () => {
    test('expects to handle different value types', () => {
      const cache = new Cache<string | number | boolean | object>();

      cache.set('string', 'test');
      cache.set('number', 123);
      cache.set('boolean', true);
      cache.set('object', { a: 1, b: 2 });
      cache.set('array', [1, 2, 3]);

      expect(cache.get('string')).toBe('test');
      expect(cache.get('number')).toBe(123);
      expect(cache.get('boolean')).toBe(true);
      expect(cache.get('object')).toEqual({ a: 1, b: 2 });
      expect(cache.get('array')).toEqual([1, 2, 3]);
    });

    test('expects to handle boundary TTL cases', () => {
      const TTL = 1000;
      const cache = new Cache<string>(TTL);

      cache.set('key', 'value');

      // TTL just not expired yet.
      vi.advanceTimersByTime(TTL);
      expect(cache.get('key')).toBe('value');

      // TTL+1 has expired.
      vi.advanceTimersByTime(1);
      expect(cache.get('key')).toBeUndefined();
    });
  });

  describe('health', () => {
    test('expects to report correct health information', () => {
      const cache = new Cache<string>();

      vi.setSystemTime(new Date('2023-01-01T00:00:00.000Z'));
      cache.set('key1', 'value1');

      vi.setSystemTime(new Date('2023-01-01T00:00:01.000Z'));
      cache.set('key2', 'value2');

      const health = cache.health;
      expect(health.size).toBe(2);
      expect(health.oldestEntry).toBe(new Date('2023-01-01T00:00:00.000Z').getTime());
    });

    test('expects to handle empty cache health information', () => {
      const cache = new Cache<string>();
      const health = cache.health;
      expect(health.size).toBe(0);
    });
  });

  describe('has', () => {
    test('expects to return false when key does not exist', () => {
      const cache = new Cache<string>();
      expect(cache.has('nonexistent')).toBeFalsy();
    });

    test('expects to return true when key exists and is not expired', () => {
      const cache = new Cache<string>();
      cache.set('key', 'value');
      expect(cache.has('key')).toBeTruthy();
    });

    test('expects to return false and remove entry when key exists but is expired', () => {
      const TTL = 1000; // 1 sec.
      const cache = new Cache<string>(TTL);

      cache.set('key', 'value');
      expect(cache.has('key')).toBeTruthy();

      // Exceeds the TTL, so the entry should be removed.
      vi.advanceTimersByTime(TTL + 100);
      expect(cache.has('key')).toBeFalsy();

      // Validate that the entry is removed from the cache.
      expect(cache.get('key')).toBeUndefined();
      expect(cache.size).toBe(0);
    });

    test('expects not to affect other valid entries when checking an expired one', () => {
      const TTL = 1000;
      const cache = new Cache<string>(TTL);

      // Add the first entry.
      cache.set('expired', 'old-value');
      vi.advanceTimersByTime(600);

      // Add the second entry.
      cache.set('valid', 'new-value');

      // Exceeds the TTL, so the first entry should be removed.
      vi.advanceTimersByTime(500); // Total: 1100ms

      // Remove the expired entry.
      expect(cache.has('expired')).toBeFalsy();

      // Not affected by the valid entry.
      expect(cache.has('valid')).toBeTruthy();
      expect(cache.get('valid')).toBe('new-value');
      expect(cache.size).toBe(1);
    });

    test('expects to delete multiple expired entries at once', () => {
      const TTL = 1000;
      const cache = new Cache<string>(TTL);

      // Add multiple entries at once.
      cache.set('key1', 'value1');
      cache.set('key2', 'value2');
      cache.set('key3', 'value3');

      expect(cache.size).toBe(3);

      // Exceed the TTL for the first entry.
      vi.advanceTimersByTime(TTL + 100);

      // Remove the entry, when checking the first key.
      expect(cache.has('key1')).toBeFalsy();

      // Not yet removed, as the expired entries are not checked until the has() method is called.
      expect(cache.size).toBe(2);

      // Remove the expired entries, when validating the second and third keys.
      expect(cache.has('key2')).toBeFalsy();
      expect(cache.has('key3')).toBeFalsy();

      // All keys are expired, so the cache should be empty.
      expect(cache.size).toBe(0);
    });
  });

  describe('delete and clear', () => {
    test('expects to delete an entry', () => {
      const cache = new Cache<string>();
      cache.set('key', 'value');
      expect(cache.get('key')).toBe('value');

      cache.delete('key');
      expect(cache.get('key')).toBeUndefined();
    });

    test('expects to clear all entries', () => {
      const cache = new Cache<string>();
      cache.set('key1', 'value1');
      cache.set('key2', 'value2');

      cache.clear();
      expect(cache.size).toBe(0);
      expect(cache.get('key1')).toBeUndefined();
      expect(cache.get('key2')).toBeUndefined();
    });
  });

  describe('automatic cleanup', () => {
    test('expects to automatically clean up expired entries', () => {
      const TTL = 1000;
      const cache = new Cache<string>(TTL);

      cache.set('key1', 'value1');
      cache.set('key2', 'value2');
      expect(cache.size).toBe(2);

      vi.advanceTimersByTime(TTL + 1);

      // Force cleanup by triggering the interval callback
      vi.runOnlyPendingTimers();

      expect(cache.size).toBe(0);
    });
  });

  describe('dispose', () => {
    test('expects to dispose resources correctly', () => {
      const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
      const cache = new Cache<string>();

      cache.set('key', 'value');
      cache.dispose();

      expect(clearIntervalSpy).toHaveBeenCalled();
      expect(cache.size).toBe(0);
    });
  });

  describe('getOrFetch', () => {
    describe('successful case', () => {
      test('calls fetchFn and caches result on first invocation', async () => {
        const cache = new Cache<string>();
        const fetchFn = vi.fn().mockResolvedValue('fetched');
        const result = await cache.getOrFetch('key', fetchFn);
        expect(fetchFn).toHaveBeenCalledTimes(1);
        expect(result).toBe('fetched');
      });

      test('returns cached value without calling fetchFn on subsequent calls', async () => {
        const cache = new Cache<string>();
        const fetchFn = vi.fn().mockResolvedValue('fetched');
        await cache.getOrFetch('key', fetchFn);
        await cache.getOrFetch('key', fetchFn);
        expect(fetchFn).toHaveBeenCalledTimes(1);
      });
    });

    describe('error cases', () => {
      test('propagates fetchFn error without caching', async () => {
        const cache = new Cache<string>();
        const fetchFn = vi.fn().mockRejectedValue(new Error('fetch failed'));
        await expect(cache.getOrFetch('key', fetchFn)).rejects.toThrow('fetch failed');
        expect(cache.size).toBe(0);
      });

      test('retries fetchFn after a previous failure', async () => {
        const cache = new Cache<string>();
        const fetchFn = vi
          .fn()
          .mockRejectedValueOnce(new Error('fetch failed'))
          .mockResolvedValue('retried');
        await expect(cache.getOrFetch('key', fetchFn)).rejects.toThrow('fetch failed');
        const result = await cache.getOrFetch('key', fetchFn);
        expect(result).toBe('retried');
        expect(fetchFn).toHaveBeenCalledTimes(2);
      });
    });

    describe('boundary cases', () => {
      test('caches empty Map as valid value', async () => {
        const cache = new Cache<Map<string, unknown>>();
        const fetchFn = vi.fn().mockResolvedValue(new Map());
        await cache.getOrFetch('key', fetchFn);
        await cache.getOrFetch('key', fetchFn);
        expect(fetchFn).toHaveBeenCalledTimes(1);
      });

      test('returns cached value just before TTL expires', async () => {
        const TTL = 1000;
        const cache = new Cache<string>(TTL);
        const fetchFn = vi.fn().mockResolvedValue('fetched');
        await cache.getOrFetch('key', fetchFn);
        vi.advanceTimersByTime(TTL - 1);
        await cache.getOrFetch('key', fetchFn);
        expect(fetchFn).toHaveBeenCalledTimes(1);
      });

      test('calls fetchFn again after TTL expires', async () => {
        const TTL = 1000;
        const cache = new Cache<string>(TTL);
        const fetchFn = vi.fn().mockResolvedValue('fetched');
        await cache.getOrFetch('key', fetchFn);
        vi.advanceTimersByTime(TTL + 1);
        await cache.getOrFetch('key', fetchFn);
        expect(fetchFn).toHaveBeenCalledTimes(2);
      });
    });

    describe('stampede prevention', () => {
      test('shares a single fetchFn call across concurrent requests for the same key', async () => {
        const cache = new Cache<string>();
        let resolvePromise: (value: string) => void;
        const fetchFn = vi.fn().mockImplementation(
          () =>
            new Promise<string>((resolve) => {
              resolvePromise = resolve;
            }),
        );

        const promise1 = cache.getOrFetch('key', fetchFn);
        const promise2 = cache.getOrFetch('key', fetchFn);

        resolvePromise!('shared');
        const [result1, result2] = await Promise.all([promise1, promise2]);

        expect(fetchFn).toHaveBeenCalledTimes(1);
        expect(result1).toBe('shared');
        expect(result2).toBe('shared');
      });

      test('cleans up inflight entry after fetchFn resolves', async () => {
        const cache = new Cache<string>();
        const fetchFn = vi.fn().mockResolvedValue('done');
        await cache.getOrFetch('key', fetchFn);

        const fetchFn2 = vi.fn().mockResolvedValue('done2');
        vi.advanceTimersByTime(cache['timeToLive'] + 1);
        const result = await cache.getOrFetch('key', fetchFn2);

        expect(fetchFn2).toHaveBeenCalledTimes(1);
        expect(result).toBe('done2');
      });

      test('cleans up inflight entry and propagates error to all waiters on fetchFn failure', async () => {
        const cache = new Cache<string>();
        const fetchFn = vi.fn().mockRejectedValue(new Error('boom'));

        const results = await Promise.allSettled([
          cache.getOrFetch('key', fetchFn),
          cache.getOrFetch('key', fetchFn),
        ]);

        expect(fetchFn).toHaveBeenCalledTimes(1);
        expect(results[0]).toEqual(expect.objectContaining({ status: 'rejected' }));
        expect(results[1]).toEqual(expect.objectContaining({ status: 'rejected' }));
        expect(cache.size).toBe(0);
      });

      test('does not share inflight between different keys', async () => {
        const cache = new Cache<string>();
        const fetchFnA = vi.fn().mockResolvedValue('a');
        const fetchFnB = vi.fn().mockResolvedValue('b');

        const [resultA, resultB] = await Promise.all([
          cache.getOrFetch('keyA', fetchFnA),
          cache.getOrFetch('keyB', fetchFnB),
        ]);

        expect(fetchFnA).toHaveBeenCalledTimes(1);
        expect(fetchFnB).toHaveBeenCalledTimes(1);
        expect(resultA).toBe('a');
        expect(resultB).toBe('b');
      });
    });
  });
});
