#!/usr/bin/env python3
"""Verify CAIN42_FINAL_PUBLIC_CLAIMS.json without trusting any CAIN-42 website.

No CAIN imports; needs Python 3.8+ and `cryptography`.

    python3 verify_claims.py https://clawx.click/evidence/claims/CAIN42_FINAL_PUBLIC_CLAIMS.json \\
        --pin <evidence-root public key b64>   # compare with the key published on the other two sites

Checks:
  REGISTRY   the registry digest re-hashes and is signed by the pinned evidence-root key
             (without --pin, the key is fetched from all three sites and must be identical)
  ARTIFACTS  every artifact is fetched from the chosen site (or --base) and re-hashed:
             VALID / TAMPERED / UNREACHABLE, with what was expected and what was found
  STATUS     a claim's status/level must be consistent (NOT_IMPLEMENTED => level 0 and
             no artifacts; level >= 3 => at least one artifact)
  SCOPE      (registry v2) every claim states its evidence environment (LIVE / DISPOSABLE_CLUSTER / OFFLINE /
             SIMULATED). The verifier re-derives it from the cluster_id values inside each fetched artifact
             (an id outside the registry's live_clusters means the evidence is not live), re-derives the public
             label from status + environment with its own copy of the rule, and rejects a claim whose limits say
             "disposable" while its environment does not.
  FRESHNESS  a claim past its revalidate_by time is STALE: it may have been true, it is no longer current.
  TOPOLOGY   with --check-topology, each live cluster's current members are fetched from its public
             membership endpoint; if they differ from the members the registry was signed against, every claim
             with evidence from that cluster is STALE until re-proven on the new membership.
A claim is VALID only if the registry is valid, all its artifacts match and its scope is consistent.
FINAL RESULT is VALID, STALE (valid but at least one claim expired) or INVALID.
"""
from __future__ import annotations

import base64
import hashlib
import json
import re
import sys
import time
import urllib.request
from typing import Any, Dict, List

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

SITES = {"cainstudio.online": "https://cainstudio.online/proof/bundle/", "mcpgate.online": "https://mcpgate.online/proof/bundle/",
         "clawx.click": "https://clawx.click/evidence/"}


def fetch(url: str) -> bytes:
    if url.startswith("http"):
        with urllib.request.urlopen(url, timeout=30) as r:
            return r.read()
    with open(url, "rb") as f:
        return f.read()


ENVS = ("LIVE", "DISPOSABLE_CLUSTER", "OFFLINE", "SIMULATED")


def expected_label(status: str, envs: List[str]) -> str:
    """Independent copy of the registry's label rule (the registry states it in label_rule)."""
    if status == "FAILED":
        return "FAILED"
    if status in ("UNVERIFIED", "NOT_IMPLEMENTED"):
        return "NOT ESTABLISHED"
    if status == "SIMULATED" or envs == ["SIMULATED"]:
        return "SIMULATED"
    if status in ("VERIFIED", "REPRODUCIBLE"):
        names = {"LIVE": "LIVE", "DISPOSABLE_CLUSTER": "DISPOSABLE CLUSTER", "OFFLINE": "OFFLINE"}
        return " + ".join(names[e] for e in ENVS if e in envs and e in names) + " VERIFIED"
    return "IMPLEMENTED"


