#!/usr/bin/env node
/**
 * agentmemory-reflect-cite.mjs — the Phase D citation floor WIRING (Stop hook).
 *
 * This is the session-end backstop to the explicit `memory_reinforce` tool. At
 * Stop it:
 *   1. derives the SAME session key the recall hook wrote under
 *      ({@link sessionKeyFor} — keyed on `session_id`, identical derivation at
 *      both ends; a mismatch would be a silent no-op, so it is inlined from the
 *      same source as the recall hook and covered by a key-parity test),
 *   2. reads the surfaced set from the recall-log ({@link collectSurfaced}),
 *   3. builds the scan text from the agent's OWN output/edits in the transcript,
 *      EXCLUDING the injected recall-context block (the self-citation guard —
 *      see {@link buildScanText}),
 *   4. runs {@link detectCitedMemories} (precision-biased contiguous-distinctive
 *      -run match), and
 *   5. POSTs the cited ids to `/classify/reinforce` with `source: 'citation'`.
 *
 * The bridge enforces the FIX-1 abuse guards, so cited ids pass the recalled-gate
 * by construction (they were surfaced this session → recorded in last_recalled_at
 * by the recall path) and the cooldown still bounds re-stamping.
 *
 * Fail-open in every branch: a missing bearer, an unreadable transcript, an empty
 * recall-log, or a bridge error all exit 0 with no output. The floor is a
 * best-effort signal and must NEVER block session end.
 *
 * ESM (.mjs) so it can import recall-log.mjs + reinforce-citation logic directly
 * and be unit-tested via node:test. The citation DETECTOR lives in TS
 * (src/reinforce-citation.ts); its small, dependency-free logic is mirrored here
 * as an inlined ESM copy for the same no-build reason the recall hook inlines
 * deriveRecallQuery (the .ts remains the source of truth + carries its own
 * adversarial unit tests).
 */

import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { realpathSync } from 'node:fs';

import { collectSurfaced, sessionKeyFor, sweepStaleLogs } from './recall-log.mjs';

// ---------------------------------------------------------------------------
// Bridge config — mirrors agentmemory-session-recall.js so recall + cite agree.
// ---------------------------------------------------------------------------

// env wins → `olam memory connect` artifact → live default. See
// packages/cli/src/lib/memory-connection.ts (keep precedence in sync).
function resolveBridgeUrlDefault() {
  if (process.env.AGENTMEMORY_BRIDGE_URL) return process.env.AGENTMEMORY_BRIDGE_URL;
  try {
    const conn = JSON.parse(
      readFileSync(join(homedir(), '.olam', 'memory-connection.json'), 'utf8'),
    );
    if (conn && typeof conn.url === 'string' && conn.url) return conn.url;
  } catch {
    // artifact absent / malformed — fall through
  }
  return 'https://atlas-agent-memory.atlas-kitchen.workers.dev';
}
const BRIDGE_URL = resolveBridgeUrlDefault();

function resolveSecretPath(name) {
  const olamHome = join(homedir(), '.olam');
  const newPath = join(olamHome, 'secrets', name);
  try {
    readFileSync(newPath);
    return newPath;
  } catch {
    return join(olamHome, name);
  }
}

function readSecretFile(name) {
  try {
    return readFileSync(resolveSecretPath(name), 'utf8').trim();
  } catch {
    return '';
  }
}

const BEARER =
  process.env.AGENTMEMORY_BRIDGE_SECRET ||
  process.env.OLAM_AGENT_MEMORY_BEARER ||
  readSecretFile('bridge-secret') ||
  readSecretFile('agent-memory-bearer') ||
  '';

// ---------------------------------------------------------------------------
// Citation detector (inlined ESM mirror of src/reinforce-citation.ts).
// Kept structurally identical — the .ts is the source of truth + unit tests.
// ---------------------------------------------------------------------------

const MIN_SHINGLE = 4;
const MIN_TITLE_TOKENS = 4;

