"""Place search through Photon (komoot), grouped for reading (AV-062).

The page searched Photon from the browser (Planning Workspace, AV-042);
"Pogoda" asks this server instead, so the mobile app gets the same answers
from the same API and the elevation can come from the server's DEM. Photon
is a shared public service used under its fair-use terms: every answer is
kept (SEARCH_TTL_SECONDS), requests leave at most one per
MIN_INTERVAL_SECONDS whoever asks, a query shorter than MIN_QUERY_LENGTH is
not sent, and each request names the application (User-Agent). The page
waits for a pause in typing before it asks.

Grouping is by the OpenStreetMap tag Photon reports (``osm_key`` /
``osm_value``): peaks (natural=peak/volcano), passes and huts
(natural=saddle, mountain_pass, tourism=alpine_hut/wilderness_hut,
amenity=shelter), settlements (place=city/town/village/... and a town's
administrative boundary), the rest. Shops, offices and the like (``shop``,
``craft``, ``office``, ``healthcare``, ``amenity`` other than a shelter)
are left out: a search for "Śnieżka" also finds paint shops of that name,
never a place to check the weather for. AV-063: the same name within
SAME_OBJECT_DISTANCE_M is one object (the most specific group kept: a peak
over its viewpoint); a town's boundary and its node within
DUPLICATE_DISTANCE_M are one town (the node kept). Within a group the hits
are ranked by Nominatim's importance, then height (both filled in by the
caller, mountain_twin.places.osm_tags), then Photon's own order. A hit
keeps its own elevation tag (``ele``) when Photon has one; otherwise the
caller fills it from the DEM (mountain_twin.places.elevation) and says so.
"""

from __future__ import annotations

import json
import math
import threading
import time
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import urlencode
from urllib.request import Request

from mountain_twin.offline import urlopen  # AV-045: offline mode for tests
from mountain_twin.usage import record_cache_hit

PHOTON_URL = "https://photon.komoot.io/api/"
USER_AGENT = "echtro-place-weather/0.1 (+https://github.com/johan314/echtro)"
MIN_QUERY_LENGTH = 2
MAX_QUERY_LENGTH = 120
RESULT_LIMIT = 15
SEARCH_TTL_SECONDS = 24 * 3600.0
SEARCH_CACHE_SIZE = 2000
MIN_INTERVAL_SECONDS = 1.0
DUPLICATE_DISTANCE_M = 10_000.0
SAME_OBJECT_DISTANCE_M = 300.0
_NOT_A_PLACE_KEYS = {"shop", "craft", "office", "healthcare"}
REQUEST_TIMEOUT_SECONDS = 10

GROUPS = ("PEAK", "PASS_HUT", "SETTLEMENT", "OTHER")
GROUP_LABELS = {
    "PEAK": "Szczyty",
    "PASS_HUT": "Przełęcze i schroniska",
    "SETTLEMENT": "Miejscowości",
    "OTHER": "Inne",
}
_PEAK = {("natural", "peak"), ("natural", "volcano")}
_PASS_HUT = {
    ("natural", "saddle"),
    ("mountain_pass", "yes"),
    ("tourism", "alpine_hut"),
    ("tourism", "wilderness_hut"),
    ("amenity", "shelter"),
}
_SETTLEMENT_VALUES = {
    "city",
    "town",
    "village",
    "hamlet",
    "suburb",
    "quarter",
    "neighbourhood",
    "municipality",
    "borough",
}


class PlaceSearchUnavailable(RuntimeError):
    """Photon did not answer (network, an error status, a broken answer)."""


