"""Generative Design — Stage 1c: developer financial model + monthly DCF (PR-1/PR-3).

We combine **our areas** (from real geometry → :class:`TEAP`) with the **cost
completeness** of a reference developer Excel model: a full static cost cascade,
VAT, profit tax → net profit + ROI (PR-1). On top of that *correct static* P&L,
PR-3 lays the cascade onto a **monthly cashflow schedule** and runs a real DCF —
NPV / IRR / payback — replacing the PR-1 IRR proxy. No new user inputs and no new
dependencies: the DCF is hand-rolled deterministic math (no numpy-financial) over
default phase norms + the existing TEAP cascade.

DCF — MONTHLY SCHEDULE (PR-3)
-----------------------------
The static cascade is spread over months using **default phase norms** (honestly
flagged ``schedule_is_default=True`` — these are typical assumptions, NOT a project
schedule):

* Land — month 0, lump sum.
* ПИР (design) — linear over ``_PIR_DURATION_MONTHS``.
* СМР + networks + dev-services + contingency — linear over the construction window
  ``[ПИР_end .. ПИР_end + construction_duration]`` (duration by ``development_type``).
* Sales (residential + parking) — linear over ``[sales_start .. sales_end)`` starting
  ``_SALES_START_MONTH`` after СМР begins; the window lasts at least
  ``_SALES_DURATION_MONTHS`` but never ends before construction completes (РФ
  ДДУ/эскроу — sales run *during* construction and close at/after ввода). For tall
  builds (long СМР) this stretches sales out, preventing an impossible early sell-out.
* Marketing — pro-rated to the sales curve (spent as units sell).

налоги — PHASING CHOICE (documented simplification)
  НДС and profit tax are **pro-rated to revenue recognition**, not lumped at the
  end: each month's tax = total_tax × (that month's revenue / total revenue). This
  keeps the cashflow's tax outflow aligned with the inflow that triggers it, and —
  crucially — keeps the **undiscounted** cashflow sum exactly equal to the static
  ``net_profit`` (INVARIANT, tested). Lumping at the end would distort IRR/PBP for
  no modelling gain. If total revenue is 0 (degenerate), tax is 0 anyway.

рассрочка / первый взнос — SIMPLIFICATION (documented)
  The Excel reference uses a 80% first payment + remainder on completion. For the
  MVP DCF we recognise the **full sale price in the month of sale** (``_FIRST_PAYMENT_PCT``
  is kept as a documented constant for a future рассрочка split in PR-4, but is NOT
  applied here). This slightly *front-loads* cash vs. an installment plan — i.e. it
  is the *optimistic* end of the timing band; the schedule caveat says so. Splitting
  the remainder to a later month would only push IRR down, never break the invariant.

INVARIANT (tested): Σ undiscounted monthly cashflow ≈ static ``net_profit`` — the
DCF can never silently diverge from the static cascade.

Cascade (in :func:`compute_financial`):

* **Revenue (GDV)** = residential area × sale price + parking spaces × parking price
  + office area × office price (нежилое 1-го этажа — коммерция/офисы).
* **Cost cascade** = construction (residential + OFFICE GFA + parking) + ПИР (design)
  + networks (ТУ) + developer services + contingency + marketing/realtor + land.
* **БДР / taxes** = gross margin − VAT − profit tax → net profit.
* **Metrics** = net ROI on cost, net margin on revenue, and DCF NPV/IRR/payback.

OFFICES / COMMERCIAL — нежилое 1-го этажа (ADDITIVE ВЫРУЧКА, БЕЗ double-count СМР)
--------------------------------------------------------------------------------
TEAP теперь несёт ``office_area_sqm`` — нежилое (коммерция/офисы 1-го этажа),
ВЫРЕЗАННОЕ из GFA до расчёта жилой (см. ``_OFFICE_SHARE_OF_GFA`` в teap.py: жилая
ужимается ровно на эту площадь, total_floor_area_sqm НЕ меняется). Финмодель:

* **Выручка** ``revenue_office = office_area_sqm × _OFFICE_SALE_PRICE_PER_SQM[class]``
  (умеренная премия к жилью; см. константу).
* **СМР нежилого — НЕ отдельная строка, double-count'а НЕТ.** Нежилая GFA осталась
  в ``total_floor_area_sqm`` → ``construction_resid = total_floor_area_sqm × ставка
  СМР`` УЖЕ её покрывает (как покрывала, когда эта площадь считалась жилой). Сдвиг
  carve-out жилой ↔ нежилой меняет только классификацию площади и ВЫРУЧКУ, а СМР по
  total_floor_area инвариантен. Поэтому нежилое = чисто additive ВЫРУЧКА.

НДС — ОСВОБОЖДЕНИЕ ЖИЛЬЯ, НДС НА ПАРКИНГ + НЕЖИЛОЕ (ст. 149 НК РФ)
------------------------------------------------------------
Реализация жилых помещений и услуги застройщика по ДДУ ОСВОБОЖДЕНЫ от НДС
(пп.22 и пп.23.1 п.3 ст. 149 НК РФ) → выходной НДС на жильё = 0. Входной НДС по
СМР при этом НЕвозмещаем (п.2 ст.170 НК РФ) и уже «сидит» в gross-ставках
себестоимости (рыночные ориентиры ``_CONSTRUCTION_COST_PER_SQM`` даны С НДС) →
чистая нагрузка по НДС на жилую часть ≈ 0.

Машиноместа И нежилое (коммерция/офисы) — НЕЖИЛЫЕ помещения: их реализация
ОБЛАГАЕТСЯ НДС 20%, входной НДС вычитается. Поэтому НДС начисляется на нежилой
value-added ОБОИХ нежилых частей (встроенный НДС в добавленной стоимости):

    parking_value_added = max(0, revenue_parking − construction_parking)
    office_value_added  = max(0, revenue_office  − office_construction)
        где office_construction = office_area_sqm × _CONSTRUCTION_COST_PER_SQM[class]
            (СМР-доля нежилого по той же ставке, что и весь СМР; та же доля УЖЕ сидит
             в construction_rub — здесь она лишь ВОССТАНАВЛИВАЕТСЯ как база НДС, в
             каскад затрат повторно НЕ добавляется → double-count'а нет).
    vat = (parking_value_added + office_value_added) × VAT_RATE / (1 + VAT_RATE)

Если паркинг/нежилое окажутся под ДДУ-льготой — оценка консервативна (чуть занижает
прибыль завышенным НДС), что соответствует honest/conservative-политике модуля.
Полную механику входного/выходного НДС с эскроу-спецификой не моделируем —
жильё освобождено, входной НДС встроен в gross-СМР.

IRR — REAL DCF, PROXY FALLBACK (PR-3)
-------------------------------------
``irr`` is now a **real** annualised IRR computed by bisection over the monthly
cashflow (``irr_is_proxy=False``). For a degenerate cashflow with no sign change
(e.g. an always-loss project — every month is an outflow, or every month inflow)
bisection cannot bracket a root; we then fall back to the PR-1 annualised net-ROI
proxy (``roi / _PROJECT_YEARS``, clamped ±1) and flag ``irr_is_proxy=True`` so the
frontend shows the «оценочный» caveat.

PRICE CALIBRATION — PR-2
------------------------
The housing **sale price** can be calibrated to real market data: callers pass
``market_price_per_sqm`` (руб/кв.м) computed from Objective listings / district
reference at the API layer. When ``None`` we fall back to the per-class norm and
flag the source honestly (``price_source="class_norm"``) so the UI/PDF never
passes a class norm off as a market figure. Construction cost (СМР) is *not*
calibrated in PR-2 — it stays a per-class norm. Parking price/cost are also
per-class norms (см. ``_PARKING_PRICE_PER_SPOT`` / ``_PARKING_COST_PER_SPOT``).

FINANCING — OVERLAY (PR-5)
--------------------------
Unlevered (project) DCF — NPV / IRR / PBP — остаётся HEADLINE и НЕ меняется:
это интринсивная доходность проекта, инвариант Σ cashflow == net_profit
сохраняется. Финансирование (кредит/проектное финансирование) — это
ДОПОЛНИТЕЛЬНЫЙ overlay ПОВЕРХ готового unlevered cashflow; он его читает
(READ-ONLY) и НЕ пересобирает.

* **Проценты — реальная стоимость долга**, поэтому ``net_profit_after_financing
  < net_profit`` для любого проекта с кассовым разрывом.
* **Ставка ``annual_rate_used`` — норматив** (``_CREDIT_RATE_ANNUAL``), отдельный
  от ставки дисконтирования (см. константы), а не выведенная из проекта величина.
* **LTC-покрытие разрыва** — кредитная линия покрывает ``_LOAN_TO_COST_RATIO``
  (70 %) каждого ежемесячного оттока; оставшиеся 30 % (собственные средства)
  co-fund отток пропорционально и процентами не облагаются. Лимиты банка НЕ
  моделируются точно.
* **Проценты капитализируются (compound)** — начисляются на остаток на начало
  месяца ДО движений и добавляются в тело долга.
* **Эскроу-специфику не моделируем точно** — раскрытие средств с эскроу-счетов
  по этапам стройки, лимиты раскрытия, ставка по бридж/целевой части — всё это
  упрощено до единой капитализируемой ставки на кассовый разрыв.
* **Levered (equity) IRR** считается по equity-потоку того же графика долга
  (``_FinancingResult.equity_cashflow``): отток = equity-доля (1−LTC) каждого
  кассового разрыва, приток = остаток притока после обслуживания долга. При
  положительном леверидже (доходность проекта > стоимости долга 15 %) levered IRR
  выше unlevered. Тот же proxy-fallback, что у unlevered (нет смены знака → roi-style
  proxy + ``levered_irr_is_proxy=True``). Вырожденные случаи (LTC>=1 — equity не под
  риском, финансирование выключено, нет equity-оттока) → ``levered_irr=0.0`` /
  ``levered_irr_is_proxy=True`` (не выдумываем доходность). debt_schedule в схему НЕ
  выносится. MVP-агрегаты: ``peak_debt_rub`` / ``total_interest_rub`` /
  ``net_profit_after_financing_rub`` (без изменений).
* **Unlevered NPV / IRR / PBP НЕ затронуты** overlay'ем — это всё та же
  интринсивная доходность. График наследует неточность ``schedule_is_default``:
  финансирование считается по тому же дефолт-графику фаз, поэтому
  ``financing_is_simplified=True`` (honest-флаг для UI/PDF).

This function stays **pure**: it does no DB lookup itself (the API layer does the
lookup and passes the result in). Детерминированно, без LLM / внешних API / БД.
"""

