#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyjwt", "requests", "cryptography"]
# ///
"""
create_bv_test_election.py — create BetterVoting test elections via the REST API
================================================================================

Creates the BV95a / BV95b "Majority Criterion" demo elections on bettervoting.com
and casts their ballots, using the API recipe from the Discord-bot integration
doc (POST /API/Elections, POST /API/Election/{id}/vote, GET /API/Election/{id}).
Then it saves each finished election object as JSON into ../../06_Other/_demo_dropbox/ so
it can be promoted into the BV95a/BV95b cases.

WHY A SCRIPT (and not the UI, or Claude): the BetterVoting builder UI is fiddly to
automate, and Claude's sandbox can't make external HTTP calls or hold your
credentials. You run this locally with your own identity; nothing secret is stored
in this file.

WHERE THE ELECTIONS LIVE: this file is the ENGINE (auth, build_payload, create,
cast, preflight). The election DEFINITIONS — every spec plus the helpers that expand
voter blocs into ballots — live in the sibling data module `bv_election_specs.py`,
which this file imports. To create something, set that module's `ELECTIONS` list to
the spec(s) you want and run this script (empty ELECTIONS = create nothing).

--------------------------------------------------------------------------------
SETUP  (uv-native — dependencies are declared inline above, PEP 723)
--------------------------------------------------------------------------------
    # Just run it with uv; it installs pyjwt/requests/cryptography automatically
    # into an ephemeral env — no pip, no .venv pollution:
    uv run STARVote_LH_tabulation_engine/tools_adam/create_bv_test_election.py

    # (Optional) override the defaults below via environment variables.

    # A throwaway identity. The doc's trick: sign the JWT with a secret that
    # equals the user id ("for now we'll just have it match"). This creates a
    # self-consistent custom_id_token the backend accepts — no real account
    # password/secret is used or stored.
    export BV_USER_ID="masiarek_mc_demo"          # any stable string you choose

    # A TEMPLATE election to copy the object shape from (safest per the doc,
    # which duplicates an existing election rather than hand-building the payload).
    # Use ANY simple STAR / single-winner election id you can GET publicly.
    # If copying breaks, try a different template id.
    export BV_TEMPLATE_ID="pet"

    python3 create_bv_test_election.py

--------------------------------------------------------------------------------
NOTES / CAVEATS (read these)
--------------------------------------------------------------------------------
* This was written from the API doc WITHOUT being able to test it — treat it as a
  strong first draft. It prints every response so you can see exactly where any
  step fails and adjust. The three endpoints and the vote payload come straight
  from the doc.
* Casting multiple ballots: each ballot uses a distinct `temp_id` cookie, so the
  backend doesn't reject them as "already voted."
* Results: STAR results are computed when the election is finalized/closed. These
  two elections have NO ties, so the outcome is deterministic (Ada / Bruno). If
  the saved JSON lacks a Results block, open the election in the UI once and close
  it, then re-run just the final GET (or use the printed URL to export normally).
* If POST /API/Elections rejects the copied payload, the most likely fix is the
  `settings`/voter-access fields — pick a template election that is already an
  unrestricted / unlimited-voting poll so those fields copy over correctly.
"""

import json
import os
import re
import sys
import textwrap
import time
import urllib.error
import urllib.request
import uuid
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed

try:
    import jwt          # PyJWT
    import requests
    from cryptography.hazmat.primitives import serialization
    from cryptography.hazmat.primitives.asymmetric import rsa
except ImportError:
    sys.exit("Missing deps. Run this script with:  uv run <this file>  "
             "(deps are declared inline via PEP 723).")

API = "https://bettervoting.com/API"
OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "06_Other/_demo_dropbox")

# Defaults so it runs from PyCharm's green button with NO env setup. Override via
# environment if you like. BV_USER_ID becomes the election's owner_id — set it to
# your REAL BetterVoting account id so script-made elections show up in /manage
# (the manage list is filtered by owner_id). Adam's account (Admin1) is the id
# below; it's not a secret (it's the owner_id in the frozen _bv_export.json files).
USER_ID = os.environ.get("BV_USER_ID", "ea09e7c7-b00d-427a-bef8-32ade437d49d")
TEMPLATE_ID = os.environ.get("BV_TEMPLATE_ID", "pet")

# Ballots are cast CONCURRENTLY (each is an independent voter, so order doesn't
# matter). A bounded pool keeps us fast without hammering the live server — the
# serial version waited ~1s per POST (100 ballots ≈ 2-4 min); at 8-wide that's
# ~15-30s. Bump BV_CONCURRENCY if you're impatient and the server tolerates it,
# but keep it modest — this is a shared public service.
CONCURRENCY = max(1, int(os.environ.get("BV_CONCURRENCY", "8")))

# The election title is MEANINGFUL and PUBLIC — these elections are listed on
# BetterVoting and their name shows on the results page, so it must read like a
# real election. NO "trash/delete/test" junk. We prepend only the BV Test ID
# (test_id, e.g. "BV2132") for traceability; the descriptive title carries the
# rest. Default prefix is empty; set BV_TITLE_PREFIX only if you deliberately want
# an extra tag. (API-created elections can't be deleted from the UI anyway, so a
# "delete me" tag was never actionable — and it looked terrible in public.)
TITLE_PREFIX = os.environ.get("BV_TITLE_PREFIX", "")

# The backend now requires ASYMMETRIC auth: the election's `auth_key` must be a
# PEM RS256 *public* key, and the identity token is signed with the matching
# *private* key. We mint a fresh keypair per run (self-consistent: the create
# request carries the public key AND a token the backend verifies against it).
_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
PRIVATE_PEM = _KEY.private_bytes(
    serialization.Encoding.PEM,
    serialization.PrivateFormat.PKCS8,
    serialization.NoEncryption()).decode()
