#!/usr/bin/env python3
"""Independently verify the published Short Deck equity enumeration.

The verifier intentionally does not import or execute the JavaScript generator.
It implements hand ranking and board enumeration separately with Python's standard
library. Full mode recomputes every win/tie/loss count; quick mode checks schemas,
sample-space arithmetic, decompositions, and evaluator edge cases.
"""

from __future__ import annotations

import argparse
import itertools
import json
import math
import os
import platform
import sys
import time
from collections import Counter
from concurrent.futures import ProcessPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable


HERE = Path(__file__).resolve().parent
DEFAULT_DATA = HERE / "short-deck-equity-matchups.json"
STANDARD_RANKS = tuple(range(2, 15))
SHORT_RANKS = tuple(range(6, 15))
RANK_CHARS = "23456789TJQKA"
SUIT_CHARS = "cdhs"
STANDARD_ORDER = {
    "high-card": 0,
    "pair": 1,
    "two-pair": 2,
    "trips": 3,
    "straight": 4,
    "flush": 5,
    "full-house": 6,
    "quads": 7,
    "straight-flush": 8,
}
SHORT_OFFICIAL_ORDER = {**STANDARD_ORDER, "flush": 6, "full-house": 5}


class VerificationError(AssertionError):
    """Raised when a frozen artifact fails closed."""


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


def parse_card(token: str) -> int:
    if len(token) != 2 or token[0] not in RANK_CHARS or token[1] not in SUIT_CHARS:
        fail(f"invalid card token {token!r}")
    return (RANK_CHARS.index(token[0]) * 4) + SUIT_CHARS.index(token[1])


