// Tests for lib/cite-back-hint.mjs — pure builder for the PostToolUse cite-back
// nudge that fires when an episode edits a file that PreToolUse:Read/Edit had
// nudged earlier in the same session.
//
// Behavior contract:
//   - Input: episode (entries with tool/files), session cooldown object
//   - Output: hint string ready to push into the flushEpisode `lines` array,
//     or null when no cite-back signal exists.
//   - Cooldown schema (post-v2.81): { "<path>": { ts: <number>, lessonIds: [#NN, ...] } }
//   - Legacy schema (pre-v2.81):    { "<path>": <number> } — must be tolerated, never emit.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { buildCiteBackHint, loadCiteBackForEpisode, extractCiteBackSignals, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge, nextCiteLowStreak, CITE_NUDGE_SILENCE_AFTER } from '../lib/cite-back-hint.mjs';

const editEntry = (file, tool = 'Edit') => ({ tool, files: [file], isError: false });
const readEntry = (file) => ({ tool: 'Read', files: [file], isError: false });
const bashErr = () => ({ tool: 'Bash', files: [], isError: true });
const bashOk = () => ({ tool: 'Bash', files: [], isError: false });
// v3.23 isHardError split — a SOFT error is output that merely mentions "error"
// (search results, green test logs) with no failure fingerprint; a HARD error is a
// real crash/exception. The bugfix-shape nudge must fire only on hard errors.
const bashSoftErr = () => ({ tool: 'Bash', files: [], isError: true, isHardError: false });
const bashHardErr = () => ({ tool: 'Bash', files: [], isError: true, isHardError: true });

