from __future__ import annotations

import re
from pathlib import Path

import numpy as np
import pytest
import torch
from coolrl_lost_cities.games.classic.deep_cfr.encoding import encode_info_state, input_dim
from coolrl_lost_cities.games.classic.deep_cfr.traversal import (
    CythonDeepCFRTraverser,
    run_cython_traversal_batch,
)
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig

from coolrl_lost_cities.games.classic.deep_cfr.benchmark import (
    benchmark_traversal,
    benchmark_traversal_modes,
)
from coolrl_lost_cities.games.classic.deep_cfr.checkpoints import load_checkpoint
from coolrl_lost_cities.games.classic.deep_cfr.cli import (
    _kebab_slug,
    _resolve_run_dir,
    _train_overrides_from_args,
    _with_overrides,
)
from coolrl_lost_cities.games.classic.deep_cfr.config import DeepCFRConfig, load_config
from coolrl_lost_cities.games.classic.deep_cfr.evaluate import evaluate_strategy_network
from coolrl_lost_cities.games.classic.deep_cfr.interleaved_traversal import (
    _apply_first_open_prior,
    _first_open_recoverable_score,
    run_interleaved_traversal_batch,
)
from coolrl_lost_cities.games.classic.deep_cfr.memory import ReservoirMemory, TrainingSample
from coolrl_lost_cities.games.classic.deep_cfr.networks import DeepCFRMLP
from coolrl_lost_cities.games.classic.deep_cfr.trainer import (
    DeepCFRTrainer,
    eval_skipped_warning,
)


def _deep_cfr_config(data: dict) -> DeepCFRConfig:
    return DeepCFRConfig.model_validate(data)


def test_deep_cfr_loads_smoke_yaml_config() -> None:
    config = load_config("legacy/deep-cfr/configs/smoke.yaml")

    assert config.run.max_iterations == 1
    assert config.network.hidden_size == 16
    assert config.traversal.traversals_per_player == 1
    assert config.run.experiment_name == "smoke"


def test_deep_cfr_loads_mapped_legacy_reproduction_config() -> None:
    config = load_config("configs/archive/deep-cfr-selfplay-full-depth-slot-playability.yaml")

    assert config.run.experiment_name.endswith("slot-playability")
    assert config.run.seed == 79
    assert config.run.max_iterations is None
    assert config.run.max_minutes == 240
    assert config.encoding.derived_playability is True
    assert config.encoding.slot_aware_playability is True
    assert config.network.hidden_size == 256
    assert config.network.num_layers == 3
    assert config.traversal.traversals_per_player == 70
    assert config.traversal.sampling_mode == "outcome"
    assert config.traversal.max_depth is None
    assert config.traversal.max_nodes_per_traversal == 1000
    assert config.traversal.worker_chunk_size == 8
    assert config.traversal.progress_every_traversals == 10
    assert config.optimization.advantage_batch_size == 1024
    assert config.optimization.strategy_batch_size == 1024
    assert config.optimization.advantage_updates_per_iteration == 256
    assert config.optimization.strategy_updates_per_iteration == 256
    assert config.optimization.weight_decay == 0.0001
    assert config.optimization.grad_clip == 1.0
    assert config.evaluation.on_max_steps == "score_diff"
    assert config.evaluation.batch_size == 64
    assert config.evaluation.device == "trainer"
    assert config.evaluation.resolved_num_workers() == 4
    assert config.regret_matching.all_negative_fallback == "uniform"
    assert config.training_weighting.mode == "none"
    assert config.checkpoint.save_every == 10


def test_deep_cfr_train_cli_accepts_run_and_traversal_config_overrides() -> None:
    args = type(
        "Args",
        (),
        {
            "config_overrides": [
                "run.max_iterations=1",
                "run.max_minutes=null",
                "traversal.traversals_per_player=1",
                "traversal.num_workers=0",
                "regret_matching.all_negative_fallback=argmax_tiebreak",
                "training_weighting.mode=lcfr",
                "checkpoint.save_latest=false",
                "checkpoint.save_every=0",
            ],
        },
    )()
    config = load_config("configs/archive/deep-cfr-selfplay-full-depth-slot-playability.yaml")

    overridden = _with_overrides(config, _train_overrides_from_args(args))

    assert overridden.run.max_iterations == 1
    assert overridden.run.max_minutes is None
    assert overridden.traversal.traversals_per_player == 1
    assert overridden.traversal.resolved_num_workers() == 0
    assert overridden.regret_matching.all_negative_fallback == "argmax_tiebreak"
    assert overridden.training_weighting.mode == "lcfr"
    assert overridden.checkpoint.save_every == 0
    assert overridden.checkpoint.save_latest is False


def test_deep_cfr_config_accepts_external_sampling_mode() -> None:
    config = _deep_cfr_config(
        {
            "traversal": {
                "sampling_mode": "external",
                "store_strategy_on_traverser_nodes": False,
            }
        }
    )

    assert config.traversal.sampling_mode == "external"


def test_deep_cfr_config_accepts_interleaved_scheduler() -> None:
    config = _deep_cfr_config(
        {"traversal": {"scheduler": "interleaved", "opponent_policy": "average_strategy"}}
    )

    assert config.traversal.scheduler == "interleaved"
    assert config.traversal.opponent_policy == "average_strategy"


def test_deep_cfr_config_rejects_unsupported_interleaved_options() -> None:
    with pytest.raises(
        ValueError,
        match=(
            "opponent_policy='network', 'average_strategy', 'discard_only', or 'heuristic_balanced'"
        ),
    ):
        _deep_cfr_config(
            {"traversal": {"scheduler": "interleaved", "opponent_policy": "self_play_league"}}
        )
    with pytest.raises(ValueError, match="requires inference_backend='local'"):
        _deep_cfr_config(
            {
                "traversal": {
                    "scheduler": "interleaved",
                    "opponent_policy": "network",
                    "inference_backend": "server",
                }
            }
        )


def test_deep_cfr_config_accepts_discard_only_with_interleaved() -> None:
    config = _deep_cfr_config(
        {"traversal": {"scheduler": "interleaved", "opponent_policy": "discard_only"}}
    )
    assert config.traversal.opponent_policy == "discard_only"


def test_deep_cfr_config_accepts_heuristic_balanced_with_interleaved() -> None:
    config = _deep_cfr_config(
        {"traversal": {"scheduler": "interleaved", "opponent_policy": "heuristic_balanced"}}
    )
    assert config.traversal.opponent_policy == "heuristic_balanced"


def test_deep_cfr_config_rejects_discard_only_with_recursive() -> None:
    with pytest.raises(ValueError, match="discard_only.*scheduler='interleaved'"):
        _deep_cfr_config(
            {"traversal": {"scheduler": "recursive", "opponent_policy": "discard_only"}}
        )


def test_deep_cfr_train_cli_checkpoint_save_overrides() -> None:
    args = type(
        "Args",
        (),
        {
            "config_overrides": [
                "checkpoint.save_latest=true",
                "checkpoint.save_every=1",
            ],
        },
    )()

    overridden = _with_overrides(DeepCFRConfig(), _train_overrides_from_args(args))

    assert overridden.checkpoint.save_latest is True
    assert overridden.checkpoint.save_every == 1


