#!/usr/bin/env python3
"""Independent verifier for the poker-range-after-action release."""

from __future__ import annotations

import csv
import hashlib
import json
from decimal import Decimal, getcontext
from itertools import combinations
from pathlib import Path

getcontext().prec = 40
ROOT = Path(__file__).resolve().parent
BOARD = {"Kd", "9c", "7h", "4c", "2s"}
MADE = {
    "9s9h": "1", "9s9d": "1", "9h9d": "1",
    "7s7c": "1", "7s7d": "1", "7c7d": "1",
    "Ks9s": ".8", "Ks9h": ".8", "Ks9d": ".8",
    "Kh9s": ".8", "Kh9h": ".8", "Kh9d": ".8",
    "Kc9s": ".8", "Kc9h": ".8", "Kc9d": ".8",
}
MISSED = {
    "AcJc": ".8", "AcTc": ".7", "QcJc": ".6", "QcTc": ".5",
    "JcTc": ".45", "Jc8c": ".4", "Tc8c": ".35", "8c6c": ".3",
    "6c5c": ".25", "5c3c": ".2", "Jc6c": ".15", "Tc6c": ".1",
}
CASES = {"unblocked": "AhQh", "club_blockers": "AcQc"}
GROUPS = {
    **{hand: "set_99" for hand in ("9s9h", "9s9d", "9h9d")},
    **{hand: "set_77" for hand in ("7s7c", "7s7d", "7c7d")},
    **{hand: "two_pair_K9" for hand in (
        "Ks9s", "Ks9h", "Ks9d", "Kh9s", "Kh9h", "Kh9d", "Kc9s", "Kc9h", "Kc9d"
    )},
    **{hand: "missed_clubs" for hand in MISSED},
}
EXPECTED = {
    "unblocked": {
        "legal": 27, "excluded": [], "prior": Decimal("27"),
        "missed_prior": Decimal("12"), "bet": Decimal("18"),
        "made_bet": Decimal("13.2"), "missed_bet": Decimal("4.8"),
        "posterior": Decimal(4) / Decimal(15), "ev": Decimal("-20"),
        "naive": Decimal(4) / Decimal(9), "naive_ev": Decimal(100) / Decimal(3),
    },
    "club_blockers": {
        "legal": 23, "excluded": ["AcJc", "AcTc", "QcJc", "QcTc"],
        "prior": Decimal("23"), "missed_prior": Decimal("8"),
        "bet": Decimal("15.4"), "made_bet": Decimal("13.2"),
        "missed_bet": Decimal("2.2"), "posterior": Decimal(1) / Decimal(7),
        "ev": -Decimal(400) / Decimal(7), "naive": Decimal(8) / Decimal(23),
        "naive_ev": Decimal(100) / Decimal(23),
    },
}


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


def cards(hand: str) -> tuple[str, str]:
    if len(hand) != 4:
        fail(f"bad hand encoding: {hand}")
    answer = (hand[:2], hand[2:])
    if answer[0] == answer[1]:
        fail(f"duplicate card in {hand}")
    return answer


def rank_five(five_cards: tuple[str, ...]) -> tuple[int, ...]:
    rank_values = {rank: value for value, rank in enumerate("23456789TJQKA", start=2)}
    values = [rank_values[card[0]] for card in five_cards]
    counts = {value: values.count(value) for value in set(values)}
    by_count = sorted(counts.items(), key=lambda item: (item[1], item[0]), reverse=True)
    flush = len({card[1] for card in five_cards}) == 1
    unique = sorted(set(values), reverse=True)
    if 14 in unique:
        unique.append(1)
    straight_high = next((high for high in unique if all(high - step in unique for step in range(5))), 0)
    if flush and straight_high:
        return (8, straight_high)
    if by_count[0][1] == 4:
        return (7, by_count[0][0], by_count[1][0])
    if by_count[0][1] == 3 and by_count[1][1] == 2:
        return (6, by_count[0][0], by_count[1][0])
    if flush:
        return (5, *sorted(values, reverse=True))
    if straight_high:
        return (4, straight_high)
    if by_count[0][1] == 3:
        kickers = sorted((value for value in values if value != by_count[0][0]), reverse=True)
        return (3, by_count[0][0], *kickers)
    pairs = sorted((value for value, count in counts.items() if count == 2), reverse=True)
    if len(pairs) == 2:
        kicker = max(value for value in values if value not in pairs)
        return (2, *pairs, kicker)
    if len(pairs) == 1:
        kickers = sorted((value for value in values if value != pairs[0]), reverse=True)
        return (1, pairs[0], *kickers)
    return (0, *sorted(values, reverse=True))


def best_seven(seven_cards: tuple[str, ...]) -> tuple[int, ...]:
    if len(seven_cards) != 7 or len(set(seven_cards)) != 7:
        fail(f"invalid seven-card hand: {seven_cards}")
    return max(rank_five(combo) for combo in combinations(seven_cards, 5))


