import logging
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from shapely.geometry import Point
from sqlalchemy import text
from sqlalchemy.orm import Session

from app.core.db import get_db
from app.schemas.concept import (
    ConceptInput,
    ConceptOutput,
    HouseTypeCatalog,
    HouseTypeCatalogItem,
    MassingProgram,
    MassingRecomputeOutput,
)
from app.services.generative import geometry
from app.services.generative.catalog import HOUSE_TYPES, available_section_types
from app.services.generative.financial import compute_financial
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
from app.services.generative.teap import synthesize_teap_from_program

logger = logging.getLogger(__name__)

router = APIRouter()

# Минимум объявлений Objective в районе для надёжной медианы цены продажи жилья.
# n>=10: с меньшей выборкой медиана шумна (1-2 нетипичных лота её перекашивают) и
# выдавать её за «рыночную цену района» нечестно. При n<10 честнее откатиться на
# справочную median_price_per_m2 района (ekb_districts, 24-мес окно), а при её
# отсутствии — на норматив класса. Порог зеркалит «осторожную выборку» из других
# district-агрегатов проекта (см. parcels.py district_price_block).
_MIN_OBJECTIVE_SAMPLE: int = 10

# Радиус поиска района от центроида участка (м). Зеркалит ST_DWithin 5 км из
# parcels.py district-context — участок может лежать у границы полигона района.
_DISTRICT_SEARCH_RADIUS_M: int = 5000

# Санитарные границы цены продажи, руб/кв.м — отсекают мусорные лоты Objective
# (price_per_m2_rub — это РУБЛИ, не тысячи). Те же пороги, что в parcels.py.
_PRICE_MIN_RUB: int = 30_000
_PRICE_MAX_RUB: int = 600_000

# Stage 2a (#1965): дефолтный лейбл source для ПРЕДРЕЗОЛВЛЕННОЙ цены в /recompute, когда
# фронт НЕ форвардит подлинный price_source. Консервативный fallback: цена пришла извне
# (фронт её уже резолвил), но без явного происхождения берём нейтральный district-median.
_DEFAULT_PRERESOLVED_SOURCE: str = "objective_district_median"

# Район ЕКБ (ekb_districts) ближайший к точке центроида + его справочная медиана цены.
# Binds через CAST(:name AS type) — psycopg v3 (постфикс-каст к bind-имени запрещён).
# ::geography приклеено к ) / колонке (разрешённое исключение). Read-only, без SAVEPOINT.
_DISTRICT_FOR_POINT_SQL = text(
    """
    SELECT d.district_name,
           d.median_price_per_m2
      FROM ekb_districts d
     WHERE d.geom IS NOT NULL
       AND ST_DWithin(
               d.geom::geography,
               ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
               CAST(:radius AS double precision)
           )
     ORDER BY ST_Distance(
                  d.geom::geography,
                  ST_GeomFromText(CAST(:wkt AS text), 4326)::geography
              ) ASC
     LIMIT 1
    """
)

# Медиана цены продажи жилья из объявлений Objective по району + размер выборки.
# price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3.
#
# #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти objective_lot_id на
# один физлот через пере-листинги) → sample_size и гейт n≥:_MIN_OBJECTIVE_SAMPLE
# были по пере-листингам, вес медианы смещался. Дедупим INLINE через DISTINCT ON
# (physflat-ключ, последний снапшот snapshot_date DESC, id DESC) — НЕ через общий
# physflat-VIEW. Причина (deep-review #1964, прод-EXPLAIN): view не несёт
# district-фильтр внутри, qual не проталкивается ниже DISTINCT ON → view сортирует
# ВСЮ таблицу 1.76M (~5.8 s, request-path внутри analyze_parcel). Inline с district
# В CTE → bitmap по району ~240k строк + sort (~1.7 s), индекс по district работает.
# Сегмент-фильтры (price NOT NULL / диапазон) объективны по физлоту → применяем
# ПОСЛЕ дедупа. Зеркало паттерна market_metrics._STOCK_SQL.
_OBJECTIVE_MEDIAN_SQL = text(
    """
    WITH latest AS (
        SELECT DISTINCT ON (
            project_name, corpus_name, section, floor, lot_number
        )
            price_per_m2_rub
          FROM objective_lots
         WHERE district = CAST(:dn AS text)
         ORDER BY project_name, corpus_name, section, floor, lot_number,
                  snapshot_date DESC, id DESC
    )
    SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2,
           COUNT(*)                                                      AS sample_size
      FROM latest
     WHERE price_per_m2_rub IS NOT NULL
       AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric)
    """
)


