import { describe, it, expect, vi, beforeEach } from 'vitest';
import RedisMock from 'ioredis-mock';

const mocks = vi.hoisted(() => ({ redis: null as any }));

vi.mock('../redis.js', () => ({
  isRedisEnabled: () => true,
  getRedis: () => mocks.redis,
}));

import {
  MemoryLoginLockout,
  RedisLoginLockout,
  MAX_LOGIN_ATTEMPTS,
} from './loginLockout.js';

describe('MemoryLoginLockout', () => {
  it('locks after MAX_LOGIN_ATTEMPTS failures with retryAfter', async () => {
    const lockout = new MemoryLoginLockout();
    for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
      await lockout.recordFailure('user@example.com');
      expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
    }
    await lockout.recordFailure('user@example.com');
    const status = await lockout.isLocked('user@example.com');
    expect(status.locked).toBe(true);
    expect(status.retryAfter).toBeGreaterThan(0);
  });

  it('clear removes the lockout', async () => {
    const lockout = new MemoryLoginLockout();
    for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
      await lockout.recordFailure('user@example.com');
    }
    await lockout.clear('user@example.com');
    expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
  });

  it('treats emails case-insensitively', async () => {
    const lockout = new MemoryLoginLockout();
    for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
      await lockout.recordFailure('User@Example.com');
    }
    expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
  });

  it('expires after the lockout window', async () => {
    vi.useFakeTimers();
    try {
      const lockout = new MemoryLoginLockout();
      for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
        await lockout.recordFailure('user@example.com');
      }
      expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
      vi.advanceTimersByTime(16 * 60 * 1000);
      expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
    } finally {
      vi.useRealTimers();
    }
  });
});

describe('RedisLoginLockout', () => {
  beforeEach(async () => {
    mocks.redis = new RedisMock();
    // ioredis-mock shares data between instances by connection string.
    await mocks.redis.flushall();
  });

  it('locks after MAX_LOGIN_ATTEMPTS failures shared via redis', async () => {
    const lockout = new RedisLoginLockout();
    for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) {
      await lockout.recordFailure('user@example.com');
      expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
    }
    await lockout.recordFailure('user@example.com');
    const status = await lockout.isLocked('user@example.com');
    expect(status.locked).toBe(true);
    expect(status.retryAfter).toBeGreaterThan(0);
  });

  it('sets the lockout window TTL on the first failure', async () => {
    const lockout = new RedisLoginLockout();
    await lockout.recordFailure('user@example.com');
    const ttl = await mocks.redis.pttl('lockout:user@example.com');
    expect(ttl).toBeGreaterThan(0);
  });

  it('clear removes the lockout', async () => {
    const lockout = new RedisLoginLockout();
    for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
      await lockout.recordFailure('user@example.com');
    }
    await lockout.clear('user@example.com');
    expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
  });

  it('treats emails case-insensitively', async () => {
    const lockout = new RedisLoginLockout();
    for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) {
      await lockout.recordFailure('User@Example.com');
    }
    expect((await lockout.isLocked('user@example.com')).locked).toBe(true);
  });

  it('fails open when the client is not initialized', async () => {
    mocks.redis = null;
    const lockout = new RedisLoginLockout();
    await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
    expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
  });

  it('fails open when the backend errors', async () => {
    mocks.redis = {
      get: () => Promise.reject(new Error('connection refused')),
      pttl: () => Promise.reject(new Error('connection refused')),
      del: () => Promise.reject(new Error('connection refused')),
      lockoutRecordFailure: () => Promise.reject(new Error('connection refused')),
    };
    const lockout = new RedisLoginLockout();
    await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined();
    expect((await lockout.isLocked('user@example.com')).locked).toBe(false);
    await expect(lockout.clear('user@example.com')).resolves.toBeUndefined();
  });
});
