/**
 * AAO Gateway Phase 2a — config → DB auto-migration.
 *
 * On gateway boot (and on admin server boot — same code path) we walk
 * config.yaml's `gateway.virtual_keys[]` and upsert each entry into the
 * `gateway_virtual_keys` table with `source='config-import'`. The hash
 * column is UNIQUE so re-imports of the same key are no-ops: a second
 * call returns `{imported: 0, skipped: N}` and produces no DB changes.
 *
 * Operators are then free to:
 *   - delete the static entries from config.yaml (the DB row keeps the
 *     key alive — admin must explicitly revoke via the admin API),
 *   - issue fresh `sk-aao-...` keys via the admin API,
 *   - leave config.yaml unchanged as a backup channel.
 *
 * The function is sync because the Repository is sync. We accept a
 * narrow Repository-shape dependency (not the full class) so unit tests
 * can drive the importer without spinning up a real DB.
 */
import type { GatewayConfig, GatewayVirtualKey } from './config.js';
import { deriveKeyPrefix, hashKey, KEY_PREFIX_HEAD } from './key-format.js';
import { logger } from '../logger.js';

/**
 * Minimal Repository slice used by the importer. Lets tests inject a
 * fake without depending on better-sqlite3 / sqlite migration order.
 *
 * Phase 3a F5 expands the slice to support resyncing existing
 * config-import rows when their YAML-side budget / rate / allowedModels
 * drift. The importer reads the existing row (find-by-hash now returns
 * the full shape it cares about) and updates only when at least one
 * tracked field differs from the new config.
 */
export interface GatewayKeyImportRepo {
  findGatewayVirtualKeyByHash(keyHash: string): {
    id: string;
    source: 'admin' | 'config-import';
    team: string;
    tokensBudget: number | null;
    rateLimitRpm: number | null;
    allowedModels: string[] | null;
  } | null;
  createGatewayVirtualKey(params: {
    keyHash: string;
    keyPrefix: string;
    team: string;
    allowedModels?: string[] | null;
    source: 'admin' | 'config-import';
    createdBy?: string | null;
    /** Phase 2b: optional monthly tokens budget propagated from config. */
    tokensBudget?: number | null;
    /** Phase 2b: optional rpm cap propagated from config. */
    rateLimitRpm?: number | null;
  }): unknown;
  /**
   * F5: patch-style update for existing config-import rows. Only called
   * when at least one tracked field differs between YAML and DB.
   *
   * Phase 3a follow-up adds `team` to the patch shape so a YAML-side
   * team rename actually reaches the DB. The bearer hash is the
   * identity (same key → same row), so renaming the team in YAML must
   * propagate or downstream logs / budget rollups carry the stale label.
   */
  updateGatewayVirtualKey(
    id: string,
    patch: {
      team?: string;
      tokensBudget?: number | null;
      rateLimitRpm?: number | null;
      allowedModels?: string[] | null;
    },
  ): unknown;
}

/**
 * Repository slice used by the orphan reconciliation pass (Phase 3a F1).
 * Same narrow shape pattern as GatewayKeyImportRepo so tests can drive
 * the reconciliation without a real DB. We list all keys (revoked or
 * not) and let the caller filter — keeps the SQL query stable across
 * Repository versions.
 */
export interface GatewayKeyOrphanReportRepo {
  listGatewayVirtualKeys(opts?: { activeOnly?: boolean }): Array<{
    keyHash: string;
    keyPrefix: string;
    team: string;
    source: 'admin' | 'config-import';
  }>;
}

export interface ImportConfigKeysResult {
  imported: number;
  skipped: number;
  /** Phase 3a F5: config-import rows whose YAML side changed and were patched. */
  resynced: number;
}

/**
 * Walk the config's virtual_keys and ensure each one exists in the DB.
 * Idempotent: re-running with no config changes produces 0 imports.
 *
 * Keys missing a `team` or `key` field are skipped with a warning; the
 * gateway's own config validator already rejects those at startup, so
 * this is belt-and-suspenders for callers that pass partial config in
 * tests.
 *
 * Config keys are imported with their raw value (whatever the operator
 * wrote in YAML) — they aren't required to be `sk-aao-*`. For non-AAO
 * shapes the stored `key_prefix` falls back to the first 14 chars of
 * the raw value so the admin UI can still surface a recognizable head.
 */
