"""Surface mapper — deterministic. Enumerate the attack surface, prove coverage, dispatch gate.py per target.
SoT: docs/specs/2026-06-17-attack-surface-mapping-design.md. The one rule: NEVER let a model invent the
entry-point list — no LLM in the mapper's enumeration/priority path. The LLM stays INSIDE gate.py.

Loaded by path (spec_from_file_location) per the project convention — NOT a package import. conventions.py and
gate.py are co-located; both are loaded by absolute path so this works under pytest, bench.py, and direct CLI."""
import os, re, sys, fnmatch, subprocess, json, argparse, importlib.util
from dataclasses import dataclass

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


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


_conv = _load("sg_conventions", os.path.join(_HERE, "conventions.py"))
KIND_SIGNALS, CONVENTIONS, PRUNE_DIRS = _conv.KIND_SIGNALS, _conv.CONVENTIONS, _conv.PRUNE_DIRS
FILE_ROUTE_RULES, EXCLUDE_NONROUTE = _conv.FILE_ROUTE_RULES, _conv.EXCLUDE_NONROUTE


@dataclass(frozen=True)
class Entry:
    path: str   # absolute
    kind: str
    why: str    # which signal/glob matched — for the coverage map


_SRC_EXT = (".ts", ".tsx", ".js", ".mjs")


def _git_source_files(root):
    """In a git work tree, enumerate source THROUGH git so the walk respects `.gitignore` EXACTLY — build output
    (`tmp/`, `.dist-stack/`, `dist/`, any ignored dir), `node_modules`, and nested worktrees/submodules are excluded
    by git itself, with no hand-maintained ignore list to drift (the hardcoded PRUNE_DIRS missed multideal's
    gitignored `tmp/`+`apps/web/.dist-stack/` → 27 compiled `.mjs` polluted the count). Returns repo-rel paths =
    tracked ∪ untracked-but-not-ignored (so a brand-new UNSTAGED route is still scanned), or None if root is not a
    git work tree (caller falls back to os.walk). TWO batched `ls-files` calls, never per-file `check-ignore`.
    Submodules list as a single gitlink (their files are NOT yielded) — same effect as the fallback's nested-`.git`
    prune. subprocess with list-argv calls git directly (no shell, no RTK rewrite)."""
    try:
        chk = subprocess.run(["git", "-C", root, "rev-parse", "--is-inside-work-tree"], capture_output=True, text=True)
        if chk.returncode != 0 or chk.stdout.strip() != "true":
            return None
        tracked = subprocess.run(["git", "-C", root, "ls-files", "-z"], capture_output=True, text=True)
        others = subprocess.run(["git", "-C", root, "ls-files", "-z", "--others", "--exclude-standard"],
                                capture_output=True, text=True)
        if tracked.returncode != 0 or others.returncode != 0:
            return None
        return [p for p in (tracked.stdout + others.stdout).split("\0") if p]
    except OSError:
        return None


def _walk_ts(root):
    """Yield TS/JS source paths. PRIMARY (git work tree): enumerate via `_git_source_files` so `.gitignore` is the
    single source of truth for what is source vs build output/vendored/copy (this SUBSUMES both fallback prunes —
    node_modules, build dirs, AND nested worktrees/submodules are all excluded by git). FALLBACK (non-git tree, the
    gate must still run there): os.walk with two prunes — (1) PRUNE_DIRS by name; (2) GENERIC — any nested dir below
    root carrying a `.git` marker is a separate worktree/clone/submodule COPY, pruned so it never double-counts.
    CAVEAT (fallback only): also prunes a legitimate submodule — map an in-scope submodule as its own root."""
    git_files = _git_source_files(root)
    if git_files is not None:
        for rel in git_files:
            if rel.endswith(_SRC_EXT):
                yield os.path.join(root, rel)
        return
    for dp, dns, fns in os.walk(root):
        dns[:] = [d for d in dns
                  if d not in PRUNE_DIRS and not os.path.exists(os.path.join(dp, d, ".git"))]
        for fn in fns:
            if fn.endswith(_SRC_EXT):
                yield os.path.join(dp, fn)


def _read(p):
    try:
        return open(p, encoding="utf-8", errors="replace").read()
    except OSError:
        return None


