"""Tests for terrain normal fitting from sensor hit points."""

from unittest.mock import MagicMock, PropertyMock

import torch

from mjlab.sensor import RayCastData, RayCastSensor
from mjlab.tasks.velocity.mdp.terrain_utils import (
  fit_terrain_normal,
  terrain_normal_from_sensors,
)


def test_flat_ground():
  """Points on z=0 plane → normal = [0, 0, 1]."""
  B, N = 4, 10
  points = torch.zeros(B, N, 3)
  torch.manual_seed(0)
  points[:, :, 0] = torch.randn(B, N)
  points[:, :, 1] = torch.randn(B, N)
  valid_mask = torch.ones(B, N, dtype=torch.bool)

  normal = fit_terrain_normal(points, valid_mask)

  assert normal.shape == (B, 3)
  expected = torch.tensor([0.0, 0.0, 1.0])
  for b in range(B):
    torch.testing.assert_close(normal[b], expected, atol=1e-5, rtol=1e-5)


def test_tilted_plane():
  """Points on z = 0.5*x plane → normal perpendicular to that."""
  B, N = 2, 20
  torch.manual_seed(42)
  points = torch.zeros(B, N, 3)
  x = torch.randn(B, N)
  y = torch.randn(B, N)
  points[:, :, 0] = x
  points[:, :, 1] = y
  points[:, :, 2] = 0.5 * x
  valid_mask = torch.ones(B, N, dtype=torch.bool)

  normal = fit_terrain_normal(points, valid_mask)

  # Normal to plane z = 0.5*x is (-0.5, 0, 1) normalized.
  expected_raw = torch.tensor([-0.5, 0.0, 1.0])
  expected = expected_raw / expected_raw.norm()
  assert normal.shape == (B, 3)
  for b in range(B):
    torch.testing.assert_close(normal[b], expected, atol=1e-4, rtol=1e-4)


def test_partial_misses():
  """Half invalid points with junk z values → still correct normal."""
  B, N = 2, 20
  torch.manual_seed(42)
  points = torch.zeros(B, N, 3)
  points[:, :, 0] = torch.randn(B, N)
  points[:, :, 1] = torch.randn(B, N)
  valid_mask = torch.ones(B, N, dtype=torch.bool)
  # Invalidate even-indexed points and put junk in them.
  valid_mask[:, ::2] = False
  points[:, ::2, 2] = 999.0

  normal = fit_terrain_normal(points, valid_mask)

  expected = torch.tensor([0.0, 0.0, 1.0])
  assert normal.shape == (B, 3)
  for b in range(B):
    torch.testing.assert_close(normal[b], expected, atol=1e-4, rtol=1e-4)


def test_fewer_than_3_valid_fallback():
  """Fewer than 3 valid points (including zero) falls back to [0, 0, 1]."""
  B, N = 3, 10
  points = torch.randn(B, N, 3)
  valid_mask = torch.zeros(B, N, dtype=torch.bool)
  # Batch 0: 0 valid, batch 1: 1 valid, batch 2: 2 valid.
  valid_mask[1, 0] = True
  valid_mask[2, :2] = True

  normal = fit_terrain_normal(points, valid_mask)

  expected = torch.tensor([0.0, 0.0, 1.0])
  for b in range(B):
    torch.testing.assert_close(normal[b], expected, atol=1e-6, rtol=1e-6)


def test_collinear_points_fallback():
  """Collinear points don't define a plane, should fall back to [0, 0, 1]."""
  B, N = 2, 10
  points = torch.zeros(B, N, 3)
  # All points along the X axis.
  points[:, :, 0] = torch.linspace(0, 1, N)
  valid_mask = torch.ones(B, N, dtype=torch.bool)

  normal = fit_terrain_normal(points, valid_mask)

  expected = torch.tensor([0.0, 0.0, 1.0])
  for b in range(B):
    torch.testing.assert_close(normal[b], expected, atol=1e-6, rtol=1e-6)


