#!/usr/bin/env node
/**
 * judge-engine (§7/§8/§10, K7, G1) — думающая часть судьи. Образец — router-classifier:
 * построитель промпта ЧИСТЫЙ (детерминированно тестируется), сам вызов модели МОКАЕТСЯ,
 * а ПРОВЕРКИ вокруг — тупо-механические (пол судьи 4-A). «Умный не баран».
 *  - VOTE_LAYOUTS: раскладка голосов по функциям (§7), голоса РАЗДЕЛЬНО.
 *  - PREMORTEM_CLASSES: фиксированные классы провала (§10) — общая карта, не растущий список.
 *  - K7: премортем-слот на Гейте-1 обязателен (входит в layout gate1).
 *  - G1 classifyByReversibility: фатальное = тяжёлое И необратимое → блок; иначе совет.
 *  - runJudge: один вердикт + механический пост-чек (слоты/под-прогоны/якорь/обратимость).
 *  - consensusDecision (J3): на высоком риске несколько судей, любой НЕТ → блок.
 * Судья СЛЕП к переписке: на вход подаётся только продукт+цель+карточки (по конструкции).
 */
import { canonicalJson } from './receipt-sign.mjs';
import { validateVerdictSlots } from './judge-verdict-slots.mjs';
import { partitionObjections } from './judge-anchor.mjs';
import { requiredSubRunsPresent } from './judge-subrun-journal.mjs';

export const VOTE_LAYOUTS = Object.freeze({
  gate1: ['completeness', 'premortem', 'goal_advocate', 'correctness'],          // премортем = K7
  gate2: ['spec_fidelity', 'verifiability', 'plan_soundness', 'execution_risk', 'step_clarity'],
  a2_divergence: ['intent_fidelity'],
  a2_destructive: ['radius_reversibility', 'attacker'],
  part_light: ['correctness', 'simplicity', 'footgun'],
  part_risky: ['radius_tests', 'twins', 'attacker'],
  gate3: ['goal_achieved', 'premortem_whole', 'behavior_vs_goal'],
});

export const PREMORTEM_CLASSES = Object.freeze([
  'technique', 'seam', 'user', 'future_change', 'abuse', 'money_irreversible',
]);

/** Линзы функции (+ по риску добавляются attacker и money). */
export function requiredLensesFor(functionName, { risk = false } = {}) {
  const base = VOTE_LAYOUTS[functionName] ? [...VOTE_LAYOUTS[functionName]] : [];
  if (risk) {
    if (!base.includes('attacker')) base.push('attacker');
    if (!base.includes('money')) base.push('money');
  }
  return base;
}

/** Чистый построитель промпта судьи: {system, user}. Детерминирован. */
export function buildJudgePrompt({ functionName, requiredLenses = [], product = {}, goal = '', cards = [], priorObjections = [], controllerResponses = [] }) {
  const system = [
    'Ты — судья (критик, не исполнитель). Выноси МОТИВИРОВАННЫЕ возражения с якорем.',
    'Ты СЛЕП к переписке и рассуждениям сторон — судишь только поданный продукт против цели.',
    'План — это БУДУЩЕЕ: слово «проверено»/«сделано» за ФАКТ не принимай (K5).',
    `Функция: ${functionName}.`,
    `Заполни слот на КАЖДУЮ линзу (пустой слот → вердикт невалиден): ${requiredLenses.join(', ')}.`,
    `Премортем по классам провала (по каждому: что от решений НЕ закрыто): ${PREMORTEM_CLASSES.join(', ')}.`,
    'Каждое возражение укажи с якорем {kind, ref}; без якоря оно станет советом, не блоком.',
  ].join('\n');
  const user = [
    `ЦЕЛЬ (неизменный якорь): ${goal}`,
    `КАРТОЧКИ НАВЫКОВ: ${canonicalJson(cards)}`,
    `ПРОДУКТ НА СУД: ${canonicalJson(product)}`,
  ].join('\n');
  const NL = String.fromCharCode(10);
  const memoryBlock = (Array.isArray(priorObjections) && priorObjections.length)
    ? NL + '--- ТВОИ ПРОШЛЫЕ ПРЕТЕНЗИИ (дословно — НЕ противоречь себе) ---' + NL
      + priorObjections.map((o, i) => '[круг ' + (i + 1) + '] ' + o).join(NL) + NL
      + '--- КАК КОНТРОЛЛЕР ИХ ЗАКРЫЛ ---' + NL
      + (Array.isArray(controllerResponses) ? controllerResponses : []).map((r) => '[круг ' + r.round + '] ' + r.position).join(NL) + NL
      + 'Сначала проверь, СНЯТЫ ли ИМЕННО эти претензии. Новую фатальную претензию поднимай только с обоснованием.'
    : '';
  return { system, user: user + memoryBlock };
}