export function importConfigKeysToDb(
  cfg: Pick<GatewayConfig, 'virtualKeys'>,
  repo: GatewayKeyImportRepo,
): ImportConfigKeysResult {
  const out: ImportConfigKeysResult = { imported: 0, skipped: 0, resynced: 0 };
  for (const k of cfg.virtualKeys ?? []) {
    if (!isImportable(k)) {
      logger.warn('[gateway-migration] skipping config virtual_key with missing team/key');
      continue;
    }
    const hash = hashKey(k.key);
    // Wrap per-key in try/catch so a transient failure (UNIQUE race with
    // another instance starting at the same time, lock contention, etc.)
    // doesn't bubble up to bootstrap and crash the whole process. The
    // find-then-insert pattern is inherently racy: two instances can
    // both observe "not found" and race to insert; the UNIQUE constraint
    // on key_hash arbitrates, but the loser sees a thrown error here.
    try {
      const existing = repo.findGatewayVirtualKeyByHash(hash);
      if (existing) {
        // F5: only touch the row when it was originally imported from
        // config. Admin-created rows (manual issuance / rotation) keep
        // their independent policy state — the team mapping is what
        // identifies a "shared" key, but allowedModels / budget / rpm
        // belong to the issuer.
        if (existing.source !== 'config-import') {
          out.skipped++;
          continue;
        }
        // F5 + Phase 3a follow-up: detect drift on the four patchable
        // fields (team + the three policy fields). Bearer hash is the
        // identity — same key, same row — so a YAML team rename must
        // propagate to the DB or downstream logs / budget rollups
        // carry the stale team label. There's no signal that tells
        // us "intentional rename" vs "typo", so we always apply but
        // log loudly enough that an operator notices a wrong rename.
        const newBudget = k.tokensBudget ?? null;
        const newRpm = k.rateLimitRpm ?? null;
        const newAllowed = k.allowedModels ?? null;
        const teamChanged = existing.team !== k.team;
        const drift =
          teamChanged ||
          existing.tokensBudget !== newBudget ||
          existing.rateLimitRpm !== newRpm ||
          !arraysEqualOrBothNull(existing.allowedModels, newAllowed);
        if (drift) {
          repo.updateGatewayVirtualKey(existing.id, {
            team: k.team,
            tokensBudget: newBudget,
            rateLimitRpm: newRpm,
            allowedModels: newAllowed,
          });
          out.resynced++;
          const teamSuffix = teamChanged ? ` team='${existing.team}'→'${k.team}'` : '';
          logger.info(
            `[gateway-migration] resynced config-import key team=${k.team} (YAML drift detected:${teamSuffix} ` +
              `budget=${existing.tokensBudget}→${newBudget} rpm=${existing.rateLimitRpm}→${newRpm} ` +
              `allowed=${describeAllowed(existing.allowedModels)}→${describeAllowed(newAllowed)})`,
          );
        } else {
          out.skipped++;
        }
        continue;
      }
      const prefix = k.key.startsWith(KEY_PREFIX_HEAD)
        ? deriveKeyPrefix(k.key)
        : k.key.slice(0, 14);
      repo.createGatewayVirtualKey({
        keyHash: hash,
        keyPrefix: prefix,
        team: k.team,
        allowedModels: k.allowedModels ?? null,
        source: 'config-import',
        createdBy: 'config',
        // Phase 2b: propagate budget / rate limits if set in YAML. The
        // Repository normalizer guards against 0/negative → null.
        tokensBudget: k.tokensBudget ?? null,
        rateLimitRpm: k.rateLimitRpm ?? null,
      });
      out.imported++;
    } catch (e) {
      const msg = e instanceof Error ? e.message : String(e);
      if (/UNIQUE/i.test(msg)) {
        // Another instance won the race; the key is in the DB either
        // way, so this is a successful skip from the cluster's point of
        // view.
        logger.info(
          `[gateway-migration] config-import race detected for team=${k.team}, ` +
            'another instance already imported this key (skipping)',
        );
        out.skipped++;
      } else {
        // Don't abort the whole import on a single bad key; log and
        // continue so the rest of config still reaches the DB.
        logger.warn(
          `[gateway-migration] config-import failed for team=${k.team}: ${msg} (continuing with remaining keys)`,
        );
      }
    }
  }
  return out;
}

/**
 * F5 helper: shallow equality for two `string[] | null` lists, treating
 * `null`, undefined, and `[]` uniformly. Order matters — a reordered
 * allowlist is treated as drift so the update happens (cheap call, and
 * operators occasionally reorder to express priority intent).
 *
 * Why `[]` is treated as equivalent to `null` HERE (drift comparison)
 * but not at the Repository layer (where `[]` semantically means "lock
 * to zero models"): a YAML author who writes `allowed_models: []` and
 * a YAML author who omits the field both expect the same DB shape,
 * but the Repository persists `[]` differently from `null`. Without
 * this normalization the importer detected drift on every boot and
 * thrashed the same row over and over (UPDATE ... SET allowed_models
 * = '[]'). We accept the cost that an operator who deliberately wants
 * the "lock to zero models" semantic via YAML must use the admin API
 * instead — the much more common case (omit / empty) stays idempotent.
 */