def actual_surface(root):
    """INDEPENDENT denominator. File-routed kinds are counted by LOCATION (FILE_ROUTE_RULES); call-registered
    kinds by CONTENT (their KIND_SIGNALS regex). Pruned trees excluded. Returns [(abspath, kind)]. A file under
    a route location is that file-routed kind (location precedence); otherwise first content-matching
    call-registered kind wins (dict insertion order). KIND_SIGNALS rows for file-routed kinds are NOT consulted
    here — their job as a content enumerator is retired (Part A); the row remains only so the table still
    declares the kind."""
    file_routed = set(FILE_ROUTE_RULES)
    out = []
    for p in _walk_ts(root):
        rel = os.path.relpath(p, root).replace(os.sep, "/")
        fk = _file_routed_kind(rel)
        if fk is not None:
            out.append((os.path.abspath(p), fk))
            continue
        body = _read(p)
        if body is None:
            continue
        for kind, (rx, _reliable) in KIND_SIGNALS.items():
            if kind in file_routed:
                continue  # counted by LOCATION above, never by content
            if rx.search(body):
                out.append((os.path.abspath(p), kind))
                break
    return out


def enumerate_surface(root):
    """The mapper's ACTUAL discovery. File-routed kinds enumerate by LOCATION (FILE_ROUTE_RULES) — every route
    file is enumerated regardless of its registration idiom (so the CF Pages `export default` idiom that the
    retired `onRequest` regex missed is now caught, and that regex's off-location false-positives are gone).
    Call-registered kinds enumerate by CONTENT (signal primary; path_glob only annotates why/priority).
    CONSEQUENCE: per-file-routed-kind within-kind recall is 1.0 BY CONSTRUCTION (enumerate == the filesystem
    oracle); for call-registered kinds the ~1.0 is structural (grep is best-available truth, no oracle)."""
    file_routed = set(FILE_ROUTE_RULES)
    rows_by_kind = {}  # call-registered rows only
    for c in CONVENTIONS:
        if c["kind"] in file_routed:
            continue
        rows_by_kind.setdefault(c["kind"], []).extend(c["path_globs"])
    out = []
    for p in _walk_ts(root):
        rel = os.path.relpath(p, root).replace(os.sep, "/")
        fk = _file_routed_kind(rel)
        if fk is not None:
            out.append(Entry(os.path.abspath(p), fk, f"route:{fk}"))
            continue
        body = _read(p)
        if body is None:
            continue
        for kind, globs in rows_by_kind.items():
            rx, _ = KIND_SIGNALS[kind]
            if rx.search(body):
                in_glob = any(fnmatch.fnmatch(p.replace(os.sep, "/"), g) for g in globs)
                out.append(Entry(os.path.abspath(p), kind, f"signal:{kind}" + ("+glob" if in_glob else "+offpath")))
                break
    return out


def _seg_route(rel, segments, basename_rx):
    """True if `segments` appear as CONSECUTIVE path segments in rel with a file element after them, and (when
    basename_rx is set) the basename matches. Segment match, NOT fnmatch — fnmatch `*` crosses `/` and would either
    over-match or (with a middle `**`) drop direct-child routes."""
    parts = rel.split("/")
    n = len(segments)
    for i in range(len(parts) - n):
        if tuple(parts[i:i + n]) == segments and (basename_rx is None or basename_rx.search(parts[-1])):
            return True
    return False


def _file_routed_kind(rel):
    """The file-routed kind whose FILE_ROUTE_RULES match this repo-relative path (a FILESYSTEM fact), or None.
    EXCLUDE_NONROUTE (framework `_`-private files, tests) are never routes. First kind in FILE_ROUTE_RULES
    insertion order wins. This is the single enumerator for file-routed kinds — content is NOT consulted."""
    if EXCLUDE_NONROUTE.search(rel):
        return None
    for kind, rules in FILE_ROUTE_RULES.items():
        if any(_seg_route(rel, segs, brx) for segs, brx in rules):
            return kind
    return None


def kind_of(rel, body=None):
    """Production kind-inference for ONE repo-relative path, blind to any ground-truth label. Same two-step
    inference enumerate_surface uses per file: file-routed kinds by LOCATION (FILE_ROUTE_RULES via
    _file_routed_kind) first; else the first content-matching call-registered kind (KIND_SIGNALS insertion
    order, file-routed kinds skipped) when `body` is given. Returns the kind str or None. Factored so
    single-file callers (DETECT run_bench) route the SAME way the repo scan does; test_mapper_kind_of locks
    no-drift vs enumerate_surface."""
    rel = rel.replace(os.sep, "/")
    fk = _file_routed_kind(rel)
    if fk is not None:
        return fk
    if body is None:
        return None
    file_routed = set(FILE_ROUTE_RULES)
    for kind, (rx, _reliable) in KIND_SIGNALS.items():
        if kind in file_routed:
            continue
        if rx.search(body):
            return kind
    return None


