#!/usr/bin/env python3
"""Independent verifier for the poker solver suit-frequency audit.

This standard-library implementation deliberately does not import the Node
generator or Rust harness. It validates the frozen inputs and complete retained
output, recomputes the H/S mappings and runouts, reconciles CSV/SVG, and checks
release hashes. It performs explicit checks so `python -O` remains meaningful.
"""

from __future__ import annotations

import csv
import hashlib
import itertools
import json
import math
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parent
EXPERIMENT_PATH = ROOT / "experiment.json"
CSV_PATH = ROOT / "suit-audit.csv"
SVG_PATH = ROOT / "suit-audit.svg"
MANIFEST_PATH = ROOT / "MANIFEST.sha256"
ENGINE_COMMIT = "9d1509fe5077d019825f833eed04b16d342dfda1"
CONVERGENCE_IDS = ("baseline", "middle", "tight")
SCENARIO_IDS = (
    "symmetric",
    "asymmetric",
    "asymmetric_relabelled",
    "board_asymmetric",
    "board_asymmetric_relabelled",
)
MAPPED_PAIRS = (
    ("asymmetric", "asymmetric_relabelled"),
    ("board_asymmetric", "board_asymmetric_relabelled"),
)
MANIFEST_ARTIFACTS = (
    "README.md",
    "experiment.json",
    "suit-audit.csv",
    "suit-audit.svg",
    "generate.mjs",
    "verify.py",
    "solver-harness/Cargo.toml",
    "solver-harness/Cargo.lock",
    "solver-harness/src/main.rs",
    "solver-harness/LICENSE",
)
SWAP = {"c": "c", "d": "d", "h": "s", "s": "h"}
RANKS = "23456789TJQKA"
SUITS = "cdhs"
FLOAT_TOLERANCE = 2e-5


class VerificationError(RuntimeError):
    """Raised when a release invariant fails."""


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


def read_json(path: Path) -> Any:
    require(path.is_file(), f"missing {path.name}")
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as error:
        raise VerificationError(f"invalid JSON in {path.name}: {error}") from error


def split_cards(value: str) -> list[str]:
    cards = re.findall(r"[2-9TJQKA][cdhs]", value)
    require("".join(cards) == value, f"invalid card string: {value}")
    return cards


def swap_card(card: str) -> str:
    require(bool(re.fullmatch(r"[2-9TJQKA][cdhs]", card)), f"invalid card: {card}")
    return card[0] + SWAP[card[1]]


def swap_cards(value: str) -> str:
    return "".join(swap_card(card) for card in split_cards(value))


def parse_range(value: str) -> dict[str, float]:
    parsed: dict[str, float] = {}
    for entry in value.split(","):
        hand, raw_weight = entry.split(":", 1)
        cards = split_cards(hand)
        require(len(cards) == 2 and len(set(cards)) == 2, f"invalid hand: {hand}")
        weight = float(raw_weight)
        require(math.isfinite(weight) and weight > 0, f"invalid range weight: {entry}")
        require(hand not in parsed, f"duplicate range hand: {hand}")
        parsed[hand] = weight
    return parsed


def swap_range(value: str) -> dict[str, float]:
    return {swap_cards(hand): weight for hand, weight in parse_range(value).items()}


def get_run(experiment: dict[str, Any], scenario: str, convergence: str) -> dict[str, Any]:
    matches = [
        run for run in experiment["results"]
        if run["scenario_id"] == scenario and run["convergence_id"] == convergence
    ]
    require(len(matches) == 1, f"expected one run for {scenario}/{convergence}")
    return matches[0]


def get_node(run: dict[str, Any], path: tuple[str, ...]) -> dict[str, Any]:
    matches = [node for node in run["nodes"] if tuple(node["path"]) == path]
    require(len(matches) == 1, f"expected one node at {run['scenario_id']}/{run['convergence_id']}/{path}")
    return matches[0]


