"""PR-3 tests — monthly DCF: NPV / IRR / payback helpers + the invariant.

The DCF is hand-rolled deterministic math (no numpy-financial). These tests pin:

* ``_npv`` / ``_irr_monthly`` / ``_payback_months`` against known textbook values;
* the **invariant** Σ undiscounted monthly cashflow ≈ static ``net_profit`` — the
  DCF can never silently diverge from the static cascade;
* real IRR vs. ROI-proxy fallback (sign-change present vs. degenerate flow);
* NPV/IRR consistency (NPV>0 ⟺ IRR>discount rate).
"""

from __future__ import annotations

from itertools import pairwise

import pytest

from app.schemas.concept import TEAP
from app.services.generative import financial
from app.services.generative.financial import (
    _build_monthly_cashflow,
    _compute_financing,
    _irr_monthly,
    _npv,
    _payback_months,
)


def _teap(residential: float, gfa: float, parking: int = 10, office: float = 0.0) -> TEAP:
    return TEAP(
        built_area_sqm=100.0,
        total_floor_area_sqm=gfa,
        office_area_sqm=office,
        residential_area_sqm=residential,
        apartments_count=10,
        density=1.0,
        parking_spaces=parking,
    )


def _rebuild_cashflow(
    model: object,
    *,
    construction_months: int,
    residential_area_sqm: float,
    market_velocity_sqm_per_month: float | None = None,
) -> list[float]:
    """Пересобрать помесячный cashflow из полей модели — для инвариант-теста.

    ``residential_area_sqm`` + ``market_velocity_sqm_per_month`` должны совпадать с
    тем, чем считалась модель, иначе окно продаж разойдётся (но инвариант суммы держится
    при любом окне — перенос выручки во времени не меняет сумму потока).
    """
    m = model  # type: ignore[assignment]
    return _build_monthly_cashflow(
        revenue=m.revenue_rub,  # type: ignore[attr-defined]
        construction=m.construction_rub,  # type: ignore[attr-defined]
        pir=m.pir_rub,  # type: ignore[attr-defined]
        networks=m.networks_rub,  # type: ignore[attr-defined]
        dev_services=m.developer_services_rub,  # type: ignore[attr-defined]
        contingency=m.contingency_rub,  # type: ignore[attr-defined]
        marketing=m.marketing_rub,  # type: ignore[attr-defined]
        land=m.land_rub,  # type: ignore[attr-defined]
        vat=m.vat_rub,  # type: ignore[attr-defined]
        profit_tax=m.profit_tax_rub,  # type: ignore[attr-defined]
        construction_duration_months=construction_months,
        residential_area_sqm=residential_area_sqm,
        market_velocity_sqm_per_month=market_velocity_sqm_per_month,
    ).cashflow


# ── DCF helpers — учебные значения ─────────────────────────────────────────────


def test_npv_textbook_zero_at_known_rate() -> None:
    # cf=[-1000,0,0,1331], monthly IRR ровно 10% → NPV@0.10 == 0.
    cf = [-1000.0, 0.0, 0.0, 1331.0]
    assert abs(_npv(0.10, cf)) < 1e-6
    # NPV при ставке 0 = простая сумма потока.
    assert _npv(0.0, cf) == 331.0


def test_irr_monthly_textbook_ten_percent() -> None:
    cf = [-1000.0, 0.0, 0.0, 1331.0]
    r = _irr_monthly(cf)
    assert r is not None
    assert abs(r - 0.10) < 1e-5


def test_irr_monthly_none_when_no_sign_change() -> None:
    # Всегда отток → нет смены знака NPV → None (caller делает proxy fallback).
    assert _irr_monthly([-100.0, -100.0, -100.0]) is None
    # Всегда приток → тоже None.
    assert _irr_monthly([100.0, 100.0, 100.0]) is None


def test_payback_months_linear_interpolation() -> None:
    # cf=[-1000,0,0,1331]: накопит. -1000,-1000,-1000,+331 → перелом в мес 3,
    # дробь = 1000/1331 от месяца → 2 + 0.751 ≈ 2.75.
    pbp = _payback_months([-1000.0, 0.0, 0.0, 1331.0])
    assert pbp is not None
    assert abs(pbp - (2.0 + 1000.0 / 1331.0)) < 1e-6


def test_payback_months_none_when_never_recovers() -> None:
    # Накопленный итог не достигает 0 → None.
    assert _payback_months([-1000.0, 100.0, 100.0, 100.0]) is None


def test_payback_months_zero_when_immediately_positive() -> None:
    assert _payback_months([0.0, 50.0, 50.0]) == 0.0


# ── ИНВАРИАНТ (ключевой): Σ undiscounted cashflow ≈ net_profit ──────────────────


def test_invariant_cashflow_sum_equals_net_profit_profitable() -> None:
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
    # Σ недисконтированного потока == чистая прибыль каскада (±float-округление).
    assert abs(sum(cf) - model.net_profit_rub) < 1.0


def test_invariant_cashflow_sum_equals_net_profit_loss() -> None:
    # Убыточный сценарий: инвариант тоже держится (net < 0, налоги = 0).
    t = _teap(residential=1.0, gfa=10_000.0, parking=0)
    model = financial.compute_financial(
        teap=t, housing_class="business", land_cost_rub=None, development_type="high_rise"
    )
    assert model.net_profit_rub < 0
    cf = _rebuild_cashflow(model, construction_months=48, residential_area_sqm=1.0)
    assert abs(sum(cf) - model.net_profit_rub) < 1.0


def test_invariant_cashflow_sum_equals_net_profit_econom_parking() -> None:
    # Классозависимый паркинг (econom: цена 800k / себестоимость 350k) кормит тот же
    # каскад → инвариант Σ cashflow == net_profit держится (магнитуды паркинга
    # изменились, но cascade-структура та же).
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
    model = financial.compute_financial(
        teap=t, housing_class="econom", land_cost_rub=150_000_000.0, development_type="mid_rise"
    )
    cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
    assert abs(sum(cf) - model.net_profit_rub) < 1.0


def test_invariant_holds_for_all_development_types() -> None:
    t = _teap(residential=8_000.0, gfa=9_000.0, parking=40)
    months = {"spot": 24, "mid_rise": 36, "high_rise": 48}
    for dev_type, dur in months.items():
        model = financial.compute_financial(
            teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type=dev_type
        )
        cf = _rebuild_cashflow(model, construction_months=dur, residential_area_sqm=8_000.0)
        assert abs(sum(cf) - model.net_profit_rub) < 1.0, dev_type


