# prevent/prevent.py
#!/usr/bin/env python3
"""prevent.py — git pre-commit adapter for the deterministic gate. Reads staged files, runs the runner,
prints warnings + COVERAGE-INCOMPLETE + blocks, exits 0 (allow) or 1 (block). NEVER prints 'clean' when
coverage is incomplete (no-false-clean). NO LLM is ever invoked. audience: AI coding agents first."""
import argparse, json, os, subprocess, sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import registry, runner  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

def staged_files(repo_root):
    p = subprocess.run(["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
                       cwd=repo_root, capture_output=True, text=True)
    return [l for l in p.stdout.splitlines() if l.strip()]

def load_gateignore(repo_root):
    """Globs DECLARING files out of security-gating scope (e.g. the gate's own tooling). One pattern per line,
    `#` comments + blank lines skipped. SOFTENS ONLY the 'no applicable detector' notice wording — files are
    NEVER partitioned out of gating, so a detector that matches an ignored file STILL runs and STILL blocks
    (un-weaponizable: .gateignore cannot suppress a real finding). Matching = registry.glob_match (fnmatch with
    a `**/` prefix special-case) — NOT gitignore: no `!` negation, no trailing-`/` dir semantics."""
    try:
        lines = open(os.path.join(repo_root, ".gateignore"), encoding="utf-8").read().splitlines()
    except (FileNotFoundError, OSError):
        return []
    return [s for s in (l.split("#", 1)[0].strip() for l in lines) if s]

def load_confirmed(p=None):
    p = p or os.path.join(os.path.dirname(os.path.abspath(__file__)), "confirmed.json")
    try:
        return json.load(open(p, encoding="utf-8"))
    except FileNotFoundError:
        return []  # no ratchet seeded yet — genuinely clean state, no signal needed
    except (OSError, ValueError) as e:
        # corrupt/unreadable ratchet = DEGRADED block-coverage (confirmed bugs revert to WARN). NEVER
        # silent (no-false-clean): warn loudly, fail-open to [] (don't wedge every commit → bypass risk).
        print(f"prevent-band: WARNING ratchet confirmed.json unreadable ({e}) — ratchet DISABLED; "
              f"confirmed findings will only WARN until fixed", file=sys.stderr)
        return []

def _print_clean_status(rep, files, repo_root):
    """The honest no-block status — NEVER claims 'clean' when nothing applied (no-false-clean). Shared by the
    enforce and report-only paths so monitor mode is byte-identical on a genuinely-clean staged set."""
    if rep.get("ran", 0) == 0:  # gate ran but nothing applied to the staged files — NOT a security pass
        # .gateignore softens the WORDING only (files still all ran through the gate above): an UNDECLARED
        # uncovered file stays loud (blind-spot discipline); only DECLARED out-of-scope files go calm.
        ignore = load_gateignore(repo_root)
        in_scope = [f for f in files if not any(registry.glob_match(f, p) for p in ignore)]
        if in_scope:
            print(f"prevent-band: no applicable detector for {len(in_scope)} staged file(s) — NOT a security clean")
        else:
            print(f"prevent-band: {len(files)} staged file(s) out of security scope (.gateignore)")
    else:
        print(f"prevent-band: no blocking findings ({rep['ran']} detector run(s))")

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--trigger", default="pre-commit", choices=["pre-commit", "pre-edit"])
    # overrides exist ONLY so the hook→git-abort seam is hermetically testable (mirrors load_confirmed(p=None));
    # omitted = production defaults (ROOT detectors + ./confirmed.json) → the installed hook is byte-unchanged.
    ap.add_argument("--detectors-root", default=ROOT)
    ap.add_argument("--confirmed", default=None)
    # report-only = MONITOR mode: surface what WOULD block, but NEVER abort (exit 0). For adopting the gate on a
    # repo with a pre-existing CVE/finding backlog without wedging commits. Flip to ENFORCE = drop this flag; the
    # block policy in runner.py is UNTOUCHED (pure adapter gate), so monitor→enforce changes nothing but the exit.
    ap.add_argument("--report-only", action="store_true",
                    help="print would-block findings but allow the commit (exit 0); drop the flag to enforce")
    a = ap.parse_args()
    repo_root = subprocess.run(["git", "rev-parse", "--show-toplevel"],
                               capture_output=True, text=True).stdout.strip() or ROOT
    files = staged_files(repo_root)
    if not files:
        print("prevent-band: no staged files")
        return 0
    detectors, skipped = registry.load(a.detectors_root)
    rep = runner.run(files, a.trigger, detectors, repo_root, load_confirmed(a.confirmed), skipped)
    for f in rep["warnings"]:
        print(f"prevent-band WARN  [{f['class']}] {f['message']} ({f['file']})")
    if rep["incomplete"]:
        print("prevent-band COVERAGE-INCOMPLETE (NOT a clean pass):")
        for i in rep["incomplete"]:
            print(f"  - {i}")
    if a.report_only:
        # monitor mode: surface what WOULD block (error-authorized + ratcheted), but NEVER abort the commit.
        for f in rep["blocking"]:
            print(f"prevent-band REPORT [{f['class']}] {f['message']} ({f['file']}:{f.get('line', 0)})"
                  f" — would block in enforce mode (report-only: NOT enforced)")
        if rep["blocking"]:
            print(f"prevent-band: report-only — {len(rep['blocking'])} finding(s) would block; commit ALLOWED")
        elif not rep["warnings"] and not rep["incomplete"]:
            _print_clean_status(rep, files, repo_root)
        return 0  # monitor mode NEVER aborts — drop --report-only to enforce

    for f in rep["blocking"]:
        print(f"prevent-band BLOCK [{f['class']}] {f['message']} ({f['file']}:{f.get('line', 0)})")
    if rep["exit_code"]:
        print(f"prevent-band: commit BLOCKED — {len(rep['blocking'])} finding(s)")
    elif not rep["warnings"] and not rep["incomplete"]:
        _print_clean_status(rep, files, repo_root)
    return rep["exit_code"]

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