/**
 * BrainWriter + Scaffolder + SlugRegistry + 4 validators.
 *
 * Runs against PGLite in-memory. No network. Engine lifecycle per-suite
 * via beforeAll/afterAll so migrations apply once.
 */

import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { tmpdir } from 'os';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';

import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import type { ResolverContext } from '../src/core/resolvers/index.ts';

import {
  BrainWriter,
  WriteError,
  type ValidationReport,
} from '../src/core/output/writer.ts';
import { SlugRegistry, SlugRegistryError } from '../src/core/output/slug-registry.ts';
import {
  tweetCitation,
  emailCitation,
  sourceCitation,
  entityLink,
  timelineLine,
  ScaffoldError,
} from '../src/core/output/scaffold.ts';
import {
  citationValidator,
  linkValidator,
  backLinkValidator,
  tripleHrValidator,
  registerBuiltinValidators,
} from '../src/core/output/validators/index.ts';
import {
  splitParagraphs,
} from '../src/core/output/validators/citation.ts';
import {
  normalizeToSlug,
  isExternalUrl,
  isNonBrainRef,
} from '../src/core/output/validators/link.ts';

// ---------------------------------------------------------------------------
// Engine fixture
// ---------------------------------------------------------------------------

let engine: BrainEngine;
let dbDir: string;

beforeAll(async () => {
  dbDir = mkdtempSync(join(tmpdir(), 'writer-test-'));
  engine = new PGLiteEngine();
  await engine.connect({ engine: 'pglite', database_path: dbDir });
  await engine.initSchema();
});

afterAll(async () => {
  await engine.disconnect();
  rmSync(dbDir, { recursive: true, force: true });
});

// Reset DB between tests by truncating — cheaper than tearing down PGLite.
async function reset(): Promise<void> {
  await engine.executeRaw('TRUNCATE pages, links, content_chunks, timeline_entries, tags, raw_data, page_versions RESTART IDENTITY CASCADE');
}

function makeCtx(overrides: Partial<ResolverContext> = {}): ResolverContext {
  return {
    config: {},
    logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
    requestId: 'test',
    remote: false,
    ...overrides,
  };
}

// ---------------------------------------------------------------------------
// Scaffolder
// ---------------------------------------------------------------------------

describe('Scaffolder', () => {
  test('tweetCitation builds canonical form', () => {
    const c = tweetCitation({ handle: 'garrytan', tweetId: '1234567890', dateISO: '2026-04-18' });
    expect(c).toBe('[Source: [X/garrytan, 2026-04-18](https://x.com/garrytan/status/1234567890)]');
  });

  test('tweetCitation strips leading @', () => {
    const c = tweetCitation({ handle: '@garrytan', tweetId: '1', dateISO: '2026-04-18' });
    expect(c).toContain('X/garrytan');
    expect(c).not.toContain('@garrytan');
  });

  test('tweetCitation rejects invalid handle', () => {
    expect(() => tweetCitation({ handle: 'not a handle', tweetId: '1' })).toThrow(ScaffoldError);
  });

  test('tweetCitation rejects non-numeric tweet id', () => {
    expect(() => tweetCitation({ handle: 'garrytan', tweetId: 'abc' })).toThrow(ScaffoldError);
  });

  test('tweetCitation rejects bad date format', () => {
    expect(() => tweetCitation({ handle: 'garrytan', tweetId: '1', dateISO: '2026/04/18' })).toThrow(ScaffoldError);
  });

  test('emailCitation builds deep link and encodes account', () => {
    const c = emailCitation({
      account: 'garry@ycombinator.com',
      messageId: 'abc123def456',
      subject: 'Re: Deal',
      dateISO: '2026-04-18',
    });
    expect(c).toContain('garry%40ycombinator.com');
    expect(c).toContain('#inbox/abc123def456');
    expect(c).toContain('"Re: Deal"');
  });

  test('emailCitation rejects short message id', () => {
    expect(() => emailCitation({
      account: 'x',
      messageId: 'short',
      subject: 'x',
    })).toThrow(ScaffoldError);
  });

  test('sourceCitation with url', () => {
    const r = sourceCitation({ source: 'perplexity-sonar', fetchedAt: new Date('2026-04-18') }, { url: 'https://example.com/r' });
    expect(r).toBe('[Source: [perplexity-sonar, 2026-04-18](https://example.com/r)]');
  });

  test('sourceCitation without url', () => {
    const r = sourceCitation({ source: 'perplexity-sonar', fetchedAt: new Date('2026-04-18') });
    expect(r).toBe('[Source: perplexity-sonar, 2026-04-18]');
  });

  test('entityLink prefix + slug', () => {
    const l = entityLink({ slug: 'people/alice-smith', displayText: 'Alice', relativePrefix: '../../' });
    expect(l).toBe('[Alice](../../people/alice-smith.md)');
  });

  test('entityLink sanitizes display text', () => {
    // Newlines → spaces, brackets stripped, trimmed
    const l = entityLink({ slug: 'people/alice', displayText: 'A\nli[ce]' });
    expect(l).toBe('[A lice](people/alice.md)');
  });

  test('entityLink rejects invalid slug', () => {
    expect(() => entityLink({ slug: 'invalid', displayText: 'x' })).toThrow(ScaffoldError);
    expect(() => entityLink({ slug: 'Bad/Slug', displayText: 'x' })).toThrow(ScaffoldError);
  });

  test('timelineLine builds canonical form', () => {
    const l = timelineLine({ dateISO: '2026-04-18', summary: 'Met Alice', citation: '[Source: x, y]' });
    expect(l).toBe('- **2026-04-18** | Met Alice [Source: x, y]');
  });
});