const STOPWORDS = new Set([
  'a', 'an', 'the', 'and', 'or', 'but', 'if', 'then', 'else', 'of', 'to', 'in',
  'on', 'at', 'by', 'for', 'with', 'as', 'is', 'are', 'was', 'were', 'be',
  'been', 'being', 'it', 'its', 'this', 'that', 'these', 'those', 'i', 'you',
  'we', 'they', 'he', 'she', 'do', 'does', 'did', 'so', 'not', 'no', 'can',
  'will', 'would', 'should', 'could', 'has', 'have', 'had', 'from', 'into',
  'out', 'up', 'down', 'over', 'under', 'about', 'than', 'too', 'very', 'just',
  'use', 'used', 'using', 'via', 'per', 'when', 'while', 'how', 'what', 'why',
]);

function tokenize(text) {
  return text.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 0);
}
function distinctiveTokens(text) {
  return tokenize(text).filter((t) => t.length > 1 && !STOPWORDS.has(t));
}
function distinctiveOf(tokens) {
  return tokens.filter((t) => t.length > 1 && !STOPWORDS.has(t));
}
function containsRun(haystackTokens, needleTokens) {
  if (needleTokens.length === 0 || needleTokens.length > haystackTokens.length) return false;
  for (let i = 0; i + needleTokens.length <= haystackTokens.length; i += 1) {
    let match = true;
    for (let j = 0; j < needleTokens.length; j += 1) {
      if (haystackTokens[i + j] !== needleTokens[j]) {
        match = false;
        break;
      }
    }
    if (match) return true;
  }
  return false;
}
function hasShingleMatch(sourceTokens, outputTokens) {
  if (sourceTokens.length < MIN_SHINGLE) return false;
  for (let i = 0; i + MIN_SHINGLE <= sourceTokens.length; i += 1) {
    if (containsRun(outputTokens, sourceTokens.slice(i, i + MIN_SHINGLE))) return true;
  }
  return false;
}
function isMemoryCited(memory, outputTokens) {
  const outputDistinctive = distinctiveOf(outputTokens);
  if (outputDistinctive.length === 0) return false;
  const titleTokens = distinctiveTokens(memory.title ?? '');
  if (titleTokens.length >= MIN_TITLE_TOKENS && containsRun(outputDistinctive, titleTokens)) return true;
  if (hasShingleMatch(titleTokens, outputDistinctive)) return true;
  const excerptTokens = distinctiveTokens(memory.excerpt ?? '');
  if (hasShingleMatch(excerptTokens, outputDistinctive)) return true;
  return false;
}

/**
 * Return the subset of `surfaced` literally referenced in `agentOutputText`.
 * Order-preserving, de-duplicated by id. (Mirror of the TS export.)
 */
export function detectCitedMemories(surfaced, agentOutputText) {
  if (!Array.isArray(surfaced) || surfaced.length === 0) return [];
  if (typeof agentOutputText !== 'string' || agentOutputText.length === 0) return [];
  const outputTokens = tokenize(agentOutputText);
  if (outputTokens.length === 0) return [];
  const cited = [];
  const seen = new Set();
  for (const m of surfaced) {
    if (!m || typeof m.id !== 'string' || seen.has(m.id)) continue;
    if (isMemoryCited(m, outputTokens)) {
      cited.push(m);
      seen.add(m.id);
    }
  }
  return cited;
}

// ---------------------------------------------------------------------------
// Self-citation guard — extract the agent's OWN output, EXCLUDING the recall block.
// ---------------------------------------------------------------------------

/**
 * Markers that identify the injected recall-context block (from
 * agentmemory-session-recall.js + agentmemory-recall-trigger.mjs). Any text
 * block containing one of these is the SYSTEM's injected context — NOT the
 * agent's own output — and must be excluded from the citation scan, or every
 * surfaced memory self-matches its own injected title (the self-citation loop).
 */
export const RECALL_BLOCK_MARKERS = [
  'Recalled from agent memory',
  'call `memory_reinforce`',
  'call memory_reinforce',
];

function looksLikeRecallBlock(text) {
  return RECALL_BLOCK_MARKERS.some((m) => text.includes(m));
}

