/**
 * Tests for Phase 3a F4 — hot-path LRU cache for virtual_key lookups.
 *
 * The cache must:
 *  - hit when looked up by id OR by keyHash (dual index)
 *  - expire entries after ttlMs
 *  - evict in insertion order when at the maxEntries cap
 *  - invalidate by id and also drop the companion hash entry
 *  - never return a row past its TTL even if size() shows it
 */
import { describe, it, expect } from 'vitest';
import { createKeyCache } from './key-cache.js';
import type { GatewayVirtualKey } from '../db/repository.js';

function makeKey(id: string, overrides: Partial<GatewayVirtualKey> = {}): GatewayVirtualKey {
  return {
    id,
    keyHash: `hash-${id}`,
    keyPrefix: `sk-aao-${id.slice(0, 6)}`,
    team: 'alpha',
    allowedModels: null,
    source: 'admin',
    createdAt: '2026-05-19T00:00:00.000Z',
    createdBy: 'admin',
    revokedAt: null,
    revokedBy: null,
    lastUsedAt: null,
    tokensBudget: null,
    rateLimitRpm: null,
    ...overrides,
  };
}

describe('createKeyCache', () => {
  it('get returns null on miss', () => {
    const cache = createKeyCache();
    expect(cache.get('nope')).toBeNull();
  });

  it('set + get round-trips by both id and keyHash (dual index)', () => {
    const cache = createKeyCache();
    const k = makeKey('k1');
    cache.set(k);
    expect(cache.get('k1')).toEqual(k);
    expect(cache.get('hash-k1')).toEqual(k);
    // Two entries in the underlying map.
    expect(cache.size()).toBe(2);
  });

  it('respects ttlMs — past TTL lookups miss', () => {
    const cache = createKeyCache({ ttlMs: 1000 });
    const k = makeKey('k1');
    cache.set(k, 100);
    expect(cache.get('k1', 500)).toEqual(k); // inside TTL
    // At nowMs == expiresAt the entry has expired (>= check in get).
    expect(cache.get('k1', 1100)).toBeNull();
    expect(cache.get('k1', 1101)).toBeNull();
  });

  it('expired entries are lazily cleaned out of the map on access', () => {
    const cache = createKeyCache({ ttlMs: 1000 });
    cache.set(makeKey('k1'), 0);
    expect(cache.size()).toBe(2);
    cache.get('k1', 2000); // expired
    // The id entry was dropped; the hash entry stays until accessed
    // (acceptable — TTL still bounds it).
    expect(cache.size()).toBe(1);
  });

  it('LRU eviction targets the oldest entries when at maxEntries cap', () => {
    // maxEntries = 4 means we can hold 2 distinct keys (each takes 2 slots).
    const cache = createKeyCache({ maxEntries: 4 });
    cache.set(makeKey('k1'));
    cache.set(makeKey('k2'));
    expect(cache.size()).toBe(4);
    // Inserting a third key evicts the oldest pair (k1) FIFO.
    cache.set(makeKey('k3'));
    expect(cache.get('k1')).toBeNull();
    expect(cache.get('hash-k1')).toBeNull();
    expect(cache.get('k2')).not.toBeNull();
    expect(cache.get('k3')).not.toBeNull();
  });

  it('get refreshes the LRU position so hot entries survive', () => {
    const cache = createKeyCache({ maxEntries: 4 });
    cache.set(makeKey('k1'));
    cache.set(makeKey('k2'));
    // Touch k1 so it moves to the tail.
    expect(cache.get('k1')).not.toBeNull();
    // Now k2 is the oldest pair → evicted first.
    cache.set(makeKey('k3'));
    expect(cache.get('k1')).not.toBeNull();
    expect(cache.get('k2')).toBeNull();
  });

  it('invalidate drops both id and keyHash entries', () => {
    const cache = createKeyCache();
    cache.set(makeKey('k1'));
    expect(cache.size()).toBe(2);
    cache.invalidate('k1');
    expect(cache.size()).toBe(0);
    expect(cache.get('k1')).toBeNull();
    expect(cache.get('hash-k1')).toBeNull();
  });

  it('invalidate on unknown id is a noop', () => {
    const cache = createKeyCache();
    cache.set(makeKey('k1'));
    cache.invalidate('totally-different');
    expect(cache.get('k1')).not.toBeNull();
  });

  it('clear drops every entry', () => {
    const cache = createKeyCache();
    cache.set(makeKey('k1'));
    cache.set(makeKey('k2'));
    cache.clear();
    expect(cache.size()).toBe(0);
    expect(cache.get('k1')).toBeNull();
  });

  it('rejects invalid ttlMs / maxEntries up front', () => {
    expect(() => createKeyCache({ ttlMs: 0 })).toThrow(/ttlMs/);
    expect(() => createKeyCache({ ttlMs: -1 })).toThrow(/ttlMs/);
    expect(() => createKeyCache({ ttlMs: Number.NaN })).toThrow(/ttlMs/);
    expect(() => createKeyCache({ maxEntries: 0 })).toThrow(/maxEntries/);
    expect(() => createKeyCache({ maxEntries: -1 })).toThrow(/maxEntries/);
    expect(() => createKeyCache({ maxEntries: Number.NaN })).toThrow(/maxEntries/);
  });

  it('set on an existing id refreshes the expiry and LRU position', () => {
    const cache = createKeyCache({ ttlMs: 1000 });
    cache.set(makeKey('k1'), 0);
    // Refresh at t=500 → expiresAt becomes 1500.
    cache.set(makeKey('k1', { team: 'beta' }), 500);
    expect(cache.get('k1', 1200)?.team).toBe('beta');
    expect(cache.get('k1', 1500)).toBeNull(); // re-expired
  });

  // Phase 3a follow-up — close the revoked-key auth-bypass window.
  // Pre-fix: invalidate(id) returned early when the id slot had been
  // evicted under LRU pressure, leaving the hash slot intact. A revoked
  // key could then re-auth via cache.get(hash) for up to ttlMs (default
  // 5s) — a real auth-bypass window.
  describe('reverse-index invalidation (revoked-key auth bypass)', () => {
    it('invalidate(id) drops the hash slot even when the id slot was already evicted', () => {
      // Set maxEntries so we can hold 3 entries total. We construct a
      // state where the id slot for k1 is at the front of the LRU
      // (oldest, due for eviction) but the hash slot is at the tail
      // (recently touched) — simulating the hot auth path that hits
      // the cache by hash only.
      const cache = createKeyCache({ maxEntries: 3 });
      cache.set(makeKey('k1'));
      // Touch the hash slot — moves hash-k1 to the tail; k1 (id slot)
      // is now the oldest entry in the map.
      expect(cache.get('hash-k1')).not.toBeNull();
      // Inserting a second key adds 2 slots → store grows to 4 entries
      // (k1, hash-k1, k2, hash-k2), then evicts the front one (k1's id
      // slot) to come back to maxEntries=3. hash-k1 survives at index 0
      // after eviction.
      cache.set(makeKey('k2'));
      // k1's id slot is gone but the hash slot still holds the row.
      expect(cache.get('k1')).toBeNull();
      expect(cache.get('hash-k1')).not.toBeNull();
      // Pre-fix: invalidate(k1) would silently no-op here because the
      // id slot is absent. Post-fix: the reverse index lets us find
      // and drop the surviving hash slot. Without this, a revoked
      // key could re-auth via cache.get(hash) for up to ttlMs.
      cache.invalidate('k1');
      expect(cache.get('hash-k1')).toBeNull();
    });

    it('invalidate(id) is still a noop for an id that was never cached', () => {
      const cache = createKeyCache();
      cache.set(makeKey('k1'));
      cache.invalidate('never-existed');
      expect(cache.get('k1')).not.toBeNull();
      expect(cache.get('hash-k1')).not.toBeNull();
    });

    it('clear() also drops the reverse index (no leak across tests)', () => {
      const cache = createKeyCache();
      cache.set(makeKey('k1'));
      cache.set(makeKey('k2'));
      cache.clear();
      // Re-set should land cleanly; if hashToId leaked, the second
      // invalidate would mis-resolve to a stale id (regression guard).
      cache.set(makeKey('k1'));
      cache.invalidate('k1');
      expect(cache.size()).toBe(0);
    });

    it('lazy-expire on the hash slot also drops the reverse index', () => {
      const cache = createKeyCache({ ttlMs: 1000 });
      cache.set(makeKey('k1'), 0);
      // Expire via hash-slot access at t=2000.
      expect(cache.get('hash-k1', 2000)).toBeNull();
      // After the hash slot is lazily dropped, a subsequent invalidate
      // by id should not crash and should not leave a stale hashToId
      // entry pointing at a deleted store row.
      cache.invalidate('k1');
      // Re-set with the same hash should work cleanly.
      cache.set(makeKey('k1'), 3000);
      expect(cache.get('hash-k1', 3001)).not.toBeNull();
    });
  });
});
