#!/usr/bin/env python3
"""Independent, fail-closed verifier for the poker rake-cap data bundle.

The verifier deliberately reimplements the JavaScript model with Python integer
arithmetic. It has no third-party dependencies and does not import the generator.
"""

from __future__ import annotations

import ast
import copy
import csv
import hashlib
import json
import math
import re
import struct
import sys
import xml.etree.ElementTree as ET
from collections import Counter
from pathlib import Path
from typing import Any, Callable, Iterable


DATA_DIRECTORY = Path(__file__).resolve().parent
MAX_EXHAUSTIVE_POT_CENTS = 200_000
PLAYER_BUCKETS = ("2", "3-4", "5+")
EXPECTED_ACCESSED_AT = "2026-09-04T23:22:09.7201938+02:00"
EXPECTED_SOURCE_URL = "https://www.pokerstars.com/poker/room/rake/"
EXPECTED_MANIFEST_FILES = (
    "source-snapshot.json",
    "schedule-breakpoints.csv",
    "worked-case.csv",
    "reference.json",
    "calculator.html",
    "generate.mjs",
    "verify.py",
    "verification-report.json",
    "README.md",
    "poker-rake-cap-curves.svg",
    "poker-rake-cap-math.webp",
)

# family, exact source label, big blind cents (None for a grouped source row),
# rate in basis points, and caps for 2 / 3-4 / 5+ players dealt.
FROZEN_ROWS = (
    ("standard_usd_nl_pl", "$0.01/$0.02", 2, 500, (100, 100, 100)),
    ("standard_usd_nl_pl", "$0.02/$0.05", 5, 500, (100, 100, 100)),
    ("standard_usd_nl_pl", "$0.05/$0.10", 10, 500, (100, 100, 100)),
    ("standard_usd_nl_pl", "$0.10/$0.25", 25, 450, (50, 100, 200)),
    ("standard_usd_nl_pl", "$0.25/$0.50", 50, 500, (75, 75, 200)),
    ("standard_usd_nl_pl", "$0.50/$1", 100, 500, (100, 100, 250)),
    ("standard_usd_nl_pl", "$1/$2", 200, 500, (125, 125, 275)),
    ("standard_usd_nl_pl", "$2/$4", 400, 500, (150, 150, 300)),
    ("standard_usd_nl_pl", "$2.50/$5", 500, 500, (150, 150, 300)),
    ("standard_usd_nl_pl", "$3/$6", 600, 500, (150, 150, 350)),
    ("standard_usd_nl_pl", "$5/$10", 1_000, 450, (150, 150, 300)),
    ("standard_usd_nl_pl", "$10/$20", 2_000, 450, (175, 175, 300)),
    ("standard_usd_nl_pl", "$25/$50", 5_000, 450, (225, 200, 300)),
    ("standard_usd_nl_pl", "$50/$100", 10_000, 450, (250, 300, 500)),
    ("standard_usd_nl_pl", "$100/$200 and above", None, 450, (300, 500, 500)),
    ("usd_micro_omaha", "$0.01/$0.02", 2, 330, (30, 30, 30)),
    ("usd_micro_omaha", "$0.02/$0.05", 5, 390, (50, 50, 100)),
    ("usd_micro_omaha", "$0.05/$0.10", 10, 425, (50, 100, 150)),
    ("usd_micro_omaha", "$0.10/$0.25", 25, 425, (50, 100, 200)),
    ("usd_micro_nlhe_zoom", "$0.01/$0.02", 2, 350, (30, 30, 30)),
    ("usd_micro_nlhe_zoom", "$0.02/$0.05", 5, 415, (50, 50, 100)),
    ("usd_micro_nlhe_zoom", "$0.05/$0.10", 10, 450, (50, 100, 150)),
)

TIE_FIXTURES = (
    (500, 10, 0),
    (500, 30, 2),
    (450, 100, 4),
    (450, 300, 14),
    (330, 500, 16),
    (330, 1_500, 50),
    (390, 500, 20),
    (390, 1_500, 58),
    (425, 200, 8),
    (425, 600, 26),
    (350, 100, 4),
    (350, 300, 10),
    (415, 1_000, 42),
    (415, 3_000, 124),
)

SCHEDULE_CSV_COLUMNS = (
    ("scheduleFamily", "schedule_family"),
    ("scheduleKey", "schedule_key"),
    ("sourceTableLabel", "source_table_label"),
    ("stakes", "stakes"),
    ("bigBlindCents", "big_blind_cents"),
    ("rateBps", "rate_basis_points"),
    ("ratePercent", "rate_percent"),
    ("playersDealtBucket", "players_dealt_bucket"),
    ("capCents", "cap_cents"),
    ("capDollars", "cap_dollars"),
    ("capBigBlinds", "cap_big_blinds"),
    ("continuousBreakpointPotCentsExact", "continuous_breakpoint_pot_cents_exact"),
    ("continuousBreakpointDollars", "continuous_breakpoint_dollars"),
    ("continuousBreakpointBigBlinds", "continuous_breakpoint_big_blinds"),
    ("displayedCapOnsetCents", "displayed_cap_onset_cents"),
    ("displayedCapOnsetDollars", "displayed_cap_onset_dollars"),
    ("displayedCapOnsetBigBlinds", "displayed_cap_onset_big_blinds"),
    ("rawCapReachCents", "raw_cap_reach_cents"),
    ("rawCapReachDollars", "raw_cap_reach_dollars"),
    ("rawCapReachBigBlinds", "raw_cap_reach_big_blinds"),
    ("strictlyBindingOnsetCents", "strictly_binding_onset_cents"),
    ("strictlyBindingOnsetDollars", "strictly_binding_onset_dollars"),
    ("strictlyBindingOnsetBigBlinds", "strictly_binding_onset_big_blinds"),
    ("sourceAccessDate", "source_access_date"),
    ("sourceAccessedAt", "source_accessed_at"),
    ("sourceUrl", "source_url"),
    ("worksheetCentConvention", "worksheet_cent_convention"),
    ("normalizationNote", "normalization_note"),
)

