#!/usr/bin/env python3
"""Independent standard-library verifier for the hand-for-hand casebook.

TDA rules used by permission of the Poker TDA, Copyright 2026, http://www.pokertda.com, All rights reserved.
"""

from __future__ import annotations

import csv
import json
import sys
from pathlib import Path


ROOT = Path(__file__).resolve().parent
JSON_PATH = ROOT / "hand-for-hand-casebook.json"
CSV_PATH = ROOT / "hand-for-hand-casebook.csv"
README_PATH = ROOT / "hand-for-hand-casebook-README.md"

EXPECTED_IDS = [
    "tda-announcement-52-left-50-paid-3-bust",
    "wsop-different-tables-unequal-stacks",
    "wsop-same-table-unequal-stacks",
    "wsop-same-table-equal-stacks",
    "pokerstars-different-tables-unequal-stacks",
    "pokerstars-equal-starting-stacks",
    "tda-general-same-hand-ranking",
    "wsop-clock-announcement-hand",
    "tda-clock-boundary",
    "wsop-all-in-hold",
]

PINNED_INPUTS = {
    "tda-announcement-52-left-50-paid-3-bust": {
        "remainingAtAnnouncement": 52,
        "placesPaid": 50,
        "bustsInAnnouncementHand": 3,
    },
    "wsop-different-tables-unequal-stacks": [
        {"id": "A", "table": "12", "startingChips": 120000},
        {"id": "B", "table": "18", "startingChips": 40000},
    ],
    "wsop-same-table-unequal-stacks": [
        {"id": "A", "table": "12", "startingChips": 120000},
        {"id": "B", "table": "12", "startingChips": 40000},
    ],
    "wsop-same-table-equal-stacks": [
        {"id": "A", "table": "12", "startingChips": 60000},
        {"id": "B", "table": "12", "startingChips": 60000},
    ],
    "pokerstars-different-tables-unequal-stacks": [
        {"id": "A", "table": "12", "startingChips": 120000},
        {"id": "B", "table": "18", "startingChips": 40000},
    ],
    "pokerstars-equal-starting-stacks": [
        {"id": "A", "table": "12", "startingChips": 60000},
        {"id": "B", "table": "18", "startingChips": 60000},
    ],
    "tda-general-same-hand-ranking": [
        {"id": "A", "table": "12", "startingChips": 120000},
        {"id": "B", "table": "18", "startingChips": 40000},
    ],
}

EXPECTED_PROFILE_SOURCES = {
    "tda-2024-rp8": (
        "https://www.pokertda.com/view-poker-tda-rules/",
        "Recommended Procedure 8 (RP-8)",
    ),
    "wsop-2026-rule-126": (
        "https://assets.wsopcdn.com/wsop/1a72ba28-781c-409d-a9c3-5ca13c4c5718.pdf",
        "Rule 126, printed pages 27–28",
    ),
    "pokerstars-platform-rules-2-2-2-3": (
        "https://www.pokerstars.com/poker/tournaments/rules/",
        "Rules 2.2 and 2.3",
    ),
}

EXPECTED_PROFILE_CLAIMS = {
    "tda-2024-rp8": {
        "label": "Poker TDA 2024 RP-8",
        "authority": "recommended-procedure",
        "synchronizedRanking": "source-silent",
        "clock": "Maximum 3 minutes deducted per hand; 2 minutes per hand recommended where possible.",
    },
    "wsop-2026-rule-126": {
        "label": "2026 WSOP Tournament Rules, Rule 126",
        "authority": "event-rule",
        "synchronizedRanking": "Different tables tie; same table ranks by chip count at the start of the hand.",
        "clock": "Exactly 2 minutes deducted per hand, beginning with the announcement hand.",
    },
    "pokerstars-platform-rules-2-2-2-3": {
        "label": "PokerStars platform Tournament Rules 2.2–2.3",
        "authority": "platform-rule",
        "synchronizedRanking": "All players eliminated in one synchronized hand are ranked by starting stack, including across tables; equal starting stacks tie.",
        "clock": "Not specified in the cited rules.",
    },
}