def test_deep_cfr_train_cli_accepts_generic_config_overrides() -> None:
    args = type(
        "Args",
        (),
        {
            "config_overrides": [
                "traversal.sampling_mode=external",
                "traversal.store_strategy_on_traverser_nodes=false",
                "traversal.max_depth=null",
                "optimization.advantage_batch_size=64",
                "checkpoint.save_latest=true",
            ],
        },
    )()

    overridden = _with_overrides(DeepCFRConfig(), _train_overrides_from_args(args))

    assert overridden.traversal.sampling_mode == "external"
    assert overridden.traversal.max_depth is None
    assert overridden.optimization.advantage_batch_size == 64
    assert overridden.checkpoint.save_latest is True


def test_deep_cfr_iteration_weights_use_sample_age() -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 12},
                "network": {"hidden_size": 16},
                "checkpoint": {"save_every": 0},
                "training_weighting": {"mode": "lcfr", "lcfr_alpha": 1.0},
            }
        ),
        LostCitiesConfig(seed=12),
    )
    trainer.iteration = 10

    weights = trainer._iteration_weights(torch.tensor([1.0, 5.0, 10.0], device=trainer.device), 1.0)

    assert np.allclose(weights.detach().cpu().numpy(), np.asarray([0.1, 0.5, 1.0]))
    assert trainer.config.training_weighting.mode == "lcfr"


def test_deep_cfr_batched_evaluation_matches_batch_size_one() -> None:
    config = _deep_cfr_config(
        {
            "network": {"hidden_size": 16},
            "encoding": {"derived_playability": True, "slot_aware_playability": True},
        }
    )
    game_config = LostCitiesConfig(seed=123)
    state = GameState.new_game(game_config, seed=123)
    network = DeepCFRMLP.from_config(
        input_dim(state, config.encoding),
        game_config.action_size,
        config.network,
    )
    network.eval()
    kwargs = {
        "strategy_network": network,
        "config": game_config,
        "games": 8,
        "seed": 55,
        "opponent": "random",
        "device": "cpu",
        "max_steps": 200,
        "encoding": config.encoding,
    }

    batch_one = evaluate_strategy_network(**kwargs, batch_size=1)
    batched = evaluate_strategy_network(**kwargs, batch_size=64)

    for key in [
        "games",
        "wins0",
        "wins1",
        "draws",
        "avg_score0",
        "avg_score1",
        "avg_score_diff0",
        "avg_game_length",
        "max_step_timeouts",
        "play_action_rate",
    ]:
        assert batched[key] == batch_one[key]
    assert np.isclose(batched["policy_entropy"], batch_one["policy_entropy"])


def test_deep_cfr_resolve_run_dir_uses_keep_flag_and_kebab_slug() -> None:
    config = _deep_cfr_config({"run": {"experiment_name": "Color Shared Attn v2"}})

    tmp_path = _resolve_run_dir(config, keep=False)
    keep_path = _resolve_run_dir(config, keep=True)

    assert tmp_path.parent == Path("runs/tmp")
    assert keep_path.parent == Path("runs")
    assert tmp_path.name.endswith("_color-shared-attn-v2")
    assert keep_path.name.endswith("_color-shared-attn-v2")
    assert _kebab_slug("Foo BAR_baz!!") == "foo-bar-baz"
    assert _kebab_slug("   ") == "run"


def test_deep_cfr_playability_encoding_extends_input_shape() -> None:
    state = GameState.new_game(LostCitiesConfig(seed=61), seed=61)
    base_dim = input_dim(state)
    derived_config = _deep_cfr_config({"encoding": {"derived_playability": True}})
    slot_config = _deep_cfr_config(
        {"encoding": {"derived_playability": True, "slot_aware_playability": True}}
    )

    derived_dim = input_dim(state, derived_config.encoding)
    slot_dim = input_dim(state, slot_config.encoding)

    assert derived_dim == base_dim + state.config.n_colors * 15 + 3
    assert slot_dim == derived_dim + state.config.hand_size * 6
    assert encode_info_state(state, 0, slot_config.encoding).shape == (slot_dim,)


def test_deep_cfr_slot_aware_playability_encoding_zero_fills_empty_hand_slots() -> None:
    config = _deep_cfr_config(
        {"encoding": {"derived_playability": True, "slot_aware_playability": True}}
    )
    state = GameState.new_game(LostCitiesConfig(seed=63), seed=63)
    state.phase = "draw"
    state.pending_discarded_color = -1
    state.apply_action(0)

    encoded = encode_info_state(state, 0, config.encoding)

    assert np.isfinite(encoded).all()


def test_deep_cfr_trainer_uses_playability_encoding() -> None:
    config = _deep_cfr_config(
        {
            "run": {"max_iterations": 1, "seed": 62},
            "encoding": {"derived_playability": True, "slot_aware_playability": True},
            "network": {"hidden_size": 16},
            "traversal": {
                "traversals_per_player": 1,
                "max_depth": 1,
                "max_nodes_per_traversal": 16,
            },
            "optimization": {
                "advantage_updates_per_iteration": 1,
                "strategy_updates_per_iteration": 1,
                "advantage_batch_size": 2,
                "strategy_batch_size": 2,
            },
            "checkpoint": {"save_every": 0},
        }
    )
    game_config = LostCitiesConfig(seed=62)
    trainer = DeepCFRTrainer(config, game_config)

    metrics = trainer.train()

    probe = GameState.new_game(game_config, seed=62)
    assert trainer.input_dim == input_dim(probe, config.encoding)
    assert metrics[0].advantage_samples > 0


def test_deep_cfr_trainer_forwards_metrics_to_extra_trackers(tmp_path) -> None:
    events: list[tuple[str, object]] = []

    class _CaptureTracker:
        def log_event(self, message: str) -> None:
            events.append(("event", message))

        def log_metrics(self, metrics: dict, *, step: int) -> None:
            events.append(("metrics", step))

        def close(self) -> None:
            events.append(("close", None))

    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 71},
                "network": {"hidden_size": 16},
                "traversal": {
                    "traversals_per_player": 1,
                    "max_depth": 2,
                    "max_nodes_per_traversal": 16,
                },
                "optimization": {"advantage_batch_size": 2, "strategy_batch_size": 2},
                "checkpoint": {"save_every": 0},
            }
        ),
        LostCitiesConfig(seed=71),
        run_dir=tmp_path / "extra-tracker",
        extra_trackers=[_CaptureTracker()],
    )

    trainer.train()

    assert any(kind == "metrics" for kind, _ in events)
    assert any(kind == "event" for kind, _ in events)
    assert ("close", None) in events
    assert (tmp_path / "extra-tracker" / "metrics.jsonl").exists()


def test_deep_cfr_trainer_smoke_run() -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 23},
                "network": {"hidden_size": 16},
                "traversal": {
                    "traversals_per_player": 1,
                    "max_depth": 3,
                    "max_nodes_per_traversal": 64,
                },
                "optimization": {
                    "advantage_updates_per_iteration": 1,
                    "strategy_updates_per_iteration": 1,
                    "advantage_batch_size": 2,
                    "strategy_batch_size": 2,
                },
                "checkpoint": {"save_every": 0},
            }
        ),
        LostCitiesConfig(seed=23),
    )

    metrics = trainer.train()

    assert len(metrics) == 1
    assert metrics[0].advantage_samples > 0
    assert metrics[0].strategy_samples > 0
    assert metrics[0].traversal_nodes > 0
    assert metrics[0].traversal_max_depth_reached <= 3
    assert metrics[0].traversal_endpoints > 0
    assert metrics[0].traversal_avg_endpoint_depth >= 0.0
    assert metrics[0].advantage_loss >= 0.0
    assert metrics[0].strategy_loss >= 0.0