PUBLIC_PEM = _KEY.public_key().public_bytes(
    serialization.Encoding.PEM,
    serialization.PublicFormat.SubjectPublicKeyInfo).decode()

ID_TOKEN = jwt.encode({"email": f"{USER_ID}@example.com", "sub": USER_ID},
                      PRIVATE_PEM, algorithm="RS256")
CREATE_COOKIES = {"custom_id_token": ID_TOKEN}


# Election DATA (the specs + the bloc->ballot helpers) lives in a sibling module,
# so this file stays a compact engine. Edit ELECTIONS *there* to choose what to
# create; each created election is recorded on BV + its export + BV_registry.md.
from bv_election_specs import ELECTIONS

def _race_specs(spec):
    """Normalize a spec to a LIST of race specs. Accepts either the multi-race
    `races: [...]` form or the flat single-race form (method/candidates/ballots)."""
    if spec.get("races"):
        return spec["races"]
    r = {"title": spec["title"], "method": spec.get("method", "STAR"),
         "num_winners": spec.get("num_winners", 1),
         "candidates": spec["candidates"], "ballots": spec["ballots"]}
    if "max_rankings" in spec:
        r["max_rankings"] = spec["max_rankings"]
    return [r]


def _pp(resp):
    """Print a response compactly."""
    body = resp.text
    if len(body) > 600:
        body = body[:600] + " …(truncated)"
    print(f"    -> HTTP {resp.status_code}  {body}")


def _verify_ballot_count(eid, expected):
    """Post-cast sanity check: GET the election's ballots and compare the count the
    server actually holds against how many we tried to cast. Non-fatal — if the
    endpoint 404s or returns an unexpected shape, we say so and move on (you can
    still confirm nTallyVotes from the UI export)."""
    url = f"{API}/Election/{eid}/ballots"
    try:
        g = requests.get(url, cookies=CREATE_COOKIES, timeout=30)
    except Exception as ex:
        print(f"  ballot-count check: GET {url} failed ({ex!r}) — skipped.")
        return
    if g.status_code != 200:
        print(f"  ballot-count check: HTTP {g.status_code} from /ballots — skipped "
              "(confirm via the UI export's nTallyVotes).")
        return
    try:
        data = g.json()
    except Exception:
        print("  ballot-count check: non-JSON response — skipped.")
        return
    got = None
    if isinstance(data, list):
        got = len(data)
    elif isinstance(data, dict):
        for k in ("ballots", "Ballots", "data"):
            if isinstance(data.get(k), list):
                got = len(data[k])
                break
        if got is None:
            got = data.get("count") or data.get("total")
    if got is None:
        print(f"  ballot-count check: unrecognized /ballots shape "
              f"({type(data).__name__}) — skipped.")
    elif got == expected:
        print(f"  ballot-count check: server holds {got}/{expected} ballots ✓")
    else:
        print(f"  ⚠ ballot-count check: server holds {got}, expected {expected} "
              "— investigate before freezing the export.")


def _cid(name):
    """Fresh unique candidate id. SPECIAL CASE: 'None of the Above' must use the
    fixed id 'c-nota' (NOTA_ID in star-vote-shared/utils/makeID) — the frontend
    recognizes NOTA by id, not name."""
    return 'c-nota' if name.strip().lower() == 'none of the above' else str(uuid.uuid4())


def build_payload(template, spec):
    """Copy the template election and rewrite its race(s) from the spec. Supports
    one race (flat spec) or several (spec['races']). Mirrors the doc's 'duplicate
    pet' recipe, cloning the template's race object once per requested race."""
    e = json.loads(json.dumps(template))          # deep copy
    elec = e.get("election") or e.get("Election") or e
    elec.pop("election_id", None)                 # let the backend assign a new id
    elec["owner_id"] = USER_ID
    elec["auth_key"] = PUBLIC_PEM                  # PEM RS256 public key (backend requires)
    # Title = "trash delete test — BV<nnn> — <title>". The BV<nnn> Test ID is put
    # INTO the title so it's actually stored on BV (visible in /manage and the
    # export), not just in our local `expected` note.
    tid = spec.get("test_id")
    title = TITLE_PREFIX + (f"{tid} — " if tid else "") + spec["title"]
    elec["title"] = title
    elec["description"] = spec["description"]
    tmpl_races = elec.get("races") or []
    if not tmpl_races:
        sys.exit("Template has no races[] — pick a different BV_TEMPLATE_ID.")
    base = tmpl_races[0]

    races_out = []
    for rs in _race_specs(spec):
        r = json.loads(json.dumps(base))           # a fresh clone of the template race
        r["title"] = _effective_race_title(spec, rs)
        r["voting_method"] = rs.get("method", "STAR")
        r["num_winners"] = rs.get("num_winners", 1)
        # Write-ins default ON so online (QR) voters can add a choice that's not on
        # the list. (The paper ballot's printed list is fixed; --write-ins there is a
        # separate, off-by-default thing.) Override per-race (rs) or per-election (spec).
        # SPECIAL: None OMITS the key from the race object entirely — the shape the
        # pre-flag-era elections have (ywckmg, kcf8vf), needed by the BV2203 probe
        # for the STV-crash bisection (crashing races carry enable_write_in: false;
        # every working STV race lacks the key).
        ewi = rs.get("enable_write_in", spec.get("enable_write_in", True))
        if ewi is None:
            r.pop("enable_write_in", None)
        else:
            r["enable_write_in"] = bool(ewi)
        # Ranked methods (IRV / STV / RankedRobin) validate ballots as 0..max_rankings
        # (rank, not score: 1 = top choice, 0 = unranked). Set the cap when given so
        # the copied STAR template doesn't reject rank values on submit.
        if "max_rankings" in rs:
            r["max_rankings"] = rs["max_rankings"]
        r["race_id"] = str(uuid.uuid4())           # fresh race id (don't reuse template's)
        # Fresh candidates, each with a UNIQUE id (backend rejects duplicate/empty ids).
        r["candidates"] = [{"candidate_id": _cid(n), "candidate_name": n}
                           for n in rs["candidates"]]
        races_out.append(r)
    elec["races"] = races_out
    # NOTE: owner_id makes the election appear in /manage, but it does NOT grant
    # UI admin access — BV's /admin page authorizes off a server-side role binding
    # that only the authenticated (Keycloak) create flow writes, not off the
    # election's owner_id/admin_ids. Setting admin_ids here was tested and IGNORED
    # (xb8r6v had admin_ids=[owner] and was still denied; the working manual
    # election had admin_ids=null). So API-created elections are public, listable,
    # and exportable, but not UI-administrable from your real login. Don't bother
    # setting admin_ids — it has no effect. (See the BV issue draft in git log.)
    return {"Election": elec}                      # API expects capital "Election"


