#!/usr/bin/env node
/**
 * PreToolUse hook — router tool gate.
 * Stage 3 of router discipline overhaul.
 *
 * Читает state из ~/.claude/runtime/router-state-<session>.json (написан router-prehook).
 * Решает: block / proceed для tools Edit, Write, Bash (non-read-only).
 *
 * Escape hatch: <!-- routing: direct_justified=true reason="..." --> в начале response пропускает.
 *
 * Mode: warn-only (только stderr) или enforce (decision: block).
 * Mode читается из ~/.claude/runtime/router-gate-mode.json {"mode": "warn-only"|"enforce"}.
 * По умолчанию warn-only (первая неделя), потом ручной переключатель.
 */

import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';

const READ_ONLY_BASH_PATTERNS = [
  /^\s*ls(\s|$)/, /^\s*cat\s/, /^\s*head\s/, /^\s*tail\s/, /^\s*wc\s/,
  /^\s*grep\s/, /^\s*find\s.*-print/, /^\s*pwd\s*$/,
  /^\s*git\s+(status|log|show|diff|rev-parse|branch|ls-tree|ls-remote|remote\s+show|tag|fetch)/,
  /^\s*node\s.*--check/, /^\s*npx\s+vitest\s+run/, /^\s*node\s+tools\/[\w-]+\.mjs\s+/,
];

export function isReadOnlyBash(command) {
  if (!command) return false;
  return READ_ONLY_BASH_PATTERNS.some((re) => re.test(command));
}

export function decodeRoutingTag(responseText) {
  if (!responseText) return null;
  const m = String(responseText).match(/<!--\s*routing:\s*direct_justified=true\s+reason=["']([^"']+)["']\s*-->/);
  if (!m) return null;
  return { directJustified: true, reason: m[1] };
}

// §17 exempt set — task types that never trigger the gate (spec §4.4).
// Continuation deliberately NOT in this list (D1): a continuation that
// inherits a `feature`/`bugfix` classification gets the same enforcement as
// the original prompt.
// H (2026-05-26): 'unknown' added to NON_BLOCKING_TASK_TYPES. Brain-retro #6
// surfaced that the LLM classifier hits parse_null occasionally (Sonnet returns
// JSON that parseClassifierResponse can't extract — prose wrapper or unexpected
// shape), falling back to regex which assigns task_type=unknown. Blocking on
// unknown is too strict — Bash/Edit gets stuck on routine work. G is the proper
// fix (better parser); H is the workaround until G ships.
const NON_BLOCKING_TASK_TYPES = ['conversation', 'micro', 'manual_override', 'unknown'];

// brain-retro #7 C2 (2026-05-27): short ambiguous prompts.
// Closes a leak in H bypass (unknown → exempt): when the LLM parser returns
// parse_null on a short prompt, regex fallback assigns task_type=unknown and
// the gate let it through. Reviewer (Opus 4.7) flagged 3/4 such cases as
// mistake_should_not_start — agent improvised 15+ tool calls instead of
// asking the user. Block them; long-prompt unknown still passes per H.
const SHORT_AMBIGUOUS_LEN = 30;
const SHORT_AMBIGUOUS_TYPES = new Set(['ambiguous', 'unknown']);

function resolveTaskType(cls) {
  return cls?.task_type ?? cls?.taskType;
}

function resolveMode(options) {
  if (typeof options.mode === 'string') return options.mode;
  // Legacy fallback: warnOnly=false maps to enforce, otherwise warn-only.
  return options.warnOnly === false ? 'enforce' : 'warn-only';
}

/**
 * §17 gate decision (spec §4.4, Phase 2 Task 13).
 *
 * @returns `false` when the tool call is allowed to proceed, or
 *          `{ block: true, reason: 'direct_in_non_conversation' | 'no_skill_found_block' | 'short_ambiguous_block' }`
 *          when the gate decides to block.
 *
 * Order of checks:
 *   1. mode off / warn-only → false (no enforcement)
 *   2. classification.no_skill_found === true → block(no_skill_found_block)
 *   3. C2 (brain-retro #7): skillInvoked=false, no direct_justified tag,
 *      task_type ∈ {ambiguous, unknown}, prompt_length ≤ 30
 *      → block(short_ambiguous_block) — narrows H bypass on short improv.
 *   4. task_type ∈ NON_BLOCKING_TASK_TYPES → false (§17 exempt set)
 *   5. skillInvokedThisTurn === true → false (skill already invoked)
 *   6. routing-tag direct_justified=true with reason → false (escape hatch)
 *   7. Bash + isReadOnlyBash(cmd) → false (read-only commands)
 *   8. tool ∉ {Edit, Write, MultiEdit, Bash} → false (not gated)
 *   9. → block(direct_in_non_conversation)
 */