def close(actual: Decimal, expected: Decimal, label: str, tolerance=Decimal("0.0000000000015")) -> None:
    if abs(actual - expected) > tolerance:
        fail(f"{label}: got {actual}, expected {expected}")


def reconstruct(case_id: str, hero: str) -> dict[str, object]:
    hero_cards = set(cards(hero))
    legal: list[tuple[str, str, Decimal]] = []
    excluded: list[str] = []
    for category, fixture in (("made_hand", MADE), ("missed_clubs", MISSED)):
        for hand, frequency_text in fixture.items():
            hand_cards = set(cards(hand))
            if len(hand_cards | BOARD | hero_cards) != 9:
                excluded.append(hand)
            else:
                legal.append((hand, category, Decimal(frequency_text)))
    made = sum((frequency for _, category, frequency in legal if category == "made_hand"), Decimal(0))
    missed = sum((frequency for _, category, frequency in legal if category == "missed_clubs"), Decimal(0))
    missed_prior = sum(1 for _, category, _ in legal if category == "missed_clubs")
    total = made + missed
    posterior = missed / total
    naive = Decimal(missed_prior) / Decimal(len(legal))
    return {
        "case_id": case_id,
        "legal": len(legal),
        "excluded": excluded,
        "prior": Decimal(len(legal)),
        "missed_prior": Decimal(missed_prior),
        "made_bet": made,
        "missed_bet": missed,
        "bet": total,
        "posterior": posterior,
        "ev": Decimal(300) * posterior - Decimal(100),
        "naive": naive,
        "naive_ev": Decimal(300) * naive - Decimal(100),
    }


def verify_summary() -> int:
    rows = list(csv.DictReader((ROOT / "summary.csv").open(encoding="utf-8", newline="")))
    if [row["case_id"] for row in rows] != list(CASES):
        fail("summary case order or allow-list changed")
    checks = 0
    for row in rows:
        case_id = row["case_id"]
        rebuilt = reconstruct(case_id, CASES[case_id])
        expected = EXPECTED[case_id]
        if rebuilt["legal"] != expected["legal"] or rebuilt["excluded"] != expected["excluded"]:
            fail(f"{case_id}: independent legality result changed")
        for key in ("prior", "missed_prior", "made_bet", "missed_bet", "bet", "posterior", "ev", "naive", "naive_ev"):
            close(Decimal(str(rebuilt[key])), Decimal(str(expected[key])), f"{case_id} independent {key}")
            checks += 1
        published = {
            "prior": row["prior_mass"],
            "made_prior": row["made_prior_mass"],
            "missed_prior": row["missed_prior_mass"],
            "prior_share": row["prior_bluff_share"],
            "made_bet": row["made_bet_mass"],
            "missed_bet": row["missed_bet_mass"],
            "bet": row["bet_mass"],
            "overall_bet": row["overall_bet_frequency"],
            "posterior": row["posterior_bluff_share"],
            "ev": row["correct_call_ev_chips"],
            "naive": row["equal_count_bluff_share"],
            "naive_ev": row["naive_call_ev_chips"],
            "break_even": row["break_even_bluff_share"],
        }
        if int(row["legal_combos"]) != expected["legal"]:
            fail(f"{case_id}: published legal combo count")
        published_excluded = row["excluded_combos"].split() if row["excluded_combos"] else []
        if published_excluded != expected["excluded"]:
            fail(f"{case_id}: published excluded combo list")
        published_expected = {
            **expected,
            "made_prior": Decimal("15"),
            "prior_share": expected["naive"],
            "overall_bet": expected["bet"] / expected["prior"],
            "break_even": Decimal(1) / Decimal(3),
        }
        for key, value in published.items():
            close(Decimal(value), Decimal(str(published_expected[key])), f"{case_id} published {key}")
            checks += 1
    return checks