def create(spec):
    print(f"\n=== {spec['title']} ===")
    print(f"  GET template /Election/{TEMPLATE_ID}")
    t = requests.get(f"{API}/Election/{TEMPLATE_ID}")
    _pp(t)
    template = json.loads(t.text)

    payload = build_payload(template, spec)
    print("  POST /Elections (create)")
    c = requests.post(f"{API}/Elections", json=payload, cookies=CREATE_COOKIES)
    _pp(c)
    created = json.loads(c.text)
    elec = created.get("election") or created.get("Election") or created
    if not isinstance(elec, dict) or "election_id" not in elec:
        raise RuntimeError(f"create failed (HTTP {c.status_code}): {c.text[:300]}")
    eid = elec["election_id"]
    print(f"  created election_id = {eid}   ({'https://bettervoting.com/' + eid})")

    # From HERE ON the election EXISTS and is permanent. Anything that fails below
    # (a bad race count, a ragged ballot row, a network drop mid-cast) must still
    # surface the id — otherwise the summary prints a bare [FAIL], the export is
    # never written to _demo_dropbox, and a real public election is orphaned with no
    # local record: invisible to the Test-ID collision pre-check, which reads those
    # exports. So every raise past this point is wrapped to carry the id up.
    try:
        return _finish_create(spec, eid)
    except PartialCreate:
        raise
    except Exception as ex:
        raise PartialCreate(eid, ex) from ex


class PartialCreate(RuntimeError):
    """The election WAS created but the run failed afterwards. Carries the id so the
    caller can print it — an orphan you don't know the id of is the worst outcome
    here, since API-created elections can't be renamed or deleted."""

    def __init__(self, eid, cause):
        super().__init__(f"election {eid} was created, then: {cause!r}")
        self.eid = eid
        self.cause = cause


