#!/usr/bin/env python3
"""Independently verify the public river block-bet JSON and CSV evidence."""

from __future__ import annotations

import csv
import json
import math
import re
from pathlib import Path


BUNDLE_DIR = Path(__file__).resolve().parent
JSON_PATH = BUNDLE_DIR / "river-block-bet-model.json"
CSV_PATH = BUNDLE_DIR / "river-block-bet-sensitivity.csv"
SVG_PATH = BUNDLE_DIR.parent / "images" / "blog_images" / "river-block-bet-ev-tree.svg"
SOLVER_SNAPSHOT_PATH = BUNDLE_DIR / "river-block-bet-solver-snapshot.json"
TOLERANCE = 1e-6


def reject_json_constant(value: str) -> None:
    raise AssertionError(f"non-finite JSON number is not allowed: {value}")


def load_json(text: str) -> dict:
    return json.loads(text, parse_constant=reject_json_constant)


def finite_number(value: float, label: str) -> float:
    if not isinstance(value, (int, float)) or isinstance(value, bool):
        raise AssertionError(f"{label} must be numeric")
    converted = float(value)
    if not math.isfinite(converted):
        raise AssertionError(f"{label} must be finite")
    return converted


def close(
    actual: float,
    expected: float,
    label: str,
    tolerance: float = TOLERANCE,
) -> None:
    if not math.isfinite(actual) or not math.isfinite(expected):
        raise AssertionError(f"{label} must compare finite numbers")
    if abs(actual - expected) > tolerance:
        raise AssertionError(f"{label}: {actual} != {expected}")


def probability(value: float, label: str) -> float:
    converted = finite_number(value, label)
    if converted < 0 or converted > 1:
        raise AssertionError(f"{label} must be between zero and one")
    return converted


def evaluate_check(inputs: dict[str, float]) -> dict[str, float | str]:
    pot = finite_number(inputs["pot"], "pot")
    check_back = probability(inputs["checkBackFrequency"], "checkBackFrequency")
    equity_checkback = probability(inputs["equityWhenCheckedBack"], "equityWhenCheckedBack")
    bet_size = finite_number(inputs["opponentBetSize"], "opponentBetSize")
    equity_vs_bet = probability(inputs["equityFacingBet"], "equityFacingBet")
    if pot <= 0 or bet_size < 0:
        raise AssertionError("pot must be positive and bet size non-negative")

    checkback_ev = equity_checkback * pot
    call_ev = equity_vs_bet * (pot + 2 * bet_size) - bet_size
    best_bet_ev = max(0.0, call_ev)
    return {
        "ev": check_back * checkback_ev + (1 - check_back) * best_bet_ev,
        "checkBackBranchEv": checkback_ev,
        "callFacingBetEv": call_ev,
        "foldFacingBetEv": 0.0,
        "bestFacingBetEv": best_bet_ev,
        "responseFacingBet": "call" if call_ev > 0 else "fold",
        "weightedCheckBackContribution": check_back * checkback_ev,
        "weightedBetContribution": (1 - check_back) * best_bet_ev,
    }


def evaluate_lead(inputs: dict[str, float]) -> dict[str, float | str]:
    pot = finite_number(inputs["pot"], "pot")
    lead_size = finite_number(inputs["leadSize"], "leadSize")
    raise_to = finite_number(inputs["raiseTo"], "raiseTo")
    fold = probability(inputs["foldFrequency"], "foldFrequency")
    call = probability(inputs["callFrequency"], "callFrequency")
    raise_frequency = probability(inputs["raiseFrequency"], "raiseFrequency")
    equity_called = probability(inputs["equityWhenCalled"], "equityWhenCalled")
    equity_raised = probability(inputs["equityFacingRaise"], "equityFacingRaise")
    if pot <= 0 or lead_size < 0 or raise_to < lead_size:
        raise AssertionError("invalid pot, lead, or raise-to size")
    close(fold + call + raise_frequency, 1.0, "response-frequency sum")

    fold_branch_ev = pot
    call_branch_ev = equity_called * (pot + 2 * lead_size) - lead_size
    fold_vs_raise_ev = -lead_size
    call_vs_raise_ev = equity_raised * (pot + 2 * raise_to) - raise_to
    best_raise_ev = max(fold_vs_raise_ev, call_vs_raise_ev)
    return {
        "ev": fold * fold_branch_ev + call * call_branch_ev + raise_frequency * best_raise_ev,
        "foldBranchEv": fold_branch_ev,
        "callBranchEv": call_branch_ev,
        "foldFacingRaiseEv": fold_vs_raise_ev,
        "callFacingRaiseEv": call_vs_raise_ev,
        "bestFacingRaiseEv": best_raise_ev,
        "responseFacingRaise": "call" if call_vs_raise_ev > fold_vs_raise_ev else "fold",
        "weightedFoldContribution": fold * fold_branch_ev,
        "weightedCallContribution": call * call_branch_ev,
        "weightedRaiseContribution": raise_frequency * best_raise_ev,
    }


