"""Persisting a JourneyPlan (pace profile, planned start, camp markers) for
a durable Journey's current RouteRevision -- Work Package 2 of
docs/route_persistence_v0_1_design.md.

Versioning mirrors mountain_twin.journey.repository.LocalJourneyRepository.
create_plan() exactly: a new pace profile, an added/moved camp marker, or
any other plan edit creates a NEW journey_plans row with a strictly
increasing version. The previous version's row is never mutated or
deleted, and journeys.current_plan_id is moved to point at the newest one
-- append-only, same as every other Journey table.

Camp markers reuse the exact CampMarker contract the existing camps staging
v0.1 feature (mountain_twin/journey/camps_service.py) already uses.
route_distance_m is computed here from the Journey's persisted route
geometry via the same mountain_twin.route_analysis.prepare_route pipeline
CampsService.add_camp uses -- never trusted from the client, exactly like
that existing code path.

The "planned start" input is a naive local wall-clock string plus an IANA
timezone, resolved through the existing, already-tested
mountain_twin.exposure.resolve_local_time (DST-aware) rather than asking
the caller to supply an already-offset instant and re-deriving/validating
that offset here -- see the final report for this choice.

journey_timezone itself is optional (Work Package 3): when omitted, it is
resolved automatically from the route's own first point via
mountain_twin.journey.route_timezone.resolve_route_timezone (offline, no
network dependency) rather than requiring the caller -- Planning
Workspace's browser clock only knows the *viewer's* offset, not the
route's location. An explicit journey_timezone is still honoured when
supplied. See the final report for this choice.

Out of scope (docs/route_persistence_v0_1_design.md section 5):
journey_analysis_runs, DEM sampling, retargeting CampsService/
day_derivation.py at this SQL path. journey-tmb-day-01-v0_1
(mountain_twin.journey.repository.LocalJourneyRepository) is untouched: a
parallel, in-memory-only fixture, never represented in journey_plans.
"""

from __future__ import annotations

import json
import uuid
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from typing import Any, Sequence

from mountain_twin.exposure import resolve_local_time
from mountain_twin.pace.pauses import RELATIVE_PAUSE_POLICY_ID
from mountain_twin.pace.profiles import pace_factor_for_profile
from mountain_twin.route_analysis import prepare_route

from .contracts import CampMarker, JourneyPlan, _is_time_of_day
from .persistence import JourneySqliteDatabase, _now, _transaction, _validate_identifier
from .route_persistence import route_points_from_geometry_geojson
from .route_timezone import resolve_route_timezone


@dataclass(frozen=True)
class CampMarkerInput:
    """A camp marker as posted by the client: a position, a label, and
    (AV-009) the Kalendarz/Noclegi accommodation fields, all passed through
    to CampMarker unchanged -- route_distance_m is the only field never
    accepted from the client, since it is always (re)computed from the
    Journey's real, persisted route geometry."""

    route_point_index: int
    label: str
    accommodation_type: str | None = None
    accommodation_type_other: str | None = None
    cost_amount: float | None = None
    cost_currency: str = "PLN"
    rest_days: int = 0


@dataclass(frozen=True)
class CreateJourneyPlan:
    owner_id: str
    journey_id: str
    pace_profile_id: str
    # None: a plan without a start (migration 004) -- stored as NULL, the
    # route timed relative to its start until a start is set as a new
    # plan version. Never replaced by a default time here.
    planned_start_local: str | None
    # None (the default) means "resolve automatically from the route's own
    # first point" -- see the module docstring and the final report. An
    # explicit value is still honoured when the caller supplies one.
    journey_timezone: str | None = None
    activity_type: str | None = None
    camp_markers: tuple[CampMarkerInput, ...] = ()
    # Rest days at the two ends of the trip (migration 006). None (the
    # default) keeps what the Journey's current plan has -- 0 for a first
    # plan: a plan edit that is about something else (pace, start, route,
    # a camp's accommodation) never has to know about them to not lose them.
    # Rest days at a camp travel with that camp's marker instead.
    rest_days_before_start: int | None = None
    rest_days_after_finish: int | None = None
    # AV-035: a start time without a date ("HH:MM"). Ignored when
    # planned_start_local is given (its own time is the start time).
    planned_start_time: str | None = None

    def __post_init__(self) -> None:
        _validate_identifier(self.owner_id, "owner")
        _validate_identifier(self.journey_id, "journey")
        pace_factor_for_profile(self.pace_profile_id)  # raises ValueError if unknown
        if self.planned_start_local is not None and (
            not isinstance(self.planned_start_local, str) or not self.planned_start_local.strip()
        ):
            raise ValueError("planned_start_local, when supplied, must be a non-empty string")
        if self.planned_start_time is not None and not _is_time_of_day(self.planned_start_time):
            raise ValueError("planned_start_time must be HH:MM")
        if self.journey_timezone is not None and (
            not self.journey_timezone or self.journey_timezone != self.journey_timezone.strip()
        ):
            raise ValueError("journey_timezone, when supplied, must be non-empty and trimmed")
        if not all(isinstance(marker, CampMarkerInput) for marker in self.camp_markers):
            raise ValueError("camp_markers must be CampMarkerInput values")


