/**
 * E2E cycle tests — Tier 1 (no API keys required).
 *
 * Exercises runCycle against REAL Postgres (via the E2E helpers' setupDB /
 * teardownDB lifecycle) with a real git repo and a mocked embedBatch.
 * Covers what the unit tests can't: the gbrain_cycle_locks table's
 * INSERT...ON CONFLICT...WHERE semantics under a real postgres-js client,
 * the v0.17 schema migration applying cleanly to a fresh Postgres, and the
 * dry-run regression guard asserting zero writes when flag is set.
 *
 * Run: DATABASE_URL=... bun test test/e2e/cycle.test.ts
 */

import { describe, test, expect, mock, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { hasDatabase, setupDB, teardownDB, getEngine, getConn } from './helpers.ts';

// Mock embedBatch BEFORE importing runCycle so no real OpenAI calls happen
// even when the full cycle's embed phase runs.
mock.module('../../src/core/embedding.ts', () => ({
  embedBatch: async (texts: string[]) => {
    // Deterministic fake vector for each chunk.
    return texts.map(() => new Float32Array(1536));
  },
  // v0.41.31: embed phase reads the current signature to stamp provenance.
  currentEmbeddingSignature: () => 'test:model:1536',
}));

const { runCycle, ALL_PHASES } = await import('../../src/core/cycle.ts');

const skip = !hasDatabase();
const describeE2E = skip ? describe.skip : describe;

if (skip) {
  console.log('Skipping E2E cycle tests (DATABASE_URL not set)');
}

function makeGitRepo(): string {
  const dir = mkdtempSync(join(tmpdir(), 'gbrain-e2e-cycle-'));
  execSync('git init', { cwd: dir, stdio: 'pipe' });
  execSync('git config user.email test@test.co', { cwd: dir, stdio: 'pipe' });
  execSync('git config user.name test', { cwd: dir, stdio: 'pipe' });

  mkdirSync(join(dir, 'people'), { recursive: true });
  writeFileSync(
    join(dir, 'people/alice.md'),
    '---\ntype: person\ntitle: Alice\n---\n\nAlice collaborates with Bob.\n',
  );
  writeFileSync(
    join(dir, 'people/bob.md'),
    '---\ntype: person\ntitle: Bob\n---\n\nBob is a person.\n',
  );
  execSync('git add -A && git commit -m init', { cwd: dir, stdio: 'pipe' });
  return dir;
}

describeE2E('E2E: runCycle against real Postgres', () => {
  let repo: string;

  beforeAll(async () => {
    await setupDB();
    repo = makeGitRepo();
  }, 30_000);

  afterAll(async () => {
    await teardownDB();
    if (repo) rmSync(repo, { recursive: true, force: true });
  });

  test('v0.17 migration v16 created gbrain_cycle_locks table', async () => {
    const conn = getConn();
    const rows = await conn.unsafe(
      `SELECT tablename FROM pg_tables WHERE tablename = 'gbrain_cycle_locks'`,
    );
    expect(rows.length).toBe(1);

    // idx_cycle_locks_ttl index also exists.
    const idx = await conn.unsafe(
      `SELECT indexname FROM pg_indexes WHERE indexname = 'idx_cycle_locks_ttl'`,
    );
    expect(idx.length).toBe(1);
  });

  test('dry-run full cycle: zero DB writes + zero filesystem changes', async () => {
    const conn = getConn();
    // Baseline: track initial state.
    const beforePages = await conn.unsafe(`SELECT count(*)::int AS n FROM pages`);
    const beforeSync = await conn.unsafe(
      `SELECT value FROM config WHERE key = 'sync.last_commit'`,
    );

    const report = await runCycle(getEngine(), {
      brainDir: repo,
      dryRun: true,
      pull: false,
    });

    expect(report.schema_version).toBe('1');
    // Every phase in ALL_PHASES pushes exactly one result (pack-gated phases
    // like extract_atoms / synthesize_concepts push a 'skipped' result), so
    // the full dry-run cycle's phase count always equals ALL_PHASES.length.
    // Assert against the live constant rather than a hardcoded number so this
    // doesn't go stale every time a phase is added (it drifted to 19 while the
    // real count was 20 — the conversation_facts_backfill phase was missed).
    expect(report.phases.length).toBe(ALL_PHASES.length);

    // Nothing got written.
    const afterPages = await conn.unsafe(`SELECT count(*)::int AS n FROM pages`);
    expect(afterPages[0].n).toBe(beforePages[0].n);

    // sync.last_commit unchanged (wasn't set before, isn't set now).
    const afterSync = await conn.unsafe(
      `SELECT value FROM config WHERE key = 'sync.last_commit'`,
    );
    expect(afterSync.length).toBe(beforeSync.length);

    // Cycle lock was acquired + released; table should be empty after.
    const locks = await conn.unsafe(`SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks`);
    expect(locks[0].n).toBe(0);
  });

  test('live cycle: pages get synced + chunks created + cycle lock cleaned up', async () => {
    const conn = getConn();

    const report = await runCycle(getEngine(), {
      brainDir: repo,
      dryRun: false,
      pull: false,
    });

    expect(report.schema_version).toBe('1');
    // The sync phase should have run and imported real pages.
    const syncPhase = report.phases.find(p => p.phase === 'sync');
    expect(syncPhase).toBeDefined();
    expect(syncPhase?.status).not.toBe('fail');

    // Pages exist in the DB.
    const pages = await conn.unsafe(`SELECT slug FROM pages ORDER BY slug`);
    const slugs = (pages as unknown as Array<{ slug: string }>).map(p => p.slug);
    expect(slugs).toContain('people/alice');
    expect(slugs).toContain('people/bob');

    // sync.last_commit bookmark is now set.
    const sync = await conn.unsafe(
      `SELECT value FROM config WHERE key = 'sync.last_commit'`,
    );
    expect(sync.length).toBe(1);
    expect((sync[0] as any).value.length).toBeGreaterThanOrEqual(7);

    // Cycle lock is released.
    const locks = await conn.unsafe(`SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks`);
    expect(locks[0].n).toBe(0);
  }, 60_000);

  test('concurrent cycle is blocked by the lock (status:skipped)', async () => {
    const conn = getConn();

    // Seed a fresh-TTL lock held by a different (fake) PID.
    await conn.unsafe(
      `INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
       VALUES ('gbrain-cycle', 99999, 'other-host', NOW(), NOW() + INTERVAL '1 hour')`,
    );

    try {
      const report = await runCycle(getEngine(), {
        brainDir: repo,
        dryRun: true,
        pull: false,
      });
      expect(report.status).toBe('skipped');
      expect(report.reason).toBe('cycle_already_running');
      expect(report.phases.length).toBe(0);
    } finally {
      // Clean up the seeded lock.
      await conn.unsafe(`DELETE FROM gbrain_cycle_locks WHERE id = 'gbrain-cycle'`);
    }
  });

  test('TTL-expired lock is auto-claimed (crashed holder recovery)', async () => {
    const conn = getConn();

    // Seed a stale lock (TTL in the past).
    await conn.unsafe(
      `INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
       VALUES ('gbrain-cycle', 99999, 'crashed-host', NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour')`,
    );

    const report = await runCycle(getEngine(), {
      brainDir: repo,
      dryRun: true,
      pull: false,
    });
    // Crashed holder's stale TTL lets the new run acquire the lock.
    expect(report.status).not.toBe('skipped');

    // Lock released after the run.
    const locks = await conn.unsafe(`SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks`);
    expect(locks[0].n).toBe(0);
  });

  test('--phase orphans skips the lock entirely (read-only optimization)', async () => {
    const conn = getConn();

    // Seed a fresh-TTL lock held by someone else. A read-only phase
    // selection should succeed anyway (orphans never acquires the lock).
    await conn.unsafe(
      `INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
       VALUES ('gbrain-cycle', 99999, 'other-host', NOW(), NOW() + INTERVAL '1 hour')`,
    );

    try {
      const report = await runCycle(getEngine(), {
        brainDir: repo,
        phases: ['orphans'],
        pull: false,
      });
      // Status is NOT skipped — orphans ran despite the held lock.
      expect(report.status).not.toBe('skipped');
      const orphansPhase = report.phases.find(p => p.phase === 'orphans');
      expect(orphansPhase).toBeDefined();
    } finally {
      await conn.unsafe(`DELETE FROM gbrain_cycle_locks WHERE id = 'gbrain-cycle'`);
    }
  });
});
