import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import {
  shouldExclude,
  deriveDomain,
  formatOrphansText,
  findOrphans,
  queryOrphanPages,
  type OrphanPage,
  type OrphanResult,
} from '../src/commands/orphans.ts';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';

// --- shouldExclude ---

describe('shouldExclude', () => {
  test('excludes pseudo-page _atlas', () => {
    expect(shouldExclude('_atlas')).toBe(true);
  });

  test('excludes pseudo-page _index', () => {
    expect(shouldExclude('_index')).toBe(true);
  });

  test('excludes pseudo-page _stats', () => {
    expect(shouldExclude('_stats')).toBe(true);
  });

  test('excludes pseudo-page _orphans', () => {
    expect(shouldExclude('_orphans')).toBe(true);
  });

  test('excludes pseudo-page _scratch', () => {
    expect(shouldExclude('_scratch')).toBe(true);
  });

  test('excludes pseudo-page claude', () => {
    expect(shouldExclude('claude')).toBe(true);
  });

  test('excludes auto-generated _index suffix', () => {
    expect(shouldExclude('companies/_index')).toBe(true);
    expect(shouldExclude('people/_index')).toBe(true);
  });

  test('excludes auto-generated /log suffix', () => {
    expect(shouldExclude('projects/acme/log')).toBe(true);
  });

  test('excludes raw source slugs', () => {
    expect(shouldExclude('companies/acme/raw/crustdata')).toBe(true);
  });

  test('excludes deny-prefix: output/', () => {
    expect(shouldExclude('output/2026-q1')).toBe(true);
  });

  test('excludes deny-prefix: dashboards/', () => {
    expect(shouldExclude('dashboards/metrics')).toBe(true);
  });

  test('excludes deny-prefix: scripts/', () => {
    expect(shouldExclude('scripts/ingest-runner')).toBe(true);
  });

  test('excludes deny-prefix: templates/', () => {
    expect(shouldExclude('templates/meeting-note')).toBe(true);
  });

  test('excludes deny-prefix: _templates/', () => {
    expect(shouldExclude('_templates/meeting-note')).toBe(true);
  });

  test('excludes deny-prefix: openclaw/config/', () => {
    expect(shouldExclude('openclaw/config/agent')).toBe(true);
  });

  test('excludes first-segment: scratch', () => {
    expect(shouldExclude('scratch/idea-dump')).toBe(true);
  });

  test('excludes first-segment: thoughts', () => {
    expect(shouldExclude('thoughts/2026-04-17')).toBe(true);
  });

  test('excludes first-segment: catalog', () => {
    expect(shouldExclude('catalog/tools')).toBe(true);
  });

  test('excludes first-segment: entities', () => {
    expect(shouldExclude('entities/product-hunt')).toBe(true);
  });

  test('excludes first-segment: skills, dreaming, and daily', () => {
    expect(shouldExclude('skills/arya/source-check')).toBe(true);
    expect(shouldExclude('dreaming/light/2026-07-20')).toBe(true);
    expect(shouldExclude('daily/2026-07-20')).toBe(true);
    expect(shouldExclude('agent-openclaw/daily/2026-07-20')).toBe(true);
  });

  test('excludes root date logs and agent workspace conventions', () => {
    expect(shouldExclude('_brain-conventions')).toBe(true);
    expect(shouldExclude('2026-07-20')).toBe(true);
    expect(shouldExclude('2026-07-20-qa-sweep')).toBe(true);
    expect(shouldExclude('agents/arya/identity')).toBe(true);
    expect(shouldExclude('agents/arya/memory/dreaming/deep/2026-07-20')).toBe(true);
  });

  test('excludes generated extracts', () => {
    expect(shouldExclude('extracts/2026-06-30/takes.proposed/round-single')).toBe(true);
  });

  test('brain-specific exclusions come from config overrides, not global defaults', () => {
    // No baked-in defaults for these:
    expect(shouldExclude('my-private-folder/some-secret-ref.md')).toBe(false);
    expect(shouldExclude('one-off-fixture-page')).toBe(false);
    // The per-brain config plane (orphans.exclude_prefixes / exclude_slugs):
    const overrides = {
      excludePrefixes: ['my-private-folder/'],
      excludeSlugs: ['one-off-fixture-page'],
    };
    expect(shouldExclude('my-private-folder/some-secret-ref.md', overrides)).toBe(true);
    expect(shouldExclude('one-off-fixture-page', overrides)).toBe(true);
    expect(shouldExclude('people/jane-doe', overrides)).toBe(false);
  });

  test('does NOT exclude a normal content page', () => {
    expect(shouldExclude('companies/acme')).toBe(false);
    expect(shouldExclude('people/jane-doe')).toBe(false);
    expect(shouldExclude('projects/gbrain')).toBe(false);
    expect(shouldExclude('agents/arya/qa-reports/launch-review')).toBe(false);
  });

  test('does NOT exclude a page ending with log-like text that is not /log', () => {
    expect(shouldExclude('devlog')).toBe(false);
    expect(shouldExclude('changelog')).toBe(false);
  });
});