EXPECTED_CASE_DESCRIPTORS = {
    "tda-announcement-52-left-50-paid-3-bust": ("tda-2024-rp8", "eligibility", "RP-8-A explicit example"),
    "wsop-different-tables-unequal-stacks": ("wsop-2026-rule-126", "ranking", "Rule 126-b"),
    "wsop-same-table-unequal-stacks": ("wsop-2026-rule-126", "ranking", "Rule 126-b"),
    "wsop-same-table-equal-stacks": ("wsop-2026-rule-126", "ranking", "Rule 126-b"),
    "pokerstars-different-tables-unequal-stacks": ("pokerstars-platform-rules-2-2-2-3", "ranking", "Rules 2.2–2.3"),
    "pokerstars-equal-starting-stacks": ("pokerstars-platform-rules-2-2-2-3", "ranking", "Rule 2.2"),
    "tda-general-same-hand-ranking": ("tda-2024-rp8", "ranking", "RP-8-A"),
    "wsop-clock-announcement-hand": ("wsop-2026-rule-126", "clock", "Rule 126-c–e"),
    "tda-clock-boundary": ("tda-2024-rp8", "clock-boundary", "RP-8-B–D"),
    "wsop-all-in-hold": ("wsop-2026-rule-126", "all-in-procedure", "Rule 126-g"),
}

EXPECTED_LIMITATIONS = [
    "This is a source-profiled casebook, not a universal poker rule, payout calculator, tournament clock, or floor ruling.",
    "Poker TDA RP-8 is a recommended procedure and does not provide a general synchronized-bustout placement algorithm.",
    "The cited WSOP clause does not state a further tiebreak for equal starting stacks at the same table.",
    "PokerStars Rules 2.2–2.3 apply to tournaments on its platform, not automatically to PokerStars-branded live events or other rooms.",
    "A tournament-specific structure sheet, house rule, regulator, or floor decision may control.",
]

TDA_PERMISSION_NOTICE = (
    "TDA rules used by permission of the Poker TDA, Copyright 2026, "
    "http://www.pokertda.com, All rights reserved."
)
TDA_PERMISSION_URL = "http://www.pokertda.com"
EXPECTED_TOP_LEVEL_VALUES = {
    "schemaVersion": 1,
    "published": "2026-09-04",
    "generatedBy": "scripts/generate-hand-for-hand-casebook.mjs",
    "question": "How do named current rule profiles handle announcement-hand eligibility, synchronized bustout order, clock deductions, and all-in holds?",
    "modelClaim": "This casebook preserves material differences among Poker TDA 2024 RP-8, 2026 WSOP Rule 126, and PokerStars platform Rules 2.2–2.3. It fails closed where the cited text is silent.",
    "randomSampling": False,
    "tdaPermissionNotice": TDA_PERMISSION_NOTICE,
    "tdaPermissionUrl": TDA_PERMISSION_URL,
}


def fail(message: str) -> None:
    raise AssertionError(message)


def rank_by_starting_chips(players: list[dict]) -> list[list[str]]:
    buckets: dict[int, list[str]] = {}
    for player in players:
        chips = player["startingChips"]
        if not isinstance(chips, int) or chips < 1:
            fail("startingChips must be a positive integer")
        buckets.setdefault(chips, []).append(player["id"])
    return [sorted(buckets[chips]) for chips in sorted(buckets, reverse=True)]


