#!/usr/bin/env python3
"""Sybil Hunt: reference generator, commitments, submission parser and scorer.

Pure Python 3.8+ standard library. Deterministic: the same (date, seed) always
gives byte-identical puzzle JSON on any platform (no `random` module, no floats
in outputs; all randomness comes from a SHA-256 counter stream).

All data is synthetic. No real PoA accounts appear in any puzzle.

CLI (run `python3 sybilhunt.py -h`):
  gen     DATE SEED_HEX            print the puzzle JSON for a date and seed
  answer  DATE SEED_HEX            print the answer JSON
  verify  PUZZLE.json ANSWER.json  check a revealed day: seed -> puzzle + answer + commitments
  commit  DATE IDS [SALT]          make your commit line and matching reveal line
  score   IDS ANSWER.json          F1 of an id list against a revealed answer
  digest  COMMITMENTS.json A B     digest of the pre-published commitments for days A..B
"""
import argparse, datetime as _dt, hashlib, hmac, json, re, secrets, sys

GAME = "sybil-hunt"
GEN_VERSION = "sybilhunt-gen-1"
ID_RE = re.compile(r"^a\d{2}$")
SALT_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# One strict line per comment. The first matching line in a comment is used.
COMMIT_LINE_RE = re.compile(r"^SYBILHUNT (\d{4}-\d{2}-\d{2}) commit=([0-9a-f]{64})$")
REVEAL_LINE_RE = re.compile(r"^SYBILHUNT (\d{4}-\d{2}-\d{2}) ids=(a\d{2}(?:,a\d{2})*) salt=([A-Za-z0-9_-]{8,64})$")
TELLS = ["timing", "funding", "wording", "grading", "invite", "stake"]
MIN = 60  # seconds per minute


# ---------------------------------------------------------------- hashing utils
def sha256_hex(s):
    if isinstance(s, str):
        s = s.encode("utf-8")
    return hashlib.sha256(s).hexdigest()


def canonical(obj):
    """Canonical JSON bytes: sorted keys, no spaces, UTF-8."""
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")


def derive_seed(master_key: bytes, date: str) -> str:
    """Operator-side: per-day secret seed = HMAC-SHA256(master, label). Hex string."""
    return hmac.new(master_key, ("sybil-hunt|v1|" + date).encode(), hashlib.sha256).hexdigest()


def seed_commit(seed_hex):
    return sha256_hex("SYBILHUNT-SEED|" + seed_hex)


def norm_ids(ids):
    if isinstance(ids, str):
        ids = [x.strip() for x in ids.split(",") if x.strip()]
    return sorted(set(i.lower() for i in ids))


def answer_commit(date, ids, seed_hex):
    return sha256_hex("SYBILHUNT-ANSWER|%s|%s|%s" % (date, ",".join(norm_ids(ids)), seed_hex))


def player_commit(date, ids, salt):
    """What a player posts on day D: sha256("SYBILHUNT|D|<sorted unique ids>|<salt>")."""
    return sha256_hex("SYBILHUNT|%s|%s|%s" % (date, ",".join(norm_ids(ids)), salt))


def f1(guess, truth):
    g, t = set(norm_ids(guess)), set(norm_ids(truth))
    if not g or not t:
        return 0.0
    tp = len(g & t)
    return 0.0 if tp == 0 else 2.0 * tp / (len(g) + len(t))


# ---------------------------------------------------------------- deterministic stream
class Stream:
    """SHA-256 counter-mode byte stream. Outputs do not reveal the seed."""

    def __init__(self, seed_hex: str, label: str):
        self.key = hashlib.sha256(("sybilhunt-stream|%s|%s" % (seed_hex, label)).encode()).digest()
        self.ctr = 0
        self.buf = b""

    def u32(self):
        if len(self.buf) < 4:
            self.buf += hashlib.sha256(self.key + self.ctr.to_bytes(8, "big")).digest()
            self.ctr += 1
        v, self.buf = int.from_bytes(self.buf[:4], "big"), self.buf[4:]
        return v

    def below(self, n):
        if n <= 0:
            raise ValueError("n must be > 0")
        limit = (1 << 32) - ((1 << 32) % n)
        while True:
            v = self.u32()
            if v < limit:
                return v % n

    def randint(self, a, b):
        return a + self.below(b - a + 1)

    def chance(self, num, den):
        return self.below(den) < num

    def choice(self, seq):
        return seq[self.below(len(seq))]

    def shuffle(self, lst):
        for i in range(len(lst) - 1, 0, -1):
            j = self.below(i + 1)
            lst[i], lst[j] = lst[j], lst[i]
        return lst

    def sample(self, seq, k):
        return self.shuffle(list(seq))[:k]

    def hexstr(self, n):
        return "".join("0123456789abcdef"[self.below(16)] for _ in range(n))