def test_deep_cfr_trainer_interleaved_scheduler_smoke_run(tmp_path) -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 24},
                "network": {"hidden_size": 16},
                "traversal": {
                    "scheduler": "interleaved",
                    "opponent_policy": "network",
                    "traversals_per_player": 2,
                    "max_depth": 3,
                    "max_nodes_per_traversal": 64,
                    "interleave_width": 4,
                    "interleave_max_batch": 8,
                },
                "optimization": {
                    "advantage_updates_per_iteration": 1,
                    "strategy_updates_per_iteration": 1,
                    "advantage_batch_size": 2,
                    "strategy_batch_size": 2,
                },
                "checkpoint": {"save_every": 0, "save_latest": False},
                "evaluation": {"eval_every": 0},
            }
        ),
        LostCitiesConfig(seed=24),
        run_dir=tmp_path / "interleaved",
    )

    metrics = trainer.train()
    runtime = metrics[0].runtime_metrics

    assert len(metrics) == 1
    assert metrics[0].advantage_samples > 0
    assert metrics[0].strategy_samples > 0
    assert metrics[0].traversal_nodes > 0
    assert runtime["interleaved/batches"] > 0
    assert runtime["interleaved/requests"] > 0
    assert runtime["interleaved/max_batch_size"] >= 1
    assert runtime["interleaved/avg_batch_size"] >= 1.0


def test_deep_cfr_trainer_discard_only_opponent_smoke_run(tmp_path) -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 25},
                "network": {"hidden_size": 16},
                "traversal": {
                    "scheduler": "interleaved",
                    "opponent_policy": "discard_only",
                    "traversals_per_player": 2,
                    "max_depth": 3,
                    "max_nodes_per_traversal": 64,
                    "interleave_width": 4,
                    "interleave_max_batch": 8,
                },
                "optimization": {
                    "advantage_updates_per_iteration": 1,
                    "strategy_updates_per_iteration": 1,
                    "advantage_batch_size": 2,
                    "strategy_batch_size": 2,
                },
                "checkpoint": {"save_every": 0, "save_latest": False},
                "evaluation": {"eval_every": 0},
            }
        ),
        LostCitiesConfig(seed=25),
        run_dir=tmp_path / "discard_only",
    )

    metrics = trainer.train()

    assert len(metrics) == 1
    assert metrics[0].advantage_samples > 0
    assert metrics[0].traversal_nodes > 0


def test_deep_cfr_trainer_heuristic_balanced_opponent_smoke_run(tmp_path) -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 27},
                "network": {"hidden_size": 16},
                "traversal": {
                    "scheduler": "interleaved",
                    "opponent_policy": "heuristic_balanced",
                    "traversals_per_player": 2,
                    "max_depth": 3,
                    "max_nodes_per_traversal": 64,
                    "interleave_width": 4,
                    "interleave_max_batch": 8,
                },
                "optimization": {
                    "advantage_updates_per_iteration": 1,
                    "strategy_updates_per_iteration": 1,
                    "advantage_batch_size": 2,
                    "strategy_batch_size": 2,
                },
                "checkpoint": {"save_every": 0, "save_latest": False},
                "evaluation": {"eval_every": 0},
            }
        ),
        LostCitiesConfig(seed=27),
        run_dir=tmp_path / "heuristic_balanced",
    )

    metrics = trainer.train()

    assert len(metrics) == 1
    assert metrics[0].advantage_samples > 0
    assert metrics[0].traversal_nodes > 0


def test_deep_cfr_interleaved_scheduler_matches_recursive_single_traversal() -> None:
    config = _deep_cfr_config(
        {
            "run": {"seed": 26},
            "network": {"hidden_size": 16},
            "traversal": {
                "opponent_policy": "network",
                "max_depth": 3,
                "max_nodes_per_traversal": 64,
                "outcome_unsampled_regret": "zero",
            },
        }
    )
    game_config = LostCitiesConfig(seed=26)
    probe = GameState.new_game(game_config, seed=26)
    action_size = game_config.action_size
    torch.manual_seed(26)
    networks = [
        DeepCFRMLP.from_config(input_dim(probe, config.encoding), action_size, config.network)
        for _ in range(2)
    ]
    for network in networks:
        network.eval()
    common = {
        "device": torch.device("cpu"),
        "action_size": action_size,
        "encoding": config.encoding,
        "epsilon": config.traversal.regret_matching_epsilon,
        "strategy_sample_interval": config.traversal.strategy_sample_interval,
        "store_strategy_on_traverser_nodes": config.traversal.store_strategy_on_traverser_nodes,
        "store_strategy_on_opponent_nodes": config.traversal.store_strategy_on_opponent_nodes,
        "max_depth": config.traversal.max_depth,
        "max_nodes": config.traversal.max_nodes_per_traversal,
        "outcome_sampling_epsilon": config.traversal.outcome_sampling_epsilon,
        "outcome_sampling_value_clip": config.traversal.outcome_sampling_value_clip,
        "endpoint_depth_bucket_width": config.traversal.endpoint_depth_bucket_width,
        "endpoint_depth_bucket_max": config.traversal.endpoint_depth_bucket_max,
        "seed": 2601,
    }

    recursive_stats, recursive_advantage, recursive_strategy = run_cython_traversal_batch(
        networks,
        game_config,
        [260],
        0,
        1,
        **common,
        strategy_network=None,
        sampling_mode=config.traversal.sampling_mode,
        outcome_unsampled_regret=config.traversal.outcome_unsampled_regret,
        cutoff_value_mode=config.traversal.cutoff_value_mode,
        cutoff_rollouts=config.traversal.cutoff_rollouts,
        cutoff_rollout_policy=config.traversal.cutoff_rollout_policy,
        cutoff_rollout_max_steps=config.traversal.cutoff_rollout_max_steps,
        opponent_policy=config.traversal.opponent_policy,
        all_negative_fallback=config.regret_matching.all_negative_fallback,
        league_advantage_networks=[],
        self_play_anchor_probability=config.self_play.anchor_probability,
        self_play_current_weight=config.self_play.current_weight,
        self_play_recent_weight=config.self_play.recent_weight,
        self_play_older_weight=config.self_play.older_weight,
        self_play_anchor_weight=config.self_play.anchor_weight,
        self_play_recent_window=config.self_play.recent_window,
    )
    interleaved_stats, interleaved_advantage, interleaved_strategy, _runtime = (
        run_interleaved_traversal_batch(
            networks,
            None,
            game_config,
            [260],
            0,
            1,
            **common,
            outcome_unsampled_regret=config.traversal.outcome_unsampled_regret,
            opponent_policy=config.traversal.opponent_policy,
            interleave_width=4,
            interleave_max_batch=8,
        )
    )

    assert interleaved_stats.to_dict() == recursive_stats.to_dict()
    assert len(interleaved_advantage) == len(recursive_advantage)
    assert len(interleaved_strategy) == len(recursive_strategy)
    assert np.allclose(
        [sample.target.sum() for sample in interleaved_advantage],
        [sample.target.sum() for sample in recursive_advantage],
        atol=1.0e-5,
    )
    for interleaved_sample, recursive_sample in zip(
        interleaved_advantage, recursive_advantage, strict=True
    ):
        assert np.allclose(interleaved_sample.target, recursive_sample.target, atol=1.0e-5)
    assert np.allclose(
        [sample.target.sum() for sample in interleaved_strategy],
        [sample.target.sum() for sample in recursive_strategy],
        atol=1.0e-6,
    )


