"""Route-only preparation with optional provider sampling; no hiking-time inference."""

from __future__ import annotations

import math
import threading
from collections import OrderedDict
from dataclasses import dataclass
from typing import Sequence

from mountain_twin.exposure import RoutePoint
from mountain_twin.terrain.provider import TerrainMetadata, TerrainProvider
from mountain_twin.trails.trail_engine import EARTH_RADIUS_M, bearing_deg, haversine_m


@dataclass(frozen=True)
class PreparedPoint:
    route_id: str
    point_index: int
    track_index: int
    segment_index: int
    segment_point_index: int
    latitude: float
    longitude: float
    gpx_elevation_m: float | None
    distance_from_previous_m: float
    cumulative_distance_m: float
    segment_cumulative_distance_m: float
    incoming_bearing_deg: float | None
    dem_elevation_m: float | None
    dem_minus_gpx_m: float | None
    dem_status: str
    terrain_derivatives_status: str = "not_calculated"
    horizon_status: str = "not_calculated"
    terrain_shadow_status: str = "not_calculated"


@dataclass(frozen=True)
class PreparedRoute:
    points: tuple[PreparedPoint, ...]
    provider: TerrainMetadata | None
    gpx_vertical_reference: str | None = None
    elevation_difference_status: str = "vertical_references_not_validated"


def prepare_route(
    points: Sequence[RoutePoint], provider: TerrainProvider | None = None
) -> PreparedRoute:
    """Preserve order; distance/bearing never bridge track or segment boundaries.

    Reuse ingestion's spherical distance and bearing primitives. Antipodal and
    coincident bearings are null. Optional DEM sampling does not imply that slope,
    aspect, horizons or sunlight have been computed.
    """
    result = []
    total = segment_distance = 0.0
    previous = None
    seen_segments = set()
    segment_point_index = 0
    for index, point in enumerate(points):
        if point.point_index != index or (previous and point.route_id != previous.route_id):
            raise ValueError("require one route with ordered contiguous point indices")
        if not (-90 <= point.latitude <= 90 and -180 <= point.longitude <= 180):
            raise ValueError("invalid route coordinates")
        if point.elevation_m is not None and not math.isfinite(point.elevation_m):
            raise ValueError("invalid GPX elevation")
        key = point.track_index, point.segment_index
        new_segment = previous is None or key != (previous.track_index, previous.segment_index)
        distance = 0.0
        bearing = None
        if new_segment:
            if key in seen_segments:
                raise ValueError("segment appears in multiple noncontiguous blocks")
            seen_segments.add(key)
            segment_point_index = 0
            segment_distance = 0.0
        else:
            segment_point_index += 1
            distance = haversine_m(
                previous.latitude, previous.longitude, point.latitude, point.longitude
            )
            if 1e-8 < distance < math.pi * EARTH_RADIUS_M - 1e-6:
                bearing = bearing_deg(
                    previous.latitude, previous.longitude, point.latitude, point.longitude
                )
        total += distance
        segment_distance += distance
        sample = provider.sample(point.latitude, point.longitude) if provider else None
        dem_height = sample.elevation_m if sample else None
        result.append(
            PreparedPoint(
                point.route_id,
                point.point_index,
                point.track_index,
                point.segment_index,
                segment_point_index,
                point.latitude,
                point.longitude,
                point.elevation_m,
                distance,
                total,
                segment_distance,
                bearing,
                dem_height,
                dem_height - point.elevation_m
                if dem_height is not None and point.elevation_m is not None
                else None,
                sample.status if sample else "provider_not_supplied",
            )
        )
        previous = point
    return PreparedRoute(tuple(result), provider.info if provider else None)


# AV-048: one Weather tab opening prepared the same route five times and the
# profile, storms and camps requests beside it did it again. Prepared routes
# (provider-free ones only) are kept for the last few geometries, keyed by
# the route's whole content -- RoutePoint and PreparedRoute are frozen, a
# saved route revision never changes, so a hit is the very same result.
_PREPARED_CACHE_SIZE = 8
_prepared_cache: "OrderedDict[tuple, PreparedRoute]" = OrderedDict()
_prepared_lock = threading.Lock()


def route_fingerprint(points: Sequence[RoutePoint]) -> tuple:
    """Identity of a route's content (every point's place, elevation and
    position in its track/segment): two equal fingerprints are the same route."""
    return (
        len(points),
        hash(
            tuple(
                (
                    point.route_id,
                    point.point_index,
                    point.track_index,
                    point.segment_index,
                    point.latitude,
                    point.longitude,
                    point.elevation_m,
                )
                for point in points
            )
        ),
    )


def prepare_route_cached(points: Sequence[RoutePoint]) -> PreparedRoute:
    """prepare_route(points) without a terrain provider, reused for the same
    route content (see _prepared_cache)."""
    key = route_fingerprint(points)
    with _prepared_lock:
        cached = _prepared_cache.get(key)
        if cached is not None:
            _prepared_cache.move_to_end(key)
            return cached
    prepared = prepare_route(points)
    with _prepared_lock:
        _prepared_cache[key] = prepared
        while len(_prepared_cache) > _PREPARED_CACHE_SIZE:
            _prepared_cache.popitem(last=False)
    return prepared
