import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { writeFileSync, mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { extractInjectedFromPreToolUse, extractCitationsFromTranscript, applyCitationDecay, computeCitationAdoption } from '../lib/citation-tracker.mjs';
import { createTestDb, insertSession, insertObs } from './test-helpers.mjs';

describe('extractInjectedFromPreToolUse', () => {
  let tmp;
  beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'cite-decay-')); });
  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 preToolAttachment(injectedIdsWithTypes) {
    const lines = ['[mem] PreToolUse recall — system-injected context, continue your planned action:', '[mem] Lessons for foo.js:'];
    for (const { id, type, body } of injectedIdsWithTypes) {
      lines.push(`  #${id} [${type}] ${body || 'placeholder lesson body'}`);
    }
    const stdout = JSON.stringify({
      suppressOutput: true,
      hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: lines.join('\n') },
    });
    return {
      type: 'attachment',
      attachment: {
        type: 'hook_success',
        hookName: 'PreToolUse:Read',
        command: 'node /home/u/.claude-mem-lite/scripts/pre-tool-recall.js',
        stdout,
        stderr: '',
        exitCode: 0,
      },
    };
  }

  it('extracts injected IDs from pre-tool-recall attachment stdout', () => {
    const path = writeTranscript([
      preToolAttachment([{ id: 42, type: 'bugfix' }, { id: 7556, type: 'decision' }]),
    ]);
    const ids = extractInjectedFromPreToolUse(path);
    expect(ids.has(42)).toBe(true);
    expect(ids.has(7556)).toBe(true);
    expect(ids.size).toBe(2);
  });

  it('ignores attachments from non-mem hooks', () => {
    const path = writeTranscript([
      {
        type: 'attachment',
        attachment: {
          type: 'hook_success',
          hookName: 'PreToolUse:Read',
          command: 'other-hook',
          stdout: 'mentions #99 but not from us',
          stderr: '',
          exitCode: 0,
        },
      },
    ]);
    const ids = extractInjectedFromPreToolUse(path);
    expect(ids.size).toBe(0);
  });

  it('ignores backfill-only "No prior lessons" lines (no #ID)', () => {
    const stdout = JSON.stringify({
      hookSpecificOutput: {
        hookEventName: 'PreToolUse',
        additionalContext: '[mem] No prior lessons for foo.js — if you solve a bug, run /lesson',
      },
    });
    const path = writeTranscript([
      { type: 'attachment', attachment: { type: 'hook_success', hookName: 'PreToolUse:Edit', command: 'pre-tool-recall.js', stdout, stderr: '', exitCode: 0 } },
    ]);
    const ids = extractInjectedFromPreToolUse(path);
    expect(ids.size).toBe(0);
  });

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

  it('returns empty set when transcriptPath is null/undefined', () => {
    expect(extractInjectedFromPreToolUse(null).size).toBe(0);
    expect(extractInjectedFromPreToolUse(undefined).size).toBe(0);
  });
});

describe('extractCitationsFromTranscript — mainOnly option', () => {
  let tmp;
  beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'cite-side-')); });
  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;
  }

  it('default behavior unchanged: includes sidechain (existing callers)', () => {
    const path = writeTranscript([
      { type: 'assistant', isSidechain: true,  message: { content: [{ type: 'text', text: 'sub-agent saw #100' }] } },
      { type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: 'main cited #200' }] } },
    ]);
    const ids = extractCitationsFromTranscript(path);
    expect(ids.has(100)).toBe(true);
    expect(ids.has(200)).toBe(true);
  });

  it('with {mainOnly:true}: drops sidechain text', () => {
    const path = writeTranscript([
      { type: 'assistant', isSidechain: true,  message: { content: [{ type: 'text', text: 'sub-agent saw #100' }] } },
      { type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: 'main cited #200' }] } },
    ]);
    const ids = extractCitationsFromTranscript(path, { mainOnly: true });
    expect(ids.has(100)).toBe(false);
    expect(ids.has(200)).toBe(true);
    expect(ids.size).toBe(1);
  });

  it('with {mainOnly:true}: treats missing isSidechain as main thread', () => {
    const path = writeTranscript([
      { type: 'assistant', message: { content: [{ type: 'text', text: 'no isSidechain field → assume main, count #300' }] } },
    ]);
    const ids = extractCitationsFromTranscript(path, { mainOnly: true });
    expect(ids.has(300)).toBe(true);
  });
});

