import { describe, it, expect, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { buildRateLimitMiddleware, createRateLimiter, MAX_TRACKED_KEYS, RATE_LIMIT_WINDOW_MS } from './rate-limiter.js';

function fakeRepo(): { incrementGatewayKeyUsage: ReturnType<typeof vi.fn> } {
  return { incrementGatewayKeyUsage: vi.fn() };
}

describe('createRateLimiter sliding window', () => {
  it('admits up to rpm limit, rejects the next', () => {
    const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
    const t0 = 1_000_000;
    for (let i = 0; i < 5; i++) {
      const r = limiter.tryAdmit('k1', 5, t0 + i);
      expect(r.admitted).toBe(true);
    }
    const denied = limiter.tryAdmit('k1', 5, t0 + 5);
    expect(denied.admitted).toBe(false);
    expect(denied.remaining).toBe(0);
    expect(denied.resetAtMs).toBe(t0 + RATE_LIMIT_WINDOW_MS);
    limiter.stop();
  });

  it('admits again once the oldest timestamp ages out of the window', () => {
    const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
    const t0 = 2_000_000;
    for (let i = 0; i < 3; i++) {
      expect(limiter.tryAdmit('k', 3, t0 + i).admitted).toBe(true);
    }
    // Still inside the window — rejected.
    expect(limiter.tryAdmit('k', 3, t0 + 100).admitted).toBe(false);
    // Jump past the oldest's TTL.
    const after = limiter.tryAdmit('k', 3, t0 + RATE_LIMIT_WINDOW_MS + 1);
    expect(after.admitted).toBe(true);
    limiter.stop();
  });

  it('null rpm = unlimited (always admit, no per-key window tracked)', () => {
    const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
    for (let i = 0; i < 1_000; i++) {
      expect(limiter.tryAdmit('k', null, 3_000_000 + i).admitted).toBe(true);
    }
    // Unlimited path skips the windows Map entirely.
    expect(limiter.trackedKeyCount()).toBe(0);
    limiter.stop();
  });

  it('per-key isolation: one team hitting the cap does not affect another', () => {
    const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
    const t0 = 4_000_000;
    for (let i = 0; i < 2; i++) expect(limiter.tryAdmit('alpha', 2, t0 + i).admitted).toBe(true);
    expect(limiter.tryAdmit('alpha', 2, t0 + 3).admitted).toBe(false);
    // beta still has full quota.
    expect(limiter.tryAdmit('beta', 2, t0 + 3).admitted).toBe(true);
    limiter.stop();
  });

  it('tryAdmit alone does NOT bump the pending counter (F2: admitted-but-failed inflation guard)', async () => {
    const repo = fakeRepo();
    const limiter = createRateLimiter({ repo, period: () => '2026-05' });
    // Admit a bunch without recording success — simulates upstream 502 /
    // client disconnect after the limiter admitted them.
    for (let i = 0; i < 5; i++) limiter.tryAdmit('alpha', 10, 5_000_000 + i);
    await limiter.flushToDb();
    // No recordSuccess was called — flush MUST be a no-op.
    expect(repo.incrementGatewayKeyUsage).not.toHaveBeenCalled();
    limiter.stop();
  });

  it('recordSuccess after tryAdmit bumps the pending counter (single key)', async () => {
    const repo = fakeRepo();
    const limiter = createRateLimiter({ repo, period: () => '2026-05' });
    limiter.tryAdmit('alpha', 10, 5_000_000);
    limiter.recordSuccess('alpha');
    await limiter.flushToDb();
    expect(repo.incrementGatewayKeyUsage).toHaveBeenCalledTimes(1);
    expect(repo.incrementGatewayKeyUsage).toHaveBeenCalledWith({
      keyId: 'alpha',
      period: '2026-05',
      requests: 1,
    });
    limiter.stop();
  });

  it('partial success: 3 admitted, only 2 succeed → DB count == 2', async () => {
    const repo = fakeRepo();
    const limiter = createRateLimiter({ repo, period: () => '2026-05' });
    for (let i = 0; i < 3; i++) limiter.tryAdmit('alpha', 10, 5_000_000 + i);
    limiter.recordSuccess('alpha');
    limiter.recordSuccess('alpha');
    await limiter.flushToDb();
    expect(repo.incrementGatewayKeyUsage).toHaveBeenCalledWith({
      keyId: 'alpha',
      period: '2026-05',
      requests: 2,
    });
    limiter.stop();
  });

  it('flushToDb pushes pending counters as one UPSERT per key', async () => {
    const repo = fakeRepo();
    const limiter = createRateLimiter({ repo, period: () => '2026-05' });
    for (let i = 0; i < 3; i++) {
      limiter.tryAdmit('alpha', 10, 5_000_000 + i);
      limiter.recordSuccess('alpha');
    }
    for (let i = 0; i < 2; i++) {
      limiter.tryAdmit('beta', 10, 5_000_000 + i);
      limiter.recordSuccess('beta');
    }
    await limiter.flushToDb();
    expect(repo.incrementGatewayKeyUsage).toHaveBeenCalledTimes(2);
    expect(repo.incrementGatewayKeyUsage).toHaveBeenCalledWith({
      keyId: 'alpha',
      period: '2026-05',
      requests: 3,
    });
    expect(repo.incrementGatewayKeyUsage).toHaveBeenCalledWith({
      keyId: 'beta',
      period: '2026-05',
      requests: 2,
    });
    limiter.stop();
  });

  it('flushToDb resets pending counter so a second flush is a no-op', async () => {
    const repo = fakeRepo();
    const limiter = createRateLimiter({ repo, period: () => '2026-05' });
    limiter.tryAdmit('alpha', 10, 6_000_000);
    limiter.recordSuccess('alpha');
    await limiter.flushToDb();
    expect(repo.incrementGatewayKeyUsage).toHaveBeenCalledTimes(1);
    await limiter.flushToDb();
    expect(repo.incrementGatewayKeyUsage).toHaveBeenCalledTimes(1);
    limiter.stop();
  });

  it('flushToDb swallows DB errors (best-effort)', async () => {
    const repo = {
      incrementGatewayKeyUsage: vi.fn(() => {
        throw new Error('write locked');
      }),
    };
    const limiter = createRateLimiter({ repo, period: () => '2026-05' });
    limiter.tryAdmit('alpha', 10, 7_000_000);
    limiter.recordSuccess('alpha');
    await expect(limiter.flushToDb()).resolves.toBeUndefined();
    limiter.stop();
  });

  it('startFlushInterval is idempotent + stop clears the timer', async () => {
    const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
    limiter.startFlushInterval(50);
    limiter.startFlushInterval(50); // no-op
    limiter.stop();
    // No assertion here other than "doesn't throw / hang the test process"
  });

  // F11: when the in-memory windows Map hits MAX_TRACKED_KEYS, the old
  // behavior was to admit-without-enforcement for every new key — a
  // silent degradation that effectively disabled the rate limit for
  // any post-cap key. Phase 3a changes that to LRU eviction of the
  // oldest tracked key so new keys still get full enforcement.
  describe('MAX_TRACKED_KEYS LRU eviction (F11)', () => {
    it('evicts the oldest tracked key when a new key arrives at the cap', () => {
      const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
      // Fill to the cap. Each key gets a single timestamp.
      for (let i = 0; i < MAX_TRACKED_KEYS; i++) {
        limiter.tryAdmit(`k${i}`, 10, 1_000_000 + i);
      }
      expect(limiter.trackedKeyCount()).toBe(MAX_TRACKED_KEYS);
      // One more key past the cap → 'k0' (the oldest, first inserted)
      // must be evicted and the new key tracked.
      limiter.tryAdmit('k-overflow', 10, 2_000_000);
      expect(limiter.trackedKeyCount()).toBe(MAX_TRACKED_KEYS);
      // 'k0' was evicted: re-tracking it now starts a fresh window.
      const reEval = limiter.tryAdmit('k0', 10, 2_000_001);
      expect(reEval.admitted).toBe(true);
      expect(reEval.recentRequests).toBe(1); // fresh window after evict
      limiter.stop();
    });

    it('repeated admissions on an already-tracked key do not trigger eviction', () => {
      const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
      for (let i = 0; i < MAX_TRACKED_KEYS; i++) {
        limiter.tryAdmit(`k${i}`, 10, 1_000_000 + i);
      }
      const before = limiter.trackedKeyCount();
      // Hammer an existing key — must not evict any sibling because
      // size doesn't grow.
      for (let i = 0; i < 5; i++) {
        limiter.tryAdmit('k0', 10, 2_000_000 + i);
      }
      expect(limiter.trackedKeyCount()).toBe(before);
      limiter.stop();
    });

    it('still enforces the rpm cap on the new key post-eviction (was the bug pre-F11)', () => {
      const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
      for (let i = 0; i < MAX_TRACKED_KEYS; i++) {
        limiter.tryAdmit(`k${i}`, 10, 1_000_000 + i);
      }
      // Send rpmLimit+1 admissions to the new key; the last MUST be
      // refused. Pre-F11 every request was admitted because the limiter
      // never created the window.
      const rpm = 3;
      let lastResult;
      for (let i = 0; i < rpm + 1; i++) {
        lastResult = limiter.tryAdmit('k-new', rpm, 2_000_000 + i);
      }
      expect(lastResult?.admitted).toBe(false);
      limiter.stop();
    });

    // Phase 3a follow-up — make eviction LRU-not-FIFO.
    // Pre-fix: the eviction pivot was `windows.keys().next().value`,
    // which is FIFO by INSERTION order (not last-admit). An attacker
    // could spray throwaway keys to wipe a hot key's window — counter
    // resets to 0 and rate limit is bypassed for the next minute.
    // Post-fix: tryAdmit on an existing key re-bumps it to the tail so
    // a hot key is never the eviction target.
    it('hot key admitted recently is re-bumped to the tail (true LRU)', () => {
      const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
      // Insert three keys k1, k2, k3 in order; k1 is the oldest by
      // insertion. Stay inside one RATE_LIMIT_WINDOW so timestamps
      // don't get swept out.
      limiter.tryAdmit('k1', 10, 1);
      limiter.tryAdmit('k2', 10, 2);
      limiter.tryAdmit('k3', 10, 3);
      // Re-admit k1 — pre-fix this is a noop on iteration order; post-fix
      // k1 moves to the tail and k2 becomes the new oldest.
      limiter.tryAdmit('k1', 10, 4);
      // Fill to MAX_TRACKED_KEYS with fresh throwaway keys (still
      // inside the window so we can observe their state).
      for (let i = 0; i < MAX_TRACKED_KEYS - 3; i++) {
        limiter.tryAdmit(`fresh-${i}`, 10, 10 + i);
      }
      expect(limiter.trackedKeyCount()).toBe(MAX_TRACKED_KEYS);
      // One more new key past the cap → eviction fires. The victim
      // should be the least-recently-admitted key. Pre-fix that was
      // k1 (FIFO by insertion). Post-fix it is k2 (LRU by last admit).
      limiter.tryAdmit('overflow', 10, 50_000);
      // k1 (hot key) must still be tracked with its accumulated history.
      // Re-admitting at t=51_000 (still inside the window) should
      // observe the 2 prior admits (at t=1 and t=4) PLUS this new one.
      const k1Reuse = limiter.tryAdmit('k1', 10, 51_000);
      expect(k1Reuse.recentRequests).toBe(3);
      // k2 must have been evicted (re-admitting starts a fresh window).
      const k2Reuse = limiter.tryAdmit('k2', 10, 51_001);
      expect(k2Reuse.recentRequests).toBe(1);
      limiter.stop();
    });

    it('churn of throwaway keys cannot wipe a hot key (rate-limit bypass guard)', () => {
      // Concrete attack scenario: a hot key (alpha) admits a single
      // request, then the attacker sprays MAX_TRACKED_KEYS+1 throwaway
      // auths. Pre-fix: alpha was inserted first → first in
      // windows.keys() → first evicted → counter erased → next alpha
      // admit appears fresh and bypasses any per-window counter logic.
      // Post-fix: the existence check + re-bump path keeps alpha at
      // the tail relative to subsequent throwaway insertions.
      const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
      limiter.tryAdmit('alpha', 10, 1_000);
      // Spray exactly MAX_TRACKED_KEYS new keys → cap reached, oldest
      // gets evicted on the next insertion.
      for (let i = 0; i < MAX_TRACKED_KEYS; i++) {
        limiter.tryAdmit(`throwaway-${i}`, 10, 2_000 + i);
      }
      // After the spray, alpha may or may not still be in the map
      // depending on the exact eviction order — what matters is that
      // re-admitting alpha re-bumps it to the tail (post-fix) so the
      // very NEXT throwaway evicts a throwaway, not alpha.
      limiter.tryAdmit('alpha', 10, 50_000);
      // One more throwaway → eviction. Pre-fix evicts alpha (was the
      // single oldest). Post-fix evicts the oldest throwaway.
      limiter.tryAdmit('final-throwaway', 10, 50_001);
      // Hammer alpha to its rpm cap and confirm the cap is honored
      // (i.e. its window survived the churn).
      for (let i = 0; i < 8; i++) {
        limiter.tryAdmit('alpha', 10, 50_010 + i);
      }
      // alpha now has: t=50_000, plus 8 more admits inside the window
      // = 9 requests, room for one more (cap=10).
      const oneMore = limiter.tryAdmit('alpha', 10, 50_020);
      expect(oneMore.admitted).toBe(true);
      // The 11th admit must be DENIED — proves the window wasn't wiped.
      const denied = limiter.tryAdmit('alpha', 10, 50_021);
      expect(denied.admitted).toBe(false);
      limiter.stop();
    });
  });

  it('stop clears in-memory state', () => {
    const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
    limiter.tryAdmit('alpha', 10);
    expect(limiter.trackedKeyCount()).toBe(1);
    limiter.stop();
    expect(limiter.trackedKeyCount()).toBe(0);
  });
});

describe('buildRateLimitMiddleware (express)', () => {
  function buildApp(limiter: ReturnType<typeof createRateLimiter>, getRateLimit: (id: string) => number | null, auth: unknown): express.Express {
    const app = express();
    app.use((req, _res, next) => {
      (req as unknown as { gatewayAuth: unknown }).gatewayAuth = auth;
      next();
    });
    app.use(buildRateLimitMiddleware({ limiter, getRateLimit }));
    app.get('/ok', (_req, res) => res.json({ ok: true }));
    return app;
  }

  it('admits when under cap and rejects with 429 + Retry-After when over', async () => {
    const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
    const app = buildApp(limiter, () => 2, { team: 'alpha', source: 'db', keyId: 'k1' });
    const r1 = await request(app).get('/ok');
    const r2 = await request(app).get('/ok');
    const r3 = await request(app).get('/ok');
    expect(r1.status).toBe(200);
    expect(r2.status).toBe(200);
    expect(r3.status).toBe(429);
    expect(r3.body.error).toBe('rate_limited');
    expect(r3.body.detail).toContain('team=alpha');
    expect(r3.body.detail).toContain('limit_rpm=2');
    expect(Number(r3.headers['retry-after'])).toBeGreaterThanOrEqual(1);
    limiter.stop();
  });

  it('skips check for config-source auth', async () => {
    const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
    const app = buildApp(limiter, () => 1, { team: 'alpha', source: 'config' });
    const r1 = await request(app).get('/ok');
    const r2 = await request(app).get('/ok');
    expect(r1.status).toBe(200);
    expect(r2.status).toBe(200);
    limiter.stop();
  });

  it('null limit returned by getRateLimit = unlimited admit', async () => {
    const limiter = createRateLimiter({ repo: fakeRepo(), period: () => '2026-05' });
    const app = buildApp(limiter, () => null, { team: 'alpha', source: 'db', keyId: 'k2' });
    for (let i = 0; i < 100; i++) {
      const r = await request(app).get('/ok');
      expect(r.status).toBe(200);
    }
    limiter.stop();
  });
});
