#!/usr/bin/env node
/**
 * context-verity (§5.6) — машинная сверка verified-context артефакта наставника.
 * Резолв EXTRACTED-цитат по СИМВОЛУ/контенту (anchor-подстрока в файле; номер строки в
 * ref — подсказка, SE-1). Чистое ядро (I/O инъектируется), образец — plan-lock.refResolves.
 * verity = РЕЗОЛВ цитат, НЕ истина (SE-6) — ревью владельца обязателен (§5.10).
 */

// Допустимые виды цитат (§5.5).
export const CITATION_KINDS = Object.freeze(['EXTRACTED', 'INFERRED']);

// SE-A2: минимальная длина anchor. Очень короткая подстрока (1-3 символа) встречается
// почти в любом файле → символ-резолв вырождается (тривиально «находится»). Порог 4 —
// большинство значащих идентификаторов кода ≥4; короче порога EXTRACTED не резолвится
// и понижается до INFERRED (secure-default). Тонкая настройка — design-choice (родственно O9).
export const MIN_ANCHOR_LENGTH = 4;

/** Парс "file:line" → {file, line:number|null} | null (не-строка). Голый файл → line:null. */
export function parseRef(ref) {
  if (typeof ref !== 'string') return null;
  const m = ref.match(/^(.*):(\d+)$/);
  if (!m) return { file: ref, line: null };
  return { file: m[1], line: Number(m[2]) };
}

/**
 * Резолв одной EXTRACTED-цитаты: anchor-подстрока ОБЯЗАНА встретиться в файле
 * (SE-1: по символу/контенту, не по номеру строки — он лишь подсказка). readFileImpl
 * инъектируется (в проде — fs.readFileSync; в тестах — мок). Возвращает {resolved, reason}.
 */
export function resolveCitation(cite, readFileImpl) {
  const parsed = parseRef(cite && cite.ref);
  if (!parsed || !parsed.file) return { resolved: false, reason: 'ref не парсится' };
  const anchor = String((cite && cite.anchor) || '').trim();
  if (!anchor) return { resolved: false, reason: 'нет anchor — резолв по символу невозможен (SE-1)' };
  if (anchor.length < MIN_ANCHOR_LENGTH) return { resolved: false, reason: `anchor короче порога ${MIN_ANCHOR_LENGTH} — символ-резолв вырождается (SE-A2)` };
  let src;
  try { src = readFileImpl(parsed.file); } catch { src = null; }
  if (src == null) return { resolved: false, reason: `файл не прочитан: ${parsed.file}` };
  return String(src).includes(anchor)
    ? { resolved: true, reason: 'anchor найден (символ-резолв)' }
    : { resolved: false, reason: 'anchor не найден в файле' };
}

/**
 * Проверить весь артефакт. EXTRACTED-записи обязаны резолвиться (по anchor). Неразрешённая
 * EXTRACTED попадает в flagged И downgraded (понижение — secure-default S2). INFERRED не
 * резолвится, но ОБЯЗАН нести непустой derivation_ref (VF-1 — иначе flagged, не молчаливый
 * проход; лимит ДОЛИ INFERRED — O9, вне этого модуля). Битая запись (null/не-объект) →
 * flagged, не крашит (зеркало N2 action-journal).
 * ok = есть ≥1 EXTRACTED (VA-9) И нет неразрешённых И нет INFERRED-без-derivation_ref (VF-1).
 * @returns {{ok, flagged:[{id,reason}], downgraded:[{id,reason}], extractedCount, entries}}
 */
export function verifyArtifact(artifact, readFileImpl) {
  const list = Array.isArray(artifact) ? artifact : [];
  const flagged = [];
  const downgraded = [];
  let extractedCount = 0;
  let inferredMissingDerivation = 0;
  let unknownKind = 0;
  const entries = [];
  for (const e of list) {
    if (!e || typeof e !== 'object' || Array.isArray(e)) { flagged.push({ id: null, reason: 'битая запись' }); continue; }
    if (e.kind === 'EXTRACTED') {
      extractedCount++;
      const r = resolveCitation({ ref: e.ref, anchor: e.anchor }, readFileImpl);
      if (!r.resolved) {
        flagged.push({ id: e.id ?? null, reason: r.reason });
        downgraded.push({ id: e.id ?? null, reason: r.reason });
        entries.push({ ...e, kind: 'INFERRED', downgraded_from: 'EXTRACTED' }); // secure-default (S2)
        continue;
      }
    } else if (e.kind === 'INFERRED' && !String(e.derivation_ref || '').trim()) {
      inferredMissingDerivation++;
      flagged.push({ id: e.id ?? null, reason: 'INFERRED без derivation_ref (VF-1)' });
    } else if (!CITATION_KINDS.includes(e.kind)) {
      // SE-A1: kind — controller-влияемая метка; неизвестный kind НЕ обходит проверку молча
      // (третья полоса обхода после S2-мис-метки и VF-1) → flagged + ok:false (fail-closed).
      unknownKind++;
      flagged.push({ id: e.id ?? null, reason: `неизвестный kind: ${JSON.stringify(e.kind)} (SE-A1)` });
    }
    entries.push(e);
  }
  const ok = extractedCount > 0 && downgraded.length === 0 && inferredMissingDerivation === 0 && unknownKind === 0;
  return { ok, flagged, downgraded, extractedCount, entries };
}

/**
 * ✅O2 freeze-side guard: артефакт с ХОТЬ ОДНОЙ неразрешённой EXTRACTED не печатается.
 * Чистый предикат для подключения в plan-lock.freezeArtifact (wiring — sub-plan C, где
 * verified-context артефакт встраивается в sealed-артефакт). Здесь — только готовый guard.
 * ⚠️ SE-A3: guard НЕ покрывает VA-9 — на пустом/null артефакте возвращает false («чисто»),
 * т.к. судит ТОЛЬКО неразрешённые EXTRACTED. Потребитель (freeze-gate, sub-plan C) обязан
 * дополнительно проверять verifyArtifact().ok (там VA-9/VF-1/SE-A1 учтены).
 */
export function artifactHasUnresolvedExtracted(artifact, readFileImpl) {
  return verifyArtifact(artifact, readFileImpl).downgraded.length > 0;
}
