/**
 * Tests for AAO Gateway Phase 2a config → DB auto-migration.
 *
 * The importer drives the Repository through a narrow interface
 * (GatewayKeyImportRepo) so we test against an in-memory fake here and
 * against the real Repository in repository.gateway-keys.test.ts.
 */
import { describe, it, expect, vi } from 'vitest';
import {
  importConfigKeysToDb,
  logOrphanedConfigImports,
  reportOrphanedConfigImports,
  type GatewayKeyImportRepo,
  type GatewayKeyOrphanReportRepo,
} from './config-migration.js';
import { hashKey } from './key-format.js';
import type { GatewayVirtualKey } from './config.js';
import { logger } from '../logger.js';

interface FakeRow {
  id: string;
  keyHash: string;
  keyPrefix: string;
  team: string;
  source: 'admin' | 'config-import';
  tokensBudget: number | null;
  rateLimitRpm: number | null;
  allowedModels: string[] | null;
}

function makeFakeRepo(): GatewayKeyImportRepo & { rows: FakeRow[] } {
  const rows: FakeRow[] = [];
  return {
    rows,
    findGatewayVirtualKeyByHash(keyHash: string) {
      const r = rows.find(x => x.keyHash === keyHash);
      if (!r) return null;
      return {
        id: r.id,
        source: r.source,
        team: r.team,
        tokensBudget: r.tokensBudget,
        rateLimitRpm: r.rateLimitRpm,
        allowedModels: r.allowedModels,
      };
    },
    createGatewayVirtualKey(params) {
      const row: FakeRow = {
        id: `id-${params.keyHash.slice(0, 6)}`,
        keyHash: params.keyHash,
        keyPrefix: params.keyPrefix,
        team: params.team,
        source: params.source,
        tokensBudget: params.tokensBudget ?? null,
        rateLimitRpm: params.rateLimitRpm ?? null,
        allowedModels: params.allowedModels ?? null,
      };
      rows.push(row);
      return params;
    },
    updateGatewayVirtualKey(id, patch) {
      const r = rows.find(x => x.id === id);
      if (!r) throw new Error(`fake-repo: updateGatewayVirtualKey id not found (${id})`);
      if (Object.prototype.hasOwnProperty.call(patch, 'team') && patch.team !== undefined) r.team = patch.team;
      if (Object.prototype.hasOwnProperty.call(patch, 'tokensBudget')) r.tokensBudget = patch.tokensBudget ?? null;
      if (Object.prototype.hasOwnProperty.call(patch, 'rateLimitRpm')) r.rateLimitRpm = patch.rateLimitRpm ?? null;
      if (Object.prototype.hasOwnProperty.call(patch, 'allowedModels')) r.allowedModels = patch.allowedModels ?? null;
      return r;
    },
  };
}

