import { describe, test, expect, beforeAll, afterAll, spyOn } from 'bun:test';
import { LATEST_VERSION, runMigrations, MIGRATIONS, getIdleBlockers } from '../src/core/migrate.ts';
import type { IdleBlocker } from '../src/core/migrate.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { readFileSync } from 'fs';
import { resolve } from 'path';

describe('migrate', () => {
  test('LATEST_VERSION is a number >= 1', () => {
    expect(typeof LATEST_VERSION).toBe('number');
    expect(LATEST_VERSION).toBeGreaterThanOrEqual(1);
  });

  test('runMigrations is exported and callable', async () => {
    expect(typeof runMigrations).toBe('function');
  });

  // Integration tests for actual migration execution require DATABASE_URL
  // and are covered in the E2E suite (test/e2e/mechanical.test.ts)
});

// ─────────────────────────────────────────────────────────────────
// v0.18.0 — v16 sources_table_additive (Step 1, Lane A)
// ─────────────────────────────────────────────────────────────────
// v16 is the ADDITIVE-ONLY migration: it installs the sources primitive
// without breaking the engine's existing ON CONFLICT (slug) upserts.
// The breaking schema changes (pages.source_id NOT NULL, composite
// UNIQUE, files.page_slug → page_id, file_migration_ledger,
// links.resolution_type) land in v17 alongside the engine API rewrite
// so the engine can execute the new ON CONFLICT (source_id, slug)
// atomically with the schema change.
// ─────────────────────────────────────────────────────────────────
describe('migrate v20 — sources_table_additive', () => {
  const v20 = MIGRATIONS.find(m => m.version === 20);

  test('v20 exists', () => {
    expect(v20).toBeDefined();
    expect(v20!.name).toBe('sources_table_additive');
  });

  test('v20 creates sources table', () => {
    expect(v20!.sql).toContain('CREATE TABLE IF NOT EXISTS sources');
    expect(v20!.sql).toContain('id            TEXT PRIMARY KEY');
    expect(v20!.sql).toContain('name          TEXT NOT NULL UNIQUE');
    expect(v20!.sql).toContain('config        JSONB NOT NULL');
  });

  test("v20 seeds 'default' source inheriting sync config", () => {
    expect(v20!.sql).toContain("INSERT INTO sources (id, name, local_path, last_commit, config)");
    expect(v20!.sql).toContain("'default'");
    // The default source pulls from existing config so post-upgrade
    // identity is preserved.
    expect(v20!.sql).toContain("SELECT value FROM config WHERE key = 'sync.repo_path'");
    expect(v20!.sql).toContain("SELECT value FROM config WHERE key = 'sync.last_commit'");
  });

  test('v20 default source is federated=true (backward-compat)', () => {
    // federated=true ensures pre-v0.17 brains keep single-namespace
    // search semantics — every page appears in unqualified search.
    expect(v20!.sql).toContain('"federated": true');
  });

  test('v20 is idempotent on re-run', () => {
    // CREATE TABLE IF NOT EXISTS + NOT EXISTS subquery on INSERT.
    expect(v20!.sql).toContain('CREATE TABLE IF NOT EXISTS sources');
    expect(v20!.sql).toContain('WHERE NOT EXISTS (SELECT 1 FROM sources WHERE id = ');
  });

  test('v20 does NOT touch pages / ingest_log / files / links', () => {
    // Step 1 is additive-only. Breaking changes deferred to v17 so they
    // land with the engine rewrite (Step 2). Guard against anyone
    // accidentally re-expanding v16's scope.
    expect(v20!.sql).not.toContain('ALTER TABLE pages');
    expect(v20!.sql).not.toContain('ALTER TABLE ingest_log');
    expect(v20!.sql).not.toContain('ALTER TABLE files');
    expect(v20!.sql).not.toContain('ALTER TABLE links');
    expect(v20!.handler).toBeUndefined();
  });
});

