/**
 * Tests for Redis Client - Token Blacklist Security
 * 
 * Verifies fail-closed behavior when Redis is unavailable.
 */

import { describe, it, expect, beforeEach, vi, afterEach, beforeAll } from 'vitest';

// Hoist the mock so it applies before module imports
const mockRedisFns = vi.hoisted(() => ({
  exists: vi.fn(),
  setex: vi.fn(),
  on: vi.fn(),
  quit: vi.fn()
}));

// Mock ioredis before any imports
vi.mock('ioredis', async () => {
  return {
    default: class MockRedis {
      status = 'ready';
      on = mockRedisFns.on;
      exists = mockRedisFns.exists;
      setex = mockRedisFns.setex;
      quit = mockRedisFns.quit;
      constructor() {
        // Simulate async connection
        setTimeout(() => {
          this.on('connect', () => {});
        }, 0);
      }
    }
  };
});

// Mock logger
vi.mock('../src/shared/utils/logger', () => ({
  logger: {
    info: vi.fn(),
    error: vi.fn(),
    warn: vi.fn(),
    debug: vi.fn()
  }
}));

// Import after mocks are set up
let redisModule: typeof import('../src/shared/database/redis.client');

describe('Token Blacklist - Security (Fail-Closed)', () => {
  beforeAll(async () => {
    // Dynamic import after mocks are established
    redisModule = await import('../src/shared/database/redis.client');
  });

  beforeEach(() => {
    redisModule.resetBlacklistMetrics();
    vi.clearAllMocks();
    // Reset mock implementations
    mockRedisFns.exists.mockReset();
    mockRedisFns.setex.mockReset();
  });

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

  it('should return true when token is blacklisted in Redis', async () => {
    mockRedisFns.exists.mockResolvedValue(1);
    
    const result = await redisModule.isTokenBlacklisted('blacklisted-token');
    
    expect(result).toBe(true);
    expect(mockRedisFns.exists).toHaveBeenCalledWith('token_blacklist:blacklisted-token');
  });

  it('should return false when token is not blacklisted in Redis', async () => {
    mockRedisFns.exists.mockResolvedValue(0);
    
    const result = await redisModule.isTokenBlacklisted('valid-token');
    
    expect(result).toBe(false);
  });

  it('should FAIL-CLOSED when Redis throws error on exists check', async () => {
    mockRedisFns.exists.mockRejectedValue(new Error('Connection lost'));
    
    // Should throw AuthenticationError instead of returning false (fail-open)
    await expect(redisModule.isTokenBlacklisted('any-token'))
      .rejects
      .toThrow('Unable to verify token status. Service temporarily unavailable.');
    
    // Verify metrics tracked the failure
    const metrics = redisModule.getBlacklistMetrics();
    expect(metrics.redisBlacklistFailures).toBeGreaterThanOrEqual(1);
    expect(metrics.redisBlacklistConsecutiveFailures).toBeGreaterThanOrEqual(1);
  });

  it('should FAIL-CLOSED when Redis is unavailable (multiple consecutive failures)', async () => {
    mockRedisFns.exists.mockRejectedValue(new Error('Redis unavailable'));
    
    // Reset metrics to start fresh
    redisModule.resetBlacklistMetrics();
    
    // Simulate multiple failures to trigger circuit breaker
    for (let i = 0; i < 5; i++) {
      try {
        await redisModule.isTokenBlacklisted('token-' + i);
      } catch (e) {
        // Expected to fail
      }
    }
    
    // After 5 failures, circuit breaker should have opened
    const metrics = redisModule.getBlacklistMetrics();
    expect(metrics.redisBlacklistConsecutiveFailures).toBeGreaterThanOrEqual(1);
  });

  it('should retry Redis operation with exponential backoff before failing', async () => {
    // First two calls fail, third succeeds
    mockRedisFns.exists
      .mockRejectedValueOnce(new Error('Temporary error'))
      .mockRejectedValueOnce(new Error('Temporary error'))
      .mockResolvedValueOnce(0);
    
    const result = await redisModule.isTokenBlacklisted('test-token');
    
    // Should succeed on third attempt after retries
    expect(result).toBe(false);
    expect(mockRedisFns.exists).toHaveBeenCalledTimes(3);
  });

  it('should track success metrics when Redis check succeeds', async () => {
    mockRedisFns.exists.mockResolvedValue(0);
    
    redisModule.resetBlacklistMetrics();
    
    await redisModule.isTokenBlacklisted('test-token');
    
    const metrics = redisModule.getBlacklistMetrics();
    expect(metrics.redisBlacklistSuccesses).toBeGreaterThanOrEqual(1);
  });
});

describe('Blacklist Token Operations', () => {
  beforeAll(async () => {
    if (!redisModule) {
      redisModule = await import('../src/shared/database/redis.client');
    }
  });

  beforeEach(() => {
    redisModule.resetBlacklistMetrics();
    vi.clearAllMocks();
    mockRedisFns.setex.mockReset();
  });

  it('should blacklist token successfully in Redis', async () => {
    mockRedisFns.setex.mockResolvedValue('OK');
    
    await redisModule.blacklistToken('token-to-blacklist');
    
    expect(mockRedisFns.setex).toHaveBeenCalledWith(
      'token_blacklist:token-to-blacklist',
      604800, // 7 days in seconds
      '1'
    );
    
    const metrics = redisModule.getBlacklistMetrics();
    expect(metrics.redisBlacklistSuccesses).toBeGreaterThanOrEqual(1);
  });

  it('should store token in memory cache when Redis fails', async () => {
    mockRedisFns.setex.mockRejectedValue(new Error('Redis down'));
    
    // Should not throw - graceful degradation to memory cache
    await expect(redisModule.blacklistToken('token-to-blacklist')).resolves.not.toThrow();
    
    const metrics = redisModule.getBlacklistMetrics();
    expect(metrics.redisBlacklistFailures).toBeGreaterThanOrEqual(1);
  });
});

