// Tests for hook-llm.mjs — saveObservation, dedup tiers, related linking, LLM episode/summary
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createTestDb, insertSession } from './test-helpers.mjs';
import { computeMinHash, parseJsonFromLLM } from '../utils.mjs';

// ─── Mocks ───────────────────────────────────────────────────────────────────

vi.mock('../hook-semaphore.mjs', () => ({
  acquireLLMSlot: vi.fn(async () => true),
  releaseLLMSlot: vi.fn(),
}));

vi.mock('../hook-shared.mjs', async () => {
  const actual = await vi.importActual('../hook-shared.mjs');
  return {
    ...actual,
    openDb: vi.fn(),
    callLLM: vi.fn(),
    sleep: vi.fn(async () => {}),
  };
});

import { saveObservation, handleLLMEpisode, handleLLMSummary, buildDegradedTitle, persistHaikuSummary, buildImmediateObservation } from '../hook-llm.mjs';
import { openDb, callLLM } from '../hook-shared.mjs';
import { acquireLLMSlot } from '../hook-semaphore.mjs';

// v2.58: callLLM now accepts string OR {system, user} (cso F#4 fix). Tests
// asserting prompt content should normalize both forms before string-matching.
function promptText(p) {
  if (typeof p === 'string') return p;
  if (p && typeof p === 'object') return `${p.system || ''}\n${p.user || ''}`;
  return String(p ?? '');
}

// ─── saveObservation ─────────────────────────────────────────────────────────

describe('saveObservation', () => {
  let db;

  beforeEach(() => {
    db = createTestDb();
    insertSession(db, { id: 'sess-1', project: 'test' });
  });

  afterEach(() => {
    db.close();
  });

  it('inserts observation and returns row ID', () => {
    const obs = {
      type: 'feature',
      title: 'Add user authentication',
      subtitle: 'auth.mjs',
      narrative: 'Implemented JWT auth flow',
      concepts: ['auth', 'jwt'],
      facts: ['Uses RS256 signing'],
      files: ['auth.mjs'],
      filesRead: ['config.mjs'],
      importance: 2,
    };

    const id = saveObservation(obs, 'test', 'sess-1', db);
    expect(id).toBeGreaterThan(0);

    const row = db.prepare('SELECT * FROM observations WHERE id = ?').get(id);
    expect(row.title).toBe('Add user authentication');
    expect(row.type).toBe('feature');
    expect(row.narrative).toBe('Implemented JWT auth flow');
    expect(row.importance).toBe(2);
    expect(row.minhash_sig).not.toBeNull();
    expect(JSON.parse(row.files_modified)).toEqual(['auth.mjs']);
    expect(JSON.parse(row.files_read)).toEqual(['config.mjs']);
    expect(row.text).toBe('auth jwt Uses RS256 signing');
  });

  it('returns null for Tier 1 Jaccard dedup within 5 minutes', () => {
    const obs = { type: 'discovery', title: 'Fix login bug in auth module' };
    const id1 = saveObservation(obs, 'test', 'sess-1', db);
    expect(id1).toBeGreaterThan(0);

    const id2 = saveObservation(obs, 'test', 'sess-1', db);
    expect(id2).toBeNull();
  });

  it('returns null for Tier 2 MinHash dedup within 7 days', () => {
    const title = 'Implementing redis caching for database queries';
    const narrative = 'Added caching layer with TTL support and invalidation logic for the service';
    const sig = computeMinHash(title + ' ' + narrative);

    // Insert existing obs 6 min ago (outside Tier 1 5-min window, inside Tier 2 7-day window)
    const sixMinAgo = Date.now() - 6 * 60 * 1000;
    db.prepare(`
      INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, created_at, created_at_epoch)
      VALUES (?, ?, '', 'discovery', ?, '', ?, '', '', '[]', '[]', 1, ?, ?, ?)
    `).run('sess-1', 'test', title, narrative, sig, new Date(sixMinAgo).toISOString(), sixMinAgo);

    // Same content should be deduped by Tier 2
    const id = saveObservation({ type: 'discovery', title, narrative }, 'test', 'sess-1', db);
    expect(id).toBeNull();
  });

  it('auto-creates session if absent', () => {
    const id = saveObservation(
      { type: 'discovery', title: 'Test observation' },
      'test', 'new-session', db
    );
    expect(id).toBeGreaterThan(0);

    const session = db.prepare(
      'SELECT * FROM sdk_sessions WHERE content_session_id = ?'
    ).get('new-session');
    expect(session).toBeDefined();
    expect(session.project).toBe('test');
    expect(session.status).toBe('active');
  });

  it('handles null concepts and facts arrays', () => {
    const id = saveObservation(
      { type: 'change', title: 'Null arrays test', concepts: null, facts: null },
      'test', 'sess-1', db
    );
    expect(id).toBeGreaterThan(0);

    const row = db.prepare('SELECT * FROM observations WHERE id = ?').get(id);
    expect(row.concepts).toBe('');
    expect(row.facts).toBe('');
    expect(row.text).toBe('');
  });

  it('handles empty concepts and facts arrays', () => {
    const id = saveObservation(
      { type: 'change', title: 'Empty arrays test', concepts: [], facts: [] },
      'test', 'sess-1', db
    );
    expect(id).toBeGreaterThan(0);

    const row = db.prepare('SELECT * FROM observations WHERE id = ?').get(id);
    expect(row.concepts).toBe('');
    expect(row.facts).toBe('');
  });

  it('handles missing optional fields gracefully', () => {
    const id = saveObservation(
      { type: 'discovery', title: 'Minimal observation' },
      'test', 'sess-1', db
    );
    expect(id).toBeGreaterThan(0);

    const row = db.prepare('SELECT * FROM observations WHERE id = ?').get(id);
    expect(row.subtitle).toBe('');
    expect(row.narrative).toBe('');
    expect(JSON.parse(row.files_read)).toEqual([]);
    expect(JSON.parse(row.files_modified)).toEqual([]);
    expect(row.importance).toBe(1);
  });

  it('does not dedup across different projects', () => {
    const obs = { type: 'discovery', title: 'Fix login bug in auth module' };

    const id1 = saveObservation(obs, 'project-a', 'sess-1', db);
    expect(id1).toBeGreaterThan(0);

    const id2 = saveObservation(obs, 'project-b', 'sess-1', db);
    expect(id2).toBeGreaterThan(0);
  });

  it('returns null when DB is unavailable (no externalDb)', () => {
    openDb.mockReturnValue(null);
    const id = saveObservation({ type: 'discovery', title: 'Test' }, 'test', 'sess-1');
    expect(id).toBeNull();
  });

  it('includes CJK bigrams in text field for Chinese titles', () => {
    const id = saveObservation(
      { type: 'bugfix', title: '修复系统崩溃的问题', narrative: '通过重启服务解决' },
      'test', 'sess-1', db
    );
    expect(id).toBeGreaterThan(0);
    const row = db.prepare('SELECT text FROM observations WHERE id = ?').get(id);
    // text field should contain bigrams from title+narrative
    expect(row.text).toContain('修复');
    expect(row.text).toContain('系统');
    expect(row.text).toContain('崩溃');
  });

  it('does not add empty bigrams for English-only content', () => {
    const id = saveObservation(
      { type: 'feature', title: 'Add caching', concepts: ['cache'], facts: ['TTL 1h'] },
      'test', 'sess-1', db
    );
    const row = db.prepare('SELECT text FROM observations WHERE id = ?').get(id);
    expect(row.text).toBe('cache TTL 1h');
  });
});

// ─── handleLLMEpisode ────────────────────────────────────────────────────────

