#!/usr/bin/env node
/**
 * floor-decide (Машина 5, Блок 1) — чистое ядро НЕСУЩЕГО ПОЛА: вето-до-плана на
 * необратимое. Срабатывает ПЕРВЫМ (обёртка enforce-floor зовёт его до seed/observe/
 * проверки членства в плане). Валидная печать плана для floor-набора нерелевантна.
 *
 * Два инспектируемых канала (§2-точность):
 *  - Bash: classifyDestructive(...).floor — whole-string (ловит chaining + quote-hardened
 *    FLOOR_RE из Пакета 1) И посегментно через tokenizeBash (unquoted-токены нейтрализуют
 *    кавычки; ловит floor-сегмент за читающим: `cat x && git push --force`).
 *  - Write/Edit/NotebookEdit: путь записи через resolving pathNormalize → запись в
 *    ~/.claude/runtime ИЛИ секрет (.env/ключ/cert) → block. fail-CLOSED (normalize
 *    бросил → block; в отличие от lenient enforce-runtime-write-deny — пол строже).
 *
 * Аварийный выход владельца (floor_escape, М6): необратимое действие проходит ТОЛЬКО при
 * наличии свежего (≤5 мин) непогашенного пропуска с ТОЧНЫМ совпадением канон-строки
 * (escape-grant::canonicalAction). Пропуски читаются READ-ONLY (floor НЕ потребляет —
 * one-shot погашение делает отдельный PostToolUse-консьюмер после реального исполнения;
 * гонки нет). Контроллер этот канал не пишет (protected ~/.claude/runtime, пишет
 * PostToolUse-хук на реальный AskUser → toFloorEscapeRecord). Сквозной: тот же пропуск
 * чтут стена М2 и egress-классификатор.
 *
 * Посессионная независимость: floor-список зашит в КОДЕ (classify-destructive), не из
 * per-session файла; unknown-сессия → пустой список пропусков → блок.
 * fail-CLOSED на свою ошибку — на уровне обёртки enforce-floor.
 */
import { classifyDestructive } from './classify-destructive.mjs';
import { tokenizeBash, detectSubshell } from './bash-tokenizer.mjs';
import { pathNormalize } from './path-normalization.mjs';
import { canonicalAction, escapeGrantOpen } from './escape-grant.mjs';
import { matchBashHardBlacklist } from './shell-content-rules.mjs';
import { psContentBlock } from './powershell-destructive.mjs';

const RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i;
const SECRET_PATH_RE = [
  /(^|\/)\.env(\.[\w-]+)?$/i,                 // .env / .env.local / .env.production
  /\.(pem|key|p12|pfx)$/i,                    // приватные ключи / сертификаты
  /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.|$)/i,  // ssh-ключи
];

