#!/usr/bin/env node
/**
 * judge-gate-floor (§3.1/§3.2/§3.5, K2/K5, F7) — механический пол гейтов ($0, не ИИ).
 * Бесплатные проверки «было/не было», на которых стоит думающая часть судьи:
 *  - existenceCheck (Гейт-1 шаг 1): все вопросы отвечены + обещанные навыки реально
 *    вызваны по журналу (неподделываемый канал), не по тексту.
 *  - specToPlanCoverage (Гейт-2): каждый § запечатанной спеки покрыт ≥1 шагом плана.
 *  - k5CriterionCheck (Гейт-2, жемчужина K5): значимый шаг объявляет КОНКРЕТНЫЙ критерий;
 *    голое «проверено/готово» за факт НЕ берём; значимость осторожна (сомнение → значим).
 *  - criteriaGreenMatched (Гейт-3): каждый критерий сопоставлен с настоящим ЗЕЛЁНЫМ прогоном.
 *  - skillTakenByJournal (A2, K2): навык взят по журналу вызовов, не по тексту TodoWrite.
 * Тупо-механика: уболтать нельзя. Качество — думающая часть 4-D + владелец.
 */
import { classifyDestructive } from './classify-destructive.mjs';
import { verifyGreen } from './floor-signer.mjs';

/** Обещанные навыки реально вызваны (по журналу)? Общий помощник для Гейта-1 и A2. */
function skillsCoveredByJournal(promised = [], invoked = []) {
  const seen = new Set(invoked);
  return (promised || []).filter((s) => !seen.has(s));
}

/** Гейт-1 шаг 1: вопросы отвечены + обещанные навыки в журнале. */
export function existenceCheck({ questions = [], skillsPromised = [], skillsInvoked = [] }) {
  const unanswered = (questions || []).filter((q) => !q.answered).map((q) => q.id);
  const missingSkills = skillsCoveredByJournal(skillsPromised, skillsInvoked);
  return { ok: unanswered.length === 0 && missingSkills.length === 0, unanswered, missingSkills };
}

/** Гейт-2 пол: каждый § спеки покрыт хотя бы одним шагом плана (по step.ref). */
export function specToPlanCoverage({ specSections = [], planSteps = [] }) {
  const covered = new Set((planSteps || []).map((s) => s && s.ref).filter(Boolean));
  const uncovered = (specSections || []).filter((sec) => !covered.has(sec));
  return { ok: uncovered.length === 0, uncovered };
}

// Голое done-слово (не критерий). Кириллические варианты — подстрокой/якорем строки.
const BARE_DONE_RE = /^(проверено|проверен[аоы]|сделано|готово|ок|done|passed|pass|ready)\.?$/i;
const MIN_CRITERION_LEN = 6;

// Чисто-читающие инструменты: не мутируют состояние → шаг не значим, критерий не нужен.
const READONLY_OPS = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead']);

/**
 * 5.2 (C3/F18): МЕХАНИЧЕСКАЯ значимость шага — НЕ из контроллер-флагов. Значим, ЕСЛИ:
 *  - object разрушителен (classifyDestructive.suspicious) — перевешивает любой op (анти-обход
 *    «op:Read, object: rm -rf»); ИЛИ
 *  - op НЕ из чисто-читающего набора (мутирующий / неизвестный / пустой op → сомнение → значим).
 * Контроллер-флаги trivial/significant ИГНОРИРУЮТСЯ: их подделка больше не разжалует значимый шаг.
 */
export function isSignificantStep(step) {
  if (!step || typeof step !== 'object') return true; // сомнение → значим (fail-CLOSE)
  const object = String(step.object ?? step.command ?? '');
  if (classifyDestructive(object).suspicious) return true; // разрушительное → всегда значимо
  const op = String(step.op || '');
  if (READONLY_OPS.has(op)) return false; // чистое чтение — критерий не нужен
  return true; // мутирующий / неизвестный op → значим
}

/** Гейт-2 K5: значимый шаг → конкретный проверяемый критерий; «проверено» не факт.
 *  Значимость — механическая (isSignificantStep), флаги контроллера не учитываются (C3/F18). */
export function k5CriterionCheck({ planSteps = [] }) {
  const missingCriterion = [];
  for (const s of planSteps || []) {
    if (!isSignificantStep(s)) continue;             // механически не значим (чистое чтение)
    const c = s && typeof s.criterion === 'string' ? s.criterion.trim() : '';
    const concrete = c.length >= MIN_CRITERION_LEN && !BARE_DONE_RE.test(c);
    if (!concrete) missingCriterion.push(s.n);
  }
  return { ok: missingCriterion.length === 0, missingCriterion };
}

