/**
 * Unit tests for the silent-failure doctor check batch (#2250, #2784, #2788).
 * Hermetic PGLite; temp dirs stand in for source repos. Postgres parity for
 * the same checks is pinned by test/e2e/doctor-silent-death-parity.test.ts.
 */

import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import {
  checkContentHashDuplicates,
  checkUndeclaredDbOnlyPages,
  checkDbOnlyCollectorCollision,
} from '../src/commands/doctor.ts';
import {
  DERIVE_PHASE_DB_ONLY_DEFAULTS,
  effectiveDbOnlyDirs,
  findDbOnlyCollisions,
} from '../src/core/storage-config.ts';

let engine: PGLiteEngine;
const tempDirs: string[] = [];

function makeRepo(): string {
  const dir = mkdtempSync(join(tmpdir(), 'gbrain-doctor-'));
  tempDirs.push(dir);
  return dir;
}

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

afterAll(async () => {
  if (engine) await engine.disconnect();
  for (const d of tempDirs) rmSync(d, { recursive: true, force: true });
}, 60_000);

beforeEach(async () => {
  await resetPgliteState(engine);
});

async function addSource(id: string, localPath: string | null): Promise<void> {
  await engine.executeRaw(
    `INSERT INTO sources (id, name, local_path, config) VALUES ($1, $1, $2, '{}'::jsonb)
     ON CONFLICT (id) DO UPDATE SET local_path = EXCLUDED.local_path`,
    [id, localPath],
  );
}

async function addPage(
  slug: string,
  opts: { sourceId?: string; hash?: string | null; pageKind?: string; deleted?: boolean } = {},
): Promise<void> {
  await engine.executeRaw(
    `INSERT INTO pages (slug, source_id, type, page_kind, title, compiled_truth, timeline, frontmatter, content_hash, deleted_at)
     VALUES ($1, $2, 'concept', $3, $1, 'body', '', '{}'::jsonb, $4, $5)`,
    [
      slug,
      opts.sourceId ?? 'default',
      opts.pageKind ?? 'markdown',
      opts.hash === undefined ? `h-${slug}` : opts.hash,
      opts.deleted ? new Date().toISOString() : null,
    ],
  );
}

describe('content_hash_duplicates (#2250)', () => {
  test('distinct hashes → ok', async () => {
    await addPage('people/alice-example');
    await addPage('projects/widget-co');
    const c = await checkContentHashDuplicates(engine);
    expect(c.status).toBe('ok');
  });

  test('bare + path-prefixed twins with same hash → warn with pair + remediation', async () => {
    await addPage('people/alice-example', { hash: 'same' });
    await addPage('alice-example', { hash: 'same' });
    const c = await checkContentHashDuplicates(engine);
    expect(c.status).toBe('warn');
    expect(c.message).toContain('alice-example <-> people/alice-example');
    expect(c.message).toContain('gbrain pages delete <bare-slug>');
    expect(c.message).toContain('gbrain pages purge-deleted --older-than 0');
    expect((c.details as any).pair_count).toBe(1);
  });

  test('multiple wrong-root pairs all counted', async () => {
    await addPage('people/alice-example', { hash: 'h1' });
    await addPage('alice-example', { hash: 'h1' });
    await addPage('projects/my-project', { hash: 'h2' });
    await addPage('my-project', { hash: 'h2' });
    const c = await checkContentHashDuplicates(engine);
    expect(c.status).toBe('warn');
    expect((c.details as any).pair_count).toBe(2);
    expect(c.message).toContain('my-project <-> projects/my-project');
  });

  test('two path-prefixed pages with same hash → ok (not the wrong-root pattern)', async () => {
    await addPage('people/alice-example', { hash: 'same' });
    await addPage('archive/people/alice-example', { hash: 'same' });
    const c = await checkContentHashDuplicates(engine);
    expect(c.status).toBe('ok');
  });

  test('soft-deleted twin is ignored', async () => {
    await addPage('people/alice-example', { hash: 'same' });
    await addPage('alice-example', { hash: 'same', deleted: true });
    const c = await checkContentHashDuplicates(engine);
    expect(c.status).toBe('ok');
  });

  test('NULL / empty content_hash never groups', async () => {
    await addPage('people/alice-example', { hash: null });
    await addPage('alice-example', { hash: null });
    await addPage('people/bob-example', { hash: '' });
    await addPage('bob-example', { hash: '' });
    const c = await checkContentHashDuplicates(engine);
    expect(c.status).toBe('ok');
  });

  test('same hash across DIFFERENT sources is not flagged (per-source grouping)', async () => {
    await addSource('other', null);
    await addPage('people/alice-example', { hash: 'same', sourceId: 'default' });
    await addPage('alice-example', { hash: 'same', sourceId: 'other' });
    const c = await checkContentHashDuplicates(engine);
    expect(c.status).toBe('ok');
  });
});