describe('applyCitationDecay', () => {
  let db;
  beforeEach(() => {
    db = createTestDb();
    insertSession(db, { id: 'sess-1', project: 'p' });
  });
  afterEach(() => { try { db.close(); } catch {} });

  function makeObs(overrides = {}) {
    const id = insertObs(db, {
      sessionId: 'sess-1',
      project: 'p',
      type: 'bugfix',
      title: 't',
      importance: 2,
      ...overrides,
    }).lastInsertRowid;
    // Post-INSERT updates for citation-decay columns (not in insertObs yet)
    if (overrides.uncited_streak !== undefined || overrides.cited_count !== undefined || overrides.last_decided_session_id !== undefined) {
      db.prepare(`
        UPDATE observations
        SET uncited_streak = ?, cited_count = ?, last_decided_session_id = ?
        WHERE id = ?
      `).run(
        overrides.uncited_streak ?? 0,
        overrides.cited_count ?? 0,
        overrides.last_decided_session_id ?? null,
        id
      );
    }
    return id;
  }

  it('cited obs gets +1 importance and cited_count += 1, streak reset to 0', () => {
    const id = makeObs({ importance: 2, uncited_streak: 1, cited_count: 0 });
    applyCitationDecay(db, 'p', new Set([id]), new Set([id]), 'sess-1');
    const row = db.prepare('SELECT importance, cited_count, uncited_streak, last_decided_session_id, decay_seen_count FROM observations WHERE id=?').get(id);
    expect(row.importance).toBe(3);
    expect(row.cited_count).toBe(1);
    expect(row.uncited_streak).toBe(0);
    expect(row.last_decided_session_id).toBe('sess-1');
    // v34: every resolution branch bumps decay_seen_count
    expect(row.decay_seen_count).toBe(1);
  });

  it('decay_seen_count bumps on all 3 branches (promote, streak-only, demote)', () => {
    const p = makeObs({ importance: 2 }); // promote path
    const s = makeObs({ importance: 2, uncited_streak: 0 }); // streak-only path
    const d = makeObs({ importance: 2, uncited_streak: 2 }); // demote path
    applyCitationDecay(db, 'p', new Set([p, s, d]), new Set([p]), 'sess-1');
    const pSeen = db.prepare('SELECT decay_seen_count FROM observations WHERE id=?').get(p).decay_seen_count;
    const sSeen = db.prepare('SELECT decay_seen_count FROM observations WHERE id=?').get(s).decay_seen_count;
    const dSeen = db.prepare('SELECT decay_seen_count FROM observations WHERE id=?').get(d).decay_seen_count;
    expect(pSeen).toBe(1);
    expect(sSeen).toBe(1);
    expect(dSeen).toBe(1);
  });

  it('suppressed (non-adopting) project caps uncited_streak instead of growing unbounded', () => {
    // Force suppression: the project has decay history (seen>=8) but ~0 cite-rate,
    // so applyCitationDecay never demotes. Pre-fix the streak-only path grew the
    // streak without bound, sinking citeFactorClause to its 0.4 floor permanently.
    const noise = makeObs({ importance: 1 });
    db.prepare('UPDATE observations SET decay_seen_count = 20, cited_count = 0 WHERE id = ?').run(noise);
    const adoption = computeCitationAdoption(db, 'p');
    expect(adoption.seen).toBeGreaterThanOrEqual(8);
    expect(adoption.rate).toBeLessThan(0.02); // => suppressDemotion = true

    const id = makeObs({ importance: 2, uncited_streak: 0 });
    // Inject-but-never-cite across many distinct sessions (distinct sessionId bypasses
    // the idempotent skip). Streak must plateau at UNCITED_STREAK_THRESHOLD-1, not climb.
    for (let i = 1; i <= 6; i++) {
      applyCitationDecay(db, 'p', new Set([id]), new Set(), `s-${i}`);
    }
    const row = db.prepare('SELECT importance, uncited_streak FROM observations WHERE id=?').get(id);
    expect(row.uncited_streak).toBe(2); // capped (threshold-1), pre-fix would be 6
    expect(row.importance).toBe(2);     // never demoted under suppression
  });

  it('importance cap: cited at importance=3 stays at 3', () => {
    const id = makeObs({ importance: 3 });
    applyCitationDecay(db, 'p', new Set([id]), new Set([id]), 'sess-1');
    expect(db.prepare('SELECT importance FROM observations WHERE id=?').get(id).importance).toBe(3);
  });

  it('uncited streak increment without demotion when streak < 3', () => {
    const id = makeObs({ importance: 2, uncited_streak: 0 });
    applyCitationDecay(db, 'p', new Set([id]), new Set(), 'sess-1');
    const r = db.prepare('SELECT importance, uncited_streak FROM observations WHERE id=?').get(id);
    expect(r.importance).toBe(2);
    expect(r.uncited_streak).toBe(1);
  });

  it('uncited at streak=2 → demotion (importance -1) and streak reset to 0', () => {
    const id = makeObs({ importance: 2, uncited_streak: 2 });
    const before = Date.now();
    applyCitationDecay(db, 'p', new Set([id]), new Set(), 'sess-1');
    const r = db.prepare('SELECT importance, uncited_streak, demoted_at FROM observations WHERE id=?').get(id);
    expect(r.importance).toBe(1);
    expect(r.uncited_streak).toBe(0);
    // v33: demoted_at gets a real timestamp on the demote branch
    expect(r.demoted_at).toBeGreaterThanOrEqual(before);
    expect(r.demoted_at).toBeLessThanOrEqual(Date.now());
  });

  it('importance floor=1: demote never drops a row below the injection-visibility boundary', () => {
    // A row at imp=1, uncited past threshold, must NOT sink to 0. Both passive
    // injection surfaces exclude imp=0 (pre-tool-recall >=2, user-prompt-search >=1),
    // so a 0 is a one-way burial: never re-injected → never re-cited → never recovers.
    // Floor=1 keeps it on the >=1 surface with a citation-recovery path.
    const id = makeObs({ importance: 1, uncited_streak: 2 });
    applyCitationDecay(db, 'p', new Set([id]), new Set(), 'sess-1');
    const r = db.prepare('SELECT importance, uncited_streak FROM observations WHERE id=?').get(id);
    expect(r.importance).toBe(1);
    expect(r.uncited_streak).toBe(0);
  });

  it('legacy imp=0 row heals up to the floor on its next demote resolution', () => {
    // Rows buried at 0 by the pre-fix floor lift back to 1 if ever resolved again
    // (MAX(1, 0-1) = 1), so the floor change is self-healing for the injected path.
    const id = makeObs({ importance: 0, uncited_streak: 2 });
    applyCitationDecay(db, 'p', new Set([id]), new Set(), 'sess-1');
    expect(db.prepare('SELECT importance FROM observations WHERE id=?').get(id).importance).toBe(1);
  });

  it('partial cite: of injected {100, 200}, cited={100} → 100 promoted, 200 streak++', () => {
    const id100 = makeObs({ importance: 2, uncited_streak: 0 });
    const id200 = makeObs({ importance: 2, uncited_streak: 0 });
    applyCitationDecay(db, 'p', new Set([id100, id200]), new Set([id100]), 'sess-1');
    const a = db.prepare('SELECT importance, cited_count, uncited_streak FROM observations WHERE id=?').get(id100);
    const b = db.prepare('SELECT importance, cited_count, uncited_streak FROM observations WHERE id=?').get(id200);
    expect(a.importance).toBe(3);  expect(a.cited_count).toBe(1);  expect(a.uncited_streak).toBe(0);
    expect(b.importance).toBe(2);  expect(b.cited_count).toBe(0);  expect(b.uncited_streak).toBe(1);
  });

  it('idempotency: running twice for same session is a no-op the second time', () => {
    const id = makeObs({ importance: 2, uncited_streak: 0 });
    applyCitationDecay(db, 'p', new Set([id]), new Set(), 'sess-1');
    const after1 = db.prepare('SELECT importance, uncited_streak FROM observations WHERE id=?').get(id);
    applyCitationDecay(db, 'p', new Set([id]), new Set(), 'sess-1');
    const after2 = db.prepare('SELECT importance, uncited_streak FROM observations WHERE id=?').get(id);
    expect(after2.importance).toBe(after1.importance);
    expect(after2.uncited_streak).toBe(after1.uncited_streak);
  });

  it('cross-project IDs silently ignored (no rows touched)', () => {
    insertSession(db, { id: 'sess-2', project: 'other' });
    const id = insertObs(db, { sessionId: 'sess-2', project: 'other', type: 'bugfix', title: 't', importance: 2 }).lastInsertRowid;
    applyCitationDecay(db, 'p', new Set([id]), new Set(), 'sess-1');
    expect(db.prepare('SELECT importance, uncited_streak FROM observations WHERE id=?').get(id).importance).toBe(2);
  });

  it('returns summary { promoted, demoted, touched } for telemetry', () => {
    const a = makeObs({ importance: 2 });
    const b = makeObs({ importance: 2, uncited_streak: 2 });  // will demote
    const result = applyCitationDecay(db, 'p', new Set([a, b]), new Set([a]), 'sess-1');
    expect(result).toEqual({ promoted: 1, demoted: 1, touched: 2 });
  });

  it('escape hatch: MEM_DISABLE_CITATION_DECAY=1 → no writes, returns zeros', () => {
    const id = makeObs({ importance: 2 });
    process.env.MEM_DISABLE_CITATION_DECAY = '1';
    try {
      const result = applyCitationDecay(db, 'p', new Set([id]), new Set([id]), 'sess-1');
      expect(result).toEqual({ promoted: 0, demoted: 0, touched: 0 });
      expect(db.prepare('SELECT importance FROM observations WHERE id=?').get(id).importance).toBe(2);
    } finally { delete process.env.MEM_DISABLE_CITATION_DECAY; }
  });

  it('null/empty injected set → no-op', () => {
    const id = makeObs({ importance: 2 });
    applyCitationDecay(db, 'p', new Set(), new Set([id]), 'sess-1');
    expect(db.prepare('SELECT importance, last_decided_session_id FROM observations WHERE id=?').get(id).importance).toBe(2);
  });

  it('session dedup: same obs injected twice in one session resolves once', () => {
    // Mirrors spec Test #1: Read→Edit both inject #100 in one session.
    // The caller assembles ONE injected set per session — passing it twice
    // mimics the Stop hook firing twice (idempotent skip on second call).
    const id = makeObs({ importance: 2, uncited_streak: 0 });
    applyCitationDecay(db, 'p', new Set([id]), new Set(), 'sess-1');
    expect(db.prepare('SELECT uncited_streak FROM observations WHERE id=?').get(id).uncited_streak).toBe(1);
    // Second invocation (whether from another Stop fire or a duplicate scan) — no change.
    applyCitationDecay(db, 'p', new Set([id]), new Set(), 'sess-1');
    expect(db.prepare('SELECT uncited_streak FROM observations WHERE id=?').get(id).uncited_streak).toBe(1);
  });
});

