#!/usr/bin/env python3
"""codepremise capture hook — one script, three agents.

After the agent edits a file, verify every .codepremise node pointing at that file:
if a pointer's hash no longer matches the code, remind the agent to amend or
re-affirm those nodes in this same run.

Wiring:
  Claude Code  .claude/settings.json  PostToolUse on Edit|Write|MultiEdit
  Codex CLI    .codex/hooks.json      PostToolUse (hooks engine, v0.124+)
  Cursor       .cursor/hooks.json     afterFileEdit (hooks, v1.7+)

Input shapes differ per agent; this script accepts all of them:
  Claude Code / Codex: {"tool_input": {"file_path": ...}, "cwd": ...}
  Cursor:              {"file_path": ..., "workspace_roots": [...]}

Exit 2 + stderr feeds the warning back to the model on Claude Code and Codex.
Cursor's afterFileEdit is observational — the warning lands in the hook
output/audit log; the always-on rule in .cursor/rules/codepremise.mdc carries the
instruction load there.
"""

import hashlib
import json
import os
import sys


def line_hash(path: str, start: int, end: int) -> str | None:
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            lines = f.read().split("\n")
        text = "\n".join(lines[start - 1 : end])
        return hashlib.sha256(text.encode()).hexdigest()[:12]
    except OSError:
        return None


def main() -> int:
    try:
        payload = json.load(sys.stdin)
    except json.JSONDecodeError:
        return 0

    # Claude Code / Codex shape first, then Cursor's
    file_path = (payload.get("tool_input") or {}).get("file_path") or payload.get(
        "file_path"
    )
    roots = payload.get("workspace_roots") or []
    cwd = payload.get("cwd") or (roots[0] if roots else None) or os.getcwd()
    if not file_path:
        return 0

    nodes_dir = os.path.join(cwd, ".codepremise", "nodes")
    if not os.path.isdir(nodes_dir):
        return 0

    rel = os.path.relpath(file_path, cwd)
    if rel.startswith(".codepremise"):
        return 0  # editing the map itself is fine

    stale = []
    for name in os.listdir(nodes_dir):
        if not name.endswith(".json"):
            continue
        try:
            with open(os.path.join(nodes_dir, name), encoding="utf-8") as f:
                node = json.load(f)
        except (OSError, json.JSONDecodeError):
            continue
        refs = list(node.get("code") or [])
        for step in node.get("steps") or []:
            if isinstance(step, dict) and step.get("code"):
                refs.append(step["code"])
        for ref in refs:
            if ref.get("file") != rel:
                continue
            actual = line_hash(
                os.path.join(cwd, rel), ref.get("start_line", 1), ref.get("end_line", 1)
            )
            if actual is not None and actual != ref.get("hash"):
                stale.append(node.get("id", name))
                break

    if stale:
        print(
            "CODEPREMISE MAP OUT OF DATE: your edit to "
            f"{rel} changed code claimed by these .codepremise nodes: {', '.join(sorted(set(stale)))}. "
            "Before finishing, for each node either AMEND it (behavior changed: update "
            "what/why + pointers) or RE-AFFIRM it (pure refactor: update only "
            "start_line/end_line/hash). Read the node's why first — it may describe a "
            "constraint your edit just violated. To see the full blast radius "
            "(dependents, flows, corner cases, shared code), run: "
            f"node .codepremise/validate.mjs . --impact {rel}. "
            "Full rules: .agents/skills/codepremise/SKILL.md",
            file=sys.stderr,
        )
        return 2

    return 0


if __name__ == "__main__":
    sys.exit(main())
