"""Location-coefficient POI scoring for trade-in estimates (#2045 BE-3, LocationDrawer).

Ported straight-line formula from Site Finder (ПТИЦА)
`backend/app/services/site_finder/poi_score.py::compute_poi_weighted_top7`:

    weight = (1 / (distance_m + 100)) * CATEGORY_WEIGHTS[category]

Reads from the LOCAL mirror table `osm_poi_ekb_local` (populated by
`app/tasks/osm_poi_ekb_refresh.py` from the `gendesign_osm_poi_ekb` FDW — see migrations
168-170). We deliberately do NOT query the FDW directly per-request: the same per-row cost
measured for the analogous cadastral-buildings FDW (~1.16s/row without a geom index on the
remote) would make a synchronous endpoint unusable.

Scope (MVP, #2045 BE-3):
  - Straight-line distance only. The ORS routing-decay mode from Site Finder
    (`compute_poi_routing_decay`) is NOT ported — no ORS infrastructure in trade-in, out of
    MVP scope.
  - Radius tuned for apartments (1000-1500m), NOT Site Finder's 2000m default for land parcels.
  - The score→coef mapping (`_score_to_coef`) is a NEW MVP heuristic, not present in Site
    Finder (there POI score is a ranking metric, not a price multiplier) — see its docstring.
"""

from __future__ import annotations

import logging
from typing import Any

from pydantic import BaseModel
from sqlalchemy import text

logger = logging.getLogger(__name__)

# Веса по категории — скопированы as-is из Site Finder CATEGORY_WEIGHTS
# (backend/app/services/site_finder/poi_score.py), чтобы ranking POI был согласован
# между продуктами.
CATEGORY_WEIGHTS: dict[str, float] = {
    "metro_stop": 6.0,
    "school": 5.0,
    "kindergarten": 4.5,
    "hospital": 4.0,
    "shop_mall": 4.0,
    "shop_supermarket": 3.5,
    "bus_stop": 4.5,
    "park": 3.5,
    "pharmacy": 2.5,
    "tram_stop": 2.0,
    "shop_small": 2.0,
    "default": 1.0,
}

# Радиус подобран для КВАРТИР (МКД), а не для участков (Ptica default 2000м) —
# пешая доступность в пределах квартала/микрорайона.
DEFAULT_RADIUS_M = 1200
DEFAULT_TOP_N = 7

# Теоретический максимум суммы весов top-7 POI.
#
# ИСТОРИЯ (audit R2 #7a): раньше максимум считался при d=0 (все top-7 категорий прямо у
# порога) — недостижимо на практике. Аудит показал, что даже сильная центральная локация в
# Екб (транспорт/садик/супермаркет рядом, школа/метро/ТЦ уже заметно дальше — типичный
# профиль квартала, реалистичные дистанции по категориям ~80-700м) даёт raw_sum лишь
# ~0.08-0.095, т.е. score ~25-30/100 при старой нормировке (/100 при d=0) → coef ~0.975-0.98.
# Сильная локация систематически читалась как "немного снижает стоимость" — знаменатель
# сжимал реалистичные scores в нижнюю треть шкалы 0-100.
#
# ФИКС: нормируем не на d=0, а на РЕФЕРЕНСНОЕ расстояние _REF_DISTANCE_M — top-7 весов,
# как если бы каждая категория была на этом расстоянии. REF=100м ровно удваивает
# знаменатель (distance_m + 100): 100+100=200 вместо 0+100=100 → теоретический максимум
# вдвое ниже прежнего → реалистичные scores вдвое выше: сильный центр ~25-30 → ~50-60
# (coef ~1.00-1.01), с запасом выше 1.0 для исключительно плотных локаций (все top-7
# категорий <150м). Слабые/разреженные локации остаются далеко ниже 1.0; пустая
# osm_poi_ekb_local (рефреш не запускался) по-прежнему даёт coef=1.0 через отдельный
# graceful-fallback путь в compute_location_coef, эту нормировку не трогающий.
#
# ВНИМАНИЕ: REF_DIST — калибровочная константа для MVP-эвристики (см. _score_to_coef),
# НЕ откалибрована на реальных ценовых дельтах — только на распределении реалистичных POI-
# профилей, чтобы диапазон [0.95, 1.05] соответствовал интуиции "сильный центр ≈ 1.0".
#
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5 (тот же набор, что у Ptica).
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
_REF_DISTANCE_M: float = 100.0
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / (
    _REF_DISTANCE_M + 100.0
)  # ≈ 0.1575 (было 0.315 при d=0)

# coef диапазон ±5% вокруг 1.0 — heuristic v1, НЕ откалибровано на реальных ценовых дельтах
# (в отличие от Ptica, где poi_weighted_score — ранжирующая метрика, не ценовой множитель).
_COEF_BASE = 0.95
_COEF_SPREAD = 0.10


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


