"""Generative Design — Stage 1b: greedy section placement with STRtree collisions.

Given a parsed :class:`~app.services.generative.geometry.Parcel` (Stage 1a) we place
rectangular residential sections (секции МКД) onto the placement grid using a greedy
sweep. Three strategies trade plot density against insolation comfort:

* ``max_area``       — tight gaps, deep building footprint -> maximum buildable area.
* ``max_insolation`` — wide gaps + slimmer footprint -> light/air between buildings.
* ``balanced``       — the middle ground.

Collisions (overlap + minimum inter-section gap) are checked with a Shapely STRtree
spatial index, rebuilt as placements accumulate. The greedy sweep is fully
deterministic: candidate anchors are visited in a fixed grid order and the first
non-colliding footprint that stays inside the buildable area wins.

Each placed footprint is reprojected back to WGS84 and emitted as a GeoJSON Feature
in ``ConceptVariant.buildings_geojson``; the metric footprints feed Stage 1c
(``teap`` + ``financial``) so the variant is filled with real numbers, not zeros.

Deterministic, no LLM / no external API / no DB.
"""

from __future__ import annotations

import logging
import math
from dataclasses import dataclass

from shapely.geometry import Polygon, box
from shapely.strtree import STRtree

from app.schemas.concept import (
    TEAP,
    BuildingProgramItem,
    ConceptInput,
    ConceptVariant,
)
from app.services.generative import catalog, financial, teap
from app.services.generative.geometry import Parcel, ParcelGeometryError

logger = logging.getLogger(__name__)

# Тип стратегии должен совпадать с Literal в ConceptVariant.strategy.
StrategyName = str

# Высота этажа (м) — для перевода target_floors в метрическую высоту/площади.
FLOOR_HEIGHT_M: float = 3.0

# ── Потолок коэффициента застройки (built / buildable) по типу застройки ──────
# Контракт не несёт явного FAR/max_coverage, поэтому используем development_type как
# естественный регулятор плотности. Жадная раскладка перестаёт ставить секции, как
# только пятно достигает доли buildable area ниже. Без этого max_area патологически
# забивает участок и даёт нереалистичный FAR. MVP-упрощение (нормативный proxy).
_COVERAGE_CAP_BY_TYPE: dict[str, float] = {
    "spot": 0.35,  # точечная застройка — низкое покрытие
    "mid_rise": 0.45,  # среднеэтажная
    "high_rise": 0.50,  # высотная — компактнее пятно, выше этажность
}
_DEFAULT_COVERAGE_CAP: float = 0.45


@dataclass(frozen=True)
class StrategySpec:
    """Параметры одной стратегии размещения.

    section_w/section_d — габариты секции (ширина x глубина), метры.
    gap_m — минимальный разрыв между секциями (инсоляция/противопожарный), метры.
    floors_factor — множитель к target_floors (комфорт-класс «садит» этажность,
        макс-площадь «тянет» вверх); этажность клампится к [1, 30] контракта.
    """

    name: StrategyName
    section_w: float
    section_d: float
    gap_m: float
    floors_factor: float


# ── Три стратегии (упрощённо для MVP, габариты типовых панельных/монолитных секций) ──
_STRATEGIES: tuple[StrategySpec, ...] = (
    # Максимум площади: глубокий корпус, минимальные противопожарные разрывы.
    StrategySpec(name="max_area", section_w=24.0, section_d=18.0, gap_m=6.0, floors_factor=1.15),
    # Максимум инсоляции: тонкий корпус, широкие разрывы между секциями.
    StrategySpec(
        name="max_insolation", section_w=18.0, section_d=12.0, gap_m=15.0, floors_factor=0.85
    ),
    # Баланс.
    StrategySpec(name="balanced", section_w=21.0, section_d=15.0, gap_m=10.0, floors_factor=1.0),
)

_FLOORS_MIN = 1
_FLOORS_MAX = 30


