"""Camps/day-staging Phase 1 service: ties day_derivation (item 6) and the
live weather time-window reduction (items 3+4) to one Journey's current
plan. Parallel to JourneyReadService -- does not modify it, and never
touches mountain_twin/journey/stages.py or the TMB fixture's static day
list. See docs/camps_staging_v0_1_design.md section 5.

Night-conditions window is a fixed heuristic (arrival time + 12 hours), not
a sunset/sunrise calculation -- mountain_twin.solar exists as its own real
domain but is deliberately not invoked here for Phase 1 (see
docs/camps_staging_v0_1_design.md section 2, "no invented seasonal/
day-length correction to pace").

Route persistence v0.1 Work Package 4 (docs/route_persistence_v0_1_design.md)
adds durable_camps_document(), the SQL-backed counterpart of
CampsService.get_camps_document() for a non-reference Journey. Both share
camps_document() -- everything downstream of "we already have RoutePoint
values" (day derivation, night conditions) is identical for TMB and for a
durable Journey; only how those RoutePoint values are obtained differs.
CampsService._route_geometry() keeps reading the TMB fixture's
FeatureCollection-wrapped geojson file, untouched; durable_camps_document()
reads route_persistence.py's bare-LineString SQL geometry with the reader
that already exists for it (route_points_from_geometry_geojson) rather than
changing either format to match the other -- see the final report for why
this was chosen over normalizing at write time in route_persistence.py.
"""

from __future__ import annotations

import json
from dataclasses import replace
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Sequence
from zoneinfo import ZoneInfo

from mountain_twin.exposure import RoutePoint
from mountain_twin.journey.activities import pace_factor_for_activity
from mountain_twin.journey.bike_speed import estimate_days
from mountain_twin.journey.contracts import CampMarker, JourneyPlan, RouteRevision
from mountain_twin.journey.day_derivation import (
    NO_PLANNED_START,
    DayDerivationUnavailable,
    derive_day_segments,
    derive_relative_day_segments,
    derive_untimed_day_segments,
    relative_day_document,
    untimed_day_dates,
    untimed_day_document,
)
from mountain_twin.journey.repository import LocalJourneyRepository
from mountain_twin.journey.route_persistence import route_points_from_geometry_geojson
from mountain_twin.pace.pauses import generate_relative_pauses
from mountain_twin.route_analysis import prepare_route
from mountain_twin.weather.live import (
    FREEZING_LEVEL_NOT_PROVIDED_FOR_REGION,
    FREEZING_LEVEL_VARIABLE,
    LiveWeatherPointResolver,
    ProviderRateLimited,
)
from mountain_twin.weather.provider import OpenMeteoProvider
from mountain_twin.weather.temporal import temporal_window_value

DEFAULT_PACE_PROFILE_ID = "normal_hiker"
NIGHT_WINDOW_HOURS = 12
# The provider's forecast reaches this many days from today (the same figure
# as telemetry.FORECAST_HORIZON_DAYS, kept here to not import that module's
# whole cockpit): a multi-night stop asks for its nights up to this day only,
# so nights past it are this night's missing data, not a refused request that
# would take the nights before it down too.
FORECAST_HORIZON_DAYS = 16