// --- deriveDomain ---

describe('deriveDomain', () => {
  test('uses frontmatter domain when present', () => {
    expect(deriveDomain('companies', 'companies/acme')).toBe('companies');
  });

  test('falls back to first slug segment', () => {
    expect(deriveDomain(null, 'people/jane-doe')).toBe('people');
    expect(deriveDomain(undefined, 'projects/gbrain')).toBe('projects');
  });

  test('returns root for single-segment slugs with no frontmatter', () => {
    expect(deriveDomain(null, 'readme')).toBe('readme');
  });

  test('ignores empty-string frontmatter domain', () => {
    expect(deriveDomain('', 'people/alice')).toBe('people');
  });

  test('ignores whitespace-only frontmatter domain', () => {
    expect(deriveDomain('   ', 'people/alice')).toBe('people');
  });
});

// --- formatOrphansText ---

describe('formatOrphansText', () => {
  function makeResult(orphans: OrphanPage[], overrides?: Partial<OrphanResult>): OrphanResult {
    return {
      orphans,
      total_orphans: orphans.length,
      total_linkable: orphans.length + 50,
      total_pages: orphans.length + 60,
      excluded: 10,
      ...overrides,
    };
  }

  test('shows summary line', () => {
    const result = makeResult([]);
    const out = formatOrphansText(result);
    expect(out).toContain('0 orphans out of');
    expect(out).toContain('total');
    expect(out).toContain('excluded');
  });

  test('shows "No orphan pages found." when empty', () => {
    const out = formatOrphansText(makeResult([]));
    expect(out).toContain('No orphan pages found.');
  });

  test('groups orphans by domain', () => {
    const orphans: OrphanPage[] = [
      { slug: 'companies/acme', title: 'Acme Corp', domain: 'companies' },
      { slug: 'people/alice', title: 'Alice', domain: 'people' },
      { slug: 'companies/beta', title: 'Beta Inc', domain: 'companies' },
    ];
    const out = formatOrphansText(makeResult(orphans));
    expect(out).toContain('[companies]');
    expect(out).toContain('[people]');
    // companies section should appear before people (alphabetical)
    const companiesIdx = out.indexOf('[companies]');
    const peopleIdx = out.indexOf('[people]');
    expect(companiesIdx).toBeLessThan(peopleIdx);
  });

  test('sorts orphans alphabetically within each domain group', () => {
    const orphans: OrphanPage[] = [
      { slug: 'companies/zeta', title: 'Zeta', domain: 'companies' },
      { slug: 'companies/alpha', title: 'Alpha', domain: 'companies' },
      { slug: 'companies/beta', title: 'Beta', domain: 'companies' },
    ];
    const out = formatOrphansText(makeResult(orphans));
    const alphaIdx = out.indexOf('companies/alpha');
    const betaIdx = out.indexOf('companies/beta');
    const zetaIdx = out.indexOf('companies/zeta');
    expect(alphaIdx).toBeLessThan(betaIdx);
    expect(betaIdx).toBeLessThan(zetaIdx);
  });

  test('includes slug and title in output', () => {
    const orphans: OrphanPage[] = [
      { slug: 'companies/acme', title: 'Acme Corp', domain: 'companies' },
    ];
    const out = formatOrphansText(makeResult(orphans));
    expect(out).toContain('companies/acme');
    expect(out).toContain('Acme Corp');
  });

  test('summary line shows correct numbers', () => {
    const orphans: OrphanPage[] = [
      { slug: 'a/b', title: 'B', domain: 'a' },
      { slug: 'a/c', title: 'C', domain: 'a' },
    ];
    const result: OrphanResult = {
      orphans,
      total_orphans: 2,
      total_linkable: 100,
      total_pages: 120,
      excluded: 20,
    };
    const out = formatOrphansText(result);
    expect(out).toContain('2 orphans out of 100 linkable pages (120 total; 20 excluded)');
  });
});

// ────────────────────────────────────────────────────────────────
// findOrphans + queryOrphanPages with explicit engine (v0.17 change)
// ────────────────────────────────────────────────────────────────