from __future__ import annotations

import logging
import math
from dataclasses import dataclass
from typing import Literal

from app.schemas.concept import TEAP, FinancialModel

logger = logging.getLogger(__name__)

HousingClass = Literal["econom", "comfort", "business"]

# ── Цена продажи жилья, руб/кв.м (proxy рынка ЕКБ/региона, упрощённо) ──────────
_SALE_PRICE_PER_SQM: dict[HousingClass, float] = {
    "econom": 110_000.0,
    "comfort": 145_000.0,
    "business": 210_000.0,
}

# ── Себестоимость СМР, руб/кв.м общей площади (выше класс -> дороже отделка/инж) ─
_CONSTRUCTION_COST_PER_SQM: dict[HousingClass, float] = {
    "econom": 72_000.0,
    "comfort": 88_000.0,
    "business": 120_000.0,
}

# ── Паркинг — ЦЕНА ПРОДАЖИ / СЕБЕСТОИМОСТЬ м/м по классу, руб ──────────────────
# TUNABLE ASSUMPTIONS (РФ-рынок паркинга). Раньше эти константы были СКАЛЯРАМИ,
# взятыми из Excel-эталона «Авангардная 13» (premium, ПОДЗЕМНЫЙ паркинг, near
# break-even: 1.9М продажа / 1.8М себестоимость). Применять их ПЛОСКО ко всем
# классам некорректно: эконом-жильё в РФ строит ДЕШЁВЫЙ НАЗЕМНЫЙ паркинг, а не
# подземный — плоская подземная себестоимость 1.8М × 0.8 м/м на квартиру раздувала
# near-break-even капитал → пиковый долг → проценты по финансированию (прод: Ж-5,
# 212 м/м × 1.8М = 382М break-even капитал → ~120М процентов, душивших
# net_after_financing).
#
# Поэтому цена/себестоимость теперь КЛАССОЗАВИСИМЫ (зеркалит паттерн
# _SALE_PRICE_PER_SQM / _CONSTRUCTION_COST_PER_SQM). ВАЖНО: НОРМА паркинга
# (_PARKING_PER_APARTMENT в teap.py: econom 0.8 / comfort 1.0 / business 1.5) НЕ
# трогается — она может отражать нормы ПЗЗ (out of scope).
#
#   business 1.9 / 1.8 — ПОДЗЕМНЫЙ, matches Excel «Авангардная 13» (без изменений).
#   econom   0.8 / 0.35 — НАЗЕМНЫЙ (surface): дешёвый СМР ~350k, продажа ~800k.
#   comfort  1.3 / 1.0  — ЧАСТИЧНО подземный (интерполяция econom↔business).
#
# Маржа на м/м: econom +450k, comfort +300k, business +100k (цена > себестоимости
# во всех классах — паркинг не убыточен ни в одном классе).
_PARKING_PRICE_PER_SPOT: dict[HousingClass, float] = {
    "econom": 800_000.0,  # наземный паркинг — продажа
    "comfort": 1_300_000.0,  # частично подземный — продажа
    "business": 1_900_000.0,  # подземный (Excel «Авангардная 13») — продажа
}
_PARKING_COST_PER_SPOT: dict[HousingClass, float] = {
    "econom": 350_000.0,  # наземный паркинг — себестоимость
    "comfort": 1_000_000.0,  # частично подземный — себестоимость
    "business": 1_800_000.0,  # подземный (Excel «Авангардная 13») — себестоимость
}

