#!/usr/bin/env python3
"""generate-tool-shims — compile tools.json deny rules into PATH shims.

Reads modules/workstation/claude/tools.json (the same registry deny-gate.mjs
enforces for Claude's own Bash tool) and, for every rule that opts in with a
"shim_binary" field, emits a standalone PATH shim for that binary next to
_git-guard-shim.sh: bin/<binary>. Each emitted shim has the matched rule's
regex and guidance baked in as literal text — it never reads tools.json (or
any other registry) at run time, so an unreadable/malformed tools.json only
ever breaks the NEXT `generate-tool-shims` run, never a shim already on disk.

A rule opts in explicitly (shim_binary), because a rule's regex text alone
cannot say whether it is safe to enforce at one binary's argv:
  - many rules span multiple processes (a dev server + a browser client) or
    the whole shell command line (an unbounded while/sleep loop) — no single
    exec boundary sees that shape.
  - some target coreutils (rm/mv/cp/ln/unlink/tee) — shimming those intercepts
    every filesystem call on the box, an unacceptable blast radius for one
    narrow rule.
  - some target this project's own wrapper scripts (cdx, na.sh, opencode.sh)
    — those already run our code and should enforce the rule themselves,
    not be re-wrapped by a second shim.
  - rewrite-band rules (gh-run-watch-unbounded, playwright-install-*) change
    the command instead of refusing it; replicating a rewrite transparently
    in a shim is a different, riskier contract than refuse-or-passthrough.
Every rule without shim_binary is still enforced locally by deny-gate.mjs;
this generator only extends coverage to remote PATH-only runtimes.

Usage:
  generate-tool-shims [--check]
    (no args)  regenerate bin/<binary> for every shim_binary rule and the
               registry-hash snapshot; exits 0 always (write mode).
    --check    regenerate into a scratch dir and diff against what is on
               disk; exit 0 clean, 1 stale (a landed tools.json edit that
               generate-tool-shims was never re-run for).
"""
import hashlib
import json
import re
import shutil
import sys
import tempfile
from pathlib import Path

BIN_DIR = Path(__file__).resolve().parent
LIB_DIR = BIN_DIR.parent / "lib"
TOOLS_JSON = BIN_DIR.parent / "tools.json"
SNAPSHOT = LIB_DIR / "tool-shims.snapshot.json"
MARKER = "OD_PATH_SHIM_MARKER"
DENY_EXIT = 76

SHIM_TEMPLATE = """#!/usr/bin/env bash
# {binary} — GENERATED by generate-tool-shims from tools.json rule "{rule_id}".
# {marker} OD_TOOL_SHIM_MARKER — do not hand-edit; re-run generate-tool-shims.
# Registry hash at generation time: {reg_hash}
set -e

name="${{0##*/}}"
SHIM_DIR="$(cd "$(dirname "$(readlink -f -- "${{BASH_SOURCE[0]}}")")" && pwd)"

GUARD_LIB="$SHIM_DIR/../lib/shim-guard.sh"
if [[ ! -r "$GUARD_LIB" ]]; then
  echo "$name: tool shim cannot read $GUARD_LIB — refusing" >&2
  exit 78
fi
# shellcheck source=../lib/shim-guard.sh
source "$GUARD_LIB"
shim_guard_enter "$name"

if ! shim_pin_read "{binary}"; then
  echo "$name: tool shim has no usable pinned {binary} (looked in: $SHIM_PIN_TRIED) — run $SHIM_DIR/install-tool-shims-real" >&2
  exit 78
fi
real="$SHIM_PIN_REAL"
shim_guard_clear "$name"

if [[ "$SHIM_REENTRY" == 1 ]]; then
  exec "$real" "$@"
fi

# Matched against "$name $*", not just "$*": the rule text (ported verbatim from
# tools.json) was written to match a full typed command line INCLUDING the binary
# name — args alone would never contain literals like "ccr" or "podman".
cmd="$name $*"
# The pattern MUST be held in a variable, not written inline in [[ =~ <literal> ]]:
# bash strips backslash escapes from an inline pattern before regcomp ever sees them,
# silently turning a boundary/whitespace class into a no-match. A variable reference
# is passed through unquoted-word-split, unmangled.
_pattern='{pattern}'
if [[ "${{AGENT_BUILD_SCOPE_ACTIVE:-}}" == "1" ]] && [[ "$cmd" =~ $_pattern ]]{unless_clause}; then
  cat >&2 <<'MSG'
tool-shim ({rule_id}): {reason_escaped}
Use instead: {wrapper_escaped}
MSG
  exit {deny_exit}
fi

exec "$real" "$@"
"""


def fail(msg):
    print(f"generate-tool-shims: {msg}", file=sys.stderr)
    sys.exit(1)


def load_rules():
    try:
        raw = TOOLS_JSON.read_text()
    except OSError as e:
        fail(f"cannot read {TOOLS_JSON}: {e}")
    try:
        doc = json.loads(raw)
    except json.JSONDecodeError as e:
        fail(f"{TOOLS_JSON} is not valid JSON: {e}")
    rules = doc.get("rules")
    if not isinstance(rules, list):
        fail(f"{TOOLS_JSON} has no 'rules' array")
    reg_hash = hashlib.sha256(raw.encode()).hexdigest()[:12]
    return rules, reg_hash


