#!/usr/bin/env node
/**
 * PreToolUse Bash gate (router-gate v4 §5.1).
 * Default-deny: команда не в whitelist → block. Hard-blacklist + sub-shell
 * sweep + chain-mutating + git (shared classifyGitCommand) + path-deny + watcher.
 * ParseError → fail-CLOSE.
 */
import { fileURLToPath } from 'url';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { tokenizeBash, isMutatingSegment } from './bash-tokenizer.mjs';
import {
  defaultPathNormalize,
  DEFAULT_PROTECTED_PATTERNS,
  pathDenyOverlay,
  extractPathArgs,
  matchBashHardBlacklist,
  BASH_HARD_BLACKLIST,
  classifyGitCommand,
  loadApprovedGitOps,
} from './shell-content-rules.mjs';
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
// Owner-authorized 2026-06-18: строго read-only удалённый доступ к боевому порталу
// (ssh liderra-prod '<read>' / gh GET) — разрешитель в remote-read-allow.mjs.
import { classifyRemoteReadCommand } from './remote-read-allow.mjs';

// M7 Task 1.0.5 (P-1): content-blacklist + матчер + stderr-redirect-страж переехали
// в единый дом shell-content-rules.mjs (один источник правды для content-floor М5 и этого
// гейта; порт-дрейф невозможен). Ре-экспорт для обратной совместимости — тесты импортируют
// matchBashHardBlacklist отсюда, и тело гейта (ниже) зовёт его локально.
export { matchBashHardBlacklist, BASH_HARD_BLACKLIST };

// ── whitelist ──
export const READING_CMDS = new Set(['ls', 'pwd', 'wc', 'head', 'tail', 'file', 'stat', 'grep', 'egrep', 'fgrep', 'cat', 'less', 'more']);
const SAFE_EXACT = [
  /^npx\s+vitest\s+(?:run|--version)\b/,
  /^npm\s+(?:test|run\s+test|run\s+lint(?::[\w-]+)?)\b/,
  /^php\s+artisan\s+(?:list|route:list|migrate:status)\b/,
  /^composer\s+(?:show|outdated)\b/,
  // node — НЕ в SAFE_EXACT (Пакет 4.2, Δ4): обрабатывается отдельной ветвью
  // classifyWhitelist через nodeScriptAllowed — сужение с «любой node-файл» до
  // allowlist (плоский tools/<file>.mjs + vitest-runner + --version). Анти-казуальная
  // мера (§7): остаток «контроллер commit'ит разрешённый скрипт» честно принят (Δ4),
  // file-watcher (scriptWatcherCheck) дополнительно блокирует session-edited до commit.
  // Laravel dev workflow (2026-05-30) — exclude tinker (REPL = arbitrary PHP exec risk).
  // Hard-blacklist (composer install/update/require/remove) remains the first check, unaffected.
  // `migrate(?=\s|$)` lookahead prevents `migrate:install` / `migrate:<unknown>` from matching bare `migrate`.
  // Машина 5 Пакет 2.4 (C4): migrate:fresh/refresh/reset УБРАНЫ из whitelist — это floor-набор
  // (необратимый дроп БД, classify-destructive.mjs). Теперь они → default-deny router-gate'ом
  // даже при незарегистрированном enforce-floor (защита-в-глубину SPOF). Остаются bare migrate
  // (миграции вперёд) + migrate:rollback (обратимо).
  /^php\s+artisan\s+(?:test|migrate:rollback|migrate(?=\s|$)|db:seed|cache:clear|config:clear|view:clear|route:clear|optimize:clear)\b/,
  /^composer\s+(?:test|pint|stan|insights|rector)\b/,
  /^(?:\.\/)?vendor\/bin\/pest\b/,
  /^pest\b/,
  // Narrow `cd app` (2026-05-31, owner-authorized) — enter the Laravel project dir
  // so already-whitelisted commands (pest, php artisan test) run from app/.
  // Scope deliberately limited to the literal `app` dir: `cd` into any other path
  // (incl. protected .claude/runtime, memory/, transcripts) stays default-deny, so
  // the cwd-shift read-bypass is contained. Mutations remain caught at the
  // hard-blacklist + chain-mutating rule (both run before the whitelist), and each
  // chain segment after `cd app &&` must still be independently whitelisted.
  /^cd\s+app$/,
];