describe('applyCitationDecay — cross-turn late citation (uncited→cited upgrade within a session)', () => {
  let db;
  beforeEach(() => {
    db = createTestDb();
    insertSession(db, { id: 'sess-1', project: 'p' });
  });
  afterEach(() => { try { db.close(); } catch {} });

  function makeObs(overrides = {}) {
    const id = insertObs(db, { sessionId: 'sess-1', project: 'p', type: 'bugfix', title: 't', importance: 2, ...overrides }).lastInsertRowid;
    if (overrides.uncited_streak !== undefined || overrides.cited_count !== undefined || overrides.last_decided_session_id !== undefined) {
      db.prepare(`UPDATE observations SET uncited_streak = ?, cited_count = ?, last_decided_session_id = ? WHERE id = ?`)
        .run(overrides.uncited_streak ?? 0, overrides.cited_count ?? 0, overrides.last_decided_session_id ?? null, id);
    }
    return id;
  }

  it('a citation in a LATER turn of the same session promotes a previously-uncited obs', () => {
    // Contract: "cite NEXT time you produce user-visible text" — may be several turns
    // later. Pre-fix, the turn-1 uncited resolution froze the verdict (last_decided=S),
    // and the turn-3 citation was skipped as an idempotent no-op → signal lost.
    const id = makeObs({ importance: 2, uncited_streak: 0 });
    const r1 = applyCitationDecay(db, 'p', new Set([id]), new Set(), 'sess-1');   // turn 1: not yet cited
    expect(r1).toEqual({ promoted: 0, demoted: 0, touched: 1 });
    expect(db.prepare('SELECT uncited_streak, importance FROM observations WHERE id=?').get(id).uncited_streak).toBe(1);

    const r2 = applyCitationDecay(db, 'p', new Set([id]), new Set([id]), 'sess-1'); // turn 3: now cited
    expect(r2.promoted).toBe(1);
    expect(r2.touched).toBe(0); // already in the injected denominator from turn 1 — not re-counted
    const row = db.prepare('SELECT importance, cited_count, uncited_streak, decay_seen_count, last_cited_session_id FROM observations WHERE id=?').get(id);
    expect(row.importance).toBe(3);
    expect(row.cited_count).toBe(1);
    expect(row.uncited_streak).toBe(0);
    expect(row.last_cited_session_id).toBe('sess-1');
    expect(row.decay_seen_count).toBe(1); // counted ONCE (turn 1), not double-counted on the upgrade
  });

  it('a late citation undoes a same-session demotion (importance restored)', () => {
    const id = makeObs({ importance: 2, uncited_streak: 2 }); // one more uncited → demote
    const r1 = applyCitationDecay(db, 'p', new Set([id]), new Set(), 'sess-1');
    expect(r1.demoted).toBe(1);
    expect(db.prepare('SELECT importance, uncited_streak FROM observations WHERE id=?').get(id).importance).toBe(1);

    const r2 = applyCitationDecay(db, 'p', new Set([id]), new Set([id]), 'sess-1'); // late cite
    expect(r2.promoted).toBe(1);
    const row = db.prepare('SELECT importance, cited_count, uncited_streak FROM observations WHERE id=?').get(id);
    expect(row.importance).toBe(2);   // restored 1 → 2
    expect(row.cited_count).toBe(1);
    expect(row.uncited_streak).toBe(0);
  });

  it('the promote upgrade is itself idempotent (guarded by last_cited_session_id)', () => {
    const id = makeObs({ importance: 1, uncited_streak: 0 }); // start below cap so climb is visible
    applyCitationDecay(db, 'p', new Set([id]), new Set(), 'sess-1');        // uncited
    applyCitationDecay(db, 'p', new Set([id]), new Set([id]), 'sess-1');    // upgrade → promote (imp 1→2)
    const after1 = db.prepare('SELECT importance, cited_count FROM observations WHERE id=?').get(id);
    expect(after1.importance).toBe(2);
    expect(after1.cited_count).toBe(1);

    const r3 = applyCitationDecay(db, 'p', new Set([id]), new Set([id]), 'sess-1'); // re-fire cited
    expect(r3.promoted).toBe(0); // already promoted this session
    const after2 = db.prepare('SELECT importance, cited_count FROM observations WHERE id=?').get(id);
    expect(after2.importance).toBe(2);  // no further climb
    expect(after2.cited_count).toBe(1); // NOT double-counted
  });

  it('first-resolution cited (injected + cited in one call) still counts touched AND promoted', () => {
    // Regression guard: the common "cite it right away" path must be unchanged —
    // it IS a first resolution, so it enters the injected denominator (touched=1).
    const id = makeObs({ importance: 2, uncited_streak: 0 });
    const r = applyCitationDecay(db, 'p', new Set([id]), new Set([id]), 'sess-1');
    expect(r).toEqual({ promoted: 1, demoted: 0, touched: 1 });
    const row = db.prepare('SELECT decay_seen_count, last_cited_session_id, last_decided_session_id FROM observations WHERE id=?').get(id);
    expect(row.decay_seen_count).toBe(1);
    expect(row.last_cited_session_id).toBe('sess-1');
    expect(row.last_decided_session_id).toBe('sess-1');
  });
});