describe('Circuit Breaker Behavior', () => {
  beforeAll(async () => {
    if (!redisModule) {
      redisModule = await import('../src/shared/database/redis.client');
    }
  });

  beforeEach(() => {
    redisModule.resetBlacklistMetrics();
    vi.clearAllMocks();
    mockRedisFns.exists.mockReset();
  });

  it('should open circuit breaker after 5 consecutive failures', async () => {
    mockRedisFns.exists.mockRejectedValue(new Error('Redis unavailable'));
    
    // Attempt 5 times
    for (let i = 0; i < 5; i++) {
      try {
        await redisModule.isTokenBlacklisted('test-token');
      } catch (e) {
        // Expected
      }
    }
    
    const metrics = redisModule.getBlacklistMetrics();
    expect(metrics.circuitBreakerOpens).toBeGreaterThanOrEqual(1);
  });

  it('should reset consecutive failures counter on successful operation', async () => {
    mockRedisFns.exists
      .mockRejectedValueOnce(new Error('Error 1'))
      .mockRejectedValueOnce(new Error('Error 2'))
      .mockResolvedValueOnce(0);
    
    // First two should fail
    try { await redisModule.isTokenBlacklisted('test-token'); } catch (e) {}
    try { await redisModule.isTokenBlacklisted('test-token'); } catch (e) {}
    
    // Third should succeed after retries and reset counter
    const result = await redisModule.isTokenBlacklisted('test-token');
    expect(result).toBe(false);
    
    const metrics = redisModule.getBlacklistMetrics();
    // After success, consecutive failures should be reset to 0
    expect(metrics.redisBlacklistConsecutiveFailures).toBe(0);
  });
});

describe('Security - Preventing Authentication Bypass', () => {
  beforeAll(async () => {
    if (!redisModule) {
      redisModule = await import('../src/shared/database/redis.client');
    }
  });

  beforeEach(() => {
    redisModule.resetBlacklistMetrics();
    vi.clearAllMocks();
    mockRedisFns.exists.mockReset();
  });

  it('should never return false (allow access) when Redis is unavailable', async () => {
    mockRedisFns.exists.mockRejectedValue(new Error('Redis connection lost'));
    
    // Even with a normally valid token, Redis failure should cause rejection
    try {
      await redisModule.isTokenBlacklisted('potentially-valid-token');
      // If we reach here, the test fails - we should have thrown
      expect(false).toBe(true);
    } catch (error) {
      expect((error as Error).message).toContain('Unable to verify token status');
    }
  });

  it('should log security events with proper token prefix (never full token)', async () => {
    const { logger } = await import('../src/shared/utils/logger');
    
    mockRedisFns.exists.mockRejectedValue(new Error('Redis down'));
    
    const longToken = 'this-is-a-very-long-secret-token-that-should-not-be-logged';
    
    try {
      await redisModule.isTokenBlacklisted(longToken);
    } catch (e) {
      // Expected
    }
    
    // Check that logger.error was called
    expect(logger.error).toHaveBeenCalled();
    
    // Get all calls to logger.error
    const errorCalls = vi.mocked(logger.error).mock.calls;
    expect(errorCalls.length).toBeGreaterThan(0);
    
    // Verify no full token in logs - check all arguments
    const allArgs = JSON.stringify(errorCalls);
    expect(allArgs).not.toContain(longToken);
  });
});

describe('Metrics and Observability', () => {
  beforeAll(async () => {
    if (!redisModule) {
      redisModule = await import('../src/shared/database/redis.client');
    }
  });

  beforeEach(() => {
    redisModule.resetBlacklistMetrics();
    vi.clearAllMocks();
    mockRedisFns.exists.mockReset();
    mockRedisFns.setex.mockReset();
  });

  it('should track all relevant metrics', async () => {
    mockRedisFns.exists.mockResolvedValue(0);
    mockRedisFns.setex.mockResolvedValue('OK');
    
    await redisModule.isTokenBlacklisted('test-1');
    await redisModule.isTokenBlacklisted('test-2');
    await redisModule.blacklistToken('token-1');
    
    const metrics = redisModule.getBlacklistMetrics();
    expect(metrics.redisBlacklistSuccesses).toBeGreaterThanOrEqual(3);
    expect(metrics.redisBlacklistFailures).toBe(0);
    expect(metrics.redisBlacklistConsecutiveFailures).toBe(0);
  });

  it('should reset metrics when resetBlacklistMetrics is called', async () => {
    // First, accumulate some metrics with a failure
    mockRedisFns.exists.mockRejectedValue(new Error('Error'));
    
    try { await redisModule.isTokenBlacklisted('test'); } catch (e) {}
    
    let metrics = redisModule.getBlacklistMetrics();
    expect(metrics.redisBlacklistFailures).toBeGreaterThanOrEqual(1);
    
    // Reset
    redisModule.resetBlacklistMetrics();
    
    metrics = redisModule.getBlacklistMetrics();
    expect(metrics.redisBlacklistFailures).toBe(0);
    expect(metrics.redisBlacklistSuccesses).toBe(0);
    expect(metrics.redisBlacklistConsecutiveFailures).toBe(0);
    expect(metrics.circuitBreakerOpens).toBe(0);
  });
});
