#!/usr/bin/env python3
"""Reproducible conditional card-removal ("bunching") experiment.

This program deliberately models only hidden-card composition.  It does not use,
estimate, or claim to reproduce GTO strategies or real-player ranges.

The main estimator draws complete ordered deals uniformly without replacement,
then accepts each deal with probability equal to the product of the
model-specific fold likelihoods for every observed folding hand.  The accepted
deals are iid from the distribution conditional on *all* observed folds
together, so ordinary binomial intervals apply to target-hand events.  This
matters: conditioning one seat at a time would fail to propagate later action
evidence back to the earlier hidden cards.
"""

from __future__ import annotations

import argparse
import csv
import json
import math
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Callable

import numpy as np


RANK_LABELS = ("2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K", "A")
RANK_VALUE_TO_LABEL = {value: label for value, label in zip(range(2, 15), RANK_LABELS)}
MODEL_ORDER = (
    "random_fold_control",
    "rank_gradient",
    "pair_suited_gradient",
    "bucketed_top_band",
    "inverted_rank_diagnostic",
)
TABLE_FOLDS = {6: 3, 8: 5, 9: 6}
METRIC_ORDER = (
    "at_least_one_ace",
    "any_pair",
    "premium_pair_tt_plus",
    "ace_king",
    "both_cards_ten_plus",
)
Z_95 = 1.959963984540054


@dataclass(frozen=True)
class ModelDefinition:
    name: str
    purpose: str
    formula: str
    probability: Callable[[int, int, bool], float]


def clamp(value: float, low: float, high: float) -> float:
    return max(low, min(high, value))


def random_fold_control(high: int, low: int, suited: bool) -> float:
    del high, low, suited
    return 0.60


def rank_gradient(high: int, low: int, suited: bool) -> float:
    del suited
    return clamp(0.86 - 0.0275 * ((high + low) - 4), 0.18, 0.86)


def pair_suited_gradient(high: int, low: int, suited: bool) -> float:
    pair = high == low
    score = high + low + (5 if pair else 0) + (1 if suited else 0)
    return 0.05 + 0.90 / (1.0 + math.exp((score - 20.0) / 2.4))


def bucketed_top_band(high: int, low: int, suited: bool) -> float:
    pair = high == low
    top_band = (
        (pair and high >= 8)
        or (high == 14 and low >= 10)
        or (
            suited
            and (
                (high == 14 and low >= 8)
                or (high == 13 and low >= 10)
                or (high == 12 and low >= 10)
            )
        )
    )
    middle_band = pair or high == 14 or (suited and high >= 11 and low >= 8)
    if top_band:
        return 0.08
    if middle_band:
        return 0.38
    return 0.86


def inverted_rank_diagnostic(high: int, low: int, suited: bool) -> float:
    del suited
    return clamp(0.20 + 0.0275 * ((high + low) - 4), 0.20, 0.86)


MODELS = {
    "random_fold_control": ModelDefinition(
        "random_fold_control",
        "Negative control: a fold conveys no card information.",
        "base p(fold | hand) = 0.60 for all 169 hand classes",
        random_fold_control,
    ),
    "rank_gradient": ModelDefinition(
        "rank_gradient",
        "Smooth synthetic model in which high-rank hands are less fold-prone.",
        "p = clamp(0.86 - 0.0275 * ((high_rank + low_rank) - 4), 0.18, 0.86)",
        rank_gradient,
    ),
    "pair_suited_gradient": ModelDefinition(
        "pair_suited_gradient",
        "Smooth synthetic model that additionally makes pairs and suited hands less fold-prone.",
        "score = high + low + 5*pair + 1*suited; p = 0.05 + 0.90/(1 + exp((score - 20)/2.4))",
        pair_suited_gradient,
    ),
    "bucketed_top_band": ModelDefinition(
        "bucketed_top_band",
        "Discontinuous stress test with an explicitly defined top, middle, and other band.",
        (
            "p=0.08 for pairs 88+, AT+, and suited A8+/KT+/QT+; "
            "p=0.38 for remaining pairs, remaining Ax, and remaining suited hands with high>=J and low>=8; "
            "p=0.86 otherwise"
        ),
        bucketed_top_band,
    ),
    "inverted_rank_diagnostic": ModelDefinition(
        "inverted_rank_diagnostic",
        "Deliberately non-behavioral direction check in which high-rank hands are more fold-prone.",
        "p = clamp(0.20 + 0.0275 * ((high_rank + low_rank) - 4), 0.20, 0.86)",
        inverted_rank_diagnostic,
    ),
}


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


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


