/**
 * AAO Gateway Phase 2a — dual-lookup auth middleware tests.
 *
 * Scenarios:
 *   - DB hit short-circuits config path (config entry never consulted)
 *   - Config fallback fires deprecation warning at most once per instance
 *   - DB lookup throws → fall through to config (don't 5xx the request)
 *   - 401 when neither path matches
 *   - touchLastUsed is dedup'd within touchIntervalMs
 */
import { describe, it, expect, vi } from 'vitest';
import {
  buildAuthMiddleware,
  createTouchDeduper,
  type AuthenticatedRequest,
  type DbKeyMatch,
} from './auth.js';
import { hashKey } from './key-format.js';
import type { GatewayVirtualKey } from './config.js';
import { logger } from '../logger.js';

function mockReq(headers: Record<string, string>): AuthenticatedRequest {
  return {
    headers,
    ip: '127.0.0.1',
    path: '/v1/chat/completions',
  } as unknown as AuthenticatedRequest;
}

function mockRes(): { status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn>; code?: number; body?: unknown } {
  const o: { status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn>; code?: number; body?: unknown } = {
    status: vi.fn(),
    json: vi.fn(),
  };
  o.status.mockImplementation((c: number) => { o.code = c; return o; });
  o.json.mockImplementation((b: unknown) => { o.body = b; return o; });
  return o;
}

describe('createTouchDeduper', () => {
  it('returns true on first touch and false within the window', () => {
    const d = createTouchDeduper(10_000);
    expect(d.shouldTouch('k1', 1_000)).toBe(true);
    expect(d.shouldTouch('k1', 2_000)).toBe(false);
    expect(d.shouldTouch('k1', 10_999)).toBe(false);
  });

  it('returns true again after the window expires', () => {
    const d = createTouchDeduper(10_000);
    expect(d.shouldTouch('k1', 1_000)).toBe(true);
    expect(d.shouldTouch('k1', 11_001)).toBe(true);
  });

  it('keeps per-key counters separate', () => {
    const d = createTouchDeduper(10_000);
    expect(d.shouldTouch('a', 100)).toBe(true);
    expect(d.shouldTouch('b', 100)).toBe(true);
    expect(d.shouldTouch('a', 101)).toBe(false);
    expect(d.shouldTouch('b', 101)).toBe(false);
  });

  it('caps memory by evicting the oldest entry when at the LRU max', () => {
    // Use a small cap so the test is fast and the assertion clear.
    const cap = 5;
    const d = createTouchDeduper(10_000, cap);
    // Fill the LRU to its cap.
    for (let i = 0; i < cap; i++) {
      expect(d.shouldTouch(`k${i}`, 1_000 + i)).toBe(true);
    }
    expect(d.size()).toBe(cap);
    // Insert a brand-new key — k0 (oldest) must be evicted.
    expect(d.shouldTouch('k-new', 2_000)).toBe(true);
    expect(d.size()).toBe(cap);
    // k4 was the most recently inserted of the original keys; it is
    // still inside the LRU window and inside the time window, so a
    // re-touch dedupes (returns false). This confirms that filling the
    // LRU did not evict a "young" entry.
    expect(d.shouldTouch('k4', 2_001)).toBe(false);
    // k0 was evicted by the k-new insert, so a fresh touch on it must
    // return true (no dedupe).
    expect(d.shouldTouch('k0', 2_002)).toBe(true);
  });

  it('treats a hot key as recently-used so it survives one round of eviction', () => {
    // LRU rule: a key's touch bumps it to the tail of insertion order,
    // so when the cap is reached and a brand-new key arrives, the
    // actual oldest (the one that has not been touched recently) is the
    // one evicted — not the hot key.
    const cap = 3;
    const d = createTouchDeduper(10_000, cap);
    d.shouldTouch('a', 1_000);
    d.shouldTouch('b', 1_001);
    // Re-touch 'a' so it becomes the most-recent entry inside the LRU.
    // Insertion order becomes: [b, a].
    d.shouldTouch('a', 11_500); // > 10_000ms later, fresh touch
    d.shouldTouch('c', 11_600); // [b, a, c]
    // 'd' overflows the cap; 'b' (the actual oldest) is evicted, not 'a'.
    d.shouldTouch('d', 11_700); // [a, c, d]
    expect(d.size()).toBe(cap);
    // 'b' is gone -> new touch returns true. (This in turn evicts 'a'
    // because 'b' is now the brand-new key — that's the expected next
    // eviction in strict LRU order; verified in the previous test.)
    expect(d.shouldTouch('b', 11_701)).toBe(true);
  });

  it('rejects nonsense maxEntries values up front', () => {
    expect(() => createTouchDeduper(10_000, 0)).toThrow(/maxEntries/);
    expect(() => createTouchDeduper(10_000, -1)).toThrow(/maxEntries/);
    expect(() => createTouchDeduper(10_000, Number.NaN)).toThrow(/maxEntries/);
  });
});