// ── node-whitelist (Пакет 4.2, Δ4) ──
// Сужение: `node <script>` допускается ТОЛЬКО если script — плоский tools/<file>.{mjs,cjs,js}
// (свои гейты/тесты) ИЛИ vitest-runner (тест-чейн не ломается). Иначе default-deny.
// Инспектируем ТОКЕН пути (shell-quote сохраняет бэкслеши Windows-пути — инвариант в тесте),
// нормализуем \ → /. Бэкслеш-обход и пробелы в пути учтены. `node --version`/`-v` (без скрипта)
// — безвредная проверка версии. §7: анти-казуальная мера, не полный close (Δ4).
const NODE_SCRIPT_ALLOW_RE = [
  /(?:^|\/)tools\/[^/]+\.(?:mjs|cjs|js)$/,
  /(?:^|\/)node_modules\/vitest\/vitest\.mjs$/,
];

export function nodeScriptAllowed(tokens) {
  const args = Array.isArray(tokens) ? tokens.slice(1) : [];
  const script = args.find((t) => typeof t === 'string' && !t.startsWith('-'));
  if (script === undefined) {
    // нет позиционного скрипта → разрешаем только безвредную проверку версии
    return args.some((t) => t === '--version' || t === '-v');
  }
  const norm = script.replace(/\\/g, '/');
  return NODE_SCRIPT_ALLOW_RE.some((re) => re.test(norm));
}

export function classifyWhitelist(segments) {
  const reading = [];
  let anyReading = false;
  for (const seg of segments) {
    const cmd = seg.tokens[0];
    if (READING_CMDS.has(cmd)) { anyReading = true; reading.push(...extractPathArgs(seg.tokens)); continue; }
    if (cmd === 'node') {
      if (nodeScriptAllowed(seg.tokens)) continue;
      return null; // node-скрипт вне allowlist → default-deny
    }
    const joined = seg.tokens.join(' ');
    if (SAFE_EXACT.some((re) => re.test(joined))) continue;
    return null; // segment not whitelisted
  }
  if (anyReading) return { kind: 'reading', paths: reading, reason: 'whitelisted reading command(s)' };
  return { kind: 'safe', paths: [], reason: 'whitelisted safe command(s)' };
}

// ── file-watcher: script execution of edited file ──
export function scriptWatcherCheck(segments, editedFiles = [], pathNormalize = defaultPathNormalize) {
  const editedSet = new Set(editedFiles.map((f) => pathNormalize(f)));
  for (const seg of segments) {
    if (seg.tokens[0] !== 'node') continue;
    for (const arg of extractPathArgs(seg.tokens)) {
      if (/\.(mjs|js|cjs|ts)$/.test(arg) && editedSet.has(pathNormalize(arg))) {
        return { block: true, reason: `file-watcher: запуск отредактированного в сессии скрипта «${arg}» запрещён до commit+GREEN (§5.1)` };
      }
    }
  }
  return { block: false };
}

function readEditedFiles(sessionId) {
  const path = join(homedir(), '.claude', 'runtime', `edited-files-${sessionId || 'unknown'}.json`);
  if (!existsSync(path)) return [];
  try {
    const data = JSON.parse(readFileSync(path, 'utf-8'));
    return Array.isArray(data) ? data : Array.isArray(data.files) ? data.files : [];
  } catch { return []; }
}