# ── НДС: освобождение жилья (ст.149 НК), НДС только на паркинг ───────────────────


def test_vat_zero_for_residential_only_project() -> None:
    # Реализация жилья + услуги застройщика по ДДУ освобождены от НДС (пп.22/пп.23.1
    # п.3 ст.149 НК РФ). Проект БЕЗ паркинга → НДС == 0 (раньше было margin×0.20/1.20).
    # Profitable comfort-проект всё равно остаётся прибыльным.
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=0)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    # Освобождение явно: нет паркинга → нет облагаемого value-added → НДС ровно 0.
    assert model.vat_rub == 0.0
    assert model.revenue_parking_rub == 0.0
    # Налогооблагаемая база до налога теперь == валовой марже (НДС не съедает её).
    assert model.profit_before_tax_rub == pytest.approx(model.gross_margin_rub, abs=0.01)
    # Проект прибыльный (освобождение НДС не делает его убыточным).
    assert model.net_profit_rub > 0


def test_vat_only_on_parking_value_added() -> None:
    # С паркингом → НДС > 0 и считается ТОЛЬКО на нежилой value-added машиномест:
    # vat == max(0, revenue_parking − construction_parking) × 0.20/1.20.
    # Жилая часть (выручка/маржа жилья) в базу НДС НЕ входит (ст.149 НК РФ).
    parking = 50
    cls = "comfort"
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=parking)
    model = financial.compute_financial(
        teap=t, housing_class=cls, land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    # Ожидаемое из констант класса: revenue_parking − construction_parking паркинга.
    revenue_parking = parking * financial._PARKING_PRICE_PER_SPOT[cls]
    construction_parking = parking * financial._PARKING_COST_PER_SPOT[cls]
    parking_value_added = revenue_parking - construction_parking
    expected_vat = max(0.0, parking_value_added) * financial._VAT_RATE / (1.0 + financial._VAT_RATE)
    assert model.vat_rub > 0.0
    assert model.vat_rub == pytest.approx(round(expected_vat, 2), abs=0.01)
    # База НДС — именно нежилой value-added, а НЕ полная валовая маржа проекта.
    full_margin_vat = (
        max(0.0, model.gross_margin_rub) * financial._VAT_RATE / (1.0 + financial._VAT_RATE)
    )
    assert model.vat_rub < full_margin_vat  # жильё освобождено → НДС много меньше margin×rate


def test_vat_invariant_holds_with_parking_vat() -> None:
    # Инвариант Σ cashflow == net_profit держится с новым НДС (vat прокидывается в
    # построитель cashflow без изменения формы — меняется только база НДС).
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    assert model.vat_rub > 0.0
    cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
    assert abs(sum(cf) - model.net_profit_rub) < 1.0


# ── НЕЖИЛОЕ (коммерция/офисы 1-го этажа) ────────────────────────────────────────


def test_office_revenue_zero_when_no_office_area() -> None:
    # Нет нежилого (office=0, как у эконома/spot) → revenue_office_rub == 0, выручка =
    # жильё + паркинг (без нежилого слагаемого).
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=50, office=0.0)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    assert model.revenue_office_rub == 0.0
    assert model.revenue_rub == pytest.approx(
        model.revenue_residential_rub + model.revenue_parking_rub, abs=0.01
    )