def resolve(case: dict) -> dict:
    mode = case["mode"]
    profile = case["profile"]
    inputs = case["input"]

    if mode == "eligibility":
        if profile != "tda-2024-rp8":
            fail("eligibility case has the wrong profile")
        return {
            "status": "resolved",
            "eligibilityBegins": "announcement",
            "eligibleBusters": inputs["bustsInAnnouncementHand"],
            "sharedPaidPlace": inputs["placesPaid"],
            "reasonCode": "tda-explicit-announcement-example",
        }
    if mode == "clock":
        if profile != "wsop-2026-rule-126":
            fail("fixed clock case has the wrong profile")
        return {
            "status": "resolved",
            "minutesDeducted": inputs["handsCompleted"] * 2,
            "perHandMinutes": 2,
            "announcementHandIncluded": inputs["announcementHand"],
            "blindsContinueToIncrease": True,
            "reasonCode": "wsop-fixed-two-minutes-per-hand",
        }
    if mode == "clock-boundary":
        if profile != "tda-2024-rp8":
            fail("clock boundary case has the wrong profile")
        if inputs != {"handsCompleted": 1}:
            fail("TDA clock boundary input changed")
        return {
            "status": "resolved",
            "maximumMinutesPerHand": 3,
            "recommendedMinutesPerHand": 2,
            "blindsContinueToIncrease": True,
            "reasonCode": "tda-maximum-three-recommend-two",
        }
    if mode == "all-in-procedure":
        if profile != "wsop-2026-rule-126":
            fail("all-in case has the wrong profile")
        if inputs.get("allInAndCall") is not True:
            fail("all-in procedure requires an all-in and call")
        return {
            "status": "resolved",
            "cardsRemainFaceDown": True,
            "dealAdditionalCardsBeforeInstruction": False,
            "reasonCode": "wsop-hold-cards-and-board",
        }
    if mode != "ranking":
        fail(f"unsupported mode {mode}")

    players = inputs["players"]
    if inputs.get("synchronizedBatch") is not True:
        fail("ranking case must represent one synchronized batch")
    if not isinstance(players, list) or len(players) != 2:
        fail("ranking case must contain exactly two players")
    if profile == "tda-2024-rp8":
        return {
            "status": "source-silent",
            "rankGroups": None,
            "reasonCode": "tda-rp8-does-not-generalize-placement-order",
            "payoutTreatment": "RP-8 supplies an eligibility-and-sharing example, not a universal placement algorithm.",
        }
    if profile == "pokerstars-platform-rules-2-2-2-3":
        groups = rank_by_starting_chips(players)
        tied = any(len(group) > 1 for group in groups)
        return {
            "status": "resolved",
            "rankGroups": groups,
            "reasonCode": (
                "pokerstars-equal-starting-stacks-tie"
                if tied
                else "pokerstars-synchronized-starting-stack-desc"
            ),
            "payoutTreatment": (
                "They tie for the rank and any prizes due to them are distributed equally."
                if tied
                else "The higher starting stack finishes higher even across tables."
            ),
        }
    if profile == "wsop-2026-rule-126":
        tables = {player["table"] for player in players}
        if len(tables) > 1:
            return {
                "status": "resolved",
                "rankGroups": [sorted(player["id"] for player in players)],
                "reasonCode": "wsop-different-tables-tie",
                "payoutTreatment": "Tie for the place finish; Rule 126 separately says eligible announcement-hand busters may share the paid place(s).",
            }
        if len({player["startingChips"] for player in players}) != len(players):
            return {
                "status": "source-silent",
                "rankGroups": None,
                "reasonCode": "wsop-equal-starting-stack-not-resolved-by-cited-clause",
                "payoutTreatment": "Ask the floor or consult the event-specific rule; the cited clause supplies no further tiebreak.",
            }
        return {
            "status": "resolved",
            "rankGroups": rank_by_starting_chips(players),
            "reasonCode": "wsop-same-table-starting-stack-desc",
            "payoutTreatment": "The higher starting stack receives the higher place finish.",
        }
    fail(f"unsupported profile {profile}")


def summary(result: dict) -> str:
    if result.get("sharedPaidPlace"):
        return f'{result["eligibleBusters"]} players share paid place {result["sharedPaidPlace"]}'
    if result.get("minutesDeducted"):
        return f'{result["minutesDeducted"]} minutes deducted across 2 hands'
    if result.get("maximumMinutesPerHand"):
        return (
            f'maximum {result["maximumMinutesPerHand"]}; '
            f'recommended {result["recommendedMinutesPerHand"]} minutes per hand'
        )
    if result.get("cardsRemainFaceDown"):
        return "cards remain face down; no additional cards before instruction"
    return result["payoutTreatment"]


def expected_csv_row(case: dict, result: dict) -> dict[str, str]:
    groups = result.get("rankGroups")
    rank_groups = " > ".join("+".join(group) for group in groups) if groups else ""
    return {
        "id": case["id"],
        "profile": case["profile"],
        "mode": case["mode"],
        "sourceLocation": case["sourceLocation"],
        "status": result["status"],
        "reasonCode": result["reasonCode"],
        "rankGroups": rank_groups,
        "result": summary(result),
        "tdaPermissionNotice": TDA_PERMISSION_NOTICE,
        "tdaPermissionUrl": TDA_PERMISSION_URL,
    }