// ─────────────────────────────────────────────────────────────────
// v0.18.0 — v17 pages_source_id_composite_unique (Step 2, Lane B)
// ─────────────────────────────────────────────────────────────────
describe('migrate v21 — pages_source_id_composite_unique', () => {
  const v21 = MIGRATIONS.find(m => m.version === 21);

  test('v21 exists and is paired with Step 2 engine rewrite', () => {
    expect(v21).toBeDefined();
    expect(v21!.name).toBe('pages_source_id_composite_unique');
  });

  // Post-codex restructure: v21 is engine-split.
  // Postgres path = additive only (source_id + index). The UNIQUE swap
  // and files_page_slug_fkey drop moved into v23's atomic transaction.
  // PGLite path = full (add + unique swap) because PGLite has no
  // concurrent writers so the integrity window doesn't apply.
  test('v21 uses sqlFor for engine-specific paths (post-codex)', () => {
    expect(v21!.sql).toBe('');
    expect(v21!.sqlFor).toBeDefined();
    expect(v21!.sqlFor!.postgres).toBeDefined();
    expect(v21!.sqlFor!.pglite).toBeDefined();
  });

  test('v21 Postgres path: additive only (source_id + index)', () => {
    const pg = v21!.sqlFor!.postgres!;
    expect(pg).toContain('ALTER TABLE pages ADD COLUMN IF NOT EXISTS source_id TEXT');
    // DEFAULT 'default' closes the race where an INSERT between ADD COLUMN
    // and SET NOT NULL could leave source_id NULL (Codex second-pass review).
    expect(pg).toContain("NOT NULL DEFAULT 'default' REFERENCES sources(id)");
    expect(pg).toContain('CREATE INDEX IF NOT EXISTS idx_pages_source_id');
    // The UNIQUE swap and files FK drop must NOT be in the Postgres path.
    // They moved into v23's atomic transaction to close the partial-state
    // window codex identified.
    expect(pg).not.toContain('pages_slug_key');
    expect(pg).not.toContain('files_page_slug_fkey');
  });

  test('v21 PGLite path: additive + UNIQUE swap (no integrity window)', () => {
    const pgl = v21!.sqlFor!.pglite!;
    expect(pgl).toContain('ALTER TABLE pages ADD COLUMN IF NOT EXISTS source_id TEXT');
    expect(pgl).toContain('CREATE INDEX IF NOT EXISTS idx_pages_source_id');
    // PGLite swaps the unique here (no files table means no FK to drop).
    expect(pgl).toContain('ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_slug_key');
    expect(pgl).toContain('pages_source_slug_key');
    expect(pgl).toContain('UNIQUE (source_id, slug)');
    // PGLite path doesn't touch files (doesn't exist on PGLite).
    expect(pgl).not.toContain('files_page_slug_fkey');
  });
});