class LocationCoefFactor(BaseModel):
    """Один POI-фактор в ответе location-coef."""

    poi_type: str
    name: str | None
    distance_m: float
    weight: float


class LocationCoefResult(BaseModel):
    """Результат compute_location_coef — потребляется эндпоинтом location-coef."""

    coef: float
    factors: list[LocationCoefFactor]
    geo_source: str  # "osm_poi_ekb" (норма) | "unavailable" (mirror пуста/не отрефрешена)


def _score_to_coef(poi_weighted_score: float) -> float:
    """MVP-эвристика score(0..100) → ценовой коэффициент.

    coef = 0.95 + (score/100) * 0.10  →  диапазон [0.95, 1.05].

    ВНИМАНИЕ: это НЕ откалиброванная на реальных ценовых дельтах формула — первая рабочая
    эвристика для MVP location-coef. Site Finder использует ту же POI-модель как ранжирующую
    метрику (poi_weighted_score), а не как прямой ценовой множитель; здесь смысл другой,
    поэтому маппинг введён отдельно и явно помечен как heuristic v1.
    """
    return round(_COEF_BASE + (poi_weighted_score / 100.0) * _COEF_SPREAD, 4)


_NEAREST_POI_SQL = text(
    """
    SELECT
        p.name,
        p.category,
        CAST(
            ST_Distance(
                p.geom::geography,
                ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography
            ) AS double precision
        ) AS distance_m
    FROM osm_poi_ekb_local p
    WHERE p.geom IS NOT NULL
      AND ST_DWithin(
          p.geom::geography,
          ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography,
          CAST(:radius_m AS double precision)
      )
    ORDER BY distance_m ASC
    LIMIT :limit
    """
)


def compute_location_coef(
    db: Any,
    lat: float,
    lon: float,
    radius_m: int = DEFAULT_RADIUS_M,
    top_n: int = DEFAULT_TOP_N,
) -> LocationCoefResult:
    """Посчитать location-coef для координат (lat, lon) по POI из osm_poi_ekb_local.

    Graceful fallback: если osm_poi_ekb_local пуста (рефреш ещё не запускался на этом
    окружении) — возвращает coef=1.0, factors=[], geo_source="unavailable" вместо 500 или
    сфабрикованных факторов. Отсутствие POI В РАДИУСЕ у непустой таблицы — это легитимный
    результат (coef=0.95, factors=[], geo_source="osm_poi_ekb"), не fallback.

    Args:
        db: SQLAlchemy Session.
        lat: широта целевой квартиры.
        lon: долгота целевой квартиры.
        radius_m: радиус поиска в метрах (default 1200 — подобран для МКД, не для участков).
        top_n: количество POI, учитываемых в score (default 7).
    """
    total = db.execute(text("SELECT count(*) FROM osm_poi_ekb_local")).scalar() or 0
    if total == 0:
        logger.warning(
            "location_coef: osm_poi_ekb_local is empty (refresh job not yet run on this "
            "environment) — returning unavailable fallback, no fabricated factors"
        )
        return LocationCoefResult(coef=1.0, factors=[], geo_source="unavailable")

    rows = (
        db.execute(
            _NEAREST_POI_SQL,
            {"lat": lat, "lon": lon, "radius_m": radius_m, "limit": top_n * 10},
        )
        .mappings()
        .all()
    )

    scored: list[tuple[float, LocationCoefFactor]] = []
    for row in rows:
        distance_m = float(row["distance_m"])
        category = row["category"] or "default"
        weight = (1.0 / (distance_m + 100.0)) * _category_weight(category)
        scored.append(
            (
                weight,
                LocationCoefFactor(
                    poi_type=category,
                    name=row["name"],
                    distance_m=round(distance_m, 1),
                    weight=round(weight, 6),
                ),
            )
        )

    scored.sort(key=lambda pair: pair[0], reverse=True)
    top_factors = [factor for _weight, factor in scored[:top_n]]

    raw_sum = sum(factor.weight for factor in top_factors)
    poi_weighted_score = min(100.0, (raw_sum / _MAX_STRAIGHT_SCORE) * 100.0)
    coef = _score_to_coef(poi_weighted_score)

    logger.debug(
        "location_coef: lat=%.5f lon=%.5f radius=%dm poi_found=%d top=%d " "score=%.1f coef=%.4f",
        lat,
        lon,
        radius_m,
        len(rows),
        len(top_factors),
        poi_weighted_score,
        coef,
    )

    return LocationCoefResult(coef=coef, factors=top_factors, geo_source="osm_poi_ekb")
