#!/usr/bin/env node
/**
 * reading-discipline (§5.8/§6.7/§5.7 спеки R6.3) — дисциплина чтения наставника.
 * D определяет ТИП текста (ДР-5) и ВИД чтения; СКИЛ-читалку и «нет скила → воздержание»
 * выбирает РОУТЕР [router-engine.mjs:113,148-149,152-153] — D не дублирует и не выдумывает.
 * Чистое ядро (всё инъектируется). Wiring в стену/шов — sub-plan C2.
 */

/**
 * Закрытый список типов контента (образец LEVEL_SKIP_CATEGORIES [router-engine.mjs:29]).
 * SE4: каждый тип имеет производителя (метаданные Task 1 / grep-fallback Task 2)
 * ИЛИ удалён — narrative-doc убран (производителя нет, generic .md → abstain → переспрос).
 */
export const READING_CONTENT_TYPES = Object.freeze([
  'code', 'spec-design', 'legal', 'scientific', 'data', 'config',
]);

// V-3 PLACEHOLDER: наборы расширений/spec-папок — baseline, уточнить под реальный
// набор расширений проекта при wiring (C2).
const CODE_EXT = new Set(['.mjs', '.js', '.ts', '.tsx', '.vue', '.php', '.py', '.go', '.rs', '.sql', '.sh']);
const DATA_EXT = new Set(['.json', '.jsonl', '.csv', '.ndjson']);
// F-D5: '.env' в CONFIG_EXT достижим только при явном ext='.env' от caller —
// path.extname('.env') даёт '' → основной детект dotfile-конфигов: CONFIG_BASENAMES.
const CONFIG_EXT = new Set(['.yaml', '.yml', '.toml', '.ini', '.env', '.neon']);
const SPEC_PATH_PREFIXES = ['docs/superpowers/', 'docs/adr/'];
// SE12: dotfiles без расширения (extname('.env')==='') ловятся basename'ом.
const CONFIG_BASENAMES = new Set(['.env']);

/**
 * Последний сегмент пути В НИЖНЕМ РЕГИСТРЕ (F-D5: Windows case-insensitive —
 * '.ENV' тот же файл). Без node:path — модуль чистый; / и \ оба разделители.
 */
function basenameOf(p) {
  const parts = String(p || '').split(/[\\/]/);
  return (parts[parts.length - 1] || '').toLowerCase();
}

/**
 * Тип контента из дешёвых метаданных (ext + path + graphNodeType). Уверенно →
 * {contentType, abstain:false}; неоднозначно/неизвестно → {contentType:null, abstain:true}
 * (зеркало воздержания 5.2 — роутер/владелец доразрешат). НЕ выдумываем legal/narrative
 * по пути — что метаданные не покрывают, уходит в grepHeaderFallback (Task 2) → иначе abstain.
 */
export function classifyReadingContent({ ext = '', path = '', graphNodeType = '' } = {}) {
  const e = String(ext || '').toLowerCase();
  const p = String(path || '');
  if (graphNodeType === 'code' || CODE_EXT.has(e)) return { contentType: 'code', abstain: false };
  if (DATA_EXT.has(e)) return { contentType: 'data', abstain: false };
  if (CONFIG_EXT.has(e) || CONFIG_BASENAMES.has(basenameOf(p))) return { contentType: 'config', abstain: false };
  if (e === '.md' && SPEC_PATH_PREFIXES.some((x) => p.startsWith(x))) return { contentType: 'spec-design', abstain: false };
  return { contentType: null, abstain: true, reason: 'тип не определён по метаданным — grep-fallback/переспрос (5.2)' };
}

/**
 * Добор типа по первым строкам файла (headerText инъектируется — caller грепает).
 * Маркеры консервативны и явны; нет совпадения → null (→ воздержание 5.2 у роутера/владельца).
 * SE4 + решение владельца 2026-06-11: scientific ТОЛЬКО по надёжным приметам
 * (Abstract / DOI: / arXiv); голое «Статья» неоднозначно (legal «Статья N» /
 * публицистика) → null. Порядок: spec-design → legal → scientific.
 */
