/**
 * v0.32.8 — multi-source bug class regression suite.
 *
 * Pins the behaviors that were silently broken pre-v0.32.8 when a brain has
 * more than one source. Pre-fix, every cycle phase and extract pass called
 * slug-only engine methods inside a loop over pages and silently defaulted
 * to source_id='default' for every non-default-source page.
 *
 * Fixture: 2 sources ('default' + 'media-corpus') with overlapping slugs.
 *   - people/alice exists in BOTH
 *   - concepts/widget exists ONLY in default
 *   - media/x/post-123 exists ONLY in media-corpus
 *
 * Coverage targets (one test per bug site):
 *   1. listAllPageRefs returns one row per (slug, source_id), ordered.
 *   2. extract-takes processes BOTH alice rows independently.
 *   3. integrity scan covers BOTH sources.
 *   4. extract-links F10 cross-source resolution:
 *        a. alice@media-corpus → widget falls back to default
 *        b. alice@media-corpus → post-123 stays in media-corpus
 *        c. alice@default → ghost-slug records nothing (skip)
 *   5. validateSourceId rejects path-traversal attempts.
 *   6. Reverse-write disk layout: .sources/<id>/<slug>.md for non-default.
 *
 * PGLite in-memory — no DATABASE_URL required. Canonical R3+R4 pattern.
 */

import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { tmpdir } from 'node:os';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import { validateSourceId } from '../../src/core/utils.ts';
import { extractTakesFromDb } from '../../src/core/cycle/extract-takes.ts';
import { copyMigrationSources } from '../../src/commands/migrate-engine.ts';

let engine: PGLiteEngine;
let migrationTarget: PGLiteEngine;

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

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

beforeEach(async () => {
  await resetPgliteState(engine);
  await resetPgliteState(migrationTarget);
  // Seed second source row. Default source is seeded by resetPgliteState.
  await engine.executeRaw(
    `INSERT INTO sources (id, name, config)
       VALUES ('media-corpus', 'media-corpus', '{}'::jsonb)
       ON CONFLICT (id) DO NOTHING`,
  );
  // Overlapping-slug seed.
  await engine.putPage('people/alice', {
    type: 'person', title: 'Alice (default)',
    compiled_truth: 'Default-source alice page.',
  }, { sourceId: 'default' });
  await engine.putPage('people/alice', {
    type: 'person', title: 'Alice (media-corpus)',
    compiled_truth: 'Media-corpus alice page.',
  }, { sourceId: 'media-corpus' });
  // Distinct slugs per source.
  await engine.putPage('concepts/widget', {
    type: 'concept', title: 'Widget',
    compiled_truth: 'Default-source-only widget.',
  }, { sourceId: 'default' });
  await engine.putPage('media/x/post-123', {
    type: 'media', title: 'Post 123',
    compiled_truth: 'Media-corpus-only post.',
  }, { sourceId: 'media-corpus' });
});

