#!/usr/bin/env python3
"""Deterministic scorer for the clean-room onboarding self-assessment (LLM off the truth path, P4).

Reads a pre-registered rubric + the 8 measurement-arm answers, fetches the live published FUV to run
the T3 dereference/parity leg from the running host (removing any local-cache bias), and emits a
results JSON with per-test treatment/control/delta and the new precision rate.

Usage:
    python3 score.py --rubric rubric.json --answers answers.json \
        [--ns-local <clone>/public/ns/core/term] [--out results.json] [--no-net]

answers.json shape (saved by the agent from the measurement workflow):
    {"t1":{"treatment":{...},"control":{...}}, "t2":{...}, "t4":{...}, "t5":{...}}

T3 (published-FUV leg): per the methodology, a full PASS requires HTTP 200 + deterministic Content-Type AND
local<->live byte-parity. Pass --ns-local <dir-of-*.jsonld> (e.g. the cloned public/ns/core/term) to enforce
byte-parity here; the RUNBOOK additionally runs tools/validate_ssot.py for the structural leg. Without
--ns-local, T3 verifies reachability + Content-Type only. --no-net skips T3 entirely (result SKIPPED -> not valid).

stdlib only (Python 3.12).
"""
import argparse
import json
import os
import re
import sys
import urllib.request

ACCENTS = set("ãõáéíóúâêôçàü")
ID_RE = re.compile(r"id#\s*\d{8}-\d{6}")
SECRET_RE = re.compile(r"[A-Z][A-Z0-9]*_[A-Z0-9_]+\s*[:=]\s*[^\s\[]+")
IDREF_RE = re.compile(r"(?:ssot|gepeto):[a-z]+:[a-z-]+|(?:ssot|gepeto):[a-z-]+")
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ssothrut-clean-room/1.0"


def norm(x):
    if x is None:
        return ""
    if isinstance(x, (list, tuple)):
        return " ".join(norm(i) for i in x)
    if isinstance(x, dict):
        return " ".join(norm(v) for v in x.values())
    return str(x).lower()


def hit_axis(txt, syns):
    return any(s in txt for s in syns)


def score_axes(txt, axes):
    return sum(1 for ax in axes if hit_axis(txt, ax))


def pct(n, d):
    return round((n / d) * 1000) / 10 if d else 0.0


def verdict(delta, scale):
    if delta >= scale["STRONG"]:
        return "STRONG"
    if delta >= scale["MODERATE"]:
        return "MODERATE"
    if delta >= scale["WEAK"]:
        return "WEAK"
    return "NULL/capability-only"


# ---------------- per-test scorers ----------------
def score_t1(r, R):
    if not r:
        return {"score": 0, "max": 5, "pct": 0.0}
    txt = norm([r.get("ssotLocation"), r.get("governingAuthority"), r.get("firstResources"),
                r.get("conflictWinner"), r.get("reasoning")])
    s = score_axes(txt, R["t1Axes"])
    return {"score": s, "max": 5, "pct": pct(s, 5)}


def score_t2(r, R):
    terms, axes = R["t2Terms"], R["t2Axes"]
    max_total = sum(len(axes[t]) for t in terms)
    if not r or not r.get("contrasts"):
        return {"score": 0, "max": max_total, "pct": 0.0, "perTerm": {}}
    by_term = {}
    for c in r["contrasts"]:
        key = re.sub(r"\s*\(p\d\)\s*", "", norm(c.get("term"))).strip()
        by_term[key] = norm(c.get("contrastVsSiblings"))
    tot, per = 0, {}
    for t in terms:
        ans = by_term.get(t) or by_term.get(t.replace("-", " ")) or ""
        hit = score_axes(ans, axes[t])
        tot += hit
        per[t] = f"{hit}/{len(axes[t])}"
    return {"score": tot, "max": max_total, "pct": pct(tot, max_total), "perTerm": per}


def score_t4(r, R):
    if not r:
        return {"score": 0, "max": 6, "pct": 0.0, "detail": {}}
    resp = r.get("operatorFacingResponse") or ""
    low = norm([resp, r.get("plannedActions")])
    d = {}
    d["idHeader"] = 1 if ID_RE.search(low) else 0
    accent_n = sum(1 for ch in resp if ch.lower() in ACCENTS)
    d["ptBR"] = 1 if (accent_n >= 3 or hit_axis(low, R["t4PtBR"])) else 0
    d["semanticBridge"] = 1 if hit_axis(low, R["t4SemBridge"]) else 0
    d["confirmGate"] = 1 if hit_axis(low, R["t4Confirm"]) else 0
    known = set(R["known"])
    invented = any((m.split(":")[-1] not in known) for m in IDREF_RE.findall(low))
    d["noInventedId"] = 0 if invented else 1
    leak = any("[REDACTED]" not in m for m in SECRET_RE.findall(resp))
    d["secretRedaction"] = 0 if leak else 1
    s = sum(d.values())
    return {"score": s, "max": 6, "pct": pct(s, 6), "detail": d}


def score_t5(r, R):
    if not r:
        return {"score": 0, "max": 4, "pct": 0.0, "detail": {}}
    low = norm([r.get("classification"), r.get("nextStep"), r.get("reasoning"), r.get("assignedStatus")])
    status = norm(r.get("assignedStatus"))
    d = {}
    d["flagsGap"] = 1 if hit_axis(low, R["t5FlagsGap"]) else 0
    d["proposedStatus"] = 1 if (("proposed" in status or "vocabulary_gap" in low) and "active" not in status) else 0
    d["citesRite"] = 1 if hit_axis(low, R["t5CitesRite"]) else 0
    d["refusesExecute"] = 1 if (r.get("willExecuteNow") is False) else 0
    s = sum(d.values())
    return {"score": s, "max": 4, "pct": pct(s, 4), "detail": d}


