#!/usr/bin/env python3
"""
Standing verifier — prove whether a claim is UNCONTESTED, trusting no one.

Every other Touchstone verifier answers "did this exist, unaltered, by a provable date?" This one
answers the other half of trust: "has anyone objected to it — and can the channel have hidden that?"
A service (a VouchTrail verdict, a Museum catalog, an attestation) NAMES a contest channel + a target
digest in what it anchors. Anyone can file a contest into that channel; each contest rides the same
append-only → checkpoint → Bitcoin chain, so once anchored the channel cannot drop, reorder, or alter
it. This script:
  1. fetches every contest against the target on the named channel, and for each ANCHORED one
     recomputes the body→payload_hash, the entry_hash, and folds its Merkle inclusion proof up to a
     checkpoint's Bitcoin-anchored root (→ `ots verify`), exactly like verify.py;
  2. CRYPTOGRAPHICALLY verifies each contest's attestation — a contestant-signed contest against the
     contestant's own key (grade verified/claimed), a server-attested one against the channel's server
     key — so attribution rests on a signature you check here, not on Touchstone's word; then
  3. decides standing: CONTESTED if any contest is anchored (before an optional --horizon), else CLEAR.

--contest-file <path|url> verifies a STANDALONE contestant-signed contest object (the kind a contestant
publishes if the channel REFUSES or DROPS their contest): it checks the signature, then whether the
same contest is present on --channel. "Signed but absent" is a channel refusing a valid objection —
the exact failure the anchored channel alone cannot show you.

Standing = the target is anchored (run verify.py / catalog-verify.py) AND no valid contest is anchored
here before the freshness horizon. Dependency-free (Python standard library); imports no Touchstone
code — every hash and the Ed25519 verify below are reimplemented — so read it, then run it.

Usage:
    python3 standing-verify.py --channel=<recorder-feed-url> --target=<digest> [--horizon=<seq>]
    python3 standing-verify.py --channel=<recorder-feed-url> --contest-file=<path|url>
"""
import sys
import json
import base64
import hashlib
import urllib.request
import urllib.parse

CONTEST_EVENT = "touchstone.contest"


