#!/usr/bin/env python3
"""security-gate orchestrator — thin wiring over two VALIDATED legs.

Legs (proven, n>=3, git-pinned — see RESULT_coverage_status.md):
  - oracle  : bun oracle2.ts <file>           (deterministic; C02/C09 complete-mediation only)
  - llm     : claude -p --model sonnet --effort medium  < v2_prompt(file)   (k>=3 rolls, UNIONED)

Orchestrator's own (thin) logic ONLY: scope resolution (pull in critical imports),
cross-file escalation, k-roll union, merge/report. Everything else calls a leg.

Usage:
  gate.py <target-file> [--alias '@/=<root>'] [--k 3] [--depth 2]
          [--config-dir DIR] [--template PATH] [--oracle PATH] [--report PATH]
"""
import argparse, os, re, subprocess, sys, concurrent.futures as cf

# imported-symbol name => CRITICAL (pull its file into scope). Proven cross-file principle (G3c).
CRITICAL = re.compile(
    r"claim|lock|guard|settle|payout|refund|charge|transfer|release|mediat|authoriz|"
    r"verif|reserve|consume|debit|credit|ledger|escrow|hold|webhook|idempoten",
    re.I,
)
XFILE = re.compile(r"unverified cross-file dependency|cannot be confirmed from this file", re.I)
IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
# import { a, b } from 'spec';  |  import a from 'spec';  |  import * as a from 'spec'
IMPORT = re.compile(r"import\s+(?:type\s+)?(.+?)\s+from\s+['\"]([^'\"]+)['\"]", re.S)
# finding header in v2 output: "**1. Title**" / "1. **Title**" / "### 1. Title"
FINDING = re.compile(r"^\s*(?:#+\s*)?(?:\*\*)?(\d+)[.)]\s*(?:\*\*)?\s*(.+?)\s*\**\s*$", re.M)
SEV = re.compile(r"\b(critical|high|medium|low)\b", re.I)
IMPERATIVE = re.compile(
    r"^(add|use|make|implement|throw|set|move|replace|ensure|consider|wrap|validate|"
    r"introduce|change|apply|prefer|document|guard|switch)\b",
    re.I,
)


def resolve(spec, base_dir, aliases):
    """module specifier -> file path on disk (apply @/* alias, strip .js, try .ts/.tsx/index)."""
    p = None
    for pre, root in aliases:
        if spec.startswith(pre):
            p = os.path.join(root, spec[len(pre):])
            break
    if p is None:
        if spec.startswith("."):
            p = os.path.normpath(os.path.join(base_dir, spec))
        else:
            return None  # bare package import — out of scope
    p = re.sub(r"\.js$", "", p)
    for cand in (p + ".ts", p + ".tsx", p, os.path.join(p, "index.ts")):
        if os.path.isfile(cand):
            return cand
    return None


def critical_imports(path, aliases):
    """parse imports; return [(file, symbol)] for symbols whose NAME is critical."""
    try:
        src = open(path, encoding="utf-8", errors="replace").read()
    except OSError:
        return []
    base = os.path.dirname(path)
    out = []
    for names_blob, spec in IMPORT.findall(src):
        syms = [s for s in IDENT.findall(names_blob) if s not in ("type", "as", "from")]
        crit = [s for s in syms if CRITICAL.search(s)]
        if not crit:
            continue
        f = resolve(spec, base, aliases)
        if f:
            for s in crit:
                out.append((f, s))
    return out


def oracle_status(oflags):
    """Classify an oracle output. Returns (silent, unreliable, label).
    unreliable = a guard may hide in an import the oracle could not load -> a SILENT result here is a
    possible FALSE CLEAN and must NOT be read as a clean pass (same discipline as the LLM xfile flag)."""
    silent = "none" in oflags.lower() and "silent" in oflags.lower()
    unreliable = "unresolved imports" in oflags.lower()
    if silent and unreliable:
        label = ("SILENT but UNRELIABLE — unresolved predicate imports; do NOT read as clean "
                 "(run from the full repo / fix aliases). See block.")
    elif silent:
        label = "SILENT"
    else:
        label = "FLAGS — see below"
    return silent, unreliable, label


def run_oracle(oracle, runtime, path):
    try:
        r = subprocess.run([runtime, oracle, path], capture_output=True, text=True, timeout=120)
    except Exception as e:  # noqa: BLE001
        return f"(oracle error: {e})"
    out = r.stdout
    m = re.search(r"=== FLAGS.*", out, re.S)
    return (m.group(0).strip() if m else out.strip()) or "(oracle silent)"


def one_roll(path, template, config_dir, idx):
    prompt = template.replace("{{MODULE_PATH}}", path)
    env = dict(os.environ)
    if config_dir:  # blind catch-test only; real deployment uses the repo's normal config
        env["CLAUDE_CONFIG_DIR"] = config_dir
    try:
        r = subprocess.run(
            ["claude", "-p", "--model", "sonnet", "--effort", "medium",
             "--dangerously-skip-permissions"],
            input=prompt, capture_output=True, text=True, timeout=600,
            cwd=os.path.dirname(path), env=env,
        )
        return r.stdout
    except Exception as e:  # noqa: BLE001
        return f"(roll {idx} error: {e})"