describe('buildCiteBackHint', () => {
  it('returns a hint when an edited file has prior lessons in cooldown', () => {
    const episode = { entries: [editEntry('/p/src/foo.mjs')] };
    const cooldown = {
      '/p/src/foo.mjs': { ts: Date.now(), lessonIds: [8447] },
    };
    const hint = buildCiteBackHint(episode, cooldown);
    expect(hint).not.toBeNull();
    expect(hint).toContain('foo.mjs');
    expect(hint).toContain('#8447');
    expect(hint).toContain('/lesson --file');
  });

  // B1 (v2.83): leader line carries explicit counts ("N file(s), M lesson(s)")
  // so the agent sees a quantified signal rather than a vague nudge. §10
  // Specificity binds: hedged hint text ("if you fixed it") is easier to
  // dismiss than a numeric framing.
  it('leader line cites the file count and total lesson count', () => {
    const cooldown = {
      '/p/foo.mjs': { ts: Date.now(), lessonIds: [101, 102] },
      '/p/bar.mjs': { ts: Date.now(), lessonIds: [203] },
    };
    const episode = { entries: [editEntry('/p/foo.mjs'), editEntry('/p/bar.mjs')] };
    const hint = buildCiteBackHint(episode, cooldown);
    expect(hint).toMatch(/2 file\(s\)/);
    expect(hint).toMatch(/3 .*lesson/);
  });

  it('leader line uses a directive verb (Save now), not a hedge (if you)', () => {
    const cooldown = { '/p/foo.mjs': { ts: Date.now(), lessonIds: [1] } };
    const hint = buildCiteBackHint({ entries: [editEntry('/p/foo.mjs')] }, cooldown);
    expect(hint).toMatch(/Save now/i);
    expect(hint).not.toMatch(/if you fixed it/i);
  });

  it('returns null when no edited file is present in cooldown', () => {
    const episode = { entries: [editEntry('/p/src/bar.mjs')] };
    const cooldown = { '/p/src/foo.mjs': { ts: Date.now(), lessonIds: [8447] } };
    expect(buildCiteBackHint(episode, cooldown)).toBeNull();
  });

  it('returns null on legacy number-only cooldown entries (no lessonIds)', () => {
    const episode = { entries: [editEntry('/p/src/foo.mjs')] };
    const cooldown = { '/p/src/foo.mjs': Date.now() };
    expect(buildCiteBackHint(episode, cooldown)).toBeNull();
  });

  it('returns null when lessonIds is empty (empty-pre-recall case)', () => {
    const episode = { entries: [editEntry('/p/src/foo.mjs')] };
    const cooldown = { '/p/src/foo.mjs': { ts: Date.now(), lessonIds: [] } };
    expect(buildCiteBackHint(episode, cooldown)).toBeNull();
  });

  it('returns null when the file was only Read, never Edited', () => {
    const episode = { entries: [readEntry('/p/src/foo.mjs')] };
    const cooldown = { '/p/src/foo.mjs': { ts: Date.now(), lessonIds: [8447] } };
    expect(buildCiteBackHint(episode, cooldown)).toBeNull();
  });

  it('lists every lesson id when a file has multiple', () => {
    const episode = { entries: [editEntry('/p/src/foo.mjs')] };
    const cooldown = {
      '/p/src/foo.mjs': { ts: Date.now(), lessonIds: [8447, 8256] },
    };
    const hint = buildCiteBackHint(episode, cooldown);
    expect(hint).toContain('#8447');
    expect(hint).toContain('#8256');
  });

  it('caps at 2 matched files and drops the rest silently', () => {
    const episode = {
      entries: [
        editEntry('/p/a.mjs'),
        editEntry('/p/b.mjs'),
        editEntry('/p/c.mjs'),
      ],
    };
    const cooldown = {
      '/p/a.mjs': { ts: Date.now(), lessonIds: [1] },
      '/p/b.mjs': { ts: Date.now(), lessonIds: [2] },
      '/p/c.mjs': { ts: Date.now(), lessonIds: [3] },
    };
    const hint = buildCiteBackHint(episode, cooldown);
    expect(hint).toContain('a.mjs');
    expect(hint).toContain('b.mjs');
    expect(hint).not.toContain('c.mjs');
  });

  it('returns null on empty episode', () => {
    expect(buildCiteBackHint({ entries: [] }, {})).toBeNull();
  });

  it('returns null on empty cooldown', () => {
    expect(buildCiteBackHint({ entries: [editEntry('/p/foo.mjs')] }, {})).toBeNull();
  });

  it('accepts NotebookEdit as an edit tool', () => {
    const episode = { entries: [editEntry('/p/n.ipynb', 'NotebookEdit')] };
    const cooldown = { '/p/n.ipynb': { ts: Date.now(), lessonIds: [42] } };
    const hint = buildCiteBackHint(episode, cooldown);
    expect(hint).toContain('#42');
  });

  it('accepts Write as an edit tool', () => {
    const episode = { entries: [editEntry('/p/new.mjs', 'Write')] };
    const cooldown = { '/p/new.mjs': { ts: Date.now(), lessonIds: [99] } };
    expect(buildCiteBackHint(episode, cooldown)).toContain('#99');
  });

  it('dedupes the same file edited multiple times', () => {
    const episode = {
      entries: [editEntry('/p/foo.mjs'), editEntry('/p/foo.mjs')],
    };
    const cooldown = { '/p/foo.mjs': { ts: Date.now(), lessonIds: [7] } };
    const hint = buildCiteBackHint(episode, cooldown);
    // Dedup invariant: one bullet line per file even if the same file appears
    // in multiple entries. (Filename intentionally appears twice per line —
    // once in the bullet header, once in the `/lesson --file <name>` template.)
    const bulletLines = hint.split('\n').filter(l => l.trim().startsWith('•'));
    expect(bulletLines.length).toBe(1);
  });

  it('tolerates null/undefined inputs without throwing', () => {
    expect(buildCiteBackHint(null, {})).toBeNull();
    expect(buildCiteBackHint({ entries: [editEntry('/p/foo.mjs')] }, null)).toBeNull();
    expect(buildCiteBackHint(null, null)).toBeNull();
  });
});