def compare_result(actual: dict[str, float | str], expected: dict, prefix: str) -> None:
    for key, expected_value in expected.items():
        actual_value = actual[key]
        if isinstance(expected_value, (int, float)) and not isinstance(expected_value, bool):
            close(float(actual_value), float(expected_value), f"{prefix}.{key}")
        elif actual_value != expected_value:
            raise AssertionError(f"{prefix}.{key}: {actual_value!r} != {expected_value!r}")


def hand_cards(hand: str) -> tuple[str, str]:
    if re.fullmatch(r"(?:[2-9TJQKA][cdhs]){2}", hand) is None:
        raise AssertionError(f"invalid physical combination: {hand}")
    cards = (hand[:2], hand[2:])
    if cards[0] == cards[1]:
        raise AssertionError(f"combination repeats a card: {hand}")
    return cards


def hands_are_compatible(left: str, right: str) -> bool:
    return set(hand_cards(left)).isdisjoint(hand_cards(right))


def canonical_hand(hand: str) -> str:
    return "".join(sorted(hand_cards(hand)))


def verify_solver_snapshot(snapshot_text: str) -> None:
    forbidden_patterns = (
        ("URL scheme", r"\b(?:https?|file)://"),
        ("local host", r"\b(?:localhost|127\.0\.0\.1)\b"),
        ("Windows absolute path", r"[A-Za-z]:(?:\\\\|/)"),
        ("Unix private path", r"/(?:Users|home|root|private|tmp|var|srv|opt)/"),
        ("UNC path", r"\\\\\\\\[A-Za-z0-9._-]+\\\\"),
        (
            "endpoint or path key",
            r'"[^"]*(?:endpoint|requestUrl|serviceUrl|filesystemPath)[^"]*"\s*:',
        ),
        (
            "bare network host",
            r'\b(?:[a-z0-9-]+\.)+(?:com|net|org|io|app|dev)(?::\d+)?(?:/|")',
        ),
    )
    for label, pattern in forbidden_patterns:
        if re.search(pattern, snapshot_text, flags=re.IGNORECASE):
            raise AssertionError(f"public solver snapshot contains a {label}")

    snapshot = load_json(snapshot_text)
    provenance = snapshot["provenance"]
    close(provenance["perCombinationFrequencyResolution"], 0.01, "frequency resolution")
    if provenance.get("aggregationUsesRoundedFrequencies") is not True:
        raise AssertionError("rounded-frequency aggregation disclosure is missing")
    setup = snapshot["setup"]
    board = setup["board"]
    oop_range = setup["oopRange"]
    ip_range = setup["ipRange"]
    if len(oop_range) != 39 or len(set(oop_range)) != 39:
        raise AssertionError("OOP range must contain 39 unique physical combinations")
    if len(ip_range) != 40 or len(set(ip_range)) != 40:
        raise AssertionError("IP range must contain 40 unique physical combinations")
    if len({canonical_hand(hand) for hand in oop_range}) != 39:
        raise AssertionError("OOP range contains a reversed duplicate combination")
    if len({canonical_hand(hand) for hand in ip_range}) != 40:
        raise AssertionError("IP range contains a reversed duplicate combination")
    if len(board) != 5 or len(set(board)) != 5:
        raise AssertionError("board must contain five unique cards")
    if any(re.fullmatch(r"[2-9TJQKA][cdhs]", card) is None for card in board):
        raise AssertionError("board contains an invalid card")
    board_cards = set(board)
    for hand in oop_range + ip_range:
        collision = board_cards.intersection(hand_cards(hand))
        if collision:
            raise AssertionError(f"{hand} collides with board card {collision.pop()}")

    rows = snapshot["perOopCombinationRootFrequency"]
    if len(rows) != 39 or [row["hand"] for row in rows] != oop_range:
        raise AssertionError("solver rows must match the 39 OOP combinations in order")
    if len({row["hand"] for row in rows}) != 39:
        raise AssertionError("solver rows contain duplicate OOP combinations")

    for row in rows:
        compatible_count = sum(
            hands_are_compatible(row["hand"], ip_hand) for ip_hand in ip_range
        )
        if row["legalOpponentCombos"] != compatible_count:
            raise AssertionError(
                f"{row['hand']} compatibility count: "
                f"{row['legalOpponentCombos']} != {compatible_count}"
            )
        actions = [row["check"], row["bet25"], row["bet300"]]
        if any(
            not isinstance(value, (int, float))
            or isinstance(value, bool)
            or not math.isfinite(float(value))
            or value < 0
            or value > 1
            for value in actions
        ):
            raise AssertionError(f"{row['hand']} has an invalid action probability")
        close(sum(actions), 1.0, f"{row['hand']} action sum", 1e-12)

    independently_counted_pairs = sum(
        hands_are_compatible(oop_hand, ip_hand)
        for oop_hand in oop_range
        for ip_hand in ip_range
    )
    aggregation = snapshot["aggregation"]
    if len(oop_range) * len(ip_range) != 1560:
        raise AssertionError("cartesian pair count changed")
    if independently_counted_pairs != 1365:
        raise AssertionError("expected 1,365 legal OOP-IP pairs")
    if (
        aggregation["cartesianPairCount"],
        aggregation["removedCardCollisionCount"],
        aggregation["legalPairCount"],
    ) != (1560, 195, 1365):
        raise AssertionError("published pair-count aggregation changed")

    weighted = {
        action: sum(
            row[action] * row["legalOpponentCombos"] for row in rows
        )
        / independently_counted_pairs
        for action in ("check", "bet25", "bet300")
    }
    for action, value in weighted.items():
        close(
            value,
            aggregation["dealWeightedRootFrequency"][action],
            f"stored weighted {action}",
            1e-12,
        )
    close(weighted["check"] * 100, 69.562637, "weighted check percent")
    close(weighted["bet25"] * 100, 23.950183, "weighted bet-25 percent")
    close(weighted["bet300"] * 100, 6.487179, "weighted bet-300 percent")
    close(sum(weighted.values()), 1.0, "weighted root action sum", 1e-12)