# ── Цена ПРОДАЖИ нежилого (коммерция/офисы 1-го этажа), руб/кв.м по классу ─────
# НОРМАТИВ (РФ-рынок встроенно-пристроенной коммерции). Ликвидный стрит-ритейл 1-го
# этажа в городском МКД обычно продаётся С ПРЕМИЕЙ к жилью того же дома (лучшая
# проходимость/витрина, инвестиционный спрос на ГАБ — готовый арендный бизнес).
# Берём УМЕРЕННУЮ премию ~+15% к нормативу продажи жилья класса (консервативно — не
# задираем; в реальных проектах премия бывает выше, но зависит от локации/трафика):
#   econom   ~127k = 110k × 1.15  (доля нежилого = 0 → на выручку не влияет, но
#                                  константа задана для полноты/будущих сценариев).
#   comfort  ~167k = 145k × 1.15
#   business ~242k = 210k × 1.15
# СЕБЕСТОИМОСТЬ нежилого ОТДЕЛЬНО не вводим: нежилая GFA уже в total_floor_area_sqm →
# ``construction = total_floor_area × ставка СМР`` её УЖЕ покрывает (см. docstring
# раздел OFFICES). Поэтому нежилое — чисто additive ВЫРУЧКА; для базы НДС берём
# СМР-долю нежилого по той же ставке ``_CONSTRUCTION_COST_PER_SQM`` (см. ниже).
_OFFICE_SALE_PRICE_PER_SQM: dict[HousingClass, float] = {
    "econom": 126_500.0,  # 110_000 × 1.15
    "comfort": 166_750.0,  # 145_000 × 1.15
    "business": 241_500.0,  # 210_000 × 1.15
}

# ── Доли каскада затрат (источник — Excel-эталон + RU-рынок) ──────────────────
# ПИР (проектирование + экспертиза + РД) как доля от СМР.
# Excel: ПИР 147М при СМР 6123М ≈ 2.4% -> округлённо 2.5%.
_PIR_PCT_OF_CONSTRUCTION: float = 0.025

# Сети / подключение по ТУ, руб/кв.м GFA.
# Excel: ТУ 330М / 55042 кв.м ≈ 6000 руб/кв.м.
_NETWORKS_COST_PER_SQM: float = 6_000.0

# Услуги заказчика / управление как доля от СМР.
# Excel: 76.5М при СМР 6123М ≈ 1.25% -> округлённо 1.5%.
_DEVELOPER_SERVICES_PCT: float = 0.015

# Резерв / непредвиденные как доля от (СМР + ПИР + сети). Excel: 3%.
_CONTINGENCY_PCT: float = 0.03

# Реклама + риэлтор + маркетинг как доля от выручки. Excel: 7%.
_MARKETING_REALTOR_PCT: float = 0.07

# ── Налоги ────────────────────────────────────────────────────────────────────
# НДС, ставка. РФ стандартная 20%.
_VAT_RATE: float = 0.20
# Налог на прибыль организаций. С 01.01.2025 базовая ставка повышена 20% -> 25%
# (ФЗ № 176-ФЗ от 12.07.2024). Используем актуальные 25%.
_PROFIT_TAX_RATE: float = 0.25

# Условный горизонт проекта (лет) для аннуализации net-ROI в IRR-proxy (fallback).
_PROJECT_YEARS: float = 3.0

# ── DCF: график фаз и продаж — ТИПОВЫЕ НОРМАТИВЫ (ДОПУЩЕНИЯ, не график проекта) ──
# Эти константы — дефолтные допущения помесячной раскладки cashflow. Они ЧЕСТНО
# помечены ``schedule_is_default=True`` в FinancialModel: NPV/IRR/PBP считаются DCF
# по ЭТОМУ графику, точность зависит от реального графика проекта. Темп продаж —
# дефолт-норматив, НЕ §22 velocity (отдельная интеграция позже).

# Годовая ставка дисконтирования (Excel-эталон девелоперской модели).
_DISCOUNT_RATE_ANNUAL: float = 0.15
# Ставка по кредиту/проектному финансированию (Excel-эталон). ОТДЕЛЬНА от
# _DISCOUNT_RATE_ANNUAL: дисконт = требуемая доходность инвестора (NPV), кредитная
# ставка = стоимость долга (проценты). Совпадение значения (0.15) — калибровка,
# НЕ тождество; не сливать в одну константу.
_CREDIT_RATE_ANNUAL: float = 0.15
# Доля банковского кредита от каждого ежемесячного оттока (LTC, Loan-to-Cost).
# Собственные средства = (1 − LTC) co-fund отток пропорционально, без начисления %.
# Норматив РФ МКД при проектном финансировании: 65–75 %. Используем 70 % (медиана).
_LOAN_TO_COST_RATIO: float = 0.70
# Длительность ПИР (проектирование/экспертиза), мес.
_PIR_DURATION_MONTHS: int = 6
# Длительность СМР по типу застройки, мес (точечная быстрее высотной).
_CONSTRUCTION_DURATION_MONTHS: dict[str, int] = {
    "spot": 24,
    "mid_rise": 36,
    "high_rise": 48,
}
# Дефолт длительности СМР, если development_type неизвестен (среднеэтажная).
_DEFAULT_CONSTRUCTION_MONTHS: int = _CONSTRUCTION_DURATION_MONTHS["mid_rise"]
# Старт продаж — через N мес после начала СМР (нормативно — после котлована/этажей).
_SALES_START_MONTH: int = 6
# Длительность распродажи, мес (нормативный темп выбытия квартир). Используется,
# когда рыночная скорость абсорбции не передана (market_velocity_sqm_per_month=None).
_SALES_DURATION_MONTHS: int = 30
# RANK 1: границы окна продаж, когда оно ДРАЙВИТСЯ рыночной скоростью абсорбции
# (residential_area_sqm / market_velocity_sqm_per_month). Клампим вычисленную
# длительность в [_SALES_DURATION_MIN, _SALES_DURATION_MAX], чтобы аномально
# горячий/холодный радиус не давал нереалистичных 1-мес распродаж или 20-летних
# хвостов. Эскроу-привязка (sales_end >= constr_end) применяется ПОВЕРХ клампа.
_SALES_DURATION_MIN: int = 6
_SALES_DURATION_MAX: int = 120
# Первый взнос, доля (Excel: 80% при контракте, остаток при вводе/рассрочка). В MVP
# DCF НЕ применяется — вся выручка признаётся в месяц продажи (см. docstring модуля).
# Сохранён константой для будущей рассрочки (PR-4).
_FIRST_PAYMENT_PCT: float = 0.80

