#!/usr/bin/env python3
"""
zap_coverage.py — how much of your own zap history can relays actually see?

Queries several relays for kind-9735 zap receipts addressed to a pubkey and shows
what EACH relay serves versus the UNION of all of them. Public data only: no keys,
no wallet credentials, no signing. It cannot spend anything and never asks you for
anything secret.

WHY THIS EXISTS

I measured my own account against ground truth my wallet log provides — it knows
exactly which zaps actually settled — and found:

    actually received:            3 zaps
    best single relay served:     1
    UNION of 22 relays served:    1

Adding relays bought nothing. Two thirds of real zaps to me had no receipt anywhere
I could reach. That is n=3 on one account and one receiving server, so it is not a
constant and I would not quote 33% as one. But the gap is worth checking on your own
account, and the union figure is the half you can verify without any credentials.

Compare the UNION this prints against your own wallet's payment log. Only you can
see the second number; this gives you the first.

WHY THE GAP EXISTS AT ALL

A zap receipt is published by the RECIPIENT'S LNURL server, to relays named in the
`relays` tag of the zap request — a tag set by the SENDER'S client. So placement is
chosen by one party and executed by another, and neither is told whether it worked.
If the named relay is paid (e.g. nostr.wine, 18,888 sat admission) a server without
an account there cannot write at all.

USAGE
    python3 zap_coverage.py npub1...
    python3 zap_coverage.py <64-char-hex-pubkey>
    python3 zap_coverage.py npub1... --relays wss://a.com,wss://b.com
    python3 zap_coverage.py --selftest        # offline, no network

Python 3.7+, standard library only. Implements just enough RFC6455 to talk to a
relay, because the stdlib has no websocket client.
"""

import sys, os, ssl, json, socket, base64, struct, argparse, hashlib
from urllib.parse import urlparse

DEFAULT_RELAYS = [
    "wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net",
    "wss://nostr.mom", "wss://offchain.pub", "wss://relay.nostr.net",
    "wss://nostr.oxtr.dev", "wss://nostr.bitcoiner.social",
    "wss://purplerelay.com", "wss://relay.snort.social",
]

# ------------------------------------------------------------------ bech32 (npub)

CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"

def bech32_decode(s):
    s = s.strip()
    if s.lower() != s and s.upper() != s:
        return None, None
    s = s.lower()
    pos = s.rfind("1")
    if pos < 1 or pos + 7 > len(s):
        return None, None
    hrp, data = s[:pos], s[pos + 1:]
    if any(c not in CHARSET for c in data):
        return None, None
    vals = [CHARSET.index(c) for c in data]
    return hrp, vals[:-6]

def convertbits(data, frm, to):
    acc = bits = 0
    out = []
    maxv = (1 << to) - 1
    for b in data:
        acc = (acc << frm) | b
        bits += frm
        while bits >= to:
            bits -= to
            out.append((acc >> bits) & maxv)
    return out

def to_hex_pubkey(s):
    s = s.strip()
    if len(s) == 64 and all(c in "0123456789abcdefABCDEF" for c in s):
        return s.lower()
    hrp, vals = bech32_decode(s)
    if hrp != "npub" or not vals:
        return None
    b = convertbits(vals, 5, 8)
    return bytes(b[:32]).hex()

# ------------------------------------------------------------------ minimal websocket

def ws_connect(url, timeout=12):
    u = urlparse(url)
    host = u.hostname
    port = u.port or (443 if u.scheme == "wss" else 80)
    path = u.path or "/"
    raw = socket.create_connection((host, port), timeout=timeout)
    if u.scheme == "wss":
        ctx = ssl.create_default_context()
        raw = ctx.wrap_socket(raw, server_hostname=host)
    key = base64.b64encode(os.urandom(16)).decode()
    req = (f"GET {path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\n"
           f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
           f"Sec-WebSocket-Version: 13\r\n\r\n")
    raw.sendall(req.encode())
    buf = b""
    while b"\r\n\r\n" not in buf:
        chunk = raw.recv(4096)
        if not chunk:
            raise ConnectionError("handshake closed")
        buf += chunk
    if b" 101 " not in buf.split(b"\r\n")[0]:
        raise ConnectionError("no upgrade: " + buf.split(b"\r\n")[0].decode(errors="replace"))
    return raw

def ws_send(sock, text):
    payload = text.encode()
    hdr = bytearray([0x81])                      # FIN + text
    n = len(payload)
    if n < 126:
        hdr.append(0x80 | n)
    elif n < 65536:
        hdr.append(0x80 | 126); hdr += struct.pack(">H", n)
    else:
        hdr.append(0x80 | 127); hdr += struct.pack(">Q", n)
    mask = os.urandom(4)
    hdr += mask
    sock.sendall(bytes(hdr) + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))

def _recv_exactly(sock, n, buf):
    while len(buf) < n:
        chunk = sock.recv(65536)
        if not chunk:
            raise ConnectionError("closed")
        buf += chunk
    return buf

