/**
 * Shared helpers for the 10-rule enforcement hook layer.
 *
 * Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
 * Plan: docs/superpowers/plans/2026-05-25-enforce-hard-rules.md
 *
 * Design contract (M7 Фаза 0, правило 1 — уточнение helpers:7):
 *   - НАБЛЮДАТЕЛЬНЫЕ хуки (FAIL_QUIET_OBSERVATION_HOOKS) — fail-quiet (exit 0, {}).
 *   - ДИСЦИПЛИНАРНЫЕ/ЗАЩИТНЫЕ хуки (FAIL_CLOSE_DISCIPLINE_HOOKS) — fail-CLOSE: любая
 *     внутренняя ошибка → block (exit 2), НЕ тихий пропуск (иначе баг хука = тихий
 *     обход дисциплины, SE2). Используют disciplineOutcome / exitDisciplineDecision.
 *     Прецедент: enforce-normative-content-rules:201-205.
 * Only deliberate violations (or discipline-hook internal errors) exit 2.
 *
 * Security note: this file uses child_process.execFileSync with FIXED arguments
 * (no user input concatenation) — pattern is safe by construction. No injection
 * surface. See readGitBranch().
 *
 * Security Guidance #40: pure parsing — no exec/execSync except readGitBranch which
 * is the documented use case (fixed args, no user input).
 */

import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
import { execFileSync } from 'child_process';
import { fileURLToPath } from 'url';

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

/** Read full stdin as utf-8 string. Returns '' on empty/error. */
export async function readStdin(stdinStream = process.stdin) {
  return new Promise((resolve) => {
    let data = '';
    let timedOut = false;
    const timer = setTimeout(() => { timedOut = true; resolve(data); }, 4500);
    stdinStream.setEncoding('utf-8');
    stdinStream.on('data', (chunk) => { data += chunk; });
    stdinStream.on('end', () => {
      if (timedOut) return;
      clearTimeout(timer);
      resolve(data);
    });
    stdinStream.on('error', () => {
      clearTimeout(timer);
      resolve('');
    });
  });
}

export function parseEventJson(raw) {
  try { return JSON.parse(raw || '{}'); } catch { return {}; }
}

/** Runtime directory: ~/.claude/runtime/ */
export function runtimeDir() {
  const dir = join(homedir(), '.claude', 'runtime');
  try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
  return dir;
}

export function sentinelPath(name, sessionId) {
  return join(runtimeDir(), `${name}-${sessionId || 'unknown'}.json`);
}

export function writeSentinel(name, sessionId, data) {
  try {
    const p = sentinelPath(name, sessionId);
    writeFileSync(p, JSON.stringify({ ...data, written_at: new Date().toISOString() }, null, 2));
    return p;
  } catch { return null; }
}

export function readSentinel(name, sessionId) {
  try {
    const p = sentinelPath(name, sessionId);
    if (!existsSync(p)) return null;
    return JSON.parse(readFileSync(p, 'utf-8'));
  } catch { return null; }
}

export function sentinelAgeSec(name, sessionId) {
  const s = readSentinel(name, sessionId);
  if (!s || !s.written_at) return null;
  const ms = Date.now() - new Date(s.written_at).getTime();
  if (!Number.isFinite(ms)) return null;
  return Math.floor(ms / 1000);
}

export function readTranscript(transcriptPath) {
  if (!transcriptPath || typeof transcriptPath !== 'string') return [];
  if (!existsSync(transcriptPath)) return [];
  try {
    const raw = readFileSync(transcriptPath, 'utf-8');
    const lines = raw.split('\n').filter(Boolean);
    const out = [];
    for (const l of lines) {
      try { out.push(JSON.parse(l)); } catch { /* skip */ }
    }
    return out;
  } catch { return []; }
}

export function lastTurnEntries(entries) {
  if (!Array.isArray(entries) || entries.length === 0) return [];
  for (let i = entries.length - 1; i >= 0; i--) {
    const e = entries[i];
    // Sibling-session find 2026-05-30: harness-injected skill bodies arrive as
    // role:'user' messages with isMeta:true AND a top-level sourceToolUseID
    // linking them back to the originating Skill tool_use. Treating them as
    // turn boundaries hides both the user's real prompt (breaks coverage
    // detection) and the Skill tool_use (breaks detectLegitSkillActive in
    // enforce-normative-content-rules). Skip ONLY this exact shape — other
    // isMeta:true messages (auto-resume "Continue from where you left off.",
    // Stop hook feedback, local-command-caveat wrappers) remain valid
    // boundaries. Discriminator field sourceToolUseID is harness-controlled
    // and not writable by controller from inside a tool call.
    if (e && e.isMeta === true && typeof e.sourceToolUseID === 'string') continue;
    if (e && e.message && e.message.role === 'user') {
      const c = e.message.content;
      if (typeof c === 'string' && c.trim().length > 0) return entries.slice(i);
      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 entries.slice(i);
      }
    }
  }
  return entries;
}