/**
 * G1: что блокирует (фатальное=тяжёлое И необратимое), что идёт советом.
 * #8 (сомнение→блок): тяжёлое блокирует, ПОКА не доказано обратимое (reversible===true).
 * Отсутствующая/неизвестная обратимость у тяжёлого возражения → блок (fail-closed).
 */
export function classifyByReversibility({ severity, reversible } = {}) {
  const heavy = severity === 'fatal' || severity === 'heavy';
  return (heavy && reversible !== true) ? 'block' : 'advice';
}

function isBlockingObjection(o) {
  return classifyByReversibility(o) === 'block';
}

/**
 * Один заход судьи + механический пост-чек. llmCall(prompt)→{decision, slots, objections}.
 * Механика поверх модели: слоты валидны? под-прогоны на месте? какие возражения реально
 * блокируют (якорь + необратимость)? Модель НЕ может проштамповать GO мимо механики.
 */
export function runJudge({ functionName, requiredLenses, subRunsRequired = [], subRuns = [], llmCall, promptArgs = {} }) {
  const prompt = buildJudgePrompt({ functionName, requiredLenses, ...promptArgs });
  const raw = llmCall(prompt) || {};

  const slotCheck = validateVerdictSlots(raw, requiredLenses);
  if (!slotCheck.valid) {
    return { decision: 'NO-GO', accepted: false, reason: 'слоты вердикта невалидны (§9.2)', slotCheck };
  }
  const subRunCheck = requiredSubRunsPresent(subRunsRequired, subRuns);
  if (!subRunCheck.ok) {
    return { decision: 'NO-GO', accepted: false, reason: 'нет требуемого под-прогона (фейк прилежности, дисциплина #1)', subRunCheck };
  }

  // Аудит M1-M4 (свежий объектив): нормализуем выход модели — objections может прийти
  // не массивом (строка/объект) или с null-элементом. Раньше `(raw.objections||[])` гасил
  // только falsy → .filter падал на не-массиве, а `o.verdict` — на null-элементе (краш ломал
  // вердикт; в инертной обёртке выброс уходил в catch→block:false = fail-open). Теперь не падает.
  const rawObjs = Array.isArray(raw.objections) ? raw.objections : [];
  const noObjs = rawObjs.filter((o) => o && o.verdict === 'NO');
  const { blocking: anchored, advisory: anchorless } = partitionObjections(noObjs);
  const trulyBlocking = anchored.filter(isBlockingObjection);   // якорь + необратимость
  const demotedReversible = anchored.filter((o) => !isBlockingObjection(o)); // якорь, но обратимо → совет
  const advice = [...anchorless, ...demotedReversible];

  return {
    decision: trulyBlocking.length ? 'NO-GO' : 'GO',
    accepted: true,
    blocking: trulyBlocking,
    advice,
    slots: raw.slots,
  };
}

/**
 * J3-консенсус (высокий риск, несколько судей): GO только если ЕСТЬ голоса И КАЖДЫЙ
 * из них чистый GO. Иначе NO-GO. (Аудит M1-M4: пустой список и битый/неполный голос
 * раньше дрейфовали к GO — «нет согласия = не блок» неверно для hard-risk; теперь fail-closed.)
 */
export function consensusDecision(verdicts = []) {
  const list = verdicts || [];
  if (list.length === 0) return 'NO-GO';
  return list.every((v) => v && v.decision === 'GO') ? 'GO' : 'NO-GO';
}
