import { describe, expect, it, vi, beforeEach } from 'vitest';
import { CacheService, CachePrefix, CacheTTL } from '../cache.service';

describe('CacheService', () => {
  let cacheService: CacheService;
  let mockRedis: {
    get: ReturnType<typeof vi.fn>;
    set: ReturnType<typeof vi.fn>;
    del: ReturnType<typeof vi.fn>;
    getClient: ReturnType<typeof vi.fn>;
    isAvailable: ReturnType<typeof vi.fn>;
  };
  let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
  let mockHitCounter: { inc: ReturnType<typeof vi.fn> };
  let mockMissCounter: { inc: ReturnType<typeof vi.fn> };
  let mockDegradationCounter: { inc: ReturnType<typeof vi.fn> };

  beforeEach(() => {
    mockRedis = {
      get: vi.fn(),
      set: vi.fn(),
      del: vi.fn(),
      getClient: vi.fn().mockReturnValue({
        scan: vi.fn().mockResolvedValue(['0', []]),
        del: vi.fn(),
      }),
      isAvailable: vi.fn().mockReturnValue(true),
    };
    mockLogger = { log: vi.fn(), warn: vi.fn() };
    mockHitCounter = { inc: vi.fn() };
    mockMissCounter = { inc: vi.fn() };
    mockDegradationCounter = { inc: vi.fn() };

    cacheService = new CacheService(
      mockRedis as any,
      mockLogger as any,
      mockHitCounter as any,
      mockMissCounter as any,
      mockDegradationCounter as any,
    );
  });

  describe('getOrSet', () => {
    it('should return cached value on cache hit', async () => {
      const data = { id: '123', name: 'test' };
      // Use the new envelope format (written by getOrSet since the cacheMeta change)
      mockRedis.get.mockResolvedValue(
        JSON.stringify({ __v: data, cachedAt: '2026-04-21T10:00:00.000Z', ttlSeconds: 300 }),
      );
      const loader = vi.fn();

      const result = await cacheService.getOrSet('cache:listing:123', loader, 300, 'listing');

      expect(result).toEqual(data);
      expect(loader).not.toHaveBeenCalled();
      expect(mockHitCounter.inc).toHaveBeenCalledWith({ resource: 'listing' });
      expect(mockMissCounter.inc).not.toHaveBeenCalled();
    });

    it('should call loader and cache result on cache miss', async () => {
      mockRedis.get.mockResolvedValue(null);
      const data = { id: '456', name: 'loaded' };
      const loader = vi.fn().mockResolvedValue(data);

      const result = await cacheService.getOrSet('cache:listing:456', loader, 300, 'listing');

      expect(result).toEqual(data);
      expect(loader).toHaveBeenCalledOnce();
      expect(mockMissCounter.inc).toHaveBeenCalledWith({ resource: 'listing' });
      // Envelope written: { __v: data, cachedAt: <iso>, ttlSeconds: 300 }
      expect(mockRedis.set).toHaveBeenCalledWith(
        'cache:listing:456',
        expect.stringContaining('"__v"'),
        300,
      );
    });

    it('should call loader when cache read fails', async () => {
      mockRedis.get.mockRejectedValue(new Error('connection lost'));
      const data = { id: '789' };
      const loader = vi.fn().mockResolvedValue(data);

      const result = await cacheService.getOrSet('key', loader, 60, 'search');

      expect(result).toEqual(data);
      expect(mockLogger.warn).toHaveBeenCalled();
      expect(mockMissCounter.inc).toHaveBeenCalledWith({ resource: 'search' });
    });

    it('should return loaded data even when cache write fails', async () => {
      mockRedis.get.mockResolvedValue(null);
      mockRedis.set.mockRejectedValue(new Error('write error'));
      const data = { id: '999' };
      const loader = vi.fn().mockResolvedValue(data);

      const result = await cacheService.getOrSet('key', loader, 60, 'search');

      expect(result).toEqual(data);
      expect(mockLogger.warn).toHaveBeenCalled();
    });

    it('should propagate loader errors', async () => {
      mockRedis.get.mockResolvedValue(null);
      const loader = vi.fn().mockRejectedValue(new Error('not found'));

      await expect(cacheService.getOrSet('key', loader, 60, 'listing')).rejects.toThrow('not found');
    });

    it('should skip Redis and call loader directly when Redis is unavailable', async () => {
      mockRedis.isAvailable.mockReturnValue(false);
      const data = { id: 'direct' };
      const loader = vi.fn().mockResolvedValue(data);

      const result = await cacheService.getOrSet('key', loader, 60, 'listing');

      expect(result).toEqual(data);
      expect(mockRedis.get).not.toHaveBeenCalled();
      expect(mockRedis.set).not.toHaveBeenCalled();
      expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'listing', operation: 'skip_unavailable' });
      expect(mockMissCounter.inc).toHaveBeenCalledWith({ resource: 'listing' });
    });

    it('should track degradation on cache read error', async () => {
      mockRedis.get.mockRejectedValue(new Error('connection lost'));
      const loader = vi.fn().mockResolvedValue({ ok: true });

      await cacheService.getOrSet('key', loader, 60, 'search');

      expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'search', operation: 'read_error' });
    });

    it('should track degradation on cache write error', async () => {
      mockRedis.get.mockResolvedValue(null);
      mockRedis.set.mockRejectedValue(new Error('write error'));
      const loader = vi.fn().mockResolvedValue({ ok: true });

      await cacheService.getOrSet('key', loader, 60, 'search');

      expect(mockDegradationCounter.inc).toHaveBeenCalledWith({ resource: 'search', operation: 'write_error' });
    });
  });

  describe('invalidate', () => {
    it('should delete the cache key', async () => {
      await cacheService.invalidate('cache:listing:123');
      expect(mockRedis.del).toHaveBeenCalledWith('cache:listing:123');
    });

    it('should handle delete errors gracefully', async () => {
      mockRedis.del.mockRejectedValue(new Error('fail'));
      await cacheService.invalidate('cache:listing:123');
      expect(mockLogger.warn).toHaveBeenCalled();
    });
  });

  describe('invalidateByPrefix', () => {
    it('should scan and delete matching keys', async () => {
      const mockClient = {
        scan: vi.fn()
          .mockResolvedValueOnce(['10', ['cache:search:a', 'cache:search:b']])
          .mockResolvedValueOnce(['0', ['cache:search:c']]),
        del: vi.fn(),
      };
      mockRedis.getClient.mockReturnValue(mockClient);

      await cacheService.invalidateByPrefix('cache:search');

      expect(mockClient.scan).toHaveBeenCalledTimes(2);
      expect(mockClient.del).toHaveBeenCalledWith('cache:search:a', 'cache:search:b');
      expect(mockClient.del).toHaveBeenCalledWith('cache:search:c');
    });
  });

  describe('buildKey', () => {
    it('should build a deterministic cache key', () => {
      const key = CacheService.buildKey(CachePrefix.LISTING, 'abc123');
      expect(key).toBe('cache:listing:abc123');
    });

    it('should handle multiple parts', () => {
      const key = CacheService.buildKey(CachePrefix.MARKET_REPORT, 'Hà Nội', '2026-Q1', 'APARTMENT');
      expect(key).toBe('cache:market:report:hà_nội:2026-q1:apartment');
    });

    it('should skip undefined parts', () => {
      const key = CacheService.buildKey(CachePrefix.SEARCH, 'query', undefined, 'SALE');
      expect(key).toBe('cache:search:query:sale');
    });

    it('should handle numeric parts', () => {
      const key = CacheService.buildKey(CachePrefix.SEARCH, 'test', 1, 20);
      expect(key).toBe('cache:search:test:1:20');
    });
  });

  describe('CacheTTL', () => {
    it('should have correct TTL values', () => {
      expect(CacheTTL.LISTING_DETAIL).toBe(300);
      expect(CacheTTL.SEARCH_RESULTS).toBe(120);
      expect(CacheTTL.MARKET_DATA).toBe(1800);
      expect(CacheTTL.USER_PROFILE).toBe(600);
      expect(CacheTTL.USER_QUOTA).toBe(60);
    });
  });
});