def raw_physical_combo_mean(model: ModelDefinition) -> float:
    values = []
    for first in range(52):
        for second in range(first + 1, 52):
            rank_first = card_rank(first)
            rank_second = card_rank(second)
            high, low = max(rank_first, rank_second), min(rank_first, rank_second)
            suited = card_suit(first) == card_suit(second)
            values.append(model.probability(high, low, suited))
    return math.fsum(values) / math.comb(52, 2)


TARGET_COMBO_MEAN_FOLD_LIKELIHOOD = 0.60
RAW_PHYSICAL_COMBO_MEANS = {
    name: raw_physical_combo_mean(model) for name, model in MODELS.items()
}
MODEL_SCALE_FACTORS = {
    name: TARGET_COMBO_MEAN_FOLD_LIKELIHOOD / RAW_PHYSICAL_COMBO_MEANS[name]
    for name in MODEL_ORDER
}


def canonical_class(high: int, low: int, suited: bool) -> str:
    if high == low:
        return RANK_VALUE_TO_LABEL[high] * 2
    suffix = "s" if suited else "o"
    return f"{RANK_VALUE_TO_LABEL[high]}{RANK_VALUE_TO_LABEL[low]}{suffix}"


def class_probability(model: ModelDefinition, high: int, low: int, suited: bool) -> float:
    value = model.probability(high, low, suited) * MODEL_SCALE_FACTORS[model.name]
    if not 0.0 < value <= 1.0:
        raise ValueError(f"Invalid fold likelihood {value} for {model.name}")
    return value


def likelihood_matrix(model: ModelDefinition) -> np.ndarray:
    matrix = np.zeros((52, 52), dtype=np.float64)
    for first in range(52):
        for second in range(first + 1, 52):
            rank_first = card_rank(first)
            rank_second = card_rank(second)
            high, low = max(rank_first, rank_second), min(rank_first, rank_second)
            suited = card_suit(first) == card_suit(second)
            value = class_probability(model, high, low, suited)
            matrix[first, second] = value
            matrix[second, first] = value
    return matrix


def hand_class_rows() -> list[dict[str, object]]:
    rows: list[dict[str, object]] = []
    for high in range(14, 1, -1):
        rows.append(
            {
                "hand_class": canonical_class(high, high, False),
                "high_rank": high,
                "low_rank": high,
                "shape": "pair",
                **{
                    name: class_probability(MODELS[name], high, high, False)
                    for name in MODEL_ORDER
                },
            }
        )
        for low in range(high - 1, 1, -1):
            for suited, shape in ((True, "suited"), (False, "offsuit")):
                rows.append(
                    {
                        "hand_class": canonical_class(high, low, suited),
                        "high_rank": high,
                        "low_rank": low,
                        "shape": shape,
                        **{
                            name: class_probability(MODELS[name], high, low, suited)
                            for name in MODEL_ORDER
                        },
                    }
                )
    if len(rows) != 169 or len({row["hand_class"] for row in rows}) != 169:
        raise AssertionError("Expected exactly 169 canonical hold'em hand classes")
    return rows


def exact_baselines() -> dict[str, float]:
    total = math.comb(52, 2)
    return {
        "at_least_one_ace": 1.0 - math.comb(48, 2) / total,
        "any_pair": (13 * math.comb(4, 2)) / total,
        "premium_pair_tt_plus": (5 * math.comb(4, 2)) / total,
        "ace_king": 16 / total,
        "both_cards_ten_plus": math.comb(20, 2) / total,
    }


def wilson_interval(successes: int, samples: int) -> tuple[float, float]:
    p_hat = successes / samples
    denominator = 1.0 + Z_95**2 / samples
    center = (p_hat + Z_95**2 / (2.0 * samples)) / denominator
    half_width = (
        Z_95
        * math.sqrt(p_hat * (1.0 - p_hat) / samples + Z_95**2 / (4.0 * samples**2))
        / denominator
    )
    return center - half_width, center + half_width