/**
 * Build the citation-scan text from a parsed transcript (array of JSONL entries).
 *
 * Includes ONLY the agent's OWN output:
 *   - `assistant` message `text` blocks (the agent's prose), and
 *   - `assistant` `tool_use` input strings (the edits/commands the agent issued)
 *
 * Excludes — by construction and by marker — anything that is NOT the agent's own
 * authored output:
 *   - all `user` messages + `tool_result` blocks (inputs / tool outputs the agent
 *     merely received, including the recalled memory content itself),
 *   - any assistant text block that carries a {@link RECALL_BLOCK_MARKERS} marker
 *     (defensive: the injected recall block is normally a system/user-context
 *     injection, but if a transcript variant echoes it into an assistant turn we
 *     still strip it).
 *
 * This is THE self-citation guard: the scanned text never contains the injected
 * titles, so a surfaced memory can only be "cited" if the AGENT independently
 * wrote its distinctive tokens.
 */
export function buildScanText(transcriptEntries) {
  if (!Array.isArray(transcriptEntries)) return '';
  const parts = [];
  for (const entry of transcriptEntries) {
    if (!entry || entry.type !== 'assistant') continue;
    const msg = entry.message;
    const content = msg && msg.content;
    if (typeof content === 'string') {
      if (!looksLikeRecallBlock(content)) parts.push(content);
      continue;
    }
    if (!Array.isArray(content)) continue;
    for (const block of content) {
      if (!block || typeof block !== 'object') continue;
      if (block.type === 'text' && typeof block.text === 'string') {
        if (!looksLikeRecallBlock(block.text)) parts.push(block.text);
      } else if (block.type === 'tool_use' && block.input && typeof block.input === 'object') {
        // The agent's edits/commands ARE its output. Pull the string-valued
        // fields (new_string / content / command / etc.); skip the recall block
        // if a tool input somehow echoes it.
        for (const v of Object.values(block.input)) {
          if (typeof v === 'string' && v.length > 0 && !looksLikeRecallBlock(v)) parts.push(v);
        }
      }
    }
  }
  return parts.join('\n');
}

/**
 * Parse a transcript JSONL file into an array of entries. Fail-open: returns []
 * on any read/parse error (the floor is best-effort).
 */
export function readTranscript(transcriptPath) {
  if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) return [];
  let raw;
  try {
    raw = readFileSync(transcriptPath, 'utf8');
  } catch {
    return [];
  }
  const entries = [];
  for (const line of raw.split('\n')) {
    const trimmed = line.trim();
    if (!trimmed) continue;
    try {
      entries.push(JSON.parse(trimmed));
    } catch {
      // skip a malformed line — never abort the whole parse
    }
  }
  return entries;
}

async function postReinforce(memoryIds) {
  const res = await fetch(`${BRIDGE_URL}/classify/reinforce`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${BEARER}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      memoryIds,
      source: 'citation',
      reason: 'session-end citation floor: literally referenced in agent output',
    }),
    signal: AbortSignal.timeout(5000),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

async function main() {
  let event = {};
  try {
    event = JSON.parse(readFileSync(0, 'utf8'));
  } catch {
    return; // unparseable stdin → fail-open
  }

  // No bearer → no-op fail-open (matches the recall hook).
  if (!BEARER) return;

  // SAME session-key derivation as the recall hook — keyed on session_id. A
  // mismatch here would silently union nothing; the key-parity test pins this.
  const sessionKey = sessionKeyFor(event);
  const surfaced = collectSurfaced(sessionKey);
  if (!surfaced || surfaced.length === 0) {
    sweepStaleLogs(); // opportunistic GC even when there's nothing to cite
    return;
  }

  const transcript = readTranscript(event.transcript_path);
  const scanText = buildScanText(transcript);
  if (!scanText) {
    sweepStaleLogs();
    return;
  }

  const cited = detectCitedMemories(surfaced, scanText);
  if (cited.length === 0) {
    sweepStaleLogs();
    return;
  }

  try {
    await postReinforce(cited.map((m) => m.id));
  } catch (err) {
    process.stderr.write(`[agentmemory-reflect-cite] reinforce failed: ${err?.message ?? err}\n`);
  }
  sweepStaleLogs();
}

function isDirectInvocation() {
  try {
    const argvPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
    const metaPath = realpathSync(fileURLToPath(import.meta.url));
    return argvPath === metaPath;
  } catch {
    return false;
  }
}

if (isDirectInvocation()) {
  main().catch(() => process.exit(0));
}