WORKED_CSV_COLUMNS = (
    ("scheduleFamily", "schedule_family"),
    ("scheduleKey", "schedule_key"),
    ("stakes", "stakes"),
    ("playersDealtBucket", "players_dealt_bucket"),
    ("grossPotCents", "gross_pot_cents"),
    ("potBigBlinds", "gross_pot_big_blinds"),
    ("grossPotDollars", "gross_pot_dollars"),
    ("ratePercent", "rate_percent"),
    ("rateBps", "rate_basis_points"),
    ("capDollars", "cap_dollars"),
    ("capCents", "cap_cents"),
    ("uncappedRoundedCents", "uncapped_half_even_rake_cents"),
    ("uncappedRoundedRakeDollars", "uncapped_half_even_rake_dollars"),
    ("rakeDollars", "capped_rake_dollars"),
    ("rakeCents", "capped_rake_cents"),
    ("netPotCents", "net_pot_cents"),
    ("netPotDollars", "net_pot_dollars"),
    ("rakeBigBlinds", "capped_rake_big_blinds"),
    ("effectiveRakePercent", "effective_rake_percent"),
    ("capDisplayed", "cap_displayed"),
    ("capStrictlyBinding", "cap_strictly_binding"),
    ("endedOnFirstBettingRound", "ended_on_first_betting_round"),
    ("eligible", "eligible"),
    ("sourceScheduleKey", "source_schedule_key"),
    ("scheduleAccessDate", "schedule_access_date"),
    ("assumptionNotice", "assumption_notice"),
    ("worksheetCentConvention", "worksheet_cent_convention"),
)


class VerificationError(RuntimeError):
    """Raised when a checked-in artifact violates the frozen contract."""


def require(condition: bool, message: str) -> None:
    if not condition:
        raise VerificationError(message)


def read_json(name: str) -> Any:
    path = DATA_DIRECTORY / name
    require(path.is_file(), f"missing required artifact: {name}")
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as error:
        raise VerificationError(f"cannot read valid JSON from {name}: {error}") from error


def ceil_divide(numerator: int, denominator: int) -> int:
    require(type(numerator) is int, "ceil_divide numerator must be an integer")
    require(type(denominator) is int and denominator > 0, "ceil_divide denominator must be positive")
    return (numerator + denominator - 1) // denominator


def half_even(numerator: int, denominator: int) -> int:
    require(type(numerator) is int and numerator >= 0, "half_even numerator must be a non-negative integer")
    require(type(denominator) is int and denominator > 0, "half_even denominator must be a positive integer")
    quotient, remainder = divmod(numerator, denominator)
    doubled_remainder = remainder * 2
    if doubled_remainder < denominator:
        return quotient
    if doubled_remainder > denominator:
        return quotient + 1
    return quotient if quotient % 2 == 0 else quotient + 1


def half_up(numerator: int, denominator: int) -> int:
    """Deliberately wrong tie policy, used only to prove fixtures detect it."""
    quotient, remainder = divmod(numerator, denominator)
    return quotient + (1 if remainder * 2 >= denominator else 0)


def rate_fraction(rate_bps: int) -> tuple[int, int]:
    divisor = math.gcd(rate_bps, 10_000)
    return rate_bps // divisor, 10_000 // divisor


def format_fraction(numerator: int, denominator: int, digits: int) -> str:
    scale = 10**digits
    rounded = half_even(numerator * scale, denominator)
    whole, fractional = divmod(rounded, scale)
    if digits == 0:
        return str(whole)
    return f"{whole}.{fractional:0{digits}d}"


def format_money(cents: int) -> str:
    return format_fraction(cents, 100, 2)


