#!/usr/bin/env node
/**
 * Transcript parser for the brain governance observer.
 * Deterministically extracts episode fields from a Claude Code session
 * transcript (JSONL). No LLM — pure parsing.
 *
 * Scope: the last turn (from the last real user prompt to end of file) —
 * one episode == one prompt→response cycle.
 *
 * Reasoning fields (triggers_matched / candidates_considered /
 * boundaries_applied) are NOT recoverable from a transcript and stay [];
 * their capture is a separate design question (ADR-011 follow-up).
 *
 * Security Guidance #40: pure parsing — no exec/execSync.
 * Per ADR-011 §6 + spec v1.1 §5.2.1.
 */

import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { readRouterState, extractRouterFields, extractClassifierOutput } from './observer-state-enricher.mjs';
import { CLASSIFIER_MODEL } from './router-config.mjs';
import { homedir } from 'node:os';
import { detectChoiceProvenance, detectAskUserQuestionChoice } from './observer-choice-detector.mjs';
import { loadChainMap, chainsFor } from './observer-chain-detector.mjs';
import { buildHookMap, resolveScriptCounts } from './observer-hook-resolver.mjs';
// recommendNode / buildClassificationMap / buildDormancyMap были использованы
// для слепого fallback на heuristic recommended_node — убрано 2026-05-26
// (brain-retro #6 follow-up). Импорты сняты как dead code.
import { loadRegistry } from './registry-load.mjs';
import { extractV4Signals } from './observer-v4-signals.mjs';
import { JUDGE_PER_CALL_USD } from './cost-pricing.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));

let CHAIN_MAP = null;
try {
  CHAIN_MAP = loadChainMap();
} catch {
  CHAIN_MAP = new Map(); // битый/отсутствующий JSON -> chainsFor вернёт null, observer не падает
}

let HOOK_MAP = null;
function getHookMap() {
  if (HOOK_MAP) return HOOK_MAP;
  const read = (p) => { try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return {}; } };
  HOOK_MAP = buildHookMap(
    read(join(__dirname, '..', '.claude', 'settings.json')),
    read(join(homedir(), '.claude', 'settings.json'))
  );
  return HOOK_MAP;
}

/**
 * Whitelist of router-node names. Used by extractCandidates to filter out
 * free-form prose bullets (analysis text, procedure steps, code snippets) that
 * the regex on its own would happily slurp into candidates_considered.
 * Sources, in order:
 *   - tools/observer-known-nodes.txt — bare names (brainstorming, ccpm, …)
 *   - tools/observer-chain-map.json keys — incl. plugin:skill form
 *   - sentinel "direct" (no-skill marker used by node_chosen)
 * Tooling IDs (#NN) and arbitrary plugin:skill forms pass via regex below.
 */
const KNOWN_NODES = (() => {
  const set = new Set(['direct']);
  try {
    const txt = readFileSync(join(__dirname, 'observer-known-nodes.txt'), 'utf8');
    for (const line of txt.split('\n')) {
      const t = line.replace(/#.*$/, '').trim();
      if (t) set.add(t);
    }
  } catch {
    // file missing in some test sandboxes — fall back to chain-map keys only
  }
  if (CHAIN_MAP) for (const node of CHAIN_MAP.keys()) set.add(node);
  return set;
})();

const TOOLING_ID_RE = /^#\d+$/;
const NAMESPACED_SKILL_RE = /^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)?$/;

/**
 * Strip lightweight markdown wrappers (bold, italic, code, trailing punctuation)
 * before testing against the whitelist. Conservative — we accept that some
 * weirdly-formatted node names slip through, but free-form prose bullets do not.
 */
function normalizeCandidate(s) {
  let t = String(s || '').trim();
  // peel outer markdown markers: **x**, *x*, `x`, _x_
  while (
    (t.startsWith('**') && t.endsWith('**') && t.length > 4) ||
    (t.startsWith('`') && t.endsWith('`') && t.length > 2) ||
    (t.startsWith('*') && t.endsWith('*') && t.length > 2) ||
    (t.startsWith('_') && t.endsWith('_') && t.length > 2)
  ) {
    if (t.startsWith('**')) t = t.slice(2, -2).trim();
    else t = t.slice(1, -1).trim();
  }
  // drop trailing punctuation (commas, periods, em-dashes) that lists often leave
  t = t.replace(/[.,;:!?—–-]+$/u, '').trim();
  return t;
}

function isKnownNode(raw) {
  const t = normalizeCandidate(raw);
  if (!t) return false;
  if (KNOWN_NODES.has(t)) return true;
  if (TOOLING_ID_RE.test(t)) return true;
  // namespaced plugin:skill we haven't seen yet — accept if shape matches and
  // contains no whitespace (a free-form bullet with a colon in prose won't pass).
  if (NAMESPACED_SKILL_RE.test(t)) return true;
  return false;
}

const SUPERPOWERS_PREFIX = 'superpowers:';

