"""POI weighted score для Site Finder (B6) + routing-radius decay (#41).

Два режима:

1. compute_poi_weighted_top7 (B6, legacy) — straight-line:
   weight = (1 / (distance_m + 100)) * category_weight

2. compute_poi_routing_decay (#41) — routing-based:
   время пешком (ORS /matrix) вместо прямой линии + per-category radius (минут) +
   per-category decay-кривая. Решает Limit_POI_Factor_Tiny (ratio 0.99-1.02 → шире).
   ORS недоступен (нет ключа / rate-limit) → graceful fallback на straight-line с
   эквивалентным минутным порогом.

Категории и их веса согласованы с _POI_WEIGHTS в parcels.py.
"""

from __future__ import annotations

import logging
from collections.abc import Callable
from typing import Any

from pydantic import BaseModel
from sqlalchemy import text

from app.services.site_finder import ors_client

logger = logging.getLogger(__name__)

# Веса по категории — согласованы с _POI_WEIGHTS в parcels.py + новые из vault B6.
# Задача: "2GIS-style ranking", метро самое приоритетное.
CATEGORY_WEIGHTS: dict[str, float] = {
    "metro_stop": 6.0,
    "school": 5.0,
    "kindergarten": 4.5,
    "hospital": 4.0,
    "shop_supermarket": 3.5,
    "shop_mall": 4.0,
    "park": 3.5,
    "bus_stop": 4.5,
    "tram_stop": 2.0,
    "pharmacy": 2.5,
    "shop_small": 2.0,
    "default": 1.0,
}

# ── Нормировочные константы (для вывода score_contribution / poi_weighted_score в 0..100) ──
#
# Теоретический максимум суммы весов top-7 POI при идеальном расположении:
#   straight-line: w_i = cat_weight_i / (0+100) → max_sum = Σ(top7 cat_weights) / 100
#   routing-decay: w_i = cat_weight_i * decay(0) = cat_weight_i → max_sum = Σ(top7 cat_weights)
#
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])

# straight-line mode: каждый POI берётся с множителем 1/(d+100); при d=0 → /100
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0  # ≈ 0.315

# routing-decay mode: decay ∈ [0,1], при t=0 decay=1.0 → max = cat_weight
_MAX_ROUTING_SCORE: float = _TOP7_WEIGHT_SUM  # = 31.5


class PoiScoreItem(BaseModel):
    """Один POI в ranked-ответе."""

    name: str | None
    category: str
    distance_m: float
    weight: float
    # Вклад данного POI в суммарный балл, в процентах от 0..100.
    # Позволяет фронтенду рендерить «долю» без знания формулы нормировки.
    score_contribution: float
    address: str | None


class PoiScoreResponse(BaseModel):
    cad_num: str
    radius_m: int
    # Суммарный взвешенный балл инфраструктуры, нормированный в диапазон 0..100.
    poi_weighted_score: float
    top_poi: list[PoiScoreItem]


def _category_weight(category: str) -> float:
    """Вернуть вес категории. Если не знаем — default."""
    return CATEGORY_WEIGHTS.get(category, CATEGORY_WEIGHTS["default"])