@dataclass(frozen=True)
class PlaceHit:
    name: str
    group: str
    osm_key: str
    osm_value: str
    latitude: float
    longitude: float
    country: str | None = None
    region: str | None = None
    locality: str | None = None
    elevation_m: float | None = None
    elevation_source: str | None = None
    osm: dict[str, Any] = field(default_factory=dict)
    importance: float | None = None

    @property
    def osm_object(self) -> str | None:
        """The OSM object ("N3889241996"), for its own tags (osm_tags)."""
        letter = {"N": "N", "W": "W", "R": "R"}.get(self.osm.get("type") or "")
        return f"{letter}{int(self.osm['id'])}" if letter and self.osm.get("id") else None

    def with_tags(self, elevation_m: float | None, importance: float | None) -> PlaceHit:
        """OSM's own height (when Photon had none) and Nominatim's importance."""
        return PlaceHit(
            **{
                **self.__dict__,
                "elevation_m": self.elevation_m if self.elevation_m is not None else elevation_m,
                "elevation_source": self.elevation_source
                if self.elevation_m is not None
                else ("OSM_ELE" if elevation_m is not None else None),
                "importance": importance,
            }
        )

    def with_elevation(self, elevation_m: float | None, source: str | None) -> PlaceHit:
        return PlaceHit(
            **{
                **self.__dict__,
                "elevation_m": elevation_m,
                "elevation_source": source if elevation_m is not None else None,
            }
        )

    def to_dict(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "group": self.group,
            "kind": place_kind(self.group),
            "osm_key": self.osm_key,
            "osm_value": self.osm_value,
            "latitude": self.latitude,
            "longitude": self.longitude,
            "country": self.country,
            "region": self.region,
            "locality": self.locality,
            "elevation_m": self.elevation_m,
            "elevation_source": self.elevation_source,
            "osm": self.osm,
            "osm_object": self.osm_object,
            "importance": self.importance,
        }


def place_kind(group: str) -> str:
    """What the forecast does with it: a PEAK gets several heights, the rest
    one."""
    return "PEAK" if group == "PEAK" else "PLACE"


def group_of(osm_key: str, osm_value: str) -> str:
    pair = (osm_key or "", osm_value or "")
    if pair in _PEAK:
        return "PEAK"
    if pair in _PASS_HUT:
        return "PASS_HUT"
    if pair[0] == "place" and pair[1] in _SETTLEMENT_VALUES:
        return "SETTLEMENT"
    if pair == ("boundary", "administrative"):
        return "SETTLEMENT"
    return "OTHER"


def is_place(osm_key: str, osm_value: str) -> bool:
    """False for a business or a service that merely carries the name."""
    if osm_key in _NOT_A_PLACE_KEYS:
        return False
    return not (osm_key == "amenity" and osm_value != "shelter")


def _distance_m(a: PlaceHit, b: PlaceHit) -> float:
    dy = (a.latitude - b.latitude) * 111_320
    dx = (a.longitude - b.longitude) * 111_320 * math.cos(math.radians(a.latitude))
    return math.hypot(dx, dy)


def _deduplicated(hits: list[PlaceHit]) -> list[PlaceHit]:
    kept: list[PlaceHit] = []
    for hit in hits:
        same = next(
            (
                index
                for index, other in enumerate(kept)
                if other.name == hit.name and _distance_m(other, hit) <= SAME_OBJECT_DISTANCE_M
            ),
            None,
        )
        if same is not None:  # one object: keep the most specific group
            if GROUPS.index(hit.group) < GROUPS.index(kept[same].group):
                kept[same] = hit
            continue
        twin = next(
            (
                index
                for index, other in enumerate(kept)
                if other.name == hit.name
                and other.group == hit.group
                and _distance_m(other, hit) <= DUPLICATE_DISTANCE_M
            ),
            None,
        )
        if twin is None:
            kept.append(hit)
        elif kept[twin].osm_key == "boundary" and hit.osm_key == "place":
            kept[twin] = hit  # the town's own node, not its boundary's centre
    return kept


def _ele(properties: dict[str, Any]) -> float | None:
    """OSM's ``ele`` tag when Photon passes it on (as ``extra.ele`` or
    ``ele``): metres, sometimes written "1603 m" or "1603;1602"."""
    raw = (properties.get("extra") or {}).get("ele", properties.get("ele"))
    if raw is None:
        return None
    text = str(raw).split(";")[0].strip().lower().removesuffix("m").strip().replace(",", ".")
    try:
        value = float(text)
    except ValueError:
        return None
    return value if -500 <= value <= 9000 else None