function parseLines(text) {
  const entries = [];
  let broken = 0;
  let total = 0;
  // quirk #101 root fix: Claude Code's transcript file accumulates duplicated
  // context-rebuild snapshots — the same entry is re-printed with the SAME
  // `uuid`. Without dedup, session_turn / task_size / events double-count and
  // session_turn becomes non-monotonic across episodes parsed at different
  // file-growth states. Keep the first occurrence per uuid; entries without a
  // uuid (synthetic test fixtures) pass through unchanged.
  const seenUuid = new Set();
  for (const line of String(text || '').split('\n')) {
    const trimmed = line.trim();
    if (!trimmed) continue;
    total += 1;
    let e;
    try {
      e = JSON.parse(trimmed);
    } catch {
      broken += 1; // broken line — counted for parse_gap, never thrown
      continue;
    }
    if (e && e.uuid) {
      if (seenUuid.has(e.uuid)) continue;
      seenUuid.add(e.uuid);
    }
    entries.push(e);
  }
  return { entries, broken, total };
}

// Synthetic user-role messages — NOT genuine prompts, must not be turn boundaries.
// Skill invocation content, local slash-command output/invocation, interrupt markers
// are recorded with role:'user' but carry no UserPromptSubmit hook context.
const SYNTHETIC_PROMPT_MARKERS = [
  'Base directory for this skill:',
  '<local-command-stdout>',
  '<local-command-caveat>',
  '<command-name>',
  '[Request interrupted by user]',
];

function isSyntheticPrompt(text) {
  const t = String(text || '').trimStart();
  return SYNTHETIC_PROMPT_MARKERS.some((m) => t.startsWith(m));
}

// A genuine user prompt (turn boundary) — not a tool_result carrier nor a
// synthetic skill/command/interrupt message.
function isRealUserPrompt(entry) {
  const msg = entry && entry.message;
  if (!msg || msg.role !== 'user') return false;
  const c = msg.content;
  if (typeof c === 'string') {
    return c.trim().length > 0 && !isSyntheticPrompt(c);
  }
  if (Array.isArray(c)) {
    const hasToolResult = c.some((b) => b && b.type === 'tool_result');
    const hasText = c.some((b) => b && b.type === 'text');
    if (!hasText || hasToolResult) return false;
    const text = c
      .filter((b) => b && b.type === 'text')
      .map((b) => b.text || '')
      .join(' ');
    return !isSyntheticPrompt(text);
  }
  return false;
}

function findTurnStart(entries) {
  for (let i = entries.length - 1; i >= 0; i--) {
    if (isRealUserPrompt(entries[i])) return i;
  }
  return 0;
}

function stripSystemReminders(text) {
  return String(text || '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
}

function promptText(entry) {
  const c = entry && entry.message && entry.message.content;
  if (typeof c === 'string') return stripSystemReminders(c);
  if (Array.isArray(c)) {
    const joined = c
      .filter((b) => b && b.type === 'text')
      .map((b) => b.text || '')
      .join(' ');
    return stripSystemReminders(joined);
  }
  return '';
}

export function classifyTask(text) {
  const t = String(text || '').toLowerCase();
  if (/обнови эталон|sync memory|обнови (?:память|memory|memory\.md)/.test(t)) return 'memory-sync';
  if (/обнови claude|правк[аи] pravila|update pravila|обнови psr|обнови tooling|нормативка/.test(t)) return 'regulatory-bump';
  if (/план|plan\b|спроектируй|design\b|brainstorm|обсудим/.test(t)) return 'planning';
  if (/\bpush\b|\bmerge\b|\bdeploy\b|\brelease\b|релиз|тегни/.test(t)) return 'release';
  if (/рефактор|refactor/.test(t)) return 'refactor';
  if (/баг|bug|почини|исправ|fix\b|сломан|broken/.test(t)) return 'bugfix';
  if (/фич|feature|добав|implement|реализ|создай|create|новый|new /.test(t)) return 'feature';
  if (/докум|readme|\bdocs?\b/.test(t)) return 'docs';
  if (/проанализ|анализ|оцени|review|examine|разбор|посмотри что/.test(t)) return 'analysis';
  if (/убери|удали|почисть|cleanup|очисти|drop\s/.test(t)) return 'cleanup';
  if (/^\s*статус\b|\bstatus\b|проверь состоян|health/.test(t)) return 'monitoring';
  if (/\?|как |что |почему|зачем|why|how |what /.test(t)) return 'question';
  return 'other';
}

function collectToolUse(entries) {
  const skills = [];
  const counts = {};
  const errors = [];
  const idToTool = {};
  // First pass — build id→tool name map (tool_results may reference tools across messages)
  for (const e of entries) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const b of content) {
      if (b && b.type === 'tool_use') idToTool[b.id] = b.name || 'unknown';
    }
  }
  // Second pass — accumulate counts + per-error attribution
  for (const e of entries) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const block of content) {
      if (!block || typeof block !== 'object') continue;
      if (block.type === 'tool_use') {
        const name = block.name || 'unknown';
        counts[name] = (counts[name] || 0) + 1;
        if (name === 'Skill') {
          skills.push((block.input && block.input.skill) || 'unknown');
        }
      } else if (block.type === 'tool_result' && block.is_error === true) {
        const tool = idToTool[block.tool_use_id] || 'unknown';
        const c = block.content;
        const text = typeof c === 'string' ? c
          : (Array.isArray(c) ? c.map((b) => (b && typeof b.text === 'string') ? b.text : '').join(' ') : '');
        errors.push({ tool, summary: text.slice(0, 80) });
      }
    }
  }
  return { skills, counts, errors };
}

