#!/usr/bin/env python3
"""Standalone verifier for the CAIN-42 multi-region PBFT evidence bundle.

No CAIN imports. Needs Python 3.8+, `cryptography`, and verify_pbft_qc_bundle.py
(the unchanged QC verifier) in the same directory.

    python3 verify_multi_region_bundle.py MULTI_REGION_BUNDLE.json

1. Runs every check of verify_pbft_qc_bundle.py (Ed25519 votes, quorums,
   decision chain identical on all 4 replicas, view-change certificates,
   authorization certificates, 7 tamper controls).
2. Checks the fault-injection claims against the signatures themselves:
   * every decision carries signatures from replicas in at least 2 regions
     (no host holds more than 2 replicas and quorum is 3);
   * decisions taken while a replica was stopped carry NO signature from it
     (a stopped replica cannot sign; if it did, the fault schedule is false):
     mia while mia was down, node-3 while it was down, and NO atl signature
     at all while the atl region (the view-0 primary) was down;
   * no request submitted while the two-replica lax host was stopped appears
     anywhere in any replica's decision chain (fail closed, never executed later);
   * decisions after the primary was killed are in a higher view and proposed
     by that view's primary, not the killed one;
   * every stated phase result is recomputed from the recorded responses.

What it cannot prove: WHERE each key physically lives. Placement is
operator-attested (see multi_region.hosts). Private keys were generated on
their hosts and never left them, which is also an operator statement.
"""
from __future__ import annotations

import json
import os
import sys
from typing import Any, Dict, List

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


def signers(qc: Dict[str, Any]) -> set:
    s = set(qc["bundle"]["individual_signatures"])
    if qc.get("leader_proposal"):
        s.add(qc["leader_proposal"]["sender_id"])
    return s


def verify(bundle: Dict[str, Any]) -> Dict[str, Any]:
    base = Q.verify_bundle(bundle)
    checks: List[Dict[str, Any]] = list(base["checks"])

    def check(name, ok, detail=""):
        checks.append({"check": name, "result": "PASS" if ok else "FAIL", "detail": detail})

    mr = bundle["multi_region"]
    placement = mr["topology"]["placement"]
    mb = Q.Membership(bundle["membership"])
    check("placement covers exactly the 4 members", sorted(placement) == sorted(mb.members), str(placement))
    regions = sorted(set(placement.values()))
    check("replicas placed in 3 distinct regions", len(regions) == 3, ", ".join(regions))
    by_region = {r: {n for n, rr in placement.items() if rr == r} for r in regions}

    # every (node, seq) -> commit_qc
    qcs: Dict[int, List[Dict[str, Any]]] = {}
    for node in bundle["nodes"].values():
        for e in node["certificates"]:
            qcs.setdefault(e["sequence"], []).append(e["commit_qc"])
    bad = [s for s, l in qcs.items() if any(len({placement[x] for x in signers(q)}) < 2 for q in l)]
    check("every decision carries signatures from at least 2 regions", not bad and bool(qcs),
          f"{len(qcs)} decisions" if not bad else f"single-region sequences: {bad[:10]}")

    phases = {p["phase"]: p for p in mr["phases"]}
    for p in mr["phases"]:
        allowed = sum(1 for r in p["responses"] if r["decision"] == "ALLOW")
        want = p["requests"] if p["expected"] == "COMMIT" else 0
        ok = allowed == p["allowed"] and ((allowed == want) == (p["result"] == "PASS"))
        if p["expected"] != "COMMIT":
            same = all(p["commit_index_after"].get(n) == p["commit_index_before"].get(n) for n in p["commit_index_after"])
            ok = ok and ((allowed == 0 and same) == (p["result"] == "PASS"))
        check(f"phase {p['phase']}: stated result {p['result']} recomputed from responses", ok,
              f"{allowed}/{p['requests']} ALLOW, expected {p['expected']}")
        check(f"phase {p['phase']}: PASS", p["result"] == "PASS")

    def absent_during(phase, down, label):
        seqs = [r["sequence"] for r in phases[phase]["responses"] if r["decision"] == "ALLOW"]
        hit = [s for s in seqs for q in qcs.get(s, []) if signers(q) & down]
        missing = [s for s in seqs if s not in qcs]
        check(f"{phase}: {len(seqs)} decisions carry no signature from stopped {label} {sorted(down)}",
              bool(seqs) and not hit and not missing,
              f"sequences {seqs}" if not (hit or missing) else f"signed by stopped replica: {hit} missing: {missing}")

    absent_during("B_mia_down", by_region["mia"], "mia region")
    absent_during("C_one_lax_replica_down", {sorted(by_region["lax"])[-1]}, "lax replica")
    absent_during("E_atl_region_and_primary_down", by_region["atl"], "atl region")

    d_resources = {f"mr/{r['request_id']}" for r in phases["D_lax_host_down"]["responses"]}
    leaked = []
    for node_id, node in bundle["nodes"].items():
        for e in node["certificates"]:
            lp = e["prepare_qc"].get("leader_proposal") or {}
            if any(res in json.dumps(lp.get("payload"), sort_keys=True) for res in d_resources):
                leaked.append((node_id, e["sequence"]))
    check("fail-closed phase: none of its requests is in any replica's decision chain (not executed later)",
          not leaked, "absent" if not leaked else f"found: {leaked[:6]}")

    e_seqs = [r["sequence"] for r in phases["E_atl_region_and_primary_down"]["responses"] if r["decision"] == "ALLOW"]
    killed_ok = True
    for s in e_seqs:
        for q in qcs.get(s, []):
            lp_q = next(e["prepare_qc"] for n in bundle["nodes"].values() for e in n["certificates"] if e["sequence"] == s)
            if q["view"] < 1 or lp_q["leader_proposal"]["sender_id"] != mb.primary_for(q["view"]):
                killed_ok = False
    view0_primary = mb.primary_for(0)
    proposers = {next(e["prepare_qc"]["leader_proposal"]["sender_id"] for n in bundle["nodes"].values()
                      for e in n["certificates"] if e["sequence"] == s) for s in e_seqs}
    check("E_atl_region_and_primary_down: decisions in a higher view, proposed by that view's primary, not the killed view-0 primary",
          bool(e_seqs) and killed_ok and view0_primary not in proposers, f"proposers {sorted(proposers)}")

    failed = [c for c in checks if c["result"] == "FAIL"]
    return {"verifier": "verify_multi_region_bundle.py + verify_pbft_qc_bundle.py (no CAIN imports)",
            "certificates_verified": base["certificates_verified"], "checks": checks,
            "passed": len(checks) - len(failed), "total": len(checks),
            "verdict": "ALL_CHECKS_PASSED" if not failed else "FAILED"}


def main(argv: List[str]) -> int:
    if len(argv) < 2:
        print(__doc__)
        return 2
    res = verify(Q.load(argv[1]))
    if "--json" in argv:
        print(json.dumps(res, indent=1))
    else:
        for c in res["checks"]:
            print(f"[{c['result']}] {c['check']}" + (f"  ({c['detail']})" if c["detail"] else ""))
        print(f"\n{res['verdict']}: {res['passed']}/{res['total']} checks, "
              f"{res['certificates_verified']} certificates verified")
    return 0 if res["verdict"] == "ALL_CHECKS_PASSED" else 1


if __name__ == "__main__":
    sys.exit(main(sys.argv))