/** Гейт-3 пол: каждый объявленный критерий сопоставлен с настоящим ЗЕЛЁНЫМ прогоном.
 *  Это «green-присутствие» (Δ6 шаг 2): свежесть отпечатка и подпись — отдельные шаги. */
export function criteriaGreenMatched({ criteria = [], greenRuns = [] }) {
  const greenIds = new Set((greenRuns || []).filter((r) => r && r.green === true).map((r) => r.criterion_id));
  const unproven = (criteria || []).filter((c) => !greenIds.has(c.id)).map((c) => c.id);
  return { ok: unproven.length === 0, unproven };
}

/**
 * 5.3 (Δ2, Δ6 шаг 3): свежесть отпечатка. Зелёный прогон засчитывается только если его
 * code_fingerprint совпадает с ТЕКУЩИМ (изменённые файлы шага + тест-файлы; считается живым
 * гейтом, инъектируется как currentFingerprints[criterion_id]). Правка файла после прогона
 * меняет текущий отпечаток → расхождение → green аннулирован (stale). Чистая функция.
 * Конструктивно fail-CLOSE: нет текущего отпечатка (undefined) ≠ записанному → stale.
 * Красные прогоны не проверяются (их «не-зелёность» ловит criteriaGreenMatched).
 */
export function fingerprintFresh({ greenRuns = [], currentFingerprints = {} }) {
  const stale = (greenRuns || [])
    .filter((r) => r && r.green === true)
    .filter((r) => r.code_fingerprint !== currentFingerprints[r.criterion_id])
    .map((r) => r.criterion_id);
  return { ok: stale.length === 0, stale };
}

/**
 * 5.4 (Δ5, Δ6 шаг 4): подлинность green — подпись ПОДПИСАНТА, не совпадение id. Подпись
 * подписанта (floor-signer) покрывает аутентифицирующую тройку {criterion_id, code_fingerprint,
 * occurrence}; реконструируем её из green-run и проверяем verifyGreen. id = целостность (Δ5),
 * подлинность = подпись. Синергия с 5.3: подмена отпечатка для прохода свежести ломает подпись
 * здесь (отпечаток в подписанной тройке). Чистая, fail-CLOSE (нет ключа/sig → unsigned).
 * Красные прогоны не требуют подписи (их «не-зелёность» ловит criteriaGreenMatched).
 */
export function greenSignaturesValid({ greenRuns = [], key }) {
  const unsigned = (greenRuns || [])
    .filter((r) => r && r.green === true)
    .filter((r) => !verifyGreen(
      { criterion_id: r.criterion_id, code_fingerprint: r.code_fingerprint, occurrence: r.occurrence, sig: r.sig },
      key,
    ))
    .map((r) => r.criterion_id);
  return { ok: unsigned.length === 0, unsigned };
}

/** A2 K2: требуемые навыки взяты по фактическому журналу вызовов (не по тексту). */
export function skillTakenByJournal({ requiredSkills = [], journalSkillCalls = [] }) {
  const missing = skillsCoveredByJournal(requiredSkills, journalSkillCalls);
  return { ok: missing.length === 0, missing };
}

/**
 * #3 (Гейт-1, §3.1 шаг 3): механический пол полноты — потребляет результат машины
 * охвата Машины 3 (`readinessChecklist` → {ready, items}) и гейтит на дыры/циклы/
 * сироты/непокрытые просьбы. Результат инъектируется (без жёсткой связки с файлом
 * coverage-machine — оркестрация/живой режим вызывает readinessChecklist и подаёт сюда).
 * Нет результата → fail-closed (сужу вслепую по полноте).
 */
export function gate1CoverageGate({ readiness }) {
  if (!readiness || typeof readiness !== 'object') {
    return { ok: false, failures: [{ label: 'машина охвата не отработала — нет результата (fail-closed)', pointer: null }] };
  }
  const failures = (readiness.items || [])
    .filter((i) => i && i.ok !== true)
    .map((i) => ({ label: i.label, pointer: i.pointer, detail: i.detail }));
  return { ok: readiness.ready === true && failures.length === 0, failures };
}

/**
 * #5 (F3/F9): объявленные критерии приёмки обязаны происходить из ЗАПЕЧАТАННОГО набора
 * (Гейт-1/Гейт-2), а не дозаполняться контроллером на демо-времени. Критерий, чьего id
 * нет в sealedCriterionIds, → unsealed (дозаполнен мимо печати) → флаг.
 */
export function criteriaFromSealedPlan({ criteria = [], sealedCriterionIds = [] }) {
  const sealed = new Set(sealedCriterionIds);
  const unsealed = (criteria || []).filter((c) => !sealed.has(c.id)).map((c) => c.id);
  return { ok: unsealed.length === 0, unsealed };
}