def parse_findings(text):
    """-> list of (title, severity, body). tolerant of v2 header variants."""
    hits = list(FINDING.finditer(text))
    out = []
    for i, m in enumerate(hits):
        title = m.group(2).strip()
        if len(title) < 6 or title[0].islower() and " " not in title:
            continue  # skip stray list items / sub-numbered lines
        body = text[m.end(): hits[i + 1].start() if i + 1 < len(hits) else len(text)]
        sev = (SEV.search(body[:400]) or SEV.search(title))
        sev_l = sev.group(1).lower() if sev else "unrated"
        # parser FP guard: a numbered item with no severity AND an imperative-verb opener is a
        # Fix/recommendation sub-item (e.g. "Add a guard…", "Use an allowlist…"), not a finding.
        if sev_l == "unrated" and IMPERATIVE.match(title):
            continue
        out.append((title, sev_l, body))
    return out


def norm(title):
    return " ".join(w for w in re.sub(r"[^a-z0-9 ]", " ", title.lower()).split())[:90]


def union_rolls(rolls):
    """union findings across k rolls; dedupe by normalized title; count rolls that found each."""
    groups = {}  # key -> {title, sev, rolls:set}
    for ri, text in enumerate(rolls):
        for title, sev, _ in parse_findings(text):
            key = norm(title)
            sig_words = set(key.split())
            merged = None
            for k, g in groups.items():
                kw = set(k.split())
                if sig_words & kw and len(sig_words & kw) >= max(2, min(len(sig_words), len(kw)) // 2):
                    merged = g
                    break
            if merged is None:
                groups[key] = {"title": title, "sev": sev, "rolls": {ri}}
            else:
                merged["rolls"].add(ri)
                if SEV_RANK.get(sev, 9) < SEV_RANK.get(merged["sev"], 9):
                    merged["sev"], merged["title"] = sev, title
    return sorted(groups.values(), key=lambda g: (SEV_RANK.get(g["sev"], 9), -len(g["rolls"])))


SEV_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3, "unrated": 4}


def xfile_symbols(rolls):
    """imported symbols the LLM could not verify cross-file -> escalation targets."""
    syms = set()
    for text in rolls:
        for m in XFILE.finditer(text):
            window = text[max(0, m.start() - 200): m.end() + 200]
            for ident in IDENT.findall(window):
                # require a camelCase boundary -> drops bare dictionary words (charge/release/refund),
                # keeps real symbols (claimWebhookEvent). The exact-import intersection still gates use.
                if CRITICAL.search(ident) and ident[0].islower() and re.search(r"[a-z][A-Z]", ident):
                    syms.add(ident)
    return syms


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("target")
    ap.add_argument("--alias", action="append", default=[], help="'@/=<root>' (repeatable)")
    ap.add_argument("--k", type=int, default=3)
    ap.add_argument("--depth", type=int, default=2)
    ap.add_argument("--config-dir", default=None,
                    help="override CLAUDE_CONFIG_DIR (blind catch-test only; omit for real runs)")
    _here = os.path.dirname(os.path.abspath(__file__))
    ap.add_argument("--template", default=os.path.join(_here, "prompt_v2.txt"))
    ap.add_argument("--oracle", default=os.path.join(_here, "oracle", "oracle2.ts"))
    ap.add_argument("--runtime", default="bun")
    ap.add_argument("--report", default=None)
    a = ap.parse_args()

    aliases = []
    for al in a.alias:
        pre, _, root = al.partition("=")
        aliases.append((pre, os.path.abspath(root)))
    template = open(a.template, encoding="utf-8").read()
    assert "{{MODULE_PATH}}" in template, "template missing {{MODULE_PATH}}"

    target = os.path.abspath(a.target)
    scope, queue, why = {target}, [(target, 0)], {target: "target"}
    # proactive pull-in from the target's own imports
    for f, sym in critical_imports(target, aliases):
        if f not in scope:
            scope.add(f); queue.append((f, 1)); why[f] = f"proactive: critical import `{sym}`"

    oracle_out, llm_union = {}, {}
    while queue:
        f, d = queue.pop(0)
        print(f"[scope] {os.path.relpath(f)}  (depth {d}; {why.get(f,'')})", file=sys.stderr)
        oracle_out[f] = run_oracle(a.oracle, a.runtime, f)
        with cf.ThreadPoolExecutor(max_workers=a.k) as ex:
            rolls = list(ex.map(lambda i: one_roll(f, template, a.config_dir, i), range(a.k)))
        llm_union[f] = union_rolls(rolls)
        if d < a.depth:  # reactive escalation
            for sym in xfile_symbols(rolls):
                for cf_file, csym in critical_imports(f, aliases):
                    if csym == sym and cf_file not in scope:
                        scope.add(cf_file); queue.append((cf_file, d + 1))
                        why[cf_file] = f"escalated: unverified cross-file dep `{sym}` (from {os.path.basename(f)})"

    # merge + report
    lines = ["# Security-gate report", "", "## Scope (files reviewed)"]
    for f in scope:
        lines.append(f"- `{os.path.relpath(f)}` — {why.get(f,'')}")
    lines.append("")
    for f in scope:
        lines.append(f"## {os.path.relpath(f)}")
        oflags = oracle_out.get(f, "")
        silent, unreliable, status = oracle_status(oflags)
        lines.append(f"**Oracle (complete-mediation):** {status}")
        if not silent or unreliable:  # surface the block whenever there is something to act on
            lines.append("```\n" + oflags + "\n```")
        lines.append("")
        lines.append(f"**LLM leg (v2@sonnet/MED, {a.k}-roll union):**")
        for g in llm_union.get(f, []):
            lines.append(f"- [{g['sev']}] {g['title']}  _(found by {len(g['rolls'])}/{a.k} rolls)_")
        lines.append("")
    report = "\n".join(lines)
    print(report)
    if a.report:
        open(a.report, "w", encoding="utf-8").write(report)
        print(f"\n[written] {a.report}", file=sys.stderr)


if __name__ == "__main__":
    main()
