import { describe, expect, it } from 'vitest';
import { clientIp, hashIp, ipHashesSince, SlidingWindowLimiter, utcDay } from './rate-limit';

describe('SlidingWindowLimiter', () => {
  it('allows up to the limit inside the window, then blocks', () => {
    const limiter = new SlidingWindowLimiter(3, 60_000);
    expect(limiter.hit('a', 0)).toEqual({ allowed: true, remaining: 2, retryAfterMs: 0 });
    expect(limiter.hit('a', 1_000).allowed).toBe(true);
    expect(limiter.hit('a', 2_000).allowed).toBe(true);
    const blocked = limiter.hit('a', 3_000);
    expect(blocked.allowed).toBe(false);
    expect(blocked.retryAfterMs).toBe(57_000);
    // Other keys are independent.
    expect(limiter.hit('b', 3_000).allowed).toBe(true);
  });

  it('slides: old hits expire and free capacity', () => {
    const limiter = new SlidingWindowLimiter(2, 10_000);
    limiter.hit('a', 0);
    limiter.hit('a', 5_000);
    expect(limiter.hit('a', 9_999).allowed).toBe(false);
    expect(limiter.hit('a', 10_001).allowed).toBe(true);
  });

  it('blocked attempts do not extend the ban', () => {
    const limiter = new SlidingWindowLimiter(1, 10_000);
    limiter.hit('a', 0);
    for (let t = 1_000; t < 10_000; t += 1_000) expect(limiter.hit('a', t).allowed).toBe(false);
    expect(limiter.hit('a', 10_001).allowed).toBe(true);
  });

  it('stays bounded under a flood of distinct keys', () => {
    const limiter = new SlidingWindowLimiter(5, 60_000, 100);
    for (let i = 0; i < 1_000; i += 1) limiter.hit(`key-${i}`, i);
    expect(limiter.size).toBe(100);
  });
});

describe('IP hashing', () => {
  it('is deterministic per salt and day, never exposes the IP', () => {
    const a = hashIp('203.0.113.7', 'salt', '2026-10-29');
    expect(a).toMatch(/^[0-9a-f]{64}$/);
    expect(a).not.toContain('203.0.113.7');
    expect(hashIp('203.0.113.7', 'salt', '2026-10-29')).toBe(a);
    expect(hashIp('203.0.113.7', 'other-salt', '2026-10-29')).not.toBe(a);
    expect(hashIp('203.0.113.7', 'salt', '2026-10-30')).not.toBe(a);
  });

  it('covers both days when the window crosses UTC midnight', () => {
    const now = Date.UTC(2026, 9, 30, 0, 20);
    const since = now - 60 * 60 * 1000;
    expect(utcDay(since)).toBe('2026-10-29');
    expect(ipHashesSince('198.51.100.1', 's', since, now)).toEqual([
      hashIp('198.51.100.1', 's', '2026-10-29'),
      hashIp('198.51.100.1', 's', '2026-10-30'),
    ]);
    const midday = Date.UTC(2026, 9, 30, 12, 0);
    expect(ipHashesSince('198.51.100.1', 's', midday - 3_600_000, midday)).toHaveLength(1);
  });
});

describe('clientIp', () => {
  it('prefers the first X-Forwarded-For hop, then X-Real-IP', () => {
    expect(clientIp(new Headers({ 'x-forwarded-for': '203.0.113.9, 10.0.0.1' }))).toBe('203.0.113.9');
    expect(clientIp(new Headers({ 'x-real-ip': '198.51.100.4' }))).toBe('198.51.100.4');
    expect(clientIp(new Headers())).toBe('unknown');
  });
});