export function grepHeaderFallback({ headerText = '' } = {}) {
  const t = String(headerText || '');
  if (!t.trim()) return null;
  if (/Implementation Plan|## §|design[- ]notes|spec\b/i.test(t)) return 'spec-design';
  if (/Федеральн\w+ закон|№\s*\d+-ФЗ|\bСтатья\s+\d+|\bдоговор\b/i.test(t)) return 'legal';
  if (/\bAbstract\b|\bDOI:|\barXiv\b/i.test(t)) return 'scientific';
  return null;
}

/** 4 вида чтения (§5.8 CD-R6-B). Порядок = приоритет различения. */
export const READ_KINDS = Object.freeze(['graph-map', 'authorial-raw', 'critic-probe', 'harness-mandatory']);

const DEFAULT_GRAPH_PREFIX = '.claude/worktrees/graphify-spike/graphify-out/';

/**
 * Различить вид чтения (CD-R6-B). Имя Read-tool НЕ отличает вид-2 (авторское сырьё)
 * от вид-4 (harness) [enforce-supreme-gate.mjs:43 пускает все Read одинаково] →
 * различаем по предикатам (инъектируемым; C2 свяжет с frozenPlan/plan-step/seam).
 * SE5 DANGER: planAuthorizesPath ОБЯЗАН строиться из frozenPlan+stepPtr (производитель
 * C2-W2 через actionMatchesStep [plan-lock.mjs:86]) — always-true стаб ОТКЛЮЧАЕТ гейт ДР-1.
 * Дефолт null fail-safe: нет производителя → ветка вид-4 пропускается → сырьё → блок.
 * F-D3 DANGER (класс S2, спека §12 round-4): isCriticProbe ОБЯЗАН производиться из
 * вердикта/трассы НАСТАВНИКА (mentor-derived, производитель C) — controller-ставимый
 * true обходит блок ДР-1 до probe-cap.
 * F-D1: путь с сегментом '..' НЕ считается граф-картой (traversal-обход префикса закрыт;
 * такой путь падает в authorial-raw → к блоку). Контракт C2-W7: путь нормализован.
 * F-D2: исключение из planAuthorizesPath глотается → false (→ сырьё → блок, то же направление).
 * Приоритет: граф-карта → авторизованный-шагом (harness) → критик-проверка → авторское сырьё.
 */
export function classifyReadKind({ path = '', frozenPlan = false, planAuthorizesPath = null,
  isCriticProbe = false, graphPathPrefix = DEFAULT_GRAPH_PREFIX } = {}) {
  const p = String(path || '');
  const hasTraversal = p.split(/[\\/]/).includes('..'); // F-D1
  if (!hasTraversal && p.startsWith(graphPathPrefix)) return 'graph-map';
  if (frozenPlan && typeof planAuthorizesPath === 'function') {
    let authorized = false;
    try { authorized = planAuthorizesPath(p) === true; } catch { authorized = false; } // F-D2
    if (authorized) return 'harness-mandatory';
  }
  if (isCriticProbe) return 'critic-probe';
  return 'authorial-raw';
}

/**
 * Гейт ДР-1 (§5.8): блок ТОЛЬКО на авторское сырьё-чтение (вид-2) в impl-режиме
 * (frozenPlan). Виды 1/3/4 свободны. Разговорный режим — НЕ блок, а сигнал владельцу
 * (read-LOG, SE-R7-5: фронт-лоад сырья до заморозки). F-D4: неизвестный/мусорный
 * readKind трактуется как authorial-raw (консервативно, к блоку — не free-pass).
 * @returns {{block, reason, signal}}
 */
export function readingGateDecision({ readKind, frozenPlan = false } = {}) {
  const kind = READ_KINDS.includes(readKind) ? readKind : 'authorial-raw'; // F-D4
  if (kind !== 'authorial-raw') return { block: false, reason: 'не авторское сырьё — свободно', signal: false };
  if (frozenPlan) return { block: true, reason: 'авторское сырьё-чтение вне шага плана — гейт ДР-1 (impl-режим)', signal: false };
  return { block: false, reason: 'разговорный режим — не блок', signal: true };
}

/** F-D6: повреждён ли read-LOG (reads есть, но не массив) либо несёт липкий corrupt-маркер. */
function isCorruptLog(logState) {
  if (logState == null || typeof logState !== 'object') return false; // отсутствие = свежий старт
  if (logState.corrupt === true) return true;
  return logState.reads !== undefined && !Array.isArray(logState.reads);
}

/**
 * Записать чтение в разговорный read-LOG (SE-R7-5). Считается ТОЛЬКО разговорное
 * (frozenPlan=false) авторское сырьё (readKind='authorial-raw') — это фронт-лоад сырья
 * до заморозки. Immutable: возвращает НОВОЕ состояние. F-D6: повреждённый вход не
 * маскируется тихим сбросом — результат несёт липкий corrupt:true (сигнал, не энфорсмент).
 * @returns {{reads:[{path}], corrupt?:true}}
 */
export function recordRead(logState, { path = '', readKind, frozenPlan = false } = {}) {
  const corrupt = isCorruptLog(logState); // F-D6
  const reads = Array.isArray(logState && logState.reads) ? logState.reads : [];
  const next = (readKind === 'authorial-raw' && !frozenPlan)
    ? { reads: [...reads, { path: String(path || '') }] }
    : { reads: [...reads] };
  if (corrupt) next.corrupt = true;
  return next;
}

/**
 * Сигнал владельцу по read-LOG: фронт-лоад сырья превысил порог → warn (НЕ блок).
 * F-D6: повреждённый/corrupt-меченый лог → warn безусловно (счёт недостоверен).
 * @returns {{frontLoadCount, warn, message}}
 */
export function readLogSignal(logState, { threshold = 5 } = {}) {
  const n = Array.isArray(logState && logState.reads) ? logState.reads.length : 0;
  if (isCorruptLog(logState)) {
    return { frontLoadCount: n, warn: true, message: 'read-LOG повреждён (reads не массив / corrupt-маркер) — счёт недостоверен (F-D6)' };
  }
  const warn = n > threshold;
  return {
    frontLoadCount: n,
    warn,
    message: warn ? `read-LOG: ${n} разговорных сырьё-чтений до заморозки (фронт-лоад, SE-R7-5)` : '',
  };
}

/** ✅O19: лимит критик-чтения наставника на КРУГ (×MENTOR_ROUND_CAP=3 в C → макс 6/задача). */
export const MENTOR_PROBE_CAP = 2;

/**
 * Можно ли наставнику ещё одно критик-чтение в этом круге. probeCountThisRound —
 * сколько уже использовано (SE5 DANGER: реальный per-round счётчик — производитель
 * C2-W2/W3; дефолт 0 в decideReadEvent отключает cap до wiring). fail-closed:
 * не-целое/отрицательное → не allowed (не открыть free-reading backdoor мимо ДР-1).
 * F-D7: cap валидируется (целое ≥1) — Infinity/строка/0 не отключают лимит молча.
 * @returns {{allowed, reason}}
 */
export function checkProbeCap(probeCountThisRound, cap = MENTOR_PROBE_CAP) {
  if (!Number.isInteger(cap) || cap < 1)
    return { allowed: false, reason: 'невалидный cap (требуется целое ≥1) — fail-closed (F-D7)' };
  if (!Number.isInteger(probeCountThisRound) || probeCountThisRound < 0)
    return { allowed: false, reason: 'битый счётчик probe — fail-closed' };
  return probeCountThisRound < cap
    ? { allowed: true, reason: `критик-чтение ${probeCountThisRound + 1}/${cap}` }
    : { allowed: false, reason: `лимит критик-чтения исчерпан (${cap}/круг) — к владельцу/след. круг` };
}

/**
 * Сборка для ОДНОГО read-события (готова к wiring C2-W2). Возвращает тип контента
 * (→ роутер выберет скил/воздержится — вне D), вид чтения, решение гейта ДР-1,
 * и статус probe-cap (только для критик-проверки; иначе null). Чистая; всё инъектируется.
 * SE5 DANGER (cross-ref C2-W7): planAuthorizesPath ОБЯЗАН строиться из frozenPlan+stepPtr
 * (always-true стаб ОТКЛЮЧАЕТ гейт ДР-1 — configuration cliff); probeCountThisRound
 * ОБЯЗАН быть реальным per-round счётчиком (дефолт 0 отключает probe-cap). Оба — C2-W2/W3.
 * @returns {{readKind, content, gate, probe:{allowed,reason}|null}}
 */
export function decideReadEvent({ ext = '', path = '', graphNodeType = '', headerText = '',
  frozenPlan = false, planAuthorizesPath = null, isCriticProbe = false,
  probeCountThisRound = 0, graphPathPrefix = DEFAULT_GRAPH_PREFIX } = {}) {
  let content = classifyReadingContent({ ext, path, graphNodeType });
  if (content.abstain && headerText) {
    const fb = grepHeaderFallback({ headerText });
    if (fb) content = { contentType: fb, abstain: false };
  }
  const readKind = classifyReadKind({ path, frozenPlan, planAuthorizesPath, isCriticProbe, graphPathPrefix });
  const gate = readingGateDecision({ readKind, frozenPlan });
  const probe = readKind === 'critic-probe' ? checkProbeCap(probeCountThisRound) : null;
  return { readKind, content, gate, probe };
}
