"""A place's own OSM tags (AV-063): the height (``ele``) and Nominatim's
``importance``, looked up by the OSM object Photon names -- Photon passes
neither on, so a peak's height came from the 90 m DEM (Śnieżka 1597 m for
1603 m, Mont Blanc 4793 m for 4806 m) and two peaks of one name came in
Photon's order, the hill before the mountain.

Nominatim's ``/lookup`` (OSM Foundation, mountain_twin/external_services.py):
up to LOOKUP_BATCH objects in one request, at most one request a second
whoever asks, the application named in the User-Agent, every answer kept
for good in a small SQLite file (a peak's height does not change; the
policy asks for caching). A failure is no tag -- the DEM stays the fallback,
said as such on the page.
"""

from __future__ import annotations

import json
import sqlite3
import threading
import time
from pathlib import Path
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.places.search import USER_AGENT, _ele
from mountain_twin.usage import record_cache_hit

NOMINATIM_LOOKUP_URL = "https://nominatim.openstreetmap.org/lookup"
LOOKUP_BATCH = 50
MIN_INTERVAL_SECONDS = 1.0
REQUEST_TIMEOUT_SECONDS = 10
_TYPE_LETTER = {"node": "N", "way": "W", "relation": "R", "N": "N", "W": "W", "R": "R"}


def osm_key(osm_type: str | None, osm_id: Any) -> str | None:
    """ "N3889241996" from Photon's ("N", 3889241996) or Nominatim's
    ("node", 3889241996); None when either is missing."""
    letter = _TYPE_LETTER.get(osm_type or "")
    if letter is None or osm_id in (None, ""):
        return None
    return f"{letter}{int(osm_id)}"


class OsmTagLookup:
    """``lookup(keys)`` -> {key: {"ele": metres or None, "importance": float
    or None}} for the keys Nominatim knows; cached for good in ``path``."""

    def __init__(self, path: Path, *, opener=urlopen, clock=time.monotonic, sleep=time.sleep):
        self.path, self._opener, self._clock, self._sleep = Path(path), opener, clock, sleep
        self._pace = threading.Lock()
        self._last_request: float | None = None
        self._ready = False
        self.request_count = 0  # observability/test hook: requests sent

    def _connect(self) -> sqlite3.Connection:
        if not self._ready:
            self.path.parent.mkdir(parents=True, exist_ok=True)
            with sqlite3.connect(self.path) as connection:
                connection.execute(
                    "CREATE TABLE IF NOT EXISTS osm_tags (osm_key TEXT PRIMARY KEY,"
                    " ele REAL, importance REAL, fetched_at REAL NOT NULL)"
                )
            self._ready = True
        return sqlite3.connect(self.path)

    def lookup(self, keys: list[str]) -> dict[str, dict[str, float | None]]:
        wanted = list(dict.fromkeys(key for key in keys if key))
        if not wanted:
            return {}
        with self._connect() as connection:
            rows = connection.execute(
                f"SELECT osm_key, ele, importance FROM osm_tags WHERE osm_key IN"
                f" ({','.join('?' * len(wanted))})",
                wanted,
            ).fetchall()
        found = {key: {"ele": ele, "importance": importance} for key, ele, importance in rows}
        missing = [key for key in wanted if key not in found]
        if found and not missing:
            record_cache_hit("nominatim")  # AV-052: a lookup the cache answered
        for first in range(0, len(missing), LOOKUP_BATCH):
            batch = missing[first : first + LOOKUP_BATCH]
            try:
                answer = self._fetch(batch)
            except (OSError, ValueError, RuntimeError):
                # RuntimeError: offline mode without a recording (kept in
                # mountain_twin.offline.misses, so a test still fails).
                continue
            fresh = {}
            for item in answer:
                key = osm_key(item.get("osm_type"), item.get("osm_id"))
                if key in batch:
                    importance = item.get("importance")
                    fresh[key] = {
                        "ele": _ele({"extra": item.get("extratags") or {}}),
                        "importance": float(importance) if importance is not None else None,
                    }
            # An object Nominatim does not return is kept as "no tags" too,
            # so it is not asked for again.
            with self._connect() as connection:
                connection.executemany(
                    "INSERT OR REPLACE INTO osm_tags (osm_key, ele, importance, fetched_at)"
                    " VALUES (?, ?, ?, ?)",
                    [
                        (
                            key,
                            fresh.get(key, {}).get("ele"),
                            fresh.get(key, {}).get("importance"),
                            time.time(),
                        )
                        for key in batch
                    ],
                )
            found.update({key: fresh.get(key, {"ele": None, "importance": None}) for key in batch})
        return found

    def _fetch(self, keys: list[str]) -> list[dict[str, Any]]:
        url = f"{NOMINATIM_LOOKUP_URL}?{urlencode({'osm_ids': ','.join(keys), 'format': 'jsonv2', 'extratags': 1})}"
        with self._pace:
            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
            request = Request(url, headers={"User-Agent": USER_AGENT})
            with self._opener(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
                answer = json.loads(response.read().decode("utf-8"))
        if not isinstance(answer, list):
            raise ValueError("Nominatim lookup did not answer a list")
        return answer
