/**
 * AAO Gateway Phase 3a F4 — hot-path LRU cache for virtual_key DB lookups.
 *
 * The gateway hits the DB three times per chat/completion:
 *   1. auth middleware → findGatewayVirtualKeyByHash(hash)
 *   2. budget middleware → findGatewayVirtualKeyById(id)
 *   3. rate-limiter middleware → findGatewayVirtualKeyById(id)
 *
 * Each lookup is a single-row partial-index scan so it's cheap (~10µs)
 * but at high RPS the cumulative cost shows up in flamegraphs, and the
 * three trips serialize through better-sqlite3's read mutex.
 *
 * This module provides a tiny in-process LRU keyed by BOTH the row id
 * and the SHA-256 key_hash so all three call sites can share one cache.
 * Entries expire after `ttlMs` (default 5s) — short enough that a PATCH /
 * revoke takes effect within a request cycle even without explicit
 * invalidation, but long enough to absorb a chat completion's three
 * lookups.
 *
 * Invalidation: callers that mutate a key (admin PATCH / revoke / rotate /
 * delete) MUST call `invalidate(id)` so the next lookup sees the fresh
 * row. The cache then deletes both the id and key_hash index entries in
 * one shot so a malicious caller can't re-auth through the cache after a
 * revoke.
 *
 * Reverse index: a Map<keyHash, id> is maintained alongside the main
 * `store` so `invalidate(id)` can find and drop the companion hash slot
 * even when the id slot was already evicted. Without this, a key that
 * was originally cached via the hash-only auth path (then had its id
 * slot evicted under LRU pressure) could continue to authenticate for
 * up to ttlMs after being revoked — a real auth-bypass window. See the
 * "revoked-key auth bypass" test in key-cache.test.ts for the scenario.
 *
 * Usage table rows (gateway_key_usage) are NOT cached — they change on
 * every request and must always be read fresh.
 */
import type { GatewayVirtualKey } from '../db/repository.js';

export interface KeyCache {
  /**
   * Lookup by either the row id (as returned by findGatewayVirtualKeyById)
   * or by the SHA-256 hex key_hash (as accepted by
   * findGatewayVirtualKeyByHash). Returns null on miss or when the
   * cached entry has expired.
   */
  get(idOrHash: string, nowMs?: number): GatewayVirtualKey | null;
  /**
   * Insert/refresh an entry. Indexes the row under both `id` and
   * `keyHash` so subsequent lookups by either key hit the cache.
   * Updates the LRU position (moves to tail) for both entries.
   */
  set(key: GatewayVirtualKey, nowMs?: number): void;
  /**
   * Drop the row from the cache by id. Also deletes the keyHash entry
   * so a revoked key can't re-auth via a stale hash hit.
   */
  invalidate(id: string): void;
  /** Drop every cached entry. Used by tests and on hot-reload. */
  clear(): void;
  /** Test hook: number of distinct entries (id and hash share storage). */
  size(): number;
}

export interface KeyCacheOptions {
  /** Entry TTL in milliseconds. Default 5000 (5s). */
  ttlMs?: number;
  /**
   * Maximum number of entries. When exceeded, oldest entries (by
   * insertion / refresh order) are evicted FIFO. Each cached key
   * counts as 2 entries (id + hash), so a maxEntries of 1000 stores
   * ~500 distinct keys. Default 1000.
   */
  maxEntries?: number;
}

interface CacheRow {
  key: GatewayVirtualKey;
  /** Wall-clock ms after which `get` returns null. */
  expiresAt: number;
}

const DEFAULT_TTL_MS = 5_000;
const DEFAULT_MAX_ENTRIES = 1_000;

/**
 * Create a new key cache. Caller is responsible for invalidating after
 * mutations — see admin-gateway-api.ts. Multiple gateway instances each
 * get their own cache; they're not synchronized (Phase 3 would add
 * Redis or pub/sub if multi-instance becomes a real shape).
 */
export function createKeyCache(opts: KeyCacheOptions = {}): KeyCache {
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
  const maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
  if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
    throw new Error(`createKeyCache: ttlMs must be a positive number (got ${ttlMs})`);
  }
  if (!Number.isFinite(maxEntries) || maxEntries <= 0) {
    throw new Error(`createKeyCache: maxEntries must be a positive number (got ${maxEntries})`);
  }
  // Single Map keyed by either id or keyHash. JS Map iteration order is
  // insertion order, which gives us LRU eviction by deleting the front.
  const store = new Map<string, CacheRow>();
  // Reverse index keyHash → id. Used by invalidate(id) so a revoke can
  // drop the hash slot even when the id slot has already been evicted
  // under LRU pressure. Kept in lockstep with `store`: every set adds
  // an entry, every removal of a hash-keyed entry from `store` must
  // also delete from here.
  const hashToId = new Map<string, string>();

  function evictIfNeeded(): void {
    while (store.size > maxEntries) {
      const oldest = store.keys().next().value;
      if (oldest === undefined) break;
      const row = store.get(oldest);
      store.delete(oldest);
      // If the evicted slot is a hash slot, drop its reverse-index
      // entry too — otherwise hashToId leaks unbounded over the
      // lifetime of the process.
      if (row && oldest === row.key.keyHash) {
        hashToId.delete(oldest);
      }
    }
  }

  function refreshLruPosition(k: string, row: CacheRow): void {
    // Re-inserting moves to the tail of the insertion-order Map.
    store.delete(k);
    store.set(k, row);
  }

  return {
    get(idOrHash, nowMs): GatewayVirtualKey | null {
      const row = store.get(idOrHash);
      if (!row) return null;
      const now = nowMs ?? Date.now();
      if (now >= row.expiresAt) {
        // Lazy expiration — drop the stale entry and pretend we missed.
        // The companion entry (id ↔ hash) will be cleaned up on its
        // own next access; we don't double-look up here to keep the
        // hot path branchless. Keep hashToId in sync when the dropped
        // slot is the hash slot.
        store.delete(idOrHash);
        if (idOrHash === row.key.keyHash) {
          hashToId.delete(idOrHash);
        }
        return null;
      }
      refreshLruPosition(idOrHash, row);
      return row.key;
    },
    set(key, nowMs): void {
      const now = nowMs ?? Date.now();
      const row: CacheRow = { key, expiresAt: now + ttlMs };
      // Index by both id and keyHash so the auth-path (hash) and the
      // post-auth path (id) share the cache.
      refreshLruPosition(key.id, row);
      refreshLruPosition(key.keyHash, row);
      hashToId.set(key.keyHash, key.id);
      evictIfNeeded();
    },
    invalidate(id): void {
      const row = store.get(id);
      if (row) {
        store.delete(id);
        store.delete(row.key.keyHash);
        hashToId.delete(row.key.keyHash);
        return;
      }
      // The id slot was evicted under LRU pressure but a hash slot for
      // the same row may still be live — without this fall-back a
      // revoked key could re-auth through the cache for up to ttlMs.
      // Scan the reverse index by id (small Map, O(n) scan acceptable
      // for invalidation cost).
      for (const [hash, mappedId] of hashToId) {
        if (mappedId === id) {
          store.delete(hash);
          hashToId.delete(hash);
          return;
        }
      }
    },
    clear(): void {
      store.clear();
      hashToId.clear();
    },
    size(): number {
      return store.size;
    },
  };
}
