/**
 * Bearer-token auth for the gateway.
 *
 * `Authorization: Bearer <key>` is required on every protected
 * endpoint. The supplied key is compared against each configured
 * `gateway.virtual_keys[].key` in **constant time** so the gateway
 * doesn't leak the length / prefix of valid keys via response timing.
 *
 * Constant-time wrapping
 * ─────────────────────
 * Node's `crypto.timingSafeEqual` throws when the two buffers differ
 * in length, which would itself be a (small) timing oracle. We sidestep
 * that by hashing both candidates with SHA-256 — fixed length, so the
 * compare is always safe to invoke. The hashing cost is dominated by
 * the surrounding async fetch latency, so it's free in practice.
 *
 * Per-key allowed_models check runs after key resolution. Phase 1 is a
 * strict allowlist; missing/empty allowed_models means "anything goes"
 * (subject to the router still matching `request.model` against a
 * configured backend).
 */
import { createHash, timingSafeEqual } from 'crypto';
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import type { GatewayVirtualKey } from './config.js';
import { hashKey } from './key-format.js';
import { logger } from '../logger.js';
import type { GatewayMetrics } from '../metrics/gateway-metrics.js';

/**
 * Resolved key info returned by a DB lookup. The Repository row is
 * adapted to this minimal shape in the bootstrap layer so this module
 * never imports the Repository directly (keeps the gateway entry-point
 * import graph small).
 */
export interface DbKeyMatch {
  /** Stable row id, surfaced to handlers as gatewayAuth.keyId. */
  id: string;
  team: string;
  /** Null = no allowlist (any backend.model is OK at the gateway layer). */
  allowedModels: string[] | null;
}

/**
 * Source of a successful auth match — useful for log triage and (later)
 * for fading out the config-based path. `db` is the canonical Phase 2a
 * path; `config` is the Phase 1 fallback that fires a one-shot
 * deprecation warning.
 */
export type GatewayAuthSource = 'db' | 'config';

/** Annotated request type — used by downstream handlers (chat-completions) to read team / key meta. */
export interface AuthenticatedRequest extends Request {
  gatewayAuth?: {
    team: string;
    /** Subset of virtual_keys for the matched entry — never the raw key. */
    allowedModels?: string[];
    /** Phase 2a additions. Optional so Phase 1 callers keep compiling. */
    source?: GatewayAuthSource;
    /** Present when matched via the DB path; lets us correlate audit logs / future budget usage. */
    keyId?: string;
  };
}

const HASH_ALG = 'sha256';

function fingerprint(s: string): Buffer {
  return createHash(HASH_ALG).update(s, 'utf8').digest();
}

/**
 * Pure key resolver: returns the matched VirtualKey or null. Exported for
 * unit testing without an Express harness.
 */
export function resolveVirtualKey(
  candidate: string,
  keys: GatewayVirtualKey[],
): GatewayVirtualKey | null {
  if (typeof candidate !== 'string' || candidate.length === 0) return null;
  const candidateHash = fingerprint(candidate);
  let matched: GatewayVirtualKey | null = null;
  // Walk every key (don't short-circuit on first match) so the time spent
  // comparing is independent of which key matched / whether any matched.
  // The branch on `match` only stores the reference; the loop length is
  // always keys.length.
  for (const k of keys) {
    const expectedHash = fingerprint(k.key);
    // Both buffers are guaranteed 32 bytes for SHA-256, so timingSafeEqual
    // never throws.
    const eq = timingSafeEqual(candidateHash, expectedHash);
    if (eq && matched === null) matched = k;
  }
  return matched;
}

/**
 * Extract bearer token from the Authorization header. Returns null when
 * the header is missing, malformed, or doesn't use the `Bearer ` scheme.
 *
 * Phase 3a F2: tightened to the RFC 6750 §2.1 `b64token` character set:
 *   ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" with optional
 *   trailing `=` padding. This rejects:
 *   - tokens containing whitespace (e.g. "Bearer sk-aao-abc evil"),
 *   - tokens containing control bytes (NUL / CR / LF / etc.),
 *   - tokens containing unicode escapes.
 *   `sk-aao-<base62>` keys live entirely inside this set, so legitimate
 *   AAO bearers are unaffected. Pre-tightening, the looser `.+` regex
 *   accepted any non-newline character, which broadened the attack
 *   surface for downstream consumers that might log / forward the
 *   string verbatim.
 */