def _finish_create(spec, eid):
    # Re-fetch to learn the assigned race_ids + candidate ids (per race).
    g = requests.get(f"{API}/Election/{eid}")
    full = json.loads(g.text)
    felec = full.get("election") or full.get("Election") or full
    rspecs = _race_specs(spec)
    races_fetched = felec.get("races", [])
    if len(races_fetched) != len(rspecs):
        raise RuntimeError(f"expected {len(rspecs)} race(s), server returned "
                           f"{len(races_fetched)}")
    # Align by order; one voter votes EVERY race, so ballot counts must match.
    race_info = []                   # (race_id, cand_names, name->cid, ballots)
    for rs, rf in zip(rspecs, races_fetched):
        n2c = {c["candidate_name"]: c["candidate_id"] for c in rf["candidates"]}
        race_info.append((rf["race_id"], rs["candidates"], n2c, rs["ballots"]))
    nb = len(race_info[0][3])
    if any(len(ri[3]) != nb for ri in race_info):
        raise RuntimeError("all races must have the same number of ballots "
                           "(one per voter): " + ", ".join(str(len(ri[3])) for ri in race_info))

    # Cast the ballots. Each is an independent voter (distinct temp_id cookie so
    # the backend doesn't reject as "already voted"), so we fire them CONCURRENTLY
    # through a bounded pool and report a compact per-bloc summary. A voter's
    # ballot carries one `votes` entry PER race.
    def _sig(idx):                   # per-voter signature across races (for bloc grouping)
        return tuple(tuple(ri[3][idx - 1]) for ri in race_info)

    def _cast_one(idx):
        votes = []
        for race_id, cnames, n2c, rballots in race_info:
            row = rballots[idx - 1]
            votes.append({"race_id": race_id,
                          "scores": [{"candidate_id": n2c[cnames[j]], "score": row[j]}
                                     for j in range(len(cnames))]})
        body = {"ballot": {
            "election_id": eid,
            "votes": votes,
            "date_submitted": int(time.time() * 1000),
            "status": "submitted",
        }}
        try:
            v = requests.post(f"{API}/Election/{eid}/vote", json=body,
                              cookies={"temp_id": f"{USER_ID}_voter{idx}"}, timeout=30)
            return (idx, v.status_code, v.status_code == 200, v.text[:200])
        except Exception as ex:
            return (idx, None, False, repr(ex))

    def _cast_all(idxs):
        results = []
        with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
            futs = [pool.submit(_cast_one, i) for i in idxs]
            for fut in as_completed(futs):
                results.append(fut.result())
        return results

    voters = list(range(1, nb + 1))
    print(f"  casting {nb} ballots × {len(race_info)} race(s) ({CONCURRENCY}-wide)…")
    results = _cast_all(voters)

    # One retry (gentle) for any that failed.
    failed = [i for i, _sc, ok, _b in results if not ok]
    if failed:
        print(f"  retrying {len(failed)} failed ballot(s) once…")
        results += _cast_all(failed)

    # A voter that failed then succeeded on retry counts as OK.
    ok_idx = {i for i, _sc, ok, _b in results if ok}
    last = {}
    for i, sc, ok, b in results:
        if i not in ok_idx or ok:
            last[i] = (sc, b)

    # Per-bloc summary keyed by the voter's cross-race signature.
    want_by_sig = Counter(_sig(i) for i in voters)
    ok_by_sig = Counter(_sig(i) for i in voters if i in ok_idx)
    # A blank score slot is None (JSON null), not 0 — so this key cannot just
    # negate. It sorts blanks last within a rung and leaves scored slots in
    # descending order. Crashed the BV2105-r2 mint (w3vvff, 2026-08-04) with
    # "bad operand type for unary -: 'NoneType'" AFTER the ballots were already
    # cast, which reads like a failed mint when nothing was actually wrong.
    for sig in sorted(want_by_sig,
                      key=lambda s: [(x is None, -x if x is not None else 0)
                                     for row in s for x in row]):
        label = " | ".join("[" + ",".join("-" if x is None else str(x) for x in row) + "]"
                           for row in sig)
        mark = "✓" if ok_by_sig[sig] == want_by_sig[sig] else "⚠"
        print(f"    {ok_by_sig[sig]:>3}/{want_by_sig[sig]} × {label}  {mark}")
    print(f"  cast {len(ok_idx)}/{nb} ballots OK.")
    still_bad = [i for i in voters if i not in ok_idx]
    if still_bad:
        print(f"  ⚠ {len(still_bad)} ballot(s) still failing after retry:")
        for i in still_bad[:10]:
            sc, b = last.get(i, (None, ""))
            print(f"      voter{i}  HTTP {sc}  {b}")

    # Server-side confirmation: does BV actually hold the ballots we think it does?
    _verify_ballot_count(eid, nb)

    # Save the finished object for promotion into the repo case.
    final = requests.get(f"{API}/Election/{eid}")
    os.makedirs(OUT_DIR, exist_ok=True)
    # Sanitize the title for use as a filename (titles may contain '/', ':' etc.,
    # e.g. "Chocolate/Vanilla" — an unsanitized '/' makes the write fail).
    safe_title = "".join(c if c not in '/\\:*?"<>|' else "-" for c in spec["title"]).strip()[:60]
    out = os.path.join(OUT_DIR, f"{safe_title}-{eid}.json")
    with open(out, "w") as fh:
        fh.write(final.text)
    print(f"  saved -> {out}")

    # Auto-freeze the FULL export (Election + Ballots + Results) alongside it.
    # The plain GET above is config-only; sibling fetch_bv_export.py assembles the
    # exact JSON the UI's "Download JSON" button gives, from three anonymous GETs
    # (Election, anonymizedBallots, ElectionResult) — no UI click needed anymore.
    try:
        sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
        from fetch_bv_export import fetch_export
        export, notes = fetch_export(eid)
        full = os.path.join(OUT_DIR, f"{safe_title}-{eid}_bv_export.json")
        with open(full, "w", encoding="utf-8") as fh:
            json.dump(export, fh, indent=2, ensure_ascii=False)
            fh.write("\n")
        print(f"  frozen full export -> {full}")
        print("    (copy into the case folder as <yaml stem>_bv_export.json)")
        for n in notes:
            print(f"    ⚠ {n}")
    except Exception as ex:
        print(f"  ⚠ full-export auto-freeze failed ({ex!r}) — run manually: "
              f"uv run fetch_bv_export.py {eid}")

    print(f"  expected: {spec.get('expected', '?')}  |  URL: "
          f"https://bettervoting.com/{eid}")
    return eid


def _effective_title(spec):
    """The exact title BV will store (what the pre-check must judge)."""
    tid = spec.get("test_id")
    return TITLE_PREFIX + (f"{tid} — " if tid else "") + spec.get("title", "")


def _effective_race_title(spec, rs):
    """The exact RACE title BV will store. Rule change (Adam, 2026-07-25): the
    BV<n> Test ID rides EVERY race title too, not just the election title — the
    /vote page leads with the race title in its big box, so a "clean" race title
    loses the cross-reference (BV2249 c73pfw is the example that prompted this).
    Elections up to BV2249 follow the old clean-race convention; don't re-mint
    them (titles are permanent). Prepended here so both build_payload and the
    pre-check share one source of truth."""
    tid = spec.get("test_id")
    base = rs.get("title") or spec.get("title", "")
    if tid and not base.startswith(str(tid)):
        base = f"{tid} — {base}"
    return base


def _existing_titles():
    """Titles of elections already created (read from the saved exports in OUT_DIR)
    → their election ids. Lets the pre-check warn before minting a *duplicate* public
    election — the API doesn't dedupe, so each re-run of a spec creates a brand-new
    permanent election (that's how the beer demo ended up with dr3h7f/m3p6v6/yt3232)."""
    seen: dict = {}
    if not os.path.isdir(OUT_DIR):
        return seen
    for fn in os.listdir(OUT_DIR):
        if not fn.endswith(".json"):
            continue
        try:
            d = json.load(open(os.path.join(OUT_DIR, fn), encoding="utf-8"))
        except Exception:
            continue
        e = d.get("election") or d.get("Election") or d
        title = (e.get("title") or "").strip()
        eid = e.get("election_id") or e.get("id")
        if title and eid:
            seen.setdefault(title, []).append(str(eid))
    return seen