class CampsService:
    def __init__(
        self,
        root: Path,
        repository: LocalJourneyRepository,
        *,
        live_resolver: LiveWeatherPointResolver | None = None,
    ) -> None:
        self.root = root
        self.repository = repository
        self._live_resolver = live_resolver or LiveWeatherPointResolver(
            OpenMeteoProvider(root / "data/cache/weather/open_meteo")
        )

    def get_camps_document(
        self, journey_id: str, *, pace_profile_id: str = DEFAULT_PACE_PROFILE_ID
    ) -> dict[str, Any]:
        journey = self.repository.get_journey(journey_id)
        plan = self.repository.get_plan(journey.current_plan_id)
        route = self.repository.get_route_revision(plan.route_revision_id)
        route_points = self._route_geometry(route.route_id)
        return camps_document(
            journey_id=journey_id,
            route_points=route_points,
            plan=plan,
            pace_profile_id=pace_profile_id,
            live_resolver=self._live_resolver,
        )

    def add_camp(
        self,
        journey_id: str,
        *,
        route_point_index: int,
        label: str,
        pace_profile_id: str = DEFAULT_PACE_PROFILE_ID,
    ) -> dict[str, Any]:
        journey = self.repository.get_journey(journey_id)
        plan = self.repository.get_plan(journey.current_plan_id)
        route = self.repository.get_route_revision(plan.route_revision_id)
        route_points = self._route_geometry(route.route_id)
        if not (0 <= route_point_index < len(route_points)):
            raise ValueError("camp route point index is outside the route")
        if any(marker.route_point_index == route_point_index for marker in plan.camp_markers):
            raise ValueError("a camp is already marked at this route point")
        prepared = prepare_route(route_points).points
        route_distance_m = prepared[route_point_index].cumulative_distance_m
        new_marker = CampMarker(route_point_index, route_distance_m, label)
        updated_markers = tuple(
            sorted((*plan.camp_markers, new_marker), key=lambda marker: marker.route_distance_m)
        )
        new_plan = replace(
            plan,
            journey_plan_id=f"{plan.journey_plan_id}-camps-v{plan.version + 1}",
            version=plan.version + 1,
            camp_markers=updated_markers,
        )
        self.repository.create_plan(new_plan, make_current=True)
        return self.get_camps_document(journey_id, pace_profile_id=pace_profile_id)

    def _route_geometry(self, route_id: str) -> list[RoutePoint]:
        geometry_path = self.root / "data/geojson" / f"{route_id}.geojson"
        document = json.loads(geometry_path.read_text(encoding="utf-8"))
        coordinates = document["features"][0]["geometry"]["coordinates"]
        return [
            RoutePoint(
                route_id=route_id,
                point_index=index,
                track_index=0,
                segment_index=0,
                latitude=lat,
                longitude=lon,
                elevation_m=elevation,
            )
            for index, (lon, lat, elevation) in enumerate(coordinates)
        ]


