#!/usr/bin/env tsx
/**
 * UserPromptSubmit hook — inject business rules + harness rules context.
 *
 * When the user's prompt mentions a known keyword, bounded context, or
 * aggregate name from a catalog, surface the relevant rule files into Claude's
 * context so it can consult them without explicit instruction.
 *
 * Failsafe behavior (NEVER lose injection on a throw; ALWAYS exit 0):
 *   - If an index file does not exist (catalog not bootstrapped on this clone),
 *     that block emits nothing.
 *   - If an index parses but is the WRONG shape (e.g. `byId` missing/null), that
 *     block emits nothing instead of dereferencing `undefined.byId` and crashing.
 *   - If the repo root cannot be located, injection is skipped entirely.
 *   - The whole `main()` body runs inside a try/catch whose handler is exit(0),
 *     so any unexpected throw still leaves the prompt unblocked (after emitting
 *     whatever was already written).
 *
 * Hook contract: reads JSON from stdin (Claude Code provides
 * `{ prompt, cwd, ... }`), writes context text to stdout (anything written
 * gets injected before the user prompt).
 */
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { loadCatalogIndex } from '../../scripts/_catalog-runtime/index.ts';
import { findRepoRoot } from '../../scripts/_catalog-runtime/repo-root.ts';
import type { IndexedRule } from '../../scripts/_catalog-runtime/types.ts';

interface HookInput {
  prompt?: string;
  cwd?: string;
}

const BR_INDEX_FILE = 'cliter/business-rules-index.json';
const HR_INDEX_FILE = 'cliter/harness-rules-index.json';

const MAX_RULES_INJECTED = 3;
const MAX_BODY_CHARS_PER_RULE = 4000;
// Harness rules get their OWN budget so they never compete with BR for the 3 BR
// slots. HR are constraints (not memory), so they are surfaced as a separate
// labeled block below the BR block.
const MAX_HR_RULES_INJECTED = 3;

function readStdinSync(): string {
  try {
    return readFileSync(0, 'utf-8');
  } catch {
    return '';
  }
}

function scoreRule(rule: IndexedRule, promptLower: string): number {
  let score = 0;
  // Aggregate name match (strong signal)
  for (const agg of rule.aggregates) {
    const aggName = agg.split('/').pop() ?? agg;
    if (promptLower.includes(aggName.toLowerCase())) score += 10;
  }
  // Bounded context name match (medium signal)
  for (const bc of rule.bounded_contexts) {
    if (promptLower.includes(bc.toLowerCase())) score += 5;
  }
  // Keyword match (weakest signal — keywords can be common words)
  for (const kw of rule.keywords) {
    const kwLower = kw.toLowerCase();
    if (kwLower.length < 4) continue; // skip noise
    if (promptLower.includes(kwLower)) score += 3;
  }
  // Rule ID explicit mention
  if (promptLower.includes(rule.id.toLowerCase())) score += 50;
  return score;
}

