"""The "Pogoda" API (AV-062) -- one entry point the HTTP server calls, so the
page today and the mobile app later ask the same thing the same way:

  GET    /api/places/search?q=Śnieżka           grouped hits (Photon)
  GET    /api/places/point?lat=..&lon=..        a point on the map as a place
  GET    /api/places/forecast?lat=..&lon=..&kind=PEAK|PLACE[&name=..&ele=..
         &group=..&country=..&region=..&horizon=24|72|7|14&extras=1&compare=<level>]
  GET    /api/places/favorites                  the user's favourite places
  POST   /api/places/favorites                  add (the place as JSON)
  DELETE /api/places/favorites/<place_key>      remove

The search and the forecast pass the access switch and the per-client
limits first (mountain_twin.places.access). Elevations: OSM's tag, else the
DEM (mountain_twin.places.elevation) -- at search time only from DEM tiles
already on disk (no download while someone is typing); on opening the
forecast the tile is fetched if needed. Levels are kept per place, so
reopening a peak does not read the DEM again.
"""

from __future__ import annotations

import math
import re
import threading
from typing import Any
from urllib.parse import unquote

from mountain_twin.places.access import PlaceAccess
from mountain_twin.places.elevation import (
    SOURCE_DEM_POINT,
    SOURCE_DEM_SUMMIT,
    SOURCE_OSM,
    forecast_levels,
    place_elevation,
)
from mountain_twin.places.favorites import PlaceFavoriteRepository, place_key
from mountain_twin.places.search import (
    MIN_QUERY_LENGTH,
    PhotonPlaceSearch,
    PlaceSearchUnavailable,
    grouped,
    place_kind,
)
from mountain_twin.terrain.copernicus import glo90_tile_name
from mountain_twin.weather.place_forecast import HORIZON_HOURS, place_forecast_document

LEVELS_CACHE_SIZE = 500
# A height the page hands back from a search hit: where it came from.
KNOWN_SOURCES = (SOURCE_OSM, SOURCE_DEM_POINT, SOURCE_DEM_SUMMIT)


class CachedTilesOnly:
    """A DEM that never downloads: a window whose tiles are not all on disk
    has no elevation (used while searching)."""

    def __init__(self, dem):
        self._dem = dem

    def grid(self, west, south, east, north):
        directory = getattr(self._dem, "cache_directory", None)
        if directory is None:
            raise RuntimeError("no DEM cache")
        for latitude in range(math.floor(south), math.floor(north) + 1):
            for longitude in range(math.floor(west), math.floor(east) + 1):
                if not (directory / f"{glo90_tile_name(latitude, longitude)}.tif").exists():
                    raise RuntimeError("DEM tile not on disk")
        return self._dem.grid(west, south, east, north)


def point_name(latitude: float, longitude: float) -> str:
    def part(value, positive, negative):
        text = f"{abs(value):.3f}".replace(".", ",")
        return f"{text}° {positive if value >= 0 else negative}"

    return f"Punkt {part(latitude, 'N', 'S')} {part(longitude, 'E', 'W')}"


