#!/usr/bin/env python3
"""Independent standard-library verifier for the published reopening fixtures."""

from __future__ import annotations

import csv
import json
import re
import sys
from pathlib import Path
from typing import Optional


SCALE = 1_000_000
MAX_SCALED = 1_000_000_000_000 * SCALE
AMOUNT_RE = re.compile(r"(?:0|[1-9][0-9]{0,12})(?:\.[0-9]{1,6})?\Z")
TDA_URL = "https://www.pokertda.com/view-poker-tda-rules/"
WSOP_URL = "https://assets.wsopcdn.com/wsop/1a72ba28-781c-409d-a9c3-5ca13c4c5718.pdf"
TDA_RULESET = "Poker TDA 2024 Rules Version 1.0, Rules 43 and 47"
WSOP_RULESET = "2026 WSOP Tournament Rules, Rule 96 comparison"

CSV_HEADERS = [
    "id",
    "claimSet",
    "ruleset",
    "sourceLocation",
    "street",
    "player",
    "playerState",
    "hasActed",
    "currentWager",
    "wagerLevelAfterLastAction",
    "lastFullIncrement",
    "incrementNowFaced",
    "reopeningThresholdWagerLevel",
    "applicable",
    "mayRaise",
    "minimumFullRaiseTo",
    "reasonCode",
]


def pinned(
    claim_set: str,
    source_location: str,
    ruleset: str,
    source_url: str,
    current: str,
    after: Optional[str],
    last_full: str,
    acted: bool,
    state: str,
    applicable: bool,
    may_raise: Optional[bool],
    increment: Optional[str],
    threshold: Optional[str],
    minimum: Optional[str],
    reason: str,
) -> dict[str, object]:
    return {
        "claimSet": claim_set,
        "sourceLocation": source_location,
        "ruleset": ruleset,
        "sourceUrl": source_url,
        "input": {
            "currentWager": current,
            "lastFullIncrement": last_full,
            "hasActed": acted,
            "wagerLevelAfterLastAction": after,
            "playerState": state,
        },
        "expected": {
            "applicable": applicable,
            "mayRaise": may_raise,
            "incrementNowFaced": increment,
            "reopeningThresholdWagerLevel": threshold,
            "minimumFullRaiseTo": minimum,
            "reasonCode": reason,
        },
    }