def ws_messages(sock, deadline):
    """Yield text messages until deadline. Handles fragmentation and ping."""
    import time
    buf = b""
    frag = b""
    while time.time() < deadline:
        sock.settimeout(max(0.5, deadline - time.time()))
        try:
            buf = _recv_exactly(sock, 2, buf)
        except (socket.timeout, ConnectionError, OSError):
            return
        b0, b1 = buf[0], buf[1]
        fin = b0 & 0x80
        op = b0 & 0x0F
        masked = b1 & 0x80
        ln = b1 & 0x7F
        off = 2
        if ln == 126:
            buf = _recv_exactly(sock, off + 2, buf); ln = struct.unpack(">H", buf[off:off+2])[0]; off += 2
        elif ln == 127:
            buf = _recv_exactly(sock, off + 8, buf); ln = struct.unpack(">Q", buf[off:off+8])[0]; off += 8
        if masked:
            buf = _recv_exactly(sock, off + 4, buf); off += 4
        buf = _recv_exactly(sock, off + ln, buf)
        payload = buf[off:off+ln]
        buf = buf[off+ln:]
        if op == 0x8:                    # close
            return
        if op == 0x9:                    # ping -> pong
            try: sock.sendall(b"\x8a\x80" + os.urandom(4))
            except Exception: return
            continue
        if op in (0x0, 0x1, 0x2):
            frag += payload
            if fin:
                try: yield frag.decode("utf-8", "replace")
                except Exception: pass
                frag = b""

# ------------------------------------------------------------------ core

def zapped_event(receipt):
    """The event a receipt is FOR, read from the signed request in `description`."""
    for t in receipt.get("tags", []):
        if t and t[0] == "description":
            try:
                req = json.loads(t[1])
                for rt in req.get("tags", []):
                    if rt and rt[0] == "e":
                        return rt[1]
            except Exception:
                return None
    return None

def query_relay(url, pubkey, limit=200, seconds=9):
    import time
    ids, evs = set(), []
    try:
        s = ws_connect(url)
    except Exception as e:
        return None, str(e)[:60]
    try:
        sub = base64.b16encode(os.urandom(4)).decode()
        ws_send(s, json.dumps(["REQ", sub, {"kinds": [9735], "#p": [pubkey], "limit": limit}]))
        deadline = time.time() + seconds
        for msg in ws_messages(s, deadline):
            try: d = json.loads(msg)
            except Exception: continue
            if not isinstance(d, list) or not d: continue
            if d[0] == "EVENT" and len(d) > 2:
                e = d[2]
                if e.get("id") not in ids:
                    ids.add(e.get("id")); evs.append(e)
            elif d[0] == "EOSE":
                break
    except Exception:
        pass
    finally:
        try: s.close()
        except Exception: pass
    return evs, None

def main():
    ap = argparse.ArgumentParser(description="Measure how much of a pubkey's zap history relays serve.")
    ap.add_argument("pubkey", nargs="?", help="npub1... or 64-char hex")
    ap.add_argument("--relays", help="comma-separated wss:// list (default: 10 common relays)")
    ap.add_argument("--limit", type=int, default=200)
    ap.add_argument("--selftest", action="store_true")
    a = ap.parse_args()

    if a.selftest:
        return selftest()
    if not a.pubkey:
        ap.print_help(); return 2

    pk = to_hex_pubkey(a.pubkey)
    if not pk:
        print("Could not parse that as an npub or hex pubkey.", file=sys.stderr); return 2

    relays = [r.strip() for r in a.relays.split(",")] if a.relays else DEFAULT_RELAYS
    print(f"pubkey {pk}")
    print(f"querying {len(relays)} relays for kind-9735 receipts...\n")

    union, per = {}, []
    for url in relays:
        evs, err = query_relay(url, pk, a.limit)
        if evs is None:
            per.append((url, None, 0)); print(f"  {'UNREACHABLE':>11}  {url}   ({err})"); continue
        targets = set()
        for e in evs:
            union[e.get("id")] = e
            z = zapped_event(e)
            targets.add(z or e.get("id"))
        per.append((url, len(evs), len(targets)))
        print(f"  {len(evs):>4} receipts  {url}")

    print()
    ok = [p for p in per if p[1] is not None]
    best = max((p[1] for p in ok), default=0)
    print(f"  relays reachable          : {len(ok)} of {len(relays)}")
    print(f"  BEST SINGLE RELAY served  : {best}")
    print(f"  UNION across all relays   : {len(union)}")
    if best and len(union) == best:
        print("\n  NOTE: the union equals the best single relay — querying more relays")
        print("        added nothing. That is what I saw on my own account too.")

    print("""
NOW THE HALF ONLY YOU CAN CHECK

Compare the UNION above against your own wallet's payment log — the count of zaps
that actually settled. The difference is your invisible fraction.

If the union matches your wallet, your coverage is fine and I would genuinely like
to hear it. If it is short, every zap statistic about you understates by at least
that much, and rankings that compare you to someone else are comparing audience app
choices as much as anything real.
""")
    return 0

def selftest():
    ok = True
    def chk(name, got, want):
        nonlocal ok
        good = got == want; ok = ok and good
        print("  %-46s %s" % (name, "PASS" if good else "FAIL got=%r want=%r" % (got, want)))

    print("known-answer tests (no network):")
    # NIP-19 test vector
    npub = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"
    hexk = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
    chk("npub decodes to known hex", to_hex_pubkey(npub), hexk)
    chk("hex passes through lowercased", to_hex_pubkey(hexk.upper()), hexk)
    chk("garbage rejected", to_hex_pubkey("not-a-key"), None)
    chk("nsec rejected (wrong hrp)", to_hex_pubkey("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"), None)

    receipt = {"id": "r1", "tags": [["description", json.dumps(
        {"kind": 9734, "tags": [["e", "abc123"], ["p", "def"]]})]]}
    chk("zapped event read from description", zapped_event(receipt), "abc123")
    chk("receipt with no description -> None", zapped_event({"tags": []}), None)
    chk("malformed description -> None", zapped_event({"tags": [["description", "{"]]}), None)
    print("\n%s" % ("ALL PASS" if ok else "FAILURES ABOVE"))
    return 0 if ok else 1

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