#!/usr/bin/env tsx
/**
 * PreToolUse hook — catalog-driven, best-effort write-time guard for Harness
 * Rules (HR).
 *
 * This is a fast nudge at the point of writing, NOT exhaustive enforcement: it
 * blocks a Write/Edit/MultiEdit whose to-be-written CONTENT contains a rule's
 * `detect.pattern` (literal substring) AND whose target file falls under that
 * rule's `paths` globs. It only sees the AI's edit tools and only the INCOMING
 * text — content written via Bash (heredoc, `catalyst generate`), or a violation
 * RELOCATED into a governed path via `mv`, bypasses it. `pnpm hr:check` over the
 * working tree is the exhaustive backstop; this hook just catches the common
 * case the moment it is written.
 *
 * The hook knows NO rule by hand. It reads `cliter/harness-rules-index.json`,
 * iterates `byId`, and acts only on rules that are `state: 'active'` and declare
 * a `detect` block. Adding a new mechanical rule is therefore a CATALOG edit
 * (declare `detect` in the rule's `.md`, run `pnpm hr:generate`), never a hook
 * edit. Today the only such rule is HR-ACL-001 (the double-cast pattern).
 *
 * Hook contract:
 *   - Receives `{ tool_name, tool_input, cwd }` via stdin (Claude Code).
 *   - Exit 0 → tool proceeds.
 *   - Exit 2 + stderr → tool blocked, the rule's `detect.message` is shown to the AI.
 *
 * Failsafe behavior (never block on infrastructure failure):
 *   - Non-Write/Edit/MultiEdit tool → exit 0.
 *   - Missing/corrupt/malformed index → exit 0 (degrade to no enforcement).
 *   - Repo root not found / malformed stdin / ANY unexpected throw → exit 0.
 *   The whole `main()` body runs inside a try/catch whose handler is `exit(0)`,
 *   so NO degraded input can ever turn this hook into an accidental block.
 *
 * Scope safety: HR-ACL-001's `paths` are `backend/src/@app/**` and
 * `backend/src/@bridges/**`. Edits under `.claude/`, `docs/`, `openspec/`, or any
 * markdown are NEVER blocked — they fall outside the rule's `paths`. This hook
 * itself lives in `.claude/hooks/`, so mentioning the pattern here does not
 * self-block. The `glob`/`rulesByPath` matcher and the index loader/guard/
 * relativizer are REUSED from the shared catalog runtime, not reimplemented.
 *
 * Repo-root note: paths are relativized against the TRUE repo root resolved by
 * walking up (`findRepoRoot`), NOT against `CLAUDE_PROJECT_DIR ?? cwd`. The
 * latter mis-relativizes when `CLAUDE_PROJECT_DIR` is unset and `cwd` is a
 * subdir, silently failing enforcement.
 */
import { readFileSync } from 'node:fs';
import {
  loadCatalogIndex,
  rulesByPath,
  toRepoRelative,
} from '../../scripts/_catalog-runtime/index.ts';
import { findRepoRoot } from '../../scripts/_catalog-runtime/repo-root.ts';
import type { IndexedRule, RuleDetect } from '../../scripts/_catalog-runtime/types.ts';

interface MultiEditEntry {
  new_string?: string;
}

interface HookInput {
  tool_name?: string;
  tool_input?: {
    file_path?: string;
    content?: string;
    new_string?: string;
    edits?: MultiEditEntry[];
  };
  cwd?: string;
}

const CONTENT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
const HR_INDEX_FILE = 'cliter/harness-rules-index.json';

// Test files are excluded from content enforcement: the double-cast pattern
// (`as ... as`) is idiomatic in test mocks, and HR-ACL-001 targets PRODUCTION
// fake-ACLs — yet its `paths` (`backend/src/@app/**`, `@bridges/**`) include
// co-located specs. Skipping any `*.spec.ts(x)` / `*.test.ts(x)` is the
// pragmatic universal default.
// NOTE (deferred, #10): the eventual richer contract is a per-rule
// `detect.exclude` glob list so each rule decides its own exclusions. Until that
// lands, this blanket test-file skip is the chosen default for ALL content rules.
const TEST_FILE_PATTERN = /\.(spec|test)\.[cm]?tsx?$/;

main();

function main(): void {
  try {
    runGuard();
  } catch {
    // Any unexpected throw (malformed input, missing repo root, etc.) must NOT
    // turn into a block. Degrade to "allow".
    process.exit(0);
  }
}

