"""Unit-тесты PURE HTML-агрегатора §4–§7 (эпик #2259 PR-B).

Чистые тесты БЕЗ native libs (WeasyPrint не импортируется) и БЕЗ БД/сети: агрегатор
только ПОТРЕБЛЯЕТ уже-персистнутый форсайт-ран (`analysis_runs.result`, schema §22-
форсайта "1.0" = `SiteFinderReport.as_dict()`) + сериализованный `ConceptOutput`.
Фикстур — СИНТЕТИЧЕСКИЙ (структура зеркалит прод-payload, без реальных прод-строк).

Покрываем:
  • реалистичный форсайт+концепция → все четыре секции §4–§7 + якоря;
  • §4 velocity/тренд/конкуренты/покрытие; §5 DCF NPV/IRR/ROI + каскад;
  • §6 дефицит по горизонтам + давление предложения + риск-индексы + сценарии;
  • §7 ТЭП/финитог/программа вариантов + плейсхолдер `{{MAP_CONCEPT}}`;
  • concept_result=None → §7 деградирует «концепция не рассчитана», без падения;
  • None / не-dict / непредвиденные типы payload → валидный HTML, без падения;
  • HTML-escape динамических строк payload (XSS-вектор через внешние источники);
  • каркас `build_full_report_html` с обеими частями (A+B) вместе.

DATABASE_URL выставляем до импорта app-модулей (зеркало test_report_md.py) — на случай
side-effect'ов импорта пакета app.
"""

from __future__ import annotations

import os
from typing import Any

os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

from app.services.exporters.full_report_html import (
    _ANCHOR_S1,
    _ANCHOR_S4,
    _ANCHOR_S5,
    _ANCHOR_S6,
    _ANCHOR_S7,
    _NO_DATA,
    MAP_CONCEPT_PLACEHOLDER,
    _build_permits_nearby,
    build_full_report_html,
    build_full_report_html_part_a,
    build_full_report_html_part_b,
)

# ── Синтетические фикстуры (структура прод-payload, без реальных прод-строк) ─────