# ── DCF: РАЗДЕЛЬНАЯ ИНФЛЯЦИЯ ИНДЕКСОВ — DORMANT CAPABILITY (default OFF) ──────────
# Excel-эталон применяет РАЗНУЮ годовую инфляцию к затратам СМР (индекс СМР Росстата
# ~7-8%/год) и к цене продажи (индекс цен ~6%/год) на многолетнем горизонте. Эта
# возможность ПРОВОДНА в `_build_monthly_cashflow` (см. `smr_inflation_annual` /
# `sale_inflation_annual`), но ДЕФОЛТ строго 0.0 — прод-поведение НЕ меняется.
#
# ПОЧЕМУ OFF ПО УМОЛЧАНИЮ (hard constraint):
#   * Статический БДР (`compute_financial`) считается в деньгах СЕГОДНЯ (today-money);
#     есть ТЕСТИРУЕМЫЙ инвариант `Σ undiscounted cashflow == net_profit`.
#   * Раздельная инфляция делает НОМИНАЛЬНЫЕ помесячные потоки РАСХОДЯЩИМИСЯ с
#     today-money `net_profit` → инвариант ломается при ставке > 0.
#   * Поэтому включение требует (а) КАЛИБРОВАННЫХ индексов (СМР ~7-8%/год, цена
#     ~6%/год — ориентиры Росстата) И (б) переосмысления headline-инварианта
#     (номинальный DCF vs today-money БДР — это РАЗНЫЕ метрики). До этого решения
#     ставки держим 0.0: capability разведена и протестирована, но выключена.
#
# При 0.0 месячный фактор `(1+0)**t == 1.0` для любого t → побайтово идентичный
# cashflow → существующий инвариант и все текущие тесты проходят без изменений.
_SMR_INFLATION_ANNUAL: float = 0.0
_SALE_INFLATION_ANNUAL: float = 0.0


# ── DCF helpers — hand-rolled, чистая математика (без numpy-financial / deps) ───


def _npv(monthly_rate: float, cf: list[float]) -> float:
    """NPV помесячного потока ``cf`` (t=0..N) при месячной ставке ``monthly_rate``.

    ``cf[t]`` — чистый денежный поток месяца t (приток +, отток −). t=0 не дисконтируется.
    """
    return sum(c / (1.0 + monthly_rate) ** t for t, c in enumerate(cf))


def _irr_monthly(
    cf: list[float],
    lo: float = -0.9999,
    hi: float = 1.0,
    tol: float = 1e-7,
    max_iter: int = 200,
) -> float | None:
    """Месячный IRR помесячного потока ``cf`` методом бисекции.

    Возвращает месячную ставку r, при которой ``_npv(r, cf) == 0``, или ``None``,
    если на ``[lo, hi]`` нет смены знака NPV (вырожденный поток — всегда отток или
    всегда приток). Caller делает fallback на roi-proxy + ``irr_is_proxy=True``.

    Bracket-guard: если ``_npv(lo) * _npv(hi) > 0`` — корня в брекете нет → ``None``.
    NPV монотонно убывает по ставке для «нормального» девелоперского потока (ранний
    отток, поздний приток), поэтому бисекция сходится к единственному корню.
    """
    # Вырожденный поток без денег (все нули) — NPV==0 при любой ставке, IRR неопределён.
    if all(abs(c) < tol for c in cf):
        return None
    f_lo = _npv(lo, cf)
    f_hi = _npv(hi, cf)
    # Краевое попадание в корень.
    if abs(f_lo) < tol:
        return lo
    if abs(f_hi) < tol:
        return hi
    # Нет смены знака на брекете → нет (единственного) корня здесь → None.
    if f_lo * f_hi > 0.0:
        return None
    for _ in range(max_iter):
        mid = (lo + hi) / 2.0
        f_mid = _npv(mid, cf)
        if abs(f_mid) < tol:
            return mid
        if f_lo * f_mid < 0.0:
            hi = mid
            f_hi = f_mid
        else:
            lo = mid
            f_lo = f_mid
    return (lo + hi) / 2.0


def _payback_months(cf: list[float]) -> float | None:
    """Срок окупаемости (мес) по НЕдисконтированному накопительному потоку.

    Накапливаем cashflow; как только сумма пересекает 0, линейно интерполируем
    дробный месяц внутри переломного интервала. ``None`` — поток не окупается на
    горизонте (накопленный итог так и не достиг 0).
    """
    cumulative = 0.0
    for t, c in enumerate(cf):
        prev = cumulative
        cumulative += c
        # Month 0 — всегда land-отток (cf[0] < 0), поэтому prev<0 на первом переломе:
        # ветка t==0 ниже — теоретический corner (поток сразу неотрицателен), не реальный путь.
        if cumulative >= 0.0 and prev < 0.0:
            # Линейная интерполяция дроби месяца: prev + frac * c = 0.
            frac = -prev / c if c != 0.0 else 0.0
            return float(t - 1) + frac
        if cumulative >= 0.0 and t == 0:
            # Окупается уже в нулевом месяце (поток сразу неотрицателен).
            return 0.0
    return None


@dataclass(frozen=True)
class _FinancingResult:
    """Результат overlay'я финансирования: агрегаты долга + ПОМЕСЯЧНЫЙ equity-поток.

    Агрегаты (``peak_debt_rub`` / ``total_interest_rub`` / ``ending_debt_rub``)
    сохраняют прежний READ-ONLY-контракт PR-5 (значения не изменились). ``equity_cashflow``
    — НОВЫЙ поток держателя equity по тому же графику долга (для levered IRR, отдельный PR):
    отток = equity-доля (1−ltc) каждого кассового разрыва (со знаком −), приток =
    остаток притока после обслуживания долга. Σ equity_cashflow == net_profit_after_financing
    (equity получает всю прибыль за вычетом процентов; банк — только тело + проценты).
    """

    peak_debt_rub: float
    total_interest_rub: float
    ending_debt_rub: float
    equity_cashflow: list[float]