describe('importConfigKeysToDb', () => {
  it('imports all config entries on a fresh DB', () => {
    const repo = makeFakeRepo();
    const keys: GatewayVirtualKey[] = [
      { key: 'sk-aao-ABCDEF1234567890ABCDEF1234567890', team: 'alpha' },
      { key: 'sk-team-beta-static', team: 'beta', allowedModels: ['qwen3:8b'] },
    ];
    const result = importConfigKeysToDb({ virtualKeys: keys }, repo);
    expect(result).toEqual({ imported: 2, skipped: 0, resynced: 0 });
    expect(repo.rows).toHaveLength(2);
    expect(repo.rows.every(r => r.source === 'config-import')).toBe(true);
  });

  it('is idempotent: a second run imports 0 and skips all', () => {
    const repo = makeFakeRepo();
    const keys: GatewayVirtualKey[] = [
      { key: 'sk-aao-A1B2C3D4E5F60718293A4B5C6D7E8F90', team: 'alpha' },
    ];
    importConfigKeysToDb({ virtualKeys: keys }, repo);
    const second = importConfigKeysToDb({ virtualKeys: keys }, repo);
    expect(second).toEqual({ imported: 0, skipped: 1, resynced: 0 });
    expect(repo.rows).toHaveLength(1);
  });

  it('derives sk-aao prefix for AAO-shaped keys and falls back to 14-char head', () => {
    const repo = makeFakeRepo();
    const keys: GatewayVirtualKey[] = [
      { key: 'sk-aao-ZZZZZZ0000000000000000000000000Z', team: 'alpha' },
      { key: 'sk-team-betalongtokenvalue', team: 'beta' },
    ];
    importConfigKeysToDb({ virtualKeys: keys }, repo);
    const aaoRow = repo.rows.find(r => r.team === 'alpha')!;
    expect(aaoRow.keyPrefix).toBe('sk-aao-ZZZZZZ');
    const betaRow = repo.rows.find(r => r.team === 'beta')!;
    expect(betaRow.keyPrefix).toBe('sk-team-betalo');
    expect(betaRow.keyPrefix).toHaveLength(14);
  });

  it('hashes the raw key to the same digest as hashKey()', () => {
    const repo = makeFakeRepo();
    importConfigKeysToDb(
      { virtualKeys: [{ key: 'sk-known-raw', team: 'alpha' }] },
      repo,
    );
    expect(repo.rows[0]!.keyHash).toBe(hashKey('sk-known-raw'));
  });

  it('skips malformed entries (missing team or empty key)', () => {
    const repo = makeFakeRepo();
    const dirty = [
      { key: '', team: 'alpha' },
      { key: 'sk-no-team', team: '' },
      { key: 'sk-good', team: 'good' },
    ] as GatewayVirtualKey[];
    const result = importConfigKeysToDb({ virtualKeys: dirty }, repo);
    expect(result).toEqual({ imported: 1, skipped: 0, resynced: 0 });
    expect(repo.rows.map(r => r.team)).toEqual(['good']);
  });

  it('tolerates missing virtualKeys array (treats as no-op)', () => {
    const repo = makeFakeRepo();
    const result = importConfigKeysToDb({ virtualKeys: undefined as unknown as GatewayVirtualKey[] }, repo);
    expect(result).toEqual({ imported: 0, skipped: 0, resynced: 0 });
    expect(repo.rows).toHaveLength(0);
  });

  it('end-to-end: write then read via the auth-style hash lookup', () => {
    const repo = makeFakeRepo();
    const raw = 'sk-end-to-end-secret';
    importConfigKeysToDb({ virtualKeys: [{ key: raw, team: 'alpha' }] }, repo);
    const found = repo.findGatewayVirtualKeyByHash(hashKey(raw));
    expect(found).not.toBeNull();
  });

  it('uses the supplied logger only on bad entries (smoke)', () => {
    const repo = makeFakeRepo();
    const warn = vi.spyOn(console, 'warn');
    importConfigKeysToDb(
      { virtualKeys: [{ key: '', team: 'bad' }] as unknown as GatewayVirtualKey[] },
      repo,
    );
    warn.mockRestore();
  });

  describe('race tolerance (Phase 2a follow-up)', () => {
    it('treats UNIQUE error from createGatewayVirtualKey as a successful skip', () => {
      // Simulates: instance A and instance B both observe "not found"
      // and race to insert; B's create throws SQLITE UNIQUE violation.
      // The import must continue and count the racing key as skipped.
      const repo: GatewayKeyImportRepo = {
        findGatewayVirtualKeyByHash: () => null, // pretend the key doesn't exist
        createGatewayVirtualKey: () => {
          throw new Error('SQLITE_CONSTRAINT_UNIQUE: gateway_virtual_keys.key_hash');
        },
        updateGatewayVirtualKey: () => {
          throw new Error('unexpected update call');
        },
      };
      const keys: GatewayVirtualKey[] = [{ key: 'sk-aao-race', team: 'racy' }];
      const result = importConfigKeysToDb({ virtualKeys: keys }, repo);
      expect(result).toEqual({ imported: 0, skipped: 1, resynced: 0 });
    });

    it('logs and continues when a single key fails with a non-UNIQUE error', () => {
      // Simulates: one key import hits an unexpected runtime error
      // (e.g. transient lock contention with non-UNIQUE message). We
      // must keep going so the rest of config still reaches the DB.
      const calls: string[] = [];
      const repo: GatewayKeyImportRepo = {
        findGatewayVirtualKeyByHash: () => null,
        createGatewayVirtualKey: (params) => {
          calls.push(params.team);
          if (params.team === 'bad') throw new Error('database is locked');
          return params;
        },
        updateGatewayVirtualKey: () => {
          throw new Error('unexpected update call');
        },
      };
      const keys: GatewayVirtualKey[] = [
        { key: 'sk-good-1', team: 'good1' },
        { key: 'sk-bad', team: 'bad' },
        { key: 'sk-good-2', team: 'good2' },
      ];
      const result = importConfigKeysToDb({ virtualKeys: keys }, repo);
      // The bad key failed (no imported, no skipped); the other two
      // imported successfully.
      expect(result.imported).toBe(2);
      expect(calls).toEqual(['good1', 'bad', 'good2']);
    });

    it('resyncs config-import key when YAML budget changes (F5)', () => {
      const repo = makeFakeRepo();
      // Initial import.
      const first = importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-aao-r5', team: 'alpha', tokensBudget: 1000, rateLimitRpm: 30 }] },
        repo,
      );
      expect(first).toEqual({ imported: 1, skipped: 0, resynced: 0 });
      // Second import with a higher budget.
      const second = importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-aao-r5', team: 'alpha', tokensBudget: 5000, rateLimitRpm: 30 }] },
        repo,
      );
      expect(second).toEqual({ imported: 0, skipped: 0, resynced: 1 });
      expect(repo.rows[0]!.tokensBudget).toBe(5000);
      expect(repo.rows[0]!.rateLimitRpm).toBe(30);
    });

    it('resyncs config-import key when allowedModels changes (F5)', () => {
      const repo = makeFakeRepo();
      importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-aao-r5b', team: 'alpha', allowedModels: ['qwen3:8b'] }] },
        repo,
      );
      const second = importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-aao-r5b', team: 'alpha', allowedModels: ['qwen3:8b', 'qwen3:14b'] }] },
        repo,
      );
      expect(second.resynced).toBe(1);
      expect(repo.rows[0]!.allowedModels).toEqual(['qwen3:8b', 'qwen3:14b']);
    });

    it('does NOT resync when nothing changed (F5: idempotent)', () => {
      const repo = makeFakeRepo();
      const keys = [{ key: 'sk-aao-r5c', team: 'alpha', tokensBudget: 1000 }];
      importConfigKeysToDb({ virtualKeys: keys }, repo);
      const second = importConfigKeysToDb({ virtualKeys: keys }, repo);
      expect(second).toEqual({ imported: 0, skipped: 1, resynced: 0 });
    });

    it('does NOT touch admin-source rows even if budget differs (F5: ownership)', () => {
      // Admin issued a key whose hash happens to match a YAML entry —
      // unrealistic but the safety guard matters because future
      // workflows might import keys both ways.
      const repo = makeFakeRepo();
      // Seed an admin-source row directly.
      repo.rows.push({
        id: 'admin-1',
        keyHash: hashKey('sk-admin-key'),
        keyPrefix: 'sk-admin-key',
        team: 'alpha',
        source: 'admin',
        tokensBudget: 100,
        rateLimitRpm: 5,
        allowedModels: null,
      });
      const result = importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-admin-key', team: 'alpha', tokensBudget: 999_999, rateLimitRpm: 999 }] },
        repo,
      );
      expect(result).toEqual({ imported: 0, skipped: 1, resynced: 0 });
      // Admin row preserved unchanged.
      expect(repo.rows[0]!.tokensBudget).toBe(100);
      expect(repo.rows[0]!.rateLimitRpm).toBe(5);
    });

    it('resyncs team when YAML rename happens (Phase 3a follow-up)', () => {
      // Bearer hash is the row identity. If an operator renames the team
      // in YAML (e.g. typo correction, org rename) the DB must follow —
      // otherwise downstream logs / budget rollups carry the stale label.
      const repo = makeFakeRepo();
      importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-rename', team: 'alpha', tokensBudget: 100 }] },
        repo,
      );
      expect(repo.rows[0]!.team).toBe('alpha');
      const renamed = importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-rename', team: 'beta', tokensBudget: 100 }] },
        repo,
      );
      expect(renamed).toEqual({ imported: 0, skipped: 0, resynced: 1 });
      expect(repo.rows[0]!.team).toBe('beta');
    });

    it('team rename log line includes the old → new team rename detail', () => {
      const repo = makeFakeRepo();
      importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-rename-log', team: 'alpha' }] },
        repo,
      );
      const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {});
      importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-rename-log', team: 'beta' }] },
        repo,
      );
      const calls = infoSpy.mock.calls.filter(c =>
        typeof c[0] === 'string' && c[0].includes('resynced config-import key'),
      );
      expect(calls).toHaveLength(1);
      const msg = calls[0]![0] as string;
      expect(msg).toMatch(/team='alpha'->'beta'|team='alpha'→'beta'/);
      infoSpy.mockRestore();
    });

    it('does NOT resync when team is unchanged (idempotent)', () => {
      const repo = makeFakeRepo();
      importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-same-team', team: 'alpha' }] },
        repo,
      );
      const second = importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-same-team', team: 'alpha' }] },
        repo,
      );
      expect(second).toEqual({ imported: 0, skipped: 1, resynced: 0 });
    });

    it('treats YAML allowed_models:[] as equivalent to null on the second boot (no false drift)', () => {
      // Regression guard for the F5 drift comparison: `[]` and `null`
      // both express "no allowlist restriction" from the YAML author's
      // POV, so the importer must NOT thrash a row over and over just
      // because the Repository persists `[]` differently from `null`.
      const repo = makeFakeRepo();
      // First import: YAML has empty array → DB stores [].
      importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-empty', team: 'alpha', allowedModels: [] }] },
        repo,
      );
      // Second boot, same YAML → must skip, NOT resync.
      const second = importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-empty', team: 'alpha', allowedModels: [] }] },
        repo,
      );
      expect(second).toEqual({ imported: 0, skipped: 1, resynced: 0 });
      // Third boot, operator drops the field entirely → still no drift,
      // because the importer treats [] and null/undefined as equivalent
      // for the purpose of comparison.
      const third = importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-empty', team: 'alpha' }] },
        repo,
      );
      expect(third).toEqual({ imported: 0, skipped: 1, resynced: 0 });
    });

    it('clears budget / rate / allowed back to null when YAML drops them (F5)', () => {
      const repo = makeFakeRepo();
      importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-aao-r5d', team: 'alpha', tokensBudget: 1000, rateLimitRpm: 30, allowedModels: ['qwen3:8b'] }] },
        repo,
      );
      // YAML edited to remove all the optional fields.
      const second = importConfigKeysToDb(
        { virtualKeys: [{ key: 'sk-aao-r5d', team: 'alpha' }] },
        repo,
      );
      expect(second.resynced).toBe(1);
      expect(repo.rows[0]!.tokensBudget).toBeNull();
      expect(repo.rows[0]!.rateLimitRpm).toBeNull();
      expect(repo.rows[0]!.allowedModels).toBeNull();
    });

    it('reports orphaned config-import keys present in DB but not in config (F1)', () => {
      // Two keys in DB; only one in config → the other is an orphan.
      const rows = [
        { keyHash: hashKey('sk-keep'), keyPrefix: 'sk-aao-keep__', team: 'kept', source: 'config-import' as const },
        { keyHash: hashKey('sk-gone'), keyPrefix: 'sk-aao-gone__', team: 'orphaned', source: 'config-import' as const },
        // admin-source rows are never reported (they aren't managed by config.yaml).
        { keyHash: hashKey('sk-admin'), keyPrefix: 'sk-aao-admin_', team: 'admin-only', source: 'admin' as const },
      ];
      const repo: GatewayKeyOrphanReportRepo = {
        listGatewayVirtualKeys: () => rows,
      };
      const report = reportOrphanedConfigImports(
        { virtualKeys: [{ key: 'sk-keep', team: 'kept' }] },
        repo,
      );
      expect(report.orphans).toEqual([{ keyPrefix: 'sk-aao-gone__', team: 'orphaned' }]);
    });

    it('returns empty orphan list when every config-import row is still in config', () => {
      const rows = [
        { keyHash: hashKey('sk-a'), keyPrefix: 'sk-aao-aaaaaa', team: 'a', source: 'config-import' as const },
        { keyHash: hashKey('sk-b'), keyPrefix: 'sk-aao-bbbbbb', team: 'b', source: 'config-import' as const },
      ];
      const repo: GatewayKeyOrphanReportRepo = {
        listGatewayVirtualKeys: () => rows,
      };
      const report = reportOrphanedConfigImports(
        { virtualKeys: [{ key: 'sk-a', team: 'a' }, { key: 'sk-b', team: 'b' }] },
        repo,
      );
      expect(report.orphans).toEqual([]);
    });

    it('logOrphanedConfigImports warns once with sorted summary when orphans exist', () => {
      const rows = [
        { keyHash: hashKey('sk-z'), keyPrefix: 'sk-aao-zzzz', team: 'zeta', source: 'config-import' as const },
        { keyHash: hashKey('sk-a'), keyPrefix: 'sk-aao-aaaa', team: 'alpha', source: 'config-import' as const },
      ];
      const repo: GatewayKeyOrphanReportRepo = {
        listGatewayVirtualKeys: () => rows,
      };
      const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
      logOrphanedConfigImports({ virtualKeys: [] }, repo);
      const warnCalls = warnSpy.mock.calls.filter(c =>
        typeof c[0] === 'string' && c[0].includes('active config-import virtual_key'),
      );
      expect(warnCalls).toHaveLength(1);
      const msg = warnCalls[0]![0] as string;
      // Sorted alphabetically by team for log stability — alpha before zeta.
      expect(msg.indexOf('team=alpha')).toBeLessThan(msg.indexOf('team=zeta'));
      expect(msg).toMatch(/POST \/api\/admin\/gateway\/keys\/:id\/revoke/);
      warnSpy.mockRestore();
    });

    it('logOrphanedConfigImports is silent when no orphans exist (no log noise on healthy boot)', () => {
      const repo: GatewayKeyOrphanReportRepo = {
        listGatewayVirtualKeys: () => [],
      };
      const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
      logOrphanedConfigImports({ virtualKeys: [] }, repo);
      const warnCalls = warnSpy.mock.calls.filter(c =>
        typeof c[0] === 'string' && c[0].includes('active config-import virtual_key'),
      );
      expect(warnCalls).toHaveLength(0);
      warnSpy.mockRestore();
    });

    it('does not throw out of importConfigKeysToDb when every key fails', () => {
      // Bootstrap relies on this function not blowing up the whole
      // process when the DB is misbehaving — startup should keep going
      // and serve traffic via the config fallback if needed.
      const repo: GatewayKeyImportRepo = {
        findGatewayVirtualKeyByHash: () => null,
        createGatewayVirtualKey: () => {
          throw new Error('disk full');
        },
        updateGatewayVirtualKey: () => {
          throw new Error('unexpected update call');
        },
      };
      const keys: GatewayVirtualKey[] = [
        { key: 'k1', team: 'a' },
        { key: 'k2', team: 'b' },
      ];
      expect(() => importConfigKeysToDb({ virtualKeys: keys }, repo)).not.toThrow();
    });
  });
});