// ─── buildUnsavedBugfixHint (B1 v2.83) ──────────────────────────────────────
// Lifts the error→fix episode nudge out of hook.mjs:194 into a pure builder
// so the lib has one home for save-prompt hints, and the message gets the
// same "Save now" + count treatment as the cite-back text.

describe('buildUnsavedBugfixHint', () => {
  it('fires when episode has error + edit + ≥3 entries', () => {
    const episode = {
      entries: [editEntry('/p/foo.mjs'), bashErr(), editEntry('/p/foo.mjs'), bashOk()],
    };
    const hint = buildUnsavedBugfixHint(episode);
    expect(hint).not.toBeNull();
    expect(hint).toContain('foo.mjs');
    expect(hint).toContain('/lesson --file');
    // Directive verb, not advisory hedge
    expect(hint).toMatch(/Save now/i);
    expect(hint).not.toMatch(/consider:/i);
  });

  it('carries the unique edited-file count', () => {
    const episode = {
      entries: [
        editEntry('/p/a.mjs'), bashErr(),
        editEntry('/p/b.mjs'), bashOk(),
      ],
    };
    const hint = buildUnsavedBugfixHint(episode);
    expect(hint).toMatch(/2 file\(s\)/);
    expect(hint).toContain('a.mjs');
    expect(hint).toContain('b.mjs');
  });

  it('returns null on no-error episodes', () => {
    const episode = { entries: [editEntry('/p/foo.mjs'), bashOk(), editEntry('/p/foo.mjs')] };
    expect(buildUnsavedBugfixHint(episode)).toBeNull();
  });

  it('returns null on no-edit episodes', () => {
    const episode = { entries: [bashOk(), bashErr(), bashOk()] };
    expect(buildUnsavedBugfixHint(episode)).toBeNull();
  });

  it('returns null when entry count is below threshold', () => {
    const episode = { entries: [editEntry('/p/foo.mjs'), bashErr()] };
    expect(buildUnsavedBugfixHint(episode)).toBeNull();
  });

  it('returns null on empty / null inputs', () => {
    expect(buildUnsavedBugfixHint(null)).toBeNull();
    expect(buildUnsavedBugfixHint({})).toBeNull();
    expect(buildUnsavedBugfixHint({ entries: [] })).toBeNull();
    expect(buildUnsavedBugfixHint({ entries: null })).toBeNull();
  });

  it('caps the displayed file list at 3 names', () => {
    const episode = {
      entries: [
        editEntry('/p/a.mjs'), editEntry('/p/b.mjs'),
        editEntry('/p/c.mjs'), editEntry('/p/d.mjs'),
        bashErr(),
      ],
    };
    const hint = buildUnsavedBugfixHint(episode);
    expect(hint).toContain('a.mjs');
    expect(hint).toContain('b.mjs');
    expect(hint).toContain('c.mjs');
    expect(hint).not.toContain('d.mjs');
    expect(hint).toMatch(/4 file\(s\)/);
  });

  // v3.23: gate the nudge on isHardError, not isError. The audit caught it firing on a
  // read-only session (greps + `node cli.mjs search "error"`) plus a scratch write —
  // output that mentions "error" but is not a fix.
  it('does NOT fire when the only error is soft — isHardError=false', () => {
    const episode = {
      entries: [editEntry('/p/foo.mjs'), bashSoftErr(), editEntry('/p/foo.mjs'), bashSoftErr()],
    };
    expect(buildUnsavedBugfixHint(episode)).toBeNull();
  });

  it('fires when a hard failure fingerprint is present — isHardError=true', () => {
    const episode = {
      entries: [editEntry('/p/foo.mjs'), bashHardErr(), bashOk(), editEntry('/p/foo.mjs')],
    };
    const hint = buildUnsavedBugfixHint(episode);
    expect(hint).not.toBeNull();
    expect(hint).toContain('Unsaved bugfix-shape');
  });

  it('falls back to isError for legacy entries lacking the isHardError field', () => {
    // bashErr() has no isHardError field (undefined) → fallback to isError so episodes
    // captured before the field existed still nudge.
    const episode = {
      entries: [editEntry('/p/foo.mjs'), bashErr(), editEntry('/p/foo.mjs'), bashOk()],
    };
    expect(buildUnsavedBugfixHint(episode)).not.toBeNull();
  });
});