def test_non_finite_points_fallback():
  """Non-finite hits (from a diverged env) must not crash eigh; fall back to up."""
  B, N = 3, 10
  points = torch.zeros(B, N, 3)
  # Batch 0: a valid tilted plane.
  points[0, :, 0] = torch.linspace(0, 1, N)
  points[0, :, 1] = torch.linspace(0, 1, N)
  points[0, :, 2] = 0.2 * points[0, :, 0]
  # Batch 1: all NaN. Batch 2: all Inf.
  points[1] = float("nan")
  points[2] = float("inf")
  valid_mask = torch.ones(B, N, dtype=torch.bool)

  normal = fit_terrain_normal(points, valid_mask)

  assert torch.isfinite(normal).all()
  expected_up = torch.tensor([0.0, 0.0, 1.0])
  torch.testing.assert_close(normal[1], expected_up, atol=1e-6, rtol=1e-6)
  torch.testing.assert_close(normal[2], expected_up, atol=1e-6, rtol=1e-6)
  # The valid plane is still fit and points upward.
  assert normal[0, 2] > 0
  torch.testing.assert_close(normal[0].norm(), torch.tensor(1.0), atol=1e-4, rtol=0)


def test_small_valid_plane_still_fits():
  """A tiny but planar patch should not be rejected as degenerate."""
  x_vals = torch.linspace(0.0, 1e-4, 4)
  y_vals = torch.linspace(0.0, 8e-5, 3)
  xx, yy = torch.meshgrid(x_vals, y_vals, indexing="ij")
  patch = torch.stack((xx.reshape(-1), yy.reshape(-1)), dim=-1)
  N = patch.shape[0]
  B = 2
  points = torch.zeros(B, N, 3)
  points[:, :, 0] = patch[:, 0]
  points[:, :, 1] = patch[:, 1]
  points[:, :, 2] = 0.5 * patch[:, 0]
  valid_mask = torch.ones(B, N, dtype=torch.bool)

  normal = fit_terrain_normal(points, valid_mask)

  expected_raw = torch.tensor([-0.5, 0.0, 1.0])
  expected = expected_raw / expected_raw.norm()
  for b in range(B):
    torch.testing.assert_close(normal[b], expected, atol=1e-4, rtol=1e-4)


def test_terrain_normal_from_sensors():
  """Mock env/sensors, verify subsampling + concatenation."""
  B = 4
  torch.manual_seed(7)

  # RayCastSensor mock: 100 rays, all hits on z=0.
  raycast_hit_pos = torch.zeros(B, 100, 3)
  raycast_hit_pos[:, :, 0] = torch.randn(B, 100)
  raycast_hit_pos[:, :, 1] = torch.randn(B, 100)
  raycast_distances = torch.ones(B, 100)  # all valid

  raycast_sensor = MagicMock(spec=RayCastSensor)
  raycast_data = RayCastData(
    distances=raycast_distances,
    normals_w=torch.zeros(B, 100, 3),
    hit_pos_w=raycast_hit_pos,
    pos_w=torch.zeros(B, 3),
    quat_w=torch.zeros(B, 4),
    frame_pos_w=torch.zeros(B, 1, 3),
    frame_quat_w=torch.zeros(B, 1, 4),
  )
  type(raycast_sensor).data = PropertyMock(return_value=raycast_data)

  # Mock env.
  sensors = {"raycast": raycast_sensor}
  env = MagicMock()
  env.scene.__getitem__ = MagicMock(side_effect=lambda name: sensors[name])

  normal = terrain_normal_from_sensors(
    env,
    sensor_names=("raycast",),
    max_points=16,
  )

  # 16 subsampled raycast points on z=0.
  assert normal.shape == (B, 3)
  expected = torch.tensor([0.0, 0.0, 1.0])
  for b in range(B):
    torch.testing.assert_close(normal[b], expected, atol=1e-4, rtol=1e-4)
