"""MET Norway Locationforecast 2.0 -- the second weather source (AV-051):
used when Open-Meteo refuses (429, a spent quota, a network failure) or its
day's count nears the limit, or first with WEATHER_PRIMARY=met_norway.

MET Norway's terms (https://api.met.no/doc/TermsOfService, checked
2026-10-05), kept here without exception:

* CC BY 4.0 -- the page says "Dane: MET Norway" wherever its data shows;
* an identifying User-Agent with the application and a contact: the
  contact from MET_NORWAY_CONTACT (the owner's e-mail, set in
  local_dev.env -- never written into the code), else the project's page;
* answers cached and revalidated: a stored answer is used until its
  ``Expires``, then asked for again with ``If-Modified-Since`` (304: the
  stored one stays);
* coordinates with at most 4 decimals, the altitude as whole metres;
* at most MAX_REQUESTS_PER_SECOND (MET allows 20 a second in all), gzip,
  HTTPS.

The answer is normalised to the hourly series Open-Meteo gives -- the same
variable names and units (m/s become km/h: a unit, not a calculation), the
asked timezone and dates -- so the charts read either source the same way.
A variable MET Norway does not publish (apparent temperature, freezing
level, snowfall, ground temperature, snow depth, CAPE, weather code) is
absent with SOURCE_DOES_NOT_PROVIDE: never estimated. MET Norway publishes
hourly steps for about the first 2.5 days and 6-hourly ones after that; the
hours between are gaps, not interpolated values.
"""

from __future__ import annotations

import gzip
import hashlib
import json
import os
import threading
import time
from datetime import date, datetime, timedelta
from email.utils import formatdate, parsedate_to_datetime
from pathlib import Path
from typing import Any
from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import Request
from zoneinfo import ZoneInfo

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

MET_NORWAY_URL = "https://api.met.no/weatherapi/locationforecast/2.0/complete"
CONTACT_ENVIRONMENT = "MET_NORWAY_CONTACT"
DEFAULT_CONTACT = "https://github.com/johan314/echtro"
APPLICATION_VERSION = "0.1"
MAX_REQUESTS_PER_SECOND = 10
REQUEST_TIMEOUT_SECONDS = 20
SOURCE_DOES_NOT_PROVIDE = "SOURCE_DOES_NOT_PROVIDE"
ATTRIBUTION = "Dane: MET Norway (CC BY 4.0)"

# Open-Meteo variable -> (where it is in a timeseries entry, factor). The
# factor converts units only (m/s -> km/h).
MAPPING: dict[str, tuple[str, str, float]] = {
    "temperature_2m": ("instant", "air_temperature", 1.0),
    "wind_speed_10m": ("instant", "wind_speed", 3.6),
    "wind_gusts_10m": ("instant", "wind_speed_of_gust", 3.6),
    "wind_direction_10m": ("instant", "wind_from_direction", 1.0),
    "relative_humidity_2m": ("instant", "relative_humidity", 1.0),
    "cloud_cover": ("instant", "cloud_area_fraction", 1.0),
    "cloud_cover_low": ("instant", "cloud_area_fraction_low", 1.0),
    "cloud_cover_mid": ("instant", "cloud_area_fraction_medium", 1.0),
    "cloud_cover_high": ("instant", "cloud_area_fraction_high", 1.0),
    "precipitation": ("next_1_hours", "precipitation_amount", 1.0),
    "precipitation_probability": ("next_1_hours", "probability_of_precipitation", 1.0),
}
UNITS = {
    "temperature_2m": "°C",
    "wind_speed_10m": "km/h",
    "wind_gusts_10m": "km/h",
    "wind_direction_10m": "°",
    "relative_humidity_2m": "%",
    "cloud_cover": "%",
    "cloud_cover_low": "%",
    "cloud_cover_mid": "%",
    "cloud_cover_high": "%",
    "precipitation": "mm",
    "precipitation_probability": "%",
}


def user_agent(environ=os.environ) -> str:
    from mountain_twin.brand import brand  # the name lives in aventurro/brand.json only

    contact = (environ.get(CONTACT_ENVIRONMENT) or "").strip() or DEFAULT_CONTACT
    return f"{brand(environ)['name']}/{APPLICATION_VERSION} {contact}"


def rounded(latitude: float, longitude: float, altitude: float | None) -> tuple:
    """MET Norway's precision rule: 4 decimals, whole metres."""
    return (
        round(latitude, 4),
        round(longitude, 4),
        None if altitude is None else int(round(altitude)),
    )