def test_deep_cfr_interleaved_scheduler_matches_average_strategy_opponent() -> None:
    config = _deep_cfr_config(
        {
            "run": {"seed": 27},
            "network": {"hidden_size": 16},
            "traversal": {
                "opponent_policy": "average_strategy",
                "store_strategy_on_opponent_nodes": False,
                "max_depth": 4,
                "max_nodes_per_traversal": 64,
            },
        }
    )
    game_config = LostCitiesConfig(seed=27)
    probe = GameState.new_game(game_config, seed=27)
    action_size = game_config.action_size
    torch.manual_seed(27)
    networks = [
        DeepCFRMLP.from_config(input_dim(probe, config.encoding), action_size, config.network)
        for _ in range(2)
    ]
    strategy_network = DeepCFRMLP.from_config(
        input_dim(probe, config.encoding), action_size, config.network
    )
    for network in [*networks, strategy_network]:
        network.eval()
    common = {
        "device": torch.device("cpu"),
        "action_size": action_size,
        "encoding": config.encoding,
        "epsilon": config.traversal.regret_matching_epsilon,
        "strategy_sample_interval": config.traversal.strategy_sample_interval,
        "store_strategy_on_traverser_nodes": config.traversal.store_strategy_on_traverser_nodes,
        "store_strategy_on_opponent_nodes": config.traversal.store_strategy_on_opponent_nodes,
        "max_depth": config.traversal.max_depth,
        "max_nodes": config.traversal.max_nodes_per_traversal,
        "outcome_sampling_epsilon": config.traversal.outcome_sampling_epsilon,
        "outcome_sampling_value_clip": config.traversal.outcome_sampling_value_clip,
        "endpoint_depth_bucket_width": config.traversal.endpoint_depth_bucket_width,
        "endpoint_depth_bucket_max": config.traversal.endpoint_depth_bucket_max,
        "seed": 2701,
    }

    recursive_stats, recursive_advantage, recursive_strategy = run_cython_traversal_batch(
        networks,
        game_config,
        [270],
        0,
        1,
        **common,
        strategy_network=strategy_network,
        sampling_mode=config.traversal.sampling_mode,
        outcome_unsampled_regret=config.traversal.outcome_unsampled_regret,
        cutoff_value_mode=config.traversal.cutoff_value_mode,
        cutoff_rollouts=config.traversal.cutoff_rollouts,
        cutoff_rollout_policy=config.traversal.cutoff_rollout_policy,
        cutoff_rollout_max_steps=config.traversal.cutoff_rollout_max_steps,
        opponent_policy=config.traversal.opponent_policy,
        all_negative_fallback=config.regret_matching.all_negative_fallback,
        league_advantage_networks=[],
        self_play_anchor_probability=config.self_play.anchor_probability,
        self_play_current_weight=config.self_play.current_weight,
        self_play_recent_weight=config.self_play.recent_weight,
        self_play_older_weight=config.self_play.older_weight,
        self_play_anchor_weight=config.self_play.anchor_weight,
        self_play_recent_window=config.self_play.recent_window,
    )
    interleaved_stats, interleaved_advantage, interleaved_strategy, _runtime = (
        run_interleaved_traversal_batch(
            networks,
            strategy_network,
            game_config,
            [270],
            0,
            1,
            **common,
            outcome_unsampled_regret=config.traversal.outcome_unsampled_regret,
            opponent_policy=config.traversal.opponent_policy,
            interleave_width=4,
            interleave_max_batch=8,
        )
    )

    assert interleaved_stats.to_dict() == recursive_stats.to_dict()
    assert len(interleaved_advantage) == len(recursive_advantage)
    assert len(interleaved_strategy) == len(recursive_strategy)
    assert np.allclose(
        [sample.target.sum() for sample in interleaved_advantage],
        [sample.target.sum() for sample in recursive_advantage],
        atol=1.0e-5,
    )
    assert np.allclose(
        [sample.target.sum() for sample in interleaved_strategy],
        [sample.target.sum() for sample in recursive_strategy],
        atol=1.0e-6,
    )


def test_deep_cfr_trainer_supports_lcfr_and_dcfr_loss_weighting() -> None:
    for mode in ("lcfr", "dcfr"):
        trainer = DeepCFRTrainer(
            _deep_cfr_config(
                {
                    "run": {"max_iterations": 1, "seed": 24},
                    "network": {"hidden_size": 16},
                    "traversal": {
                        "traversals_per_player": 1,
                        "max_depth": 2,
                        "max_nodes_per_traversal": 32,
                    },
                    "optimization": {
                        "advantage_updates_per_iteration": 1,
                        "strategy_updates_per_iteration": 1,
                        "advantage_batch_size": 2,
                        "strategy_batch_size": 2,
                    },
                    "training_weighting": {"mode": mode},
                    "checkpoint": {"save_every": 0},
                }
            ),
            LostCitiesConfig(seed=24),
        )

        metrics = trainer.train()

        assert len(metrics) == 1
        assert metrics[0].advantage_loss >= 0.0
        assert metrics[0].strategy_loss >= 0.0


def test_deep_cfr_trainer_amp_cpu_falls_back_to_fp32() -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 25, "device": "cpu", "use_amp": True},
                "network": {"hidden_size": 16},
                "traversal": {
                    "traversals_per_player": 1,
                    "max_depth": 2,
                    "max_nodes_per_traversal": 32,
                },
                "optimization": {
                    "advantage_updates_per_iteration": 1,
                    "strategy_updates_per_iteration": 1,
                    "advantage_batch_size": 2,
                    "strategy_batch_size": 2,
                },
                "checkpoint": {"save_every": 0},
                "evaluation": {"eval_every": 0},
            }
        ),
        LostCitiesConfig(seed=25),
        device="cpu",
    )

    metrics = trainer.train()

    assert len(metrics) == 1
    assert metrics[0].runtime_metrics["amp/grad_scale"] == 1.0
    assert metrics[0].runtime_metrics["amp/nonfinite_loss_count"] == 0
    assert metrics[0].advantage_loss >= 0.0
    assert metrics[0].strategy_loss >= 0.0


