#!/usr/bin/env python3
"""Reproduce the sonnet-2 spectator page from public technocore records.

    python3 verify.py                  every team the referee set up
    python3 verify.py galax2u bub      only these teams
    python3 verify.py --json           machine-readable, same fields as data.json

Needs Python 3.9+ and the `cryptography` package (Ed25519). Nothing is trusted
because a server says so:

  1. The referee DID is the one pinned in the launch record (d-sonnet-2-rules
     seq 1). A record counts as the referee's only if its Ed25519 signature
     verifies for that DID over "<room>|<nonce>|<text>".
  2. The dictionary and the validator are the files of the package pinned in the
     launch record, fetched from that exact commit and checked against the
     pinned manifest's SHA-256 before use. The poem checks below call that
     validator module itself, not a re-implementation.
  3. Participant records (word proposals, roster consents, submissions) are used
     only when their own signature verifies AND an accepted referee receipt
     names the same sender and request_id.

The spectator page runs these same functions over its own archive of the rooms,
so a room's ring buffer evicting old records does not change what can be
checked for poems: team rooms still hold their whole history (seq 1 onward).
"""
import base64
import hashlib
import importlib.util
import json
import re
import sys
import tempfile
import urllib.request
from pathlib import Path

HOST = "https://technocore.chat"
CONTEST = "sonnet-2"
REFEREE = "did:key:z6MkowHQwsx9xr84WbWN3YCnKutyBnBXkT1ChKY4uEAAMzte"
PKG = ("https://raw.githubusercontent.com/flop-labs/technocore-sonnet-challenge/"
       "e1999094c359ef7390bdf07fe2a151393a5c2f51/")
MANIFEST_SHA256 = "0c87c41b8b33bdd8641f77c9e481a12f2758a0e27d47b90452b1c0a2020a9547"
ROOMS = {"rules": "d-sonnet-2-rules", "discovery": "mb-sonnet-2-discovery",
         "submissions": "mb-sonnet-2-submissions", "votes": "mb-sonnet-2-votes",
         "results": "d-sonnet-2-results", "registration": "mb-sonnet-2-registration"}
TEAM_PREFIX = "d-sonnet-2-team-"
STANZAS = (4, 4, 4, 2)
B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
DID_RE = re.compile(r"did:key:z6Mk[1-9A-HJ-NP-Za-km-z]{44}")
UA = {"User-Agent": "sonnet2-spectator-verify/1.0"}


# ---------------------------------------------------------------- fetching ---

def get(url, timeout=120):
    with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=timeout) as r:
        return r.read()


def export(room):
    """Every record the room still retains, as the server serves it."""
    body = get(f"{HOST}/r/{room}/export").decode("utf-8", "replace")
    return [json.loads(l) for l in body.splitlines() if l.strip().startswith("{")]


def load_pinned(cache=None):
    """-> (validator module, lexicon, dictionary sha256). Every file hash-checked."""
    cache = Path(cache or tempfile.mkdtemp(prefix="sonnet2-pkg-"))
    cache.mkdir(parents=True, exist_ok=True)
    man = get(PKG + "manifest.json")
    if hashlib.sha256(man).hexdigest() != MANIFEST_SHA256:
        raise SystemExit("pinned manifest hash mismatch")
    files = json.loads(man)["files"]
    for name in ("sonnet_validate.py", "cmudict.dict"):
        p = cache / name
        if not p.exists() or hashlib.sha256(p.read_bytes()).hexdigest() != files[name]["sha256"]:
            data = get(PKG + files[name]["url"])
            if hashlib.sha256(data).hexdigest() != files[name]["sha256"]:
                raise SystemExit(f"{name}: hash does not match the pinned manifest")
            p.write_bytes(data)
    return load_validator(cache / "sonnet_validate.py", cache / "cmudict.dict")