def _synthetic_forecast() -> dict[str, Any]:
    """Реалистичный форсайт-ран: §22-форсайт (SiteFinderReport.as_dict(), schema "1.0")."""
    return {
        "schema_version": "1.0",
        "meta": {
            "cad_num": "00:00:0000000:0000",
            "district": "Тестовый район",
            "horizons": [6, 12, 18, 24],
        },
        # §4 — рынок сейчас
        "market_now": {
            "summary": "Текущий рынок: абсорбция ~231 ед./мес, средняя цена ~150 тыс.",
            "market_metrics": {
                "district": "Тестовый район",
                "premise_kind": "квартира",
                "obj_count": 64,
                "n_lots": 44116,
                "n_sold": 23405,
                "n_available": 20711,
                "unit_velocity": 231.33,
                "area_velocity": 11959.9,
                "window_months": 6,
                "absorption_rate": 0.0112,
                "months_of_supply": 89.5,
                "overstock_index": 0.711,
                "sell_through_pct": 53.1,
            },
            "competitors": [
                {
                    "comm_name": "Тестовый ЖК А",
                    "dev_name": "Тестовый девелопер",
                    "obj_class": "Комфорт",
                    "distance_m": 1224.01,
                    "flat_count": 227,
                },
                {
                    "comm_name": "Тестовый ЖК Б",
                    "dev_name": "Другой девелопер",
                    "obj_class": "Бизнес",
                    "distance_m": 915.4,
                    "flat_count": 411,
                },
            ],
        },
        # §6 — будущий рынок / дефицит
        "future_market": {
            "summary": "Прогноз: затоварка, индекс дефицита −1.0 на целевом горизонте.",
            "future_supply": {
                "index": 1.0,
                "confidence": "low",
                "breakdown": {
                    "open_units": 20720,
                    "hidden_units": 0,
                    "months_of_pressure": 23.8,
                    "future_units_by_horizon": 5503.0,
                    "monthly_absorption_units": 231.33,
                },
            },
            "scenarios_summary": {"base": -1.0, "aggressive": -1.0, "conservative": -1.0},
            "forecasts_by_horizon": [
                {
                    "horizon_months": 6,
                    "projected_demand_units": 989.9,
                    "projected_supply_units": 18499.1,
                    "deficit_index": -1.0,
                    "months_of_inventory": 118.1,
                    "confidence": "low",
                },
                {
                    "horizon_months": 12,
                    "projected_demand_units": 1980.0,
                    "projected_supply_units": 19000.0,
                    "deficit_index": -1.0,
                    "months_of_inventory": 95.0,
                    "confidence": "low",
                },
            ],
        },
        # §6 — сценарии (в этом фикстуре НЕ коллапснуты → разброс дефицита)
        "scenarios": {
            "summary": "Сценарии (3): conservative, base, aggressive.",
            "scenarios_collapsed": False,
            "scenarios_collapse_reason": None,
            "scenarios_summary": {"base": -1.0, "aggressive": -0.8, "conservative": -1.0},
        },
        # §4 покрытие + §6 риск-индексы
        "confidence": {
            "level": "low",
            "rationale": "Low потому что коротка история и мало покрытие ДОМ.РФ.",
            "factors": {
                "deal_count": {
                    "label": "Сделки",
                    "level": "high",
                    "note": "1388 сделок за 6 мес — достаточно",
                },
                "domrf_coverage": {
                    "label": "Покрытие ДОМ.РФ",
                    "level": "low",
                    "note": "Планировки известны у 15% будущих проектов (мало)",
                },
                "history_months": {
                    "label": "История",
                    "level": "low",
                    "note": "6 мес истории (мало)",
                },
            },
        },
        "scoring": {
            "overall": 0.366,
            "special_indices": {
                "indices": {
                    "product_void": {
                        "key": "product_void",
                        "label": "нет белых пятен",
                        "value": 0.0,
                    },
                    "cost_of_error": {
                        "key": "cost_of_error",
                        "label": "риск дорогой ошибки",
                        "value": 0.369,
                        "detail": {
                            "ref_area_m2": 50.0,
                            "price_per_m2": 155937.0,
                            "avg_ticket_rub": 7796826.0,
                            "oversupply_risk": 0.711,
                        },
                    },
                    "launch_window": {
                        "key": "launch_window",
                        "label": "6 мес",
                        "value": 0.0,
                    },
                }
            },
        },
    }


def _synthetic_financial() -> dict[str, Any]:
    """Финмодель варианта концепции (ConceptVariant.financial → FinancialModel)."""
    return {
        "revenue_rub": 2_400_000_000.0,
        "cost_rub": 1_800_000_000.0,
        "gross_margin_rub": 600_000_000.0,
        "irr": 0.184,
        "revenue_residential_rub": 2_200_000_000.0,
        "revenue_parking_rub": 150_000_000.0,
        "revenue_office_rub": 50_000_000.0,
        "construction_rub": 1_200_000_000.0,
        "pir_rub": 90_000_000.0,
        "networks_rub": 60_000_000.0,
        "developer_services_rub": 80_000_000.0,
        "contingency_rub": 70_000_000.0,
        "marketing_rub": 50_000_000.0,
        "land_rub": 250_000_000.0,
        "vat_rub": 25_000_000.0,
        "profit_before_tax_rub": 480_000_000.0,
        "profit_tax_rub": 120_000_000.0,
        "net_profit_rub": 360_000_000.0,
        "roi": 0.2,
        "margin_pct": 0.15,
        "irr_is_proxy": False,
        "npv_rub": 145_000_000.0,
        "payback_months": 28.5,
        "discount_rate_used": 0.16,
        "schedule_is_default": True,
        "financing_enabled": True,
        "peak_debt_rub": 900_000_000.0,
        "total_interest_rub": 120_000_000.0,
        "net_profit_after_financing_rub": 240_000_000.0,
        "levered_irr": 0.31,
        "levered_irr_is_proxy": False,
        "price_per_sqm_used": 155937.0,
        "price_is_calibrated": True,
        "price_source": "objective_district_median",
    }