def test_deep_cfr_trainer_amp_cuda_smoke() -> None:
    if not torch.cuda.is_available():
        pytest.skip("CUDA is not available")
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 26, "device": "cuda", "use_amp": True},
                "network": {"hidden_size": 16},
                "traversal": {
                    "traversals_per_player": 1,
                    "max_depth": 2,
                    "max_nodes_per_traversal": 32,
                },
                "optimization": {
                    "advantage_updates_per_iteration": 1,
                    "strategy_updates_per_iteration": 1,
                    "advantage_batch_size": 2,
                    "strategy_batch_size": 2,
                },
                "checkpoint": {"save_every": 0},
                "evaluation": {"eval_every": 0},
            }
        ),
        LostCitiesConfig(seed=26),
        device="cuda",
    )

    metrics = trainer.train()

    assert len(metrics) == 1
    assert metrics[0].runtime_metrics["amp/grad_scale"] > 0.0
    assert metrics[0].runtime_metrics["amp/nonfinite_loss_count"] == 0
    assert np.isfinite(metrics[0].advantage_loss)
    assert np.isfinite(metrics[0].strategy_loss)


def test_deep_cfr_cython_traverser_restores_state_and_collects_samples() -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 29},
                "network": {"hidden_size": 16},
                "traversal": {
                    "traversals_per_player": 1,
                    "max_depth": 2,
                    "max_nodes_per_traversal": 32,
                },
                "optimization": {"advantage_batch_size": 2, "strategy_batch_size": 2},
                "checkpoint": {"save_every": 0},
            }
        ),
        LostCitiesConfig(seed=29),
    )
    state = GameState.new_game(LostCitiesConfig(seed=29), seed=29)
    before = state.to_snapshot()
    traverser = CythonDeepCFRTraverser(
        trainer.advantage_networks,
        device=trainer.device,
        action_size=trainer.action_size,
        max_depth=2,
        max_nodes=32,
        seed=29,
    )

    value, stats = traverser.traverse(state, traverser=0, iteration=1)
    advantage_samples, strategy_samples = traverser.drain_samples()

    assert isinstance(value, float)
    assert state.to_snapshot() == before
    assert stats.nodes > 0
    assert stats.depth_cutoffs + stats.terminals + stats.node_limit_cutoffs > 0
    assert stats.strategy_samples > 0
    assert stats.advantage_samples > 0
    assert len(strategy_samples) == stats.strategy_samples
    assert len(advantage_samples) == stats.advantage_samples
    sample = advantage_samples[0]
    assert sample.legal_mask.dtype == bool
    assert sample.target.shape == sample.legal_mask.shape


def test_deep_cfr_cython_traverser_supports_outcome_sampling_and_rollout_cutoffs() -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 31},
                "network": {"hidden_size": 16},
                "traversal": {
                    "traversals_per_player": 1,
                    "max_depth": 1,
                    "max_nodes_per_traversal": 32,
                    "outcome_sampling_epsilon": 0.25,
                    "outcome_sampling_value_clip": 100.0,
                    "outcome_unsampled_regret": "zero",
                    "cutoff_value_mode": "random_rollout",
                    "cutoff_rollouts": 2,
                    "cutoff_rollout_policy": "random",
                    "cutoff_rollout_max_steps": 16,
                },
                "optimization": {"advantage_batch_size": 2, "strategy_batch_size": 2},
                "checkpoint": {"save_every": 0},
            }
        ),
        LostCitiesConfig(seed=31),
    )
    state = GameState.new_game(LostCitiesConfig(seed=31), seed=31)
    before = state.to_snapshot()
    traverser = CythonDeepCFRTraverser(
        trainer.advantage_networks,
        device=trainer.device,
        action_size=trainer.action_size,
        max_depth=1,
        max_nodes=32,
        outcome_sampling_epsilon=0.25,
        outcome_sampling_value_clip=100.0,
        outcome_unsampled_regret="zero",
        cutoff_value_mode="random_rollout",
        cutoff_rollouts=2,
        cutoff_rollout_policy="random",
        cutoff_rollout_max_steps=16,
        seed=31,
    )

    _, stats = traverser.traverse(state, traverser=0, iteration=1)
    advantage_samples, _ = traverser.drain_samples()

    assert state.to_snapshot() == before
    assert stats.depth_cutoffs > 0
    assert stats.cutoff_rollouts == stats.depth_cutoffs * 2
    assert stats.cutoff_rollout_steps > 0
    sample = advantage_samples[0]
    unsampled_legal = sample.legal_mask.copy()
    unsampled_legal[np.nonzero(sample.target)[0]] = False
    assert np.all(sample.target[unsampled_legal] == 0.0)


def test_deep_cfr_cython_traverser_supports_external_sampling() -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 33},
                "network": {"hidden_size": 16},
                "traversal": {
                    "traversals_per_player": 1,
                    "sampling_mode": "external",
                    "store_strategy_on_traverser_nodes": False,
                    "max_depth": 1,
                    "max_nodes_per_traversal": 64,
                },
                "optimization": {"advantage_batch_size": 2, "strategy_batch_size": 2},
                "checkpoint": {"save_every": 0},
            }
        ),
        LostCitiesConfig(seed=33),
    )
    metrics = trainer.train()

    assert len(metrics) == 1
    assert metrics[0].advantage_samples > 0
    state = GameState.new_game(LostCitiesConfig(seed=33), seed=33)
    before = state.to_snapshot()
    traverser = CythonDeepCFRTraverser(
        trainer.advantage_networks,
        device=trainer.device,
        action_size=trainer.action_size,
        sampling_mode="external",
        max_depth=1,
        max_nodes=64,
        seed=33,
    )

    value, stats = traverser.traverse(state, traverser=0, iteration=1)
    advantage_samples, strategy_samples = traverser.drain_samples()

    assert isinstance(value, float)
    assert state.to_snapshot() == before
    assert stats.nodes > 0
    assert stats.depth_cutoffs > 0
    assert stats.advantage_samples > 0
    assert stats.strategy_samples > 0
    assert len(advantage_samples) == stats.advantage_samples
    assert len(strategy_samples) == stats.strategy_samples
    sample = advantage_samples[0]
    assert sample.legal_mask.dtype == bool
    assert sample.target.shape == sample.legal_mask.shape
    assert np.count_nonzero(sample.target[sample.legal_mask]) > 1


def test_deep_cfr_cython_traverser_records_regret_fallback_metrics() -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 37},
                "network": {"hidden_size": 16},
                "traversal": {"traversals_per_player": 1, "max_depth": 1},
                "optimization": {"advantage_batch_size": 2, "strategy_batch_size": 2},
                "checkpoint": {"save_every": 0},
                "regret_matching": {"all_negative_fallback": "argmax_tiebreak"},
            }
        ),
        LostCitiesConfig(seed=37),
    )
    for network in trainer.advantage_networks:
        for parameter in network.parameters():
            parameter.data.zero_()
    state = GameState.new_game(LostCitiesConfig(seed=37), seed=37)
    traverser = CythonDeepCFRTraverser(
        trainer.advantage_networks,
        device=trainer.device,
        action_size=trainer.action_size,
        max_depth=1,
        all_negative_fallback="argmax_tiebreak",
        seed=37,
    )

    _, stats = traverser.traverse(state, traverser=0, iteration=1)
    metrics = stats.to_dict()
    action_count_sum = (
        stats.regret_fallback_action_play_existing
        + stats.regret_fallback_action_open_new
        + stats.regret_fallback_action_discard
        + stats.regret_fallback_action_draw_deck
        + stats.regret_fallback_action_draw_pile
    )

    assert stats.regret_matching_decisions > 0
    assert stats.regret_fallback_count > 0
    assert action_count_sum == stats.regret_fallback_count
    assert metrics["traversal_regret_fallback_rate"] > 0.0
    assert "traversal_regret_fallback_open_new_selected_rate" in metrics
    assert metrics["traversal_regret_fallback_legal_actions_mean"] > 0.0
    assert "traversal_regret_fallback_open_new_available_rate" in metrics
    assert "traversal_regret_fallback_open_new_selection_over_availability" in metrics
    assert "traversal_regret_fallback_depth_bucket_0_49" in metrics
    assert "traversal_regret_fallback_opened_colors_count_0" in metrics
    assert metrics["traversal_regret_fallback_argmax_tie_rate"] > 0.0
    assert metrics["traversal_regret_fallback_argmax_tie_size_mean"] > 0.0


