"""Phases 2-4: match reward, match observation, asymmetric critic, both-seat training."""

import jax
import jax.numpy as jnp
import numpy as np
import pytest

from lost_cities_jax.match import match_reset, match_score, match_step
from lost_cities_jax.match_obs import (
    MATCH_CRITIC_OBS_DIM,
    MATCH_OBS_DIM,
    match_critic_observation,
    match_observation,
)
from lost_cities_jax.match_ppo import (
    Ablation,
    create_match_train_state,
    make_match_rollout_fn,
    make_match_train_iteration,
)
from lost_cities_jax.opponents import random_legal_action
from lost_cities_jax.ppo import JaxPPOConfig
from lost_cities_jax.types import LOC_P0_HAND, N_CARDS


def _cfg(rollout_steps: int = 700, batch_games: int = 8) -> JaxPPOConfig:
    cfg = JaxPPOConfig()
    cfg.ppo.batch_games = batch_games
    cfg.ppo.rollout_steps = rollout_steps
    cfg.ppo.minibatches = 4
    cfg.ppo.epochs = 1
    cfg.network.hidden_size = 32
    cfg.network.num_layers = 1
    return cfg


# --- the asymmetric critic -------------------------------------------------


def test_the_critic_sees_the_opponents_hand_and_the_actor_does_not():
    match = match_reset(jax.random.PRNGKey(0))
    loc = np.asarray(match.round.card_loc)
    opp_hand = np.flatnonzero(loc == LOC_P0_HAND + 1)

    actor = np.asarray(match_observation(match, jnp.int32(0)))
    critic = np.asarray(match_critic_observation(match, jnp.int32(0)))

    # Card-major, three channels each, matching the base observation's layout.
    hidden = critic[MATCH_OBS_DIM:].reshape(N_CARDS, 3)
    assert np.array_equal(np.flatnonzero(hidden[:, 0]), opp_hand)

    # The actor's view is a strict prefix of it and carries none of that.
    assert np.array_equal(actor, critic[:MATCH_OBS_DIM])
    assert critic.shape == (MATCH_CRITIC_OBS_DIM,)


def test_privileged_input_cannot_move_the_policy_logits():
    """Separate trunks, or the critic's view of the deck leaks into play.

    The privileged critic is off by default -- it measured negative, see the
    ablation in the plan -- but the isolation property still has to hold for
    anyone who switches it on, and for its later reuse as a search evaluator.
    """
    cfg = _cfg()
    state = create_match_train_state(cfg, jax.random.PRNGKey(1), Ablation(privileged_critic=True))
    match = match_reset(jax.random.PRNGKey(2))

    obs = match_observation(match, jnp.int32(0))[None, :]
    critic_a = match_critic_observation(match, jnp.int32(0))[None, :]
    critic_b = critic_a.at[0, MATCH_OBS_DIM:].set(1.0)  # a totally different hidden state

    logits_a, value_a = state.apply_fn(state.params, obs, critic_a)
    logits_b, value_b = state.apply_fn(state.params, obs, critic_b)

    assert np.array_equal(np.asarray(logits_a), np.asarray(logits_b))
    # ...and the value head must actually be using it, or the critic is pointless.
    assert not np.allclose(np.asarray(value_a), np.asarray(value_b))


# --- the match reward ------------------------------------------------------


def test_reward_is_zero_sum():
    cfg = _cfg()
    state = create_match_train_state(cfg, jax.random.PRNGKey(3))
    env = jax.jit(jax.vmap(match_reset))(
        jax.random.split(jax.random.PRNGKey(4), cfg.ppo.batch_games)
    )

    rollout = make_match_rollout_fn(cfg)
    _, transitions, _, metrics = rollout(state, env, jax.random.PRNGKey(5), jnp.asarray(1.0))

    reward = np.asarray(transitions.reward)  # (T, 2 * batch)
    seat0, seat1 = reward[:, : cfg.ppo.batch_games], reward[:, cfg.ppo.batch_games :]
    assert np.allclose(seat0, -seat1)
    assert float(metrics["matches_completed"]) > 0