function arraysEqualOrBothNull(a: string[] | null | undefined, b: string[] | null | undefined): boolean {
  const aa = normalizeEmptyToNull(a);
  const bb = normalizeEmptyToNull(b);
  if (aa === null && bb === null) return true;
  if (aa === null || bb === null) return false;
  if (aa.length !== bb.length) return false;
  for (let i = 0; i < aa.length; i++) {
    if (aa[i] !== bb[i]) return false;
  }
  return true;
}

function normalizeEmptyToNull(v: string[] | null | undefined): string[] | null {
  if (v === null || v === undefined) return null;
  if (v.length === 0) return null;
  return v;
}

function describeAllowed(v: string[] | null): string {
  if (v === null) return 'null';
  if (v.length === 0) return '[]';
  return `[${v.join(',')}]`;
}

function isImportable(k: GatewayVirtualKey): k is GatewayVirtualKey {
  return (
    typeof k?.key === 'string' &&
    k.key.length > 0 &&
    typeof k?.team === 'string' &&
    k.team.length > 0
  );
}

export interface OrphanReport {
  /** Active config-import rows whose hash is NOT in the supplied config snapshot. */
  orphans: Array<{ keyPrefix: string; team: string }>;
}

/**
 * Phase 3a F1: warn at boot when active `source='config-import'` rows
 * exist in the DB but no longer have a matching entry in config.yaml.
 *
 * Background: by Phase 2a design, deleting a virtual_key from config.yaml
 * does NOT remove the corresponding DB row — operators have to call
 * POST /api/admin/gateway/keys/:id/revoke to disable the key. Without
 * this report, an admin who edits config thinking it's the source of
 * truth will be surprised when the removed key keeps authenticating
 * traffic.
 *
 * Pure: takes a config snapshot + a Repository slice, returns the
 * orphan list. The caller logs it (or surfaces it in admin UI later).
 * Splitting the data collection from the side-effect makes the helper
 * trivially unit-testable.
 */
export function reportOrphanedConfigImports(
  cfg: Pick<GatewayConfig, 'virtualKeys'>,
  repo: GatewayKeyOrphanReportRepo,
): OrphanReport {
  const configHashes = new Set<string>();
  for (const k of cfg.virtualKeys ?? []) {
    if (!isImportable(k)) continue;
    configHashes.add(hashKey(k.key));
  }
  // listGatewayVirtualKeys({activeOnly: true}) returns non-revoked rows.
  // We filter to source='config-import' and then to those NOT in the
  // current config snapshot — these are the orphans an operator likely
  // removed from YAML without revoking.
  const active = repo.listGatewayVirtualKeys({ activeOnly: true });
  const orphans: Array<{ keyPrefix: string; team: string }> = [];
  for (const row of active) {
    if (row.source !== 'config-import') continue;
    if (configHashes.has(row.keyHash)) continue;
    orphans.push({ keyPrefix: row.keyPrefix, team: row.team });
  }
  return { orphans };
}

/**
 * Convenience side-effect wrapper around reportOrphanedConfigImports.
 * Emits a single warn line listing the orphans (sorted for log
 * determinism) so operators see one digestible message at startup.
 * No-op when there are no orphans (we never log a "0 orphans" line —
 * the absence of the warn IS the success signal).
 */
export function logOrphanedConfigImports(
  cfg: Pick<GatewayConfig, 'virtualKeys'>,
  repo: GatewayKeyOrphanReportRepo,
): OrphanReport {
  const report = reportOrphanedConfigImports(cfg, repo);
  if (report.orphans.length === 0) return report;
  // Sort by (team, keyPrefix) so the log line is stable across runs.
  // Without this the order would depend on Repository row insert order,
  // which makes log-diffing painful during incident triage.
  const sorted = [...report.orphans].sort((a, b) => {
    if (a.team !== b.team) return a.team < b.team ? -1 : 1;
    return a.keyPrefix < b.keyPrefix ? -1 : 1;
  });
  const summary = sorted.map(o => `${o.keyPrefix}(team=${o.team})`).join(', ');
  logger.warn(
    `[gateway-migration] ${sorted.length} active config-import virtual_key(s) present in DB but missing from config.yaml; ` +
      `they remain valid until revoked via POST /api/admin/gateway/keys/:id/revoke: ${summary}`,
  );
  return report;
}
