"""Persisting a drawn/imported route's geometry as a new RouteRevision for a
durable (SQLite-backed) Journey -- Work Package 1 of
docs/route_persistence_v0_1_design.md.

Deliberately a new, adjacent component rather than an extension of existing
ones: mountain_twin.journey.persistence.JourneySqliteRepository documents
itself as "Journey container metadata only", and
mountain_twin.journey.creator.JourneyCreatorService documents itself as
creating Journeys "without introducing route/plan records" (matching
docs/journey_creator_v0_1_design.md section 8's "Creator never makes
placeholder geometry"). This module is the one place that does create real
route geometry.

Out of scope here (docs/route_persistence_v0_1_design.md section 5):
JourneyPlan creation, AnalysisRun/AnalysisScenario, DEM sampling. This
persists exactly what the caller supplied -- elevation is None where it
was not given, never guessed or sampled in.

journey-tmb-day-01-v0_1 (mountain_twin.journey.repository.
tmb_day_1_repository()) is untouched by this module: it is a parallel,
in-memory-only fixture, never represented in the journey_routes table this
module writes to.
"""

from __future__ import annotations

import hashlib
import json
import math
import uuid
from dataclasses import dataclass
from typing import Any, Sequence

from mountain_twin.exposure import RoutePoint

from .activities import activity_for
from .contracts import RouteRevision
from .persistence import JourneySqliteDatabase, _now, _transaction, _validate_identifier
from .route_segments import movement_segment_indexes, validate_segments
from .route_surface import surface_spans, validate_point_surfaces


@dataclass(frozen=True)
class RoutePointInput:
    """One posted point: real GPS coordinates, elevation only where the
    caller actually knows it (docs/route_persistence_v0_1_design.md section
    3) -- never filled in with a guess."""

    latitude: float
    longitude: float
    elevation_m: float | None = None

    def __post_init__(self) -> None:
        if not math.isfinite(self.latitude) or not -90 <= self.latitude <= 90:
            raise ValueError("route point latitude is invalid")
        if not math.isfinite(self.longitude) or not -180 <= self.longitude <= 180:
            raise ValueError("route point longitude is invalid")
        if self.elevation_m is not None and not math.isfinite(self.elevation_m):
            raise ValueError("route point elevation must be finite when supplied")


@dataclass(frozen=True)
class CreateRouteRevision:
    """Owner-scoped command, mirroring mountain_twin.journey.creator.
    CreateJourney's shape."""

    owner_id: str
    journey_id: str
    route_name: str
    points: tuple[RoutePointInput, ...]
    # The points the user placed (Planning Workspace anchors), as strictly
    # increasing indexes into ``points`` -- stored so "Edytuj trasę" can
    # restore them (docs/journey_edit_delete_v0_1_design.md section 3).
    # None when the caller has no such notion (e.g. an imported route).
    anchor_point_indexes: tuple[int, ...] | None = None
    # AV-031: the activity the route was drawn for (activities.py); None is
    # trekking. Its BRouter profile is stored with the revision.
    activity_id: str | None = None
    # AV-031 (WP2): one entry per point -- the OSM surface of the way leading
    # to it (route_surface.py), None where no BRouter answer was kept.
    point_surfaces: tuple[dict[str, Any] | None, ...] | None = None
    # AV-064: the transfer spans (route_segments.py); empty = all movement.
    segments: tuple[dict[str, Any], ...] = ()

    def __post_init__(self) -> None:
        _validate_identifier(self.owner_id, "owner")
        _validate_identifier(self.journey_id, "journey")
        if not self.route_name or self.route_name != self.route_name.strip():
            raise ValueError("route name must be non-empty and trimmed")
        if len(self.points) < 2:
            raise ValueError("a route requires at least two points")
        if not all(isinstance(point, RoutePointInput) for point in self.points):
            raise ValueError("route points must be RoutePointInput values")
        if self.anchor_point_indexes is not None:
            indexes = self.anchor_point_indexes
            if not all(isinstance(index, int) and not isinstance(index, bool) for index in indexes):
                raise ValueError("anchor point indexes must be integers")
            if not indexes or indexes[0] < 0 or indexes[-1] >= len(self.points):
                raise ValueError("anchor point indexes must lie within the route")
            if any(later <= earlier for earlier, later in zip(indexes, indexes[1:])):
                raise ValueError("anchor point indexes must be strictly increasing")
        activity_for(self.activity_id)  # raises ValueError for an unknown activity
        object.__setattr__(
            self, "segments", validate_segments(list(self.segments or ()), len(self.points))
        )
        if self.point_surfaces is not None:
            object.__setattr__(
                self,
                "point_surfaces",
                validate_point_surfaces(list(self.point_surfaces), len(self.points)),
            )