def _resolve_floors(target_floors: int, factor: float) -> int:
    """target_floors * factor, округление к ближайшему, клампинг к [1, 30]."""
    floors = round(target_floors * factor)
    return max(_FLOORS_MIN, min(_FLOORS_MAX, floors))


class _Placer:
    """Аккумулятор размещённых секций + STRtree-индекс для проверки разрывов.

    Извлечён из жадной раскладки, чтобы и :func:`_greedy_place` (coverage-cap sweep), и
    :func:`place_program` (фиксированная программа типовых домов, Stage 3a) пользовались
    ОДНОЙ И ТОЙ ЖЕ collision/setback-машиной, а не дублировали её. Состояние: принятые
    footprints, их буферизованные на ``half_gap`` версии и перестраиваемый по ним STRtree.
    """

    def __init__(self) -> None:
        self.placed: list[Polygon] = []
        self.built_area: float = 0.0
        # Буферизованные footprints для проверки разрыва; индекс STRtree по ним.
        self._buffered: list[Polygon] = []
        self._tree: STRtree | None = None

    def try_place(self, footprint: Polygon, buildable: Polygon, half_gap: float) -> bool:
        """Попытаться принять ``footprint``. True — принят, False — не лёг.

        Принимается, если целиком внутри ``buildable`` (covers допускает касание границы)
        И не нарушает разрыв ``half_gap*2`` с уже принятыми (буферим кандидата на
        ``half_gap`` и проверяем пересечение с буферизованными соседями через STRtree —
        две секции с зазором >= gap не пересекутся).
        """
        if not buildable.covers(footprint):
            return False

        candidate_buf = footprint.buffer(half_gap, join_style="mitre")
        if self._tree is not None:
            for idx in self._tree.query(candidate_buf):
                if candidate_buf.intersects(self._buffered[idx]):
                    return False

        self.placed.append(footprint)
        self.built_area += footprint.area
        self._buffered.append(candidate_buf)
        self._tree = STRtree(self._buffered)
        return True


def _centered_footprint(cx: float, cy: float, width: float, depth: float) -> Polygon:
    """Прямоугольное пятно секции ``width × depth``, центрированное на (cx, cy), метры."""
    half_w = width / 2.0
    half_d = depth / 2.0
    return box(cx - half_w, cy - half_d, cx + half_w, cy + half_d)


def _greedy_place(
    parcel: Parcel,
    spec: StrategySpec,
    coverage_cap: float,
) -> list[Polygon]:
    """Жадно разложить секции ``spec`` по сетке участка. Возвращает footprints (метры).

    Алгоритм:
      * кандидат-якоря — центры ячеек сетки в фиксированном порядке;
      * footprint строится центрированно на якоре;
      * принимается, если целиком внутри buildable area И не нарушает разрыв ``gap_m``
        с уже принятыми (проверка через STRtree, см. :class:`_Placer`);
      * раскладка останавливается, когда пятно достигает ``coverage_cap`` от buildable
        area (регулятор плотности по типу застройки) — это также ограничивает число
        размещений и держит O(n^2)-перестройку STRtree в бюджете.
    """
    buildable = parcel.buildable_m
    max_built = buildable.area * coverage_cap
    placer = _Placer()
    half_gap = spec.gap_m / 2.0

    for cell in parcel.grid:
        if placer.built_area >= max_built:
            break
        footprint = _centered_footprint(cell.cx, cell.cy, spec.section_w, spec.section_d)
        placer.try_place(footprint, buildable, half_gap)

    logger.info(
        "strategy=%s placed %d sections (%.0fx%.0f m, gap=%.0f m, coverage<=%.0f%%)",
        spec.name,
        len(placer.placed),
        spec.section_w,
        spec.section_d,
        spec.gap_m,
        coverage_cap * 100,
    )
    return placer.placed