export function lastUserPromptText(entries) {
  const turn = lastTurnEntries(entries);
  if (!turn || turn.length === 0) return '';
  const e = turn[0];
  if (!e || !e.message) return '';
  const c = e.message.content;
  if (typeof c === 'string') return c;
  if (Array.isArray(c)) {
    return c.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('\n');
  }
  return '';
}

export function lastAssistantText(entries) {
  const turn = lastTurnEntries(entries);
  let out = '';
  for (const e of turn) {
    if (e && e.message && e.message.role === 'assistant') {
      const c = e.message.content;
      if (Array.isArray(c)) {
        for (const b of c) {
          if (b && b.type === 'text' && typeof b.text === 'string') out += b.text + '\n';
        }
      }
    }
  }
  return out;
}

export function parseCoverageLine(text) {
  if (typeof text !== 'string') return null;
  const m = text.match(/coverage:\s*(skill|node|chain|hook|agent|direct)\s*:\s*([^\s\n<>]+)/i);
  if (!m) return null;
  return { channel: m[1].toLowerCase(), id: m[2] };
}

export function sessionToolUses(entries) {
  if (!Array.isArray(entries)) return [];
  const uses = [];
  for (const e of entries) {
    const c = e && e.message && e.message.content;
    if (!Array.isArray(c)) continue;
    for (const b of c) {
      if (b && b.type === 'tool_use') uses.push({ name: b.name, input: b.input || {} });
    }
  }
  return uses;
}

export function turnToolUses(entries) {
  const turn = lastTurnEntries(entries);
  const uses = [];
  for (const e of turn) {
    const c = e && e.message && e.message.content;
    if (!Array.isArray(c)) continue;
    for (const b of c) {
      if (b && b.type === 'tool_use') uses.push({ name: b.name, input: b.input || {} });
    }
  }
  return uses;
}

export function turnToolResults(entries) {
  const turn = lastTurnEntries(entries);
  const results = [];
  for (const e of turn) {
    const c = e && e.message && e.message.content;
    if (!Array.isArray(c)) continue;
    for (const b of c) {
      if (b && b.type === 'tool_result') {
        const txt = typeof b.content === 'string' ? b.content
          : Array.isArray(b.content) ? b.content.map((p) => (p && p.text) || '').join('\n') : '';
        results.push({ tool_use_id: b.tool_use_id, is_error: b.is_error === true, content: txt });
      }
    }
  }
  return results;
}

// v4 stubs — universal vocab override surface removed per spec §4.2.
// Keep symbols exported so callers in other hooks compile; runtime returns null/empty.
export function loadOverrideVocab(_path) {
  return { phrases: [] };
}

export function _resetVocabCache() { /* no-op, vocab disabled */ }

export function findOverride(_userPrompt, _ruleKey, _vocab) {
  return null;
}

export function findOverrideAttempt(_userPrompt, _ruleKey, _vocab) {
  return null;
}
export function logHookOutcome(ruleKey, outcome, sessionId) {
  try {
    const f = join(runtimeDir(), 'hook-outcomes.jsonl');
    appendFileSync(f, JSON.stringify({
      ts: new Date().toISOString(),
      session_id: sessionId || null,
      rule: ruleKey,
      outcome,
    }) + '\n');
  } catch { /* ignore */ }
}

export function logOverride(ruleKey, phraseObj, sessionId) {
  try {
    const f = join(runtimeDir(), 'override-usage.jsonl');
    appendFileSync(f, JSON.stringify({
      ts: new Date().toISOString(),
      session_id: sessionId || null,
      rule: ruleKey,
      phrase: phraseObj && phraseObj.phrase,
    }) + '\n');
  } catch { /* ignore */ }
}

/**
 * Read current git branch via execFileSync with fixed args (no shell, no user
 * input concatenation — safe by construction). Returns empty string on error.
 */