def geometry_geojson(points: Sequence[RoutePointInput]) -> dict[str, Any]:
    """A GeoJSON LineString (docs/route_persistence_v0_1_design.md section
    3): each position is [lon, lat] where elevation is unknown, or
    [lon, lat, elevation_m] where it is -- the standard optional third
    GeoJSON coordinate, never a fabricated placeholder."""
    coordinates = [
        [point.longitude, point.latitude]
        if point.elevation_m is None
        else [point.longitude, point.latitude, point.elevation_m]
        for point in points
    ]
    return {"type": "LineString", "coordinates": coordinates}


def route_revision_document(route: RouteRevision) -> dict[str, Any]:
    return {
        "route_revision_id": route.route_revision_id,
        "journey_id": route.journey_id,
        "route_id": route.route_id,
        "route_name": route.route_name,
        "geometry_fingerprint": route.geometry_fingerprint,
        "point_count": route.point_count,
        "source_reference": route.source_reference,
        **route_activity_document(route),
    }


def route_activity_document(route: RouteRevision) -> dict[str, Any]:
    """The route's activity (AV-031) as the page needs it: the registry entry
    of the stored id, trekking for a route saved before AV-031."""
    activity = activity_for(route.activity_id)
    return {
        "activity": {
            "activity_id": activity.activity_id,
            "display_name_pl": activity.display_name_pl,
            "family": activity.family,
            "pace_model_available": activity.pace_model_available,
            "stored": route.activity_id is not None,
        }
    }


def new_route_id() -> str:
    return f"route-v0_1:{uuid.uuid4()}"


def new_route_revision_id() -> str:
    return f"route-revision-v0_1:{uuid.uuid4()}"


