#!/usr/bin/env python3
"""
Standalone QBEACON verifier. Independent of the qbeacon package.

    python verify.py pulses.jsonl prover_pubkey.pem [genesis_hex]

Dependencies: the Python standard library plus `cryptography`. NOT numpy.

This file deliberately duplicates protocol logic rather than importing it:
two independent implementations that must agree.

Every pulse records its `device_class` and `soundness_assumptions`; the
verifier prints them for the ledger you check.

(c) Liju James / Jameson Machining Inc. -- 2026-07-26
"""

from __future__ import annotations

import argparse
import base64
import hashlib
import json
import math
import struct
import sys
from collections import defaultdict

from cryptography.hazmat.primitives import serialization

PROTOCOL_VERSION = "qbeacon/v3"

DOM_SETTINGS = b"qbeacon/v3/settings"
DOM_CHALLENGE = b"qbeacon/v3/challenge"
DOM_LEAF = b"qbeacon/v3/leaf"
DOM_PULSE = b"qbeacon/v3/pulse"
DOM_R_RAW = b"qbeacon/v3/R_raw"
DOM_GENESIS = b"qbeacon/v3/genesis"
DOM_RELAY_COMMIT = b"qbeacon/v3/relay-commit"
DOM_EXTRACTOR_SEED = b"qbeacon/v3/extractor-seed"

# Tier A: a relay pulse carries no Bell pairs, no outcomes, no CHSH value --
# the generic settings/commitment/challenge pipeline below does not apply to
# it at all. Checked via _verify_relay_pulse instead. Kept as a tuple, same
# as qbeacon.prover.RELAY_DEVICE_CLASSES, in case a second relay source is
# ever added.
RELAY_DEVICE_CLASSES = ("anu_qrng_relay",)

TSIRELSON = 2.0 * math.sqrt(2.0)
PBR_KAPPA = 0.55
P_WIN_QUANTUM = math.cos(math.pi / 8) ** 2
EPS_BELL = 2.0**-64
EPS_STAT = 1e-6


# ─── Hashing ─────────────────────────────────────────────────────────────
def tagged_hash(tag, *parts):
    h = hashlib.sha256()
    h.update(len(tag).to_bytes(4, "big"))
    h.update(tag)
    for p in parts:
        h.update(len(p).to_bytes(4, "big"))
        h.update(p)
    return h.digest()


def canonical_json(obj):
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
        "utf-8"
    )


# ─── Merkle (RFC 6962) ───────────────────────────────────────────────────
def leaf_hash(data):
    return hashlib.sha256(b"\x00" + data).digest()


def node_hash(left, right):
    return hashlib.sha256(b"\x01" + left + right).digest()


def _split(n):
    k = 1
    while k << 1 < n:
        k <<= 1
    return k


def merkle_root(leaves):
    if not leaves:
        return hashlib.sha256(b"").digest()
    layer = [leaf_hash(d) for d in leaves]

    def rec(off, size):
        if size == 1:
            return layer[off]
        k = _split(size)
        return node_hash(rec(off, k), rec(off + k, size - k))

    return rec(0, len(layer))


# ─── Settings ────────────────────────────────────────────────────────────
def _expand_bits(seed, n_bits):
    out = bytearray()
    counter = 0
    while len(out) * 8 < n_bits:
        out += hashlib.sha256(seed + counter.to_bytes(4, "big")).digest()
        counter += 1
    bits = []
    for byte in out:
        for k in range(7, -1, -1):
            bits.append((byte >> k) & 1)
            if len(bits) == n_bits:
                return bits
    return bits


def genesis_hash(label=b"default"):
    return tagged_hash(DOM_GENESIS, label).hex()


def settings_seed(prev_hash, pulse_id, n_pairs):
    return tagged_hash(
        DOM_SETTINGS,
        bytes.fromhex(prev_hash),
        pulse_id.to_bytes(4, "big"),
        n_pairs.to_bytes(4, "big"),
    ).hex()


def derive_bases(seed_hex, n_pairs):
    bits = _expand_bits(bytes.fromhex(seed_hex), n_pairs * 2)
    return bits[0::2][:n_pairs], bits[1::2][:n_pairs]


def derive_challenge(commitment_hex, prev_hash, pulse_id):
    return tagged_hash(
        DOM_CHALLENGE,
        bytes.fromhex(commitment_hex),
        bytes.fromhex(prev_hash),
        pulse_id.to_bytes(4, "big"),
    ).hex()