const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit']);

/**
 * Deterministic environment factors for the turn that starts at turnStartIdx.
 * economy_level / parallel_session are scanned from the stringified turn;
 * model / post_compaction / session_turn from structural fields.
 */
export function extractEnvironment(allEntries, turnStartIdx) {
  const turn = allEntries.slice(turnStartIdx);
  const rawTurn = JSON.stringify(turn);

  const econ = rawTurn.match(/=== ECONOMY MODE:\s*(\d+)\s*%/);
  const economy_level = econ ? Number(econ[1]) : null;

  let model = null;
  for (const e of turn) {
    if (e && e.message && e.message.model) {
      model = e.message.model;
      break;
    }
  }

  // The transcript file accumulates duplicated context-rebuild snapshots
  // (repeated isCompactSummary entries — see feedback_environment quirk #101).
  // Counting prompts from i=0 inflates session_turn with those dupes. Count
  // from the LAST compaction before the turn: session_turn = real prompts
  // since it, which is monotonic ("turns since last compaction").
  let lastCompactIdx = -1;
  for (let i = 0; i < turnStartIdx && i < allEntries.length; i++) {
    if (allEntries[i] && allEntries[i].isCompactSummary === true) lastCompactIdx = i;
  }
  const post_compaction = lastCompactIdx >= 0;

  let session_turn = 0;
  for (let i = lastCompactIdx + 1; i <= turnStartIdx && i < allEntries.length; i++) {
    if (isRealUserPrompt(allEntries[i])) session_turn += 1;
  }

  // Only strong collision evidence — a bare mention of "parallel sessions" is
  // not a signal (best-effort per spec R2; prefer false-negative over false-positive).
  // Scope NARROWED to tool_result content (real command output / Bash stderr): prose
  // mentions in user prompts / assistant text — including analysis text that
  // references collision phrases — must not trigger. Fixes live FP (episode line 20).
  const parallel_session =
    /чужой staged|foreign git index|index\.lock|another git process/i.test(collectToolResultText(turn))
    || hasPreFlightFetch(turn);

  return { economy_level, model, post_compaction, session_turn, parallel_session };
}

/**
 * Pravila §15.2 pre-flight signal (Task 13 PIVOT): Bash-команда turn'а
 * содержит `git fetch ... && git log HEAD..origin/main ...` — это hard-rule
 * pre-flight sync перед правкой нормативки в параллельных сессиях. Сильный
 * сигнал «заказчик ожидает параллельных сессий», аддитивный к F1 collision
 * detector (parallel_session). Не overwrite — OR-clause.
 */
function hasPreFlightFetch(turn) {
  for (const e of turn || []) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const b of content) {
      if (b && b.type === 'tool_use' && b.name === 'Bash' && b.input) {
        const cmd = String(b.input.command || '');
        if (/git\s+fetch[^|&;]*&&[^|&;]*git\s+log\s+HEAD\.\.origin\//i.test(cmd)) return true;
      }
    }
  }
  return false;
}

/**
 * Collect text content from tool_result blocks in the turn — the only surface
 * trusted for parallel_session collision evidence (see extractEnvironment).
 * Supports both string content and the structured array form
 * (`content: [{ type: 'text', text }]`).
 */
function collectToolResultText(turn) {
  const parts = [];
  for (const e of turn) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const b of content) {
      if (!b || b.type !== 'tool_result') continue;
      const c = b.content;
      if (typeof c === 'string') {
        parts.push(c);
      } else if (Array.isArray(c)) {
        for (const sub of c) {
          if (sub && typeof sub.text === 'string') parts.push(sub.text);
        }
      }
    }
  }
  return parts.join('\n');
}