def _footprints_to_geojson(
    parcel: Parcel,
    footprints: list[Polygon],
    floors: int,
    spec: StrategySpec,
) -> dict[str, object]:
    """Метрические footprints -> WGS84 FeatureCollection (контракт buildings_geojson)."""
    features: list[dict[str, object]] = []
    for i, fp in enumerate(footprints):
        geom_wgs = parcel.metric_geom_to_wgs84(fp)
        features.append(
            {
                "type": "Feature",
                "geometry": geom_wgs,
                "properties": {
                    "section_id": i + 1,
                    "floors": floors,
                    "footprint_sqm": round(float(fp.area), 1),
                    "strategy": spec.name,
                },
            }
        )
    return {"type": "FeatureCollection", "features": features}


# ── Stage 3a (#1965): program-driven placement (типовые дома вместо max-FAR sweep) ──
# Разрыв между секциями в program-режиме (м). Пункт программы не несёт gap (контракт —
# только тип/этажность/количество), поэтому берём один нормативный противопожарный/
# инсоляционный зазор для всех секций программы — середина диапазона стратегий 1b
# (max_area gap=6 … max_insolation gap=15). Достаточно консервативно для реалистичной
# раскладки, не патологически разрежено.
_PROGRAM_GAP_M: float = 10.0


@dataclass(frozen=True)
class _PlacedSection:
    """Одна размещённая секция программы: пятно + этажность + тип каталога (для GeoJSON)."""

    footprint: Polygon
    floors: int
    section_type: str


@dataclass(frozen=True)
class PlacedProgram:
    """Результат program-раскладки: что легло + честный счётчик «N из M».

    ``sections``        — размещённые секции (пятна + этажность + тип) в порядке программы.
    ``requested_count`` — сколько секций просили (Σ count по программе).
    ``placed_count``    — сколько реально влезло (== len(sections)). placed < requested →
        участок мал, разместилось N из M (без hard-422 — честный сигнал для Stage 3b).
    """

    sections: tuple[_PlacedSection, ...]
    requested_count: int
    placed_count: int


def place_program(
    parcel: Parcel,
    program: list[BuildingProgramItem],
    *,
    gap_m: float = _PROGRAM_GAP_M,
) -> PlacedProgram:
    """Stage 3a: разложить РОВНО заданную программу типовых домов на участок.

    Для каждого пункта программы (``section_type`` из каталога × ``count`` секций) кладём
    до ``count`` секций каталожного пятна на сетку участка, переиспользуя ТУ ЖЕ
    collision/STRtree/setback-машину, что и жадная раскладка (:class:`_Placer`) — никакого
    coverage-cap, стоп-критерий = достигнут ``count`` для пункта или кончились свободные
    якоря. Пункты обрабатываются по порядку; накопленные секции участвуют в проверке
    разрыва для последующих (общий :class:`_Placer`).

    Если участок не вмещает все запрошенные секции, НЕ роняем 422 — кладём сколько влезло
    и возвращаем честный ``placed_count``/``requested_count`` (разместилось N из M).

    Raises:
        KeyError: ``section_type`` пункта нет в каталоге (валидируется на API-слое до
            размещения; здесь это программная ошибка контракта, не пользовательский ввод).
    """
    buildable = parcel.buildable_m
    half_gap = gap_m / 2.0
    placer = _Placer()
    placed_sections: list[_PlacedSection] = []
    requested = 0

    for item in program:
        house = catalog.get_house_type(item.section_type)
        requested += item.count
        # Ручное пятно (Stage 3c): если пользователь задал ОБА габарита — кладём
        # его вместо каталожного («вписать пятно»). Частичное задание игнорируем
        # (нужны и ширина, и глубина), падая обратно на каталог.
        if item.footprint_w_m is not None and item.footprint_d_m is not None:
            fp_w, fp_d = item.footprint_w_m, item.footprint_d_m
        else:
            fp_w, fp_d = house.footprint_w_m, house.footprint_d_m
        placed_for_item = 0
        for cell in parcel.grid:
            if placed_for_item >= item.count:
                break
            footprint = _centered_footprint(cell.cx, cell.cy, fp_w, fp_d)
            if placer.try_place(footprint, buildable, half_gap):
                placed_sections.append(
                    _PlacedSection(
                        footprint=footprint,
                        floors=item.floors,
                        section_type=item.section_type,
                    )
                )
                placed_for_item += 1
        if placed_for_item < item.count:
            logger.warning(
                "program: type=%s placed %d of %d sections (%.0fx%.0f m) — участок мал",
                item.section_type,
                placed_for_item,
                item.count,
                house.footprint_w_m,
                house.footprint_d_m,
            )

    result = PlacedProgram(
        sections=tuple(placed_sections),
        requested_count=requested,
        placed_count=len(placed_sections),
    )
    logger.info(
        "program placed %d of %d sections across %d type(s)",
        result.placed_count,
        result.requested_count,
        len(program),
    )
    return result