def _synthetic_teap() -> dict[str, Any]:
    """ТЭП варианта концепции (ConceptVariant.teap → TEAP)."""
    return {
        "built_area_sqm": 8000.0,
        "total_floor_area_sqm": 64000.0,
        "office_area_sqm": 2000.0,
        "residential_area_sqm": 55000.0,
        "apartments_count": 920,
        "density": 2.34,
        "parking_spaces": 460,
    }


def _1b_feature(section_id: int, floors: int, strategy: str) -> dict[str, Any]:
    """1b-стратегия footprint: properties как в placement._footprints_to_geojson.

    БЕЗ `section_type` (у 1b-стратегий каталожного типа нет) → таблица программы для
    таких вариантов не рисуется.
    """
    return {
        "type": "Feature",
        "properties": {
            "section_id": section_id,
            "floors": floors,
            "footprint_sqm": 432.0,
            "strategy": strategy,
        },
    }


def _program_feature(section_id: int, section_type: str, floors: int) -> dict[str, Any]:
    """Program footprint: properties как в placement._program_footprints_to_geojson.

    Несёт `section_type` + СВОИ `floors`; `strategy == "program"` (маркер program-режима).
    """
    return {
        "type": "Feature",
        "properties": {
            "section_id": section_id,
            "floors": floors,
            "footprint_sqm": 450.0,
            "section_type": section_type,
            "strategy": "program",
        },
    }


def _synthetic_concept() -> dict[str, Any]:
    """Сериализованный ConceptOutput: два варианта (1b-стратегия + program).

    Структура `buildings_geojson.features[].properties` зеркалит реального продюсера
    (placement.py): 1b-стратегия кладёт `floors`+`strategy` без `section_type`; program-
    режим — `section_type`+`floors`+`strategy="program"`. `building_program` НЕ впрыскиваем
    (выходной ConceptVariant это поле не несёт — состав восстанавливается из фич).
    """
    fin_low = dict(_synthetic_financial())
    fin_low["npv_rub"] = 90_000_000.0
    return {
        "variants": [
            {
                "strategy": "max_area",
                "buildings_geojson": {
                    "type": "FeatureCollection",
                    "features": [
                        _1b_feature(1, 12, "max_area"),
                        _1b_feature(2, 12, "max_area"),
                    ],
                },
                "teap": _synthetic_teap(),
                "financial": _synthetic_financial(),
            },
            {
                # program-вариант рапортует под слотом "balanced", но фичи помечены
                # strategy="program" → каркас подписывает «Ваша программа».
                "strategy": "balanced",
                "buildings_geojson": {
                    "type": "FeatureCollection",
                    "features": [
                        _program_feature(1, "tower_a", 25),
                        _program_feature(2, "tower_a", 25),
                        _program_feature(3, "plate_b", 16),
                    ],
                },
                "teap": _synthetic_teap(),
                "financial": fin_low,
                # partial-fit: попросили 4 секции, разместилось 3.
                "placed_count": 3,
                "requested_count": 4,
            },
        ]
    }


# ── 1. Полный форсайт+концепция → все четыре секции + якоря ──────────────────────


def test_all_sections_and_anchors_present() -> None:
    html = build_full_report_html_part_b(
        _synthetic_forecast(), _synthetic_concept(), cad="00:00:0000000:0000"
    )
    for anchor in (_ANCHOR_S4, _ANCHOR_S5, _ANCHOR_S6, _ANCHOR_S7):
        assert f'id="{anchor}"' in html


# ── 2. §4 Рынок: velocity / тренд / конкуренты / покрытие ────────────────────────


def test_section_4_market_velocity_competitors_coverage() -> None:
    html = build_full_report_html_part_b(_synthetic_forecast(), None, cad="X")
    # velocity / метрики темпа
    assert "Темп продаж (velocity)" in html
    assert "231.33" in html  # unit_velocity
    # тренд-резюме
    assert "абсорбция" in html
    # конкуренты
    assert "Тестовый ЖК А" in html
    assert "Тестовый девелопер" in html
    # покрытие данными (factors)
    assert "Покрытие ДОМ.РФ" in html
    assert "1388 сделок" in html