def get_hand(node: dict[str, Any], name: str) -> dict[str, Any]:
    matches = [hand for hand in node["hands"] if hand["hand"] == name]
    require(len(matches) == 1, f"expected one {name} row at {node['path']}")
    return matches[0]


def get_action(hand: dict[str, Any], name: str) -> dict[str, Any]:
    matches = [action for action in hand["actions"] if action["action"] == name]
    require(len(matches) == 1, f"expected one {name} action for {hand['hand']}")
    return matches[0]


def scenario_map(experiment: dict[str, Any]) -> dict[str, dict[str, Any]]:
    return {scenario["id"]: scenario for scenario in experiment["config"]["scenarios"]}


def compare_runs(left: dict[str, Any], right: dict[str, Any], swap_labels: bool) -> float:
    maximum = abs(left["achieved_exploitability_chips"] - right["achieved_exploitability_chips"])
    require(len(left["nodes"]) == len(right["nodes"]), "mapped node count differs")
    for left_node in left["nodes"]:
        right_node = get_node(right, tuple(left_node["path"]))
        require(left_node["acting_player"] == right_node["acting_player"], "mapped acting player differs")
        require(len(left_node["hands"]) == len(right_node["hands"]), "mapped hand count differs")
        for left_hand in left_node["hands"]:
            right_name = swap_cards(left_hand["hand"]) if swap_labels else left_hand["hand"]
            right_hand = get_hand(right_node, right_name)
            maximum = max(
                maximum,
                abs(left_hand["compatible_reach_mass"] - right_hand["compatible_reach_mass"]),
                abs(left_hand["mixed_ev_chips"] - right_hand["mixed_ev_chips"]),
            )
            require(len(left_hand["actions"]) == len(right_hand["actions"]), "mapped action count differs")
            for left_action in left_hand["actions"]:
                right_action = get_action(right_hand, left_action["action"])
                maximum = max(
                    maximum,
                    abs(left_action["strategy"] - right_action["strategy"]),
                    abs(left_action["action_ev_chips"] - right_action["action_ev_chips"]),
                )
    return maximum


def count_flush_runouts(flop: str, hand: str) -> tuple[int, int]:
    known = split_cards(flop) + split_cards(hand)
    require(len(known) == 5 and len(set(known)) == 5, "known-card collision")
    deck = [rank + suit for rank in RANKS for suit in SUITS if rank + suit not in known]
    count = 0
    for first, second in itertools.combinations(deck, 2):
        suit_counts = {suit: 0 for suit in SUITS}
        for card in known + [first, second]:
            suit_counts[card[1]] += 1
        if max(suit_counts.values()) >= 5:
            count += 1
    return count, math.comb(len(deck), 2)


def natural_metrics(experiment: dict[str, Any], convergence: str) -> dict[str, float]:
    run = get_run(experiment, "board_asymmetric", convergence)
    node = get_node(run, ("check",))
    hearts = get_hand(node, "Ah5h")
    spades = get_hand(node, "As5s")
    hearts_bet = get_action(hearts, "bet_75")
    spades_bet = get_action(spades, "bet_75")
    hearts_check = get_action(hearts, "check")
    spades_check = get_action(spades, "check")
    return {
        "achieved": run["achieved_exploitability_chips"],
        "iterations": run["iterations"],
        "hearts_frequency": hearts_bet["strategy"],
        "spades_frequency": spades_bet["strategy"],
        "frequency_gap": abs(hearts_bet["strategy"] - spades_bet["strategy"]),
        "hearts_action_ev": hearts_bet["action_ev_chips"],
        "spades_action_ev": spades_bet["action_ev_chips"],
        "hearts_alternative_ev": hearts_check["action_ev_chips"],
        "spades_alternative_ev": spades_check["action_ev_chips"],
        "hearts_ev_gap": hearts_bet["action_ev_chips"] - hearts_check["action_ev_chips"],
        "spades_ev_gap": spades_bet["action_ev_chips"] - spades_check["action_ev_chips"],
    }