def verify_rows_and_json() -> int:
    rows = list(csv.DictReader((ROOT / "input-combos.csv").open(encoding="utf-8", newline="")))
    if len(rows) != 54:
        fail(f"combo ledger has {len(rows)} rows, expected 54")
    by_case = {case_id: [row for row in rows if row["case_id"] == case_id] for case_id in CASES}
    if any(len(case_rows) != 27 for case_rows in by_case.values()):
        fail("each Hero case must retain all 27 candidate rows")
    fixture = {**MADE, **MISSED}
    expected_order = list(fixture)
    checks = 2
    for case_id, case_rows in by_case.items():
        if [row["hand"] for row in case_rows] != expected_order:
            fail(f"{case_id}: combo row order or allow-list changed")
        rebuilt = reconstruct(case_id, CASES[case_id])
        posterior_total = sum(Decimal(row["posterior_share"]) for row in case_rows)
        prior_total = sum(Decimal(row["prior_share"]) for row in case_rows)
        close(posterior_total, Decimal(1), f"{case_id} posterior normalization", Decimal("0.00000000003"))
        close(prior_total, Decimal(1), f"{case_id} prior normalization", Decimal("0.00000000003"))
        checks += 2
        for row in case_rows:
            hand = row["hand"]
            hero = CASES[case_id]
            compatible = len(set(cards(hand)) | BOARD | set(cards(hero))) == 9
            if (row["compatible"] == "true") != compatible:
                fail(f"{case_id} {hand}: compatibility flag")
            expected_category = "made_hand" if hand in MADE else "missed_clubs"
            if row["hero"] != hero or row["board"] != "Kd 9c 7h 4c 2s":
                fail(f"{case_id} {hand}: case identity")
            if row["group"] != GROUPS[hand] or row["category"] != expected_category:
                fail(f"{case_id} {hand}: category or group")
            frequency = Decimal(fixture[hand])
            close(Decimal(row["reach_weight"]), Decimal(1), f"{case_id} {hand} reach")
            close(Decimal(row["bet_frequency"]), frequency, f"{case_id} {hand} frequency")
            expected_action = frequency if compatible else Decimal(0)
            expected_prior_share = Decimal(1) / Decimal(rebuilt["legal"]) if compatible else Decimal(0)
            expected_posterior_share = frequency / Decimal(rebuilt["bet"]) if compatible else Decimal(0)
            close(Decimal(row["action_mass"]), expected_action, f"{case_id} {hand} action mass")
            close(Decimal(row["prior_share"]), expected_prior_share, f"{case_id} {hand} prior share")
            close(Decimal(row["posterior_share"]), expected_posterior_share, f"{case_id} {hand} posterior share")
            expected_reason = "" if compatible else "shares a card with Hero"
            if row["exclusion_reason"] != expected_reason:
                fail(f"{case_id} {hand}: exclusion reason")
            if not compatible and any(Decimal(row[field]) != 0 for field in ("action_mass", "prior_share", "posterior_share")):
                fail(f"blocked row carries weight: {case_id} {row['hand']}")
            if compatible:
                hero_rank = best_seven((*cards(hero), *tuple(BOARD)))
                villain_rank = best_seven((*cards(hand), *tuple(BOARD)))
                expected_hero_wins = expected_category == "missed_clubs"
                if (hero_rank > villain_rank) != expected_hero_wins or hero_rank == villain_rank:
                    fail(f"{case_id} {hand}: showdown category is wrong")
                if expected_category == "missed_clubs":
                    turn_clubs = sum(card.endswith("c") for card in (*cards(hand), "Kd", "9c", "7h", "4c"))
                    river_clubs = turn_clubs + int("2s".endswith("c"))
                    if turn_clubs != 4 or river_clubs != 4:
                        fail(f"{case_id} {hand}: not a missed turn club draw")
            checks += 10
    payload = json.loads((ROOT / "results.json").read_text(encoding="utf-8"))
    if payload["schemaVersion"] != 1 or payload["model"]["board"] != ["Kd", "9c", "7h", "4c", "2s"]:
        fail("results model identity changed")
    if [result["id"] for result in payload["results"]] != list(CASES):
        fail("results case allow-list changed")
    if len(payload["rows"]) != 54:
        fail("results JSON combo row count changed")
    for result in payload["results"]:
        expected = EXPECTED[result["id"]]
        close(Decimal(str(result["posteriorBluffShare"])), expected["posterior"], f"{result['id']} JSON posterior")
        close(Decimal(str(result["correctCallEv"])), expected["ev"], f"{result['id']} JSON EV")
        close(Decimal(str(result["equalCountBluffShare"])), expected["naive"], f"{result['id']} JSON equal-count share")
        if "naivePosteriorBluffShare" in result:
            fail(f"{result['id']}: misleading legacy JSON field")
        if result["legalComboCount"] != expected["legal"] or result["excludedCombos"] != expected["excluded"]:
            fail(f"{result['id']}: JSON legality summary")
        checks += 5
    return checks + 3


def verify_manifest() -> int:
    expected_names = {
        "README.md", "generate.mjs", "input-combos.csv", "range-update-ja.svg",
        "range-update.svg", "results.json", "summary.csv", "verify.py",
    }
    lines = (ROOT / "MANIFEST.sha256").read_text(encoding="utf-8").strip().splitlines()
    seen: set[str] = set()
    for line in lines:
        try:
            digest, filename = line.split("  ", 1)
        except ValueError as error:
            raise RuntimeError(f"malformed manifest row: {line}") from error
        if filename in seen or filename not in expected_names:
            fail(f"unexpected or duplicate manifest file: {filename}")
        actual = hashlib.sha256((ROOT / filename).read_bytes()).hexdigest()
        if actual != digest:
            fail(f"manifest mismatch: {filename}")
        seen.add(filename)
    if seen != expected_names:
        fail(f"manifest allow-list mismatch: {sorted(seen ^ expected_names)}")
    return len(seen)


def main() -> None:
    checks = verify_summary() + verify_rows_and_json() + verify_manifest()
    print(f"Poker range-update verification passed: {checks} explicit checks")


if __name__ == "__main__":
    main()