# ── 3. §5 Финмодель: DCF / NPV / IRR / ROI + каскад затрат ───────────────────────


def test_section_5_financial_dcf_and_cascade() -> None:
    html = build_full_report_html_part_b(_synthetic_forecast(), _synthetic_concept(), cad="X")
    # DCF headline (₽ округлены до млрд/млн)
    assert "NPV (DCF)" in html
    assert "145 млн ₽" in html  # npv_rub 145_000_000
    assert "18.4%" in html  # irr 0.184 → 18.4%
    assert "20.0%" in html  # roi 0.2
    assert "28.5 мес" in html  # payback
    # каскад затрат — статьи
    assert "СМР" in html
    assert "Налог на прибыль" in html
    assert "Чистая прибыль" in html
    # источник цены (калиброван по рынку) — RU-фраза
    assert "медиана объявлений Objective" in html
    # опорный вариант = макс. NPV (max_area, 145 млн > balanced 90 млн)
    assert "Макс. площадь" in html


def test_section_5_financing_overlay_rows() -> None:
    html = build_full_report_html_part_b(_synthetic_forecast(), _synthetic_concept(), cad="X")
    # financing_enabled=True → строки долга в каскаде
    assert "Пиковый долг" in html
    assert "Проценты по кредиту" in html
    assert "IRR на собственные средства (LTC)" in html


# ── 4. §6 Риски/дефицит: горизонты + давление + индексы + сценарии ───────────────


def test_section_6_deficit_pressure_indices_scenarios() -> None:
    html = build_full_report_html_part_b(_synthetic_forecast(), None, cad="X")
    # дефицит по горизонтам
    assert "Индекс дефицита" in html
    assert "Мес. запаса" in html
    # давление будущего предложения
    assert "Месяцев давления" in html
    # риск-индексы
    assert "риск дорогой ошибки" in html
    # сценарии НЕ коллапснуты → разброс (значения base/aggressive/conservative)
    assert "aggressive" in html
    assert "Сценарная дифференциация недоступна" not in html


def test_section_6_scenarios_collapsed_honest_note() -> None:
    forecast = _synthetic_forecast()
    forecast["scenarios"] = {
        "scenarios_collapsed": True,
        "scenarios_collapse_reason": "Чувствительность спроса к ставке не оценена.",
    }
    html = build_full_report_html_part_b(forecast, None, cad="X")
    assert "Сценарная дифференциация недоступна" in html
    assert "Чувствительность спроса к ставке не оценена" in html


def test_permits_nearby_empty_honest_full_sentence() -> None:
    """Нет permits_nearby / total_count=0 → честная плашка полным предложением (не аббр.)."""
    assert "новых разрешений на строительство не найдено" in _build_permits_nearby({})
    zero = {"permits_nearby": {"total_count": 0, "items": []}}
    out = _build_permits_nearby(zero)
    assert "ГИСОГД Свердловской области" in out
    assert "не найдено" in out


def test_permits_nearby_non_empty_summary_and_list() -> None:
    """Есть данные → сводка РНС/РВЭ/ближайшее + список документов."""
    result = {
        "permits_nearby": {
            "total_count": 2,
            "rs_count": 1,
            "rv_count": 1,
            "nearest_distance_m": 123.5,
            "items": [
                {
                    "doc_group": "RS",
                    "doc_name": "Разрешение на строительство МКД",
                    "date_doc": "2025-03-14",
                    "approved_organization": "Администрация ЕКБ",
                    "distance_m": 123.5,
                },
                {
                    "doc_group": "RV",
                    "doc_name": "Разрешение на ввод",
                    "date_doc": "2024-11-01",
                    "approved_organization": "Минстрой СО",
                    "distance_m": 340.0,
                },
            ],
        }
    }
    out = _build_permits_nearby(result)
    assert "не найдено" not in out
    assert "Разрешение на строительство МКД" in out
    assert "Администрация ЕКБ" in out
    assert "123.5" in out


