import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
import { RedisCache } from '../src/redis-cache';
import type { CacheOptions } from '../src/types';

// Mock Redis instance
const createMockRedis = () => ({
  status: 'ready',
  on: mock(() => {}),
  once: mock(() => {}),
  get: mock(async () => null),
  set: mock(async () => 'OK'),
  setex: mock(async () => 'OK'),
  del: mock(async () => 1),
  exists: mock(async () => 0),
  keys: mock(async () => []),
  ping: mock(async () => 'PONG'),
  ttl: mock(async () => -2),
  eval: mock(async () => [null, -2]),
  _eventCallbacks: {} as Record<string, Function>,
  _triggerEvent(event: string, ...args: any[]) {
    if (this._eventCallbacks[event]) {
      this._eventCallbacks[event](...args);
    }
  }
});

// Create mock instance getter that we can control
let mockConnectionManagerInstance: any = null;

// Mock the connection manager
mock.module('../src/connection-manager', () => ({
  RedisConnectionManager: {
    getInstance: () => mockConnectionManagerInstance
  }
}));

describe('RedisCache', () => {
  let cache: RedisCache;
  let mockRedis: ReturnType<typeof createMockRedis>;
  let mockLogger: any;
  let mockConnectionManager: any;

  beforeEach(() => {
    mockLogger = {
      info: mock(() => {}),
      error: mock(() => {}),
      warn: mock(() => {}),
      debug: mock(() => {}),
    };

    mockRedis = createMockRedis();
    mockConnectionManager = {
      getConnection: mock(() => mockRedis)
    };

    // Set the mock instance for the module
    mockConnectionManagerInstance = mockConnectionManager;
  });

  afterEach(() => {
    // Clear mocks
    mockLogger.info.mockClear();
    mockLogger.error.mockClear();
    mockLogger.warn.mockClear();
    mockLogger.debug.mockClear();
  });

  describe('constructor', () => {
    it('should create cache with default options', () => {
      const options: CacheOptions = {
        redisConfig: { host: 'localhost', port: 6379 },
      };

      cache = new RedisCache(options);

      expect(mockConnectionManager.getConnection).toHaveBeenCalledWith({
        name: 'CACHE-SERVICE',
        singleton: true,
        redisConfig: options.redisConfig,
        logger: expect.any(Object),
      });
    });

    it('should use custom name and prefix', () => {
      const options: CacheOptions = {
        name: 'MyCache',
        keyPrefix: 'custom:',
        redisConfig: { host: 'localhost', port: 6379 },
        logger: mockLogger,
      };

      cache = new RedisCache(options);

      expect(mockConnectionManager.getConnection).toHaveBeenCalledWith({
        name: 'MyCache-SERVICE',
        singleton: true,
        redisConfig: options.redisConfig,
        logger: mockLogger,
      });
    });

    it('should handle non-shared connections', () => {
      const options: CacheOptions = {
        shared: false,
        redisConfig: { host: 'localhost', port: 6379 },
        logger: mockLogger,
      };

      cache = new RedisCache(options);

      // Should create a new connection for non-shared
      expect(mockConnectionManager.getConnection).toHaveBeenCalledWith({
        name: 'CACHE-SERVICE',
        singleton: false,
        redisConfig: options.redisConfig,
        logger: mockLogger,
      });
    });

    it('should sanitize prefix for connection name', () => {
      const options: CacheOptions = {
        keyPrefix: 'my-special:prefix!',
        redisConfig: { host: 'localhost', port: 6379 },
      };

      cache = new RedisCache(options);

      expect(mockConnectionManager.getConnection).toHaveBeenCalledWith(
        expect.objectContaining({
          name: 'MYSPECIALPREFIX-SERVICE',
        })
      );
    });
  });

  describe('get', () => {
    beforeEach(() => {
      cache = new RedisCache({
        keyPrefix: 'test:',
        redisConfig: { host: 'localhost', port: 6379 },
        logger: mockLogger,
      });
    });

    it('should get value with prefix', async () => {
      const testValue = { data: 'test' };
      (mockRedis.get as any).mockResolvedValue(JSON.stringify(testValue));

      const result = await cache.get('mykey');

      expect(mockRedis.get).toHaveBeenCalledWith('test:mykey');
      expect(result).toEqual(testValue);
      expect(mockLogger.debug).toHaveBeenCalledWith('Cache hit', { key: 'mykey' });
    });

    it('should handle cache miss', async () => {
      (mockRedis.get as any).mockResolvedValue(null);

      const result = await cache.get('nonexistent');

      expect(result).toBeNull();
      expect(mockLogger.debug).toHaveBeenCalledWith('Cache miss', { key: 'nonexistent' });
    });

    it('should handle non-JSON strings', async () => {
      (mockRedis.get as any).mockResolvedValue('plain string');

      const result = await cache.get<string>('stringkey');

      expect(result).toBe('plain string');
    });

    it('should handle Redis errors gracefully', async () => {
      (mockRedis.get as any).mockRejectedValue(new Error('Redis error'));

      const result = await cache.get('errorkey');

      expect(result).toBeNull();
      expect(mockLogger.error).toHaveBeenCalledWith(
        'Redis get failed',
        expect.objectContaining({ error: 'Redis error' })
      );
    });

    it('should handle not ready state', async () => {
      mockRedis.status = 'connecting';

      const result = await cache.get('key');

      expect(result).toBeNull();
      expect(mockLogger.warn).toHaveBeenCalledWith(
        'Redis not ready for get, using fallback'
      );
    });
  });

  describe('set', () => {
    beforeEach(() => {
      cache = new RedisCache({
        keyPrefix: 'test:',
        ttl: 7200,
        redisConfig: { host: 'localhost', port: 6379 },
        logger: mockLogger,
      });
    });

    it('should set value with default TTL', async () => {
      const value = { data: 'test' };

      await cache.set('mykey', value);

      expect(mockRedis.setex).toHaveBeenCalledWith(
        'test:mykey',
        7200,
        JSON.stringify(value)
      );
    });

    it('should set value with custom TTL as number', async () => {
      await cache.set('mykey', 'value', 3600);

      expect(mockRedis.setex).toHaveBeenCalledWith('test:mykey', 3600, 'value');
    });

    it('should set value with options object', async () => {
      await cache.set('mykey', 'value', { ttl: 1800 });

      expect(mockRedis.setex).toHaveBeenCalledWith('test:mykey', 1800, 'value');
    });

    it('should handle preserveTTL option', async () => {
      // Key exists with TTL
      (mockRedis.ttl as any).mockResolvedValue(3600);

      await cache.set('mykey', 'newvalue', { preserveTTL: true });

      expect(mockRedis.ttl).toHaveBeenCalledWith('test:mykey');
      expect(mockRedis.setex).toHaveBeenCalledWith('test:mykey', 3600, 'newvalue');
    });

    it('should handle preserveTTL with no expiry', async () => {
      // Key exists with no expiry
      (mockRedis.ttl as any).mockResolvedValue(-1);

      await cache.set('mykey', 'value', { preserveTTL: true });

      expect(mockRedis.set).toHaveBeenCalledWith('test:mykey', 'value');
    });

    it('should handle onlyIfExists option', async () => {
      (mockRedis.set as any).mockResolvedValue(null);

      await cache.set('mykey', 'value', { onlyIfExists: true });

      expect(mockRedis.set).toHaveBeenCalledWith(
        'test:mykey',
        'value',
        'EX',
        7200,
        'XX'
      );
    });

    it('should handle onlyIfNotExists option', async () => {
      (mockRedis.set as any).mockResolvedValue('OK');

      await cache.set('mykey', 'value', { onlyIfNotExists: true });

      expect(mockRedis.set).toHaveBeenCalledWith(
        'test:mykey',
        'value',
        'EX',
        7200,
        'NX'
      );
    });

    it('should get old value when requested', async () => {
      const oldValue = { old: 'data' };
      (mockRedis.get as any).mockResolvedValue(JSON.stringify(oldValue));

      const result = await cache.set('mykey', 'newvalue', { getOldValue: true });

      expect(mockRedis.get).toHaveBeenCalledWith('test:mykey');
      expect(result).toEqual(oldValue);
    });

    it('should throw error for conflicting options', async () => {
      await expect(
        cache.set('mykey', 'value', { onlyIfExists: true, onlyIfNotExists: true })
      ).rejects.toThrow('Cannot specify both onlyIfExists and onlyIfNotExists');
    });

    it('should handle string values directly', async () => {
      await cache.set('mykey', 'plain string');

      expect(mockRedis.setex).toHaveBeenCalledWith('test:mykey', 7200, 'plain string');
    });
  });

  describe('del', () => {
    beforeEach(() => {
      cache = new RedisCache({
        keyPrefix: 'test:',
        redisConfig: { host: 'localhost', port: 6379 },
        logger: mockLogger,
      });
    });

    it('should delete key with prefix', async () => {
      await cache.del('mykey');

      expect(mockRedis.del).toHaveBeenCalledWith('test:mykey');
      expect(mockLogger.debug).toHaveBeenCalledWith('Cache delete', { key: 'mykey' });
    });

    it('should handle delete errors gracefully', async () => {
      (mockRedis.del as any).mockRejectedValue(new Error('Delete failed'));

      await cache.del('errorkey');

      expect(mockLogger.error).toHaveBeenCalledWith(
        'Redis del failed',
        expect.objectContaining({ error: 'Delete failed' })
      );
    });
  });

  describe('exists', () => {
    beforeEach(() => {
      cache = new RedisCache({
        keyPrefix: 'test:',
        redisConfig: { host: 'localhost', port: 6379 },
      });
    });

    it('should check key existence', async () => {
      (mockRedis.exists as any).mockResolvedValue(1);

      const result = await cache.exists('mykey');

      expect(mockRedis.exists).toHaveBeenCalledWith('test:mykey');
      expect(result).toBe(true);
    });

    it('should return false for non-existent key', async () => {
      (mockRedis.exists as any).mockResolvedValue(0);

      const result = await cache.exists('nonexistent');

      expect(result).toBe(false);
    });
  });

  describe('clear', () => {
    beforeEach(() => {
      cache = new RedisCache({
        keyPrefix: 'test:',
        redisConfig: { host: 'localhost', port: 6379 },
        logger: mockLogger,
      });
    });

    it('should clear all prefixed keys', async () => {
      const keys = ['test:key1', 'test:key2', 'test:key3'];
      (mockRedis.keys as any).mockResolvedValue(keys);

      await cache.clear();

      expect(mockRedis.keys).toHaveBeenCalledWith('test:*');
      expect(mockRedis.del).toHaveBeenCalledWith(...keys);
      expect(mockLogger.warn).toHaveBeenCalledWith('Cache cleared', { keysDeleted: 3 });
    });

    it('should handle empty cache', async () => {
      (mockRedis.keys as any).mockResolvedValue([]);

      await cache.clear();

      expect(mockRedis.del).not.toHaveBeenCalled();
    });
  });

  describe('getRaw', () => {
    beforeEach(() => {
      cache = new RedisCache({
        keyPrefix: 'test:',
        redisConfig: { host: 'localhost', port: 6379 },
        logger: mockLogger,
      });
    });

    it('should get value without prefix', async () => {
      const value = { raw: 'data' };
      (mockRedis.get as any).mockResolvedValue(JSON.stringify(value));

      const result = await cache.getRaw('raw:key');

      expect(mockRedis.get).toHaveBeenCalledWith('raw:key');
      expect(result).toEqual(value);
    });

    it('should handle parse errors', async () => {
      (mockRedis.get as any).mockResolvedValue('invalid json');

      const result = await cache.getRaw('badkey');

      expect(result).toBe('invalid json');
      expect(mockLogger.warn).toHaveBeenCalledWith(
        'Cache getRaw JSON parse failed',
        expect.objectContaining({
          key: 'badkey',
          valueLength: 12,
        })
      );
    });
  });

  describe('keys', () => {
    beforeEach(() => {
      cache = new RedisCache({
        keyPrefix: 'test:',
        redisConfig: { host: 'localhost', port: 6379 },
      });
    });

    it('should get keys with pattern and strip prefix', async () => {
      (mockRedis.keys as any).mockResolvedValue([
        'test:user:1',
        'test:user:2',
        'test:user:3',
      ]);

      const keys = await cache.keys('user:*');

      expect(mockRedis.keys).toHaveBeenCalledWith('test:user:*');
      expect(keys).toEqual(['user:1', 'user:2', 'user:3']);
    });
  });

  describe('health', () => {
    beforeEach(() => {
      cache = new RedisCache({
        redisConfig: { host: 'localhost', port: 6379 },
        logger: mockLogger,
      });
    });

    it('should return true when healthy', async () => {
      const result = await cache.health();

      expect(mockRedis.ping).toHaveBeenCalled();
      expect(result).toBe(true);
    });

    it('should return false on ping failure', async () => {
      (mockRedis.ping as any).mockRejectedValue(new Error('Ping failed'));

      const result = await cache.health();

      expect(result).toBe(false);
      expect(mockLogger.error).toHaveBeenCalledWith(
        'Redis health check failed',
        expect.objectContaining({ error: 'Ping failed' })
      );
    });
  });

  describe('stats', () => {
    beforeEach(() => {
      cache = new RedisCache({
        redisConfig: { host: 'localhost', port: 6379 },
        enableMetrics: true,
      });
    });

    it('should track cache hits', async () => {
      (mockRedis.get as any).mockResolvedValue('value');

      await cache.get('key1');
      await cache.get('key2');

      const stats = cache.getStats();
      expect(stats.hits).toBe(2);
      expect(stats.total).toBe(2);
      expect(stats.hitRate).toBe(1.0);
    });

    it('should track cache misses', async () => {
      (mockRedis.get as any).mockResolvedValue(null);

      await cache.get('key1');
      await cache.get('key2');

      const stats = cache.getStats();
      expect(stats.misses).toBe(2);
      expect(stats.total).toBe(2);
      expect(stats.hitRate).toBe(0);
    });

    it('should track errors', async () => {
      mockRedis.status = 'connecting';

      await cache.get('key1');

      const stats = cache.getStats();
      expect(stats.errors).toBe(1);
    });

    it('should not track stats when disabled', async () => {
      cache = new RedisCache({
        redisConfig: { host: 'localhost', port: 6379 },
        enableMetrics: false,
      });

      (mockRedis.get as any).mockResolvedValue('value');
      await cache.get('key');

      const stats = cache.getStats();
      expect(stats.hits).toBe(0);
    });
  });

  describe('waitForReady', () => {
    beforeEach(() => {
      cache = new RedisCache({
        redisConfig: { host: 'localhost', port: 6379 },
      });
    });

    it('should resolve immediately if ready', async () => {
      mockRedis.status = 'ready';

      await expect(cache.waitForReady(1000)).resolves.toBeUndefined();
    });

    it('should wait for ready event', async () => {
      mockRedis.status = 'connecting';
      mockRedis.once = mock((event: string, handler: Function) => {
        if (event === 'ready') {
          setTimeout(() => handler(), 10);
        }
      });

      await expect(cache.waitForReady(1000)).resolves.toBeUndefined();
    });

    it('should timeout if not ready', async () => {
      mockRedis.status = 'connecting';
      mockRedis.once = mock(() => {}); // Don't trigger any events

      await expect(cache.waitForReady(100)).rejects.toThrow(
        'Redis connection timeout after 100ms'
      );
    });

    it('should reject on error', async () => {
      mockRedis.status = 'connecting';
      mockRedis.once = mock((event: string, handler: Function) => {
        if (event === 'error') {
          setTimeout(() => handler(new Error('Connection failed')), 10);
        }
      });

      await expect(cache.waitForReady(1000)).rejects.toThrow('Connection failed');
    });
  });

  describe('isReady', () => {
    beforeEach(() => {
      cache = new RedisCache({
        redisConfig: { host: 'localhost', port: 6379 },
      });
    });

    it('should return true when ready', () => {
      mockRedis.status = 'ready';
      expect(cache.isReady()).toBe(true);
    });

    it('should return false when not ready', () => {
      mockRedis.status = 'connecting';
      expect(cache.isReady()).toBe(false);
    });
  });

  describe('convenience methods', () => {
    beforeEach(() => {
      cache = new RedisCache({
        keyPrefix: 'test:',
        redisConfig: { host: 'localhost', port: 6379 },
      });
    });

    it('should update value preserving TTL', async () => {
      (mockRedis.ttl as any).mockResolvedValue(3600);
      (mockRedis.get as any).mockResolvedValue(JSON.stringify({ old: 'value' }));

      const result = await cache.update('key', { new: 'value' });

      expect(mockRedis.setex).toHaveBeenCalledWith(
        'test:key',
        3600,
        JSON.stringify({ new: 'value' })
      );
      expect(result).toEqual({ old: 'value' });
    });

    it('should setIfExists', async () => {
      (mockRedis.set as any).mockResolvedValue('OK');
      (mockRedis.exists as any).mockResolvedValue(1);

      const result = await cache.setIfExists('key', 'value', 1800);

      expect(mockRedis.set).toHaveBeenCalledWith('test:key', 'value', 'EX', 1800, 'XX');
      expect(result).toBe(true);
    });

    it('should setIfNotExists', async () => {
      (mockRedis.set as any).mockResolvedValue('OK');

      const result = await cache.setIfNotExists('key', 'value', 1800);

      expect(mockRedis.set).toHaveBeenCalledWith('test:key', 'value', 'EX', 1800, 'NX');
      expect(result).toBe(true);
    });

    it('should replace existing value', async () => {
      (mockRedis.get as any).mockResolvedValue(JSON.stringify({ old: 'data' }));
      (mockRedis.set as any).mockResolvedValue('OK');

      const result = await cache.replace('key', { new: 'data' }, 3600);

      expect(result).toEqual({ old: 'data' });
    });

    it('should update field atomically', async () => {
      (mockRedis.eval as any).mockResolvedValue(['{"count": 5}', 3600]);

      const updater = (current: any) => ({
        ...current,
        count: (current?.count || 0) + 1,
      });

      const result = await cache.updateField('key', updater);

      expect(mockRedis.eval).toHaveBeenCalled();
      expect(result).toEqual({ count: 5 });
    });

    it('should handle updateField with new key', async () => {
      (mockRedis.eval as any).mockResolvedValue([null, -2]);

      const updater = (_current: unknown) => ({ value: 'new' });

      await cache.updateField('key', updater);

      expect(mockRedis.setex).toHaveBeenCalled();
    });
  });

  describe('event handlers', () => {
    it('should handle connection events for non-shared cache', () => {
      // Create non-shared cache
      mockRedis.on = mock((event: string, handler: Function) => {
        mockRedis._eventCallbacks[event] = handler;
      });

      cache = new RedisCache({
        shared: false,
        redisConfig: { host: 'localhost', port: 6379 },
        logger: mockLogger,
      });

      // Trigger events
      mockRedis._triggerEvent('connect');
      expect(mockLogger.info).toHaveBeenCalledWith('Redis cache connected');

      mockRedis._triggerEvent('ready');
      expect(mockLogger.info).toHaveBeenCalledWith('Redis cache ready');

      mockRedis._triggerEvent('error', new Error('Test error'));
      expect(mockLogger.error).toHaveBeenCalledWith(
        'Redis cache connection error',
        expect.objectContaining({ error: 'Test error' })
      );

      mockRedis._triggerEvent('close');
      expect(mockLogger.warn).toHaveBeenCalledWith('Redis cache connection closed');

      mockRedis._triggerEvent('reconnecting');
      expect(mockLogger.warn).toHaveBeenCalledWith('Redis cache reconnecting...');
    });
  });
});