def sample_ordered_deals(samples: int, cards_needed: int, rng: np.random.Generator) -> np.ndarray:
    """Draw prefixes of uniform permutations with a vectorized partial Fisher-Yates shuffle."""

    deck = np.broadcast_to(np.arange(52, dtype=np.int16), (samples, 52)).copy()
    rows = np.arange(samples)
    for index in range(cards_needed):
        swap_index = rng.integers(index, 52, size=samples)
        at_index = deck[:, index].copy()
        at_swap = deck[rows, swap_index].copy()
        deck[:, index] = at_swap
        deck[rows, swap_index] = at_index
    return deck[:, :cards_needed]


def target_class_index_matrix(class_names: list[str]) -> np.ndarray:
    lookup = {name: index for index, name in enumerate(class_names)}
    matrix = np.full((52, 52), -1, dtype=np.int16)
    for first in range(52):
        for second in range(first + 1, 52):
            rank_first, rank_second = card_rank(first), card_rank(second)
            high, low = max(rank_first, rank_second), min(rank_first, rank_second)
            suited = card_suit(first) == card_suit(second)
            index = lookup[canonical_class(high, low, suited)]
            matrix[first, second] = index
            matrix[second, first] = index
    return matrix


def propose_batch(
    proposals: int,
    folds: int,
    fold_matrix: np.ndarray,
    class_matrix: np.ndarray,
    rng: np.random.Generator,
) -> dict[str, object]:
    deals = sample_ordered_deals(proposals, 2 * folds + 2, rng)
    folding_cards = deals[:, : 2 * folds].reshape(proposals, folds, 2)
    fold_probabilities = fold_matrix[folding_cards[:, :, 0], folding_cards[:, :, 1]]
    complete_pattern_probability = fold_probabilities.prod(axis=1, dtype=np.float64)
    accepted_indices = np.flatnonzero(rng.random(proposals) < complete_pattern_probability)

    accepted_folding_cards = folding_cards[accepted_indices]
    remaining_by_rank = np.full((accepted_indices.size, 13), 4, dtype=np.int16)
    flat_folding_ranks = accepted_folding_cards.reshape(accepted_indices.size, 2 * folds) // 4
    for rank_index in range(13):
        remaining_by_rank[:, rank_index] -= (flat_folding_ranks == rank_index).sum(
            axis=1, dtype=np.int16
        )
    rank_shares = remaining_by_rank / (52 - 2 * folds)

    first = deals[accepted_indices, 2 * folds]
    second = deals[accepted_indices, 2 * folds + 1]
    first_rank = first // 4 + 2
    second_rank = second // 4 + 2
    high = np.maximum(first_rank, second_rank)
    low = np.minimum(first_rank, second_rank)
    pair = high == low
    metrics = {
        "at_least_one_ace": (high == 14),
        "any_pair": pair,
        "premium_pair_tt_plus": pair & (high >= 10),
        "ace_king": (high == 14) & (low == 13),
        "both_cards_ten_plus": (low >= 10),
    }
    return {
        "accepted_indices": accepted_indices,
        "metrics": metrics,
        "rank_shares": rank_shares,
        "target_class_indices": class_matrix[first, second],
    }


def seed_for(base_seed: int, model_index: int, table_size: int) -> int:
    return base_seed + model_index * 10_000 + table_size * 101


