/**
 * Op-layer capture integration test (v0.21.0).
 *
 * The hook lives at the `query` and `search` op handlers in
 * src/core/operations.ts, not at the MCP dispatch site — this catches
 * MCP callers, CLI callers, and subagent tool-bridge callers all from
 * one code path.
 *
 * This test invokes the op handler directly with various
 * OperationContext shapes (remote MCP, local CLI, subagent with jobId)
 * and asserts that captured rows carry the expected origin metadata.
 *
 * Capture is fire-and-forget from the caller — but the INSERT fires on
 * the same tick, so awaiting a microtask boundary is enough to let the
 * row land before we assert.
 */

import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { operations } from '../src/core/operations.ts';
import type { OperationContext } from '../src/core/operations.ts';
import type { GBrainConfig } from '../src/core/config.ts';
import type { PageInput } from '../src/core/types.ts';

let engine: PGLiteEngine;
const savedKey = process.env.OPENAI_API_KEY;

beforeAll(async () => {
  delete process.env.OPENAI_API_KEY; // force keyword-only path so tests don't need live credentials
  engine = new PGLiteEngine();
  await engine.connect({});
  await engine.initSchema();
  const page: PageInput = {
    type: 'person',
    title: 'Alice Example',
    compiled_truth: 'Alice Example for op-layer capture tests.',
  };
  await engine.putPage('people/alice-example', page);
  await engine.executeRaw(
    `INSERT INTO sources (id, name) VALUES ('testsrc', 'Test Source') ON CONFLICT DO NOTHING`,
  );
  await engine.putPage('notes/source-override-default', {
    type: 'note',
    title: 'Default Source Override',
    compiled_truth: 'sourceoverrideunique belongs to the default source.',
  });
  await engine.upsertChunks('notes/source-override-default', [
    {
      chunk_index: 0,
      chunk_text: 'sourceoverrideunique belongs to the default source.',
      chunk_source: 'compiled_truth',
    },
  ]);
  await engine.putPage('notes/source-override-testsrc', {
    type: 'note',
    title: 'Test Source Override',
    compiled_truth: 'sourceoverrideunique belongs to the explicit source.',
  }, { sourceId: 'testsrc' });
  await engine.upsertChunks('notes/source-override-testsrc', [
    {
      chunk_index: 0,
      chunk_text: 'sourceoverrideunique belongs to the explicit source.',
      chunk_source: 'compiled_truth',
    },
  ], { sourceId: 'testsrc' });
});

afterAll(async () => {
  if (savedKey === undefined) delete process.env.OPENAI_API_KEY;
  else process.env.OPENAI_API_KEY = savedKey;
  await engine.disconnect();
});

beforeEach(async () => {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  await (engine as any).db.exec('DELETE FROM eval_candidates');
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  await (engine as any).db.exec('DELETE FROM eval_capture_failures');
});

function makeConfig(overrides: Partial<GBrainConfig['eval']> = {}): GBrainConfig {
  return {
    engine: 'pglite',
    eval: { capture: true, scrub_pii: true, ...overrides },
  };
}

function makeCtx(overrides: Partial<OperationContext> = {}): OperationContext {
  return {
    engine,
    config: makeConfig(),
    logger: console,
    dryRun: false,
    remote: true,
    ...overrides,
  } as OperationContext;
}

/** Tiny helper: wait for fire-and-forget INSERT to land. */
async function waitForCapture(): Promise<void> {
  // Two microtask cycles is plenty — the handler already awaited the op,
  // so the fire-and-forget INSERT is enqueued. One await resolves the
  // logEvalCandidate promise; one more for any follow-up.
  await new Promise(r => setTimeout(r, 10));
}

describe('op-layer capture — query', () => {
  const queryOp = operations.find(o => o.name === 'query')!;

  test('captures MCP query call with remote=true', async () => {
    const ctx = makeCtx({ remote: true });
    await queryOp.handler(ctx, { query: 'alice' });
    await waitForCapture();

    const rows = await engine.listEvalCandidates();
    expect(rows).toHaveLength(1);
    const row = rows[0]!;
    expect(row.tool_name).toBe('query');
    expect(row.query).toBe('alice');
    expect(row.remote).toBe(true);
    expect(row.expand_enabled).toBe(true); // default
    expect(row.vector_enabled).toBe(false); // OPENAI_API_KEY deleted
    expect(row.job_id).toBeNull();
    expect(row.subagent_id).toBeNull();
  });

  test('captures CLI query call with remote=false', async () => {
    const ctx = makeCtx({ remote: false });
    await queryOp.handler(ctx, { query: 'alice' });
    await waitForCapture();

    const rows = await engine.listEvalCandidates();
    expect(rows[0]!.remote).toBe(false);
  });

  test('captures subagent query call with jobId + subagentId', async () => {
    const ctx = makeCtx({ remote: true, jobId: 9001, subagentId: 42 });
    await queryOp.handler(ctx, { query: 'alice' });
    await waitForCapture();

    const row = (await engine.listEvalCandidates())[0]!;
    expect(row.job_id).toBe(9001);
    expect(row.subagent_id).toBe(42);
  });

  test('scrubs PII by default before insert', async () => {
    const ctx = makeCtx();
    await queryOp.handler(ctx, { query: 'email alice@example.com about it' });
    await waitForCapture();

    const row = (await engine.listEvalCandidates())[0]!;
    expect(row.query).not.toContain('alice@example.com');
    expect(row.query).toContain('[REDACTED]');
  });

  test('preserves PII when scrub_pii is disabled', async () => {
    const ctx = makeCtx({
      config: makeConfig({ scrub_pii: false }),
    });
    await queryOp.handler(ctx, { query: 'email alice@example.com' });
    await waitForCapture();

    const row = (await engine.listEvalCandidates())[0]!;
    expect(row.query).toBe('email alice@example.com');
  });

  test('does nothing when eval.capture is false (off-switch works)', async () => {
    const ctx = makeCtx({
      config: makeConfig({ capture: false }),
    });
    await queryOp.handler(ctx, { query: 'alice' });
    await waitForCapture();

    const rows = await engine.listEvalCandidates();
    expect(rows).toHaveLength(0);
  });

  test('explicit source_id overrides ctx.sourceId for query retrieval', async () => {
    const ctx = makeCtx({
      sourceId: 'default',
      config: makeConfig({ capture: false }),
    });

    const results = await queryOp.handler(ctx, {
      query: 'sourceoverrideunique',
      source_id: 'testsrc',
      expand: false,
      use_cache: false,
    }) as Array<{ slug: string }>;

    expect(results.map(r => r.slug)).toContain('notes/source-override-testsrc');
    expect(results.map(r => r.slug)).not.toContain('notes/source-override-default');
  });
});