def _parcel_centroid_wkt(payload: ConceptInput) -> str:
    """Центроид полигона участка как WKT-точка (WGS84) для spatial-lookup."""
    polygon = _parse_polygon(payload.parcel_geojson)
    c = polygon.centroid
    wkt: str = Point(c.x, c.y).wkt
    return wkt


def _lookup_market_price(db: Session, wkt_point: str) -> tuple[float | None, str]:
    """Рыночная цена продажи жилья, руб/кв.м, для участка по его центроиду + источник.

    Алгоритм (от точного к общему):
      1. Центроид → ближайший район ЕКБ (ekb_districts, ST_DWithin 5 км).
      2. Медиана Objective по этому району (price_per_m2_rub, n>=:_MIN_OBJECTIVE_SAMPLE)
         → ("objective_district_median"). Свежайший рыночный сигнал.
      3. Иначе справочная ekb_districts.median_price_per_m2 → ("district_reference").
      4. Иначе (нет района / нет данных) → (None, "class_norm") — финмодель возьмёт
         норматив класса.

    Никогда не роняет генерацию концепции: ЛЮБАЯ ошибка (вне ЕКБ, нет PostGIS, сбой
    БД, нет таблицы) логируется warning'ом и деградирует в (None, "class_norm").
    Read-only — SAVEPOINT не нужен.
    """
    try:
        district_row = (
            db.execute(
                _DISTRICT_FOR_POINT_SQL,
                {"wkt": wkt_point, "radius": _DISTRICT_SEARCH_RADIUS_M},
            )
            .mappings()
            .first()
        )
    except Exception as exc:
        logger.warning("concept market-price: district lookup failed, fallback class_norm: %s", exc)
        return None, "class_norm"

    if not district_row or not district_row["district_name"]:
        logger.info("concept market-price: parcel outside ЕКБ districts → class_norm")
        return None, "class_norm"

    district_name: str = district_row["district_name"]
    district_reference = district_row["median_price_per_m2"]

    # 2) Свежайший сигнал — медиана объявлений Objective по району (n>=порог).
    try:
        obj_row = (
            db.execute(
                _OBJECTIVE_MEDIAN_SQL,
                {"dn": district_name, "lo": _PRICE_MIN_RUB, "hi": _PRICE_MAX_RUB},
            )
            .mappings()
            .first()
        )
    except Exception as exc:
        logger.warning(
            "concept market-price: objective median failed (district=%s), "
            "trying district reference: %s",
            district_name,
            exc,
        )
        obj_row = None

    if (
        obj_row
        and obj_row["sample_size"]
        and int(obj_row["sample_size"]) >= _MIN_OBJECTIVE_SAMPLE
        and obj_row["median_ppm2"]
    ):
        price = float(obj_row["median_ppm2"])
        logger.info(
            "concept market-price: objective median %.0f руб/м² (district=%s, n=%d)",
            price,
            district_name,
            int(obj_row["sample_size"]),
        )
        return price, "objective_district_median"

    # 3) Справочная медиана района (24-мес окно ekb_districts).
    if district_reference is not None:
        price = float(district_reference)
        logger.info(
            "concept market-price: district reference %.0f руб/м² (district=%s, n<%d)",
            price,
            district_name,
            _MIN_OBJECTIVE_SAMPLE,
        )
        return price, "district_reference"

    logger.info(
        "concept market-price: district=%s has no price reference → class_norm", district_name
    )
    return None, "class_norm"


