"""Tests for CircularBuffer."""

import pytest
import torch
from conftest import get_test_device

from mjlab.utils.buffers import CircularBuffer


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


def test_circular_buffer_basic_append(device):
  """Basic append and chronological retrieval (oldest -> newest)."""
  buffer = CircularBuffer(max_len=3, batch_size=2, device=device)

  buffer.append(torch.tensor([[1.0, 2.0], [3.0, 4.0]], device=device))
  buffer.append(torch.tensor([[5.0, 6.0], [7.0, 8.0]], device=device))
  buffer.append(torch.tensor([[9.0, 10.0], [11.0, 12.0]], device=device))

  result = buffer.buffer
  assert result.shape == (2, 3, 2)
  # Oldest to newest.
  assert torch.allclose(
    result[0], torch.tensor([[1.0, 2.0], [5.0, 6.0], [9.0, 10.0]], device=device)
  )
  assert torch.allclose(
    result[1], torch.tensor([[3.0, 4.0], [7.0, 8.0], [11.0, 12.0]], device=device)
  )


def test_circular_buffer_overwrite(device):
  """Overwrites oldest once capacity reached."""
  buffer = CircularBuffer(max_len=2, batch_size=1, device=device)

  buffer.append(torch.tensor([[1.0]], device=device))
  buffer.append(torch.tensor([[2.0]], device=device))
  buffer.append(torch.tensor([[3.0]], device=device))  # Overwrites first.

  result = buffer.buffer
  assert result.shape == (1, 2, 1)
  assert torch.allclose(result[0], torch.tensor([[2.0], [3.0]], device=device))


def test_circular_buffer_reset_single_batch(device):
  """Reset clears values and counters for specified batch rows."""
  buffer = CircularBuffer(max_len=2, batch_size=3, device=device)

  buffer.append(torch.tensor([[1.0], [2.0], [3.0]], device=device))
  buffer.append(torch.tensor([[4.0], [5.0], [6.0]], device=device))

  # Reset only batch index 1.
  buffer.reset(batch_ids=torch.tensor([1], device=device))

  result = buffer.buffer
  # Oldest-to-newest for each batch row.
  assert result[0, 0, 0] == 1.0
  assert result[1, 0, 0] == 0.0  # Reset backfilled to zeros for that row.
  assert result[2, 0, 0] == 3.0

  # current_length reflects reset: rows 0 and 2 had 2 pushes, row 1 is 0.
  cl = buffer.current_length
  assert torch.equal(cl.cpu(), torch.tensor([2, 0, 2]))


def test_circular_buffer_first_append_fills(device):
  """First append back-fills whole history for each batch row."""
  buffer = CircularBuffer(max_len=3, batch_size=2, device=device)
  buffer.append(torch.tensor([[1.0], [2.0]], device=device))

  result = buffer.buffer
  assert torch.allclose(result[0], torch.tensor([[1.0], [1.0], [1.0]], device=device))
  assert torch.allclose(result[1], torch.tensor([[2.0], [2.0], [2.0]], device=device))

  # And current_length reflects valid frames so far.
  cl = buffer.current_length
  assert torch.equal(cl.cpu(), torch.tensor([1, 1]))


def test_current_length_counts_and_clamps(device):
  """current_length counts per-batch valid frames and clamps to max_len."""
  buffer = CircularBuffer(max_len=4, batch_size=3, device=device)

  # Two appends -> length 2 everywhere.
  for _ in range(2):
    buffer.append(torch.arange(3, dtype=torch.float32, device=device).unsqueeze(-1))

  assert torch.equal(buffer.current_length.cpu(), torch.tensor([2, 2, 2]))

  # Reset middle row -> it becomes 0.
  buffer.reset(batch_ids=[1])
  assert torch.equal(buffer.current_length.cpu(), torch.tensor([2, 0, 2]))

  # One more append -> rows [0,2] become 3; row 1 becomes 1.
  buffer.append(torch.arange(3, dtype=torch.float32, device=device).unsqueeze(-1))
  assert torch.equal(buffer.current_length.cpu(), torch.tensor([3, 1, 3]))

  # Fill beyond capacity -> clamp to max_len.
  for _ in range(5):
    buffer.append(torch.arange(3, dtype=torch.float32, device=device).unsqueeze(-1))
  assert torch.equal(buffer.current_length.cpu(), torch.tensor([4, 4, 4]))


