from __future__ import annotations

import numpy as np
import pytest
from coolrl_lost_cities.games.classic.deep_cfr.cfr_math import (
    normalize_legal_policy,
    regret_matching,
    sample_policy,
)
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 (
    random_rollout_value,
    root_action_values,
)
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig


def test_swap_deck_cards_swaps_internal_deck_order_and_validates_bounds() -> None:
    state = GameState.new_game(LostCitiesConfig(seed=7))
    before = state.to_snapshot()
    first = before["deck"][0]
    last = before["deck"][-1]

    state.swap_deck_cards(0, len(before["deck"]) - 1)

    after = state.to_snapshot()
    assert after["deck"][0] == last
    assert after["deck"][-1] == first
    state.validate_invariants()

    state.swap_deck_cards(0, len(before["deck"]) - 1)
    assert state.to_snapshot() == before

    with pytest.raises(IndexError, match="deck index out of range"):
        state.swap_deck_cards(-1, 0)
    with pytest.raises(IndexError, match="deck index out of range"):
        state.swap_deck_cards(0, len(before["deck"]))


def test_regret_matching_uses_positive_legal_regrets() -> None:
    policy = regret_matching(
        np.asarray([1.0, -2.0, 3.0, 5.0], dtype=np.float32),
        np.asarray([True, True, False, True]),
    )

    np.testing.assert_allclose(policy, [1.0 / 6.0, 0.0, 0.0, 5.0 / 6.0])


def test_regret_matching_falls_back_to_uniform_legal_policy() -> None:
    policy = regret_matching(
        np.asarray([-1.0, 0.0, 3.0, 5.0], dtype=np.float32),
        np.asarray([True, True, False, False]),
    )
    no_legal = regret_matching(
        np.asarray([1.0, 2.0], dtype=np.float32),
        np.asarray([False, False]),
    )

    np.testing.assert_allclose(policy, [0.5, 0.5, 0.0, 0.0])
    np.testing.assert_allclose(no_legal, [0.0, 0.0])


def test_normalize_legal_policy_clamps_and_normalizes_legal_weights() -> None:
    policy = normalize_legal_policy(
        np.asarray([2.0, -1.0, 4.0, 6.0], dtype=np.float32),
        np.asarray([True, True, False, True]),
    )
    fallback = normalize_legal_policy(
        np.asarray([0.0, -1.0, 3.0], dtype=np.float32),
        np.asarray([True, True, False]),
    )

    np.testing.assert_allclose(policy, [0.25, 0.0, 0.0, 0.75])
    np.testing.assert_allclose(fallback, [0.5, 0.5, 0.0])


def test_sample_policy_uses_cumulative_probability_boundaries() -> None:
    policy = np.asarray([0.2, 0.3, 0.5], dtype=np.float32)

    assert sample_policy(policy, 0.0) == 0
    assert sample_policy(policy, 0.21) == 1
    assert sample_policy(policy, 0.51) == 2
    assert sample_policy(policy, 0.999) == 2


def test_encode_info_state_is_deterministic_and_matches_legal_mask_tail() -> None:
    state = GameState.new_game(LostCitiesConfig(seed=13))

    encoded = encode_info_state(state, 0)
    encoded_again = encode_info_state(state, 0)
    legal_mask = np.asarray(state.unified_legal_mask(), dtype=np.float32)

    assert encoded.dtype == np.float32
    assert encoded.shape == (input_dim(state),)
    assert input_dim(state) > 5 + state.config.hand_size * 3 + len(legal_mask)
    np.testing.assert_array_equal(encoded, encoded_again)
    np.testing.assert_array_equal(encoded[-len(legal_mask) :], legal_mask)

    with pytest.raises(ValueError, match="invalid player"):
        encode_info_state(state, 2)


def test_random_rollout_value_restores_state_and_is_deterministic() -> None:
    state = GameState.new_game(LostCitiesConfig(seed=17))
    before = state.to_snapshot()

    value = random_rollout_value(state, 0, seed=101, max_steps=128)
    same_value = random_rollout_value(state, 0, seed=101, max_steps=128)

    assert value == same_value
    assert state.to_snapshot() == before


def test_root_action_values_restores_state_and_marks_legal_actions() -> None:
    state = GameState.new_game(LostCitiesConfig(seed=19))
    before = state.to_snapshot()

    values, legal = root_action_values(state, 0, seed=202, rollouts_per_action=1, max_steps=128)

    assert values.dtype == np.float32
    assert legal.dtype == np.uint8
    assert values.shape == legal.shape
    np.testing.assert_array_equal(legal.astype(bool), state.unified_legal_mask())
    assert state.to_snapshot() == before