function loadRuleBody(repoRoot: string, filePath: string, ruleId: string): string | null {
  try {
    const abs = resolve(repoRoot, filePath);
    const content = readFileSync(abs, 'utf-8');
    // Locate the section starting at "## <ruleId> —" until the next "## " or EOF
    const headingPattern = new RegExp(`^##\\s+${ruleId}\\s+—`, 'm');
    const match = content.match(headingPattern);
    if (!match || match.index === undefined) return null;
    const start = match.index;
    const rest = content.slice(start + 1);
    const nextHeadingIdx = rest.search(/^##\s/m);
    const end = nextHeadingIdx === -1 ? content.length : start + 1 + nextHeadingIdx;
    let section = content.slice(start, end).trimEnd();
    if (section.length > MAX_BODY_CHARS_PER_RULE) {
      section = section.slice(0, MAX_BODY_CHARS_PER_RULE) + '\n\n... (truncated)';
    }
    return section;
  } catch {
    return null;
  }
}

main();

function main(): void {
  try {
    runInjection();
  } catch {
    // Any unexpected throw must not lose the prompt. Whatever was already
    // written to stdout stays; we simply exit cleanly.
    process.exit(0);
  }
}

function runInjection(): void {
  const raw = readStdinSync();
  if (!raw.trim()) return;
  let input: HookInput;
  try {
    input = JSON.parse(raw) as HookInput;
  } catch {
    return;
  }
  const prompt = input.prompt ?? '';
  if (!prompt.trim()) return;

  // Resolve the TRUE repo root by walking up. `findRepoRoot` throws when not
  // found; degrade that to "skip injection" rather than crash. (Replaces this
  // hook's former drifted copy of findRepoRoot.)
  let repoRoot: string;
  try {
    repoRoot = findRepoRoot(input.cwd ?? process.cwd());
  } catch {
    return;
  }

  // Missing-index signal (NON-blocking, once-per-prompt): when the HR index file
  // is genuinely ABSENT (existence check, NOT content — a present-but-empty index
  // is fine), surface a single stderr notice so the otherwise-silent degradation
  // (no HR injection, no HR content enforcement) becomes visible. This NEVER
  // changes the exit code and NEVER blocks the prompt. The per-tool-call hooks
  // (architecture-checkpoint, content-guard) stay silent to avoid per-Write spam;
  // this once-per-prompt hook is the sole emitter.
  if (!existsSync(resolve(repoRoot, HR_INDEX_FILE))) {
    process.stderr.write(
      'HR index missing — run `pnpm hr:generate` to activate harness rules\n',
    );
  }

  const promptLower = prompt.toLowerCase();

  // BR block first — its bytes are PRESERVED exactly (the regression baseline).
  // `null` means "BR produced nothing" (index missing, malformed, or no matching
  // rules), which is the case today since the BR catalog is empty.
  const businessRulesOutput = buildBusinessRulesBlock(repoRoot, promptLower);
  if (businessRulesOutput !== null) {
    process.stdout.write(businessRulesOutput);
  }

  // HR block second — a SEPARATE labeled block with CONSTRAINT framing. Degrades
  // to `null` (writes nothing) when the HR index is absent/malformed/irrelevant,
  // so the combined stdout stays byte-identical to the BR-only baseline.
  const harnessRulesOutput = buildHarnessRulesBlock(repoRoot, promptLower);
  if (harnessRulesOutput !== null) {
    process.stdout.write(harnessRulesOutput);
  }
}

/**
 * Builds the Business Rules block exactly as before (byte-for-byte), returning
 * the full stdout payload (`lines.join('\n') + '\n'`) or `null` when nothing
 * should be emitted. The BR scoring/selection/injection logic is UNCHANGED.
 *
 * `loadCatalogIndex` is the shared HOOK failsafe loader: deep structural guard,
 * never throws/exits, returns `null` when the index is absent, corrupt, or
 * malformed (e.g. `byId` missing/null). This is the guard the old inline
 * `JSON.parse(...) as CatalogIndex` lacked, which crashed on
 * `Object.values(undefined)` when an index parsed but had no `byId`.
 */
function buildBusinessRulesBlock(repoRoot: string, promptLower: string): string | null {
  const index = loadCatalogIndex(repoRoot, BR_INDEX_FILE);
  if (index === null) return null;

  const scored: Array<{ rule: IndexedRule; score: number }> = [];
  for (const rule of Object.values(index.byId)) {
    if (rule.state === 'derogated') continue;
    const score = scoreRule(rule, promptLower);
    if (score > 0) scored.push({ rule, score });
  }
  if (scored.length === 0) return null;

  scored.sort((a, b) => b.score - a.score);
  const top = scored.slice(0, MAX_RULES_INJECTED);

  const lines: string[] = [];
  lines.push('---');
  lines.push('# Business rules potencialmente relevantes para este prompt');
  lines.push('');
  lines.push(
    `Detectadas ${scored.length} reglas activas/propuestas con relación al prompt; mostrando las ${top.length} más relevantes. El catálogo completo vive en \`cliter/<bc>/business-rules/<aggregate>.md\`.`,
  );
  lines.push('');
  for (const { rule } of top) {
    const body = loadRuleBody(repoRoot, rule.filePath, rule.id);
    if (body) {
      lines.push(body);
      lines.push('');
    } else {
      lines.push(`## ${rule.id} — ${rule.title}`);
      lines.push(
        `(state: ${rule.state}, severity: ${rule.severity}, file: ${rule.filePath})`,
      );
      lines.push('');
    }
  }
  lines.push('---');
  lines.push(
    'Recordatorio: las reglas anteriores son **memoria + detector**, no enforcement. El código sigue siendo la verdad ejecutable. Si el cambio toca alguna de estas reglas, declara las acciones en `proposal.md` (sección "Business rules affected") y ejecuta `/business-rules:promote` tras `/opsx:archive`.',
  );

  return lines.join('\n') + '\n';
}

/**
 * Builds the Harness Rules block: a SEPARATE labeled block framed as
 * CONSTRAINTS (the HR govern what you are about to touch), distinct from BR's
 * "memory not enforcement" footer. Reuses the same keyword scorer, but with its
 * OWN budget (`MAX_HR_RULES_INJECTED`). Only `state === 'active'` rules. Returns
 * `null` (nothing emitted) when the HR index is absent/corrupt/malformed or no
 * rule scores > 0 — preserving the BR-only baseline.
 */
function buildHarnessRulesBlock(repoRoot: string, promptLower: string): string | null {
  const index = loadCatalogIndex(repoRoot, HR_INDEX_FILE);
  if (index === null) return null;

  const scored: Array<{ rule: IndexedRule; score: number }> = [];
  for (const rule of Object.values(index.byId)) {
    if (rule.state !== 'active') continue;
    const score = scoreRule(rule, promptLower);
    if (score > 0) scored.push({ rule, score });
  }
  if (scored.length === 0) return null;

  scored.sort((a, b) => b.score - a.score);
  const top = scored.slice(0, MAX_HR_RULES_INJECTED);

  const lines: string[] = [];
  lines.push('---');
  lines.push('# Harness rules que gobiernan este trabajo — constraints arquitectónicos, no solo memoria');
  lines.push('');
  lines.push(
    `Detectadas ${scored.length} harness rules activas relacionadas con el prompt; mostrando las ${top.length} más relevantes. Estas NO son memoria opcional: son CONSTRAINTS arquitectónicos que gobiernan lo que vas a tocar. El catálogo vive en \`cliter/harness-rules/<topic>.md\` (índice: \`cliter/harness-rules-index.json\`).`,
  );
  lines.push('');
  for (const { rule } of top) {
    const body = loadRuleBody(repoRoot, rule.filePath, rule.id);
    if (body) {
      lines.push(body);
      lines.push('');
    } else {
      lines.push(`## ${rule.id} — ${rule.title}`);
      lines.push(
        `(state: ${rule.state}, severity: ${rule.severity}, paths: ${rule.paths.join(', ')}, file: ${rule.filePath})`,
      );
      lines.push('');
    }
  }
  lines.push('---');
  lines.push(
    'Antes de tocar código gobernado por estas reglas: COMPLY por defecto. Si tu approach VIOLA una HR `blocking`, PARA — la opción es derogar la regla (decisión arquitectónica deliberada en su propio change), crear una excepción justificada, o demostrar que no hay conflicto. Las harness rules no se cristalizan por-change como las business rules: son constraints estables.',
  );

  return lines.join('\n') + '\n';
}