def _minted_test_ids():
    """Every BV Test ID that has ALREADY been minted, mapped to its election id(s).

    Why this exists (2026-07-25): BV numbers were being chosen by reading the
    "next free number" line in BV_registry.md, which is regenerated from
    *committed* repo files. Concurrent sessions share this checkout, so a session
    that mints BV<n> and hasn't committed yet is invisible to that line — and the
    number gets handed out twice. It happened: BV2252 went to Goodberry's
    (6tthfv) while another session was building a case it believed was BV2252.

    The fix is to read the strongest evidence that needs no git operation at all.
    Every created election is saved to OUT_DIR by this script AT MINT TIME, and
    its stored title always begins "BV<n> — " (see _effective_title). So the
    dropbox is a near-real-time ledger of minted numbers, visible to every
    session on this filesystem the instant the election exists.

    Also folds in `bv_test_id:` from the repo's case YAMLs, which covers older
    elections whose export predates the dropbox convention.

    That YAML scan reads every line of the file, and must keep doing so. It used
    to stop at the `ballots:` block on the assumption that the registry fields
    come first — but house style puts them either side, and BV2264-BV2268 carry
    `bv_test_id:` *below* their ballots. Those five were also minted from a
    standalone driver that never wrote to the dropbox, so both evidence sources
    missed them at once and this function reported BV2264 — already live on
    j3hqvb — as the next free number (caught 2026-08-04, before the mint). A
    duplicate BV<n> cannot be undone, so the scan buys correctness with a few
    milliseconds and does not shortcut."""
    used: dict = {}
    for title, ids in _existing_titles().items():
        m = re.match(r"^\s*(BV\w+)\s*[—–-]", title)
        if m:
            used.setdefault(m.group(1), []).extend(ids)
    repo = os.path.join(os.path.dirname(__file__), "..", "..")
    for root, dirs, files in os.walk(repo):
        dirs[:] = [d for d in dirs if d not in
                   (".git", ".venv", "site", "node_modules", "__pycache__")]
        for fn in files:
            if not fn.endswith(".yaml"):
                continue
            try:
                with open(os.path.join(root, fn), encoding="utf-8") as fh:
                    for line in fh:
                        m = re.match(r"^\s*bv_test_id:\s*(\S+)", line)
                        if m:
                            used.setdefault(m.group(1).strip('"\''), []).append(fn)
                            break
            except Exception:
                continue
    return used


def _next_free_test_id(used):
    """Highest numeric BV<n> seen, plus one. Advisory only — the master sheet is
    authoritative for the sequence; this just avoids collisions with reality."""
    nums = [int(m.group(1)) for t in used
            if (m := re.fullmatch(r"BV(\d+)", str(t)))]
    return f"BV{max(nums) + 1}" if nums else None


def _preflight_test_id_collisions(elections, fatal=True):
    """HARD STOP if a spec reuses a Test ID that is already live on BetterVoting.

    A duplicate BV<n> is not recoverable: the number rides the permanent election
    title AND every permanent race title, and BV descriptions/titles cannot be
    edited or deleted through the API. Two elections sharing a number destroys the
    one property the Test ID exists for — that BV<n> names exactly one election
    and is findable from either side."""
    used = _minted_test_ids()
    clash = [(s.get("test_id"), s.get("title", "<untitled>"), used[s["test_id"]])
             for s in elections
             if s.get("test_id") and s["test_id"] in used]
    nxt = _next_free_test_id(used)
    if clash:
        print("\n⛔ PRE-CHECK — these Test IDs are ALREADY MINTED and cannot be reused:")
        for tid, ttl, where in clash:
            print(f"    • {tid} on “{ttl}”")
            print(f"        already used by: {', '.join(sorted(set(map(str, where)))[:4])}")
        if nxt:
            print(f"\n  Next free Test ID looks like: {nxt}")
        if fatal:
            sys.exit("Aborted — a BV<n> is permanent and must name exactly ONE election. "
                     "Renumber the spec (and its repo case files) and re-run.")
        print("  (dry run — reporting only, not aborting)")
        return
    if nxt:
        print(f"  test-id check: {len(used)} BV numbers already minted; next free ≈ {nxt}")