def file_routed_recall(root):
    """WITHIN-KIND completeness for file-routed kinds. After Part A enumeration is BY LOCATION, so within_kind_recall
    = enumerated/fs_routes is 1.0 BY CONSTRUCTION. `retired_signal_recall` = content_signal_hits/fs_routes is a
    DIAGNOSTIC of the now-retired content regex: how many location-routes that regex WOULD have matched. A
    retired_signal_recall < 1.0 is exactly the recall the content signal lost (e.g. the CF Pages export-default
    idiom) and is the empirical justification for switching to location. missed_sample = location routes NOT
    enumerated (must be empty post-Part-A; a non-empty set is a FILE_ROUTE_RULES bug, surfaced not silenced)."""
    enr = {e.path for e in enumerate_surface(root)}
    out = {}
    for kind, rules in FILE_ROUTE_RULES.items():
        rx, _ = KIND_SIGNALS[kind]
        fs_routes = content_signal_hits = enumerated = 0
        missed = []
        for p in _walk_ts(root):
            rel = os.path.relpath(p, root).replace(os.sep, "/")
            if EXCLUDE_NONROUTE.search(rel) or not any(_seg_route(rel, segs, brx) for segs, brx in rules):
                continue
            fs_routes += 1
            body = _read(p)
            if body is not None and rx.search(body):
                content_signal_hits += 1
            if os.path.abspath(p) in enr:
                enumerated += 1
            else:
                missed.append(rel)
        out[kind] = {
            "fs_routes": fs_routes,
            "enumerated": enumerated,
            "content_signal_hits": content_signal_hits,
            "within_kind_recall": round(enumerated / fs_routes, 3) if fs_routes else None,
            "retired_signal_recall": round(content_signal_hits / fs_routes, 3) if fs_routes else None,
            "missed_sample": sorted(missed)[:10],
        }
    return out


def _critical():
    """Reuse resolver.py's CRITICAL regex — single source for the priority token set (NOT a gate, just ordering)."""
    return _load("sg_resolver_for_critical", os.path.join(_HERE, "resolver.py")).CRITICAL


def prioritize(entries):
    """Order by deterministic risk: CRITICAL-token path first, then mutation-ish kinds, then the rest. ORDERS,
    NEVER EXCLUDES — an un-dispatched target becomes `budget-dropped` in the map, never silently `clean`."""
    crit = _critical()
    def rank(e):
        return (0 if crit.search(e.path) else 1,
                0 if e.kind in ("http-defn-call", "edge-function") else 1,
                e.path)
    return sorted(entries, key=rank)


def dispatch(entry, out_dir, gate_args):
    """Invoke the UNCHANGED gate.py as a subprocess: gate.py <target> --report <out> + passthrough flags.
    Returns the report path on success, None on gate failure (caller logs it — a failed gate is NEVER 'clean')."""
    os.makedirs(out_dir, exist_ok=True)
    rep = os.path.join(out_dir, os.path.basename(entry.path) + ".report.md")
    cmd = [sys.executable, os.path.join(_HERE, "gate.py"), entry.path, "--report", rep] + gate_args
    r = subprocess.run(cmd, capture_output=True, text=True)
    return rep if r.returncode == 0 and os.path.isfile(rep) else None


def coverage_map(enumerated, actual, scanned):
    """3 DISJOINT buckets — the hard invariant. A surface that omits an entry point silently reads as 'covered';
    these buckets make every omission auditable. scanned = {path: report_path}. Cross-target dedup is NOT done
    here (advisor-4: a helper imported by N targets is reviewed N times) — v1 surfaces duplication, defers dedup."""
    enr_paths = {e.path for e in enumerated}
    return {
        "enumerated_scanned": [{"path": p, "report": r} for p, r in scanned.items()],
        "enumerated_budget_dropped": sorted(p for p in enr_paths if p not in scanned),
        "not_enumerated": sorted(({"path": p, "kind": k} for p, k in actual if p not in enr_paths),
                                 key=lambda d: d["path"]),
    }


def main(argv=None):
    ap = argparse.ArgumentParser(description="deterministic attack-surface mapper over gate.py")
    ap.add_argument("repo_root")
    ap.add_argument("--max-targets", type=int, default=25)
    ap.add_argument("--out-dir", default="mapper-reports")
    ap.add_argument("--map", default=None, help="write coverage map JSON here (default: stdout)")
    args, gate_args = ap.parse_known_args(argv)  # unknown flags (--alias/--k/--depth/--config-dir/--dry-run) -> gate.py
    root = os.path.abspath(args.repo_root)
    enr = prioritize(enumerate_surface(root))
    act = actual_surface(root)
    scanned = {}
    for e in enr[:args.max_targets]:
        rep = dispatch(e, args.out_dir, gate_args)
        if rep:
            scanned[e.path] = rep
        else:
            print(f"[gate-failed] {os.path.relpath(e.path, root)} — NOT clean, re-run", file=sys.stderr)
    cmap = coverage_map(enr, act, scanned)
    js = json.dumps(cmap, indent=2)
    if args.map:
        open(args.map, "w").write(js)
    else:
        print(js)


if __name__ == "__main__":
    main()