// Pass 3 — path-pattern classifier (project-brain-factor-analysis-4passes).
// Returns one of: test / config / spec / norm / data / src / other.
// Priority order matters (test before src, norm before src, etc).
export function classifyFilePath(path) {
  if (!path) return 'other';
  const p = String(path).replace(/\\/g, '/');
  const base = p.split('/').pop() || p;

  // 1. tests
  if (/\.(?:test|spec)\.[a-z0-9]+$/i.test(base)) return 'test';
  if (/(?:^|\/)(?:tests?|spec)\//i.test(p)) return 'test';

  // 2. normative documents (CLAUDE.md / Pravila / PSR / Tooling / Открытые_вопросы / memory store).
  if (/(?:^|\/)CLAUDE\.md$/i.test(p)) return 'norm';
  if (/(?:^|\/)Pravila_raboty_Claude[^/]*\.md$/i.test(p)) return 'norm';
  if (/(?:^|\/)Plugin_stack_rules[^/]*\.md$/i.test(p)) return 'norm';
  if (/(?:^|\/)Tooling[^/]*\.md$/i.test(p)) return 'norm';
  if (/(?:^|\/)Открытые_вопросы[^/]*\.md$/i.test(p)) return 'norm';
  if (/(?:^|\/)MEMORY\.md$/i.test(p)) return 'norm';
  if (/\/memory\/[^/]+\.md$/i.test(p)) return 'norm';

  // 3. spec / plan
  if (/(?:^|\/)docs\/superpowers\/(?:specs|plans)\//i.test(p)) return 'spec';

  // 4. config
  if (/(?:^|\/)\.env(?:\.|$)/i.test(p)) return 'config';
  if (/(?:^|\/)(?:package|composer|tsconfig)\.json$/i.test(base)) return 'config';
  if (/\.config\.[a-z0-9]+$/i.test(base)) return 'config';
  if (/(?:^|\/)(?:lefthook|\.eslintrc|cspell|stylelint|prettier|pint)[^/]*\.(?:yml|yaml|json|cjs|mjs|js|toml)$/i.test(p)) return 'config';

  // 5. data
  if (/\.(?:jsonl|csv|sql|sqlite)$/i.test(base)) return 'data';

  // 6. src
  if (/(?:^|\/)(?:app|tools|resources|src|lib|db\/migrations)\//i.test(p)) return 'src';

  return 'other';
}

const FILE_TYPE_CATEGORIES = ['src', 'test', 'config', 'spec', 'norm', 'data', 'other'];

export function extractFileTypeDistribution(files) {
  const dist = Object.fromEntries(FILE_TYPE_CATEGORIES.map((c) => [c, 0]));
  for (const f of files || []) {
    dist[classifyFilePath(f)] += 1;
  }
  return dist;
}

// Pass 3 — MCP server fingerprint. tool_use[].name follows
// `mcp__<server>__<tool>` where <server> may itself contain single underscores
// (e.g. mcp__plugin_brand-voice_box__authenticate). Non-greedy match stops at
// the FIRST `__` after the prefix so multi-word server names land whole.
export function extractMcpServers(turn) {
  const servers = new Set();
  for (const e of turn || []) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const b of content) {
      if (b && b.type === 'tool_use' && typeof b.name === 'string') {
        const m = b.name.match(/^mcp__(.+?)__/);
        if (m) servers.add(m[1]);
      }
    }
  }
  return [...servers];
}

/** Task size: total tool calls + unique file paths touched (per spec §3, gap-resolution 2). */
export function extractTaskSize(turn) {
  let tool_calls = 0;
  const files = new Set();
  for (const e of turn) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const b of content) {
      if (b && b.type === 'tool_use') {
        tool_calls += 1;
        if (FILE_TOOLS.has(b.name) && b.input) {
          const p = b.input.file_path || b.input.notebook_path;
          if (p) files.add(String(p));
        }
      }
    }
  }
  return { tool_calls, files_touched: files.size, files: [...files] };
}

/**
 * Token-usage aggregation across all assistant messages in the turn.
 *
 * DESIGN: returns zero-filled object (NOT null) when no `usage` data was
 * captured. Consumers cannot currently distinguish "actually 0 tokens" from
 * "no usage data" — accepted trade-off because (a) every assistant message
 * in real Claude Code transcripts has `usage` (verified B1 brain-retro
 * 2026-05-20: 6265/6265 messages with usage, 0 partial-stream), and
 * (b) `task_cost` is not yet read by analyzer/STATUS.md, so the semantic
 * gap is a future-only concern. Re-evaluate when factor matrix adds cost.
 *
 * Captures: 4 base token fields + `iterations` (extended-thinking detector)
 * + `server_tool_use.{web_search,web_fetch}_requests` counts.
 * Other usage fields (cache_creation object, inference_geo, service_tier,
 * speed) — out-of-scope for current analyzer.
 *
 * Defensive: skips entries where `usage` is not a plain object (handles
 * malformed transcript edge cases like `"usage": 42`).
 */
// Normalize `usage.iterations` to a count.
// Claude Code transcripts may emit it as: a number (legacy / no extended-thinking),
// an array of step-objects (extended-thinking turns), or a plain object map.
// Coerce to a number; non-finite / unknown → 0. Prevents "0[object Object]…"
// string concatenation that previously poisoned task_cost.iterations.
function iterationsCount(v) {
  if (typeof v === 'number' && Number.isFinite(v)) return v;
  if (Array.isArray(v)) return v.length;
  if (v && typeof v === 'object') return Object.keys(v).length;
  return 0;
}