def _preflight_test_ids(elections):
    """PRE-CHECK (before any network call). Elections created via the API are
    PUBLIC and CANNOT be renamed, closed, or deleted afterward (only a BV admin
    with DB access can purge them), so the title has to be right the FIRST time.
    This gate:
      • requires a BV Test ID (`test_id`, e.g. 'BV2132') — embedded in the title
        for traceability; missing/malformed/duplicate IDs are flagged;
      • blocks JUNK/placeholder titles ('trash', 'delete', 'test', 'tbd', 'xxx',
        'asdf', 'todo', 'zzz', 'foo/bar') — that's how 'trash delete test —' went
        public on bwbc6d/mw9kpp and can't be undone.
    Any problem requires an explicit y/N confirmation. Skip non-interactively with
    BV_ALLOW_NO_TESTID=1 (missing id) / BV_ALLOW_JUNK_TITLE=1 (junk title)."""
    import re
    print("  reminder: BV titles are PERMANENT and PUBLIC — API elections can't be "
          "renamed or deleted. Make each title real.")

    # Junk/placeholder title guard (the thing that actually burned us). Whole-word
    # match so real titles aren't caught ("test" won't trip "contest"/"latest").
    JUNK = ("trash", "delete", "test", "tests", "junk", "tbd", "xxx", "asdf",
            "todo", "zzz", "foobar", "foo", "bar", "dummy", "placeholder")
    def _junk_hits(title):
        low = title.lower()
        return [w for w in JUNK if re.search(rf"\b{re.escape(w)}\b", low)]
    bad_titles = [(_effective_title(s), _junk_hits(_effective_title(s)))
                  for s in elections if _junk_hits(_effective_title(s))]
    if bad_titles:
        print("\n⚠ PRE-CHECK — these titles look like throwaway/junk names "
              "(they will be PUBLIC and permanent):")
        for t, hits in bad_titles:
            print(f"    • “{t}”   ← {', '.join(hits)}")
        if os.environ.get("BV_ALLOW_JUNK_TITLE") != "1":
            try:
                ans = input("  Publish these titles anyway, on purpose? [y/N] ").strip().lower()
            except EOFError:
                ans = ""
            if ans not in ("y", "yes"):
                sys.exit("Aborted. Give each election a real, meaningful title "
                         "(set BV_ALLOW_JUNK_TITLE=1 only if you truly mean it).")
        print("  Proceeding with those titles.\n")

    # Duplicate-title guard: re-running a spec mints a NEW public election every time
    # (the API doesn't dedupe) — that's how the beer demo got dr3h7f/m3p6v6/yt3232.
    # If a saved export already carries this exact title, warn and require an OK.
    existing = _existing_titles()
    dup = [(_effective_title(s), existing[_effective_title(s)])
           for s in elections if _effective_title(s) in existing]
    if dup:
        print("\n⚠ PRE-CHECK — these titles were ALREADY created (a saved export exists "
              "in _demo_dropbox); re-running mints ANOTHER permanent, undeletable duplicate:")
        for t, ids in dup:
            print(f"    • “{t}”   ← already exists as {', '.join(sorted(set(ids)))}")
        if os.environ.get("BV_ALLOW_DUP_TITLE") != "1":
            try:
                ans = input("  Create duplicate(s) anyway? [y/N] ").strip().lower()
            except EOFError:
                ans = ""
            if ans not in ("y", "yes"):
                sys.exit("Aborted — reuse the existing election (see the ids above), or "
                         "change the title. Set BV_ALLOW_DUP_TITLE=1 to force a duplicate.")
        print("  Proceeding — a duplicate will be created.\n")

    # Race-title Test-ID guard (Adam, 2026-07-25). The BV<n> must ride EVERY race
    # title (the /vote page leads with the race title, so a clean race title loses
    # the cross-reference — see BV2249 c73pfw). _effective_race_title() prepends it
    # automatically; this check proves the invariant on the exact titles about to
    # be stored (they're as permanent as the election title) and shows them for a
    # last eyeball. Hard stop on violation — no env-var override, since a miss can
    # only mean the auto-prepend was bypassed/broken.
    for s in elections:
        tid = s.get("test_id")
        if not tid:
            continue                      # handled by the missing-test_id gate below
        rtitles = [_effective_race_title(s, rs) for rs in _race_specs(s)]
        bare = [t for t in rtitles if not t.startswith(str(tid))]
        if bare:
            print(f"\n⚠ PRE-CHECK — race title(s) in “{_effective_title(s)}” missing "
                  f"the {tid} prefix (race titles are PERMANENT too):")
            for t in bare:
                print(f"    • “{t}”")
            sys.exit("Aborted — every race title must carry the election's BV<n> "
                     "prefix (auto-prepended by _effective_race_title; this should "
                     "be unreachable unless that path was bypassed).")
        print(f"  race titles for {tid}:")
        for t in rtitles:
            print(f"    • {t}")

    missing = [s.get("title", "<untitled>") for s in elections if not s.get("test_id")]
    odd = [(s.get("test_id"), s.get("title", "<untitled>"))
           for s in elections
           if s.get("test_id") and not re.match(r"^BV\w+$", str(s["test_id"]))]
    dupes = [t for t, c in Counter(s.get("test_id") for s in elections
                                   if s.get("test_id")).items() if c > 1]
    for tid, ttl in odd:
        print(f"  note: test_id {tid!r} on “{ttl}” doesn’t look like 'BV<n>'.")
    if dupes:
        print(f"  ⚠ duplicate test_id(s) across this run: {', '.join(map(str, dupes))}")
    if not missing:
        return
    print("\n⚠ PRE-CHECK — these election(s) have NO BV Test ID (test_id, e.g. 'BV2132'):")
    for t in missing:
        print(f"    • {t}")
    print("  The Test ID is embedded in the BV title for traceability in /manage.")
    if os.environ.get("BV_ALLOW_NO_TESTID") == "1":
        print("  BV_ALLOW_NO_TESTID=1 set — proceeding un-numbered ON PURPOSE.\n")
        return
    try:
        ans = input("  Create these WITHOUT a BV number, on purpose? [y/N] ").strip().lower()
    except EOFError:
        ans = ""
    if ans not in ("y", "yes"):
        sys.exit("Aborted. Add a `test_id` (e.g. \"BV2132\") to each election above, "
                 "or set BV_ALLOW_NO_TESTID=1 to proceed intentionally.")
    print("  Confirmed — proceeding without a BV number.\n")


