# prevent/runner.py
#!/usr/bin/env python3
"""runner.py — the deterministic dispatcher (the core). Pure: select detectors, build cross-file context
via the VALIDATED resolver, run each as a subprocess, apply the block policy. NO LLM is ever invoked
(it imports ONLY gate.py's deterministic helpers, never its one_roll/union_rolls path).
audience: AI coding agents first.

Block policy ladder (stop at first that holds), per finding from a staged file:
  1. level==error AND the detector is block-AUTHORIZED (precise + ships a GREEN cell) → BLOCK
  2. (class, file-suffix, symbol) ∈ confirmed.json → BLOCK  (ratchet: human confirmed this exact instance)
  3. else → WARN
Plus: any detector status degraded/error with unresolved coverage → COVERAGE-INCOMPLETE (surfaced, never
blocks — a broken detector must not wedge every commit, which would train devs to bypass the gate)."""
import importlib.util, json, os, subprocess, sys

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

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))  # security-gate install root
DEPTH, MAX_SCOPE, TIMEOUT = 1, 60, 10  # depth=1=VALIDATED band-2 shape; TIMEOUT=10s DEFAULT per detector.
# A manifest MAY declare its own "timeout" (s) — only when its work is inherently slower than a fast local
# scan (deps: `pnpm audit` is network-bound, measured 2-17s). Fail-open still caps the tail: an overrun →
# COVERAGE-INCOMPLETE, never a wrong block/clean. Default stays 10s so every validated detector is byte-identical.

def _load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod); return mod

_resolver = _load("sg_resolver", os.path.join(ROOT, "orchestrator", "resolver.py"))

def is_ratcheted(f, confirmed):
    """A finding whose (class, file-suffix, symbol) a human confirmed → precise per-instance → block.
    An empty `class` on a confirmed entry matches any class (backward compat with classless seeds), so a
    different detector class colliding on the same suffix+symbol is NOT falsely ratcheted once class is set."""
    fsym = (f.get("symbol") or "").lower()
    fcls = (f.get("class") or "").lower()
    ffile = (f.get("file") or "").replace(os.sep, "/").lower()
    for c in confirmed:
        csym = (c.get("symbol") or "").lower()
        ccls = (c.get("class") or "").lower()
        cfile = (c.get("file") or "").replace(os.sep, "/").lower()
        if csym and csym == fsym and cfile and ffile.endswith(cfile) and (not ccls or ccls == fcls):
            return True
    return False

def block_authorized(detector):
    """precise→block authorized ONLY if the detector ships a 'safe'-named cell file (admission gate by
    convention — proves a GREEN cell EXISTS, not that it was run/clean). A detector that DECLARES precise
    but ships no such cell is demoted to warn (no false-block). Tighten to run-the-cell when precise
    detectors actually ship."""
    if detector.get("precision") != "precise":
        return False
    cells = os.path.join(detector.get("_dir", ""), "cells")
    if not os.path.isdir(cells):
        return False
    return any("safe" in name.lower() for name in os.listdir(cells))

def _resolve_script(cmd):
    """In-place: resolve the FIRST non-flag arg after the interpreter to an abs path under ROOT (so
    ["python3","-O","s.py"] works, not just ["python3","s.py"]); an already-abs path is preserved."""
    for i in range(1, len(cmd)):
        if not cmd[i].startswith("-"):
            if not os.path.isabs(cmd[i]):
                cmd[i] = os.path.join(ROOT, cmd[i])
            break
    return cmd

