#!/usr/bin/env python3
"""CAIN-42 auditor evidence kit verifier.  Don't trust CAIN-42. Verify CAIN-42.

Python 3.8+ standard library only. Imports no CAIN-42 code, needs no account, sends nothing anywhere unless you
pass --live. Run it from the unzipped kit folder:

    python3 verify.py            offline: file hashes, Ed25519 signature, every recorded result vs. its claim
    python3 verify.py --live     also re-run every claim yourself against the public site and compare
    python3 verify.py --live --base https://mcpgate.online      (or https://clawx.click; same product, other domain)

What each check proves:
  1. hashes     every file in the kit is exactly what MANIFEST.json lists (nothing edited after signing)
  2. signature  MANIFEST.json was signed by the key in kit-key.json. Compare that key's fingerprint with the one
                published on all three sites (https://cainstudio.online/evidence-kit-key.json, mcpgate.online,
                clawx.click). Three independent copies agreeing is your reason to trust the key, not this file.
  3. transcript every recorded live answer contains the value the claim says it should
  4. --live     you send the same requests now, from your own machine, and check the answers yourself.
                This is the check that does not depend on anything we recorded.
Exit code 0 = every check passed; 1 = something failed (it says what). A claim that cannot be checked is a FAIL,
never a pass.
"""
from __future__ import annotations

import argparse
import base64
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.request

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