def displayed_cap_onset(rate_numerator: int, rate_denominator: int, cap_cents: int) -> int:
    lower_boundary = rate_denominator * ((2 * cap_cents) - 1)
    denominator = 2 * rate_numerator
    if cap_cents % 2 == 0:
        return ceil_divide(lower_boundary, denominator)
    return (lower_boundary // denominator) + 1


def raw_cap_reach(rate_numerator: int, rate_denominator: int, cap_cents: int) -> int:
    return ceil_divide(cap_cents * rate_denominator, rate_numerator)


def strictly_binding_onset(rate_numerator: int, rate_denominator: int, cap_cents: int) -> int:
    upper_boundary = rate_denominator * ((2 * cap_cents) + 1)
    denominator = 2 * rate_numerator
    if cap_cents % 2 == 1:
        return ceil_divide(upper_boundary, denominator)
    return (upper_boundary // denominator) + 1


def rake_details(
    pot_cents: int,
    rate_numerator: int,
    rate_denominator: int,
    cap_cents: int,
    ended_on_first_betting_round: bool,
) -> dict[str, Any]:
    require(type(pot_cents) is int and pot_cents >= 0, "gross pot must be a non-negative integer number of cents")
    require(type(rate_numerator) is int and rate_numerator > 0, "rate numerator must be a positive integer")
    require(type(rate_denominator) is int and rate_denominator > 0, "rate denominator must be a positive integer")
    require(type(cap_cents) is int and cap_cents >= 0, "cap must be a non-negative integer number of cents")
    require(type(ended_on_first_betting_round) is bool, "endedOnFirstBettingRound must be a boolean")
    uncapped = half_even(pot_cents * rate_numerator, rate_denominator)
    rake = 0 if ended_on_first_betting_round else min(cap_cents, uncapped)
    return {
        "uncappedRoundedCents": uncapped,
        "rakeCents": rake,
        "netPotCents": pot_cents - rake,
        "capDisplayed": not ended_on_first_betting_round and rake == cap_cents,
        "capStrictlyBinding": not ended_on_first_betting_round and uncapped > cap_cents,
        "effectiveRate": None if pot_cents == 0 else (rake, pot_cents),
    }


MONEY_PATTERN = re.compile(r"(?:0|[1-9][0-9]*)(?:\.[0-9]{1,2})?\Z", re.ASCII)


def parse_usd_cents(value: str) -> int:
    require(type(value) is str and MONEY_PATTERN.fullmatch(value) is not None,
            "money must be unsigned plain decimal text with at most two fractional digits")
    whole, separator, fractional = value.partition(".")
    return int(whole) * 100 + int((fractional if separator else "").ljust(2, "0") or "0")


def validate_snapshot(snapshot: Any) -> None:
    require(type(snapshot) is dict, "source snapshot must be an object")
    require(snapshot.get("schemaVersion") == 2, "unexpected source snapshot schema version")
    require(snapshot.get("snapshotDate") == "2026-09-04", "source snapshot date drift")
    require(snapshot.get("accessedAt") == EXPECTED_ACCESSED_AT, "source access timestamp drift")
    require(snapshot.get("operator") == "PokerStars", "source operator drift")
    require(snapshot.get("sourceUrl") == EXPECTED_SOURCE_URL, "source URL drift")
    require(snapshot.get("sourceReceipt") == {
        "httpStatus": 200,
        "rawHtmlSha256": "f7804b8336b2f8204a7ba2a26b6bbe6f45e192c0cf50522b88ce41fb792771ee",
    }, "source HTTP receipt drift")
    require(snapshot.get("currency") == "USD", "source currency must be USD")
    require(snapshot.get("playerBucketDefinition") ==
            "Players dealt into that particular hand, not players seated at the table",
            "players-dealt definition drift")
    require(snapshot.get("eligibilityStatement") ==
            "No rake if a Hold'em or Omaha hand ends before the flop",
            "no-flop/no-drop statement drift")
    require(snapshot.get("roundingStatement") == "True percentage using round half to even",
            "rounding statement drift")
    require("integer-cent ledger" in snapshot.get("worksheetPrecisionConvention", ""),
            "nearest-cent worksheet assumption is missing")
    expected_families = {
        "standard_usd_nl_pl": {"sourceTableLabel": "US Dollar Ring Games — No Limit and Pot Limit"},
        "usd_micro_omaha": {"sourceTableLabel": "No Limit, Pot Limit Omaha and Omaha Hi/Lo Micro Stakes"},
        "usd_micro_nlhe_zoom": {"sourceTableLabel": "No Limit Hold'em Zoom Micro Stakes"},
    }
    require(snapshot.get("scheduleFamilies") == expected_families,
            "schedule family labels drift")

    rows = snapshot.get("rows")
    require(type(rows) is list and len(rows) == 22, "source snapshot must contain exactly 22 rows")
    family_counts = Counter(row.get("scheduleFamily") for row in rows if type(row) is dict)
    require(family_counts == Counter({
        "standard_usd_nl_pl": 15,
        "usd_micro_omaha": 4,
        "usd_micro_nlhe_zoom": 3,
    }), f"schedule family split drift: {dict(family_counts)}")

    seen_routes: set[tuple[str, str]] = set()
    for index, (actual, frozen) in enumerate(zip(rows, FROZEN_ROWS)):
        require(type(actual) is dict, f"source row {index} must be an object")
        family, stakes, big_blind, rate_bps, caps = frozen
        route = (family, stakes)
        require(route not in seen_routes, f"duplicate frozen route: {route}")
        seen_routes.add(route)
        require(actual.get("scheduleFamily") == family, f"row {index} schedule family drift")
        require(actual.get("stakes") == stakes, f"row {index} exact stake label drift")
        require(actual.get("bigBlindCents") == big_blind, f"row {index} big blind drift")
        require(actual.get("rateBps") == rate_bps, f"row {index} rate drift")
        actual_caps = actual.get("capsCents")
        require(type(actual_caps) is dict and tuple(actual_caps.get(bucket) for bucket in PLAYER_BUCKETS) == caps,
                f"row {index} cap drift")
        require(set(actual_caps) == set(PLAYER_BUCKETS), f"row {index} player buckets drift")
        numerator, denominator = rate_fraction(rate_bps)
        require(math.gcd(numerator, denominator) == 1, f"row {index} rate is not reducible to an exact rational")

    grouped = rows[14]
    require(grouped.get("stakes") == "$100/$200 and above" and grouped.get("bigBlindCents") is None,
            "grouped $100/$200-and-above source row must remain explicit")
    require("omitted" in grouped.get("normalizationNote", ""),
            "grouped source row must explain omitted big-blind normalization")
    require(tuple(rows[12]["capsCents"][bucket] for bucket in PLAYER_BUCKETS) == (225, 200, 300),
            "$25/$50 non-monotone caps were not preserved")


def route_map(snapshot: dict[str, Any]) -> dict[tuple[str, str], dict[str, Any]]:
    return {(row["scheduleFamily"], row["stakes"]): row for row in snapshot["rows"]}


def resolve_schedule(
    snapshot: dict[str, Any],
    family: str,
    stakes: str,
    dealt_player_count: int,
    ended_on_first_betting_round: bool,
    currency: str = "USD",
) -> tuple[dict[str, Any], str]:
    require(currency == "USD", "unsupported currency")
    require(type(family) is str and type(stakes) is str, "schedule route must use exact strings")
    require(type(dealt_player_count) is int and dealt_player_count >= 2,
            "dealt player count must be an integer of at least two")
    require(type(ended_on_first_betting_round) is bool,
            "endedOnFirstBettingRound must be present and boolean")
    row = route_map(snapshot).get((family, stakes))
    require(row is not None, "not in this dated schedule")
    bucket = "2" if dealt_player_count == 2 else "3-4" if dealt_player_count <= 4 else "5+"
    return row, bucket


def expected_schedule_rows(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
    expected: list[dict[str, Any]] = []
    for source_row in snapshot["rows"]:
        rate_bps = source_row["rateBps"]
        rate_numerator, rate_denominator = rate_fraction(rate_bps)
        for bucket in PLAYER_BUCKETS:
            cap_cents = source_row["capsCents"][bucket]
            display = displayed_cap_onset(rate_numerator, rate_denominator, cap_cents)
            raw = raw_cap_reach(rate_numerator, rate_denominator, cap_cents)
            binding = strictly_binding_onset(rate_numerator, rate_denominator, cap_cents)
            exact_numerator = cap_cents * 10_000
            divisor = math.gcd(exact_numerator, rate_bps)
            exact_denominator = rate_bps // divisor
            exact_numerator //= divisor
            big_blind = source_row["bigBlindCents"]
            expected.append({
                "scheduleFamily": source_row["scheduleFamily"],
                "scheduleKey": f'{source_row["scheduleFamily"]}|{source_row["stakes"]}|{bucket}',
                "sourceTableLabel": snapshot["scheduleFamilies"][source_row["scheduleFamily"]]["sourceTableLabel"],
                "stakes": source_row["stakes"],
                "bigBlindCents": big_blind,
                "rateBps": rate_bps,
                "ratePercent": format_fraction(rate_bps, 100, 2),
                "playersDealtBucket": bucket,
                "capCents": cap_cents,
                "capDollars": format_money(cap_cents),
                "capBigBlinds": None if big_blind is None else format_fraction(cap_cents, big_blind, 4),
                "continuousBreakpointPotCentsExact": f"{exact_numerator}/{exact_denominator}",
                "continuousBreakpointDollars": format_fraction(cap_cents * 100, rate_bps, 4),
                "continuousBreakpointBigBlinds": None if big_blind is None else
                    format_fraction(cap_cents * 10_000, rate_bps * big_blind, 4),
                "displayedCapOnsetCents": display,
                "displayedCapOnsetDollars": format_money(display),
                "displayedCapOnsetBigBlinds": None if big_blind is None else format_fraction(display, big_blind, 4),
                "rawCapReachCents": raw,
                "rawCapReachDollars": format_money(raw),
                "rawCapReachBigBlinds": None if big_blind is None else format_fraction(raw, big_blind, 4),
                "strictlyBindingOnsetCents": binding,
                "strictlyBindingOnsetDollars": format_money(binding),
                "strictlyBindingOnsetBigBlinds": None if big_blind is None else format_fraction(binding, big_blind, 4),
                "sourceUrl": snapshot["sourceUrl"],
                "sourceAccessDate": snapshot["snapshotDate"],
                "sourceAccessedAt": snapshot["accessedAt"],
                "worksheetCentConvention": True,
                "normalizationNote": source_row.get("normalizationNote", ""),
            })
    return expected


def expected_worked_rows() -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    big_blind_cents = 50
    rate_bps = 500
    for bucket, cap_cents in (("2", 75), ("5+", 200)):
        for pot_big_blinds in (10, 20, 30, 40, 60, 80, 100):
            pot_cents = pot_big_blinds * big_blind_cents
            uncapped = half_even(pot_cents * rate_bps, 10_000)
            rake = min(cap_cents, uncapped)
            rows.append({
                "scheduleFamily": "standard_usd_nl_pl",
                "scheduleKey": f"standard_usd_nl_pl|$0.25/$0.50|{bucket}",
                "stakes": "$0.25/$0.50",
                "playersDealtBucket": bucket,
                "grossPotCents": pot_cents,
                "potBigBlinds": pot_big_blinds,
                "grossPotDollars": format_money(pot_cents),
                "ratePercent": "5.00",
                "rateBps": rate_bps,
                "capDollars": format_money(cap_cents),
                "capCents": cap_cents,
                "uncappedRoundedCents": uncapped,
                "uncappedRoundedRakeDollars": format_money(uncapped),
                "rakeCents": rake,
                "rakeDollars": format_money(rake),
                "netPotCents": pot_cents - rake,
                "netPotDollars": format_money(pot_cents - rake),
                "rakeBigBlinds": format_fraction(rake, big_blind_cents, 4),
                "effectiveRakePercent": format_fraction(rake * 100, pot_cents, 4),
                "capDisplayed": rake == cap_cents,
                "capStrictlyBinding": uncapped > cap_cents,
                "endedOnFirstBettingRound": False,
                "eligible": True,
                "sourceScheduleKey": f"standard_usd_nl_pl|$0.25/$0.50|{bucket}",
                "scheduleAccessDate": "2026-09-04",
                "assumptionNotice": "Nearest-cent final-gross-pot worksheet convention; not an operator-ledger reconstruction.",
                "worksheetCentConvention": True,
            })
    return rows


def record_matches(actual: Any, expected: dict[str, Any], label: str) -> None:
    require(type(actual) is dict, f"{label} must be an object")
    missing = set(expected) - set(actual)
    require(not missing, f"{label} is missing fields: {sorted(missing)}")
    for key, value in expected.items():
        require(actual.get(key) == value,
                f"{label}.{key} drift: expected {value!r}, got {actual.get(key)!r}")


def csv_value(value: Any) -> str:
    if value is None:
        return ""
    if type(value) is bool:
        return "true" if value else "false"
    return str(value)


def verify_csv(name: str, expected_rows: list[dict[str, Any]], columns: tuple[tuple[str, str], ...]) -> None:
    path = DATA_DIRECTORY / name
    require(path.is_file(), f"missing required artifact: {name}")
    try:
        with path.open("r", encoding="utf-8", newline="") as handle:
            reader = csv.DictReader(handle)
            actual_rows = list(reader)
            require(tuple(reader.fieldnames or ()) == tuple(header for _, header in columns),
                    f"{name} column schema or order drift")
    except (OSError, csv.Error) as error:
        raise VerificationError(f"cannot read {name}: {error}") from error
    require(len(actual_rows) == len(expected_rows),
            f"{name} row count drift: expected {len(expected_rows)}, got {len(actual_rows)}")
    for index, (actual, expected) in enumerate(zip(actual_rows, expected_rows)):
        expected_csv = {header: csv_value(expected[key]) for key, header in columns}
        require(actual == expected_csv, f"{name} row {index + 2} differs from the independent model")


def verify_reference(reference: Any, snapshot: dict[str, Any], schedule_rows: list[dict[str, Any]],
                     worked_rows: list[dict[str, Any]]) -> None:
    require(type(reference) is dict, "reference.json must contain an object")
    require(reference.get("schemaVersion") == 2, "reference schema version drift")
    require(reference.get("generatedOn") == "2026-09-04", "reference generation date drift")
    require("half_even" in reference.get("formula", {}).get("uncappedRakeCents", ""),
            "reference must name half-even arithmetic")
    formula_text = " ".join(
        f"{key} {value}" for key, value in reference.get("formula", {}).items()
    ).lower()
    for term in ("displayed", "raw", "strictly"):
        require(term in formula_text, f"reference formula omits the {term} cap threshold")
    require(reference.get("precisionBoundary") == snapshot["worksheetPrecisionConvention"],
            "reference precision boundary differs from the snapshot")

    source_reference = reference.get("sourceSnapshot", {})
    for key in ("operator", "sourceUrl", "sourceReceipt", "accessedAt", "sourceSection", "currency", "tableScope",
                "printedExclusions", "playerBucketDefinition", "eligibilityStatement", "roundingStatement",
                "scheduleFamilies"):
        require(source_reference.get(key) == snapshot.get(key), f"reference sourceSnapshot.{key} drift")
    require(source_reference.get("stakeRowCount") == 22, "reference source row count drift")

    actual_schedule = reference.get("scheduleRows")
    require(type(actual_schedule) is list and len(actual_schedule) == 66,
            "reference must contain exactly 66 normalized schedule rows")
    for index, (actual, expected) in enumerate(zip(actual_schedule, schedule_rows)):
        record_matches(actual, expected, f"reference schedule row {index}")

    selected = reference.get("selectedWorkedScheduleRows")
    selected_expected = [row for row in schedule_rows
                         if row["scheduleFamily"] == "standard_usd_nl_pl" and row["stakes"] == "$0.25/$0.50"]
    require(selected == selected_expected,
            "selected worked schedule rows must contain only the three standard $0.25/$0.50 buckets")

    actual_worked = reference.get("workedRows")
    require(type(actual_worked) is list and len(actual_worked) == 14,
            "reference must contain exactly 14 worked rows")
    for index, (actual, expected) in enumerate(zip(actual_worked, worked_rows)):
        record_matches(actual, expected, f"reference worked row {index}")

    fixed_checks = reference.get("fixedChecks", {})
    expected_checks = {
        "twoPlayerContinuousBreakpointDollars": "15.0000",
        "twoPlayerContinuousBreakpointBigBlinds": "30.0000",
        "fivePlusContinuousBreakpointDollars": "40.0000",
        "fivePlusContinuousBreakpointBigBlinds": "80.0000",
        "twoPlayerEffectiveRateAt100bbPercent": "1.5000",
        "fivePlusEffectiveRateAt100bbPercent": "4.0000",
        "twoPlayerDisplayedCapOnsetDollars": "14.91",
        "fivePlusDisplayedCapOnsetDollars": "39.90",
        "twoPlayerRawCapReachDollars": "15.00",
        "fivePlusRawCapReachDollars": "40.00",
        "twoPlayerStrictlyBindingOnsetDollars": "15.10",
        "fivePlusStrictlyBindingOnsetDollars": "40.11",
    }
    require(fixed_checks == expected_checks, "reference fixed checks drift")
    boundaries = " ".join(reference.get("boundaries", [])).lower()
    for term in ("date-specific", "nearest-cent", "grouped", "optimal action"):
        require(term in boundaries, f"reference boundaries omit {term!r}")


def validate_tie_fixtures(rounding: Callable[[int, int], int]) -> None:
    for rate_bps, pot_cents, expected in TIE_FIXTURES:
        numerator = pot_cents * rate_bps
        require((numerator * 2) % 10_000 == 0, "tie fixture is not an exact half-cent")
        require(rounding(numerator, 10_000) == expected,
                f"half-even tie fixture failed for rate {rate_bps} bps at {pot_cents} cents")


def brute_breakpoints(rate_numerator: int, rate_denominator: int, cap_cents: int,
                      upper_bound: int) -> tuple[int, int, int]:
    first_displayed = None
    first_raw = None
    first_binding = None
    for pot_cents in range(upper_bound + 1):
        uncapped = half_even(pot_cents * rate_numerator, rate_denominator)
        if first_displayed is None and uncapped >= cap_cents:
            first_displayed = pot_cents
        if first_raw is None and pot_cents * rate_numerator >= cap_cents * rate_denominator:
            first_raw = pot_cents
        if first_binding is None and uncapped > cap_cents:
            first_binding = pot_cents
            break
    require(first_displayed is not None and first_raw is not None and first_binding is not None,
            "brute-force breakpoint search did not converge")
    return first_displayed, first_raw, first_binding


def verify_breakpoints(snapshot: dict[str, Any], schedule_rows: list[dict[str, Any]]) -> None:
    for source_row in snapshot["rows"]:
        rate_numerator, rate_denominator = rate_fraction(source_row["rateBps"])
        for bucket in PLAYER_BUCKETS:
            cap_cents = source_row["capsCents"][bucket]
            display = displayed_cap_onset(rate_numerator, rate_denominator, cap_cents)
            raw = raw_cap_reach(rate_numerator, rate_denominator, cap_cents)
            binding = strictly_binding_onset(rate_numerator, rate_denominator, cap_cents)
            brute_display, brute_raw, brute_binding = brute_breakpoints(
                rate_numerator, rate_denominator, cap_cents, binding + 2
            )
            label = f'{source_row["scheduleFamily"]}|{source_row["stakes"]}|{bucket}'
            require((display, raw, binding) == (brute_display, brute_raw, brute_binding),
                    f"formula/brute-force breakpoint mismatch for {label}")
            require(display <= raw <= binding, f"breakpoint ordering failed for {label}")
    require(len(schedule_rows) == 66, "breakpoint verification did not cover 66 combinations")


def verify_exhaustive_grid(snapshot: dict[str, Any]) -> int:
    checked = 0
    for source_row in snapshot["rows"]:
        rate_numerator, rate_denominator = rate_fraction(source_row["rateBps"])
        for bucket in PLAYER_BUCKETS:
            cap_cents = source_row["capsCents"][bucket]
            binding = strictly_binding_onset(rate_numerator, rate_denominator, cap_cents)
            previous_uncapped = -1
            previous_rake = -1
            for pot_cents in range(MAX_EXHAUSTIVE_POT_CENTS + 1):
                numerator = pot_cents * rate_numerator
                uncapped = half_even(numerator, rate_denominator)
                rake = min(cap_cents, uncapped)
                cap_before_round = half_even(min(numerator, cap_cents * rate_denominator), rate_denominator)
                require(0 <= rake <= cap_cents, "exhaustive cap bound failed")
                require(rake <= pot_cents, "exhaustive rake-versus-pot bound failed")
                require((pot_cents - rake) + rake == pot_cents, "exhaustive net-pot identity failed")
                require(uncapped >= previous_uncapped and rake >= previous_rake,
                        "exhaustive monotonicity failed")
                if previous_uncapped >= 0:
                    require(uncapped - previous_uncapped in (0, 1),
                            "uncapped rake changed by more than one cent")
                    require(rake - previous_rake in (0, 1),
                            "capped rake changed by more than one cent")
                require(rake == cap_before_round, "cap/round ordering equivalence failed")
                if pot_cents >= binding:
                    require(rake == cap_cents, "rake did not remain capped after binding onset")
                previous_uncapped = uncapped
                previous_rake = rake
                checked += 1

            for pot_cents in (0, max(0, binding - 1), binding, MAX_EXHAUSTIVE_POT_CENTS):
                details = rake_details(
                    pot_cents, rate_numerator, rate_denominator, cap_cents, True
                )
                require(details["rakeCents"] == 0 and details["netPotCents"] == pot_cents,
                        "no-flop/no-drop override failed")
                require(not details["capDisplayed"] and not details["capStrictlyBinding"],
                        "ineligible hand must not report a displayed or binding cap")
    return checked


def expect_verification_failure(operation: Callable[[], None], label: str) -> None:
    failed_closed = False
    try:
        operation()
    except VerificationError:
        failed_closed = True
    require(failed_closed, f"tamper simulation did not fail closed: {label}")


def verify_routing_and_regressions(snapshot: dict[str, Any]) -> None:
    # Strict input parsing: only a plain unsigned decimal is normalized.
    require(parse_usd_cents("0.10") == 10 and parse_usd_cents("0") == 0 and
            parse_usd_cents("1000.00") == 100_000, "documented money parser normalization failed")
    for invalid in ("$0.10", "10¢", "1,000.00", "0.001", "-0", "1e2", " 0.10", "+0.10"):
        expect_verification_failure(lambda value=invalid: parse_usd_cents(value), f"money syntax {invalid!r}")

    for invalid_count in (1, 2.5, True, None):
        expect_verification_failure(
            lambda count=invalid_count: resolve_schedule(
                snapshot, "standard_usd_nl_pl", "$0.25/$0.50", count, False
            ),
            f"dealt player count {invalid_count!r}",
        )
    expect_verification_failure(
        lambda: resolve_schedule(snapshot, "standard_usd_nl_pl", "$200/$400", 2, False),
        "implicit high-stakes fallback",
    )
    expect_verification_failure(
        lambda: resolve_schedule(snapshot, "standard_usd_nl_pl", "$0.25/$0.50", 2, None),
        "missing first-round status",
    )
    expect_verification_failure(
        lambda: resolve_schedule(snapshot, "standard_usd_nl_pl", "$0.25/$0.50", 2, False, "EUR"),
        "unsupported currency",
    )
    for invalid_pot in (-1, 1.5, True):
        expect_verification_failure(
            lambda pot=invalid_pot: rake_details(pot, 1, 20, 75, False),
            f"invalid pot {invalid_pot!r}",
        )

    # Half-even and the three nearby $0.75-cap thresholds.
    validate_tie_fixtures(half_even)
    expected_75 = ((1_490, 74, 74), (1_491, 75, 75), (1_500, 75, 75), (1_510, 76, 75))
    for pot_cents, expected_uncapped, expected_rake in expected_75:
        details = rake_details(pot_cents, 1, 20, 75, False)
        require(details["uncappedRoundedCents"] == expected_uncapped and
                details["rakeCents"] == expected_rake,
                f"$0.75-cap regression failed at {pot_cents} cents")
    require((displayed_cap_onset(1, 20, 75), raw_cap_reach(1, 20, 75),
             strictly_binding_onset(1, 20, 75)) == (1_491, 1_500, 1_510),
            "$0.75-cap breakpoint regression failed")

    # Player-count routing at standard $0.10/$0.25 and explicit family routing.
    player_expected = {2: 50, 4: 100, 6: 135}
    for player_count, expected_rake in player_expected.items():
        row, bucket = resolve_schedule(snapshot, "standard_usd_nl_pl", "$0.10/$0.25",
                                       player_count, False)
        numerator, denominator = rate_fraction(row["rateBps"])
        details = rake_details(3_000, numerator, denominator, row["capsCents"][bucket], False)
        require(details["rakeCents"] == expected_rake,
                f"players-dealt routing regression failed for {player_count} players")

    family_expected = {
        "standard_usd_nl_pl": 50,
        "usd_micro_omaha": 42,
        "usd_micro_nlhe_zoom": 45,
    }
    for family, expected_rake in family_expected.items():
        row, bucket = resolve_schedule(snapshot, family, "$0.05/$0.10", 2, False)
        numerator, denominator = rate_fraction(row["rateBps"])
        details = rake_details(1_000, numerator, denominator, row["capsCents"][bucket], False)
        require(details["rakeCents"] == expected_rake,
                f"explicit schedule-family routing failed for {family}")

    row, _ = resolve_schedule(snapshot, "standard_usd_nl_pl", "$25/$50", 2, False)
    require(tuple(min(row["capsCents"][bucket], half_even(10_000 * 450, 10_000))
                  for bucket in PLAYER_BUCKETS) == (225, 200, 300),
            "$25/$50 worked cap regression failed")
    require(rake_details(0, 1, 20, 75, False)["effectiveRate"] is None,
            "zero-pot effective rate must be null/not applicable")


def verify_tamper_detection(snapshot: dict[str, Any], reference: dict[str, Any],
                            schedule_rows: list[dict[str, Any]], worked_rows: list[dict[str, Any]]) -> None:
    mutations: list[tuple[str, Callable[[dict[str, Any]], None]]] = [
        ("rate", lambda data: data["rows"][0].__setitem__("rateBps", 501)),
        ("cap", lambda data: data["rows"][0]["capsCents"].__setitem__("2", 99)),
        ("route label", lambda data: data["rows"][0].__setitem__("stakes", "$0.01/$0.03")),
        ("access date", lambda data: data.__setitem__("accessedAt", "2026-09-05T00:00:00+02:00")),
    ]
    for label, mutate in mutations:
        candidate = copy.deepcopy(snapshot)
        mutate(candidate)
        expect_verification_failure(lambda data=candidate: validate_snapshot(data), label)

    expect_verification_failure(lambda: validate_tie_fixtures(half_up), "rounding parity branch")
    changed_reference = copy.deepcopy(reference)
    changed_reference["scheduleRows"][0]["displayedCapOnsetCents"] += 1
    expect_verification_failure(
        lambda: verify_reference(changed_reference, snapshot, schedule_rows, worked_rows),
        "derived output",
    )


class OfflineHtmlInspector:
    """Small state container populated by a conservative HTML token scan."""

    def __init__(self, html: str) -> None:
        self.html = html

    def verify(self) -> None:
        lower = self.html.lower()
        require("<script src=" not in lower and "<script type=\"module\" src=" not in lower,
                "calculator must not load an external script")
        require("<iframe" not in lower, "calculator must not embed an external frame")
        for token in ("fetch(", "xmlhttprequest", "websocket", "import(", "math.round", "parsefloat("):
            require(token not in lower, f"calculator contains forbidden dependency/arithmetic token: {token}")
        require("bigint" in lower and "halfeven" in lower,
                "calculator must implement BigInt half-even arithmetic")
        for family in ("standard_usd_nl_pl", "usd_micro_omaha", "usd_micro_nlhe_zoom"):
            require(family in self.html, f"calculator omits schedule family {family}")
        for token in ("schedule", "players dealt", "endedOnFirstBettingRound", "strictly binding"):
            require(token.lower() in lower, f"calculator omits required schedule-aware control/model term: {token}")
        require(self.html.count('"scheduleFamily"') >= 22,
                "calculator must embed all 22 explicit schedule-row choices")
        require("$100/$200 and above" in self.html,
                "calculator must expose the explicit grouped high-stakes row")


def webp_dimensions(path: Path) -> tuple[int, int]:
    data = path.read_bytes()
    require(len(data) >= 30 and data[:4] == b"RIFF" and data[8:12] == b"WEBP",
            "hero is not a valid RIFF WebP file")
    offset = 12
    while offset + 8 <= len(data):
        chunk_type = data[offset:offset + 4]
        chunk_size = struct.unpack_from("<I", data, offset + 4)[0]
        payload = offset + 8
        require(payload + chunk_size <= len(data), "truncated WebP chunk")
        if chunk_type == b"VP8X":
            require(chunk_size >= 10, "invalid VP8X chunk")
            width = 1 + int.from_bytes(data[payload + 4:payload + 7], "little")
            height = 1 + int.from_bytes(data[payload + 7:payload + 10], "little")
            return width, height
        if chunk_type == b"VP8 ":
            require(chunk_size >= 10 and data[payload + 3:payload + 6] == b"\x9d\x01\x2a",
                    "invalid VP8 frame header")
            width = struct.unpack_from("<H", data, payload + 6)[0] & 0x3FFF
            height = struct.unpack_from("<H", data, payload + 8)[0] & 0x3FFF
            return width, height
        if chunk_type == b"VP8L":
            require(chunk_size >= 5 and data[payload] == 0x2F, "invalid VP8L frame header")
            bits = int.from_bytes(data[payload + 1:payload + 5], "little")
            return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
        offset = payload + chunk_size + (chunk_size % 2)
    raise VerificationError("WebP contains no recognized dimension-bearing chunk")


def verify_visuals() -> None:
    hero = DATA_DIRECTORY / "poker-rake-cap-math.webp"
    chart = DATA_DIRECTORY / "poker-rake-cap-curves.svg"
    require(hero.is_file() and chart.is_file(), "bundle visual artifacts are missing")
    require(webp_dimensions(hero) == (1200, 800), "hero dimensions must be exactly 1200x800")
    try:
        root = ET.fromstring(chart.read_text(encoding="utf-8"))
    except (OSError, ET.ParseError) as error:
        raise VerificationError(f"cannot parse chart SVG: {error}") from error
    require(root.attrib.get("viewBox") == "0 0 960 520", "chart viewBox must be exactly 960x520")
    svg_text = chart.read_text(encoding="utf-8")
    require("<title" in svg_text and "<desc" in svg_text,
            "chart must include accessible title and description text")


def verify_documentation_and_specification() -> None:
    report = read_json("verification-report.json")
    require(report.get("recordType") == "verification-specification" and report.get("status") == "not-executed",
            "verification-report must remain an honest pre-execution specification")
    require("not proof" in report.get("statusBoundary", ""),
            "verification-report must distinguish specification from execution evidence")
    checks = report.get("checks", {})
    expected_checks = {
        "sourceStakeRows": 22,
        "normalizedScheduleRows": 66,
        "workedPotSizes": 7,
        "workedRows": 14,
        "halfEvenTieFixtures": 14,
        "manifestArtifacts": 11,
    }
    for key, value in expected_checks.items():
        require(checks.get(key) == value, f"verification-report check count drift: {key}")
    boundaries = " ".join(report.get("requiredBoundaries", [])).lower().replace("-", " ")
    for term in ("displayed", "raw", "strictly binding", "nearest cent", "grouped", "strategy"):
        require(term in boundaries, f"verification-report boundaries omit {term!r}")

    readme = (DATA_DIRECTORY / "README.md").read_text(encoding="utf-8")
    readme_lower = readme.lower()
    for term in ("22 stake/family rows", "66 normalized", "first pot that displays",
                 "raw percentage reaches", "first pot where the cap changes",
                 "nearest-cent", "players dealt", "date-specific", "python public/data/poker-rake-cap/verify.py"):
        require(term in readme_lower, f"README omits required disclosure/instruction: {term!r}")


def verify_generator_and_calculator() -> None:
    generator = (DATA_DIRECTORY / "generate.mjs").read_text(encoding="utf-8")
    require("BigInt" in generator and "roundHalfEven" in generator,
            "generator must use BigInt half-even arithmetic")
    require("Math.round" not in generator, "generator must not use binary-float Math.round")
    for exported_name in ("firstDisplayedCapPot", "firstRawCapReachPot", "firstStrictlyBindingCapPot"):
        require(f"export const {exported_name}" in generator,
                f"generator omits {exported_name}")
    calculator = (DATA_DIRECTORY / "calculator.html").read_text(encoding="utf-8")
    OfflineHtmlInspector(calculator).verify()


def verify_manifest() -> None:
    manifest_path = DATA_DIRECTORY / "MANIFEST.sha256"
    require(manifest_path.is_file(), "MANIFEST.sha256 is missing")
    lines = manifest_path.read_text(encoding="ascii").splitlines()
    require(len(lines) == len(EXPECTED_MANIFEST_FILES), "manifest must contain exactly 11 entries")
    parsed: dict[str, str] = {}
    pattern = re.compile(r"([0-9a-f]{64})  ([A-Za-z0-9._-]+)\Z", re.ASCII)
    for line_number, line in enumerate(lines, start=1):
        match = pattern.fullmatch(line)
        require(match is not None, f"invalid manifest syntax on line {line_number}")
        digest, name = match.groups()
        require(name not in parsed, f"duplicate manifest entry: {name}")
        parsed[name] = digest
    require(tuple(parsed) == EXPECTED_MANIFEST_FILES, "manifest file set or order drift")
    for name, expected_digest in parsed.items():
        path = DATA_DIRECTORY / name
        require(path.is_file(), f"manifest target is missing: {name}")
        actual_digest = hashlib.sha256(path.read_bytes()).hexdigest()
        require(actual_digest == expected_digest, f"manifest hash mismatch: {name}")


def verify_no_optimized_mode_gap() -> None:
    tree = ast.parse(Path(__file__).read_text(encoding="utf-8"), filename=str(__file__))
    require(not any(isinstance(node, ast.Assert) for node in ast.walk(tree)),
            "verifier contains an optimization-removable assertion statement")


def main() -> int:
    snapshot = read_json("source-snapshot.json")
    validate_snapshot(snapshot)
    schedule_rows = expected_schedule_rows(snapshot)
    worked_rows = expected_worked_rows()
    require(len(schedule_rows) == 66, "independent normalizer did not produce 66 rows")
    require(len({row["scheduleKey"] for row in schedule_rows}) == 66,
            "normalized schedule keys must be unique")

    reference = read_json("reference.json")
    verify_reference(reference, snapshot, schedule_rows, worked_rows)
    verify_csv("schedule-breakpoints.csv", schedule_rows, SCHEDULE_CSV_COLUMNS)
    verify_csv("worked-case.csv", worked_rows, WORKED_CSV_COLUMNS)
    verify_routing_and_regressions(snapshot)
    verify_breakpoints(snapshot, schedule_rows)
    checked_grid_points = verify_exhaustive_grid(snapshot)
    verify_tamper_detection(snapshot, reference, schedule_rows, worked_rows)
    verify_generator_and_calculator()
    verify_documentation_and_specification()
    verify_visuals()
    verify_no_optimized_mode_gap()
    verify_manifest()

    print(
        "Verified poker rake-cap bundle: "
        f"22 source rows, 66 schedule buckets, {len(TIE_FIXTURES)} tie fixtures, "
        f"{checked_grid_points:,} cent-grid cases, 3 breakpoints per bucket, "
        f"and {len(EXPECTED_MANIFEST_FILES)} manifest hashes."
    )
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except VerificationError as error:
        print(f"VERIFICATION FAILED: {error}", file=sys.stderr)
        raise SystemExit(1) from error
