"""Offline mode (AV-045): no request to an outside service, answers from
recorded fixtures instead.

Every client of an outside service (Open-Meteo, RainViewer, EUMETSAT,
BRouter, Copernicus DEM, the live-tracking server, Strava) opens its URLs
through :func:`urlopen` below. Without ``ECHTRO_OFFLINE`` it is
``urllib.request.urlopen``. With ``ECHTRO_OFFLINE=1`` (the test suite sets
it for every test, see tests/conftest.py) nothing leaves this machine:

* Open-Meteo -- real recorded hourly series (tests/fixtures/open_meteo/),
  re-shaped to what was asked: the requested points (each from the nearest
  recorded place), dates, timezone, variables and models (``models=a,b,c``
  answers ``<variable>_<model>``, as the provider does). The values are the
  recorded ones, hour by hour from the requested start; the time axis is
  the requested one.
* RainViewer -- the recorded frame index, its times moved to now.
* EUMETSAT -- the recorded WMS capabilities of each layer, the newest image
  time moved to now (rounded down to the layer's interval).
* Photon (AV-062) -- recorded place searches (tests/fixtures/photon/
  searches.json), looked up by the query as the server sends it
  (whitespace-normalized, case-folded).
* MET Norway (AV-051) -- recorded Locationforecast answers (tests/
  fixtures/met_norway/*.json), the nearest recorded place to the asked
  point, its times moved so the first one is the current hour.
* Nominatim (AV-063) -- recorded object lookups (tests/fixtures/nominatim/
  lookups.json, one entry per OSM object); a lookup naming an object
  without a recording is a miss.
* BRouter -- recorded routes (tests/fixtures/brouter/routes/), looked up by
  profile and points; ``ECHTRO_OFFLINE=record`` fetches a missing one once
  (from ``ECHTRO_RECORD_BROUTER``, else the URL's own server) and keeps it
  (scripts/record_test_fixtures.py).
* anything else -- :class:`OfflineFixtureMissing`.

A request without a fixture is also kept in :data:`misses`, so a test that
an application ``except`` would otherwise let pass quietly still fails
(tests/conftest.py checks the list after each test). The fixtures are test
data, never product truth: the answers here are only ever seen by tests.
"""

from __future__ import annotations

import json
import math
import os
import re
import tempfile
import threading
import urllib.request
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlsplit
from zoneinfo import ZoneInfo

OFFLINE_ENVIRONMENT = "ECHTRO_OFFLINE"
FIXTURES_ENVIRONMENT = "ECHTRO_OFFLINE_FIXTURES"
RECORD_BROUTER_ENVIRONMENT = "ECHTRO_RECORD_BROUTER"
DEFAULT_FIXTURES = Path(__file__).resolve().parents[1] / "tests" / "fixtures"

_lock = threading.Lock()
# Requests offline mode could not answer, oldest first: (host, url, why).
misses: list[tuple[str, str, str]] = []


class OfflineFixtureMissing(RuntimeError):
    """Offline mode has no recorded answer for this request."""


def offline_mode() -> str | None:
    """None (online), "1" (fixtures only) or "record" (BRouter: fetch and
    keep a missing route; everything else as "1")."""
    value = (os.environ.get(OFFLINE_ENVIRONMENT) or "").strip().lower()
    if value in ("", "0", "false", "no", "off"):
        return None
    return "record" if value == "record" else "1"


def offline_cache_directory() -> Path:
    """Where offline mode keeps the weather cache: this process's own
    scratch directory, never the real one a development server reads."""
    return Path(tempfile.gettempdir()) / f"echtro-offline-cache-{os.getpid()}"


def fixtures_root() -> Path:
    return Path(os.environ.get(FIXTURES_ENVIRONMENT) or DEFAULT_FIXTURES)