export function readGitBranch(cwd) {
  try {
    return execFileSync('git', ['branch', '--show-current'], {
      cwd: cwd || process.cwd(),
      encoding: 'utf-8',
      timeout: 1000,
      stdio: ['ignore', 'pipe', 'ignore'],
    }).trim();
  } catch { return ''; }
}

export function expectedBranchPath(sessionId) {
  return join(runtimeDir(), `expected-branch-${sessionId || 'unknown'}`);
}

export function getExpectedBranch(sessionId) {
  try {
    const p = expectedBranchPath(sessionId);
    if (!existsSync(p)) return '';
    return readFileSync(p, 'utf-8').trim();
  } catch { return ''; }
}

export function setExpectedBranch(sessionId, branch) {
  try {
    writeFileSync(expectedBranchPath(sessionId), String(branch || '').trim());
    return true;
  } catch { return false; }
}

export function appendRationalizationFlag(sessionId, kind, evidence) {
  try {
    const f = join(runtimeDir(), `rationalization-flags-${sessionId || 'unknown'}.jsonl`);
    appendFileSync(f, JSON.stringify({
      ts: new Date().toISOString(),
      kind,
      evidence: typeof evidence === 'string' ? evidence.slice(0, 240) : evidence,
    }) + '\n');
  } catch { /* ignore */ }
}

export function readRationalizationFlags(sessionId) {
  try {
    const f = join(runtimeDir(), `rationalization-flags-${sessionId || 'unknown'}.jsonl`);
    if (!existsSync(f)) return [];
    return readFileSync(f, 'utf-8').split('\n').filter(Boolean).map((l) => {
      try { return JSON.parse(l); } catch { return null; }
    }).filter(Boolean);
  } catch { return []; }
}

export function logJudgeVerdict(sessionId, { tool, verdict }, opts = {}) {
  try {
    const dir = opts.baseDir || runtimeDir();
    const fi = join(dir, `llm-judge-verdicts-${sessionId || 'unknown'}.jsonl`);
    appendFileSync(fi, JSON.stringify({
      ts: new Date().toISOString(),
      tool: tool || 'unknown',
      verdict: verdict == null ? null : String(verdict),
    }) + '\n');
  } catch { /* ignore */ }
}

export function readJudgeVerdicts(sessionId, opts = {}) {
  try {
    const dir = opts.baseDir || runtimeDir();
    const fi = join(dir, `llm-judge-verdicts-${sessionId || 'unknown'}.jsonl`);
    if (!existsSync(fi)) return [];
    return readFileSync(fi, 'utf-8').split('\n').filter(Boolean).map((l) => {
      try { return JSON.parse(l); } catch { return null; }
    }).filter(Boolean);
  } catch { return []; }
}

export function logSafeBaselineAction(sessionId, { tool, action }, opts = {}) {
  try {
    const dir = opts.baseDir || runtimeDir();
    const fi = join(dir, `safe-baseline-actions-${sessionId || 'unknown'}.jsonl`);
    appendFileSync(fi, JSON.stringify({
      ts: new Date().toISOString(),
      tool: tool || 'unknown',
      action: String(action || 'allow'),
    }) + '\n');
  } catch { /* ignore */ }
}

export function readSafeBaselineActions(sessionId, opts = {}) {
  try {
    const dir = opts.baseDir || runtimeDir();
    const fi = join(dir, `safe-baseline-actions-${sessionId || 'unknown'}.jsonl`);
    if (!existsSync(fi)) return [];
    return readFileSync(fi, 'utf-8').split('\n').filter(Boolean).map((l) => {
      try { return JSON.parse(l); } catch { return null; }
    }).filter(Boolean);
  } catch { return []; }
}

export function readRouterState(sessionId) {
  try {
    const p = join(runtimeDir(), `router-state-${sessionId || 'unknown'}.json`);
    if (!existsSync(p)) return null;
    return JSON.parse(readFileSync(p, 'utf-8'));
  } catch { return null; }
}

export function exitDecision({ block, message } = {}) {
  if (block) {
    if (message) process.stderr.write(message + '\n');
    process.exit(2);
    return;
  }
  try { process.stdout.write('{}'); } catch { /* ignore */ }
  process.exit(0);
}