def range_metrics(experiment: dict[str, Any], convergence: str) -> dict[str, float]:
    run = get_run(experiment, "asymmetric", convergence)
    node = get_node(run, ("check", "bet_75"))
    hearts = get_hand(node, "AhJh")
    spades = get_hand(node, "AsJs")
    hearts_call = get_action(hearts, "call")
    spades_call = get_action(spades, "call")
    return {
        "achieved": run["achieved_exploitability_chips"],
        "iterations": run["iterations"],
        "hearts_frequency": hearts_call["strategy"],
        "spades_frequency": spades_call["strategy"],
        "frequency_gap": abs(hearts_call["strategy"] - spades_call["strategy"]),
        "hearts_action_ev": hearts_call["action_ev_chips"],
        "spades_action_ev": spades_call["action_ev_chips"],
        "hearts_mass": hearts["compatible_reach_mass"],
        "spades_mass": spades["compatible_reach_mass"],
    }


def verify_csv(experiment: dict[str, Any]) -> int:
    require(CSV_PATH.is_file(), "missing suit-audit.csv")
    with CSV_PATH.open(newline="", encoding="utf-8") as handle:
        rows = list(csv.DictReader(handle))
    require(len(rows) == 6, "CSV must have six rows")
    indexed = {(row["control"], row["convergence"]): row for row in rows}
    require(len(indexed) == 6, "duplicate CSV control/convergence row")
    for convergence in CONVERGENCE_IDS:
        for control, metrics in (
            ("board_runout_asymmetry", natural_metrics(experiment, convergence)),
            ("range_weight_asymmetry", range_metrics(experiment, convergence)),
        ):
            row = indexed[(control, convergence)]
            for column, key in (
                ("achieved_exploitability_chips", "achieved"),
                ("iterations", "iterations"),
                ("hearts_action_frequency", "hearts_frequency"),
                ("spades_action_frequency", "spades_frequency"),
                ("action_frequency_gap", "frequency_gap"),
                ("hearts_action_ev_chips", "hearts_action_ev"),
                ("spades_action_ev_chips", "spades_action_ev"),
            ):
                require(math.isclose(float(row[column]), float(metrics[key]), abs_tol=1e-12), f"CSV mismatch: {control}/{convergence}/{column}")
    tight = indexed[("board_runout_asymmetry", "tight")]
    require(int(tight["hearts_flush_completing_unordered_runouts"]) == 45, "CSV hearts runout count differs")
    require(int(tight["spades_flush_completing_unordered_runouts"]) == 0, "CSV spades runout count differs")
    require(int(tight["legal_unordered_runouts"]) == 1081, "CSV runout denominator differs")
    return len(rows)


def verify_svg() -> None:
    require(SVG_PATH.is_file(), "missing suit-audit.svg")
    source = SVG_PATH.read_text(encoding="utf-8")
    root = ET.fromstring(source)
    require(root.tag.endswith("svg"), "SVG root is not svg")
    require(root.attrib.get("viewBox") == "0 0 1200 760", "wrong SVG viewBox")
    require(root.attrib.get("width") == "1200" and root.attrib.get("height") == "760", "wrong SVG dimensions")
    require("aria-labelledby" in root.attrib, "SVG lacks aria-labelledby")
    require("<title" in source and "<desc" in source, "SVG lacks title or description")
    require(not re.search(r"<script|foreignObject|onload\s*=|href\s*=\s*['\"]https?", source, re.I), "unsafe SVG content")
    for fragment in ("71.96%", "49.87%", "22.09-point", "0.0028", "-0.0001", "45 / 1081", "0 / 1081"):
        require(fragment in source, f"SVG missing {fragment}")