export function extractBearer(headerVal: string | undefined): string | null {
  if (!headerVal || typeof headerVal !== 'string') return null;
  // Be permissive about whitespace AROUND the scheme ("Bearer   foo")
  // but strict about the scheme name (case-insensitive per RFC 6750)
  // and the token character set. Trailing whitespace inside the header
  // is allowed (some clients add it) and stripped via `.trim()`.
  const m = headerVal.match(/^Bearer\s+([A-Za-z0-9._~+/-]+=*)\s*$/i);
  if (!m) return null;
  const tok = m[1]?.trim();
  return tok && tok.length > 0 ? tok : null;
}

/**
 * Check whether `requestedModel` is in the per-key allowlist. Returns
 * true when no allowlist is configured (or the model is explicitly
 * listed). Returns false when an allowlist exists and the model is
 * missing from it.
 *
 * Empty array is treated as "allow nothing" — distinct from undefined —
 * so an admin can lock a key down to zero models intentionally.
 */
export function isModelAllowed(
  requestedModel: string | undefined,
  allowedModels: string[] | undefined,
): boolean {
  if (allowedModels === undefined) return true;
  if (typeof requestedModel !== 'string' || requestedModel.length === 0) return false;
  return allowedModels.includes(requestedModel);
}

interface AuthMiddlewareOptions {
  /**
   * Gateway virtual keys loaded from config. Snapshot, not a getter —
   * caller can re-create middleware on hot reload. Phase 2a treats this
   * as the fallback path: DB lookup runs first, config-only matches fire
   * a one-shot deprecation warning so operators can migrate.
   */
  keys: GatewayVirtualKey[];
  /**
   * Phase 2a optional DB lookup. When present, it's invoked first with
   * the SHA-256 hex hash of the candidate bearer; a non-null return
   * short-circuits the config path. Provider is supplied at wiring time
   * (gateway/bootstrap.ts) so this module stays Repository-free.
   *
   * The function is sync because the Repository layer is sync
   * (better-sqlite3). A sync callback also lets the auth middleware stay
   * synchronous, matching the Phase 1 contract.
   *
   * Phase 3a F4 (LRU cache): when a cache is configured upstream, the
   * dbLookup callback may itself be a cache-then-DB wrapper. The auth
   * middleware doesn't need to know — it just sees a sync provider.
   */
  dbLookup?: (keyHash: string) => DbKeyMatch | null;
  /**
   * Phase 2a touch-on-use. Invoked best-effort after a DB match; the
   * middleware itself dedups per-key within `touchIntervalMs`, so the
   * supplied function still needs to handle the (rare) double call
   * around clock skew. Caller is expected to swallow errors.
   */
  touchLastUsed?: (keyId: string) => void;
  /**
   * Bucket width for the touch dedup map, in milliseconds. Default
   * 30_000 ≈ 30s, which keeps `last_used_at` fresh enough for billing /
   * cleanup heuristics without slamming the DB on a high-RPS key.
   */
  touchIntervalMs?: number;
  /**
   * Phase 3b: optional metrics handle. When supplied, the 401 branch
   * increments `requestsTotal{status="auth_fail"}` so operators see
   * brute-force attempts in Grafana. The successful-auth path does NOT
   * fire requests_total — stream-proxy is the canonical emitter for
   * served requests (it has the team / backend / model labels).
   */
  metrics?: GatewayMetrics;
}

/** Default cap on the per-instance touch dedup map. */
export const DEFAULT_TOUCH_DEDUPER_MAX_ENTRIES = 10_000;