def rank_of(card: int) -> int:
    return (card // 4) + 2


def suit_of(card: int) -> int:
    return card % 4


def straight_high(ranks: Iterable[int], short_deck: bool) -> int:
    values = set(ranks)
    lower = 10 if short_deck else 5
    for high in range(14, lower - 1, -1):
        if all(rank in values for rank in range(high - 4, high + 1)):
            return high
    low_straight = {14, 9, 8, 7, 6} if short_deck else {14, 5, 4, 3, 2}
    return 9 if short_deck and low_straight <= values else 5 if low_straight <= values else 0


def evaluate_seven(cards: tuple[int, ...], short_deck: bool) -> tuple[str, tuple[int, ...]]:
    if len(cards) != 7 or len(set(cards)) != 7:
        fail("evaluator requires seven unique cards")
    ranks = [rank_of(card) for card in cards]
    rank_counts = Counter(ranks)
    suit_ranks = {
        suit: sorted((rank_of(card) for card in cards if suit_of(card) == suit), reverse=True)
        for suit in range(4)
    }

    for suited_ranks in suit_ranks.values():
        if len(suited_ranks) >= 5:
            high = straight_high(suited_ranks, short_deck)
            if high:
                return "straight-flush", (high,)

    quads = sorted((rank for rank, count in rank_counts.items() if count == 4), reverse=True)
    if quads:
        kicker = max(rank for rank in ranks if rank != quads[0])
        return "quads", (quads[0], kicker)

    trips = sorted((rank for rank, count in rank_counts.items() if count >= 3), reverse=True)
    if trips:
        pair_candidates = sorted(
            (rank for rank, count in rank_counts.items() if rank != trips[0] and count >= 2),
            reverse=True,
        )
        if pair_candidates:
            return "full-house", (trips[0], pair_candidates[0])

    flushes = [suited_ranks[:5] for suited_ranks in suit_ranks.values() if len(suited_ranks) >= 5]
    if flushes:
        return "flush", tuple(max(flushes))

    high = straight_high(ranks, short_deck)
    if high:
        return "straight", (high,)

    if trips:
        kickers = sorted((rank for rank in ranks if rank != trips[0]), reverse=True)
        return "trips", (trips[0], *tuple(dict.fromkeys(kickers))[:2])

    pairs = sorted((rank for rank, count in rank_counts.items() if count >= 2), reverse=True)
    if len(pairs) >= 2:
        kicker = max(rank for rank in ranks if rank not in pairs[:2])
        return "two-pair", (pairs[0], pairs[1], kicker)
    if pairs:
        kickers = sorted(set(rank for rank in ranks if rank != pairs[0]), reverse=True)[:3]
        return "pair", (pairs[0], *kickers)
    return "high-card", tuple(sorted(set(ranks), reverse=True)[:5])


def evaluate_five(cards: tuple[int, ...], short_deck: bool) -> tuple[str, tuple[int, ...]]:
    """Independent five-card classifier used as a reference oracle."""
    if len(cards) != 5 or len(set(cards)) != 5:
        fail("five-card evaluator requires five unique cards")
    ranks = [rank_of(card) for card in cards]
    counts = Counter(ranks)
    ordered_counts = sorted(((count, rank) for rank, count in counts.items()), reverse=True)
    is_flush = len({suit_of(card) for card in cards}) == 1
    high = straight_high(ranks, short_deck) if len(counts) == 5 else 0
    if is_flush and high:
        return "straight-flush", (high,)
    if ordered_counts[0][0] == 4:
        return "quads", (ordered_counts[0][1], ordered_counts[1][1])
    if [item[0] for item in ordered_counts] == [3, 2]:
        return "full-house", (ordered_counts[0][1], ordered_counts[1][1])
    if is_flush:
        return "flush", tuple(sorted(ranks, reverse=True))
    if high:
        return "straight", (high,)
    if ordered_counts[0][0] == 3:
        kickers = sorted((rank for rank in ranks if rank != ordered_counts[0][1]), reverse=True)
        return "trips", (ordered_counts[0][1], *kickers)
    pairs = sorted((rank for rank, count in counts.items() if count == 2), reverse=True)
    if len(pairs) == 2:
        kicker = next(rank for rank, count in counts.items() if count == 1)
        return "two-pair", (pairs[0], pairs[1], kicker)
    if len(pairs) == 1:
        kickers = sorted((rank for rank, count in counts.items() if count == 1), reverse=True)
        return "pair", (pairs[0], *kickers)
    return "high-card", tuple(sorted(ranks, reverse=True))


def reference_evaluate_seven(
    cards: tuple[int, ...], short_deck: bool, order: dict[str, int]
) -> tuple[str, tuple[int, ...]]:
    return max(
        (evaluate_five(tuple(choice), short_deck) for choice in itertools.combinations(cards, 5)),
        key=lambda value: (order[value[0]], value[1]),
    )


def verify_five_card_class_counts() -> None:
    deck = tuple(((rank - 2) * 4) + suit for rank in SHORT_RANKS for suit in range(4))
    expected = {
        True: {
            "straight-flush": 24,
            "quads": 288,
            "flush": 480,
            "full-house": 1728,
            "straight": 6120,
            "trips": 16128,
            "two-pair": 36288,
            "pair": 193536,
            "high-card": 122400,
        },
        False: {
            "straight-flush": 20,
            "quads": 288,
            "flush": 484,
            "full-house": 1728,
            "straight": 5100,
            "trips": 16128,
            "two-pair": 36288,
            "pair": 193536,
            "high-card": 123420,
        },
    }
    for low_straight_enabled in (True, False):
        observed: Counter[str] = Counter()
        for hand in itertools.combinations(deck, 5):
            observed[evaluate_five(hand, low_straight_enabled)[0]] += 1
        if dict(observed) != expected[low_straight_enabled]:
            fail(
                "five-card category oracle mismatch for "
                f"low_straight_enabled={low_straight_enabled}: {dict(observed)}"
            )


def compare_hands(
    hero: tuple[str, tuple[int, ...]],
    villain: tuple[str, tuple[int, ...]],
    order: dict[str, int],
) -> int:
    hero_key = (order[hero[0]], hero[1])
    villain_key = (order[villain[0]], villain[1])
    return 1 if hero_key > villain_key else -1 if hero_key < villain_key else 0


def add_outcome(counts: dict[str, int], result: int) -> None:
    counts["wins" if result > 0 else "losses" if result < 0 else "ties"] += 1


def enumerate_matchup(spec: dict[str, Any]) -> dict[str, Any]:
    hero = tuple(parse_card(token) for token in spec["hero"])
    villain = tuple(parse_card(token) for token in spec["villain"])
    dead = set(hero + villain)
    if len(dead) != 4:
        fail(f"{spec['id']} does not contain four unique hole cards")

    short_counts = {"wins": 0, "ties": 0, "losses": 0}
    low_straight_counts = {"wins": 0, "ties": 0, "losses": 0}
    conventional_counts = {"wins": 0, "ties": 0, "losses": 0}
    low_straight_change = {"heroImproved": 0, "heroWorsened": 0, "unchanged": 0}
    swap = {"heroImproved": 0, "heroWorsened": 0, "unchanged": 0}
    short_deck = [((rank - 2) * 4) + suit for rank in SHORT_RANKS for suit in range(4)]
    short_available = [card for card in short_deck if card not in dead]
    for board in itertools.combinations(short_available, 5):
        hero_low = evaluate_seven(hero + board, True)
        villain_low = evaluate_seven(villain + board, True)
        hero_conventional = evaluate_seven(hero + board, False)
        villain_conventional = evaluate_seven(villain + board, False)
        conventional = compare_hands(hero_conventional, villain_conventional, STANDARD_ORDER)
        low_straight = compare_hands(hero_low, villain_low, STANDARD_ORDER)
        official = compare_hands(hero_low, villain_low, SHORT_OFFICIAL_ORDER)
        add_outcome(conventional_counts, conventional)
        add_outcome(low_straight_counts, low_straight)
        add_outcome(short_counts, official)
        if low_straight > conventional:
            low_straight_change["heroImproved"] += 1
        elif low_straight < conventional:
            low_straight_change["heroWorsened"] += 1
        else:
            low_straight_change["unchanged"] += 1
        if official > low_straight:
            swap["heroImproved"] += 1
        elif official < low_straight:
            swap["heroWorsened"] += 1
        else:
            swap["unchanged"] += 1

    standard_counts = {"wins": 0, "ties": 0, "losses": 0}
    standard_deck = [((rank - 2) * 4) + suit for rank in STANDARD_RANKS for suit in range(4)]
    standard_available = [card for card in standard_deck if card not in dead]
    for board in itertools.combinations(standard_available, 5):
        hero_value = evaluate_seven(hero + board, False)
        villain_value = evaluate_seven(villain + board, False)
        add_outcome(standard_counts, compare_hands(hero_value, villain_value, STANDARD_ORDER))

    return {
        "id": spec["id"],
        "standardHoldem": standard_counts,
        "shortDeckConventionalStraightStandardCategoryOrder": conventional_counts,
        "shortDeckLowStraightStandardCategoryOrder": low_straight_counts,
        "shortDeckOfficial": short_counts,
        "lowStraightBoardOutcomes": low_straight_change,
        "rankingSwapBoardOutcomes": swap,
    }


def require_count_partition(result: dict[str, Any], boards: int, label: str) -> None:
    keys = ("wins", "ties", "losses")
    if any(not isinstance(result.get(key), int) for key in keys):
        fail(f"{label} outcomes must be integer counts")
    if sum(result[key] for key in keys) != boards:
        fail(f"{label} outcomes do not partition {boards} boards")
    expected_equity = round(((result["wins"] + (result["ties"] / 2)) / boards) * 100, 6)
    if not math.isclose(result.get("equityPct"), expected_equity, rel_tol=0, abs_tol=5e-7):
        fail(f"{label} equity does not equal (wins + ties/2) / boards")


def quick_checks(data: dict[str, Any]) -> None:
    if data.get("schemaVersion") != 1 or data.get("studyVersion") != "1.0.0":
        fail("unexpected study schema or version")
    if data.get("matchupCount") != 12 or len(data.get("matchups", [])) != 12:
        fail("study must contain exactly 12 matchups")
    spaces = data.get("sampleSpaces", {})
    standard_boards = math.comb(48, 5)
    short_boards = math.comb(32, 5)
    if spaces.get("standardBoardsPerMatchup") != standard_boards:
        fail("standard sample-space count is not C(48,5)")
    if spaces.get("shortDeckBoardsPerMatchup") != short_boards:
        fail("short-deck sample-space count is not C(32,5)")

    ids: set[str] = set()
    for row in data["matchups"]:
        if row["id"] in ids:
            fail(f"duplicate matchup id {row['id']}")
        ids.add(row["id"])
        cards = row["hero"] + row["villain"]
        if len({parse_card(card) for card in cards}) != 4:
            fail(f"{row['id']} contains colliding cards")
        if any(rank_of(parse_card(card)) < 6 for card in cards):
            fail(f"{row['id']} contains a card below six")
        require_count_partition(row["standardHoldem"], standard_boards, f"{row['id']} standard")
        require_count_partition(
            row["shortDeckConventionalStraightStandardCategoryOrder"], short_boards, f"{row['id']} deck-only"
        )
        require_count_partition(
            row["shortDeckLowStraightStandardCategoryOrder"], short_boards, f"{row['id']} low-straight control"
        )
        require_count_partition(row["shortDeckOfficial"], short_boards, f"{row['id']} official")
        low_change = row["decomposition"]["lowStraightBoardOutcomes"]
        if sum(low_change.values()) != short_boards:
            fail(f"{row['id']} low-straight outcomes do not partition short-deck boards")
        swap = row["decomposition"]["rankingSwapBoardOutcomes"]
        if sum(swap.values()) != short_boards:
            fail(f"{row['id']} rule-swap outcomes do not partition short-deck boards")
        deck_removal_effect = round(
            row["shortDeckConventionalStraightStandardCategoryOrder"]["equityPct"]
            - row["standardHoldem"]["equityPct"], 6
        )
        low_straight_effect = round(
            row["shortDeckLowStraightStandardCategoryOrder"]["equityPct"]
            - row["shortDeckConventionalStraightStandardCategoryOrder"]["equityPct"], 6
        )
        rank_effect = round(
            row["shortDeckOfficial"]["equityPct"]
            - row["shortDeckLowStraightStandardCategoryOrder"]["equityPct"], 6
        )
        total_effect = round(
            row["shortDeckOfficial"]["equityPct"] - row["standardHoldem"]["equityPct"], 6
        )
        decomposition = row["decomposition"]
        if decomposition["deckRemovalEffectPp"] != deck_removal_effect:
            fail(f"{row['id']} deck-removal effect mismatch")
        if decomposition["lowStraightEffectPp"] != low_straight_effect:
            fail(f"{row['id']} low-straight effect mismatch")
        if decomposition["rankingSwapEffectPp"] != rank_effect:
            fail(f"{row['id']} ranking-swap effect mismatch")
        if decomposition["totalEffectPp"] != total_effect:
            fail(f"{row['id']} total effect mismatch")
        expected_residual = round(total_effect - deck_removal_effect - low_straight_effect - rank_effect, 6)
        if decomposition["roundingResidualPp"] != expected_residual:
            fail(f"{row['id']} decomposition residual mismatch")

    def cards(*tokens: str) -> tuple[int, ...]:
        return tuple(parse_card(token) for token in tokens)

    if evaluate_seven(cards("As", "6c", "7d", "8h", "9s", "Kc", "Qd"), True) != ("straight", (9,)):
        fail("A-6-7-8-9 short-deck straight edge case failed")
    if evaluate_seven(cards("As", "2c", "3d", "4h", "5s", "Kc", "Qd"), False) != ("straight", (5,)):
        fail("A-2-3-4-5 standard straight edge case failed")
    two_trips = evaluate_seven(cards("As", "Ah", "Ad", "Kc", "Kd", "Kh", "2s"), False)
    if two_trips != ("full-house", (14, 13)):
        fail("two-trips full-house edge case failed")
    flush = evaluate_seven(cards("As", "Js", "9s", "7s", "6s", "Kh", "Kd"), True)
    full_house = evaluate_seven(cards("Ah", "Ad", "Ac", "Kh", "Kd", "7c", "6c"), True)
    if compare_hands(flush, full_house, SHORT_OFFICIAL_ORDER) <= 0:
        fail("official Short Deck flush-over-full-house ordering failed")
    if compare_hands(flush, full_house, STANDARD_ORDER) >= 0:
        fail("standard full-house-over-flush ordering failed")
    straight = evaluate_seven(cards("As", "Ks", "Qd", "Jh", "Tc", "8c", "7c"), True)
    trips = evaluate_seven(cards("9s", "9h", "9d", "Ac", "Kc", "7h", "6h"), True)
    if compare_hands(straight, trips, SHORT_OFFICIAL_ORDER) <= 0:
        fail("selected rules profile must keep straight above trips")

    shared_board_hero = evaluate_seven(cards("Ac", "Kc", "Qc", "9c", "8c", "Jd", "Js"), True)
    shared_board_villain = evaluate_seven(cards("Qs", "Qh", "Qc", "9c", "8c", "Jd", "Js"), True)
    if compare_hands(shared_board_hero, shared_board_villain, STANDARD_ORDER) >= 0:
        fail("full-house-over-flush shared-board control failed")
    if compare_hands(shared_board_hero, shared_board_villain, SHORT_OFFICIAL_ORDER) <= 0:
        fail("flush-over-full-house shared-board official result failed")

    adversarial = [
        cards("As", "6s", "7s", "8s", "9s", "Kc", "Qd"),
        cards("As", "Ks", "Qd", "Jh", "Tc", "9c", "8c"),
        cards("As", "Ks", "Qs", "7s", "6s", "Jh", "Tc"),
        cards("As", "Ah", "Ad", "Kc", "Kd", "Kh", "6s"),
        cards("As", "Ah", "Ks", "Kh", "Qs", "Qh", "Jd"),
        cards("As", "Kh", "Qd", "7c", "6s", "Jc", "Tc"),
    ]
    for fixture in adversarial:
        direct = evaluate_seven(fixture, True)
        reference = reference_evaluate_seven(fixture, True, SHORT_OFFICIAL_ORDER)
        if direct != reference:
            fail(f"direct seven-card evaluator disagrees with 21-subset oracle: {fixture}")

    board_only_a = evaluate_seven(cards("As", "6c", "Ah", "Kh", "Qh", "Jh", "Th"), True)
    board_only_b = evaluate_seven(cards("9s", "8c", "Ah", "Kh", "Qh", "Jh", "Th"), True)
    if compare_hands(board_only_a, board_only_b, SHORT_OFFICIAL_ORDER) != 0:
        fail("board-playing tie fixture failed")

    wraparound = evaluate_seven(cards("As", "Ks", "Qd", "7h", "6c", "9c", "9d"), True)
    if wraparound[0] == "straight":
        fail("Q-K-A-6-7 must not be recognized as a straight")

    verify_five_card_class_counts()


def verify_full(data: dict[str, Any], workers: int) -> list[dict[str, Any]]:
    specs = [{"id": row["id"], "hero": row["hero"], "villain": row["villain"]} for row in data["matchups"]]
    if workers == 1:
        recalculated = []
        for index, spec in enumerate(specs, start=1):
            recalculated.append(enumerate_matchup(spec))
            print(f"recomputed {index}/{len(specs)}: {spec['id']}", file=sys.stderr, flush=True)
    else:
        with ProcessPoolExecutor(max_workers=workers) as executor:
            pending = {executor.submit(enumerate_matchup, spec): spec["id"] for spec in specs}
            recalculated = []
            for index, future in enumerate(as_completed(pending), start=1):
                recalculated.append(future.result())
                print(
                    f"recomputed {index}/{len(specs)}: {pending[future]}",
                    file=sys.stderr,
                    flush=True,
                )
    by_id = {row["id"]: row for row in data["matchups"]}
    for result in recalculated:
        frozen = by_id[result["id"]]
        for field in (
            "standardHoldem",
            "shortDeckConventionalStraightStandardCategoryOrder",
            "shortDeckLowStraightStandardCategoryOrder",
            "shortDeckOfficial",
        ):
            for key in ("wins", "ties", "losses"):
                if result[field][key] != frozen[field][key]:
                    fail(
                        f"{result['id']} {field}.{key}: expected {result[field][key]}, "
                        f"artifact has {frozen[field][key]}"
                    )
        if result["lowStraightBoardOutcomes"] != frozen["decomposition"]["lowStraightBoardOutcomes"]:
            fail(f"{result['id']} low-straight board counts differ")
        if result["rankingSwapBoardOutcomes"] != frozen["decomposition"]["rankingSwapBoardOutcomes"]:
            fail(f"{result['id']} ranking-swap board counts differ")
    return recalculated


def load_data(path: Path) -> dict[str, Any]:
    raw = path.read_bytes()
    if b"\r" in raw or not raw.endswith(b"\n") or raw.endswith(b"\n\n"):
        fail("JSON artifact must use LF and end with exactly one newline")
    return json.loads(raw.decode("utf-8"))


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--data", type=Path, default=DEFAULT_DATA)
    parser.add_argument("--quick", action="store_true", help="Skip full board re-enumeration.")
    parser.add_argument("--workers", type=int, default=max(1, min(4, os.cpu_count() or 1)))
    parser.add_argument("--report", type=Path, help="Write a machine-readable verification report.")
    args = parser.parse_args()
    if args.workers < 1:
        parser.error("--workers must be at least 1")

    started = time.perf_counter()
    data = load_data(args.data)
    quick_checks(data)
    recalculated = [] if args.quick else verify_full(data, args.workers)
    elapsed = round(time.perf_counter() - started, 3)
    report = {
        "schemaVersion": 1,
        "checkedAtUtc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "verifier": "public/data/short-deck-equity/short-deck-equity-verifier.py",
        "python": platform.python_version(),
        "mode": "quick" if args.quick else "full",
        "workers": args.workers,
        "runtimeSeconds": elapsed,
        "matchupsChecked": len(data["matchups"]),
        "fiveCardHandsClassifiedPerConvention": math.comb(36, 5),
        "exactOutcomePartitionsRecomputed": 0 if args.quick else len(recalculated) * 4,
        "status": "pass",
    }
    if args.report:
        args.report.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8", newline="\n")
    print(json.dumps(report, indent=2))
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except (OSError, ValueError, VerificationError) as error:
        print(f"verification failed: {error}", file=sys.stderr)
        raise SystemExit(1)