def _geometry_fingerprint(points: Sequence[RoutePointInput]) -> str:
    canonical = json.dumps(geometry_geojson(points), sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


class JourneyRouteRepository:
    """Owns journey_routes and the journeys.current_route_revision_id
    pointer -- the SQL-persisted counterpart, for durable (owner-scoped,
    SQLite) Journeys, to mountain_twin.journey.repository.
    LocalJourneyRepository's route handling for the TMB fixture. Each
    instance owns its own connection (same per-request-connection
    philosophy as JourneyCatalogService./JourneySqliteRepository)."""

    def __init__(self, database: JourneySqliteDatabase):
        self._connection = database.connect()

    def close(self) -> None:
        self._connection.close()

    def create_route_revision(self, command: CreateRouteRevision) -> RouteRevision:
        # A friendly pre-check for a clear error; the real, enforced
        # guarantee is journey_routes.journey_id's SQL foreign key
        # (docs/route_persistence_v0_1_design.md section 7, item 1).
        if (
            self._connection.execute(
                "SELECT 1 FROM journeys WHERE journey_id = ? AND owner_id = ?",
                (command.journey_id, command.owner_id),
            ).fetchone()
            is None
        ):
            raise KeyError(command.journey_id)
        route_revision_id = new_route_revision_id()
        # docs/journey_edit_delete_v0_1_design.md section 2: a Journey that
        # already has a route gets a new revision of that same route (same
        # route_id, continuing its lineage) -- not a brand-new route_id, the
        # gap the WP1 report flagged. Older revisions stay in the table.
        previous = self._connection.execute(
            """SELECT r.route_id FROM journey_routes r
                 JOIN journeys j ON j.current_route_revision_id = r.route_revision_id
                WHERE j.journey_id = ?""",
            (command.journey_id,),
        ).fetchone()
        route = RouteRevision(
            route_revision_id=route_revision_id,
            journey_id=command.journey_id,
            route_id=previous["route_id"] if previous is not None else new_route_id(),
            route_name=command.route_name,
            geometry_fingerprint=_geometry_fingerprint(command.points),
            point_count=len(command.points),
            source_reference=f"journey_routes:{route_revision_id}",
            activity_id=activity_for(command.activity_id).activity_id,
            routing_profile=activity_for(command.activity_id).routing_profile,
        )
        geometry = json.dumps(geometry_geojson(command.points), sort_keys=True)
        anchors = (
            None
            if command.anchor_point_indexes is None
            else json.dumps(list(command.anchor_point_indexes))
        )
        # Spans only where at least one point has a recorded surface.
        surface = (
            json.dumps(surface_spans(command.point_surfaces))
            if command.point_surfaces is not None and any(command.point_surfaces)
            else None
        )
        created_at = _now()
        with _transaction(self._connection):
            self._connection.execute(
                """INSERT INTO journey_routes(
                       route_revision_id, journey_id, route_id, route_name,
                       geometry_fingerprint, point_count, source_reference,
                       geometry_geojson, anchor_point_indexes_json, created_at,
                       activity_id, routing_profile, surface_json, segments_json
                   ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (
                    route.route_revision_id,
                    route.journey_id,
                    route.route_id,
                    route.route_name,
                    route.geometry_fingerprint,
                    route.point_count,
                    route.source_reference,
                    geometry,
                    anchors,
                    created_at,
                    route.activity_id,
                    route.routing_profile,
                    surface,
                    json.dumps(list(command.segments)) if command.segments else None,
                ),
            )
            cursor = self._connection.execute(
                "UPDATE journeys SET current_route_revision_id = ? WHERE journey_id = ?",
                (route.route_revision_id, command.journey_id),
            )
            if cursor.rowcount != 1:  # pragma: no cover - defensive, guarded by the pre-check above
                raise KeyError(command.journey_id)
        return route

    def get_current_route(
        self, *, journey_id: str, owner_id: str
    ) -> tuple[RouteRevision, dict[str, Any]] | None:
        """The Journey's current route revision and its parsed geometry, or
        None if it has none yet -- never a fabricated empty route."""
        row = self._connection.execute(
            """SELECT r.* FROM journey_routes r
                 JOIN journeys j ON j.current_route_revision_id = r.route_revision_id
                WHERE j.journey_id = ? AND j.owner_id = ?""",
            (journey_id, owner_id),
        ).fetchone()
        if row is None:
            return None
        route = RouteRevision(
            route_revision_id=row["route_revision_id"],
            journey_id=row["journey_id"],
            route_id=row["route_id"],
            route_name=row["route_name"],
            geometry_fingerprint=row["geometry_fingerprint"],
            point_count=row["point_count"],
            source_reference=row["source_reference"],
            activity_id=row["activity_id"],
            routing_profile=row["routing_profile"],
        )
        return route, with_segments(json.loads(row["geometry_geojson"]), row["segments_json"])

    def current_route_starts(self, *, owner_id: str) -> dict[str, tuple[float, float]]:
        """Each of the owner's Journeys with a route: its current route's
        first point as (latitude, longitude) -- read in SQL, the geometry is
        not parsed (the Journey list's hero photo region, AV-027)."""
        rows = self._connection.execute(
            """SELECT j.journey_id,
                      json_extract(r.geometry_geojson, '$.coordinates[0][1]') AS latitude,
                      json_extract(r.geometry_geojson, '$.coordinates[0][0]') AS longitude
                 FROM journeys j
                 JOIN journey_routes r ON r.route_revision_id = j.current_route_revision_id
                WHERE j.owner_id = ?""",
            (owner_id,),
        ).fetchall()
        return {
            row["journey_id"]: (row["latitude"], row["longitude"])
            for row in rows
            if row["latitude"] is not None and row["longitude"] is not None
        }

    def current_route_activities(self, *, owner_id: str) -> dict[str, str | None]:
        """Each of the owner's Journeys with a route: the activity its
        current route was drawn for (None for a route saved before AV-031)."""
        rows = self._connection.execute(
            """SELECT j.journey_id, r.activity_id FROM journeys j
                 JOIN journey_routes r ON r.route_revision_id = j.current_route_revision_id
                WHERE j.owner_id = ?""",
            (owner_id,),
        ).fetchall()
        return {row["journey_id"]: row["activity_id"] for row in rows}

    def get_current_surface_spans(
        self, *, journey_id: str, owner_id: str
    ) -> list[dict[str, Any]] | None:
        """The current revision's surface spans (AV-031), or None when the
        Journey has no route or the revision kept no surface."""
        row = self._connection.execute(
            """SELECT r.surface_json FROM journey_routes r
                 JOIN journeys j ON j.current_route_revision_id = r.route_revision_id
                WHERE j.journey_id = ? AND j.owner_id = ?""",
            (journey_id, owner_id),
        ).fetchone()
        if row is None or row["surface_json"] is None:
            return None
        return json.loads(row["surface_json"])

    def get_current_anchor_point_indexes(
        self, *, journey_id: str, owner_id: str
    ) -> tuple[int, ...] | None:
        """The current revision's stored anchors, or None when the Journey
        has no route or the revision was saved without anchors."""
        row = self._connection.execute(
            """SELECT r.anchor_point_indexes_json FROM journey_routes r
                 JOIN journeys j ON j.current_route_revision_id = r.route_revision_id
                WHERE j.journey_id = ? AND j.owner_id = ?""",
            (journey_id, owner_id),
        ).fetchone()
        if row is None or row["anchor_point_indexes_json"] is None:
            return None
        return tuple(json.loads(row["anchor_point_indexes_json"]))

    def list_route_revisions(self, *, journey_id: str, owner_id: str) -> tuple[RouteRevision, ...]:
        """Every revision of this Journey's route, oldest first -- proves an
        edit adds a revision under the same route_id and keeps the old one."""
        rows = self._connection.execute(
            """SELECT r.* FROM journey_routes r
                 JOIN journeys j ON j.journey_id = r.journey_id
                WHERE r.journey_id = ? AND j.owner_id = ?
                ORDER BY r.created_at, r.rowid""",
            (journey_id, owner_id),
        ).fetchall()
        return tuple(
            RouteRevision(
                route_revision_id=row["route_revision_id"],
                journey_id=row["journey_id"],
                route_id=row["route_id"],
                route_name=row["route_name"],
                geometry_fingerprint=row["geometry_fingerprint"],
                point_count=row["point_count"],
                source_reference=row["source_reference"],
                activity_id=row["activity_id"],
                routing_profile=row["routing_profile"],
            )
            for row in rows
        )


def with_segments(geojson: dict[str, Any], segments_json: str | None) -> dict[str, Any]:
    """The stored LineString with its transfer spans (AV-064) as the GeoJSON
    foreign member ``segments`` -- absent for a route of one movement
    stretch, so its document is exactly as before."""
    if segments_json:
        geojson = {**geojson, "segments": json.loads(segments_json)}
    return geojson


def route_points_from_geometry_geojson(
    route_revision_id: str, geojson: dict[str, Any]
) -> list[RoutePoint]:
    """The inverse of geometry_geojson(): parse a stored LineString back
    into RoutePoint values for mountain_twin.route_analysis.prepare_route.
    Shared by Work Package 2 (plan_persistence.py, for camp-marker
    distances) and Work Package 3 (geometry_bootstrap.py, for Mission
    Control's geometry-only Journey view) so both read the exact same
    geometry the exact same way."""
    points = []
    # AV-064: each transfer step in a segment of its own -- the movement view
    # (route_segments.py); one segment for a route without transfers.
    segment_of = movement_segment_indexes(
        geojson.get("segments") or (), len(geojson["coordinates"])
    )
    for index, coordinate in enumerate(geojson["coordinates"]):
        longitude, latitude = coordinate[0], coordinate[1]
        elevation_m = coordinate[2] if len(coordinate) > 2 else None
        points.append(
            RoutePoint(
                route_id=route_revision_id,
                point_index=index,
                track_index=0,
                segment_index=segment_of[index],
                latitude=latitude,
                longitude=longitude,
                elevation_m=elevation_m,
            )
        )
    return points


def route_points_from_payload(raw_points: Sequence[dict[str, Any]]) -> tuple[RoutePointInput, ...]:
    """Parse the browser's plain point list (section 3) into validated
    RoutePointInput values. Raises ValueError/KeyError/TypeError on
    malformed input -- callers translate those into an HTTP 400."""
    return tuple(
        RoutePointInput(
            latitude=float(item["latitude"]),
            longitude=float(item["longitude"]),
            elevation_m=None if item.get("elevation_m") is None else float(item["elevation_m"]),
        )
        for item in raw_points
    )
