"""New York City's off-year vote came back: the analysis behind a SparkyData article.

    py src/build_article.py the-off-year-vote

The question: the Board of Elections called the 2025 mayoral turnout historic, the most since 1969;
how does it compare, per eligible New Yorker, with every general election since 1990, and what does
that say about a governor's race in 2026?

Datasets (cards in datasets/):
- ``nyc_boe_turnout``: the Board's "General Elections Turnout" table (ballots cast and registered
  voters, every general election from 1953 to 2022), its yearly summaries by borough (2022-2025,
  with the ballots cast early), and the State's enrollment workbooks (February 2025 and 2026).
- ``census_history`` and ``acs1_pums``: adult citizens (18 and over), the census's count of the
  people who may vote, from the 1990 and 2000 censuses and the ACS one-year files 2006-2024 (2005's
  file has no group quarters and is left out), for the city and each borough, with replicate margins
  where the files carry them (2020's experimental release has none).
The join is a ratio: ballots cast in an election over the adult citizens of that year. Years
between the censuses and the first ACS used (1991-1999, 2001-2005) take a straight line between the
measured years, and 2025 takes the 2024 file's count (the 2025 file is postponed); both are
assumptions, marked as such in results and on the page.

Deterministic: no sampling.
"""

from __future__ import annotations

import json

import numpy as np
import pandas as pd

from _paths import REFERENCE
from article_kit import Article, lines, r, strip
from nycdata import elections
from sparkylib.stats import moe_sum

SLUG = "the-off-year-vote"
SEED = 20261026
FIRST = 1990                     # the first year with an adult-citizen count (the 1990 census)
LATEST_ACS = 2024
BOROUGHS = ["Bronx", "Brooklyn", "Manhattan", "Queens", "Staten Island"]
KIND_LABEL = {"president": "President", "governor": "Governor", "mayor": "Mayor"}
BOE25 = ("nyc_boe_annual_report_2025", "nyc_boe/BOE_Annual_Report_2025.pdf")
BOE19 = ("nyc_boe_annual_report_2019", "nyc_boe/BOE_Annual_Report_2019.pdf")
DEADLINES = ("nyc_boe_registration_deadlines_2026", "nyc_boe/registration_deadlines_2026.html")
QUALIFY = ("nyc_boe_voter_qualifications", "nyc_boe/voter_qualifications_2026.html")
COGE = ("nyc_coge_ballot_2026_10_13", "nyc_coge_ballot_2026-10-13.html")
DATES = ("nyc_boe_important_dates_2026", "nyc_boe/all_important_dates_2026.html")
CHECKINS = ("nyc_boe_early_voting_checkins_2026", "nyc_boe/early_voting_checkins_2026-10-18.html")
#: the saved copy of the Board's 2026 check-in page this version reads (sources.yaml: nyc_boe_early_voting_checkins_<date>)
CHECKINS_AS_OF = "2026-10-24"
ACS2006 = ("census_acs_comparing_2006", "census_acs_comparing_2006.html")
B29001 = REFERENCE / "tables" / "acs_b29001_nyc_2024_1yr.json"      # source acs_b29001_nyc_2024_1yr
GEO = {"New York City": "16000US3651000", "Bronx": "05000US36005", "Brooklyn": "05000US36047", "Manhattan": "05000US36061",
       "Queens": "05000US36081", "Staten Island": "05000US36085"}


# ------------------------------------------------------------------ the denominators


def denominators(last: int) -> tuple[dict[int, dict], dict[int, dict]]:
    """Adult citizens for every year from 1990 to ``last`` (nycdata.elections.adult_citizens: measured in a census or
    survey year, a straight line between, the latest file's count after it, 2005 left out), as {year: {"city",
    "city_moe", "how", "borough": {...}, "borough_moe": {...}}}, and the measured years alone."""
    c = elections.adult_citizens(FIRST, last)
    den = {}
    for y, g in c.groupby("year"):
        g = g.set_index("area")
        den[int(y)] = {"city": float(g.loc["New York City", "count"]), "city_moe": g.loc["New York City", "moe"],
                       "how": g.loc["New York City", "basis"],
                       "borough": {b: float(g.loc[b, "count"]) for b in BOROUGHS},
                       "borough_moe": {b: g.loc[b, "moe"] for b in BOROUGHS}}
    measured = {y: v for y, v in den.items() if v["how"] == "measured"}
    return den, measured


# ------------------------------------------------------------------ the analysis