def test_office_revenue_added_when_office_area_present() -> None:
    # office > 0 → revenue_office_rub = office_area × цена нежилого класса; общая выручка
    # = жильё + паркинг + нежилое.
    cls = "comfort"
    office = 550.0  # ~5% от GFA 11000
    t = _teap(residential=9_450.0, gfa=11_000.0, parking=50, office=office)
    model = financial.compute_financial(
        teap=t, housing_class=cls, land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    expected_office = office * financial._OFFICE_SALE_PRICE_PER_SQM[cls]
    assert model.revenue_office_rub == pytest.approx(round(expected_office, 2), abs=0.01)
    assert model.revenue_office_rub > 0.0
    assert model.revenue_rub == pytest.approx(
        model.revenue_residential_rub + model.revenue_parking_rub + model.revenue_office_rub,
        abs=0.01,
    )


def test_office_construction_not_double_counted() -> None:
    # СМР нежилого НЕ добавляется отдельной строкой: construction_rub зависит ТОЛЬКО от
    # total_floor_area (+паркинг), а не от office_area. Два проекта с одинаковым GFA, но
    # разной долей нежилого → одинаковый construction_rub (нежилое лишь меняет выручку).
    no_office = _teap(residential=8_580.0, gfa=11_000.0, parking=50, office=0.0)
    with_office = _teap(residential=8_151.0, gfa=11_000.0, parking=50, office=550.0)
    m_no = financial.compute_financial(
        teap=no_office, housing_class="comfort", land_cost_rub=None, development_type="mid_rise"
    )
    m_off = financial.compute_financial(
        teap=with_office, housing_class="comfort", land_cost_rub=None, development_type="mid_rise"
    )
    # СМР идентичен (нежилое уже в total_floor_area → double-count'а нет).
    assert m_off.construction_rub == m_no.construction_rub
    # Но нежилое даёт дополнительную выручку.
    assert m_off.revenue_office_rub > 0.0
    assert m_no.revenue_office_rub == 0.0


def test_office_vat_includes_office_value_added() -> None:
    # НДС с нежилым = НДС(паркинг value-added) + НДС(office value-added). office_value_added
    # = revenue_office − office_area × ставка СМР. Проверяем точную формулу.
    cls = "comfort"
    parking = 50
    office = 550.0
    t = _teap(residential=8_151.0, gfa=11_000.0, parking=parking, office=office)
    model = financial.compute_financial(
        teap=t, housing_class=cls, land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    revenue_parking = parking * financial._PARKING_PRICE_PER_SPOT[cls]
    construction_parking = parking * financial._PARKING_COST_PER_SPOT[cls]
    parking_va = max(0.0, revenue_parking - construction_parking)
    revenue_office = office * financial._OFFICE_SALE_PRICE_PER_SQM[cls]
    office_construction = office * financial._CONSTRUCTION_COST_PER_SQM[cls]
    office_va = max(0.0, revenue_office - office_construction)
    expected_vat = (parking_va + office_va) * financial._VAT_RATE / (1.0 + financial._VAT_RATE)
    assert model.vat_rub == pytest.approx(round(expected_vat, 2), abs=0.01)
    # НДС с нежилым строго БОЛЬШЕ, чем НДС только паркинга (нежилое добавляет базу).
    parking_only_vat = parking_va * financial._VAT_RATE / (1.0 + financial._VAT_RATE)
    assert model.vat_rub > parking_only_vat


def test_office_invariant_cashflow_sum_equals_net_profit() -> None:
    # Инвариант Σ cashflow == net_profit держится с нежилым (office-выручка течёт по той
    # же revenue → cashflow дорожке; НДС нежилого прокидывается в построитель потока).
    t = _teap(residential=8_151.0, gfa=11_000.0, parking=50, office=550.0)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    assert model.revenue_office_rub > 0.0
    cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=8_151.0)
    assert abs(sum(cf) - model.net_profit_rub) < 1.0


def test_office_carveout_shrinks_residential_via_compute_teap() -> None:
    # РЕАЛИЗМ-ФИКС на уровне compute_teap: нежилое вырезается из GFA → жилая ужимается на
    # office_area × efficiency, а total_floor_area не меняется. Comfort (share 0.05) vs
    # econom (share 0.0) при той же геометрии: у comfort office>0, жилая ниже на office-вклад.
    from shapely.geometry import box

    from app.services.generative import teap as teap_mod

    footprint = box(0, 0, 20, 10)  # 200 кв.м пятна
    comfort = teap_mod.compute_teap(
        footprints=[footprint], floors=10, site_area_sqm=1000.0, housing_class="comfort"
    )
    # GFA 2000; office = 2000 × 0.05 = 100; residential = (2000−100) × 0.78 = 1482.
    assert comfort.total_floor_area_sqm == 2000.0
    assert comfort.office_area_sqm == 100.0
    assert comfort.residential_area_sqm == pytest.approx(1482.0, abs=0.1)
    # econom: share 0 → office 0, residential по полной GFA × 0.82 = 1640 (unchanged).
    econom = teap_mod.compute_teap(
        footprints=[footprint], floors=10, site_area_sqm=1000.0, housing_class="econom"
    )
    assert econom.office_area_sqm == 0.0
    assert econom.residential_area_sqm == pytest.approx(1640.0, abs=0.1)


# ── Настоящий IRR vs proxy fallback ─────────────────────────────────────────────


def test_real_irr_on_normal_developer_flow() -> None:
    # Нормальный поток: затраты рано, выручка поздно → смена знака есть → IRR настоящий.
    t = _teap(residential=11_000.0, gfa=12_000.0, parking=80)
    model = financial.compute_financial(
        teap=t, housing_class="business", land_cost_rub=100_000_000.0, development_type="spot"
    )
    assert model.irr_is_proxy is False
    assert model.net_profit_rub > 0
    # Годовой IRR настоящий (а не зажатый proxy).
    assert model.irr > 0.0


def test_proxy_fallback_on_always_loss_flow() -> None:
    # Всегда-убыток (выручка ниже затрат, нет месяца с притоком сверх оттока в начале):
    # NPV без смены знака → IRR=None → proxy fallback, флаг proxy.
    t = _teap(residential=1.0, gfa=20_000.0, parking=0)
    model = financial.compute_financial(
        teap=t, housing_class="business", land_cost_rub=500_000_000.0, development_type="mid_rise"
    )
    assert model.gross_margin_rub < 0
    assert model.irr_is_proxy is True
    assert -1.0 <= model.irr <= 1.0


# ── NPV/IRR консистентность ─────────────────────────────────────────────────────


def test_npv_positive_when_irr_above_discount_rate() -> None:
    t = _teap(residential=11_000.0, gfa=12_000.0, parking=80)
    model = financial.compute_financial(
        teap=t, housing_class="business", land_cost_rub=100_000_000.0, development_type="spot"
    )
    assert model.irr_is_proxy is False
    # Консистентность DCF: NPV>0 ⟺ IRR>ставка дисконтирования.
    assert (model.npv_rub > 0.0) == (model.irr > model.discount_rate_used)


# ── Новые DCF-поля присутствуют и осмысленны ────────────────────────────────────


def test_dcf_fields_present_and_typed() -> None:
    t = _teap(residential=5_000.0, gfa=6_000.0, parking=20)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=50_000_000.0, development_type="mid_rise"
    )
    assert isinstance(model.npv_rub, float)
    assert model.discount_rate_used == 0.15
    assert model.schedule_is_default is True
    assert model.payback_months is None or isinstance(model.payback_months, float)


def _sign_changes(cf: list[float], *, tol: float = 1e-6) -> int:
    """Число смен знака в потоке (нули пропускаем). Нормальный девелоперский поток
    [--- +++] имеет ровно 1 смену; патологический [--- +++ ---] имеет 2 → IRR-бисекция
    не брекетит."""
    signs = [1 if c > tol else -1 if c < -tol else 0 for c in cf]
    nonzero = [s for s in signs if s != 0]
    return sum(1 for a, b in pairwise(nonzero) if a != b)


def test_development_type_changes_construction_window_and_dcf() -> None:
    # Та же экономика, разный development_type → разная длительность СМР → разные NPV/IRR.
    # Статический net_profit идентичен (development_type на каскад не влияет), но DCF
    # расходится из-за разной фазировки cashflow.
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=60)
    spot = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type="spot"
    )
    high = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type="high_rise"
    )
    assert spot.net_profit_rub == high.net_profit_rub
    assert spot.npv_rub != high.npv_rub
    assert spot.irr != high.irr


