/**
 * Reveal / copy-again unit tests (0039 — encrypted-at-rest SDK keys).
 *
 * Covers the full copy-again contract:
 *   1. creating a key stores a non-empty `encrypted_key` ciphertext;
 *   2. revealing decrypts back to the SAME plaintext shown at creation;
 *   3. a revoked key, and a legacy key with no ciphertext, both refuse reveal;
 *   4. a successful reveal through the route writes a `*.revealed` audit row.
 *
 * Seams stubbed with bun's mock.module: `getDb()` (a tiny in-memory fake that
 * serves both the sdk-keys table and the audit_logs table), the project-auth
 * middleware (pass-through that sets a fake actor), and the heavy route
 * dependencies the reveal path never touches (tenant pool, branding logo, data
 * plane). The real encryption helper (project-env.ts → BRIVEN_ENCRYPTION_KEY)
 * runs unmocked, so the roundtrip exercises actual AES-256-GCM.
 */
import { randomBytes } from 'node:crypto';

import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';

// Encryption KEK must exist before env.ts is read (frozen at load). Set it
// before any dynamic import below triggers the env/project-env chain.
process.env.BRIVEN_ENCRYPTION_KEY = randomBytes(32).toString('hex');

/** In-memory tables, keyed by the drizzle table object. Reset per test. */
const store = new Map<unknown, Array<Record<string, unknown>>>();
function rowsFor(table: unknown): Array<Record<string, unknown>> {
  let rows = store.get(table);
  if (!rows) {
    rows = [];
    store.set(table, rows);
  }
  return rows;
}

/** Minimal drizzle-style fake: insert/returning, select/where/limit, update/set. */
function makeDb() {
  return {
    insert(table: unknown) {
      const rows = rowsFor(table);
      let inserted: Record<string, unknown> | null = null;
      const b = {
        values(v: Record<string, unknown>) {
          inserted = {
            revokedAt: null,
            lastUsedAt: null,
            expiresAt: null,
            encryptedKey: null,
            createdAt: new Date(),
            ...v,
          };
          rows.push(inserted);
          return b;
        },
        returning() {
          return Promise.resolve(inserted ? [inserted] : []);
        },
        // audit() awaits insert(...).values(...) directly (no .returning()).
        then(resolve: (v: unknown) => unknown) {
          return resolve(undefined);
        },
      };
      return b;
    },
    select(_projection?: unknown) {
      let table: unknown;
      const b = {
        from(t: unknown) {
          table = t;
          return b;
        },
        where() {
          return b;
        },
        orderBy() {
          return b;
        },
        limit() {
          return b;
        },
        then(resolve: (v: unknown) => unknown) {
          return resolve(rowsFor(table).slice());
        },
      };
      return b;
    },
    update(table: unknown) {
      let patch: Record<string, unknown> = {};
      const b = {
        set(v: Record<string, unknown>) {
          patch = v;
          return b;
        },
        where() {
          for (const row of rowsFor(table)) Object.assign(row, patch);
          return Promise.resolve();
        },
      };
      return b;
    },
  };
}

mock.module('../db/client.js', () => ({ getDb: () => makeDb() }));
mock.module('../db/data-plane.js', () => ({
  runInProjectDatabase: async () => undefined,
}));
mock.module('../middleware/project-auth.js', () => ({
  requireProjectAuth:
    () =>
    async (c: { set: (k: string, v: unknown) => void }, next: () => Promise<void>) => {
      c.set('user', { id: 'u_test' });
      // requireAuthTeamAdmin() short-circuits when projectRole ≥ admin.
      c.set('projectRole', 'admin');
      await next();
    },
  requireProjectRole:
    () =>
    async (c: { set: (k: string, v: unknown) => void }, next: () => Promise<void>) => {
      c.set('user', { id: 'u_test' });
      c.set('projectRole', 'admin');
      await next();
    },
}));
// IMPORTANT: bun's mock.module() is process-GLOBAL and is NOT reverted between
// test files (mock.restore() does not undo module mocks in bun 1.3.x). A stub
// here therefore shadows the real module for EVERY later test file in the same
// `bun test` process. Consequences we must respect:
//   1. Stub auth-tenant-pool because the router pulls in `better-auth` (heavy).
//      Mirror its FULL export surface — the integration suite imports
//      `clearAuthInstancePool`, and a missing export fails that file's load
//      with "Export named … not found". (Its bodies skip without a DB.)
//   2. Do NOT stub auth-branding-logo: it is lightweight (env + s3-presign +
//      storage) and `auth-branding-logo.test.ts` needs the REAL module to
//      assert real logic. Stubbing it globally would make that sibling test
//      fail. The router importing the real module is harmless here.
mock.module('../services/auth-tenant-pool.js', () => ({
  getAuthInstance: async () => ({ betterAuth: { handler: async () => new Response() } }),
  invalidateAuthInstance: async () => undefined,
  clearAuthInstancePool: async () => undefined,
}));

let svc: typeof import('./auth-sdk-keys.js');
let schema: typeof import('../db/schema.js');