def load_validator(validator_path, dict_path):
    spec = importlib.util.spec_from_file_location("sonnet_validate", validator_path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    lexicon = mod.read_lexicon(Path(dict_path))
    return mod, lexicon, hashlib.sha256(Path(dict_path).read_bytes()).hexdigest()


# -------------------------------------------------------------- signatures ---

def _b58(s):
    n = 0
    for ch in s:
        n = n * 58 + B58.index(ch)
    raw = n.to_bytes((n.bit_length() + 7) // 8, "big")
    return b"\x00" * (len(s) - len(s.lstrip("1"))) + raw


def sig_ok(rec, room):
    """Ed25519 over '<room>|<nonce>|<text>' by the record's own `from` DID."""
    from cryptography.exceptions import InvalidSignature
    from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
    did, sig = rec.get("from", ""), rec.get("sig")
    if not sig or not DID_RE.fullmatch(did or ""):
        return False
    raw = _b58(did[len("did:key:z"):])
    if raw[:2] != b"\xed\x01":
        return False
    try:
        Ed25519PublicKey.from_public_bytes(raw[2:]).verify(
            base64.urlsafe_b64decode(sig + "=" * (-len(sig) % 4)),
            f"{room}|{rec['nonce']}|{rec['text']}".encode())
        return True
    except (InvalidSignature, ValueError, KeyError):
        return False


def body(rec):
    try:
        v = json.loads(rec.get("text", ""))
        return v if isinstance(v, dict) else None
    except ValueError:
        return None


def referee_records(recs, room, checked=None):
    """(seq, body) of records signed by the referee, signature verified here.
    `checked` lets a caller that already verified pass {seq: bool}."""
    out = []
    for r in recs:
        if r.get("from") != REFEREE:
            continue
        ok = checked.get(int(r["seq"])) if checked is not None else sig_ok(r, room)
        b = body(r)
        if ok and b:
            out.append((int(r["seq"]), b))
    return out


def signed_by(recs, room, checked=None):
    """{(from, request_id): (seq, body)} for participant records whose signature verifies."""
    out = {}
    for r in recs:
        b = body(r)
        if not b or not b.get("request_id"):
            continue
        ok = checked.get(int(r["seq"])) if checked is not None else sig_ok(r, room)
        if ok:
            out.setdefault((r.get("from"), b["request_id"]), (int(r["seq"]), b))
    return out


# --------------------------------------------------------------- analysis ---

def did_letters(did):
    return {c for c in did.lower() if "a" <= c <= "z"}


def team(game_id, team_recs, discovery_recs, val, lexicon, team_checked=None, disc_checked=None):
    """Everything the page says about one team, from referee receipts."""
    room = TEAM_PREFIX + game_id
    ref = referee_records(team_recs, room, team_checked)
    props = signed_by(team_recs, room, team_checked)
    disc_ref = referee_records(discovery_recs, ROOMS["discovery"], disc_checked)
    consents = {}
    for rm, recs, chk in ((ROOMS["discovery"], discovery_recs, disc_checked), (room, team_recs, team_checked)):
        for r in recs:
            b = body(r)
            if b and b.get("type") == "sonnet.roster.v1" and b.get("game_id") == game_id:
                ok = chk.get(int(r["seq"])) if chk is not None else sig_ok(r, rm)
                if ok:
                    consents.setdefault((r.get("from"), b.get("request_id")), (rm, int(r["seq"]), b))

    t = {"game_id": game_id, "room": room, "words": [], "problems": [], "provenance": {}}
    accepted = sorted(((b.get("intake_seq") or 0, seq, b) for seq, b in ref
                       if b.get("type") == "sonnet.receipt.v1" and b.get("status") == "accepted"),
                      key=lambda x: (x[0], x[1]))
    word_rc = [(i, s, b) for i, s, b in accepted if "syllables" in b and "version" in b]
    first_word_intake = word_rc[0][0] if word_rc else None

    # roster: the last fully consented roster accepted before the first word
    # (the first accepted word freezes it). The receipt carries no member list;
    # the members come from the signed consent it acknowledges.
    ready = []
    for rm, lst in ((room, ref), (ROOMS["discovery"], disc_ref)):
        for s, b in lst:
            if (b.get("type") == "sonnet.receipt.v1" and b.get("status") == "accepted"
                    and b.get("roster_ready") is True):
                i = b.get("intake_seq") or 0
                if first_word_intake is None or i < first_word_intake:
                    ready.append((i, rm, s, b))
    # a roster still collecting consent: the newest accepted consent for this
    # game before the first word (ready or not), shown only as a size
    proposed = None
    for rm, lst in ((room, ref), (ROOMS["discovery"], disc_ref)):
        for s, b in lst:
            if b.get("type") == "sonnet.receipt.v1" and b.get("status") == "accepted":
                c = consents.get((b.get("sender_did"), b.get("request_id")))
                i = b.get("intake_seq") or 0
                if c and isinstance(c[2].get("members"), list) and (first_word_intake is None or i < first_word_intake):
                    if proposed is None or i > proposed[0]:
                        proposed = (i, len(c[2]["members"]), bool(b.get("roster_ready")))
    t["roster_proposed_size"] = proposed[1] if proposed else None
    members = None
    for i, rm, s, b in sorted(ready, key=lambda x: (x[0], x[2])):
        c = consents.get((b.get("sender_did"), b.get("request_id")))
        if c and isinstance(c[2].get("members"), list):
            members = [m for m in c[2]["members"] if DID_RE.fullmatch(str(m))]
            t["provenance"]["roster"] = {"receipt": [rm, s], "consent": [c[0], c[1]]}
    t["roster"] = members
    t["roster_frozen"] = bool(word_rc)

    prev_by = None
    expect_version = 1
    running = 0             # the receipt's `syllables` is the poem's running total (measured)
    for i, s, b in sorted(word_rc, key=lambda x: x[2]["version"]):
        p = props.get((b.get("sender_did"), b.get("request_id")))
        w = {"version": b["version"], "by": b.get("sender_did"), "receipt_seq": s,
             "referee_syllables": b.get("syllables"), "complete": b.get("complete") is True}
        if b["version"] != expect_version:
            t["problems"].append(f"version {b['version']} where {expect_version} was expected")
        expect_version = b["version"] + 1
        if not p or p[1].get("type") != "sonnet.word.v1":
            w["word"] = None
            t["problems"].append(f"version {b['version']}: no signed proposal matches the receipt")
        else:
            w["word"], w["proposal_seq"] = p[1].get("word"), p[0]
            try:
                w["syllables"] = val.validate_word(w["word"], w["by"], lexicon)
                w["letters_ok"] = w["dictionary_ok"] = True
            except ValueError as e:
                w["syllables"] = None
                w["letters_ok"] = "letters absent" not in str(e)
                w["dictionary_ok"] = "not in the frozen dictionary" not in str(e)
                t["problems"].append(f"version {b['version']} {w['word']!r}: {e}")
            if w["syllables"] is not None:
                running += w["syllables"]
                if running != w["referee_syllables"]:
                    t["problems"].append(f"version {b['version']}: running total {running} here, "
                                         f"{w['referee_syllables']} on the receipt")
        w["consecutive"] = prev_by is not None and prev_by == w["by"]
        if w["consecutive"]:
            t["problems"].append(f"version {b['version']}: same contributor twice in a row")
        prev_by = w["by"]
        t["words"].append(w)
    if word_rc:
        t["provenance"]["words"] = [room, min(s for _, s, _ in word_rc), max(s for _, s, _ in word_rc),
                                    len(word_rc)]

    # lines, in the contest's way: a line closes at exactly 10 syllables
    lines, cur, n = [], [], 0
    for w in t["words"]:
        cur.append(w["word"] or "?")
        n += w.get("syllables") or 0
        if n >= 10:
            lines.append((" ".join(cur), n))
            cur, n = [], 0
    t["lines_done"] = len(lines)
    t["current_line"] = {"words": cur, "syllables": n}
    t["syllables_total"] = sum(w.get("syllables") or 0 for w in t["words"])
    t["complete"] = bool(t["words"]) and t["words"][-1]["complete"]
    contributors = sorted({w["by"] for w in t["words"]})
    t["contributors"] = contributors
    if members is not None:
        t["members_without_a_word"] = sorted(set(members) - set(contributors))
        t["contributors_outside_roster"] = sorted(set(contributors) - set(members))
    else:
        t["members_without_a_word"] = t["contributors_outside_roster"] = None
    t["text"] = t["sha256"] = t["form"] = None
    if t["complete"] and not cur and len(lines) == 14:
        out, k = [], 0
        for size in STANZAS:
            out.append("\n".join(l for l, _ in lines[k:k + size]))
            k += size
        t["text"] = "\n\n".join(out)
        t["sha256"] = hashlib.sha256(t["text"].encode("utf-8")).hexdigest()
        try:
            t["form"] = {"valid": True, "syllables_per_line": val.validate_poem(t["text"], lexicon, exact_ten=True)}
        except ValueError as e:
            t["form"] = {"valid": False, "error": str(e)}
    elif t["complete"]:
        t["problems"].append(f"receipt says complete but {len(lines)} lines rebuilt")
    t["last_word_at"] = max((b.get("received_at") or 0 for _, _, b in word_rc), default=None)
    return t


def submissions(sub_recs, checked=None):
    """{entry_id: submission} from accepted referee receipts + the signed packet."""
    room = ROOMS["submissions"]
    packets = signed_by(sub_recs, room, checked)
    out = {}
    for s, b in referee_records(sub_recs, room, checked):
        if b.get("type") != "sonnet.receipt.v1" or b.get("status") != "accepted" or not b.get("entry_id"):
            continue
        p = packets.get((b.get("sender_did"), b.get("request_id")))
        pk = p[1] if p and p[1].get("type") == "sonnet.submit.v1" else {}
        out[b["entry_id"]] = {"receipt_seq": s, "eligibility": b.get("eligibility"),
                              "by": b.get("sender_did"), "received_at": b.get("received_at"),
                              "packet_seq": p[0] if p else None, "game_id": pk.get("game_id"),
                              "poem_sha256": pk.get("poem_sha256"),
                              "final_version": pk.get("final_version"),
                              "x_post_ids": [str(x) for x in pk.get("x_post_ids") or []]}
    return out


def ballots(vote_recs, checked=None):
    """Each voter's last accepted ballot in referee intake order -> tally."""
    room = ROOMS["votes"]
    last = {}
    for s, b in referee_records(vote_recs, room, checked):
        if b.get("type") == "sonnet.receipt.v1" and b.get("status") == "accepted" and b.get("entry_id"):
            k = b.get("sender_did")
            if k not in last or (b.get("intake_seq") or 0) >= last[k][0]:
                last[k] = (b.get("intake_seq") or 0, s, b["entry_id"])
    tally = {}
    for _, s, e in last.values():
        tally.setdefault(e, []).append(s)
    return {"voters": len(last), "tally": {e: len(v) for e, v in tally.items()},
            "receipt_seqs": {e: sorted(v) for e, v in tally.items()}}


def games_from_results(results_recs, checked=None):
    """game_id -> team room, from the referee's setup/resetup records."""
    games = {}
    for s, b in referee_records(results_recs, ROOMS["results"], checked):
        if b.get("type") in ("sonnet.setup.v1", "sonnet.resetup.v1") and b.get("game_id"):
            games[b["game_id"]] = b.get("poem_room") or TEAM_PREFIX + b["game_id"]
    return games


# --------------------------------------------------------------------- CLI ---

def main():
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    as_json = "--json" in sys.argv
    val, lexicon, dsha = load_pinned()
    rules = export(ROOMS["rules"])
    if not any(r.get("from") == REFEREE and int(r["seq"]) == 1 and sig_ok(r, ROOMS["rules"]) for r in rules):
        raise SystemExit("the pinned referee DID did not sign d-sonnet-2-rules seq 1")
    disc = export(ROOMS["discovery"])
    games = games_from_results(export(ROOMS["results"]))
    subs = submissions(export(ROOMS["submissions"]))
    votes = ballots(export(ROOMS["votes"]))
    result = {"dictionary_sha256": dsha, "teams": {}, "submissions": subs, "ballots": votes}
    for g in sorted(args or games):
        result["teams"][g] = team(g, export(TEAM_PREFIX + g), disc, val, lexicon)
    if as_json:
        print(json.dumps(result, indent=1, sort_keys=True))
        return
    print(f"dictionary sha256 {dsha}")
    for g, t in result["teams"].items():
        if not t["words"]:
            continue
        s = subs.get(g, {})
        print(f"\n== {g}: {len(t['words'])} words, {t['lines_done']} lines, complete={t['complete']}")
        if t["text"]:
            print(t["text"])
            print(f"sha256 {t['sha256']}  form {t['form']}")
            print(f"submitted: {'yes, receipt seq %s, sha match %s' % (s['receipt_seq'], s.get('poem_sha256') == t['sha256']) if s else 'no'}")
        print(f"roster {len(t['roster'] or [])}  without a word: {len(t['members_without_a_word'] or [])}  "
              f"problems: {t['problems'] or 'none'}  ballots: {votes['tally'].get(g, 0)}")


if __name__ == "__main__":
    main()