describe('Stop hook integration — fixture transcript composition', () => {
  let db, tmp;
  beforeEach(() => {
    db = createTestDb();
    insertSession(db, { id: 'sess-int', project: 'p' });
    tmp = mkdtempSync(join(tmpdir(), 'cite-int-'));
  });
  afterEach(() => {
    try { db.close(); } catch {}
    try { rmSync(tmp, { recursive: true, force: true }); } catch {}
  });

  function makeObs(overrides = {}) {
    const result = insertObs(db, {
      sessionId: 'sess-int', project: 'p', type: 'bugfix', title: 't', importance: 2,
      ...overrides,
    });
    const id = result.lastInsertRowid;
    // Apply the citation-decay defaults via raw update (test-helpers doesn't know about new columns).
    if (overrides.uncited_streak !== undefined || overrides.cited_count !== undefined || overrides.last_decided_session_id !== undefined) {
      db.prepare(`
        UPDATE observations
        SET uncited_streak = ?, cited_count = ?, last_decided_session_id = ?
        WHERE id = ?
      `).run(
        overrides.uncited_streak ?? 0,
        overrides.cited_count ?? 0,
        overrides.last_decided_session_id ?? null,
        id
      );
    }
    return id;
  }

  it('fixture transcript with one injected #ID and a citation → promotion', () => {
    const id = makeObs({ importance: 2 });
    const path = join(tmp, 'transcript.jsonl');
    writeFileSync(path, [
      // PreToolUse mem injection
      JSON.stringify({
        type: 'attachment',
        attachment: {
          type: 'hook_success', hookName: 'PreToolUse:Read',
          command: 'pre-tool-recall.js',
          stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: `  #${id} [bugfix] sample` } }),
          stderr: '', exitCode: 0,
        },
      }),
      // Assistant cites it (main thread)
      JSON.stringify({
        type: 'assistant', isSidechain: false,
        message: { content: [{ type: 'text', text: `applied #${id}, all good` }] },
      }),
    ].join('\n'));

    const injected = extractInjectedFromPreToolUse(path);
    const cited = extractCitationsFromTranscript(path, { mainOnly: true });
    const result = applyCitationDecay(db, 'p', injected, cited, 'sess-int');
    expect(result).toEqual({ promoted: 1, demoted: 0, touched: 1 });
    expect(db.prepare('SELECT importance FROM observations WHERE id=?').get(id).importance).toBe(3);
  });

  it('cite-back signal promotes an injected obs the agent edited but never cited (P5 ①)', async () => {
    const { extractCiteBackSignals } = await import('../lib/cite-back-hint.mjs');
    const id = makeObs({ importance: 2, uncited_streak: 0 });
    const path = join(tmp, 'transcript.jsonl');
    const hint = `[mem] ⚠ Cite-back: edited 1 file(s) with 1 prior lesson(s) this session. Save now if any was the root cause:\n  • foo.mjs ← #${id} — /lesson --file foo.mjs "<root cause + fix>"`;
    writeFileSync(path, [
      // PreToolUse injected the lesson…
      JSON.stringify({
        type: 'attachment',
        attachment: {
          type: 'hook_success', hookName: 'PreToolUse:Edit', command: 'pre-tool-recall.js',
          stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: `  #${id} [bugfix] sample` } }),
          stderr: '', exitCode: 0,
        },
      }),
      // …PostToolUse cite-back hint fired (agent edited the warned file)…
      JSON.stringify({
        type: 'attachment',
        attachment: {
          type: 'hook_success', hookName: 'PostToolUse', command: 'hook.mjs post-tool-use',
          stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: hint } }),
          stderr: '', exitCode: 0,
        },
      }),
      // …but the assistant produced text WITHOUT citing #NN.
      JSON.stringify({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: 'fixed the bug, no explicit cite' }] } }),
    ].join('\n'));

    // Replicate the Stop handler union.
    const injected = extractInjectedFromPreToolUse(path);
    const citeBack = extractCiteBackSignals(path);
    for (const cid of citeBack) injected.add(cid);
    const cited = extractCitationsFromTranscript(path, { mainOnly: true });
    for (const cid of citeBack) cited.add(cid);

    const result = applyCitationDecay(db, 'p', injected, cited, 'sess-int');
    expect(result.promoted).toBe(1);
    expect(db.prepare('SELECT importance, uncited_streak FROM observations WHERE id=?').get(id).importance).toBe(3);
  });

  it('fixture transcript: injection from sidechain agent does NOT promote main', () => {
    const id = makeObs({ importance: 2 });
    const path = join(tmp, 'transcript.jsonl');
    writeFileSync(path, [
      JSON.stringify({
        type: 'attachment',
        attachment: {
          type: 'hook_success', hookName: 'PreToolUse:Read',
          command: 'pre-tool-recall.js',
          stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: `  #${id} [bugfix] sample` } }),
          stderr: '', exitCode: 0,
        },
      }),
      // Only the sub-agent cites it — main thread silent
      JSON.stringify({
        type: 'assistant', isSidechain: true,
        message: { content: [{ type: 'text', text: `sub-agent used #${id}` }] },
      }),
    ].join('\n'));

    const injected = extractInjectedFromPreToolUse(path);
    const cited = extractCitationsFromTranscript(path, { mainOnly: true });
    const result = applyCitationDecay(db, 'p', injected, cited, 'sess-int');
    expect(result.touched).toBe(1);
    expect(result.promoted).toBe(0);
    expect(db.prepare('SELECT uncited_streak FROM observations WHERE id=?').get(id).uncited_streak).toBe(1);
  });
});