# These source sentinels are intentionally hand-authored rather than loaded from
# the JSON. A coherently edited JSON/CSV pair therefore still fails verification.
PINNED = {
    "tda-47-example-1-player-a": pinned(
        "tda-validation", "Illustration Addendum to Rule 47, Example 1", TDA_RULESET, TDA_URL,
        "200", "100", "100", True, "active", True, True, "100", "200", "300",
        "acted-player-faces-full-increment",
    ),
    "tda-47-example-1-a": pinned(
        "tda-validation", "Illustration Addendum to Rule 47, Example 1-A", TDA_RULESET, TDA_URL,
        "200", "125", "100", True, "active", True, False, "75", "225", None,
        "acted-player-below-full-increment",
    ),
    "tda-47-example-1-b": pinned(
        "tda-validation", "Illustration Addendum to Rule 47, Example 1-B", TDA_RULESET, TDA_URL,
        "300", "125", "100", True, "active", True, True, "175", "225", "400",
        "acted-player-faces-full-increment",
    ),
    "tda-47-example-2": pinned(
        "tda-validation", "Illustration Addendum to Rule 47, Example 2", TDA_RULESET, TDA_URL,
        "800", None, "300", False, "active", True, True, None, None, "1100",
        "unacted-player-retains-raise-rights",
    ),
    "tda-47-example-3-a-big-blind": pinned(
        "tda-validation", "Illustration Addendum to Rule 47, Example 3-A, big blind decision", TDA_RULESET, TDA_URL,
        "7500", None, "4000", False, "active", True, True, None, None, "11500",
        "unacted-player-retains-raise-rights",
    ),
    "tda-47-example-3-a-limper": pinned(
        "tda-validation", "Illustration Addendum to Rule 47, Example 3-A, limper decision", TDA_RULESET, TDA_URL,
        "7500", "4000", "4000", True, "active", True, False, "3500", "8000", None,
        "acted-player-below-full-increment",
    ),
    "tda-47-example-3-b": pinned(
        "tda-validation", "Illustration Addendum to Rule 47, Example 3-B", TDA_RULESET, TDA_URL,
        "11500", "4000", "4000", True, "active", True, True, "7500", "8000", "15500",
        "acted-player-faces-full-increment",
    ),
    "wsop-2026-rule-96-b-or-c": pinned(
        "wsop-comparison-not-tda-validation", "Rule 96(a)", WSOP_RULESET, WSOP_URL,
        "1700", "1000", "500", True, "active", True, True, "700", "1500", "2200",
        "acted-player-faces-full-increment",
    ),
    "tda-derived-opening-underbet-acted-checker": pinned(
        "tda-rule-derived", "Synthetic opening-underbet check for an acted player derived from Poker TDA 2024 Rules 43 and 47", TDA_RULESET, TDA_URL,
        "50", "0", "100", True, "active", True, False, "50", "100", None,
        "acted-player-below-full-increment",
    ),
    "tda-derived-opening-underbet-unacted-player": pinned(
        "tda-rule-derived", "Synthetic opening-underbet check for an unacted player derived from Poker TDA 2024 Rules 43 and 47", TDA_RULESET, TDA_URL,
        "50", None, "100", False, "active", True, True, None, None, "150",
        "unacted-player-retains-raise-rights",
    ),
    "synthetic-decimal-equality": pinned(
        "synthetic-boundary", "Synthetic equality boundary derived from Poker TDA 2024 Rules 43 and 47", TDA_RULESET, TDA_URL,
        "200.000001", "100.000001", "100", True, "active", True, True, "100", "200.000001", "300.000001",
        "acted-player-faces-full-increment",
    ),
    "synthetic-decimal-one-unit-short": pinned(
        "synthetic-boundary", "Synthetic below-boundary check derived from Poker TDA 2024 Rules 43 and 47", TDA_RULESET, TDA_URL,
        "200", "100.000001", "100", True, "active", True, False, "99.999999", "200.000001", None,
        "acted-player-below-full-increment",
    ),
    "synthetic-folded-not-applicable": pinned(
        "synthetic-boundary", "Synthetic applicability check derived from Poker TDA 2024 Rules 43 and 47", TDA_RULESET, TDA_URL,
        "200", "100", "100", True, "folded", False, None, None, None, None, "player-folded",
    ),
    "synthetic-all-in-not-applicable": pinned(
        "synthetic-boundary", "Synthetic applicability check derived from Poker TDA 2024 Rules 43 and 47", TDA_RULESET, TDA_URL,
        "200", "100", "100", True, "all-in", False, None, None, None, None, "player-all-in",
    ),
}


class VerificationError(Exception):
    pass


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


def parse_amount(value: object, field: str, *, positive: bool = False) -> int:
    require(type(value) is str, f"{field} must be an exact decimal string")
    require(AMOUNT_RE.fullmatch(value) is not None, f"{field} has invalid decimal syntax: {value!r}")
    whole, separator, fraction = value.partition(".")
    scaled = int(whole) * SCALE + int((fraction if separator else "").ljust(6, "0") or "0")
    require(scaled <= MAX_SCALED, f"{field} exceeds the 1-trillion input maximum")
    if positive:
        require(scaled > 0, f"{field} must be greater than zero")
    require(format_amount(scaled) == value, f"{field} is not normalized: {value!r}")
    return scaled


def format_amount(scaled: int) -> str:
    require(type(scaled) is int and scaled >= 0, "scaled amount must be a non-negative integer")
    whole, fraction = divmod(scaled, SCALE)
    fraction_text = f"{fraction:06d}".rstrip("0")
    return f"{whole}.{fraction_text}" if fraction_text else str(whole)