def test_permits_nearby_org_name_escaped() -> None:
    """org-имя проходит html.escape (недоверенный вход)."""
    result = {
        "permits_nearby": {
            "total_count": 1,
            "rs_count": 1,
            "rv_count": 0,
            "nearest_distance_m": 10.0,
            "items": [
                {
                    "doc_group": "RS",
                    "doc_name": "<b>hack</b>",
                    "date_doc": "2025-01-01",
                    "approved_organization": "A&B <script>",
                    "distance_m": 10.0,
                }
            ],
        }
    }
    out = _build_permits_nearby(result)
    assert "<script>" not in out
    assert "&lt;script&gt;" in out
    assert "&amp;" in out
    assert "<b>hack</b>" not in out


def test_permits_nearby_truncation_disclosed_beyond_row_cap() -> None:
    """#2464 cluster B: >10 items (upstream cap) → «и ещё N записей» дисклоузится,
    вместо молчаливого среза до 10 (total_count — честный total апстрима,
    permits_nearby.get_permits_nearby считает его COUNT'ом БЕЗ SQL LIMIT)."""
    items = [
        {
            "doc_group": "RS",
            "doc_name": f"РНС №{i}",
            "date_doc": "2025-01-01",
            "approved_organization": "Администрация",
            "distance_m": float(i * 10),
        }
        for i in range(12)
    ]
    result = {
        "permits_nearby": {
            "total_count": 15,  # > len(items)=12: апстрим уже капнул items на 30,
            # здесь ещё раз режем до 10 — итоговый hidden = 15-10 = 5.
            "rs_count": 15,
            "rv_count": 0,
            "nearest_distance_m": 0.0,
            "items": items,
            "items_truncated": True,
        }
    }
    out = _build_permits_nearby(result)
    assert "РНС №9" in out  # 10-й показанный (index 9, row cap=10)
    assert "РНС №10" not in out  # 11-й — за кэпом, НЕ показан напрямую
    assert "и ещё 5 записей" in out
    assert "веб-версии §6" in out


def test_permits_nearby_no_truncation_note_when_all_shown() -> None:
    """total_count совпадает с показанными (<=10) → БЕЗ приписки «и ещё» (zero behavior
    change для маленьких выборок, как раньше)."""
    items = [
        {
            "doc_group": "RS",
            "doc_name": f"РНС №{i}",
            "date_doc": "2025-01-01",
            "approved_organization": "Администрация",
            "distance_m": float(i * 10),
        }
        for i in range(7)
    ]
    result = {
        "permits_nearby": {
            "total_count": 7,
            "rs_count": 7,
            "rv_count": 0,
            "nearest_distance_m": 0.0,
            "items": items,
            "items_truncated": False,
        }
    }
    out = _build_permits_nearby(result)
    assert "и ещё" not in out
    for i in range(7):
        assert f"РНС №{i}" in out


def test_section_6_wires_permits_nearby_from_analyze() -> None:
    """analyze_result прокинут в §6 → блок разрешений рядом виден в part_b HTML."""
    analyze = {
        "permits_nearby": {
            "total_count": 1,
            "rs_count": 1,
            "rv_count": 0,
            "nearest_distance_m": 42.0,
            "items": [
                {
                    "doc_group": "RS",
                    "doc_name": "РНС уникальный-маркер-XYZ",
                    "date_doc": "2025-05-01",
                    "approved_organization": "Администрация",
                    "distance_m": 42.0,
                }
            ],
        }
    }
    html = build_full_report_html_part_b(
        _synthetic_forecast(), None, cad="X", analyze_result=analyze
    )
    assert "Разрешения на строительство рядом" in html
    assert "РНС уникальный-маркер-XYZ" in html


