# prevent/registry.py
#!/usr/bin/env python3
"""registry.py — discover detector manifests and select which run for a (changed file, trigger).
audience: AI coding agents first. Same glob discipline as bench.py: domains/*/detectors/*/detector.json.
A malformed manifest is SKIPPED (the runner surfaces the reduced coverage — a missing detector is never a
false clean). With hundreds registered, each commit runs only the handful whose globs match."""
import fnmatch, glob, json, os

_GLOBS = {"detector": ("detectors", "detector.json"), "solution": ("solutions", "solution.json")}

def load(domains_root, kind="detector"):
    """domains/*/<kind-dir>/*/<manifest>.json → (manifests, skipped). kind='detector' (DEFAULT — unchanged for
    every existing caller) discovers detector.json; kind='solution' discovers domains/*/solutions/*/solution.json.
    detectors/solutions=[manifest dict + '_dir']; skipped=[(path, reason)] for manifests that failed to parse.
    A malformed manifest is SKIPPED + surfaced (the consumer maps it to reduced coverage — never a silent drop)."""
    sub, fname = _GLOBS[kind]
    out, skipped = [], []
    for mf in sorted(glob.glob(os.path.join(domains_root, "domains", "*", sub, "*", fname))):
        try:
            m = json.load(open(mf, encoding="utf-8"))
        except (OSError, ValueError) as e:
            skipped.append((mf, str(e)))  # malformed → surfaced as reduced coverage (no-false-clean)
            continue
        m["_dir"] = os.path.dirname(mf)
        out.append(m)
    return out, skipped

def glob_match(path, pattern):
    """Match a repo-relative path against a scope/trigger glob. fnmatch's '*' crosses '/', so a leading
    '**/' must ALSO match a top-level file (no '/'): match the basename against the tail OR the full path."""
    path = path.replace(os.sep, "/")
    if pattern.startswith("**/"):
        tail = pattern[3:]
        return fnmatch.fnmatch(os.path.basename(path), tail) or fnmatch.fnmatch(path, pattern)
    return fnmatch.fnmatch(path, pattern)

def applicable_per_file(detectors, changed_file, trigger):
    return [d for d in detectors
            if d.get("scope") == "per-file"
            and trigger in d.get("triggers", [])
            and any(glob_match(changed_file, g) for g in d.get("scope_globs", []))]

def matched_trigger_files(detector, changed_files):
    """The changed files (repo-relative) matching this detector's trigger_globs. The runner hands these to an
    optional `trigger_filter` so a git-stateful filter sees EXACTLY which files fired the detector (not the whole
    changeset). Empty trigger_globs → []."""
    tg = detector.get("trigger_globs", [])
    return [cf for cf in changed_files if any(glob_match(cf, g) for g in tg)]

def applicable_repo(detectors, changed_files, trigger):
    out = []
    for d in detectors:
        if d.get("scope") != "repo" or trigger not in d.get("triggers", []):
            continue
        tg = d.get("trigger_globs", [])
        if any(glob_match(cf, g) for cf in changed_files for g in tg):
            out.append(d)
    return out