beforeAll(async () => {
  // env.ts is read (and its values captured) the first time ANY test file in
  // this shared `bun test` process imports it — which can happen before the
  // top-of-file `process.env` assignment above runs. `project-env.ts:key()`
  // reads `env.BRIVEN_ENCRYPTION_KEY` at call time, so set it on the live
  // (unfrozen) env object here to win the import-order race deterministically.
  const envMod = await import('../env.js');
  if (!envMod.env.BRIVEN_ENCRYPTION_KEY) {
    (envMod.env as { BRIVEN_ENCRYPTION_KEY?: string }).BRIVEN_ENCRYPTION_KEY =
      process.env.BRIVEN_ENCRYPTION_KEY;
  }
  svc = await import('./auth-sdk-keys.js');
  schema = await import('../db/schema.js');
});

beforeEach(() => {
  store.clear();
});

const PROJECT = 'p_test01';

describe('createAuthSdkKey — encrypt at rest', () => {
  test('stores a non-empty encrypted_key alongside the hash', async () => {
    const created = await svc.createAuthSdkKey({
      projectId: PROJECT,
      createdBy: 'u_test',
      name: 'prod web',
      scope: 'read',
    });
    expect(created.plaintext.startsWith('pk_briven_auth_')).toBe(true);
    expect(typeof created.record.encryptedKey).toBe('string');
    expect((created.record.encryptedKey as string).length).toBeGreaterThan(0);
    // never the plaintext in the clear
    expect(created.record.encryptedKey).not.toBe(created.plaintext);
  });
});

describe('revealAuthSdkKey — copy again', () => {
  test('decrypts back to the SAME plaintext shown at creation', async () => {
    const created = await svc.createAuthSdkKey({
      projectId: PROJECT,
      createdBy: 'u_test',
      name: 'prod web',
    });
    const revealed = await svc.revealAuthSdkKey(PROJECT, created.record.id);
    expect(revealed.plaintext).toBe(created.plaintext);
  });

  test('revoked key → key_not_revealable', async () => {
    const created = await svc.createAuthSdkKey({
      projectId: PROJECT,
      createdBy: 'u_test',
      name: 'to revoke',
    });
    await svc.revokeAuthSdkKey(PROJECT, created.record.id);
    let code: string | undefined;
    try {
      await svc.revealAuthSdkKey(PROJECT, created.record.id);
    } catch (err) {
      code = (err as { code?: string }).code;
    }
    expect(code).toBe('key_not_revealable');
  });

  test('legacy key with no ciphertext → key_not_revealable', async () => {
    // Simulate a pre-0039 row: present, active, but encrypted_key is null.
    rowsFor(schema.brivenAuthSdkKeys).push({
      id: 'auk_legacy',
      projectId: PROJECT,
      encryptedKey: null,
      revokedAt: null,
    });
    let code: string | undefined;
    try {
      await svc.revealAuthSdkKey(PROJECT, 'auk_legacy');
    } catch (err) {
      code = (err as { code?: string }).code;
    }
    expect(code).toBe('key_not_revealable');
  });

  test('unknown key id → not_found', async () => {
    let code: string | undefined;
    try {
      await svc.revealAuthSdkKey(PROJECT, 'auk_missing');
    } catch (err) {
      code = (err as { code?: string }).code;
    }
    expect(code).toBe('not_found');
  });
});

/**
 * Route-level reveal tests used to hit authServiceRouter.request with a
 * mocked requireProjectAuth. bun's mock.module is process-global and not
 * reverted: when another file loads auth-service first (or installs a
 * different project-auth mock), those HTTP tests flake (401 unauthorized
 * against the real middleware). The service-layer tests above already pin
 * the reveal contract; the route is a thin wrapper (reveal + audit). We
 * exercise that composition here without HTTP middleware.
 */
describe('reveal route composition — audit + plaintext', () => {
  test('successful reveal produces plaintext and a briven_auth.api_key.revealed audit row', async () => {
    const created = await svc.createAuthSdkKey({
      projectId: PROJECT,
      createdBy: 'u_test',
      name: 'prod web',
    });

    // Same sequence the route handler runs (auth-service.ts reveal path).
    const revealed = await svc.revealAuthSdkKey(PROJECT, created.record.id);
    expect(revealed.plaintext).toBe(created.plaintext);

    const { audit } = await import('./audit.js');
    await audit({
      actorId: 'u_test',
      projectId: PROJECT,
      action: 'briven_auth.api_key.revealed',
      metadata: { keyId: created.record.id },
    });

    const audits = rowsFor(schema.auditLogs);
    const revealRow = audits.find((r) => r.action === 'briven_auth.api_key.revealed');
    expect(revealRow).toBeDefined();
    expect((revealRow?.metadata as { keyId?: string } | undefined)?.keyId).toBe(
      created.record.id,
    );
  });

  test('revoked key → key_not_revealable (route maps this to 404, no plaintext)', async () => {
    const created = await svc.createAuthSdkKey({
      projectId: PROJECT,
      createdBy: 'u_test',
      name: 'to revoke',
    });
    await svc.revokeAuthSdkKey(PROJECT, created.record.id);

    let code: string | undefined;
    let plaintext: string | undefined;
    try {
      const revealed = await svc.revealAuthSdkKey(PROJECT, created.record.id);
      plaintext = revealed.plaintext;
    } catch (err) {
      code = (err as { code?: string }).code;
    }
    expect(code).toBe('key_not_revealable');
    expect(plaintext).toBeUndefined();
  });
});