def _compute_financing(
    operating_cf: list[float],
    annual_rate: float,
    ltc_ratio: float = 1.0,
) -> _FinancingResult:
    """OVERLAY финансирования поверх unlevered cashflow (PR-5). Чистая функция.

    ``operating_cf`` — готовый unlevered cashflow (READ-ONLY, НЕ мутируется).
    Кредитная линия покрывает ``ltc_ratio`` долю кассового разрыва; оставшийся
    (1 − ltc_ratio) — equity-взнос (без процентов). Проценты КАПИТАЛИЗИРУЮТСЯ
    в долг (compound) — упрощение под эскроу-логику РФ. Порядок внутри месяца:
    процент НАЧИСЛЯЕМ на остаток ДО draw/repay, затем выборка (cf<0) либо
    погашение притоком (cf>0, не уходя ниже 0).

    Если поток не уходит в минус → долг не выбирается → debt=0, interest=0
    (корректно: финансирование не требуется).

    ВОЗВРАТ — :class:`_FinancingResult`: агрегаты (``peak_debt_rub`` — пиковая
    задолженность, ``total_interest_rub`` — суммарные капитализированные проценты,
    ``ending_debt_rub`` — остаток долга на конец) ПЛЮС ``equity_cashflow`` —
    помесячный поток держателя equity по тому же графику долга (для levered IRR,
    отдельный PR; см. :class:`_FinancingResult`). Агрегаты не изменились — контракт цел.
    """
    monthly_rate = (1.0 + annual_rate) ** (1.0 / 12.0) - 1.0
    debt = 0.0
    peak_debt = 0.0
    total_interest = 0.0
    equity_cashflow: list[float] = []
    for cf in operating_cf:
        interest = debt * monthly_rate  # процент на остаток на начало месяца (до движений)
        debt += interest
        total_interest += interest
        if cf < 0:
            debt += -cf * ltc_ratio  # банк покрывает ltc_ratio от оттока; equity-часть без %
            # Equity вносит (1−ltc) долю оттока (со знаком −, как и cf): свои деньги под риском.
            equity_cashflow.append(cf * (1.0 - ltc_ratio))
        else:
            repay = min(cf, debt)  # гасим долг притоком (не уходим в минус)
            debt -= repay
            # Остаток притока после обслуживания долга достаётся equity (приток, +).
            equity_cashflow.append(cf - repay)
        peak_debt = max(peak_debt, debt)
    return _FinancingResult(
        peak_debt_rub=round(peak_debt, 2),
        total_interest_rub=round(total_interest, 2),
        ending_debt_rub=round(debt, 2),
        equity_cashflow=equity_cashflow,
    )


def _spread_linear(total: float, start: int, duration: int, horizon: int) -> list[float]:
    """Разложить ``total`` равными долями по месяцам ``[start .. start+duration)``.

    Возвращает список длины ``horizon`` (по индексу = месяц). ``duration <= 0`` →
    единоразово в месяц ``start``. Месяцы вне горизонта молча обрезаются (страховка).
    """
    out = [0.0] * horizon
    if total == 0.0:
        return out
    if duration <= 0:
        if 0 <= start < horizon:
            out[start] += total
        return out
    per = total / duration
    for m in range(start, start + duration):
        if 0 <= m < horizon:
            out[m] += per
    return out


@dataclass(frozen=True)
class _CashflowResult:
    """Результат раскладки cashflow: сам поток + реализованное окно продаж и флаг,
    был ли график построен по рыночной скорости абсорбции (RANK 1)."""

    cashflow: list[float]
    sales_duration_months_realized: int  # фактическое окно sales_end − sales_start
    schedule_is_market: bool  # True → окно драйвилось market_velocity, не дефолт-30


