#!/usr/bin/env node
/**
 * escape-grant (Машина 6, Блок 1) — чистое ядро аварийного выхода.
 * Заменяет узкую git-only «дверь владельца» floor-decide.approvalOpen.
 * Пропуск kind:"floor_escape" пишет среда (enforce-askuser-answer-parser на реальный
 * AskUser); контроллер канал не пишет (~/.claude/runtime protected). Одноразовый:
 * гасится PostToolUse-консьюмером после исполнения (floor-escape-consume).
 */
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { normalizeCommand, verifyFloorEscapeRecord } from './askuser-answer-parser.mjs';
import { pathNormalizeSafe } from './path-normalization.mjs';
import { resolveReceiptKey } from './receipt-key-config.mjs';
import fsDefault from 'node:fs';

export const FLOOR_ESCAPE_WINDOW_MS = 5 * 60 * 1000;
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 '';
}

/** Каноническая строка действия (binding-ключ). normalizeImpl инъектируем для тестов.
 *  M7 Фаза 0 (правило 7а, SE-I/L6): тотальна — НИКОГДА не бросает (иначе escape-чек
 *  недостижим и баг кирпичит сессию мимо аварийного выхода владельца). pathNormalizeSafe
 *  тотален; внешний try ловит остаточный throw (бросающий getter/Proxy, мусор). */
export function canonicalAction(toolName, toolInput, { normalizeImpl = pathNormalizeSafe } = {}) {
  try {
    const name = String(toolName || '');
    const input = toolInput || {};
    if (name === 'Bash') return `bash:${normalizeCommand(input.command || '')}`;
    // M7 Task 1.2b (P-2): PowerShell несёт команду в input.command (как Bash), не путь записи.
    // Без этой ветки PS уходил в write-fallback → пустой путь резолвился в cwd → один escape-грант
    // разблокировал ЛЮБУЮ PS-команду в окне. Специфичная канон-строка — escape привязан к точной команде.
    if (name === 'PowerShell') return `powershell:${normalizeCommand(input.command || '')}`;
    if (name.startsWith('mcp__')) {
      let args; try { args = JSON.stringify(input); } catch { args = String(input); }
      return `mcp:${name}:${normalizeCommand(args)}`;
    }
    // ✅O13 (router-mentor): escape привязан к КОНКРЕТНОМУ скилу (не write:cwd-агностик).
    // Канон нормализован (lowercase/trim) — совпадает с тем, что пишет toFloorEscapeRecord.
    if (name === 'Skill') return `skill:${String(input.skill || '').toLowerCase().trim()}`;
    const p = extractWritePath(input);
    return `write:${String(normalizeImpl(p) || '')}`;
  } catch {
    // Тотальность: детерминированный безопасный ключ на любой throw — вход всегда доходит до escape-чека.
    return 'unknown:';
  }
}

/**
 * Найти СВЕЖИЙ непогашенный грант с точным совпадением строки действия — или null.
 * Единый предикат свежести (окно + нижняя граница времени + один-раз) для всех
 * потребителей: open-проверка (стена/пол/egress) И consume (погашение гасит ИМЕННО
 * тот грант, который открыл, без расхождения при дублях/future-ts).
 */
export function findOpenGrant(action, grants, consumed, now = Date.now()) {
  if (!action || !Array.isArray(grants) || grants.length === 0) return null;
  const isConsumed = (g) => Array.isArray(consumed) && consumed.some(
    (c) => c && c.action === g.action && c.ts === g.ts);
  return grants.find(
    (g) => g && g.action === action && typeof g.ts === 'number'
      && now - g.ts >= 0 && now - g.ts <= FLOOR_ESCAPE_WINDOW_MS && !isConsumed(g),
  ) || null;
}

/** Свежий непогашенный пропуск с точным совпадением строки действия? */
export function escapeGrantOpen(action, grants, consumed, now = Date.now()) {
  return findOpenGrant(action, grants, consumed, now) !== null;
}

/**
 * Panic-предикат (M7 Фаза 2, правило 7б): покрывает ли открытый escape-грант
 * действие из события инструмента. Тотален (canonicalAction из Фазы 0 не бросает;
 * внешний try на любой остаток → false). Для panic-веток floor/supreme-gate:
 * escape оценивается даже когда per-tool-логика бросает (иначе баг = кирпич мимо escape).
 */
export function escapeAllowsEvent(event, grants, consumed, now = Date.now()) {
  try {
    const name = event && event.tool_name;
    const input = (event && event.tool_input) || {};
    return escapeGrantOpen(canonicalAction(name, input), grants, consumed, now);
  } catch {
    return false;
  }
}

/**
 * I/O: floor_escape-пропуски сессии (зеркало shell-content::loadApprovedGitOps). M6 FIX-5:
 * key-gated подпись — ключ есть → оставить только валидно-подписанные (форж/неподписанный
 * отброшен); нет ключа (truthy) → принять все (текущее поведение, content-floor backstop).
 * Быстрый путь: пропусков нет → [] БЕЗ резолва ключа (keychain-subprocess не дёргаем на каждый
 * tool-use в пустом случае; защита идентична — проверять нечего). keyImpl/fsImpl/runtimeDir
 * инъектируемы для тестов.
 */
export function loadFloorEscapes(sessionId, now = Date.now(), { keyImpl = resolveReceiptKey, fsImpl = fsDefault, runtimeDir } = {}) {
  const records = readFloorEscapeRecordsAt(sessionId, fsImpl, runtimeDir);
  if (records.length === 0) return [];
  let key = null; try { key = keyImpl(); } catch { key = null; }
  const verified = key ? records.filter((r) => verifyFloorEscapeRecord(r, key)) : records;
  return verified
    .map((r) => ({ action: r.action, ts: typeof r.ts === 'number' ? r.ts : 0 }))
    .filter((g) => now - g.ts <= FLOOR_ESCAPE_WINDOW_MS);
}

/** I/O: отметки-погашения. */
export function loadConsumed(sessionId) {
  const path = join(homedir(), '.claude', 'runtime', `floor-escape-consumed-${sessionId || 'unknown'}.jsonl`);
  if (!existsSync(path)) return [];
  const out = [];
  try {
    for (const line of readFileSync(path, 'utf-8').split(/\r?\n/)) {
      if (!line.trim()) continue;
      let r; try { r = JSON.parse(line); } catch { continue; }
      if (r && typeof r.action === 'string' && typeof r.ts === 'number') out.push({ action: r.action, ts: r.ts });
    }
  } catch { return []; }
  return out;
}

/** Полные floor_escape-записи сессии (с sig), без stripping. runtimeDir/fsImpl инъектируемы
 *  для тестов; runtimeDir по умолчанию — homedir()/.claude/runtime. Единственный вызыватель —
 *  loadFloorEscapes (заменил прежний generic loadRecords). loadConsumed читает ДРУГОЙ файл. */
function readFloorEscapeRecordsAt(sessionId, fsImpl, runtimeDir) {
  const base = runtimeDir || join(homedir(), '.claude', 'runtime');
  const path = join(base, `askuser-decisions-${sessionId || 'unknown'}.jsonl`);
  if (!fsImpl.existsSync(path)) return [];
  const out = [];
  try {
    for (const line of fsImpl.readFileSync(path, 'utf-8').split(/\r?\n/)) {
      if (!line.trim()) continue;
      let r; try { r = JSON.parse(line); } catch { continue; }
      if (r && r.type === 'floor_escape' && typeof r.action === 'string') out.push(r);
    }
  } catch { return []; }
  return out;
}
