#!/usr/bin/env tsx
/**
 * PreToolUse hook — a one-time NUDGE on creation of NEW architectural files in
 * backend/src/@(api|app|aurora|bridges)/... It surfaces the structural context
 * (path classification, the relevant `catalyst-project-structure` skill section,
 * and any governing harness rules) ONCE per path per session, then lets the
 * model proceed.
 *
 * This is a NUDGE, NOT a verification gate. There is NO verification signal
 * between attempts: the path is cached at the FIRST block (before `exit(2)`), so
 * an identical retry to the SAME path proceeds with nothing consulted. The value
 * is the one-shot context injection at the moment a new architectural file is
 * about to be created — not hard prevention. The REAL enforcement for mechanical
 * rules is the content-guard (`harness-rules-content-guard.ts`) plus
 * `pnpm hr:check` over the working tree; this hook only makes sure the model
 * sees the structural context before it writes.
 *
 * Hook contract:
 *   - Receives `{ tool_name, tool_input, ... }` via stdin (Claude Code).
 *   - Exit 0 → tool proceeds.
 *   - Exit 2 + stderr → tool blocked once, message shown to the AI.
 *
 * One-nudge-per-path-per-session semantics:
 *   - The first Write to a NEW architectural path is blocked once with a message
 *     pointing the model to the catalyst-project-structure skill.
 *   - The path is appended to a per-project state file
 *     (`.claude/.cache/architecture-checkpoint.txt`) BEFORE the block. On retry to
 *     the same path the hook allows the write — there is no check that the skill
 *     was actually consulted; the cache simply ensures the nudge fires at most
 *     once per path.
 *   - The state file is wiped on `SessionStart` (see `.claude/settings.json`),
 *     so each new session starts with a clean slate. This is essential: without
 *     the wipe, the hook would silently degrade over time as the cache accumulated
 *     every architectural path ever created in the project.
 *   - Edits to EXISTING files are never intercepted — `@aurora-catalyst-generated`
 *     header files are fully editable; Cliter's `.origin` reconciliation handles regen.
 *
 * Path-shape detection:
 *   - The hook classifies the target path as one of:
 *       1. AGGREGATE (Cliter-generated territory) — `@(api|app)/<bc>/<folder>/...`
 *          where `cliter/<bc>/<folder>.aurora.yaml` exists.
 *       2. HAND-WRITTEN FLOW — same path shape, but no matching YAML. The folder
 *          name is a hand-written protocol-flow (e.g., `oauth-flows`).
 *       3. CROSS-BC PORT — `@bridges/...`.
 *       4. FRAMEWORK / SHARED — `@aurora/...`.
 *   - The block message is tailored to the classification, pointing the model at
 *     the SPECIFIC skill section relevant to the new file.
 *
 * Failsafe: any error in the hook itself exits 0 (never block on hook bugs).
 */
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, isAbsolute, resolve } from 'node:path';
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';

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

interface ContentViolation {
  id: string;
  message: string;
}

interface HookInput {
  tool_name?: string;
  tool_input?: {
    file_path?: string;
    content?: string;
  };
  cwd?: string;
}

type PathClassification =
  | { kind: 'aggregate'; bc: string; aggregate: string }
  | { kind: 'hand-written-flow'; bc: string; flow: string; layer: 'api' | 'app' }
  | { kind: 'cross-bc' }
  | { kind: 'framework' }
  | { kind: 'unknown' };

const WATCHED_PATTERN = /backend\/src\/@(api|app|aurora|bridges)\//;
const API_APP_BC_FOLDER = /backend\/src\/@(api|app)\/([^/]+)\/([^/]+)\//;
const BRIDGES_PATTERN = /backend\/src\/@bridges\//;
const AURORA_PATTERN = /backend\/src\/@aurora\//;