// ─── countUnsavedBugfixShape (B2 v2.83.1) ───────────────────────────────────
// Scans a transcript.jsonl and returns {nudged, saved, unsaved} where:
//   • nudged = count of attachments whose stdout contains the
//     `[mem] ⚠ Unsaved bugfix-shape` literal (one per fired hint)
//   • saved  = count of lesson/bugfix-save signals in the same window
//     (Bash `activity save --type lesson|bugfix`, OR mem_save tool_use with
//     type in {bugfix, lesson})
//   • unsaved = max(0, nudged - saved) — the headline number SessionStart
//     surfaces to make the gap socially visible.
describe('countUnsavedBugfixShape', () => {
  let tmp;
  beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'unsaved-bugfix-')); });
  afterEach(() => { try { rmSync(tmp, { recursive: true, force: true }); } catch {} });

  function writeTranscript(entries) {
    const path = join(tmp, 't.jsonl');
    writeFileSync(path, entries.map(e => JSON.stringify(e)).join('\n'));
    return path;
  }

  function bugfixShapeAttachment(fileList = 'foo.mjs') {
    return {
      type: 'attachment',
      attachment: {
        type: 'hook_success',
        hookName: 'PostToolUse',
        command: 'hook.mjs post-tool-use',
        stdout: JSON.stringify({
          suppressOutput: true,
          hookSpecificOutput: {
            hookEventName: 'PostToolUse',
            additionalContext: `[mem] episode flushed: 5 entries\n[mem] ⚠ Unsaved bugfix-shape: error+edit across 1 file(s) in 5 entries (${fileList}). Save now if it was a real fix: /lesson --file ${fileList} "<root cause + fix>"`,
          },
        }),
        stderr: '',
        exitCode: 0,
      },
    };
  }

  function memSaveToolUse(type) {
    return {
      type: 'assistant',
      message: {
        content: [
          { type: 'tool_use', name: 'mcp__claude_mem_lite__mem_save', input: { type, title: 't', lesson_learned: 'x' } },
        ],
      },
    };
  }

  function bashLessonSave() {
    return {
      type: 'assistant',
      message: {
        content: [
          { type: 'tool_use', name: 'Bash', input: { command: 'claude-mem-lite activity save --type lesson --title "fix" --body "<root cause>"' } },
        ],
      },
    };
  }

  it('returns zeros on missing/empty transcript', () => {
    expect(countUnsavedBugfixShape(null)).toEqual({ nudged: 0, saved: 0, unsaved: 0 });
    expect(countUnsavedBugfixShape('/nope.jsonl')).toEqual({ nudged: 0, saved: 0, unsaved: 0 });
    expect(countUnsavedBugfixShape(writeTranscript([]))).toEqual({ nudged: 0, saved: 0, unsaved: 0 });
  });

  it('counts each Unsaved-bugfix-shape attachment once', () => {
    const path = writeTranscript([
      bugfixShapeAttachment('a.mjs'),
      bugfixShapeAttachment('b.mjs'),
      bugfixShapeAttachment('c.mjs'),
    ]);
    const r = countUnsavedBugfixShape(path);
    expect(r.nudged).toBe(3);
    expect(r.saved).toBe(0);
    expect(r.unsaved).toBe(3);
  });

  it('credits mem_save tool_use with type=bugfix as a save', () => {
    const path = writeTranscript([
      bugfixShapeAttachment(),
      memSaveToolUse('bugfix'),
    ]);
    const r = countUnsavedBugfixShape(path);
    expect(r.nudged).toBe(1);
    expect(r.saved).toBe(1);
    expect(r.unsaved).toBe(0);
  });

  // P2(a): /bug and /lesson now write searchable observations via `cli.mjs save …
  // --lesson` (was `activity save --type lesson|bugfix`). The saved-signal counter
  // must recognize the redirected command or it over-fires the unsaved nudge.
  it('credits the redirected /bug /lesson save (cli.mjs save … --lesson) as a save', () => {
    const bashObsInsightSave = {
      type: 'assistant',
      message: { content: [
        { type: 'tool_use', name: 'Bash', input: { command: 'node /root/.claude/plugins/cache/x/cli.mjs save "fixed the pool deadlock" --type bugfix --title "deadlock" --lesson "reorder acquisition"' } },
      ] },
    };
    const path = writeTranscript([bugfixShapeAttachment(), bashObsInsightSave]);
    const r = countUnsavedBugfixShape(path);
    expect(r.saved).toBe(1);
    expect(r.unsaved).toBe(0);
  });

  // The /bug /lesson skill templates document the save as a MULTI-LINE,
  // backslash-continued command (save on line 1, --lesson several lines down).
  // After JSON.parse the command carries real newlines, so a `[^\n]*` gap between
  // `save` and `--lesson` cannot span them → the save goes uncredited and the
  // unsaved-bugfix nudge over-fires. The recognizer must tolerate newlines.
  it('credits the redirected save even when the command is multi-line (template shape)', () => {
    const bashMultiLineSave = {
      type: 'assistant',
      message: { content: [
        { type: 'tool_use', name: 'Bash', input: { command:
          'node /root/.claude/plugins/cache/x/cli.mjs save "fixed the pool deadlock" \\\n'
          + '  --type bugfix \\\n'
          + '  --title "deadlock" \\\n'
          + '  --lesson "reorder acquisition" \\\n'
          + '  --importance 2' } },
      ] },
    };
    const path = writeTranscript([bugfixShapeAttachment(), bashMultiLineSave]);
    const r = countUnsavedBugfixShape(path);
    expect(r.saved).toBe(1);
    expect(r.unsaved).toBe(0);
  });

  it('credits mem_save tool_use with type=lesson as a save', () => {
    const path = writeTranscript([
      bugfixShapeAttachment(),
      memSaveToolUse('lesson'),
    ]);
    expect(countUnsavedBugfixShape(path).saved).toBe(1);
  });

  it('does NOT credit mem_save with non-lesson/bugfix type', () => {
    const path = writeTranscript([
      bugfixShapeAttachment(),
      memSaveToolUse('change'),
    ]);
    expect(countUnsavedBugfixShape(path).saved).toBe(0);
  });

  it('credits Bash `activity save --type lesson` as a save', () => {
    const path = writeTranscript([
      bugfixShapeAttachment(),
      bashLessonSave(),
    ]);
    expect(countUnsavedBugfixShape(path).saved).toBe(1);
  });

  it('unsaved is clamped at 0 (more saves than nudges)', () => {
    const path = writeTranscript([
      bugfixShapeAttachment(),
      memSaveToolUse('bugfix'),
      memSaveToolUse('bugfix'),
      memSaveToolUse('lesson'),
    ]);
    const r = countUnsavedBugfixShape(path);
    expect(r.nudged).toBe(1);
    expect(r.saved).toBe(3);
    expect(r.unsaved).toBe(0);
  });

  it('ignores attachments that lack the Unsaved-bugfix-shape literal', () => {
    const path = writeTranscript([
      {
        type: 'attachment',
        attachment: {
          type: 'hook_success',
          hookName: 'PostToolUse',
          command: 'hook.mjs post-tool-use',
          stdout: JSON.stringify({ hookSpecificOutput: { additionalContext: '[mem] episode flushed: 2 entries' } }),
          stderr: '', exitCode: 0,
        },
      },
    ]);
    expect(countUnsavedBugfixShape(path).nudged).toBe(0);
  });
});