def test_reservoir_memory_caps_samples_and_filters_player_batches() -> None:
    memory = ReservoirMemory(capacity=3)
    rng = np.random.default_rng(37)
    for index in range(10):
        memory.add(
            TrainingSample(
                info_state=np.asarray([index], dtype=np.float32),
                target=np.asarray([index], dtype=np.float32),
                legal_mask=np.asarray([True]),
                iteration=index,
                player=index % 2,
            ),
            rng,
        )

    assert len(memory) == 3
    assert memory.seen == 10
    player_one = memory.sample(8, rng, player=1)
    assert player_one
    assert all(sample.player == 1 for sample in player_one)


def test_reservoir_memory_filters_first_open_batches() -> None:
    memory = ReservoirMemory()
    rng = np.random.default_rng(37)
    for index in range(6):
        memory.add(
            TrainingSample(
                info_state=np.asarray([index], dtype=np.float32),
                target=np.asarray([index], dtype=np.float32),
                legal_mask=np.asarray([True]),
                iteration=index,
                player=0,
                is_first_open=index % 2 == 0,
            ),
            rng,
        )

    first_open = memory.sample(8, rng, first_open_only=True)

    assert len(first_open) == 3
    assert all(sample.is_first_open for sample in first_open)
    assert memory.count(first_open_only=True) == 3


def test_reservoir_memory_updates_first_open_index_on_replacement() -> None:
    memory = ReservoirMemory(capacity=1)
    replacement_rng = np.random.default_rng(1)
    sample_rng = np.random.default_rng(37)
    memory.add(
        TrainingSample(
            info_state=np.asarray([1], dtype=np.float32),
            target=np.asarray([1], dtype=np.float32),
            legal_mask=np.asarray([True]),
            iteration=1,
            player=0,
            is_first_open=True,
        ),
        replacement_rng,
    )
    memory.add(
        TrainingSample(
            info_state=np.asarray([2], dtype=np.float32),
            target=np.asarray([2], dtype=np.float32),
            legal_mask=np.asarray([True]),
            iteration=2,
            player=0,
            is_first_open=False,
        ),
        replacement_rng,
    )

    assert memory.count(first_open_only=True) == 0
    with pytest.raises(ValueError, match="cannot sample from empty memory"):
        memory.sample(1, sample_rng, first_open_only=True)


def test_deep_cfr_trainer_can_oversample_first_open_advantage_batches(tmp_path) -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"seed": 41},
                "network": {"hidden_size": 16},
                "optimization": {
                    "advantage_batch_size": 4,
                    "advantage_first_open_fraction": 0.5,
                },
            }
        ),
        run_dir=tmp_path,
        device="cpu",
    )
    for index in range(8):
        trainer.advantage_memories[0].add(
            TrainingSample(
                info_state=np.asarray([index], dtype=np.float32),
                target=np.asarray([index], dtype=np.float32),
                legal_mask=np.asarray([True]),
                iteration=index,
                player=0,
                is_first_open=index in {1, 3},
            ),
            trainer.rng,
        )

    batch = trainer._sample_advantage_batch(player=0)

    assert len(batch) == 4
    assert sum(sample.is_first_open for sample in batch) >= 2


def test_deep_cfr_trainer_saves_loads_and_evaluates_checkpoint(tmp_path) -> None:
    checkpoint_dir = tmp_path / "deep_cfr"
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 41},
                "network": {"hidden_size": 16},
                "traversal": {
                    "traversals_per_player": 1,
                    "max_depth": 2,
                    "max_nodes_per_traversal": 32,
                },
                "optimization": {"advantage_batch_size": 2, "strategy_batch_size": 2},
                "checkpoint": {"save_every": 1},
                "evaluation": {"eval_every": 1, "games": 2, "opponents": ("random",)},
            }
        ),
        LostCitiesConfig(seed=41),
        run_dir=checkpoint_dir,
    )

    metrics = trainer.train()
    latest = checkpoint_dir / "latest.pt"
    restored = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"seed": 41},
                "network": {"hidden_size": 16},
                "checkpoint": {"save_every": 0},
            }
        ),
        LostCitiesConfig(seed=41),
        run_dir=checkpoint_dir,
    )
    restored.load_checkpoint(latest)

    assert latest.exists()
    assert load_checkpoint(latest)["resume_semantics"] == "networks_optimizers_iteration_only"
    assert (checkpoint_dir / "config.json").exists()
    assert (checkpoint_dir / "metrics.jsonl").exists()
    assert (checkpoint_dir / "runtime_progress.json").exists()
    assert (checkpoint_dir / "train.log").exists()
    train_log = (checkpoint_dir / "train.log").read_text(encoding="utf-8")
    assert re.search(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", train_log)
    assert "Iteration complete" in train_log
    assert "[i=1]" in train_log
    assert "reservoir memories and RNG state are not restored" in train_log
    assert restored.iteration == 1
    assert "eval/random/games" in metrics[0].eval_metrics
    assert "eval/random/play_action_rate" in metrics[0].eval_metrics
    assert "eval/random/policy_entropy" in metrics[0].eval_metrics
    assert "eval/random/avg_opened_colors" in metrics[0].eval_metrics
    assert "eval/random/bad_open_actions" in metrics[0].eval_metrics
    assert "eval/random/positive_expedition_rate" in metrics[0].eval_metrics
    assert (
        "eval/random/first_open_recoverable_score_mean_for_positive_final"
        in metrics[0].eval_metrics
    )


def test_deep_cfr_trainer_always_saves_latest_checkpoint(tmp_path) -> None:
    checkpoint_dir = tmp_path / "latest-each-iteration"
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 42},
                "network": {"hidden_size": 16},
                "traversal": {"traversals_per_player": 1, "max_depth": 1},
                "checkpoint": {"save_every": 10},
            }
        ),
        LostCitiesConfig(seed=42),
        run_dir=checkpoint_dir,
    )

    trainer.train()

    assert (checkpoint_dir / "latest.pt").exists()
    assert not (checkpoint_dir / "iteration_00001.pt").exists()