main();

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

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

  if (input.tool_name !== 'Write') process.exit(0);
  const rawFilePath = input.tool_input?.file_path;
  if (!rawFilePath) process.exit(0);
  if (!WATCHED_PATTERN.test(rawFilePath)) process.exit(0);

  // Resolve to an absolute path against the TRUE repo root before the
  // new-vs-existing `existsSync` check. Today's `file_path` is already absolute
  // (no-op), but a relative path would otherwise be resolved against the hook's
  // cwd and misjudge new-vs-existing — mirroring how the HR lookup below
  // relativizes against `findRepoRoot()`. Degrade to the raw path if the repo
  // root cannot be located, preserving the prior behavior.
  let filePath = rawFilePath;
  if (!isAbsolute(filePath)) {
    try {
      filePath = resolve(findRepoRoot(), rawFilePath);
    } catch {
      filePath = rawFilePath;
    }
  }

  // Editing an existing file is fine — the @aurora-catalyst-generated header
  // is editable; .origin reconciliation handles regen.
  if (existsSync(filePath)) process.exit(0);

  const projectDir = process.env.CLAUDE_PROJECT_DIR ?? input.cwd ?? process.cwd();
  const stateFile = resolve(projectDir, '.claude/.cache/architecture-checkpoint.txt');

  // Allow if this exact path was already nudged earlier this session. There is
  // NO verification signal between attempts — the cache only guarantees the nudge
  // fires at most once per path; a retry to the same path simply proceeds.
  if (existsSync(stateFile)) {
    const nudgedPaths = readFileSync(stateFile, 'utf-8').split('\n').filter(Boolean);
    if (nudgedPaths.includes(filePath)) process.exit(0);
  }

  // Record this path BEFORE the block so a retry to the same path proceeds (the
  // nudge fires once per path per session).
  try {
    mkdirSync(dirname(stateFile), { recursive: true });
    const existing = existsSync(stateFile) ? readFileSync(stateFile, 'utf-8') : '';
    writeFileSync(stateFile, existing + filePath + '\n');
  } catch {
    // Non-fatal — proceed to block message anyway.
  }

  const classification = classifyPath(filePath, projectDir);
  // Rule-aware messaging: resolve the active HR rules that GOVERN this path and
  // surface their IDs in the (already-firing) block message. This adds NO new
  // blocking condition — the exit(2) below stays governed solely by the
  // structural gate above. The lookup is FULLY ISOLATED (its own try/catch
  // returning `[]`), so a missing/corrupt/malformed index — or any throw inside
  // it — degrades to an empty HR list and the structural block STILL fires. The
  // message is byte-identical to the pre-HR behavior when the list is empty.
  const governingHrIds = resolveGoverningHrIds(filePath);
  // Surface, in this SAME first block message, any content-detectable violation
  // the about-to-be-written content would trip — so the model sees the structural
  // objection AND the content objection in one shot. This is an isolated lookup
  // (its own try/catch → []), so it can NEVER suppress or alter the structural
  // exit(2) below; it only enriches the message.
  const contentViolations = resolveContentViolations(filePath, input.tool_input?.content ?? '');
  process.stderr.write(
    buildMessage(filePath, classification, governingHrIds, contentViolations),
  );
  process.exit(2);
}

/**
 * Read-only lookup of the active Harness Rules whose `paths` globs match the
 * target file. REUSES the shared catalog runtime (`loadCatalogIndex`,
 * `rulesByPath`, `toRepoRelative`) plus the canonical `findRepoRoot`.
 *
 * The ENTIRE body is wrapped so it returns `[]` on ANY failure — missing/corrupt
 * index, repo root not found, or an unexpected throw. This is what guarantees a
 * problem in the HR lookup can NEVER suppress or alter the structural exit(2)
 * that follows the call site.
 */
function resolveGoverningHrIds(filePath: string): string[] {
  try {
    const repoRoot = findRepoRoot();
    const index = loadCatalogIndex(repoRoot, HR_INDEX_FILE);
    if (index === null) return [];
    // Rule `paths` globs are repo-relative and anchored (`^…$`); the hook
    // receives an ABSOLUTE `file_path`. Relativize against the TRUE repo root.
    const relativePath = toRepoRelative(repoRoot, filePath);
    const matched: IndexedRule[] = rulesByPath(index, [relativePath]);
    return matched
      .filter((rule) => rule.state === 'active')
      .map((rule) => rule.id)
      .sort((a, b) => a.localeCompare(b));
  } catch {
    return [];
  }
}