def test_sales_never_end_before_construction_for_all_dev_types() -> None:
    # ИНВАРИАНТ ГРАФИКА (регресс-тест бага): продажи не заканчиваются раньше завершения
    # стройки → нет хвоста затрат после окончания продаж → ровно ОДНА смена знака в
    # потоке (нормальный [--- +++]), а не патологические две ([--- +++ ---]). Двойная
    # смена знака ломала IRR-бисекцию и роняла profitable-проект в proxy с инверсией NPV.
    # Для high_rise (СМР=48 → constr_end=54, sales_start=30) старое окно [30..60) уже
    # покрывало ввод; критичный тип — где sales_end < constr_end в старой логике.
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=60)
    months = {"spot": 24, "mid_rise": 36, "high_rise": 48}
    for dev_type, dur in months.items():
        model = financial.compute_financial(
            teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type=dev_type
        )
        assert model.net_profit_rub > 0, dev_type  # сценарий profitable для всех типов
        cf = _rebuild_cashflow(model, construction_months=dur, residential_area_sqm=10_000.0)
        # Ровно одна смена знака → бисекция брекетит → настоящий IRR, не proxy.
        assert _sign_changes(cf) == 1, (dev_type, _sign_changes(cf))
        assert model.irr_is_proxy is False, dev_type


def test_profitable_high_rise_gets_real_irr_not_proxy() -> None:
    # РЕГРЕСС именно на баг: profitable high_rise (СМР=48) раньше распродавался на мес 42
    # за 12 мес ДО ввода (54) → хвост затрат после продаж → двойная смена знака →
    # IRR-бисекция не брекетит → молчаливый proxy + инверсия направления NPV. Привязка
    # sales_end >= construction_end это устраняет: настоящий IRR.
    t = _teap(residential=11_000.0, gfa=12_000.0, parking=80)
    high = financial.compute_financial(
        teap=t, housing_class="business", land_cost_rub=100_000_000.0, development_type="high_rise"
    )
    assert high.net_profit_rub > 0
    assert high.irr_is_proxy is False
    assert high.irr is not None
    # Поток high_rise: ровно одна смена знака (нет патологического хвоста затрат).
    cf = _rebuild_cashflow(high, construction_months=48, residential_area_sqm=11_000.0)
    assert _sign_changes(cf) == 1


def test_unknown_development_type_defaults_to_mid_rise() -> None:
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=60)
    explicit_mid = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type="mid_rise"
    )
    none_type = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=80_000_000.0, development_type=None
    )
    assert none_type.npv_rub == explicit_mid.npv_rub
    assert none_type.irr == explicit_mid.irr


# ── FINANCING — OVERLAY (PR-5) ──────────────────────────────────────────────────


def test_financing_does_not_mutate_operating_cashflow() -> None:
    # OVERLAY: _compute_financing читает unlevered поток READ-ONLY, НЕ мутирует.
    cf = [-1000.0, -500.0, 0.0, 800.0, 900.0]
    snapshot = list(cf)
    _compute_financing(cf, 0.15)
    assert cf == snapshot


def test_financing_draws_debt_on_cash_gap() -> None:
    # Ранний отток / поздняя выручка → нужна кредитная линия → peak_debt>0, %>0.
    cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
    res = _compute_financing(cf, 0.15)
    assert res.peak_debt_rub > 0.0
    assert res.total_interest_rub > 0.0


def test_financing_zero_when_no_cash_gap() -> None:
    # Поток без минуса → финансирование не требуется → debt=0, interest=0.
    cf = [100.0, 200.0, 300.0]
    res = _compute_financing(cf, 0.15)
    assert res.peak_debt_rub == 0.0
    assert res.total_interest_rub == 0.0
    assert res.ending_debt_rub == 0.0


def test_financing_interest_capitalises_compound() -> None:
    # Долг 1000 в мес 0, дальше ноль → проценты КАПИТАЛИЗИРУЮТСЯ (начисляются на
    # растущий остаток). За мес 1 и 2 суммарный % > 2× месячного % от 1000 (compound).
    cf = [-1000.0, 0.0, 0.0]
    res = _compute_financing(cf, 0.15)
    monthly_rate = (1.0 + 0.15) ** (1.0 / 12.0) - 1.0
    # Мес 0: % с нуля = 0, выборка 1000. Мес 1: % = 1000·r. Мес 2: % = (1000+1000·r)·r.
    expected = 1000.0 * monthly_rate + (1000.0 + 1000.0 * monthly_rate) * monthly_rate
    assert abs(res.total_interest_rub - round(expected, 2)) < 0.5
    # Compound строго больше простого 2× месячного % от исходного тела долга.
    assert res.total_interest_rub > 2.0 * 1000.0 * monthly_rate


def test_financing_interest_monotonic_in_rate() -> None:
    # Монотонность: выше ставка → выше суммарные проценты при том же потоке.
    cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
    low = _compute_financing(cf, 0.10)
    high = _compute_financing(cf, 0.20)
    assert high.total_interest_rub > low.total_interest_rub
    assert high.peak_debt_rub >= low.peak_debt_rub


def test_net_profit_after_financing_equals_net_minus_interest() -> None:
    # Тождество модели: net_after == net_profit − total_interest (float-tol).
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    expected = model.net_profit_rub - model.total_interest_rub
    assert abs(model.net_profit_after_financing_rub - expected) < 0.01


def test_net_profit_after_financing_below_net_for_project_with_gap() -> None:
    # Проект с кассовым разрывом: проценты режут прибыль → net_after < net.
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    assert model.total_interest_rub > 0.0
    assert model.net_profit_after_financing_rub < model.net_profit_rub


def test_financing_overlay_does_not_touch_unlevered_invariant() -> None:
    # РЕГРЕСС: overlay не трогает unlevered cashflow → инвариант Σ cf == net_profit
    # держится как до PR-5 (financing — поверх готового потока, не пересборка).
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
    assert abs(sum(cf) - model.net_profit_rub) < 1.0


def test_financing_enabled_on_realistic_parcel() -> None:
    # Интеграция compute_financial: реалистичный участок → overlay посчитан.
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    assert model.financing_enabled is True
    assert model.financing_is_simplified is True
    assert model.annual_rate_used == financial._CREDIT_RATE_ANNUAL
    assert model.peak_debt_rub > 0.0


def test_financing_ltc_reduces_debt_vs_full_coverage() -> None:
    # LTC 0.70 → пиковый долг и проценты < чем при 100% покрытии (ltc=1.0).
    cf = [-1000.0, -1000.0, -500.0, 0.0, 1500.0, 1500.0]
    full = _compute_financing(cf, 0.15, ltc_ratio=1.0)
    ltc70 = _compute_financing(cf, 0.15, ltc_ratio=0.70)
    assert ltc70.peak_debt_rub < full.peak_debt_rub
    assert ltc70.total_interest_rub < full.total_interest_rub
    # LTC 0.70 → debt = 70% от full для этого fixture (все оттоки ДО притоков,
    # поэтому peak достигается до первого погашения и масштабируется линейно).
    assert abs(ltc70.peak_debt_rub / full.peak_debt_rub - 0.70) < 0.01