describe('buildAuthMiddleware (dual-lookup)', () => {
  const configKeys: GatewayVirtualKey[] = [
    { key: 'sk-config-only', team: 'beta' },
  ];
  const dbKey: DbKeyMatch = {
    id: 'k-uuid-1',
    team: 'alpha',
    allowedModels: ['qwen3:8b'],
  };

  it('matches via DB first and never consults config', () => {
    const dbLookup = vi.fn((h: string): DbKeyMatch | null =>
      h === hashKey('sk-db-raw') ? dbKey : null,
    );
    const mw = buildAuthMiddleware({ keys: configKeys, dbLookup });
    const req = mockReq({ authorization: 'Bearer sk-db-raw' });
    const res = mockRes();
    const next = vi.fn();
    mw(req, res as unknown as import('express').Response, next);
    expect(next).toHaveBeenCalled();
    expect(req.gatewayAuth?.source).toBe('db');
    expect(req.gatewayAuth?.team).toBe('alpha');
    expect(req.gatewayAuth?.keyId).toBe('k-uuid-1');
    expect(dbLookup).toHaveBeenCalledTimes(1);
  });

  it('falls back to config when DB lookup returns null', () => {
    const dbLookup = vi.fn().mockReturnValue(null);
    const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
    const mw = buildAuthMiddleware({ keys: configKeys, dbLookup });

    const req = mockReq({ authorization: 'Bearer sk-config-only' });
    const res = mockRes();
    const next = vi.fn();
    mw(req, res as unknown as import('express').Response, next);
    expect(next).toHaveBeenCalled();
    expect(req.gatewayAuth?.source).toBe('config');
    expect(req.gatewayAuth?.team).toBe('beta');

    // Second config match must NOT re-warn.
    const req2 = mockReq({ authorization: 'Bearer sk-config-only' });
    const res2 = mockRes();
    const next2 = vi.fn();
    mw(req2, res2 as unknown as import('express').Response, next2);
    expect(next2).toHaveBeenCalled();

    const deprecationWarnings = warnSpy.mock.calls.filter(c =>
      typeof c[0] === 'string' && c[0].includes('matched via config'),
    );
    expect(deprecationWarnings).toHaveLength(1);
    warnSpy.mockRestore();
  });

  it('401s when neither DB nor config matches', () => {
    const dbLookup = vi.fn().mockReturnValue(null);
    const mw = buildAuthMiddleware({ keys: configKeys, dbLookup });
    const req = mockReq({ authorization: 'Bearer sk-totally-unknown' });
    const res = mockRes();
    const next = vi.fn();
    mw(req, res as unknown as import('express').Response, next);
    expect(res.code).toBe(401);
    expect(next).not.toHaveBeenCalled();
  });

  it('tolerates dbLookup throwing and falls back to config', () => {
    const dbLookup = vi.fn(() => {
      throw new Error('db is locked');
    });
    const mw = buildAuthMiddleware({ keys: configKeys, dbLookup });
    const req = mockReq({ authorization: 'Bearer sk-config-only' });
    const res = mockRes();
    const next = vi.fn();
    mw(req, res as unknown as import('express').Response, next);
    expect(next).toHaveBeenCalled();
    expect(req.gatewayAuth?.team).toBe('beta');
  });

  it('rate-limits the dbLookup-threw warning so a persistent failure does not flood logs', async () => {
    // Use fake timers so we can advance past the cooldown deterministically.
    vi.useFakeTimers();
    try {
      const dbLookup = vi.fn(() => {
        throw new Error('database is locked');
      });
      const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
      const mw = buildAuthMiddleware({ keys: configKeys, dbLookup });

      // 100 requests within the cooldown window should produce exactly
      // one "dbLookup threw" warning (the rest are suppressed). The
      // "reject" and "matched via config" warnings have their own
      // independent counters, so we filter by message substring.
      for (let i = 0; i < 100; i++) {
        const req = mockReq({ authorization: 'Bearer sk-config-only' });
        const res = mockRes();
        const next = vi.fn();
        mw(req, res as unknown as import('express').Response, next);
        expect(next).toHaveBeenCalled();
      }
      const dbWarnings = warnSpy.mock.calls.filter(c =>
        typeof c[0] === 'string' && c[0].includes('dbLookup threw'),
      );
      expect(dbWarnings).toHaveLength(1);
      expect(dbWarnings[0]![0]).toMatch(/database is locked/);
      expect(dbWarnings[0]![0]).toMatch(/suppressing similar warnings/);

      // Advance past the cooldown — the next request must emit a fresh
      // warning.
      vi.advanceTimersByTime(60_001);
      const req = mockReq({ authorization: 'Bearer sk-config-only' });
      const res = mockRes();
      const next = vi.fn();
      mw(req, res as unknown as import('express').Response, next);
      const dbWarningsAfter = warnSpy.mock.calls.filter(c =>
        typeof c[0] === 'string' && c[0].includes('dbLookup threw'),
      );
      expect(dbWarningsAfter).toHaveLength(2);
      warnSpy.mockRestore();
    } finally {
      vi.useRealTimers();
    }
  });

  it('invokes touchLastUsed once and dedups within the window', () => {
    const dbLookup = vi.fn().mockReturnValue(dbKey);
    const touchLastUsed = vi.fn();
    const mw = buildAuthMiddleware({
      keys: [],
      dbLookup,
      touchLastUsed,
      touchIntervalMs: 60_000,
    });
    for (let i = 0; i < 3; i++) {
      const req = mockReq({ authorization: 'Bearer anything' });
      const res = mockRes();
      const next = vi.fn();
      mw(req, res as unknown as import('express').Response, next);
      expect(next).toHaveBeenCalled();
    }
    expect(touchLastUsed).toHaveBeenCalledTimes(1);
    expect(touchLastUsed).toHaveBeenCalledWith('k-uuid-1');
  });

  it('cache wrapper: subsequent auth requests reuse the cached row (F4)', () => {
    // Simulates the bootstrap wiring: dbLookup is a cache-then-DB
    // closure. After the first hit, the inner DB call is bypassed.
    const dbStub = vi.fn().mockReturnValue(dbKey);
    let cached: DbKeyMatch | null = null;
    const cachedLookup = (h: string): DbKeyMatch | null => {
      if (cached) return cached;
      const row = dbStub(h);
      if (row) cached = row;
      return row;
    };
    const mw = buildAuthMiddleware({ keys: [], dbLookup: cachedLookup });
    for (let i = 0; i < 5; i++) {
      const req = mockReq({ authorization: 'Bearer sk-cached' });
      const res = mockRes();
      const next = vi.fn();
      mw(req, res as unknown as import('express').Response, next);
      expect(next).toHaveBeenCalled();
    }
    expect(dbStub).toHaveBeenCalledTimes(1); // 4 cache hits, 1 DB call
  });

  it('still resolves a DB match when allowedModels is null (no allowlist)', () => {
    const lax: DbKeyMatch = { id: 'k2', team: 'alpha', allowedModels: null };
    const mw = buildAuthMiddleware({
      keys: [],
      dbLookup: () => lax,
    });
    const req = mockReq({ authorization: 'Bearer sk-anything' });
    const res = mockRes();
    const next = vi.fn();
    mw(req, res as unknown as import('express').Response, next);
    expect(req.gatewayAuth?.allowedModels).toBeUndefined();
    expect(next).toHaveBeenCalled();
  });
});
