// Extracted from src/db/repository.ts (mechanical split — bodies unchanged).
// gateway repository domain. Facade: src/db/repository.ts (Repository class).
import Database from 'better-sqlite3';
import { randomUUID } from 'crypto';

// ── AAO Gateway Phase 2a: virtual keys ─────────────────────────────────
// Stored shape returned by all gateway-key repository methods. The raw
// bearer is NEVER persisted or returned — only `keyHash` (sha256) lives
// in the DB, and `keyPrefix` is the human-readable head used in admin UI
// lists (`sk-aao-XXXXXX`). The raw key surfaces exactly once, from the
// admin API on issue / rotate; see src/bridge/admin-gateway-api.ts.

export type GatewayVirtualKeySource = 'admin' | 'config-import';


export interface GatewayVirtualKey {
  id: string;
  keyHash: string;
  keyPrefix: string;
  team: string;
  /** Null = no per-key allowlist (any backend.model is accepted). */
  allowedModels: string[] | null;
  source: GatewayVirtualKeySource;
  createdAt: string;
  createdBy: string | null;
  /** ISO timestamp if revoked; null while active. */
  revokedAt: string | null;
  revokedBy: string | null;
  lastUsedAt: string | null;
  /**
   * Phase 2b: monthly tokens budget. NULL = unlimited. When set, the
   * gateway rejects requests with 402 once the current UTC month
   * `tokens_in + tokens_out` reaches this number (post-hoc enforcement —
   * the offending request that pushes the counter over the limit still
   * completes; the next one is rejected).
   */
  tokensBudget: number | null;
  /**
   * Phase 2b: per-key requests-per-minute cap. NULL = unlimited. Enforced
   * as a sliding 60-second window in-process; multi-instance setups are
   * intentionally NOT synchronized (Phase 3 if needed).
   */
  rateLimitRpm: number | null;
}


export interface GatewayVirtualKeyRow {
  id: string;
  key_hash: string;
  key_prefix: string;
  team: string;
  allowed_models: string | null;
  source: string;
  created_at: string;
  created_by: string | null;
  revoked_at: string | null;
  revoked_by: string | null;
  last_used_at: string | null;
  tokens_budget: number | null;
  rate_limit_rpm: number | null;
}


// Phase 2b: monthly usage counter per virtual key.
export interface GatewayKeyUsage {
  keyId: string;
  /** UTC month bucket as 'YYYY-MM' — see src/gateway/period.ts. */
  periodStart: string;
  tokensIn: number;
  tokensOut: number;
  requests: number;
  lastUpdatedAt: string;
}


export interface GatewayKeyUsageRow {
  key_id: string;
  period_start: string;
  tokens_in: number;
  tokens_out: number;
  requests: number;
  last_updated_at: string;
}


export function rowToGatewayKeyUsage(row: GatewayKeyUsageRow): GatewayKeyUsage {
  return {
    keyId: row.key_id,
    periodStart: row.period_start,
    tokensIn: row.tokens_in,
    tokensOut: row.tokens_out,
    requests: row.requests,
    lastUpdatedAt: row.last_updated_at,
  };
}


/**
 * Coerce an optional limit (tokens_budget / rate_limit_rpm) to either
 * a positive integer or null. Anything else (undefined, null, 0,
 * negative, NaN, non-number) collapses to null = "no limit" so callers
 * can't accidentally persist a value that would silently block all
 * traffic.
 */
export function normalizeOptionalPositiveInt(v: unknown): number | null {
  if (v === undefined || v === null) return null;
  if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) return null;
  return Math.floor(v);
}