/**
 * Дисциплинарный исход (pure, тестируемо): выполнить decideFn; ЛЮБАЯ внутренняя
 * ошибка → fail-CLOSE (block:true), НЕ тихий пропуск (M7 Фаза 0, правило 1).
 * Наблюдательные хуки это НЕ используют — остаются fail-quiet.
 */
export async function disciplineOutcome(decideFn, { label = 'discipline' } = {}) {
  let o;
  try {
    o = await decideFn();
  } catch {
    return { block: true, message: `[${label}] внутренняя ошибка — fail-CLOSE` };
  }
  // Малформ-возврат (undefined/null/не-объект/нет boolean block) → fail-CLOSE.
  // Зеркалит прецедент normative-content-rules: там undefined-результат кидал на .block →
  // catch → block. Только явный объект с boolean block трактуется как решение decideFn —
  // забытый return обязан блокировать ГРОМКО, не пропускать молча (анти-SE2, правило 1).
  if (!o || typeof o !== 'object' || typeof o.block !== 'boolean') {
    return { block: true, message: `[${label}] некорректный исход (нет block) — fail-CLOSE` };
  }
  return { block: o.block, message: o.message };
}

/** Thin-main: дисциплинарный exit. Ошибка decideFn → блок (fail-CLOSE). */
export async function exitDisciplineDecision(decideFn, opts = {}) {
  exitDecision(await disciplineOutcome(decideFn, opts));
}

/**
 * P-7 (M7 Фаза 0): дисциплинарные/защитные хуки обязаны быть fail-CLOSE; наблюдательные —
 * fail-quiet. Манифест-тест (Фаза 6 / P-6) сверит, что каждый зарегистрированный
 * дисциплинарный хук числится здесь и использует fail-CLOSE-обёртку.
 * NB: enforce-skill-journaler добавлен в Фазе 3 (SE-K); поглощённая дисциплина §4.2 — в Фазе 4.
 */
export const FAIL_CLOSE_DISCIPLINE_HOOKS = Object.freeze([
  'enforce-floor',
  'enforce-supreme-gate',
  'enforce-judge-gate',
  'enforce-snapshot',
  'enforce-floor-escape-consume',
  'enforce-read-path-deny',
  'enforce-mcp-classification',
  'enforce-normative-content-rules',
  'enforce-skill-journaler',
  'enforce-coverage-verify',
  'enforce-todowrite-skill-verifier',
  'enforce-rationalization-audit',
  'enforce-self-debrief-detector',
]);

export const FAIL_QUIET_OBSERVATION_HOOKS = Object.freeze([
  'observer-stop-hook',
  'cost-stop-hook',
]);