def test_deep_cfr_exact_resume_is_explicitly_not_implemented(tmp_path) -> None:
    checkpoint_dir = tmp_path / "exact"
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 44},
                "network": {"hidden_size": 16},
                "traversal": {"traversals_per_player": 1, "max_depth": 1},
                "checkpoint": {"save_every": 1},
            }
        ),
        LostCitiesConfig(seed=44),
        run_dir=checkpoint_dir,
    )
    trainer.train()
    exact = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "network": {"hidden_size": 16},
                "checkpoint": {"exact_resume": True},
            }
        ),
        LostCitiesConfig(seed=44),
        run_dir=checkpoint_dir,
    )

    try:
        exact.load_checkpoint(checkpoint_dir / "latest.pt")
    except NotImplementedError as exc:
        assert "exact_resume" in str(exc)
    else:  # pragma: no cover
        raise AssertionError("expected NotImplementedError")


def test_deep_cfr_trainer_multiprocessing_smoke_run(tmp_path) -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 43},
                "network": {"hidden_size": 16},
                "traversal": {
                    "traversals_per_player": 1,
                    "max_depth": 2,
                    "max_nodes_per_traversal": 32,
                    "num_workers": 8,
                    "worker_chunk_size": 1,
                    "progress_every_traversals": 1,
                },
                "optimization": {"advantage_batch_size": 2, "strategy_batch_size": 2},
                "checkpoint": {"save_every": 0},
            }
        ),
        LostCitiesConfig(seed=43),
        run_dir=tmp_path / "mp",
    )

    metrics = trainer.train()
    train_log = (tmp_path / "mp" / "train.log").read_text(encoding="utf-8")

    assert metrics[0].traversal_nodes > 0
    assert metrics[0].advantage_samples > 0
    assert "Traversal multiprocessing enabled" in train_log
    assert "Traversal worker count capped" in train_log
    assert "Traversal multiprocessing progress" in train_log


def test_deep_cfr_traversal_benchmark_smoke() -> None:
    result = benchmark_traversal(
        _deep_cfr_config(
            {
                "run": {"seed": 47},
                "network": {"hidden_size": 16},
                "traversal": {"traversals_per_player": 1, "max_depth": 2},
                "checkpoint": {"save_every": 0},
            }
        )
    )

    assert result["traversal_nodes"] > 0
    assert result["nodes_per_second"] > 0.0
    comparison = benchmark_traversal_modes(
        _deep_cfr_config(
            {
                "run": {"seed": 48},
                "network": {"hidden_size": 16},
                "traversal": {"traversals_per_player": 1, "max_depth": 2},
                "checkpoint": {"save_every": 0},
            }
        )
    )
    assert comparison["summary"]["speedup"] > 0.0


def test_deep_cfr_self_play_league_records_snapshots(tmp_path) -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 2, "seed": 53},
                "network": {"hidden_size": 16},
                "traversal": {
                    "traversals_per_player": 1,
                    "max_depth": 2,
                    "max_nodes_per_traversal": 32,
                    "opponent_policy": "self_play_league",
                },
                "self_play": {
                    "snapshot_every": 1,
                    "max_snapshots": 1,
                    "anchor_probability": 1.0,
                },
                "optimization": {"advantage_batch_size": 2, "strategy_batch_size": 2},
                "checkpoint": {"save_every": 0},
            }
        ),
        LostCitiesConfig(seed=53),
        run_dir=tmp_path / "league",
    )

    metrics = trainer.train()

    assert len(metrics) == 2
    assert len(trainer.self_play_league_snapshots) == 1


def test_deep_cfr_weighted_self_play_league_uses_snapshot_bucket(tmp_path) -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 2, "seed": 59},
                "network": {"hidden_size": 16},
                "traversal": {
                    "traversals_per_player": 1,
                    "max_depth": 2,
                    "max_nodes_per_traversal": 32,
                    "opponent_policy": "self_play_league",
                },
                "self_play": {
                    "snapshot_every": 1,
                    "max_snapshots": 2,
                    "current_weight": 0.0,
                    "recent_weight": 1.0,
                    "older_weight": 0.0,
                    "anchor_weight": 0.0,
                    "recent_window": 1,
                },
                "optimization": {"advantage_batch_size": 2, "strategy_batch_size": 2},
                "checkpoint": {"save_every": 0},
            }
        ),
        LostCitiesConfig(seed=59),
        run_dir=tmp_path / "weighted-league",
    )

    metrics = trainer.train()

    assert len(metrics) == 2
    assert len(trainer.self_play_league_snapshots) == 2
    assert metrics[1].traversal_nodes > 0


def test_eval_skipped_warning_returns_none_when_eval_disabled() -> None:
    assert eval_skipped_warning(0, max_iterations=10, eval_every=0) is None
    assert eval_skipped_warning(0, max_iterations=10, eval_every=-5) is None


def test_eval_skipped_warning_returns_none_when_max_iterations_unbounded() -> None:
    assert eval_skipped_warning(0, max_iterations=None, eval_every=50) is None


def test_eval_skipped_warning_returns_none_when_eval_will_run_within_budget() -> None:
    assert eval_skipped_warning(0, max_iterations=50, eval_every=50) is None
    assert eval_skipped_warning(0, max_iterations=51, eval_every=50) is None
    assert eval_skipped_warning(0, max_iterations=200, eval_every=50) is None


def test_eval_skipped_warning_returns_none_when_starting_just_before_an_eval() -> None:
    # Resuming at iteration=49: next iter (50) is an eval.
    assert eval_skipped_warning(49, max_iterations=50, eval_every=50) is None


def test_eval_skipped_warning_warns_when_max_below_first_eval() -> None:
    warning = eval_skipped_warning(0, max_iterations=10, eval_every=50)
    assert warning is not None
    assert "max_iterations=10" in warning
    assert "iteration 50" in warning


def test_eval_skipped_warning_warns_on_resume_when_no_more_evals_fit() -> None:
    # Resumed at iter 100 (just past the last scheduled eval).
    # Next eval is 150, but we only have budget up to 110.
    warning = eval_skipped_warning(100, max_iterations=110, eval_every=50)
    assert warning is not None
    assert "iteration 150" in warning


def test_eval_skipped_warning_no_warn_when_resume_lands_exactly_on_next_eval() -> None:
    # iter=50 means iteration 50 already evaluated; next is 100. Budget 100 fits.
    assert eval_skipped_warning(50, max_iterations=100, eval_every=50) is None


def test_eval_skipped_warning_warns_when_eval_every_one_but_max_is_zero() -> None:
    # Pathological: max_iterations=0 means no iterations will run.
    warning = eval_skipped_warning(0, max_iterations=0, eval_every=1)
    assert warning is not None


def test_deep_cfr_trainer_logs_eval_skipped_warning_on_start(tmp_path) -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 90},
                "network": {"hidden_size": 16},
                "traversal": {"traversals_per_player": 1, "max_depth": 1},
                "optimization": {
                    "advantage_batch_size": 2,
                    "strategy_batch_size": 2,
                    "advantage_updates_per_iteration": 1,
                    "strategy_updates_per_iteration": 1,
                },
                "checkpoint": {"save_every": 0, "save_latest": False},
                "evaluation": {"eval_every": 50, "games": 2, "opponents": ["random"]},
            }
        ),
        LostCitiesConfig(seed=90),
        run_dir=tmp_path,
    )

    trainer.train()
    log_text = (tmp_path / "train.log").read_text()
    assert "WARNING evaluation will not run" in log_text


