"""Tests for observation history functionality."""

from unittest.mock import Mock

import pytest
import torch
from conftest import get_test_device

from mjlab.managers.observation_manager import (
  ObservationGroupCfg,
  ObservationManager,
  ObservationTermCfg,
)


@pytest.fixture
def device():
  """Test device fixture."""
  return get_test_device()


@pytest.fixture
def mock_env(device):
  """Create a mock environment."""
  env = Mock()
  env.num_envs = 4
  env.device = device
  env.step_dt = 0.02
  return env


@pytest.fixture
def simple_obs_func(device):
  """Create a simple observation function that returns a counter."""
  counter = {"value": 0}

  def obs_func(env):
    counter["value"] += 1
    return torch.full((env.num_envs, 3), float(counter["value"]), device=device)

  return obs_func


# Basic observation history tests.


def test_no_history_by_default(mock_env, simple_obs_func):
  """Test that observations work without history (default behavior)."""

  cfg = {
    "actor": ObservationGroupCfg(
      terms={
        "obs1": ObservationTermCfg(func=simple_obs_func, params={}),
      }
    ),
  }

  manager = ObservationManager(cfg, mock_env)
  assert manager.group_obs_dim["actor"] == (3,)

  obs = manager.compute()
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)
  assert policy_obs.shape == (4, 3)


def test_single_step_history(mock_env, simple_obs_func):
  """Test observation with history_length=1."""

  cfg = {
    "actor": ObservationGroupCfg(
      terms={
        "obs1": ObservationTermCfg(func=simple_obs_func, params={}, history_length=1),
      }
    ),
  }

  manager = ObservationManager(cfg, mock_env)
  assert manager.group_obs_dim["actor"] == (3,)

  obs = manager.compute()
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)
  assert policy_obs.shape == (4, 3)


def test_multi_step_history_flattened(mock_env, simple_obs_func):
  """Test observation with history_length=3 and flattened."""

  cfg = {
    "actor": ObservationGroupCfg(
      terms={
        "obs1": ObservationTermCfg(
          func=simple_obs_func, params={}, history_length=3, flatten_history_dim=True
        ),
      }
    ),
  }

  manager = ObservationManager(cfg, mock_env)
  assert manager.group_obs_dim["actor"] == (9,)

  obs = manager.compute()
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)
  assert policy_obs.shape == (4, 9)


def test_multi_step_history_not_flattened(mock_env, simple_obs_func):
  """Test observation with history_length=3 and not flattened."""

  cfg = {
    "actor": ObservationGroupCfg(
      terms={
        "obs1": ObservationTermCfg(
          func=simple_obs_func, params={}, history_length=3, flatten_history_dim=False
        ),
      }
    ),
  }

  manager = ObservationManager(cfg, mock_env)
  assert manager.group_obs_dim["actor"] == (3, 3)

  obs = manager.compute()
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)
  assert policy_obs.shape == (4, 3, 3)


# History accumulation tests.


def test_history_accumulates_correctly(mock_env, simple_obs_func):
  """Test that history buffer accumulates observations in correct order."""

  cfg = {
    "actor": ObservationGroupCfg(
      terms={
        "obs1": ObservationTermCfg(
          func=simple_obs_func, params={}, history_length=3, flatten_history_dim=False
        ),
      }
    ),
  }

  manager = ObservationManager(cfg, mock_env)
  device = mock_env.device
  # Note: counter is incremented during _prepare_terms (value=1).

  # First compute uses value=2 and initializes buffer.
  obs = manager.compute(update_history=False)
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)
  assert torch.allclose(policy_obs[0], torch.full((3, 3), 2.0, device=device))

  # Update with value=3.
  obs = manager.compute(update_history=True)
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)
  # History: [2, 2, 3] (oldest to newest).
  expected = torch.stack(
    [
      torch.full((3,), 2.0, device=device),
      torch.full((3,), 2.0, device=device),
      torch.full((3,), 3.0, device=device),
    ]
  )
  assert torch.allclose(policy_obs[0], expected)

  # Update with value=4.
  obs = manager.compute(update_history=True)
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)
  # History: [2, 3, 4].
  expected = torch.stack(
    [
      torch.full((3,), 2.0, device=device),
      torch.full((3,), 3.0, device=device),
      torch.full((3,), 4.0, device=device),
    ]
  )
  assert torch.allclose(policy_obs[0], expected)

  # Update with value=5, circular overwrite.
  obs = manager.compute(update_history=True)
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)
  # History: [3, 4, 5].
  expected = torch.stack(
    [
      torch.full((3,), 3.0, device=device),
      torch.full((3,), 4.0, device=device),
      torch.full((3,), 5.0, device=device),
    ]
  )
  assert torch.allclose(policy_obs[0], expected)