// ---------------------------------------------------------------------------
// SlugRegistry
// ---------------------------------------------------------------------------

describe('SlugRegistry', () => {
  beforeEach(async () => { await reset(); });

  test('create on empty brain returns desired slug', async () => {
    const reg = new SlugRegistry(engine);
    const r = await reg.create({
      desiredSlug: 'people/alice-smith',
      displayName: 'Alice Smith',
      type: 'person',
    });
    expect(r.slug).toBe('people/alice-smith');
    expect(r.exact).toBe(true);
    expect(r.disambiguator).toBeUndefined();
  });

  test('create disambiguates on collision', async () => {
    const reg = new SlugRegistry(engine);
    await engine.putPage('people/alice-smith', {
      type: 'person', title: 'Alice Smith', compiled_truth: 'x', frontmatter: {},
    });
    const r = await reg.create({
      desiredSlug: 'people/alice-smith',
      displayName: 'Different Alice',
      type: 'person',
    });
    expect(r.slug).toBe('people/alice-smith-2');
    expect(r.exact).toBe(false);
    expect(r.disambiguator).toBe(2);
  });

  test('create throws on collision when onCollision=throw', async () => {
    const reg = new SlugRegistry(engine);
    await engine.putPage('people/bob', { type: 'person', title: 'Bob', compiled_truth: 'x', frontmatter: {} });
    await expect(reg.create({
      desiredSlug: 'people/bob',
      displayName: 'Bob',
      type: 'person',
      onCollision: 'throw',
    })).rejects.toThrow(SlugRegistryError);
  });

  test('create accepts CJK slugs (#738)', async () => {
    const reg = new SlugRegistry(engine);
    const r = await reg.create({
      desiredSlug: '知识/品牌圣经',
      displayName: '品牌圣经',
      type: 'note',
    });
    expect(r.slug).toBe('知识/品牌圣经');
    expect(r.exact).toBe(true);
  });

  test('create throws on invalid slug', async () => {
    const reg = new SlugRegistry(engine);
    await expect(reg.create({
      desiredSlug: 'bad slug with spaces',
      displayName: 'x',
      type: 'person',
    })).rejects.toThrow(SlugRegistryError);
  });

  test('isFree + suggestDisambiguators', async () => {
    const reg = new SlugRegistry(engine);
    await engine.putPage('people/charlie', { type: 'person', title: 'Charlie', compiled_truth: 'x', frontmatter: {} });
    expect(await reg.isFree('people/charlie')).toBe(false);
    expect(await reg.isFree('people/dave')).toBe(true);
    const suggestions = await reg.suggestDisambiguators('people/charlie', 3);
    expect(suggestions).toEqual(['people/charlie-2', 'people/charlie-3', 'people/charlie-4']);
  });
});