def test_section_6_permits_nearby_absent_analyze_shows_honest_note() -> None:
    """Без analyze_result §6 всё равно рисует честную плашку «не найдено» (не падает)."""
    html = build_full_report_html_part_b(_synthetic_forecast(), None, cad="X")
    assert "новых разрешений на строительство не найдено" in html


# ── 5. §7 Концепция: ТЭП/финитог/программа + {{MAP_CONCEPT}} ─────────────────────


def test_section_7_concept_teap_program_map() -> None:
    html = build_full_report_html_part_b(_synthetic_forecast(), _synthetic_concept(), cad="X")
    # плейсхолдер footprint-плана
    assert MAP_CONCEPT_PLACEHOLDER in html
    # ТЭП
    assert "Плотность (FAR)" in html
    assert "2.34" in html  # density
    # обе стратегии подписаны (program-вариант → «Ваша программа»)
    assert "Макс. площадь" in html
    assert "Ваша программа" in html
    # программа застройки восстановлена из фич (section_type в properties)
    assert "Программа застройки" in html
    assert "tower_a" in html
    assert "plate_b" in html
    # partial-fit честная заметка (placed 3 < requested 4)
    assert "Размещено 3 из 4 секций" in html


def test_section_7_program_grouped_from_features() -> None:
    """Состав программы группируется из features[].properties (section_type × floors → count).

    Гарантирует, что таблица «Программа застройки» строится из РАЗМЕЩЁННЫХ фич, а не из
    несуществующего на выходе `building_program` (регресс-гард: без section_type в
    properties таблица бы молча не рисовалась).
    """
    concept = _synthetic_concept()
    html = build_full_report_html_part_b(_synthetic_forecast(), concept, cad="X")
    # tower_a: 2 секции @25 эт; plate_b: 1 @16 — сгруппировано по (тип, этажность).
    prog_start = html.index("Программа застройки")
    prog = html[prog_start : prog_start + 800]
    assert "tower_a" in prog
    assert "plate_b" in prog
    assert ">25<" in prog  # этажность tower_a
    assert ">16<" in prog  # этажность plate_b
    assert ">2<" in prog  # count tower_a (сгруппировано)


def test_section_7_1b_strategy_has_no_program_table() -> None:
    """1b-стратегия (features без section_type) → таблица программы НЕ рисуется.

    max_area/max_insolation/balanced кладут только floors/strategy без section_type —
    у них нет каталожного состава, честно НЕ показываем пустую/фиктивную программу.
    """
    concept: dict[str, Any] = {
        "variants": [
            {
                "strategy": "max_area",
                "buildings_geojson": {
                    "type": "FeatureCollection",
                    "features": [_1b_feature(1, 12, "max_area"), _1b_feature(2, 12, "max_area")],
                },
                "teap": _synthetic_teap(),
                "financial": _synthetic_financial(),
            }
        ]
    }
    html = build_full_report_html_part_b(_synthetic_forecast(), concept, cad="X")
    # ТЭП есть, а таблицы программы — нет (нет каталожного состава секций).
    assert "Плотность (FAR)" in html
    assert "Программа застройки" not in html


# ── 6. concept_result=None → §7 деградирует без падения ──────────────────────────


def test_section_7_concept_none_degrades() -> None:
    html = build_full_report_html_part_b(_synthetic_forecast(), None, cad="X")
    assert f'id="{_ANCHOR_S7}"' in html
    assert "Концепция застройки не рассчитана" in html
    # §5 без концепции → рыночный контекст цены + честная заметка, без падения
    assert f'id="{_ANCHOR_S5}"' in html
    assert "считается по концепции застройки" in html
    # плейсхолдер карты не рисуется, когда вариантов нет
    assert MAP_CONCEPT_PLACEHOLDER not in html


def test_concept_empty_variants_degrades() -> None:
    for empty in ({}, {"variants": []}, {"variants": None}):
        html = build_full_report_html_part_b(_synthetic_forecast(), empty, cad="X")
        assert "Концепция застройки не рассчитана" in html


# ── 7. None / не-dict / непредвиденные типы → валидный HTML, без падения ─────────