def _placed_program_to_geojson(
    parcel: Parcel,
    sections: tuple[_PlacedSection, ...],
) -> dict[str, object]:
    """Размещённые секции программы -> WGS84 FeatureCollection (контракт buildings_geojson).

    Зеркалит :func:`_footprints_to_geojson`, но каждая секция несёт СВОИ floors и тип
    каталога (program-режим смешивает типы/этажности), а ``strategy`` помечается
    ``"program"`` — маркер, что вариант построен из программы, а не из 1b-стратегии.
    """
    features: list[dict[str, object]] = []
    for i, sec in enumerate(sections):
        geom_wgs = parcel.metric_geom_to_wgs84(sec.footprint)
        features.append(
            {
                "type": "Feature",
                "geometry": geom_wgs,
                "properties": {
                    "section_id": i + 1,
                    "floors": sec.floors,
                    "footprint_sqm": round(float(sec.footprint.area), 1),
                    "section_type": sec.section_type,
                    "strategy": "program",
                },
            }
        )
    return {"type": "FeatureCollection", "features": features}


def _aggregate_program_teap(
    sections: tuple[_PlacedSection, ...],
    *,
    site_area_sqm: float,
    housing_class: teap.HousingClass,
) -> TEAP:
    """Свести размещённую программу (СМЕШАННАЯ этажность) в один :class:`TEAP` — ТОЧНО.

    ``compute_teap`` берёт ОДНУ этажность на список пятен, поэтому при смешанной по типам
    этажности нельзя просто скормить ему все пятна с одним числом (округлённая «средняя»
    этажность даёт дрейф GFA ~1%). Вместо этого группируем секции по этажности, считаем
    ``compute_teap`` для каждой однородной группы и СУММИРУЕМ результаты:

    * built / GFA / office / residential — аддитивны → сумма точна (GFA = Σ площадь_i×floors_i);
    * apartments — ``Σ floor(жилая_g / avg)`` по группам: физически корректнее, чем
      ``floor(Σжилая / avg)`` (нельзя «склеивать» дробные квартиры между корпусами);
    * parking — пересчитываем от ИТОГОВОГО числа квартир по той же норме класса (ceil от
      суммы, а не сумма ceil — иначе пер-группное округление вверх задвоит места);
    * density (FAR) — от суммарной GFA и площади участка (защита от деления на ноль).

    Единый источник всех нормативных коэффициентов остаётся ``teap``-модуль (классовые
    словари), новых магических чисел нет.
    """
    if not sections:
        return teap.compute_teap(
            footprints=[], floors=0, site_area_sqm=site_area_sqm, housing_class=housing_class
        )

    # Группируем по этажности; внутри группы compute_teap корректен (одна этажность).
    groups: dict[int, list[Polygon]] = {}
    for sec in sections:
        groups.setdefault(sec.floors, []).append(sec.footprint)

    built = 0.0
    gfa = 0.0
    office = 0.0
    residential = 0.0
    apartments = 0
    for floors, fps in groups.items():
        # site_area_sqm здесь не важна для аддитивных полей — FAR пересчитаем в конце.
        group = teap.compute_teap(
            footprints=fps,
            floors=floors,
            site_area_sqm=site_area_sqm,
            housing_class=housing_class,
        )
        built += group.built_area_sqm
        gfa += group.total_floor_area_sqm
        office += group.office_area_sqm
        residential += group.residential_area_sqm
        apartments += group.apartments_count

    density = gfa / site_area_sqm if site_area_sqm > 0 else 0.0
    parking_norm = teap._PARKING_PER_APARTMENT[housing_class]
    parking_spaces = math.ceil(apartments * parking_norm)

    return TEAP(
        built_area_sqm=round(built, 1),
        total_floor_area_sqm=round(gfa, 1),
        office_area_sqm=round(office, 1),
        residential_area_sqm=round(residential, 1),
        apartments_count=apartments,
        density=round(density, 3),
        parking_spaces=parking_spaces,
    )