// ---------------------------------------------------------------------------
// BrainWriter
// ---------------------------------------------------------------------------

describe('BrainWriter', () => {
  beforeEach(async () => { await reset(); });

  test('transaction creates entity, returns slug + empty report', async () => {
    const writer = new BrainWriter(engine);
    const { result, report } = await writer.transaction(async (tx) => {
      return tx.createEntity({
        desiredSlug: 'people/alice',
        displayName: 'Alice',
        type: 'person',
        compiledTruth: 'Alice is a person.',
      });
    }, makeCtx());
    expect(result).toBe('people/alice');
    expect(report.errorCount).toBe(0);
    expect(report.touchedSlugs).toEqual(['people/alice']);
  });

  test('transaction disambiguates slug collision', async () => {
    const writer = new BrainWriter(engine);
    await writer.transaction(async (tx) => tx.createEntity({
      desiredSlug: 'people/eve',
      displayName: 'Eve',
      type: 'person',
      compiledTruth: 'first eve',
    }), makeCtx());
    const { result } = await writer.transaction(async (tx) => tx.createEntity({
      desiredSlug: 'people/eve',
      displayName: 'Eve (different)',
      type: 'person',
      compiledTruth: 'second eve',
    }), makeCtx());
    expect(result).toBe('people/eve-2');
  });

  test('addLink creates forward + back-link', async () => {
    const writer = new BrainWriter(engine);
    await writer.transaction(async (tx) => {
      await tx.createEntity({ desiredSlug: 'people/a', displayName: 'A', type: 'person', compiledTruth: 'a' });
      await tx.createEntity({ desiredSlug: 'people/b', displayName: 'B', type: 'person', compiledTruth: 'b' });
      await tx.addLink('people/a', 'people/b', 'connected', 'knows');
    }, makeCtx());

    const outbound = await engine.getLinks('people/a');
    const inbound = await engine.getBacklinks('people/a');
    expect(outbound.map(l => l.to_slug)).toContain('people/b');
    expect(inbound.map(l => l.from_slug)).toContain('people/b');
  });

  test('strict mode rolls back on validator error', async () => {
    const writer = new BrainWriter(engine, { strictMode: 'strict' });
    writer.register({
      id: 'synthetic-fail',
      async validate({ slug }) {
        return [{ slug, validator: 'synthetic-fail', severity: 'error', message: 'boom' }];
      },
    });
    await expect(writer.transaction(async (tx) => {
      await tx.createEntity({
        desiredSlug: 'people/ghost',
        displayName: 'Ghost',
        type: 'person',
        compiledTruth: 'x',
      });
    }, makeCtx())).rejects.toThrow(WriteError);

    // Page should not exist after rollback
    const page = await engine.getPage('people/ghost');
    expect(page).toBeNull();
  });

  test('lint mode does NOT roll back on validator error', async () => {
    const writer = new BrainWriter(engine, { strictMode: 'lint' });
    writer.register({
      id: 'synthetic-fail',
      async validate({ slug }) {
        return [{ slug, validator: 'synthetic-fail', severity: 'error', message: 'still writes in lint' }];
      },
    });
    const { result, report } = await writer.transaction(async (tx) => {
      return tx.createEntity({
        desiredSlug: 'people/lint-test',
        displayName: 'Lint',
        type: 'person',
        compiledTruth: 'x',
      });
    }, makeCtx());
    expect(result).toBe('people/lint-test');
    expect(report.errorCount).toBe(1);
    const page = await engine.getPage('people/lint-test');
    expect(page).not.toBeNull();
  });

  test('off mode skips validators entirely', async () => {
    const writer = new BrainWriter(engine, { strictMode: 'off' });
    let called = 0;
    writer.register({
      id: 'should-not-run',
      async validate() { called++; return []; },
    });
    await writer.transaction(async (tx) => {
      await tx.createEntity({ desiredSlug: 'people/no-validator', displayName: 'x', type: 'person', compiledTruth: 'x' });
    }, makeCtx());
    expect(called).toBe(0);
  });

  test('validators skip pages with validate:false frontmatter', async () => {
    const writer = new BrainWriter(engine, { strictMode: 'strict' });
    let called = 0;
    writer.register({
      id: 'count',
      async validate() { called++; return []; },
    });
    await writer.transaction(async (tx) => {
      await tx.createEntity({
        desiredSlug: 'people/grandfathered',
        displayName: 'Old',
        type: 'person',
        compiledTruth: 'legacy content without citations',
        frontmatter: { validate: false },
      });
    }, makeCtx());
    expect(called).toBe(0);
  });

  test('setCompiledTruth updates existing page', async () => {
    const writer = new BrainWriter(engine);
    await writer.transaction(async (tx) => tx.createEntity({
      desiredSlug: 'people/update',
      displayName: 'Update',
      type: 'person',
      compiledTruth: 'original',
    }), makeCtx());
    await writer.transaction(async (tx) => tx.setCompiledTruth('people/update', 'updated'), makeCtx());
    const page = await engine.getPage('people/update');
    expect(page?.compiled_truth).toBe('updated');
  });

  test('setFrontmatterField merges into existing frontmatter', async () => {
    const writer = new BrainWriter(engine);
    await writer.transaction(async (tx) => tx.createEntity({
      desiredSlug: 'people/fm',
      displayName: 'FM',
      type: 'person',
      compiledTruth: 'x',
      frontmatter: { role: 'founder' },
    }), makeCtx());
    await writer.transaction(async (tx) => tx.setFrontmatterField('people/fm', 'validate', false), makeCtx());
    const page = await engine.getPage('people/fm');
    expect(page?.frontmatter?.role).toBe('founder');
    expect(page?.frontmatter?.validate).toBe(false);
  });

  test('registeredValidators lists ids', () => {
    const writer = new BrainWriter(engine);
    registerBuiltinValidators(writer);
    expect(writer.registeredValidators).toEqual(['citation', 'link', 'back-link', 'triple-hr']);
  });
});