def test_reset_all_none_path(device):
  """reset(None) zeros the entire buffer and counters without indexing."""
  buffer = CircularBuffer(max_len=3, batch_size=2, device=device)
  buffer.append(torch.tensor([[1.0], [2.0]], device=device))
  buffer.append(torch.tensor([[3.0], [4.0]], device=device))

  buffer.reset()  # None -> reset all.

  # Counters are zero.
  assert torch.equal(buffer.current_length.cpu(), torch.tensor([0, 0]))
  # Buffer zeros (safe to read even after reset because storage exists).
  result = buffer.buffer
  assert torch.count_nonzero(result) == 0


def test_getitem_lifo_and_clamp(device):
  """__getitem__ returns lagged frames per-batch (LIFO), clamping when needed."""
  buffer = CircularBuffer(max_len=3, batch_size=2, device=device)

  buffer.append(torch.tensor([[1.0], [10.0]], device=device))  # t0
  buffer.append(torch.tensor([[2.0], [20.0]], device=device))  # t1
  buffer.append(torch.tensor([[3.0], [30.0]], device=device))  # t2

  # Lag 0 for batch 0 (-> 3), lag 2 for batch 1 (-> oldest 10).
  out = buffer[torch.tensor([0, 2], device=device)]
  assert torch.allclose(out, torch.tensor([[3.0], [10.0]], device=device))

  # Clamp: huge lag for batch 0 -> oldest (1), lag 1 for batch 1 -> 20.
  out = buffer[torch.tensor([99, 1], device=device)]
  assert torch.allclose(out, torch.tensor([[1.0], [20.0]], device=device))


def test_backfill_after_per_batch_reset(device):
  """After resetting a row, the next append back-fills its entire history for that row."""
  buffer = CircularBuffer(max_len=3, batch_size=2, device=device)

  buffer.append(torch.tensor([[1.0], [10.0]], device=device))  # t0
  buffer.append(torch.tensor([[2.0], [20.0]], device=device))  # t1

  # Reset only batch row 1; row 0 remains with 2 valid frames.
  buffer.reset(batch_ids=[1])
  assert torch.equal(buffer.current_length.cpu(), torch.tensor([2, 0]))

  # Next append: row 0 gets new value; row 1 is "first push" -> back-filled.
  buffer.append(torch.tensor([[3.0], [99.0]], device=device))  # t2

  hist = buffer.buffer  # shape (2, 3, 1)
  # Row 0 keeps real chronology [1, 2, 3].
  assert torch.allclose(
    hist[0].squeeze(-1), torch.tensor([1.0, 2.0, 3.0], device=device)
  )
  # Row 1 is back-filled to all 99s.
  assert torch.allclose(
    hist[1].squeeze(-1), torch.tensor([99.0, 99.0, 99.0], device=device)
  )


def test_backfill_multidimensional_data(device):
  """First-push backfill works for >2D data whose trailing dims differ from batch.

  Regression for #1045: the backfill broadcast hard-coded a 3D condition shape,
  which misaligned the batch axis with a trailing axis when num_targets != batch.
  Here batch_size=4 but the trailing axis is 2, so the buggy broadcast raised a
  4-vs-2 mismatch.
  """
  batch_size, num_targets, num_channels = 4, 2, 3
  buffer = CircularBuffer(max_len=3, batch_size=batch_size, device=device)

  data = torch.arange(
    batch_size * num_targets * num_channels, dtype=torch.float32, device=device
  ).reshape(batch_size, num_targets, num_channels)
  buffer.append(data)

  hist = buffer.buffer  # (batch, max_len, num_targets, num_channels)
  assert hist.shape == (batch_size, 3, num_targets, num_channels)
  # First push backfills every history slot with the same frame per batch row.
  for t in range(3):
    assert torch.allclose(hist[:, t], data)


