/**
 * Phase 2b — admin REST API integration tests for budget / rate / usage.
 *
 * Covers:
 *   - POST accepts tokensBudget + rateLimitRpm in the body
 *   - POST rejects malformed budget (NaN, negative, string)
 *   - PATCH updates fields independently (tokensBudget, rateLimitRpm,
 *     allowedModels) and supports null reset
 *   - PATCH rejects config-import keys with 400
 *   - GET /:id/usage returns currentPeriod stats + history
 *   - GET /:id/usage reports zero counters when there's no usage row
 *   - GET /:id/usage reports remaining=0 when over budget
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express, { type Request, type RequestHandler } from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository } from '../db/repository.js';
import { createAdminGatewayApi } from './admin-gateway-api.js';
import { currentPeriodYearMonth } from '../gateway/period.js';
import { hashKey } from '../gateway/key-format.js';

function buildAdminApp(repo: Repository): express.Application {
  const app = express();
  app.use(express.json({ limit: '4kb' }));
  const guard: RequestHandler = (req, _res, next) => {
    (req as Request & { user?: unknown }).user = { id: 'admin-1', role: 'admin', status: 'active' };
    next();
  };
  const router = createAdminGatewayApi({
    repo,
    requireAdmin: guard,
    getUserId: (req) => {
      const u = (req as Request & { user?: { id?: string } }).user;
      return u?.id ?? null;
    },
  });
  app.use('/api/admin/gateway/keys', router);
  return app;
}

describe('admin-gateway-api Phase 2b', () => {
  let tmpDir: string;
  let repo: Repository;
  let app: express.Application;

  beforeEach(() => {
    tmpDir = mkdtempSync(join(tmpdir(), 'admin-gw-2b-'));
    repo = new Repository(join(tmpDir, 'test.db'));
    app = buildAdminApp(repo);
  });

  afterEach(() => {
    repo.close();
    rmSync(tmpDir, { recursive: true, force: true });
  });

  describe('POST /', () => {
    it('accepts tokensBudget + rateLimitRpm and round-trips them on GET', async () => {
      const created = await request(app).post('/api/admin/gateway/keys').send({
        team: 'alpha',
        tokensBudget: 1_000_000,
        rateLimitRpm: 60,
      });
      expect(created.status).toBe(201);
      expect(created.body.tokensBudget).toBe(1_000_000);
      expect(created.body.rateLimitRpm).toBe(60);
      expect(typeof created.body.key).toBe('string');

      const get = await request(app).get(`/api/admin/gateway/keys/${created.body.id}`);
      expect(get.status).toBe(200);
      expect(get.body.tokensBudget).toBe(1_000_000);
      expect(get.body.rateLimitRpm).toBe(60);
      expect(get.body.key).toBeUndefined();
    });

    it('defaults to null when budget/rate omitted', async () => {
      const created = await request(app).post('/api/admin/gateway/keys').send({ team: 'beta' });
      expect(created.body.tokensBudget).toBeNull();
      expect(created.body.rateLimitRpm).toBeNull();
    });

    it.each([
      ['negative budget', { team: 'alpha', tokensBudget: -1 }],
      ['zero budget', { team: 'alpha', tokensBudget: 0 }],
      ['string budget', { team: 'alpha', tokensBudget: 'lots' }],
      ['negative rpm', { team: 'alpha', rateLimitRpm: -5 }],
      ['zero rpm', { team: 'alpha', rateLimitRpm: 0 }],
    ])('rejects %s with 400', async (_name, body) => {
      const res = await request(app).post('/api/admin/gateway/keys').send(body);
      expect(res.status).toBe(400);
    });

    // F7: hard caps to keep INTEGER arithmetic exact in SQLite.
    it('rejects tokensBudget above 1e12 with 400 (F7: INT overflow guard)', async () => {
      const res = await request(app).post('/api/admin/gateway/keys').send({
        team: 'alpha',
        tokensBudget: 1e20,
      });
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/tokensBudget exceeds maximum/);
    });

    it('rejects tokensBudget == max+1 with 400', async () => {
      const res = await request(app).post('/api/admin/gateway/keys').send({
        team: 'alpha',
        tokensBudget: 1_000_000_000_001,
      });
      expect(res.status).toBe(400);
    });

    it('accepts tokensBudget == max with 201', async () => {
      const res = await request(app).post('/api/admin/gateway/keys').send({
        team: 'alpha',
        tokensBudget: 1_000_000_000_000,
      });
      expect(res.status).toBe(201);
      expect(res.body.tokensBudget).toBe(1_000_000_000_000);
    });

    it('floors fractional tokensBudget to an integer (1.7 → 1)', async () => {
      const res = await request(app).post('/api/admin/gateway/keys').send({
        team: 'alpha',
        tokensBudget: 1.7,
      });
      expect(res.status).toBe(201);
      expect(res.body.tokensBudget).toBe(1);
    });

    it('rejects rateLimitRpm above 1e6 with 400', async () => {
      const res = await request(app).post('/api/admin/gateway/keys').send({
        team: 'alpha',
        rateLimitRpm: 1_000_001,
      });
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/rateLimitRpm exceeds maximum/);
    });

    it('accepts rateLimitRpm at the cap', async () => {
      const res = await request(app).post('/api/admin/gateway/keys').send({
        team: 'alpha',
        rateLimitRpm: 1_000_000,
      });
      expect(res.status).toBe(201);
      expect(res.body.rateLimitRpm).toBe(1_000_000);
    });
  });

  describe('PATCH /:id', () => {
    async function seedAdminKey(): Promise<string> {
      const created = await request(app).post('/api/admin/gateway/keys').send({
        team: 'alpha',
        tokensBudget: 1000,
        rateLimitRpm: 30,
        allowedModels: ['qwen3:8b'],
      });
      return created.body.id as string;
    }

    it('updates tokensBudget only, leaving others alone', async () => {
      const id = await seedAdminKey();
      const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 5000 });
      expect(patched.status).toBe(200);
      expect(patched.body.tokensBudget).toBe(5000);
      expect(patched.body.rateLimitRpm).toBe(30);
      expect(patched.body.allowedModels).toEqual(['qwen3:8b']);
    });

    it('updates allowedModels alone', async () => {
      const id = await seedAdminKey();
      const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ allowedModels: ['qwen3:14b'] });
      expect(patched.body.allowedModels).toEqual(['qwen3:14b']);
    });

    it('explicit null clears tokensBudget back to unlimited', async () => {
      const id = await seedAdminKey();
      const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: null });
      expect(patched.body.tokensBudget).toBeNull();
    });

    it('explicit null clears allowedModels', async () => {
      const id = await seedAdminKey();
      const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ allowedModels: null });
      expect(patched.body.allowedModels).toBeNull();
    });

    it('rejects an empty body with 400', async () => {
      const id = await seedAdminKey();
      const res = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({});
      expect(res.status).toBe(400);
    });

    it('returns 404 for unknown id', async () => {
      const res = await request(app).patch(`/api/admin/gateway/keys/nope`).send({ tokensBudget: 1 });
      expect(res.status).toBe(404);
    });

    it('PATCH rejects tokensBudget above 1e12 with 400 (F7)', async () => {
      const id = await seedAdminKey();
      const res = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 1e20 });
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/tokensBudget exceeds maximum/);
    });

    it('rejects PATCH on revoked key with 409 (F6: consistency with rotate)', async () => {
      const id = await seedAdminKey();
      // Revoke first.
      const revokeRes = await request(app).post(`/api/admin/gateway/keys/${id}/revoke`).send({});
      expect(revokeRes.status).toBe(200);
      // Now PATCH must fail with 409.
      const patchRes = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 9999 });
      expect(patchRes.status).toBe(409);
      expect(patchRes.body.error).toMatch(/revoked/);
      expect(patchRes.body.revokedAt).toBeTruthy();
      // Original value still intact (PATCH was rejected before write).
      const get = await request(app).get(`/api/admin/gateway/keys/${id}`);
      expect(get.body.tokensBudget).toBe(1000);
    });

    it('still allows PATCH on an active key (regression guard for F6)', async () => {
      const id = await seedAdminKey();
      const patched = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 7777 });
      expect(patched.status).toBe(200);
      expect(patched.body.tokensBudget).toBe(7777);
    });

    it('PATCH and concurrent revoke are atomic — revoked-mid-patch returns 409 (TOCTOU guard)', async () => {
      // Phase 3a follow-up regression guard. Pre-fix the read (revoked
      // check) and the UPDATE lived outside any transaction, so this
      // sequence:
      //   1. PATCH handler reads row → not revoked
      //   2. concurrent admin revokes
      //   3. PATCH handler UPDATEs the now-revoked row
      // left a ghost mutation: the row's policy fields changed AFTER
      // the revoke timestamp, but the row could no longer authenticate.
      //
      // Post-fix the read + update both run inside a single
      // better-sqlite3 transaction. To prove the read-time guard fires
      // we stub findGatewayVirtualKeyById to return a revoked row on
      // the first call (the one made inside the PATCH txn) — this
      // simulates what the txn's read would observe if a concurrent
      // revoke had committed just before the txn started.
      //
      // We can't reproduce a true concurrent-write race via repo.revoke
      // from inside the txn because that write would be rolled back
      // along with everything else when the txn aborts; mocking the
      // observed read isolates the handler's behavior cleanly.
      const id = await seedAdminKey();
      const original = repo.findGatewayVirtualKeyById.bind(repo);
      let raced = false;
      const spy = vi.spyOn(repo, 'findGatewayVirtualKeyById').mockImplementation((targetId: string) => {
        const out = original(targetId);
        if (!raced && targetId === id && out && out.revokedAt === null) {
          raced = true;
          // Simulate a concurrent revoke that committed just before the
          // PATCH txn read. Returning a synthesized revoked snapshot
          // forces the handler down the 409 path.
          return { ...out, revokedAt: '2026-05-19T00:00:00.000Z', revokedBy: 'racing-admin' };
        }
        return out;
      });
      try {
        const res = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 7777 });
        expect(res.status).toBe(409);
        expect(res.body.error).toMatch(/revoked/);
        expect(raced).toBe(true);
      } finally {
        spy.mockRestore();
      }
      // Crucially: the budget did NOT change to 7777 — the transaction
      // aborted before the UPDATE. Pre-fix this would be 7777 because
      // the validation lived outside the write path. After restoring
      // the spy we read the row directly: budget unchanged, NOT
      // actually revoked (the spy was synthesizing the revoked state).
      const fresh = repo.findGatewayVirtualKeyById(id);
      expect(fresh?.tokensBudget).toBe(1000);
    });

    it('true cross-connection race: revoke from a sibling Repository before PATCH still 409s (atomicity smoke)', async () => {
      // Belt-and-suspenders for the TOCTOU fix: open a second Repository
      // pointing at the SAME DB file, revoke through it, and confirm
      // the original handler's PATCH txn sees the revoke and 409s. This
      // exercises the actual sqlite isolation semantics (better-sqlite3
      // uses the file lock per-connection so writes are visible to other
      // connections at txn-commit boundaries).
      const id = await seedAdminKey();
      // Reach into the test fixture's tmpDir via the repo's db path.
      // The Repository keeps the path internally but not on the public
      // API; we rely on the well-known location from the test fixture.
      const dbPath = (repo.getDb() as unknown as { name?: string }).name;
      if (!dbPath || typeof dbPath !== 'string') {
        // Should not happen with better-sqlite3 — guard so the test
        // surfaces a clear failure rather than a cryptic crash.
        throw new Error('test setup: could not read repo db path');
      }
      const sibling = new Repository(dbPath);
      try {
        // Concurrent revoke commits BEFORE the PATCH txn starts.
        expect(sibling.revokeGatewayVirtualKey(id, 'sibling-admin')).toBe(true);
        // PATCH must observe the revoked state inside its own txn
        // (better-sqlite3 reads via the journal so the revoke is
        // visible) and return 409. Pre-fix this depended on the same
        // observation but the validation was outside the write path,
        // so a revoke landing AFTER the read but BEFORE the UPDATE
        // would slip through. Post-fix the atomic txn guarantees no
        // such window exists.
        const res = await request(app).patch(`/api/admin/gateway/keys/${id}`).send({ tokensBudget: 1234 });
        expect(res.status).toBe(409);
        expect(res.body.error).toMatch(/revoked/);
        const fresh = repo.findGatewayVirtualKeyById(id);
        expect(fresh?.tokensBudget).toBe(1000);
        expect(fresh?.revokedAt).not.toBeNull();
      } finally {
        sibling.close();
      }
    });

    it('rejects PATCH on config-import row with 400', async () => {
      // Seed a config-import row directly via the Repository — admin
      // POST always uses 'admin' source so this is the only way to
      // exercise the guard.
      const created = repo.createGatewayVirtualKey({
        keyHash: hashKey('config-key-raw'),
        keyPrefix: 'config-key-r',
        team: 'imported',
        source: 'config-import',
      });
      const res = await request(app).patch(`/api/admin/gateway/keys/${created.id}`).send({ tokensBudget: 9999 });
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/config-import/);
    });
  });

  describe('GET /:id/usage', () => {
    async function seedAdminKeyWithBudget(budget = 1000): Promise<string> {
      const created = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha', tokensBudget: budget });
      return created.body.id as string;
    }

    it('returns zero counters when no usage row exists yet', async () => {
      const id = await seedAdminKeyWithBudget();
      const res = await request(app).get(`/api/admin/gateway/keys/${id}/usage`);
      expect(res.status).toBe(200);
      expect(res.body.keyId).toBe(id);
      expect(res.body.currentPeriod).toBe(currentPeriodYearMonth());
      expect(res.body.tokensIn).toBe(0);
      expect(res.body.tokensOut).toBe(0);
      expect(res.body.tokensTotal).toBe(0);
      expect(res.body.tokensBudget).toBe(1000);
      expect(res.body.remaining).toBe(1000);
      expect(res.body.requestsThisMonth).toBe(0);
      expect(res.body.history).toEqual([]);
      // F9: rateRecentRequests was dead (always null, UI never read it)
      // and is dropped from the wire shape entirely.
      expect(res.body).not.toHaveProperty('rateRecentRequests');
    });

    it('reports current-period totals and remaining headroom', async () => {
      const id = await seedAdminKeyWithBudget(1000);
      repo.incrementGatewayKeyUsage({ keyId: id, period: currentPeriodYearMonth(), tokensIn: 200, tokensOut: 300, requests: 5 });
      const res = await request(app).get(`/api/admin/gateway/keys/${id}/usage`);
      expect(res.body.tokensIn).toBe(200);
      expect(res.body.tokensOut).toBe(300);
      expect(res.body.tokensTotal).toBe(500);
      expect(res.body.remaining).toBe(500);
      expect(res.body.requestsThisMonth).toBe(5);
    });

    it('clamps remaining at 0 when over budget', async () => {
      const id = await seedAdminKeyWithBudget(500);
      repo.incrementGatewayKeyUsage({ keyId: id, period: currentPeriodYearMonth(), tokensIn: 600, tokensOut: 100 });
      const res = await request(app).get(`/api/admin/gateway/keys/${id}/usage`);
      expect(res.body.tokensTotal).toBe(700);
      expect(res.body.remaining).toBe(0);
    });

    it('returns null remaining for unlimited budget', async () => {
      const created = await request(app).post('/api/admin/gateway/keys').send({ team: 'alpha' });
      repo.incrementGatewayKeyUsage({ keyId: created.body.id, period: currentPeriodYearMonth(), tokensIn: 9999 });
      const res = await request(app).get(`/api/admin/gateway/keys/${created.body.id}/usage`);
      expect(res.body.tokensBudget).toBeNull();
      expect(res.body.remaining).toBeNull();
    });

    it('history contains older periods, ordered DESC, excluding current period', async () => {
      const id = await seedAdminKeyWithBudget();
      repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-03', tokensIn: 10, requests: 1 });
      repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-04', tokensIn: 20, requests: 2 });
      repo.incrementGatewayKeyUsage({ keyId: id, period: currentPeriodYearMonth(), tokensIn: 30, requests: 3 });
      const res = await request(app).get(`/api/admin/gateway/keys/${id}/usage`);
      const periods = (res.body.history as Array<{ period: string }>).map(h => h.period);
      expect(periods).toEqual(['2026-04', '2026-03']);
    });

    it('returns 404 for unknown id', async () => {
      const res = await request(app).get(`/api/admin/gateway/keys/missing/usage`);
      expect(res.status).toBe(404);
    });
  });
});