def simulate_scenario(
    model_name: str,
    table_size: int,
    samples: int,
    batch_size: int,
    base_seed: int,
) -> dict[str, object]:
    folds = TABLE_FOLDS[table_size]
    model_index = MODEL_ORDER.index(model_name)
    scenario_seed = seed_for(base_seed, model_index, table_size)
    rng = np.random.Generator(np.random.PCG64DXSM(scenario_seed))
    fold_matrix = likelihood_matrix(MODELS[model_name])
    class_rows = hand_class_rows()
    class_names = [str(row["hand_class"]) for row in class_rows]
    class_matrix = target_class_index_matrix(class_names)

    successes = {metric: 0 for metric in METRIC_ORDER}
    half_successes = [dict.fromkeys(METRIC_ORDER, 0), dict.fromkeys(METRIC_ORDER, 0)]
    half_samples = [0, 0]
    class_counts = np.zeros(169, dtype=np.int64)
    rank_sum = np.zeros(13, dtype=np.float64)
    rank_sum_squares = np.zeros(13, dtype=np.float64)
    completed = 0
    attempted = 0
    midpoint = samples // 2

    while completed < samples:
        proposed = batch_size
        batch = propose_batch(proposed, folds, fold_matrix, class_matrix, rng)
        accepted_indices = batch["accepted_indices"]
        accepted = accepted_indices.size
        if accepted == 0:
            attempted += proposed
            continue
        take = min(accepted, samples - completed)
        if take < accepted:
            attempted += int(accepted_indices[take - 1]) + 1
        else:
            attempted += proposed

        for metric in METRIC_ORDER:
            successes[metric] += int(batch["metrics"][metric][:take].sum())
        selected_ranks = batch["rank_shares"][:take]
        rank_sum += selected_ranks.sum(axis=0, dtype=np.float64)
        rank_sum_squares += np.square(selected_ranks).sum(axis=0, dtype=np.float64)
        class_counts += np.bincount(
            batch["target_class_indices"][:take], minlength=169
        ).astype(np.int64)

        batch_cursor = 0
        while batch_cursor < take:
            half_index = 0 if completed < midpoint else 1
            half_boundary = midpoint if half_index == 0 else samples
            segment = min(take - batch_cursor, half_boundary - completed)
            for metric in METRIC_ORDER:
                half_successes[half_index][metric] += int(
                    batch["metrics"][metric][batch_cursor : batch_cursor + segment].sum()
                )
            half_samples[half_index] += segment
            batch_cursor += segment
            completed += segment

    baselines = exact_baselines()
    metric_results: dict[str, object] = {}
    split_half: dict[str, object] = {}
    for metric in METRIC_ORDER:
        estimate = successes[metric] / samples
        lower, upper = wilson_interval(successes[metric], samples)
        standard_error = math.sqrt(estimate * (1.0 - estimate) / samples)
        baseline = baselines[metric]
        change = estimate - baseline
        resolved_at_95 = not (lower <= baseline <= upper)
        first_half = half_successes[0][metric] / half_samples[0]
        second_half = half_successes[1][metric] / half_samples[1]
        split_se = math.sqrt(
            first_half * (1.0 - first_half) / half_samples[0]
            + second_half * (1.0 - second_half) / half_samples[1]
        )
        split_z = abs(first_half - second_half) / split_se if split_se else 0.0
        metric_results[metric] = {
            "successes": successes[metric],
            "estimate": estimate,
            "monte_carlo_standard_error": standard_error,
            "ci95": [lower, upper],
            "exact_no_information_baseline": baseline,
            "absolute_change": change,
            "absolute_change_percentage_points": 100.0 * change,
            "relative_change_percent": 100.0 * change / baseline,
            "event_frequency_translation": {
                "direction": "more" if change > 0 else "fewer" if change < 0 else "unchanged",
                "difference_resolved_at_95_percent": resolved_at_95,
                "approximately_one_event_per_target_hands": (
                    1.0 / abs(change) if change and resolved_at_95 else None
                ),
            },
        }
        split_half[metric] = {
            "first_half_estimate": first_half,
            "second_half_estimate": second_half,
            "absolute_difference": abs(first_half - second_half),
            "difference_z_score": split_z,
        }

    rank_results: dict[str, object] = {}
    baseline_share = 1.0 / 13.0
    for index, label in enumerate(RANK_LABELS):
        mean = rank_sum[index] / samples
        sample_variance = (rank_sum_squares[index] - samples * mean * mean) / (samples - 1)
        standard_error = math.sqrt(max(0.0, sample_variance) / samples)
        lower = max(0.0, mean - Z_95 * standard_error)
        upper = min(1.0, mean + Z_95 * standard_error)
        rank_results[label] = {
            "expected_share_of_remaining_cards": mean,
            "monte_carlo_standard_error": standard_error,
            "ci95": [lower, upper],
            "exact_no_information_share": baseline_share,
            "absolute_change_percentage_points": 100.0 * (mean - baseline_share),
            "relative_change_percent": 100.0 * (mean - baseline_share) / baseline_share,
        }

    class_results: dict[str, object] = {}
    total_combos = math.comb(52, 2)
    for index, row in enumerate(class_rows):
        hand_class = str(row["hand_class"])
        shape = str(row["shape"])
        combo_count = 6 if shape == "pair" else 4 if shape == "suited" else 12
        baseline = combo_count / total_combos
        estimate = class_counts[index] / samples
        lower, upper = wilson_interval(int(class_counts[index]), samples)
        change = estimate - baseline
        class_results[hand_class] = {
            "shape": shape,
            "physical_combo_count": combo_count,
            "count": int(class_counts[index]),
            "estimate": estimate,
            "ci95_wilson": [lower, upper],
            "exact_no_information_baseline": baseline,
            "absolute_change_percentage_points": 100.0 * change,
            "relative_change_percent": 100.0 * change / baseline,
        }

    return {
        "model": model_name,
        "table_size": table_size,
        "observed_folds_before_button": folds,
        "accepted_deals": samples,
        "attempted_deals": attempted,
        "rng": "NumPy PCG64DXSM",
        "seed": scenario_seed,
        "sampling_method": "whole-deal accept/reject using the product of all upstream fold likelihoods",
        "metrics": metric_results,
        "remaining_rank_composition": rank_results,
        "all_169_button_hand_classes": class_results,
        "diagnostics": {
            "observed_complete_pattern_acceptance_rate": samples / attempted,
            "accepted_deals": samples,
            "attempted_deals": attempted,
            "split_half": split_half,
            "maximum_split_half_z_score": max(
                item["difference_z_score"] for item in split_half.values()
            ),
        },
    }


