#!/usr/bin/env node
/**
 * enforce-safe-baseline-metering — PreToolUse wrapper around the pure
 * safe-baseline-metering module (router-gate v4 §3.1.2 Direction 1).
 *
 * Catches skill-substitution laundering: many Read/Grep/Glob/LS/TodoWrite/
 * AskUserQuestion calls used as an analysis channel INSTEAD of invoking the
 * recommended Skill, then a mutating tool (Edit/Write/Bash/…) lands without any
 * skill ever matching. Safe-baseline tools themselves stay allowed (legit
 * continuation reading); only a mutating tool past the hard threshold is blocked.
 *
 * Stream H tail — adds the wrapper. Pure metering + threshold logic live in
 * safe-baseline-metering.mjs; this file is just the hook entry composition.
 *
 * Convention (mirrors enforce-decomposition-detector.mjs): the testable unit is
 * the pure `decide()` composition. The live `main()` — task-boundary inference,
 * skill-match detection from the transcript, and per-task counter persistence —
 * is a deferred no-op (exit 0) until that wiring is designed in the spec/plan.
 * Until then the hook NEVER blocks (no self-lockout, same posture as the sibling
 * Stream H wrappers). Settings.json registration is also deferred.
 */
import {
  incrementCounter,
  evaluateThresholds,
  DEFAULT_THRESHOLDS,
  newCounterState,
  shouldInheritTaskId,
  deriveTaskId,
} from './safe-baseline-metering.mjs';
import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import {
  readStdin,
  parseEventJson,
  readTranscript,
  lastUserPromptText,
  lastTurnEntries,
  exitDecision,
  logSafeBaselineAction,
} from './enforce-hook-helpers.mjs';

/**
 * Pure decision: increment the per-task counter for `toolName`, then evaluate
 * thresholds against the resulting state.
 *
 * @param {object} args
 * @param {object} args.state         - current per-task counter state (newCounterState shape)
 * @param {string} args.toolName      - the tool about to run
 * @param {boolean} [args.skillMatched] - whether a recommended Skill matched in this task
 * @param {object} [args.thresholds]  - override DEFAULT_THRESHOLDS
 * @returns {{state:object, action:'allow'|'soft_flag'|'hard_block', reason?:string}}
 */
export function decide({ state, toolName, skillMatched = false, thresholds = DEFAULT_THRESHOLDS }) {
  const next = incrementCounter(state, toolName);
  const evalResult = evaluateThresholds(next, toolName, skillMatched, thresholds);
  return { state: next, action: evalResult.action, reason: evalResult.reason };
}

/**
 * Task-boundary head: decide whether the current event continues the prior task
 * or starts a fresh one, then meter it.
 *
 * Continuation rules (delegated to the pure module):
 *   - no prior ledger                           → fresh task
 *   - reset marker in promptText                → fresh task (shouldInheritTaskId=false)
 *   - keyword overlap with prior task < 2       → fresh task
 *   - otherwise                                 → inherit prior counters
 *
 * @param {object} args
 * @param {object} args.event           - PreToolUse event ({ tool_name })
 * @param {object|null} args.priorLedger - { state, lastKeywords } from the last event, or null
 * @param {string[]} args.currentKeywords - keywords distilled from the current prompt
 * @param {string} args.promptText      - the current user prompt (for reset-marker detection)
 * @param {boolean} [args.skillMatched] - whether a recommended Skill matched in this task
 * @param {object} [args.thresholds]    - override DEFAULT_THRESHOLDS
 * @returns {{action:string, reason?:string, ledger:{state:object, lastKeywords:string[]}}}
 */
export function processEvent({
  event,
  priorLedger,
  currentKeywords = [],
  promptText = '',
  skillMatched = false,
  thresholds = DEFAULT_THRESHOLDS,
}) {
  const toolName = event && event.tool_name;
  const inherit =
    priorLedger &&
    priorLedger.state &&
    shouldInheritTaskId(priorLedger.lastKeywords || [], currentKeywords, promptText);

  const baseState = inherit
    ? priorLedger.state
    : newCounterState({
        taskId: deriveTaskId(promptText),
        startedAtIso: '',
        firstPromptExcerpt: promptText,
      });

  const d = decide({ state: baseState, toolName, skillMatched, thresholds });
  return {
    action: d.action,
    reason: d.reason,
    ledger: { state: d.state, lastKeywords: currentKeywords },
  };
}

// ── 1b live-wiring: pure helpers (safe-baseline-live-wiring-design.md v4) ──

// Common RU imperatives + RU/EN stopwords that would otherwise create spurious
// keyword overlap between unrelated tasks (G2). Length<4 tokens are dropped
// separately; this set targets >=4-char common words.
const STOPWORDS = new Set([
  'сделай', 'сделать', 'проверь', 'проверить', 'посмотри', 'добавь', 'добавить',
  'напиши', 'написать', 'нужно', 'надо', 'давай', 'можешь', 'потом', 'после',
  'перед', 'через', 'очень', 'если', 'чтобы', 'этот', 'эта', 'это', 'эти',
  'или', 'тоже', 'также', 'когда', 'пока', 'весь', 'всё', 'все', 'теперь',
  'здесь', 'там', 'нет', 'есть', 'будет', 'было', 'твой', 'мой', 'самый',
  'then', 'this', 'that', 'with', 'from', 'your', 'please', 'just', 'make',
  'check', 'look', 'need', 'want', 'also', 'into', 'more', 'very', 'should',
  'will', 'have', 'does', 'done', 'them', 'they', 'here', 'there',
]);