describe('applyCitationDecay — project adoption-rate gate (P5 ②)', () => {
  let db;
  beforeEach(() => {
    db = createTestDb();
    insertSession(db, { id: 'sess-1', project: 'p' });
  });
  afterEach(() => { try { db.close(); } catch {} });

  // makeObs + raw set of the citation-decay history columns (incl. decay_seen_count,
  // which insertObs doesn't know about) so we can simulate a project's prior
  // citation history.
  function makeObs({ importance = 2, uncited_streak = 0, cited_count = 0, decay_seen_count = 0 } = {}) {
    const id = insertObs(db, { sessionId: 'sess-1', project: 'p', type: 'bugfix', title: 't', importance }).lastInsertRowid;
    db.prepare(`UPDATE observations SET uncited_streak = ?, cited_count = ?, decay_seen_count = ? WHERE id = ?`)
      .run(uncited_streak, cited_count, decay_seen_count, id);
    return id;
  }

  it('computeCitationAdoption returns cite-rate over the project resolution history', () => {
    makeObs({ cited_count: 3, decay_seen_count: 10 });
    makeObs({ cited_count: 0, decay_seen_count: 10 });
    const a = computeCitationAdoption(db, 'p');
    expect(a.cited).toBe(3);
    expect(a.seen).toBe(20);
    expect(a.rate).toBeCloseTo(0.15, 5);
  });

  it('suppresses demotion for a non-adopting project (≥min-seen, 0% cite-rate)', () => {
    // Target at streak=2 would normally demote, but the project has 20 prior
    // resolutions and never cited anything → suppress the importance drop.
    const target = makeObs({ importance: 2, uncited_streak: 2, decay_seen_count: 20, cited_count: 0 });
    const r = applyCitationDecay(db, 'p', new Set([target]), new Set(), 'sess-1');
    const row = db.prepare('SELECT importance, uncited_streak, demoted_at FROM observations WHERE id=?').get(target);
    expect(row.importance).toBe(2);          // NOT demoted
    expect(row.demoted_at).toBeNull();        // demote branch never ran
    expect(row.uncited_streak).toBe(2);       // CAPPED at threshold-1 under suppression —
                                              // not advanced unbounded (would sink cite_factor to floor)
    expect(r.demoted).toBe(0);
    expect(r.touched).toBe(1);
  });

  it('allows demotion once the project cites enough to clear the threshold', () => {
    const target = makeObs({ importance: 2, uncited_streak: 2, decay_seen_count: 20, cited_count: 5 });
    applyCitationDecay(db, 'p', new Set([target]), new Set(), 'sess-1');
    const row = db.prepare('SELECT importance, uncited_streak FROM observations WHERE id=?').get(target);
    expect(row.importance).toBe(1);           // demoted normally
    expect(row.uncited_streak).toBe(0);
  });

  it('does not engage on low-data projects (<min-seen) — existing behavior preserved', () => {
    const target = makeObs({ importance: 2, uncited_streak: 2, decay_seen_count: 2, cited_count: 0 });
    applyCitationDecay(db, 'p', new Set([target]), new Set(), 'sess-1');
    expect(db.prepare('SELECT importance FROM observations WHERE id=?').get(target).importance).toBe(1);
  });

  it('env override CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD widens the suppression band', () => {
    // 15% cite-rate normally clears the 2% default, but a 0.5 override suppresses it.
    const target = makeObs({ importance: 2, uncited_streak: 2, decay_seen_count: 20, cited_count: 3 });
    process.env.CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD = '0.5';
    try {
      applyCitationDecay(db, 'p', new Set([target]), new Set(), 'sess-1');
      expect(db.prepare('SELECT importance FROM observations WHERE id=?').get(target).importance).toBe(2);
    } finally { delete process.env.CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD; }
  });

  it('promotion is never gated — a cited obs still promotes in a non-adopting project', () => {
    const target = makeObs({ importance: 2, uncited_streak: 0, decay_seen_count: 20, cited_count: 0 });
    applyCitationDecay(db, 'p', new Set([target]), new Set([target]), 'sess-1');
    expect(db.prepare('SELECT importance FROM observations WHERE id=?').get(target).importance).toBe(3);
  });
});