# ---------------------------------------------------------------- text banks
SYL1 = ["Ash", "Brin", "Cor", "Dun", "El", "Fen", "Gal", "Hol", "Ivo", "Jun", "Kel", "Lum", "Mor", "Nes",
        "Ost", "Pel", "Quin", "Ros", "Sel", "Tam", "Ul", "Ver", "Wix", "Yar", "Zel"]
SYL2 = ["moth", "wick", "fern", "gale", "ridge", "brook", "spark", "weir", "knoll", "loom", "quill",
        "vane", "drift", "cairn", "glint", "hollow", "reed", "thorn", "lark", "mere"]
SUFFIX = ["", "", "", "-AI", "_bot", "-agent", "7", "42", "-v2", "_x"]
OPENERS = ["Bond each new account", "Require a stake lock", "Weight every vote", "Rate-limit fresh accounts",
           "Cross-check the grader panel", "Sample answers at random", "Score answers on mechanism",
           "Delay rewards for new keys", "Pair each grader with a stranger", "Track invite depth"]
MECHS = ["with a refundable deposit", "by account age in epochs", "against a public reputation ledger",
         "through randomized jury seats", "using a slashing bond", "with proof of recurring work",
         "by graph distance from the asker", "with a commit-reveal ballot", "via a cooling-off window",
         "with per-operator quotas"]
GOALS = ["cheap clones stop paying off", "collusion costs more than it earns", "honest newcomers still get in",
         "copied answers lose value", "one operator cannot fill a panel", "farms show up in the data",
         "grading stays independent", "rewards follow real effort"]
DETAILS = ["audit a random tenth of verdicts", "publish the slashing log", "cap rewards per funding source",
           "expire stale invites", "flag near-duplicate wording", "rotate seats every epoch",
           "require a short reasoning note", "let appeals pause payouts", "compare submission timing",
           "decay weight for idle accounts", "reward dissent that proves right", "log every transfer edge"]
SYNONYMS = {"Bond": "Lock", "each": "every", "new": "fresh", "Require": "Demand", "stake": "bond",
            "Weight": "Scale", "random": "chance", "public": "open", "cheap": "low-cost", "stop": "cease",
            "costs": "runs", "earns": "pays", "still": "can", "lose": "drop", "cannot": "can't",
            "show": "turn", "stays": "remains", "follow": "track", "audit": "check", "publish": "post",
            "cap": "limit", "expire": "retire", "flag": "mark", "rotate": "shuffle", "short": "brief",
            "pause": "halt", "compare": "match", "decay": "lower", "log": "record", "with": "using",
            "through": "via", "window": "period", "quotas": "limits", "every": "each", "verdicts": "rulings"}
HONEST_SIGNOFFS = ["-- {h}", "/{h}", "cheers, {h}", "({h}, {e})", "{h} out", "signed {h}"]
SYBIL_SIGNOFFS = [":: relay {t} :: cycle {e}", "~ node {t} out ~", "[{t}] end of transmission",
                  "<<{t}|{e}>>", "== unit {t} reporting =="]


def handle(rs, used):
    while True:
        h = rs.choice(SYL1) + rs.choice(SYL2) + rs.choice(SUFFIX)
        if h not in used:
            used.add(h)
            return h


def compose(rs):
    return [rs.below(len(OPENERS)), rs.below(len(MECHS)), rs.below(len(GOALS)), rs.below(len(DETAILS))]


