#!/usr/bin/env node
/**
 * agentmemory-classify-queue.mjs
 *
 * PostToolUse hook for Claude Code — captures every PostToolUse event
 * as a queue candidate at `~/.olam/agentmemory-queue/<ts>-<rand>.jsonl`
 * for later batch-classification by Phase B2's background classifier.
 *
 * Hot-path SLO: p99 dump latency < 100ms (Phase A plan P1 row).
 * Fail-open: any error exits 0 with no stdout — never blocks the tool loop.
 *
 * Pipeline:
 *   1. Read JSON event from stdin (Claude Code hook protocol).
 *   2. Per-tool capture (closes B1 CP3 ADV-B1-002 + ADV-B1-003): each
 *      tool_name has an explicit extraction function instead of generic
 *      shape-probing. Write/NotebookEdit DO NOT capture file body
 *      (only `wrote <path> (<N> bytes)`) — avoids leaking .env /
 *      credentials / id_rsa file writes into the queue. Edit/MultiEdit
 *      capture OLD/NEW strings so substantive changes reach B2.
 *   3. Strip secrets from the captured text BEFORE writing to disk
 *      (12 patterns; see SECRET_PATTERNS). Includes quoted env-var
 *      values, AWS access keys, Slack tokens, Stripe keys, JWTs,
 *      private-key armor, DB URLs with creds, and context-anchored
 *      hex64 bearer tokens. Closes plan OQ12 + B1 CP3 ADV-B1-001 +
 *      ADV-B1-004.
 *   4. Cap queue depth at 50 (force-flush trigger) / 80 (hard drop).
 *      Closes OQ10.
 *   5. Atomic write via tmp + rename. 8-byte rand suffix (2^64 space;
 *      birthday-paradox-free at any realistic capture rate). Closes
 *      B1 CP3 ADV-B1-005.
 *
 * Candidate schema (B1.1 — closes ADV-B1-006):
 *   { ts, tool_name, cwd, session_id, captured_text, queue_pressure }
 *
 * Install (operator runs manually):
 *   $ cp packages/memory-service/hooks/agentmemory-classify-queue.mjs \
 *        ~/.claude/scripts/hooks/agentmemory-classify-queue.mjs
 *   $ jq '.hooks.PostToolUse += [{"command": "~/.claude/scripts/hooks/agentmemory-classify-queue.mjs"}]' \
 *        ~/.claude/settings.json > ~/.claude/settings.json.new
 *   $ mv ~/.claude/settings.json.new ~/.claude/settings.json
 *   (Phase B3 will ship `olam memory classifier install-hook` to
 *   automate this.)
 *
 * Plan reference:
 *   docs/plans/agentmemory-classifier-and-regret/phase-b-tasks.md B1
 *   OQ10 (queue depth cap), OQ12 (secret stripper before enqueue)
 *   B1 CP3 ADV-B1-001..006 (B1.1 follow-up)
 */