def verify_manifest() -> int:
    require(MANIFEST_PATH.is_file(), "missing MANIFEST.sha256")
    rows = MANIFEST_PATH.read_text(encoding="utf-8").strip().splitlines()
    require(len(rows) == len(MANIFEST_ARTIFACTS), "manifest row count differs")
    names: list[str] = []
    for row in rows:
        match = re.fullmatch(r"([0-9a-f]{64})  (.+)", row)
        require(match is not None, f"invalid manifest row: {row}")
        expected_hash, name = match.groups()
        names.append(name)
        path = ROOT / name
        require(path.is_file(), f"manifest file missing: {name}")
        actual_hash = hashlib.sha256(path.read_bytes()).hexdigest()
        require(actual_hash == expected_hash, f"manifest hash differs: {name}")
    require(tuple(names) == MANIFEST_ARTIFACTS, "manifest order or names differ")
    return len(rows)


def verify() -> dict[str, Any]:
    experiment = read_json(EXPERIMENT_PATH)
    require(experiment["engine"]["repository"] == "https://github.com/b-inary/postflop-solver", "wrong engine repository")
    require(experiment["engine"]["commit"] == ENGINE_COMMIT, "wrong engine commit")
    require(experiment["engine"]["license"] == "AGPL-3.0-or-later", "wrong engine license")
    require(experiment["engine"]["source_git_head_verified"] == ENGINE_COMMIT, "engine HEAD was not verified")
    require(experiment["engine"]["source_git_worktree_clean"] is True, "engine checkout was dirty")
    config = experiment["config"]
    require(config["starting_pot_chips"] == 100 and config["effective_stack_behind_chips"] == 100, "pot/stack differs")
    require(config["rake_rate"] == 0 and config["rake_cap_chips"] == 0, "rake differs")
    require(config["flop_bet_sizes"] == ["OOP: none (check only)", "IP: 75% pot"], "flop sizes differ")
    require(config["flop_raise_sizes"] == ["none", "none"], "raise sizes differ")
    require(config["turn_bet_sizes"] == ["none (check only)", "none (check only)"], "turn tree differs")
    require(config["river_bet_sizes"] == ["none (check only)", "none (check only)"], "river tree differs")
    require(config["exploitability_check_interval_iterations"] == 10, "exploitability interval differs")
    scenarios = scenario_map(experiment)
    require(tuple(scenarios) == SCENARIO_IDS, "scenario list differs")
    expected_results = {(scenario, convergence) for scenario in SCENARIO_IDS for convergence in CONVERGENCE_IDS}
    actual_results = {(run["scenario_id"], run["convergence_id"]) for run in experiment["results"]}
    require(actual_results == expected_results and len(experiment["results"]) == 15, "run matrix differs")

    for scenario in scenarios.values():
        board = split_cards(scenario["flop"])
        require(len(board) == 3 and len(set(board)) == 3, f"invalid board: {scenario['id']}")
        for range_key in ("oop_range", "ip_range"):
            for hand in parse_range(scenario[range_key]):
                require(not set(split_cards(hand)) & set(board), f"range/board collision: {scenario['id']}/{hand}")

    for original_id, mapped_id in MAPPED_PAIRS:
        original = scenarios[original_id]
        mapped = scenarios[mapped_id]
        require(swap_cards(original["flop"]) == mapped["flop"], f"board map differs: {original_id}")
        require(swap_range(original["oop_range"]) == parse_range(mapped["oop_range"]), f"OOP range map differs: {original_id}")
        require(swap_range(original["ip_range"]) == parse_range(mapped["ip_range"]), f"IP range map differs: {original_id}")

    mapped_deltas: list[float] = []
    for convergence in CONVERGENCE_IDS:
        mapped_deltas.append(compare_runs(
            get_run(experiment, "symmetric", convergence),
            get_run(experiment, "symmetric", convergence),
            True,
        ))
        for original_id, mapped_id in MAPPED_PAIRS:
            mapped_deltas.append(compare_runs(
                get_run(experiment, original_id, convergence),
                get_run(experiment, mapped_id, convergence),
                True,
            ))

    require(max(mapped_deltas) <= FLOAT_TOLERANCE, "mapped output exceeds tolerance")
    for scenario in SCENARIO_IDS:
        achieved = [get_run(experiment, scenario, convergence)["achieved_exploitability_chips"] for convergence in CONVERGENCE_IDS]
        require(achieved[2] < achieved[1] < achieved[0], f"convergence ladder not decreasing: {scenario}")
        for convergence in CONVERGENCE_IDS:
            run = get_run(experiment, scenario, convergence)
            require(run["achieved_exploitability_chips"] <= run["target_exploitability_chips"], f"target missed: {scenario}/{convergence}")
            for node in run["nodes"]:
                for hand in node["hands"]:
                    values = [hand["compatible_reach_mass"], hand["mixed_ev_chips"]]
                    values += [value for action in hand["actions"] for value in (action["strategy"], action["action_ev_chips"])]
                    require(all(math.isfinite(value) for value in values), f"non-finite output: {scenario}/{convergence}/{hand['hand']}")
                    require(math.isclose(sum(action["strategy"] for action in hand["actions"]), 1.0, abs_tol=1e-5), f"strategy sum differs: {scenario}/{convergence}/{hand['hand']}")

    hearts_flushes, denominator = count_flush_runouts("Qc7h2d", "Ah5h")
    spades_flushes, denominator_again = count_flush_runouts("Qc7h2d", "As5s")
    require((hearts_flushes, spades_flushes, denominator, denominator_again) == (45, 0, 1081, 1081), "runout enumeration differs")
    tight_natural = natural_metrics(experiment, "tight")
    tight_range = range_metrics(experiment, "tight")
    require(math.isclose(tight_natural["hearts_frequency"], 0.71958524, abs_tol=1e-8), "Ah5h frequency differs")
    require(math.isclose(tight_natural["spades_frequency"], 0.49873212, abs_tol=1e-8), "As5s frequency differs")
    require(math.isclose(tight_natural["frequency_gap"], 0.22085312, abs_tol=1e-8), "natural frequency gap differs")
    require(math.isclose(tight_natural["hearts_ev_gap"], 0.002781, abs_tol=1e-6), "Ah5h action-EV gap differs")
    require(math.isclose(tight_natural["spades_ev_gap"], -0.000121, abs_tol=1e-6), "As5s action-EV gap differs")
    require(tight_range["hearts_frequency"] == 0 and tight_range["spades_frequency"] > 0.72, "range-weight frequency split differs")
    require(tight_range["hearts_action_ev"] < -21.65 and abs(tight_range["spades_action_ev"]) < 0.001, "range-weight EV contrast differs")

    csv_rows = verify_csv(experiment)
    verify_svg()
    manifest_rows = verify_manifest()
    return {
        "passed": True,
        "checks": {
            "retained_runs": len(experiment["results"]),
            "mapped_comparisons": len(mapped_deltas),
            "maximum_mapped_absolute_delta": max(mapped_deltas),
            "unordered_runouts_per_hand": denominator,
            "csv_rows": csv_rows,
            "manifest_rows": manifest_rows,
        },
        "tight_board_control": tight_natural,
        "tight_range_weight_control": tight_range,
        "limitations": [
            "This verifier is not a second equilibrium solver.",
            "The fixture is synthetic and the action tree is deliberately narrow.",
            "Exploitability is not a per-combination frequency error bar.",
            "The highlighted actions are nearly indifferent, so their exact frequencies are not a universal prescription.",
        ],
    }


if __name__ == "__main__":
    try:
        print(json.dumps(verify(), indent=2))
    except Exception as error:
        print(f"verification failed: {error}", file=sys.stderr)
        raise SystemExit(1) from error