class PlaceWeatherService:
    def __init__(
        self, *, resolver, dem, favorites_database=None, search=None, access=None, osm_tags=None
    ):
        """``resolver``, ``dem``, ``favorites_database`` and ``osm_tags``
        (mountain_twin.places.osm_tags.OsmTagLookup) are factories (the
        server builds them lazily, tests hand in stand-ins); without
        ``osm_tags`` heights come from Photon or the DEM only."""
        self._resolver, self._dem, self._favorites_database = resolver, dem, favorites_database
        self._osm_tags = osm_tags
        self.search = search or PhotonPlaceSearch()
        self.access = access or PlaceAccess()
        self._levels: dict[tuple, tuple] = {}
        self._lock = threading.Lock()

    # --- HTTP -----------------------------------------------------------------
    def handle(
        self,
        method: str,
        path: str,
        query: dict[str, list[str]],
        body: dict[str, Any] | None,
        *,
        owner_id: str,
        client: str,
        signed_in: bool = True,
    ) -> tuple[int, dict[str, Any], dict[str, str]]:
        """(status, JSON document, extra headers)."""
        parts = path.strip("/").split("/")[2:]  # after "api/places"
        try:
            if parts == ["search"] and method == "GET":
                return self._guarded("search", client, signed_in, lambda: self._search(query))
            if parts == ["point"] and method == "GET":
                return self._guarded("search", client, signed_in, lambda: self._point(query))
            if parts == ["forecast"] and method == "GET":
                return self._guarded("forecast", client, signed_in, lambda: self._forecast(query))
            if parts == ["favorites"] and method == "GET":
                return 200, {"favorites": self._favorites().list(owner_id)}, {}
            if parts == ["favorites"] and method == "POST":
                return 201, {"favorite": self._favorites().add(owner_id, body or {})}, {}
            if len(parts) == 2 and parts[0] == "favorites" and method == "DELETE":
                removed = self._favorites().remove(owner_id, unquote(parts[1]))
                return (200 if removed else 404), {"removed": removed}, {}
        except (KeyError, ValueError, TypeError) as error:
            return 400, {"error": "BAD_REQUEST", "message": str(error)}, {}
        return 404, {"error": "NOT_FOUND"}, {}

    def _guarded(self, kind, client, signed_in, action):
        refused = self.access.check(kind, client, signed_in)
        if refused is not None:
            status, code, wait = refused
            headers = {"Retry-After": str(int(math.ceil(wait)))} if wait else {}
            return status, {"error": code}, headers
        return 200, action(), {}

    def _favorites(self) -> PlaceFavoriteRepository:
        if self._favorites_database is None:
            raise ValueError("favourites are not stored on this server")
        return PlaceFavoriteRepository(self._favorites_database())

    # --- the documents ----------------------------------------------------------
    def _search(self, query) -> dict[str, Any]:
        text = (query.get("q") or [""])[0]
        if len(PhotonPlaceSearch.normalized(text)) < MIN_QUERY_LENGTH:
            return {"query": text, "state": "TOO_SHORT", "groups": []}
        # AV-052: Photon's level -- the page waits longer between keystrokes
        # at YELLOW; at STOP nothing is asked.
        from mountain_twin import budgets

        level = budgets.level("photon")
        pace = {"budget_level": level, "debounce_ms": 350 if level == "GREEN" else 900}
        if level == "STOP":
            return {"query": text, "state": "BUDGET_STOP", "groups": [], **pace}
        try:
            hits = self.search.search(text)
        except PlaceSearchUnavailable:
            return {
                "query": text,
                "state": "UNAVAILABLE",
                "reason": "SEARCH_PROVIDER_UNAVAILABLE",
                "groups": [],
            }
        # AV-063: OSM's own height and Nominatim's importance for the peaks,
        # passes and huts -- one lookup for the whole answer, cached for good.
        hits = self._with_osm_tags(hits)
        dem = CachedTilesOnly(self._dem())
        filled = []
        for hit in hits:
            if hit.elevation_m is None and hit.group in ("PEAK", "PASS_HUT"):
                value, source = place_elevation(
                    place_kind(hit.group), hit.latitude, hit.longitude, None, dem
                )
                hit = hit.with_elevation(value, source)
            filled.append(hit)
        return {
            "query": text,
            "state": "AVAILABLE" if filled else "NO_RESULTS",
            "groups": grouped(filled),
        }

    def _with_osm_tags(self, hits):
        wanted = [
            hit.osm_object for hit in hits if hit.group in ("PEAK", "PASS_HUT") and hit.osm_object
        ]
        if not wanted or self._osm_tags is None:
            return hits
        tags = self._osm_tags().lookup(wanted)
        return [
            hit.with_tags(tags[hit.osm_object]["ele"], tags[hit.osm_object]["importance"])
            if hit.osm_object in tags
            else hit
            for hit in hits
        ]

    def _osm_ele(self, query) -> float | None:
        """A place opened by its address (no height in it): the OSM
        object's own height, when the address names the object."""
        key = (query.get("osm") or [None])[0]
        if not key or self._osm_tags is None or not re.fullmatch(r"[NWR]\d{1,12}", key):
            return None
        return self._osm_tags().lookup([key]).get(key, {}).get("ele")

    def _point(self, query) -> dict[str, Any]:
        latitude, longitude = _coordinates(query)
        elevation, source = place_elevation("PLACE", latitude, longitude, None, self._dem())
        return {
            "name": point_name(latitude, longitude),
            "group": "POINT",
            "kind": "PLACE",
            "latitude": latitude,
            "longitude": longitude,
            "elevation_m": elevation,
            "elevation_source": source,
        }

    def _forecast(self, query) -> dict[str, Any]:
        latitude, longitude = _coordinates(query)
        kind = (query.get("kind") or ["PLACE"])[0]
        if kind not in ("PEAK", "PLACE"):
            raise ValueError("kind must be PEAK or PLACE")
        horizon = (query.get("horizon") or ["72"])[0]
        if horizon not in HORIZON_HOURS:
            raise ValueError("horizon must be 24, 72, 7 or 14")
        given = (query.get("ele") or [None])[0]
        osm_ele = float(given) if given not in (None, "") else None
        if osm_ele is None:
            osm_ele = self._osm_ele(query)
        if osm_ele is not None and not -500 <= osm_ele <= 9000:
            raise ValueError("ele out of range")
        source_hint = (query.get("ele_source") or [SOURCE_OSM])[0]
        if source_hint not in KNOWN_SOURCES:
            raise ValueError("ele_source is not a known elevation source")
        place = {
            "name": ((query.get("name") or [None])[0] or point_name(latitude, longitude))[:200],
            "kind": kind,
            "group": (query.get("group") or ["OTHER"])[0][:20],
            "country": (query.get("country") or [None])[0],
            "region": (query.get("region") or [None])[0],
            "latitude": latitude,
            "longitude": longitude,
            "place_key": place_key(latitude, longitude),
            "osm_object": (query.get("osm") or [None])[0],
        }
        levels, reason, elevation, source = self._place_levels(
            kind, latitude, longitude, osm_ele, source_hint
        )
        place.update({"elevation_m": elevation, "elevation_source": source})
        compare = (query.get("compare") or [None])[0]
        if compare is not None and compare not in [level["level_id"] for level in levels]:
            raise ValueError("compare must name one of the levels")
        return place_forecast_document(
            place=place,
            levels=levels,
            single_level_reason=reason,
            live_resolver=self._resolver(),
            horizon=horizon,
            extras=(query.get("extras") or ["0"])[0] == "1",
            compare_level=compare,
        )

    def _place_levels(self, kind, latitude, longitude, osm_ele, source_hint):
        key = (kind, place_key(latitude, longitude), osm_ele)
        with self._lock:
            cached = self._levels.get(key)
        if cached is not None:
            return cached
        dem = self._dem()
        if osm_ele is not None:
            elevation, source = osm_ele, source_hint
        else:
            elevation, source = place_elevation(kind, latitude, longitude, None, dem)
        levels, reason = forecast_levels(kind, latitude, longitude, elevation, source, dem)
        result = ([level.to_dict() for level in levels], reason, elevation, source)
        with self._lock:
            if len(self._levels) >= LEVELS_CACHE_SIZE:
                self._levels.pop(next(iter(self._levels)))
            self._levels[key] = result
        return result


def _coordinates(query) -> tuple[float, float]:
    latitude, longitude = float(query["lat"][0]), float(query["lon"][0])
    if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
        raise ValueError("lat/lon out of range")
    return latitude, longitude