def main(argv: list[str] | None = None) -> int:
    """Run the analysis and write every file (no options)."""
    A = Article(SLUG, __file__, seed=SEED)
    P = A.printed
    Q = A.quotes()

    # ---- the Board's records
    t = elections.turnout()
    # the reconciliations "Under the hood" describes: the two printings of the table, and the summaries' sums
    checks = elections.check_printings() + elections.check_summaries()
    assert all(c.passed for c in checks), [c for c in checks if not c.passed]
    A.results["checks"] = {c.name: c.detail for c in checks}
    last_election = int(t["year"].max())
    assert last_election == 2025, "the page is written for the 2025 election as the latest"
    cw = t[t["citywide"]].copy()

    # ---- adult citizens
    den, measured = denominators(last_election)
    measured_years = sorted(measured)
    assert 2005 not in measured and max(measured) == LATEST_ACS, "2005 is left out (no group quarters); 2024 is the latest"
    # the Bureau's published count of citizens 18 and over (B29001), reproduced from the 2024 file
    pub = json.loads(B29001.read_text(encoding="utf-8"))["data"]
    m24 = measured[LATEST_ACS]
    for area, geo in GEO.items():
        t_ = pub[geo]["B29001"]
        v_, m_ = (m24["city"], m24["city_moe"]) if area == "New York City" else (m24["borough"][area], m24["borough_moe"][area])
        A.reproduce(f"Citizens 18 and over, {area}, 2024 (B29001)", published=t_["estimate"]["B29001001"],
                    published_moe90=t_["error"]["B29001001"], reproduced=r(v_, 0),
                    reproduced_moe90=r(m_, 0), kind="count")
    assert all(row["within_margin"] for row in A.results["reproduction"]), A.results["reproduction"]
    # what "Under the hood" says about the table: the mayoral elections it holds before 1969, and the first odd year
    # whose top race was not for mayor, governor or president to print a count (the earlier ones print "Non-citywide")
    odd = t[~t["citywide"] & t["ballots"].notna()]
    A.results["provenance"] = {"table_years_before_1969": sorted(int(y) for y in t.loc[t["year"] < 1969, "year"]),
                               "first_odd_year_with_count": int(odd["year"].min()),
                               "printings": checks[0].detail}
    assert A.results["provenance"]["table_years_before_1969"] == [1953, 1957, 1961, 1965]
    assert A.results["provenance"]["first_odd_year_with_count"] == 2011
    A.results["adult_citizens"] = {str(y): {"count": r(v["city"], -3), "moe": None if v["city_moe"] is None or pd.isna(v["city_moe"]) else r(v["city_moe"], -3),
                                            "how": v["how"]} for y, v in den.items()}

    # ---- ballots per 100 adult citizens, every citywide general election from 1990
    per = {}
    for row in cw[cw["year"] >= FIRST].itertuples():
        d = den[int(row.year)]
        rate = 100 * row.ballots / d["city"]
        moe = None if d["city_moe"] is None or pd.isna(d["city_moe"]) else 100 * row.ballots / d["city"] * d["city_moe"] / d["city"]
        per[int(row.year)] = {"kind": row.kind, "ballots": int(row.ballots), "per_100": r(rate, 2),
                              "moe": None if moe is None else r(moe, 1), "denominator": d["how"],
                              "registered": int(row.registered), "pct_printed": row.pct_printed}
    A.results["per_100"] = {str(y): v for y, v in per.items()}
    by_kind = {k: {y: v["per_100"] for y, v in per.items() if v["kind"] == k} for k in KIND_LABEL}

    # ---- the record, placed in its series
    mayors = cw[cw["kind"] == "mayor"].set_index("year")["ballots"]
    b25 = int(mayors.loc[2025])
    more = [int(y) for y, b in mayors.items() if b > b25 and y < 2025]
    assert more and max(more) == 1969, "the Board's 'most since 1969' should hold on its own table"
    since = [int(y) for y, v in by_kind["mayor"].items() if y < 2025 and v >= by_kind["mayor"][2025]]
    A.results["record"] = {
        "ballots_2025": b25, "ballots_1969": int(mayors.loc[1969]), "last_mayoral_above": max(more),
        "per_100_2025": by_kind["mayor"][2025], "last_mayoral_above_per_100": max(since) if since else None,
        "per_100_1993": by_kind["mayor"][1993], "per_100_2018": by_kind["governor"][2018],
        "per_100_2021": by_kind["mayor"][2021], "ballots_2021": int(mayors.loc[2021]),
        "every_election_above_2025": sorted(int(y) for y, v in per.items() if v["per_100"] > by_kind["mayor"][2025] and y != 2025),
    }
    assert max(since) == 1993, "the page says the most for a mayor since 1993"
    # the change from 2021: the ballots are counts, so its margin is the two survey denominators' (2025's is
    # 2024's count, assumed), taken as independent samples
    p21, p25 = per[2021], per[2025]
    r21, r25 = 100 * p21["ballots"] / den[2021]["city"], 100 * b25 / den[2025]["city"]
    m21, m25 = r21 * den[2021]["city_moe"] / den[2021]["city"], r25 * den[2025]["city_moe"] / den[2025]["city"]
    dm = moe_sum(m21, m25)
    A.results["change_2021_2025"] = {"diff": r(r25 - r21, 2), "moe": r(dm, 2), "lo": r(r25 - r21 - dm, 2),
                                     "hi": r(r25 - r21 + dm, 2), "moe_2021": r(m21, 2), "moe_2025": r(m25, 2)}
    A.claim("change_2021_2025", "up")
    # every off-year race above 2025's per-citizen rate since 1990
    off = {y: v for y, v in per.items() if v["kind"] != "president"}
    A.results["record"]["off_year_above_2025"] = sorted(int(y) for y, v in off.items() if v["per_100"] > by_kind["mayor"][2025])
    A.results["record"]["off_year_max"] = {"year": int(max(off, key=lambda y: off[y]["per_100"])),
                                           "per_100": max(v["per_100"] for v in off.values())}

    # 2025's denominator is 2024's count: how far it could move 2025's rate, from the largest one-year rise and the
    # largest one-year fall between consecutive survey years (2020's experimental file left out)
    acs_years = [y for y in measured_years if y >= 2006 and y != 2020]
    steps = [measured[b]["city"] - measured[a]["city"] for a, b in zip(acs_years, acs_years[1:]) if b == a + 1]
    lo25 = 100 * b25 / (den[2024]["city"] + max(steps))
    hi25 = 100 * b25 / (den[2024]["city"] + min(steps))
    A.results["record"]["per_100_2025_range"] = {"lo": r(lo25, 2), "hi": r(hi25, 2), "largest_rise": r(max(steps), -3),
                                                  "largest_fall": r(-min(steps), -3)}
    others = [v for y, v in by_kind["mayor"].items() if y not in (1993, 2025)]
    assert lo25 > max(others) and hi25 < by_kind["mayor"][1993], "the sensitivity keeps 2025 the most for a mayor since 1993"

    # ---- the same rates on one population basis (the methodologist's review of 24 October). Each survey file was
    # weighted to the population estimates of its own vintage, and the 2020 census revised the city's upward, so the
    # rate's base steps between 2019 and 2021. Each measured year's count is put on the series revised to the latest
    # census (elections.population_controls; the share of adult citizens assumed unaffected): a check on every
    # comparison across the step, not a replacement for the files' own counts, which the page prints.
    pc = elections.population_controls().set_index("year")
    A.results["population_controls"] = {str(y): {"file_total": r(row["file_total"], -3), "estimate": r(row["estimate"], -3),
                                                 "ratio": r(row["ratio"], 4), "step_pct": r(100 * (row["ratio"] - 1), 1)}
                                        for y, row in pc.iterrows() if y >= 2006}
    rebased = {}
    for y, v in per.items():
        ry = y if den[y]["how"] == "measured" else (LATEST_ACS if y > LATEST_ACS else None)
        if ry is not None and ry in pc.index and ry >= 2006:
            rebased[y] = r(v["per_100"] / pc.loc[ry, "ratio"], 2)
    A.results["per_100_rebased"] = {str(y): v for y, v in rebased.items()}
    gov_r = {y: v for y, v in rebased.items() if per[y]["kind"] == "governor"}
    may_r = {y: v for y, v in rebased.items() if per[y]["kind"] == "mayor"}
    # the page: on one basis last year's race comes out above 2018's governor's race, which still stands above every
    # other governor's race since 2006 and above 2022's; and 2025 above every mayoral race since 2006
    assert rebased[2025] > rebased[2018] > rebased[2022], rebased
    assert max(gov_r, key=gov_r.get) == 2018 and max(may_r, key=may_r.get) == 2025, (gov_r, may_r)
    assert 1.05 < pc.loc[2018, "ratio"] < 1.06 and 1.05 < pc.loc[2019, "ratio"] < 1.06, "the page says about 5%"

    # ---- the slide, and the return
    gov, may = by_kind["governor"], by_kind["mayor"]
    low_gov = min(gov, key=gov.get)
    A.results["slide"] = {
        "governor_1994": gov[1994], "governor_low_year": low_gov, "governor_low": gov[low_gov],
        "governor_2018": gov[2018], "governor_2022": gov[2022], "governor_1990": gov[1990],
        "mayor_1993": may[1993], "mayor_low": min(v for y, v in may.items() if y < 2025),
        "mayor_low_years": sorted(y for y, v in may.items() if y < 2025 and v - min(x for z, x in may.items() if z < 2025) < 1),
        "governor_ballots_2014": int(cw.set_index("year").loc[2014, "ballots"]),
        "governor_ballots_2018": int(cw.set_index("year").loc[2018, "ballots"]),
        "governor_ballots_2022": int(cw.set_index("year").loc[2022, "ballots"]),
    }
    assert low_gov == 2014 and gov[2018] == max(gov.values()), "the page says 2014 the low and 2018 the high of governor's races"
    assert A.results["slide"]["mayor_low_years"] == [2013, 2017, 2021], A.results["slide"]["mayor_low_years"]
    # the page: the slide runs from 1994 (1990's race was lower than 1994's), and 2022 fell back below 2018
    assert gov[1990] < gov[1994] and gov[2022] < gov[2018]
    # the page: from 1994 the governor's races fell to 2014, with a rise in 2010 only
    seq = [gov[y] for y in (1994, 1998, 2002, 2006, 2010, 2014)]
    rises = [y for y, a_, b_ in zip((1998, 2002, 2006, 2010, 2014), seq, seq[1:]) if b_ > a_]
    assert rises == [2010], rises
    # and the mayoral races from 1993 fell to 2013 with a rise in 2001 only (the methodologist's review of 24 October:
    # 1997 to 2001 rose, both years on interpolated denominators)
    myears = (1993, 1997, 2001, 2005, 2009, 2013)
    mseq = [may[y] for y in myears]
    mrises = [y for y, a_, b_ in zip(myears[1:], mseq, mseq[1:]) if b_ > a_]
    assert mrises == [2001], mrises
    A.results["slide"]["governor_rises"] = rises
    A.results["slide"]["mayor_rises"] = mrises
    A.results["slide"]["mayor_2025_over_2021"] = r(may[2025] / may[2021], 2)
    # what 2018's and 2022's rates would mean in ballots at the 2024 count of adult citizens (for election night)
    A.results["slide"]["ballots_at_2018_rate"] = r(gov[2018] / 100 * den[2024]["city"], -4)
    A.results["slide"]["ballots_at_2022_rate"] = r(gov[2022] / 100 * den[2024]["city"], -4)
    pres = by_kind["president"]
    A.results["president_range"] = {"lo": min(pres.values()), "hi": max(pres.values()),
                                    "lo_year": min(pres, key=pres.get), "hi_year": max(pres, key=pres.get)}
    # 2020's denominator is the Bureau's experimental release (card acs1_pums): the band without it
    pres_x = {y: v for y, v in pres.items() if y != 2020}
    A.results["president_range"]["hi_without_2020"] = max(pres_x.values())
    A.results["president_range"]["hi_year_without_2020"] = max(pres_x, key=pres_x.get)
    # the page: "in 2008 and 2016, about the same": every year within the combined margins of the top (0.3 each)
    top_x = max(pres_x.values())
    near = sorted(y for y, v in pres_x.items() if top_x - v <= moe_sum(per[y]["moe"] or 0, per[max(pres_x, key=pres_x.get)]["moe"] or 0))
    A.results["president_range"]["top_without_2020_within_margins"] = near
    assert near == [2008, 2016], near
    assert A.results["president_range"]["hi_year"] == 2020, "the page says 2020 is the top of the band"
    # every governor's race against the presidential election two years before it
    drops = {}
    for y in sorted(gov):
        if y - 2 in pres:
            drops[str(y)] = {"president": pres[y - 2], "governor": gov[y], "drop": r(pres[y - 2] - gov[y], 1)}
    A.results["drop_off"] = drops
    dd = [v["drop"] for v in drops.values()]
    A.results["drop_off_range"] = {"lo": min(dd), "hi": max(dd), "pairs": len(dd),
                                   "every_pair_lower": all(v > 0 for v in dd)}

    # ---- the boroughs: per 100 adult citizens in 2022, 2024 and 2025
    bt = elections.borough_summaries()
    bor = {}
    for y in (2022, 2024, 2025):
        rows = bt[bt["year"] == y].set_index("borough")
        dy = den[y]
        bor[str(y)] = {b: {"per_100": r(100 * rows.loc[b, "ballots"] / dy["borough"][b], 2),
                           "ballots": int(rows.loc[b, "ballots"]), "active": int(rows.loc[b, "active"]),
                           "active_pct": r(100 * rows.loc[b, "ballots"] / rows.loc[b, "active"], 0),
                           "early_share": r(100 * rows.loc[b, "early"] / rows.loc[b, "ballots"], 0),
                           "adult_citizens": r(dy["borough"][b], -3)} for b in BOROUGHS}
    A.results["boroughs"] = bor
    b25r = {b: v["per_100"] for b, v in bor["2025"].items()}
    A.results["borough_gap_2025"] = {"high": max(b25r, key=b25r.get), "low": min(b25r, key=b25r.get),
                                     "high_per_100": max(b25r.values()), "low_per_100": min(b25r.values())}
    # the borough adult-citizen counts carry margins of their own: how far each moves its 2024-based rate
    city25 = by_kind["mayor"][2025]
    A.results["borough_gap_2025"]["city_per_100"] = city25
    eff = [bor["2025"][b]["per_100"] * den[2024]["borough_moe"][b] / den[2024]["borough"][b] for b in BOROUGHS]
    A.results["borough_margin_effect"] = {"lo": r(min(eff), 1), "hi": r(max(eff), 1)}

    # ---- early voting, 2022-2025
    early = {}
    for y in (2022, 2023, 2024, 2025):
        rows = bt[bt["year"] == y]
        early[str(y)] = {"early": int(rows["early"].sum()), "ballots": int(rows["ballots"].sum()),
                         "share": r(100 * rows["early"].sum() / rows["ballots"].sum(), 0)}
    A.results["early"] = early

    # ---- the rolls: active voters, February 2026 against 2025, and against adult citizens
    e25, e26 = elections.enrollment(2025), elections.enrollment(2026)
    a25, a26 = int(e25["active"].sum()), int(e26["active"].sum())
    A.results["rolls"] = {
        "active_feb_2026": a26, "active_feb_2025": a25, "change": a26 - a25, "as_of": e26["as_of"].iloc[0],
        "total_feb_2026": int(e26["total"].sum()), "inactive_feb_2026": int(e26["inactive"].sum()),
        "active_per_100_adult_citizens": r(100 * a26 / den[2025]["city"], 0),
        "largest_in_table": int(t["registered"].max()), "largest_in_table_year": int(t.loc[t["registered"].idxmax(), "year"]),
    }
    # ---- 2026 so far: the Board's early-voting check-ins at the close of each day, against the same day of every
    # general election since 2020 (2022 is the like-for-like governor's race); unofficial, and a check-in is a voter
    # signed in at an early-voting site, not a ballot counted
    ck = elections.check_checkins(CHECKINS_AS_OF)[0]
    assert ck.passed, ck.detail
    A.results["checks"]["boe_early_checkins"] = ck.detail
    k = elections.early_checkins(CHECKINS_AS_OF)
    city = k[k["area"] == "New York City"]
    by_day = {int(y): {int(dd): int(v) for dd, v in g.set_index("day")["checkins"].items()} for y, g in city.groupby("year")}
    assert 2026 in by_day, "2026's check-ins are not saved yet"
    n26 = max(by_day[2026])
    now = by_day[2026][n26]
    g26 = city[city["year"] == 2026].set_index("day")
    past = sorted(y for y in by_day if y < 2026)
    A.results["checkins_2026"] = {
        "source_id": f"nyc_boe_early_voting_checkins_{CHECKINS_AS_OF.replace('-', '_')}",
        "file": f"early_voting_checkins_{CHECKINS_AS_OF}.html",
        "days": n26, "date": str(g26.loc[n26, "date"]), "checkins": now,
        "same_day": {str(y): {"checkins": by_day[y][n26], "kind": elections.CHECKIN_KIND[y]} for y in past},
        "ratio_to_2022": r(now / by_day[2022][n26], 2), "nine_days_2022": by_day[2022][9],
        "boroughs": {b: int(v) for b, v in k[(k["year"] == 2026) & (k["day"] == n26) & (k["area"] != "New York City")]
                     .set_index("area")["checkins"].items()}}
    A.latest(str(g26.loc[n26, "date"]), f"The Board of Elections' early-voting check-ins for the 3 November 2026 general "
             f"election, unofficial, at the close of day {n26}; the State's enrollment as of 20 February 2026; the Board's "
             "turnout records to the 4 November 2025 election")
    A.long_view(FIRST, last_election, "Ballots cast in every citywide general election per 100 adult citizens, 1990-2025 "
                "(the Board's turnout table and summaries; the 1990 and 2000 censuses and the ACS one-year files for "
                "the denominator); ballots cast from 1953")

    # ---- the documents
    hist = {}
    q = Q.quote(*BOE25, "a historic turnout of more than two million voters")
    hist["boe_2025_historic"] = q
    hist["boe_2025_since_1969"] = Q.quote(*BOE25, "dating back to 1969")
    hist["early_2025"] = Q.quote(*BOE25, "General Election – November 4, 2025 Early Voting Days: October 25 – November 2")
    hist["deadline_2026"] = Q.quote(*DEADLINES, "Last day to register to vote in person is October 24, 2026")
    hist["election_2026"] = Q.quote(*DEADLINES, "General Election - November 3, 2026")
    hist["qualifications"] = Q.quote(*QUALIFY, "Be a citizen of the United States")
    hist["coge_five"] = Q.quote(*COGE, "there are five proposals on the ballot")
    hist["early_2026"] = Q.quote(*DATES, "Drop off your ballot to an early voting poll site in your county between October 24 and November 1, 2026")
    hist["early_voting_2019"] = Q.quote(*BOE19, "early voting was passed by the state legislature for the November 5th general election")
    hist["boe_2019_credit"] = Q.quote(*BOE19, "we credit the higher turnout to the increased media coverage of early voting")
    hist["boe_2019_higher"] = Q.quote(*BOE19, "Voter turnout overall was higher than in recent years")
    hist["acs_2005_gq"] = Q.quote(*ACS2006, "is included in the 2006 ACS and not included in the 2005 ACS")
    hist["offices_2026"] = Q.quote(*BOE25, "voters will elect members of the New York State Legislature, the Governor")
    hist["active_as_of"] = Q.quote(*BOE25, "as of 10/26/2025")
    hist["residence_rule"] = Q.quote(*QUALIFY, "Be a New York City resident for at least 30 days")
    hist["prison_rule"] = Q.quote(*QUALIFY, "Not be in prison for a felony conviction")
    hist["coge_charter"] = Q.quote(*COGE, "the 2026 New York City Charter Revision Commission")
    hist["acs_2025_date"] = Q.quote("census_acs_2025_release_postponed", "census_acs_updates_2026.html",
                                    "The release date for the 2025 ACS 1-year estimates is being determined")
    hist["checkins_close_of_polls"] = Q.quote(*CHECKINS, "Unofficial as of Close of Polls")
    # the Bureau's microdata folder, as listed on 18 October 2026: 2024 the last year in it
    hist["pums_folder_latest"] = Q.quote("census_acs_pums_directory_2026_10_18", "census_acs_pums_directory_2026-10-18.html",
                                         "2024/ 2026-01-29 10:06 - --> CONNECT WITH US")
    A.results["quotes"] = {k: {"quote": v["quote"], "page": v.get("page"), "source": v.get("source")} for k, v in hist.items()}

    # ---- printed figures (the page's strings; whole numbers print from two-decimal rates)
    R0, S0, B0, E0, L0 = A.results["record"], A.results["slide"], A.results["boroughs"], A.results["early"], A.results["rolls"]
    AC = A.results["adult_citizens"]
    P("record.ballots_2025", f"{R0['ballots_2025']:,}")
    P("record.ballots_1969", f"{R0['ballots_1969']:,}")
    P("record.ballots_2021", f"{R0['ballots_2021']:,}")
    P("record.per_100_2025", f"{R0['per_100_2025']:.0f}")
    P("slide.governor_1994", f"{S0['governor_1994']:.0f}")
    P("slide.governor_low", f"{S0['governor_low']:.1f}")
    P("slide.governor_2018", f"{S0['governor_2018']:.0f}")
    P("slide.governor_2022", f"{S0['governor_2022']:.0f}")
    P("slide.mayor_1993", f"{S0['mayor_1993']:.0f}")
    P("slide.mayor_low", f"{S0['mayor_low']:.0f}")
    P("slide.governor_ballots_2018", f"{S0['governor_ballots_2018']:,}")
    P("slide.governor_ballots_2022", f"{S0['governor_ballots_2022']:,}")
    P("president_range.lo", f"{A.results['president_range']['lo']:.0f}")
    P("president_range.hi", f"{A.results['president_range']['hi']:.0f}")
    P("drop_off_range.lo", f"{A.results['drop_off_range']['lo']:.0f}")
    P("drop_off_range.hi", f"{A.results['drop_off_range']['hi']:.0f}")
    P("adult_citizens.1990.count", f"{AC['1990']['count'] / 1e6:.1f} million")
    P("adult_citizens.2024.count", f"{AC['2024']['count'] / 1e6:.1f} million")
    P("per_100.2025.registered", f"{per[2025]['registered']:,}")
    P("per_100.2025.pct_printed", per[2025]["pct_printed"])
    for y, bs in (("2025", ("Bronx", "Manhattan")), ("2022", ("Bronx", "Staten Island"))):
        for b in bs:
            P(f"boroughs.{y}.{b}.per_100", f"{B0[y][b]['per_100']:.0f}")
    P("boroughs.2025.Bronx.active_pct", f"{B0['2025']['Bronx']['active_pct']:.0f}%")
    P("boroughs.2025.Manhattan.active_pct", f"{B0['2025']['Manhattan']['active_pct']:.0f}%")
    for y in ("2024", "2025"):
        P(f"early.{y}.share", f"{E0[y]['share']:.0f}%")
    P("rolls.active_feb_2026", f"{L0['active_feb_2026']:,}")
    P("rolls.change", f"{L0['change']:,}")
    P("rolls.active_per_100_adult_citizens", f"{L0['active_per_100_adult_citizens']:.0f}")
    P("rolls.largest_in_table", f"{L0['largest_in_table']:,}")
    RG = R0["per_100_2025_range"]
    P("record.per_100_2025_range.lo", f"{RG['lo']:.0f}")
    P("record.per_100_2025_range.hi", f"{RG['hi']:.0f}")
    P("record.per_100_2025_range.largest_rise", f"{RG['largest_rise']:,.0f}")
    P("record.per_100_2025_range.largest_fall", f"{RG['largest_fall']:,.0f}")
    CH = A.results["change_2021_2025"]
    P("change_2021_2025.diff", f"{CH['diff']:.1f}")
    P("change_2021_2025.moe", f"{CH['moe']:.1f}")
    P("president_range.hi_without_2020", f"{A.results['president_range']['hi_without_2020']:.0f}")
    P("per_100_rebased.2018", f"{rebased[2018]:.1f}")
    P("per_100_rebased.2025", f"{rebased[2025]:.1f}")
    P("population_controls.2018.step_pct", f"{A.results['population_controls']['2018']['step_pct']:.0f}%")
    K0 = A.results["checkins_2026"]
    P("checkins_2026.checkins", f"{K0['checkins']:,}")
    P("checkins_2026.same_day.2022.checkins", f"{K0['same_day']['2022']['checkins']:,}")
    P("checkins_2026.same_day.2025.checkins", f"{K0['same_day']['2025']['checkins']:,}")
    A.results["rolls"]["registered_1969"] = int(t.set_index("year").loc[1969, "registered"])

    # ---- charts
    years = sorted(per)
    xs = [[str(y), str(y)] for y in years]
    def cell(kind):
        return {str(y): r(per[y]["per_100"], 1) for y in years if per[y]["kind"] == kind}
    A.chart("per_citizen", lines(
        title="Off-year turnout slid from 1994 to 2014, then rose in 2018 and again in 2025",
        subtitle=f"Ballots cast in each general election per 100 adult citizens, New York City, {years[0]} to {years[-1]}, by the race at the top of the ballot",
        x=xs, series=[[k, f"{lab}'s race", lab] for k, lab in KIND_LABEL.items()],
        cells={"": {k: cell(k) for k in KIND_LABEL}},
        format="dec1", axis="Ballots per 100 adult citizens", labels="legend", connect=True,
        x_ticks=[str(y) for y in years if y % 5 == 0 or y == years[-1]],
        note="Ballots are every ballot the Board of Elections counted; adult citizens are the census's count of citizens 18 "
             "and over (the 1990 and 2000 censuses, then the American Community Survey from 2006; 2005's survey left out "
             "people in group quarters; 2020's is the Census Bureau's experimental release, with no margins). Years "
             "without a file are a straight line between measured years, and 2025 uses 2024's count, since the 2025 "
             "survey is postponed: both assumed. Each survey year is weighted to that year's population estimates, which "
             "the 2020 census revised upward for the city: the 2018 and 2019 files sit about 5% below the revised "
             "estimates, so a late-2010s rate stands on a lower base than a 2020s one (the questions below). Years whose "
             "top race was not for mayor, governor or president are left out.",
        provenance="Board of Elections in the City of New York, annual reports; U.S. Census Bureau microdata"))
    bal = t[t["citywide"]]
    bx = [[str(int(y)), str(int(y))] for y in bal["year"]]
    # the caption: from 1976 each presidential year drew more ballots than the races within three years of it
    # (1972 drew fewer than 1969's mayoral race and 1970's governor's race)
    pb = bal[bal["kind"] == "president"].set_index("year")["ballots"]
    below = [int(y) for y, b in pb.items()
             if (bal[(bal["kind"] != "president") & ((bal["year"] - y).abs() <= 3)]["ballots"] > b).any()]
    assert below == [1972], below
    A.results["record"]["presidential_below_neighbours"] = below
    A.chart("ballots_since_1953", lines(
        title="2025's mayoral race drew the most ballots for a mayor since 1969",
        subtitle=f"Ballots cast in each citywide general election, New York City, 1953 to {last_election}, millions",
        x=bx, series=[[k, f"{lab}'s race", lab] for k, lab in KIND_LABEL.items()],
        cells={"": {k: {str(int(rw.year)): r(rw.ballots / 1e6, 2) for rw in bal.itertuples() if rw.kind == k} for k in KIND_LABEL}},
        format="m", axis="Ballots cast (millions)", labels="legend", connect=True,
        x_ticks=[str(int(y)) for y in bal["year"] if int(y) % 10 == 0 or int(y) in (1953, last_election)],
        note="The Board's table gives the mayoral elections of 1953, 1957, 1961 and 1965 and every November from 1969; "
             "a count of ballots is not a rate, and the city's population and its citizens changed over seven decades.",
        provenance="Board of Elections in the City of New York, annual reports (2006, 2022-2025)"))
    A.chart("boroughs", strip(
        title="The Bronx voted least in each of the last three races for governor, president or mayor",
        subtitle="Ballots cast per 100 adult citizens, by borough: the 2022 governor's race, the 2024 presidential race and the 2025 mayoral race",
        x=[["2022", "2022, governor", 2], ["2024", "2024, president", 1], ["2025", "2025, mayor", 0]],
        series=[[b, b, b] for b in BOROUGHS],
        cells={"": {b: {y: r(B0[y][b]["per_100"], 1) for y in ("2022", "2024", "2025")} for b in BOROUGHS}},
        format="dec1", axis="Ballots per 100 adult citizens", value_x="2025", value_label="2025",
        note="Ballots from the Board of Elections' yearly borough summaries, not the citywide table behind the long series; adult "
             "citizens by borough from the American Community Survey one-year files (2022 and 2024; 2025 uses 2024's, assumed). "
             f"A borough's estimate carries a margin that moves its rate by {A.results['borough_margin_effect']['lo']:.1f} "
             f"to {A.results['borough_margin_effect']['hi']:.1f} ballots per 100.",
        provenance="Board of Elections in the City of New York, annual reports; U.S. Census Bureau, ACS microdata"))

    # ---- data.csv: one row per election from 1953 and per borough from 2022
    # one row per election from 1953 and per borough from 2022; the early-voting check-ins (cumulative, unofficial,
    # never to be summed across days) in their own columns
    rows = []
    for rw in t.itertuples():
        y = int(rw.year)
        d = den.get(y)
        rows.append({"table": "general_elections", "year": y, "area": "New York City", "kind": rw.kind,
                     "citywide": bool(rw.citywide), "ballots": None if pd.isna(rw.ballots) else int(rw.ballots),
                     "registered_or_active": int(rw.registered), "pct_printed": rw.pct_printed,
                     "adult_citizens": None if d is None else r(d["city"], 0),
                     "adult_citizens_basis": None if d is None else d["how"],
                     "per_100_adult_citizens": per.get(y, {}).get("per_100"),
                     "early_voting_day": None, "date": None, "checkins_cumulative_unofficial": None})
    for rw in bt.itertuples():
        y = int(rw.year)
        rows.append({"table": "boroughs", "year": y, "area": rw.borough, "kind": None, "citywide": None,
                     "ballots": int(rw.ballots), "registered_or_active": int(rw.active), "pct_printed": rw.pct_printed,
                     "adult_citizens": r(den[y]["borough"][rw.borough], 0) if y in den else None,
                     "adult_citizens_basis": den[y]["how"] if y in den else None,
                     "per_100_adult_citizens": B0.get(str(y), {}).get(rw.borough, {}).get("per_100"),
                     "early_voting_day": None, "date": None, "checkins_cumulative_unofficial": None})
    for rw in k.itertuples():
        rows.append({"table": "early_checkins", "year": int(rw.year), "area": rw.area, "kind": rw.kind, "citywide": None,
                     "ballots": None, "registered_or_active": None, "pct_printed": None,
                     "adult_citizens": None, "adult_citizens_basis": None, "per_100_adult_citizens": None,
                     "early_voting_day": int(rw.day), "date": str(rw.date), "checkins_cumulative_unofficial": int(rw.checkins)})
    A.write(data=pd.DataFrame(rows))
    print(f"{SLUG}: wrote {A.out}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