def place_program_variant(
    parcel: Parcel,
    payload: ConceptInput,
    *,
    market_price_per_sqm: float | None = None,
    price_source: str = "class_norm",
) -> ConceptVariant | None:
    """Stage 3a: построить ОДИН вариант из ``payload.building_program`` (типовые дома).

    Раскладывает программу (:func:`place_program`), сводит размещённые пятна в ТЭП
    (:func:`_aggregate_program_teap` — точная GFA по группам этажности) и финмодель, и
    наклеивает честный сигнал частичного размещения (``placed_count``/``requested_count``).
    Возвращает ``None``, если ни одна секция не легла (участок не вмещает даже одну секцию
    программы) — вызывающий отбракует, как и в жадном пути.

    ``payload.building_program`` ДОЛЖЕН быть задан (вызывается только из program-ветки).
    """
    program = payload.building_program
    if not program:  # защитный инвариант: эту ветку зовут только при заданной программе
        raise ValueError("place_program_variant called without building_program")

    placed = place_program(parcel, program)
    if placed.placed_count == 0:
        logger.warning(
            "program placed 0 of %d sections — участок не вмещает программу, отбраковка",
            placed.requested_count,
        )
        return None

    teap_result = _aggregate_program_teap(
        placed.sections,
        site_area_sqm=parcel.site_area_sqm,
        housing_class=payload.housing_class,
    )
    financial_result = financial.compute_financial(
        teap=teap_result,
        housing_class=payload.housing_class,
        land_cost_rub=payload.land_cost_rub,
        market_price_per_sqm=market_price_per_sqm,
        price_source=price_source,
        development_type=payload.development_type,
    )
    buildings_geojson = _placed_program_to_geojson(parcel, placed.sections)

    # Program-вариант репортуется под "balanced" (контракт strategy — фиксированный
    # Literal трёх стратегий 1b; program-режим не вводит новую стратегию, маркер режима
    # лежит в properties.strategy="program" каждой фичи GeoJSON). Один вариант на программу.
    return ConceptVariant(
        strategy="balanced",
        buildings_geojson=buildings_geojson,
        teap=teap_result,
        financial=financial_result,
        placed_count=placed.placed_count,
        requested_count=placed.requested_count,
    )