def test_none_and_junk_payloads_do_not_crash() -> None:
    for junk in (None, [], "строка", 42):
        html = build_full_report_html_part_b(junk, None, cad="X")  # type: ignore[arg-type]
        # Все четыре секции присутствуют, «нет данных» где пусто.
        assert f'id="{_ANCHOR_S4}"' in html
        assert f'id="{_ANCHOR_S7}"' in html
        assert _NO_DATA in html


def test_none_fields_within_sections() -> None:
    forecast: dict[str, Any] = {
        "market_now": {"summary": None, "market_metrics": None, "competitors": None},
        "future_market": {"summary": None, "forecasts_by_horizon": None, "future_supply": None},
        "confidence": {"level": None, "factors": None, "rationale": None},
        "scoring": {"special_indices": {"indices": None}},
        "scenarios": {"scenarios_collapsed": False, "scenarios_summary": None},
    }
    concept: dict[str, Any] = {"variants": [{"teap": None, "financial": None}]}
    html = build_full_report_html_part_b(forecast, concept, cad="X")
    assert f'id="{_ANCHOR_S4}"' in html
    assert _NO_DATA in html


# ── 8. HTML-escape динамических строк payload (XSS-вектор) ───────────────────────


def test_dynamic_strings_are_escaped() -> None:
    forecast: dict[str, Any] = {
        "market_now": {
            "summary": "<script>alert('xss')</script>",
            "competitors": [{"comm_name": "<b>ЖК</b>", "dev_name": "<i>dev</i>"}],
        },
        "confidence": {
            "rationale": "<img src=x onerror=alert(1)>",
            "factors": {"f": {"label": "<code>lbl</code>", "level": "low", "note": "n"}},
        },
    }
    concept: dict[str, Any] = {
        "variants": [
            {
                "strategy": "<script>s</script>",
                "buildings_geojson": {"features": []},
                "teap": {"apartments_count": 100},
                "financial": {"npv_rub": 1.0},
            }
        ]
    }
    html = build_full_report_html_part_b(forecast, concept, cad="X")
    assert "<script>alert" not in html
    assert "&lt;script&gt;alert" in html
    assert "<img src=x onerror" not in html
    assert "&lt;img src=x onerror" in html
    assert "&lt;b&gt;ЖК&lt;/b&gt;" in html


# ── 9. Каркас документа: Part A + Part B вместе ──────────────────────────────────


def test_document_frame_with_both_parts() -> None:
    part_a = build_full_report_html_part_a({}, cad="00:00:0000000:0000")
    part_b = build_full_report_html_part_b(
        _synthetic_forecast(), _synthetic_concept(), cad="00:00:0000000:0000"
    )
    doc = build_full_report_html(
        part_a,
        part_b,
        cad="00:00:0000000:0000",
        address="улица Примерная, 1",
        generated_at="03.07.2026",
    )
    assert doc.startswith("<!DOCTYPE html>")
    # Part A (§1) и Part B (§4–§7) оба встроены.
    assert f'id="{_ANCHOR_S1}"' in doc
    assert f'id="{_ANCHOR_S4}"' in doc
    assert f'id="{_ANCHOR_S7}"' in doc
    # Оглавление содержит пункты §4–§7 (has_part_b=True).
    assert f'href="#{_ANCHOR_S4}"' in doc
    assert f'href="#{_ANCHOR_S7}"' in doc
    # Ровно один общий <style> (не дублируется на Part B).
    assert doc.count("<style>") == 1


# ── FIX 4: конкуренты — дубли-корпуса + безымянные с 0 лотов ─────────────────────


def _forecast_with_competitors(competitors: list[dict[str, Any]]) -> dict[str, Any]:
    """Форсайт только с блоком конкурентов (остальное пустое → graceful)."""
    return {"market_now": {"competitors": competitors}}


