"""Abstract base class for terrain data sources.

Defines the interface that terrain data providers (DTED, Copernicus, GeoTIFF, etc.)
must implement. gri-terrain implements concrete sources; gri-utils defines the
interface and uses it in adaptive intersection algorithms.

Example::

    from gri_utils.terrain import TerrainSource, TerrainBounds

    class MyTerrainSource(TerrainSource):
        def get_covering_sheets(self, lats, lons):
            # Load tiles covering the requested points
            ...

        def covers(self, lat, lon):
            # Check if data exists at this location
            ...
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, NamedTuple

if TYPE_CHECKING:
    from collections.abc import Sequence

    import numpy as np
    from numpy.typing import NDArray


class TerrainBounds(NamedTuple):
    """Geographic bounds of a terrain sheet.

    Attributes:
        lat_min: Minimum latitude in decimal degrees.
        lat_max: Maximum latitude in decimal degrees.
        lon_min: Minimum longitude in decimal degrees.
        lon_max: Maximum longitude in decimal degrees.
    """

    lat_min: float
    lat_max: float
    lon_min: float
    lon_max: float

    def contains(self, lat: float, lon: float) -> bool:
        """Check if a point is within these bounds.

        Args:
            lat: Latitude in decimal degrees.
            lon: Longitude in decimal degrees.

        Returns:
            True if point is within bounds (inclusive).
        """
        return (
            self.lat_min <= lat <= self.lat_max and self.lon_min <= lon <= self.lon_max
        )

    def overlaps(self, other: TerrainBounds) -> bool:
        """Check if two bounds overlap.

        Args:
            other: Another TerrainBounds to check against.

        Returns:
            True if bounds overlap.
        """
        return not (
            self.lat_max < other.lat_min
            or self.lat_min > other.lat_max
            or self.lon_max < other.lon_min
            or self.lon_min > other.lon_max
        )


class TerrainSource(ABC):
    """Abstract base class for terrain data sources.

    Implementations provide access to tiled terrain data (DTED, Copernicus,
    GeoTIFF, etc.). Sources return full-resolution tiles as XYZ sheets;
    adaptive resolution is handled by subsampling in the intersection algorithm.

    The source is responsible for:
    - Finding which tiles contain requested points
    - Loading tile data at full resolution
    - Converting to XYZ ECEF coordinates
    - Reporting actual tile bounds

    The caller (gri-utils) is responsible for:
    - Stitching multiple tiles if needed
    - Subsampling for adaptive algorithms
    - Running intersection algorithms

    Example:
        Implementing a simple terrain source::

            class ConstantAltitudeSource(TerrainSource):
                def __init__(self, altitude_m: float = 0.0):
                    self.altitude = altitude_m

                def get_covering_sheets(self, lats, lons):
                    # Create a simple grid at constant altitude
                    ...

                def covers(self, lat, lon):
                    return True  # Covers everywhere
    """

    @abstractmethod
    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]]:
        """Get all terrain tiles covering the specified points.

        Args:
            lats: Latitude(s) in decimal degrees. Scalar, sequence, or array.
            lons: Longitude(s) in decimal degrees. Scalar, sequence, or array.
                Must be broadcastable with lats.

        Returns:
            List of (sheet, bounds) tuples. Each sheet is an XYZ array in ECEF
            meters with shape (M, N, 3), at full resolution as stored in the
            source. Bounds reflect actual tile extent, not requested points.

            Returns empty list if no data available for any of the points.

        Note:
            Caller is responsible for stitching/mosaicking if multiple tiles
            are returned. Use ``terrain.stitch_sheets()`` helper.

        Example:
            Get terrain for a region::

                sheets = source.get_covering_sheets(
                    lats=[38.5, 39.5],
                    lons=[-105.5, -104.5],
                )
                for sheet, bounds in sheets:
                    print(f"Tile: {bounds}, shape: {sheet.shape}")
        """
        ...

    @abstractmethod
    def covers(self, lat: float, lon: float) -> bool:
        """Check if source has data at the given location.

        Args:
            lat: Latitude in decimal degrees.
            lon: Longitude in decimal degrees.

        Returns:
            True if source has data covering this point.

        Note:
            This is a lightweight check that does not load data. Use it to
            verify coverage before calling get_covering_sheets().
        """
        ...