export function isProductionCodePath(p) {
  if (typeof p !== 'string') return false;
  const n = p.replace(/\\/g, '/');
  if (/\.(test|spec)\.[a-z0-9]+$/i.test(n)) return false;
  if (/(?:^|\/)tests?\//.test(n) || /(?:^|\/)spec\//.test(n)) return false;
  if (/(?:^|\/)tools\/[^/]+\.mjs$/.test(n)) return true;
  if (/(?:^|\/)app\/app\/.+\.php$/.test(n)) return true;
  if (/(?:^|\/)resources\/js\/.+\.(vue|ts|tsx|js)$/.test(n)) return true;
  return false;
}

export function isMemoryPath(p) {
  if (typeof p !== 'string') return false;
  const n = p.replace(/\\/g, '/');
  if (/\/memory\/[^/]+\.md$/i.test(n)) return true;
  if (/\/MEMORY\.md$/i.test(n)) return true;
  return false;
}

/**
 * Returns true if path is docs-only (ends with `.md`, case-insensitive).
 *
 * Used by verify-before-push to short-circuit regression-gating for docs /
 * memory / spec / plan / SKILL.md pushes — no executable code → no test value.
 *
 * Anything else (.json, .php, .ts, .yaml, .mjs, no-extension files) returns false
 * and remains under the normal regression gate.
 */
export function isDocsOnlyPath(p) {
  if (typeof p !== 'string' || p.length === 0) return false;
  return /\.md$/i.test(p);
}

/**
 * Returns true iff `paths` is a non-empty array where EVERY entry is docs-only.
 * Empty/non-array → false (unknown = conservative, fall through to normal gate).
 */
export function isDocsOnlyChange(paths) {
  if (!Array.isArray(paths) || paths.length === 0) return false;
  return paths.every(isDocsOnlyPath);
}

/**
 * List changed file paths for an in-progress git commit / push.
 *   - commit: staged files (`git diff --cached --name-only`)
 *   - push:   commits ahead of upstream (`git diff --name-only @{u}..HEAD`)
 *
 * Returns [] on any git error (no upstream, detached HEAD, git missing, etc.)
 * or unrecognized `kind`. Callers MUST treat empty as "unknown" and NOT short-
 * circuit on it.
 *
 * Security: execFileSync with fixed args, no user-input concatenation.
 */
export function listChangedFiles(kind, cwd) {
  try {
    let args;
    if (kind === 'commit') {
      args = ['diff', '--cached', '--name-only'];
    } else if (kind === 'push') {
      args = ['diff', '--name-only', '@{u}..HEAD'];
    } else {
      return [];
    }
    const out = execFileSync('git', args, {
      cwd: cwd || process.cwd(),
      encoding: 'utf-8',
      timeout: 2000,
      stdio: ['ignore', 'pipe', 'ignore'],
    });
    return out.split('\n').map((s) => s.trim()).filter(Boolean);
  } catch {
    return [];
  }
}

export function detectGitCommandKind(cmd) {
  if (typeof cmd !== 'string') return null;
  const c = cmd.trim();
  if (/(^|\s|;|&&|\|\|)git\s+push\b/i.test(c)) return 'push';
  if (/(^|\s|;|&&|\|\|)git\s+commit\b/i.test(c)) return 'commit';
  if (/(^|\s|;|&&|\|\|)git\s+cherry-pick\b/i.test(c)) return 'cherry-pick';
  if (/(^|\s|;|&&|\|\|)git\s+reset\s+--hard\b/i.test(c)) return 'reset-hard';
  if (/(^|\s|;|&&|\|\|)git\s+rebase\b/i.test(c)) return 'rebase';
  if (/(^|\s|;|&&|\|\|)git\s+branch\s+-[df]\b/i.test(c)) return 'branch-force';
  return null;
}

export function detectFullTestRun(cmd) {
  if (typeof cmd !== 'string') return null;
  const c = cmd.toLowerCase();
  // FIRST-REAL-COMMAND approach: split on shell separators, find first segment
  // after skipping cd / env-prefix. Only that command counts. Embedded args
  // (commit messages, echo strings) don't matter — they live inside the args
  // of the first command, not as independent shell segments.
  //
  // Caveat: naive `&&` split can match inside quoted strings. We accept this
  // because we use the FIRST segment only; later segments are ignored. As
  // long as user's first real command is git/echo/etc, the whole command is
  // classified as that.
  const segments = c.split(/\s*(?:&&|\|\||;|\|)\s*/);
  let firstReal = null;
  for (let seg of segments) {
    seg = seg.trim();
    // Strip env-var prefixes (KEY=value) and skip `cd <path>` segments.
    seg = seg.replace(/^(?:[a-z_][a-z0-9_]*=\S+\s+)+/i, '').trim();
    if (/^cd\b/i.test(seg)) continue;
    firstReal = seg;
    break;
  }
  if (!firstReal) return null;

  // Hard guard: first real command starts with a non-test shell-utility →
  // whole compound is not a test run, regardless of quoted args.
  if (/^(?:git|scp|ssh|curl|wget|cat|echo|grep|awk|sed|tar|gzip|bzip2|cp|mv|rm|mkdir|touch|chmod|chown|ls|cd|pwd|head|tail|find)\b/.test(firstReal)) {
    return null;
  }

  if (/^npx\s+vitest\s+run\b/.test(firstReal) || /^vitest\s+run\b/.test(firstReal)) {
    // narrow vitest (specific .test file) is NOT full
    if (/\btools\/[^\s]+\.test\.mjs\b/.test(firstReal)) return null;
    return 'vitest-full';
  }
  if (/^npm\s+run\s+test\b/.test(firstReal)) return 'npm-test';
  if (/^php\s+artisan\s+test\b/.test(firstReal) || /^composer\s+test\b/.test(firstReal)) return 'pest';
  if (/^(?:\.\/)?(?:vendor\/bin\/)?pest\b/.test(firstReal)) return 'pest';
  return null;
}

export function isVerificationFresh(sessionId, maxAgeSec = 1800) {
  const s = readSentinel('verify-pass', sessionId);
  if (!s || s.result !== 'pass') return false;
  const age = sentinelAgeSec('verify-pass', sessionId);
  return age !== null && age <= maxAgeSec;
}
