#!/usr/bin/env node
/**
 * project-graph (§5.3/§5.4) — слоистая подача project-графа наставнику.
 * Читает graphify-out graph.json (networkx node-link) + staleness-сигнал.
 * Чистое ядро (I/O инъектируется), образец — context-verity / plan-lock.refResolves.
 * Гибрид (владелец 2026-06-10): директорий-ось = ПЕРВИЧНАЯ партиция районов;
 * 1009 communities = ВТОРИЧНЫЙ cohesion-сигнал (god-node hints внутри района).
 * Карта районов — ДЁШЕВАЯ «карта метро» (ДР-3 слой-0), НЕ содержимое файлов.
 */

/** Защитный парс node-link JSON → {nodes, links}. Любой сбой → пустые массивы. */
export function parseGraph(text) {
  if (typeof text !== 'string') return { nodes: [], links: [] };
  let obj;
  try { obj = JSON.parse(text); } catch { return { nodes: [], links: [] }; }
  if (!obj || typeof obj !== 'object') return { nodes: [], links: [] };
  return {
    nodes: Array.isArray(obj.nodes) ? obj.nodes : [],
    links: Array.isArray(obj.links) ? obj.links : [],
  };
}

/**
 * Правила район<-source_file (ПЕРВИЧНАЯ ось, Гибрид). Порядок ВАЖЕН: specific
 * префикс ДО generic (docs/superpowers/ до docs/). PLACEHOLDER (V-3/SE8): baseline
 * из плана; Step 0 enumeration (node -e) режется router-gate'ом → НЕ верифицировано
 * против живого graph.json. Заземлить выводом enumeration (owner-терминал, Task 8),
 * сохраняя «specific раньше generic» + OTHER<10%.
 */
export const DISTRICT_RULES = Object.freeze([
  { prefix: 'docs/superpowers/', name: 'docs-superpowers' },
  { prefix: 'docs/observer/', name: 'docs-observer' },
  { prefix: 'docs/adr/', name: 'docs-adr' },
  { prefix: 'docs/registry/', name: 'docs-registry' },
  { prefix: 'docs/', name: 'docs' },
  { prefix: 'app/resources/js/', name: 'app-frontend' },
  { prefix: 'app/tests/', name: 'app-tests' },
  { prefix: 'app/database/', name: 'app-db' },
  { prefix: 'app/', name: 'app-backend' },
  { prefix: 'tools/', name: 'tools' },
  { prefix: 'db/', name: 'db' },
  { prefix: '.claude/skills/', name: 'claude-skills' },
  { prefix: '.claude/agents/', name: 'claude-agents' },
  { prefix: '.claude/', name: 'claude-config' },
]);

/** Район узла по source_file (первый совпавший префикс; fallback 'other'). */
export function nodeDistrict(sourceFile, rules = DISTRICT_RULES) {
  const sf = typeof sourceFile === 'string' ? sourceFile : '';
  if (!sf) return 'other';
  for (const r of rules) {
    if (sf.startsWith(r.prefix)) return r.name;
  }
  return 'other';
}

/**
 * Junk-префиксы source_file (build/зависимости/осиротевшее). PLACEHOLDER (V-3/SE8):
 * baseline из плана; 'app/public/build/' подтверждён [needs_update.log build-CSS].
 * Step 0 enumeration (node -e) режется router-gate'ом → node_modules/vendor/.git
 * НЕ верифицированы против живого graph.json → добавлять только подтверждённые (>0)
 * через owner-терминал enumeration (Task 8).
 */
export const PRUNE_PREFIXES = Object.freeze([
  'app/public/build/',
]);

/** Отсечь узлы с junk-префиксом source_file. @returns {{kept, prunedCount}} */
export function pruneJunk(nodes, prunePrefixes = PRUNE_PREFIXES) {
  const list = Array.isArray(nodes) ? nodes : [];
  const kept = [];
  let prunedCount = 0;
  for (const n of list) {
    const sf = (n && typeof n.source_file === 'string') ? n.source_file : '';
    if (sf && prunePrefixes.some((p) => sf.startsWith(p))) { prunedCount++; continue; }
    kept.push(n);
  }
  return { kept, prunedCount };
}