describe('op-layer capture — search', () => {
  const searchOp = operations.find(o => o.name === 'search')!;

  test('captures search call with tool_name="search" (cheap-hybrid contract, T4/D4)', async () => {
    const ctx = makeCtx();
    await searchOp.handler(ctx, { query: 'alice' });
    await waitForCapture();

    const rows = await engine.listEvalCandidates();
    expect(rows).toHaveLength(1);
    const row = rows[0]!;
    expect(row.tool_name).toBe('search');
    // T4/D4: search is now cheap-hybrid (vector+keyword+RRF). With no embedding
    // provider in this test it falls open to keyword (vector_enabled=false), but
    // expansion is structurally OFF for `search` (the cheap-hybrid contract) —
    // false, not null, because search now HAS an expansion concept.
    expect(row.vector_enabled).toBe(false); // no embedding provider configured in test
    expect(row.expand_enabled).toBe(false); // cheap-hybrid: expansion always off
  });

  test('respects eval.capture=false off-switch', async () => {
    const ctx = makeCtx({
      config: makeConfig({ capture: false }),
    });
    await searchOp.handler(ctx, { query: 'alice' });
    await waitForCapture();
    expect(await engine.listEvalCandidates()).toHaveLength(0);
  });
});

describe('op-layer capture — non-query/search ops are NOT captured', () => {
  test('list_pages does not insert into eval_candidates', async () => {
    const listPagesOp = operations.find(o => o.name === 'list_pages')!;
    const ctx = makeCtx();
    await listPagesOp.handler(ctx, { limit: 10 });
    await waitForCapture();

    const rows = await engine.listEvalCandidates();
    expect(rows).toHaveLength(0);
  });

  test('get_page does not insert into eval_candidates', async () => {
    const getPageOp = operations.find(o => o.name === 'get_page')!;
    const ctx = makeCtx();
    await getPageOp.handler(ctx, { slug: 'people/alice-example' });
    await waitForCapture();

    const rows = await engine.listEvalCandidates();
    expect(rows).toHaveLength(0);
  });
});

describe('op-layer capture — failure isolation (F1/F2)', () => {
  test('capture failures do not propagate to op response', async () => {
    // Disconnect-then-reconnect the engine to simulate an INSERT failure.
    // We can't easily inject a rejecting engine here since operations.ts
    // imports the real one, but we can break the table temporarily.
    // Drop the eval_candidates table, run the op, assert (a) op response
    // succeeds and (b) a failure row appears in eval_capture_failures.
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    await (engine as any).db.exec('DROP TABLE eval_candidates');
    const queryOp = operations.find(o => o.name === 'query')!;
    const ctx = makeCtx();
    // Op must still succeed and return results.
    const results = await queryOp.handler(ctx, { query: 'alice' });
    expect(Array.isArray(results)).toBe(true);

    await waitForCapture();
    // Failure should have landed in the companion table.
    const failures = await engine.listEvalCaptureFailures();
    expect(failures.length).toBeGreaterThanOrEqual(1);
    expect(['db_down', 'other']).toContain(failures[0]!.reason);

    // Restore the table for subsequent tests.
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    await (engine as any).db.exec(`
      CREATE TABLE IF NOT EXISTS eval_candidates (
        id SERIAL PRIMARY KEY,
        tool_name TEXT NOT NULL CHECK (tool_name IN ('query', 'search')),
        query TEXT NOT NULL CHECK (length(query) <= 51200),
        retrieved_slugs TEXT[] NOT NULL DEFAULT '{}',
        retrieved_chunk_ids INTEGER[] NOT NULL DEFAULT '{}',
        source_ids TEXT[] NOT NULL DEFAULT '{}',
        expand_enabled BOOLEAN,
        detail TEXT,
        detail_resolved TEXT,
        vector_enabled BOOLEAN NOT NULL,
        expansion_applied BOOLEAN NOT NULL,
        latency_ms INTEGER NOT NULL,
        remote BOOLEAN NOT NULL,
        job_id INTEGER,
        subagent_id INTEGER,
        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
      )
    `);
  });
});