def test_compute_financial_uses_ltc_ratio() -> None:
    # compute_financial передаёт _LOAN_TO_COST_RATIO в _compute_financing:
    # total_interest строго < чем при 100%-долге на том же cashflow.
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    # Нет прямого доступа к «100%-версии», но LTC < 1.0 → peak_debt < revenue
    # (у реалистичного МКД-проекта затраты ~ 70-80% выручки, значит
    # LTC*затраты < затраты — пиковый долг не равен всем затратам).
    assert 0.0 < model.peak_debt_rub < model.cost_rub
    assert model.total_interest_rub > 0.0
    assert model.net_profit_after_financing_rub == pytest.approx(
        model.net_profit_rub - model.total_interest_rub, abs=0.01
    )


# ── LEVERED (EQUITY) IRR ─────────────────────────────────────────────────────────


def test_equity_cashflow_shape_invested_matches_outflow_share() -> None:
    # equity_invested = Σ |отрицательных equity_cf| ≈ (1−ltc) × Σ оттоков unlevered.
    cf = [-1000.0, -1000.0, -500.0, 0.0, 1500.0, 1500.0]
    ltc = 0.70
    res = _compute_financing(cf, 0.15, ltc_ratio=ltc)
    equity_cf = res.equity_cashflow
    assert len(equity_cf) == len(cf)
    equity_invested = sum(-c for c in equity_cf if c < 0.0)
    total_outflow = sum(-c for c in cf if c < 0.0)
    # Equity-доля оттоков = (1−ltc) от каждого разрыва → Σ масштабируется линейно.
    assert equity_invested == pytest.approx((1.0 - ltc) * total_outflow, rel=1e-9)


def test_equity_cashflow_sum_equals_net_after_financing() -> None:
    # Σ equity_cashflow == net_profit_after_financing: equity получает всю прибыль за
    # вычетом процентов (банк — только тело долга + проценты). Sanity на equity-поток.
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
    res = _compute_financing(cf, financial._CREDIT_RATE_ANNUAL, financial._LOAN_TO_COST_RATIO)
    assert abs(sum(res.equity_cashflow) - model.net_profit_after_financing_rub) < 1.0


def test_levered_irr_exceeds_unlevered_on_profitable_project() -> None:
    # Положительный леверидж: доходность проекта > стоимости долга 15% → equity-возврат
    # выше проектного. levered_irr > irr на профитном leveraged-проекте.
    t = _teap(residential=11_000.0, gfa=12_000.0, parking=80)
    model = financial.compute_financial(
        teap=t, housing_class="business", land_cost_rub=100_000_000.0, development_type="spot"
    )
    assert model.net_profit_rub > 0
    assert model.irr_is_proxy is False
    assert model.levered_irr_is_proxy is False
    # Положительный леверидж → equity-IRR строго выше unlevered project-IRR.
    assert model.levered_irr > model.irr


def test_levered_irr_proxy_on_degenerate_equity_flow() -> None:
    # Вырожденный equity-поток (нет смены знака) → IRR не брекетится → proxy fallback.
    # Equity-поток без смены знака: только оттоки (нет притока сверх обслуживания долга).
    cf = [-1000.0, -1000.0, -1000.0]  # всегда отток → equity_cf тоже всегда отток
    equity_cf = _compute_financing(cf, 0.15, ltc_ratio=0.70).equity_cashflow
    assert _irr_monthly(equity_cf) is None  # вырожден → бисекция не брекетит


def test_levered_irr_zero_proxy_when_ltc_full() -> None:
    # LTC=1.0 → банк покрывает весь разрыв → equity не под риском (equity_invested=0)
    # → levered_irr=0.0 / proxy (не выдумываем доходность). Проверяем через прямой поток.
    cf = [-1000.0, -1000.0, 0.0, 1500.0, 1500.0]
    res = _compute_financing(cf, 0.15, ltc_ratio=1.0)
    equity_invested = sum(-c for c in res.equity_cashflow if c < 0.0)
    assert equity_invested == pytest.approx(0.0, abs=1e-9)


def test_compute_financial_levered_irr_fields_present() -> None:
    # Интеграция: профитный участок → levered_irr посчитан, поля присутствуют/типизированы.
    # business/spot — проектный IRR ВЫШЕ стоимости долга 15% → положительный леверидж
    # (у marginal-проекта с проектным IRR < 15% леверидж отрицателен — это корректно,
    # см. test_levered_irr_below_unlevered_on_marginal_project ниже).
    t = _teap(residential=11_000.0, gfa=12_000.0, parking=80)
    model = financial.compute_financial(
        teap=t, housing_class="business", land_cost_rub=100_000_000.0, development_type="spot"
    )
    assert isinstance(model.levered_irr, float)
    assert isinstance(model.levered_irr_is_proxy, bool)
    # Профитный leveraged-проект (LTC=0.70, проектный IRR > 15%): levered IRR настоящий,
    # выше unlevered (положительный финансовый рычаг).
    assert model.levered_irr_is_proxy is False
    assert model.levered_irr > model.irr


def test_levered_irr_below_unlevered_on_marginal_project() -> None:
    # ОТРИЦАТЕЛЬНЫЙ леверидж — sanity на корректность знака: marginal comfort/mid_rise
    # проект, где проектный IRR (~4.6%) НИЖЕ стоимости долга 15% → долг разрушает
    # стоимость equity → levered_irr < irr. Модель НЕ выдумывает положительный рычаг,
    # когда проект не отбивает стоимость кредита.
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    assert model.net_profit_rub > 0  # статически проект прибыльный
    assert model.irr < model.annual_rate_used  # но проектный IRR ниже стоимости долга
    # Отрицательный рычаг: долг дороже доходности проекта → equity-возврат хуже проектного.
    assert model.levered_irr < model.irr


# ── RANK 1: окно продаж по рыночной абсорбции района ────────────────────────────


