#!/usr/bin/env node
/**
 * enforce-llm-judge-per-tool — PreToolUse wrapper around the pure
 * llm-judge-per-tool engine (router-gate v4.1 §4.7 Layer 4).
 *
 * The engine (llm-judge-per-tool.mjs) asks a single Sonnet judge whether a
 * mutating tool call is consistent with the declared user task + recommended
 * skill scope (NO / doubt → block). Running it costs real LLM money, so the
 * judge MUST stay OFF until the owner deliberately activates Layer 4. This
 * wrapper is the missing seam between the engine and settings.json, built — like
 * the sibling Stream H wrappers (enforce-safe-baseline-metering / -decomposition-
 * detector) — with a testable pure `decide()` and a DELIBERATE no-op `main()`.
 *
 * Activation (step 2b — owner-driven, NOT done here):
 *   1. store the API key (keychain `router-gate-llm-judge`/`default` or ROUTER_LLM_KEY),
 *   2. set ROUTER_LLM_JUDGE_ENABLED=1,
 *   3. register this hook (PreToolUse, block) in .claude/settings.json.
 * Until all three, decide() short-circuits to allow on a disabled config and the
 * live main() is a no-op (exit 0) — $0, no LLM call, no self-lockout.
 */
import { judgePerTool, MUTATING_TOOLS, readDeclaredTask, resolveEffectiveTask } from './llm-judge-per-tool.mjs';
import { resolveJudgeConfig } from './llm-judge-config.mjs';
import { readJudgeBudget, bumpJudgeBudget, JUDGE_SESSION_BUDGET, llmJudgeCall } from './llm-judge.mjs';
import { readStdin, parseEventJson, exitDecision, readTranscript, lastUserPromptText, logJudgeVerdict } from './enforce-hook-helpers.mjs';
import { classifyBashCommand } from './enforce-router-gate.mjs';

/**
 * Pure decision. Composes the Layer-4 enabling-gate (resolveJudgeConfig output)
 * with the per-tool judge engine:
 *   - non-mutating tool        → allow (out of judge scope)
 *   - judge disabled / no key  → allow + degraded flag (Layer 4 off, $0)
 *   - judge enabled            → delegate to judgePerTool (YES → allow; NO / doubt → block)
 *
 * @param {object} args
 * @param {object} args.event           - PreToolUse event ({ tool_name, tool_input })
 * @param {{enabled:boolean, apiKey:?string}} args.judgeConfig - resolveJudgeConfig() output
 * @param {object} [args.declaredTask]  - { task_summary, recommended_node, recommended_chain }
 * @param {object} [args.budgetState]   - { spent, limit } per-session judge budget
 * @param {Function} [args.llmJudgeCallImpl] - injected single-judge caller (tests / real binding)
 * @returns {Promise<{block:boolean, reason?:string, degraded?:boolean, verdict?:string|null}>}
 */
export async function decide({
  event,
  judgeConfig,
  declaredTask = {},
  budgetState,
  llmJudgeCallImpl,
}) {
  const toolName = event && event.tool_name;
  if (!MUTATING_TOOLS.has(toolName)) {
    return { block: false, reason: 'non-mutating tool — outside per-tool judge scope' };
  }
  if (!judgeConfig || !judgeConfig.enabled) {
    return { block: false, degraded: true, reason: 'Layer 4 judge disabled' };
  }
  return judgePerTool({
    toolName,
    toolInput: (event && event.tool_input) || {},
    declaredTask,
    apiKey: judgeConfig.apiKey,
    budgetState,
    llmJudgeCallImpl,
  });
}

/**
 * Testable wiring core. Composes resolveJudgeConfig output + decide(); bumps the
 * session budget ONLY when a real judge call was made (result carries a verdict).
 * No verdict ⇒ non-mutating / disabled / no-key / budget-exhausted ⇒ no spend.
 */
/**
 * Calibration 2026-05-31 (SCOPE fix, NOT a discipline drop): readonly Bash
 * commands ("смотрелки" — git status/log/diff, cat, grep, ls) change nothing,
 * so they are outside the "judge on mutating tools" scope. Reuse the router-gate
 * Bash classifier: an allow-verdict whose reason mentions readonly/reading is a
 * no-state-change command. Everything that can mutate (file edits, git
 * commit/push, dangerous Bash, Skill/Task) is unaffected — doubt→block stands.
 */
export function isReadonlyBashEvent(event) {
  if (!event || event.tool_name !== 'Bash') return false;
  const command = (event.tool_input && event.tool_input.command) || '';
  if (!command) return false;
  try {
    const c = classifyBashCommand(command, {});
    return !!c && c.result === 'allow' && /readonly|reading/i.test(c.reason || '');
  } catch {
    return false;
  }
}

