"""TTL-кэш для Open-Meteo (PR #1130 Phase A+B — Site Finder analyze latency).

ПРОБЛЕМА: `analyze_parcel` в hot-path делал ТРИ внешних HTTP-вызова к Open-Meteo на
каждый запрос (forecast + climate normals + air-quality), без кэша. На проде backend в
private network с restricted egress — DNS до `*.open-meteo.com` фейлится, каждый replay
висит полный timeout (5s + 15s + 5s = до 25с лишних на analyze). Профиль cProfile показал
~0.26с air-quality в успешном hit и +5с при DNS-fail (тот же антипаттерн, что был у
forecast/climate до Phase A).

РЕШЕНИЕ: in-process TTL-кэш по округлённым до ~1 км координатам.

  • Forecast (`get_weather_cached`) — 7-day погода, мало меняется в пределах часов.
    TTL hit: 6 часов; TTL negative (failure): 5 минут — чтобы DNS-fail не повторял
    `timeout` на каждый analyze, но восстановление подхватывалось через ~5 минут.
  • Climate normals (`get_seasonal_weather_cached`) — 30-летние сезонные нормали,
    обновляются ~раз в год. TTL hit: 7 суток; TTL negative: 5 минут.
  • Air quality (`get_air_quality_cached`) — текущий час pm2_5/pm10/no2.
    TTL hit: 1 час (фронт-API API hourly bucket, обновлять чаще forecast);
    TTL negative: 5 минут.

Ключ — `(round(lat, 2), round(lon, 2))`: округление до сотых ≈ 1 км, ЕКБ-wide один cad
≈ один ключ. Soft-cap 256 записей per-cache (eviction первого попавшегося) — защита от
утечки при потенциальном дрейфе ключей; для одного города этот лимит не достигается.

КОРРЕКТНОСТЬ: контракт для caller'а не меняется — None по-прежнему допустим (caller в
`parcels.py:_build_environmental_score` обрабатывает `bool(weather)` как env_ok-флаг).
Все исключения httpx/json/KeyError ловятся → `logger.warning` + negative-cache + None.

THREAD-SAFETY: analyze_parcel — sync def, под Uvicorn идёт в threadpool. Каждый кэш
защищён своим `threading.Lock`, который удерживается ТОЛЬКО на время check/store — сам
сетевой httpx-вызов идёт ВНЕ lock'а (#1370), иначе все analyze одного cache-типа
сериализуются на время вызова (lock — per-cache-type, не per-key). Trade-off: конкурентный
cold-start на ОДИН ключ может породить несколько параллельных запросов (last-write wins),
что приемлемо — вызовы идемпотентны, а TTL длинный.

time.monotonic используется вместо time.time — устойчиво к скачкам системного времени
(NTP sync, ручной выставление). Тесты могут подменять `weather_cache._now` для проталки
времени за expires_at без манки самого time.
"""

from __future__ import annotations

import logging
import math
import threading
import time
from typing import Any

import httpx

logger = logging.getLogger(__name__)

# TTL в секундах (см. модульный docstring).
_WEATHER_TTL_S: float = 6 * 3600  # forecast — 6 часов
_SEASONAL_TTL_S: float = 7 * 86400  # climate normals — 7 суток
_AIR_TTL_S: float = 1 * 3600  # air quality — 1 час (текущий час, обновлять чаще forecast)
_NEGATIVE_TTL_S: float = 300  # failed/timeout/exception — 5 минут все кэши

# Тайм-ауты httpx сокращены против оригинальных (5s/15s/5s) — open-meteo обычно отвечает
# за 200-600ms; при DNS-fail лучше быстрее упасть в negative-cache, чем держать worker
# 15с в hot-path.
_WEATHER_TIMEOUT_S: float = 2.0
_SEASONAL_TIMEOUT_S: float = 3.0
_AIR_TIMEOUT_S: float = 2.0

# Soft-cap на размер каждой таблицы (округлённых координат для одного города мало).
_MAX_ENTRIES: int = 256

# (value | None, expires_at_monotonic_seconds). None в value = negative-cache.
_CacheEntry = tuple[dict[str, Any] | None, float]

_FORECAST_CACHE: dict[tuple[float, float], _CacheEntry] = {}
_FORECAST_LOCK = threading.Lock()

_CLIMATE_CACHE: dict[tuple[float, float], _CacheEntry] = {}
_CLIMATE_LOCK = threading.Lock()