# ===================== vendored Ed25519 verify (RFC 8032 reference) =====================
# Verify-only, no secret handling. Vendored so this tool trusts no compiled crypto library —
# an auditor reads it. (Same implementation as the touchstone-verify package, validated vs libsodium.)
_p = 2**255 - 19
_L = 2**252 + 27742317777372353535851937790883648493
_d = (-121665 * pow(121666, _p - 2, _p)) % _p
_I = pow(2, (_p - 1) // 4, _p)


def _inv(x):
    return pow(x, _p - 2, _p)


def _x_recover(y):
    xx = (y * y - 1) * _inv(_d * y * y + 1)
    x = pow(xx, (_p + 3) // 8, _p)
    if (x * x - xx) % _p != 0:
        x = (x * _I) % _p
    if x % 2 != 0:
        x = _p - x
    return x


_By = (4 * _inv(5)) % _p
_Bx = _x_recover(_By)
_B = (_Bx % _p, _By % _p, 1, (_Bx * _By) % _p)


def _add(P, Q):
    x1, y1, z1, t1 = P
    x2, y2, z2, t2 = Q
    a = ((y1 - x1) * (y2 - x2)) % _p
    b = ((y1 + x1) * (y2 + x2)) % _p
    c = (t1 * 2 * _d * t2) % _p
    dd = (z1 * 2 * z2) % _p
    e, f, g, h = b - a, dd - c, dd + c, b + a
    return ((e * f) % _p, (g * h) % _p, (f * g) % _p, (e * h) % _p)


def _mul(P, e):
    if e == 0:
        return (0, 1, 1, 0)
    Q = _mul(P, e // 2)
    Q = _add(Q, Q)
    if e & 1:
        Q = _add(Q, P)
    return Q


def _encode(P):
    x, y, z, _t = P
    zi = _inv(z)
    x = (x * zi) % _p
    y = (y * zi) % _p
    return (y | ((x & 1) << 255)).to_bytes(32, "little")


def _on_curve(P):
    x, y, z, _t = P
    zi = _inv(z)
    x = (x * zi) % _p
    y = (y * zi) % _p
    return (-x * x + y * y - 1 - _d * x * x * y * y) % _p == 0


def _decode(s):
    y = int.from_bytes(s, "little") & ((1 << 255) - 1)
    x = _x_recover(y)
    if x & 1 != (int.from_bytes(s, "little") >> 255) & 1:
        x = _p - x
    P = (x % _p, y % _p, 1, (x * y) % _p)
    if not _on_curve(P):
        raise ValueError("point not on curve")
    return P


def _ed_verify(pub, msg, sig):
    if len(pub) != 32 or len(sig) != 64:
        return False
    try:
        A = _decode(pub)
        R = sig[:32]
        s = int.from_bytes(sig[32:], "little")
        if s >= _L:
            return False
        h = int.from_bytes(hashlib.sha512(R + pub + msg).digest(), "little") % _L
        return _encode(_mul(_B, s)) == _encode(_add(_decode(R), _mul(A, h)))
    except Exception:
        return False


def _b64(s):
    return base64.b64decode(s.replace("-", "+").replace("_", "/") + "===")


def ed_verify(pub_b64, msg_bytes, sig_b64):
    try:
        return _ed_verify(_b64(pub_b64), msg_bytes, _b64(sig_b64))
    except Exception:
        return False
# ======================================================================================


# --- clean-room JCS canonicalization (RFC 8785 subset) — identical to verify.py ---
def _sort(v):
    if isinstance(v, dict):
        return {k: _sort(v[k]) for k in sorted(v.keys())}
    if isinstance(v, list):
        return [_sort(x) for x in v]
    return v


def jcs(value):
    return json.dumps(_sort(value), separators=(",", ":"), ensure_ascii=False)


# --- RFC 6962 Merkle over entry_hash values (mirrors Touchstone's MerkleService) ---
def _leaf(h):
    return hashlib.sha256(b"\x00" + bytes.fromhex(h)).hexdigest()


def _node(l, r):
    return hashlib.sha256(b"\x01" + bytes.fromhex(l) + bytes.fromhex(r)).hexdigest()


def fold_proof(entry_hash, proof):
    acc = _leaf(entry_hash)
    for s in proof:
        acc = _node(s["hash"], acc) if s["side"] == "left" else _node(acc, s["hash"])
    return acc


def recompute_entry_hash(e):
    parts = [
        str(e["seq"]), e["prev_hash"], e["server_ts"], e["payload_hash"],
        e["actor_sub"], e.get("counterparty_sub") or "", e["actor_sig"],
    ]
    b = e.get("server_beacon")  # beacon-bound entries append one line; absent → legacy preimage
    if b is not None:
        if not isinstance(b, dict) or not all(k in b for k in ("chain", "round", "randomness")):
            return "malformed-server_beacon"   # present-but-malformed → reject (fail closed, php/js parity)
        parts.append("beacon:%s:%s:%s" % (b["chain"], b["round"], b["randomness"]))
    return hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()


def contest_body(c):
    # The exact cleartext body Touchstone hashed for a contest (ChainService::appendContest).
    return {
        "v": 1, "type": "contest",
        "target_digest": c.get("target_digest"), "target_ref": c.get("target_ref"),
        "reason": c.get("reason"), "contestant_sub": c.get("actor_sub") or c.get("contestant_sub"),
        "contestant_pubkey": c.get("contestant_pubkey"),
    }


def contest_body_hash(c):
    return hashlib.sha256(jcs(contest_body(c)).encode("utf-8")).hexdigest()


def signed_content(recorder, actor_sub, payload_hash):
    # The exact bytes actor_sig covers (Hashing::signedContent) for a contest.
    return jcs({
        "v": 1, "recorder_id": recorder, "event_type": CONTEST_EVENT,
        "actor_sub": actor_sub, "counterparty_sub": None,
        "payload_hash": payload_hash, "client_ts": None,
    }).encode("utf-8")


def get_json(url):
    req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "standing-verify/2"})
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.loads(r.read().decode("utf-8"))


def read_source(src):
    if src.startswith("http://") or src.startswith("https://"):
        return get_json(src)
    with open(src, "r", encoding="utf-8") as fh:
        return json.load(fh)


class Keys:
    """Independently grades a contestant pubkey: verified iff it is the subject's OWN key
    (bound via a self-operated recorder at /pubkeys), else claimed. Cached per subject."""

    def __init__(self, origin):
        self.origin = origin
        self.cache = {}

    def grade(self, sub, pubkey):
        if sub not in self.cache:
            try:
                d = get_json(self.origin + "/.well-known/touchstone/pubkeys/" + urllib.parse.quote(sub, safe=""))
                self.cache[sub] = [b["signing_pubkey"] for b in d.get("bindings", []) if b.get("verified")]
            except Exception:
                self.cache[sub] = []
        return "verified" if pubkey in self.cache[sub] else "claimed"


def check_attestation(c, recorder, server_pubkey, keys):
    """(mode, sig_ok, grade). Verifies actor_sig here — contestant-signed against the contestant's
    key, server-attested against the channel's server key."""
    msg = signed_content(recorder, c["actor_sub"], c["payload_hash"])
    pk = c.get("contestant_pubkey")
    if pk:
        ok = ed_verify(pk, msg, c["actor_sig"])
        return ("contestant-signed", ok, (keys.grade(c["actor_sub"], pk) if ok else None))
    if server_pubkey:
        return ("server-attested", ed_verify(server_pubkey, msg, c["actor_sig"]), None)
    return ("server-attested", None, None)  # channel published no server_pubkey to check against


def main(argv):
    channel = target = horizon = contest_file = None
    for a in argv[1:]:
        if a.startswith("--channel="):
            channel = a.split("=", 1)[1].rstrip("/")
        elif a.startswith("--target="):
            target = a.split("=", 1)[1]
        elif a.startswith("--horizon="):
            horizon = int(a.split("=", 1)[1])
        elif a.startswith("--contest-file="):
            contest_file = a.split("=", 1)[1]
    if not channel:
        print(__doc__)
        return 2

    base = channel[:-len("/contests")] if channel.endswith("/contests") else channel
    origin = base.split("/.well-known/", 1)[0]
    keys = Keys(origin)

    if contest_file:
        return verify_contest_file(contest_file, base, keys)

    if not target:
        print(__doc__)
        return 2

    url = base + "/contests?target=" + urllib.parse.quote(target, safe="")
    chan = get_json(url)
    recorder = chan.get("recorder")
    server_pubkey = chan.get("server_pubkey")

    print("Standing verifier — is this claim uncontested, and can the channel have hidden a contest?\n")
    print("Channel : %s" % recorder)
    print("Target  : %s" % target)
    latest = chan.get("latest_checkpoint")
    print("Coverage: latest Bitcoin-anchored checkpoint = %s (commits seq <= %s)\n"
          % (("#%s" % latest["id"]) if latest else "none yet", latest["seq_end"] if latest else "-"))

    counted, pending, bad = [], [], []
    for c in chan.get("contests", []):
        if c.get("event_type") != CONTEST_EVENT or c.get("target_digest") != target:
            continue
        body_ok = (contest_body_hash(c) == c.get("payload_hash"))
        eh_ok = (recompute_entry_hash(c) == c.get("entry_hash"))
        mode, sig_ok, grade = check_attestation(c, recorder, server_pubkey, keys)
        if not c.get("anchored"):
            pending.append((c, mode, sig_ok, grade))
            continue
        cp = c["checkpoint"]
        root_ok = (fold_proof(c["entry_hash"], c["inclusion_proof"]) == cp["merkle_root"])
        if body_ok and eh_ok and root_ok and sig_ok is not False:
            counted.append((c, mode, sig_ok, grade))
        else:
            bad.append((c, body_ok, eh_ok, root_ok, sig_ok))

    active = [t for t in counted if horizon is None or t[0]["seq"] <= horizon]

    for c, mode, sig_ok, grade in active:
        cp = c["checkpoint"]
        att = "%s%s" % (mode, ("/" + grade) if grade else "")
        print("  ✗ CONTEST  seq %s by %s   [%s%s]"
              % (c["seq"], c["actor_sub"], att, "" if sig_ok else ", SIGNATURE UNVERIFIED"))
        print("      reason      : %s" % (c.get("reason") or "").strip()[:200])
        print("      ✓ body→payload_hash, ✓ entry_hash, ✓ inclusion proof → checkpoint #%s root %s…, %s attestation"
              % (cp["id"], cp["merkle_root"][:12], "✓" if sig_ok else "—"))
        print("      → Bitcoin   : curl -O %s%s  &&  ots verify %s"
              % (origin, cp["ots"], cp["ots"].rsplit("/", 1)[-1]))
    for c, *_ in bad:
        print("  ! seq %s claims to contest the target but its proof/signature does NOT check out — ignored." % c["seq"])
    for c, mode, sig_ok, grade in pending:
        print("  · seq %s contests the target [%s] but is not yet checkpointed (recorded, not yet Bitcoin-bound)." % (c["seq"], mode))

    print()
    # Four states, identical to standing.js and the PHP badge: CONTESTED (an anchored contest),
    # UNKNOWN (no checkpoint to bound the negative), STALE (a contest recorded but not yet anchored —
    # standing not yet determinable), else CLEAR.
    if active:
        vg = sum(1 for _c, _m, _s, g in active if g == "verified")
        result = "CONTESTED"
        print("RESULT: CONTESTED ✗ — %d anchored contest(s) against this target%s (%d contestant-verified)."
              % (len(active), (" at/below seq %s" % horizon) if horizon is not None else "", vg))
        print("        The claim may still be true, but it is NOT uncontested. Read the reasons above.")
    elif latest is None:
        result = "UNKNOWN"
        print("RESULT: UNKNOWN — no checkpoint yet to bound the negative; standing is not yet determinable.")
    elif pending:
        result = "STALE"
        print("RESULT: STALE — %d contest(s) recorded but not yet Bitcoin-anchored; standing not yet "
              "determinable (re-check after the next checkpoint)." % len(pending))
    else:
        result = "CLEAR"
        print("RESULT: CLEAR ✓ — no valid anchored contest against this target as of checkpoint #%s (seq <= %s)."
              % (latest["id"], latest["seq_end"]))
    print("\nStanding = target anchored (verify.py / catalog-verify.py) AND clear here. Contestant-signed contests")
    print("are checked against the contestant's own key (attribution trusts no one); a contestant-signed contest")
    print("also stays verifiable OFF-channel, so a refused/dropped one is detectable with --contest-file. This")
    print("tool cannot prove the channel ACCEPTED every contest ever submitted — that residual is why refused")
    print("contestants publish their signed object. Trust in 'uncontested' = trust the channel is complete.")
    return {"CONTESTED": 1, "CLEAR": 0, "STALE": 2, "UNKNOWN": 2}[result]


def verify_contest_file(src, base, keys):
    """Verify a standalone contestant-signed contest object, then check whether the channel shows it.
    'Signed but absent' = a valid objection the channel is not surfacing (refused or dropped)."""
    obj = read_source(src)
    recorder = obj.get("recorder")
    pk = obj.get("contestant_pubkey")
    sub = obj.get("actor_sub") or obj.get("contestant_sub")
    target = obj.get("target_digest")
    print("Standing verifier — a standalone (possibly refused) contestant-signed contest.\n")
    print("Recorder : %s\nContestant: %s\nTarget   : %s\n" % (recorder, sub, target))
    if not (recorder and pk and sub and target and obj.get("actor_sig")):
        print("RESULT: MALFORMED ✗ — need recorder, actor_sub, target_digest, contestant_pubkey, actor_sig.")
        return 2

    c = {"actor_sub": sub, "target_digest": target, "target_ref": obj.get("target_ref"),
         "reason": obj.get("reason"), "contestant_pubkey": pk}
    payload_hash = contest_body_hash(c)
    msg = signed_content(recorder, sub, payload_hash)
    sig_ok = ed_verify(pk, msg, obj["actor_sig"])
    grade = keys.grade(sub, pk) if sig_ok else None
    print("[1/2] Signature")
    print("  %s actor_sig verifies against contestant_pubkey  (%s)"
          % (("✓" if sig_ok else "✗"), ("grade " + grade) if grade else "attribution unproven"))
    if not sig_ok:
        print("\nRESULT: INVALID ✗ — this is not a genuine contestant-signed contest; ignore it.")
        return 1

    print("[2/2] Presence on the channel")
    present = False
    try:
        chan = get_json(base + "/contests?target=" + urllib.parse.quote(target, safe=""))
        for x in chan.get("contests", []):
            if x.get("payload_hash") == payload_hash and x.get("contestant_pubkey") == pk:
                present = True
                print("  ✓ PRESENT — the channel shows this contest (anchored: %s)." % x.get("anchored"))
                break
        if not present:
            print("  ✗ ABSENT — a valid, signed objection the channel is NOT surfacing (refused or dropped).")
    except Exception as ex:
        print("  ! could not reach the channel: %s" % ex)

    print("\nRESULT: %s"
          % ("SIGNED, PRESENT ✓ — the channel is carrying this contest." if present
             else "SIGNED BUT ABSENT ✗ — the contestant holds a valid objection the named channel omits. "
                  "Standing computed from the channel alone is INCOMPLETE."))
    return 0 if present else 1


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