// ---------------------------------------------------------------------------
// Citation validator (pure, no engine needed for most cases)
// ---------------------------------------------------------------------------

describe('citation validator', () => {
  beforeEach(async () => { await reset(); });

  async function run(compiled: string, slug = 'concepts/test'): Promise<ReturnType<typeof citationValidator.validate>> {
    return citationValidator.validate({
      slug,
      type: 'concept',
      compiledTruth: compiled,
      timeline: '',
      frontmatter: {},
      engine,
    });
  }

  test('passes paragraph with [Source: ...]', async () => {
    const findings = await run('Alice was a founder [Source: X/garrytan, 2026-04-18].');
    expect(findings).toEqual([]);
  });

  test('passes paragraph with inline URL', async () => {
    const findings = await run('She wrote [an essay](https://example.com/essay) about scaling.');
    expect(findings).toEqual([]);
  });

  test('flags factual paragraph missing citation', async () => {
    const findings = await run('Alice raised $5M in Series A from Sequoia.');
    expect(findings).toHaveLength(1);
    expect(findings[0].severity).toBe('error');
    expect(findings[0].validator).toBe('citation');
  });

  test('ignores headings', async () => {
    const findings = await run('# Big header\n## Subhead');
    expect(findings).toEqual([]);
  });

  test('ignores key-value lines', async () => {
    const findings = await run('**Status:** Active');
    expect(findings).toEqual([]);
  });

  test('ignores code fences entirely', async () => {
    const findings = await run('```\nThis paragraph inside code has no citation and should NOT trigger\n```');
    expect(findings).toEqual([]);
  });

  test('a [Source:] INSIDE a code fence does NOT satisfy the check for surrounding prose', async () => {
    const compiled = `Alice raised money.

\`\`\`
[Source: fake]
\`\`\``;
    const findings = await run(compiled);
    expect(findings.length).toBeGreaterThan(0);
  });

  test('ignores inline code within paragraph (but paragraph still needs citation)', async () => {
    const compiled = 'Alice shipped `gbrain` last week.';
    const findings = await run(compiled);
    expect(findings).toHaveLength(1);
  });

  test('ignores pure wikilink bullets (See Also style)', async () => {
    const compiled = '- [Alice](../people/alice.md)';
    const findings = await run(compiled);
    expect(findings).toEqual([]);
  });

  test('ignores HTML comments', async () => {
    const compiled = '<!-- This is a note -->';
    const findings = await run(compiled);
    expect(findings).toEqual([]);
  });

  test('ignores blockquotes', async () => {
    const compiled = '> quoted content without citation';
    const findings = await run(compiled);
    expect(findings).toEqual([]);
  });

  test('empty [Source:] marker does NOT satisfy citation check', async () => {
    const findings = await run('Alice raised $5M in Series A from Sequoia [Source:].');
    expect(findings).toHaveLength(1);
    expect(findings[0].validator).toBe('citation');
  });

  test('whitespace-only [Source:   ] marker does NOT satisfy citation check', async () => {
    const findings = await run('Alice raised $5M in Series A from Sequoia [Source:   ].');
    expect(findings).toHaveLength(1);
  });

  test('splitParagraphs handles blank-line separation', () => {
    const input = 'First para.\n\nSecond para.';
    const out = splitParagraphs(input);
    expect(out).toHaveLength(2);
    expect(out[0].startLine).toBe(1);
    expect(out[1].startLine).toBe(3);
  });
});