def compute_poi_weighted_top7(
    db: Any,
    cad_num: str,
    lat: float,
    lon: float,
    radius_m: int = 2000,
    top_n: int = 7,
) -> PoiScoreResponse:
    """Найти top-N POI вокруг (lat, lon) в radius_m, ранжировать по weighted score.

    Запрос к osm_poi_ekb через ST_DWithin + ST_Distance.
    Формула: weight = (1 / (distance_m + 100)) * category_weight

    Args:
        db: SQLAlchemy Session
        cad_num: кадастровый номер (для ответа)
        lat: широта центроида участка
        lon: долгота центроида участка
        radius_m: радиус поиска в метрах (default 2000)
        top_n: количество POI в ответе (default 7)

    Returns:
        PoiScoreResponse с отсортированными по weight DESC POI.
    """
    # ST_DWithin с geography=true использует метры напрямую.
    # ST_Distance тоже в метрах при geography=true.
    rows = (
        db.execute(
            text("""
                SELECT
                    p.name,
                    p.category,
                    p.tags,
                    CAST(
                        ST_Distance(
                            p.geom::geography,
                            ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography
                        ) AS double precision
                    ) AS distance_m
                FROM osm_poi_ekb p
                WHERE ST_DWithin(
                    p.geom::geography,
                    ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
                    :radius_m
                )
                ORDER BY distance_m ASC
                LIMIT :limit
            """),
            {
                "lat": lat,
                "lon": lon,
                "radius_m": radius_m,
                "limit": top_n * 10,  # запрашиваем больше, потом ранжируем
            },
        )
        .mappings()
        .all()
    )

    logger.debug(
        "poi_score: cad=%s lat=%.5f lon=%.5f radius=%dm → %d candidates",
        cad_num,
        lat,
        lon,
        radius_m,
        len(rows),
    )

    items: list[PoiScoreItem] = []
    for row in rows:
        distance_m = float(row["distance_m"])
        category = row["category"] or "default"
        cat_weight = _category_weight(category)
        weight = (1.0 / (distance_m + 100.0)) * cat_weight

        # Адрес из tags jsonb если есть
        tags: dict[str, str] = row["tags"] or {}
        addr_parts = [
            tags.get("addr:street"),
            tags.get("addr:housenumber"),
        ]
        address = ", ".join(p for p in addr_parts if p) or None

        items.append(
            PoiScoreItem(
                name=row["name"],
                category=category,
                distance_m=round(distance_m, 1),
                weight=round(weight, 6),
                score_contribution=0.0,  # заполним после нормировки
                address=address,
            )
        )

    # Сортировка по weight DESC, берём top_n
    items.sort(key=lambda x: x.weight, reverse=True)
    top_items = items[:top_n]

    # Нормировка: poi_weighted_score = (Σ weight_i / _MAX_STRAIGHT_SCORE) * 100, клэмп [0, 100]
    raw_sum = sum(i.weight for i in top_items)
    poi_weighted_score = round(min(100.0, (raw_sum / _MAX_STRAIGHT_SCORE) * 100.0), 1)

    # score_contribution — доля данного POI в общем балле (0..100)
    for item in top_items:
        item.score_contribution = round((item.weight / _MAX_STRAIGHT_SCORE) * 100.0, 1)

    return PoiScoreResponse(
        cad_num=cad_num,
        radius_m=radius_m,
        poi_weighted_score=poi_weighted_score,
        top_poi=top_items,
    )


# ── #41: routing-radius decay ────────────────────────────────────────────────
#
# Per-category радиус доступности В МИНУТАХ ПЕШКОМ (не метров прямой линии).
# Сразу за порогом вес категории = 0 (POI не учитывается). Значения из vault A3
# (СП 42.13330 + 2GIS-практика пешей доступности). Категория без записи →
# _DEFAULT_RADIUS_MIN.
_CATEGORY_RADIUS_MIN: dict[str, float] = {
    "metro_stop": 20.0,
    "school": 15.0,
    "kindergarten": 10.0,
    "park": 10.0,
    "pharmacy": 12.0,
    "shop_supermarket": 12.0,
    "shop_mall": 15.0,
    "shop_small": 10.0,
    "hospital": 20.0,
    "bus_stop": 12.0,
    "tram_stop": 15.0,
}
_DEFAULT_RADIUS_MIN: float = 12.0

# Средняя пешая скорость для straight-line fallback (м/мин). 5 км/ч ≈ 83 м/мин.
_WALK_SPEED_M_PER_MIN: float = 83.0


def _category_radius_min(category: str) -> float:
    """Порог доступности категории в минутах пешком."""
    return _CATEGORY_RADIUS_MIN.get(category, _DEFAULT_RADIUS_MIN)


def _decay_linear(t_min: float, radius_min: float) -> float:
    """Линейный спад: 1.0 в нуле → 0.0 на radius_min. Для метро/остановок."""
    if t_min >= radius_min:
        return 0.0
    return 1.0 - (t_min / radius_min)


def _decay_piecewise(t_min: float, radius_min: float) -> float:
    """Кусочный: полный вес 0..⅓ радиуса, линейный спад до радиуса, дальше 0.

    Для школ/детсадов/поликлиник — рядом одинаково удобно, "обрыв" только
    ближе к краю изохроны.
    """
    full_until = radius_min / 3.0
    if t_min <= full_until:
        return 1.0
    if t_min >= radius_min:
        return 0.0
    return 1.0 - (t_min - full_until) / (radius_min - full_until)


def _decay_step(t_min: float, radius_min: float) -> float:
    """Ступенька: 1.0 если в радиусе, иначе 0.0. Для парков (есть/нет рядом)."""
    return 1.0 if t_min <= radius_min else 0.0


# Какую кривую спада применять к категории. Дефолт — linear.
_CATEGORY_DECAY: dict[str, Callable[[float, float], float]] = {
    "metro_stop": _decay_linear,
    "bus_stop": _decay_linear,
    "tram_stop": _decay_linear,
    "school": _decay_piecewise,
    "kindergarten": _decay_piecewise,
    "hospital": _decay_piecewise,
    "pharmacy": _decay_piecewise,
    "park": _decay_step,
}