describe('multi-source bug class', () => {
  test('migration copies source metadata before overlapping-slug pages', async () => {
    await engine.executeRaw(`
      UPDATE sources SET
        local_path = '/tmp/media-corpus', last_commit = 'commit-media',
        last_sync_at = '2026-07-02T00:00:00Z',
        config = '{"federated":false,"custom":"preserved"}'::jsonb,
        archived = true, archived_at = '2026-07-03T00:00:00Z',
        archive_expires_at = '2026-08-03T00:00:00Z',
        contextual_retrieval_mode = 'tokenmax',
        trust_frontmatter_overrides = true,
        newest_content_at = '2026-07-01T00:00:00Z'
      WHERE id = 'media-corpus'`);

    await copyMigrationSources(engine, migrationTarget);
    const sourceRows = await migrationTarget.executeRaw<any>(
      `SELECT * FROM sources WHERE id = 'media-corpus'`,
    );
    expect(sourceRows).toHaveLength(1);
    expect(sourceRows[0].last_commit).toBe('commit-media');
    expect(sourceRows[0].archived).toBe(true);
    expect(sourceRows[0].contextual_retrieval_mode).toBe('tokenmax');
    expect(sourceRows[0].trust_frontmatter_overrides).toBe(true);
    const config = typeof sourceRows[0].config === 'string'
      ? JSON.parse(sourceRows[0].config)
      : sourceRows[0].config;
    expect(config.custom).toBe('preserved');

    const overlapping = await engine.listPages({ limit: 100 });
    for (const page of overlapping) {
      await migrationTarget.putPage(page.slug, {
        type: page.type,
        title: page.title,
        compiled_truth: page.compiled_truth,
        timeline: page.timeline,
        frontmatter: page.frontmatter,
        content_hash: page.content_hash,
      }, { sourceId: page.source_id });
    }
    expect(await migrationTarget.getPage('people/alice', { sourceId: 'default' })).not.toBeNull();
    expect(await migrationTarget.getPage('people/alice', { sourceId: 'media-corpus' })).not.toBeNull();
  });

  test('listAllPageRefs returns one row per (slug, source_id), ordered (F11)', async () => {
    const refs = await engine.listAllPageRefs();
    // 4 rows: alice@default, alice@media-corpus, widget@default, post-123@media-corpus
    expect(refs.length).toBe(4);
    // Sorted by (source_id, slug)
    const ordered = refs.map(r => `${r.source_id}::${r.slug}`);
    expect(ordered).toEqual([
      'default::concepts/widget',
      'default::people/alice',
      'media-corpus::media/x/post-123',
      'media-corpus::people/alice',
    ]);
  });

  test('getPage with sourceId picks the right (source, slug) row', async () => {
    const aliceDefault = await engine.getPage('people/alice', { sourceId: 'default' });
    const aliceMedia = await engine.getPage('people/alice', { sourceId: 'media-corpus' });
    expect(aliceDefault?.title).toBe('Alice (default)');
    expect(aliceMedia?.title).toBe('Alice (media-corpus)');
    expect(aliceDefault?.source_id).toBe('default');
    expect(aliceMedia?.source_id).toBe('media-corpus');
  });

  test('extract-takes processes both alice pages independently', async () => {
    // Re-seed with takes fences so extract-takes has something to find.
    // Fence markers come from src/core/takes-fence.ts (`<!--- gbrain:takes:begin -->`).
    const TAKES_BODY = `<!--- gbrain:takes:begin -->
| # | claim | kind | who | weight | since | source |
| --- | --- | --- | --- | --- | --- | --- |
| 1 | Alice founded the thing | fact | garry | 0.9 | 2024-01-01 |  |
<!--- gbrain:takes:end -->`;
    await engine.putPage('people/alice', {
      type: 'person', title: 'Alice (default)',
      compiled_truth: 'Default alice.\n' + TAKES_BODY,
    }, { sourceId: 'default' });
    await engine.putPage('people/alice', {
      type: 'person', title: 'Alice (media-corpus)',
      compiled_truth: 'Media alice.\n' + TAKES_BODY,
    }, { sourceId: 'media-corpus' });

    const result = await extractTakesFromDb(engine, {});
    // Pre-fix: only one of the two alice rows had takes extracted because
    // the loop matched by bare slug. Post-fix: both rows get processed.
    // Each alice has 1 take; widget + post-123 have none.
    expect(result.pagesWithTakes).toBe(2);
    expect(result.takesUpserted).toBe(2);
  });

  test('listPages filters correctly with PageFilters.sourceId', async () => {
    const onlyDefault = await engine.listPages({ sourceId: 'default', limit: 100 });
    const onlyMedia = await engine.listPages({ sourceId: 'media-corpus', limit: 100 });
    expect(onlyDefault.length).toBe(2);
    expect(onlyMedia.length).toBe(2);
    for (const p of onlyDefault) expect(p.source_id).toBe('default');
    for (const p of onlyMedia) expect(p.source_id).toBe('media-corpus');
  });

  test('addLinksBatch with from/to_source_id targets the right rows (F4)', async () => {
    // Link alice@media-corpus → post-123@media-corpus (in-source link).
    const inserted = await engine.addLinksBatch([
      {
        from_slug: 'people/alice',
        to_slug: 'media/x/post-123',
        link_type: 'mentions',
        link_source: 'markdown',
        from_source_id: 'media-corpus',
        to_source_id: 'media-corpus',
      },
    ]);
    expect(inserted).toBe(1);

    const links = await engine.getLinks('people/alice', { sourceId: 'media-corpus' });
    expect(links.length).toBe(1);
    expect(links[0].to_slug).toBe('media/x/post-123');

    // alice@default should have NO links — they're scoped to media-corpus.
    const defaultLinks = await engine.getLinks('people/alice', { sourceId: 'default' });
    expect(defaultLinks.length).toBe(0);
  });

  test('validateSourceId rejects path traversal (F6)', () => {
    // v0.38 (codex P1-D + eng E2): regex TIGHTENED from permissive
    // ^[a-z0-9_-]+$ to strict kebab ^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$.
    // Underscores no longer allowed at the path-safety gate (matches
    // what sources-ops always rejected at creation time). 'jarvis_memory'
    // was a v0.32.8 permissive-regex test case; now lives in the
    // rejected set. Path-traversal rejection unchanged.
    //
    // Allowed (strict kebab):
    expect(() => validateSourceId('default')).not.toThrow();
    expect(() => validateSourceId('media-corpus')).not.toThrow();
    expect(() => validateSourceId('jarvis-memory')).not.toThrow();
    expect(() => validateSourceId('abc123')).not.toThrow();
    expect(() => validateSourceId('a')).not.toThrow();
    // Rejected — path traversal / unsafe chars:
    expect(() => validateSourceId('..')).toThrow();
    expect(() => validateSourceId('../etc')).toThrow();
    expect(() => validateSourceId('foo/bar')).toThrow();
    expect(() => validateSourceId('foo bar')).toThrow();
    expect(() => validateSourceId('Default')).toThrow(); // uppercase
    expect(() => validateSourceId('.hidden')).toThrow();
    expect(() => validateSourceId('')).toThrow();
    // Rejected — strict regex additions (v0.38):
    expect(() => validateSourceId('jarvis_memory')).toThrow(); // underscores
    expect(() => validateSourceId('snake_case')).toThrow();
    expect(() => validateSourceId('-leading')).toThrow();      // edge hyphen
    expect(() => validateSourceId('trailing-')).toThrow();
    const tooLong = 'a' + 'b'.repeat(31) + 'c'; // 33 chars
    expect(() => validateSourceId(tooLong)).toThrow();
  });

  test('reverse-write disk layout uses .sources/<id>/<slug>.md for non-default (F6)', () => {
    // Pure-function test of the disk-path computation embedded in
    // reverseWriteRefs (patterns.ts + synthesize.ts). We don't drive the
    // full dream cycle here — that requires LLM + subagent infra. Instead
    // we replicate the path computation and verify the file layout matches
    // what the production code would write.
    const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-multi-source-disk-'));
    try {
      const computePath = (source_id: string, slug: string): string =>
        source_id === 'default'
          ? join(tmpDir, `${slug}.md`)
          : join(tmpDir, '.sources', source_id, `${slug}.md`);

      const defaultPath = computePath('default', 'people/alice');
      const mediaPath = computePath('media-corpus', 'people/alice');

      // The two paths must NOT collide.
      expect(defaultPath).not.toBe(mediaPath);
      expect(mediaPath).toContain('.sources/media-corpus/');

      // Actually write to both paths to prove disk separation.
      mkdirSync(join(tmpDir, 'people'), { recursive: true });
      mkdirSync(join(tmpDir, '.sources', 'media-corpus', 'people'), { recursive: true });
      writeFileSync(defaultPath, 'default alice');
      writeFileSync(mediaPath, 'media-corpus alice');

      expect(readFileSync(defaultPath, 'utf8')).toBe('default alice');
      expect(readFileSync(mediaPath, 'utf8')).toBe('media-corpus alice');
      expect(existsSync(defaultPath)).toBe(true);
      expect(existsSync(mediaPath)).toBe(true);
    } finally {
      rmSync(tmpDir, { recursive: true, force: true });
    }
  });
});