// ─── buildCiteRecallNudge (B2, v2.83.1) ─────────────────────────────────────
// SessionStart surface. Reads `runtime/cite-recall-<project>.json` written by
// handleStop. Two independent gates: cite-recall ratio (default <0.6, min 5
// injected) and unsaved-bugfix-shape count (>0). Empty string when both pass.

describe('buildCiteRecallNudge', () => {
  let tmp;
  beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'cite-nudge-')); });
  afterEach(() => { try { rmSync(tmp, { recursive: true, force: true }); } catch {} });

  function seed(project, data) {
    const safe = project.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
    writeFileSync(join(tmp, `cite-recall-${safe}.json`), JSON.stringify(data));
  }

  it('returns empty string when no prior cite-recall file exists', () => {
    expect(buildCiteRecallNudge('nope', tmp, {})).toBe('');
  });

  it('surfaces ratio nudge when recall < 0.6 and injected >= 5', () => {
    seed('p1', { injected: 10, recalled: 4, ratio: 0.4 });
    const out = buildCiteRecallNudge('p1', tmp, {});
    expect(out).toContain('cite-recall 40%');
    expect(out).toContain('(4/10)');
  });

  it('suppresses ratio nudge when recall >= 0.6', () => {
    seed('p2', { injected: 10, recalled: 7, ratio: 0.7 });
    expect(buildCiteRecallNudge('p2', tmp, {})).toBe('');
  });

  it('suppresses ratio nudge when injected < min-floor', () => {
    seed('p3', { injected: 3, recalled: 0, ratio: 0 });
    expect(buildCiteRecallNudge('p3', tmp, {})).toBe('');
  });

  it('surfaces unsaved-bugfix line independently when unsaved > 0', () => {
    seed('p4', { injected: 0, recalled: 0, ratio: 0, unsaved: 2 });
    const out = buildCiteRecallNudge('p4', tmp, {});
    expect(out).toContain('2 unsaved bugfix-shape');
    expect(out).toContain('/lesson --file');
    // Ratio surface skipped — injected = 0
    expect(out).not.toContain('cite-recall');
  });

  it('surfaces BOTH lines when both gates fire', () => {
    seed('p5', { injected: 10, recalled: 2, ratio: 0.2, unsaved: 1 });
    const out = buildCiteRecallNudge('p5', tmp, {});
    expect(out).toContain('cite-recall 20%');
    expect(out).toContain('1 unsaved bugfix-shape');
    // Two distinct lines
    expect(out.split('\n').length).toBe(2);
  });

  it('respects CLAUDE_MEM_NO_CITE_NUDGE=1 (full silence)', () => {
    seed('p6', { injected: 10, recalled: 0, ratio: 0, unsaved: 5 });
    expect(buildCiteRecallNudge('p6', tmp, { CLAUDE_MEM_NO_CITE_NUDGE: '1' })).toBe('');
  });

  it('treats unsaved=0 as no nudge', () => {
    seed('p7', { injected: 0, recalled: 0, ratio: 0, unsaved: 0 });
    expect(buildCiteRecallNudge('p7', tmp, {})).toBe('');
  });

  it('treats missing `unsaved` field as no nudge (back-compat with pre-v2.83.1 files)', () => {
    seed('p8', { injected: 10, recalled: 8, ratio: 0.8 }); // no unsaved key
    expect(buildCiteRecallNudge('p8', tmp, {})).toBe('');
  });

  it('honors env override thresholds', () => {
    seed('p9', { injected: 4, recalled: 3, ratio: 0.75 });
    // Default: injected < 5 → silent. Override min-injected to 3 → fires.
    expect(buildCiteRecallNudge('p9', tmp, {})).toBe('');
    const out = buildCiteRecallNudge('p9', tmp, {
      CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED: '3',
      CLAUDE_MEM_CITE_NUDGE_THRESHOLD: '0.8',
    });
    expect(out).toContain('cite-recall 75%');
  });

  it('sanitizes project name into safe filename', () => {
    seed('weird/proj:name@chars', { injected: 0, recalled: 0, ratio: 0, unsaved: 1 });
    const out = buildCiteRecallNudge('weird/proj:name@chars', tmp, {});
    expect(out).toContain('1 unsaved bugfix-shape');
  });

  it('self-silences the ratio nag after lowStreak reaches the threshold', () => {
    // Same low-cite stats that WOULD fire, but the project has ignored it
    // CITE_NUDGE_SILENCE_AFTER times running → suppress the ratio line.
    seed('p-silence', { injected: 10, recalled: 0, ratio: 0, lowStreak: CITE_NUDGE_SILENCE_AFTER });
    expect(buildCiteRecallNudge('p-silence', tmp, {})).toBe('');
  });

  it('still nags while lowStreak is below the threshold', () => {
    seed('p-loud', { injected: 10, recalled: 0, ratio: 0, lowStreak: CITE_NUDGE_SILENCE_AFTER - 1 });
    expect(buildCiteRecallNudge('p-loud', tmp, {})).toContain('cite-recall 0%');
  });

  it('still surfaces unsaved-bugfix line even when the ratio nag is silenced', () => {
    seed('p-mix', { injected: 10, recalled: 0, ratio: 0, lowStreak: CITE_NUDGE_SILENCE_AFTER, unsaved: 2 });
    const out = buildCiteRecallNudge('p-mix', tmp, {});
    expect(out).not.toContain('cite-recall');
    expect(out).toContain('2 unsaved bugfix-shape');
  });

  it('CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER=0 never silences', () => {
    seed('p-never', { injected: 10, recalled: 0, ratio: 0, lowStreak: 99 });
    expect(buildCiteRecallNudge('p-never', tmp, { CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER: '0' })).toContain('cite-recall 0%');
  });
});