def camps_document(
    *,
    journey_id: str,
    route_points: Sequence[RoutePoint],
    plan: JourneyPlan,
    pace_profile_id: str,
    live_resolver: LiveWeatherPointResolver,
    activity_id: str | None = None,
) -> dict[str, Any]:
    """The day-segments/night-conditions document for one Journey's plan,
    from its already-resolved route_points. Shared by CampsService.
    get_camps_document() (TMB, reading a FeatureCollection file) and
    durable_camps_document() (route persistence v0.1 Work Package 4,
    reading route_persistence.py's bare-LineString SQL geometry) -- once
    route_points exist, day derivation and night conditions are computed
    identically for both; only how those route_points were obtained
    differs, resolved by each caller rather than by format-sniffing here."""
    prepared = prepare_route(route_points).points
    # AV-031: a route drawn for an activity without a pace model (bikes) is
    # untimed below -- its camps stay, days and arrivals do not exist.
    try:
        pace_factor = pace_factor_for_activity(activity_id, pace_profile_id)
    except DayDerivationUnavailable as unavailable:
        pace_factor, no_pace_model = None, unavailable
    pauses = generate_relative_pauses(prepared[-1].cumulative_distance_m)
    sorted_markers = tuple(sorted(plan.camp_markers, key=lambda marker: marker.route_point_index))
    last_point_index = prepared[-1].point_index

    def untimed(reason_codes, unavailable_elevation_points, elevation_gaps=()):
        # The route cannot be timed (points without elevation, or a plan
        # without a start): the planned camps are still real, their days/
        # arrivals/nights are not computable -- an explicit state instead of
        # failing the whole Journey, never a guessed time.
        return {
            "journey_id": journey_id,
            "pace_profile_id": pace_profile_id,
            "state": "UNAVAILABLE",
            "reason_codes": list(reason_codes),
            "unavailable_elevation_points": unavailable_elevation_points,
            "elevation_gaps": list(elevation_gaps),
            "day_segments": [],
            "camps": [
                {
                    "route_point_index": marker.route_point_index,
                    "route_distance_m": marker.route_distance_m,
                    "label": marker.label,
                    "arrival_time": None,
                    "checkout_time": None,
                    "night_conditions": None,
                    "accommodation_type": marker.accommodation_type,
                    "accommodation_type_other": marker.accommodation_type_other,
                    "cost_amount": marker.cost_amount,
                    "cost_currency": marker.cost_currency,
                    "rest_days": marker.rest_days,
                    "nights": None,
                }
                for marker in sorted_markers
            ],
            "day_boundaries": day_boundaries_document(plan, sorted_markers, last_point_index),
        }

    if pace_factor is None:
        return _days_without_pace(
            journey_id,
            route_points,
            plan,
            pace_profile_id,
            sorted_markers,
            last_point_index,
            no_pace_model.reason_codes,
            activity_id=activity_id,
        )
    if plan.planned_start_local is None:
        # A plan without a start (step T3, docs/design_reference/
        # weather_start_models_spike_v0_1.md): its days are real -- where
        # they run, how far, how long -- but not their dates or clock times,
        # and a camp's night needs a date: NOT_EVALUATED, never a guess.
        try:
            relative = derive_relative_day_segments(
                route_points, pace_factor=pace_factor, pauses=pauses, camp_markers=plan.camp_markers
            )
        except DayDerivationUnavailable as unavailable:
            return untimed(
                unavailable.reason_codes,
                unavailable.unavailable_elevation_points,
                unavailable.gaps_document(),
            )
        return {
            "journey_id": journey_id,
            "pace_profile_id": pace_profile_id,
            "state": "AVAILABLE",
            "timing": "RELATIVE",
            "timing_reason": NO_PLANNED_START,
            "day_segments": [relative_day_document(segment) for segment in relative],
            "camps": [
                {
                    "route_point_index": marker.route_point_index,
                    "route_distance_m": marker.route_distance_m,
                    "label": marker.label,
                    "arrival_time": None,
                    "checkout_time": None,
                    "night_conditions": {"state": "NOT_EVALUATED", "reason": NO_PLANNED_START},
                    "accommodation_type": marker.accommodation_type,
                    "accommodation_type_other": marker.accommodation_type_other,
                    "cost_amount": marker.cost_amount,
                    "cost_currency": marker.cost_currency,
                    "rest_days": marker.rest_days,
                    "nights": None,
                }
                for marker in sorted_markers
            ],
            # The numbers of rest days are real without a start; their dates
            # are not (rest_dates null) -- never a guessed calendar.
            "day_boundaries": day_boundaries_document(plan, sorted_markers, last_point_index),
        }
    try:
        segments = derive_day_segments(
            route_points,
            pace_factor=pace_factor,
            pauses=pauses,
            start_datetime=datetime.fromisoformat(plan.planned_start_local),
            camp_markers=plan.camp_markers,
            timezone_name=plan.journey_timezone,
            rest_days_before_start=plan.rest_days_before_start,
        )
    except DayDerivationUnavailable as unavailable:
        return untimed(
            unavailable.reason_codes,
            unavailable.unavailable_elevation_points,
            unavailable.gaps_document(),
        )
    camps_out = []
    for day_index, (marker, segment) in enumerate(zip(sorted_markers, segments)):
        point = route_points[marker.route_point_index]
        # One night per day spent here: the arrival night plus one for every
        # rest day, all from ONE provider request for the whole stop.
        nights = stay_night_conditions(
            live_resolver,
            latitude=point.latitude,
            longitude=point.longitude,
            elevation_m=point.elevation_m,
            arrival_time=segment.arrival_time,
            timezone_name=plan.journey_timezone,
            rest_days=marker.rest_days,
        )
        night_conditions = nights[0]
        # The Kalendarz tab (AV-009) draws a camp as one bar from this
        # arrival to the following day's departure -- the same overnight
        # span day_derivation already computes, never a separate estimate.
        next_day = segments[day_index + 1] if day_index + 1 < len(segments) else None
        camps_out.append(
            {
                "route_point_index": marker.route_point_index,
                "route_distance_m": marker.route_distance_m,
                "label": marker.label,
                "arrival_time": segment.arrival_time.isoformat(),
                "checkout_time": next_day.departure_time.isoformat() if next_day else None,
                "night_conditions": night_conditions,
                "accommodation_type": marker.accommodation_type,
                "accommodation_type_other": marker.accommodation_type_other,
                "cost_amount": marker.cost_amount,
                "cost_currency": marker.cost_currency,
                "rest_days": marker.rest_days,
                "nights": [
                    {"night_number": number, **night}
                    for number, night in enumerate(nights, start=1)
                ],
            }
        )
    return {
        "journey_id": journey_id,
        "pace_profile_id": pace_profile_id,
        "state": "COMPLETE",
        "day_segments": [segment.to_dict() for segment in segments],
        "camps": camps_out,
        "day_boundaries": day_boundaries_document(plan, sorted_markers, last_point_index, segments),
    }