def new_journey_plan_id() -> str:
    return f"journey-plan-v0_1:{uuid.uuid4()}"


def journey_plan_document(plan: JourneyPlan) -> dict[str, Any]:
    return {
        "journey_plan_id": plan.journey_plan_id,
        "journey_id": plan.journey_id,
        "route_revision_id": plan.route_revision_id,
        "version": plan.version,
        "journey_timezone": plan.journey_timezone,
        "planned_start_local": plan.planned_start_local,
        "planned_start_utc": plan.planned_start_utc,
        "planned_start_time": plan.start_time_of_day(),
        "pace_policy_id": plan.pace_policy_id,
        "pause_policy_id": plan.pause_policy_id,
        "activity_type": plan.activity_type,
        "camp_markers": [marker.to_dict() for marker in plan.camp_markers],
        "rest_days_before_start": plan.rest_days_before_start,
        "rest_days_after_finish": plan.rest_days_after_finish,
    }


def camp_marker_inputs_from_payload(
    raw_markers: Sequence[dict[str, Any]],
) -> tuple[CampMarkerInput, ...]:
    return tuple(
        CampMarkerInput(
            route_point_index=int(item["route_point_index"]),
            label=item["label"],
            accommodation_type=item.get("accommodation_type"),
            accommodation_type_other=item.get("accommodation_type_other"),
            cost_amount=item.get("cost_amount"),
            cost_currency=item.get("cost_currency") or "PLN",
            rest_days=item.get("rest_days") or 0,
        )
        for item in raw_markers
    )


def _plan_from_row(row) -> JourneyPlan:
    camp_markers = tuple(
        CampMarker(
            item["route_point_index"],
            item["route_distance_m"],
            item["label"],
            accommodation_type=item.get("accommodation_type"),
            accommodation_type_other=item.get("accommodation_type_other"),
            cost_amount=item.get("cost_amount"),
            cost_currency=item.get("cost_currency") or "PLN",
            # Absent on every marker saved before rest days existed.
            rest_days=item.get("rest_days", 0),
        )
        for item in json.loads(row["camp_markers_json"])
    )
    return JourneyPlan(
        journey_plan_id=row["journey_plan_id"],
        journey_id=row["journey_id"],
        route_revision_id=row["route_revision_id"],
        version=row["version"],
        journey_timezone=row["journey_timezone"],
        planned_start_local=row["planned_start_local"],
        planned_start_utc=row["planned_start_utc"],
        pace_policy_id=row["pace_policy_id"],
        pause_policy_id=row["pause_policy_id"],
        activity_type=row["activity_type"],
        camp_markers=camp_markers,
        rest_days_before_start=row["rest_days_before_start"],
        rest_days_after_finish=row["rest_days_after_finish"],
        planned_start_time=row["planned_start_time"],
    )