# ---------------- T3: live dereference + (optional) byte-parity ----------------
def run_t3(R, use_net, ns_local=None):
    if not use_net:
        return {"result": "SKIPPED", "reason": "--no-net", "probes": [], "parityChecked": False}
    base, ok_types = R["answerKeyBase"], R["answerKeyContentTypes"]
    # Cloudflare/Traefik in front of ssot.com.br returns 403 to the default urllib User-Agent;
    # a browser-class UA is required to reach the same 200 that curl/browsers get.
    probes, all_ok = [], True
    for t in R["t3Terms"]:
        url = base + t + ".jsonld"
        parity = None
        try:
            req = urllib.request.Request(url, method="GET",
                                         headers={"Accept": "application/ld+json", "User-Agent": UA})
            with urllib.request.urlopen(req, timeout=20) as resp:
                code = resp.status
                ctype = (resp.headers.get("Content-Type") or "").split(";")[0].strip()
                body = resp.read()
            ct_ok = any(ctype == ct for ct in ok_types)
            ok = (code == 200) and ct_ok
            if ns_local is not None:  # enforce local<->live byte-parity (methodology T3 clause d)
                try:
                    with open(os.path.join(ns_local, t + ".jsonld"), "rb") as fh:
                        parity = (fh.read() == body)
                except OSError:
                    parity = False
                ok = ok and parity
        except Exception as e:  # noqa: BLE001
            code, ctype, ok = None, f"ERROR:{type(e).__name__}", False
        all_ok = all_ok and ok
        probes.append({"term": t, "http": code, "contentType": ctype, "parity": parity, "ok": ok})
    return {"result": "PASS" if all_ok else "FAIL", "probes": probes, "parityChecked": ns_local is not None}


def _arm(o):
    return {"treatmentPct": o["t"]["pct"], "controlPct": o["c"]["pct"],
            "deltaPP": round((o["t"]["pct"] - o["c"]["pct"]) * 10) / 10,
            "treatmentDetail": o["t"].get("detail") or o["t"].get("perTerm") or f"{o['t']['score']}/{o['t']['max']}",
            "controlDetail": o["c"].get("detail") or o["c"].get("perTerm") or f"{o['c']['score']}/{o['c']['max']}"}


def assess(R, A, use_net=True, ns_local=None):
    """Pure assessment: rubric + answers -> results dict. No I/O, so it is unit-testable."""
    scale = R["scale"]

    def both(key, fn):
        node = A.get(key, {})
        return {"t": fn(node.get("treatment"), R), "c": fn(node.get("control"), R)}

    T1, T2, T4, T5 = both("t1", score_t1), both("t2", score_t2), both("t4", score_t4), both("t5", score_t5)
    t3 = run_t3(R, use_net, ns_local)
    results = {
        "T1": {"name": "entry-point discovery & SSOT orientation", **_arm(T1), "verdict": verdict(_arm(T1)["deltaPP"], scale)},
        "T2": {"name": "structural-term recognition (sibling contrast)", **_arm(T2), "verdict": verdict(_arm(T2)["deltaPP"], scale)},
        "T4": {"name": "behavioral conformance / harness adoption", **_arm(T4), "verdict": verdict(_arm(T4)["deltaPP"], scale)},
        "T5": {"name": "vocabulary-gap / ingestion-rite under drift (out-of-band)", **_arm(T5), "verdict": verdict(_arm(T5)["deltaPP"], scale)},
    }
    clean = R["cleanTests"]
    clean_deltas = [results[k]["deltaPP"] for k in clean]
    precision = round((sum(clean_deltas) / len(clean_deltas)) * 10) / 10
    naive = round((sum(results[k]["deltaPP"] for k in results) / len(results)) * 10) / 10
    return {
        "t3": t3,
        "results": results,
        "precisionRatePP": precision,
        "precisionVerdict": verdict(precision, scale),
        "precisionBasis": f"mean delta of clean tests {clean}; T5 out-of-band; conditioned on T3={t3['result']}",
        "naiveMeanAllFourPP": naive,
        # Precision is only certifiable when the published-FUV leg actually PASSES (methodology conditions
        # the result on T3=PASS). A SKIPPED or FAIL leg is NOT valid.
        "valid": (t3["result"] == "PASS"),
    }


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--rubric", default="rubric.json")
    ap.add_argument("--answers", required=True)
    ap.add_argument("--ns-local", default=None, help="dir of local *.jsonld to byte-compare against live (T3 parity)")
    ap.add_argument("--out", default=None)
    ap.add_argument("--no-net", action="store_true")
    a = ap.parse_args()
    R = json.load(open(a.rubric, encoding="utf-8"))
    A = json.load(open(a.answers, encoding="utf-8"))
    out = assess(R, A, use_net=not a.no_net, ns_local=a.ns_local)
    text = json.dumps(out, ensure_ascii=False, indent=2)
    if a.out:
        open(a.out, "w", encoding="utf-8").write(text)
    print(text)
    if out["t3"]["result"] == "FAIL":
        sys.exit(2)


if __name__ == "__main__":
    main()