describe('nextCiteLowStreak', () => {
  it('increments when the ratio gate fires (low recall, enough volume)', () => {
    expect(nextCiteLowStreak(2, { injected: 10, ratio: 0.2 })).toBe(3);
  });

  it('resets to 0 when cite-recall recovers above threshold', () => {
    expect(nextCiteLowStreak(5, { injected: 10, ratio: 0.8 })).toBe(0);
  });

  it('resets to 0 when injection volume is below the floor (no signal)', () => {
    expect(nextCiteLowStreak(5, { injected: 2, ratio: 0 })).toBe(0);
  });

  it('treats a non-numeric prior streak as 0', () => {
    expect(nextCiteLowStreak(undefined, { injected: 10, ratio: 0 })).toBe(1);
  });
});

// ─── loadCiteBackForEpisode ─────────────────────────────────────────────────
// Bridges the pure hint builder to the on-disk cooldown file that
// scripts/pre-tool-recall.js writes. Path scheme must match pre-tool-recall's
// cooldownPathFor() — drift here would silently zero cite-back across the
// release. These tests pin the contract.

describe('loadCiteBackForEpisode', () => {
  let runtimeDir;

  beforeEach(() => {
    runtimeDir = mkdtempSync(join(tmpdir(), 'cite-back-runtime-'));
  });

  afterEach(() => {
    try { rmSync(runtimeDir, { recursive: true, force: true }); } catch {}
  });

  function seedCooldown(sessionId, data) {
    const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
    const cooldownPath = join(runtimeDir, `pre-recall-cooldown-${safe}.json`);
    writeFileSync(cooldownPath, JSON.stringify(data));
  }

  it('returns hint when cooldown file exists and matches edited file', () => {
    seedCooldown('sess-1', { '/p/foo.mjs': { ts: Date.now(), lessonIds: [8447] } });
    const episode = {
      sessionId: 'sess-1',
      entries: [{ tool: 'Edit', files: ['/p/foo.mjs'], isError: false }],
    };
    const hint = loadCiteBackForEpisode(episode, runtimeDir);
    expect(hint).toContain('#8447');
    expect(hint).toContain('foo.mjs');
  });

  it('returns null when cooldown file does not exist', () => {
    const episode = {
      sessionId: 'sess-missing',
      entries: [{ tool: 'Edit', files: ['/p/foo.mjs'], isError: false }],
    };
    expect(loadCiteBackForEpisode(episode, runtimeDir)).toBeNull();
  });

  it('returns null when episode has no sessionId', () => {
    seedCooldown('sess-x', { '/p/foo.mjs': { ts: Date.now(), lessonIds: [8447] } });
    const episode = {
      entries: [{ tool: 'Edit', files: ['/p/foo.mjs'], isError: false }],
    };
    expect(loadCiteBackForEpisode(episode, runtimeDir)).toBeNull();
  });

  it('returns null when cooldown JSON is malformed', () => {
    const safe = 'sess-bad';
    writeFileSync(join(runtimeDir, `pre-recall-cooldown-${safe}.json`), '{not json');
    const episode = {
      sessionId: safe,
      entries: [{ tool: 'Edit', files: ['/p/foo.mjs'], isError: false }],
    };
    expect(loadCiteBackForEpisode(episode, runtimeDir)).toBeNull();
  });

  it('sanitizes the sessionId the same way pre-tool-recall.js does', () => {
    // pre-tool-recall.js: replace non-[A-Za-z0-9_.-] with `-`, slice(0,64)
    // Verifies the path scheme stays in lockstep across the two files.
    const rawSessionId = 'sess/with:weird@chars';
    const safe = rawSessionId.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
    writeFileSync(
      join(runtimeDir, `pre-recall-cooldown-${safe}.json`),
      JSON.stringify({ '/p/foo.mjs': { ts: Date.now(), lessonIds: [42] } }),
    );
    const episode = {
      sessionId: rawSessionId,
      entries: [{ tool: 'Edit', files: ['/p/foo.mjs'], isError: false }],
    };
    expect(loadCiteBackForEpisode(episode, runtimeDir)).toContain('#42');
  });
});