export function shouldBlock(tool, state, responseText, options = {}) {
  const mode = resolveMode(options);
  if (mode === 'off' || mode === 'warn-only') return false;

  const cls = state?.classification || {};

  if (cls.no_skill_found === true) {
    return { block: true, reason: 'no_skill_found_block' };
  }

  const taskType = resolveTaskType(cls);
  const tag = decodeRoutingTag(responseText);

  // C2: short ambiguous/unknown prompts. Check BEFORE NON_BLOCKING so 'unknown'
  // (which is in NON_BLOCKING for parse_null tolerance) is still caught when
  // the prompt is short. Respects skill-invoked + routing-tag escape hatches.
  const promptLen = state?.prompt_length;
  if (
    typeof promptLen === 'number'
    && promptLen <= SHORT_AMBIGUOUS_LEN
    && SHORT_AMBIGUOUS_TYPES.has(taskType)
    && state?.skillInvokedThisTurn !== true
    && !tag?.directJustified
  ) {
    return { block: true, reason: 'short_ambiguous_block' };
  }

  if (NON_BLOCKING_TASK_TYPES.includes(taskType)) return false;
  if (state?.skillInvokedThisTurn === true) return false;

  if (tag?.directJustified) return false;

  if (tool === 'Bash' && isReadOnlyBash(options.bashCommand || '')) return false;
  if (!['Edit', 'Write', 'MultiEdit', 'Bash'].includes(tool)) return false;

  return { block: true, reason: 'direct_in_non_conversation' };
}

export function decideDecision(tool, state, responseText, options = {}) {
  const cls = state?.classification || {};
  const taskType = resolveTaskType(cls);
  const block = shouldBlock(tool, state, responseText, options);

  if (block && block.block) {
    const recNode = cls.recommendedNode ?? cls.recommended_node ?? '(unknown)';
    const recChain = cls.recommendedChain ?? cls.recommended_chain_id;
    const chainSuf = recChain ? ` (chain ${recChain})` : '';
    let reasonText;
    if (block.reason === 'no_skill_found_block') {
      reasonText = `Классификатор не нашёл подходящий узел (no_skill_found). Уточни задачу или дай routing-tag direct_justified. Узел: ${recNode}.`;
    } else if (block.reason === 'short_ambiguous_block') {
      reasonText = `Короткий размытый промпт (${state?.prompt_length ?? '?'} симв, task_type=${taskType}). На таких — лучше уточнить через AskUserQuestion, чем угадывать. Если задача и правда тривиальна — начни ответ с <!-- routing: direct_justified=true reason="..." -->.`;
    } else {
      reasonText = `Эта задача классифицирована как ${taskType}. Реестр рекомендует узел ${recNode}${chainSuf}. Вызови соответствующий навык ПЕРВЫМ, либо начни ответ с <!-- routing: direct_justified=true reason="..." --> с явным обоснованием.`;
    }
    return { decision: 'block', reason: reasonText, reason_code: block.reason };
  }

  const mode = resolveMode(options);
  if (
    mode === 'warn-only'
    && taskType
    && !NON_BLOCKING_TASK_TYPES.includes(taskType)
    && cls.no_skill_found !== true
    && !state?.skillInvokedThisTurn
  ) {
    const recNode = cls.recommendedNode ?? cls.recommended_node ?? '(unknown)';
    return {
      warning: `[router-gate WARN-ONLY] ${tool} would be blocked — recommended ${recNode}.`,
    };
  }

  return {};
}

function gateMode() {
  const path = join(homedir(), '.claude', 'runtime', 'router-gate-mode.json');
  if (!existsSync(path)) return 'warn-only';
  try {
    const data = JSON.parse(readFileSync(path, 'utf-8'));
    if (data.mode === 'enforce') return 'enforce';
    if (data.mode === 'off') return 'off';
    return 'warn-only';
  } catch { return 'warn-only'; }
}

function readState(sessionId) {
  const path = join(homedir(), '.claude', 'runtime', `router-state-${sessionId}.json`);
  if (!existsSync(path)) return null;
  try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
}

async function main() {
  const input = await readStdinAsUtf8(process.stdin);
  const event = JSON.parse(input || '{}');
  const sessionId = event.session_id || 'unknown';
  const tool = event.tool_name;
  const state = readState(sessionId);

  if (!state) { process.stdout.write(JSON.stringify({})); process.exit(0); return; }

  const mode = gateMode();
  const responseText = ''; // PreToolUse event doesn't include response
  const bashCommand = (event.tool_input || {}).command || '';

  const decision = decideDecision(tool, state, responseText, { mode, bashCommand });

  if (decision.warning) process.stderr.write(decision.warning + '\n');
  process.stdout.write(JSON.stringify(decision.decision ? decision : {}));
  process.exit(0);
}

// CLI guard — Windows-cyrillic quirk: use fileURLToPath(import.meta.url)
import { fileURLToPath } from 'url';
import { readStdinAsUtf8 } from './router-stdin-helper.mjs';
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { main(); }