def dry_run(spec):
    """TEST MODE — show exactly what would be sent to BetterVoting, and check the
    things that are UNFIXABLE once sent. Nothing is created; no POST is made.

    Everything about a BV election is permanent: the title, the race titles, the
    description (and therefore the backlink URL inside it — BV2249 shipped a 404
    that can never be corrected), and the candidate list. This prints all of them,
    resolves the description's "[Full lesson & tabulation](…)" URL, and reports the
    ballot count, so the irreversible step is the SECOND thing you do, not the
    first. (An offline check existed early on and was removed in the 2026-07
    simplification; restored deliberately — a dry run costs nothing and the
    mistakes it catches cost a permanent public artifact.)"""
    print(f"\n=== [DRY RUN] {spec['title']} ===")
    print(f"  election title  : {_effective_title(spec)}")
    races = _race_specs(spec)
    nb = {len(rs["ballots"]) for rs in races}
    for i, rs in enumerate(races):
        print(f"  race[{i}] title  : {_effective_race_title(spec, rs)}")
        print(f"          method  : {rs.get('method', 'STAR')}  "
              f"seats={rs.get('num_winners', 1)}"
              + (f"  max_rankings={rs['max_rankings']}" if "max_rankings" in rs else ""))
        print(f"          cands   : {len(rs['candidates'])} — "
              + ", ".join(rs["candidates"]))
        print(f"          ballots : {len(rs['ballots'])}")
        # Write-ins resolve PER RACE in build_payload (race → election → default
        # True), and None means "omit the key entirely". Report the same three-state
        # resolution here — an election-level summary would sign off on a setting
        # that isn't the one being sent, and race objects can never be edited.
        ewi = rs.get("enable_write_in", spec.get("enable_write_in", True))
        print("          write-ins: " + ("key omitted (BV default)" if ewi is None
                                         else "ON" if ewi else "off"))
        # A ragged ballot row is only caught by the SERVER, i.e. after the election
        # is already permanent. Catch it here, while it's still free.
        ragged = [j for j, row in enumerate(rs["ballots"])
                  if len(row) != len(rs["candidates"])]
        if ragged:
            print(f"          ⚠ {len(ragged)} ballot row(s) don't match the candidate "
                  f"count ({len(rs['candidates'])}): rows {ragged[:8]}"
                  + ("…" if len(ragged) > 8 else "")
                  + " — FIX BEFORE CREATING; the election would be minted and only "
                    "then fail to accept its ballots.")
    if len(nb) > 1:
        print(f"  ⚠ races disagree on ballot count {sorted(nb)} — the create will fail "
              f"(every voter votes every race).")
    if nb == {0}:
        print("  note: zero ballots — minted empty, for a poll that collects real votes.")

    desc = spec.get("description", "")
    print(f"  description     : {len(desc)} chars")
    for line in textwrap.wrap(desc, 88)[:4]:
        print(f"      {line}")
    if len(textwrap.wrap(desc, 88)) > 4:
        print("      …")
    # The two permanent mistakes a dry run can actually catch: a backlink that 404s,
    # and a backlink that never becomes a link at all.
    #
    # House form is a MARKDOWN link. BetterVoting renders election and race
    # descriptions through formatMarkdown() (packages/shared/src/utils/formatMarkdown.ts),
    # whose only link rule is /\[([^\]]*?)\]\(([^)]*?)\)/ — it linkifies [text](url) and
    # NOTHING else. There is no bare-URL autolinker, so a description ending
    # "Full lesson & tabulation: https://…" ships as plain, unclickable grey text that
    # the reader has to copy-paste, permanently. 65 of the repo's frozen exports are in
    # that boat and cannot be fixed. The bare form is still ACCEPTED here so an older
    # spec keeps its 404 check, but it warns.
    md = re.search(r"\[Full lesson & tabulation\]\((https?://[^)\s]+)\)", desc)
    bare = re.search(r"Full lesson & tabulation:\s*(https?://\S+)", desc)
    m = md or bare
    if not m:
        print("  ⚠ description has no '[Full lesson & tabulation](<url>)' backlink — house "
              "rule; BV descriptions can NEVER be edited, so add it before creating.")
    else:
        url = m.group(1).rstrip(").,")
        try:
            with urllib.request.urlopen(url, timeout=10) as r:
                code = r.status
        except urllib.error.HTTPError as e:
            code = e.code
        except Exception as e:
            code = f"unreachable ({e.__class__.__name__})"
        good = code == 200
        print(f"  backlink        : {'✓' if good else '⚠'} HTTP {code}  {url}")
        if not md:
            print("      ⚠ that backlink is a BARE URL, so BetterVoting will render it as "
                  "plain text — it linkifies [text](url) and nothing else. Rewrite it as "
                  "[Full lesson & tabulation](<url>) BEFORE minting; descriptions are "
                  "permanent, so an unclickable backlink stays unclickable.")
        if not good:
            print("      ^ fix this BEFORE creating — the description is permanent and "
                  "the API offers no edit path (this is exactly how BV2249 got a "
                  "forever-404). A folder README publishes as .../index.html, not "
                  ".../README.html.")
    print(f"  expected        : {spec.get('expected', '?')}")
    print("  --> nothing created (dry run). Re-run without --dry-run to mint.")
    print("      Want to see the paper ballot first too? "
          f"bv_ballot_sheet.py --spec <SPEC_NAME> --copies 1")


KNOWN_FLAGS = ("--dry-run",)


def _parse_argv():
    """Return (dry, names) — and HARD-STOP on any flag we don't recognize.

    This function is a SAFETY GATE, not convenience parsing. The script's live path
    POSTs permanent, public, undeletable elections, and the dry run is the only
    thing standing in front of it. A membership test like `"--dry-run" in sys.argv`
    treats every near-miss spelling — `--dry-run=SPEC`, `--dryrun`, `--dry_run`,
    `-n`, even `--dry-run-only` — as "no flag given", i.e. as MINT. Someone typing a
    cautious-sounding flag must never get the irreversible path; so an unrecognized
    dash-token exits non-zero, and `--dry-run=NAME` is accepted as the equivalent
    form it obviously is. (Found by an adversarial review, 2026-07-25, which
    reproduced five spellings that each reached POST /API/Elections.)"""
    dry = os.environ.get("BV_DRY_RUN") == "1"
    names, unknown = [], []
    for a in sys.argv[1:]:
        if not a.startswith("-"):
            names.append(a)
        elif a == "--dry-run":
            dry = True
        elif a.startswith("--dry-run="):
            dry = True
            names += [p for p in a.split("=", 1)[1].split(",") if p]
        else:
            unknown.append(a)
    if unknown:
        sys.exit(f"unknown option(s): {', '.join(unknown)}\n"
                 f"This tool creates PERMANENT public elections, so an unrecognized "
                 f"flag is refused rather than ignored — a misspelled --dry-run must "
                 f"never fall through to the create path.\n"
                 f"Known flags: {', '.join(KNOWN_FLAGS)}  (plus spec NAMEs to inspect)")
    if names and not dry:
        sys.exit(f"positional spec name(s) given without --dry-run: {', '.join(names)}\n"
                 f"Naming a spec only selects what to INSPECT. To create, point "
                 f"ELECTIONS at the spec in bv_election_specs.py and run with no "
                 f"arguments.")
    return dry, names


