"""Tests for proportion-based robot spawning on terrain columns."""

from __future__ import annotations

import numpy as np
import pytest
import torch

from mjlab.terrains.primitive_terrains import BoxFlatTerrainCfg
from mjlab.terrains.terrain_entity import TerrainEntity, TerrainEntityCfg
from mjlab.terrains.terrain_generator import TerrainGenerator, TerrainGeneratorCfg


@pytest.fixture(scope="module")
def terrain_entity() -> TerrainEntity:
  cfg = TerrainEntityCfg(terrain_type="plane", num_envs=1, env_spacing=2.0)
  return TerrainEntity(cfg, device="cpu")


def _make_origins(num_rows: int, num_cols: int) -> torch.Tensor:
  origins = torch.zeros(num_rows, num_cols, 3)
  for r in range(num_rows):
    for c in range(num_cols):
      origins[r, c, 0] = float(r)
      origins[r, c, 1] = float(c)
  return origins


def _normalized(proportions: list[float]) -> np.ndarray:
  p = np.array(proportions)
  return p / p.sum()


def _get_counts(entity: TerrainEntity, num_cols: int) -> list[int]:
  return [int((entity.terrain_types == col).sum().item()) for col in range(num_cols)]


# Fallback (no proportions).


def test_even_distribution_without_proportions(terrain_entity: TerrainEntity) -> None:
  num_envs = 30
  num_cols = 3
  origins = _make_origins(5, num_cols)

  terrain_entity._compute_env_origins_curriculum(num_envs, origins, None)

  assert _get_counts(terrain_entity, num_cols) == [10, 10, 10]


# Proportional distribution.


def test_basic_proportions(terrain_entity: TerrainEntity) -> None:
  num_envs = 100
  proportions = _normalized([0.5, 0.3, 0.2])
  origins = _make_origins(5, len(proportions))

  terrain_entity._compute_env_origins_curriculum(num_envs, origins, proportions)

  assert _get_counts(terrain_entity, len(proportions)) == [50, 30, 20]


@pytest.mark.parametrize("num_envs", [3, 7, 32, 64, 100, 512, 1024, 4096])
def test_counts_sum_to_num_envs(terrain_entity: TerrainEntity, num_envs: int) -> None:
  """Total terrain_types length must always equal num_envs."""
  proportions = _normalized([0.6, 0.25, 0.1, 0.05])
  origins = _make_origins(5, len(proportions))

  terrain_entity._compute_env_origins_curriculum(num_envs, origins, proportions)

  assert len(terrain_entity.terrain_types) == num_envs


@pytest.mark.parametrize(
  "num_envs, proportions",
  [
    (3, [0.98, 0.01, 0.01]),
    (5, [0.9, 0.05, 0.03, 0.01, 0.01]),
    (100, [0.9, 0.05, 0.05]),
    (10, [0.97, 0.01, 0.01, 0.01]),
  ],
)
def test_every_column_gets_at_least_one(
  terrain_entity: TerrainEntity, num_envs: int, proportions: list[float]
) -> None:
  """When num_envs >= num_cols, every column gets at least 1 robot."""
  p = _normalized(proportions)
  origins = _make_origins(5, len(p))

  terrain_entity._compute_env_origins_curriculum(num_envs, origins, p)

  counts = _get_counts(terrain_entity, len(p))
  for col, count in enumerate(counts):
    assert count >= 1, f"Column {col} got 0 envs (counts={counts})"


def test_fewer_envs_than_cols(terrain_entity: TerrainEntity) -> None:
  """When num_envs < num_cols, should still work without errors."""
  num_envs = 2
  proportions = _normalized([0.5, 0.3, 0.1, 0.1])
  origins = _make_origins(5, len(proportions))

  result = terrain_entity._compute_env_origins_curriculum(
    num_envs, origins, proportions
  )

  assert len(terrain_entity.terrain_types) == num_envs
  assert result.shape == (num_envs, 3)


def test_equal_proportions_gives_equal_counts(terrain_entity: TerrainEntity) -> None:
  num_envs = 30
  proportions = _normalized([1.0, 1.0, 1.0, 1.0])
  origins = _make_origins(5, len(proportions))

  terrain_entity._compute_env_origins_curriculum(num_envs, origins, proportions)

  counts = _get_counts(terrain_entity, len(proportions))
  for c in counts:
    assert 7 <= c <= 8, f"Expected 7 or 8 envs per column, got {c}"


def test_env_origins_shape(terrain_entity: TerrainEntity) -> None:
  num_envs = 20
  proportions = _normalized([0.5, 0.3, 0.2])
  origins = _make_origins(5, len(proportions))

  result = terrain_entity._compute_env_origins_curriculum(
    num_envs, origins, proportions
  )

  assert result.shape == (num_envs, 3)


def test_env_origins_match_terrain_origins(terrain_entity: TerrainEntity) -> None:
  """Each env_origin must correspond to a valid (level, type) in origins."""
  num_envs = 50
  proportions = _normalized([0.6, 0.3, 0.1])
  origins = _make_origins(5, len(proportions))

  result = terrain_entity._compute_env_origins_curriculum(
    num_envs, origins, proportions
  )

  for i in range(num_envs):
    level = int(terrain_entity.terrain_levels[i].item())
    ttype = int(terrain_entity.terrain_types[i].item())
    expected = origins[level, ttype]
    assert torch.allclose(result[i], expected), (
      f"env {i}: origin {result[i].tolist()} != "
      f"origins[{level},{ttype}]={expected.tolist()}"
    )


def test_mismatched_cols_falls_back_to_even(terrain_entity: TerrainEntity) -> None:
  """When proportions length != num_cols, should fall back to even."""
  num_envs = 30
  num_cols = 3
  # 4 proportions but only 3 columns in origins.
  proportions = _normalized([0.4, 0.3, 0.2, 0.1])
  origins = _make_origins(5, num_cols)

  terrain_entity._compute_env_origins_curriculum(num_envs, origins, proportions)

  assert _get_counts(terrain_entity, num_cols) == [10, 10, 10]


def test_proportions_are_respected_approximately(
  terrain_entity: TerrainEntity,
) -> None:
  """With large num_envs, actual distribution should track proportions."""
  num_envs = 10000
  raw = [0.5, 0.3, 0.15, 0.05]
  proportions = _normalized(raw)
  origins = _make_origins(5, len(proportions))

  terrain_entity._compute_env_origins_curriculum(num_envs, origins, proportions)

  counts = _get_counts(terrain_entity, len(proportions))
  for col, (count, prop) in enumerate(zip(counts, proportions, strict=True)):
    expected = prop * num_envs
    assert abs(count - expected) < 10, (
      f"Column {col}: got {count}, expected ~{expected:.0f}"
    )


def test_generator_does_not_mutate_cfg_num_cols() -> None:
  """TerrainGenerator must not mutate the caller's cfg.num_cols."""
  cfg = TerrainGeneratorCfg(
    size=(4.0, 4.0),
    curriculum=True,
    num_cols=1,
    sub_terrains={
      "a": BoxFlatTerrainCfg(proportion=0.5, size=(4.0, 4.0)),
      "b": BoxFlatTerrainCfg(proportion=0.3, size=(4.0, 4.0)),
      "c": BoxFlatTerrainCfg(proportion=0.2, size=(4.0, 4.0)),
    },
  )
  TerrainGenerator(cfg)
  assert cfg.num_cols == 1, f"cfg.num_cols was mutated to {cfg.num_cols}"