_AIR_CACHE: dict[tuple[float, float], _CacheEntry] = {}
_AIR_LOCK = threading.Lock()


def _now() -> float:
    """Монотонный таймер (тесты подменяют этот helper, а не сам `time`)."""
    return time.monotonic()


def _round_key(lat: float, lon: float) -> tuple[float, float]:
    """Ключ кэша — координаты, округлённые до 0.01° (~1 км). См. docstring модуля."""
    return (round(lat, 2), round(lon, 2))


def _evict_one_if_full(cache: dict[tuple[float, float], _CacheEntry]) -> None:
    """Soft-cap eviction: при превышении `_MAX_ENTRIES` выбросить старейшую по вставке запись.

    Фактически FIFO благодаря insertion-order dict в CPython 3.7+: `next(iter(cache))` —
    первый по порядку вставки ключ, т.е. самый старый (с большой вероятностью stale).
    Не LRU — достаточно простой защиты от утечки на случай дрейфа ключей; LRU потребовал
    бы `OrderedDict.move_to_end` на каждый hit (over-engineering при 256 lim + 6h TTL).
    Под lock'ом вызывающей стороны.
    """
    if len(cache) >= _MAX_ENTRIES:
        victim = next(iter(cache))
        cache.pop(victim, None)


def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
    """Open-Meteo Forecast API (7-day) — собственно сетевой вызов и парсинг.

    Любое исключение (httpx, json, KeyError) → logger.warning + None (caller обернёт
    None в negative-cache на короткий TTL).
    """
    try:
        with httpx.Client(timeout=_WEATHER_TIMEOUT_S) as c:
            r = c.get(
                "https://api.open-meteo.com/v1/forecast",
                params={
                    "latitude": lat,
                    "longitude": lon,
                    "daily": (
                        "temperature_2m_max,temperature_2m_min,"
                        "precipitation_sum,uv_index_max,"
                        "winddirection_10m_dominant,windspeed_10m_max"
                    ),
                    "timezone": "Europe/Moscow",
                    "forecast_days": 7,
                },
            )
            r.raise_for_status()
            daily = r.json().get("daily", {})
            if not daily.get("time"):
                return None

            # Open-Meteo штатно возвращает null для отдельных дней (uv_index_max и др.)
            # при непустом daily.time — отфильтровываем None ПЕРЕД min/max/sum, иначе
            # TypeError в Python 3.12 уронит весь 7-day forecast в negative-cache (#1577).
            t_max = [v for v in (daily.get("temperature_2m_max") or []) if v is not None]
            t_min = [v for v in (daily.get("temperature_2m_min") or []) if v is not None]
            precip = [v for v in (daily.get("precipitation_sum") or []) if v is not None]
            uv = [v for v in (daily.get("uv_index_max") or []) if v is not None]
            wind_d = [v for v in (daily.get("winddirection_10m_dominant") or []) if v is not None]
            wind_s = [v for v in (daily.get("windspeed_10m_max") or []) if v is not None]

            # Circular mean направления ветра (vector sum) — избегает jump 359→1.
            # Если нет ни одного валидного сэмпла — dominant=None (не 0.0° = север).
            if wind_d:
                x = sum(math.cos(math.radians(d)) for d in wind_d)
                y = sum(math.sin(math.radians(d)) for d in wind_d)
                dominant_deg: float | None = (math.degrees(math.atan2(y, x)) + 360) % 360
            else:
                dominant_deg = None
            rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"]
            wind_label: str | None = (
                rose[round(dominant_deg / 45) % 8] if dominant_deg is not None else None
            )

            return {
                "forecast_days": len(daily.get("time", [])),
                "temperature": {
                    "min_c": round(min(t_min), 1) if t_min else None,
                    "max_c": round(max(t_max), 1) if t_max else None,
                    "avg_max_c": round(sum(t_max) / len(t_max), 1) if t_max else None,
                    "avg_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
                },
                "precipitation_total_mm": round(sum(precip), 1) if precip else 0,
                "precipitation_days": sum(1 for p in precip if p and p > 0.5),
                "uv_index_max": round(max(uv), 1) if uv else None,
                "wind": {
                    "dominant_direction_deg": (
                        round(dominant_deg) if dominant_deg is not None else None
                    ),
                    "dominant_direction_label": wind_label,
                    "max_speed_m_s": round(max(wind_s), 1) if wind_s else None,
                },
                "source": "open-meteo",
                "note": (
                    "7-day forecast. Для исторических норм и B2B-данных — "
                    "Yandex Business / Gismeteo (платно)."
                ),
            }
    except Exception as e:
        logger.warning("weather fetch failed: %s", e)
        return None