describe('findOrphans (engine-injected)', () => {
  let engine: PGLiteEngine;

  beforeEach(async () => {
    engine = new PGLiteEngine();
    await engine.connect({});
    await engine.initSchema();
  }, 60_000); // OAuth v25 + full migration chain needs breathing room

  afterEach(async () => {
    if (engine) await engine.disconnect();
  }, 60_000);

  test('returns pages with no inbound links, excluding pseudo-pages', async () => {
    // Build a tiny brain: alice links to bob. alice is an orphan (nothing
    // points to her), bob is not (alice points to him). _atlas is a pseudo
    // page that should be excluded by default.
    await engine.putPage('people/alice', {
      type: 'person',
      title: 'Alice',
      compiled_truth: 'Alice works with Bob.',
      timeline: '',
    });
    await engine.putPage('people/bob', {
      type: 'person',
      title: 'Bob',
      compiled_truth: 'Bob.',
      timeline: '',
    });
    await engine.putPage('_atlas', {
      type: 'concept',
      title: 'Atlas',
      compiled_truth: 'pseudo-page',
      timeline: '',
    });
    // Create the link alice -> bob.
    await engine.addLink('people/alice', 'people/bob', 'mentioned', 'references', 'markdown');

    const result = await findOrphans(engine);

    const slugs = result.orphans.map(o => o.slug).sort();
    expect(slugs).toEqual(['people/alice']); // _atlas excluded by default; bob has a backlink
    expect(result.total_orphans).toBe(1);
    expect(result.total_pages).toBe(3);
    expect(result.excluded).toBeGreaterThanOrEqual(1); // _atlas was filtered
  });

  test('includePseudo: true surfaces pseudo-pages too', async () => {
    await engine.putPage('_atlas', {
      type: 'concept',
      title: 'Atlas',
      compiled_truth: 'pseudo',
      timeline: '',
    });

    const result = await findOrphans(engine, { includePseudo: true });

    const slugs = result.orphans.map(o => o.slug).sort();
    expect(slugs).toContain('_atlas');
  });

  test('queryOrphanPages delegates to the passed engine (no global db)', async () => {
    await engine.putPage('topic/standalone', {
      type: 'concept',
      title: 'Standalone',
      compiled_truth: 'no inbound links',
      timeline: '',
    });

    const rows = await queryOrphanPages(engine);
    const slugs = rows.map(r => r.slug);
    expect(slugs).toContain('topic/standalone');
  });

  test('zero pages: empty result (no crash on empty brain)', async () => {
    const result = await findOrphans(engine);
    expect(result.orphans).toEqual([]);
    expect(result.total_orphans).toBe(0);
    expect(result.total_pages).toBe(0);
  });

  // ────────────────────────────────────────────────────────────────
  // Soft-delete filtering on BOTH sides (v0.26.5 invariant; codex C11)
  // ────────────────────────────────────────────────────────────────

  test('REGRESSION: soft-deleted page with no inbound is NOT in orphan results', async () => {
    // Candidate-filter regression. Pre-fix, findOrphanPages returned every
    // page without inbound links — including soft-deleted ones. Now the
    // outer query filters p.deleted_at IS NULL.
    await engine.putPage('people/alice', {
      type: 'person',
      title: 'Alice',
      compiled_truth: 'Alice has no inbound links and is soft-deleted.',
      timeline: '',
    });
    // Soft-delete alice directly via the DB handle (no engine method exposed).
    await (engine as any).db.query(
      `UPDATE pages SET deleted_at = now() WHERE slug = 'people/alice'`
    );

    const rows = await queryOrphanPages(engine);
    const slugs = rows.map(r => r.slug);
    expect(slugs).not.toContain('people/alice');
  });

  test('REGRESSION: live page with ONLY inbound link from soft-deleted source IS orphan (codex C11)', async () => {
    // Link-source-filter regression. Pre-fix, a live page that had ONE
    // inbound link from a soft-deleted source was hidden from orphan
    // results because the EXISTS check didn't filter the source side.
    // Now the inner JOIN filters src.deleted_at IS NULL too.
    await engine.putPage('people/alice', {
      type: 'person',
      title: 'Alice',
      compiled_truth: 'Alice was soft-deleted but used to link to Bob.',
      timeline: '',
    });
    await engine.putPage('people/bob', {
      type: 'person',
      title: 'Bob',
      compiled_truth: 'Bob has no live inbound links.',
      timeline: '',
    });
    await engine.addLink('people/alice', 'people/bob', 'mentioned', 'references', 'markdown');

    // Soft-delete alice. Bob's ONLY inbound link is now from a deleted page.
    await (engine as any).db.query(
      `UPDATE pages SET deleted_at = now() WHERE slug = 'people/alice'`
    );

    const rows = await queryOrphanPages(engine);
    const slugs = rows.map(r => r.slug).sort();
    // alice is soft-deleted → not in results (candidate filter).
    // bob has no LIVE inbound link → IS in results (link-source filter — codex C11).
    expect(slugs).not.toContain('people/alice');
    expect(slugs).toContain('people/bob');
  });

  test('live page with inbound link from LIVE source is NOT orphan (regression for unchanged behavior)', async () => {
    // Sanity check: the soft-delete filter must NOT break the basic
    // "live link counts" case.
    await engine.putPage('people/alice', {
      type: 'person',
      title: 'Alice',
      compiled_truth: 'Alice is live and links to Bob.',
      timeline: '',
    });
    await engine.putPage('people/bob', {
      type: 'person',
      title: 'Bob',
      compiled_truth: 'Bob has a live inbound from Alice.',
      timeline: '',
    });
    await engine.addLink('people/alice', 'people/bob', 'mentioned', 'references', 'markdown');

    const rows = await queryOrphanPages(engine);
    const slugs = rows.map(r => r.slug);
    // alice is orphan (no inbound), bob is NOT (alice links to him).
    expect(slugs).toContain('people/alice');
    expect(slugs).not.toContain('people/bob');
  });
});