def main() -> None:
    payload = load_json(JSON_PATH.read_text(encoding="utf-8"))
    solve_settings = load_json(SOLVER_SNAPSHOT_PATH.read_text(encoding="utf-8"))["setup"]["solveSettings"]
    if solve_settings.get("printInterval") != 10 or solve_settings.get("useIsomorphism") is not True:
        raise AssertionError("solver snapshot omits pinned execution settings")
    node = payload["referenceNode"]
    pot = float(node["pot"])
    lead_size = float(node["leadSize"])
    raise_to = float(node["raiseTo"])

    thresholds = payload["thresholds"]
    close(
        thresholds["zeroEquityLeadRequiredFoldFrequency"]["value"],
        lead_size / (pot + lead_size),
        "zero-equity lead threshold",
    )
    close(
        thresholds["zeroEquityBluffRaiseRequiredFoldFrequency"]["value"],
        raise_to / (pot + lead_size + raise_to),
        "zero-equity bluff-raise threshold",
    )
    close(
        thresholds["equityRequiredToCallRaiseInsteadOfFold"]["value"],
        (raise_to - lead_size) / (pot + 2 * raise_to),
        "raise-call equity threshold",
    )
    if [thresholds[key]["percent"] for key in thresholds] != [20, 44.444444, 25]:
        raise AssertionError("pinned threshold percentages changed")

    baseline_inputs = payload["baseline"]["inputs"]
    baseline_result = payload["baseline"]["result"]
    checked = evaluate_check(baseline_inputs["check"])
    led = evaluate_lead(baseline_inputs["lead"])
    compare_result(checked, baseline_result["check"], "baseline.check")
    compare_result(led, baseline_result["lead"], "baseline.lead")
    delta = float(led["ev"]) - float(checked["ev"])
    close(delta, baseline_result["deltaLeadMinusCheck"], "baseline delta")
    close(delta, 22.75, "pinned baseline delta")
    if baseline_result["preferredAction"] != "lead":
        raise AssertionError("pinned baseline action changed")

    rows = payload["sensitivity"]["rows"]
    if len(rows) != 16 or payload["sensitivity"]["rowCount"] != 16:
        raise AssertionError("expected 16 sensitivity rows")
    if len({row["id"] for row in rows}) != len(rows):
        raise AssertionError("sensitivity row IDs are not unique")

    with CSV_PATH.open(newline="", encoding="utf-8") as handle:
        csv_rows = {row["id"]: row for row in csv.DictReader(handle)}
    if set(csv_rows) != {row["id"] for row in rows}:
        raise AssertionError("CSV and JSON row IDs differ")

    outcome_counts = {"lead": 0, "check": 0, "tie": 0}
    constant_lead = payload["sensitivity"]["heldConstant"]["leadInputsExceptResponseFrequencies"]
    for row in rows:
        lead_inputs = {
            **constant_lead,
            "foldFrequency": row["foldFrequency"],
            "callFrequency": row["callFrequency"],
            "raiseFrequency": row["raiseFrequency"],
            "equityWhenCalled": row["equityWhenCalled"],
        }
        row_lead = evaluate_lead(lead_inputs)
        close(float(row_lead["ev"]), row["evLead"], f"{row['id']}.evLead")
        close(float(checked["ev"]), row["evCheck"], f"{row['id']}.evCheck")
        row_delta = float(row_lead["ev"]) - float(checked["ev"])
        close(row_delta, row["deltaLeadMinusCheck"], f"{row['id']}.delta")
        preferred = "tie" if abs(row_delta) <= TOLERANCE else "lead" if row_delta > 0 else "check"
        if row["preferredAction"] != preferred:
            raise AssertionError(f"{row['id']} preferred action mismatch")
        outcome_counts[preferred] += 1

        csv_row = csv_rows[row["id"]]
        for key, value in row.items():
            if isinstance(value, (int, float)) and not isinstance(value, bool):
                close(float(csv_row[key]), float(value), f"CSV {row['id']}.{key}")
            elif csv_row[key] != str(value):
                raise AssertionError(f"CSV {row['id']}.{key} mismatch")

    if outcome_counts != payload["sensitivity"]["outcomeCounts"]:
        raise AssertionError("sensitivity outcome counts mismatch")

    svg_verified = SVG_PATH.exists()
    if svg_verified:
        svg = SVG_PATH.read_text(encoding="utf-8")
        for sentinel in (
            '<title id="river-block-title">',
            '<desc id="river-block-desc">',
            "Illustrative model",
            "MODEL DELTA: LEAD − CHECK = +22.75",
        ):
            if sentinel not in svg:
                raise AssertionError(f"SVG is missing {sentinel!r}")

    verify_solver_snapshot(SOLVER_SNAPSHOT_PATH.read_text(encoding="utf-8"))

    svg_status = ", and accessible SVG" if svg_verified else ""
    print(
        "Verified the 20%/44.444444%/25% thresholds, branch-complete baseline, "
        "sensitivity grid, collision-weighted solver snapshot"
        f"{svg_status}."
    )


if __name__ == "__main__":
    main()