@router.get("/house-types", response_model=HouseTypeCatalog)
async def list_house_types() -> HouseTypeCatalog:
    """Stage 3b (#1965, эпик #1953) — каталог типовых домов для фронт-пикера.

    Read-only проекция ``catalog.HOUSE_TYPES`` (single source of truth по типам
    секций): фронт Stage 3b показывает эти типы в режиме «Выбрать дома» и кладёт
    выбранные ``section_type`` в ``ConceptInput.building_program``. Без БД, без
    side-effects — справочник захардкожен в коде (см. модуль ``catalog``). Так
    фронт НЕ хардкодит габариты/этажности — они приходят отсюда.
    """
    return HouseTypeCatalog(
        house_types=[
            HouseTypeCatalogItem(
                section_type=ht.section_type,
                label_ru=ht.label_ru,
                footprint_w_m=ht.footprint_w_m,
                footprint_d_m=ht.footprint_d_m,
                footprint_sqm=ht.footprint_sqm,
                default_floors=ht.default_floors,
                housing_class=ht.housing_class,
            )
            for ht in HOUSE_TYPES
        ]
    )


@router.post("", response_model=ConceptOutput)
async def create_concept(
    payload: ConceptInput,
    db: Annotated[Session, Depends(get_db)],
) -> ConceptOutput:
    """Generate 3 building variants for the given parcel polygon.

    Stage 1a: Shapely parse + buildable area (setback) + placement grid.
    Stage 1b: greedy section placement with STRtree collisions (3 strategies).
    Stage 1c: real ТЭП + financial model attached to each variant. The housing
    sale price is calibrated to real market data (Objective / district reference)
    when available, falling back to the per-class norm with an honest source flag
    (PR-2, эпик #1881).

    A degenerate parcel (setback consumes everything, malformed geometry) yields a
    422 rather than empty variants — that is a bad request, not a valid empty result.

    Stage 3a (#1965): если задана ``building_program`` (типовые дома из каталога), кладём
    РОВНО эту программу вместо жадной max-FAR раскладки (один вариант). Неизвестный
    ``section_type`` → 422 (валидируется тут, до размещения). Если участок не вмещает всю
    программу — НЕ 422: кладём сколько влезло и отдаём честный ``placed_count`` < requested.
    """
    # Stage 3a: валидируем ключи программы по каталогу ДО размещения — неизвестный
    # section_type это bad request (422), а не 500 из KeyError в глубине placement.
    if payload.building_program:
        known = available_section_types()
        unknown = sorted({item.section_type for item in payload.building_program} - known)
        if unknown:
            raise HTTPException(
                status_code=422,
                detail=(
                    f"unknown house type(s): {', '.join(unknown)}; "
                    f"available: {', '.join(sorted(known))}"
                ),
            )

    # Рыночную цену продажи жилья считаем ОДИН раз на участок (она едина для всех
    # стратегий). DB-lookup — синхронный SQLAlchemy → run_in_threadpool, чтобы не
    # блокировать event loop. Падение lookup'а не должно ронять генерацию.
    market_price: float | None = None
    price_source: str = "class_norm"
    try:
        wkt_point = await run_in_threadpool(_parcel_centroid_wkt, payload)
        market_price, price_source = await run_in_threadpool(_lookup_market_price, db, wkt_point)
    except ParcelGeometryError:
        # Невалидная геометрия — пусть geometry.generate поднимет её ниже (один 422).
        pass
    except Exception as exc:
        # Защитный пояс: любая иная ошибка lookup'а → норматив класса, не 500.
        logger.warning("concept market-price: unexpected lookup error, class_norm: %s", exc)

    try:
        # geometry.generate — синхронный CPU-bound (Shapely/STRtree), мостим через
        # run_in_threadpool, чтобы НЕ блокировать event loop (тот же приём, что и в chat.py).
        variants = await run_in_threadpool(
            geometry.generate,
            payload,
            market_price_per_sqm=market_price,
            price_source=price_source,
        )
    except ParcelGeometryError as exc:
        logger.warning("concept generation rejected parcel: %s", exc)
        raise HTTPException(status_code=422, detail=str(exc)) from exc
    return ConceptOutput(variants=variants)