def test_competitors_group_corpuses_and_sum_lots() -> None:
    """6 корпусов «ЖК ОК: Премиум» → одна строка «(6 корпусов)», лоты суммой (FIX 4)."""
    corpuses = [
        {
            "comm_name": "ЖК ОК: Премиум",
            "dev_name": "Девелопер X",
            "obj_class": "Премиум",
            "distance_m": 1000 + i * 10,
            "flat_count": 50,
        }
        for i in range(6)
    ]
    html = build_full_report_html_part_b(_forecast_with_competitors(corpuses), None, cad="X")
    assert "ЖК ОК: Премиум (6 корпусов)" in html
    assert "300" in html  # 6 × 50 лотов суммой
    # Ближайшая дистанция группы — минимум (1000), с пробелом-разрядом «1 000».
    assert "1 000" in html


def test_competitors_skip_nameless_zero_lots() -> None:
    """Безымянные строки с 0 лотов («—» Эфес) скипаются (FIX 4)."""
    competitors = [
        {"comm_name": None, "dev_name": "Эфес", "flat_count": 0, "distance_m": 500},
        {"comm_name": None, "dev_name": "Эфес", "flat_count": 0, "distance_m": 600},
        {"comm_name": "ЖК Реальный", "dev_name": "Дев", "flat_count": 120, "distance_m": 800},
    ]
    html = build_full_report_html_part_b(_forecast_with_competitors(competitors), None, cad="X")
    assert "ЖК Реальный" in html
    assert "Эфес" not in html  # безымянные с 0 лотов выброшены


def test_competitors_single_corpus_no_suffix() -> None:
    """Один корпус → без суффикса «(K корпусов)» (FIX 4)."""
    competitors = [
        {"comm_name": "ЖК Одиночка", "dev_name": "Дев", "flat_count": 40, "distance_m": 300}
    ]
    html = build_full_report_html_part_b(_forecast_with_competitors(competitors), None, cad="X")
    assert "ЖК Одиночка" in html
    assert "корпусов" not in html


# ── FIX 6b: confidence-факторы — схлопывание дублирующей колонки «Комментарий» ────


def test_confidence_factors_collapse_duplicate_note_column() -> None:
    """note дублирует label у всех факторов → колонка «Комментарий» скрыта (FIX 6b)."""
    forecast = {
        "confidence": {
            "level": "medium",
            "factors": {
                "a": {"label": "Сделки", "level": "high", "note": "Сделки"},
                "b": {"label": "История", "level": "low", "note": "История"},
            },
        }
    }
    html = build_full_report_html_part_b(forecast, None, cad="X")
    assert "Сделки" in html
    assert "Комментарий" not in html  # дублирующая колонка схлопнута


def test_confidence_factors_keep_note_when_informative() -> None:
    """note несёт данные сверх label → колонка «Комментарий» остаётся (FIX 6b)."""
    forecast = {
        "confidence": {
            "level": "low",
            "factors": {
                "a": {"label": "Сделки", "level": "high", "note": "1388 сделок за 6 мес"},
            },
        }
    }
    html = build_full_report_html_part_b(forecast, None, cad="X")
    assert "Комментарий" in html
    assert "1388 сделок за 6 мес" in html


# ── FIX 6c/6d: абсорбция как %, средний чек без «+» ──────────────────────────────


def test_absorption_rate_shown_as_percent() -> None:
    """absorption_rate 0.0112 → «1.1%», не сырое «0.01» (FIX 6c)."""
    forecast = {"market_now": {"market_metrics": {"absorption_rate": 0.0112}}}
    html = build_full_report_html_part_b(forecast, None, cad="X")
    assert "1.1%" in html
    assert ">0.01<" not in html


def test_avg_ticket_has_no_leading_plus() -> None:
    """Средний чек лота — цена, не дельта: «8 млн ₽» без ведущего «+» (FIX 6d)."""
    forecast = {
        "scoring": {
            "special_indices": {
                "indices": {
                    "cost_of_error": {"detail": {"avg_ticket_rub": 8_000_000.0}},
                }
            }
        }
    }
    html = build_full_report_html_part_b(forecast, None, cad="X")
    assert "8 млн ₽" in html
    assert "+8 млн ₽" not in html