/**
 * Calibration 3 (2026-05-31, SCOPE fix, NOT a discipline drop): a test run
 * (vitest / pest / phpunit / php artisan test / composer test / npm test) only
 * inspects the code and reports pass/fail — it mutates no protected state, and
 * running tests is a MANDATORY step of TDD which the rules require. Treat such
 * commands like readonly Bash: outside the mutating-tool judge scope. A command
 * that chains to anything else (&& / ; / | / backtick / $( ) is NOT exempt and
 * stays judged — the exemption covers a pure test invocation only.
 */
const TEST_RUNNER_RE =
  /^(?:npx\s+)?vitest(?:\s|$)|^(?:\.\/)?(?:node_modules\/\.bin\/|vendor\/bin\/)?pest(?:\s|$)|^(?:\.\/)?vendor\/bin\/phpunit(?:\s|$)|^php\s+artisan\s+test(?:\s|$|:)|^composer\s+test(?::\S+)?(?:\s|$)|^npm\s+(?:run\s+)?test(?::\S+)?(?:\s|$)/i;

export function isTestRunnerBashEvent(event) {
  if (!event || event.tool_name !== 'Bash') return false;
  const command = ((event.tool_input && event.tool_input.command) || '').trim();
  if (!command) return false;
  // Exemption is for a pure test run only — reject anything chaining to another command.
  if (/[;&|`]/.test(command) || command.includes('$(')) return false;
  return TEST_RUNNER_RE.test(command);
}

export async function runPerTool({
  event,
  judgeConfig,
  readDeclaredTaskImpl,
  readLastUserPromptImpl,
  readBudgetImpl,
  bumpBudgetImpl,
  llmJudgeCallImpl,
  logVerdictImpl = () => {},
  sessionBudget = JUDGE_SESSION_BUDGET,
}) {
  // Readonly Bash never mutates → outside the judge's scope; skip (no LLM call, no spend).
  if (isReadonlyBashEvent(event)) {
    return { block: false, reason: 'readonly bash — outside mutating-tool judge scope (calibration 2026-05-31)' };
  }
  // Test-runner Bash only inspects + reports; mandatory TDD step → outside scope (calibration 3).
  if (isTestRunnerBashEvent(event)) {
    return { block: false, reason: 'test-runner bash — outside mutating-tool judge scope (calibration 3, 2026-05-31)' };
  }
  const sessionId = event && event.session_id;
  const declaredTask = readDeclaredTaskImpl({ sessionId });
  // Calibration 4 (soft): only when the classifier summary is unknown/empty,
  // consult the user's actual last prompt and judge against that instead.
  let effectiveTask = declaredTask;
  const summary = declaredTask && declaredTask.task_summary;
  const summaryUnknown = !summary || summary === '(unknown)' || !String(summary).trim();
  if (summaryUnknown && typeof readLastUserPromptImpl === 'function') {
    const lastPrompt = readLastUserPromptImpl({ transcriptPath: event && event.transcript_path });
    effectiveTask = resolveEffectiveTask(declaredTask, lastPrompt);
  }
  const spent = readBudgetImpl({ sessionId });
  const result = await decide({
    event,
    judgeConfig,
    declaredTask: effectiveTask,
    budgetState: { spent, limit: sessionBudget },
    llmJudgeCallImpl,
  });
  if (result.verdict !== undefined) {
    bumpBudgetImpl({ sessionId, by: 1 });
    logVerdictImpl({ sessionId, tool: event && event.tool_name, verdict: result.block ? 'block' : (result.verdict ?? 'YES') });
  }
  return result;
}

async function main() {
  // Live wiring (2b): spend is gated by resolveJudgeConfig (flag AND key). With
  // the flag off or no key, decide() short-circuits to a degraded allow — NO LLM
  // call, $0. Fail-quiet so a judge bug can never wedge the session.
  try {
    const event = parseEventJson(await readStdin());
    const judgeConfig = resolveJudgeConfig();
    const result = await runPerTool({
      event,
      judgeConfig,
      readDeclaredTaskImpl: readDeclaredTask,
      readLastUserPromptImpl: ({ transcriptPath }) => lastUserPromptText(readTranscript(transcriptPath)),
      readBudgetImpl: readJudgeBudget,
      bumpBudgetImpl: bumpJudgeBudget,
      llmJudgeCallImpl: (opts) => llmJudgeCall(opts),
      logVerdictImpl: ({ sessionId, tool, verdict }) => logJudgeVerdict(sessionId, { tool, verdict }),
    });
    exitDecision({ block: result.block, message: result.reason });
  } catch {
    exitDecision({ block: false });
  }
}

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