def _fetch_seasonal_remote(lat: float, lon: float) -> dict[str, Any] | None:
    """Open-Meteo Climate API — 30-летние сезонные нормали (MRI-AGCM3.2-S).

    Группирует 1995-2024 по четырём сезонам. Любое исключение → logger.warning + None
    (caller обернёт None в negative-cache на короткий TTL).
    """
    try:
        with httpx.Client(timeout=_SEASONAL_TIMEOUT_S) as c:
            r = c.get(
                "https://climate-api.open-meteo.com/v1/climate",
                params={
                    "latitude": lat,
                    "longitude": lon,
                    "start_date": "1995-01-01",
                    "end_date": "2024-12-31",
                    "models": "MRI_AGCM3_2_S",
                    "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
                },
            )
            r.raise_for_status()
            data = r.json()
            daily = data.get("daily", {})
            times = daily.get("time") or []
            t_max = daily.get("temperature_2m_max") or []
            t_min = daily.get("temperature_2m_min") or []
            precip = daily.get("precipitation_sum") or []
            if not times:
                return None

            seasons_months = {
                "winter": [12, 1, 2],
                "spring": [3, 4, 5],
                "summer": [6, 7, 8],
                "autumn": [9, 10, 11],
            }
            buckets: dict[str, dict[str, list[float]]] = {
                k: {"t_max": [], "t_min": [], "precip": []} for k in seasons_months
            }
            for i, t in enumerate(times):
                month = int(t[5:7])  # 'YYYY-MM-DD'
                for season, months in seasons_months.items():
                    if month in months:
                        if i < len(t_max) and t_max[i] is not None:
                            buckets[season]["t_max"].append(t_max[i])
                        if i < len(t_min) and t_min[i] is not None:
                            buckets[season]["t_min"].append(t_min[i])
                        if i < len(precip) and precip[i] is not None:
                            buckets[season]["precip"].append(precip[i])
                        break

            seasons: dict[str, Any] = {}
            for season, vals in buckets.items():
                if not vals["t_max"]:
                    seasons[season] = None
                    continue
                # t_min/precip накапливаются НЕЗАВИСИМО от t_max (раздельные None-guard'ы
                # выше) — при непустом t_max и all-null t_min/precip эти списки пусты, и
                # sum()/len() даст ZeroDivisionError, min([]) — ValueError (#1578). Метрики
                # по пустому списку → None вместо падения всего сезонного ответа.
                t_min = vals["t_min"]
                precip = vals["precip"]
                seasons[season] = {
                    "avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
                    "avg_t_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
                    "max_t_c": round(max(vals["t_max"]), 1),
                    "min_t_c": round(min(t_min), 1) if t_min else None,
                    "avg_precip_per_day_mm": (
                        round(sum(precip) / len(precip), 1) if precip else None
                    ),
                    "total_precip_mm": round(sum(precip), 0) if precip else 0,
                    "days_observed": len(vals["t_max"]),
                }
            return {
                "seasons": seasons,
                "period": "1995-2024 (30 лет)",
                "model": "MRI-AGCM3-2-S",
                "source": "open-meteo-climate",
                "note": ("Климатические нормали. Текущая погода — отдельный API."),
            }
    except Exception as e:
        logger.warning("seasonal weather fetch failed: %s", e)
        return None


def get_weather_cached(lat: float, lon: float) -> dict[str, Any] | None:
    """Open-Meteo Forecast (7-day) с TTL-кэшем.

    Hit (успех): кэш 6 часов. Negative (None при ошибке): кэш 5 минут. Ключ —
    координаты, округлённые до 0.01° (~1 км). На любой Exception от httpx/json
    возвращает None и кэширует None под коротким TTL — контракт для caller'а не
    меняется (None по-прежнему допустим).
    """
    key = _round_key(lat, lon)
    now = _now()
    with _FORECAST_LOCK:
        entry = _FORECAST_CACHE.get(key)
        if entry is not None and entry[1] > now:
            return entry[0]
    # MISS или истёк → сетевой вызов ВНЕ lock'а, иначе все analyze одного cache-типа
    # (даже для разных координат) сериализуются на время httpx-вызова (#1370). Ценой —
    # cold-start на ОДИН ключ может породить несколько параллельных запросов (last-write
    # wins при store ниже), что приемлемо: вызовы идемпотентны, TTL длинный.
    value = _fetch_weather_remote(lat, lon)
    ttl = _WEATHER_TTL_S if value is not None else _NEGATIVE_TTL_S
    with _FORECAST_LOCK:
        _evict_one_if_full(_FORECAST_CACHE)
        _FORECAST_CACHE[key] = (value, _now() + ttl)
    return value