def exact_one_fold_oracle() -> dict[str, object]:
    """Enumerate every folded combo and every possible Button combo after one fold."""

    class_rows = hand_class_rows()
    class_names = [str(row["hand_class"]) for row in class_rows]
    class_matrix = target_class_index_matrix(class_names)
    fold_pairs = [(first, second) for first in range(52) for second in range(first + 1, 52)]
    pair_count = len(fold_pairs)
    target_denominator = math.comb(50, 2)

    conditional_metrics = np.zeros((pair_count, len(METRIC_ORDER)), dtype=np.float64)
    conditional_rank_shares = np.zeros((pair_count, 13), dtype=np.float64)
    conditional_classes = np.zeros((pair_count, 169), dtype=np.float64)

    for pair_index, (first, second) in enumerate(fold_pairs):
        remaining_counts = np.full(13, 4, dtype=np.int16)
        remaining_counts[card_rank(first) - 2] -= 1
        remaining_counts[card_rank(second) - 2] -= 1
        ace_count = int(remaining_counts[12])
        broadway_count = int(remaining_counts[8:].sum())
        conditional_metrics[pair_index] = (
            1.0 - math.comb(50 - ace_count, 2) / target_denominator,
            sum(math.comb(int(count), 2) for count in remaining_counts) / target_denominator,
            sum(math.comb(int(count), 2) for count in remaining_counts[8:]) / target_denominator,
            int(remaining_counts[12]) * int(remaining_counts[11]) / target_denominator,
            math.comb(broadway_count, 2) / target_denominator,
        )
        conditional_rank_shares[pair_index] = remaining_counts / 50.0

        available = np.array(
            [card for card in range(52) if card != first and card != second], dtype=np.int16
        )
        target_class_counts = np.zeros(169, dtype=np.int32)
        for left_index in range(49):
            indices = class_matrix[available[left_index], available[left_index + 1 :]]
            target_class_counts += np.bincount(indices, minlength=169).astype(np.int32)
        if int(target_class_counts.sum()) != target_denominator:
            raise AssertionError("One-fold oracle did not enumerate 1,225 target combos")
        conditional_classes[pair_index] = target_class_counts / target_denominator

    result_models: dict[str, object] = {}
    baselines = exact_baselines()
    total_combos = math.comb(52, 2)
    for model_name in MODEL_ORDER:
        fold_matrix = likelihood_matrix(MODELS[model_name])
        weights = np.array([fold_matrix[first, second] for first, second in fold_pairs])
        posterior = weights / weights.sum()
        metric_estimates = posterior @ conditional_metrics
        rank_estimates = posterior @ conditional_rank_shares
        class_estimates = posterior @ conditional_classes
        result_models[model_name] = {
            "metrics": {
                metric_name: {
                    "estimate": float(metric_estimates[index]),
                    "exact_no_information_baseline": baselines[metric_name],
                    "absolute_change_percentage_points": 100.0
                    * (float(metric_estimates[index]) - baselines[metric_name]),
                    "relative_change_percent": 100.0
                    * (float(metric_estimates[index]) - baselines[metric_name])
                    / baselines[metric_name],
                }
                for index, metric_name in enumerate(METRIC_ORDER)
            },
            "remaining_rank_composition": {
                label: {
                    "expected_share_of_remaining_cards": float(rank_estimates[index]),
                    "exact_no_information_share": 1.0 / 13.0,
                    "absolute_change_percentage_points": 100.0
                    * (float(rank_estimates[index]) - 1.0 / 13.0),
                }
                for index, label in enumerate(RANK_LABELS)
            },
            "all_169_button_hand_classes": {
                str(row["hand_class"]): {
                    "estimate": float(class_estimates[index]),
                    "exact_no_information_baseline": (
                        6 if row["shape"] == "pair" else 4 if row["shape"] == "suited" else 12
                    )
                    / total_combos,
                    "absolute_change_percentage_points": 100.0
                    * (
                        float(class_estimates[index])
                        - (
                            6
                            if row["shape"] == "pair"
                            else 4
                            if row["shape"] == "suited"
                            else 12
                        )
                        / total_combos
                    ),
                }
                for index, row in enumerate(class_rows)
            },
        }

    return {
        "method": (
            "Exact enumeration of 1,326 possible upstream folded hands and, for each, "
            "all 1,225 possible Button hands from the remaining 50 cards."
        ),
        "folded_hand_combos": math.comb(52, 2),
        "button_hand_combos_per_folded_hand": target_denominator,
        "models": result_models,
    }