def derive_test_bits(challenge_hex, n_pairs):
    return _expand_bits(bytes.fromhex(challenge_hex), n_pairs)[:n_pairs]


# ─── Transcript ──────────────────────────────────────────────────────────
def unpack_outcomes(b64, n_pairs):
    raw = base64.b64decode(b64)
    if len(raw) < (2 * n_pairs + 7) // 8:
        raise ValueError("transcript too short")
    bits = []
    for byte in raw:
        for k in range(7, -1, -1):
            bits.append((byte >> k) & 1)
    a = [1 - 2 * bits[2 * i] for i in range(n_pairs)]
    b = [1 - 2 * bits[2 * i + 1] for i in range(n_pairs)]
    return a, b


def leaf_data(i, ba, bb, a, b):
    return DOM_LEAF + struct.pack(">IBBBB", i, ba, bb, (1 - a) // 2, (1 - b) // 2)


# ─── CHSH ────────────────────────────────────────────────────────────────
def chsh_S(revealed):
    sums, counts = defaultdict(int), defaultdict(int)
    for r in revealed:
        key = (r["ba"], r["bb"])
        sums[key] += r["a"] * r["b"]
        counts[key] += 1
    e = {k: sums[k] / counts[k] for k in sums}
    return e.get((0, 0), 0.0) + e.get((0, 1), 0.0) + e.get((1, 0), 0.0) - e.get((1, 1), 0.0)


def is_win(a, b, ba, bb):
    return (((1 - a) // 2) ^ ((1 - b) // 2)) == (ba & bb)


def pbr_pvalue(revealed, kappa=PBR_KAPPA):
    if not revealed:
        return 1.0
    win = math.log2(1 + kappa / 4)
    loss = math.log2(1 - 3 * kappa / 4)
    log2_T = 0.0
    for r in revealed:
        log2_T += win if is_win(r["a"], r["b"], r["ba"], r["bb"]) else loss
    if log2_T <= 0:
        return 1.0
    return 2.0 ** (-log2_T) if log2_T < 1000 else 0.0


def tsirelson_allowance(n_min_cell, eps=EPS_STAT):
    if n_min_cell <= 0:
        return float("inf")
    return math.sqrt(8.0 * math.log(2.0 / eps) / n_min_cell)


def no_signaling_violation(revealed):
    """Largest dependence of one party's marginal on the OTHER party's setting.

    Zero in expectation for any genuine two-station device. Catches the
    partially-signaling prover that sits below the Tsirelson ceiling.
    """
    acc = {}
    for r in revealed:
        key = (r["ba"], r["bb"])
        if key not in acc:
            acc[key] = [0, 0, 0]
        acc[key][0] += r["a"] == 1
        acc[key][1] += r["b"] == 1
        acc[key][2] += 1
    if len(acc) < 4:
        return 0.0
    worst = 0.0
    for bb in (0, 1):
        worst = max(
            worst, abs(acc[(0, bb)][1] / acc[(0, bb)][2] - acc[(1, bb)][1] / acc[(1, bb)][2])
        )
    for ba in (0, 1):
        worst = max(
            worst, abs(acc[(ba, 0)][0] / acc[(ba, 0)][2] - acc[(ba, 1)][0] / acc[(ba, 1)][2])
        )
    return worst


def no_signaling_allowance(n_min_cell, eps=EPS_STAT):
    if n_min_cell <= 0:
        return float("inf")
    return math.sqrt(2.0 * math.log(16.0 / eps) / n_min_cell)


def min_revealed_for_grinding(lam=64, log2_q_max=60, kappa=PBR_KAPPA):
    gain = P_WIN_QUANTUM * math.log2(1 + kappa / 4) + (1 - P_WIN_QUANTUM) * math.log2(
        1 - 3 * kappa / 4
    )
    return math.ceil((lam + log2_q_max) / gain)


# ─── R ───────────────────────────────────────────────────────────────────
def r_raw(seed_hex, hidden_leaves):
    return tagged_hash(DOM_R_RAW, bytes.fromhex(seed_hex), b"".join(hidden_leaves)).hex()


def pulse_hash(d):
    return tagged_hash(DOM_PULSE, canonical_json(d)).hex()


# ─── Relay (Tier A) extraction ──────────────────────────────────────────
# Ported from qbeacon.entropy / qbeacon.prover / qbeacon.verifier's
# _verify_relay_pulse -- the two must agree, which is the entire point of
# this file. See qbeacon.entropy.extractor_seed's docstring for why the
# extractor seed is fiat-shamir(prev_hash, pulse_id), NOT derived from the
# outcomes/source bytes themselves (a v3 bug class this fixes).
def _bytes_to_bits(data):
    bits = []
    for byte in data:
        for k in range(7, -1, -1):
            bits.append((byte >> k) & 1)
    return bits


def extractor_seed(prev_hash, pulse_id, challenge, challenge_source):
    if challenge_source != "fiat-shamir":
        return bytes.fromhex(challenge)
    return hashlib.sha256(
        DOM_EXTRACTOR_SEED + bytes.fromhex(prev_hash) + pulse_id.to_bytes(4, "big")
    ).digest()


def _expand_seed_shake(seed, n_bits):
    need = (n_bits + 7) // 8
    raw = hashlib.shake_256(DOM_EXTRACTOR_SEED + seed).digest(need)
    bits = []
    for byte in raw:
        for k in range(7, -1, -1):
            bits.append((byte >> k) & 1)
            if len(bits) == n_bits:
                return bits
    return bits


def toeplitz_extract(source_bits, seed, out_len_bits):
    n = len(source_bits)
    m = out_len_bits
    if m <= 0:
        return b""
    if m > n:
        raise ValueError(f"cannot extract {m} bits from a {n}-bit source")
    t = _expand_seed_shake(seed, n + m - 1)
    out_bits = []
    for j in range(m):
        acc = 0
        base = j + n - 1
        for i in range(n):
            if source_bits[i]:
                acc ^= t[base - i]
        out_bits.append(acc)
    out = bytearray()
    for i in range(0, len(out_bits), 8):
        byte = 0
        for k in range(8):
            byte = (byte << 1) | (out_bits[i + k] if i + k < len(out_bits) else 0)
        out.append(byte)
    return bytes(out)


def _verify_relay_pulse(d, prev_hash):
    """anu_qrng_relay (and any future RELAY_DEVICE_CLASSES member): checks
    signature/chain/version have already passed by the time this runs.
    Verifies the commitment binds the published raw source bytes to this
    chain position, and that R is the honest Toeplitz extract of those
    bytes -- NOT a Bell test, no settings/commitment-tree/challenge to check."""
    info = {"S_hat": 0.0, "p_value": None, "device_class": d.get("device_class", "?")}

    if not (d.get("n_pairs") == 0 and d.get("outcomes_b64", "") == ""):
        return False, "TRANSCRIPT_MALFORMED", info

    try:
        source_bytes = bytes.fromhex(d.get("R_raw") or "")
    except ValueError:
        return False, "TRANSCRIPT_MALFORMED", info
    if not source_bytes:
        return False, "TRANSCRIPT_MALFORMED", info

    prev_bytes = bytes.fromhex(prev_hash) if prev_hash else b""
    expected_commitment = tagged_hash(
        DOM_RELAY_COMMIT, prev_bytes, d["pulse_id"].to_bytes(8, "big"), source_bytes
    ).hex()
    if expected_commitment != d["commitment"]:
        return False, "ROOT_MISMATCH", info

    try:
        source_bits = _bytes_to_bits(source_bytes)
        ext_seed = extractor_seed(
            d["prev_hash"], d["pulse_id"], d.get("challenge", ""), d.get("challenge_source", "")
        )
        r_recomputed = toeplitz_extract(
            source_bits, ext_seed, d.get("output_len_bits", 0)
        ).hex()
    except Exception:
        return False, "R_MISMATCH", info
    if r_recomputed != d.get("R", ""):
        return False, "R_MISMATCH", info

    return True, "OK", info


# ─── Verification ────────────────────────────────────────────────────────
def verify_pulse(d, pk, prev_hash, eps_bell=EPS_BELL, enforce_grinding=True):
    """Returns (ok, reason_code, detail_dict)."""
    info = {"S_hat": None, "p_value": None, "device_class": d.get("device_class", "?")}

    payload = canonical_json({k: v for k, v in d.items() if k != "signature"})
    try:
        pk.verify(bytes.fromhex(d.get("signature", "")), payload)
    except Exception:
        return False, "SIG_INVALID", info

    if d["prev_hash"] != prev_hash:
        return False, "CHAIN_BROKEN", info
    if d.get("protocol_version") != PROTOCOL_VERSION:
        return False, "VERSION_MISMATCH", info

    if d.get("device_class") in RELAY_DEVICE_CLASSES:
        return _verify_relay_pulse(d, prev_hash)

    n = d["n_pairs"]
    try:
        oa, ob = unpack_outcomes(d["outcomes_b64"], n)
    except Exception:
        return False, "TRANSCRIPT_MALFORMED", info

    seed = settings_seed(d["prev_hash"], d["pulse_id"], n)
    if seed != d["settings_seed"]:
        return False, "SETTINGS_MISMATCH", info
    ba, bb = derive_bases(seed, n)

    leaves = [leaf_data(i, ba[i], bb[i], oa[i], ob[i]) for i in range(n)]
    if merkle_root(leaves).hex() != d["commitment"]:
        return False, "ROOT_MISMATCH", info

    if d.get("challenge_source") == "fiat-shamir":
        if derive_challenge(d["commitment"], d["prev_hash"], d["pulse_id"]) != d["challenge"]:
            return False, "CHALLENGE_MISMATCH", info

    t = derive_test_bits(d["challenge"], n)
    rev_idx = [i for i in range(n) if t[i] == 1]
    hid_idx = [i for i in range(n) if t[i] == 0]
    revealed = [{"i": i, "ba": ba[i], "bb": bb[i], "a": oa[i], "b": ob[i]} for i in rev_idx]

    if len(revealed) < 100:
        return False, "INSUFFICIENT_REVEALED", info
    counts = defaultdict(int)
    for r in revealed:
        counts[(r["ba"], r["bb"])] += 1
    if len(counts) < 4:
        return False, "INSUFFICIENT_REVEALED", info
    n_min_cell = min(counts.values())

    budget = d.get("grinding_budget_bits", 60)
    if enforce_grinding and d.get("challenge_source") == "fiat-shamir":
        if len(revealed) < min_revealed_for_grinding(log2_q_max=budget):
            return False, "GRINDING_BUDGET_UNMET", info

    S = chsh_S(revealed)
    info["S_hat"] = S
    allowance = tsirelson_allowance(n_min_cell)
    if abs(S) > TSIRELSON + allowance:
        return False, "PROVER_LYING_SUPERQUANTUM", info

    ns = no_signaling_violation(revealed)
    info["no_signaling_violation"] = ns
    if ns > no_signaling_allowance(n_min_cell):
        return False, "SIGNALING_DETECTED", info

    p = pbr_pvalue(revealed)
    info["p_value"] = p
    threshold = eps_bell / (2.0**budget) if d.get("challenge_source") == "fiat-shamir" else eps_bell
    if p > threshold:
        return False, "BELL_NOT_VIOLATED", info

    if r_raw(seed, [leaves[i] for i in hid_idx]) != d["R_raw"]:
        return False, "R_MISMATCH", info

    # Checks R_raw, the committed beacon material that binds the output to the
    # transcript; R is a deterministic post-processing of it.
    return True, "OK", info


def main(argv=None):
    ap = argparse.ArgumentParser(description="Standalone QBEACON v3 verifier")
    ap.add_argument("ledger")
    ap.add_argument("pubkey")
    ap.add_argument(
        "genesis",
        nargs="?",
        default=None,
        help="expected genesis prev_hash (default: the protocol genesis)",
    )
    args = ap.parse_args(argv)

    pk = serialization.load_pem_public_key(open(args.pubkey, "rb").read())
    prev = args.genesis or genesis_hash()

    n_ok = n_total = 0
    assumptions = set()
    device_classes = set()

    with open(args.ledger, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            d = json.loads(line)
            n_total += 1
            ok, code, info = verify_pulse(d, pk, prev)
            n_ok += ok
            assumptions.update(d.get("soundness_assumptions", []))
            device_classes.add(d.get("device_class", "?"))
            s = "n/a" if info["S_hat"] is None else f"{info['S_hat']:+.4f}"
            p = "n/a" if info["p_value"] is None else f"{info['p_value']:.2e}"
            print(
                f"pulse #{d['pulse_id']:4d}  S={s:>8}  p={p:>9}  {'OK  ' if ok else 'FAIL'}  {code}"
            )
            prev = pulse_hash(d)

    print(f"\n{n_ok}/{n_total} pulses verified")
    print(f"device classes: {', '.join(sorted(device_classes))}")
    print("\nVerified: every pulse is well-formed, signed and chained, and its")
    print("statistics are re-derived from the published outcomes.")
    print("Stated assumptions carried by this ledger:")
    for a in sorted(assumptions):
        print(f"  - {a}")
    return 0 if n_ok == n_total and n_total > 0 else 1


if __name__ == "__main__":
    sys.exit(main())
