"""Tests for TerrainSource ABC and TerrainBounds."""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
import pytest

from gri_utils.terrain import lla_to_xyz_sheet
from gri_utils.terrain.source import TerrainBounds, TerrainSource

if TYPE_CHECKING:
    from collections.abc import Sequence

    from numpy.typing import NDArray


class MockTerrainSource(TerrainSource):
    """Mock terrain source for testing.

    Returns flat terrain at a specified altitude for any requested region.
    """

    def __init__(
        self,
        altitude_m: float = 0.0,
        resolution_deg: float = 0.01,
        tile_size_deg: float = 1.0,
    ) -> None:
        """Initialize mock source with altitude, resolution, and tile size."""
        self.altitude = altitude_m
        self.resolution = resolution_deg
        self.tile_size = tile_size_deg

    def get_covering_sheets(
        self,
        lats: NDArray[np.floating] | Sequence[float] | float,
        lons: NDArray[np.floating] | Sequence[float] | float,
    ) -> list[tuple[NDArray[np.floating], TerrainBounds]]:
        """Return flat terrain tiles covering the requested region."""
        lats_arr = np.atleast_1d(np.asarray(lats, dtype=np.float64))
        lons_arr = np.atleast_1d(np.asarray(lons, dtype=np.float64))

        # Find tile bounds (1x1 degree tiles)
        lat_min_tile = np.floor(np.min(lats_arr))
        lat_max_tile = np.floor(np.max(lats_arr))
        lon_min_tile = np.floor(np.min(lons_arr))
        lon_max_tile = np.floor(np.max(lons_arr))

        sheets = []
        lat_tile = lat_min_tile
        while lat_tile <= lat_max_tile:
            lon_tile = lon_min_tile
            while lon_tile <= lon_max_tile:
                bounds = TerrainBounds(
                    lat_min=lat_tile,
                    lat_max=lat_tile + self.tile_size,
                    lon_min=lon_tile,
                    lon_max=lon_tile + self.tile_size,
                )
                n_points = int(self.tile_size / self.resolution) + 1
                tile_lats = np.linspace(bounds.lat_min, bounds.lat_max, n_points)
                tile_lons = np.linspace(bounds.lon_min, bounds.lon_max, n_points)
                tile_alts = np.full((n_points, n_points), self.altitude)
                sheet = lla_to_xyz_sheet(tile_lats, tile_lons, tile_alts)
                sheets.append((sheet, bounds))
                lon_tile += self.tile_size
            lat_tile += self.tile_size

        return sheets

    def covers(self, lat: float, lon: float) -> bool:  # noqa: ARG002
        """Return True for all locations."""
        return True


# -----------------------------------------------------------------------------
# TerrainBounds tests
# -----------------------------------------------------------------------------


def test_terrain_bounds_contains_inside() -> None:
    """Test contains for point inside bounds."""
    bounds = TerrainBounds(lat_min=38.0, lat_max=39.0, lon_min=-106.0, lon_max=-105.0)
    assert bounds.contains(38.5, -105.5)


def test_terrain_bounds_contains_on_edge() -> None:
    """Test contains for point on edge (inclusive)."""
    bounds = TerrainBounds(lat_min=38.0, lat_max=39.0, lon_min=-106.0, lon_max=-105.0)
    assert bounds.contains(38.0, -106.0)
    assert bounds.contains(39.0, -105.0)


def test_terrain_bounds_contains_outside() -> None:
    """Test contains for point outside bounds."""
    bounds = TerrainBounds(lat_min=38.0, lat_max=39.0, lon_min=-106.0, lon_max=-105.0)
    assert not bounds.contains(37.5, -105.5)
    assert not bounds.contains(38.5, -104.5)


def test_terrain_bounds_overlaps_true() -> None:
    """Test overlaps for overlapping bounds."""
    bounds1 = TerrainBounds(lat_min=38.0, lat_max=39.0, lon_min=-106.0, lon_max=-105.0)
    bounds2 = TerrainBounds(lat_min=38.5, lat_max=39.5, lon_min=-105.5, lon_max=-104.5)
    assert bounds1.overlaps(bounds2)
    assert bounds2.overlaps(bounds1)