// M7 Task 1.4 (P-3, forge-страж): PS-запись в защищённый путь. Поле `command` не парсится
// общим write-стражем (extractWritePath ждёт file_path/path/…), поэтому PS-глагол записи в
// ~/.claude/runtime / .env / секрет подделал бы escape-грант. NB (находка реализации): plan-версия
// тестила whole-string с $-якорем — `.env` в позиции аргумента (-Path app/.env -Value …) терялся.
// Робастно: проверяем КАЖДЫЙ токен (без кавычек, \→/) против anchored RUNTIME_RE/SECRET_PATH_RE.
// sharp-edges (после 1.4): +алиасы PS-глаголов записи (sc/ac/cpi/copy/mi/move/ni/tee) — иначе
// `sc ~/.claude/runtime/x` (алиас Set-Content) форджит escape-грант мимо литерального глагола.
const PS_WRITE_VERB_RE = /\b(?:Set-Content|Add-Content|Out-File|Copy-Item|New-Item|Tee-Object|Move-Item|sc|ac|cpi|copy|mi|move|ni|tee)\b/i;
function psProtectedWrite(cmd) {
  const s = String(cmd || '');
  const isWrite = PS_WRITE_VERB_RE.test(s) || /(?:^|[^0-9>&])>{1,2}(?![>&])/.test(s);
  if (!isWrite) return false;
  const slashed = s.split('\\').join('/');
  if (RUNTIME_RE.test(slashed)) return true; // -Path~/.claude/runtime слитно тоже
  for (const raw of s.split(/\s+/)) {
    const t = raw.replace(/^['"]|['"]$/g, '').split('\\').join('/');
    if (RUNTIME_RE.test(t) || SECRET_PATH_RE.some((re) => re.test(t))) return true;
  }
  return false;
}

const OBSERVE_TOOLS = new Set(['Read', 'Grep', 'Glob']); // только смотрят — floor записи не касается
// B4-выравнивание: писатели несут путь под разными именами полей (как extractPath/actionOf).
const PATH_FIELDS = ['file_path', 'notebook_path', 'path', 'target_file', 'filename', 'destination', 'dest', 'output_path', 'uri'];

function extractWritePath(input) {
  if (!input || typeof input !== 'object') return '';
  for (const f of PATH_FIELDS) {
    if (typeof input[f] === 'string' && input[f]) return input[f];
  }
  return '';
}

/** Bash → floor? whole-string (chaining/quotes) ИЛИ любой floor-сегмент (посегментно). */
export function bashIsFloor(command) {
  const raw = String(command || '');
  if (classifyDestructive(raw).floor) return true;
  const tok = tokenizeBash(raw);
  if (tok && tok.ok && Array.isArray(tok.segments)) {
    for (const s of tok.segments) {
      if (classifyDestructive((s.tokens || []).join(' ')).floor) return true;
    }
  }
  return false;
}

/**
 * Bash → content-block? (правило 8 §4.1, V1). Опасное-по-СОДЕРЖАНИЮ (произвольное
 * исполнение / install / egress / redirect) — единый матчер (P-1), whole-string И
 * посегментно: паритет с bashIsFloor (P-4). NB: matchBashHardBlacklist подстрочный —
 * `echo "node -e foo"` тоже даёт true (FP-класс, принятый floor'ом для `git push "--force"`;
 * fail-safe, escapable). Это осознанно: для пола under-block страшнее over-block.
 */
export function bashIsContentBlock(command) {
  const raw = String(command || '');
  if (matchBashHardBlacklist(raw)) return true;
  // SE-hardening (M7 Task 1.3 sharp-edges): sub-shell ($()/backtick/process-subst/heredoc) =
  // произвольное исполнение, не де-обфусцируемое подстрочным матчером (split-assembly
  // `$(echo no)$(echo de) -e` собирает интерпретатор только при shell-eval). Рубим как класс,
  // независимо от parse-успеха — закрывает дыру ДО Фазы 8 (увольнение router-gate). Escapable;
  // router-gate тоже блокирует все sub-shell → 0 новых FP.
  if (detectSubshell(raw).found) return true;
  const tok = tokenizeBash(raw);
  if (tok && tok.ok && Array.isArray(tok.segments)) {
    for (const s of tok.segments) {
      if (matchBashHardBlacklist((s.tokens || []).join(' '))) return true;
    }
  }
  return false;
}

/**
 * Решение пола. block=true → необратимое без аварийного выхода / запись в секрет-runtime
 * без аварийного выхода / fail-close. Аварийный выход (floor_escape) — сквозной: свежий
 * (≤5 мин) непогашенный пропуск с ТОЧНЫМ совпадением канон-строки действия снимает блок
 * в любой ветке (Bash-floor и запись в runtime/секрет). Блок-сообщение выводит точный
 * canonicalAction — владелец дословно вставляет его как `FLOOR-ESCAPE: <строка>` (G-5).
 * @param {object} p
 * @param {{name:string,input:object}} p.toolUse
 * @param {Array<{action:string,ts:number}>} [p.escapeGrants]   - read-only floor_escape-пропуски
 * @param {Array<{action:string,ts:number}>} [p.escapeConsumed] - отметки one-shot погашения
 * @param {number} [p.now]
 * @param {Function} [p.normalizeImpl] - injectable pathNormalize (test determinism)
 * @returns {{block:boolean, reason:string}}
 */
export function floorDecide({ toolUse, escapeGrants = [], escapeConsumed = [], now = Date.now(), normalizeImpl = pathNormalize }) {
  if (!toolUse || typeof toolUse !== 'object') return { block: false, reason: 'floor: нет инструмента' };
  const name = toolUse.name;
  const input = toolUse.input || {};
  const action = canonicalAction(name, input, { normalizeImpl });
  const escaped = () => escapeGrantOpen(action, escapeGrants, escapeConsumed, now);

  if (name === 'Bash') {
    // M7 Task 1.3 (правило 8, V1): content-block по СОДЕРЖАНИЮ — независимо от плана, escapable.
    if (bashIsContentBlock(input.command || '')) {
      if (escaped()) return { block: false, reason: 'floor: content-block снят аварийным выходом (floor_escape)' };
      return { block: true, reason: `floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: ${action}` };
    }
    if (bashIsFloor(input.command || '')) {
      if (escaped()) return { block: false, reason: 'floor: разрешено аварийным выходом владельца (floor_escape)' };
      return { block: true, reason: `floor: необратимая команда без аварийного выхода — блок (вето-до-плана); FLOOR-ESCAPE: ${action}` };
    }
    return { block: false, reason: 'floor: Bash не необратимо' };
  }

  // M7 Task 1.4 + PS single-source: PowerShell — psContentBlock (тонкий floor-предикат над ЕДИНЫМ
  // matchPsHardBlacklist = powershell-gate; variant-analysis закрыл дрейф psContentBlock⊂gate) +
  // P-3 forge-страж psProtectedWrite (для New-Item-в-протектед, что у́же blacklist'а). Симметрия с
  // bashIsContentBlock. Реоткрытие v3.8 F1: PowerShell был вне пола. Escapable.
  if (name === 'PowerShell') {
    const cmd = input.command || '';
    if (psContentBlock(cmd) || psProtectedWrite(cmd)) {
      if (escaped()) return { block: false, reason: 'floor: PS content-block снят аварийным выходом (floor_escape)' };
      return { block: true, reason: `floor: опасная PowerShell-команда без аварийного выхода — блок (правило 8, V1-PS); FLOOR-ESCAPE: ${action}` };
    }
    return { block: false, reason: 'floor: PowerShell не опасно по содержанию' };
  }

  if (OBSERVE_TOOLS.has(name)) return { block: false, reason: 'floor: observe-only вне scope записи' };

  // P10-a (атака-линза): путь записи проверяется tool-agnostic (как enforce-runtime-write-deny),
  // не только для именованных Write/Edit — ловит MCP-writer'ы (.env/runtime под чужим tool-name).
  const fp = extractWritePath(input);
  if (!fp) return { block: false, reason: 'floor: нет пути записи' };
  let norm;
  try { norm = String(normalizeImpl(fp) || ''); } catch { return { block: true, reason: `floor: путь записи не резолвится — fail-CLOSED; FLOOR-ESCAPE: ${action}` }; }
  const slashed = norm.split('\\').join('/');
  if (RUNTIME_RE.test(slashed) || SECRET_PATH_RE.some((re) => re.test(slashed))) {
    if (escaped()) return { block: false, reason: 'floor: запись разрешена аварийным выходом владельца (floor_escape)' };
    return { block: true, reason: `floor: запись в runtime/секрет без аварийного выхода — блок; FLOOR-ESCAPE: ${action}` };
  }
  return { block: false, reason: 'floor: запись в обычный файл' };
}