def test_update_history_false_doesnt_modify_buffer(mock_env, simple_obs_func):
  """Test that update_history=False doesn't modify the buffer."""

  cfg = {
    "actor": ObservationGroupCfg(
      terms={
        "obs1": ObservationTermCfg(
          func=simple_obs_func, params={}, history_length=2, flatten_history_dim=False
        ),
      }
    ),
  }

  manager = ObservationManager(cfg, mock_env)

  # Initialize (value=1).
  obs1 = manager.compute(update_history=False)
  policy_obs1 = obs1["actor"]
  assert isinstance(policy_obs1, torch.Tensor)

  # Call without update (value=2, but buffer unchanged).
  obs2 = manager.compute(update_history=False)
  policy_obs2 = obs2["actor"]
  assert isinstance(policy_obs2, torch.Tensor)

  # History should still be [1, 1].
  assert torch.allclose(policy_obs1, policy_obs2)


# Group-level history override tests.


def test_group_history_overrides_term(mock_env, simple_obs_func):
  """Test group history_length overrides term history_length."""

  cfg = {
    "actor": ObservationGroupCfg(
      history_length=5,  # Group level.
      flatten_history_dim=False,
      terms={
        "obs1": ObservationTermCfg(
          func=simple_obs_func,
          params={},
          history_length=2,  # Overridden.
        ),
      },
    ),
  }

  manager = ObservationManager(cfg, mock_env)
  assert manager.group_obs_dim["actor"] == (5, 3)

  obs = manager.compute()
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)
  assert policy_obs.shape == (4, 5, 3)


# History reset tests.


def test_reset_clears_all_envs(mock_env, simple_obs_func):
  """Test that reset without env_ids clears all environments."""

  cfg = {
    "actor": ObservationGroupCfg(
      terms={
        "obs1": ObservationTermCfg(
          func=simple_obs_func, params={}, history_length=2, flatten_history_dim=False
        ),
      }
    ),
  }

  manager = ObservationManager(cfg, mock_env)

  # Build up history.
  manager.compute(update_history=True)
  manager.compute(update_history=True)

  # Reset all.
  manager.reset()

  # Buffer should be zeroed.
  obs = manager.compute(update_history=False)
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)
  assert torch.allclose(policy_obs, torch.zeros((4, 2, 3), device=mock_env.device))


def test_reset_partial_envs(mock_env, simple_obs_func):
  """Test that reset with specific env_ids only resets those envs."""

  cfg = {
    "actor": ObservationGroupCfg(
      terms={
        "obs1": ObservationTermCfg(
          func=simple_obs_func, params={}, history_length=3, flatten_history_dim=False
        ),
      }
    ),
  }

  manager = ObservationManager(cfg, mock_env)

  # Build up history.
  manager.compute(update_history=True)
  manager.compute(update_history=True)
  obs_before = manager.compute(update_history=True)
  policy_obs_before = obs_before["actor"]
  assert isinstance(policy_obs_before, torch.Tensor)

  # Reset only envs 0 and 2.
  manager.reset(env_ids=torch.tensor([0, 2], device=mock_env.device))

  obs_after = manager.compute(update_history=False)
  policy_obs_after = obs_after["actor"]
  assert isinstance(policy_obs_after, torch.Tensor)

  # Envs 0 and 2 should be reset (zeros), 1 and 3 unchanged.
  assert torch.allclose(
    policy_obs_after[0], torch.zeros((3, 3), device=mock_env.device)
  )
  assert torch.allclose(policy_obs_after[1], policy_obs_before[1])
  assert torch.allclose(
    policy_obs_after[2], torch.zeros((3, 3), device=mock_env.device)
  )
  assert torch.allclose(policy_obs_after[3], policy_obs_before[3])