def urlopen(url, data=None, timeout=None, **kwargs):
    """``urllib.request.urlopen``, or the recorded answer in offline mode.

    AV-052: the one function every request to an outside service goes
    through -- the service's budget is checked first (mountain_twin.budgets:
    a level that forbids this feature raises ServiceBudgetExceeded) and the
    outcome is counted (mountain_twin.usage), online and offline alike (the
    usage simulator counts recorded answers)."""
    from urllib.error import HTTPError

    from mountain_twin import budgets, usage
    from mountain_twin.external_services import service_for_url

    full_url = url.full_url if isinstance(url, urllib.request.Request) else str(url)
    service = service_for_url(full_url)
    store = usage.store()
    try:
        budgets.check(service)
    except budgets.ServiceBudgetExceeded as refused:
        # AV-052: what a threshold held back is counted too (the demand).
        if store is not None:
            store.record(service, outcome=f"REFUSED_{refused.level}", weight=usage.current_weight())
        raise
    try:
        response = _open(url, data, timeout, **kwargs)
    except HTTPError as error:
        if store is not None:
            outcome = (
                "OK" if error.code == 304 else "RATE_LIMITED" if error.code == 429 else "ERROR"
            )
            store.record(
                service, outcome=outcome, weight=usage.current_weight(), detail=f"HTTP {error.code}"
            )
        raise
    except Exception as error:
        if store is not None:
            store.record(
                service, outcome="ERROR", weight=usage.current_weight(), detail=type(error).__name__
            )
        raise
    if store is not None:
        store.record(service, outcome="OK", weight=usage.current_weight())
    return response


def _open(url, data=None, timeout=None, **kwargs):
    mode = offline_mode()
    if mode is None:
        if timeout is None:
            return urllib.request.urlopen(url, data, **kwargs)  # noqa: S310
        return urllib.request.urlopen(url, data, timeout, **kwargs)  # noqa: S310
    full_url = url.full_url if isinstance(url, urllib.request.Request) else str(url)
    parts = urlsplit(full_url)
    host = parts.hostname or ""
    try:
        if host.endswith("open-meteo.com"):
            return _Response(json.dumps(_open_meteo(parts)).encode("utf-8"))
        if host == "api.met.no":
            return _Response(json.dumps(_met_norway(parts)).encode("utf-8"))
        if host == "nominatim.openstreetmap.org" and parts.path.rstrip("/") == "/lookup":
            return _Response(json.dumps(_nominatim(parts)).encode("utf-8"))
        if host == "photon.komoot.io":
            return _Response(json.dumps(_photon(parts)).encode("utf-8"))
        if host == "api.rainviewer.com":
            return _Response(json.dumps(_rainviewer()).encode("utf-8"))
        if host == "view.eumetsat.int" and "GetCapabilities" in parts.query:
            return _Response(_eumetsat(parts).encode("utf-8"))
        if parts.path.rstrip("/").endswith("/brouter"):
            return _Response(_brouter(full_url, parts, mode, timeout))
    except OfflineFixtureMissing as error:
        _miss(host, full_url, str(error))
        raise
    _miss(host, full_url, "no offline answer for this service")
    raise OfflineFixtureMissing(f"{OFFLINE_ENVIRONMENT}: no fixture for {host} ({full_url[:160]})")


def _miss(host: str, url: str, why: str) -> None:
    with _lock:
        misses.append((host, url[:300], why))


class _Response:
    """What the clients use of an HTTP response: a context manager with
    ``read()``, ``status`` and ``headers``."""

    def __init__(self, body: bytes, status: int = 200):
        self._body, self.status, self.headers = body, status, {}

    def read(self, *_):
        return self._body

    def getcode(self):
        return self.status

    def __enter__(self):
        return self

    def __exit__(self, *_):
        return False


# --- MET Norway (AV-051) ------------------------------------------------------
def _met_norway(parts) -> Any:
    query = {key: values[0] for key, values in parse_qs(parts.query).items()}
    latitude, longitude = float(query["lat"]), float(query["lon"])
    recordings = []
    for path in sorted((fixtures_root() / "met_norway").glob("*.json")):
        recordings.append(json.loads(path.read_text(encoding="utf-8")))
    if not recordings:
        raise OfflineFixtureMissing("no MET Norway recordings (api.met.no)")
    recording = min(
        recordings,
        key=lambda r: (r["latitude"] - latitude) ** 2 + (r["longitude"] - longitude) ** 2,
    )
    answer = json.loads(json.dumps(recording["answer"]))
    series = answer["properties"]["timeseries"]
    first = datetime.fromisoformat(series[0]["time"].replace("Z", "+00:00"))
    shift = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0) - first
    for entry in series:
        moment = datetime.fromisoformat(entry["time"].replace("Z", "+00:00")) + shift
        entry["time"] = moment.strftime("%Y-%m-%dT%H:%M:%SZ")
    return answer