/** Степень каждого узла = число вхождений его id в source/target всех links. */
export function nodeDegrees(links) {
  const deg = new Map();
  if (!Array.isArray(links)) return deg;
  for (const l of links) {
    if (!l || typeof l !== 'object') continue;
    for (const end of [l.source, l.target]) {
      if (typeof end === 'string' && end) deg.set(end, (deg.get(end) || 0) + 1);
    }
  }
  return deg;
}

/**
 * LAYER-0: карта районов (§5.3). Прунит junk → группирует по району (директорий-ось)
 * → на район: nodeCount + topNodes (по степени, god-nodes района) + communities
 * (вторичный cohesion-сигнал, Гибрид). Чистая; links/rules/префиксы инъектируемы.
 * @returns {Array<{district, nodeCount, topNodes:[{id,label,degree}], communities:number[]}>}
 */
export function buildDistrictMap(nodes, links, opts = {}) {
  const rules = opts.rules || DISTRICT_RULES;
  const prunePrefixes = opts.prunePrefixes || PRUNE_PREFIXES;
  const topN = Number.isInteger(opts.topNodesPerDistrict) ? opts.topNodesPerDistrict : 5;
  const { kept } = pruneJunk(nodes, prunePrefixes);
  const deg = nodeDegrees(links);
  const byDistrict = new Map();
  for (const n of kept) {
    if (!n || typeof n !== 'object') continue;
    const d = nodeDistrict(n.source_file, rules);
    if (!byDistrict.has(d)) byDistrict.set(d, []);
    byDistrict.get(d).push(n);
  }
  const out = [];
  for (const [district, ns] of byDistrict) {
    const ranked = ns
      .map((n) => ({ id: n.id, label: n.label, degree: deg.get(n.id) || 0 }))
      .sort((x, y) => (y.degree - x.degree) || String(x.id).localeCompare(String(y.id)))
      .slice(0, topN);
    const comms = Array.from(new Set(
      ns.map((n) => n.community).filter((c) => typeof c === 'number'),
    )).sort((a, b) => a - b);
    out.push({ district, nodeCount: ns.length, topNodes: ranked, communities: comms });
  }
  return out.sort((a, b) => b.nodeCount - a.nodeCount || a.district.localeCompare(b.district));
}

/**
 * LAYER-1: детали района (§5.3). Узлы района + группировка по community (вторично,
 * Гибрид). community:null → ключ 'none'. Прунинг применяется (junk не в детали).
 * @returns {{district, nodes:[{id,label,file_type,community}], byCommunity:Record<string,string[]>}}
 */
export function districtDetail(nodes, district, opts = {}) {
  const rules = opts.rules || DISTRICT_RULES;
  const prunePrefixes = opts.prunePrefixes || PRUNE_PREFIXES;
  const { kept } = pruneJunk(nodes, prunePrefixes);
  const inDistrict = kept.filter(
    (n) => n && typeof n === 'object' && nodeDistrict(n.source_file, rules) === district,
  );
  const byCommunity = {};
  for (const n of inDistrict) {
    const key = typeof n.community === 'number' ? String(n.community) : 'none';
    (byCommunity[key] ||= []).push(n.id);
  }
  return {
    district,
    nodes: inDistrict.map((n) => ({
      id: n.id, label: n.label, file_type: n.file_type, community: n.community ?? null,
    })),
    byCommunity,
  };
}

const toInt = (v) => (Number.isFinite(Number(v)) ? Math.trunc(Number(v)) : 0);