def _build(
    *,
    residential: float,
    velocity: float | None,
    construction_months: int = 36,
) -> object:
    """Прямой вызов _build_monthly_cashflow с минимальным валидным каскадом."""
    return _build_monthly_cashflow(
        revenue=1_000_000_000.0,
        construction=400_000_000.0,
        pir=10_000_000.0,
        networks=30_000_000.0,
        dev_services=6_000_000.0,
        contingency=13_000_000.0,
        marketing=70_000_000.0,
        land=100_000_000.0,
        vat=50_000_000.0,
        profit_tax=40_000_000.0,
        construction_duration_months=construction_months,
        residential_area_sqm=residential,
        market_velocity_sqm_per_month=velocity,
    )


def test_velocity_none_keeps_default_30mo_schedule() -> None:
    # (a) velocity=None → дефолт-30-мес окно, schedule_is_default=True,
    # Σ cashflow == net_profit (перенос выручки не меняет сумму).
    t = _teap(residential=22_000.0, gfa=24_000.0, parking=60)
    default_model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=150_000_000.0, development_type="mid_rise"
    )
    assert default_model.schedule_is_default is True
    # net_profit инвариантен сумме потока (велосити не трогали).
    cf = _rebuild_cashflow(
        default_model,
        construction_months=36,
        residential_area_sqm=22_000.0,
        market_velocity_sqm_per_month=None,
    )
    assert abs(sum(cf) - default_model.net_profit_rub) < 1.0
    # sales_duration_months выставлен и совпадает с дефолт-окном (с эскроу-привязкой).
    assert default_model.sales_duration_months is not None
    # Дефолт-окно для mid_rise: constr_end=42, sales_start=12 → max(12+30, 42)−12 = 30.
    assert default_model.sales_duration_months == pytest.approx(30.0)


def test_velocity_realistic_drives_market_schedule() -> None:
    # (b) residential 22000 м², velocity 800 м²/мес → ceil(27.5)=28 мес окно.
    t = _teap(residential=22_000.0, gfa=24_000.0, parking=60)
    model = financial.compute_financial(
        teap=t,
        housing_class="comfort",
        land_cost_rub=150_000_000.0,
        development_type="mid_rise",
        market_velocity_sqm_per_month=800.0,
    )
    assert model.schedule_is_default is False
    assert model.sales_duration_months is not None
    # mid_rise: constr_end=42, sales_start=12. base=28 → sales_end=max(40,42)=42 → 30.
    # Эскроу-привязка вытягивает окно до ввода; market-флаг при этом остаётся.
    assert model.sales_duration_months == pytest.approx(30.0)
    # Инвариант держится при market-графике.
    cf = _rebuild_cashflow(
        model,
        construction_months=36,
        residential_area_sqm=22_000.0,
        market_velocity_sqm_per_month=800.0,
    )
    assert abs(sum(cf) - model.net_profit_rub) < 1.0


def test_velocity_market_window_below_construction_for_short_build() -> None:
    # (b') Тот же темп, но spot (короткая стройка) → market-окно 28 мес РЕАЛЬНО видно:
    # constr_end=30, sales_start=12 → base=28 → sales_end=max(40,30)=40 → окно 28.
    t = _teap(residential=22_000.0, gfa=24_000.0, parking=60)
    model = financial.compute_financial(
        teap=t,
        housing_class="comfort",
        land_cost_rub=150_000_000.0,
        development_type="spot",
        market_velocity_sqm_per_month=800.0,
    )
    assert model.schedule_is_default is False
    assert model.sales_duration_months == pytest.approx(28.0)
    # Регресс PR-3: даже когда market-окно КОРОЧЕ дефолта (эскроу не доминирует),
    # cashflow обязан иметь РОВНО одну смену знака → IRR настоящий, не proxy.
    assert model.irr_is_proxy is False
    res = _build(residential=22_000.0, velocity=800.0, construction_months=24)
    assert _sign_changes(res.cashflow) == 1  # type: ignore[attr-defined]


def test_velocity_high_clamps_to_min_and_respects_escrow() -> None:
    # (c) очень высокая velocity → raw крошечный → клампится в _SALES_DURATION_MIN,
    # sales_end НЕ раньше constr_end (эскроу), профитный проект → IRR не proxy.
    t = _teap(residential=22_000.0, gfa=24_000.0, parking=60)
    model = financial.compute_financial(
        teap=t,
        housing_class="comfort",
        land_cost_rub=150_000_000.0,
        development_type="mid_rise",
        market_velocity_sqm_per_month=1_000_000.0,  # абсурдно горячий рынок
    )
    assert model.schedule_is_default is False
    assert model.net_profit_rub > 0
    # Прямой каскад: base клампится к MIN=6, но эскроу вытягивает до constr_end.
    res = _build(residential=22_000.0, velocity=1_000_000.0, construction_months=36)
    # base=6 → sales_end=max(12+6, 42)=42 → realized=30 (эскроу > MIN).
    assert res.schedule_is_market is True  # type: ignore[attr-defined]
    assert res.sales_duration_months_realized == 30  # type: ignore[attr-defined]
    # IRR настоящий на профитном проекте (нет двойной смены знака).
    assert model.irr_is_proxy is False


def test_velocity_high_clamps_to_min_visible_on_short_build() -> None:
    # (c') high velocity на spot: base=MIN=6, constr_end=30 → sales_end=max(18,30)=30 → 18.
    # MIN-кламп виден на коротком строительстве, где эскроу не доминирует над окном.
    res = _build(residential=22_000.0, velocity=1_000_000.0, construction_months=24)
    # constr_end=6+24=30, sales_start=12, base=6 → sales_end=max(18,30)=30 → 18.
    assert res.sales_duration_months_realized == 18  # type: ignore[attr-defined]
    assert res.schedule_is_market is True  # type: ignore[attr-defined]


def test_velocity_low_clamps_to_max() -> None:
    # (d) очень низкая velocity → raw огромный → клампится в _SALES_DURATION_MAX=120.
    # residential 22000 / velocity 10 → raw=2200 → clamp 120.
    res = _build(residential=22_000.0, velocity=10.0, construction_months=36)
    # base=120 → sales_end=max(12+120, 42)=132 → realized=120.
    assert res.sales_duration_months_realized == financial._SALES_DURATION_MAX  # type: ignore[attr-defined]
    assert res.schedule_is_market is True  # type: ignore[attr-defined]