describe('extractCiteBackSignals (P5 ① — Stop-time positive signal)', () => {
  let tmp;
  beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'citeback-sig-')); });
  afterEach(() => { try { rmSync(tmp, { recursive: true, force: true }); } catch {} });

  // Mirror how hook.mjs flushEpisode emits the hint: PostToolUse JSON wrapping
  // additionalContext, recorded in the transcript as a hook_success attachment.
  function citeBackAttachment(hintText) {
    return {
      type: 'attachment',
      attachment: {
        type: 'hook_success',
        hookName: 'PostToolUse',
        command: 'node /home/u/.claude-mem-lite/hook.mjs post-tool-use',
        stdout: JSON.stringify({ suppressOutput: true, hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: hintText } }),
        stderr: '', exitCode: 0,
      },
    };
  }

  function writeTranscript(entries) {
    const path = join(tmp, 't.jsonl');
    writeFileSync(path, entries.map(e => JSON.stringify(e)).join('\n'));
    return path;
  }

  it('extracts the #NN lesson ids from a real cite-back hint emission', () => {
    const hint = buildCiteBackHint(
      { entries: [{ tool: 'Edit', files: ['/p/src/foo.mjs'], isError: false }] },
      { '/p/src/foo.mjs': { ts: Date.now(), lessonIds: [8447, 9012] } }
    );
    const ids = extractCiteBackSignals(writeTranscript([citeBackAttachment(hint)]));
    expect(ids.has(8447)).toBe(true);
    expect(ids.has(9012)).toBe(true);
    expect(ids.size).toBe(2);
  });

  it('ignores attachments without the cite-back leader (e.g. plain mem context)', () => {
    const other = {
      type: 'attachment',
      attachment: { type: 'hook_success', hookName: 'PostToolUse', command: 'x', stdout: 'just #777 in some other output', stderr: '', exitCode: 0 },
    };
    expect(extractCiteBackSignals(writeTranscript([other])).size).toBe(0);
  });

  it('returns an empty set on missing / null transcript', () => {
    expect(extractCiteBackSignals('/no/such/file').size).toBe(0);
    expect(extractCiteBackSignals(null).size).toBe(0);
  });
});