def _build_monthly_cashflow(
    *,
    revenue: float,
    construction: float,
    pir: float,
    networks: float,
    dev_services: float,
    contingency: float,
    marketing: float,
    land: float,
    vat: float,
    profit_tax: float,
    construction_duration_months: int,
    residential_area_sqm: float,
    market_velocity_sqm_per_month: float | None = None,
    smr_inflation_annual: float = 0.0,
    sale_inflation_annual: float = 0.0,
) -> _CashflowResult:
    """Разложить статический каскад в помесячный чистый cashflow по графику фаз.

    Чистая функция. График — типовые нормативы (см. docstring модуля): земля (мес 0),
    ПИР линейно, СМР+сети+услуги+резерв линейно по окну строительства, продажи линейно
    по окну распродажи; маркетинг прорейтится на график продаж; НДС+налог на прибыль
    прорейтятся на признание выручки. Выручка — полностью в месяц продажи (упрощение).

    РАЗДЕЛЬНАЯ ИНФЛЯЦИЯ ИНДЕКСОВ — DORMANT CAPABILITY (default OFF, см. модульные
    константы `_SMR_INFLATION_ANNUAL` / `_SALE_INFLATION_ANNUAL`):
      * ``smr_inflation_annual`` — годовая инфляция СМР-стороны затрат (индекс СМР):
        эскалирует ПИР, СМР+сети+услуги+резерв. ЗЕМЛЯ НЕ эскалируется — это лумп-сумма
        в мес 0 (платится сегодня, t=0 → фактор `(1+r)**0 == 1.0` и так бы дал нейтрал,
        но земля исключена ЯВНО: участок покупается по сегодняшней цене, будущий индекс
        СМР к ней неприменим).
      * ``sale_inflation_annual`` — годовая инфляция цены продажи (индекс цен): эскалирует
        приток выручки. Маркетинг/НДС/налог прорейтятся на УЖЕ ПРОЭСКАЛИРОВАННУЮ выручку
        (доля = эскалированная revenue_cf[m] / Σ эскалированной выручки) → они едут на
        той же номинальной выручке, что их рождает (консистентно).
      * Месячный фактор: ``smr_m = (1+smr_inflation_annual)**(1/12) − 1``, аналогично
        ``sale_m``; компонент месяца ``t`` (0-based) множится на ``(1+factor_m)**t``.
      * ДЕФОЛТ 0.0 ⇒ ``(1+0)**t == 1.0`` для всех t ⇒ ПОБАЙТОВО идентичный cashflow ⇒
        инвариант ниже и все текущие тесты держатся (safety-гарантия zero-diff).

    ВНИМАНИЕ: при ставке > 0 номинальные потоки РАСХОДЯТСЯ с today-money ``net_profit``
    → инвариант ниже верен ТОЛЬКО при нулевой инфляции (today-money режим). Это
    ожидаемо: номинальный DCF != today-money БДР (см. модульные константы).

    RANK 1 — окно продаж по рыночной абсорбции: если передан конечный
    ``market_velocity_sqm_per_month > 0`` (темп выбытия ОДНОГО типового конкурента
    района) и ``residential_area_sqm > 0``, базовое окно =
    ``ceil(residential_area_sqm / market_velocity_sqm_per_month)``, клампится в
    ``[_SALES_DURATION_MIN, _SALES_DURATION_MAX]`` (``schedule_is_market=True``).
    Иначе — дефолт ``_SALES_DURATION_MONTHS`` (``schedule_is_market=False``). ПОВЕРХ
    базового окна — та же эскроу-привязка ``sales_end >= constr_end`` (РФ ДДУ —
    продажи заканчиваются у/после ввода). Перенос выручки во времени НЕ меняет сумму
    потока, поэтому инвариант ниже держится при любом окне.

    ИНВАРИАНТ: ``sum(cashflow) == revenue − all_costs − vat − profit_tax == net_profit``
    (с точностью до float-округления). Гарантирует, что DCF не разошёлся со статикой.
    """
    pir_end = _PIR_DURATION_MONTHS
    constr_start = pir_end
    constr_end = pir_end + construction_duration_months
    # Продажи стартуют через _SALES_START_MONTH после начала СМР (нормативно, эскроу).
    sales_start = constr_start + _SALES_START_MONTH

    # RANK 1: базовое окно продаж — по рыночной абсорбции, если она передана и валидна.
    if (
        market_velocity_sqm_per_month is not None
        and math.isfinite(market_velocity_sqm_per_month)
        and market_velocity_sqm_per_month > 0.0
        and residential_area_sqm > 0.0
    ):
        raw = math.ceil(residential_area_sqm / market_velocity_sqm_per_month)
        base_duration = max(_SALES_DURATION_MIN, min(_SALES_DURATION_MAX, raw))
        schedule_is_market = True
    else:
        base_duration = _SALES_DURATION_MONTHS  # 30 — дефолт-норматив
        schedule_is_market = False

    # Окончание продаж: базовое окно, но НЕ раньше завершения стройки
    # (РФ ДДУ/эскроу — продажи идут ВО ВРЕМЯ стройки и заканчиваются у/после ввода).
    # Без этого high_rise (СМР до 54) распродавал бы всё на мес 42 — за 12 мес ДО ввода
    # → хвост затрат ПОСЛЕ окончания продаж → двойная смена знака → IRR-бисекция не
    # брекетит profitable-проект и молча падает в proxy. Привязка к constr_end это чинит.
    sales_end = max(sales_start + base_duration, constr_end)
    sales_duration = sales_end - sales_start
    # Горизонт = максимум всех фаз (+1 — месяц как индекс, длина = последний+1).
    horizon = max(constr_end, sales_end, 1)

    # ── Месячные факторы инфляции (DORMANT: при ставке 0.0 фактор тождественно 1.0) ─
    # smr_m / sale_m — месячные эквиваленты годовых ставок. (1+0)**(1/12)−1 == 0.0 →
    # все факторы (1+0)**t == 1.0 → побайтово идентичный cashflow при дефолте (zero-diff).
    smr_m = (1.0 + smr_inflation_annual) ** (1.0 / 12.0) - 1.0
    sale_m = (1.0 + sale_inflation_annual) ** (1.0 / 12.0) - 1.0
    smr_factor = [(1.0 + smr_m) ** t for t in range(horizon)]
    sale_factor = [(1.0 + sale_m) ** t for t in range(horizon)]

    # ── Затраты (отток, со знаком −) ───────────────────────────────────────────
    # ЗЕМЛЯ НЕ эскалируется: лумп-сумма в мес 0 (участок покупается по сегодняшней цене,
    # индекс СМР к ней неприменим). При t=0 фактор и так == 1.0, но исключаем явно.
    land_cf = _spread_linear(land, 0, 0, horizon)  # земля — единоразово в мес 0
    # ПИР и СМР+сети+услуги+резерв — СМР-сторона: эскалируются индексом СМР по месяцу t.
    pir_cf = [c * smr_factor[m] for m, c in enumerate(_spread_linear(pir, 0, pir_end, horizon))]
    # СМР+сети+услуги+резерв линейно по окну строительства [pir_end .. constr_end).
    constr_total = construction + networks + dev_services + contingency
    constr_cf = [
        c * smr_factor[m]
        for m, c in enumerate(
            _spread_linear(constr_total, pir_end, construction_duration_months, horizon)
        )
    ]

    # ── Выручка (приток, +) и привязанные к ней маркетинг/налоги ────────────────
    # Темп продаж пересчитан на ФАКТИЧЕСКОЕ окно [sales_start, sales_end): выручка
    # линейно по sales_duration (>= _SALES_DURATION_MONTHS, растянуто до ввода).
    # Выручка эскалируется индексом цены продажи по месяцу t (sale_factor).
    revenue_cf = [
        c * sale_factor[m]
        for m, c in enumerate(_spread_linear(revenue, sales_start, sales_duration, horizon))
    ]
    # Маркетинг + НДС + налог на прибыль прорейтятся на признание выручки. Прорейт идёт
    # на УЖЕ ПРОЭСКАЛИРОВАННУЮ выручку: доля месяца = revenue_cf[m] / total_nominal_revenue
    # → они едут на той же номинальной выручке, что их рождает (консистентно).
    # ZERO-DIFF GUARANTEE: при выключенной инфляции продаж знаменатель = сырая ``revenue``
    # (ровно как прежде, без зависимости от порядка float-суммирования) → формула
    # ПОБАЙТОВО тождественна прежней → Σ undiscounted cashflow == net_profit (инвариант).
    # При revenue==0 — нули.
    tax_marketing_total = marketing + vat + profit_tax
    total_nominal_revenue = revenue if sale_inflation_annual == 0.0 else sum(revenue_cf)
    if total_nominal_revenue > 0.0:
        prorate = [
            tax_marketing_total * (revenue_cf[m] / total_nominal_revenue) for m in range(horizon)
        ]
    else:
        prorate = [0.0] * horizon

    cashflow = [
        revenue_cf[m] - land_cf[m] - pir_cf[m] - constr_cf[m] - prorate[m] for m in range(horizon)
    ]
    return _CashflowResult(
        cashflow=cashflow,
        sales_duration_months_realized=sales_duration,
        schedule_is_market=schedule_is_market,
    )