def test_terrain_bounds_overlaps_adjacent() -> None:
    """Test overlaps for adjacent bounds (touching edge)."""
    bounds1 = TerrainBounds(lat_min=38.0, lat_max=39.0, lon_min=-106.0, lon_max=-105.0)
    bounds2 = TerrainBounds(lat_min=39.0, lat_max=40.0, lon_min=-106.0, lon_max=-105.0)
    # Touching at edge is considered overlapping (inclusive bounds)
    assert bounds1.overlaps(bounds2)


def test_terrain_bounds_overlaps_false() -> None:
    """Test overlaps for non-overlapping bounds."""
    bounds1 = TerrainBounds(lat_min=38.0, lat_max=39.0, lon_min=-106.0, lon_max=-105.0)
    bounds2 = TerrainBounds(lat_min=40.0, lat_max=41.0, lon_min=-106.0, lon_max=-105.0)
    assert not bounds1.overlaps(bounds2)


# -----------------------------------------------------------------------------
# TerrainSource ABC tests
# -----------------------------------------------------------------------------


def test_mock_source_get_single_tile() -> None:
    """Test getting a single tile for points within one tile."""
    source = MockTerrainSource(altitude_m=1000.0, resolution_deg=0.1)
    sheets = source.get_covering_sheets(lats=[38.5], lons=[-105.5])

    assert len(sheets) == 1
    sheet, bounds = sheets[0]

    assert bounds.lat_min == 38.0
    assert bounds.lat_max == 39.0
    assert bounds.lon_min == -106.0
    assert bounds.lon_max == -105.0

    # Check sheet shape (11x11 for 1 degree at 0.1 resolution)
    assert sheet.shape == (11, 11, 3)


def test_mock_source_get_multiple_tiles() -> None:
    """Test getting multiple tiles for points spanning tiles."""
    source = MockTerrainSource(altitude_m=0.0, resolution_deg=0.1)
    sheets = source.get_covering_sheets(
        lats=[38.5, 39.5],
        lons=[-105.5, -104.5],
    )

    # Should return 4 tiles (2x2 grid)
    assert len(sheets) == 4

    # Check all bounds are unique
    all_bounds = [b for _, b in sheets]
    lat_mins = sorted(b.lat_min for b in all_bounds)
    lon_mins = sorted(b.lon_min for b in all_bounds)

    assert 38.0 in lat_mins
    assert 39.0 in lat_mins
    assert -106.0 in lon_mins
    assert -105.0 in lon_mins


def test_mock_source_covers() -> None:
    """Test covers method."""
    source = MockTerrainSource()
    assert source.covers(38.5, -105.5)


def test_abc_cannot_instantiate() -> None:
    """Test that TerrainSource ABC cannot be instantiated directly."""
    with pytest.raises(TypeError, match="abstract"):
        TerrainSource()  # type: ignore[abstract]


def test_abc_requires_get_covering_sheets() -> None:
    """Test that subclass must implement get_covering_sheets."""

    class IncompleteSource(TerrainSource):
        def covers(self, lat: float, lon: float) -> bool:  # noqa: ARG002
            return True

    with pytest.raises(TypeError, match="abstract"):
        IncompleteSource()  # type: ignore[abstract]


def test_abc_requires_covers() -> None:
    """Test that subclass must implement covers."""

    class IncompleteSource(TerrainSource):
        def get_covering_sheets(
            self,
            lats: NDArray[np.floating] | Sequence[float] | float,  # noqa: ARG002
            lons: NDArray[np.floating] | Sequence[float] | float,  # noqa: ARG002
        ) -> list[tuple[NDArray[np.floating], TerrainBounds]]:
            return []

    with pytest.raises(TypeError, match="abstract"):
        IncompleteSource()  # type: ignore[abstract]