def _run_trigger_filter(detector, repo_root, matched):
    """Run a detector's optional git-stateful `trigger_filter` and return (run: bool, extra_args: list).
    Contract: stdin {repo_root, changed:[matched trigger files]} → stdout {"run": bool, "args": [...]}.
    A filter lets a repo detector decide run-or-skip + compute per-commit scope args (e.g. deps: skip on a
    no-dep-change edit; --commit-scope --scope <ws> on a real one). No `trigger_filter` declared → (True, []).
    FAIL-OPEN (no-false-clean): any crash/timeout/non-JSON/missing 'run' → (True, []) = run UNSCOPED. A precise
    detector must NEVER be silently skipped by a broken filter — over-run, never under-run."""
    tf = detector.get("trigger_filter")
    if not tf:
        return True, []
    cmd = _resolve_script(list(tf))
    try:
        p = subprocess.run(cmd, capture_output=True, text=True, timeout=detector.get("timeout", TIMEOUT),
                           input=json.dumps({"repo_root": repo_root, "changed": list(matched)}))
        out = json.loads(p.stdout)
        if "run" not in out:
            return True, []
        return bool(out.get("run")), list(out.get("args") or [])
    except Exception:  # noqa: BLE001 -- broken filter → fail-open, run unscoped (never a silent skip)
        return True, []

def _run_detector(detector, files, repo_root, trigger, extra_args=()):
    cmd = _resolve_script(list(detector["exec"]))
    try:
        p = subprocess.run(cmd + list(extra_args) + list(files), capture_output=True, text=True,
                           timeout=detector.get("timeout", TIMEOUT),
                           input=json.dumps({"trigger": trigger, "repo_root": repo_root}))
        return json.loads(p.stdout)
    except Exception as e:  # noqa: BLE001 -- crash/timeout/non-JSON → fail-open + COVERAGE-INCOMPLETE
        return {"detector": detector.get("id", "?"), "status": "error", "findings": [],
                "coverage": {"scanned": [], "unresolved": [f"detector error: {e}"]}}

def run(changed_files, trigger, detectors, repo_root, confirmed=(), skipped_manifests=()):
    aliases = _resolver.build_workspace_aliases(repo_root)
    results, incomplete = [], []
    for mf, reason in skipped_manifests:  # malformed manifest = SURFACED reduced coverage (no-false-clean)
        incomplete.append(f"registry: detector '{os.path.basename(os.path.dirname(mf))}' unloadable — {reason}")
    for cf in changed_files:
        abs_cf = cf if os.path.isabs(cf) else os.path.join(repo_root, cf)
        for d in registry.applicable_per_file(detectors, cf, trigger):
            files = [abs_cf]
            if d.get("needs_context") == "deps":
                deps, dropped = _resolver.collect_deps(abs_cf, aliases, DEPTH, MAX_SCOPE)
                files += [x[0] for x in deps]
                if dropped:  # resolver partial → dependent detector coverage incomplete (no-false-clean)
                    incomplete.append(f"{d['id']}: {len(dropped)} dep(s) not inlined (budget) for {cf}")
            results.append((d, _run_detector(d, files, repo_root, trigger)))
    seen_repo = set()
    for d in registry.applicable_repo(detectors, changed_files, trigger):
        if d["id"] in seen_repo:
            continue
        seen_repo.add(d["id"])
        matched = registry.matched_trigger_files(d, changed_files)
        do_run, extra = _run_trigger_filter(d, repo_root, matched)
        if not do_run:  # filter verdict: no dep-relevant change → detector not applicable, no finding (no false trigger)
            continue
        results.append((d, _run_detector(d, [repo_root], repo_root, trigger, extra)))

    blocking, warnings = [], []
    for d, res in results:
        if res.get("status") in ("degraded", "error"):  # gate on status ALONE — a degraded with empty
            unresolved = res.get("coverage", {}).get("unresolved") or ["(degraded, no detail)"]  # unresolved
            incomplete.append(f"{res.get('detector', d['id'])}: {res['status']} — {unresolved}")  # still surfaces
        for f in res.get("findings", []):
            if f.get("level") == "error" and block_authorized(d):
                blocking.append(f)
            elif is_ratcheted(f, confirmed):
                blocking.append(f)
            else:
                warnings.append(f)
    return {"blocking": blocking, "warnings": warnings, "incomplete": incomplete,
            "ran": len(results), "exit_code": 1 if blocking else 0}
