#!/usr/bin/env node
/**
 * framework-edit-gate — Claude Code PreToolUse hook.
 *
 * Intercepts Edit / Write / MultiEdit calls whose target resolves (via
 * symlink) to a path inside `.framework/` — i.e. the BALDART subtree that
 * flows upstream via `baldart push`. Runs the contamination scanner on the
 * new content and blocks the call with an informative `reason` if any
 * project-specific tokens are present (Neo-Brutalism, merchant, fidelity,
 * Recharts, hardcoded paths, etc.).
 *
 * Effect: Claude sees the block reason and either reformulates the content
 * generically (referencing `${paths.X}` / `identity.X`) or moves the change
 * into `.baldart/overlays/<skill>.md`. Project-specific tokens never reach
 * the framework subtree silently.
 *
 * Registration: see src/utils/hooks.js — `baldart add` and `baldart update`
 * auto-register this hook in the consumer's `.claude/settings.json`.
 *
 * Fail-safe contract: this hook MUST NOT block legitimate work. If anything
 * unexpected happens (missing scanner, parse error, IO error), it exits 0
 * and lets the tool call through. We optimise for false negatives over
 * false blocks — the scanner is a safety net, not a tribunal.
 */

'use strict';

const fs = require('fs');
const path = require('path');

function safeExit() {
  process.exit(0);
}

function readStdin() {
  try {
    return fs.readFileSync(0, 'utf8');
  } catch (_) {
    return '';
  }
}

function parseInput(raw) {
  if (!raw) return null;
  try { return JSON.parse(raw); } catch (_) { return null; }
}

function extractNewContent(toolName, toolInput) {
  if (!toolInput) return '';
  if (toolName === 'Edit') return String(toolInput.new_string || '');
  if (toolName === 'Write') return String(toolInput.content || '');
  if (toolName === 'MultiEdit') {
    const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [];
    return edits.map((e) => String(e.new_string || '')).join('\n');
  }
  if (toolName === 'NotebookEdit') return String(toolInput.new_source || '');
  return '';
}

function resolveReal(targetPath) {
  if (!targetPath) return null;
  // If file exists, realpath resolves symlinks all the way.
  if (fs.existsSync(targetPath)) {
    try { return fs.realpathSync(targetPath); } catch (_) {}
  }
  // For files about to be created, resolve the parent and rejoin.
  try {
    const parent = path.dirname(targetPath);
    if (fs.existsSync(parent)) {
      return path.join(fs.realpathSync(parent), path.basename(targetPath));
    }
  } catch (_) {}
  return path.resolve(targetPath);
}

function findFrameworkRoot(realPath) {
  // realPath looks like .../<consumer>/.framework/framework/.claude/skills/...
  // Find the segment ".framework" and return the path up through it.
  const sep = path.sep;
  const idx = realPath.lastIndexOf(`${sep}.framework${sep}`);
  if (idx === -1) return null;
  return realPath.slice(0, idx + `${sep}.framework`.length);
}

function loadScanner(frameworkRoot) {
  // Prefer the scanner from the same framework subtree the file lives in.
  const candidates = [
    path.join(frameworkRoot, 'src', 'utils', 'contamination.js'),
    path.join(process.cwd(), '.framework', 'src', 'utils', 'contamination.js'),
  ];
  for (const c of candidates) {
    if (fs.existsSync(c)) {
      try { return require(c); } catch (_) { /* try next */ }
    }
  }
  return null;
}

function blockReason(realPath, decisions, blockings) {
  const findingLines = [...blockings, ...decisions]
    .slice(0, 12)
    .map((f) => `  • L${f.lineNum}: ${f.label} (matched: "${f.match.slice(0, 40)}") — ${f.advice}`)
    .join('\n');
  const more = (decisions.length + blockings.length) > 12
    ? `\n  • … and ${decisions.length + blockings.length - 12} more`
    : '';

  const blockNote = blockings.length
    ? `\n\nIMPORTANT: ${blockings.length} of these are HARD BLOCKS (look like secrets or credentials). Even forcing through will require manual cleanup.`
    : '';

  return [
    `framework-edit-gate: you are about to write to`,
    `  ${realPath}`,
    `which is inside the BALDART framework subtree (\`.framework/\`).`,
    `Changes here flow UPSTREAM via \`baldart push\`. The contamination`,
    `scanner detected ${decisions.length + blockings.length} project-specific token(s):`,
    ``,
    findingLines + more,
    blockNote,
    ``,
    `Decide which case this is:`,
    ``,
    `(A) Generic improvement (useful to all consumers of the framework)`,
    `    → Reformulate WITHOUT these tokens:`,
    `      - hardcoded paths       → use \${paths.X} (e.g. \${paths.design_system})`,
    `      - "Neo-Brutalism" etc.  → reference identity.design_philosophy`,
    `      - "merchant"/"customer" → reference identity.audience_segments`,
    `      - "recharts" / "@nivo"  → reference stack.charting.canonical`,
    `    Then retry the edit. The scanner will let it through.`,
    ``,
    `(B) Project-specific opinion (only relevant to THIS consumer)`,
    `    → Do not write to \`.framework/\`. Instead write to:`,
    `      .baldart/overlays/<skill-name>.md`,
    `    This file is consumer-owned, never pushed upstream.`,
    `    → Tip: invoke \`/overlay\` for a guided scaffolding flow (correct`,
    `      frontmatter, base_file_sha, drift check). See also`,
    `      \`npx baldart overlay scaffold <type>/<name>\`.`,
    ``,
    `(C) Legitimate illustrative example (e.g. docs that quote the canonical`,
    `    paths as examples — \`PROJECT-CONFIGURATION.md\`, \`project-context.md\`)`,
    `    → Add the opt-out marker at the top of the file:`,
    `      <!-- contamination-scan: skip -->`,
    `    or in the YAML frontmatter:`,
    `      contamination_scan: skip`,
    `    Then retry the edit.`,
    ``,
    `If you are sure none of A/B/C apply, ask the user explicitly before`,
    `forcing the change through (e.g. by temporarily disabling this hook).`,
  ].join('\n');
}