function runGuard(): void {
  let raw = '';
  try {
    raw = readFileSync(0, 'utf-8');
  } catch {
    process.exit(0);
  }
  if (!raw.trim()) process.exit(0);

  let input: HookInput;
  try {
    input = JSON.parse(raw) as HookInput;
  } catch {
    process.exit(0);
  }

  if (typeof input.tool_name !== 'string' || !CONTENT_TOOLS.has(input.tool_name)) {
    process.exit(0);
  }

  const filePath = input.tool_input?.file_path;
  if (typeof filePath !== 'string' || filePath.length === 0) process.exit(0);

  // Test-file exclusion (see TEST_FILE_PATTERN note above).
  if (TEST_FILE_PATTERN.test(filePath)) process.exit(0);

  const content = extractContent(input);
  if (content.length === 0) process.exit(0);

  // Resolve the TRUE repo root by walking up. A not-found repo root would throw
  // from `findRepoRoot`; the outer try/catch turns that into exit(0).
  const repoRoot = findRepoRoot();
  const index = loadCatalogIndex(repoRoot, HR_INDEX_FILE);
  if (index === null) process.exit(0);

  // Rule `paths` globs are repo-relative and anchored (`^…$`), but Claude Code
  // hands the hook an ABSOLUTE `file_path`. Relativize against the repo root
  // before matching so `backend/src/@app/**` can match.
  const relativePath = toRepoRelative(repoRoot, filePath);

  // Resolve which active HR rules govern this exact file path (REUSES the shared
  // glob matcher — no reimplementation). Restrict the mechanical scan to those.
  const governing = rulesByPath(index, [relativePath]);
  for (const rule of governing) {
    const detect = activeDetect(rule);
    if (detect === null) continue;
    if (content.includes(detect.pattern)) {
      process.stderr.write(blockMessage(rule.id, filePath, detect));
      process.exit(2);
    }
  }

  process.exit(0);
}

/**
 * Extracts the to-be-written content for each supported tool:
 *   - Write     → `tool_input.content`
 *   - Edit      → `tool_input.new_string`
 *   - MultiEdit → concatenation of every `edits[].new_string`
 * Returns '' when nothing is present (the caller treats that as "nothing to scan").
 *
 * KNOWN LIMITATION (deferred, #5): for Edit/MultiEdit this scans only the
 * INCOMING `new_string`(s), not the merged result of applying the edit to the
 * existing file. A pattern split across an unchanged region and an edit would
 * not be detected. This is a best-effort write-time guard, not a full content
 * audit; `pnpm hr:check` over the working tree remains the exhaustive backstop.
 */
function extractContent(input: HookInput): string {
  const toolInput = input.tool_input;
  if (toolInput === undefined) return '';

  if (input.tool_name === 'Write') {
    return typeof toolInput.content === 'string' ? toolInput.content : '';
  }
  if (input.tool_name === 'Edit') {
    return typeof toolInput.new_string === 'string' ? toolInput.new_string : '';
  }
  if (input.tool_name === 'MultiEdit') {
    const edits = toolInput.edits;
    if (!Array.isArray(edits)) return '';
    const parts: string[] = [];
    for (const edit of edits) {
      if (edit && typeof edit.new_string === 'string') parts.push(edit.new_string);
    }
    return parts.join('\n');
  }
  return '';
}

/**
 * Returns the rule's `detect` contract only when the rule is active AND declares
 * a usable `detect` (non-empty `pattern` and `message`); otherwise `null`. The
 * index is genuinely external JSON, so the shape is narrowed at runtime here
 * (honest boundary narrowing, NOT a fake ACL).
 */
function activeDetect(rule: IndexedRule): RuleDetect | null {
  if (rule.state !== 'active') return null;
  const detect = rule.detect;
  if (detect === undefined) return null;
  if (typeof detect.pattern !== 'string' || detect.pattern.length === 0) return null;
  if (typeof detect.message !== 'string' || detect.message.length === 0) return null;
  return detect;
}

function blockMessage(ruleId: string, filePath: string, detect: RuleDetect): string {
  return `
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🛑  HARNESS RULE VIOLATION (content-guard) — ${ruleId}

Target: ${filePath}
Patrón detectado: ${JSON.stringify(detect.pattern)}

${detect.message}

Este es un bloqueo MECÁNICO catalog-driven: la regla ${ruleId} gobierna este path y
declara un patrón de contenido prohibido. Corrige el contenido y reintenta el Write.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`;
}