def test_the_dense_reward_telescopes_to_the_match_total():
    """The whole justification for paying every ply: at gamma=1 the sum is the total.

    That is what makes "score more across three rounds" the objective, rather than
    some shaped proxy for it.
    """
    match = match_reset(jax.random.PRNGKey(6))
    key = jax.random.PRNGKey(7)
    scale = 50.0

    paid = 0.0
    while not bool(match.done):
        key, step_key = jax.random.split(key)
        action = random_legal_action(match.round, match.round.to_move, step_key)
        before = np.asarray(match_score(match))
        match, _, _ = match_step(match, action)
        after = np.asarray(match_score(match))
        paid += ((after[0] - after[1]) - (before[0] - before[1])) / scale

    final = np.asarray(match_score(match))
    total_lead = (final[0] - final[1]) / scale
    # Every point banked along the way, and nothing else.
    assert paid == pytest.approx(total_lead, abs=1e-4)


# --- both seats ------------------------------------------------------------


def test_exactly_one_seat_acts_per_ply_but_both_are_trained():
    cfg = _cfg()
    state = create_match_train_state(cfg, jax.random.PRNGKey(8))
    env = jax.jit(jax.vmap(match_reset))(
        jax.random.split(jax.random.PRNGKey(9), cfg.ppo.batch_games)
    )

    rollout = make_match_rollout_fn(cfg)
    _, transitions, last_value, _ = rollout(state, env, jax.random.PRNGKey(10), jnp.asarray(0.0))

    batch = cfg.ppo.batch_games
    actor = np.asarray(transitions.actor_mask)
    seat0, seat1 = actor[:, :batch], actor[:, batch:]

    # One mover per ply while the match is live.
    live = np.asarray(transitions.active)[:, :batch]
    assert np.array_equal(seat0 ^ seat1, live)

    # But the critic gets both seats: every live ply is a value target on both.
    assert np.asarray(transitions.active).sum() == 2 * live.sum()
    assert transitions.value.shape == (cfg.ppo.rollout_steps, 2 * batch)
    assert last_value.shape == (2 * batch,)


def test_self_play_is_balanced_and_a_train_step_stays_finite():
    cfg = _cfg(rollout_steps=700, batch_games=16)
    state = create_match_train_state(cfg, jax.random.PRNGKey(11))
    env = jax.jit(jax.vmap(match_reset))(jax.random.split(jax.random.PRNGKey(12), 16))
    train_iteration = make_match_train_iteration(cfg)

    state, env, _, metrics = train_iteration(state, env, jax.random.PRNGKey(13), jnp.asarray(0.1))

    assert bool(jnp.isfinite(metrics["loss"]))
    assert float(metrics["matches_completed"]) > 0
    # One network on both sides: neither seat should be favoured.
    assert 0.2 < float(metrics["match_win_rate"]) < 0.8


# --- the match observation -------------------------------------------------


def test_carry_reaches_the_observation_with_a_usable_scale():
    """The old score_diff divided by 780; a 50-point lead vanished into 0.06."""
    match = match_reset(jax.random.PRNGKey(14))
    behind = match._replace(carry=jnp.asarray([0, 50], dtype=jnp.int32))
    ahead = match._replace(carry=jnp.asarray([50, 0], dtype=jnp.int32))

    obs_behind = np.asarray(match_observation(behind, jnp.int32(0)))
    obs_ahead = np.asarray(match_observation(ahead, jnp.int32(0)))

    delta = np.abs(obs_ahead - obs_behind)
    # A 100-point swing has to be plainly visible, not a rounding error.
    assert delta.max() > 0.5
    assert (delta > 0.01).sum() >= 2  # the scalar and at least one bin flip


@pytest.mark.parametrize("round_idx", [0, 1, 2])
def test_the_round_index_is_observable(round_idx):
    match = match_reset(jax.random.PRNGKey(15))._replace(
        round_idx=jnp.asarray(round_idx, dtype=jnp.int32)
    )
    obs = np.asarray(match_observation(match, jnp.int32(0)))
    assert obs.shape == (MATCH_OBS_DIM,)
    assert np.isfinite(obs).all()


def test_whose_turn_it_is_is_observable():
    """The single-round obs never said; the critic had to read it off step_count."""
    match = match_reset(jax.random.PRNGKey(16))
    mover = int(match.round.to_move)

    from_mover = np.asarray(match_observation(match, jnp.int32(mover)))
    from_waiter = np.asarray(match_observation(match, jnp.int32(1 - mover)))
    assert not np.array_equal(from_mover, from_waiter)