def test_deep_cfr_trainer_does_not_log_eval_warning_when_eval_disabled(tmp_path) -> None:
    trainer = DeepCFRTrainer(
        _deep_cfr_config(
            {
                "run": {"max_iterations": 1, "seed": 91},
                "network": {"hidden_size": 16},
                "traversal": {"traversals_per_player": 1, "max_depth": 1},
                "optimization": {
                    "advantage_batch_size": 2,
                    "strategy_batch_size": 2,
                    "advantage_updates_per_iteration": 1,
                    "strategy_updates_per_iteration": 1,
                },
                "checkpoint": {"save_every": 0, "save_latest": False},
                "evaluation": {"eval_every": 0},
            }
        ),
        LostCitiesConfig(seed=91),
        run_dir=tmp_path,
    )

    trainer.train()
    log_text = (tmp_path / "train.log").read_text()
    assert "WARNING evaluation will not run" not in log_text


def test_first_open_prior_overrides_unsampled_play_targets_with_signed_alpha() -> None:
    state = GameState.new_game(LostCitiesConfig(seed=123), seed=123)
    player = int(state.current_player)
    card_action_size = state.config.hand_size * 2
    legal_mask = np.zeros(card_action_size + 5, dtype=bool)
    play_actions: list[int] = []
    for slot, card in enumerate(state.hand_slots(player)):
        if card is None:
            continue
        play_actions.append(2 * slot)
        legal_mask[2 * slot] = True
        legal_mask[2 * slot + 1] = True
    assert play_actions, "fresh game state should have legal play actions"

    sampled_action = play_actions[0]
    target = np.zeros(legal_mask.shape[0], dtype=np.float32)
    target[sampled_action] = 7.0

    alpha = 5.0
    _apply_first_open_prior(target, state, player, legal_mask, sampled_action, alpha)

    assert target[sampled_action] == pytest.approx(7.0)

    expeditions = state.expeditions[player]
    hand = state.hand_slots(player)
    for action in play_actions:
        if action == sampled_action:
            continue
        card = hand[action // 2]
        color = int(card.color)
        if expeditions[color]:
            assert target[action] == 0.0
            continue
        score = _first_open_recoverable_score(state, player, color)
        expected = alpha if score >= 0.0 else -alpha
        assert target[action] == pytest.approx(expected)
        assert target[action + 1] == 0.0


def test_first_open_prior_zero_alpha_is_noop() -> None:
    state = GameState.new_game(LostCitiesConfig(seed=7), seed=7)
    player = int(state.current_player)
    card_action_size = state.config.hand_size * 2
    legal_mask = np.zeros(card_action_size + 5, dtype=bool)
    for slot, card in enumerate(state.hand_slots(player)):
        if card is None:
            continue
        legal_mask[2 * slot] = True
        legal_mask[2 * slot + 1] = True
    target = np.zeros(legal_mask.shape[0], dtype=np.float32)
    _apply_first_open_prior(target, state, player, legal_mask, sampled_action=0, alpha=0.0)
    assert np.all(target == 0.0)


def test_interleaved_regret_matching_argmax_tiebreak_concentrates_on_best() -> None:
    from coolrl_lost_cities.games.classic.deep_cfr.interleaved_traversal import _regret_matching

    advantages = np.array([-1.0, -0.5, -0.5, -2.0, -0.5], dtype=np.float32)
    legal_mask = np.array([True, True, True, True, True])

    uniform_policy, fallback_u, _, _ = _regret_matching(
        advantages, legal_mask, epsilon=1.0e-8, fallback_mode="uniform"
    )
    argmax_policy, fallback_a, tie_size, _ = _regret_matching(
        advantages, legal_mask, epsilon=1.0e-8, fallback_mode="argmax_tiebreak"
    )

    assert fallback_u is True
    assert fallback_a is True
    assert tie_size == 3
    assert np.allclose(uniform_policy, np.full(5, 0.2, dtype=np.float32))
    expected_argmax = np.zeros(5, dtype=np.float32)
    expected_argmax[1] = 1.0
    assert np.allclose(argmax_policy, expected_argmax)


def test_interleaved_regret_matching_no_fallback_unchanged_by_mode() -> None:
    from coolrl_lost_cities.games.classic.deep_cfr.interleaved_traversal import _regret_matching

    advantages = np.array([1.0, 3.0, 0.0, 2.0], dtype=np.float32)
    legal_mask = np.array([True, True, True, True])
    policy_uniform, fallback_u, _, _ = _regret_matching(
        advantages, legal_mask, epsilon=1.0e-8, fallback_mode="uniform"
    )
    policy_argmax, fallback_a, _, _ = _regret_matching(
        advantages, legal_mask, epsilon=1.0e-8, fallback_mode="argmax_tiebreak"
    )
    assert fallback_u is False
    assert fallback_a is False
    assert np.allclose(policy_uniform, policy_argmax)
    assert np.allclose(policy_uniform.sum(), 1.0)


def test_evaluation_opponents_for_iteration_core_only_when_extended_disabled() -> None:
    from coolrl_lost_cities.games.classic.deep_cfr.config import EvaluationConfig

    cfg = EvaluationConfig(
        eval_every=5,
        opponents=("random", "discard_only", "heuristic_cautious"),
        extended_eval_every=0,
        extended_opponents=("heuristic_balanced",),
    )
    assert cfg.opponents_for_iteration(0) == ()
    assert cfg.opponents_for_iteration(3) == ()
    assert cfg.opponents_for_iteration(5) == (
        "random",
        "discard_only",
        "heuristic_cautious",
    )
    assert cfg.opponents_for_iteration(50) == (
        "random",
        "discard_only",
        "heuristic_cautious",
    )


def test_evaluation_opponents_for_iteration_extends_on_extended_cadence() -> None:
    from coolrl_lost_cities.games.classic.deep_cfr.config import EvaluationConfig

    cfg = EvaluationConfig(
        eval_every=5,
        opponents=("random", "discard_only", "heuristic_cautious"),
        extended_eval_every=50,
        extended_opponents=(
            "heuristic_balanced",
            "heuristic_aggressive",
            "heuristic_noisy",
        ),
    )
    assert cfg.opponents_for_iteration(5) == (
        "random",
        "discard_only",
        "heuristic_cautious",
    )
    assert cfg.opponents_for_iteration(45) == (
        "random",
        "discard_only",
        "heuristic_cautious",
    )
    assert cfg.opponents_for_iteration(50) == (
        "random",
        "discard_only",
        "heuristic_cautious",
        "heuristic_balanced",
        "heuristic_aggressive",
        "heuristic_noisy",
    )
    assert cfg.opponents_for_iteration(100) == (
        "random",
        "discard_only",
        "heuristic_cautious",
        "heuristic_balanced",
        "heuristic_aggressive",
        "heuristic_noisy",
    )


def test_evaluation_opponents_for_iteration_dedupes_overlap() -> None:
    from coolrl_lost_cities.games.classic.deep_cfr.config import EvaluationConfig

    cfg = EvaluationConfig(
        eval_every=5,
        opponents=("random", "heuristic_cautious"),
        extended_eval_every=10,
        extended_opponents=("heuristic_cautious", "heuristic_balanced"),
    )
    assert cfg.opponents_for_iteration(10) == (
        "random",
        "heuristic_cautious",
        "heuristic_balanced",
    )