def write_csv(rows: list[dict[str, object]], output: Path) -> None:
    fieldnames = ["hand_class", "high_rank", "low_rank", "shape", *MODEL_ORDER]
    with output.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=fieldnames, lineterminator="\n")
        writer.writeheader()
        for row in rows:
            writer.writerow(
                {
                    key: f"{value:.12f}" if isinstance(value, float) else value
                    for key, value in row.items()
                }
            )


def write_result_csvs(payload: dict[str, object], output_dir: Path) -> None:
    class_fields = (
        "model",
        "table_size",
        "observed_folds",
        "hand_class",
        "shape",
        "physical_combo_count",
        "estimate",
        "ci95_lower",
        "ci95_upper",
        "exact_baseline",
        "absolute_change_percentage_points",
        "relative_change_percent",
    )
    with (output_dir / "button_hand_class_results.csv").open(
        "w", newline="", encoding="utf-8"
    ) as handle:
        writer = csv.DictWriter(handle, fieldnames=class_fields, lineterminator="\n")
        writer.writeheader()
        for scenario in payload["scenarios"]:
            for hand_class, result in scenario["all_169_button_hand_classes"].items():
                writer.writerow(
                    {
                        "model": scenario["model"],
                        "table_size": scenario["table_size"],
                        "observed_folds": scenario["observed_folds_before_button"],
                        "hand_class": hand_class,
                        "shape": result["shape"],
                        "physical_combo_count": result["physical_combo_count"],
                        "estimate": f"{result['estimate']:.12f}",
                        "ci95_lower": f"{result['ci95_wilson'][0]:.12f}",
                        "ci95_upper": f"{result['ci95_wilson'][1]:.12f}",
                        "exact_baseline": f"{result['exact_no_information_baseline']:.12f}",
                        "absolute_change_percentage_points": f"{result['absolute_change_percentage_points']:.9f}",
                        "relative_change_percent": f"{result['relative_change_percent']:.9f}",
                    }
                )

    metric_fields = (
        "model",
        "table_size",
        "observed_folds",
        "accepted_deals",
        "attempted_deals",
        "metric",
        "estimate",
        "ci95_lower",
        "ci95_upper",
        "exact_baseline",
        "absolute_change_percentage_points",
        "relative_change_percent",
        "event_direction",
        "one_event_per_button_hands",
    )
    with (output_dir / "headline_metric_results.csv").open(
        "w", newline="", encoding="utf-8"
    ) as handle:
        writer = csv.DictWriter(handle, fieldnames=metric_fields, lineterminator="\n")
        writer.writeheader()
        for scenario in payload["scenarios"]:
            for metric, result in scenario["metrics"].items():
                writer.writerow(
                    {
                        "model": scenario["model"],
                        "table_size": scenario["table_size"],
                        "observed_folds": scenario["observed_folds_before_button"],
                        "accepted_deals": scenario["accepted_deals"],
                        "attempted_deals": scenario["attempted_deals"],
                        "metric": metric,
                        "estimate": f"{result['estimate']:.12f}",
                        "ci95_lower": f"{result['ci95'][0]:.12f}",
                        "ci95_upper": f"{result['ci95'][1]:.12f}",
                        "exact_baseline": f"{result['exact_no_information_baseline']:.12f}",
                        "absolute_change_percentage_points": f"{result['absolute_change_percentage_points']:.9f}",
                        "relative_change_percent": f"{result['relative_change_percent']:.9f}",
                        "event_direction": result["event_frequency_translation"]["direction"],
                        "one_event_per_button_hands": (
                            f"{result['event_frequency_translation']['approximately_one_event_per_target_hands']:.6f}"
                            if result["event_frequency_translation"]["approximately_one_event_per_target_hands"] is not None
                            else ""
                        ),
                    }
                )