// ---------------------------------------------------------------------------
// Link validator
// ---------------------------------------------------------------------------

describe('link validator', () => {
  beforeEach(async () => { await reset(); });

  test('normalizeToSlug strips relative prefix + .md', () => {
    expect(normalizeToSlug('people/alice.md')).toBe('people/alice');
    expect(normalizeToSlug('../../people/alice.md')).toBe('people/alice');
    expect(normalizeToSlug('/people/alice')).toBe('people/alice');
    expect(normalizeToSlug('companies/acme/labs')).toBe('companies/acme/labs');
  });

  test('normalizeToSlug returns null for non-slug shapes', () => {
    expect(normalizeToSlug('mailto:x@y')).toBeNull();
    expect(normalizeToSlug('just-one-component')).toBeNull();
    expect(normalizeToSlug('x')).toBeNull();
  });

  test('isExternalUrl detects http(s)', () => {
    expect(isExternalUrl('https://example.com')).toBe(true);
    expect(isExternalUrl('http://example.com')).toBe(true);
    expect(isExternalUrl('people/alice.md')).toBe(false);
  });

  test('isNonBrainRef detects mailto/anchor/etc', () => {
    expect(isNonBrainRef('mailto:x@y.com')).toBe(true);
    expect(isNonBrainRef('#section')).toBe(true);
    expect(isNonBrainRef('people/alice.md')).toBe(false);
  });

  test('flags dangling wikilink', async () => {
    const findings = await linkValidator.validate({
      slug: 'people/bob',
      type: 'person',
      compiledTruth: 'Bob met [Alice](../people/alice.md) yesterday [Source: meeting, 2026-04-18]',
      timeline: '',
      frontmatter: {},
      engine,
    });
    expect(findings.length).toBeGreaterThan(0);
    expect(findings[0].severity).toBe('error');
    expect(findings[0].message).toContain('people/alice');
  });

  test('passes when wikilink target exists', async () => {
    await engine.putPage('people/alice', { type: 'person', title: 'Alice', compiled_truth: 'x', frontmatter: {} });
    const findings = await linkValidator.validate({
      slug: 'people/bob',
      type: 'person',
      compiledTruth: 'Bob met [Alice](../people/alice.md).',
      timeline: '',
      frontmatter: {},
      engine,
    });
    expect(findings).toEqual([]);
  });

  test('ignores external URLs', async () => {
    const findings = await linkValidator.validate({
      slug: 'concepts/x',
      type: 'concept',
      compiledTruth: 'Read [this](https://example.com/page) for context.',
      timeline: '',
      frontmatter: {},
      engine,
    });
    expect(findings).toEqual([]);
  });

  test('flags mailto as warning', async () => {
    const findings = await linkValidator.validate({
      slug: 'concepts/x',
      type: 'concept',
      compiledTruth: 'Email [me](mailto:x@y.com).',
      timeline: '',
      frontmatter: {},
      engine,
    });
    expect(findings.some(f => f.severity === 'warning')).toBe(true);
  });

  test('ignores links inside fenced code', async () => {
    const compiled = '```\n[link](../people/not-real.md)\n```';
    const findings = await linkValidator.validate({
      slug: 'concepts/x',
      type: 'concept',
      compiledTruth: compiled,
      timeline: '',
      frontmatter: {},
      engine,
    });
    expect(findings).toEqual([]);
  });
});