export function classifyBashCommand(command, ctx = {}) {
  const tok = tokenizeBash(command);
  if (!tok.ok) return { result: 'block', reason: 'invalid shell syntax — переформулируй команду' };
  if (tok.hasSubshell) return { result: 'block', reason: `sub-shell construct (${tok.subshellKinds.join(', ')}) — hard-blocked (§5.1)` };

  // 1. raw hard-blacklist (redirects, C16, #4/#21/#22/#34, G7/G8, rm/composer/npm/...)
  const hb = matchBashHardBlacklist(command);
  if (hb) return { result: 'block', reason: hb };

  // 2. chain (>1 segment) where ANY part mutating → block (C13)
  if (tok.segments.length > 1 && tok.segments.some((s) => isMutatingSegment(s.tokens))) {
    return { result: 'block', reason: 'chain (;/&&/||/|) с мутирующей частью — hard-blocked (C13)' };
  }

  // 3. single git command → shared git classifier
  if (tok.segments.length === 1 && tok.segments[0].tokens[0] === 'git') {
    const git = classifyGitCommand(command, ctx);
    if (git) return git;
  }

  // 3.5 remote read-only (owner-authorized 2026-06-18, conversational-mode prod read):
  //     ssh liderra-prod '<одна read-команда>' / gh <GET>. Строгий разборщик
  //     (remote-read-allow.mjs) уже после hard-blacklist (шаг 1) и chain-mutating (шаг 2),
  //     так что rm/mv/редирект в удалённой строке отсекаются раньше. Любое отклонение
  //     от формы → rr.ok=false → падает в whitelist/default-deny ниже.
  const rr = classifyRemoteReadCommand(command);
  if (rr.ok) return { result: 'allow', reason: `remote read-only ${rr.kind} (ssh liderra-prod / gh GET) — reading` };

  // 4. whitelist + path-deny + watcher
  const wl = classifyWhitelist(tok.segments);
  if (wl) {
    if (wl.kind === 'reading') {
      const pd = pathDenyOverlay({
        candidatePaths: wl.paths,
        pathNormalize: ctx.pathNormalize,
        protectedPaths: ctx.protectedPaths,
      });
      if (pd.block) return { result: 'block', reason: pd.reason };
    }
    const sw = scriptWatcherCheck(tok.segments, ctx.editedFiles, ctx.pathNormalize || defaultPathNormalize);
    if (sw.block) return { result: 'block', reason: sw.reason };
    return { result: 'allow', reason: wl.reason };
  }

  // 5. default-deny
  return { result: 'block', reason: 'команда не в whitelist — default-deny (§5.1)' };
}

// Re-export для Stream A decide() (bashContentClassify interface, master plan §4).
export { classifyBashCommand as bashContentClassify };

// Swap-at-merge: пытаемся подтянуть реальный normalize Stream A; иначе fallback.
export async function resolvePathNormalize() {
  try {
    const mod = await import('./path-normalization.mjs');
    if (typeof mod.pathNormalize === 'function') return mod.pathNormalize;
    if (typeof mod.default === 'function') return mod.default;
  } catch { /* Stream A not merged yet */ }
  return defaultPathNormalize;
}

async function main() {
  try {
    const raw = await readStdin();
    const event = parseEventJson(raw);
    if (event.tool_name !== 'Bash') { exitDecision({ block: false }); return; }
    const command = (event.tool_input && event.tool_input.command) || '';
    const sessionId = event.session_id || 'unknown';
    const pathNormalize = await resolvePathNormalize();
    const ctx = {
      approvedGitOps: loadApprovedGitOps(sessionId),
      editedFiles: readEditedFiles(sessionId),
      pathNormalize,
      protectedPaths: DEFAULT_PROTECTED_PATTERNS,
      now: Date.now(),
    };
    const verdict = classifyBashCommand(command, ctx);
    exitDecision(verdict.result === 'block' ? { block: true, message: `[router-gate] ${verdict.reason}` } : { block: false });
  } catch {
    // fail-CLOSE: внутренняя ошибка гейта → блок (безопасный дефолт для security-хука)
    exitDecision({ block: true, message: '[router-gate] внутренняя ошибка гейта — fail-CLOSE' });
  }
}

const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isCli) main();