class JourneyPlanRepository:
    """Owns journey_plans and the journeys.current_plan_id pointer -- the
    SQL-persisted counterpart, for durable Journeys, to
    LocalJourneyRepository.create_plan()'s versioning for the TMB fixture.
    Each instance owns its own connection (same per-request-connection
    philosophy as JourneyCatalogService/JourneyRouteRepository)."""

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

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

    def create_plan(self, command: CreateJourneyPlan) -> JourneyPlan:
        journey_row = self._connection.execute(
            "SELECT current_route_revision_id FROM journeys WHERE journey_id = ? AND owner_id = ?",
            (command.journey_id, command.owner_id),
        ).fetchone()
        if journey_row is None:
            raise KeyError(command.journey_id)
        route_revision_id = journey_row["current_route_revision_id"]
        if route_revision_id is None:
            raise ValueError("journey has no route revision to plan against")

        route_row = self._connection.execute(
            "SELECT geometry_geojson FROM journey_routes WHERE route_revision_id = ?",
            (route_revision_id,),
        ).fetchone()
        route_points = route_points_from_geometry_geojson(
            route_revision_id, json.loads(route_row["geometry_geojson"])
        )
        prepared = prepare_route(route_points).points

        camp_markers = []
        for marker_input in command.camp_markers:
            index = marker_input.route_point_index
            if not (0 <= index < len(prepared)):
                raise ValueError("camp marker route point index is outside the route")
            camp_markers.append(
                CampMarker(
                    index,
                    prepared[index].cumulative_distance_m,
                    marker_input.label,
                    accommodation_type=marker_input.accommodation_type,
                    accommodation_type_other=marker_input.accommodation_type_other,
                    cost_amount=marker_input.cost_amount,
                    cost_currency=marker_input.cost_currency,
                    rest_days=marker_input.rest_days,
                )
            )
        sorted_markers = tuple(sorted(camp_markers, key=lambda marker: marker.route_distance_m))

        journey_timezone = command.journey_timezone or resolve_route_timezone(
            route_points[0].latitude, route_points[0].longitude
        )
        planned_start_local = planned_start_utc = None
        if command.planned_start_local is not None:
            local_dt = resolve_local_time(command.planned_start_local, journey_timezone)
            planned_start_local = local_dt.isoformat()
            planned_start_utc = local_dt.astimezone(timezone.utc).isoformat()
        planned_start_time = (
            planned_start_local[11:16]
            if planned_start_local is not None
            else command.planned_start_time
        )

        current = self.get_current_plan(journey_id=command.journey_id, owner_id=command.owner_id)
        rest_days_before_start, rest_days_after_finish = (
            (getattr(current, name) if current is not None else 0) if given is None else given
            for name, given in (
                ("rest_days_before_start", command.rest_days_before_start),
                ("rest_days_after_finish", command.rest_days_after_finish),
            )
        )
        plan = JourneyPlan(
            journey_plan_id=new_journey_plan_id(),
            journey_id=command.journey_id,
            route_revision_id=route_revision_id,
            version=self._next_version(command.journey_id),
            journey_timezone=journey_timezone,
            planned_start_local=planned_start_local,
            planned_start_utc=planned_start_utc,
            pace_policy_id=command.pace_profile_id,
            pause_policy_id=RELATIVE_PAUSE_POLICY_ID,
            activity_type=command.activity_type,
            camp_markers=sorted_markers,
            rest_days_before_start=rest_days_before_start,
            rest_days_after_finish=rest_days_after_finish,
            planned_start_time=planned_start_time,
        )
        self._insert_as_current(plan)
        return plan

    def set_rest_days(
        self, *, journey_id: str, owner_id: str, after_day_number: int, days: int
    ) -> JourneyPlan:
        """The Journey's current plan, as a NEW plan version whose only
        difference is how many rest days follow walking day
        ``after_day_number`` (0: before day 1). ``days`` is the resulting
        number, not an increment, so a repeated request changes nothing more.

        The server copies the plan itself: a client never has to rebuild a
        whole plan (and risk dropping a field) to change one number. Where
        the number is stored follows from where the day ends -- at a camp it
        is that camp's ``rest_days``; before day 1, and after a last day that
        does not end at a camp, the plan's own two fields."""
        current = self.get_current_plan(journey_id=journey_id, owner_id=owner_id)
        if current is None:
            if (
                self._connection.execute(
                    "SELECT 1 FROM journeys WHERE journey_id = ? AND owner_id = ?",
                    (journey_id, owner_id),
                ).fetchone()
                is None
            ):
                raise KeyError(journey_id)
            raise ValueError("journey has no plan to add rest days to")
        if isinstance(after_day_number, bool) or not isinstance(after_day_number, int):
            raise ValueError("after_day_number must be a whole number")
        markers = current.camp_markers
        route_row = self._connection.execute(
            "SELECT geometry_geojson FROM journey_routes WHERE route_revision_id = ?",
            (current.route_revision_id,),
        ).fetchone()
        last_point_index = len(json.loads(route_row["geometry_geojson"])["coordinates"]) - 1
        ends_at_camp = bool(markers) and markers[-1].route_point_index == last_point_index
        day_count = len(markers) if ends_at_camp else len(markers) + 1
        if not 0 <= after_day_number <= day_count:
            raise ValueError(f"after_day_number must be from 0 to {day_count}")
        if after_day_number == 0:
            changed = replace(current, rest_days_before_start=days)
        elif after_day_number <= len(markers):
            position = after_day_number - 1
            changed = replace(
                current,
                camp_markers=tuple(
                    replace(marker, rest_days=days) if index == position else marker
                    for index, marker in enumerate(markers)
                ),
            )
        else:
            changed = replace(current, rest_days_after_finish=days)
        plan = replace(
            changed,
            journey_plan_id=new_journey_plan_id(),
            version=self._next_version(journey_id),
        )
        self._insert_as_current(plan)
        return plan

    def set_day_one(self, *, journey_id: str, owner_id: str, date: str) -> JourneyPlan:
        """AV-038: Day 1 of the trip falls on ``date`` (YYYY-MM-DD) -- the
        Journey's date. A NEW plan version copied from the current one; only
        its start moves: same time of day (08:00 when the plan has none),
        the plan's own timezone. Rest days before day 1 stay before it, so
        the start is ``date`` minus those days and Day 1 lands on ``date``;
        every later day follows from the plan (day_derivation)."""
        from datetime import date as calendar_date
        from datetime import time as clock
        from datetime import timedelta
        from zoneinfo import ZoneInfo

        current = self.get_current_plan(journey_id=journey_id, owner_id=owner_id)
        if current is None:
            if (
                self._connection.execute(
                    "SELECT 1 FROM journeys WHERE journey_id = ? AND owner_id = ?",
                    (journey_id, owner_id),
                ).fetchone()
                is None
            ):
                raise KeyError(journey_id)
            raise ValueError("journey has no plan to date")
        try:
            day_one = calendar_date.fromisoformat(date)
        except (TypeError, ValueError):
            raise ValueError("date must be YYYY-MM-DD") from None
        hours, minutes = (int(part) for part in (current.start_time_of_day() or "08:00").split(":"))
        start = datetime.combine(
            day_one - timedelta(days=current.rest_days_before_start),
            clock(hours, minutes),
            tzinfo=ZoneInfo(current.journey_timezone),
        )
        plan = replace(
            current,
            journey_plan_id=new_journey_plan_id(),
            version=self._next_version(journey_id),
            planned_start_local=start.isoformat(),
            planned_start_utc=start.astimezone(timezone.utc).isoformat(),
            planned_start_time=f"{hours:02d}:{minutes:02d}",
        )
        self._insert_as_current(plan)
        return plan

    def _next_version(self, journey_id: str) -> int:
        version_row = self._connection.execute(
            "SELECT COALESCE(MAX(version), 0) AS max_version FROM journey_plans WHERE journey_id = ?",
            (journey_id,),
        ).fetchone()
        return version_row["max_version"] + 1

    def _insert_as_current(self, plan: JourneyPlan) -> None:
        created_at = _now()
        camp_markers_json = json.dumps([marker.to_dict() for marker in plan.camp_markers])
        with _transaction(self._connection):
            self._connection.execute(
                """INSERT INTO journey_plans(
                       journey_plan_id, journey_id, route_revision_id, version,
                       journey_timezone, planned_start_local, planned_start_utc,
                       pace_policy_id, pause_policy_id, activity_type,
                       camp_markers_json, created_at,
                       rest_days_before_start, rest_days_after_finish, planned_start_time
                   ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (
                    plan.journey_plan_id,
                    plan.journey_id,
                    plan.route_revision_id,
                    plan.version,
                    plan.journey_timezone,
                    plan.planned_start_local,
                    plan.planned_start_utc,
                    plan.pace_policy_id,
                    plan.pause_policy_id,
                    plan.activity_type,
                    camp_markers_json,
                    created_at,
                    plan.rest_days_before_start,
                    plan.rest_days_after_finish,
                    plan.planned_start_time,
                ),
            )
            cursor = self._connection.execute(
                "UPDATE journeys SET current_plan_id = ? WHERE journey_id = ?",
                (plan.journey_plan_id, plan.journey_id),
            )
            if cursor.rowcount != 1:  # pragma: no cover - defensive, guarded by the pre-check above
                raise KeyError(plan.journey_id)

    def get_current_plan(self, *, journey_id: str, owner_id: str) -> JourneyPlan | None:
        """The Journey's current plan version, or None if it has none yet --
        never a fabricated plan."""
        row = self._connection.execute(
            """SELECT p.* FROM journey_plans p
                 JOIN journeys j ON j.current_plan_id = p.journey_plan_id
                WHERE j.journey_id = ? AND j.owner_id = ?""",
            (journey_id, owner_id),
        ).fetchone()
        return None if row is None else _plan_from_row(row)

    def list_plan_versions(self, *, journey_id: str, owner_id: str) -> tuple[JourneyPlan, ...]:
        """Every plan version for this Journey, oldest first -- proves old
        versions survive new writes (docs/route_persistence_v0_1_design.md
        section 7 acceptance criteria)."""
        if (
            self._connection.execute(
                "SELECT 1 FROM journeys WHERE journey_id = ? AND owner_id = ?",
                (journey_id, owner_id),
            ).fetchone()
            is None
        ):
            raise KeyError(journey_id)
        rows = self._connection.execute(
            "SELECT * FROM journey_plans WHERE journey_id = ? ORDER BY version", (journey_id,)
        ).fetchall()
        return tuple(_plan_from_row(row) for row in rows)