export function extractTokenUsage(turn) {
  let input = 0, output = 0, cache_read = 0, cache_creation = 0;
  let web_search = 0, web_fetch = 0, iterations = 0;
  for (const e of turn || []) {
    const u = e && e.message && e.message.usage;
    if (!u || typeof u !== 'object') continue;
    input          += u.input_tokens || 0;
    output         += u.output_tokens || 0;
    cache_read     += u.cache_read_input_tokens || 0;
    cache_creation += u.cache_creation_input_tokens || 0;
    iterations     += iterationsCount(u.iterations);
    if (u.server_tool_use) {
      web_search += u.server_tool_use.web_search_requests || 0;
      web_fetch  += u.server_tool_use.web_fetch_requests  || 0;
    }
  }
  return {
    input_tokens: input,
    output_tokens: output,
    cache_read_input_tokens: cache_read,
    cache_creation_input_tokens: cache_creation,
    web_search_requests: web_search,
    web_fetch_requests: web_fetch,
    iterations,
    // v4.3 LLM-agent cost fields — always zero at parse time;
    // populated retroactively by controller scripts / reviewer response.
    classifier_input_tokens: 0,
    classifier_output_tokens: 0,
    self_assessment_input_tokens: 0,
    self_assessment_output_tokens: 0,
    reviewer_input_tokens: 0,
    reviewer_output_tokens: 0,
    reviewer_subagent_usd: 0,
    reviewer_direct_fallback_usd: 0,
  };
}

/**
 * For each AskUserQuestion toolUseResult in the turn, emit one event per question.
 * answer_kind: 'option' (exact label match), 'custom' (free-text), 'no_answer' (missing/empty).
 */
/** Collect concatenated text from all assistant text blocks in the turn. */
function assistantTextOfTurn(turn) {
  const parts = [];
  for (const e of turn || []) {
    if (!e || !e.message || e.message.role !== 'assistant') continue;
    const content = Array.isArray(e.message.content) ? e.message.content : [];
    for (const b of content) {
      if (b && b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
    }
  }
  return parts.join('\n');
}

const TRIGGER_PATTERNS = [
  /\bPravila\s+§\d+(?:\.\d+)?/g,
  /\bADR-\d+/g,
  /\bPSR_v1\s+R\d+(?:\.\d+)?/g,
  /\brouting-off-phase\s+L\d+/g,
  /\bL\d+\s+chain/g,
  /\bhard-(?:floor|rule)\b/gi,
];

/** Heuristic triggers from assistant text. Conservative-broad — false positives OK. */
export function extractTriggers(turn) {
  const text = assistantTextOfTurn(turn);
  const out = new Set();
  for (const re of TRIGGER_PATTERNS) {
    const matches = text.match(re);
    if (matches) for (const m of matches) {
      const norm = /^L\d+\s+chain$/.test(m) ? `routing-off-phase ${m.split(/\s+/)[0]}` : m;
      out.add(norm);
    }
  }
  return [...out];
}

const CANDIDATE_NUMBERED_RE = /^\s*\d+[.\)]\s+([^\n]+)$/gm;
const CANDIDATE_BULLET_RE = /^\s*[-*]\s+([^\n]+)$/gm;

/**
 * Heuristic candidates: ≥2 numbered (preferred) or bulleted items, filtered to
 * router-node identifiers (see isKnownNode). Free-form prose bullets are
 * rejected — they belong in the assistant's narrative, not in
 * primary_rationale.candidates_considered. The opt-in <!-- reasoning --> tag
 * (parseReasoningTag) bypasses this filter; that channel is authoritative.
 */
export function extractCandidates(turn) {
  const text = assistantTextOfTurn(turn);
  const numbered = [...text.matchAll(CANDIDATE_NUMBERED_RE)].map((m) => m[1].trim());
  if (numbered.length >= 2) {
    const filtered = numbered.map(normalizeCandidate).filter(isKnownNode);
    if (filtered.length >= 2) return filtered;
  }
  const bulleted = [...text.matchAll(CANDIDATE_BULLET_RE)].map((m) => m[1].trim());
  if (bulleted.length >= 2) {
    const filtered = bulleted.map(normalizeCandidate).filter(isKnownNode);
    if (filtered.length >= 2) return filtered;
  }
  return [];
}

const BOUNDARY_PATTERNS = [
  /\bADR-\d+(?:\s+§\d+(?:\.\d+)?)?/g,
  /\bPSR_v1\s+R\d+(?:\.\d+)?/g,
  /\bPravila\s+§\d+(?:\.\d+)?/g,
  /\brouting-off-phase\s+L\d+/g,
  /\bL\d+\s+chain/g,
];

/** Heuristic boundaries — overlaps with triggers, dedup per-array only. */
export function extractBoundaries(turn) {
  const text = assistantTextOfTurn(turn);
  const out = new Set();
  for (const re of BOUNDARY_PATTERNS) {
    const matches = text.match(re);
    if (matches) for (const m of matches) {
      const norm = /^L\d+\s+chain$/.test(m) ? `routing-off-phase ${m.split(/\s+/)[0]}` : m;
      out.add(norm);
    }
  }
  return [...out];
}

export function extractAskUserQuestionEvents(turn) {
  const events = [];
  for (const e of turn || []) {
    const tur = e && e.toolUseResult;
    if (!tur || !Array.isArray(tur.questions) || !tur.answers) continue;
    const qCount = tur.questions.length;
    for (const q of tur.questions) {
      const labels = (q.options || []).map((o) => o && o.label).filter((l) => typeof l === 'string');
      const answer = tur.answers[q.question];
      let answer_kind;
      if (typeof answer !== 'string' || answer.length === 0) answer_kind = 'no_answer';
      else if (labels.some((l) => l.trim() === answer.trim())) answer_kind = 'option';
      else answer_kind = 'custom';
      events.push({ kind: 'ask_user_question', question_count: qCount, answer_kind });
    }
  }
  return events;
}

