#!/usr/bin/env node
/**
 * skill-contract — схема контракта скила (C-13 + L-серия) + механический
 * валидатор формы + G4 хеш-страж дрейфа чужих скилов. Чистый модуль (без LLM):
 * полнота/тип слотов — операции над структурой («умный не баран»). Фундамент
 * для машины охвата 3-C (needs/produces = рёбра графа) и входа роутера 3-D.
 */
import { createHash } from 'node:crypto';

// Канонические поля (C-13 базовые needs/produces/constraints + L-поля).
export const CONTRACT_FIELDS = Object.freeze([
  'skill', 'kind', 'needs', 'produces', 'constraints',
  'preview-form', 'defaults', 'key-decisions', 'acceptance-criteria', 'inherent',
]);

// L1 preview-form — закрытый список форм дешёвого образца ('none' для мелочи).
export const PREVIEW_FORMS = Object.freeze(['none', 'outline', 'mockup', 'sample', 'dry-run', 'diagram']);

// Маркеры «прошлого промаха» (гард R4): rationale про природу навыка, НЕ про то,
// что мы прошлый раз прошляпили. Generic (не project-specific), инъецируемо.
export const DEFAULT_MISTAKE_MARKERS = Object.freeze([
  'забыл', 'забыли', 'прошлый раз', 'в прошлый раз', 'пропустил', 'пропустили',
  'промах', 'прошляпил', 'упустил', 'не учли', 'в прошлом',
]);

const ARRAY_FIELDS = Object.freeze(['needs', 'produces', 'constraints', 'defaults', 'key-decisions', 'acceptance-criteria']);

function isStringArray(v) { return Array.isArray(v) && v.every((x) => typeof x === 'string'); }

/**
 * Механический валидатор контракта → {ok, errors[]}. Проверяет ФОРМУ (наличие/тип
 * полей, enum preview-form, source{version,hash} у external), НЕ «полноту под
 * задачу» (это машина охвата 3-C).
 */
export function validateContract(c, { mistakeMarkers = DEFAULT_MISTAKE_MARKERS } = {}) {
  if (!c || typeof c !== 'object') return { ok: false, errors: ['contract is not an object'] };
  const errors = [];
  if (typeof c.skill !== 'string' || !c.skill.trim()) errors.push('skill: non-empty string required');
  if (c.kind !== 'own' && c.kind !== 'external') errors.push("kind: must be 'own' or 'external'");
  for (const f of ARRAY_FIELDS) if (!isStringArray(c[f])) errors.push(`${f}: array of strings required`);
  if (!PREVIEW_FORMS.includes(c['preview-form'])) errors.push(`preview-form: one of ${PREVIEW_FORMS.join('|')}`);
  if (c.kind === 'external') {
    const s = c.source;
    if (!s || typeof s !== 'object') errors.push('external: source{version,hash} required');
    else {
      if (typeof s.version !== 'string' || !s.version.trim()) errors.push('source.version: non-empty string required');
      if (typeof s.hash !== 'string' || !/^[0-9a-f]{64}$/.test(s.hash)) errors.push('source.hash: sha256 hex required');
    }
  }
  if (c.inherent != null) {
    if (!Array.isArray(c.inherent)) {
      errors.push('inherent: array required');
    } else {
      c.inherent.forEach((it, i) => {
        if (!it || typeof it !== 'object' || typeof it.need !== 'string' || !it.need.trim())
          errors.push(`inherent[${i}].need: non-empty string required`);
        else if (typeof it.rationale !== 'string' || !it.rationale.trim())
          errors.push(`inherent[${i}].rationale: non-empty (зачем по природе) required — гард R4`);
        else {
          const low = it.rationale.toLowerCase();
          const hit = (mistakeMarkers || []).find((m) => typeof m === 'string' && m && low.includes(m.toLowerCase()));
          if (hit) errors.push(`inherent[${i}].rationale: похоже на прошлый промах (маркер «${hit}»), нужна «природа навыка» — гард R4`);
        }
      });
    }
  }
  return { ok: errors.length === 0, errors };
}

/** Нормализация формы перед чтением/валидацией: отсутствующие массивы → [],
 *  preview-form → 'none', trim skill. Не «исправляет» невалидное — только форма. */