function main() {
  const raw = readStdin();
  const input = parseInput(raw);
  if (!input) safeExit();

  const toolName = input.tool_name;
  if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) safeExit();

  const filePath = input.tool_input && input.tool_input.file_path;
  if (!filePath || typeof filePath !== 'string') safeExit();

  // v3.8.0+: also block direct edits to BALDART-generated files (agents /
  // commands generated from base + overlay). These live OUTSIDE .framework/
  // but carry the baldart-generated marker comment in their head. Pre-v3.27.0
  // the marker was on line 1; v3.27.0+ places it after the YAML frontmatter
  // and the actual file lives in .baldart/generated/<kind>s/<name>.md with a
  // symlink from .claude/agents/<name>.md. Match both layouts. The structural
  // regex (requires kind=…name=…overlay_sha=…) avoids false-positives on
  // framework source files that mention the marker as a string literal
  // (e.g. this hook itself, agent-discovery-info.sh, overlay-merger.js).
  const GENERATED_MARKER_RE =
    /<!-- baldart-generated:[\s\S]{0,400}?kind=\S+[\s\S]{0,400}?name=\S+[\s\S]{0,400}?overlay_sha=\S+/;
  try {
    const fs = require('fs');
    if (fs.existsSync(filePath)) {
      const head = fs.readFileSync(filePath, 'utf8').slice(0, 5000);
      if (GENERATED_MARKER_RE.test(head)) {
        // Allow ONLY if the edit explicitly retains the marker —
        // catches accidental overwrites while letting BALDART itself regenerate.
        const newContent = extractNewContent(toolName, input.tool_input) || '';
        if (!GENERATED_MARKER_RE.test(newContent)) {
          const reason =
            `Cannot edit BALDART-generated file (${filePath}).\n` +
            `This file is auto-generated by \`npx baldart update\` from a base ` +
            `framework file plus an overlay. Manual edits will be overwritten.\n\n` +
            `To customise: edit the corresponding overlay under .baldart/overlays/` +
            `(or create it if absent), then re-run \`npx baldart update\`.\n` +
            `→ Or invoke \`/overlay\` for a guided flow.`;
          try {
            process.stdout.write(JSON.stringify({
              hookSpecificOutput: {
                hookEventName: 'PreToolUse',
                permissionDecision: 'deny',
                permissionDecisionReason: reason
              }
            }));
          } catch (_) {
            process.stderr.write(reason + '\n');
            process.exit(2);
          }
          process.exit(0);
        }
      }
    }
  } catch (_) { /* fail-safe: do not block on hook internal errors */ }

  const realPath = resolveReal(filePath);
  if (!realPath) safeExit();

  const frameworkRoot = findFrameworkRoot(realPath);
  if (!frameworkRoot) safeExit(); // outside .framework/ — nothing to do.

  // Allow writes to .baldart/overlays/ even if the realPath has .framework in
  // it for some reason — paranoid guard, shouldn't normally fire.
  if (realPath.includes(`${path.sep}.baldart${path.sep}overlays${path.sep}`)) safeExit();

  const scanner = loadScanner(frameworkRoot);
  if (!scanner) safeExit(); // fail-safe — scanner missing, let it through.

  const newContent = extractNewContent(toolName, input.tool_input);
  if (!newContent) safeExit();

  let findings;
  try {
    const r = scanner.scan(newContent);
    if (r.optedOut) safeExit(); // file declared opt-out — let through.
    findings = r.findings || [];
  } catch (_) {
    safeExit();
  }

  const decisions = findings.filter((f) => f.severity === 'requires-decision');
  const blockings = findings.filter((f) => f.severity === 'block');
  if (decisions.length === 0 && blockings.length === 0) safeExit();

  const reason = blockReason(realPath, decisions, blockings);

  try {
    process.stdout.write(JSON.stringify({
      hookSpecificOutput: {
        hookEventName: 'PreToolUse',
        permissionDecision: 'deny',
        permissionDecisionReason: reason,
      },
    }));
  } catch (_) {
    // Fallback to legacy mode: exit code 2 + stderr.
    process.stderr.write(reason + '\n');
    process.exit(2);
  }
  process.exit(0);
}

try { main(); } catch (_) { safeExit(); }