@pytest.mark.parametrize("bad_velocity", [0.0, -50.0, float("inf"), float("nan")])
def test_velocity_nonpositive_or_nonfinite_falls_back_to_default(bad_velocity: float) -> None:
    # (e) velocity <= 0 или non-finite → как None → дефолт-график.
    res = _build(residential=22_000.0, velocity=bad_velocity, construction_months=36)
    assert res.schedule_is_market is False  # type: ignore[attr-defined]
    # Дефолт mid_rise окно: 30.
    assert res.sales_duration_months_realized == 30  # type: ignore[attr-defined]


def test_velocity_zero_residential_falls_back_to_default() -> None:
    # residential 0 → деление невозможно → дефолт-график.
    res = _build(residential=0.0, velocity=800.0, construction_months=36)
    assert res.schedule_is_market is False  # type: ignore[attr-defined]


def test_velocity_invariant_holds_across_window_sizes() -> None:
    # Перенос выручки во времени НЕ меняет сумму потока — инвариант при любом окне.
    for vel in (None, 800.0, 50.0, 1_000_000.0):
        res = _build(residential=22_000.0, velocity=vel, construction_months=36)
        cf = res.cashflow  # type: ignore[attr-defined]
        # Σ = revenue − costs − vat − tax (земля+ПИР+СМР+сети+услуги+резерв+маркетинг).
        expected = (
            1_000_000_000.0
            - 400_000_000.0
            - 10_000_000.0
            - 30_000_000.0
            - 6_000_000.0
            - 13_000_000.0
            - 70_000_000.0
            - 100_000_000.0
            - 50_000_000.0
            - 40_000_000.0
        )
        assert abs(sum(cf) - expected) < 1.0, vel


# ── РАЗДЕЛЬНАЯ ИНФЛЯЦИЯ ИНДЕКСОВ — DORMANT CAPABILITY (default OFF) ───────────────


def _build_inflation(
    *,
    smr_inflation_annual: float = 0.0,
    sale_inflation_annual: float = 0.0,
    construction_months: int = 36,
    revenue: float = 1_000_000_000.0,
    construction: float = 400_000_000.0,
    pir: float = 10_000_000.0,
    networks: float = 30_000_000.0,
    dev_services: float = 6_000_000.0,
    contingency: float = 13_000_000.0,
    marketing: float = 70_000_000.0,
    land: float = 100_000_000.0,
    vat: float = 50_000_000.0,
    profit_tax: float = 40_000_000.0,
) -> object:
    """Прямой вызов _build_monthly_cashflow с явными ставками инфляции (capability)."""
    return _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=22_000.0,
        market_velocity_sqm_per_month=None,
        smr_inflation_annual=smr_inflation_annual,
        sale_inflation_annual=sale_inflation_annual,
    )


def test_module_inflation_constants_default_off() -> None:
    # SAFETY: модульные константы строго 0.0 (capability разведена, но выключена в проде).
    assert financial._SMR_INFLATION_ANNUAL == 0.0
    assert financial._SALE_INFLATION_ANNUAL == 0.0


def test_zero_diff_explicit_zero_vs_omitted_byte_identical() -> None:
    # ZERO-DIFF (критично): дефолт без аргументов vs явные 0.0 → ПОБАЙТОВО идентичный
    # cashflow. Пин: прод (константы 0.0) не изменился вообще.
    omitted = _build(residential=22_000.0, velocity=None, construction_months=36)
    explicit = _build_inflation(
        smr_inflation_annual=0.0, sale_inflation_annual=0.0, construction_months=36
    )
    cf_omitted = omitted.cashflow  # type: ignore[attr-defined]
    cf_explicit = explicit.cashflow  # type: ignore[attr-defined]
    assert cf_explicit == cf_omitted  # списки float идентичны элемент-в-элемент