export function normalizeContract(raw) {
  const c = { ...(raw || {}) };
  if (typeof c.skill === 'string') c.skill = c.skill.trim();
  for (const f of ARRAY_FIELDS) if (c[f] == null) c[f] = [];
  if (c['preview-form'] == null) c['preview-form'] = 'none';
  if (c.inherent == null) c.inherent = [];
  return c;
}

/** Нужды контракта (копия) — рёбра графа 3-C. */
export function needsOf(c) { return Array.isArray(c?.needs) ? [...c.needs] : []; }
/** Что производит (копия) — рёбра графа 3-C. */
export function producesOf(c) { return Array.isArray(c?.produces) ? [...c.produces] : []; }

/** Нужды, присущие навыку по природе (#1) — список need-строк для сверщика полноты. */
export function inherentNeedsOf(c) {
  return Array.isArray(c?.inherent)
    ? c.inherent.map((it) => it?.need).filter((n) => typeof n === 'string' && n.trim())
    : [];
}

/** sha256 содержания — отпечаток чужого скила для G4. */
export function contractHash(content) {
  return createHash('sha256').update(String(content ?? '')).digest('hex');
}

/**
 * G4 — хеш-страж дрейфа (C-13 ключевой гард-рейл): сверяет сохранённый отпечаток
 * (contract.source.hash) с актуальным содержанием чужого SKILL.md. Расхождение →
 * drifted=true + fallback 'soft-reasoning' (откат на мягкое рассуждение судьи).
 * Своим (kind='own') не сторожим — контракт пишем сами.
 */
export function checkContractDrift({ contract, currentContent }) {
  if (!contract || contract.kind !== 'external')
    return { ok: true, drifted: false, reason: 'own/нет внешнего источника — дрейф не сторожится' };
  // G-E: нет локального источника (path пуст) И нечего сравнивать (content не прочитан) →
  // сторожить нечего, G4 инертен. Это ровно прод-случай зеро-хеша (Р5 MCP/marketplace):
  // loadRegistry при пустом path не читает content → currentContent === undefined.
  // Прямой вызов с поданным currentContent (тест/иной потребитель) → drift сверяется как обычно.
  if (!contract.source?.path && currentContent == null)
    return { ok: true, drifted: false, reason: 'external без локального source.path и без содержания — дрейф не сторожится (G4 инертен)' };
  const stored = contract.source?.hash;
  const actual = contractHash(currentContent);
  if (!stored) return { ok: false, drifted: true, reason: 'нет сохранённого отпечатка внешнего скила', fallback: 'soft-reasoning' };
  if (stored !== actual)
    return { ok: false, drifted: true, reason: `дрейф чужого скила: ${stored.slice(0, 12)}… ≠ ${actual.slice(0, 12)}…`, fallback: 'soft-reasoning' };
  return { ok: true, drifted: false, reason: 'отпечаток совпал' };
}

/**
 * G1 — мягкий страж нейтральности этикетки: контракт должен быть про САМ скил, без
 * проектных фактов («лендингу нужен teal» → хардкод). Список проектных терминов
 * ИНЪЕЦИРУЕТСЯ (портативно, без зашитых проектных слов в коде); пустой список →
 * neutral (нечего судить). Совпадение (без регистра, подстрока) по любому
 * строковому полю → not neutral + перечень. Это страж авторства, не блок формы.
 */
export function checkContractNeutrality(contract, { projectTerms = [] } = {}) {
  const terms = (projectTerms || []).filter((t) => typeof t === 'string' && t.trim());
  if (terms.length === 0) return { neutral: true, hits: [] };
  // F4: «любое строковое поле» — массивы + inherent{need,rationale} + имя скила,
  // а не только ARRAY_FIELDS (проектный термин мог прятаться в inherent-rationale).
  const fromArrays = ARRAY_FIELDS.flatMap((f) => (Array.isArray(contract?.[f]) ? contract[f] : []));
  const fromInherent = Array.isArray(contract?.inherent)
    ? contract.inherent.flatMap((it) => [it?.need, it?.rationale].filter((x) => typeof x === 'string'))
    : [];
  const fromSkill = typeof contract?.skill === 'string' ? [contract.skill] : [];
  const haystack = [...fromArrays, ...fromInherent, ...fromSkill].join('\n').toLowerCase();
  const hits = [...new Set(terms.filter((t) => haystack.includes(t.toLowerCase())))];
  return { neutral: hits.length === 0, hits };
}