// ─────────────────────────────────────────────────────────────────
// v0.18.0 — v19 files_source_id_page_id_ledger (Step 7, Lane E)
// ─────────────────────────────────────────────────────────────────
describe('migrate v23 — files_source_id_page_id_ledger', () => {
  const v23 = MIGRATIONS.find(m => m.version === 23);

  test('v23 exists as handler-only (Postgres files table, PGLite no-op)', () => {
    expect(v23).toBeDefined();
    expect(v23!.name).toBe('files_source_id_page_id_ledger');
    expect(v23!.sql).toBe('');
    expect(v23!.handler).toBeDefined();
  });

  test('v23 handler gates on engine.kind for PGLite (no files table)', () => {
    expect(v23!.handler!.toString()).toMatch(/engine\.kind\s*===\s*["']pglite["']/);
  });

  test('v23 adds files.source_id + files.page_id + ledger creation', () => {
    const body = v23!.handler!.toString();
    expect(body).toContain('ALTER TABLE files ADD COLUMN IF NOT EXISTS source_id');
    expect(body).toContain('ALTER TABLE files ADD COLUMN IF NOT EXISTS page_id');
    expect(body).toContain('CREATE TABLE IF NOT EXISTS file_migration_ledger');
  });

  test('v23 is atomic: wraps all work in engine.transaction (integrity-window fix)', () => {
    const body = v23!.handler!.toString();
    // Codex caught: if files_page_slug_fkey is dropped in v21 but the
    // replacement files.page_id is only added in v23, a process-death
    // between v21 and v23 leaves files permanently unconstrained.
    // Fix: move BOTH the FK drop AND the pages UNIQUE swap into v23,
    // wrap everything in engine.transaction so it commits atomically.
    expect(body).toContain('engine.transaction');
    expect(body).toContain('files_page_slug_fkey');
    expect(body).toContain('pages_slug_key');
    expect(body).toContain('pages_source_slug_key');
  });

  test('v23 backfills files.page_id scoped to default source (Codex fix)', () => {
    const body = v23!.handler!.toString();
    // Without source_id='default' scope, the JOIN could hit the wrong
    // page after new sources with duplicate slugs are added.
    expect(body).toContain('UPDATE files f');
    expect(body).toContain("p.source_id = 'default'");
  });

  test('v23 ledger PK is file_id (Codex: two sources can share old path)', () => {
    const body = v23!.handler!.toString();
    expect(body).toContain('file_id           INTEGER PRIMARY KEY');
    // State machine values all present.
    for (const state of ['pending', 'copy_done', 'db_updated', 'complete', 'failed']) {
      expect(body).toContain(`'${state}'`);
    }
  });
});

describe('migrate — ordering guarantee (v15 must NOT be skipped by v16)', () => {
  test('runMigrations sorts by version ascending', async () => {
    // Regression: if v16 preceded v15 in the MIGRATIONS array, the iterator
    // would setConfig(version, 16) first, then skip v15 on the next pass.
    // runMigrations applies a defensive sort so array order doesn't matter.
    // This test asserts v15 exists (if we broke the sort, v15 would still
    // exist in MIGRATIONS but would never apply at runtime).
    const v15 = MIGRATIONS.find(m => m.version === 15);
    const v20 = MIGRATIONS.find(m => m.version === 20);
    expect(v15).toBeDefined();
    expect(v20).toBeDefined();
    // Sanity: versions are distinct and progress.
    const versions = MIGRATIONS.map(m => m.version);
    const uniq = new Set(versions);
    expect(uniq.size).toBe(versions.length);
  });
});

// ─────────────────────────────────────────────────────────────────
// v0.18.1 RLS hardening — structural guard for migration v24
// ─────────────────────────────────────────────────────────────────
//
// The base schema shipped 8 gbrain-managed public tables without RLS
// enabled (access_tokens, mcp_request_log, minion_inbox,
// minion_attachments, subagent_messages, subagent_tool_executions,
// subagent_rate_leases, gbrain_cycle_locks). Migration v12 created
// two more (budget_ledger, budget_reservations) without RLS.
// Migration v24 backfills the ENABLE RLS statements for existing
// brains. This test guards against regressions where the migration
// gets truncated or the wrong tables get enabled.

describe('migration v24 — rls_backfill_missing_tables', () => {
  const RLS_BACKFILL_TABLES = [
    'access_tokens',
    'mcp_request_log',
    'minion_inbox',
    'minion_attachments',
    'subagent_messages',
    'subagent_tool_executions',
    'subagent_rate_leases',
    'gbrain_cycle_locks',
    'budget_ledger',
    'budget_reservations',
  ];

  test('exists with the expected name', () => {
    const v24 = MIGRATIONS.find(m => m.version === 24);
    expect(v24).toBeDefined();
    expect(v24?.name).toBe('rls_backfill_missing_tables');
  });

  test('enables RLS on all 10 backfill tables', () => {
    const v24 = MIGRATIONS.find(m => m.version === 24);
    expect(v24).toBeDefined();
    const sql = v24!.sql || '';
    for (const tbl of RLS_BACKFILL_TABLES) {
      expect(sql).toContain(`ALTER TABLE ${tbl} ENABLE ROW LEVEL SECURITY`);
    }
  });

  test('is gated on BYPASSRLS so it never locks a non-bypass session out of its data', () => {
    const v24 = MIGRATIONS.find(m => m.version === 24);
    const sql = v24!.sql || '';
    expect(sql).toContain('rolbypassrls');
    // The gate can be either IF has_bypass / early-raise pattern.
    expect(sql).toMatch(/IF (NOT )?has_bypass/);
  });

  // Self-healing guard: the budget_* tables are migration-only (v12). If an
  // operator manually dropped them, or if a brain was somehow pinned to a
  // pre-v12 version when those tables didn't exist, a bare `ALTER TABLE
  // budget_ledger ...` would fail with 42P01 and abort v24. Wrapping those
  // two ALTERs in an `IF EXISTS (information_schema.tables ...)` check lets
  // the migration skip them silently instead of erroring out. The other 8
  // tables are created by schema.sql on every initSchema and don't need
  // the guard — bare ALTER is fine.
  test('guards budget_ledger + budget_reservations with information_schema.tables IF EXISTS', () => {
    const v24 = MIGRATIONS.find(m => m.version === 24);
    const sql = v24!.sql || '';
    // Both budget tables must be wrapped in an existence check.
    expect(sql).toMatch(
      /IF EXISTS \(SELECT 1 FROM information_schema\.tables[\s\S]{0,200}table_name = 'budget_ledger'\)[\s\S]{0,200}ALTER TABLE budget_ledger ENABLE ROW LEVEL SECURITY/,
    );
    expect(sql).toMatch(
      /IF EXISTS \(SELECT 1 FROM information_schema\.tables[\s\S]{0,200}table_name = 'budget_reservations'\)[\s\S]{0,200}ALTER TABLE budget_reservations ENABLE ROW LEVEL SECURITY/,
    );
  });

  // Codex found: if v24 RAISE WARNINGs instead of raising on non-BYPASSRLS,
  // the migration runner still bumps schema_version to 24, permanently
  // skipping the backfill on future runs even after the role is fixed.
  // The fix is to raise loudly so the transaction aborts, version stays
  // at 23, and the next initSchema call retries after role reassignment.
  test('fails loudly on non-BYPASSRLS roles instead of silently bumping version', () => {
    const v24 = MIGRATIONS.find(m => m.version === 24);
    const sql = v24!.sql || '';
    expect(sql).toMatch(/RAISE EXCEPTION[^;]*BYPASSRLS/);
    expect(sql).not.toMatch(/RAISE WARNING[^;]*BYPASSRLS/);
  });

  test('LATEST_VERSION has caught up to 24', () => {
    expect(LATEST_VERSION).toBeGreaterThanOrEqual(24);
  });
});

// ─────────────────────────────────────────────────────────────────
// REGRESSION TESTS — migrations v8 + v9 perf on duplicate-heavy tables
// ─────────────────────────────────────────────────────────────────
//
// Garry's production brain hit Supabase Management API's 60s ceiling because
// the DELETE...USING self-join in migrations v8 + v9 was O(n²) without an
// index on the dedup columns. The fix pre-creates a btree helper index
// before the DELETE, then drops it. These tests guard against any future
// change that re-introduces the missing helper index.
//
// Two-layer guard:
//   1. Structural — assert the migration SQL literally contains the helper
//      CREATE INDEX + DROP INDEX (deterministic, fast, catches the regression
//      even at 0-row scale where wall-clock can't distinguish O(n²) from O(1)).
//   2. Behavioral — populate 1000 duplicates and assert the migration completes
//      under the wall-clock cap. Sanity check at small scale; the structural
//      assertion is the real guard.

describe('migrations v8 + v9 — structural guard for helper-index fix', () => {
  test('migration v8 SQL contains idx_links_dedup_helper CREATE+DROP around the DELETE', () => {
    const v8 = MIGRATIONS.find(m => m.version === 8);
    expect(v8).toBeDefined();
    const sql = v8!.sql;

    // The fix must: (a) create the helper btree, (b) DELETE...USING, (c) drop the helper, (d) add the unique constraint.
    // If anyone reorders or removes the helper-index lines, this fails.
    expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_links_dedup_helper');
    expect(sql).toContain('ON links(from_page_id, to_page_id, link_type)');
    expect(sql).toContain('DROP INDEX IF EXISTS idx_links_dedup_helper');
    expect(sql).toContain('DELETE FROM links a USING links b');
    expect(sql).toContain('ALTER TABLE links ADD CONSTRAINT links_from_to_type_unique');

    // Order matters: CREATE INDEX before DELETE, DROP INDEX after DELETE, before ADD CONSTRAINT.
    const createIdx = sql.indexOf('CREATE INDEX IF NOT EXISTS idx_links_dedup_helper');
    const deleteUsing = sql.indexOf('DELETE FROM links a USING links b');
    const dropIdx = sql.indexOf('DROP INDEX IF EXISTS idx_links_dedup_helper');
    const addConstraint = sql.indexOf('ALTER TABLE links ADD CONSTRAINT links_from_to_type_unique');
    expect(createIdx).toBeLessThan(deleteUsing);
    expect(deleteUsing).toBeLessThan(dropIdx);
    expect(dropIdx).toBeLessThan(addConstraint);
  });

  test('migration v9 SQL contains idx_timeline_dedup_helper CREATE+DROP around the DELETE', () => {
    const v9 = MIGRATIONS.find(m => m.version === 9);
    expect(v9).toBeDefined();
    const sql = v9!.sql;

    expect(sql).toContain('CREATE INDEX IF NOT EXISTS idx_timeline_dedup_helper');
    expect(sql).toContain('ON timeline_entries(page_id, date, summary)');
    expect(sql).toContain('DROP INDEX IF EXISTS idx_timeline_dedup_helper');
    expect(sql).toContain('DELETE FROM timeline_entries a USING timeline_entries b');
    expect(sql).toContain('CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_dedup');

    const createHelper = sql.indexOf('CREATE INDEX IF NOT EXISTS idx_timeline_dedup_helper');
    const deleteUsing = sql.indexOf('DELETE FROM timeline_entries a USING timeline_entries b');
    const dropHelper = sql.indexOf('DROP INDEX IF EXISTS idx_timeline_dedup_helper');
    const createUnique = sql.indexOf('CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_dedup');
    expect(createHelper).toBeLessThan(deleteUsing);
    expect(deleteUsing).toBeLessThan(dropHelper);
    expect(dropHelper).toBeLessThan(createUnique);
  });
});

// v0.14.1 — fix wave structural assertions (migrations renumbered from v12/v13 to
// v14/v15 after master merged budget_ledger (v12) + minion_quiet_hours_stagger (v13)).
describe('migrate v14 — pages_updated_at_index (handler-based, engine-aware)', () => {
  const v14 = MIGRATIONS.find(m => m.version === 14);
  test('v14 exists and uses a handler (not pure SQL) for engine-aware branching', () => {
    expect(v14).toBeDefined();
    expect(v14!.name).toBe('pages_updated_at_index');
    expect(typeof v14!.handler).toBe('function');
    expect(v14!.sql).toBe('');
  });

  test('v14 handler source contains CONCURRENTLY + invalid-index cleanup for Postgres branch', async () => {
    const { readFileSync } = await import('fs');
    const src = readFileSync('src/core/migrate.ts', 'utf-8');
    const v14Start = src.indexOf("name: 'pages_updated_at_index'");
    expect(v14Start).toBeGreaterThan(-1);
    const v14Block = src.slice(v14Start, v14Start + 3000);
    expect(v14Block).toContain('pg_index');
    expect(v14Block).toContain('indisvalid');
    expect(v14Block).toContain('DROP INDEX CONCURRENTLY IF EXISTS idx_pages_updated_at_desc');
    expect(v14Block).toContain('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_pages_updated_at_desc');
    // Order within the handler body: DROP IF EXISTS must precede CREATE IF NOT EXISTS,
    // so a failed prior CONCURRENTLY build is cleaned before re-create. Anchor on the
    // explicit "IF EXISTS" / "IF NOT EXISTS" phrases so the header doc-comment
    // (which mentions both unqualified) doesn't fool the ordering assertion.
    const dropIdx = v14Block.indexOf('DROP INDEX CONCURRENTLY IF EXISTS');
    const createIdx = v14Block.indexOf('CREATE INDEX CONCURRENTLY IF NOT EXISTS');
    expect(dropIdx).toBeLessThan(createIdx);
    expect(v14Block).toContain('engine.kind');
  });
});

describe('migrate v15 — minion_jobs_max_stalled_default_5', () => {
  const v15 = MIGRATIONS.find(m => m.version === 15);
  test('v15 exists and alters max_stalled default to 5', () => {
    expect(v15).toBeDefined();
    expect(v15!.name).toBe('minion_jobs_max_stalled_default_5');
    expect(v15!.sql).toContain('ALTER TABLE minion_jobs ALTER COLUMN max_stalled SET DEFAULT 5');
  });

  test('v15 backfill UPDATE targets the correct non-terminal statuses', () => {
    const sql = v15!.sql;
    expect(sql).toContain(`'waiting'`);
    expect(sql).toContain(`'active'`);
    expect(sql).toContain(`'delayed'`);
    expect(sql).toContain(`'waiting-children'`);
    expect(sql).toContain(`'paused'`);
    expect(sql).not.toContain(`'completed'`);
    expect(sql).not.toContain(`'dead'`);
    expect(sql).not.toContain(`'cancelled'`);
    expect(sql).not.toContain(`'claimed'`);
    expect(sql).not.toContain(`'running'`);
    expect(sql).not.toContain(`'stalled'`);
  });

  test('v15 UPDATE clause has the < 5 guard so idempotent re-runs are no-ops', () => {
    expect(v15!.sql).toContain('max_stalled < 5');
  });
});

describe('migrate — runner behavioral (v14 handler + v15 backfill)', () => {
  let engine: PGLiteEngine;

  beforeAll(async () => {
    engine = new PGLiteEngine();
    await engine.connect({});
    await engine.initSchema();
  });

  afterAll(async () => {
    await engine.disconnect();
  });

  test('v14 created idx_pages_updated_at_desc on PGLite via handler branch', async () => {
    const rows = await (engine as any).db.query(
      `SELECT indexname FROM pg_indexes WHERE indexname = 'idx_pages_updated_at_desc'`
    );
    expect(rows.rows.length).toBe(1);
  });

  test('v15 backfilled any max_stalled=1 rows (smoke: schema default is 5)', async () => {
    await (engine as any).db.exec(
      `INSERT INTO minion_jobs (name, queue, status, max_stalled) VALUES ('test', 'default', 'waiting', 1)`
    );
    await (engine as any).db.exec(
      `UPDATE minion_jobs SET max_stalled = 5
         WHERE status IN ('waiting','active','delayed','waiting-children','paused')
           AND max_stalled < 5`
    );
    const rows = await (engine as any).db.query(
      `SELECT max_stalled FROM minion_jobs WHERE name = 'test'`
    );
    expect((rows.rows[0] as any).max_stalled).toBe(5);

    await (engine as any).db.exec(
      `UPDATE minion_jobs SET max_stalled = 5
         WHERE status IN ('waiting','active','delayed','waiting-children','paused')
           AND max_stalled < 5`
    );
    const rows2 = await (engine as any).db.query(
      `SELECT max_stalled FROM minion_jobs WHERE name = 'test'`
    );
    expect((rows2.rows[0] as any).max_stalled).toBe(5);
  });
});

describe('migrate: v8 (links_dedup) regression — must be fast on 1K duplicate rows', () => {
  let engine: PGLiteEngine;

  beforeAll(async () => {
    engine = new PGLiteEngine();
    await engine.connect({});
    await engine.initSchema();
  });

  afterAll(async () => {
    await engine.disconnect();
  });

  test('1000 duplicate links dedup completes in <90s and leaves table deduped', async () => {
    // Set up: drop BOTH the old (v8) and new (v11) unique constraints so
    // duplicates can be inserted, then reset version so v8 + v11 re-run.
    // v11 replaces the v8 constraint name; we drop whichever is present.
    const db = (engine as any).db;
    await db.exec(`ALTER TABLE links DROP CONSTRAINT IF EXISTS links_from_to_type_unique`);
    await db.exec(`ALTER TABLE links DROP CONSTRAINT IF EXISTS links_from_to_type_source_origin_unique`);

    // Two pages so the FK is satisfied
    await engine.putPage('p/from', { type: 'concept', title: 'F', compiled_truth: '', timeline: '' });
    await engine.putPage('p/to', { type: 'concept', title: 'T', compiled_truth: '', timeline: '' });
    const fromId = (await db.query(`SELECT id FROM pages WHERE slug = 'p/from'`)).rows[0].id;
    const toId = (await db.query(`SELECT id FROM pages WHERE slug = 'p/to'`)).rows[0].id;

    // Insert 1000 duplicates of the same (from, to, type) row
    for (let i = 0; i < 1000; i++) {
      await db.query(
        `INSERT INTO links (from_page_id, to_page_id, link_type, context) VALUES ($1, $2, $3, $4)`,
        [fromId, toId, 'mention', `dup-${i}`]
      );
    }
    const beforeCount = (await db.query(`SELECT COUNT(*)::int AS c FROM links`)).rows[0].c;
    expect(beforeCount).toBe(1000);

    // Reset version to 7 so v8 + v9 + v10 + v11 re-run
    await engine.setConfig('version', '7');

    // Run migrations and assert wall-clock + correctness.
    //
    // Budget note: 90s, not 5s. The 5s budget guarded the original O(n²) v8
    // regression in isolation when the chain only had ~8 migrations to run.
    // Cathedral II (v0.21.0) added v27 + v28 (TSVECTOR column + GIN index +
    // plpgsql trigger compile + 2 new tables w/ FK CASCADE), pushing the
    // full v7→v28 chain to ~30-40s on PGLite WASM. The O(n²) regression
    // would still take MINUTES on 1K duplicate rows (the original incident
    // was multi-minute), so 90s preserves the gate intent while
    // accommodating the longer schema chain.
    const start = Date.now();
    await runMigrations(engine);
    const elapsedMs = Date.now() - start;

    expect(elapsedMs).toBeLessThan(90_000);

    const afterCount = (await db.query(`SELECT COUNT(*)::int AS c FROM links`)).rows[0].c;
    expect(afterCount).toBe(1); // deduped to one row

    // v11 replaces v8's constraint name. Assert the current (v11) constraint
    // exists and the legacy v8 name is gone.
    const constraints = (await db.query(`
      SELECT conname FROM pg_constraint
      WHERE conrelid = 'links'::regclass AND contype = 'u'
    `)).rows;
    expect(constraints.some((c: { conname: string }) => c.conname === 'links_from_to_type_source_origin_unique')).toBe(true);
    expect(constraints.some((c: { conname: string }) => c.conname === 'links_from_to_type_unique')).toBe(false);

    // Helper index was dropped after dedup
    const helperIdx = (await db.query(`
      SELECT indexname FROM pg_indexes
      WHERE tablename = 'links' AND indexname = 'idx_links_dedup_helper'
    `)).rows;
    expect(helperIdx.length).toBe(0);
  });
});

describe('migrate: v9 (timeline_dedup_index) regression — must be fast on 1K duplicate rows', () => {
  let engine: PGLiteEngine;

  beforeAll(async () => {
    engine = new PGLiteEngine();
    await engine.connect({});
    await engine.initSchema();
  });

  afterAll(async () => {
    await engine.disconnect();
  });

  test('1000 duplicate timeline entries dedup completes in <90s and leaves table deduped', async () => {
    const db = (engine as any).db;
    await db.exec(`DROP INDEX IF EXISTS idx_timeline_dedup`);

    await engine.putPage('p/timeline', { type: 'concept', title: 'TL', compiled_truth: '', timeline: '' });
    const pageId = (await db.query(`SELECT id FROM pages WHERE slug = 'p/timeline'`)).rows[0].id;

    // Insert 1000 duplicates of the same (page_id, date, summary) row
    for (let i = 0; i < 1000; i++) {
      await db.query(
        `INSERT INTO timeline_entries (page_id, date, source, summary, detail) VALUES ($1, $2::date, $3, $4, $5)`,
        [pageId, '2024-01-15', `src-${i}`, 'Founded NovaMind', `detail-${i}`]
      );
    }
    const beforeCount = (await db.query(`SELECT COUNT(*)::int AS c FROM timeline_entries`)).rows[0].c;
    expect(beforeCount).toBe(1000);

    await engine.setConfig('version', '7');

    // Same 90s budget as the v8 link-dedup test for the same reason — see
    // its "Budget note" comment. The 5s budget was for v9 in isolation;
    // post-Cathedral II the chain runs through v28's TSVECTOR + GIN setup.
    const start = Date.now();
    await runMigrations(engine);
    const elapsedMs = Date.now() - start;

    expect(elapsedMs).toBeLessThan(90_000);

    const afterCount = (await db.query(`SELECT COUNT(*)::int AS c FROM timeline_entries`)).rows[0].c;
    expect(afterCount).toBe(1);

    const uniqueIdx = (await db.query(`
      SELECT indexname FROM pg_indexes
      WHERE tablename = 'timeline_entries' AND indexname = 'idx_timeline_dedup'
    `)).rows;
    expect(uniqueIdx.length).toBe(1);

    const helperIdx = (await db.query(`
      SELECT indexname FROM pg_indexes
      WHERE tablename = 'timeline_entries' AND indexname = 'idx_timeline_dedup_helper'
    `)).rows;
    expect(helperIdx.length).toBe(0);
  });
});

// ─────────────────────────────────────────────────────────────────
// resolvePoolSize — GBRAIN_POOL_SIZE env override
// ─────────────────────────────────────────────────────────────────
//
// Guards the Bug 2 fix: users on constrained poolers (Supabase port 6543)
// must be able to cap the pool size via GBRAIN_POOL_SIZE. The default
// (10) is unchanged when the env var is unset.

describe('resolvePoolSize — env var + explicit override', () => {
  const { resolvePoolSize } = require('../src/core/db.ts');
  const original = process.env.GBRAIN_POOL_SIZE;

  afterAll(() => {
    if (original === undefined) delete process.env.GBRAIN_POOL_SIZE;
    else process.env.GBRAIN_POOL_SIZE = original;
  });

  test('returns 10 default when unset and no explicit override', () => {
    delete process.env.GBRAIN_POOL_SIZE;
    expect(resolvePoolSize()).toBe(10);
  });

  test('reads GBRAIN_POOL_SIZE as an integer', () => {
    process.env.GBRAIN_POOL_SIZE = '2';
    expect(resolvePoolSize()).toBe(2);
    process.env.GBRAIN_POOL_SIZE = '5';
    expect(resolvePoolSize()).toBe(5);
  });

  test('ignores invalid GBRAIN_POOL_SIZE values', () => {
    process.env.GBRAIN_POOL_SIZE = 'not-a-number';
    expect(resolvePoolSize()).toBe(10);
    process.env.GBRAIN_POOL_SIZE = '0';
    expect(resolvePoolSize()).toBe(10);
    process.env.GBRAIN_POOL_SIZE = '-1';
    expect(resolvePoolSize()).toBe(10);
  });

  test('explicit argument wins over env + default', () => {
    delete process.env.GBRAIN_POOL_SIZE;
    expect(resolvePoolSize(3)).toBe(3);
    process.env.GBRAIN_POOL_SIZE = '7';
    expect(resolvePoolSize(3)).toBe(3);
  });
});

// ─────────────────────────────────────────────────────────────────
// PR #356 regression guards — migration hardening
// ─────────────────────────────────────────────────────────────────
//
// These tests guard the codex + eng review findings folded into PR #356.
// If anyone refactors away the fixes, these catch it.

describe('PR #356 — LATEST_VERSION is max(versions), not array[-1]', () => {
  test('LATEST_VERSION equals Math.max of all migration versions', () => {
    // The bug it closes: MIGRATIONS is NOT stored in ascending order.
    // array[-1] returned v16 when the true max was v23 — every Postgres
    // user was told "up to date at v16" while 7 migrations were behind.
    // This regression guard catches any refactor back to array[-1].
    const expectedMax = Math.max(...MIGRATIONS.map(m => m.version));
    expect(LATEST_VERSION).toBe(expectedMax);
  });

  test('Math.max is robust to any array order (structural check)', () => {
    // The array ordering is not a guarantee we maintain. v0.18.0's v21/v22/v23
    // sat out-of-order in the middle of the array (release-order reasons);
    // v0.18.1's v24 was appended sensibly. Both need to work. The invariant
    // is: LATEST_VERSION equals max across any ordering. Scramble and verify.
    const scrambled = [...MIGRATIONS].sort(() => Math.random() - 0.5);
    const scrambledMax = Math.max(...scrambled.map(m => m.version));
    expect(scrambledMax).toBe(LATEST_VERSION);

    // Guard against regression to array[-1]: the production source must use
    // Math.max, never indexed access to the last element.
    const src = readFileSync(resolve('src/core/migrate.ts'), 'utf-8');
    expect(src).toMatch(/LATEST_VERSION\s*=\s*MIGRATIONS\.length[\s\S]{0,200}Math\.max/);
    expect(src).not.toMatch(/MIGRATIONS\[MIGRATIONS\.length\s*-\s*1\]\.version/);
  });
});

describe('PR #356 — getIdleBlockers pg_stat_activity shape', () => {
  // Minimal mock of BrainEngine — we only need kind + executeRaw.
  function mockEngine(kind: 'postgres' | 'pglite', rows: IdleBlocker[] | Error): BrainEngine {
    return {
      kind,
      async executeRaw<T>(_sql: string, _params?: unknown[]): Promise<T[]> {
        if (rows instanceof Error) throw rows;
        return rows as unknown as T[];
      },
    } as unknown as BrainEngine;
  }

  test('returns [] on PGLite (no pool, no idle-in-tx concept)', async () => {
    const engine = mockEngine('pglite', [{ pid: 1, state: 'idle in transaction', query_start: 'x', query: 'y' }]);
    const blockers = await getIdleBlockers(engine);
    expect(blockers).toEqual([]);
  });

  test('returns rows from pg_stat_activity on Postgres', async () => {
    const fixture: IdleBlocker[] = [
      { pid: 12345, state: 'idle in transaction', query_start: '2026-04-22 06:00:00+00', query: 'BEGIN; SELECT * FROM pages' },
    ];
    const engine = mockEngine('postgres', fixture);
    const blockers = await getIdleBlockers(engine);
    expect(blockers).toEqual(fixture);
  });

  test('returns [] (not throw) when pg_stat_activity query fails', async () => {
    // Some managed Postgres tenants restrict pg_stat_activity. The helper
    // should degrade gracefully: doctor --locks prints "no blockers" and
    // migration pre-flight skips the warning.
    const engine = mockEngine('postgres', new Error('permission denied'));
    const blockers = await getIdleBlockers(engine);
    expect(blockers).toEqual([]);
  });
});

describe('PR #356 — 57014 catch path emits actionable 4-part diagnostic', () => {
  test('runMigrations surfaces SQLSTATE 57014 with fix + verify steps', async () => {
    // Mock an engine whose runMigration throws a code-57014 error
    // once; the catch branch should log the 4-part structure AND
    // rethrow preserving err.code so callers can re-branch.
    const err = Object.assign(new Error('canceling statement due to statement timeout'), { code: '57014' });

    let caughtCode: string | undefined;
    // getConfig returns '15' so pending starts with v16 (has sql content
    // in the MIGRATIONS array). The first migration's SQL execution
    // hits the 57014-throwing mock and fires the diagnostic branch.
    const engine = {
      kind: 'postgres' as const,
      async getConfig(_k: string) { return '15'; },
      async setConfig() {},
      async executeRaw() { return []; },
      async transaction<T>(fn: (e: BrainEngine) => Promise<T>): Promise<T> { return fn(engine as unknown as BrainEngine); },
      async withReservedConnection() { throw new Error('unreached'); },
      async runMigration() { throw err; },
    } as unknown as BrainEngine;

    const errSpy = spyOn(console, 'error').mockImplementation(() => {});

    try {
      await runMigrations(engine);
    } catch (e: unknown) {
      caughtCode = (e as { code?: string }).code;
    }
    expect(caughtCode).toBe('57014');

    // Assert the diagnostic lines hit stderr with the exact agent-driven shape:
    // what happened, why, fix, verify.
    const msgs = errSpy.mock.calls.map(c => String(c[0]));
    const joined = msgs.join('\n');
    expect(joined).toContain('statement_timeout');
    expect(joined).toContain('SQLSTATE 57014');
    expect(joined).toContain('gbrain doctor --locks');
    expect(joined).toContain('gbrain apply-migrations --yes');
    expect(joined).toContain('Verify:');
    expect(joined).toContain('gbrain doctor');

    errSpy.mockRestore();
  });
});

describe('PR #356 — apply-migrations pre-flight schema-version warning', () => {
  test('source contains the pre-flight check branch before plan execution', () => {
    // Structural check: the pre-flight block compares the engine's
    // reported schema version against LATEST_VERSION and warns if
    // behind. If someone removes this branch, users who run
    // apply-migrations expecting it to handle schema migrations get
    // the silent-gaslight experience from the field report.
    const source = readFileSync(resolve('src/commands/apply-migrations.ts'), 'utf-8');
    expect(source).toContain('LATEST_VERSION');
    expect(source).toContain('Schema version');
    expect(source).toContain('is behind latest');
  });
});

describe('PR #356 — setSessionDefaults is applied on both db.ts and postgres-engine.ts paths', () => {
  test('structural: idle_in_transaction_session_timeout set via single helper', () => {
    // After PR #356 extracted setSessionDefaults, both connect paths
    // should call the helper, not inline the SET. Any regression
    // that re-duplicates the block gets caught here.
    const dbSrc = readFileSync(resolve('src/core/db.ts'), 'utf-8');
    const pgSrc = readFileSync(resolve('src/core/postgres-engine.ts'), 'utf-8');

    // Helper is defined in db.ts
    expect(dbSrc).toContain('export async function setSessionDefaults');
    expect(dbSrc).toContain('idle_in_transaction_session_timeout');

    // connect() in db.ts calls the helper, doesn't inline the SET
    // (the SET only appears inside the helper itself now).
    const setMatches = dbSrc.match(/SET idle_in_transaction_session_timeout/g) || [];
    expect(setMatches.length).toBe(1); // only in the helper

    // postgres-engine.ts calls the helper too, doesn't duplicate
    expect(pgSrc).toContain('db.setSessionDefaults');
    expect(pgSrc).not.toContain("SET idle_in_transaction_session_timeout");
  });
});

describe('PR #356 — non-transactional DDL runs via reserved connection', () => {
  test('runMigrationSQL uses withReservedConnection for transaction:false branch', () => {
    // The else-branch of runMigrationSQL (CREATE INDEX CONCURRENTLY etc.)
    // must go through engine.withReservedConnection + SET statement_timeout,
    // NOT engine.runMigration on the shared pool. Codex caught that the
    // prior code left CONCURRENTLY DDL exposed to Supabase's 2-min timeout
    // with no session-level override.
    const source = readFileSync(resolve('src/core/migrate.ts'), 'utf-8');

    // The runMigrationSQL function must mention reserved connection + session timeout.
    const runFnIdx = source.indexOf('async function runMigrationSQL');
    expect(runFnIdx).toBeGreaterThan(-1);
    const fnBody = source.slice(runFnIdx, runFnIdx + 2500);
    expect(fnBody).toContain('withReservedConnection');
    expect(fnBody).toContain("SET statement_timeout = '600000'");
  });
});