export function rowToGatewayVirtualKey(row: GatewayVirtualKeyRow): GatewayVirtualKey {
  let allowedModels: string[] | null = null;
  if (row.allowed_models !== null && row.allowed_models !== '') {
    try {
      const parsed: unknown = JSON.parse(row.allowed_models);
      if (Array.isArray(parsed) && parsed.every(x => typeof x === 'string')) {
        allowedModels = parsed;
      }
    } catch {
      // Corrupt JSON: treat as "no allowlist" (safer than fail-open
      // because routing has its own backend.model gate; this is just
      // the per-key filter on top).
      allowedModels = null;
    }
  }
  const source: GatewayVirtualKeySource =
    row.source === 'config-import' ? 'config-import' : 'admin';
  // tokens_budget / rate_limit_rpm may legitimately arrive as null (no
  // limit). Coerce non-positive integers to null defensively because the
  // gateway middleware treats null as "unlimited" — a corrupt `0` would
  // otherwise silently block every request.
  const tokensBudget =
    typeof row.tokens_budget === 'number' && Number.isFinite(row.tokens_budget) && row.tokens_budget > 0
      ? Math.floor(row.tokens_budget)
      : null;
  const rateLimitRpm =
    typeof row.rate_limit_rpm === 'number' && Number.isFinite(row.rate_limit_rpm) && row.rate_limit_rpm > 0
      ? Math.floor(row.rate_limit_rpm)
      : null;
  return {
    id: row.id,
    keyHash: row.key_hash,
    keyPrefix: row.key_prefix,
    team: row.team,
    allowedModels,
    source,
    createdAt: row.created_at,
    createdBy: row.created_by,
    revokedAt: row.revoked_at,
    revokedBy: row.revoked_by,
    lastUsedAt: row.last_used_at,
    tokensBudget,
    rateLimitRpm,
  };
}


  // ── AAO Gateway Phase 2a: virtual keys ───────────────────────────────
  //
  // The gateway auth middleware reads `findGatewayVirtualKeyByHash` on
  // every request, so it MUST stay an indexed point lookup. The partial
  // index `idx_gateway_keys_hash_active` covers that path. Admin-side
  // methods (list/get/revoke/rotate/delete) are not hot.

  /**
   * Insert a new virtual key row. Throws on UNIQUE(key_hash) violation —
   * callers must hash the raw key first (via src/gateway/key-format.ts)
   * and pass the hash here. The raw key is never accepted by the
   * Repository on purpose: there is no path that could log it.
   *
   * `allowedModels` is JSON-encoded when present and stored as NULL when
   * omitted — distinct from `[]` which means "lock to zero models".
   */
  export function createGatewayVirtualKey(db: Database.Database, params: {
    id?: string;
    keyHash: string;
    keyPrefix: string;
    team: string;
    allowedModels?: string[] | null;
    source?: GatewayVirtualKeySource;
    createdBy?: string | null;
    createdAt?: string;
    /** Phase 2b: optional monthly tokens budget. null/undefined = unlimited. */
    tokensBudget?: number | null;
    /** Phase 2b: optional requests-per-minute cap. null/undefined = unlimited. */
    rateLimitRpm?: number | null;
  }): GatewayVirtualKey {
    const id = params.id ?? randomUUID();
    const allowedJson =
      params.allowedModels === null || params.allowedModels === undefined
        ? null
        : JSON.stringify(params.allowedModels);
    const source: GatewayVirtualKeySource = params.source ?? 'admin';
    const createdAt = params.createdAt ?? new Date().toISOString();
    const tokensBudget = normalizeOptionalPositiveInt(params.tokensBudget);
    const rateLimitRpm = normalizeOptionalPositiveInt(params.rateLimitRpm);
    db
      .prepare(
        `INSERT INTO gateway_virtual_keys
           (id, key_hash, key_prefix, team, allowed_models, source, created_at, created_by, tokens_budget, rate_limit_rpm)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      )
      .run(
        id,
        params.keyHash,
        params.keyPrefix,
        params.team,
        allowedJson,
        source,
        createdAt,
        params.createdBy ?? null,
        tokensBudget,
        rateLimitRpm,
      );
    const row = db
      .prepare(`SELECT * FROM gateway_virtual_keys WHERE id = ?`)
      .get(id) as GatewayVirtualKeyRow;
    return rowToGatewayVirtualKey(row);
  }


  /**
   * Phase 2b: partial update of a virtual key's policy fields. The
   * bearer hash, team, source, and creation metadata are immutable here
   * (use rotate to change the bearer). Each field is opt-in — undefined
   * means "leave alone"; explicit null clears the limit (= unlimited).
   *
   * Returns the refreshed row. Throws when the id doesn't exist (caller
   * is expected to 404 before calling).
   */
  export function updateGatewayVirtualKey(db: Database.Database, id: string, patch: {
      /**
       * Phase 3a follow-up: team is now patchable so the config-migration
       * importer can propagate a YAML-side team rename to the DB. Admin
       * PATCH never sends this field (the team is intentionally immutable
       * via the public API to avoid an admin accidentally rewriting the
       * owner of a key); the only caller is importConfigKeysToDb.
       */
      team?: string;
      tokensBudget?: number | null;
      rateLimitRpm?: number | null;
      allowedModels?: string[] | null;
    }): GatewayVirtualKey {
    const sets: string[] = [];
    const args: unknown[] = [];
    if (Object.prototype.hasOwnProperty.call(patch, 'team')) {
      sets.push('team = ?');
      args.push(patch.team);
    }
    if (Object.prototype.hasOwnProperty.call(patch, 'tokensBudget')) {
      sets.push('tokens_budget = ?');
      args.push(normalizeOptionalPositiveInt(patch.tokensBudget));
    }
    if (Object.prototype.hasOwnProperty.call(patch, 'rateLimitRpm')) {
      sets.push('rate_limit_rpm = ?');
      args.push(normalizeOptionalPositiveInt(patch.rateLimitRpm));
    }
    if (Object.prototype.hasOwnProperty.call(patch, 'allowedModels')) {
      sets.push('allowed_models = ?');
      args.push(
        patch.allowedModels === null || patch.allowedModels === undefined
          ? null
          : JSON.stringify(patch.allowedModels),
      );
    }
    if (sets.length > 0) {
      args.push(id);
      db
        .prepare(`UPDATE gateway_virtual_keys SET ${sets.join(', ')} WHERE id = ?`)
        .run(...args);
    }
    const refreshed = findGatewayVirtualKeyById(db, id);
    if (!refreshed) {
      throw new Error(`updateGatewayVirtualKey: id not found (${id})`);
    }
    return refreshed;
  }


  /**
   * Auth hot path: look up an active (non-revoked) key by SHA-256 hash.
   * The partial index covers this query so the planner uses it directly.
   * Returns null on miss; never throws.
   */
  export function findGatewayVirtualKeyByHash(db: Database.Database, keyHash: string): GatewayVirtualKey | null {
    const row = db
      .prepare(
        `SELECT * FROM gateway_virtual_keys WHERE key_hash = ? AND revoked_at IS NULL`,
      )
      .get(keyHash) as GatewayVirtualKeyRow | undefined;
    return row ? rowToGatewayVirtualKey(row) : null;
  }


  /**
   * Admin lookup by row id. Includes revoked keys (the admin list/detail
   * view shows them so an admin can audit a recent revoke).
   */
  export function findGatewayVirtualKeyById(db: Database.Database, id: string): GatewayVirtualKey | null {
    const row = db
      .prepare(`SELECT * FROM gateway_virtual_keys WHERE id = ?`)
      .get(id) as GatewayVirtualKeyRow | undefined;
    return row ? rowToGatewayVirtualKey(row) : null;
  }


  /**
   * Admin list. `activeOnly` filters out revoked rows; `team` narrows by
   * team string (exact match). Ordering is `created_at DESC, id DESC` so
   * the freshest issuance is first regardless of system clock skew.
   */
  export function listGatewayVirtualKeys(db: Database.Database, opts?: { team?: string; activeOnly?: boolean }): GatewayVirtualKey[] {
    const where: string[] = [];
    const args: unknown[] = [];
    if (opts?.team !== undefined) {
      where.push('team = ?');
      args.push(opts.team);
    }
    if (opts?.activeOnly) {
      where.push('revoked_at IS NULL');
    }
    const sql =
      `SELECT * FROM gateway_virtual_keys` +
      (where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '') +
      ` ORDER BY created_at DESC, id DESC`;
    const rows = db.prepare(sql).all(...args) as GatewayVirtualKeyRow[];
    return rows.map(rowToGatewayVirtualKey);
  }


  /**
   * Mark a key as revoked. Returns true if the row was active (so the
   * caller can return a clean 200) and false if it was already revoked
   * or doesn't exist (so the caller can return 404 / 409). Idempotent
   * second calls return false.
   */
  export function revokeGatewayVirtualKey(db: Database.Database, id: string, revokedBy: string, at?: string): boolean {
    const ts = at ?? new Date().toISOString();
    const info = db
      .prepare(
        `UPDATE gateway_virtual_keys
           SET revoked_at = ?, revoked_by = ?
         WHERE id = ? AND revoked_at IS NULL`,
      )
      .run(ts, revokedBy, id);
    return info.changes > 0;
  }


  /**
   * Hard delete. The admin API guards `source='config-import'` and
   * returns 400 before calling this — but the Repository itself doesn't
   * enforce that (tests need to be able to clean up). Returns true if a
   * row was deleted.
   */
  /**
   * Hard-delete a virtual key row.
   *
   * Defense-in-depth: refuses to delete rows with `source='config-import'`
   * by throwing. The admin REST API also rejects this case (returning a
   * 400 with a human-readable message), but a future internal caller
   * could easily forget — and a hard delete of a config-import row
   * would simply be replayed on the next gateway boot when
   * importConfigKeysToDb re-imports the entry from config.yaml. That
   * recreates the row with a different id, which silently breaks any
   * audit history that referenced the previous id and is generally
   * confusing operator behavior. Force callers to use `revoke` (soft
   * delete) or to remove the entry from config.yaml first.
   *
   * Returns true when a row was deleted, false when the id didn't
   * exist. Throws when the row exists but is config-import.
   */
  export function deleteGatewayVirtualKey(db: Database.Database, id: string): boolean {
    const row = findGatewayVirtualKeyById(db, id);
    if (!row) return false;
    if (row.source === 'config-import') {
      throw new Error(
        `cannot delete config-import virtual key (id=${id}); ` +
          "remove the entry from config.yaml's gateway.virtual_keys instead, " +
          'or use revoke for a soft delete',
      );
    }
    const info = db
      .prepare(`DELETE FROM gateway_virtual_keys WHERE id = ?`)
      .run(id);
    return info.changes > 0;
  }


  /**
   * Bump `last_used_at` for an active key. Called from the gateway auth
   * middleware on successful match. Per-request volume can be high, so
   * callers typically dedup with a 30-second in-memory bucket (see
   * src/gateway/auth.ts) before touching the DB. Best-effort: failures
   * are swallowed by the caller so a temporary write-lock contention
   * never blocks auth.
   */
  export function touchGatewayVirtualKeyLastUsed(db: Database.Database, id: string, at?: string): void {
    const ts = at ?? new Date().toISOString();
    db
      .prepare(`UPDATE gateway_virtual_keys SET last_used_at = ? WHERE id = ?`)
      .run(ts, id);
  }


  // ── AAO Gateway Phase 2b: usage tracking ─────────────────────────────
  //
  // Read path is on the budget enforcement hot loop, so it stays a
  // single point lookup over the composite PRIMARY KEY. Write path is
  // an UPSERT (`ON CONFLICT … DO UPDATE`) so the gateway can fire-and-
  // forget after every chat completion without a pre-read.

  /**
   * Point-lookup over the (key_id, period_start) PRIMARY KEY. Returns
   * null when there's no row yet (= "no usage in this period"), which
   * the caller treats as zero counters.
   */
  export function getGatewayKeyUsage(db: Database.Database, keyId: string, periodStart: string): GatewayKeyUsage | null {
    const row = db
      .prepare(
        `SELECT * FROM gateway_key_usage WHERE key_id = ? AND period_start = ?`,
      )
      .get(keyId, periodStart) as GatewayKeyUsageRow | undefined;
    return row ? rowToGatewayKeyUsage(row) : null;
  }


  /**
   * UPSERT: bump the per-(key, period) counters by the supplied deltas.
   * All three deltas are clamped at zero so a buggy caller can never
   * decrement a counter. `last_updated_at` always reflects the call
   * time (or the explicit `at` override) so a downstream sweeper can
   * tell when activity stopped.
   *
   * Called from two places on the gateway hot path:
   *   1. stream-proxy's finally block (token deltas from upstream usage)
   *   2. rate-limiter's 30-second batch flush (request count only)
   *
   * The second caller passes `tokensIn=0 tokensOut=0` so the UPSERT
   * still creates a row even when no token usage was extracted.
   */
  export function incrementGatewayKeyUsage(db: Database.Database, params: {
    keyId: string;
    period: string;
    tokensIn?: number;
    tokensOut?: number;
    requests?: number;
    at?: string;
  }): void {
    const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0));
    const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0));
    const reqs = Math.max(0, Math.floor(params.requests ?? 0));
    const ts = params.at ?? new Date().toISOString();
    db
      .prepare(
        `INSERT INTO gateway_key_usage
           (key_id, period_start, tokens_in, tokens_out, requests, last_updated_at)
         VALUES (?, ?, ?, ?, ?, ?)
         ON CONFLICT (key_id, period_start) DO UPDATE SET
           tokens_in       = tokens_in  + excluded.tokens_in,
           tokens_out      = tokens_out + excluded.tokens_out,
           requests        = requests   + excluded.requests,
           last_updated_at = excluded.last_updated_at`,
      )
      .run(params.keyId, params.period, tIn, tOut, reqs, ts);
  }


  /**
   * Admin view: history of usage rows for a key, freshest period first.
   * Default limit 12 covers a full year of monthly buckets — enough for
   * the in-UI bar chart and for `GET /:id/usage` to embed history without
   * a follow-up call.
   */
  export function listGatewayKeyUsagesByKey(db: Database.Database, keyId: string, opts?: { limit?: number }): GatewayKeyUsage[] {
    const limit = Math.max(1, Math.min(120, Math.floor(opts?.limit ?? 12)));
    const rows = db
      .prepare(
        `SELECT * FROM gateway_key_usage
           WHERE key_id = ?
           ORDER BY period_start DESC
           LIMIT ?`,
      )
      .all(keyId, limit) as GatewayKeyUsageRow[];
    return rows.map(rowToGatewayKeyUsage);
  }