/** Classify the opening user-prompt sentiment (per spec §6 / gap-resolution 1). */
export function classifyPromptSignal(text) {
  const t = String(text || '').toLowerCase().trim();
  if (
    /не совсем|другое|другая|не сходится|wrong direction|не то\b|не так\b|переделай|отбой|\bстоп\b|почему ты|неверно|не верно|это не |не работает|не правильн|сломал|опять|снова не|всё ещё|все ещё|все еще|верни как|откат|\brevert\b|\bundo\b|still not|doesn'?t work|does not work|\bwrong\b/.test(
      t
    )
  ) {
    return 'correction';
  }
  if (/^(ок|окей|ok|спасибо|супер|отлично|готово|дальше|идеально|класс|хорошо|принято|well done|\bnice\b)([,\s]|$)/.test(t)) {
    return 'approval';
  }
  if (/^(?:теперь|далее|следующее)(?=\s|[,.!?:;]|$)|^next\b|^now\b/.test(t)) return 'new_task';
  if (classifyTask(t) !== 'other' && t.length > 15) return 'new_task';
  return 'neutral';
}

const TIME_BURN_THRESHOLD_MS = 900000; // 15 min — turn wall-clock above this = time_burn
const PARSE_GAP_RATIO = 0.1; // >10% unparseable lines = parse_gap

/** Heuristic retry count: an errored tool whose name is used again later in the turn. */
function detectRetries(turn) {
  const idToName = {};
  const uses = [];
  turn.forEach((entry, idx) => {
    const content = entry && entry.message && Array.isArray(entry.message.content) ? entry.message.content : [];
    for (const b of content) {
      if (b && b.type === 'tool_use') {
        idToName[b.id] = b.name;
        uses.push({ name: b.name, idx });
      }
    }
  });
  const errors = [];
  turn.forEach((entry, idx) => {
    const content = entry && entry.message && Array.isArray(entry.message.content) ? entry.message.content : [];
    for (const b of content) {
      if (b && b.type === 'tool_result' && b.is_error === true) {
        errors.push({ name: idToName[b.tool_use_id] || null, idx });
      }
    }
  });
  let retries = 0;
  for (const err of errors) {
    if (err.name && uses.some((u) => u.name === err.name && u.idx > err.idx)) retries += 1;
  }
  return retries;
}

/**
 * Process events for the turn: hook_fired (summary), interrupt, retry,
 * time_burn, parse_gap. broken/total/durationMs are computed by the caller.
 */
export function extractProcessEvents(turn, broken, total, durationMs) {
  const events = [];

  const hookCounts = {};
  let hookErrors = 0;
  for (const e of turn) {
    const att = e && e.attachment;
    if (att && (att.type === 'hook_success' || att.type === 'hook_error')) {
      const name = att.hookName || 'unknown';
      hookCounts[name] = (hookCounts[name] || 0) + 1;
      if (att.type === 'hook_error') hookErrors += 1;
    }
  }
  if (Object.keys(hookCounts).length > 0) {
    const scripts = resolveScriptCounts(hookCounts, getHookMap());
    events.push({ kind: 'hook_fired', counts: hookCounts, scripts, errors: hookErrors });
  }

  for (const e of turn) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    const isUser = e && e.message && e.message.role === 'user';
    if (
      isUser &&
      content.some((b) => b && b.type === 'text' && String(b.text || '').includes('[Request interrupted by user]'))
    ) {
      events.push({ kind: 'interrupt' });
    }
  }

  const retries = detectRetries(turn);
  for (let i = 0; i < retries; i++) events.push({ kind: 'retry' });

  if (durationMs > TIME_BURN_THRESHOLD_MS) {
    events.push({ kind: 'time_burn', duration_ms: durationMs });
  }

  if (total > 0 && broken / total > PARSE_GAP_RATIO) {
    events.push({ kind: 'parse_gap', broken, total });
  }

  // unrecovered_error: emitted iff the LAST tool_result in the turn was
  // is_error=true. Distinguishes "turn ended on failure" from "errors that
  // were retried away" (e.g., TDD red→green, expected-fail commands). The
  // analyzer uses this event to flag `blocked` instead of raw error/retry
  // count — see brain-retro-analyzer.inferOutcome (A-1 fix).
  let lastToolResultIsError = null;
  outer: for (let i = turn.length - 1; i >= 0; i--) {
    const content =
      turn[i] && turn[i].message && Array.isArray(turn[i].message.content) ? turn[i].message.content : [];
    for (let j = content.length - 1; j >= 0; j--) {
      const b = content[j];
      if (b && b.type === 'tool_result') {
        lastToolResultIsError = b.is_error === true;
        break outer;
      }
    }
  }
  if (lastToolResultIsError === true) {
    events.push({ kind: 'unrecovered_error' });
  }

  return events;
}

