"""Location-сервис — district-level индексы первоклассной «локации» (#948 Part B, §8.2).

Промоутит район (district) в queryable сущность `location` (data/sql/146_location.sql),
несущую 4 нормированных индекса:
  • infra        — инфраструктура района
  • competition  — конкуренция (доступное/затоваренное конкурирующее предложение)
  • demand       — спрос (темп продаж)
  • future_supply — будущее давление предложения

ADDITIVE (HARD CONSTRAINT #948B): `district` (string) НЕ рефакторится в FK. Этот
сервис — НОВЫЙ слой РЯДОМ: ключует `location` по `district_name` (joinable по строке),
переиспользуя существующие per-district forecast-функции (они #1129-кэшированы, вызов
per-district дёшев). Существующий код продолжает ходить по district-строкам.

ИСТОЧНИКИ ИНДЕКСОВ (reuse, не изобретаем) + НОРМАЛИЗАЦИЯ к [0,1] (None при отсутствии
данных — НИКОГДА не фабрикуем 0, дух market_metrics.py / future_supply.py):

  • competition ← market_metrics.compute_market_metrics(db, district=…).overstock_index
        overstock_index = долго-экспонируемый непроданный сток / все доступные ед.
        ∈ [0,1] — доля «зависшего» конкурирующего предложения (в продаже > N мес без
        сделки). Это ИСТИННЫЙ сигнал конкуренции = доступное/затоваренное конкурирующее
        предложение: выше = больше конкурирующего стока простаивает = БОЛЬШЕ
        конкурентного давления для нового входящего игрока. УЖЕ нормирован [0,1]
        (доля от доступных) — берём как есть, без пере-нормировки. None когда метрика
        None (нет доступных лотов / пустая выборка).

        ОРТОГОНАЛЬНОСТЬ к demand: competition мерит ДОСТУПНОЕ/непроданное предложение,
        demand — скорость продаж (unit_velocity). Это разные оси (saturation supply vs
        sales velocity), не монотонно связанные. Раньше тут стоял sell_through_pct
        (доля реализованного стока) — но это absorption/heat-сигнал, монотонно
        растущий с продажами (коррелирован с demand=velocity) И инвертированный
        относительно «свободного конкурирующего стока»: высокий sell-through =
        МЕНЬШЕ доступной конкуренции, а не больше. overstock_index — честная,
        ортогональная спросу замена.

  • demand ← market_metrics.compute_market_metrics(db, district=…).unit_velocity,
        нормированный CITY-RELATIVE (как infra ниже), а НЕ фиксированной константой.
        unit_velocity = ед., проданных в месяц (за окно) ∈ [0, ∞). Это ТОТ ЖЕ сигнал
        спроса, что использует forecasting/demand_supply_forecast (base_pace). У него
        нет естественного абсолютного потолка → нормируем ОТНОСИТЕЛЬНО самого рынка:
        demand(district) = clamp01(velocity / city_reference_velocity), где
        city_reference_velocity = MAX скорость продаж среди всех районов прогона.
        «Спрос относительно самого горячего района»: busiest = 1.0, остальные
        пропорционально ниже. None когда unit_velocity None (нет выборки).

        ПОЧЕМУ relative, а НЕ absolute (#948 fix): прежняя реализация делила на жёсткую
        константу _DEMAND_SATURATION_UPM=50 ед./мес. На live-прогоне ВСЕ 8 районов ЕКБ
        продавали ≥50/мес → demand=1.0 У ВСЕХ (плоское насыщение, НОЛЬ дискриминации
        между районами — бесполезно как сравнительный сигнал). Абсолютная константа
        «гниёт»: она была неверна уже сегодня и расходилась бы дальше с ростом рынка.
        Относительная нормировка (как у infra: district POI / city POI) само-
        калибруется — всегда даёт спред для любого невырожденного распределения и
        НИКОГДА не требует ручной перенастройки. Выбран MAX как якорь (простейший
        статистик, гарантирующий дискриминацию; при 8 районах «выброс» = сигнал, а не
        шум — самый горячий район и ЕСТЬ потолок спроса). Вырожденные случаи:
        reference=0 (все честно продали 0) → demand=0.0 у всех (честный ноль, НЕ None);
        все скорости равны → 1.0 у всех («одинаково горячо»).

  • future_supply ← future_supply.compute_future_supply_pressure(db, district=…).index
        УЖЕ нормирован ∈ [0,1] самим сервисом (насыщение months_of_pressure). Берём
        как есть — НЕ пере-нормируем. None когда index None (нет supply-данных /
        поглощения).

  • infra ← analytics_queries._district_poi_score(db, district_name) /
            analytics_queries._city_avg_poi_score(db)
        _district_poi_score = средний по ЖК района weighted POI-count (метро/медицина
        тяжелее) в радиусе 1000м; _city_avg_poi_score — то же по всему ЕКБ (готовый
        нормализатор, уже существует). Нормализация: district / city, clamp [0,1] —
        район со средней-по-городу инфраструктурой ≈ 0.5..1.0, лучше города → clamp в
        1.0. Это РЕАЛЬНЫЙ per-district infra-сигнал (НЕ NULL-deferred). None когда в
        районе <3 ЖК с POI (_district_poi_score вернул None) ИЛИ нет city-baseline.

DISTRICT SOURCE: 8 официальных АДМИН-районов ЕКБ — через
district_resolver._admin_names(db) (готовый кэшированный запрос
`SELECT district_name FROM ekb_districts WHERE geom IS NOT NULL`; 'не определён' там
нет — и не должно быть). Это канонический district-вокабуляр для наполнения локаций.

CENTROID: derivable из ekb_districts_geom (PostGIS-полигоны, миграция 56) через
ST_Centroid(geom) — наполняется в refresh_locations прямым SQL (район может не иметь
geom-строки → centroid остаётся NULL, graceful).

Conventions: psycopg v3 CAST(:x AS type) — НИКОГДА :x::type; SAVEPOINT per-row в
refresh-цикле (backend.md); logger не print; graceful — сбой одного района не валит
остальные. Детерминированно, без LLM.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Any

from sqlalchemy import text
from sqlalchemy.orm import Session

from app.schemas.location import LocationList, LocationOut
from app.services.analytics_queries import _city_avg_poi_score, _district_poi_score
from app.services.site_finder.district_resolver import _admin_names
from app.services.site_finder.future_supply import compute_future_supply_pressure
from app.services.site_finder.market_metrics import compute_market_metrics

logger = logging.getLogger(__name__)

# demand-индекс нормируется CITY-RELATIVE (велосити района / city_reference_velocity),
# а не фиксированной константой — никакого _DEMAND_SATURATION_UPM (см. #948 fix в
# docstring выше + normalize_demand ниже). Якорь city_reference выводится в
# refresh_locations из собранных за прогон скоростей (MAX), потому константы здесь нет.

# Окно метрик рынка (мес) — совпадает с дефолтом compute_market_metrics (§9.2).
_MARKET_WINDOW_MONTHS: int = 6

# Горизонт будущего предложения (мес) — дефолт compute_future_supply_pressure (§9.3).
_FUTURE_SUPPLY_HORIZON_MONTHS: int = 12


@dataclass(frozen=True)
class LocationIndices:
    """4 district-level индекса (#948 §8.2). Каждый ∈ [0,1] или None (нет данных).

    None НИКОГДА не подменяется 0 — «нет данных» и «честный ноль» обязаны различаться
    (дух market_metrics.py graceful-on-thin-data).

    demand_index — CITY-RELATIVE (#948 fix), поэтому считается не локально по одному
    району, а нормировкой против опорной скорости по городу (см. normalize_demand +
    refresh_locations two-pass). compute_location_indices возвращает СЫРУЮ скорость
    района в raw_unit_velocity (входной материал для нормировки) и оставляет
    demand_index=None как плейсхолдер — финальное значение проставляет pass 2 refresh.
    """

    infra_index: float | None
    competition_index: float | None
    demand_index: float | None
    future_supply_index: float | None
    # Сырая unit_velocity (ед./мес) района — None при отсутствии выборки. НЕ персистится
    # в колонку; служит входом для city-relative нормировки demand в refresh_locations.
    raw_unit_velocity: float | None = None

    def as_dict(self) -> dict[str, float | None]:
        return {
            "infra_index": _round_or_none(self.infra_index),
            "competition_index": _round_or_none(self.competition_index),
            "demand_index": _round_or_none(self.demand_index),
            "future_supply_index": _round_or_none(self.future_supply_index),
        }


def _round_or_none(value: float | None, digits: int = 4) -> float | None:
    return round(value, digits) if value is not None else None


# ──────────────────────────────────────────────────────────────────────────────
# Pure-нормализация — без БД, полностью юнит-тестируемо.
# Каждая функция graceful: None на входе → None на выходе (не 0).
# ──────────────────────────────────────────────────────────────────────────────


def _clamp01(value: float) -> float:
    """Зажать в [0,1]. PURE."""
    return max(0.0, min(1.0, value))


def normalize_competition(overstock_index: float | None) -> float | None:
    """Индекс конкуренции = доступное/затоваренное конкурирующее предложение → [0,1]. PURE.

    Источник — market_metrics.overstock_index = долго-экспонируемый непроданный сток /
    все доступные ед. ∈ [0,1]: доля «зависшего» конкурирующего предложения. Выше =
    больше конкурирующего стока простаивает = БОЛЬШЕ конкурентного давления для нового
    входящего игрока. Это ИСТИННЫЙ сигнал конкуренции и он ОРТОГОНАЛЕН demand (скорости
    продаж = unit_velocity), а не коррелирован с ним (как был старый sell_through).

    Уже нормирован [0,1] самим market_metrics (доля от доступных) — только clamp как
    защита от артефактов. None → None (нет данных / нет доступных лотов, НЕ 0 — иначе
    «нет конкурирующего стока для замера» неотличимо от «честный ноль затоваривания»).
    """
    if overstock_index is None:
        return None
    return _clamp01(overstock_index)


def normalize_demand(
    unit_velocity: float | None,
    *,
    city_reference_velocity: float | None,
) -> float | None:
    """Индекс спроса CITY-RELATIVE: velocity района / city_reference → [0,1]. PURE.

    Зеркалит дисциплину normalize_infra (district / city-reference, clamp), а НЕ
    фиксированную константу. city_reference_velocity = опорная скорость продаж по городу
    (refresh_locations передаёт MAX среди всех районов прогона). Так demand ВСЕГДА
    дискриминирует районы и само-калибруется — см. #948 fix в module docstring (старый
    /50 давал demand=1.0 у всех 8 районов ЕКБ → ноль дискриминации).

    Semantics / edge cases (каждый покрыт юнит-тестом):
      • unit_velocity is None → None («нет выборки», НЕ 0 — load-bearing дисциплина
        модуля: «нет данных» ≠ «честный ноль»).
      • city_reference is None → None (нет опоры: все районы без данных).
      • city_reference == 0 (вырождение: все районы честно продали 0) → 0.0, НЕ None
        и без ZeroDivisionError (честный нулевой спрос).
      • unit_velocity == 0 при положительной опоре → 0.0 (честный ноль).
      • все скорости равны / единственный район → 1.0 («одинаково горячо»).
    clamp01 страхует от velocity > reference (плавающая арифметика / редкие артефакты).
    """
    if unit_velocity is None or city_reference_velocity is None:
        return None
    if city_reference_velocity <= 0:
        # Вырождение: опора 0 ⇒ весь город продал 0/мес. Честный нулевой спрос у всех
        # (НЕ None — выборка была, market_metrics уже отличил «0 продаж» от «нет данных»),
        # без деления на ноль.
        return 0.0
    return _clamp01(unit_velocity / city_reference_velocity)


def normalize_infra(
    district_poi: float | None,
    city_avg_poi: float | None,
) -> float | None:
    """Инфра-индекс из POI района относительно POI города → [0,1]. PURE.

    district_poi / city_avg_poi, clamp [0,1]: район со средней-по-городу
    инфраструктурой ≈ 1.0 (но clamp удержит лучше-города в 1.0; район беднее города
    → <1.0). None если district_poi None (в районе <3 ЖК с POI) ИЛИ city-baseline
    None/≤0 (нет нормализатора). Никогда не 0-как-заглушка.
    """
    if district_poi is None or city_avg_poi is None or city_avg_poi <= 0:
        return None
    return _clamp01(district_poi / city_avg_poi)


# ──────────────────────────────────────────────────────────────────────────────
# DB-оркестратор индексов — тонкий, graceful. Pure-нормализация выше тестируется без БД.
# ──────────────────────────────────────────────────────────────────────────────


def compute_location_indices(db: Session, district: str) -> LocationIndices:
    """Вычислить per-district индексы района (#948 §8.2) — PASS 1 материал для refresh.

    Переиспользует существующие per-district forecast-функции (#1129-кэшированы) и
    нормализует каждый локально-вычислимый результат к [0,1] (None при отсутствии данных
    — см. модульный docstring про нормализацию каждого индекса). НИКОГДА не фабрикует 0.

    demand_index НЕ вычисляется здесь: он CITY-RELATIVE (#948 fix) и требует опорной
    скорости по городу, доступной только после сбора всех районов. Возвращаем СЫРУЮ
    скорость района в raw_unit_velocity, а demand_index оставляем None (плейсхолдер);
    финальную city-relative нормировку проставляет refresh_locations (pass 2). Это
    единственный вызов compute_market_metrics на район (без O(n²)).

    Graceful: если источник бросает — соответствующий индекс = None (logged), остальные
    считаются. Никогда не валит весь расчёт из-за одного сбойного сигнала.

    Args:
        db: SQLAlchemy sync Session.
        district: имя района (одно из 8 админ-районов ЕКБ).

    Returns:
        LocationIndices: infra/competition/future_supply нормированы (или None);
        demand_index=None (заполнит pass 2); raw_unit_velocity = сырая скорость района
        (или None) для city-relative нормировки demand.
    """
    competition_index: float | None = None
    raw_unit_velocity: float | None = None
    future_supply_index: float | None = None
    infra_index: float | None = None

    # ── competition + raw demand-velocity ← market_metrics (ОДИН вызов, два сигнала) ─
    try:
        metrics = compute_market_metrics(db, district=district, window_months=_MARKET_WINDOW_MONTHS)
        # competition ← overstock_index (доступное/затоваренное конкурирующее
        # предложение) — ортогонален demand. НЕ sell_through (тот коррелирован с
        # velocity и инвертирован относительно «свободной конкуренции»).
        competition_index = normalize_competition(metrics.overstock_index)
        # demand нормируется city-relative в pass 2 — здесь только собираем сырьё.
        raw_unit_velocity = metrics.unit_velocity
    except Exception:
        logger.exception(
            "location_indices: market_metrics failed (district=%s) → competition/velocity None",
            district,
        )

    # ── future_supply ← future_supply (уже 0..1) ────────────────────────────────
    try:
        fsp = compute_future_supply_pressure(
            db, district=district, horizon_months=_FUTURE_SUPPLY_HORIZON_MONTHS
        )
        # index уже нормирован [0,1] самим сервисом — берём как есть (None passthrough).
        future_supply_index = fsp.index
    except Exception:
        logger.exception(
            "location_indices: future_supply failed (district=%s) → future_supply None",
            district,
        )

    # ── infra ← district POI / city POI ─────────────────────────────────────────
    try:
        district_poi = _district_poi_score(db, district_name=district)
        city_poi = _city_avg_poi_score(db)
        infra_index = normalize_infra(district_poi, city_poi)
    except Exception:
        logger.exception(
            "location_indices: infra (POI) failed (district=%s) → infra None", district
        )
        # Plain rollback OK here (unlike begin_nested at the pass-2 upsert loop below):
        # compute_location_indices только вызывается из refresh_locations, единственный
        # caller которой — Celery-таск location_refresh.py, открывающий СВОЙ SessionLocal()
        # (owned session, не shared request/report-scoped). На момент этого блока (pass 1,
        # read-only) в транзакции ещё нет незакоммиченных write'ов — откатывать нечего,
        # rollback() не сиротит внешний SessionTransaction (в отличие от velocity.py:170).
        # Без rollback аборченная Postgres-транзакция валит СЛЕДУЮЩИЙ район в цикле pass 1
        # (см. #2464) — как и все db.execute() в pass 2 upsert.
        db.rollback()

    logger.info(
        "location_indices: district=%s infra=%s competition=%s raw_velocity=%s future_supply=%s",
        district,
        _round_or_none(infra_index),
        _round_or_none(competition_index),
        _round_or_none(raw_unit_velocity),
        _round_or_none(future_supply_index),
    )

    return LocationIndices(
        infra_index=infra_index,
        competition_index=competition_index,
        demand_index=None,  # city-relative, заполнит pass 2 refresh_locations
        future_supply_index=future_supply_index,
        raw_unit_velocity=raw_unit_velocity,
    )


# ──────────────────────────────────────────────────────────────────────────────
# Refresh — upsert индексов по всем районам (SAVEPOINT per-row, CAST, ON CONFLICT).
# ──────────────────────────────────────────────────────────────────────────────

# centroid derive из ekb_districts_geom: ST_Centroid полигона района. Подзапрос —
# район может не иметь geom-строки → centroid NULL (graceful). ON CONFLICT по
# district_name (UNIQUE) обновляет индексы + centroid + indices_computed_at.
# psycopg v3: ВСЕ binds через CAST(:x AS type) — НИКОГДА :x::type.
_UPSERT_LOCATION_SQL = text(
    """
    INSERT INTO location (
        district_name, region, centroid,
        infra_index, competition_index, demand_index, future_supply_index,
        indices_computed_at, updated_at
    ) VALUES (
        CAST(:district_name AS text),
        CAST(:region AS text),
        (
            SELECT ST_Centroid(g.geom)
              FROM ekb_districts_geom g
             WHERE g.district_name = CAST(:district_name AS text)
             LIMIT 1
        ),
        CAST(:infra_index AS numeric),
        CAST(:competition_index AS numeric),
        CAST(:demand_index AS numeric),
        CAST(:future_supply_index AS numeric),
        now(),
        now()
    )
    ON CONFLICT (district_name) DO UPDATE SET
        region              = EXCLUDED.region,
        centroid            = EXCLUDED.centroid,
        infra_index         = EXCLUDED.infra_index,
        competition_index   = EXCLUDED.competition_index,
        demand_index        = EXCLUDED.demand_index,
        future_supply_index = EXCLUDED.future_supply_index,
        indices_computed_at = EXCLUDED.indices_computed_at,
        updated_at          = now()
    """
)


def refresh_locations(db: Session, *, region: str | None = None) -> dict[str, Any]:
    """Пересчитать + upsert-нуть индексы по всем 8 админ-районам ЕКБ (#948 §8.2).

    Two-pass (#948 fix — demand стал CITY-RELATIVE):
      • Pass 1: для каждого района считаем locally-вычислимые индексы
        (compute_location_indices) + собираем сырую unit_velocity. ОДИН вызов
        compute_market_metrics на район (без O(n²)).
      • Между проходами: city_reference_velocity = MAX среди собранных НЕ-None
        скоростей (опора для city-relative нормировки demand). None если ни у одного
        района нет выборки → demand=None у всех (graceful).
      • Pass 2: normalize_demand(velocity, city_reference) на район + upsert в `location`.

    Идемпотентно: ON CONFLICT (district_name) обновляет индексы + centroid +
    indices_computed_at (повторный прогон даёт тот же результат при тех же данных).

    SAVEPOINT per-row (backend.md): сбойный район откатывается изолированно через
    begin_nested, не роняя всю транзакцию и не сбивая счётчики (#261/pzz_loader bug).
    db.commit() один раз в конце.

    Args:
        db: SQLAlchemy sync Session.
        region: метка региона в колонку `location.region` (None = ЕКБ/Свердл.).

    Returns:
        Счётчики: {"districts": N, "upserted": M, "failed": K}.
    """
    districts = sorted(_admin_names(db))
    if not districts:
        logger.warning(
            "refresh_locations: district source empty (ekb_districts geom?) — nothing to refresh"
        )
        return {"districts": 0, "upserted": 0, "failed": 0}

    # ── Pass 1: per-district индексы + сырьё для city-relative demand ────────────
    # Сбой compute_location_indices по одному району не валит прогон: индексы=None,
    # район всё равно апсертится в pass 2 (graceful, как было).
    per_district: list[tuple[str, LocationIndices]] = []
    for district in districts:
        try:
            per_district.append((district, compute_location_indices(db, district)))
        except Exception as e:
            logger.warning(
                "refresh_locations: district=%s compute failed: %s — indices None", district, e
            )
            per_district.append((district, LocationIndices(None, None, None, None)))

    # ── City-reference: MAX скорость среди НЕ-None (опора demand-нормировки). ────
    # «Спрос относительно самого горячего района» — само-калибрующийся якорь, всегда
    # даёт спред (см. #948 fix в module docstring). None ⇒ ни у кого нет данных.
    velocities = [
        idx.raw_unit_velocity for _, idx in per_district if idx.raw_unit_velocity is not None
    ]
    city_reference_velocity: float | None = max(velocities) if velocities else None
    logger.info(
        "refresh_locations: city_reference_velocity=%s (from %d non-None of %d districts)",
        _round_or_none(city_reference_velocity),
        len(velocities),
        len(districts),
    )

    # ── Pass 2: city-relative demand + upsert (SAVEPOINT per-row) ───────────────
    upserted = 0
    failed = 0
    for district, indices in per_district:
        try:
            demand_index = normalize_demand(
                indices.raw_unit_velocity, city_reference_velocity=city_reference_velocity
            )
            with db.begin_nested():
                db.execute(
                    _UPSERT_LOCATION_SQL,
                    {
                        "district_name": district,
                        "region": region,
                        "infra_index": indices.infra_index,
                        "competition_index": indices.competition_index,
                        "demand_index": demand_index,
                        "future_supply_index": indices.future_supply_index,
                    },
                )
            upserted += 1
        except Exception as e:
            failed += 1
            logger.warning("refresh_locations: district=%s upsert failed: %s", district, e)

    db.commit()
    logger.info(
        "refresh_locations: districts=%d upserted=%d failed=%d",
        len(districts),
        upserted,
        failed,
    )
    return {"districts": len(districts), "upserted": upserted, "failed": failed}


# ──────────────────────────────────────────────────────────────────────────────
# Read-helpers для GET-API (read-only сущность — без CRUD).
# centroid не отдаём бинарём: ST_X/ST_Y → lon/lat (NULL если centroid NULL).
# ──────────────────────────────────────────────────────────────────────────────

_SELECT_COLS = """
    id, district_name, region,
    ST_X(centroid) AS lon, ST_Y(centroid) AS lat,
    infra_index, competition_index, demand_index, future_supply_index,
    indices_computed_at, created_at, updated_at
"""


def _row_to_out(r: Any) -> LocationOut:
    return LocationOut(
        id=int(r["id"]),
        district_name=r["district_name"],
        region=r["region"],
        lon=float(r["lon"]) if r["lon"] is not None else None,
        lat=float(r["lat"]) if r["lat"] is not None else None,
        infra_index=float(r["infra_index"]) if r["infra_index"] is not None else None,
        competition_index=(
            float(r["competition_index"]) if r["competition_index"] is not None else None
        ),
        demand_index=float(r["demand_index"]) if r["demand_index"] is not None else None,
        future_supply_index=(
            float(r["future_supply_index"]) if r["future_supply_index"] is not None else None
        ),
        indices_computed_at=r["indices_computed_at"],
        created_at=r["created_at"],
        updated_at=r["updated_at"],
    )


def list_locations(db: Any) -> LocationList:
    """Все локации с индексами (8 районов — пагинация не нужна, отдаём целиком)."""
    rows = (
        db.execute(text(f"SELECT {_SELECT_COLS} FROM location ORDER BY district_name"))
        .mappings()
        .all()
    )
    out = [_row_to_out(r) for r in rows]
    return LocationList(total=len(out), rows=out)


def get_location(db: Any, district_name: str) -> LocationOut | None:
    """Вернуть одну локацию по district_name. None если нет (роутер → 404)."""
    row = (
        db.execute(
            text(f"SELECT {_SELECT_COLS} FROM location WHERE district_name = :dn"),
            {"dn": district_name},
        )
        .mappings()
        .first()
    )
    if row is None:
        return None
    return _row_to_out(row)