def place_strategy(
    parcel: Parcel,
    payload: ConceptInput,
    spec: StrategySpec,
    *,
    market_price_per_sqm: float | None = None,
    price_source: str = "class_norm",
) -> ConceptVariant | None:
    """Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant.

    Возвращает ``None``, если ни одна секция не легла в пятно застройки (узкий/мелкий
    участок, footprint стратегии целиком не помещается). Без этого вырожденный вариант
    с нулевым размещением (revenue=0, margin=-land, IRR<0) выдавался бы как валидный —
    ложь в отчёте. Отбраковку делает вызывающий :func:`place_all_strategies`.

    ``market_price_per_sqm`` / ``price_source`` прокидываются в :func:`compute_financial`
    для калибровки цены продажи жилья по рынку (PR-2). Lookup делает API-слой; здесь
    только проброс. ``None`` → норматив класса.
    """
    floors = _resolve_floors(payload.target_floors, spec.floors_factor)
    coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP)
    footprints = _greedy_place(parcel, spec, coverage_cap)
    if not footprints:
        logger.warning(
            "strategy=%s placed 0 sections (footprint %.0fx%.0f m not buildable) — отбраковка",
            spec.name,
            spec.section_w,
            spec.section_d,
        )
        return None

    teap_result = teap.compute_teap(
        footprints=footprints,
        floors=floors,
        site_area_sqm=parcel.site_area_sqm,
        housing_class=payload.housing_class,
    )
    financial_result = financial.compute_financial(
        teap=teap_result,
        housing_class=payload.housing_class,
        land_cost_rub=payload.land_cost_rub,
        market_price_per_sqm=market_price_per_sqm,
        price_source=price_source,
        # development_type задаёт длительность СМР в DCF-графике финмодели (PR-3).
        development_type=payload.development_type,
    )
    buildings_geojson = _footprints_to_geojson(parcel, footprints, floors, spec)

    # spec.name строится из фиксированного литерала -> совпадает с Literal контракта.
    return ConceptVariant(
        strategy=spec.name,  # type: ignore[arg-type]
        buildings_geojson=buildings_geojson,
        teap=teap_result,
        financial=financial_result,
    )


def place_all_strategies(
    parcel: Parcel,
    payload: ConceptInput,
    *,
    market_price_per_sqm: float | None = None,
    price_source: str = "class_norm",
) -> list[ConceptVariant]:
    """Stage 1b entry: построить три варианта (max_area / max_insolation / balanced).

    Вырожденные стратегии (нулевое размещение) отбраковываются — в результат попадают
    только варианты с реальными секциями. Если ни одна стратегия не легла (участок не
    вмещает даже самую компактную секцию), это вырожденный участок: поднимаем
    :class:`ParcelGeometryError` (API мапит в 422) — лучше отказ, чем пустой/лживый ответ.

    ``market_price_per_sqm`` / ``price_source`` (рыночная калибровка цены жилья, PR-2)
    прокидываются неизменными в каждую стратегию — цена едина для участка.

    Stage 3a (#1965): если задана ``payload.building_program`` — раскладываем РОВНО эту
    программу типовых домов (:func:`place_program_variant`, один вариант), а НЕ три жадные
    стратегии. ``building_program is None`` → существующий жадный путь без изменений.
    """
    if payload.building_program:
        program_variant = place_program_variant(
            parcel,
            payload,
            market_price_per_sqm=market_price_per_sqm,
            price_source=price_source,
        )
        if program_variant is None:
            raise ParcelGeometryError(
                "программа застройки не вместила ни одной секции — "
                "участок слишком узкий/мелкий для выбранных типов домов"
            )
        logger.info(
            "placed program variant: %d of %d sections, %dкв",
            program_variant.placed_count,
            program_variant.requested_count,
            program_variant.teap.apartments_count,
        )
        return [program_variant]

    variants = [
        variant
        for spec in _STRATEGIES
        if (
            variant := place_strategy(
                parcel,
                payload,
                spec,
                market_price_per_sqm=market_price_per_sqm,
                price_source=price_source,
            )
        )
        is not None
    ]
    if not variants:
        raise ParcelGeometryError(
            "ни одна стратегия размещения не вместила секцию — участок слишком узкий/мелкий"
        )
    logger.info(
        "placed all strategies: %s",
        ", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants),
    )
    return variants


__all__ = [
    "FLOOR_HEIGHT_M",
    "PlacedProgram",
    "StrategySpec",
    "place_all_strategies",
    "place_program",
    "place_program_variant",
    "place_strategy",
]
