#!/usr/bin/env node
/**
 * plan-lock — Замок плана (несущий №2, P15-f): заморозка одобренного плана
 * хеш-печатью (HMAC из Машины 1). «В плане» — фикция без печати: правка шагов
 * меняет sig, что видно владельцу. Детерминированный матч «действие ↔ шаг».
 */
import { createHash } from 'node:crypto';
import fsDefault from 'node:fs';
import { canonicalJson, signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs';
import { pathNormalize } from './path-normalization.mjs';
import { assertSafeSessionId } from './action-journal.mjs';
import { deserializePointer, nodeAt, normalizeToLeaf, isContainer, MAX_TREE_DEPTH } from './step-pointer.mjs';

/** Стабильный id плана = sha256 канонизированных шагов. */
export function planId(steps) {
  return createHash('sha256').update(canonicalJson(steps)).digest('hex');
}

/**
 * Детерминированный criterion_id шага (Машина 5 Пакет 5, 5.1, Δ5): sha256 канонизированного
 * СОДЕРЖИМОГО шага БЕЗ самого criterion_id (идемпотентность на повторной заморозке). Чистая
 * функция от публичного содержания → id = ЦЕЛОСТНОСТЬ, не подлинность (контроллер пересчитает;
 * подлинность даёт подпись подписанта на зелёном прогоне, Δ5). Ценность здесь: id привязан
 * к смыслу шага (подмена object/op меняет id) и запечатывается подписью плана (нельзя выдумать
 * id на демо-этапе мимо печати — sealedCriterionIds = вход для criteriaFromSealedPlan).
 */
export function stepCriterionId(step) {
  const { criterion_id: _drop, ...rest } = step || {};
  return createHash('sha256').update(canonicalJson(rest)).digest('hex');
}

/** Обогатить шаги детерминированным criterion_id ДО печати (5.1). R-08: рекурсия в substeps —
 *  под-шаги тоже получают criterion_id (запечатан на всех уровнях). */
function withCriterionIds(steps) {
  return (steps || []).map((s) => {
    const base = { ...s, criterion_id: stepCriterionId(s) };
    if (Array.isArray(s.substeps)) base.substeps = withCriterionIds(s.substeps);
    return base;
  });
}

/** Запечатанный набор criterion_id плана — вход для criteriaFromSealedPlan (Гейт-2, F3/F9). */
export function sealedCriterionIds(frozenPlan) {
  // V1 (R-08): листья дерева, не только верхний уровень — иначе пропускаем criterion_id под-шагов.
  return treeLeaves((frozenPlan && frozenPlan.steps) || []).map((s) => s && s.criterion_id).filter(Boolean);
}

/** Допустимые режимы судьи в печати (defense-in-depth, SE-2 §4 / sealed-plan §11):
 *  null/undefined (нет режима / legacy-печать без поля — на inert печать не ставится),
 *  'shadow' (наблюдение), 'live-block' (энфорсмент). Любое иное (опечатка 'Shadow'/'live_block',
 *  'inert', не-строка) — fail-CLOSE: мнимый режим в печать не попадает (стена whitelist'ит
 *  только 'live-block'; источник так же fail-closed, как соседние ворота). */
export function assertValidJudgeMode(mode) {
  if (mode === undefined || mode === null) return;
  if (mode === 'shadow' || mode === 'live-block') return;
  throw new Error(`invalid judge_mode: ${JSON.stringify(mode)} (allowed: null|'shadow'|'live-block')`);
}

/** Заморозить план: проставить id + версию артефакта + время + подпись-печать.
 *  artifactId — на какой опечатанный артефакт опирается план (null, если без артефакта).
 *  5.1: каждый шаг получает детерминированный criterion_id ДО planId/печати → id запечатан. */
export function freezePlan({ steps, skills = [], artifactId = null, judgeMode = null, key, nowMs }) {
  assertValidJudgeMode(judgeMode);
  const sealedSteps = withCriterionIds(steps);
  const id = planId(sealedSteps);
  // judge_mode входит в ПОДПИСАННУЮ базу (VA-2/SE-2): стена различает shadow- и live-печать,
  // подмена режима ломает подпись. Существующие печати без judge_mode: base без поля → verify ок.
  const base = { plan_id: id, artifact_id: artifactId, judge_mode: judgeMode, skills: Array.isArray(skills) ? skills : [], frozen_at: typeof nowMs === 'number' ? nowMs : Date.now(), steps: sealedSteps };
  return { ...base, sig: signPayload(base, key, RECEIPT_DOMAINS.FROZEN_PLAN) };
}

/** Печать цела? (правка шагов/времени/id → false; без ключа/подписи → false). */
export function verifyFrozenPlan(plan, key) {
  if (!plan || typeof plan !== 'object') return false;
  return verifyReceipt(plan, key, RECEIPT_DOMAINS.FROZEN_PLAN);
}

/** Нормализация команды Bash: схлопнуть пробелы, trim. */
function normCommand(c) { return String(c || '').split(/\s+/).filter(Boolean).join(' '); }

/**
 * Детерминированный матч действия и шага (P15-a + P15-e): op И object обязаны
 * совпасть. Файловые object — через normalize (default pathNormalize); Bash — через
 * normCommand. Никакого LLM, никаких зашитых списков.
 */
export function actionMatchesStep(step, action, { normalize = pathNormalize } = {}) {
  if (!step || !action) return false;
  if (String(step.op) !== String(action.op)) return false;
  if (step.op === 'Bash') {
    const stepCmd = normCommand(step.object);
    if (!stepCmd) return false; // F5 (аудит M1-M4): пустой шаг-команда не матчит ничего
    return stepCmd === normCommand(action.object);
  }
  // F5: пустой файловый object шага = брак (не валидный шаг плана) → джокером быть не может.
  // Иначе шаг {op:'Write', object:''} совпал бы с любым Write, чей путь не извлёкся (object '').
  if (!String(step.object ?? '').trim()) return false;
  let a, b;
  try { a = normalize(String(step.object)); b = normalize(String(action.object)); }
  catch { return false; }
  return a === b;
}

/** Шаг по указателю (или null за концом). */
export function nextStep(steps, ptr) {
  if (!Array.isArray(steps) || ptr < 0 || ptr >= steps.length) return null;
  return steps[ptr];
}

// ── R-08: дерево листьев / лист по указателю / валидация дерева ──

/** Все листья дерева depth-first (V1/V2 вход). Плоский план → сами steps. */
export function treeLeaves(steps) {
  const out = [];
  const walk = (arr) => {
    for (const s of arr || []) {
      if (s && Array.isArray(s.substeps)) walk(s.substeps);
      else out.push(s);
    }
  };
  walk(steps);
  return out;
}

/** Один лист по сериализованному указателю (целое/массив). Спуск через контейнеры (SE-2):
 *  возвращает узел БЕЗ substeps либо null (за концом / битый указатель / контейнер-тупик). */
export function treeLeafAt(steps, serializedPtr) {
  const p0 = deserializePointer(serializedPtr, steps);
  if (!p0) return null;
  let leafPtr;
  try { leafPtr = normalizeToLeaf(steps, p0); } catch { return null; }
  if (!leafPtr) return null;
  const node = nodeAt(steps, leafPtr);
  if (node == null || isContainer(node)) return null;
  return node;
}

/** Структурная валидация дерева ДО доверия (fail-CLOSED): SE-2 контейнер не несёт op/object/ref;
 *  SE-4 непустой substeps + глубина ≤ предела; substeps только массив. */
export function validatePlanTree(steps) {
  const check = (arr, depth) => {
    if (depth > MAX_TREE_DEPTH) return false;
    if (!Array.isArray(arr)) return false;
    for (const s of arr || []) {
      if (!s || typeof s !== 'object') return false;
      if ('substeps' in s) {
        if (!Array.isArray(s.substeps) || s.substeps.length === 0) return false; // SE-4
        if (s.op != null || s.object != null || s.ref != null) return false;     // SE-2
        if (!check(s.substeps, depth + 1)) return false;
      }
    }
    return true;
  };
  return { ok: check(steps, 1) };
}

function planPath(runtimeDir, sessionId) {
  assertSafeSessionId(sessionId); // N3-shared guard формы sessionId (path-traversal)
  const sep = runtimeDir.endsWith('/') ? '' : '/';
  return `${runtimeDir}${sep}frozen-plan-${sessionId}.json`;
}

/** Атомарная запись печати (SE-4/VA-3): temp → rename. Битый/частичный файл не кирпичит
 *  стену до пере-печати — финальный путь появляется лишь после полной записи temp. */
function writeAtomic(path, data, fsImpl) {
  const tmp = `${path}.tmp-${planId([path, data.length])}`;
  fsImpl.writeFileSync(tmp, data);
  fsImpl.renameSync(tmp, path);
}

export function saveFrozenPlan({ plan, sessionId, runtimeDir, fsImpl = fsDefault }) {
  writeAtomic(planPath(runtimeDir, sessionId), JSON.stringify(plan), fsImpl);
}
export function loadFrozenPlan({ sessionId, runtimeDir, fsImpl = fsDefault }) {
  try { return JSON.parse(fsImpl.readFileSync(planPath(runtimeDir, sessionId), 'utf8')); }
  catch (e) { if (e && e.code === 'ENOENT') return null; throw e; }
}

/**
 * Фаза 5 (чистое завершение, спека §6.5): снять печать плана (unlink). После последнего
 * шага стена зовёт это сама → следующее действие в разговорном режиме. Нет файла → no-op
 * (best-effort на ENOENT). path-guard через planPath (assertSafeSessionId).
 */
export function removeFrozenPlan({ sessionId, runtimeDir, fsImpl = fsDefault }) {
  const p = planPath(runtimeDir, sessionId);
  try { fsImpl.unlinkSync(p); }
  catch (e) { if (e && e.code === 'ENOENT') return; throw e; }
}

/** Каждое журнальное действие обязано иметь шаг плана; иначе — сирота (пропущен гейт). */
export function reconcileJournalToPlan(journal, steps, { normalize = pathNormalize } = {}) {
  // V2 (R-08): матчим по листьям дерева — иначе лист-действие не совпадёт с контейнером верхнего
  // уровня → ложные сироты «обход стены». Плоский план → treeLeaves = steps (без регрессии).
  const leaves = treeLeaves(steps);
  const orphans = (journal || []).filter(
    (a) => !leaves.some((s) => actionMatchesStep(s, a, { normalize }))
  );
  return { ok: orphans.length === 0, orphans };
}

/** Стабильный id артефакта = sha256 канонизированного содержания. */
export function artifactId(artifact) {
  return createHash('sha256').update(canonicalJson(artifact)).digest('hex');
}
/** Заморозить артефакт разговорной фазы (вторая печать). */
export function freezeArtifact({ artifact, key, nowMs }) {
  assertValidJudgeMode(artifact && artifact.judge_mode);
  const base = { ...artifact, artifact_id: artifactId(artifact), frozen_at: typeof nowMs === 'number' ? nowMs : Date.now() };
  return { ...base, sig: signPayload(base, key, RECEIPT_DOMAINS.FROZEN_ARTIFACT) };
}
/** Печать артефакта цела? */
export function verifyFrozenArtifact(artifact, key) {
  if (!artifact || typeof artifact !== 'object') return false;
  return verifyReceipt(artifact, key, RECEIPT_DOMAINS.FROZEN_ARTIFACT);
}

// Персист артефакта (как у плана, Task 3) — нужен рантайму: main() грузит его,
// стена сверяет версию (artifact_id) и резолвит ссылки шагов (закрытая дверь).
function artifactPath(runtimeDir, sessionId) {
  assertSafeSessionId(sessionId); // N3-shared guard формы sessionId (path-traversal)
  const sep = runtimeDir.endsWith('/') ? '' : '/';
  return `${runtimeDir}${sep}frozen-artifact-${sessionId}.json`;
}
export function saveFrozenArtifact({ artifact, sessionId, runtimeDir, fsImpl = fsDefault }) {
  // Артефакт персистится атомарно и ДО плана (VA-3) — порядок событий в хуке судьи.
  writeAtomic(artifactPath(runtimeDir, sessionId), JSON.stringify(artifact), fsImpl);
}
export function loadFrozenArtifact({ sessionId, runtimeDir, fsImpl = fsDefault }) {
  try { return JSON.parse(fsImpl.readFileSync(artifactPath(runtimeDir, sessionId), 'utf8')); }
  catch (e) { if (e && e.code === 'ENOENT') return null; throw e; }
}

/**
 * Закрытая дверь (C-5): если у шага есть ссылка ref на решение артефакта,
 * она ОБЯЗАНА резолвиться в опечатанном артефакте. Нет ref → требование снято
 * (простой шаг). Есть ref, но раздела нет → false (стена блокирует).
 */
export function refResolves(step, frozenArtifact) {
  if (!step || !step.ref) return true;
  const sections = (frozenArtifact && frozenArtifact.sections) || {};
  return Object.prototype.hasOwnProperty.call(sections, step.ref);
}

export const _internals = { fsDefault };
