/**
 * recall-log.mjs — the session-local recall-linkage log (Phase D).
 *
 * When the SessionStart recall hook surfaces memories, it appends the surfaced
 * set here, keyed by a stable session key. At session end the citation hook
 * reads this log + the transcript and reinforces the subset that was literally
 * cited (the deterministic backstop to the explicit `memory_reinforce` tool).
 *
 * Design constraints:
 *   - Pure-ish + fail-open: any IO error is swallowed (the log is a
 *     best-effort signal, never load-bearing for the session).
 *   - Bounded: each session file keeps at most {@link MAX_ENTRIES} recall
 *     events and at most {@link MAX_SURFACED_PER_ENTRY} memories per event, so a
 *     long pathological session can't grow the file without bound.
 *   - Append/merge: re-recall within the same session appends a new entry
 *     rather than clobbering — the citation detector unions across entries.
 *
 * The module is ESM so the .mjs citation hook + the node:test suite can import
 * it directly. The CJS recall hook (agentmemory-session-recall.js) inlines a
 * small copy of {@link sessionKeyFor} + {@link appendRecall} for the same
 * no-build reason it inlines deriveRecallQuery.
 */

import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';

export const MAX_ENTRIES = 50;
export const MAX_SURFACED_PER_ENTRY = 20;
/** Excerpt cap per surfaced memory — keeps the log small + matches recall's 240-char excerpt budget. */
export const MAX_EXCERPT_CHARS = 240;
/** Delete session logs older than this on the occasional sweep (best-effort GC). */
export const LOG_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 days

/** Resolved recall-log directory: `~/.olam/recall-log/`. */
export function recallLogDir() {
  return join(homedir(), '.olam', 'recall-log');
}

/**
 * Stable, filesystem-safe session key. Prefers the harness session_id; falls
 * back to a cwd-slug + UTC date so two sessions in the same dir on the same day
 * coalesce (acceptable — the citation detector is conservative regardless).
 *
 * @param {{ session_id?: string, cwd?: string }} event
 * @param {Date} [now]
 * @returns {string}
 */
export function sessionKeyFor(event, now = new Date()) {
  const sid = event && typeof event.session_id === 'string' ? event.session_id.trim() : '';
  if (sid) return slug(sid);
  const cwd = event && typeof event.cwd === 'string' ? event.cwd : '';
  const cwdSlug = slug(cwd.replace(/\/+$/, '').split('/').slice(-2).join('-')) || 'session';
  const date = now.toISOString().slice(0, 10); // YYYY-MM-DD
  return `${cwdSlug}-${date}`;
}

function slug(s) {
  return String(s).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
}

function sessionLogPath(sessionKey) {
  return join(recallLogDir(), `${slug(sessionKey)}.json`);
}

function ensureDir(dir = recallLogDir()) {
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}

/**
 * Normalise an array of surfaced memories to the stored shape, dropping
 * entries without an id and capping count + excerpt length.
 *
 * @param {Array<{id?: string, obsId?: string, title?: string, excerpt?: string, contentExcerpt?: string, content?: string}>} surfaced
 * @returns {Array<{id: string, title: string, excerpt: string}>}
 */
export function normalizeSurfaced(surfaced) {
  const out = [];
  for (const m of Array.isArray(surfaced) ? surfaced : []) {
    if (!m || typeof m !== 'object') continue;
    const id = typeof m.id === 'string' ? m.id : typeof m.obsId === 'string' ? m.obsId : '';
    if (!id) continue;
    const title = typeof m.title === 'string' ? m.title : '';
    const rawExcerpt =
      (typeof m.excerpt === 'string' && m.excerpt) ||
      (typeof m.contentExcerpt === 'string' && m.contentExcerpt) ||
      (typeof m.content === 'string' && m.content) ||
      '';
    out.push({ id, title, excerpt: String(rawExcerpt).slice(0, MAX_EXCERPT_CHARS) });
    if (out.length >= MAX_SURFACED_PER_ENTRY) break;
  }
  return out;
}

/**
 * Read the recall log for a session, returning `{ entries: [...] }` or an empty
 * shape on any error / absence. Each entry is `{ ts, surfaced: [{id,title,excerpt}] }`.
 *
 * @param {string} sessionKey
 * @returns {{ entries: Array<{ ts: number, surfaced: Array<{id:string,title:string,excerpt:string}> }> }}
 */
export function readRecallLog(sessionKey) {
  try {
    const raw = readFileSync(sessionLogPath(sessionKey), 'utf8');
    const parsed = JSON.parse(raw);
    if (parsed && Array.isArray(parsed.entries)) return parsed;
  } catch {
    // fall through to empty
  }
  return { entries: [] };
}

/**
 * Append a recall event to the session log (append/merge, bounded, atomic,
 * fail-open). Returns the surfaced set that was actually recorded (post-
 * normalisation), or [] if nothing was written.
 *
 * @param {string} sessionKey
 * @param {Array<object>} surfaced
 * @param {number} [now]
 * @returns {Array<{id:string,title:string,excerpt:string}>}
 */
export function appendRecall(sessionKey, surfaced, now = Date.now()) {
  const normalized = normalizeSurfaced(surfaced);
  if (normalized.length === 0) return [];
  try {
    ensureDir();
    const log = readRecallLog(sessionKey);
    log.entries.push({ ts: now, surfaced: normalized });
    // Bound: keep only the most recent MAX_ENTRIES events.
    if (log.entries.length > MAX_ENTRIES) {
      log.entries = log.entries.slice(log.entries.length - MAX_ENTRIES);
    }
    const final = sessionLogPath(sessionKey);
    const tmp = `${final}.${randomBytes(6).toString('hex')}.tmp`;
    writeFileSync(tmp, JSON.stringify(log), 'utf8');
    renameSync(tmp, final);
    return normalized;
  } catch {
    return [];
  }
}

/**
 * Collect the de-duplicated union of all surfaced memories across every entry in
 * a session log. Used by the citation hook as the candidate set to test against
 * the transcript. Returns a map id → {id,title,excerpt} (latest excerpt wins).
 *
 * @param {string} sessionKey
 * @returns {Array<{id:string,title:string,excerpt:string}>}
 */
export function collectSurfaced(sessionKey) {
  const byId = new Map();
  for (const entry of readRecallLog(sessionKey).entries) {
    for (const m of entry.surfaced ?? []) {
      if (m && typeof m.id === 'string') byId.set(m.id, m);
    }
  }
  return [...byId.values()];
}

/**
 * Best-effort GC: delete session logs older than {@link LOG_TTL_MS}. Fail-open.
 * Called opportunistically by the citation hook at session end.
 */
export function sweepStaleLogs(now = Date.now()) {
  try {
    const dir = recallLogDir();
    if (!existsSync(dir)) return;
    for (const name of readdirSync(dir)) {
      if (!name.endsWith('.json')) continue;
      const p = join(dir, name);
      try {
        if (now - statSync(p).mtimeMs > LOG_TTL_MS) {
          rmSync(p, { force: true });
        }
      } catch {
        /* ignore single-file errors */
      }
    }
  } catch {
    /* ignore */
  }
}