def day_boundaries_document(
    plan: JourneyPlan,
    sorted_markers: Sequence[CampMarker],
    last_point_index: int,
    segments: Sequence[Any] | None = None,
    day_dates: Sequence[date] | None = None,
) -> list[dict[str, Any]]:
    """Every place a rest day can go, in trip order, with how many are there
    now: before day 1 (``after_day_number`` 0), after each walking day, after
    the last one. The client draws its "add a rest day" controls from this
    list and its rest-day cells from ``rest_dates`` -- it computes no dates.

    ``rest_dates`` (local calendar dates of the journey's timezone) exist
    only for a timed plan (``segments`` given): the plan's start date onwards
    before day 1; otherwise the dates right after that day's arrival. Without
    them the numbers are still real, the dates null."""
    zone = ZoneInfo(plan.journey_timezone)

    def dates(first: date | None, count: int) -> list[str] | None:
        if first is None:
            return None
        return [(first + timedelta(days=offset)).isoformat() for offset in range(count)]

    def first_date_after(day_number: int) -> date | None:
        # AV-032: days without a pace end on their own calendar date.
        if day_dates is not None:
            return day_dates[day_number - 1] + timedelta(days=1)
        if segments is None:
            return None
        return segments[day_number - 1].arrival_time.astimezone(zone).date() + timedelta(days=1)

    trip_start = (
        datetime.fromisoformat(plan.planned_start_local).astimezone(zone).date()
        if segments is not None or day_dates is not None
        else None
    )
    boundaries = [
        {
            "after_day_number": 0,
            "rest_days": plan.rest_days_before_start,
            "rest_dates": dates(trip_start, plan.rest_days_before_start),
            "place_label": None,
        }
    ]
    for day_number, marker in enumerate(sorted_markers, start=1):
        boundaries.append(
            {
                "after_day_number": day_number,
                "rest_days": marker.rest_days,
                "rest_dates": dates(first_date_after(day_number), marker.rest_days),
                "place_label": marker.label,
            }
        )
    if not sorted_markers or sorted_markers[-1].route_point_index != last_point_index:
        day_number = len(sorted_markers) + 1
        boundaries.append(
            {
                "after_day_number": day_number,
                "rest_days": plan.rest_days_after_finish,
                "rest_dates": dates(first_date_after(day_number), plan.rest_days_after_finish),
                "place_label": None,
            }
        )
    return boundaries