/**
 * Read-only lookup of CONTENT violations the about-to-be-written `content` would
 * trip: for the active HR rules that govern this path AND declare a usable
 * `detect` (the same filter the content-guard's `activeDetect` applies), keep the
 * ones whose `detect.pattern` is a substring of `content`. Returns each such
 * rule's id + its `detect.message` so the checkpoint can surface the content
 * objection in its FIRST block message, alongside the governing-HR-id section.
 *
 * This adds NO new blocking condition — it only enriches a message on an
 * already-structurally-blocked Write. Like `resolveGoverningHrIds`, the ENTIRE
 * body is wrapped so it returns `[]` on ANY failure (missing/corrupt/malformed
 * index, repo root not found, or any throw), so a problem here can NEVER suppress
 * or alter the structural exit(2). An absent/empty `content` yields `[]`.
 */
function resolveContentViolations(filePath: string, content: string): ContentViolation[] {
  try {
    if (content.length === 0) return [];
    const repoRoot = findRepoRoot();
    const index = loadCatalogIndex(repoRoot, HR_INDEX_FILE);
    if (index === null) return [];
    const relativePath = toRepoRelative(repoRoot, filePath);
    const matched: IndexedRule[] = rulesByPath(index, [relativePath]);
    const violations: ContentViolation[] = [];
    for (const rule of matched) {
      const detect = activeDetect(rule);
      if (detect === null) continue;
      if (content.includes(detect.pattern)) {
        violations.push({ id: rule.id, message: detect.message });
      }
    }
    return violations.sort((a, b) => a.id.localeCompare(b.id));
  } catch {
    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`. This
 * mirrors the content-guard's `activeDetect` filter so both hooks agree on which
 * rules carry a mechanical content check. The index is genuinely external JSON,
 * so the shape is narrowed at runtime here (honest boundary narrowing).
 */
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 classifyPath(filePath: string, projectDir: string): PathClassification {
  const apiAppMatch = filePath.match(API_APP_BC_FOLDER);
  if (apiAppMatch) {
    const [, layer, bc, folder] = apiAppMatch;
    const aggregateYaml = resolve(projectDir, 'cliter', bc, `${folder}.aurora.yaml`);
    if (existsSync(aggregateYaml)) {
      return { kind: 'aggregate', bc, aggregate: folder };
    }
    // No matching YAML → hand-written flow OR a BC whose YAML uses subdirs.
    // Quick sanity check: if cliter/<bc>/ exists but no <folder>.aurora.yaml,
    // treat as hand-written. If the BC dir does not exist at all, fall through.
    const bcDir = resolve(projectDir, 'cliter', bc);
    if (existsSync(bcDir) && listYamlAggregates(bcDir).length > 0) {
      return {
        kind: 'hand-written-flow',
        bc,
        flow: folder,
        layer: layer as 'api' | 'app',
      };
    }
  }

  if (BRIDGES_PATTERN.test(filePath)) return { kind: 'cross-bc' };
  if (AURORA_PATTERN.test(filePath)) return { kind: 'framework' };

  return { kind: 'unknown' };
}

function listYamlAggregates(bcDir: string): string[] {
  try {
    return readdirSync(bcDir)
      .filter((name) => name.endsWith('.aurora.yaml'))
      .map((name) => name.replace(/\.aurora\.yaml$/, ''));
  } catch {
    return [];
  }
}

function buildMessage(
  filePath: string,
  classification: PathClassification,
  governingHrIds: string[],
  contentViolations: ContentViolation[],
): string {
  const header = `
🏗 Architecture checkpoint · new file under backend/src/
   Target: ${filePath}
   Type:   ${describeClassification(classification)}
`;

  const body = bodyFor(classification);

  // Emitted ONLY when at least one active HR governs this path. When empty (no
  // match, or HR index absent/corrupt), this contributes the empty string and
  // the message is byte-identical to the pre-HR baseline.
  const harnessRulesSection =
    governingHrIds.length > 0
      ? `   Harness rules que gobiernan este path: ${governingHrIds.join(', ')} — consúltalas.\n`
      : '';

  // Emitted ONLY when the about-to-be-written content trips a governing rule's
  // mechanical `detect.pattern`. When empty (no content violation, content
  // absent, or HR index absent/corrupt), this contributes the empty string and
  // the message stays byte-identical to the structural-only / HR-id-only baseline.
  const contentViolationSection =
    contentViolations.length > 0
      ? `   Violaciones de contenido detectadas (corrígelas ANTES de reintentar):\n${contentViolations
          .map((violation) => `     • ${violation.id}: ${violation.message}`)
          .join('\n')}\n`
      : '';

  return (
    header +
    body +
    harnessRulesSection +
    contentViolationSection +
    `   Nudge único por sesión — reintenta el Write tras consultar lo anterior.\n`
  );
}

function describeClassification(c: PathClassification): string {
  switch (c.kind) {
    case 'aggregate':
      return `Cliter-generated aggregate territory (${c.bc}/${c.aggregate})`;
    case 'hand-written-flow':
      return `HAND-WRITTEN protocol-flow (${c.bc}/${c.flow}, layer @${c.layer})`;
    case 'cross-bc':
      return 'Cross-BC bridge / port / adapter';
    case 'framework':
      return 'Framework / shared infrastructure (@aurora)';
    case 'unknown':
      return 'Unclassified path under backend/src/';
  }
}

function bodyFor(c: PathClassification): string {
  if (c.kind === 'hand-written-flow') {
    return `
Hand-written protocol-flow under \`@${c.layer}/${c.bc}/${c.flow}/\` — not a Cliter aggregate; it
follows the SAME barrel-and-spread convention as Cliter aggregates. Before writing, read:
  • Skill 'catalyst-project-structure' → "Hand-written protocol-flow endpoints (non-aggregate)"
  • Skill 'catalyst-handler-composer' → "Composite handlers" (if exposed over BOTH REST + GraphQL)
Don't create a dedicated \`<flow>.module.ts\` — register via barrel-and-spread in \`${c.bc}.module.ts\`.
Don't place \`*.controller.ts\` / \`*.resolver.ts\` flat in the flow root — use \`controllers/\` and \`resolvers/\` subdirs.
`;
  }

  if (c.kind === 'aggregate') {
    return `
Path under a Cliter-managed aggregate (\`${c.bc}/${c.aggregate}\`) — most code here is CLI-generated.
  • Skill 'catalyst-project-structure' → "Decision trees" → "I need a new file. Where does it go?"
  • CRUD on the aggregate → edit \`cliter/${c.bc}/${c.aggregate}.aurora.yaml\` and run
    \`catalyst load back module --name=${c.bc}/${c.aggregate} --force\`.
  • \`@aurora-catalyst-generated\` files ARE editable (.origin reconciles) — do NOT create a shadow module.
`;
  }

  if (c.kind === 'cross-bc') {
    return `
Path in \`@bridges/\` — cross-BC port / adapter / bridge.
  • Skill 'catalyst-cross-bounded-context-ports' (REQUIRED): the 4-file pattern (port + token + adapter + bridge).
  • Direction: the port lives in the CONSUMER's territory, the adapter in the SUPPLIER's. Don't flip them.
`;
  }

  if (c.kind === 'framework') {
    return `
Path in \`@aurora/\` — framework / shared infrastructure (hand-written, owned by the project; edit freely).
  • Skill 'catalyst-project-structure' for placement (decorators in \`@aurora/decorators/\`, modules in \`@aurora/modules/<feature>/\`).
  • Guard / interceptor / pipe / decorator? → Skill 'catalyst-nestjs-primitives' (reuse-first gate + dual-context pattern).
`;
  }

  return `
Unclassified path under \`backend/src/\`. Read Skill 'catalyst-project-structure' and verify the layer
(\`@api\` / \`@app\` / \`@aurora\` / \`@bridges\`) and the file-name convention before retrying the Write.
`;
}