import {
  existsSync,
  mkdirSync,
  readdirSync,
  readFileSync,
  realpathSync,
  renameSync,
  writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { isAgentMemoryEnabled } from './agent-memory-gate.mjs';

/** Max bytes of captured text persisted per candidate. Keeps queue files small. */
export const MAX_CAPTURED_CHARS = 4000;

/** Queue depth at which `main()` triggers a force-flush of the oldest 20 entries. */
export const QUEUE_FORCE_FLUSH_THRESHOLD = 50;

/** Queue depth at which `main()` drops the new event entirely (warn-log only). */
export const QUEUE_DROP_THRESHOLD = 80;

/**
 * Random bytes appended to each queue filename. 8 bytes = 2^64 space;
 * birthday-paradox-free at any realistic capture rate (collision
 * probability < 1e-12 even at 100M events). Was 4 bytes in B1 (~1.6%
 * collision at 360K events/hour bursts — silently lost a candidate per
 * collision per ADV-B1-005).
 */
const RAND_BYTES = 8;

/**
 * Vetted secret patterns. Each pattern matches a specific secret shape;
 * matches are redacted with `<REDACTED:<pattern-name>>` so the queue
 * file stays parseable and the redaction is observable in audits.
 *
 * Pattern set covers (closes ADV-B1-001 quoted-env-var bypass + adds
 * coverage for shapes B1's initial 6-pattern set missed):
 *   env-var-assign        — `KEY=value`, `KEY='value'`, `KEY="value"`,
 *                            `"KEY":"value"` JSON-stringified form.
 *                            Permissive key matcher (any uppercase
 *                            identifier ending in SECRET|KEY|TOKEN|
 *                            PASSWORD|PASS|BEARER|AUTH|CREDS|CREDENTIALS)
 *                            so STRIPE_SECRET_KEY, CUSTOM_API_TOKEN,
 *                            etc. all fire.
 *   bearer                — `Bearer <token>` Authorization headers.
 *   sk-prefix             — Anthropic/OpenAI keys.
 *   gh-pat                — GitHub personal access tokens.
 *   slack-token           — `xoxa|xoxb|xoxp|xoxr|xoxs|xoxo-...`
 *   aws-access-key-id     — `AKIA[A-Z0-9]{16}` access key IDs.
 *   stripe-key            — `sk_live_...` / `sk_test_...`.
 *   jwt                   — 3-segment base64url tokens.
 *   hex64-bearer          — 64-hex tokens ONLY when adjacent to
 *                            bearer|token|secret|key context word.
 *                            (Was unanchored in B1; false-fired on
 *                            container digests / sha256sum output per
 *                            ADV-B1-004.)
 *   private-key-armor     — PEM/OpenSSH key headers.
 *   db-url-with-creds     — `postgres|mysql|mongodb|redis://user:pw@host`.
 */
export const SECRET_PATTERNS = [
  {
    name: 'env-var-assign',
    re: /\b[A-Z][A-Z0-9_]*(?:SECRET|KEY|TOKEN|PASSWORD|PASS|BEARER|AUTH|CREDS|CREDENTIALS)[A-Z0-9_]*\s*[:=]\s*(?:"[^"\n]+"|'[^'\n]+'|[^\s"',;\n]+)/g,
  },
  {
    // Same shape but inside JSON-string keys: `"FOO_KEY":"value"`.
    // env-var-assign catches the unquoted KEY case; this rule catches
    // the JSON-stringified key case where the key itself is wrapped in
    // double-quotes.
    name: 'env-var-assign',
    re: /"[A-Z][A-Z0-9_]*(?:SECRET|KEY|TOKEN|PASSWORD|PASS|BEARER|AUTH|CREDS|CREDENTIALS)[A-Z0-9_]*"\s*:\s*"[^"\n]+"/g,
  },
  { name: 'bearer', re: /\bBearer\s+[A-Za-z0-9._-]{16,}/g },
  { name: 'sk-prefix', re: /\bsk-[A-Za-z0-9_-]{20,}/g },
  { name: 'gh-pat', re: /\bghp_[A-Za-z0-9]{30,}/g },
  { name: 'slack-token', re: /\bxox[abopsr]-[A-Za-z0-9-]{10,}/g },
  { name: 'aws-access-key-id', re: /\bAKIA[A-Z0-9]{16}\b/g },
  { name: 'stripe-key', re: /\bsk_(?:live|test)_[A-Za-z0-9]{20,}/g },
  { name: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
  { name: 'hex64-bearer', re: /\b(?:bearer|token|secret|key)[\s:=]+[a-f0-9]{64}\b/gi },
  { name: 'private-key-armor', re: /-----BEGIN [A-Z ]+PRIVATE KEY-----/g },
  { name: 'db-url-with-creds', re: /\b(?:postgres|postgresql|mysql|mongodb|redis):\/\/[^:@\s'"]+:[^@\s'"]+@[^\s'"]+/g },
];

/** Resolved queue directory path under `~/.olam/agentmemory-queue/`. */
export function queueDir() {
  return join(homedir(), '.olam', 'agentmemory-queue');
}

/**
 * Strip all configured secret patterns from `text`. Returns the
 * redacted text. Idempotent — running twice doesn't re-redact.
 */
export function stripSecrets(text) {
  if (typeof text !== 'string' || text.length === 0) return text;
  let out = text;
  for (const { name, re } of SECRET_PATTERNS) {
    const pattern = new RegExp(re.source, re.flags);
    out = out.replace(pattern, `<REDACTED:${name}>`);
  }
  return out;
}

/** Ensure the queue directory exists (idempotent). */
export function ensureQueueDir(dir = queueDir()) {
  if (!existsSync(dir)) {
    mkdirSync(dir, { recursive: true });
  }
}

/** Count `.jsonl` files in the queue dir (excludes the `.warn.log`). */
export function queueDepth(dir = queueDir()) {
  if (!existsSync(dir)) return 0;
  return readdirSync(dir).filter((n) => n.endsWith('.jsonl')).length;
}

/**
 * Atomic queue-file write. Writes to a tmp path then renames into place
 * so a partial JSONL never ends up in the queue dir. Uses 8-byte rand
 * to eliminate collision class (ADV-B1-005).
 */
export function writeCandidate(candidate, dir = queueDir()) {
  ensureQueueDir(dir);
  const ts = candidate.ts ?? Date.now();
  const rand = randomBytes(RAND_BYTES).toString('hex');
  const final = join(dir, `${ts}-${rand}.jsonl`);
  const tmp = final + '.tmp';
  writeFileSync(tmp, JSON.stringify(candidate) + '\n', 'utf-8');
  renameSync(tmp, final);
  return final;
}

/** Append a structured warning to the queue dir's `.warn.log` (fail-open). */
export function warnLog(message, dir = queueDir()) {
  try {
    ensureQueueDir(dir);
    const line = JSON.stringify({ ts: Date.now(), level: 'warn', message }) + '\n';
    writeFileSync(join(dir, '.warn.log'), line, { flag: 'a' });
  } catch {
    // Fail-open: warn-log failure is non-fatal.
  }
}

/**
 * Build a candidate payload from a Claude Code PostToolUse event. The
 * captured text is per-tool (see {@link extractCapturedText}); secrets
 * are stripped before this candidate ever touches disk.
 *
 * Schema (B1.1):
 *   ts             unix ms timestamp
 *   tool_name      from event.tool_name
 *   cwd            from event.cwd
 *   session_id     from event.session_id (B2 needs this for chain
 *                  reconstruction — was missing in B1, would have
 *                  forever-orphaned every pre-fix candidate)
 *   captured_text  per-tool extraction, secret-stripped, truncated
 *   queue_pressure depth at capture time (B2 observability)
 */
export function buildCandidate(event, depth = 0, now = Date.now()) {
  const rawText = extractCapturedText(event);
  const stripped = stripSecrets(rawText);
  const truncated = stripped.length > MAX_CAPTURED_CHARS
    ? stripped.slice(0, MAX_CAPTURED_CHARS)
    : stripped;
  return {
    ts: now,
    tool_name: event?.tool_name ?? null,
    cwd: event?.cwd ?? null,
    session_id: event?.session_id ?? null,
    captured_text: truncated,
    queue_pressure: depth,
  };
}

/**
 * Per-tool capture map. Each tool_name has an explicit extraction
 * function — generic shape-probing would leak file bodies for Write
 * (per ADV-B1-002) and miss substantive edits for Edit/MultiEdit
 * (per ADV-B1-003). The explicit map closes both classes.
 *
 * Write / NotebookEdit: capture ONLY `wrote <path> (<N> bytes)` — never
 *   the file body. Operator writing ~/.env or ~/.aws/credentials gets
 *   the metadata in the queue, not the secret content.
 * Edit / MultiEdit: capture OLD + NEW strings (substantive change).
 *   stripSecrets runs over the serialized form, so secret-bearing
 *   edits are still redacted.
 * Read: capture `read <path>` + first 2KB of content. Same Write
 *   reasoning would apply but Read explicitly returns content the
 *   agent will reason over — withholding it makes classification
 *   useless.
 * Bash: capture command + stdout.
 * Generic fallback: tool_response.content / .text.
 */
export function extractCapturedText(event) {
  if (!event || typeof event !== 'object') return '';
  const toolName = event.tool_name;
  const ti = event.tool_input ?? event.toolInput ?? null;
  const tr = event.tool_response ?? event.toolResponse ?? null;

  if (toolName === 'Bash') {
    const command = ti?.command ?? '';
    const stdout = readContentField(tr) ?? '';
    return command ? (stdout ? `$ ${command}\n${stdout}` : `$ ${command}`) : (stdout ?? '');
  }

  if (toolName === 'Write' || toolName === 'NotebookEdit') {
    const filePath = ti?.file_path ?? ti?.notebook_path ?? '<unknown>';
    const content = ti?.content ?? ti?.new_source ?? '';
    const len = typeof content === 'string' ? content.length : 0;
    return `wrote ${filePath} (${len} bytes)`;
  }

  if (toolName === 'Edit') {
    const filePath = ti?.file_path ?? '<unknown>';
    const oldStr = ti?.old_string ?? '';
    const newStr = ti?.new_string ?? '';
    return `edit ${filePath}\nOLD:\n${oldStr}\nNEW:\n${newStr}`;
  }

  if (toolName === 'MultiEdit') {
    const filePath = ti?.file_path ?? '<unknown>';
    const edits = Array.isArray(ti?.edits) ? ti.edits : [];
    const parts = edits.map((e, i) => `[${i}] OLD:\n${e?.old_string ?? ''}\nNEW:\n${e?.new_string ?? ''}`);
    return `multi-edit ${filePath}\n${parts.join('\n---\n')}`;
  }

  if (toolName === 'Read') {
    const filePath = ti?.file_path ?? '<unknown>';
    const content = readContentField(tr) ?? '';
    const truncated = content.length > MAX_CAPTURED_CHARS / 2
      ? content.slice(0, MAX_CAPTURED_CHARS / 2)
      : content;
    return `read ${filePath}\n${truncated}`;
  }

  // Generic fallback: tool_response content/text or tool_input content.
  const fromResponse = readContentField(tr);
  if (typeof fromResponse === 'string' && fromResponse.length > 0) return fromResponse;
  if (ti && typeof ti === 'object') {
    if (typeof ti.content === 'string') return ti.content;
    if (typeof ti.command === 'string') return ti.command;
  }
  return '';
}

/**
 * Read a content payload from a tool_response-shaped object. Handles:
 *   - string (return as-is)
 *   - { content: [{text}, ...] } MCP-style array (concat text blocks)
 *   - { text: string }
 *   - { content: string }
 */
function readContentField(obj) {
  if (!obj) return null;
  if (typeof obj === 'string') return obj;
  if (Array.isArray(obj.content)) {
    return obj.content
      .map((c) => (typeof c?.text === 'string' ? c.text : ''))
      .join('\n');
  }
  if (typeof obj.text === 'string') return obj.text;
  if (typeof obj.content === 'string') return obj.content;
  return null;
}

async function main() {
  // Gate: memory is off by default — exit silently when not enabled.
  if (!isAgentMemoryEnabled()) return;

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

  const dir = queueDir();
  ensureQueueDir(dir);
  const depth = queueDepth(dir);

  if (depth >= QUEUE_DROP_THRESHOLD) {
    warnLog(`queue depth ${depth} >= drop threshold ${QUEUE_DROP_THRESHOLD}; dropping event tool_name=${event?.tool_name ?? '?'}`, dir);
    return;
  }

  const candidate = buildCandidate(event, depth);
  writeCandidate(candidate, dir);

  if (depth >= QUEUE_FORCE_FLUSH_THRESHOLD) {
    // Phase B2 will own the force-flush trigger via an out-of-band
    // signal (touching a sentinel file the launchd timer watches).
    // For B1 ship we just warn — the next scheduled flush picks it up.
    warnLog(`queue depth ${depth} >= force-flush threshold ${QUEUE_FORCE_FLUSH_THRESHOLD}; awaiting B2 timer`, dir);
  }
}

// Run main only when invoked directly (allows test files to import the
// pure functions above without firing the hook side-effects). Compare
// REAL paths (not raw argv vs file://) so the hook still fires when
// resolved through a symlink — toolbox sync mounts these scripts via
// $HOME/.claude/scripts/agentmemory-classifier/ which is a symlink to the
// canonical atlas-toolbox path. Pre-fix, the raw-string compare missed
// the symlink and main() never ran.
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));
}