const ROUTING_TAG_RE =
  /<!--\s*routing:\s*provenance=([\w_]+)\s+node=(\S+)\s+counterfactual=(\S+)\s*-->/;

/** Find the routing tag Claude prints when a method was user-directed (spec §4.2). */
export function parseRoutingTag(turn) {
  for (const e of turn) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const b of content) {
      if (b && b.type === 'text' && typeof b.text === 'string') {
        const m = b.text.match(ROUTING_TAG_RE);
        if (m) return { kind: m[1], node: m[2], claude_would_have_chosen: m[3] };
      }
    }
  }
  return null;
}

/**
 * Per-Agent-tool_use event (Task 12) — surfaces subagent dispatches in the
 * episode `events[]`. Captures subagent_type / model (if explicit in input)
 * / first 80 chars of description.
 *
 * Not the full subagent trace (that lives in ~/.claude/projects/.../subagents/);
 * just visibility from the parent Claude's perspective.
 */
export function extractAgentInvocations(turn) {
  const out = [];
  for (const e of turn || []) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const b of content) {
      if (b && b.type === 'tool_use' && b.name === 'Agent') {
        const inp = b.input || {};
        out.push({
          kind: 'subagent_invoked',
          subagent_type: inp.subagent_type || 'unknown',
          model: inp.model || null,
          description: typeof inp.description === 'string' ? inp.description.slice(0, 80) : '',
        });
      }
    }
  }
  return out;
}

const REASONING_TAG_RE =
  /<!--\s*reasoning:\s*triggers="([^"]*)"\s+candidates="([^"]*)"\s+boundaries="([^"]*)"\s*-->/;

/**
 * Opt-in reasoning tag (Task 11). Claude may emit at most one such comment
 * per turn to declare triggers / candidates / boundaries explicitly. Values
 * are semicolon-separated. When present, parser merges them into the
 * heuristic-derived arrays via Set-dedupe.
 */
export function parseReasoningTag(turn) {
  for (const e of turn || []) {
    const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : [];
    for (const b of content) {
      if (b && b.type === 'text' && typeof b.text === 'string') {
        const m = b.text.match(REASONING_TAG_RE);
        if (m) {
          const split = (s) => s.split(';').map((x) => x.trim()).filter(Boolean);
          return { triggers: split(m[1]), candidates: split(m[2]), boundaries: split(m[3]) };
        }
      }
    }
  }
  return null;
}

/** Text of the last real user prompt — used by the Stop-hook routing-gate (Task 5). */
export function extractLastUserPromptText(transcriptText) {
  const { entries } = parseLines(transcriptText);
  const start = findTurnStart(entries);
  return promptText(entries[start]);
}

/**
 * Content of the last assistant message strictly before the turn start —
 * the message that may have offered options to the user (spec §11.5).
 */
function extractLastAssistantContent(entries, turnStartIdx) {
  for (let i = turnStartIdx - 1; i >= 0; i--) {
    const e = entries[i];
    if (e && e.message && e.message.role === 'assistant') {
      const content = e.message.content;
      if (Array.isArray(content)) return content;
      if (typeof content === 'string') return content;
    }
  }
  return null;
}

/**
 * Parse a transcript JSONL string into an observer episode (schema v2).
 * @param {string} transcriptText - Raw JSONL transcript contents.
 * @param {string|null} fallbackSessionId - Used when the transcript has no sessionId.
 * @returns {object} v2 episode.
 */