describe('handleLLMEpisode', () => {
  let db;
  let tmpFile;
  const filesToCleanup = [];
  const originalArgv3 = process.argv[3];

  beforeEach(() => {
    tmpFile = join(tmpdir(), `hook-llm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
    filesToCleanup.push(tmpFile);
    process.argv[3] = tmpFile;
    process.env.CLAUDE_MEM_NO_DELAY = '1';

    db = createTestDb();
    // Prevent handleLLMEpisode from closing our test DB
    db._realClose = db.close;
    db.close = () => {};

    openDb.mockReturnValue(db);
    // v2.54.0: include lesson_learned in default mock so tests that assert
    // importance=2 pass under the lesson-cap rule (no-lesson feature/refactor/
    // bugfix is now demoted to imp=1). Tests that specifically exercise the
    // no-lesson path override this default with their own mock.
    callLLM.mockReturnValue(JSON.stringify({
      type: 'feature',
      title: 'Add user authentication',
      narrative: 'Implemented JWT-based auth flow',
      concepts: ['auth', 'jwt'],
      facts: ['Uses RS256 signing'],
      importance: 2,
      lesson_learned: 'JWT auth flow chose RS256 over HS256 for key rotation support',
    }));
  });

  afterEach(() => {
    if (db?._realClose) db._realClose();
    process.argv[3] = originalArgv3;
    delete process.env.CLAUDE_MEM_NO_DELAY;
    while (filesToCleanup.length) {
      try { rmSync(filesToCleanup.pop(), { force: true }); } catch {}
    }
    vi.clearAllMocks();
  });

  it('P3: bugfix with null lesson retries and merges substantive lesson', async () => {
    // First pass returns type='bugfix' but lesson_learned=null.
    // P3 retry fires and recovers a real lesson. Observation saved (to events
    // table since bugfix is EVENT_TYPE) carries the retry lesson.
    callLLM
      .mockReturnValueOnce(JSON.stringify({
        type: 'bugfix',
        title: 'Fixed FTS corruption on access_count UPDATE',
        narrative: 'Wrapped in try/catch to prevent cascade',
        concepts: ['fts5'],
        facts: ['observations_au trigger re-inserts FTS row on any UPDATE'],
        importance: 2,
        lesson_learned: 'none',
      }))
      .mockReturnValueOnce(JSON.stringify({
        lesson: 'FTS5 trigger fires on ANY column UPDATE including access_count — wrap writes in try/catch so SQLITE_CORRUPT_VTAB does not cascade.',
      }));

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['schema.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Wrap FTS update in try/catch', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    // bugfix lands in events (EVENT_TYPE). Body carries the retry lesson.
    const ev = db.prepare(`SELECT * FROM events WHERE project = ?`).all('test-proj');
    expect(ev.length).toBe(1);
    expect(ev[0].event_type).toBe('bugfix');
    expect(ev[0].body).toContain('FTS5 trigger fires on ANY column UPDATE');
    // callLLM was called exactly twice (initial + retry)
    expect(callLLM.mock.calls.length).toBe(2);
  });

  it('P3: decision with null lesson retries', async () => {
    callLLM
      .mockReturnValueOnce(JSON.stringify({
        type: 'decision',
        title: 'Rejected schema migration for signal filter',
        narrative: 'Went with pure-data module + CI sync test instead',
        concepts: ['schema'],
        facts: [],
        importance: 2,
        lesson_learned: '',
      }))
      .mockReturnValueOnce(JSON.stringify({
        lesson: 'Before schema migration, measure drift points — 1 inline call site, not 4 — pure-data module avoids DB touch.',
      }));

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['lib/low-signal.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Add isNoiseObservation helper', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    const ev = db.prepare(`SELECT * FROM events WHERE project = ? AND event_type = ?`).all('test-proj', 'decision');
    expect(ev.length).toBe(1);
    expect(ev[0].body).toContain('Before schema migration');
    expect(callLLM.mock.calls.length).toBe(2);
  });

  it('P3: change type does NOT trigger lesson retry (scoped to bugfix/decision)', async () => {
    callLLM.mockReturnValueOnce(JSON.stringify({
      type: 'change',
      title: 'Refactored config loading',
      narrative: 'Extracted env parsing into loader',
      concepts: ['config'],
      facts: [],
      importance: 1,
      lesson_learned: 'none',
    }));

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['config.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Refactor config', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    // Only 1 callLLM call — no retry for 'change'
    expect(callLLM.mock.calls.length).toBe(1);
  });

  // v2.56.0 #1 paired-gate DROP. Catches Haiku-titled change obs with null
  // lesson + capped importance — the dominant noise band (16.5% hit-rate).
  // Pairs with capNoiseImportance demote (#8152). End-to-end assertion: obs
  // lands NOWHERE (not events, not observations), no upgrade, no insert.
  it('v2.56.0 #1: drops type=change with null lesson and importance=1 (no DB write)', async () => {
    callLLM.mockReturnValueOnce(JSON.stringify({
      type: 'change',
      title: 'Refactored config loading helper',
      narrative: 'Extracted env parsing into a single loader function',
      concepts: ['config'],
      facts: [],
      importance: 1,
      lesson_learned: 'none',
    }));

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['config.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Refactor config', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    const obsRows = db.prepare(`SELECT id FROM observations WHERE project = ?`).all('test-proj');
    expect(obsRows.length).toBe(0);
    const evRows = db.prepare(`SELECT id FROM events WHERE project = ?`).all('test-proj');
    expect(evRows.length).toBe(0);
  });

  it('v2.56.0 #1: deletes pre-saved obs when Haiku reclassifies as low-yield change', async () => {
    callLLM.mockReturnValueOnce(JSON.stringify({
      type: 'change',
      title: 'Refactored config loading helper',
      narrative: 'Extracted env parsing into a single loader function',
      concepts: [],
      facts: [],
      importance: 1,
      lesson_learned: 'none',
    }));

    // Pre-save a row to mimic the foreground rule-fallback insert.
    insertSession(db, { id: 'ep-sess', project: 'test-proj' });
    const preSavedId = db.prepare(`
      INSERT INTO observations (memory_session_id, project, type, title, importance, created_at, created_at_epoch, narrative, concepts, facts, files_read, files_modified, text)
      VALUES (?, ?, 'change', 'Modified config.mjs', 1, ?, ?, '', '', '', '[]', '[]', '')
    `).run('ep-sess', 'test-proj', new Date().toISOString(), Date.now()).lastInsertRowid;

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj', savedId: preSavedId,
      files: ['config.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Refactor config', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    const stillExists = db.prepare(`SELECT id FROM observations WHERE id = ?`).get(preSavedId);
    expect(stillExists).toBeUndefined();
  });

  it('v2.56.0 #1: KEEPS type=change with substantive lesson', async () => {
    callLLM.mockReturnValueOnce(JSON.stringify({
      type: 'change',
      title: 'Updated FTS5 query in scoring-sql',
      narrative: 'Switched from hand-built MATCH to sanitizeFtsQuery helper',
      concepts: ['fts5'],
      facts: [],
      importance: 1,
      lesson_learned: 'BM25 score sign flipped — lower is better in SQLite FTS5; rank ASC, not DESC.',
    }));

    insertSession(db, { id: 'ep-sess', project: 'test-proj' });

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['scoring-sql.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Update query', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    const obsRows = db.prepare(`SELECT title, lesson_learned FROM observations WHERE project = ?`).all('test-proj');
    expect(obsRows.length).toBe(1);
    expect(obsRows[0].lesson_learned).toContain('BM25 score sign flipped');
  });

  it('v2.56.0 #1 opt-out: CLAUDE_MEM_KEEP_LOW_SIGNAL=1 disables drop', async () => {
    vi.stubEnv('CLAUDE_MEM_KEEP_LOW_SIGNAL', '1');
    callLLM.mockReturnValueOnce(JSON.stringify({
      type: 'change',
      title: 'Refactored config loading helper',
      narrative: 'Extracted env parsing into a single loader function',
      concepts: [],
      facts: [],
      importance: 1,
      lesson_learned: 'none',
    }));

    insertSession(db, { id: 'ep-sess', project: 'test-proj' });

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['config.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Refactor config', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    const obsRows = db.prepare(`SELECT id FROM observations WHERE project = ?`).all('test-proj');
    expect(obsRows.length).toBe(1);
  });

  it('P3 opt-out: CLAUDE_MEM_NO_LESSON_RETRY=1 skips retry for bugfix', async () => {
    vi.stubEnv('CLAUDE_MEM_NO_LESSON_RETRY', '1');
    callLLM.mockReturnValueOnce(JSON.stringify({
      type: 'bugfix',
      title: 'Fixed something',
      narrative: 'Fixed it',
      concepts: [],
      facts: [],
      importance: 2,
      lesson_learned: 'none',
    }));

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['x.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Fix x.mjs', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    try {
      await handleLLMEpisode();
      expect(callLLM.mock.calls.length).toBe(1);
    } finally {
      vi.unstubAllEnvs();
    }
  });

  it('P3: retry returning "none" leaves lesson null (no false promotion)', async () => {
    callLLM
      .mockReturnValueOnce(JSON.stringify({
        type: 'bugfix',
        title: 'Routine fix',
        narrative: 'Mechanical fix',
        concepts: [],
        facts: [],
        importance: 2,
        lesson_learned: 'none',
      }))
      .mockReturnValueOnce(JSON.stringify({ lesson: 'none' }));

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['y.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Mechanical fix', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    const ev = db.prepare(`SELECT * FROM events WHERE project = ? AND event_type = ?`).all('test-proj', 'bugfix');
    expect(ev.length).toBe(1);
    // Retry "none" must NOT be promoted to lesson. persistHaikuSummary.body falls
    // back to narrative when lesson_learned is null — verify body is the narrative.
    expect(ev[0].body).toBe('Mechanical fix');
    expect(ev[0].body).not.toContain('none');
    expect(callLLM.mock.calls.length).toBe(2);
  });

  it('P1: Haiku prompt contains decision-type trigger examples (single-entry)', async () => {
    // v2.36 P1: ensure decision classification guidance reaches the LLM. The
    // trigger string is a prompt-level addition, so the test captures what was
    // actually sent to callLLM and checks the guidance survived.
    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['auth.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Add JWT middleware', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    const calls = callLLM.mock.calls;
    expect(calls.length).toBeGreaterThan(0);
    const lastPrompt = promptText(calls[calls.length - 1][0]);
    expect(lastPrompt).toContain('decision = explicit tradeoff');
    expect(lastPrompt).toContain('chose X over Y because Z');
  });

  it('P1: Haiku prompt contains decision-type trigger examples (multi-entry)', async () => {
    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['auth.mjs'], filesRead: [],
      entries: [
        { tool: 'Edit', desc: 'Add JWT middleware', isError: false },
        { tool: 'Edit', desc: 'Update config loader', isError: false },
      ],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    const calls = callLLM.mock.calls;
    expect(calls.length).toBeGreaterThan(0);
    const lastPrompt = promptText(calls[calls.length - 1][0]);
    expect(lastPrompt).toContain('decision = explicit tradeoff');
    expect(lastPrompt).toContain('bugfix = prior-failing path');
  });

  it('audit-fix: lesson_learned prompt does not recommend "none" string output (single-entry)', async () => {
    // Audit finding: previous prompt "non-obvious insight or 'none' if routine"
    // taught Haiku to write 'none' as a fallback, then downstream lowSignalLesson
    // gate rejected 'none' as noise — prompt fighting its own gates. Per cite-recall
    // baseline 67% of `change` obs were dropped post-Haiku. Regression guard: prompt
    // must (a) not present 'none' as a valid output, (b) tell model to use JSON null
    // for the no-insight case, (c) explicitly forbid the noise tokens.
    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['auth.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Bump version', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    const calls = callLLM.mock.calls;
    expect(calls.length).toBeGreaterThan(0);
    const lastPrompt = promptText(calls[calls.length - 1][0]);
    expect(lastPrompt).not.toMatch(/['"]none['"]\s+if\s+routine/i);
    expect(lastPrompt).not.toMatch(/write\s+['"]none['"]/i);
    expect(lastPrompt).toContain('output JSON null');
    expect(lastPrompt).toMatch(/Do NOT invent a lesson/i);
  });

  it('audit-fix: lesson_learned prompt does not recommend "none" string output (multi-entry)', async () => {
    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['auth.mjs'], filesRead: [],
      entries: [
        { tool: 'Edit', desc: 'Bump version', isError: false },
        { tool: 'Edit', desc: 'Update lock file', isError: false },
      ],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    const calls = callLLM.mock.calls;
    expect(calls.length).toBeGreaterThan(0);
    const lastPrompt = promptText(calls[calls.length - 1][0]);
    expect(lastPrompt).not.toMatch(/['"]none['"]\s+if\s+routine/i);
    expect(lastPrompt).not.toMatch(/write\s+['"]none['"]/i);
    expect(lastPrompt).toContain('output JSON null');
  });

  it('extracts and saves event from single-entry episode (feature type → events table)', async () => {
    // Default mock returns type='feature' → EVENT_TYPE → routes to events.
    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['auth.mjs'], filesRead: ['config.mjs'],
      entries: [{ tool: 'Edit', desc: 'Add JWT middleware', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    // Feature-typed Haiku summary lands in `events`, not `observations`.
    const obs = db.prepare('SELECT * FROM observations').all();
    expect(obs.length).toBe(0);
    const ev = db.prepare(`SELECT * FROM events WHERE project = ?`).all('test-proj');
    expect(ev.length).toBe(1);
    expect(ev[0].title).toBe('Add user authentication');
    expect(ev[0].event_type).toBe('feature');
    expect(ev[0].importance).toBe(2);
    expect(JSON.parse(ev[0].file_paths)).toEqual(['auth.mjs']);
  });

  it('extracts event from multi-entry episode (feature type)', async () => {
    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['auth.mjs', 'config.mjs'], filesRead: [],
      entries: [
        { tool: 'Edit', desc: 'Add JWT middleware', isError: false },
        { tool: 'Bash', desc: 'npm test', isError: true },
        { tool: 'Edit', desc: 'Fix test assertion', isError: false },
      ],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    // Feature-typed Haiku summary lands in `events`, not `observations`.
    const obs = db.prepare('SELECT * FROM observations').all();
    expect(obs.length).toBe(0);
    const ev = db.prepare(`SELECT * FROM events WHERE project = ?`).all('test-proj');
    expect(ev.length).toBe(1);
  });

  it('P0: degraded fallback drops pure-noise episode (LOW_SIGNAL title + no signal)', async () => {
    // v2.36: P0 write-side filter blocks low-signal pre-save/fallback inserts.
    // A bare Edit episode produces "Modified app.mjs" (LOW_SIGNAL) with no facts
    // and no lesson — isNoiseObservation() drops it before saveObservation inserts.
    acquireLLMSlot.mockResolvedValueOnce(false);

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['app.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Update configuration', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    const obs = db.prepare('SELECT * FROM observations WHERE memory_session_id = ?').all('ep-sess');
    expect(obs.length).toBe(0);
  });

  it('P0 opt-out: CLAUDE_MEM_KEEP_LOW_SIGNAL=1 preserves pre-v2.36 fallback save', async () => {
    vi.stubEnv('CLAUDE_MEM_KEEP_LOW_SIGNAL', '1');
    acquireLLMSlot.mockResolvedValueOnce(false);

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['app.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Update configuration', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    try {
      await handleLLMEpisode();
      const obs = db.prepare('SELECT * FROM observations WHERE memory_session_id = ?').all('ep-sess');
      expect(obs.length).toBe(1);
      expect(obs[0].title).toBe('Modified app.mjs');
      expect(obs[0].type).toBe('change');
    } finally {
      vi.unstubAllEnvs();
    }
  });

  it('infers bugfix type in fallback when entry has error (routes to events)', async () => {
    // Fallback path with error → buildImmediateObservation infers type='bugfix',
    // which is an EVENT_TYPE → dispatcher routes to `events`, not `observations`.
    acquireLLMSlot.mockResolvedValueOnce(false);

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['app.mjs'], filesRead: [],
      entries: [{ tool: 'Bash', desc: 'npm test failed', isError: true }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    expect(db.prepare(`SELECT COUNT(*) c FROM observations WHERE type='bugfix'`).get().c).toBe(0);
    const ev = db.prepare(`SELECT * FROM events WHERE event_type='bugfix' AND project = ?`).all('test-proj');
    expect(ev.length).toBe(1);
    // buildDegradedTitle: file + error → "Error: app.mjs"
    expect(ev[0].title).toBe('Error: app.mjs');
  });

  // v2.44+: buildImmediateObservation cap logic. computeRuleImportance uses
  // coarse file-name heuristics (schema.*, migration, auth.*, .env, .pem)
  // that fire on incidental file touches in broad multi-file episodes. For
  // LOW_SIGNAL titles (Haiku couldn't extract meaning) or auto-generated
  // review titles, we can't distinguish "critical file was primary focus"
  // from "one of N files read" — cap importance at 2 even when rule=3.
  // Production baseline (2026-04-23): 34/100 discovery/imp=3 obs had
  // LOW_SIGNAL titles with this leak; fix closes that path.
  describe('buildImmediateObservation importance cap', () => {
    it('caps isReviewPattern importance at 2 even when rule says 3', () => {
      // 5+ Read tools without edits/errors → isReviewPattern=true.
      // schema.js file → computeRuleImportance returns 3.
      // Prior: Math.max(2, 3) = 3 → leaked as imp=3.
      // New: cap at 2 regardless of rule.
      const episode = {
        entries: Array.from({ length: 5 }, (_, i) => ({
          tool: 'Read', files: [`/src/file${i}.js`], bashSig: null,
        })),
        files: ['/src/file0.js', '/src/schema.js', '/src/file2.js', '/src/file3.js', '/src/file4.js'],
        filesRead: ['/src/file0.js', '/src/schema.js'],
      };
      const obs = buildImmediateObservation(episode);
      expect(obs.title).toMatch(/^Reviewed \d+ files:/);
      expect(obs.importance).toBe(2);
    });

    it('caps LOW_SIGNAL title with rule=3 at importance 2', () => {
      // Edit on schema.* triggers rule=3 but title is "Modified schema.js"
      // (LOW_SIGNAL). Prior: else branch set imp=3. New: cap at 2.
      const episode = {
        entries: [{ tool: 'Edit', files: ['/src/schema.js'], bashSig: null }],
        files: ['/src/schema.js'],
        filesRead: [],
      };
      const obs = buildImmediateObservation(episode);
      // buildDegradedTitle produces "Modified schema.js" — LOW_SIGNAL
      expect(obs.importance).toBe(2);
    });

    it('keeps LOW_SIGNAL title with rule<=2 at importance 1 (cap preserved)', () => {
      // Edit on non-critical file → rule=1, title LOW_SIGNAL. Stays at 1.
      const episode = {
        entries: [{ tool: 'Edit', files: ['/src/app.mjs'], bashSig: null }],
        files: ['/src/app.mjs'],
        filesRead: [],
      };
      const obs = buildImmediateObservation(episode);
      expect(obs.importance).toBe(1);
    });

    it('preserves non-LOW_SIGNAL rule=3 at importance 3', () => {
      // This path isn't directly reachable via buildImmediateObservation
      // (which always produces degraded titles), but exercises the else
      // branch contract: non-LOW_SIGNAL + rule=3 → 3. The Haiku path
      // invokes the same cap logic via persistHaikuSummary isLowSignal
      // check — Haiku-generated titles typically clear LOW_SIGNAL.
      const episode = {
        entries: [
          { tool: 'Bash', files: [], bashSig: { isError: true, isTest: true, isBuild: false, isGit: false, isDeploy: false } },
        ],
        files: [],
        filesRead: [],
      };
      const obs = buildImmediateObservation(episode);
      // Degraded title for error → "Error: <file>" or similar — LOW_SIGNAL
      // so hits the isLowSignal branch → rule=3 → cap to 2. Verifies cap
      // also applies when rule=3 from error-signature path, not just files.
      expect(obs.importance).toBe(2);
    });
  });

  it('returns early when no tmpFile specified', async () => {
    process.argv[3] = undefined;
    await handleLLMEpisode();
    expect(callLLM).not.toHaveBeenCalled();
  });

  it('returns early when episode has no entries', async () => {
    writeFileSync(tmpFile, JSON.stringify({
      sessionId: 'ep-sess', project: 'test-proj',
      files: [], filesRead: [], entries: [],
    }));

    await handleLLMEpisode();
    expect(callLLM).not.toHaveBeenCalled();
  });

  it('returns early for invalid JSON in tmpFile', async () => {
    writeFileSync(tmpFile, 'not valid json {{{');

    await handleLLMEpisode();
    expect(callLLM).not.toHaveBeenCalled();
  });

  it('links related observations by FTS5 title match', async () => {
    // Linking only applies to rows in `observations` — use type='change' (not an
    // EVENT_TYPE) so both the seed and the new row land in observations.
    insertSession(db, { id: 'ep-sess', project: 'test-proj' });
    db.prepare(`
      INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, created_at, created_at_epoch)
      VALUES (?, ?, '', 'change', ?, '', 'Previous auth work', '', '', '[]', '[]', 1, ?, ?)
    `).run('ep-sess', 'test-proj', 'Authentication middleware setup', new Date().toISOString(), Date.now());

    callLLM.mockReturnValue(JSON.stringify({
      type: 'change',
      title: 'Add authentication validation layer',
      narrative: 'Extended authentication with validation',
      concepts: ['auth'], facts: [], importance: 1,
      // v2.56.0 #1: substantive lesson required to bypass paired-DROP gate
      // for type=change + null lesson + imp<2. The test exercises link logic,
      // not the gate; mock a real lesson so the obs is saved.
      lesson_learned: 'Authentication middleware must guard validation layer before route handlers — order matters.',
    }));

    writeFileSync(tmpFile, JSON.stringify({
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['auth.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Add auth check', isError: false }],
    }));

    await handleLLMEpisode();

    const allObs = db.prepare('SELECT id, related_ids FROM observations ORDER BY id').all();
    expect(allObs.length).toBe(2);

    const firstRelated = JSON.parse(allObs[0].related_ids || '[]');
    const secondRelated = JSON.parse(allObs[1].related_ids || '[]');
    const hasBidirectional = firstRelated.includes(allObs[1].id) && secondRelated.includes(allObs[0].id);
    expect(hasBidirectional).toBe(true);
  });

  it('links related observations by file overlap', async () => {
    insertSession(db, { id: 'ep-sess', project: 'test-proj' });
    db.prepare(`
      INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, created_at, created_at_epoch)
      VALUES (?, ?, '', 'change', ?, '', '', '', '', '[]', ?, 1, ?, ?)
    `).run('ep-sess', 'test-proj', 'Previous edit to shared file', '["shared.mjs"]', new Date().toISOString(), Date.now());

    callLLM.mockReturnValue(JSON.stringify({
      type: 'change',
      title: 'Completely different title here',
      narrative: 'Unrelated narrative text',
      concepts: [], facts: [], importance: 1,
      // v2.56.0 #1: bypass paired-DROP gate (see "links related observations by FTS5 title match" comment)
      lesson_learned: 'Shared module edits must update consumer call sites in the same change to keep type contract aligned.',
    }));

    writeFileSync(tmpFile, JSON.stringify({
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['shared.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Update shared module', isError: false }],
    }));

    await handleLLMEpisode();

    const allObs = db.prepare('SELECT id, related_ids FROM observations ORDER BY id').all();
    expect(allObs.length).toBe(2);

    const firstRelated = JSON.parse(allObs[0].related_ids || '[]');
    const secondRelated = JSON.parse(allObs[1].related_ids || '[]');
    const hasLink = firstRelated.includes(allObs[1].id) || secondRelated.includes(allObs[0].id);
    expect(hasLink).toBe(true);
  });

  it('caps related_ids at 5', async () => {
    insertSession(db, { id: 'ep-sess', project: 'test-proj' });

    for (let i = 0; i < 7; i++) {
      db.prepare(`
        INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, created_at, created_at_epoch)
        VALUES (?, ?, '', 'change', ?, '', '', '', '', '[]', ?, 1, ?, ?)
      `).run('ep-sess', 'test-proj', `Performance optimization step ${i}`, '["perf.mjs"]', new Date().toISOString(), Date.now() + i);
    }

    // Use type='change' (non-EVENT_TYPE) so the new row lands in `observations`
    // and `linkRelatedObservations` runs — the cap-at-5 contract is an
    // observations-linking concern.
    callLLM.mockReturnValue(JSON.stringify({
      type: 'change',
      title: 'Final performance optimization pass',
      narrative: 'Completed performance optimization work',
      concepts: ['optimization'], facts: [], importance: 1,
      // v2.56.0 #1: bypass paired-DROP gate
      lesson_learned: 'Bulk perf passes must respect related_ids cap=5 so historic linkage does not unbounded-grow.',
    }));

    writeFileSync(tmpFile, JSON.stringify({
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['perf.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Optimize perf.mjs', isError: false }],
    }));

    await handleLLMEpisode();

    const newObs = db.prepare('SELECT related_ids FROM observations ORDER BY id DESC LIMIT 1').get();
    const relatedIds = JSON.parse(newObs.related_ids || '[]');
    expect(relatedIds.length).toBeLessThanOrEqual(5);
  });

  it('upgrade-delete: pre-saved observation replaced by event when Haiku classifies as EVENT_TYPE', async () => {
    // Pre-save a rule-based observation (simulating what flushEpisode does).
    // Default mock returns type='feature' → EVENT_TYPE → pre-saved observations
    // row must be DELETED and a fresh event inserted.
    // v2.36 P0: pre-save uses importance=2 to simulate a rule-signal episode
    // (e.g. error-in-test / config-change) so isNoiseObservation does NOT block.
    insertSession(db, { id: 'ep-sess', project: 'test-proj' });
    const preSavedId = saveObservation(
      { type: 'change', title: 'Modified auth.mjs', narrative: 'Edit auth.mjs', importance: 2 },
      'test-proj', 'ep-sess', db
    );
    expect(preSavedId).toBeGreaterThan(0);

    writeFileSync(tmpFile, JSON.stringify({
      sessionId: 'ep-sess', project: 'test-proj',
      savedId: preSavedId,
      files: ['auth.mjs'], filesRead: ['config.mjs'],
      entries: [{ tool: 'Edit', desc: 'Add JWT middleware', isError: false }],
    }));

    await handleLLMEpisode();

    // Pre-saved observations row is gone.
    const deleted = db.prepare('SELECT * FROM observations WHERE id = ?').get(preSavedId);
    expect(deleted).toBeUndefined();
    // No other observations for this session either.
    const allObs = db.prepare('SELECT * FROM observations WHERE memory_session_id = ?').all('ep-sess');
    expect(allObs.length).toBe(0);

    // A fresh event exists with the LLM-enriched fields.
    const evs = db.prepare(`SELECT * FROM events WHERE project = ?`).all('test-proj');
    expect(evs.length).toBe(1);
    expect(evs[0].title).toBe('Add user authentication');
    expect(evs[0].event_type).toBe('feature');
    expect(evs[0].importance).toBe(2);
    expect(JSON.parse(evs[0].file_paths)).toEqual(['auth.mjs']);
  });

  it('upgrade-in-place: pre-saved observation stays in observations when Haiku classifies as non-EVENT_TYPE', async () => {
    // Pre-save a rule-based observation, LLM classifies as 'change' (not an
    // EVENT_TYPE) → UPDATE existing observations row, do not touch events.
    callLLM.mockReturnValue(JSON.stringify({
      type: 'change',
      title: 'Refactored config loading',
      narrative: 'Extracted env parsing into loader module',
      concepts: ['config'],
      facts: [],
      importance: 2,
      // v2.33.1: supply a real lesson so the low-signal downgrade doesn't trip.
      // Without this, type='change' + missing lesson → importance capped at 1.
      lesson_learned: 'Loader extraction clarifies config-path resolution order.',
    }));

    // v2.36 P0: importance=2 bypasses isNoiseObservation so pre-save lands.
    insertSession(db, { id: 'ep-sess', project: 'test-proj' });
    const preSavedId = saveObservation(
      { type: 'change', title: 'Modified config.mjs', narrative: 'Edit config', importance: 2 },
      'test-proj', 'ep-sess', db
    );
    expect(preSavedId).toBeGreaterThan(0);

    writeFileSync(tmpFile, JSON.stringify({
      sessionId: 'ep-sess', project: 'test-proj',
      savedId: preSavedId,
      files: ['config.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Refactor config', isError: false }],
    }));

    await handleLLMEpisode();

    // Same row, enriched.
    const allObs = db.prepare('SELECT * FROM observations WHERE memory_session_id = ?').all('ep-sess');
    expect(allObs.length).toBe(1);
    expect(allObs[0].id).toBe(preSavedId);
    expect(allObs[0].title).toBe('Refactored config loading');
    expect(allObs[0].type).toBe('change');
    expect(allObs[0].importance).toBe(2);
    // No events written.
    expect(db.prepare(`SELECT COUNT(*) c FROM events`).get().c).toBe(0);
  });

  it('keeps pre-saved observation when LLM fails and savedId present', async () => {
    acquireLLMSlot.mockResolvedValueOnce(false);

    // v2.36 P0: importance=2 bypasses isNoiseObservation so pre-save lands.
    // v2.47 P0-3: capNoiseImportance now demotes LOW_SIGNAL+no-signal imp=2
    // down to 1 at write time. Attach a lesson so the cap is bypassed and
    // importance=2 survives (the pre-save scenario the test exercises).
    insertSession(db, { id: 'ep-sess', project: 'test-proj' });
    const preSavedId = saveObservation(
      { type: 'change', title: 'Modified app.mjs', narrative: 'Quick edit', importance: 2,
        lessonLearned: 'Quick edit landed; background LLM fills the enriched fields later.' },
      'test-proj', 'ep-sess', db
    );

    writeFileSync(tmpFile, JSON.stringify({
      sessionId: 'ep-sess', project: 'test-proj',
      savedId: preSavedId,
      files: ['app.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Update app', isError: false }],
    }));

    await handleLLMEpisode();

    // Pre-saved observation should remain unchanged
    const obs = db.prepare('SELECT * FROM observations WHERE id = ?').get(preSavedId);
    expect(obs.title).toBe('Modified app.mjs');
    expect(obs.importance).toBe(2);

    // No duplicate observations
    const allObs = db.prepare('SELECT COUNT(*) as c FROM observations WHERE memory_session_id = ?').get('ep-sess');
    expect(allObs.c).toBe(1);
  });

  it('handleLLMEpisode routes bugfix Haiku summary to events, not observations (T9)', async () => {
    // Full wired-path integration: LLM returns bugfix, handleLLMEpisode must
    // dispatch through persistHaikuSummary, landing in `events`.
    callLLM.mockReturnValue(JSON.stringify({
      type: 'bugfix',
      title: 'fixed null deref in session init',
      narrative: 'session file was created without atomic rename causing TOCTOU',
      concepts: ['race-condition'],
      facts: [],
      importance: 2,
      lesson_learned: 'Always use atomic write (tmp+rename) for concurrent file access',
      search_aliases: ['TOCTOU', 'file race'],
    }));

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['session.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Add atomic rename to session init', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    expect(db.prepare(`SELECT COUNT(*) c FROM observations WHERE type='bugfix'`).get().c).toBe(0);
    const ev = db.prepare(`SELECT * FROM events WHERE event_type='bugfix' AND project = ?`).get('test-proj');
    expect(ev).toBeDefined();
    expect(ev.title).toBe('fixed null deref in session init');
    expect(ev.body).toBe('Always use atomic write (tmp+rename) for concurrent file access');
    expect(JSON.parse(ev.file_paths)).toEqual(['session.mjs']);
    expect(ev.importance).toBe(2);
  });

  it('discards observation when LLM returns importance=0', async () => {
    callLLM.mockReturnValue(JSON.stringify({
      type: 'discovery',
      title: 'Browsed some files',
      narrative: 'Just looking around',
      concepts: [],
      facts: [],
      importance: 0,
    }));

    const episode = {
      sessionId: 'ep-sess', project: 'test-proj',
      files: ['readme.md'], filesRead: ['readme.md'],
      entries: [{ tool: 'Read', desc: 'Read readme.md', isError: false }],
    };
    writeFileSync(tmpFile, JSON.stringify(episode));

    await handleLLMEpisode();

    const obs = db.prepare('SELECT * FROM observations WHERE memory_session_id = ?').all('ep-sess');
    expect(obs.length).toBe(0);
  });

  it('discards and deletes pre-saved observation when LLM returns importance=0', async () => {
    insertSession(db, { id: 'ep-sess', project: 'test-proj' });
    const preSavedId = saveObservation(
      { type: 'discovery', title: 'Read readme.md', narrative: 'Browsing', importance: 1 },
      'test-proj', 'ep-sess', db
    );
    expect(preSavedId).toBeGreaterThan(0);

    // The discard path calls openDb() to get a DB handle for deleting the pre-saved obs.
    // We return a wrapper around our test db that tracks close() but doesn't actually close.
    const deleteDbProxy = { ...db, close: vi.fn(), prepare: (...a) => db.prepare(...a) };
    openDb.mockReturnValueOnce(deleteDbProxy);

    callLLM.mockReturnValue(JSON.stringify({
      type: 'discovery',
      title: 'Read readme.md',
      narrative: 'Browsing',
      concepts: [],
      facts: [],
      importance: 0,
    }));

    writeFileSync(tmpFile, JSON.stringify({
      sessionId: 'ep-sess', project: 'test-proj',
      savedId: preSavedId,
      files: ['readme.md'], filesRead: ['readme.md'],
      entries: [{ tool: 'Read', desc: 'Read readme.md', isError: false }],
    }));

    await handleLLMEpisode();

    // The pre-saved observation should be deleted
    const deleted = db.prepare('SELECT * FROM observations WHERE id = ?').get(preSavedId);
    expect(deleted).toBeUndefined();
    // The delete DB handle should have been closed
    expect(deleteDbProxy.close).toHaveBeenCalled();
  });
});

// ─── handleLLMSummary ────────────────────────────────────────────────────────

describe('handleLLMSummary', () => {
  let db;
  const originalArgv3 = process.argv[3];
  const originalArgv4 = process.argv[4];

  beforeEach(() => {
    process.argv[3] = 'test-session';
    process.argv[4] = 'test-proj';
    process.env.CLAUDE_MEM_FLUSH_TIMEOUT = '0';

    db = createTestDb();
    db._realClose = db.close;
    db.close = () => {};

    openDb.mockReturnValue(db);
    callLLM.mockReturnValue(JSON.stringify({
      request: 'Implementing auth system',
      investigated: 'JWT vs session tokens',
      learned: 'JWT is stateless and scalable',
      completed: 'Basic auth flow with login/logout',
      next_steps: 'Add refresh token rotation',
    }));
  });

  afterEach(() => {
    if (db?._realClose) db._realClose();
    process.argv[3] = originalArgv3;
    process.argv[4] = originalArgv4;
    delete process.env.CLAUDE_MEM_FLUSH_TIMEOUT;
    vi.clearAllMocks();
  });

  it('creates session summary from observations', async () => {
    insertSession(db, { id: 'test-session', project: 'test-proj' });
    for (let i = 0; i < 3; i++) {
      db.prepare(`
        INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, created_at, created_at_epoch)
        VALUES (?, ?, '', 'feature', ?, '', 'Narrative text', '', '', '[]', '[]', 1, ?, ?)
      `).run('test-session', 'test-proj', `Observation ${i}`, new Date().toISOString(), Date.now() + i);
    }

    await handleLLMSummary();

    const summaries = db.prepare('SELECT * FROM session_summaries WHERE memory_session_id = ?').all('test-session');
    expect(summaries.length).toBe(1);
    expect(summaries[0].request).toBe('Implementing auth system');
    expect(summaries[0].completed).toBe('Basic auth flow with login/logout');
    expect(summaries[0].next_steps).toBe('Add refresh token rotation');
  });

  it('preserves structural extractor content when Haiku returns empty for a field', async () => {
    // Regression: the fast-baseline write now pre-populates remaining_items from
    // CLAUDE.md §10 Done/Not done markers in the tail assistant message. A later
    // Haiku pass that returns empty remaining_items must NOT clobber that floor.
    insertSession(db, { id: 'test-session', project: 'test-proj' });
    db.prepare(`
      INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
      VALUES (?, ?, 'first prompt', '', '', 'structured-done', '', 'structured-notdone: Gap #3 data backfill', '[]', '[]', 'fast', ?, ?)
    `).run('test-session', 'test-proj', new Date().toISOString(), Date.now());

    db.prepare(`
      INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, created_at, created_at_epoch)
      VALUES (?, ?, '', 'feature', 'obs title', '', '', '', '', '[]', '[]', 1, ?, ?)
    `).run('test-session', 'test-proj', new Date().toISOString(), Date.now());

    // Haiku returns rich completed but empty remaining_items — common degraded
    // return shape observed in prod.
    callLLM.mockReturnValueOnce(JSON.stringify({
      request: 'Implementing auth system',
      completed: 'Basic auth flow with login/logout',
      remaining_items: '',
      next_steps: 'Add refresh token rotation',
    }));

    await handleLLMSummary();

    const row = db.prepare('SELECT * FROM session_summaries WHERE memory_session_id = ?').get('test-session');
    expect(row.completed).toBe('Basic auth flow with login/logout');  // Haiku richer → overwritten
    expect(row.remaining_items).toBe('structured-notdone: Gap #3 data backfill');  // Haiku empty → preserved
    expect(row.next_steps).toBe('Add refresh token rotation');
    expect(row.notes).toBe('llm');
  });

  it('upgrades existing fast summary instead of creating duplicate', async () => {
    insertSession(db, { id: 'test-session', project: 'test-proj' });
    // Simulate fast summary created by handleStop
    db.prepare(`
      INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
      VALUES (?, ?, 'fast request', '', '', 'fast completed', '', '', '[]', '[]', 'fast', ?, ?)
    `).run('test-session', 'test-proj', new Date().toISOString(), Date.now());

    for (let i = 0; i < 3; i++) {
      db.prepare(`
        INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, created_at, created_at_epoch)
        VALUES (?, ?, '', 'feature', ?, '', 'Narrative text', '', '', '[]', '[]', 1, ?, ?)
      `).run('test-session', 'test-proj', `Observation ${i}`, new Date().toISOString(), Date.now() + i);
    }

    await handleLLMSummary();

    const summaries = db.prepare('SELECT * FROM session_summaries WHERE memory_session_id = ?').all('test-session');
    // Should have exactly 1 summary (upgraded, not duplicated)
    expect(summaries.length).toBe(1);
    expect(summaries[0].notes).toBe('llm');
    expect(summaries[0].request).toBe('Implementing auth system');
    expect(summaries[0].completed).toBe('Basic auth flow with login/logout');
  });

  it('skips summary when no observations exist', async () => {
    insertSession(db, { id: 'test-session', project: 'test-proj' });

    await handleLLMSummary();

    const count = db.prepare('SELECT COUNT(*) as cnt FROM session_summaries').get();
    expect(count.cnt).toBe(0);
    expect(callLLM).not.toHaveBeenCalled();
  });

  it('skips summary when LLM slot unavailable', async () => {
    insertSession(db, { id: 'test-session', project: 'test-proj' });
    db.prepare(`
      INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, created_at, created_at_epoch)
      VALUES (?, ?, '', 'change', 'Test obs', '', '', '', '', '[]', '[]', 1, ?, ?)
    `).run('test-session', 'test-proj', new Date().toISOString(), Date.now());

    acquireLLMSlot.mockResolvedValueOnce(false);

    await handleLLMSummary();

    const count = db.prepare('SELECT COUNT(*) as cnt FROM session_summaries').get();
    expect(count.cnt).toBe(0);
  });
});

// ─── session summary structured knowledge ────────────────────────────────────

describe('session summary structured knowledge', () => {
  it('parses lessons array from summary', () => {
    const raw = JSON.stringify({
      request: 'Fix authentication flow',
      completed: 'Fixed token refresh in auth.ts',
      remaining_items: '',
      next_steps: 'Add integration tests',
      lessons: ['Token refresh needs exponential backoff', 'OAuth state must be crypto random'],
      key_decisions: ['Chose jose over jsonwebtoken for ESM compatibility']
    });
    const parsed = parseJsonFromLLM(raw);
    expect(parsed.lessons).toHaveLength(2);
    expect(parsed.key_decisions).toHaveLength(1);
  });

  it('handles missing lessons gracefully', () => {
    const raw = JSON.stringify({
      request: 'Update readme', completed: 'Done', remaining_items: '', next_steps: ''
    });
    const parsed = parseJsonFromLLM(raw);
    expect(parsed.lessons).toBeUndefined();
    expect(parsed.key_decisions).toBeUndefined();
  });

  it('persists lessons and key_decisions in session_summaries table', async () => {
    const db = createTestDb();
    db._realClose = db.close;
    db.close = () => {};

    const origArgv3 = process.argv[3];
    const origArgv4 = process.argv[4];
    process.argv[3] = 'lessons-sess';
    process.argv[4] = 'test-proj';
    process.env.CLAUDE_MEM_FLUSH_TIMEOUT = '0';

    openDb.mockReturnValue(db);
    callLLM.mockReturnValue(JSON.stringify({
      request: 'Fix auth flow',
      completed: 'Fixed token refresh',
      remaining_items: '',
      next_steps: 'Add tests',
      lessons: ['Exponential backoff needed for token refresh'],
      key_decisions: ['Chose jose for ESM compat']
    }));

    insertSession(db, { id: 'lessons-sess', project: 'test-proj' });
    db.prepare(`
      INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, created_at, created_at_epoch)
      VALUES (?, ?, '', 'feature', 'Auth fix', '', '', '', '', '[]', '[]', 1, ?, ?)
    `).run('lessons-sess', 'test-proj', new Date().toISOString(), Date.now());

    await handleLLMSummary();

    const summary = db.prepare('SELECT * FROM session_summaries WHERE memory_session_id = ?').get('lessons-sess');
    expect(summary).toBeDefined();
    expect(summary.lessons).toBe(JSON.stringify(['Exponential backoff needed for token refresh']));
    expect(summary.key_decisions).toBe(JSON.stringify(['Chose jose for ESM compat']));

    process.argv[3] = origArgv3;
    process.argv[4] = origArgv4;
    delete process.env.CLAUDE_MEM_FLUSH_TIMEOUT;
    db._realClose();
  });

  it('stores null for empty lessons and key_decisions arrays', async () => {
    const db = createTestDb();
    db._realClose = db.close;
    db.close = () => {};

    const origArgv3 = process.argv[3];
    const origArgv4 = process.argv[4];
    process.argv[3] = 'no-lessons-sess';
    process.argv[4] = 'test-proj';
    process.env.CLAUDE_MEM_FLUSH_TIMEOUT = '0';

    openDb.mockReturnValue(db);
    callLLM.mockReturnValue(JSON.stringify({
      request: 'Update readme',
      completed: 'Done',
      remaining_items: '',
      next_steps: '',
      lessons: [],
      key_decisions: []
    }));

    insertSession(db, { id: 'no-lessons-sess', project: 'test-proj' });
    db.prepare(`
      INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, created_at, created_at_epoch)
      VALUES (?, ?, '', 'change', 'Update readme', '', '', '', '', '[]', '[]', 1, ?, ?)
    `).run('no-lessons-sess', 'test-proj', new Date().toISOString(), Date.now());

    await handleLLMSummary();

    const summary = db.prepare('SELECT * FROM session_summaries WHERE memory_session_id = ?').get('no-lessons-sess');
    expect(summary).toBeDefined();
    expect(summary.lessons).toBeNull();
    expect(summary.key_decisions).toBeNull();

    process.argv[3] = origArgv3;
    process.argv[4] = origArgv4;
    delete process.env.CLAUDE_MEM_FLUSH_TIMEOUT;
    db._realClose();
  });
});

// ─── lesson_learned and search_aliases extraction ────────────────────────────

describe('lesson_learned and search_aliases extraction', () => {
  const filesToCleanup = [];

  afterEach(() => {
    while (filesToCleanup.length) {
      try { rmSync(filesToCleanup.pop(), { force: true }); } catch {}
    }
  });

  it('parses lesson_learned from LLM response', () => {
    const raw = JSON.stringify({
      type: 'bugfix', title: 'Fix race condition in session init',
      narrative: 'Session file was created without atomic rename',
      concepts: ['race-condition', 'atomicity'],
      facts: ['session file write needs atomic rename to prevent TOCTOU'],
      importance: 2,
      lesson_learned: 'Always use atomic write (tmp+rename) for concurrent file access',
      search_aliases: ['TOCTOU', 'file race', 'concurrent write', '原子写入'],
    });
    const parsed = parseJsonFromLLM(raw);
    expect(parsed.lesson_learned).toBe('Always use atomic write (tmp+rename) for concurrent file access');
    expect(parsed.search_aliases).toEqual(['TOCTOU', 'file race', 'concurrent write', '原子写入']);
  });

  it('lesson_learned is null for routine observations', () => {
    const raw = JSON.stringify({
      type: 'change', title: 'Updated config', narrative: 'Changed port',
      concepts: ['config'], facts: ['port changed to 3001'],
      importance: 1, lesson_learned: null, search_aliases: ['config change'],
    });
    const parsed = parseJsonFromLLM(raw);
    expect(parsed.lesson_learned).toBeNull();
  });

  // v2.56.0 #1: under the paired-DROP gate, type=change + lesson="none" + imp<2
  // is now DROPPED entirely (not saved with null lesson). The pre-v2.56 normalize
  // path is preserved at line 638-641 — what changed is the downstream drop.
  it('lesson_learned "none" causes drop for change type (v2.56.0)', async () => {
    callLLM.mockReturnValue(JSON.stringify({
      type: 'change',
      title: 'Minor config update',
      narrative: 'Updated port settings',
      concepts: ['config'],
      facts: ['Port changed to 3001'],
      importance: 1,
      lesson_learned: 'none',
      search_aliases: ['config change'],
    }));

    const db = createTestDb();
    db._realClose = db.close;
    db.close = () => {};
    openDb.mockReturnValue(db);

    const tmpFile2 = join(tmpdir(), `hook-llm-test-none-${Date.now()}.json`);
    filesToCleanup.push(tmpFile2);
    const origArgv3 = process.argv[3];
    process.argv[3] = tmpFile2;
    process.env.CLAUDE_MEM_NO_DELAY = '1';

    writeFileSync(tmpFile2, JSON.stringify({
      sessionId: 'none-sess', project: 'test-proj',
      files: ['config.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Update port config', isError: false }],
    }));

    await handleLLMEpisode();

    const obs = db.prepare('SELECT lesson_learned FROM observations WHERE memory_session_id = ?').get('none-sess');
    expect(obs).toBeUndefined(); // v2.56.0: dropped, not saved

    process.argv[3] = origArgv3;
    db._realClose();
  });

  it('lesson_learned "None" (capitalized) causes drop for change type (v2.56.0)', async () => {
    callLLM.mockReturnValue(JSON.stringify({
      type: 'change',
      title: 'Routine file edit',
      narrative: 'Standard update',
      concepts: [],
      facts: [],
      importance: 1,
      lesson_learned: 'None',
      search_aliases: [],
    }));

    const db = createTestDb();
    db._realClose = db.close;
    db.close = () => {};
    openDb.mockReturnValue(db);

    const tmpFile2 = join(tmpdir(), `hook-llm-test-None-${Date.now()}.json`);
    filesToCleanup.push(tmpFile2);
    const origArgv3 = process.argv[3];
    process.argv[3] = tmpFile2;
    process.env.CLAUDE_MEM_NO_DELAY = '1';

    writeFileSync(tmpFile2, JSON.stringify({
      sessionId: 'none-cap-sess', project: 'test-proj',
      files: ['app.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Standard edit', isError: false }],
    }));

    await handleLLMEpisode();

    const obs = db.prepare('SELECT lesson_learned FROM observations WHERE memory_session_id = ?').get('none-cap-sess');
    expect(obs).toBeUndefined(); // v2.56.0: dropped, not saved

    process.argv[3] = origArgv3;
    db._realClose();
  });

  // v2.33.1 Fix 2: extended low-signal filter — 'n/a', 'todo', '-', ultra-short
  // lessons are normalized to null and noise-prone types get importance capped.
  it.each([
    ['n/a', 'n-a'],
    ['TODO', 'todo'],
    ['-', 'dash'],
    ['nil', 'nil'],
    ['ok', 'too-short'],
  ])('v2.56.0: lesson_learned %s causes drop for change type (was: normalized + capped)', async (lessonText, tag) => {
    // v2.33.1 capped imp=2→1 for low-signal lesson via line 686. v2.56.0 #1
    // adds the paired DROP gate so this combo (change + low-signal lesson +
    // capped imp=1) lands NOWHERE — the same noise band, just a stricter exit.
    callLLM.mockReturnValue(JSON.stringify({
      type: 'change',
      title: `Routine change ${tag}`,
      narrative: 'Nothing notable',
      concepts: [],
      facts: [],
      importance: 2, // Haiku over-rates; capped to 1 by line 686, then dropped by gate
      lesson_learned: lessonText,
      search_aliases: [],
    }));

    const db = createTestDb();
    db._realClose = db.close;
    db.close = () => {};
    openDb.mockReturnValue(db);

    const sessId = `low-sig-${tag}`;
    const tmpFile2 = join(tmpdir(), `hook-llm-lowsig-${tag}-${Date.now()}.json`);
    filesToCleanup.push(tmpFile2);
    const origArgv3 = process.argv[3];
    process.argv[3] = tmpFile2;
    process.env.CLAUDE_MEM_NO_DELAY = '1';

    writeFileSync(tmpFile2, JSON.stringify({
      sessionId: sessId, project: 'test-proj',
      files: [`file-${tag}.mjs`], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Edit', isError: false }],
    }));

    await handleLLMEpisode();

    const obs = db.prepare('SELECT lesson_learned, importance FROM observations WHERE memory_session_id = ?').get(sessId);
    expect(obs).toBeUndefined(); // v2.56.0: dropped, not saved

    process.argv[3] = origArgv3;
    db._realClose();
  });

  it('v2.33.1: real lesson text passes through and importance is preserved', async () => {
    // Using type='change' (observations path, not events) since we assert on
    // the observations table. A real lesson means isLessonLowSignal=false,
    // so the v2.33.1 downgrade does not apply regardless of type.
    callLLM.mockReturnValue(JSON.stringify({
      type: 'change',
      title: 'Refactored token refresh',
      narrative: 'Extracted per-user lock to prevent 401 under concurrent refresh',
      concepts: [],
      facts: [],
      importance: 2,
      lesson_learned: 'Refresh locks must be per-user, not per-process — mutex collision under load.',
      search_aliases: [],
    }));

    const db = createTestDb();
    db._realClose = db.close;
    db.close = () => {};
    openDb.mockReturnValue(db);

    const tmpFile2 = join(tmpdir(), `hook-llm-reallesson-${Date.now()}.json`);
    filesToCleanup.push(tmpFile2);
    const origArgv3 = process.argv[3];
    process.argv[3] = tmpFile2;
    process.env.CLAUDE_MEM_NO_DELAY = '1';

    writeFileSync(tmpFile2, JSON.stringify({
      sessionId: 'real-lesson-sess', project: 'test-proj',
      files: ['auth.mjs'], filesRead: [],
      entries: [{ tool: 'Edit', desc: 'Refactor refresh', isError: false }],
    }));

    await handleLLMEpisode();

    const obs = db.prepare('SELECT lesson_learned, importance FROM observations WHERE memory_session_id = ?').get('real-lesson-sess');
    expect(obs).toBeDefined();
    expect(obs.lesson_learned).toContain('per-user');
    expect(obs.importance).toBe(2); // preserved — not downgraded

    process.argv[3] = origArgv3;
    db._realClose();
  });
});

// ─── buildDegradedTitle ─────────────────────────────────────────────────────

// ─── persistHaikuSummary (T9 routing) ───────────────────────────────────────
// T9: routes event-typed summaries to the `events` table, keeps non-event
// types on the legacy observations path so memdir semantics stay intact.

describe('persistHaikuSummary (T9 routing)', () => {
  let db;

  beforeEach(() => {
    db = createTestDb();
    insertSession(db, { id: 'sess-1', project: 'mem' });
  });

  afterEach(() => {
    db.close();
  });

  it('bugfix-type summary writes to events, not observations', () => {
    persistHaikuSummary(db, {
      type: 'bugfix',
      title: 'fixed null deref in foo',
      lesson_learned: 'always nullcheck bar() before deref',
      importance: 2,
      files_modified: ['foo.mjs'],
    }, { project: 'mem', session_id: 'sess-1' });

    expect(db.prepare(`SELECT COUNT(*) c FROM events WHERE event_type='bugfix'`).get().c).toBe(1);
    expect(db.prepare(`SELECT COUNT(*) c FROM observations WHERE type='bugfix'`).get().c).toBe(0);

    const ev = db.prepare(`SELECT * FROM events WHERE event_type='bugfix'`).get();
    expect(ev.title).toBe('fixed null deref in foo');
    expect(ev.body).toBe('always nullcheck bar() before deref');
    expect(JSON.parse(ev.file_paths)).toEqual(['foo.mjs']);
    expect(ev.importance).toBe(2);
  });

  it('change-type summary still writes to observations (legacy path)', () => {
    // `change` is not in EVENT_TYPES and is the actual non-event type Haiku
    // emits for routine file edits — it must stay on the legacy observations
    // path so existing consumers (session-summary, compression, FTS5) keep working.
    persistHaikuSummary(db, {
      type: 'change',
      title: 'refactored config loading',
      narrative: 'moved env parsing into loader',
      importance: 1,
      files_modified: ['config.mjs'],
    }, { project: 'mem', session_id: 'sess-1' });

    expect(db.prepare(`SELECT COUNT(*) c FROM observations WHERE type='change'`).get().c).toBe(1);
    expect(db.prepare(`SELECT COUNT(*) c FROM events`).get().c).toBe(0);
  });

  it('discovery / decision / observation / refactor / feature / lesson / bug all route to events', () => {
    const eventTypes = ['discovery', 'decision', 'observation', 'refactor', 'feature', 'lesson', 'bug'];
    for (const type of eventTypes) {
      persistHaikuSummary(db, {
        type, title: `a ${type}`, importance: 1, files_modified: [],
      }, { project: 'mem', session_id: 'sess-1' });
    }
    expect(db.prepare(`SELECT COUNT(*) c FROM events`).get().c).toBe(eventTypes.length);
    expect(db.prepare(`SELECT COUNT(*) c FROM observations`).get().c).toBe(0);
  });

  it('falls back to narrative for event body when lesson_learned absent', () => {
    persistHaikuSummary(db, {
      type: 'discovery',
      title: 'noticed cache invalidation gap',
      narrative: 'cache misses after rotation',
      importance: 2,
      files_modified: [],
    }, { project: 'mem', session_id: 'sess-1' });

    const ev = db.prepare(`SELECT * FROM events WHERE event_type='discovery'`).get();
    expect(ev.body).toBe('cache misses after rotation');
  });

  it('leaves file_paths NULL when files_modified is empty or missing', () => {
    persistHaikuSummary(db, {
      type: 'decision', title: 'picked vitest over jest', importance: 2,
    }, { project: 'mem', session_id: 'sess-1' });

    const ev = db.prepare(`SELECT file_paths FROM events WHERE event_type='decision'`).get();
    expect(ev.file_paths).toBeNull();
  });
});

// ─── buildDegradedTitle ─────────────────────────────────────────────────────

describe('buildDegradedTitle', () => {
  it('strips tab characters and CI status from error hints', () => {
    const episode = {
      files: ['plugin.json'],
      entries: [{
        tool: 'Bash',
        desc: 'gh run list → ERROR: in_progress\t\tchore(release): bump version',
        isError: true,
      }],
    };
    const title = buildDegradedTitle(episode);
    expect(title).not.toMatch(/\t/);
    expect(title).not.toMatch(/in_progress/);
    expect(title).toMatch(/^Error: plugin\.json/);
  });

  it('strips tabs from no-file fallback desc', () => {
    const episode = {
      files: [],
      entries: [{
        tool: 'Bash',
        desc: 'check\tstatus\there',
        isError: false,
      }],
    };
    const title = buildDegradedTitle(episode);
    expect(title).not.toMatch(/\t/);
    expect(title).toBe('check status here');
  });
});