/**
 * Build the per-instance touch dedup map. Exported so unit tests can
 * verify the bucket behavior directly.
 *
 * Memory bound: the map is capped at `maxEntries` (default 10k) using a
 * simple insertion-order LRU. JavaScript Maps iterate in insertion
 * order, so dropping `keys().next().value` removes the oldest entry.
 * Each `shouldTouch(keyId)` that returns true re-inserts the key
 * (delete + set), bumping it to the tail of the LRU. Without this cap
 * the map would grow unboundedly across the lifetime of the process —
 * a real concern in deployments with frequent key rotation or many
 * short-lived bearers.
 */
export function createTouchDeduper(
  intervalMs: number,
  maxEntries: number = DEFAULT_TOUCH_DEDUPER_MAX_ENTRIES,
): {
  shouldTouch(keyId: string, nowMs?: number): boolean;
  clear(): void;
  size(): number;
} {
  if (!Number.isFinite(maxEntries) || maxEntries <= 0) {
    throw new Error(`createTouchDeduper: maxEntries must be a positive number (got ${maxEntries})`);
  }
  const lastTouchAt = new Map<string, number>();
  return {
    shouldTouch(keyId: string, nowMs?: number): boolean {
      const now = nowMs ?? Date.now();
      const prev = lastTouchAt.get(keyId);
      if (prev !== undefined && now - prev < intervalMs) {
        return false;
      }
      // Re-insert (delete + set) moves the key to the tail of the
      // insertion-order iteration so LRU eviction targets the oldest
      // entry. Do this BEFORE eviction so a hot key isn't accidentally
      // evicted by its own touch when the map is full.
      lastTouchAt.delete(keyId);
      if (lastTouchAt.size >= maxEntries) {
        const oldest = lastTouchAt.keys().next().value;
        if (oldest !== undefined) lastTouchAt.delete(oldest);
      }
      lastTouchAt.set(keyId, now);
      return true;
    },
    clear(): void {
      lastTouchAt.clear();
    },
    size(): number {
      return lastTouchAt.size;
    },
  };
}

/**
 * Build an Express middleware that requires a valid bearer token.
 *
 * Phase 2a lookup order:
 *   1. DB path via `dbLookup(sha256(bearer))` — point lookup over the
 *      partial index `idx_gateway_keys_hash_active`.
 *   2. Config-yaml path via `resolveVirtualKey` (constant-time over the
 *      static array). First config match fires a one-shot warning so
 *      operators know to migrate.
 *
 * On miss/bad-format: 401 `{error: "invalid api key"}` with no detail
 * about why (don't leak whether the header was missing vs the key was
 * wrong — both look the same from outside).
 *
 * The middleware sets `req.gatewayAuth` with the matched team / allowed
 * model list so downstream handlers can run the allowlist check without
 * re-parsing the header. `source` and `keyId` (DB only) are added for
 * Phase 2a so log lines and (Phase 2b) budget counters can attribute
 * traffic.
 */
/** Cooldown (ms) between dbLookup warning logs. Keeps a persistent DB failure from flooding logs at request volume. */
export const DB_LOOKUP_WARN_COOLDOWN_MS = 60_000;