# ---------------------------------------------------------------- Ed25519 verify (RFC 8032, section 5.1.7), stdlib
_p = 2 ** 255 - 19
_L = 2 ** 252 + 27742317777372353535851937790883648493
_d = -121665 * pow(121666, _p - 2, _p) % _p
_I = pow(2, (_p - 1) // 4, _p)


def _xrecover(y, sign):
    if y >= _p:
        return None
    x2 = (y * y - 1) * pow(_d * y * y + 1, _p - 2, _p) % _p
    if x2 == 0:
        return None if sign else 0
    x = pow(x2, (_p + 3) // 8, _p)
    if (x * x - x2) % _p != 0:
        x = x * _I % _p
    if (x * x - x2) % _p != 0:
        return None
    if (x & 1) != sign:
        x = _p - x
    return x


def _add(P, Q):
    A = (P[1] - P[0]) * (Q[1] - Q[0]) % _p
    B = (P[1] + P[0]) * (Q[1] + Q[0]) % _p
    C = 2 * P[3] * Q[3] * _d % _p
    D = 2 * P[2] * Q[2] % _p
    E, F, G, H = B - A, D - C, D + C, B + A
    return (E * F % _p, G * H % _p, F * G % _p, E * H % _p)


def _mul(s, P):
    Q = (0, 1, 1, 0)
    while s > 0:
        if s & 1:
            Q = _add(Q, P)
        P = _add(P, P)
        s >>= 1
    return Q


def _eq(P, Q):
    return (P[0] * Q[2] - Q[0] * P[2]) % _p == 0 and (P[1] * Q[2] - Q[1] * P[2]) % _p == 0


_gy = 4 * pow(5, _p - 2, _p) % _p
_gx = _xrecover(_gy, 0)
_G = (_gx, _gy, 1, _gx * _gy % _p)


def _decode(b):
    if len(b) != 32:
        return None
    y = int.from_bytes(b, "little")
    sign = y >> 255
    y &= (1 << 255) - 1
    x = _xrecover(y, sign)
    return None if x is None else (x, y, 1, x * y % _p)


def ed25519_verify(pub: bytes, msg: bytes, sig: bytes) -> bool:
    if len(pub) != 32 or len(sig) != 64:
        return False
    A, R = _decode(pub), _decode(sig[:32])
    if A is None or R is None:
        return False
    s = int.from_bytes(sig[32:], "little")
    if s >= _L:
        return False
    h = int.from_bytes(hashlib.sha512(sig[:32] + pub + msg).digest(), "little") % _L
    return _eq(_mul(s, _G), _add(R, _mul(h, A)))


# ---------------------------------------------------------------- helpers
def canonical(obj) -> bytes:
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")


def get_path(o, path):
    for k in [x for x in (path or "").split(".") if x]:
        if isinstance(o, list):
            try:
                o = o[int(k)]
            except (ValueError, IndexError):
                return None
        elif isinstance(o, dict):
            o = o.get(k)
        else:
            return None
    return o


def same(got, expect):
    """Exact JSON equality with one of the expected values (so 1 != true, 0.85 == 0.85)."""
    return any(json.dumps(x, sort_keys=True) == json.dumps(got, sort_keys=True) for x in expect)


def load(name):
    with open(os.path.join(HERE, name), "rb") as f:
        return f.read()


class R:
    def __init__(self):
        self.fails = 0
        self.limited = 0

    def ok(self, cond, what):
        print(("  PASS  " if cond else "  FAIL  ") + what)
        if not cond:
            self.fails += 1
        return cond


def check_claim(c, status, reply):
    """A claim holds when the answer contains an expected value (if the claim names one) AND the HTTP status is an
    expected one (if the claim names statuses). A claim naming neither cannot be checked: FAIL."""
    if not c.get("expect") and not c.get("expect_status"):
        return False, "nothing to check"
    got = get_path(reply, c.get("path", "")) if c.get("path") else None
    ok = True
    if c.get("expect"):
        ok = same(got, c["expect"])
    if c.get("expect_status"):
        ok = ok and status in c["expect_status"]
    shown = (f"{c['path']} = {json.dumps(got)}" if c.get("path") else "") + (f" HTTP {status}" if c.get("expect_status") else "")
    return ok, shown.strip()


def run_live(claim, base):
    """Send one claim's request step(s) and return the final reply. Placeholders follow the website's demo buttons:
    "${rand}" -> a fresh random id per run; "${N.path}" -> the value at path in step N's reply; "set" overwrites one
    field of the body with such a value."""
    import re
    import secrets
    res, rand = [], secrets.token_hex(8)

    def fill(v):
        if isinstance(v, str):
            v = v.replace("${rand}", rand)
            m = re.fullmatch(r"\$\{(\d+)\.([^}]+)\}", v)
            if m:
                return get_path(res[int(m.group(1))], m.group(2))
            return re.sub(r"\$\{(\d+)\.([^}]+)\}",
                          lambda m: "" if get_path(res[int(m.group(1))], m.group(2)) is None else str(get_path(res[int(m.group(1))], m.group(2))), v)
        if isinstance(v, list):
            return [fill(x) for x in v]
        if isinstance(v, dict):
            return {k: fill(x) for k, x in v.items()}
        return v

    status = None
    for st in claim["requests"]:
        m = (st.get("method") or "POST").upper()
        body = fill(st.get("body"))
        for pth, val in (st.get("set") or {}).items():
            ks, o = pth.split("."), body
            for q in ks[:-1]:
                o = o[int(q)] if isinstance(o, list) else (o or {}).get(q)
            if isinstance(o, list):
                o[int(ks[-1])] = fill(val)
            elif isinstance(o, dict):
                o[ks[-1]] = fill(val)
        data = None if m == "GET" else json.dumps(body if body is not None else {}).encode()
        hdr = dict({"Content-Type": "application/json", "User-Agent": "cain-evidence-kit-verifier/1"}, **(fill(st.get("headers")) or {}))
        url = fill(st["url"])
        # clawx.click forwards only /api, /fabric, /.well-known and /health to the gateway; other gateway routes are
        # reachable there under /api/v1 (the same rule the website's demo buttons use).
        if "clawx.click" in base and not re.match(r"^/(api|fabric|\.well-known|health)(/|$)", url):
            url = "/api/v1" + url
        req = urllib.request.Request(base.rstrip("/") + url, data=data, method=m, headers=hdr)
        try:
            with urllib.request.urlopen(req, timeout=170) as r:
                status = r.status
                res.append(json.loads(r.read().decode("utf-8") or "null"))
        except urllib.error.HTTPError as e:
            status = e.code
            try:
                res.append(json.loads(e.read().decode("utf-8") or "null"))
            except ValueError:
                res.append({"_http_error": e.code})
    return status, (res[-1] if res else None)


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--live", action="store_true", help="re-run every claim against the public site")
    ap.add_argument("--base", default=None, help="site to re-run against (default: the kit's own base)")
    a = ap.parse_args()
    r = R()
    manifest = json.loads(load("MANIFEST.json"))
    key = json.loads(load("kit-key.json"))
    print(f"CAIN-42 evidence kit: {manifest.get('product')}  (kit {manifest.get('kit_id')}, recorded {manifest.get('recorded_at')})")
    print("1. File hashes")
    for name, h in sorted(manifest["files"].items()):
        try:
            r.ok(hashlib.sha256(load(name)).hexdigest() == h, f"{name}")
        except OSError:
            r.ok(False, f"{name} (missing)")
    print("2. Signature")
    pub = base64.b64decode(key["public_key_b64"])
    fp = hashlib.sha256(pub).hexdigest()
    sig = base64.b64decode(load("MANIFEST.sig").decode().strip())
    r.ok(ed25519_verify(pub, canonical(manifest), sig), "MANIFEST.json signed by the kit key")
    print(f"        key fingerprint (SHA-256 of the public key): {fp}")
    print("        compare it with https://cainstudio.online/evidence-kit-key.json, https://mcpgate.online/evidence-kit-key.json")
    print("        and https://clawx.click/evidence-kit-key.json; all three must show the same fingerprint.")
    claims = json.loads(load("claims.json"))
    transcript = {t["id"]: t for t in json.loads(load("transcript.json"))}
    print(f"3. Recorded results ({len(claims['claims'])} claims)")
    for c in claims["claims"]:
        t = transcript.get(c["id"])
        ok, shown = (False, "missing") if t is None else check_claim(c, t.get("http_status"), t.get("reply"))
        r.ok(ok, f"{c['id']}: {c['claim']}  [recorded {shown}]")
    if a.live:
        base = a.base or claims.get("base") or "https://cainstudio.online"
        print(f"4. Live re-run against {base} (from your machine, now)")
        answers = {}   # several claims can read one answer: identical requests are sent once, which also respects the demo's rate limit
        for c in claims["claims"]:
            key = json.dumps(c.get("requests"), sort_keys=True)
            try:
                if key in answers and "${rand}" not in key:
                    live = answers[key]
                else:
                    live = run_live(c, base)
                    for wait in (30, 60, 120):   # the public demo limits each address; being limited says nothing about the product
                        if live[0] != 429:
                            break
                        print(f"        rate limited by the public demo; waiting {wait} s and trying again")
                        time.sleep(wait)
                        live = run_live(c, base)
                    answers[key] = live
                if live[0] == 429:
                    r.limited += 1
                    print(f"  NOT CHECKED  {c['id']}: still rate limited; run this again later")
                    continue
                ok, shown = check_claim(c, *live)
            except Exception as e:  # noqa: BLE001 -- cannot check means FAIL, never PASS
                ok, shown = False, f"<error: {type(e).__name__}: {e}>"
            r.ok(ok, f"{c['id']}: {c['claim']}  [now {shown}; expected {json.dumps(c.get('expect') or c.get('expect_status'))}]")
    print()
    if r.fails:
        print(f"RESULT: FAIL ({r.fails} check(s) failed). Do not rely on the failed claims.")
        return 1
    if r.limited:
        print(f"RESULT: NOT FINISHED. {r.limited} live claim(s) could not be checked because the public demo rate-limited this address. "
              "Nothing failed, but this is not a pass: run python3 verify.py --live again in an hour.")
        return 2
    print("RESULT: PASS. Every check above was computed on your machine." + ("" if a.live else " Add --live to re-run the claims yourself."))
    return 0


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