def verify() -> None:
    document = json.loads(JSON_PATH.read_text(encoding="utf-8"))
    expected_top_level_fields = {
        "schemaVersion", "published", "generatedBy", "question", "modelClaim",
        "randomSampling", "tdaPermissionNotice", "tdaPermissionUrl", "profiles",
        "limitations", "cases",
    }
    if set(document) != expected_top_level_fields:
        fail("top-level field set changed")
    for field, expected_value in EXPECTED_TOP_LEVEL_VALUES.items():
        if document.get(field) != expected_value:
            fail(f"top-level value changed: {field}")
    if document.get("limitations") != EXPECTED_LIMITATIONS:
        fail("JSON limitations changed")
    cases = document.get("cases", [])
    if [case.get("id") for case in cases] != EXPECTED_IDS:
        fail("case ids or order changed")

    if set(document["profiles"]) != set(EXPECTED_PROFILE_SOURCES):
        fail("profile id set changed")

    for profile_id, (url, location) in EXPECTED_PROFILE_SOURCES.items():
        profile = document["profiles"].get(profile_id)
        if not profile or profile.get("sourceUrl") != url or profile.get("sourceLocation") != location:
            fail(f"source identity changed for {profile_id}")
        if profile.get("accessed") != "2026-09-04":
            fail(f"access date changed for {profile_id}")
        expected_keys = {
            "label", "authority", "sourceUrl", "sourceLocation", "accessed",
            "synchronizedRanking", "clock",
        }
        if set(profile) != expected_keys:
            fail(f"profile field set changed for {profile_id}")
        for key, expected_value in EXPECTED_PROFILE_CLAIMS[profile_id].items():
            if profile.get(key) != expected_value:
                fail(f"profile claim {key} changed for {profile_id}")

    by_id = {case["id"]: case for case in cases}
    for case_id, descriptor in EXPECTED_CASE_DESCRIPTORS.items():
        case = by_id[case_id]
        actual_descriptor = (case.get("profile"), case.get("mode"), case.get("sourceLocation"))
        if actual_descriptor != descriptor:
            fail(f"case descriptor changed for {case_id}")
    if by_id["tda-announcement-52-left-50-paid-3-bust"]["input"] != PINNED_INPUTS[
        "tda-announcement-52-left-50-paid-3-bust"
    ]:
        fail("TDA announcement example changed pinned input")
    for case_id in [
        "wsop-different-tables-unequal-stacks",
        "wsop-same-table-unequal-stacks",
        "wsop-same-table-equal-stacks",
        "pokerstars-different-tables-unequal-stacks",
        "pokerstars-equal-starting-stacks",
        "tda-general-same-hand-ranking",
    ]:
        if by_id[case_id]["input"]["players"] != PINNED_INPUTS[case_id]:
            fail(f"{case_id} changed pinned input")

    for case in cases:
        if case["mode"] == "ranking" and case["input"].get("synchronizedBatch") is not True:
            fail(f'{case["id"]} changed synchronized-batch boundary')

    if by_id["wsop-clock-announcement-hand"]["input"] != {
        "announcementHand": True,
        "handsCompleted": 2,
    }:
        fail("WSOP clock sentinel changed pinned input")
    if by_id["tda-clock-boundary"]["input"] != {"handsCompleted": 1}:
        fail("TDA clock boundary changed pinned input")
    if by_id["wsop-all-in-hold"]["input"] != {"allInAndCall": True}:
        fail("WSOP all-in sentinel changed pinned input")

    expected_rows = []
    for case in cases:
        actual = resolve(case)
        if actual != case["expected"]:
            fail(f'{case["id"]}: independently recomputed result differs from JSON')
        expected_rows.append(expected_csv_row(case, actual))

    with CSV_PATH.open(newline="", encoding="utf-8") as handle:
        rows = list(csv.DictReader(handle))
    if rows != expected_rows:
        fail("CSV rows differ from independently recomputed JSON results")

    readme = README_PATH.read_text(encoding="utf-8")
    rendered_readme = readme.replace(
        "[http://www.pokertda.com](http://www.pokertda.com)",
        "http://www.pokertda.com",
    )
    required_boundaries = [
        "not a universal poker rule",
        "does not publish a general placement algorithm",
        "does not state a further tiebreak",
        "apply PokerStars platform rules to live events",
        "floor decision may control",
        "Passing proves internal agreement",
        "[http://www.pokertda.com](http://www.pokertda.com)",
    ]
    for phrase in required_boundaries:
        if phrase.lower() not in readme.lower():
            fail(f"README boundary is missing: {phrase}")
    if TDA_PERMISSION_NOTICE.lower() not in rendered_readme.lower():
        fail("README TDA permission notice changed")

    print(
        "Verified 10 deterministic hand-for-hand cases "
        "(3 source profiles, 2 source-silent boundaries, independent CSV parity)."
    )


if __name__ == "__main__":
    try:
        verify()
    except Exception as error:  # noqa: BLE001 - concise standalone CLI failure
        print(f"Verification failed: {error}", file=sys.stderr)
        raise SystemExit(1)