def calculate_fixture(fixture: dict[str, object]) -> dict[str, object]:
    inputs = fixture.get("input")
    require(type(inputs) is dict, f"{fixture.get('id')} input must be an object")
    current = parse_amount(inputs.get("currentWager"), "currentWager", positive=True)
    last_full = parse_amount(inputs.get("lastFullIncrement"), "lastFullIncrement", positive=True)
    acted = inputs.get("hasActed")
    state = inputs.get("playerState")
    require(type(acted) is bool, "hasActed must be boolean")
    require(state in {"active", "folded", "all-in"}, "playerState is unsupported")
    after = None
    if acted:
        after = parse_amount(inputs.get("wagerLevelAfterLastAction"), "wagerLevelAfterLastAction")
        require(after <= current, "wagerLevelAfterLastAction cannot exceed currentWager")
    else:
        require(inputs.get("wagerLevelAfterLastAction") is None, "unacted fixture history must be null")

    if state != "active":
        return {
            "applicable": False,
            "mayRaise": None,
            "incrementNowFaced": None,
            "reopeningThresholdWagerLevel": None,
            "minimumFullRaiseTo": None,
            "reasonCode": "player-folded" if state == "folded" else "player-all-in",
        }

    if not acted:
        return {
            "applicable": True,
            "mayRaise": True,
            "incrementNowFaced": None,
            "reopeningThresholdWagerLevel": None,
            "minimumFullRaiseTo": format_amount(current + last_full),
            "reasonCode": "unacted-player-retains-raise-rights",
        }

    increment = current - after
    threshold = after + last_full
    may_raise = increment >= last_full
    return {
        "applicable": True,
        "mayRaise": may_raise,
        "incrementNowFaced": format_amount(increment),
        "reopeningThresholdWagerLevel": format_amount(threshold),
        "minimumFullRaiseTo": format_amount(current + last_full) if may_raise else None,
        "reasonCode": (
            "acted-player-faces-full-increment"
            if may_raise
            else "acted-player-below-full-increment"
        ),
    }


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


def verify_json(path: Path) -> tuple[dict[str, object], list[dict[str, object]]]:
    try:
        document = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, UnicodeError, json.JSONDecodeError) as error:
        raise VerificationError(f"cannot read fixture JSON: {error}") from error

    require(type(document) is dict, "fixture JSON root must be an object")
    require(document.get("schemaVersion") == 1, "unexpected schemaVersion")
    require(document.get("published") == "2026-09-03", "unexpected publication date")
    require(document.get("primaryRuleset", {}).get("url") == TDA_URL, "primary TDA source URL changed")
    require(document.get("comparisonRuleset", {}).get("url") == WSOP_URL, "WSOP comparison source URL changed")
    require(document.get("numericContract", {}).get("maximumInput") == "1000000000000", "maximumInput changed")
    require(document.get("numericContract", {}).get("decimalPlaces") == 6, "decimalPlaces changed")
    require(document.get("numericContract", {}).get("randomSampling") is False, "fixture set must be deterministic")
    require("not part of the TDA validation claim" in document.get("modelClaim", ""), "WSOP/TDA claim boundary is missing")

    fixtures = document.get("fixtures")
    require(type(fixtures) is list, "fixtures must be an array")
    ids = [fixture.get("id") for fixture in fixtures if type(fixture) is dict]
    require(ids == list(PINNED), "fixture ids or order changed")

    for fixture in fixtures:
        fixture_id = fixture["id"]
        sentinel = PINNED[fixture_id]
        for field in ("claimSet", "sourceLocation", "ruleset", "sourceUrl", "input", "expected"):
            require(fixture.get(field) == sentinel[field], f"{fixture_id} changed pinned {field}")
        require(fixture.get("sourceAccessed") == "2026-09-03", f"{fixture_id} source access date changed")
        calculated = calculate_fixture(fixture)
        require(calculated == fixture.get("expected"), f"{fixture_id} expected result does not match independent calculation")

    serialized = json.dumps(document, ensure_ascii=False)
    require("matchedAmount" not in serialized, "deprecated matchedAmount field found")
    require("wagerLevelAfterLastAction" in serialized, "required wager history field missing")
    return document, fixtures