export function buildAuthMiddleware(opts: AuthMiddlewareOptions): RequestHandler {
  const keys = opts.keys;
  const dbLookup = opts.dbLookup;
  const touchLastUsed = opts.touchLastUsed;
  const touchIntervalMs = opts.touchIntervalMs ?? 30_000;
  const deduper = createTouchDeduper(touchIntervalMs);
  const metrics = opts.metrics;
  const recordAuthFail = (): void => {
    if (!metrics) return;
    // Auth fails happen before we know team/backend/model, so use the
    // sentinel `unknown`/`none`/`none` to keep the label set well-formed.
    try {
      metrics.requestsTotal
        .labels({ team: 'unknown', backend: 'none', model: 'none', status: 'auth_fail' })
        .inc();
    } catch {
      // Metric emission must never affect the response.
    }
  };
  // One-shot deprecation warn for config-based matches. Held in closure
  // so the warning fires at most once per middleware instance (= once per
  // gateway start with hot-reload, which is what operators expect).
  let deprecationWarned = false;
  // Cooldown timestamp (ms) for dbLookup-threw warnings. Without this
  // cap, a persistent DB failure (lock contention, disk full, etc.)
  // would emit one warn per inbound request — easily thousands per
  // second — making the real signal impossible to find.
  let lastDbWarnAt = 0;
  return function gatewayAuthMiddleware(req: Request, res: Response, next: NextFunction): void {
    const bearer = extractBearer(req.headers['authorization']);
    if (!bearer) {
      recordAuthFail();
      res.status(401).json({ error: 'invalid api key' });
      return;
    }

    // 1) DB path — runs first so Phase 2a migrations win over stale
    //    config entries (config-import rows are also reachable here).
    if (dbLookup) {
      let dbMatch: DbKeyMatch | null = null;
      try {
        dbMatch = dbLookup(hashKey(bearer));
      } catch (e) {
        // A DB error is logged but doesn't block fallback to the config
        // path — we'd rather continue serving requests if a transient
        // write-lock contention shows up. The 401 path is still reached
        // if both paths fail.
        const now = Date.now();
        if (now - lastDbWarnAt > DB_LOOKUP_WARN_COOLDOWN_MS) {
          logger.warn(
            `[gateway-auth] dbLookup threw: ${e instanceof Error ? e.message : String(e)} ` +
              `(suppressing similar warnings for ${Math.round(DB_LOOKUP_WARN_COOLDOWN_MS / 1000)}s)`,
          );
          lastDbWarnAt = now;
        }
      }
      if (dbMatch) {
        (req as AuthenticatedRequest).gatewayAuth = {
          team: dbMatch.team,
          allowedModels: dbMatch.allowedModels ?? undefined,
          source: 'db',
          keyId: dbMatch.id,
        };
        if (touchLastUsed && deduper.shouldTouch(dbMatch.id)) {
          // Fire-and-forget; the supplied function is expected to
          // swallow errors so a touch failure never 500s a request.
          try {
            touchLastUsed(dbMatch.id);
          } catch (e) {
            logger.warn(
              `[gateway-auth] touchLastUsed threw: ${e instanceof Error ? e.message : String(e)}`,
            );
          }
        }
        next();
        return;
      }
    }

    // 2) Config fallback — preserves Phase 1 deployments that never run
    //    the auto-migration (rare; bootstrap always runs it when DB is
    //    available). A bearer that matches both paths still resolves via
    //    the DB path above.
    const configMatch = resolveVirtualKey(bearer, keys);
    if (!configMatch) {
      logger.warn(`[gateway-auth] reject ip=${req.ip ?? 'unknown'} path=${req.path}`);
      recordAuthFail();
      res.status(401).json({ error: 'invalid api key' });
      return;
    }
    if (!deprecationWarned) {
      logger.warn(
        '[gateway-auth] virtual_key matched via config (deprecated, migrate to DB via POST /api/admin/gateway/keys)',
      );
      deprecationWarned = true;
    }
    (req as AuthenticatedRequest).gatewayAuth = {
      team: configMatch.team,
      allowedModels: configMatch.allowedModels,
      source: 'config',
    };
    next();
  };
}

/**
 * Middleware that enforces `request.body.model` is within the matched
 * key's allowed_models. Must run AFTER buildAuthMiddleware and AFTER
 * express.json() (so `req.body` is populated).
 *
 * The 403 message intentionally surfaces the requested model — that's
 * the same model the caller sent, so it's not new information to them.
 */
export function buildAllowedModelsMiddleware(): RequestHandler {
  return function allowedModelsCheck(req: Request, res: Response, next: NextFunction): void {
    const auth = (req as AuthenticatedRequest).gatewayAuth;
    if (!auth) {
      // Shouldn't happen if mounted after buildAuthMiddleware, but be
      // defensive — never let an unauthenticated request reach the
      // model allowlist check (it would always pass for undefined keys).
      res.status(401).json({ error: 'invalid api key' });
      return;
    }
    const body = req.body as { model?: unknown } | undefined;
    const requested = typeof body?.model === 'string' ? body.model : undefined;
    if (!isModelAllowed(requested, auth.allowedModels)) {
      res.status(403).json({ error: `model '${requested ?? ''}' not allowed for this key` });
      return;
    }
    next();
  };
}
