import { describe, expect, it } from 'vitest';
import { clientIp, hashIp, ipHashesSince, ipKey, SlidingWindowLimiter, trustProxyHops, 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('trustProxyHops', () => {
  it('parses TRUST_PROXY like the game server', () => {
    expect(trustProxyHops(undefined)).toBe(0);
    expect(trustProxyHops('')).toBe(0);
    expect(trustProxyHops('0')).toBe(0);
    expect(trustProxyHops('false')).toBe(0);
    expect(trustProxyHops('true')).toBe(1);
    expect(trustProxyHops('1')).toBe(1);
    expect(trustProxyHops('2')).toBe(2);
    expect(trustProxyHops('nonsense')).toBe(1);
  });
});

describe('clientIp', () => {
  const h = (init: Record<string, string>) => new Headers(init);

  it('without a trusted proxy ignores X-Forwarded-For and fails closed', () => {
    expect(clientIp(h({ 'x-forwarded-for': '203.0.113.9' }), 0)).toBe('unknown');
    expect(clientIp(h({}), 0)).toBe('unknown');
    // Platform headers (Vercel overwrites both; nginx sets X-Real-IP) are used when present.
    expect(clientIp(h({ 'x-forwarded-for': '203.0.113.9', 'x-real-ip': '198.51.100.4' }), 0)).toBe('198.51.100.4');
    expect(clientIp(h({ 'x-vercel-forwarded-for': '198.51.100.5', 'x-real-ip': '198.51.100.4' }), 0)).toBe('198.51.100.5');
  });

  it('behind N trusted proxies takes the N-th X-Forwarded-For entry from the right', () => {
    // The client sent "1.1.1.1"; the one trusted proxy appended the address it really saw.
    const headers = h({ 'x-forwarded-for': '1.1.1.1, 203.0.113.9' });
    expect(clientIp(headers, 1)).toBe('203.0.113.9');
    expect(clientIp(h({ 'x-forwarded-for': '1.1.1.1, 203.0.113.9, 10.0.0.2' }), 2)).toBe('203.0.113.9');
    // Rotating the spoofed left-most value changes nothing.
    expect(clientIp(h({ 'x-forwarded-for': '9.9.9.9, 203.0.113.9' }), 1)).toBe('203.0.113.9');
    // Fewer entries than hops: fall back to the platform header, then 'unknown'.
    expect(clientIp(h({ 'x-forwarded-for': '203.0.113.9' }), 2)).toBe('unknown');
    expect(clientIp(h({ 'x-forwarded-for': '203.0.113.9', 'x-real-ip': '198.51.100.4' }), 2)).toBe('198.51.100.4');
  });

  it('never keys on junk', () => {
    expect(clientIp(h({ 'x-real-ip': 'not-an-ip' }), 0)).toBe('unknown');
    expect(clientIp(h({ 'x-forwarded-for': '203.0.113.9, <script>' }), 1)).toBe('unknown');
  });
});

describe('ipKey', () => {
  it('keeps IPv4 and strips ports', () => {
    expect(ipKey('203.0.113.7')).toBe('203.0.113.7');
    expect(ipKey('203.0.113.7:51234')).toBe('203.0.113.7');
  });

  it('groups IPv6 by /64 so one subscriber cannot rotate addresses', () => {
    expect(ipKey('2001:db8:1:2:aaaa:bbbb:cccc:dddd')).toBe('2001:db8:1:2::/64');
    expect(ipKey('2001:db8:1:2::1')).toBe('2001:db8:1:2::/64');
    expect(ipKey('2001:DB8:1:2:ffff::9')).toBe('2001:db8:1:2::/64');
    expect(ipKey('2001:db8::1')).toBe('2001:db8:0:0::/64');
    expect(ipKey('[2001:db8:1:2::1]:443')).toBe('2001:db8:1:2::/64');
    expect(ipKey('fe80::1%eth0')).toBe('fe80:0:0:0::/64');
    expect(ipKey('::1')).toBe('0:0:0:0::/64');
  });

  it('maps IPv4-mapped IPv6 back to IPv4', () => {
    expect(ipKey('::ffff:203.0.113.7')).toBe('203.0.113.7');
    expect(ipKey('::ffff:cb00:7107')).toBe('203.0.113.7');
  });

  it('turns anything else into unknown', () => {
    for (const junk of ['', 'unknown', 'localhost', '999.1.1.1', '2001:db8::1::2', '1.2.3']) expect(ipKey(junk)).toBe('unknown');
  });
});

describe('SlidingWindowLimiter.count', () => {
  it('reports hits in the window without counting', () => {
    const limiter = new SlidingWindowLimiter(5, 10_000);
    limiter.hit('a', 0);
    limiter.hit('a', 5_000);
    expect(limiter.count('a', 6_000)).toBe(2);
    expect(limiter.count('a', 6_000)).toBe(2);
    expect(limiter.count('a', 10_001)).toBe(1);
    expect(limiter.count('nobody', 0)).toBe(0);
  });
});