def verify_csv(path: Path, fixtures: list[dict[str, object]]) -> None:
    try:
        with path.open("r", encoding="utf-8", newline="") as handle:
            reader = csv.DictReader(handle)
            require(reader.fieldnames == CSV_HEADERS, "CSV header changed")
            rows = list(reader)
    except (OSError, UnicodeError, csv.Error) as error:
        raise VerificationError(f"cannot read result CSV: {error}") from error

    require(len(rows) == len(fixtures), "CSV row count does not match fixture JSON")
    for fixture, row in zip(fixtures, rows):
        calculated = calculate_fixture(fixture)
        inputs = fixture["input"]
        expected_row = {
            "id": fixture["id"],
            "claimSet": fixture["claimSet"],
            "ruleset": fixture["ruleset"],
            "sourceLocation": fixture["sourceLocation"],
            "street": fixture["street"],
            "player": fixture["player"],
            "playerState": inputs["playerState"],
            "hasActed": inputs["hasActed"],
            "currentWager": inputs["currentWager"],
            "wagerLevelAfterLastAction": inputs["wagerLevelAfterLastAction"],
            "lastFullIncrement": inputs["lastFullIncrement"],
            "incrementNowFaced": calculated["incrementNowFaced"],
            "reopeningThresholdWagerLevel": calculated["reopeningThresholdWagerLevel"],
            "applicable": calculated["applicable"],
            "mayRaise": calculated["mayRaise"],
            "minimumFullRaiseTo": calculated["minimumFullRaiseTo"],
            "reasonCode": calculated["reasonCode"],
        }
        normalized = {key: csv_value(value) for key, value in expected_row.items()}
        require(row == normalized, f"CSV row for {fixture['id']} differs from the independently calculated JSON row")


def verify_readme(path: Path) -> None:
    try:
        text = path.read_text(encoding="utf-8")
    except (OSError, UnicodeError) as error:
        raise VerificationError(f"cannot read public README: {error}") from error
    for required in (
        "Poker TDA 2024 Rules Version 1.0",
        "wsop-comparison-not-tda-validation",
        "does not calculate pot-limit maximums",
        "stack sufficiency",
        "topic=1456.0",
        "physical-chip or verbal-action meaning",
    ):
        require(required in text, f"README boundary is missing: {required}")


def main() -> int:
    base = Path(__file__).resolve().parent
    try:
        _, fixtures = verify_json(base / "short-all-in-reopening-fixtures.json")
        verify_csv(base / "short-all-in-reopening-results.csv", fixtures)
        verify_readme(base / "short-all-in-reopening-README.md")
    except VerificationError as error:
        print(f"Verification failed: {error}", file=sys.stderr)
        return 1

    tda_count = sum(fixture["claimSet"] == "tda-validation" for fixture in fixtures)
    comparison_count = sum(fixture["claimSet"] == "wsop-comparison-not-tda-validation" for fixture in fixtures)
    derived_count = sum(fixture["claimSet"] == "tda-rule-derived" for fixture in fixtures)
    synthetic_count = sum(fixture["claimSet"] == "synthetic-boundary" for fixture in fixtures)
    print(
        "Verified short-all-in reopening bundle: "
        f"{len(fixtures)} fixtures ({tda_count} TDA, {comparison_count} WSOP comparison, "
        f"{derived_count} TDA rule-derived, {synthetic_count} synthetic), exact six-decimal arithmetic, "
        "JSON/CSV parity."
    )
    return 0


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