// ---------------------------------------------------------------------------
// Back-link validator
// ---------------------------------------------------------------------------

describe('back-link validator', () => {
  beforeEach(async () => { await reset(); });

  test('no outbound links → no findings', async () => {
    await engine.putPage('people/isolated', { type: 'person', title: 'x', compiled_truth: 'x', frontmatter: {} });
    const findings = await backLinkValidator.validate({
      slug: 'people/isolated',
      type: 'person',
      compiledTruth: 'x',
      timeline: '',
      frontmatter: {},
      engine,
    });
    expect(findings).toEqual([]);
  });

  test('outbound link without reverse → warning', async () => {
    await engine.putPage('people/x', { type: 'person', title: 'x', compiled_truth: 'x', frontmatter: {} });
    await engine.putPage('people/y', { type: 'person', title: 'y', compiled_truth: 'y', frontmatter: {} });
    await engine.addLink('people/x', 'people/y', 'mentions', 'mentions');
    // no reverse back-link

    const findings = await backLinkValidator.validate({
      slug: 'people/x',
      type: 'person',
      compiledTruth: 'x',
      timeline: '',
      frontmatter: {},
      engine,
    });
    expect(findings.length).toBe(1);
    expect(findings[0].severity).toBe('warning');
    expect(findings[0].message).toContain('people/y');
  });

  test('bidirectional links → no findings', async () => {
    await engine.putPage('people/a', { type: 'person', title: 'a', compiled_truth: 'x', frontmatter: {} });
    await engine.putPage('people/b', { type: 'person', title: 'b', compiled_truth: 'x', frontmatter: {} });
    await engine.addLink('people/a', 'people/b', 'x', 'knows');
    await engine.addLink('people/b', 'people/a', 'x', 'knows_back');

    const findings = await backLinkValidator.validate({
      slug: 'people/a',
      type: 'person',
      compiledTruth: 'x',
      timeline: '',
      frontmatter: {},
      engine,
    });
    expect(findings).toEqual([]);
  });
});

// ---------------------------------------------------------------------------
// Triple-HR validator
// ---------------------------------------------------------------------------

describe('triple-hr validator', () => {
  test('no issues on clean compiled_truth', async () => {
    const findings = await tripleHrValidator.validate({
      slug: 'people/clean',
      type: 'person',
      compiledTruth: 'Clean content, no bar in compiled_truth.',
      timeline: '- **2026-04-18** | Met',
      frontmatter: {},
      engine,
    });
    expect(findings).toEqual([]);
  });

  test('bare --- in compiled_truth flags warning', async () => {
    const compiled = 'Alice did a thing.\n\n---\n\nAnd another.';
    const findings = await tripleHrValidator.validate({
      slug: 'people/dangerous',
      type: 'person',
      compiledTruth: compiled,
      timeline: '',
      frontmatter: {},
      engine,
    });
    expect(findings.some(f => f.message.includes('---'))).toBe(true);
    expect(findings[0].severity).toBe('warning');
  });

  test('--- inside code fence does NOT flag', async () => {
    const compiled = 'Content.\n\n```\n---\nshown as output\n---\n```';
    const findings = await tripleHrValidator.validate({
      slug: 'people/safe',
      type: 'person',
      compiledTruth: compiled,
      timeline: '',
      frontmatter: {},
      engine,
    });
    expect(findings).toEqual([]);
  });

  test('heading in timeline → warning', async () => {
    const findings = await tripleHrValidator.validate({
      slug: 'people/spill',
      type: 'person',
      compiledTruth: 'x',
      timeline: '## This should not be here\n- **2026-04-18** | event',
      frontmatter: {},
      engine,
    });
    expect(findings.some(f => f.message.includes('Heading in timeline'))).toBe(true);
  });

  test('## Timeline header line in timeline is allowed', async () => {
    const findings = await tripleHrValidator.validate({
      slug: 'people/ok',
      type: 'person',
      compiledTruth: 'x',
      timeline: '## Timeline\n- **2026-04-18** | event',
      frontmatter: {},
      engine,
    });
    expect(findings).toEqual([]);
  });
});