@router.post("/recompute", response_model=MassingRecomputeOutput)
async def recompute_massing(
    payload: MassingProgram,
    db: Annotated[Session, Depends(get_db)],
) -> MassingRecomputeOutput:
    """Stage 2a (#1965, эпик #1953) — LIVE-пересчёт экономики из 3D-массинга.

    Hot, debounced-эндпоинт для интерактивных слайдеров Stage 2b: фронтовый
    ``computeModel`` отдаёт агрегированную программу (суммарное пятно × этажность),
    мы синтезируем :class:`TEAP` (``synthesize_teap_from_program`` — те же нормативные
    константы, что и обычная генерация) и прогоняем готовый чистый ``compute_financial``
    → пересчитанный ТЭП + финмодель.

    Цена продажи жилья:
      * ``market_price_per_sqm`` в теле → используется как есть (FAST path, БЕЗ БД) —
        фронт резолвит цену один раз и шлёт её в каждом keystroke-запросе. Источник
        берём из ``payload.price_source`` (ПОДЛИННЫЙ source, который фронт получил из
        financial_estimate — objective_geo_radius / objective_district_median /
        district_reference / class_norm); если фронт его не прислал — дефолтный лейбл.
        НЕ хардкодим source — иначе honest-флаг в UI/PDF врёт о происхождении цены.
      * иначе, если передан ``parcel_centroid_wkt`` → ``_lookup_market_price`` по центроиду
        через ``run_in_threadpool`` (тот же приём, что и в ``create_concept``; sync SQLAlchemy
        не должен блокировать event loop). Падение lookup'а → норматив класса, не 500.
      * иначе (нет ни цены, ни центроида) → норматив класса (``price_source="class_norm"``).
    """
    # ── Цена продажи: предрезолвленная (fast) → DB-fallback → норматив класса ───────
    market_price: float | None = payload.market_price_per_sqm
    price_source: str = "class_norm"
    if market_price is not None:
        # FAST path: фронт уже резолвил рыночную цену — НЕ ходим в БД на каждый keystroke.
        # Используем ПОДЛИННЫЙ source, который фронт форвардит из financial_estimate; если
        # его нет — дефолтный лейбл. (При market_price=None compute_financial всё равно
        # форсит class_norm, но сюда мы попадаем только при заданной цене.)
        price_source = payload.price_source or _DEFAULT_PRERESOLVED_SOURCE
    elif payload.parcel_centroid_wkt:
        # Fallback: резолвим по центроиду. Sync-lookup → threadpool (как в create_concept).
        # Любая ошибка → норматив класса (graceful, без 500) — деградация уже внутри
        # _lookup_market_price, защитный пояс на непредвиденное оставляем тут.
        try:
            market_price, price_source = await run_in_threadpool(
                _lookup_market_price, db, payload.parcel_centroid_wkt
            )
        except Exception as exc:
            logger.warning("recompute market-price: unexpected lookup error, class_norm: %s", exc)
            market_price, price_source = None, "class_norm"

    teap = synthesize_teap_from_program(
        total_footprint_sqm=payload.total_footprint_sqm,
        floors=payload.floors,
        site_area_sqm=payload.site_area_sqm,
        housing_class=payload.housing_class,
        sections=payload.sections,
    )
    # compute_financial — чистая, CPU-bound, лёгкая (без БД) → можно вызывать прямо в
    # event loop (hot path; threadpool-overhead тут не нужен). price_source игнорируется
    # внутри, когда market_price_per_sqm is None (форсится class_norm) — корректно.
    financial = compute_financial(
        teap=teap,
        housing_class=payload.housing_class,
        land_cost_rub=payload.land_cost_rub,
        market_price_per_sqm=market_price,
        price_source=price_source,
        development_type=payload.development_type,
    )
    return MassingRecomputeOutput(teap=teap, financial=financial)