def test_reset_partial_envs_with_backfill(mock_env, simple_obs_func):
  """Test that reset envs get backfilled on next update."""

  cfg = {
    "actor": ObservationGroupCfg(
      terms={
        "obs1": ObservationTermCfg(
          func=simple_obs_func, params={}, history_length=3, flatten_history_dim=False
        ),
      }
    ),
  }

  manager = ObservationManager(cfg, mock_env)
  device = mock_env.device

  # Build history: [2, 3, 4] for all envs (counter starts at 1 after _prepare_terms).
  manager.compute(update_history=True)  # value=2
  manager.compute(update_history=True)  # value=3
  manager.compute(update_history=True)  # value=4

  # Reset only envs 0 and 2.
  manager.reset(env_ids=torch.tensor([0, 2], device=device))

  # Next update with value=5.
  obs = manager.compute(update_history=True)
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)

  # Env 0: [5, 5, 5] (backfilled after reset).
  expected_env0 = torch.stack(
    [
      torch.full((3,), 5.0, device=device),
      torch.full((3,), 5.0, device=device),
      torch.full((3,), 5.0, device=device),
    ]
  )
  assert torch.allclose(policy_obs[0], expected_env0)

  # Env 1: [3, 4, 5] (continues normally).
  expected_env1 = torch.stack(
    [
      torch.full((3,), 3.0, device=device),
      torch.full((3,), 4.0, device=device),
      torch.full((3,), 5.0, device=device),
    ]
  )
  assert torch.allclose(policy_obs[1], expected_env1)

  # Env 2: [5, 5, 5] (backfilled after reset).
  assert torch.allclose(policy_obs[2], expected_env0)

  # Env 3: [3, 4, 5] (continues normally).
  assert torch.allclose(policy_obs[3], expected_env1)


# History with other features tests.


def test_history_with_clip(mock_env, simple_obs_func):
  """Test that clipping is applied before history."""

  cfg = {
    "actor": ObservationGroupCfg(
      terms={
        "obs1": ObservationTermCfg(
          func=simple_obs_func,
          params={},
          history_length=2,
          flatten_history_dim=False,
          clip=(-0.5, 0.5),
        ),
      }
    ),
  }

  manager = ObservationManager(cfg, mock_env)

  obs = manager.compute(update_history=True)
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)
  # Values should be clipped.
  assert torch.all(policy_obs >= -0.5)
  assert torch.all(policy_obs <= 0.5)


def test_history_with_scale(mock_env, simple_obs_func):
  """Test that scaling is applied before history."""

  cfg = {
    "actor": ObservationGroupCfg(
      terms={
        "obs1": ObservationTermCfg(
          func=simple_obs_func,
          params={},
          history_length=2,
          flatten_history_dim=False,
          scale=2.0,
        ),
      }
    ),
  }

  manager = ObservationManager(cfg, mock_env)
  device = mock_env.device
  # Counter at 1 after _prepare_terms.

  # First call uses value=2, scaled to 4, initializes buffer.
  manager.compute(update_history=False)
  # Second call uses value=3, scaled to 6, and updates history.
  obs = manager.compute(update_history=True)
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)

  # History: [4, 6].
  expected = torch.stack(
    [torch.full((3,), 4.0, device=device), torch.full((3,), 6.0, device=device)]
  )
  assert torch.allclose(policy_obs[0], expected)


# Mixed history terms tests.


def test_mixed_terms_concatenated(mock_env, simple_obs_func, device):
  """Test group with both history and non-history terms concatenated."""

  counter = {"value": 0}

  def obs_func2(env):
    counter["value"] += 1
    return torch.full((env.num_envs, 2), float(counter["value"]) * 10, device=device)

  cfg = {
    "actor": ObservationGroupCfg(
      terms={
        "obs_with_history": ObservationTermCfg(
          func=simple_obs_func, params={}, history_length=2, flatten_history_dim=True
        ),
        "obs_no_history": ObservationTermCfg(func=obs_func2, params={}),
      }
    ),
  }

  manager = ObservationManager(cfg, mock_env)

  # Should concatenate: (3*2) + 2 = 8.
  assert manager.group_obs_dim["actor"] == (8,)

  obs = manager.compute()
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)
  assert policy_obs.shape == (4, 8)