def get_seasonal_weather_cached(lat: float, lon: float) -> dict[str, Any] | None:
    """Open-Meteo Climate API (30-летние сезонные нормали) с TTL-кэшем.

    Hit (успех): кэш 7 суток (нормали меняются ~раз в год). Negative (None при
    ошибке): кэш 5 минут. Ключ — координаты, округлённые до 0.01° (~1 км). На любой
    Exception от httpx/json возвращает None и кэширует None под коротким TTL.
    """
    key = _round_key(lat, lon)
    now = _now()
    with _CLIMATE_LOCK:
        entry = _CLIMATE_CACHE.get(key)
        if entry is not None and entry[1] > now:
            return entry[0]
    # Сетевой вызов ВНЕ lock'а — не сериализуем разные ключи (#1370). См. get_weather_cached.
    value = _fetch_seasonal_remote(lat, lon)
    ttl = _SEASONAL_TTL_S if value is not None else _NEGATIVE_TTL_S
    with _CLIMATE_LOCK:
        _evict_one_if_full(_CLIMATE_CACHE)
        _CLIMATE_CACHE[key] = (value, _now() + ttl)
    return value


def _fetch_air_quality_remote(lat: float, lon: float) -> dict[str, Any] | None:
    """Open-Meteo Air Quality API — pm2_5/pm10/no2 текущего часа.

    Использует `current=...` (bucket текущего часа), а не `hourly[0]` (=00:00 forecast-дня) —
    docstring/UX обещают «качество воздуха сейчас» (#1377). Формат возвращаемого dict
    (ключи pm2_5/pm10/no2/ts/source) сохранён — фронт зависит. Любое исключение →
    logger.warning + None (caller обернёт None в negative-cache на короткий TTL).
    """
    try:
        with httpx.Client(timeout=_AIR_TIMEOUT_S) as c:
            r = c.get(
                "https://air-quality-api.open-meteo.com/v1/air-quality",
                params={
                    "latitude": lat,
                    "longitude": lon,
                    # `current` отдаёт bucket текущего часа (а не hourly[0]=00:00) —
                    # docstring/UX обещают «качество воздуха сейчас». См. #1377.
                    "current": "pm2_5,pm10,nitrogen_dioxide",
                },
            )
            r.raise_for_status()
            data = r.json()
            current = data.get("current", {})
            if not current.get("time"):
                return None
            return {
                "pm2_5": current.get("pm2_5"),
                "pm10": current.get("pm10"),
                "no2": current.get("nitrogen_dioxide"),
                "ts": current["time"],
                "source": "open-meteo",
            }
    except Exception as e:
        logger.warning("air quality fetch failed: %s", e)
        return None


def get_air_quality_cached(lat: float, lon: float) -> dict[str, Any] | None:
    """Open-Meteo Air Quality API (pm2_5/pm10/no2 текущего часа) с TTL-кэшем.

    Hit (успех): кэш 1 час (hourly bucket API — обновлять чаще forecast). Negative
    (None при ошибке): кэш 5 минут. Ключ — координаты, округлённые до 0.01° (~1 км).
    На любой Exception от httpx/json возвращает None и кэширует None под коротким TTL.
    """
    key = _round_key(lat, lon)
    now = _now()
    with _AIR_LOCK:
        entry = _AIR_CACHE.get(key)
        if entry is not None and entry[1] > now:
            return entry[0]
    # Сетевой вызов ВНЕ lock'а — не сериализуем разные ключи (#1370). См. get_weather_cached.
    value = _fetch_air_quality_remote(lat, lon)
    ttl = _AIR_TTL_S if value is not None else _NEGATIVE_TTL_S
    with _AIR_LOCK:
        _evict_one_if_full(_AIR_CACHE)
        _AIR_CACHE[key] = (value, _now() + ttl)
    return value