/** Deterministic keyword extraction (H1): lowercase, drop <4-char + stopwords, unique, sorted. */
export function extractKeywords(promptText) {
  if (typeof promptText !== 'string') return [];
  const tokens = promptText
    .toLowerCase()
    .split(/[^\p{L}\p{N}]+/u)
    .filter((t) => t.length >= 4 && !STOPWORDS.has(t));
  return [...new Set(tokens)].sort();
}

const SKILL_MATCH_TOOLS = new Set(['Skill', 'EnterPlanMode']);

/** C2/V2-5: true iff the turn has a real assistant tool_use of Skill or EnterPlanMode. */
export function detectSkillMatch(turnEntries) {
  if (!Array.isArray(turnEntries)) return false;
  for (const e of turnEntries) {
    const c = e && e.message && e.message.content;
    if (!Array.isArray(c)) continue;
    for (const b of c) {
      if (b && b.type === 'tool_use' && SKILL_MATCH_TOOLS.has(b.name)) return true;
    }
  }
  return false;
}

/**
 * V2-1 stickiness contract: the pure pipeline neither persists nor task-scopes
 * skill-match, so this wrapper owns it. Compute inherit (same predicate as
 * processEvent), scope the prior sticky flag to inherit, OR in this turn's match,
 * run the decision, then write the effective flag back into the persisted state.
 */
export function runLiveDecision({ event, priorLedger, promptText, currentKeywords, skillMatchedThisTurn, thresholds }) {
  const inherit = !!(priorLedger && priorLedger.state &&
    shouldInheritTaskId(priorLedger.lastKeywords || [], currentKeywords, promptText));
  const priorSticky = inherit ? !!priorLedger.state.skill_match_within_task : false;
  const effectiveSkillMatched = priorSticky || !!skillMatchedThisTurn;

  const res = processEvent({
    event, priorLedger, currentKeywords, promptText,
    skillMatched: effectiveSkillMatched, thresholds,
  });
  res.ledger.state.skill_match_within_task = effectiveSkillMatched;
  return res;
}

// ── live I/O composition ──

const ESCAPE_MSG = 'invoke the recommended Skill, or EnterPlanMode, to proceed (skill/plan invocations are never blocked by this layer).';

function ledgerDir(override) {
  return override || join(homedir(), '.claude', 'runtime');
}
function loadLedger(dir, sess) {
  try { return JSON.parse(readFileSync(join(dir, `safe-baseline-ledger-${sess || 'unknown'}.json`), 'utf8')); }
  catch { return null; }
}
function saveLedger(dir, sess, ledger) {
  try {
    mkdirSync(dir, { recursive: true });
    writeFileSync(join(dir, `safe-baseline-ledger-${sess || 'unknown'}.json`), JSON.stringify(ledger));
  } catch { /* fail-quiet */ }
}
function logFlag(dir, sess, entry) {
  try {
    mkdirSync(dir, { recursive: true });
    appendFileSync(join(dir, `safe-baseline-flags-${sess || 'unknown'}.jsonl`),
      JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
  } catch { /* ignore */ }
}

/** Testable live head: returns {block, message?} and persists the ledger. Fail-quiet. */
export async function runMain({ event, runtimeDir, transcript: injectedTranscript } = {}) {
  try {
    const sess = event.session_id;
    const dir = ledgerDir(runtimeDir);
    const transcript = injectedTranscript || readTranscript(event.transcript_path);
    const promptText = lastUserPromptText(transcript) || '';
    const currentKeywords = extractKeywords(promptText);
    const skillMatchedThisTurn = detectSkillMatch(lastTurnEntries(transcript)) ||
      SKILL_MATCH_TOOLS.has(event.tool_name);
    const priorLedger = loadLedger(dir, sess);

    const res = runLiveDecision({ event, priorLedger, promptText, currentKeywords, skillMatchedThisTurn });
    saveLedger(dir, sess, res.ledger);

    if (res.action !== 'allow') logSafeBaselineAction(sess, { tool: event.tool_name, action: res.action }, { baseDir: dir });
    if (res.action === 'soft_flag') logFlag(dir, sess, { tool: event.tool_name, reason: res.reason });
    if (res.action === 'hard_block') return { block: true, message: `[safe-baseline] ${res.reason}\n${ESCAPE_MSG}` };
    return { block: false };
  } catch {
    return { block: false }; // fail-quiet — never crash the session
  }
}

async function main() {
  const event = parseEventJson(await readStdin());
  const res = await runMain({ event });
  exitDecision(res);
}

if ((process.argv[1] || '').replace(/\\/g, '/').endsWith('/enforce-safe-baseline-metering.mjs')) {
  main().catch(() => process.exit(0));
}