def _days_without_pace(
    journey_id,
    route_points,
    plan,
    pace_profile_id,
    sorted_markers,
    last_point_index,
    reason_codes,
    activity_id=None,
) -> dict[str, Any]:
    """AV-032: a route with camps but no pace model (a bike ride): its days,
    their distances and -- with a start -- their dates are real (the user
    put the camps; dates are calendar arithmetic), every clock time, arrival
    and night is NOT_EVALUATED. Rest days work as for a walking trip."""
    days = derive_untimed_day_segments(route_points, camp_markers=plan.camp_markers)
    zone = ZoneInfo(plan.journey_timezone)
    start = (
        None
        if plan.planned_start_local is None
        else datetime.fromisoformat(plan.planned_start_local).astimezone(zone).date()
    )
    rest_after = [marker.rest_days for marker in sorted_markers]
    dates = untimed_day_dates(start, plan.rest_days_before_start, rest_after, len(days))
    not_evaluated = {"state": "NOT_EVALUATED", "reason": reason_codes[0]}
    # O1: a riding-time estimate per day (bike_speed.py) -- a "szacunek",
    # beside the days, never their departure/arrival times.
    estimates = estimate_days(
        route_points, activity_id, [(day.start_point_index, day.end_point_index) for day in days]
    )
    segments = [untimed_day_document(day, date) for day, date in zip(days, dates)]
    if estimates is not None:
        for segment, estimate in zip(segments, estimates):
            segment["riding_estimate"] = estimate
    return {
        "journey_id": journey_id,
        "pace_profile_id": pace_profile_id,
        "state": "AVAILABLE",
        "timing": "UNTIMED",
        "timing_reason": reason_codes[0],
        "reason_codes": list(reason_codes),
        "unavailable_elevation_points": 0,
        "day_segments": segments,
        "camps": [
            {
                "route_point_index": marker.route_point_index,
                "route_distance_m": marker.route_distance_m,
                "label": marker.label,
                "arrival_time": None,
                "checkout_time": None,
                # The night of the day the camp closes, by the calendar.
                "arrival_date": None if dates[index] is None else dates[index].isoformat(),
                "night_conditions": not_evaluated,
                "accommodation_type": marker.accommodation_type,
                "accommodation_type_other": marker.accommodation_type_other,
                "cost_amount": marker.cost_amount,
                "cost_currency": marker.cost_currency,
                "rest_days": marker.rest_days,
                "nights": None,
            }
            for index, marker in enumerate(sorted_markers)
        ],
        "day_boundaries": day_boundaries_document(
            plan, sorted_markers, last_point_index, day_dates=dates if start is not None else None
        ),
    }


def durable_camps_document(
    *,
    journey_id: str,
    route: RouteRevision,
    geometry_geojson: dict[str, Any],
    plan: JourneyPlan,
    pace_profile_id: str,
    live_resolver: LiveWeatherPointResolver,
) -> dict[str, Any]:
    """Route persistence v0.1 Work Package 4: the SQL-backed counterpart to
    CampsService.get_camps_document() for a durable (non-reference)
    Journey. WP1 stores geometry as a bare GeoJSON LineString, not the
    FeatureCollection CampsService._route_geometry() reads from a file --
    rather than reconciling the two file formats, this reads the SQL
    geometry with the reader that already exists for it
    (route_persistence.route_points_from_geometry_geojson, also used by
    plan_persistence.py and geometry_bootstrap.py). Everything from there
    on is the identical camps_document() computation TMB uses."""
    route_points = route_points_from_geometry_geojson(route.route_revision_id, geometry_geojson)
    return camps_document(
        journey_id=journey_id,
        route_points=route_points,
        plan=plan,
        pace_profile_id=pace_profile_id,
        live_resolver=live_resolver,
        activity_id=route.activity_id,
    )


def night_conditions_for_point(
    live_resolver: LiveWeatherPointResolver,
    *,
    latitude: float,
    longitude: float,
    elevation_m: float | None,
    arrival_time: datetime,
    timezone_name: str,
) -> dict[str, Any]:
    """Freezing level / minimum temperature / maximum precipitation probability for
    the NIGHT_WINDOW_HOURS after ``arrival_time`` at one point. Shared by
    CampsService (a saved Journey's camps) and the stateless Planning
    Workspace preview (an unsaved, drawn route's camps) so both go through
    the exact same real reduction, never two diverging implementations.
    """
    return stay_night_conditions(
        live_resolver,
        latitude=latitude,
        longitude=longitude,
        elevation_m=elevation_m,
        arrival_time=arrival_time,
        timezone_name=timezone_name,
    )[0]