def canon(o) -> bytes:
    return json.dumps(o, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()


def changed_clusters(reg: Dict[str, Any]) -> Dict[str, str]:
    """cluster_id -> why its live membership no longer matches the one the registry was signed against."""
    out = {}
    for cid, t in (reg.get("live_topology") or {}).items():
        want = sorted(f"{m['node_id']}:{m['public_key_b64']}" for m in t["members"])
        if hashlib.sha256("\n".join(want).encode()).hexdigest() != t.get("key_fingerprint"):
            out[cid] = "registry topology entry is internally inconsistent"
            continue
        try:
            live = json.loads(fetch(t["membership_url"]))["membership"]["members"]
            got = sorted(f"{m['node_id']}:{m['public_key_b64']}" for m in live)
            if got != want:
                out[cid] = "live membership differs from the one the claims were proven against"
        except Exception as e:  # noqa: BLE001
            out[cid] = f"live membership unreachable ({str(e)[:60]})"
    return out


def verify(reg_src: str, pin: str = None, base: str = None, check_topology: bool = False) -> Dict[str, Any]:
    reg = json.loads(fetch(reg_src))
    out = {"registry": {}, "claims": []}
    body = {k: v for k, v in reg.items() if k not in ("registry_digest", "signature_b64")}
    digest = hashlib.sha256(canon(body)).hexdigest()
    keys = {}
    if pin is None:
        for site, root in SITES.items():
            try:
                keys[site] = json.loads(fetch(root + "claims/evidence-root.pub.json"))["public_key_b64"]
            except Exception as e:  # noqa: BLE001
                keys[site] = f"UNREACHABLE: {e}"
        pin = keys.get("clawx.click")
    reasons = []
    if pin is None or (keys and len(set(keys.values())) != 1):
        reasons.append(f"evidence-root key differs between sites or is unreachable: {keys}")
    if reg.get("evidence_root_public_key_b64") != pin:
        reasons.append("registry names a different evidence-root key than the pinned one")
    if digest != reg.get("registry_digest"):
        reasons.append("registry content does not match its digest (edited after signing)")
    try:
        Ed25519PublicKey.from_public_bytes(base64.b64decode(pin)).verify(base64.b64decode(reg["signature_b64"]),
                                                                         digest.encode())
    except Exception:
        reasons.append("signature does not verify against the pinned evidence-root key")
    out["registry"] = {"result": "VALID" if not reasons else "INVALID", "why": reasons, "digest": digest,
                       "pinned_key": pin, "keys_seen": keys}
    base = base or reg_src.rsplit("/claims/", 1)[0] + "/"
    v2 = str(reg.get("registry", "")).endswith("/v2")
    live_clusters = set(reg.get("live_clusters") or [])
    now = time.time()
    moved = changed_clusters(reg) if (v2 and check_topology) else {}
    out["topology"] = {"checked": bool(v2 and check_topology), "changed": moved}
    for c in reg.get("claims", []):
        rows, bad = [], []
        for a in c["artifacts"]:
            url = base + a["bundle"] + "/" + a["file"]
            try:
                raw = fetch(url)
                got = hashlib.sha256(raw).hexdigest()
                ok = got == a["sha256"]
                if ok and v2:
                    seen = set(re.findall(rb'"cluster_id"\s*:\s*"([^"]+)"', raw))
                    seen = {x.decode() for x in seen}
                    if seen and not seen <= set(a.get("cluster_ids") or []):
                        bad.append(f"{a['file']}: records clusters {sorted(seen)} the registry does not list for it")
                    if seen and a.get("environment") == "LIVE" and not seen <= live_clusters:
                        bad.append(f"{a['file']}: labelled LIVE but produced by {sorted(seen - live_clusters)}, not a live cluster")
                rows.append({"file": f"{a['bundle']}/{a['file']}", "result": "VALID" if ok else "TAMPERED",
                             "expected": a["sha256"], "found": got})
                if not ok:
                    bad.append(f"{a['file']}: expected sha256 {a['sha256'][:16]}..., found {got[:16]}...")
            except Exception as e:  # noqa: BLE001
                rows.append({"file": f"{a['bundle']}/{a['file']}", "result": "UNREACHABLE", "error": str(e)[:120]})
                bad.append(f"{a['file']}: unreachable")
        if c["status"] == "NOT_IMPLEMENTED" and (c["evidence_level"] != 0 or c["artifacts"]):
            bad.append("NOT_IMPLEMENTED claim carries evidence level or artifacts")
        if c["evidence_level"] >= 3 and not c["artifacts"]:
            bad.append(f"evidence level {c['evidence_level']} without artifacts")
        stale = False
        if v2:
            envs = c.get("evidence_environment") or []
            if not envs or any(e not in ENVS for e in envs):
                bad.append(f"evidence environment missing or unknown: {envs}")
            art_envs = [e for e in ENVS if any(a.get("environment") == e for a in c["artifacts"])]
            if c["artifacts"] and art_envs != envs:
                bad.append(f"claim environment {envs} differs from its artifacts' {art_envs}")
            if c.get("public_label") != expected_label(c["status"], envs):
                bad.append(f"public label {c.get('public_label')!r} should be {expected_label(c['status'], envs)!r}")
            if "disposable" in c.get("limits", "").lower() and "DISPOSABLE_CLUSTER" not in envs:
                bad.append("limits say the evidence is from a disposable cluster; the environment does not")
            try:
                stale = time.mktime(time.strptime(c["revalidate_by"], "%Y-%m-%dT%H:%M:%SZ")) - time.timezone < now
            except Exception:  # noqa: BLE001
                bad.append("revalidate_by missing or malformed")
            for a in c["artifacts"]:
                for cid in a.get("cluster_ids") or []:
                    if a.get("environment") == "LIVE" and cid in moved:
                        stale = True
                        note = f"topology: {cid} {moved[cid]}"
                        if note not in bad:
                            c.setdefault("_notes", []).append(note)
        result = "VALID" if not bad and out["registry"]["result"] == "VALID" else (
            "TAMPERED" if any("expected sha256" in b for b in bad) else "INVALID")
        if result == "VALID" and stale:
            result = "STALE"
            bad.extend(sorted(set(c.pop("_notes", []))))
        out["claims"].append({"claim_id": c["claim_id"], "status": c["status"], "evidence_level": c["evidence_level"],
                              "public_label": c.get("public_label"), "revalidate_by": c.get("revalidate_by"),
                              "result": result, "why": bad, "artifacts": rows, "limits": c["limits"]})
    results = {x["result"] for x in out["claims"]}
    out["final"] = "INVALID" if out["registry"]["result"] != "VALID" or results - {"VALID", "STALE"} else (
        "STALE" if "STALE" in results else "VALID")
    return out


def main(argv: List[str]) -> int:
    if len(argv) < 2:
        print(__doc__)
        return 2
    pin = argv[argv.index("--pin") + 1] if "--pin" in argv else None
    base = argv[argv.index("--base") + 1] if "--base" in argv else None
    r = verify(argv[1], pin, base, "--check-topology" in argv)
    if "--json" in argv:
        print(json.dumps(r, indent=1))
        return 0 if r["final"] == "VALID" else 1
    print(f"REGISTRY: {r['registry']['result']}  (digest {r['registry']['digest'][:16]}...)")
    if r.get("topology", {}).get("checked"):
        moved = r["topology"]["changed"]
        print("TOPOLOGY: " + ("unchanged since signing" if not moved else "; ".join(f"{k}: {v}" for k, v in moved.items())))
    for w in r["registry"]["why"]:
        print(f"    - {w}")
    for c in r["claims"]:
        label = f"  {c['public_label']}" if c.get("public_label") else ""
        print(f"{c['result']:9s} {c['claim_id']:34s} {c['status']:16s} level {c['evidence_level']}{label}")
        for w in c["why"]:
            print(f"    - {w}")
    print(f"\nFINAL RESULT: {r['final']}")
    return 0 if r["final"] == "VALID" else 1


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