def shimmable(rules):
    """-> (binary -> rule dict), audit list of (rule_id, reason)."""
    chosen = {}
    audit = []
    for rule in rules:
        rid = rule.get("id", "<unnamed>")
        binary = rule.get("shim_binary")
        if not binary:
            audit.append((rid, "no shim_binary — not opted in (see module docstring for why)"))
            continue
        if rule.get("band") in ("suggest", "rewrite"):
            audit.append((rid, f"band={rule['band']} — shims only refuse-or-passthrough, never rewrite/advise"))
            continue
        if rule.get("rewrite_from"):
            audit.append((rid, "rewrite_from set — rewrite rules are not shimmed"))
            continue
        if not re.fullmatch(r"[A-Za-z0-9._-]+", binary):
            fail(f"rule {rid}: shim_binary {binary!r} is not a bare filename")
        if binary in chosen:
            fail(f"rule {rid}: shim_binary {binary!r} already claimed by rule {chosen[binary].get('id')}")
        any_of = rule.get("any_of") or []
        all_of = rule.get("all_of") or []
        if not any_of or all_of:
            audit.append((rid, "shim_binary set but rule is not a plain any_of-only regex set (bash [[ =~ ]] cannot AND multiple patterns) — needs a generator update, not silently dropped"))
            continue
        chosen[binary] = rule
    return chosen, audit


def compile_pattern(rule):
    any_of = rule.get("any_of")
    all_of = rule.get("all_of")
    # bash [[ =~ ]] runs glibc's ERE engine (GNU extensions incl. \b, but NOT
    # PCRE non-capturing groups or lookahead) — only plain literal/word-boundary
    # any_of patterns are eligible; all_of (needs lookahead to AND) is rejected
    # by shimmable() before this runs, so only the any_of branch is reachable.
    if not any_of:
        raise AssertionError("all_of pattern reached compile_pattern — shimmable() must reject it")
    compiled = "|".join(f"({p})" for p in any_of)
    if "'" in compiled:
        fail("pattern contains a single quote — cannot be embedded in the shim's bash literal; fix the rule or extend the generator's escaping, do not guess")
    return compiled


def render(binary, rule, reg_hash):
    pattern = compile_pattern(rule)
    unless = rule.get("unless_contains") or []
    unless_clause = ""
    if unless:
        parts = " || ".join(f'"$cmd" == *"{u}"*' for u in unless)
        unless_clause = f" && ! [[ {parts} ]]"
    reason = rule.get("reason", "").replace("'", "'\\''")
    wrapper = rule.get("wrapper", "").replace("'", "'\\''")
    return SHIM_TEMPLATE.format(
        binary=binary,
        rule_id=rule.get("id"),
        marker=MARKER,
        reg_hash=reg_hash,
        pattern=pattern,
        unless_clause=unless_clause,
        reason_escaped=reason,
        wrapper_escaped=wrapper,
        deny_exit=DENY_EXIT,
    )


def build(out_bin_dir: Path, out_lib_dir: Path):
    rules, reg_hash = load_rules()
    chosen, audit = shimmable(rules)
    out_bin_dir.mkdir(parents=True, exist_ok=True)
    generated = {}
    for binary, rule in sorted(chosen.items()):
        text = render(binary, rule, reg_hash)
        path = out_bin_dir / binary
        path.write_text(text)
        path.chmod(0o755)
        generated[binary] = rule.get("id")
    snapshot = {
        "registry_hash": reg_hash,
        "generated_at_rules_count": len(rules),
        "shims": generated,
        "audit_skipped": [{"rule": rid, "reason": reason} for rid, reason in audit],
    }
    out_lib_dir.mkdir(parents=True, exist_ok=True)
    (out_lib_dir / SNAPSHOT.name).write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n")
    return generated, snapshot


def main(argv):
    if argv == ["--check"]:
        with tempfile.TemporaryDirectory() as td:
            scratch = Path(td)
            build(scratch / "bin", scratch / "lib")
            stale = []
            for f in sorted((scratch / "bin").iterdir()):
                live = BIN_DIR / f.name
                if not live.exists() or live.read_text() != f.read_text():
                    stale.append(f.name)
            live_snap = SNAPSHOT
            new_snap = scratch / "lib" / SNAPSHOT.name
            if not live_snap.exists() or live_snap.read_text() != new_snap.read_text():
                stale.append(SNAPSHOT.name)
            if stale:
                print("generate-tool-shims --check: STALE — " + ", ".join(stale)
                      + " — run generate-tool-shims (no args) and commit the result", file=sys.stderr)
                return 1
            print("generate-tool-shims --check: clean")
            return 0
    if argv:
        fail(f"usage: {Path(sys.argv[0]).name} [--check]")
    generated, snapshot = build(BIN_DIR, LIB_DIR)
    for binary, rule_id in sorted(generated.items()):
        print(f"generate-tool-shims: wrote bin/{binary} (rule {rule_id})")
    print(f"generate-tool-shims: {len(snapshot['audit_skipped'])} rule(s) not shimmed (see {SNAPSHOT})")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