def compute_financial(
    *,
    teap: TEAP,
    housing_class: HousingClass,
    land_cost_rub: float | None,
    market_price_per_sqm: float | None = None,
    price_source: str = "class_norm",
    development_type: str | None = None,
    market_velocity_sqm_per_month: float | None = None,
) -> FinancialModel:
    """Свести ТЭП + класс + стоимость земли в полный :class:`FinancialModel` с DCF.

    Полный девелоперский каскад: выручка (жильё + паркинг + нежилое 1-го этажа) →
    каскад затрат (СМР + ПИР + сети + услуги заказчика + резерв + маркетинг + земля) →
    БДР с НДС и налогом на прибыль → чистая прибыль + ROI. Поверх статики — помесячный
    DCF (NPV/IRR/PBP) по дефолт-графику фаз (см. docstring модуля). НДС — жильё
    освобождено (ст. 149 НК РФ), начисляется на нежилые части (паркинг + коммерция/
    офисы); СМР нежилого уже в total_floor_area → отдельной строки затрат нет
    (см. раздел OFFICES в docstring модуля).

    Чистая функция: НЕ ходит в БД. Калибровку цены продажи жилья делает API-слой
    (lookup рынка) и прокидывает сюда через ``market_price_per_sqm``.

    Args:
        teap: Stage 1c ТЭП (residential_area_sqm, total_floor_area_sqm, parking_spaces).
        housing_class: задаёт цену продажи (fallback) и себестоимость СМР.
        land_cost_rub: стоимость участка (опционально); None -> 0 в затратах.
        market_price_per_sqm: калиброванная рыночная цена продажи жилья, руб/кв.м.
            ``None`` → fallback на норматив класса ``_SALE_PRICE_PER_SQM``. Калибруем
            ТОЛЬКО цену жилья; цена паркинга и себестоимость СМР остаются нормативом.
        price_source: метка источника цены для honest-флага в UI/PDF — одно из
            "objective_district_median" / "district_reference" / "class_norm" (concepts-путь
            через ``_lookup_market_price``), либо "objective_geo_radius" — этот лейбл
            приходит ТОЛЬКО из site-finder-моста (``synthesize_parcel_financial``), не из
            concepts. Игнорируется (форсится "class_norm"), если ``market_price_per_sqm is None``.
        development_type: "spot"/"mid_rise"/"high_rise" — задаёт длительность СМР в
            DCF-графике (``_CONSTRUCTION_DURATION_MONTHS``). ``None``/неизвестный →
            дефолт mid_rise (36 мес). График — типовое допущение (schedule_is_default).
        market_velocity_sqm_per_month: RANK 1 — темп абсорбции ОДНОГО типового
            конкурента района (м²/мес), из velocity.project_absorption_sqm_per_month.
            Драйвит окно продаж в DCF: ``ceil(residential_area_sqm / velocity)``,
            клампится в [_SALES_DURATION_MIN, _SALES_DURATION_MAX], затем эскроу-привязка
            ``sales_end >= constr_end``. ``None``/<=0/non-finite → дефолт-30-мес окно и
            ``schedule_is_default=True``. Это НЕ суммарная скорость района (новый проект —
            один продавец из многих), иначе срок занижается и NPV/IRR раздуваются.
    """
    # Калибруем ТОЛЬКО цену продажи жилья. Если рыночной цены нет — честный fallback
    # на норматив класса, и source форсим в "class_norm" (не выдаём норму за рынок).
    if market_price_per_sqm is not None:
        sale_price = market_price_per_sqm
        price_is_calibrated = True
        resolved_price_source = price_source
    else:
        sale_price = _SALE_PRICE_PER_SQM[housing_class]
        price_is_calibrated = False
        resolved_price_source = "class_norm"
    construction_cost = _CONSTRUCTION_COST_PER_SQM[housing_class]
    # Паркинг — классозависимы цена/себестоимость (econom наземный, business
    # подземный); НОРМА м/м на квартиру задаётся в teap (не здесь).
    parking_price = _PARKING_PRICE_PER_SPOT[housing_class]
    parking_cost = _PARKING_COST_PER_SPOT[housing_class]
    # Нежилое (коммерция/офисы 1-го этажа) — цена продажи по классу (премия к жилью).
    office_price = _OFFICE_SALE_PRICE_PER_SQM[housing_class]

    # ── Выручка (GDV) ──────────────────────────────────────────────────────────
    revenue_residential = teap.residential_area_sqm * sale_price
    revenue_parking = teap.parking_spaces * parking_price
    # Нежилое — чисто ADDITIVE выручка (СМР по нему уже в construction ниже).
    revenue_office = teap.office_area_sqm * office_price
    revenue = revenue_residential + revenue_parking + revenue_office

    # ── Затраты (каскад) ───────────────────────────────────────────────────────
    # ВАЖНО: ``total_floor_area_sqm`` — это ВСЯ GFA (жилая + нежилое 1-го этажа; см.
    # teap: нежилое вырезано из ЖИЛОЙ, но осталось в total). Значит СМР по нежилой GFA
    # УЖЕ покрыт здесь → отдельной строки затрат нежилого НЕ добавляем (no double-count).
    construction_resid = teap.total_floor_area_sqm * construction_cost
    construction_parking = teap.parking_spaces * parking_cost
    construction = construction_resid + construction_parking
    pir = construction * _PIR_PCT_OF_CONSTRUCTION
    networks = teap.total_floor_area_sqm * _NETWORKS_COST_PER_SQM
    dev_services = construction * _DEVELOPER_SERVICES_PCT
    contingency = (construction + pir + networks) * _CONTINGENCY_PCT
    marketing = revenue * _MARKETING_REALTOR_PCT
    land = land_cost_rub if land_cost_rub is not None else 0.0
    cost = construction + pir + networks + dev_services + contingency + marketing + land

    # ── БДР / налоги → чистая прибыль ─────────────────────────────────────────
    gross_margin = revenue - cost  # валовая (как было)
    # НДС: жилые помещения и услуги застройщика по ДДУ освобождены (пп.22 / пп.23.1
    # п.3 ст. 149 НК РФ) → output-НДС на жильё = 0, а невозмещаемый входной НДС по СМР
    # уже сидит в gross-ставках себестоимости. НДС начисляется на НЕЖИЛЫЕ части —
    # машиноместа И коммерция/офисы 1-го этажа (их реализация облагается): встроенный
    # НДС в их value-added. Если паркинг/нежилое тоже окажутся по ДДУ-льготе — оценка
    # консервативна (чуть занижает прибыль), что соответствует «honest» политике модуля.
    parking_value_added = revenue_parking - construction_parking
    # СМР-доля нежилого по той же ставке СМР (она УЖЕ внутри construction_resid выше —
    # здесь лишь восстанавливаем её как базу НДС, в каскад затрат повторно НЕ кладём).
    office_construction = teap.office_area_sqm * construction_cost
    office_value_added = revenue_office - office_construction
    nonresidential_value_added = max(0.0, parking_value_added) + max(0.0, office_value_added)
    vat = nonresidential_value_added * _VAT_RATE / (1.0 + _VAT_RATE)
    profit_before_tax = gross_margin - vat
    profit_tax = max(0.0, profit_before_tax) * _PROFIT_TAX_RATE
    net_profit = profit_before_tax - profit_tax

    # ── Метрики ────────────────────────────────────────────────────────────────
    roi = net_profit / cost if cost > 0 else 0.0  # чистый ROI на затраты
    margin_pct = net_profit / revenue if revenue > 0 else 0.0  # чистая маржа на выручку

    # ── DCF: помесячный cashflow → NPV / IRR / PBP (PR-3) ──────────────────────
    construction_months = _CONSTRUCTION_DURATION_MONTHS.get(
        development_type or "", _DEFAULT_CONSTRUCTION_MONTHS
    )
    cf_result = _build_monthly_cashflow(
        revenue=revenue,
        construction=construction,
        pir=pir,
        networks=networks,
        dev_services=dev_services,
        contingency=contingency,
        marketing=marketing,
        land=land,
        vat=vat,
        profit_tax=profit_tax,
        construction_duration_months=construction_months,
        residential_area_sqm=teap.residential_area_sqm,
        market_velocity_sqm_per_month=market_velocity_sqm_per_month,
        # DORMANT capability: раздельная инфляция СМР/цены. Дефолт 0.0 → zero-diff
        # (фактор (1+0)**t == 1.0). Включение требует калибровки индексов И
        # переосмысления инварианта Σ cashflow == net_profit (см. модульные константы).
        smr_inflation_annual=_SMR_INFLATION_ANNUAL,
        sale_inflation_annual=_SALE_INFLATION_ANNUAL,
    )
    cashflow = cf_result.cashflow
    # Годовая ставка → месячная: (1+r_год)**(1/12) − 1.
    monthly_rate = (1.0 + _DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0
    npv = _npv(monthly_rate, cashflow)
    payback = _payback_months(cashflow)

    monthly_irr = _irr_monthly(cashflow)
    if monthly_irr is not None:
        # Настоящий IRR: месячный → годовой эффективный.
        irr = (1.0 + monthly_irr) ** 12 - 1.0
        irr_is_proxy = False
    else:
        # Вырожденный поток (нет смены знака NPV — всегда отток/приток). Fallback на
        # PR-1 proxy: аннуализированный net-ROI, клампинг ±1; флаг proxy.
        irr = max(-1.0, min(1.0, roi / _PROJECT_YEARS))
        irr_is_proxy = True

    # ── Финансирование: OVERLAY поверх unlevered cashflow (PR-5) ───────────────
    # Тот же массив cashflow, что построен выше (НЕ пересобираем — защита инварианта:
    # overlay читает unlevered поток и НЕ мутирует его). Проценты — реальная стоимость
    # долга, режут прибыль: net_after < net.
    financing = _compute_financing(cashflow, _CREDIT_RATE_ANNUAL, _LOAN_TO_COST_RATIO)
    net_profit_after_financing = net_profit - financing.total_interest_rub

    # ── Levered (equity) IRR — доходность на собственные средства ───────────────
    # IRR equity-потока того же графика долга. equity_invested = Σ |отрицательных
    # equity_cf| (суммарный отток собственных средств). Вырожденные случаи (LTC>=1 —
    # equity не под риском, нет equity-оттока) → levered_irr=0.0 / proxy (не выдумываем
    # доходность). Иначе — тот же DCF-IRR + roi-proxy fallback, что у unlevered.
    equity_cf = financing.equity_cashflow
    equity_invested = sum(-c for c in equity_cf if c < 0.0)
    if equity_invested <= 0.0:
        # LTC>=1.0 (банк покрывает весь разрыв) ИЛИ нет кассового разрыва → equity не
        # рискует → доходность equity не определена. Honest: 0.0 + proxy-флаг.
        levered_irr = 0.0
        levered_irr_is_proxy = True
    else:
        monthly_levered_irr = _irr_monthly(equity_cf)
        if monthly_levered_irr is not None:
            # Настоящий levered IRR: месячный → годовой эффективный (как unlevered).
            levered_irr = (1.0 + monthly_levered_irr) ** 12 - 1.0
            levered_irr_is_proxy = False
        else:
            # Вырожденный equity-поток (нет смены знака). Fallback на roi-style proxy:
            # прибыль на equity после процентов / equity / горизонт, клампинг ±1 — точно
            # как unlevered делает irr=roi/_PROJECT_YEARS.
            levered_roi = net_profit_after_financing / equity_invested
            levered_irr = max(-1.0, min(1.0, levered_roi / _PROJECT_YEARS))
            levered_irr_is_proxy = True

    model = FinancialModel(
        # legacy summary
        revenue_rub=round(revenue, 2),
        cost_rub=round(cost, 2),
        gross_margin_rub=round(gross_margin, 2),
        irr=round(irr, 4),
        # revenue breakdown
        revenue_residential_rub=round(revenue_residential, 2),
        revenue_parking_rub=round(revenue_parking, 2),
        revenue_office_rub=round(revenue_office, 2),
        # cost cascade
        construction_rub=round(construction, 2),
        pir_rub=round(pir, 2),
        networks_rub=round(networks, 2),
        developer_services_rub=round(dev_services, 2),
        contingency_rub=round(contingency, 2),
        marketing_rub=round(marketing, 2),
        land_rub=round(land, 2),
        # БДР / taxes
        vat_rub=round(vat, 2),
        profit_before_tax_rub=round(profit_before_tax, 2),
        profit_tax_rub=round(profit_tax, 2),
        net_profit_rub=round(net_profit, 2),
        # metrics
        roi=round(roi, 4),
        margin_pct=round(margin_pct, 4),
        irr_is_proxy=irr_is_proxy,
        # DCF (PR-3)
        npv_rub=round(npv, 2),
        payback_months=(round(payback, 1) if payback is not None else None),
        discount_rate_used=_DISCOUNT_RATE_ANNUAL,
        # RANK 1: schedule_is_default — honest-флаг. True, когда окно продаж —
        # дефолт-норматив 30 мес; False, когда оно построено по рыночной абсорбции.
        schedule_is_default=(not cf_result.schedule_is_market),
        # Реализованное окно продаж (sales_end − sales_start), мес — для UI/PDF.
        sales_duration_months=float(cf_result.sales_duration_months_realized),
        # financing overlay (PR-5)
        financing_enabled=True,
        annual_rate_used=_CREDIT_RATE_ANNUAL,
        peak_debt_rub=financing.peak_debt_rub,
        total_interest_rub=financing.total_interest_rub,
        net_profit_after_financing_rub=round(net_profit_after_financing, 2),
        financing_is_simplified=True,
        # levered (equity) IRR — доходность на собственные средства при LTC
        levered_irr=round(levered_irr, 4),
        levered_irr_is_proxy=levered_irr_is_proxy,
        # price calibration (PR-2)
        price_per_sqm_used=round(sale_price, 2),
        price_is_calibrated=price_is_calibrated,
        price_source=resolved_price_source,
    )
    logger.info(
        "financial: revenue=%.0f cost=%.0f net=%.0f roi=%.3f npv=%.0f "
        "irr=%.3f%s pbp=%s price=%.0f/m2 source=%s "
        "peak_debt=%.0f total_interest=%.0f net_after_fin=%.0f levered_irr=%.3f%s",
        model.revenue_rub,
        model.cost_rub,
        model.net_profit_rub,
        model.roi,
        model.npv_rub,
        model.irr,
        " (proxy)" if model.irr_is_proxy else "",
        model.payback_months,
        model.price_per_sqm_used,
        model.price_source,
        model.peak_debt_rub,
        model.total_interest_rub,
        model.net_profit_after_financing_rub,
        model.levered_irr,
        " (proxy)" if model.levered_irr_is_proxy else "",
    )
    return model


__all__ = ["HousingClass", "compute_financial"]