# --- Nominatim (AV-063) -------------------------------------------------------
def _nominatim(parts) -> Any:
    keys = [key for key in (parse_qs(parts.query).get("osm_ids") or [""])[0].split(",") if key]
    path = fixtures_root() / "nominatim" / "lookups.json"
    recordings = (
        json.loads(path.read_text(encoding="utf-8")).get("objects", {}) if path.exists() else {}
    )
    missing = [key for key in keys if key not in recordings]
    if missing:
        raise OfflineFixtureMissing(
            f"no Nominatim recording (nominatim.openstreetmap.org) for {', '.join(missing)}"
        )
    return [recordings[key] for key in keys if recordings[key] is not None]


# --- Photon (AV-062) ----------------------------------------------------------
def photon_query_key(query: str) -> str:
    return " ".join((query or "").split()).casefold()


def _photon(parts) -> Any:
    query = (parse_qs(parts.query).get("q") or [""])[0]
    path = fixtures_root() / "photon" / "searches.json"
    recordings = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
    answer = recordings.get("searches", {}).get(photon_query_key(query))
    if answer is None:
        raise OfflineFixtureMissing(
            f"no Photon recording (photon.komoot.io) for the query {query!r}"
        )
    return answer


# --- Open-Meteo ------------------------------------------------------------
_open_meteo_cache: dict[str, Any] = {}


def _open_meteo_recordings() -> dict[str, Any]:
    """{model: recording}; "best_match" is the request without ``models``."""
    if not _open_meteo_cache:
        folder = fixtures_root() / "open_meteo"
        for path in sorted(folder.glob("forecast_*.json")):
            recording = json.loads(path.read_text(encoding="utf-8"))
            _open_meteo_cache[recording["model"]] = recording
        if not _open_meteo_cache:
            raise OfflineFixtureMissing(f"no Open-Meteo recordings in {folder}")
    return _open_meteo_cache


def _floats(text: str) -> list[float | None]:
    return [None if item in ("", "nan") else float(item) for item in text.split(",")]


def _open_meteo(parts) -> Any:
    query = {key: values[0] for key, values in parse_qs(parts.query).items()}
    latitudes, longitudes = _floats(query["latitude"]), _floats(query["longitude"])
    elevations = _floats(query["elevation"]) if "elevation" in query else [None] * len(latitudes)
    variables = [name for name in query.get("hourly", "").split(",") if name]
    zone_name = query.get("timezone") or "GMT"
    zone = timezone.utc if zone_name in ("GMT", "UTC") else ZoneInfo(zone_name)
    today = datetime.now(timezone.utc).astimezone(zone).date()
    start = date.fromisoformat(query["start_date"]) if "start_date" in query else today
    end = date.fromisoformat(query["end_date"]) if "end_date" in query else start + timedelta(6)
    models = [name for name in query.get("models", "").split(",") if name] or ["best_match"]
    recordings = _open_meteo_recordings()
    for model in models:
        if model not in recordings:
            raise OfflineFixtureMissing(f"no Open-Meteo recording for model {model}")
    hours = (end - start).days * 24 + 24
    times = [
        (datetime.combine(start, datetime.min.time()) + timedelta(hours=h)).strftime(
            "%Y-%m-%dT%H:%M"
        )
        for h in range(hours)
    ]
    answers = []
    for latitude, longitude, elevation in zip(latitudes, longitudes, elevations):
        hourly: dict[str, Any] = {"time": times}
        units: dict[str, str] = {"time": "iso8601"}
        for model in models:
            recording = recordings[model]
            place = _nearest(recording["places"], latitude, longitude)
            for variable in variables:
                series = place["hourly"].get(variable)
                if series is None:
                    raise OfflineFixtureMissing(
                        f"the Open-Meteo recording ({model}) has no variable {variable}"
                    )
                key = variable if len(models) == 1 else f"{variable}_{model}"
                hourly[key] = [series[h % len(series)] for h in range(hours)]
                units[key] = recording["hourly_units"].get(variable, "undefined")
        offset = datetime.combine(start, datetime.min.time(), tzinfo=zone).utcoffset()
        answers.append(
            {
                "latitude": latitude,
                "longitude": longitude,
                "elevation": elevation if elevation is not None else place.get("elevation"),
                "generationtime_ms": 0.1,
                "utc_offset_seconds": int(offset.total_seconds()) if offset else 0,
                "timezone": zone_name,
                "timezone_abbreviation": zone_name,
                "hourly_units": units,
                "hourly": hourly,
            }
        )
    return answers if len(answers) > 1 else answers[0]