/**
 * ✅O16 каноничное inline staleness-поле. Чистое: примитивы → {stale, commits_behind,
 * uncommitted}. **СВЕЖО требует ПОЛОЖИТЕЛЬНОГО подтверждения** (§5.4 «сигнал ВСЕГДА»):
 * stale = needs_update присутствует ИЛИ граф-commit ≠ HEAD ИЛИ невозможно сравнить
 * (graphCommit/headCommit пуст). SE-B3: нечитаемый граф (битый junction, SE8) НЕ
 * маскируется под «свежо» — любой пробел сравнения → консервативно stale:true.
 * SE11: commitsBehind === null сохраняется как null («устарел, величина неизвестна»).
 */
export function readStaleness(inputs = {}) {
  const present = inputs.needsUpdatePresent === true;
  const haveBoth = !!inputs.graphCommit && !!inputs.headCommit;
  const mismatch = haveBoth && inputs.graphCommit !== inputs.headCommit;
  return {
    stale: present || mismatch || !haveBoth, // !haveBoth = свежесть не подтверждена (SE-B3)
    commits_behind: inputs.commitsBehind === null ? null : toInt(inputs.commitsBehind),
    uncommitted: toInt(inputs.uncommittedCount),
  };
}

/**
 * Тонкая обёртка: собирает сырые входы для readStaleness из инъектированных fs/git.
 * needsUpdatePath — graphify-out/needs_update; graphReportPath — GRAPH_REPORT.md
 * (строка «Built from commit: `<sha>`»). gitImpl(argsArray) → stdout-строка.
 * SE11: ошибка `git rev-list` (граф-commit вне истории) → commitsBehind = null
 * (НЕИЗВЕСТНО), НЕ 0.
 */
export function gatherStalenessInputs({ readFileImpl, existsImpl, gitImpl, headCommit,
  needsUpdatePath = '.claude/worktrees/graphify-spike/graphify-out/needs_update',
  graphReportPath = '.claude/worktrees/graphify-spike/graphify-out/GRAPH_REPORT.md' }) {
  const needsUpdatePresent = (() => {
    try { return !!existsImpl(needsUpdatePath); } catch { return false; }
  })();
  let graphCommit = '';
  try {
    const rep = String(readFileImpl(graphReportPath) || '');
    const m = rep.match(/Built from commit:\s*`?([0-9a-f]{7,40})`?/i);
    if (m) graphCommit = m[1];
  } catch { graphCommit = ''; }
  let commitsBehind = null;
  try {
    if (graphCommit && headCommit) commitsBehind = toInt(gitImpl(['rev-list', '--count', `${graphCommit}..${headCommit}`]));
  } catch { commitsBehind = null; }
  let uncommittedCount = 0;
  try {
    const st = String(gitImpl(['status', '--porcelain']) || '').trim();
    uncommittedCount = st ? st.split('\n').filter((l) => l.trim()).length : 0;
  } catch { uncommittedCount = 0; }
  return { needsUpdatePresent, graphCommit, headCommit, commitsBehind, uncommittedCount };
}

/**
 * CD-R6-G: собрать ОТДЕЛЬНУЮ project-граф секцию промпта (layer-0 карта районов +
 * inline staleness ✅O16). НЕ skill-каталог. Готова к wiring в buildRouterPrompt (C).
 * Отсутствующий staleness → fail-safe stale:true (сигнал ВСЕГДА, §5.4).
 */
export function buildGraphSection({ districtMap = [], staleness } = {}) {
  const layer0 = Array.isArray(districtMap) ? districtMap : [];
  const safeStaleness = (staleness && typeof staleness === 'object')
    ? {
        stale: !!staleness.stale,
        // SE-B1: SE11-null («величина неизвестна») сохраняется, НЕ схлопывается в 0.
        commits_behind: staleness.commits_behind === null ? null : toInt(staleness.commits_behind),
        uncommitted: toInt(staleness.uncommitted),
      }
    : { stale: true, commits_behind: 0, uncommitted: 0 };
  return {
    kind: 'project-graph',
    districtCount: layer0.length,
    layer0,
    staleness: safeStaleness,
  };
}