describe('undeclared_db_only_pages (#2784)', () => {
  test('no sources with local_path → ok (not applicable)', async () => {
    await addPage('floating/page');
    const c = await checkUndeclaredDbOnlyPages(engine);
    expect(c.status).toBe('ok');
    expect(c.message).toContain('Not applicable');
  });

  test('file-backed page → ok', async () => {
    const repo = makeRepo();
    mkdirSync(join(repo, 'people'), { recursive: true });
    writeFileSync(join(repo, 'people', 'alice-example.md'), '# Alice');
    await addSource('src-a', repo);
    await addPage('people/alice-example', { sourceId: 'src-a' });
    const c = await checkUndeclaredDbOnlyPages(engine);
    expect(c.status).toBe('ok');
  });

  test('derive-phase default prefixes are implicitly declared', async () => {
    const repo = makeRepo();
    await addSource('src-a', repo);
    for (const prefix of DERIVE_PHASE_DB_ONLY_DEFAULTS) {
      await addPage(`${prefix}page-1`, { sourceId: 'src-a' });
    }
    const c = await checkUndeclaredDbOnlyPages(engine);
    expect(c.status).toBe('ok');
    expect(c.message).toContain('life/events/');
  });

  test('declared db_only prefix in gbrain.yml keeps the check quiet', async () => {
    const repo = makeRepo();
    writeFileSync(join(repo, 'gbrain.yml'), 'storage:\n  db_only:\n    - notes/\n');
    await addSource('src-a', repo);
    await addPage('notes/db-resident', { sourceId: 'src-a' });
    const c = await checkUndeclaredDbOnlyPages(engine);
    expect(c.status).toBe('ok');
  });

  test('page with no backing file outside every db_only path → warn with sample + fix', async () => {
    const repo = makeRepo();
    await addSource('src-a', repo);
    await addPage('people/ghost-page', { sourceId: 'src-a' });
    const c = await checkUndeclaredDbOnlyPages(engine);
    expect(c.status).toBe('warn');
    expect(c.message).toContain('people/ghost-page');
    expect(c.message).toContain('storage.db_only');
    expect((c.details as any).total).toBe(1);
    expect((c.details as any).per_source['src-a']).toBe(1);
  });

  test('code pages are excluded (different slug scheme)', async () => {
    const repo = makeRepo();
    await addSource('src-a', repo);
    await addPage('src-core-thing-ts', { sourceId: 'src-a', pageKind: 'code' });
    const c = await checkUndeclaredDbOnlyPages(engine);
    expect(c.status).toBe('ok');
  });

  test('source whose local_path is missing on this host is skipped', async () => {
    await addSource('src-gone', '/nonexistent/gbrain-test-path');
    await addPage('people/ghost-page', { sourceId: 'src-gone' });
    const c = await checkUndeclaredDbOnlyPages(engine);
    expect(c.status).toBe('ok');
    expect(c.message).toContain('Not applicable');
  });

  test('effectiveDbOnlyDirs unions declared + defaults, deduped', () => {
    const dirs = effectiveDbOnlyDirs(['notes/', 'atoms/']);
    expect(dirs.filter(d => d === 'atoms/').length).toBe(1);
    expect(dirs).toContain('notes/');
    for (const d of DERIVE_PHASE_DB_ONLY_DEFAULTS) expect(dirs).toContain(d);
  });
});

describe('db_only_collector_collision (#2788)', () => {
  test('no collectors declare output paths → ok', async () => {
    const c = await checkDbOnlyCollectorCollision(engine, { collectors: [] });
    expect(c.status).toBe('ok');
  });

  test('collector output inside a db_only path → warn naming collector, path, and fix', async () => {
    const repo = makeRepo();
    writeFileSync(join(repo, 'gbrain.yml'), 'storage:\n  db_only:\n    - daily/\n');
    await addSource('src-a', repo);
    const c = await checkDbOnlyCollectorCollision(engine, {
      collectors: [{ id: 'calendar-to-brain', output_path: 'daily/calendar/' }],
    });
    expect(c.status).toBe('warn');
    expect(c.message).toContain("collector 'calendar-to-brain'");
    expect(c.message).toContain("'daily/calendar/'");
    expect(c.message).toContain("db_only path 'daily/'");
    expect(c.message).toContain('silently skip');
    expect(c.message).toContain('storage.db_only');
  });

  test('exact-match db_only dir also collides', async () => {
    const repo = makeRepo();
    writeFileSync(join(repo, 'gbrain.yml'), 'storage:\n  db_only:\n    - daily/calendar/\n');
    await addSource('src-a', repo);
    const c = await checkDbOnlyCollectorCollision(engine, {
      collectors: [{ id: 'calendar-to-brain', output_path: 'daily/calendar/' }],
    });
    expect(c.status).toBe('warn');
  });

  test('db_only elsewhere → ok', async () => {
    const repo = makeRepo();
    writeFileSync(join(repo, 'gbrain.yml'), 'storage:\n  db_only:\n    - media/x/\n');
    await addSource('src-a', repo);
    const c = await checkDbOnlyCollectorCollision(engine, {
      collectors: [{ id: 'calendar-to-brain', output_path: 'daily/calendar/' }],
    });
    expect(c.status).toBe('ok');
  });

  test('sibling prefix does NOT collide (daily/calendar-x vs daily/calendar/)', () => {
    const hits = findDbOnlyCollisions(
      [{ id: 'x', output_path: 'daily/calendar-extra/' }],
      ['daily/calendar/'],
    );
    expect(hits.length).toBe(0);
  });

  test('findDbOnlyCollisions tolerates missing trailing slashes', () => {
    const hits = findDbOnlyCollisions(
      [{ id: 'x', output_path: 'daily/calendar' }],
      ['daily'],
    );
    expect(hits.length).toBe(1);
    expect(hits[0].db_only_dir).toBe('daily');
  });
});
