"""Оптимизатор программы застройки — топ-N вариантов (класс × этажность) по NPV (#2181).

Когда финмодель участка (``financial_estimate`` в ``/analyze``) даёт ОТРИЦАТЕЛЬНЫЙ
вердикт (NPV < 0 или ROI < 0), девелоперу мало «не выгодно» — важно честно показать,
существует ли ВООБЩЕ прибыльная раскладка на этом участке при других параметрах
(класс жилья + этажность), и если нет — какие варианты «наименее убыточные».

Этот модуль перебирает небольшую сетку (класс × этажность), прогоняет каждый комбо
через ту же :func:`compute_financial`, что и основная финмодель, и возвращает топ-N по
NPV. Всё детерминированно и **быстро** (≈15 мс на 12 комбо → считается СИНХРОННО в теле
запроса, без Celery), и **чисто** — без БД / LLM / внешних API. Данные участка
(площадь, предельные параметры зоны, откалиброванная цена, стоимость земли, абсорбция)
прокидывает caller — ровно те же входы, что использованы для основного
``financial_estimate`` (см. ``synthesize_parcel_financial``).

КРИТИЧНО — ЕДИНАЯ ЦЕНА ВО ВСЕХ КОМБО
------------------------------------
Когда рыночная цена известна, ОДНА И ТА ЖЕ откалиброванная ``market_price_per_sqm`` идёт
во все комбо (класс двигает ТОЛЬКО себестоимость/эффективность/паркинг/ср.лот, но НЕ
цену продажи). Иначе «business» ложно выиграл бы за счёт норматива продажи 210k против
110k у эконома — сравнивали бы цены, а не экономику застройки. Норматив класса
(``_SALE_PRICE_PER_SQM``) применяется ТОЛЬКО когда рынка нет — то же поведение, что в
:func:`synthesize_parcel_financial`.

HONEST CAVEAT
-------------
Как и основная финмодель — это оценка по МАКСИМАЛЬНОЙ застройке зоны (синтез ТЭП из
предельных параметров градрегламента), а НЕ по реальной проектной раскладке. Caveat
(``_FINANCIAL_CAVEAT``) кладётся в результат и обязан показываться в UI/PDF.
"""

from __future__ import annotations

import logging
from typing import Any, get_args

from app.services.generative.financial import HousingClass, compute_financial
from app.services.site_finder.parcel_financial import (
    _FINANCIAL_CAVEAT,
    _infer_development_type,
    synthesize_teap_from_buildability,
)

logger = logging.getLogger(__name__)

# Классы жилья для перебора — весь набор (econom / comfort / business). Берём из
# Literal-типа HousingClass, чтобы сетка не разошлась с финмоделью при добавлении класса.
_CLASSES_GRID: tuple[HousingClass, ...] = get_args(HousingClass)

# Опорная сетка этажности (кламп по max_floors_allowed зоны). Значения — типовые
# высотные пороги РФ (5 — среднеэтажка, 9 — «хрущёвочный» потолок, 14 — высотка,
# 25 — башня); двигают GFA и development_type (порог spot/mid_rise/high_rise в
# _infer_development_type → длительность СМР в DCF-графике).
_FLOORS_GRID: tuple[int, ...] = (5, 9, 14, 25)

_DEFAULT_TOP_N: int = 3


def _build_floors_grid(max_floors_allowed: int | None) -> list[int]:
    """Сетка этажности, клампнутая по предельной этажности зоны.

    * ``max_floors_allowed`` не задан (``None``) → весь опорный набор (зона неизвестна,
      перебираем всё).
    * задан → оставляем только этажи ``<= max_floors_allowed``.
    * после клампа пусто (предел ниже минимального опорного, напр. 3) → берём сам
      ``max_floors_allowed`` (единственная физически допустимая этажность).
    """
    if max_floors_allowed is None:
        return list(_FLOORS_GRID)
    clamped = [f for f in _FLOORS_GRID if f <= max_floors_allowed]
    if not clamped:
        # Предел ниже минимального опорного (напр. 3 этажа) — считаем по самому пределу.
        return [max_floors_allowed]
    return clamped