def test_errors_and_types(device):
  """Error paths: wrong batch, pre-append access, and bad key size."""
  buffer = CircularBuffer(max_len=2, batch_size=2, device=device)

  # Wrong batch size on append.
  with pytest.raises(ValueError):
    buffer.append(torch.tensor([[1.0]], device=device))  # batch_size=1 wrong

  # buffer property before first append.
  with pytest.raises(RuntimeError):
    _ = CircularBuffer(max_len=1, batch_size=1, device=device).buffer

  # __getitem__ before any valid pushes.
  with pytest.raises(RuntimeError):
    _ = buffer[torch.tensor([0, 0], device=device)]

  # Now append once so storage exists and counters > 0.
  buffer.append(torch.tensor([[1.0], [2.0]], device=device))

  # __getitem__ with wrong key length.
  with pytest.raises(ValueError):
    _ = buffer[torch.tensor([0], device=device)]


def test_dtype_and_device_preserved(device):
  """Buffer preserves dtype and device."""
  buffer = CircularBuffer(max_len=2, batch_size=2, device=device)
  x = torch.tensor([[1.0], [2.0]], dtype=torch.float32, device=device)
  buffer.append(x)
  assert buffer.buffer.dtype == torch.float32
  assert buffer.buffer.device.type == torch.device(device).type


def test_backfill_only_touches_given_rows(device):
  """backfill writes one frame into all slots of given rows, no pointer advance."""
  buffer = CircularBuffer(max_len=3, batch_size=3, device=device)
  buffer.append(torch.tensor([[1.0], [10.0], [100.0]], device=device))
  buffer.append(torch.tensor([[2.0], [20.0], [200.0]], device=device))
  buffer.append(torch.tensor([[3.0], [30.0], [300.0]], device=device))

  buffer.reset(batch_ids=torch.tensor([1], device=device))
  data = torch.tensor([[-1.0], [99.0], [-1.0]], device=device)
  buffer.backfill(data, torch.tensor([1], device=device))

  result = buffer.buffer
  assert torch.allclose(
    result[0].flatten(), torch.tensor([1.0, 2.0, 3.0], device=device)
  )
  assert torch.allclose(
    result[1].flatten(), torch.tensor([99.0, 99.0, 99.0], device=device)
  )
  assert torch.allclose(
    result[2].flatten(), torch.tensor([100.0, 200.0, 300.0], device=device)
  )
  assert buffer.current_length.tolist() == [3, 1, 3]

  # Subsequent appends treat the backfilled row normally.
  buffer.append(torch.tensor([[4.0], [40.0], [400.0]], device=device))
  result = buffer.buffer
  assert torch.allclose(
    result[1].flatten(), torch.tensor([99.0, 99.0, 40.0], device=device)
  )
  assert buffer.current_length.tolist() == [3, 2, 3]


def test_backfill_uninitialized_raises(device):
  buffer = CircularBuffer(max_len=2, batch_size=2, device=device)
  with pytest.raises(RuntimeError, match="not initialized"):
    buffer.backfill(torch.zeros(2, 1, device=device), torch.tensor([0], device=device))


def test_getitem_lag_clamps_to_oldest_after_wrap(device):
  buffer = CircularBuffer(max_len=2, batch_size=1, device=device)
  for v in [1.0, 2.0, 3.0]:
    buffer.append(torch.tensor([[v]], device=device))

  # Retained frames: [2, 3] (num_pushes exceeds max_len).
  assert buffer[torch.tensor([0], device=device)].flatten().item() == 3.0
  assert buffer[torch.tensor([1], device=device)].flatten().item() == 2.0
  # A lag beyond the retained history clamps to the oldest frame instead of
  # wrapping around to a newer one.
  assert buffer[torch.tensor([2], device=device)].flatten().item() == 2.0