def _nearest(places: list[dict[str, Any]], latitude, longitude) -> dict[str, Any]:
    def distance(place):
        return math.hypot(
            place["latitude"] - (latitude or 0.0),
            (place["longitude"] - (longitude or 0.0)) * math.cos(math.radians(latitude or 0.0)),
        )

    return min(places, key=distance)


# --- RainViewer ------------------------------------------------------------
def _rainviewer() -> dict[str, Any]:
    path = fixtures_root() / "rainviewer" / "weather-maps.json"
    if not path.exists():
        raise OfflineFixtureMissing(f"no RainViewer recording ({path})")
    index = json.loads(path.read_text(encoding="utf-8"))
    frames = (index.get("radar") or {}).get("past") or []
    newest = max((frame["time"] for frame in frames), default=index.get("generated", 0))
    now = int(datetime.now(timezone.utc).timestamp()) // 600 * 600
    shift = now - int(newest)
    for kind in ("past", "nowcast"):
        for frame in (index.get("radar") or {}).get(kind) or []:
            frame["time"] = int(frame["time"]) + shift
    if index.get("generated"):
        index["generated"] = int(index["generated"]) + shift
    return index


# --- EUMETSAT --------------------------------------------------------------
_TIME_DEFAULT = re.compile(r'(<Dimension\s+name="time"[^>]*\sdefault=")([^"]+)(")')


def _eumetsat(parts) -> str:
    from mountain_twin.weather.clouds import LAYERS  # the layers' own intervals

    workspace, name = (part for part in parts.path.split("/")[2:4])
    path = fixtures_root() / "eumetsat" / f"{workspace}_{name}_capabilities.xml"
    if not path.exists():
        raise OfflineFixtureMissing(f"no EUMETSAT recording for {workspace}:{name} ({path})")
    text = path.read_text(encoding="utf-8")
    match = _TIME_DEFAULT.search(text)
    spec = next((spec for spec in LAYERS.values() if spec["layer"] == f"{workspace}:{name}"), None)
    if match and spec:
        interval = spec["interval_minutes"]
        now = datetime.now(timezone.utc)
        minutes = (now.hour * 60 + now.minute) // interval * interval
        newest = now.replace(hour=minutes // 60, minute=minutes % 60, second=0, microsecond=0)
        suffix = ".000Z" if match.group(2).endswith(".000Z") else "Z"
        text = (
            text[: match.start(2)]
            + newest.strftime("%Y-%m-%dT%H:%M:%S")
            + suffix
            + text[match.end(2) :]
        )
    return text


# --- BRouter ---------------------------------------------------------------
def brouter_key(profile: str, lonlats: str) -> str:
    """The recorded route's name: the profile and its points (5 decimals,
    about 1 m), so the same click on the same map finds it again."""
    points = []
    for pair in lonlats.split("|"):
        longitude, latitude = (float(value) for value in pair.split(","))
        points.append(f"{longitude:.5f}_{latitude:.5f}")
    return re.sub(r"[^A-Za-z0-9_.-]", "-", f"{profile}__{'__'.join(points)}")


def _brouter(full_url: str, parts, mode: str, timeout) -> bytes:
    query = {key: values[0] for key, values in parse_qs(parts.query).items()}
    key = brouter_key(query.get("profile", ""), query.get("lonlats", ""))
    path = fixtures_root() / "brouter" / "routes" / f"{key}.json"
    if path.exists():
        return path.read_bytes()
    if mode == "record":
        # ECHTRO_RECORD_BROUTER: the server to record from (the owner's own,
        # scripts/record_test_fixtures.py); the recording is named by the
        # profile and points only, so it plays back for any endpoint.
        endpoint = os.environ.get(RECORD_BROUTER_ENVIRONMENT) or full_url.split("?", 1)[0]
        source = f"{endpoint.rstrip('/')}?{parts.query}"
        with urllib.request.urlopen(source, timeout=timeout or 60) as response:  # noqa: S310
            body = response.read()
        json.loads(body)  # only a well-formed answer is kept
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_bytes(body)
        return body
    raise OfflineFixtureMissing(
        f"no recorded BRouter route {key} -- record it: scripts/record_test_fixtures.py"
    )


__all__ = [
    "FIXTURES_ENVIRONMENT",
    "OFFLINE_ENVIRONMENT",
    "OfflineFixtureMissing",
    "brouter_key",
    "misses",
    "offline_cache_directory",
    "offline_mode",
    "urlopen",
]