def pct(value: float, digits: int = 2) -> str:
    return f"{100.0 * value:.{digits}f}%"


def signed_pp(value: float) -> str:
    return f"{100.0 * value:+.2f} pp"


def write_results_markdown(payload: dict[str, object], output: Path) -> None:
    scenario_lookup = {
        (entry["model"], entry["table_size"]): entry for entry in payload["scenarios"]
    }
    lines = [
        "# Conditional card-removal experiment results",
        "",
        f"Generated {payload['study']['generated_on']} with {payload['study']['accepted_deals_per_scenario']:,} iid accepted deals per scenario.",
        "",
        "These are card-composition results under explicit synthetic fold-likelihood models. They are not estimates of GTO ranges, population behavior, or strategic EV.",
        "",
        "## At least one ace in the Button's hand",
        "",
        f"Exact no-information baseline: {pct(payload['exact_no_information_baselines']['at_least_one_ace'], 4)}.",
        "",
        "| Model | 6-max / 3 folds | 8-max / 5 folds | 9-max / 6 folds |",
        "| --- | ---: | ---: | ---: |",
    ]
    for model_name in MODEL_ORDER:
        cells = []
        for table_size in TABLE_FOLDS:
            metric = scenario_lookup[(model_name, table_size)]["metrics"]["at_least_one_ace"]
            cells.append(f"{pct(metric['estimate'])} ({signed_pp(metric['absolute_change'])})")
        lines.append(f"| `{model_name}` | " + " | ".join(cells) + " |")

    lines.extend(
        [
            "",
            "## 9-max sensitivity across outcome definitions",
            "",
            "Each cell gives the conditional estimate and absolute change from its exact no-information baseline.",
            "",
            "| Model | ≥1 ace | Any pair | TT+ pair | AK | Both cards T+ |",
            "| --- | ---: | ---: | ---: | ---: | ---: |",
        ]
    )
    for model_name in MODEL_ORDER:
        scenario = scenario_lookup[(model_name, 9)]
        cells = []
        for metric_name in METRIC_ORDER:
            metric = scenario["metrics"][metric_name]
            cells.append(f"{pct(metric['estimate'])} ({signed_pp(metric['absolute_change'])})")
        lines.append(f"| `{model_name}` | " + " | ".join(cells) + " |")

    lines.extend(
        [
            "",
            "## Precision and stability",
            "",
            "Binary-event intervals in `results.json` are 95% Wilson intervals. Rank-share intervals use a normal approximation to the Monte Carlo sample mean. These intervals quantify simulation noise only; they do not quantify whether any synthetic fold model resembles a real player or solver.",
            "",
            "Each scenario also records first-half versus second-half estimates and a standardized difference. The independent checker uses a different estimator: self-normalized importance sampling from unconditional deals.",
            "",
            "## Reading the scale translation",
            "",
            "For each event, `approximately_one_event_per_target_hands` is `1 / |conditional probability - exact baseline|`. For example, 50 means the model produces about one additional (or one fewer) event per 50 Button hands in repeated deals under that model. It is an absolute-frequency translation, not a waiting-time forecast and not a strategy recommendation.",
            "",
            "## Hard limits",
            "",
            "- Every non-control action likelihood is synthetic and public, not fitted to hand histories or solver output.",
            "- The same likelihood rule is repeated for every folding seat so table-size comparisons isolate the number of observed folds.",
            "- The target is the Button's two-card hand conditional on upstream folds; no betting sizes, stacks, rake, antes, or position-specific strategy are modeled.",
            "- All hole cards are physically dealt before action. Generating upstream cards first and Button cards last is exchangeable sampling bookkeeping, not a claim about dealing order.",
            "- The experiment measures card-rank composition only. It cannot establish GTO frequencies, exploitability, action EV, product behavior, or profit.",
            "- The inverted diagnostic intentionally demonstrates that direction follows the conditioning model; no universal enrichment claim is warranted.",
            "",
        ]
    )
    output.write_text("\n".join(lines), encoding="utf-8", newline="\n")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--samples", type=int, default=250_000, help="Accepted iid deals per model/table-size scenario")
    parser.add_argument("--batch-size", type=int, default=100_000, help="Unconditional deals proposed per batch")
    parser.add_argument("--seed", type=int, default=2026090406)
    parser.add_argument("--output-dir", type=Path, default=Path(__file__).resolve().parent)
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    if args.samples < 10_000 or args.samples % 2:
        raise SystemExit("--samples must be an even integer of at least 10,000")
    if args.batch_size <= 0:
        raise SystemExit("--batch-size must be positive")
    args.output_dir.mkdir(parents=True, exist_ok=True)

    rows = hand_class_rows()
    write_csv(rows, args.output_dir / "fold_likelihoods.csv")

    oracle = exact_one_fold_oracle()
    with (args.output_dir / "one_fold_exact_oracle.json").open(
        "w", encoding="utf-8", newline="\n"
    ) as handle:
        json.dump(oracle, handle, indent=2, sort_keys=False)
        handle.write("\n")

    physical_combo_means = {}
    for model_name in MODEL_ORDER:
        matrix = likelihood_matrix(MODELS[model_name])
        upper = matrix[np.triu_indices(52, k=1)]
        physical_combo_means[model_name] = float(upper.mean())

    scenarios = []
    for model_name in MODEL_ORDER:
        for table_size in TABLE_FOLDS:
            print(f"Simulating {model_name}, {table_size}-max...", flush=True)
            scenarios.append(
                simulate_scenario(
                    model_name=model_name,
                    table_size=table_size,
                    samples=args.samples,
                    batch_size=args.batch_size,
                    base_seed=args.seed,
                )
            )

    payload = {
        "schema_version": 1,
        "study": {
            "generated_on": date.today().isoformat(),
            "accepted_deals_per_scenario": args.samples,
            "batch_size": args.batch_size,
            "base_seed": args.seed,
            "deck": "standard 52-card deck; two private cards per hand",
            "target": "Button's two-card hand conditional on all upstream folds",
            "dealing_order_note": (
                "All private cards are dealt before action; the simulation order is exchangeable "
                "bookkeeping only."
            ),
            "primary_estimator": (
                "whole-deal acceptance/rejection on the product of all upstream fold likelihoods; "
                "accepted deals are iid"
            ),
            "interval_convention": (
                "95% Wilson intervals for binary events and hand classes; normal 95% Monte Carlo "
                "intervals for mean remaining-rank shares"
            ),
            "table_size_mapping": {
                str(size): {
                    "observed_folds_before_button": folds,
                    "interpretation": f"all {folds} seats before the button folded",
                }
                for size, folds in TABLE_FOLDS.items()
            },
            "scope_warning": (
                "Synthetic card-composition experiment only; no GTO, population, EV, "
                "or product claim is encoded or supported."
            ),
        },
        "models": {
            name: {
                "purpose": MODELS[name].purpose,
                "base_formula": MODELS[name].formula,
                "raw_unconditional_physical_combo_mean": RAW_PHYSICAL_COMBO_MEANS[name],
                "multiplicative_normalization_factor": MODEL_SCALE_FACTORS[name],
                "actual_probability_rule": (
                    f"({MODELS[name].formula.removeprefix('base ')}) * "
                    f"{MODEL_SCALE_FACTORS[name]:.15g}"
                ),
                "target_unconditional_physical_combo_mean": TARGET_COMBO_MEAN_FOLD_LIKELIHOOD,
                "unconditional_physical_combo_mean_fold_likelihood": physical_combo_means[name],
            }
            for name in MODEL_ORDER
        },
        "exact_no_information_baselines": exact_baselines(),
        "exact_no_information_rank_share": 1.0 / 13.0,
        "one_fold_exact_oracle_file": "one_fold_exact_oracle.json",
        "scenarios": scenarios,
    }
    with (args.output_dir / "results.json").open("w", encoding="utf-8", newline="\n") as handle:
        json.dump(payload, handle, indent=2, sort_keys=False)
        handle.write("\n")
    write_result_csvs(payload, args.output_dir)
    write_results_markdown(payload, args.output_dir / "RESULTS.md")
    print(f"Wrote results for {len(scenarios)} scenarios to {args.output_dir}")


if __name__ == "__main__":
    main()
