import { describe, it, expect } from 'vitest';
import { LoginRateLimiter, throttleScopeForIp } from './login-rate-limit.js';

describe('throttleScopeForIp', () => {
  it('keys IPv4 on the full address', () => {
    expect(throttleScopeForIp('203.0.113.7')).toBe('203.0.113.7');
  });
  it('passes through unknown', () => {
    expect(throttleScopeForIp('unknown')).toBe('unknown');
  });
  it('collapses a full IPv6 address to its /64 prefix', () => {
    expect(throttleScopeForIp('2001:db8:1:2:aaaa:bbbb:cccc:dddd')).toBe('2001:db8:1:2::/64');
  });
  it('maps different hosts in the same /64 to the same key', () => {
    const a = throttleScopeForIp('2001:db8:abcd:1234::1');
    const b = throttleScopeForIp('2001:db8:abcd:1234:ffff:ffff:ffff:ffff');
    expect(a).toBe(b);
    expect(a).toBe('2001:db8:abcd:1234::/64');
  });
  it('expands a :: run before taking the prefix', () => {
    expect(throttleScopeForIp('2001:db8::1')).toBe('2001:db8:0:0::/64');
  });
  it('strips an IPv6 zone id', () => {
    expect(throttleScopeForIp('fe80::1%eth0')).toBe('fe80:0:0:0::/64');
  });
  it('keys an IPv4-mapped IPv6 on the whole address (single host)', () => {
    expect(throttleScopeForIp('::ffff:203.0.113.7')).toBe('::ffff:203.0.113.7');
  });
});

function fixedClock(start = 1_000_000) {
  let t = start;
  return { now: () => t, advance: (ms: number) => { t += ms; } };
}

describe('LoginRateLimiter', () => {
  it('locks a key after maxAttempts failures within the window', () => {
    const clock = fixedClock();
    const rl = new LoginRateLimiter({ maxAttempts: 3, windowMs: 1000, lockoutMs: 5000, now: clock.now });
    expect(rl.isLocked('ip:1')).toBe(false);
    rl.recordFailure('ip:1');
    rl.recordFailure('ip:1');
    expect(rl.isLocked('ip:1')).toBe(false); // 2 < 3
    rl.recordFailure('ip:1');
    expect(rl.isLocked('ip:1')).toBe(true); // 3 >= 3 -> locked
  });

  it('clears the lockout after lockoutMs elapses', () => {
    const clock = fixedClock();
    const rl = new LoginRateLimiter({ maxAttempts: 2, windowMs: 1000, lockoutMs: 5000, now: clock.now });
    rl.recordFailure('ip:1');
    rl.recordFailure('ip:1');
    expect(rl.isLocked('ip:1')).toBe(true);
    clock.advance(5001);
    expect(rl.isLocked('ip:1')).toBe(false);
  });

  it('keeps the lockout when the window expires while still locked', () => {
    const clock = fixedClock();
    const rl = new LoginRateLimiter({ maxAttempts: 2, windowMs: 1000, lockoutMs: 5000, now: clock.now });
    rl.recordFailure('ip:1');
    rl.recordFailure('ip:1'); // locked until t+5000
    expect(rl.isLocked('ip:1')).toBe(true);
    clock.advance(1500); // window expired, lockout still active
    rl.recordFailure('ip:1'); // must not replace the entry and clear blockedUntil
    expect(rl.isLocked('ip:1')).toBe(true);
  });

  it('resets the window when failures are spread beyond windowMs', () => {
    const clock = fixedClock();
    const rl = new LoginRateLimiter({ maxAttempts: 2, windowMs: 1000, lockoutMs: 5000, now: clock.now });
    rl.recordFailure('ip:1');
    clock.advance(1001); // window expired -> next failure starts fresh
    rl.recordFailure('ip:1');
    expect(rl.isLocked('ip:1')).toBe(false);
  });

  it('reset() clears a key (called on successful login)', () => {
    const clock = fixedClock();
    const rl = new LoginRateLimiter({ maxAttempts: 2, windowMs: 1000, lockoutMs: 5000, now: clock.now });
    rl.recordFailure('ip:1');
    rl.recordFailure('ip:1');
    expect(rl.isLocked('ip:1')).toBe(true);
    rl.reset('ip:1');
    expect(rl.isLocked('ip:1')).toBe(false);
  });

  it('isLocked is true if ANY supplied key is locked', () => {
    const clock = fixedClock();
    const rl = new LoginRateLimiter({ maxAttempts: 1, windowMs: 1000, lockoutMs: 5000, now: clock.now });
    rl.recordFailure('ip:8');
    expect(rl.isLocked('ip:9', 'ip:8')).toBe(true);
    expect(rl.isLocked('ip:9')).toBe(false);
  });

  it('bounds memory: never exceeds maxKeys even under arbitrary distinct keys', () => {
    const clock = fixedClock();
    const rl = new LoginRateLimiter({ maxAttempts: 5, windowMs: 60_000, lockoutMs: 60_000, maxKeys: 50, now: clock.now });
    for (let i = 0; i < 500; i++) rl.recordFailure(`ip:${i}`);
    // @ts-expect-error — reach into the private map for the invariant check
    expect(rl.byKey.size).toBeLessThanOrEqual(50);
  });

  it('eviction prefers expired entries, then oldest', () => {
    const clock = fixedClock();
    const rl = new LoginRateLimiter({ maxAttempts: 5, windowMs: 1000, lockoutMs: 1000, maxKeys: 3, now: clock.now });
    rl.recordFailure('ip:old'); // will expire
    clock.advance(2000);
    rl.recordFailure('ip:a');
    rl.recordFailure('ip:b');
    rl.recordFailure('ip:c'); // size 3 (old expired but still present until swept)
    // @ts-expect-error private
    expect(rl.byKey.has('ip:old')).toBe(false); // swept on the bound check
  });
});
