/**
 * v0.28 e2e: per-token takes_holders allow-list, end-to-end through the
 * access_tokens.permissions JSONB column. Closes Codex P0 #3 verification.
 *
 * The HTTP transport's validateToken reads permissions.takes_holders from
 * the access_tokens row and threads it into the dispatch context. This
 * test exercises that path against real Postgres without booting the
 * full Bun.serve transport (the auth probe is the load-bearing piece).
 */
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { createHash, randomBytes } from 'node:crypto';
import { setupDB, teardownDB, hasDatabase, getEngine } from './helpers.ts';
import { dispatchToolCall } from '../../src/mcp/dispatch.ts';

const RUN = hasDatabase();
const d = RUN ? describe : describe.skip;

function hashToken(token: string): string {
  return createHash('sha256').update(token).digest('hex');
}

function generateToken(): string {
  return 'gbrain_' + randomBytes(32).toString('hex');
}

let alicePageId: number;

beforeAll(async () => {
  if (!RUN) return;
  const engine = await setupDB();
  const alice = await engine.putPage('people/alice-example', {
    title: 'Alice', type: 'person', compiled_truth: '## Takes\n',
  });
  alicePageId = alice.id;
  await engine.addTakesBatch([
    { page_id: alicePageId, row_num: 1, claim: 'CEO of Acme', kind: 'fact', holder: 'world', weight: 1.0 },
    { page_id: alicePageId, row_num: 2, claim: 'Strong technical founder', kind: 'take', holder: 'garry', weight: 0.85 },
    { page_id: alicePageId, row_num: 3, claim: 'Burned out signal', kind: 'hunch', holder: 'brain', weight: 0.4 },
  ]);
});

afterAll(async () => {
  if (!RUN) return;
  await teardownDB();
});

d('access_tokens.permissions.takes_holders end-to-end', () => {
  test('newly-created token defaults to {takes_holders: ["world"]} via migration v32 backfill', async () => {
    const engine = getEngine();
    const token = generateToken();
    const hash = hashToken(token);
    await engine.executeRaw(
      `INSERT INTO access_tokens (name, token_hash) VALUES ($1, $2)`,
      [`tok-default-${Date.now()}`, hash],
    );
    const rows = await engine.executeRaw<{ permissions: { takes_holders?: unknown } }>(
      `SELECT permissions FROM access_tokens WHERE token_hash = $1`,
      [hash],
    );
    expect(rows[0]?.permissions).toEqual({ takes_holders: ['world'] });
  });

  test('explicit ["world","garry"] permission filters dispatch responses correctly', async () => {
    const engine = getEngine();
    const token = generateToken();
    const hash = hashToken(token);
    await engine.executeRaw(
      `INSERT INTO access_tokens (name, token_hash, permissions) VALUES ($1, $2, $3::jsonb)`,
      // Pass the object directly — JSON.stringify + ::jsonb cast double-encodes
      // (per CLAUDE.md memory: postgres-js JSONB double-encode trap).
      [`tok-wg-${Date.now()}`, hash, { takes_holders: ['world', 'garry'] }],
    );
    // Read back permissions to simulate validateToken's path
    const rows = await engine.executeRaw<{ permissions: { takes_holders?: string[] } }>(
      `SELECT permissions FROM access_tokens WHERE token_hash = $1`,
      [hash],
    );
    const allowList = rows[0]?.permissions?.takes_holders ?? ['world'];
    expect(allowList).toEqual(['world', 'garry']);

    // Now dispatch with that allow-list, verify SQL filter applies
    const result = await dispatchToolCall(engine, 'takes_list', { page_slug: 'people/alice-example' }, {
      remote: true, sourceId: 'default',      takesHoldersAllowList: allowList,
    });
    expect(result.isError).toBeFalsy();
    const takes = JSON.parse(result.content[0].text) as Array<{ holder: string }>;
    const holders = new Set(takes.map(t => t.holder));
    expect(holders.has('world')).toBe(true);
    expect(holders.has('garry')).toBe(true);
    expect(holders.has('brain')).toBe(false); // brain hunch is hidden
  });

  test('default ["world"] hides garry hunches even from search', async () => {
    const engine = getEngine();
    const token = generateToken();
    const hash = hashToken(token);
    await engine.executeRaw(
      `INSERT INTO access_tokens (name, token_hash, permissions) VALUES ($1, $2, $3)`,
      [`tok-w-${Date.now()}`, hash, { takes_holders: ['world'] }],
    );
    const result = await dispatchToolCall(engine, 'takes_search', { query: 'founder' }, {
      remote: true, sourceId: 'default',      takesHoldersAllowList: ['world'],
    });
    const hits = JSON.parse(result.content[0].text) as Array<{ holder: string }>;
    expect(hits.every(h => h.holder === 'world')).toBe(true);
  });

  test('NULL permissions row defaults to ["world"] (back-compat for pre-v32 tokens edited manually)', async () => {
    const engine = getEngine();
    const token = generateToken();
    const hash = hashToken(token);
    // Simulate a manually-tampered token where permissions was set to NULL after creation
    await engine.executeRaw(
      `INSERT INTO access_tokens (name, token_hash, permissions) VALUES ($1, $2, $3)`,
      [`tok-null-${Date.now()}`, hash, {}],
    );
    const rows = await engine.executeRaw<{ permissions: { takes_holders?: string[] } }>(
      `SELECT permissions FROM access_tokens WHERE token_hash = $1`,
      [hash],
    );
    // perm was {} so takes_holders is undefined; HTTP transport defaults to ['world']
    const allowList = Array.isArray(rows[0]?.permissions?.takes_holders) ? rows[0].permissions!.takes_holders! : ['world'];
    expect(allowList).toEqual(['world']);
  });

  test('revoked token is excluded from active token query', async () => {
    const engine = getEngine();
    const token = generateToken();
    const hash = hashToken(token);
    await engine.executeRaw(
      `INSERT INTO access_tokens (name, token_hash, revoked_at) VALUES ($1, $2, now())`,
      [`tok-revoked-${Date.now()}`, hash],
    );
    // The HTTP transport's validateToken filters WHERE revoked_at IS NULL — confirm the row is invisible there.
    const rows = await engine.executeRaw<{ id: string }>(
      `SELECT id FROM access_tokens WHERE token_hash = $1 AND revoked_at IS NULL`,
      [hash],
    );
    expect(rows).toHaveLength(0);
  });
});