def stay_night_conditions(
    live_resolver: LiveWeatherPointResolver,
    *,
    latitude: float,
    longitude: float,
    elevation_m: float | None,
    arrival_time: datetime,
    timezone_name: str,
    rest_days: int = 0,
    today: date | None = None,
) -> list[dict[str, Any]]:
    """The conditions of every night of one stop: the arrival night, then one
    more for each rest day -- each the same NIGHT_WINDOW_HOURS window, on the
    following dates (``rest_days`` 0: exactly the one night there always was,
    asked for exactly as before).

    All nights come from ONE provider request for the whole stop (its point
    does not change, only the dates), so a rest day adds no request -- measured
    on a real 9-camp plan with 3 rest days: 9 requests either way, against 12
    with a request per night. A stop reaching past the forecast horizon asks
    up to the horizon only; the nights beyond are UNAVAILABLE on their own."""
    windows = [
        (start, start + timedelta(hours=NIGHT_WINDOW_HOURS))
        for start in (arrival_time + timedelta(days=offset) for offset in range(rest_days + 1))
    ]
    start_date, end_date = windows[0][0].date(), windows[-1][1].date()
    if rest_days:
        horizon_end = (
            today or datetime.now(timezone.utc).astimezone(arrival_time.tzinfo).date()
        ) + timedelta(days=FORECAST_HORIZON_DAYS - 1)
        if start_date <= horizon_end < end_date:
            end_date = horizon_end
    failure = None
    try:
        sample = live_resolver.resolve(
            latitude=latitude,
            longitude=longitude,
            elevation_m=elevation_m,
            start_date=start_date,
            end_date=end_date,
            timezone_name=timezone_name,
        )
    except ProviderRateLimited as limited:
        # A spent quota is this night's missing data, not a failed request
        # for the whole Journey (which answered 500 before).
        sample, failure = None, limited.reason_code
    except RuntimeError:
        sample, failure = None, "PROVIDER_NETWORK_FAILURE"
    return [
        _night_conditions(sample, failure, window_start, window_end)
        for window_start, window_end in windows
    ]


def _night_conditions(sample, failure, window_start: datetime, window_end: datetime):
    base = {"window_start": window_start.isoformat(), "window_end": window_end.isoformat()}
    if sample is None:
        return {
            **base,
            "state": "UNAVAILABLE",
            "unavailable_reason": failure or "PROVIDER_NO_DATA",
            "freezing_level_m": None,
            "min_temperature_c": None,
            "max_precipitation_probability_pct": None,
            **_freezing_level_provenance(None, None),
        }
    freezing = temporal_window_value(
        sample, window_start, window_end, "freezing_level_height", "MIN"
    )
    temperature = temporal_window_value(sample, window_start, window_end, "temperature_2m", "MIN")
    # precipitation_probability is the chance of any precipitation, not of a
    # thunderstorm: Open-Meteo has no usable storm-probability field
    # (docs/design_reference/live_route_weather_spike_v0_1.md section 4.2).
    precipitation = temporal_window_value(
        sample, window_start, window_end, "precipitation_probability", "MAX"
    )
    resolved = (freezing, temperature, precipitation)
    if all(item.state == "COMPLETE" for item in resolved):
        state = "AVAILABLE"
    elif any(item.state in ("COMPLETE", "PARTIAL") for item in resolved):
        state = "PARTIAL"
    else:
        state = "UNAVAILABLE"
    return {
        **base,
        "state": state,
        "freezing_level_m": freezing.value,
        "min_temperature_c": temperature.value,
        "max_precipitation_probability_pct": precipitation.value,
        **_freezing_level_provenance(sample, freezing),
    }


FREEZING_LEVEL_REGION_EXPLANATION = "Freezing level niedostępny dla tego regionu"


def _freezing_level_provenance(sample, freezing) -> dict[str, Any]:
    """Which model the freezing level came from, and -- when there is no
    value because no model covers the region -- an explicit UNAVAILABLE with
    its explanation (mountain_twin/weather/live.py FREEZING_LEVEL_*)."""
    if sample is None:
        return {
            "freezing_level_state": "UNAVAILABLE",
            "freezing_level_model": None,
            "freezing_level_unavailable_reason": None,
            "freezing_level_explanation": None,
        }
    value = freezing.value
    reason = sample.base.missing_variable_reasons.get(FREEZING_LEVEL_VARIABLE)
    region_gap = value is None and reason == FREEZING_LEVEL_NOT_PROVIDED_FOR_REGION
    return {
        "freezing_level_state": {"COMPLETE": "AVAILABLE", "PARTIAL": "PARTIAL"}.get(
            freezing.state, "UNAVAILABLE"
        ),
        "freezing_level_model": (
            sample.variable_models.get(FREEZING_LEVEL_VARIABLE, "best_match")
            if value is not None
            else None
        ),
        "freezing_level_unavailable_reason": reason if value is None else None,
        "freezing_level_explanation": FREEZING_LEVEL_REGION_EXPLANATION if region_gap else None,
    }