def hits_from_geojson(answer: dict[str, Any]) -> list[PlaceHit]:
    hits = []
    for feature in answer.get("features") or []:
        properties = feature.get("properties") or {}
        coordinates = (feature.get("geometry") or {}).get("coordinates") or ()
        name = properties.get("name")
        if not name or len(coordinates) < 2:
            continue
        osm_key, osm_value = properties.get("osm_key") or "", properties.get("osm_value") or ""
        if not is_place(osm_key, osm_value):
            continue
        ele = _ele(properties)
        hits.append(
            PlaceHit(
                name=name,
                group=group_of(osm_key, osm_value),
                osm_key=osm_key,
                osm_value=osm_value,
                latitude=float(coordinates[1]),
                longitude=float(coordinates[0]),
                country=properties.get("country"),
                region=properties.get("state") or properties.get("county"),
                locality=properties.get("city")
                or properties.get("county")
                or properties.get("district"),
                elevation_m=ele,
                elevation_source="OSM_ELE" if ele is not None else None,
                osm={
                    "type": properties.get("osm_type"),
                    "id": properties.get("osm_id"),
                },
            )
        )
    return _deduplicated(hits)


def ranked(hits: list[PlaceHit]) -> list[PlaceHit]:
    """Importance first (known before unknown), then height, then Photon's
    own order -- a stable sort."""
    return sorted(
        hits,
        key=lambda hit: (
            -(hit.importance if hit.importance is not None else -1.0),
            -(hit.elevation_m if hit.elevation_m is not None else -1e9),
        ),
    )


def grouped(hits: list[PlaceHit]) -> list[dict[str, Any]]:
    """The groups in reading order, each ranked (see ranked()); empty
    groups left out."""
    return [
        {
            "group": group,
            "label": GROUP_LABELS[group],
            "places": [hit.to_dict() for hit in ranked([h for h in hits if h.group == group])],
        }
        for group in GROUPS
        if any(hit.group == group for hit in hits)
    ]


class PhotonPlaceSearch:
    """Photon behind a cache and a global pace. ``clock``/``sleep`` are
    injectable for tests."""

    def __init__(self, *, opener=urlopen, clock=time.monotonic, sleep=time.sleep, url=PHOTON_URL):
        self._opener, self._clock, self._sleep, self._url = opener, clock, sleep, url
        self._cache: OrderedDict[tuple[str, str], tuple[float, list[PlaceHit]]] = OrderedDict()
        self._lock = threading.Lock()
        self._pace = threading.Lock()
        self._last_request = None
        self.request_count = 0  # observability/test hook: requests sent

    @staticmethod
    def normalized(query: str) -> str:
        return " ".join((query or "").split())[:MAX_QUERY_LENGTH]

    def search(self, query: str, *, lang: str = "default") -> list[PlaceHit]:
        text = self.normalized(query)
        if len(text) < MIN_QUERY_LENGTH:
            return []
        key = (text.casefold(), lang)
        with self._lock:
            cached = self._cache.get(key)
            if cached is not None and self._clock() - cached[0] < SEARCH_TTL_SECONDS:
                self._cache.move_to_end(key)
                record_cache_hit("photon")  # AV-052
                return cached[1]
        hits = hits_from_geojson(self._fetch(text, lang))
        with self._lock:
            self._cache[key] = (self._clock(), hits)
            while len(self._cache) > SEARCH_CACHE_SIZE:
                self._cache.popitem(last=False)
        return hits

    def _fetch(self, text: str, lang: str) -> dict[str, Any]:
        params = {"q": text, "limit": RESULT_LIMIT}
        if lang and lang != "default":
            params["lang"] = lang
        url = f"{self._url}?{urlencode(params)}"
        with self._pace:  # one request at a time, MIN_INTERVAL_SECONDS apart
            if self._last_request is not None:
                wait = MIN_INTERVAL_SECONDS - (self._clock() - self._last_request)
                if wait > 0:
                    self._sleep(wait)
            self._last_request = self._clock()
            self.request_count += 1
            try:
                request = Request(url, headers={"User-Agent": USER_AGENT})
                with self._opener(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
                    return json.loads(response.read().decode("utf-8"))
            except (OSError, ValueError, RuntimeError) as error:
                # RuntimeError: offline mode without a recording (kept in
                # mountain_twin.offline.misses, so a test still fails).
                raise PlaceSearchUnavailable(str(error)) from error