def _dry_run_targets(names=()):
    """What --dry-run should inspect: the names given on the command line, else
    ELECTIONS. `ELECTIONS` is normally EMPTY (its resting state — you point it at a
    spec only for the run that mints it), so `--dry-run NAME [NAME…]` inspects a
    spec straight from bv_election_specs.py without touching that list. With no
    names and an empty ELECTIONS, list what's available instead of doing nothing."""
    from bv_election_specs import spec_names
    catalog = spec_names()
    if names:
        picked, missing = [], []
        for n in names:
            (picked.append(catalog[n]) if n in catalog else missing.append(n))
        if missing:
            print(f"\n⚠ no such spec in bv_election_specs.py: {', '.join(missing)}")
            _print_catalog(catalog)
            sys.exit(1)
        return picked
    if ELECTIONS:
        return list(ELECTIONS)
    print("\nELECTIONS is empty — nothing is queued to create (that's the normal "
          "resting state).\nInspect any spec by name:  --dry-run <SPEC_NAME> "
          "[<SPEC_NAME>…]")
    _print_catalog(catalog)
    sys.exit(0)


def _print_catalog(catalog):
    """List the specs defined in the data module, newest (highest BV<n>) first."""
    if not catalog:
        print("  (bv_election_specs.py defines no election specs.)")
        return
    def _key(item):
        tid = str(item[1].get("test_id") or "")
        m = re.search(r"\d+", tid)
        return (-int(m.group()) if m else 0, item[0])
    print(f"\n  {len(catalog)} spec(s) defined in bv_election_specs.py:")
    for name, spec in sorted(catalog.items(), key=_key):
        tid = spec.get("test_id") or "—"
        nraces = len(spec.get("races") or [1])
        print(f"    {name:<24} {tid:<8} {nraces} race(s)  {spec.get('title', '')[:56]}")


if __name__ == "__main__":
    DRY, DRY_NAMES = _parse_argv()   # exits on an unknown flag — never falls through
    print(f"BetterVoting API @ {API}")
    print(f"identity BV_USER_ID={USER_ID}  template={TEMPLATE_ID}")
    if DRY:
        targets = _dry_run_targets(DRY_NAMES)   # named spec(s), else ELECTIONS
        print(f"DRY RUN — inspecting {len(targets)} election(s); nothing will be "
              f"created.\n")
        # The pre-check's gates exist to stop an irreversible POST. In a dry run
        # there is no POST, so report and keep going instead of prompting/aborting —
        # you still SEE every warning, which is the point of looking first.
        os.environ.setdefault("BV_ALLOW_DUP_TITLE", "1")
        os.environ.setdefault("BV_ALLOW_NO_TESTID", "1")
        os.environ.setdefault("BV_ALLOW_JUNK_TITLE", "1")
        _preflight_test_id_collisions(targets, fatal=False)
        _preflight_test_ids(targets)
        for spec in targets:
            dry_run(spec)
        print("\n" + "=" * 72)
        print(f"DRY RUN complete — {len(targets)} election(s) inspected, 0 created.")
        print("=" * 72)
        sys.exit(0)
    print(f"Creating {len(ELECTIONS)} election(s)...\n")

    _preflight_test_id_collisions(ELECTIONS)  # gate: BV<n> must not already exist
    _preflight_test_ids(ELECTIONS)          # gate: confirm any missing BV Test IDs

    summary = []  # (title, eid_or_None, expected, partial?)
    for spec in ELECTIONS:
        try:
            eid = create(spec)
            summary.append((spec["title"], eid, spec.get("expected", "?"), False))
        except PartialCreate as ex:        # the election EXISTS — never hide its id
            print(f"  !! failed AFTER the election was created: {ex.cause!r}")
            print(f"  !! https://bettervoting.com/{ex.eid} is LIVE and permanent — "
                  f"freeze it:  uv run fetch_bv_export.py {ex.eid}")
            summary.append((spec["title"], ex.eid, spec.get("expected", "?"), True))
        except Exception as ex:            # keep going to the next election
            print(f"  !! failed: {ex!r}")
            summary.append((spec["title"], None, spec.get("expected", "?"), False))

    created = [s for s in summary if s[1]]
    partial = [s for s in summary if s[3]]
    print("\n" + "=" * 72)
    print(f"SUMMARY — {len(created)} of {len(ELECTIONS)} election(s) created"
          + (f" ({len(partial)} INCOMPLETE)" if partial else ""))
    print("=" * 72)
    for title, eid, expected, is_partial in summary:
        if eid and not is_partial:
            print(f"  [OK]   {title}")
            print(f"           vote:     https://bettervoting.com/{eid}")
            print(f"           results:  https://bettervoting.com/{eid}/results")
            print(f"           expected: {expected}")
        elif eid:
            print(f"  [PARTIAL] {title}")
            print(f"           ⚠ the election WAS created and cannot be deleted; its "
                  f"ballots may be missing or incomplete.")
            print(f"           vote:     https://bettervoting.com/{eid}")
            print(f"           freeze:   uv run fetch_bv_export.py {eid}")
        else:
            print(f"  [FAIL] {title}   (nothing was created)")
    print("=" * 72)
    if partial:
        print("\n⚠ Re-running will mint a DUPLICATE — the id(s) above already exist. "
              "Cast any missing ballots against the existing election instead.")
    print("\nDone. If a JSON lacks Results, close the election in the UI once, "
          "then re-run the final GET (or export from the URL above).")