def optimize_program(
    *,
    area_m2: float | None,
    max_far: float | None,
    max_building_pct: float | None,
    max_floors_allowed: int | None,
    land_cost_rub: float | None,
    market_price_per_sqm: float | None,
    price_source: str,
    velocity_sqm_per_month: float | None,
    top_n: int = _DEFAULT_TOP_N,
) -> dict[str, Any] | None:
    """Перебрать сетку (класс × этажность) → топ-N вариантов программы по NPV.

    Прогоняет каждый комбо через :func:`compute_financial` (та же финмодель, что и
    основной ``financial_estimate``) и ранжирует по NPV убыв. (тай-брейк — ROI убыв.).

    Args:
        area_m2: площадь участка, кв.м. ``None``/<=0 → ``None`` (нечего считать).
        max_far: КСИТ/max_far зоны (руб/кв.м GFA). Задаёт GFA при синтезе ТЭП. Если
            ``None``, GFA выводится из ``max_building_pct × floors`` (по каждому комбо
            своя этажность из сетки).
        max_building_pct: % застройки зоны. Нужен вместе с этажностью, если нет ``max_far``.
        max_floors_allowed: предельная этажность зоны — кламп сетки этажности. ``None`` →
            весь опорный набор.
        land_cost_rub: стоимость участка (обычно кадастровая) → land_cost. ``None`` → 0.
        market_price_per_sqm: ЕДИНАЯ откалиброванная рыночная цена продажи жилья во ВСЕ
            комбо. ``None`` → каждый комбо использует норматив своего класса
            (``_SALE_PRICE_PER_SQM``) — то же поведение, что в основной финмодели.
        price_source: honest-метка источника цены (из ``select_calibrated_price``).
            Игнорируется финмоделью, если ``market_price_per_sqm is None`` (форсится
            ``class_norm``).
        velocity_sqm_per_month: темп абсорбции района (м²/мес) — окно продаж DCF. ``None``
            → дефолт-30-мес график (``schedule_is_default``).
        top_n: сколько лучших вариантов вернуть (по умолчанию 3).

    Returns:
        ``dict`` с ключами ``variants`` (список топ-N), ``any_viable`` (хоть один вариант
        с NPV > 0), ``grid_size`` (сколько комбо реально посчиталось), ``caveat``. Либо
        ``None``, если считать нельзя (нет площади / ни один комбо не дал ТЭП).
    """
    if area_m2 is None or area_m2 <= 0:
        return None

    floors_grid = _build_floors_grid(max_floors_allowed)

    variants: list[dict[str, Any]] = []
    for housing_class in _CLASSES_GRID:
        for floors in floors_grid:
            variant = _evaluate_combo(
                housing_class=housing_class,
                floors=floors,
                area_m2=area_m2,
                max_far=max_far,
                max_building_pct=max_building_pct,
                land_cost_rub=land_cost_rub,
                market_price_per_sqm=market_price_per_sqm,
                price_source=price_source,
                velocity_sqm_per_month=velocity_sqm_per_month,
            )
            if variant is not None:
                variants.append(variant)

    if not variants:
        # Ни один комбо не дал ТЭП (нет ни far, ни pct+floors) — считать нечего.
        return None

    # Ранжир по NPV убыв.; тай-брейк — ROI убыв. (при равном NPV предпочитаем
    # эффективность капитала). При all-negative топ-N — «наименее убыточные».
    variants.sort(key=lambda v: (v["npv_rub"], v["roi"]), reverse=True)

    any_viable = any(v["npv_rub"] > 0 for v in variants)

    return {
        "variants": variants[:top_n],
        "any_viable": any_viable,
        "grid_size": len(variants),
        # HEAVY caveat — тот же, что у основной финмодели (оценка по МАКС. застройке
        # зоны, НЕ по реальной раскладке). Обязан показываться в UI/PDF.
        "caveat": _FINANCIAL_CAVEAT,
    }


def _evaluate_combo(
    *,
    housing_class: HousingClass,
    floors: int,
    area_m2: float,
    max_far: float | None,
    max_building_pct: float | None,
    land_cost_rub: float | None,
    market_price_per_sqm: float | None,
    price_source: str,
    velocity_sqm_per_month: float | None,
) -> dict[str, Any] | None:
    """Посчитать один комбо (класс × этажность) → компактный вариант программы.

    Сбой одного комбо (например, ТЭП не вывелся) НЕ валит весь грид — логируется и
    возвращается ``None`` (caller скипает). Это тот же «пояс безопасности», что у
    ``financial_estimate`` в analyze-эндпоинте.
    """
    try:
        # development_type по ЭТАЖНОСТИ комбо (не по зоне) — этаж двигает график СМР в DCF.
        development_type = _infer_development_type(floors)

        teap = synthesize_teap_from_buildability(
            area_m2=area_m2,
            max_far=max_far,
            max_building_pct=max_building_pct,
            max_floors=floors,
            housing_class=housing_class,
        )
        if teap is None:
            return None

        fin = compute_financial(
            teap=teap,
            housing_class=housing_class,
            land_cost_rub=land_cost_rub,
            # ЕДИНАЯ цена во все комбо (когда рынок есть) — класс НЕ двигает цену продажи.
            market_price_per_sqm=market_price_per_sqm,
            price_source=price_source,
            development_type=development_type,
            market_velocity_sqm_per_month=velocity_sqm_per_month,
        )

        return {
            "housing_class": housing_class,
            "floors": floors,
            "development_type": development_type,
            "npv_rub": fin.npv_rub,
            "irr": fin.irr,
            "roi": fin.roi,
            "net_profit_rub": fin.net_profit_rub,
            "residential_sqm": teap.residential_area_sqm,
            "apartments_count": teap.apartments_count,
            # honest-поля источника/графика цены — те же флаги, что в основной финмодели.
            "price_per_sqm_used": fin.price_per_sqm_used,
            "price_source": fin.price_source,
            "schedule_is_default": fin.schedule_is_default,
        }
    except Exception:
        # Один сбойный комбо не валит грид — логируем и скипаем (не глотаем молча).
        logger.exception(
            "program_optimizer combo failed: class=%s floors=%d area=%.0f",
            housing_class,
            floors,
            area_m2,
        )
        return None


__all__ = ["optimize_program"]