describe('regression: extractor + decay defensive paths (D#21)', () => {
  let tmp, db;
  beforeEach(() => {
    tmp = mkdtempSync(join(tmpdir(), 'cite-reg-'));
    db = createTestDb();
    insertSession(db, { id: 'sess-r', project: 'p' });
  });
  afterEach(() => {
    try { db.close(); } catch {}
    try { rmSync(tmp, { recursive: true, force: true }); } catch {}
  });

  it('extractInjectedFromPreToolUse: falls back to raw-text scan when stdout is not JSON', () => {
    const path = join(tmp, 't.jsonl');
    writeFileSync(path, JSON.stringify({
      type: 'attachment',
      attachment: {
        type: 'hook_success',
        hookName: 'PreToolUse:Read',
        command: 'pre-tool-recall.js',
        stdout: '[mem] Lessons for foo.js:\n  #404 [bugfix] raw-text fallback path',
        stderr: '',
        exitCode: 0,
      },
    }));
    const ids = extractInjectedFromPreToolUse(path);
    expect(ids.has(404)).toBe(true);
    expect(ids.size).toBe(1);
  });

  it('applyCitationDecay: silently skips IDs that are not in observations (events-table ID case)', () => {
    const realId = insertObs(db, { sessionId: 'sess-r', project: 'p', type: 'bugfix', title: 't', importance: 2 }).lastInsertRowid;
    const ghostId = 99999999;
    const result = applyCitationDecay(db, 'p', new Set([realId, ghostId]), new Set(), 'sess-r');
    expect(result.touched).toBe(1);
    expect(result.demoted).toBe(0);
    expect(result.promoted).toBe(0);
    const realRow = db.prepare('SELECT uncited_streak FROM observations WHERE id=?').get(realId);
    expect(realRow.uncited_streak).toBe(1);
    const ghost = db.prepare('SELECT id FROM observations WHERE id=?').get(ghostId);
    expect(ghost).toBeUndefined();
  });
});