def test_no_double_append_on_first_call(mock_env, simple_obs_func):
  """Test that first call with update_history=True only appends once, not twice."""

  cfg = {
    "actor": ObservationGroupCfg(
      terms={
        "obs1": ObservationTermCfg(
          func=simple_obs_func, params={}, history_length=3, flatten_history_dim=False
        ),
      }
    ),
  }

  manager = ObservationManager(cfg, mock_env)
  device = mock_env.device
  # Counter is at 1 after _prepare_terms.

  # First call with update_history=True (value=2).
  # This should initialize the buffer AND append once (not twice).
  obs = manager.compute(update_history=True)
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)

  # Verify buffer was initialized and backfilled correctly.
  # All slots should be filled with value 2.
  expected_first = torch.stack(
    [
      torch.full((3,), 2.0, device=device),
      torch.full((3,), 2.0, device=device),
      torch.full((3,), 2.0, device=device),
    ]
  )
  assert torch.allclose(policy_obs[0], expected_first)

  # Get the circular buffer and check pointer position.
  circular_buffer = manager._group_obs_term_history_buffer["actor"]["obs1"]
  # After one append, pointer should be at 0 (not 1 which would indicate double-append).
  assert circular_buffer._pointer == 0
  # And we should have exactly 1 push recorded.
  assert torch.all(circular_buffer._num_pushes == 1)

  # Second call with update_history=True (value=3).
  obs = manager.compute(update_history=True)
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)

  # History should be [2, 2, 3] (oldest to newest).
  expected_second = torch.stack(
    [
      torch.full((3,), 2.0, device=device),
      torch.full((3,), 2.0, device=device),
      torch.full((3,), 3.0, device=device),
    ]
  )
  assert torch.allclose(policy_obs[0], expected_second)

  # Pointer should now be at 1.
  assert circular_buffer._pointer == 1
  # And we should have exactly 2 pushes.
  assert torch.all(circular_buffer._num_pushes == 2)


def test_term_major_ordering(mock_env, device):
  """Test that history flattening uses term-major ordering.

  Term-major: [A_t0, A_t1, ..., A_tH-1, B_t0, B_t1, ..., B_tH-1]
  Time-major: [A_t0, B_t0, ..., A_t1, B_t1, ..., A_tH-1, B_tH-1]

  This test verifies mjlab uses term-major ordering.
  """

  # Create observation functions with distinct values per term.
  def obs_A(env):
    # Returns [100, 101] for easy identification.
    return torch.tensor([[100.0, 101.0]] * env.num_envs, device=device)

  def obs_B(env):
    # Returns [200, 201, 202] for easy identification.
    return torch.tensor([[200.0, 201.0, 202.0]] * env.num_envs, device=device)

  cfg = {
    "actor": ObservationGroupCfg(
      concatenate_terms=True,
      terms={
        "term_A": ObservationTermCfg(
          func=obs_A, params={}, history_length=3, flatten_history_dim=True
        ),
        "term_B": ObservationTermCfg(
          func=obs_B, params={}, history_length=3, flatten_history_dim=True
        ),
      },
    ),
  }

  manager = ObservationManager(cfg, mock_env)

  # Compute observations (history will be backfilled with same values).
  obs = manager.compute(update_history=False)
  policy_obs = obs["actor"]
  assert isinstance(policy_obs, torch.Tensor)

  # Expected shape: (4 envs, 2*3 + 3*3) = (4, 15).
  assert policy_obs.shape == (4, 15)

  # Check ordering for first environment.
  result = policy_obs[0].cpu().tolist()

  # Term-major: all A history, then all B history.
  # A has 2 dims, B has 3 dims, history_length=3.
  # fmt: off
  expected_term_major = [
    100.0, 101.0, 100.0, 101.0, 100.0, 101.0,  # All A history
    200.0, 201.0, 202.0, 200.0, 201.0, 202.0, 200.0, 201.0, 202.0,  # All B
  ]
  # fmt: on

  # Time-major would interleave: [A_t0, B_t0, A_t1, B_t1, A_t2, B_t2].
  # fmt: off
  expected_time_major = [
    100.0, 101.0, 200.0, 201.0, 202.0,  # Frame t0
    100.0, 101.0, 200.0, 201.0, 202.0,  # Frame t1
    100.0, 101.0, 200.0, 201.0, 202.0,  # Frame t2
  ]
  # fmt: on

  # Verify term-major ordering.
  assert result == expected_term_major, f"Expected term-major ordering, got {result}"
  assert result != expected_time_major, "Should not match time-major"