def _decay_for(category: str) -> Callable[[float, float], float]:
    return _CATEGORY_DECAY.get(category, _decay_linear)


def compute_poi_routing_decay(
    db: Any,
    cad_num: str,
    lat: float,
    lon: float,
    radius_m: int = 2000,
    top_n: int = 7,
    *,
    profile: str = "foot-walking",
) -> PoiScoreResponse:
    """#41: POI-score по ВРЕМЕНИ В ПУТИ (ORS routing) + per-category decay.

    Шаги:
      1. Достать POI-кандидатов из osm_poi_ekb в radius_m (как B6), c lon/lat.
      2. Один ORS /matrix вызов: время пешком origin → все POI (минуты).
         ORS недоступен → straight-line fallback (метры/скорость → минуты).
      3. weight = category_weight * decay(t_min, _CATEGORY_RADIUS_MIN[category]).
         За порогом радиуса decay=0 → POI выбывает.
      4. top_n по weight DESC.

    Сигнатура совместима с compute_poi_weighted_top7 (drop-in для callers).
    PoiScoreItem.distance_m здесь — straight-line метры (для UI/отладки),
    ранжирование идёт по routing-времени через weight.
    """
    rows = (
        db.execute(
            text("""
                SELECT
                    p.name,
                    p.category,
                    p.tags,
                    ST_X(p.geom) AS lon,
                    ST_Y(p.geom) AS lat,
                    CAST(
                        ST_Distance(
                            p.geom::geography,
                            ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography
                        ) AS double precision
                    ) AS distance_m
                FROM osm_poi_ekb p
                WHERE ST_DWithin(
                    p.geom::geography,
                    ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
                    :radius_m
                )
                ORDER BY distance_m ASC
                LIMIT :limit
            """),
            {
                "lat": lat,
                "lon": lon,
                "radius_m": radius_m,
                "limit": ors_client.MAX_MATRIX_DESTINATIONS,
            },
        )
        .mappings()
        .all()
    )

    if not rows:
        return PoiScoreResponse(
            cad_num=cad_num, radius_m=radius_m, poi_weighted_score=0.0, top_poi=[]
        )

    destinations = [(float(r["lon"]), float(r["lat"])) for r in rows]

    # ── routing-время пешком, с graceful fallback на straight-line ──
    times_min: list[float | None]
    routing_used = False
    try:
        times_min = ors_client.matrix_durations_min(lon, lat, destinations, profile=profile)
        routing_used = True
    except ors_client.OrsUnavailableError as exc:
        logger.info(
            "poi_routing_decay: ORS недоступен (%s) → straight-line fallback (cad=%s)",
            exc,
            cad_num,
        )
        times_min = [float(r["distance_m"]) / _WALK_SPEED_M_PER_MIN for r in rows]

    logger.debug(
        "poi_routing_decay: cad=%s %d candidates routing=%s",
        cad_num,
        len(rows),
        routing_used,
    )

    items: list[PoiScoreItem] = []
    for row, t_min in zip(rows, times_min, strict=True):
        category = row["category"] or "default"
        distance_m = float(row["distance_m"])
        # ORS не построил маршрут (None) → fallback на straight-line оценку времени.
        if t_min is None:
            t_min = distance_m / _WALK_SPEED_M_PER_MIN

        radius_min = _category_radius_min(category)
        factor = _decay_for(category)(t_min, radius_min)
        if factor <= 0.0:
            continue  # за порогом доступности — POI не учитывается

        weight = _category_weight(category) * factor

        tags: dict[str, str] = row["tags"] or {}
        addr_parts = [tags.get("addr:street"), tags.get("addr:housenumber")]
        address = ", ".join(p for p in addr_parts if p) or None

        items.append(
            PoiScoreItem(
                name=row["name"],
                category=category,
                distance_m=round(distance_m, 1),
                weight=round(weight, 6),
                score_contribution=0.0,  # заполним после нормировки
                address=address,
            )
        )

    items.sort(key=lambda x: x.weight, reverse=True)
    top_items = items[:top_n]

    # Нормировка: poi_weighted_score = (Σ weight_i / _MAX_ROUTING_SCORE) * 100, клэмп [0, 100]
    raw_sum = sum(i.weight for i in top_items)
    poi_weighted_score = round(min(100.0, (raw_sum / _MAX_ROUTING_SCORE) * 100.0), 1)

    # score_contribution — доля данного POI в общем балле (0..100)
    for item in top_items:
        item.score_contribution = round((item.weight / _MAX_ROUTING_SCORE) * 100.0, 1)

    return PoiScoreResponse(
        cad_num=cad_num,
        radius_m=radius_m,
        poi_weighted_score=poi_weighted_score,
        top_poi=top_items,
    )