def render(parts):
    o, m, g, d = parts
    return "%s %s so %s; %s." % (OPENERS[o], MECHS[m], GOALS[g], DETAILS[d])


def mutate(rs, text, swaps):
    words = text.split(" ")
    idx = [i for i, w in enumerate(words) if w.strip(";.") in SYNONYMS]
    for i in rs.sample(idx, min(swaps, len(idx))):
        w = words[i]
        core = w.strip(";.")
        words[i] = w.replace(core, SYNONYMS[core])
    return " ".join(words)


# ---------------------------------------------------------------- generator
def _iso(base, minute):
    return (base + _dt.timedelta(minutes=minute)).strftime("%Y-%m-%dT%H:%MZ")


def generate(date: str, seed_hex: str):
    """Return (puzzle, answer) dicts for a UTC date and a hex seed."""
    if not DATE_RE.match(date):
        raise ValueError("date must be YYYY-MM-DD")
    seed_hex = seed_hex.lower()
    if not re.match(r"^[0-9a-f]{64}$", seed_hex):
        raise ValueError("seed must be 64 hex chars")
    rs = Stream(seed_hex, date)
    end = _dt.datetime.strptime(date, "%Y-%m-%d")
    start = end - _dt.timedelta(days=7)
    WORLD = 7 * 1440
    EP0 = 6 * 1440  # four 6h epochs fill the final day
    epochs = [{"epoch": "e%d" % (i + 1), "start": _iso(start, EP0 + 360 * i), "end": _iso(start, EP0 + 360 * (i + 1))}
              for i in range(4)]

    n = rs.randint(30, 60)
    k = rs.randint(4, min(9, n // 5))
    strong = sorted(rs.sample(TELLS, 3), key=TELLS.index)
    funding_mode = rs.choice(["hub", "external"])
    roles = ["sybil"] * k + ["honest"] * (n - k)
    rs.shuffle(roles)
    ids = ["a%02d" % (i + 1) for i in range(n)]
    sybils = [ids[i] for i in range(n) if roles[i] == "sybil"]
    honest = [ids[i] for i in range(n) if roles[i] == "honest"]
    anchor = sybils[0]
    quiet, quiet_drop = None, None
    if k >= 5:
        quiet = rs.choice(sybils[1:])
        quiet_drop = rs.choice(strong)
    tells = {s: set(strong) for s in sybils}
    if quiet:
        tells[quiet].discard(quiet_drop)

    def has(a, t):
        return a in tells and t in tells[a]

    used = set()
    acc = {a: {"id": a, "handle": handle(rs, used)} for a in ids}
    join = {}
    # honest joins: spread over the first six days
    for a in honest:
        join[a] = rs.randint(0, EP0 - 60)
    # decoy team: 3-4 honest accounts who join together and share a recruiter (but nothing else)
    rec = sorted(honest, key=lambda a: join[a])[:6]
    recruiters = rs.sample(rec, 2)
    pool = [a for a in honest if a not in recruiters]
    team = rs.sample(pool, rs.randint(3, 4))
    tbase = rs.randint(2 * 1440, EP0 - 300)
    for a in team:
        join[a] = tbase + rs.randint(0, 120)
    for r in recruiters:
        join[r] = min(join[r], tbase - 600)
    # sybil joins
    burst = rs.randint(2 * 1440, EP0 - 200)
    for a in sybils:
        join[a] = burst + rs.randint(0, 45) if has(a, "timing") else rs.randint(600, EP0 - 60)
    if has(anchor, "invite") or (has(anchor, "funding") and funding_mode == "hub"):
        others = [join[a] for a in sybils if a != anchor]
        join[anchor] = max(0, min(others) - rs.randint(90, 900))
    for a in ids:
        acc[a]["joined_at"] = _iso(start, join[a])

    def earlier(a, cands):
        return [c for c in cands if join[c] < join[a] and c != a]

    # invites
    for a in ids:
        inv = None
        if a in team:
            inv = recruiters[0]
        elif a in sybils and a != anchor and has(a, "invite"):
            inv = anchor
        else:
            if rs.chance(55, 100):
                e = earlier(a, ids)
                if e:
                    er = [r for r in recruiters if r in e]
                    inv = rs.choice(er) if er and rs.chance(1, 2) else rs.choice(e)
        acc[a]["invited_by"] = inv
    # stake
    sy_stake = rs.choice([500, 750, 1000, 1200, 1500, 2000])
    round_vals = [500, 1000, 2000]
    for a in ids:
        if has(a, "stake"):
            st = sy_stake + rs.randint(-2, 2)
        elif rs.chance(1, 8):
            st = rs.choice(round_vals)  # decoy: some honest accounts also stake round numbers
        else:
            st = rs.randint(80, 4000)
        acc[a]["stake"] = st
    # funding + transfers
    transfers = []
    ext_hub = "ext:" + rs.hexstr(6)
    for a in sorted(ids, key=lambda x: join[x]):
        if a in sybils and has(a, "funding") and a != anchor:
            if funding_mode == "hub" and has(anchor, "funding"):
                src = anchor
            else:
                src = ext_hub
        elif a in sybils and has(a, "funding") and funding_mode == "external":
            src = ext_hub
        else:
            r = rs.below(100)
            e = earlier(a, ids)
            if r < 40:
                src = "ext:faucet"
            elif r < 75 or not e:
                src = "ext:" + rs.hexstr(6)
            else:
                src = rs.choice(e)
        acc[a]["funded_by"] = src
        if not src.startswith("ext:"):
            amt = acc[a]["stake"] + rs.randint(0, 40)
            transfers.append({"from": src, "to": a, "amount": amt, "at_m": max(join[src], join[a] - rs.randint(1, 25))})
    # sweeps back to the hub
    for a in sybils:
        if a != anchor and has(a, "funding") and acc[a]["funded_by"] == anchor:
            t = rs.randint(EP0 - 600, WORLD - 30)
            transfers.append({"from": a, "to": anchor, "amount": rs.randint(20, 90), "at_m": t})
    # background honest transfers (one decoy pair trades often)
    pair = rs.sample(honest, 2)
    for _ in range(rs.randint(3, 5)):
        x, y = (pair if rs.chance(1, 2) else pair[::-1])
        transfers.append({"from": x, "to": y, "amount": rs.randint(10, 300),
                          "at_m": rs.randint(max(join[x], join[y]) + 10, WORLD - 10)})
    for _ in range(n // 3):
        x, y = rs.sample(ids, 2)
        lo = max(join[x], join[y]) + 10
        if lo < WORLD - 10:
            transfers.append({"from": x, "to": y, "amount": rs.randint(5, 400), "at_m": rs.randint(lo, WORLD - 10)})

    # answers
    quality = {a: rs.randint(2, 4) for a in honest}
    sy_epochs = sorted(rs.sample(range(4), rs.randint(3, 4)))
    sy_base = {e: compose(rs) for e in range(4)}
    sy_sync = {e: EP0 + 360 * e + rs.randint(20, 300) for e in range(4)}
    sy_form = rs.choice(SYBIL_SIGNOFFS)
    tag = {a: rs.hexstr(3) for a in sybils}
    signoff = {}
    for a in ids:
        signoff[a] = rs.choice(HONEST_SIGNOFFS) if rs.chance(35, 100) else None
    raw = []
    for a in ids:
        sync = has(a, "timing")
        for e in range(4):
            if sync:
                if e not in sy_epochs:
                    continue
                t = sy_sync[e] + rs.randint(0, 6)
            else:
                if not rs.chance(3, 4):
                    continue
                t = EP0 + 360 * e + rs.randint(5, 350)
            if has(a, "wording"):
                text = mutate(rs, render(sy_base[e]), rs.randint(1, 2)) + " " + sy_form.format(t=tag[a], e="e%d" % (e + 1))
            else:
                text = render(compose(rs))
                if signoff[a]:
                    text += " " + signoff[a].format(h=acc[a]["handle"], e="e%d" % (e + 1))
            raw.append({"account": a, "epoch": "e%d" % (e + 1), "t": t, "text": text})
    raw.sort(key=lambda r: (r["t"], r["account"]))
    # grades
    grades = []
    for r in raw:
        a, t = r["account"], r["t"]
        eligible = [g for g in ids if g != a and join[g] < t]
        colluders = []
        if has(a, "grading"):
            colluders = rs.sample([g for g in eligible if has(g, "grading")], 2) if \
                len([g for g in eligible if has(g, "grading")]) >= 2 else [g for g in eligible if has(g, "grading")]
        others = rs.sample([g for g in eligible if g not in colluders], 3 - len(colluders))
        for g in colluders:
            grades.append({"grader": g, "r": r, "score": 5, "t": min(WORLD - 1, t + rs.randint(2, 12))})
        for g in others:
            if has(g, "grading"):
                sc = 5 if a in sybils and has(a, "grading") else rs.randint(1, 2)
            else:
                q = 2 if a in sybils else quality[a]
                sc = max(1, min(5, q + rs.randint(-1, 1)))
            grades.append({"grader": g, "r": r, "score": sc, "t": min(WORLD - 1, t + rs.randint(20, 300))})

    # ------------------------------------------------ assemble public puzzle
    answers = []
    for i, r in enumerate(raw):
        r["aid"] = "x%03d" % (i + 1)
        answers.append({"id": r["aid"], "account": r["account"], "epoch": r["epoch"],
                        "submitted_at": _iso(start, r["t"]), "text": r["text"]})
    grades.sort(key=lambda g: (g["t"], g["r"]["aid"], g["grader"]))
    grades_out = [{"grader": g["grader"], "answer": g["r"]["aid"], "score": g["score"], "at": _iso(start, g["t"])}
                  for g in grades]
    transfers.sort(key=lambda x: (x["at_m"], x["from"], x["to"], x["amount"]))
    transfers_out = [{"from": x["from"], "to": x["to"], "amount": x["amount"], "at": _iso(start, x["at_m"])}
                     for x in transfers]
    accounts = [{"id": a, "handle": acc[a]["handle"], "joined_at": acc[a]["joined_at"],
                 "invited_by": acc[a]["invited_by"], "funded_by": acc[a]["funded_by"], "stake": acc[a]["stake"]}
                for a in ids]
    puzzle = {
        "game": GAME,
        "generator": GEN_VERSION,
        "date": date,
        "synthetic": True,
        "about": "Synthetic network. No real accounts. Find the one hidden sybil cluster.",
        "world": {"start": _iso(start, 0), "end": _iso(start, WORLD), "epochs": epochs},
        "cluster": {"count": 1, "size_min": 4, "size_max": 9},
        "seed_commit": seed_commit(seed_hex),
        "answer_commit": answer_commit(date, sybils, seed_hex),
        "accounts": accounts,
        "transfers": transfers_out,
        "answers": answers,
        "grades": grades_out,
    }
    answer = {
        "game": GAME,
        "generator": GEN_VERSION,
        "date": date,
        "seed": seed_hex,
        "sybils": sorted(sybils),
        "size": k,
        "tells": strong,
        "funding_mode": funding_mode if "funding" in strong else None,
        "anchor": anchor,
        "quiet_member": quiet,
        "quiet_missing_tell": quiet_drop,
        "decoys": {"team": sorted(team), "recruiters": sorted(recruiters), "trading_pair": sorted(pair),
                   "shared_faucet": "ext:faucet"},
        "answer_commit": puzzle["answer_commit"],
        "seed_commit": puzzle["seed_commit"],
        "puzzle_sha256": sha256_hex(canonical(puzzle)),
    }
    return puzzle, answer


def commitments_digest(days, first, last):
    """sha256 over the canonical JSON list of commitment rows with first <= date <= last."""
    rows = [d for d in days if first <= d["date"] <= last]
    rows.sort(key=lambda d: d["date"])
    return sha256_hex(canonical(rows)), len(rows)


def puzzle_sha256(puzzle):
    return sha256_hex(canonical(puzzle))


def verify_day(puzzle, answer, commitment=None):
    """Check a revealed day end to end. Returns list of (check, ok)."""
    date, seed = answer["date"], answer["seed"]
    p2, a2 = generate(date, seed)
    out = [
        ("seed matches seed_commit", seed_commit(seed) == puzzle["seed_commit"]),
        ("regenerated puzzle == published puzzle", canonical(p2) == canonical(puzzle)),
        ("regenerated answer ids == published ids", a2["sybils"] == answer["sybils"]),
        ("answer_commit matches ids+seed", answer_commit(date, answer["sybils"], seed) == puzzle["answer_commit"]),
    ]
    if commitment is not None:
        out.append(("pre-published puzzle_sha256 matches", commitment["puzzle_sha256"] == puzzle_sha256(puzzle)))
        out.append(("pre-published answer_commit matches", commitment["answer_commit"] == puzzle["answer_commit"]))
        out.append(("pre-published seed_commit matches", commitment["seed_commit"] == puzzle["seed_commit"]))
    return out


# ---------------------------------------------------------------- forum submissions
def _day(date):
    return _dt.datetime.strptime(date, "%Y-%m-%d")


def parse_ts(s):
    """Forum created_at is UTC 'YYYY-MM-DD HH:MM:SS'."""
    return _dt.datetime.strptime(s[:19].replace("T", " "), "%Y-%m-%d %H:%M:%S")


def first_line(body, rx):
    for line in (body or "").splitlines():
        m = rx.match(line.strip())
        if m:
            return m
    return None


def score_thread(comments, answers):
    """Score every player from forum comments.

    comments: list of dicts with id, body, created_at, author, author_public_id (forum API order).
    answers:  {date: [ids]} for every revealed day.
    Returns {"days": {date: [row,...]}, "players": [row,...]}.

    Rules:
      * Commit for day D counts only if posted D 00:00:00 to D 23:59:59 UTC.
      * One commit per player per day: the first valid one counts.
      * A commit hash already posted earlier by anyone is void (no copying).
      * Reveal for day D counts only if posted D+1 00:00:00 to D+2 23:59:59 UTC
        and sha256("SYBILHUNT|D|ids|salt") equals that player's counted commit.
      * Score = F1 of revealed ids vs answer. Committed but unrevealed = 0.
    """
    cs = sorted(comments, key=lambda c: (parse_ts(c["created_at"]), int(c["id"])))
    seen_hash = set()
    commits = {}  # (player, date) -> dict
    names = {}
    for c in cs:
        m = first_line(c.get("body"), COMMIT_LINE_RE)
        if not m:
            continue
        date, h = m.group(1), m.group(2)
        try:
            d0 = _day(date)
        except ValueError:
            continue
        ts = parse_ts(c["created_at"])
        if not (d0 <= ts < d0 + _dt.timedelta(days=1)):
            continue
        pid = str(c.get("author_public_id") or c.get("author"))
        names[pid] = c.get("author") or pid
        if h in seen_hash:
            continue
        seen_hash.add(h)
        if (pid, date) in commits:
            continue
        commits[(pid, date)] = {"hash": h, "comment": int(c["id"]), "at": c["created_at"]}
    reveals = {}
    for c in cs:
        m = first_line(c.get("body"), REVEAL_LINE_RE)
        if not m:
            continue
        date, ids, salt = m.group(1), m.group(2), m.group(3)
        pid = str(c.get("author_public_id") or c.get("author"))
        key = (pid, date)
        if key not in commits or key in reveals:
            continue
        d0 = _day(date)
        ts = parse_ts(c["created_at"])
        if not (d0 + _dt.timedelta(days=1) <= ts < d0 + _dt.timedelta(days=3)):
            continue
        if player_commit(date, ids, salt) != commits[key]["hash"]:
            continue
        reveals[key] = {"ids": norm_ids(ids), "comment": int(c["id"])}
    days, players = {}, {}
    for (pid, date), cm in sorted(commits.items(), key=lambda kv: (kv[0][1], kv[1]["comment"])):
        if date not in answers:
            continue
        rv = reveals.get((pid, date))
        s = round(f1(rv["ids"], answers[date]), 4) if rv else 0.0
        row = {"player": pid, "name": names.get(pid, pid), "date": date, "commit_comment": cm["comment"],
               "committed_at": cm["at"], "revealed": bool(rv), "ids": rv["ids"] if rv else None,
               "reveal_comment": rv["comment"] if rv else None, "f1": s}
        days.setdefault(date, []).append(row)
        p = players.setdefault(pid, {"player": pid, "name": names.get(pid, pid), "days": 0, "revealed": 0,
                                     "total_f1": 0.0, "perfect": 0})
        p["days"] += 1
        p["revealed"] += 1 if rv else 0
        p["total_f1"] = round(p["total_f1"] + s, 4)
        p["perfect"] += 1 if s == 1.0 else 0
    for d in days.values():
        d.sort(key=lambda r: (-r["f1"], r["commit_comment"]))
    plist = sorted(players.values(), key=lambda p: (-p["total_f1"], -p["perfect"], p["days"], p["name"]))
    for p in plist:
        p["mean_f1"] = round(p["total_f1"] / p["days"], 4) if p["days"] else 0.0
    return {"days": days, "players": plist}


# ---------------------------------------------------------------- CLI
def _load(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def main(argv=None):
    ap = argparse.ArgumentParser(description="Sybil Hunt reference tool")
    sp = ap.add_subparsers(dest="cmd", required=True)
    g = sp.add_parser("gen", help="print puzzle JSON for DATE SEED_HEX")
    g.add_argument("date"); g.add_argument("seed")
    a = sp.add_parser("answer", help="print answer JSON for DATE SEED_HEX")
    a.add_argument("date"); a.add_argument("seed")
    v = sp.add_parser("verify", help="verify a revealed day")
    v.add_argument("puzzle"); v.add_argument("answer"); v.add_argument("--commitments")
    c = sp.add_parser("commit", help="make your commit and reveal lines")
    c.add_argument("date"); c.add_argument("ids", help="comma-separated, e.g. a07,a13"); c.add_argument("salt", nargs="?")
    s = sp.add_parser("score", help="F1 of IDS against a revealed answer file")
    s.add_argument("ids"); s.add_argument("answer")
    dg = sp.add_parser("digest", help="digest of commitment rows FIRST..LAST (as announced on the forum)")
    dg.add_argument("commitments"); dg.add_argument("first"); dg.add_argument("last")
    args = ap.parse_args(argv)
    if args.cmd == "gen":
        print(json.dumps(generate(args.date, args.seed)[0], indent=1, ensure_ascii=False))
    elif args.cmd == "answer":
        print(json.dumps(generate(args.date, args.seed)[1], indent=1, ensure_ascii=False))
    elif args.cmd == "verify":
        pz, an = _load(args.puzzle), _load(args.answer)
        cm = None
        if args.commitments:
            cm = next((d for d in _load(args.commitments)["days"] if d["date"] == an["date"]), None)
        res = verify_day(pz, an, cm)
        for name, ok in res:
            print(("OK   " if ok else "FAIL ") + name)
        return 0 if all(ok for _, ok in res) else 1
    elif args.cmd == "commit":
        ids = norm_ids(args.ids)
        if not ids or not all(ID_RE.match(i) for i in ids):
            print("ids must look like a07,a13", file=sys.stderr); return 2
        salt = args.salt or secrets.token_urlsafe(12).replace("=", "")
        if not SALT_RE.match(salt):
            print("salt must be 8-64 chars of A-Z a-z 0-9 _ -", file=sys.stderr); return 2
        print("Day %s (post during that UTC day):" % args.date)
        print("SYBILHUNT %s commit=%s" % (args.date, player_commit(args.date, ids, salt)))
        print("Keep this private until the next UTC day, then post it:")
        print("SYBILHUNT %s ids=%s salt=%s" % (args.date, ",".join(ids), salt))
    elif args.cmd == "digest":
        h, nrows = commitments_digest(_load(args.commitments)["days"], args.first, args.last)
        print("%s  (%d days %s..%s)" % (h, nrows, args.first, args.last))
    elif args.cmd == "score":
        print("%.4f" % f1(args.ids, _load(args.answer)["sybils"]))
    return 0


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