def normalised(
    answer: dict[str, Any],
    *,
    timezone_name: str,
    start_date: date,
    end_date: date,
    variables: tuple[str, ...],
) -> dict[str, Any]:
    """MET Norway's timeseries as an Open-Meteo-shaped hourly answer on the
    asked timezone's whole hours from ``start_date`` to ``end_date``."""
    zone = ZoneInfo(timezone_name)
    first = datetime.combine(start_date, datetime.min.time(), tzinfo=zone)
    hours = [first + timedelta(hours=h) for h in range(((end_date - start_date).days + 1) * 24)]
    position = {
        hour.astimezone(ZoneInfo("UTC")).replace(tzinfo=None): i for i, hour in enumerate(hours)
    }
    series = {variable: [None] * len(hours) for variable in variables if variable in MAPPING}
    for entry in (answer.get("properties") or {}).get("timeseries") or []:
        moment = datetime.fromisoformat(entry["time"].replace("Z", "+00:00")).replace(tzinfo=None)
        index = position.get(moment)
        if index is None:
            continue
        data = entry.get("data") or {}
        for variable in series:
            block, field, factor = MAPPING[variable]
            details = ((data.get(block) or {}).get("details")) or {}
            value = details.get(field)
            if value is not None:
                series[variable][index] = round(value * factor, 2)
    geometry = (answer.get("geometry") or {}).get("coordinates") or [None, None, None]
    return {
        "elevation": geometry[2] if len(geometry) > 2 else None,
        "timezone": timezone_name,
        "hourly_units": {variable: UNITS[variable] for variable in series},
        "hourly": {"time": [hour.strftime("%Y-%m-%dT%H:%M") for hour in hours], **series},
        "_source": "met_norway",
        "_updated_at": ((answer.get("properties") or {}).get("meta") or {}).get("updated_at"),
    }


class MetNorwayClient:
    """One point a request (MET Norway has no multi-location call), each
    answer kept on disk with its Expires/Last-Modified. ``clock`` and
    ``sleep`` are injectable for tests."""

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

    def _path(self, key: tuple) -> Path:
        digest = hashlib.sha256(json.dumps(key).encode()).hexdigest()[:24]
        return self.cache_directory / f"met_norway_{digest}.json"

    def forecast(self, latitude: float, longitude: float, altitude: float | None) -> dict[str, Any]:
        """MET Norway's own answer for the point (cached; revalidated after
        its Expires). Raises RuntimeError when there is none to be had."""
        key = rounded(latitude, longitude, altitude)
        path = self._path(key)
        stored = json.loads(path.read_text(encoding="utf-8")) if path.exists() else None
        if stored and self._clock() < stored.get("expires_at", 0):
            record_cache_hit("met_norway")  # AV-052
            return stored["answer"]
        params = {"lat": f"{key[0]:.4f}", "lon": f"{key[1]:.4f}"}
        if key[2] is not None:
            params["altitude"] = str(key[2])
        headers = {"User-Agent": user_agent(), "Accept-Encoding": "gzip"}
        if stored and stored.get("last_modified"):
            headers["If-Modified-Since"] = stored["last_modified"]
        with self._pace:
            if self._last_request is not None:
                wait = 1.0 / MAX_REQUESTS_PER_SECOND - (time.monotonic() - self._last_request)
                if wait > 0:
                    self._sleep(wait)
            self._last_request = time.monotonic()
            self.request_count += 1
            try:
                with self._opener(
                    Request(f"{MET_NORWAY_URL}?{urlencode(params)}", headers=headers),
                    timeout=REQUEST_TIMEOUT_SECONDS,
                ) as response:
                    body = response.read()
                    response_headers = dict(getattr(response, "headers", {}) or {})
            except HTTPError as error:
                if error.code == 304 and stored:
                    stored["expires_at"] = self._expiry(dict(error.headers or {}))
                    self._keep(path, stored)
                    return stored["answer"]
                raise RuntimeError(f"MET_NORWAY_HTTP_{error.code}") from error
            except OSError as error:
                raise RuntimeError("MET_NORWAY_NETWORK_FAILURE") from error
        if body[:2] == b"\x1f\x8b":
            body = gzip.decompress(body)
        answer = json.loads(body.decode("utf-8"))
        self._keep(
            path,
            {
                "answer": answer,
                "expires_at": self._expiry(response_headers),
                "last_modified": response_headers.get("Last-Modified")
                or formatdate(self._clock(), usegmt=True),
            },
        )
        return answer

    def _expiry(self, headers: dict) -> float:
        expires = headers.get("Expires") or headers.get("expires")
        try:
            return parsedate_to_datetime(expires).timestamp() if expires else self._clock() + 1800
        except (TypeError, ValueError):
            return self._clock() + 1800

    def _keep(self, path: Path, record: dict) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(record), encoding="utf-8")