def test_zero_diff_npv_irr_invariant_unchanged_at_default() -> None:
    # ZERO-DIFF: NPV, IRR и инвариант Σ cashflow == net_profit идентичны при явных 0.0.
    omitted = _build(residential=22_000.0, velocity=None, construction_months=48)
    explicit = _build_inflation(
        sale_inflation_annual=0.0, smr_inflation_annual=0.0, construction_months=48
    )
    cf_o = omitted.cashflow  # type: ignore[attr-defined]
    cf_e = explicit.cashflow  # type: ignore[attr-defined]
    monthly_rate = (1.0 + financial._DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0
    assert _npv(monthly_rate, cf_e) == _npv(monthly_rate, cf_o)
    assert _irr_monthly(cf_e) == _irr_monthly(cf_o)
    # Инвариант (today-money): Σ == revenue − все затраты − vat − tax.
    expected = (
        1_000_000_000.0
        - 400_000_000.0
        - 10_000_000.0
        - 30_000_000.0
        - 6_000_000.0
        - 13_000_000.0
        - 70_000_000.0
        - 100_000_000.0
        - 50_000_000.0
        - 40_000_000.0
    )
    assert abs(sum(cf_e) - expected) < 1.0


def test_compute_financial_zero_diff_with_module_defaults() -> None:
    # ZERO-DIFF на уровне compute_financial: пересборка cashflow с теми же ставками,
    # что использует прод (константы 0.0), даёт инвариант Σ == net_profit (как было).
    t = _teap(residential=10_000.0, gfa=11_000.0, parking=50)
    model = financial.compute_financial(
        teap=t, housing_class="comfort", land_cost_rub=200_000_000.0, development_type="mid_rise"
    )
    cf = _rebuild_cashflow(model, construction_months=36, residential_area_sqm=10_000.0)
    # _rebuild_cashflow не передаёт ставки → дефолт 0.0 → тот же поток, что построил прод.
    assert abs(sum(cf) - model.net_profit_rub) < 1.0


def test_inflation_escalates_late_cost_by_monthly_compound_factor() -> None:
    # COMPOUNDING (a): поздний СМР-отток = базовый × (1+smr_m)**t, где
    # smr_m = (1.08)**(1/12) − 1. Сравниваем месяц t для базового vs инфлированного
    # потока → отношение == ровно фактор компаундинга месяца t.
    base = _build_inflation(smr_inflation_annual=0.0, construction_months=36)
    infl = _build_inflation(smr_inflation_annual=0.08, construction_months=36)
    cf_base = base.cashflow  # type: ignore[attr-defined]
    cf_infl = infl.cashflow  # type: ignore[attr-defined]
    smr_m = (1.08) ** (1.0 / 12.0) - 1.0
    # Возьмём месяц ВНУТРИ окна СМР, ДО старта продаж (чистый СМР-отток, без выручки):
    # pir_end=6, constr_start=6, sales_start=6+6=12. Месяц 10 — чистый СМР+сети+резерв.
    t = 10
    # Затраты — отрицательные; отношение по модулю == фактор компаундинга.
    factor = (1.0 + smr_m) ** t
    assert cf_infl[t] == pytest.approx(cf_base[t] * factor, rel=1e-9)
    # Sanity: фактор для t=10 близок к (1.08)**(10/12).
    assert factor == pytest.approx(1.08 ** (10.0 / 12.0), rel=1e-9)


def test_inflation_revenue_escalates_by_sale_factor() -> None:
    # COMPOUNDING: приток выручки месяца t = базовый × (1+sale_m)**t. Берём проект без
    # затрат в окне продаж, чтобы изолировать выручку: месяц с чистым притоком.
    base = _build_inflation(sale_inflation_annual=0.0, construction_months=24)
    infl = _build_inflation(sale_inflation_annual=0.06, construction_months=24)
    cf_base = base.cashflow  # type: ignore[attr-defined]
    cf_infl = infl.cashflow  # type: ignore[attr-defined]
    # Последний месяц горизонта — чистый приток (СМР завершён, продажи идут): на нём
    # выручка эскалирована сильнее всего. Проверяем направление: инфл-приток > базового.
    last = len(cf_base) - 1
    assert cf_infl[last] > cf_base[last]


def test_nominal_cashflow_sum_diverges_from_net_profit_when_on() -> None:
    # COMPOUNDING (b): при ставках > 0 Σ НОМИНАЛЬНОГО потока != today-money сумме.
    # Это ОЖИДАЕМО (номинальный DCF vs today-money БДР — разные метрики), а не баг.
    today_money_sum = (
        1_000_000_000.0
        - 400_000_000.0
        - 10_000_000.0
        - 30_000_000.0
        - 6_000_000.0
        - 13_000_000.0
        - 70_000_000.0
        - 100_000_000.0
        - 50_000_000.0
        - 40_000_000.0
    )
    infl = _build_inflation(smr_inflation_annual=0.08, sale_inflation_annual=0.06)
    cf = infl.cashflow  # type: ignore[attr-defined]
    # Номинальная сумма ОТЛИЧАЕТСЯ от today-money (инвариант намеренно не держится on).
    assert abs(sum(cf) - today_money_sum) > 1.0


def test_npv_lower_when_cost_inflation_applied_leverage_on_cost() -> None:
    # COMPOUNDING (c): ИЗОЛИРОВАННЫЙ эффект СМР-инфляции — leverage на затраты.
    # СМР-инфляция эскалирует ОТТОКИ (затраты), не трогая притоки → NPV строго НИЖЕ
    # нулевого сценария. Это однозначное направление «leverage on cost»: затраты
    # дорожают, выручка нет → ценность падает. Проверяем НАПРАВЛЕНИЕ, не магич. число.
    #
    # ПРИМЕЧАНИЕ (почему изолируем СМР, а не сравниваем smr8/sale6 vs zero): на этом
    # фикстуре выручка (1.0B) много больше затрат, поэтому sale-инфляция 6% на крупном
    # back-loaded притоке ПЕРЕВЕШИВАЕТ cost-инфляцию 8% — комбинированный NPV выходит ВЫШЕ
    # нулевого. Это корректно (направление комбинации зависит от отношения выручка/затраты);
    # «leverage on cost» — про ИЗОЛИРОВАННЫЙ cost-канал, который монотонно режет NPV.
    monthly_rate = (1.0 + financial._DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0
    zero = _build_inflation(smr_inflation_annual=0.0, sale_inflation_annual=0.0)
    cost_only = _build_inflation(smr_inflation_annual=0.08, sale_inflation_annual=0.0)
    npv_zero = _npv(monthly_rate, zero.cashflow)  # type: ignore[attr-defined]
    npv_cost = _npv(monthly_rate, cost_only.cashflow)  # type: ignore[attr-defined]
    assert npv_cost < npv_zero
    # Зеркало: ИЗОЛИРОВАННАЯ sale-инфляция эскалирует притоки → NPV ВЫШЕ нулевого.
    sale_only = _build_inflation(smr_inflation_annual=0.0, sale_inflation_annual=0.06)
    npv_sale = _npv(monthly_rate, sale_only.cashflow)  # type: ignore[attr-defined]
    assert npv_sale > npv_zero


def test_npv_monotonic_decreasing_in_smr_inflation() -> None:
    # MONOTONICITY: выше СМР-инфляция → ниже (или равный) NPV, всё прочее равно
    # (sale_inflation фиксирована 0). Проверяем по сетке ставок.
    monthly_rate = (1.0 + financial._DISCOUNT_RATE_ANNUAL) ** (1.0 / 12.0) - 1.0
    rates = [0.0, 0.02, 0.05, 0.08, 0.12]
    npvs = [
        _npv(monthly_rate, _build_inflation(smr_inflation_annual=r).cashflow)  # type: ignore[attr-defined]
        for r in rates
    ]
    for prev, nxt in pairwise(npvs):
        assert nxt <= prev  # NPV не растёт при росте СМР-инфляции


def test_land_not_escalated_by_smr_inflation() -> None:
    # ЗЕМЛЯ НЕ эскалируется: лумп в мес 0. Меняем ТОЛЬКО землю при той же СМР-инфляции —
    # cashflow[0] двигается ровно на дельту земли (без множителя инфляции).
    a = _build_inflation(smr_inflation_annual=0.08, land=100_000_000.0)
    b = _build_inflation(smr_inflation_annual=0.08, land=200_000_000.0)
    cf_a = a.cashflow  # type: ignore[attr-defined]
    cf_b = b.cashflow  # type: ignore[attr-defined]
    # Месяц 0 — только земля (ПИР тоже в окне [0..6), но он одинаков в обоих вызовах).
    # Разница cashflow[0] == разница земли × 1.0 (фактор t=0 == 1.0, земля не эскалирует).
    assert cf_a[0] - cf_b[0] == pytest.approx(200_000_000.0 - 100_000_000.0, rel=1e-9)