export function parseTranscript(transcriptText, fallbackSessionId = null, options = {}) {
  const { entries, broken, total } = parseLines(transcriptText);

  const withSession = entries.find((e) => e && e.sessionId);
  const sessionId =
    (withSession && withSession.sessionId) || fallbackSessionId || `unknown-${Date.now()}`;

  const routerStateBaseDir = options.routerStateBaseDir;
  const routerState = readRouterState(sessionId, routerStateBaseDir ? { baseDir: routerStateBaseDir } : {});
  const routerFields = extractRouterFields(routerState);

  const start = findTurnStart(entries);
  const turn = entries.slice(start);

  const stamps = turn.map((e) => e && e.timestamp).filter(Boolean);
  const started_at = stamps[0] || new Date().toISOString();
  const ended_at = stamps[stamps.length - 1] || started_at;
  const durationMs = new Date(ended_at) - new Date(started_at);

  const _v4 = extractV4Signals(sessionId, {
    startMs: new Date(started_at).getTime(),
    endMs: new Date(ended_at).getTime(),
    baseDir: options.runtimeBaseDir || routerStateBaseDir,
  });

  const { skills, counts, errors } = collectToolUse(turn);

  const events = [];
  for (const skill of skills) events.push({ kind: 'skill_invoked', skill });
  if (Object.keys(counts).length > 0) events.push({ kind: 'tool_summary', counts });
  for (const err of errors) {
    events.push({ kind: 'error', tool: err.tool, summary: err.summary });
  }
  events.push(...extractProcessEvents(turn, broken, total, durationMs));
  events.push(...extractAskUserQuestionEvents(turn));
  events.push(...extractAgentInvocations(turn));

  const usedSuperpowers = skills.some((s) => String(s).startsWith(SUPERPOWERS_PREFIX));
  const prompt = promptText(entries[start]);

  const lastAsstContent = extractLastAssistantContent(entries, start);
  const choice = detectChoiceProvenance(prompt, lastAsstContent) || detectAskUserQuestionChoice(turn);
  let decision_provenance;
  if (choice) {
    decision_provenance = choice;
  } else {
    const tag = parseRoutingTag(turn);
    decision_provenance =
      tag && tag.kind === 'user_directed_method'
        ? { kind: 'user_directed_method', claude_would_have_chosen: tag.claude_would_have_chosen }
        : { kind: 'autonomous', claude_would_have_chosen: null };
  }

  // Phase 2 Task 15 — schema v4.0. Adds classifier_output (LLM-first decision
  // record), degraded_mode (LLM→regex fallback flag), and
  // environment.classifier_model. Phase 3 (Tasks 16-20) will bump schema_minor
  // for execution_trace, self_assessment, embedding etc.
  const _state = readRouterState(sessionId);
  const _classifierOutput = extractClassifierOutput(_state);
  const _degraded = _state?.classification?.degraded === true;
  const _envBase = extractEnvironment(entries, start);
  const _classifierModel = _classifierOutput?.source === 'llm' ? CLASSIFIER_MODEL : null;

  return {
    schema_version: 4,
    schema_minor: 4,
    task_id: sessionId,
    task_ref: sessionId,
    timestamps: { started_at, ended_at },
    path_type: usedSuperpowers ? 'regulated' : 'improvised',
    outcome: 'unknown',
    // v4.3: reviewed outcome — always null at write time, filled by /brain-retro reviewer.
    outcome_reviewed: null,
    outcome_reviewed_source: null,
    // v4.3: embedding of first user prompt — null at parse time (sync parser cannot
    // await model load); populated asynchronously by the Stop-hook after parseTranscript.
    prompt_embedding_base64: null,
    prompt_signal: classifyPromptSignal(prompt),
    decision_provenance,
    environment: { ..._envBase, classifier_model: _classifierModel },
    task_size: extractTaskSize(turn),
    // A1 (2026-05-26): merge router-state.task_cost (classifier LLM tokens) on top of
    // extractTokenUsage (assistant per-turn tokens). State-file fields win for the
    // classifier_/self_assessment_/reviewer_ block; assistant input_tokens/output_tokens
    // come from extractTokenUsage and stay intact.
    // NB: routerState (line 855) honours routerStateBaseDir option; _state at line 898
    // does not (always default dir). Use routerState here so tests with custom temp dir
    // see the merged values.
    task_cost: {
      ...extractTokenUsage(turn),
      ...((routerState && routerState.task_cost) || {}),
      judge_spend_usd: _v4.judge_calls * JUDGE_PER_CALL_USD,
    },
    v4_signals: _v4,
    // Pass 3 — dynamics meta-block (project-brain-factor-analysis-4passes).
    // prompt_length_chars: strlen of first user prompt (engagement / clarity proxy).
    // mcp_servers_used: unique mcp__<server>__* fingerprints in this turn.
    // file_type_distribution: per-bucket counts of unique paths touched.
    task_meta: (() => {
      const ts = extractTaskSize(turn);
      return {
        prompt_length_chars: typeof prompt === 'string' ? prompt.length : 0,
        mcp_servers_used: extractMcpServers(turn),
        file_type_distribution: extractFileTypeDistribution(ts.files),
      };
    })(),
    classifier_output: _classifierOutput,
    degraded_mode: _degraded,
    primary_rationale: (() => {
      const tag = parseReasoningTag(turn);
      const merge = (heur, fromTag) => [...new Set([...heur, ...fromTag])];
      // recommended_node ОБЯЗАН отражать реальный сигнал классификатора:
      // - routerFields.recommended_node (state-файл от LLM / regex fallback / prefilter inherited),
      // - либо null, если классификатор не сработал.
      // Старый classifMapNode fallback на keyword-regex (`recommendNode(classifyTask, ...)`) запекал
      // ложные «рекомендации» в episode JSONL — brain-retro #6 показала 60-70% false-positive в анализе
      // «direct_ignored_rec». Убрано 2026-05-26.
      return {
        step: 1,
        node_chosen: skills.length > 0 ? skills[0] : 'direct',
        chain_ref: chainsFor(skills.length > 0 ? skills[0] : 'direct', CHAIN_MAP),
        triggers_matched: merge(extractTriggers(turn), tag ? tag.triggers : []),
        candidates_considered: merge(extractCandidates(turn), tag ? tag.candidates : []),
        boundaries_applied: merge(extractBoundaries(turn), tag ? tag.boundaries : []),
        hard_floor: usedSuperpowers
          ? { invoked: true, rules: ['Pravila §12'] }
          : { invoked: false, rules: [] },
        task_classification: classifyTask(prompt),
        recommended_node: routerFields.recommended_node,
        recommended_chain: routerFields.recommended_chain,
        chain_progress: routerFields.chain_progress,
        chain_completed: routerFields.chain_completed,
      };
    })(),
    events,
  };
}
