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 { MemoryRateLimiter, RedisRateLimiter } from './rateLimiter.js';

describe('MemoryRateLimiter', () => {
  it('allows up to max within the window, then blocks with retryAfter', async () => {
    const limiter = new MemoryRateLimiter();
    for (let i = 0; i < 3; i++) {
      expect((await limiter.consume('k', 3, 60_000)).allowed).toBe(true);
    }
    const blocked = await limiter.consume('k', 3, 60_000);
    expect(blocked.allowed).toBe(false);
    expect(blocked.retryAfter).toBeGreaterThan(0);
    expect(blocked.retryAfter).toBeLessThanOrEqual(60);
  });

  it('resets after the window elapses', async () => {
    vi.useFakeTimers();
    try {
      const limiter = new MemoryRateLimiter();
      expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
      expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(false);
      vi.advanceTimersByTime(1_500);
      expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true);
    } finally {
      vi.useRealTimers();
    }
  });
});

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

  it('sets the window TTL atomically on the first hit', async () => {
    const limiter = new RedisRateLimiter();
    expect((await limiter.consume('k', 5, 60_000)).allowed).toBe(true);
    const ttl = await mocks.redis.pttl('rl:k');
    expect(ttl).toBeGreaterThan(0);
    expect(ttl).toBeLessThanOrEqual(60_000);
  });

  it('blocks over the limit with a sane retryAfter', async () => {
    const limiter = new RedisRateLimiter();
    for (let i = 0; i < 2; i++) {
      expect((await limiter.consume('k', 2, 60_000)).allowed).toBe(true);
    }
    const blocked = await limiter.consume('k', 2, 60_000);
    expect(blocked.allowed).toBe(false);
    expect(blocked.retryAfter).toBeGreaterThan(0);
    expect(blocked.retryAfter).toBeLessThanOrEqual(60);
  });

  it('self-heals a counter stranded without a TTL', async () => {
    await mocks.redis.set('rl:k', '3');
    expect(await mocks.redis.pttl('rl:k')).toBeLessThan(0);
    const limiter = new RedisRateLimiter();
    await limiter.consume('k', 10, 60_000);
    expect(await mocks.redis.pttl('rl:k')).toBeGreaterThan(0);
  });

  it('fails open when the backend errors', async () => {
    mocks.redis = { rlConsume: () => Promise.reject(new Error('connection refused')) };
    const limiter = new RedisRateLimiter();
    expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
  });

  it('fails open when the client is not initialized', async () => {
    mocks.redis = null;
    const limiter = new RedisRateLimiter();
    expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true);
  });
});
