"""Unit-тесты PURE HTML-агрегатора §1–§3 + альтернативы (эпик #2259 PR-A).

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

Покрываем:
  • реалистичный полный payload → все секции §1–§3 + якоря + факты;
  • отсутствующие секции / None-поля → «нет данных», без падения (graceful);
  • пустой / не-dict payload → валидный HTML с «нет данных»;
  • HTML-escape динамических строк payload (XSS-вектор через внешние источники);
  • плейсхолдер карты `{{MAP_PARCEL}}` присутствует;
  • блок альтернатив рисуется только при наличии `program_alternatives`;
  • каркас `build_full_report_html`: титул / оглавление / опциональный Part 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_ALT,
    _ANCHOR_S1,
    _ANCHOR_S2,
    _ANCHOR_S3,
    _NO_DATA,
    MAP_PARCEL_PLACEHOLDER,
    build_full_report_html,
    build_full_report_html_part_a,
)

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


def _synthetic_result() -> dict[str, Any]:
    """Реалистичный полный analyze-payload: §1 участок + §2 окружение + §3 сети."""
    return {
        "cad_num": "00:00:0000000:0000",
        # §1 — кадастр / регламент / ЗОУИТ / gate
        "egrn": {
            "address": "Тестовый регион, тестовый город, улица Примерная, 1",
            "area_m2": 12345.0,
            "land_category": "Категория земель тестовая",
            "permitted_use_text": "тестовое разрешённое использование",
            "parcel_status": "Учтенный",
            "ownership_type": "Частная",
            "right_type": "Собственность",
            "cadastral_value_rub": 9876543.0,
        },
        "parcel_meta": {
            "land_subtype": "Землепользование",
            "land_category": "Категория земель тестовая",
            "permitted_use": "тестовое разрешённое использование",
            "cad_cost": 9876543.0,
        },
        "geometry_suitability": {"area_m2": 12345, "label": "подходящий"},
        "nspd_zoning": {
            "zone_code": "ТЗ-1",
            "zone_name": None,
            "max_far": 2.0,
            "max_floors": 12,
            "max_height_m": 40,
            "max_building_pct": 60,
            "min_parcel_area_m2": None,
            "regulation_source": "test-geoportal",
            "regulation_zone_index": "ТЗ-1",
            "main_vri": ["2.6 Тестовая многоэтажная застройка", "3.1 Тестовое обслуживание"],
        },
        "encumbrance": {
            "has_zouit": True,
            "zouit_count": 2,
            "zouit_types": ["Тестовая охранная зона", "Тестовая санитарная зона"],
        },
        "nspd_zouit_overlaps": [
            {
                "type_zone": "Тестовая охранная зона сетей",
                "name": "Тестовая охранная зона сетей",
                "reg_numb_border": "00:00-0.0000",
                "coverage_pct": 0.5,
            }
        ],
        "gate_verdict": {
            "source": "nspd_dump",
            "can_build_mkd": False,
            "verdict_label": "Нельзя",
            "blockers": [{"code": "TEST_BLOCK", "detail": "Тестовый блокер застройки"}],
            "warnings": [{"code": "TEST_WARN", "detail": "Тестовое предупреждение"}],
        },
        # §2 — окружение
        "location": {"distance_to_center_km": 7.5},
        "metro": {"nearest_top3": [{"name": "Тестовая станция", "distance_m": 4200}]},
        "noise": {"level": "тихо", "estimated_db": 43.2},
        "air_quality": {"no2": 1.6, "pm10": 4.9, "pm2_5": 4.1, "source": "test-source"},
        "geotech_risk": {
            "seismic_label": "минимальная сейсмика",
            "seismic_intensity_balls": 5,
            "permafrost": False,
            "industrial_within_500m": 3,
        },
        "hydrology": {
            "flood_risk_flag": False,
            "nearest": [{"name": "Тестовая река", "subtype": "river", "distance_m": 500}],
        },
        "neighbors_summary": {
            "neighbors": [
                {
                    "building_name": "Тестовое здание",
                    "cad_num": "00:00:0000000:0001",
                    "purpose": "Нежилое",
                    "floors_parsed": 2,
                    "distance_m": 0,
                }
            ]
        },
        # §3 — сети
        "utilities": {
            "nearest_substation_m": 120,
            "nearest_water_main_m": None,
            "nearest_gas_m": None,
            "nearest_heat_m": None,
            "power_line_охранная_зона_flag": False,
            "summary": [
                {"subtype": "substation", "nearest_m": 120, "count_within_2km": 2},
                {"subtype": "pipeline", "nearest_m": 175, "count_within_2km": 4},
            ],
        },
        "nspd_engineering_nearby": [
            {
                "name": None,
                "type": None,
                "distance_m": 50,
                "raw_props": {
                    "params_name": "Тестовый трубопровод",
                    "params_purpose": "тестовое коммунальное сооружение",
                    "cad_number": "00:00:0000000:0002",
                },
            }
        ],
    }


def _synthetic_alternatives() -> dict[str, Any]:
    """Блок `program_alternatives` (отрицательный фин-вердикт → топ вариантов)."""
    return {
        "variants": [
            {
                "housing_class": "comfort",
                "floors": 12,
                "development_type": "high_rise",
                "npv_rub": -216_000_000.0,
                "irr": -0.05,
                "roi": -0.12,
                "residential_sqm": 20000.0,
                "apartments_count": 350,
                "price_per_sqm_used": 142500.0,
                "price_source": "objective_district_median",
                "schedule_is_default": True,
            },
            {
                "housing_class": "econom",
                "floors": 9,
                "development_type": "mid_rise",
                "npv_rub": -300_000_000.0,
                "irr": -0.09,
                "roi": -0.2,
                "residential_sqm": 15000.0,
                "apartments_count": 280,
                "price_per_sqm_used": 110000.0,
                "price_source": "class_norm",
                "schedule_is_default": False,
            },
        ],
        "any_viable": False,
        "grid_size": 9,
        "caveat": "Оценка по максимальной застройке зоны, не инвест-рекомендация.",
    }


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


def test_full_payload_renders_all_sections_and_anchors() -> None:
    html = build_full_report_html_part_a(_synthetic_result(), cad="00:00:0000000:0000")
    for anchor in (_ANCHOR_S1, _ANCHOR_S2, _ANCHOR_S3):
        assert f'id="{anchor}"' in html
    # Ключевые факты §1
    assert "улица Примерная, 1" in html
    assert "ТЗ-1" in html  # зона ПЗЗ
    assert "12 345" in html  # площадь с пробелом-разрядом
    # §2 окружение
    assert "Тестовая станция" in html
    assert "минимальная сейсмика" in html
    # §3 сети
    assert "substation" in html
    assert "Тестовый трубопровод" in html


# ── 2. Плейсхолдер карты присутствует ───────────────────────────────────────────


def test_map_placeholder_present() -> None:
    html = build_full_report_html_part_a(_synthetic_result(), cad="X")
    assert MAP_PARCEL_PLACEHOLDER in html
    assert "{{MAP_PARCEL}}" in html


# ── 3. gate-вердикт: «Нельзя» + блокеры/предупреждения ──────────────────────────


def test_gate_verdict_renders_blockers_and_warnings() -> None:
    html = build_full_report_html_part_a(_synthetic_result(), cad="X")
    assert "Нельзя" in html
    assert "Тестовый блокер застройки" in html
    assert "Тестовое предупреждение" in html


# ── 4. Отсутствующие секции → «нет данных», без падения ─────────────────────────


def test_missing_sections_render_no_data() -> None:
    html = build_full_report_html_part_a({}, cad="X")
    # Секции и якоря всё равно на месте (каркас граничный).
    for anchor in (_ANCHOR_S1, _ANCHOR_S2, _ANCHOR_S3):
        assert f'id="{anchor}"' in html
    assert _NO_DATA in html
    # Плейсхолдер карты рисуется даже на пустом payload.
    assert MAP_PARCEL_PLACEHOLDER in html


# ── 5. Не-dict / None payload → валидный HTML с «нет данных» ─────────────────────


def test_non_dict_payload_is_graceful() -> None:
    for junk in (None, [], "строка", 42):
        html = build_full_report_html_part_a(junk, cad="X")  # type: ignore[arg-type]
        assert f'id="{_ANCHOR_S1}"' in html
        assert _NO_DATA in html


# ── 6. None-поля внутри существующих секций не ломают рендер ─────────────────────


def test_none_fields_within_sections() -> None:
    result: dict[str, Any] = {
        "egrn": {"address": None, "area_m2": None, "land_category": None},
        "nspd_zoning": {"zone_code": None, "max_far": None, "main_vri": None},
        "utilities": {"summary": None, "nearest_substation_m": None},
        "metro": {"nearest_top3": None},
        "hydrology": {"nearest": None},
        "neighbors_summary": {"neighbors": None},
    }
    html = build_full_report_html_part_a(result, cad="X")
    # Не должно быть исключений; секции присутствуют, «нет данных» где пусто.
    assert f'id="{_ANCHOR_S2}"' in html
    assert _NO_DATA in html


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


def test_dynamic_strings_are_escaped() -> None:
    result: dict[str, Any] = {
        "egrn": {"address": "<script>alert('xss')</script>"},
        "nspd_zoning": {
            "zone_code": "<b>zone</b>",
            "main_vri": ["<img src=x onerror=alert(1)>"],
        },
        "gate_verdict": {
            "can_build_mkd": True,
            "verdict_label": "<i>ok</i>",
            "blockers": [{"code": "<code>", "detail": "<detail>"}],
        },
    }
    html = build_full_report_html_part_a(result, cad="X")
    # Сырые теги payload не должны попасть в разметку неэкранированными.
    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;zone&lt;/b&gt;" in html


# ── 8. Блок альтернатив: рисуется только при наличии program_alternatives ────────


def test_alternatives_block_present_when_data() -> None:
    result = _synthetic_result()
    result["program_alternatives"] = _synthetic_alternatives()
    html = build_full_report_html_part_a(result, cad="X")
    assert f'id="{_ANCHOR_ALT}"' in html
    assert "Комфорт" in html  # RU-метка housing_class
    assert "высотная" in html  # RU-метка development_type
    assert "216 млн ₽" in html  # NPV округлён до млн
    assert "наименее убыточные" in html  # any_viable=False → эта формулировка
    assert "не инвест-рекомендация" in html  # caveat


def test_alternatives_block_absent_when_missing() -> None:
    # Нет ключа вовсе.
    html = build_full_report_html_part_a(_synthetic_result(), cad="X")
    assert f'id="{_ANCHOR_ALT}"' not in html
    # None / пустые variants → тоже пропуск.
    for empty in (None, {}, {"variants": []}):
        result = _synthetic_result()
        result["program_alternatives"] = empty
        html = build_full_report_html_part_a(result, cad="X")
        assert f'id="{_ANCHOR_ALT}"' not in html


def test_alternatives_any_viable_true_subtitle() -> None:
    alt = _synthetic_alternatives()
    alt["any_viable"] = True
    alt["variants"][0]["npv_rub"] = 42_000_000.0  # положительный → «+42 млн ₽»
    result = _synthetic_result()
    result["program_alternatives"] = alt
    html = build_full_report_html_part_a(result, cad="X")
    assert "сходится при другой программе" in html
    assert "+42 млн ₽" in html


# ── 8b. connection-capacity в §3 (эпик #2259 PR-D — ресурсные резервы) ──────────


def _synthetic_connection_capacity() -> dict[str, Any]:
    """Синтетический connection-capacity (структура ConnectionCapacityResponse)."""
    return {
        "power_summary": {
            "total_power_points": 3,
            "by_load_index": {"open": 2, "closed": 1},
            "nearest_with_reserve": {
                "reserve_mva": 12.5,
                "reserve_unit": "МВА",
                "voltage_class": "110/10",
                "distance_m": 640.0,
            },
        },
        "power_points": [{"sc_name": "ПС Тестовая"}],
        "water": [{"system_name": "ЗФС, ГСВ", "reserve_thousand_m3_day": 5.4, "period": "2026-Q2"}],
        "gas": {
            "city_grs": [
                {"grs_name": "ГРС-1", "free_capacity_th_m3_h": 18.0, "free_capacity_pct": 22.3}
            ]
        },
        "heat": {
            "systems": [
                {"org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.1, "period": "2026-Q2"}
            ]
        },
        "nearby_network_zones": [
            {
                "net_kind": "thermal",
                "human_label": "Тепловая сеть (по охранной зоне)",
                "distance_m": 24,
            }
        ],
    }


def test_connection_capacity_block_rendered_in_section_3() -> None:
    html = build_full_report_html_part_a(
        _synthetic_result(), cad="X", connection_capacity=_synthetic_connection_capacity()
    )
    assert "Электроснабжение — свободная мощность" in html
    assert "Водоснабжение/водоотведение — резервы" in html
    assert "Газоснабжение — свободная мощность ГРС" in html
    assert "Теплоснабжение — резервы систем" in html
    assert "Сети рядом (охранные зоны)" in html
    # Значения/подписи присутствуют.
    assert "110/10" in html  # класс напряжения ближайшего ЦП
    assert "22.3%" in html  # free_capacity_pct — уже проценты, НЕ домножены на 100
    assert "Тепловая сеть (по охранной зоне)" in html


def test_connection_capacity_absent_when_none() -> None:
    """connection_capacity=None (default) → блок резервов не рисуется (backward-compat)."""
    html = build_full_report_html_part_a(_synthetic_result(), cad="X")
    assert "Электроснабжение — свободная мощность" not in html
    # Пустой dict → тоже пропуск.
    html2 = build_full_report_html_part_a(_synthetic_result(), cad="X", connection_capacity={})
    assert "Газоснабжение — свободная мощность ГРС" not in html2


# ── FIX 5: противоречие ЗОУИТ-блоков (has_zouit falsy, но overlaps есть) ─────────


def test_zouit_reconciles_falsy_flag_with_overlaps() -> None:
    """has_zouit=нет + zouit_count=0, но nspd_zouit_overlaps непуст → «да (по данным НСПД)»."""
    result = {
        "encumbrance": {"has_zouit": False, "zouit_count": 0},
        "nspd_zouit_overlaps": [
            {"type_zone": "Охранная зона ЛЭП", "reg_numb_border": "66:41-1", "coverage_pct": 0.3},
            {"type_zone": "Санитарная зона", "reg_numb_border": "66:41-2", "coverage_pct": 0.1},
        ],
    }
    html = build_full_report_html_part_a(result, cad="X")
    assert "да (по данным НСПД)" in html
    # Кол-во типов = число пересечений; типы восстановлены из overlaps.
    assert "Охранная зона ЛЭП" in html
    assert "Санитарная зона" in html


def test_zouit_flag_true_unchanged() -> None:
    """has_zouit=да → сводка не переписывается (FIX 5 трогает только противоречие)."""
    result = {
        "encumbrance": {"has_zouit": True, "zouit_count": 1, "zouit_types": ["Охранная"]},
        "nspd_zouit_overlaps": [{"type_zone": "Охранная", "coverage_pct": 0.5}],
    }
    html = build_full_report_html_part_a(result, cad="X")
    assert "да (по данным НСПД)" not in html


# ── FIX 6a: НСПД-сооружения — пропуск строк-призраков ────────────────────────────


def test_engineering_nearby_skips_ghost_rows() -> None:
    """Строки где И имя, И назначение пустые («— — 7») скипаются (FIX 6a)."""
    result = {
        "nspd_engineering_nearby": [
            {"name": None, "type": None, "distance_m": 7, "raw_props": {}},  # призрак
            {"name": "Трубопровод", "type": "тепло", "distance_m": 40, "raw_props": {}},
        ]
    }
    html = build_full_report_html_part_a(result, cad="X")
    assert "Трубопровод" in html
    # Заголовок есть (записи были), призрак-строки нет.
    assert "Инженерные сооружения рядом (НСПД)" in html


def test_engineering_nearby_all_empty_shows_no_data() -> None:
    """Все записи пустые → заголовок + «нет данных» (не пустой невидимый блок, FIX 6a)."""
    result = {
        "nspd_engineering_nearby": [
            {"name": None, "type": None, "distance_m": 7, "raw_props": {}},
            {"name": "", "type": "", "distance_m": 9, "raw_props": {}},
        ]
    }
    html = build_full_report_html_part_a(result, cad="X")
    assert "Инженерные сооружения рядом (НСПД)" in html
    assert _NO_DATA in html


# ── FIX 3: тепло/вода — фильтр областного шума + агрегат ЕКБ + кап ───────────────


def _heat_systems_province() -> dict[str, Any]:
    """56-строчная тепло-выборка: горсть ЕКБ + много не-ЕКБ (Ирбит/Тавда/…)."""
    systems: list[dict[str, Any]] = [
        {"org": "ЕТК", "system_name": "СТ Екатеринбург №54", "reserve_gcal_h": 3.1},
        {"org": "Т Плюс", "system_name": "СТ Екатеринбург ТЭЦ", "reserve_gcal_h": 12.0},
        # Дефицит ЕКБ — под кап НЕ прячем.
        {"org": "ЕТК", "system_name": "СТ Екатеринбург №7", "reserve_gcal_h": -2.5},
    ]
    # Много областного шума (не-ЕКБ).
    for city in ("Ирбит", "Тавда", "Красноуфимск", "Первоуральск", "Лесной", "Нижняя Тура"):
        systems.append(
            {"org": "Регионгаз-инвест", "system_name": f"СТ {city} №1", "reserve_gcal_h": 1.0}
        )
    return {"systems": systems, "total_reserve_gcal_h": 100.0}


def test_heat_table_aggregates_ekb_and_hides_province_noise() -> None:
    """Тепло: агрегат «Суммарно (ЕКБ)» + не-ЕКБ схлопнуты в «… и ещё K систем» (FIX 3)."""
    cap = {"heat": _heat_systems_province()}
    html = build_full_report_html_part_a(_synthetic_result(), cad="X", connection_capacity=cap)
    assert "Суммарно (ЕКБ)" in html
    # ЕКБ-системы видны (в т.ч. дефицит), не-ЕКБ схлопнуты.
    assert "Екатеринбург №54" in html
    assert "Екатеринбург №7" in html  # дефицит НЕ спрятан
    assert "и ещё" in html  # областной шум схлопнут
    # Не-ЕКБ город не должен фигурировать строкой.
    assert "Ирбит" not in html


def test_heat_generic_province_units_hidden_ekb_mention_wins() -> None:
    """Generic-маркеры областных админ-единиц скрыты; упоминание ЕКБ перевешивает (FIX 3+)."""
    systems = [
        {
            "org": "Т Плюс",
            "system_name": "СТ: Нижнетуринский муниципальный округ",
            "reserve_gcal_h": 239.0,
        },
        {
            "org": "Регионгаз-инвест",
            "system_name": "Невьянский муниципальный округ",
            "reserve_gcal_h": 51.0,
        },
        # Упоминание Екатеринбурга перевешивает generic-маркер — строка ЕКБ.
        {
            "org": "ЕТК",
            "system_name": "муниципальное образование город Екатеринбург",
            "reserve_gcal_h": 10.0,
        },
    ]
    html = build_full_report_html_part_a(
        _synthetic_result(), cad="X", connection_capacity={"heat": {"systems": systems}}
    )
    assert "Нижнетуринский" not in html
    assert "Невьянский" not in html
    assert "город Екатеринбург" in html
    # Агрегат считает только ЕКБ-строку (10.0), а не областные 239+51.
    assert "239" not in html


def test_heat_deficit_not_hidden_under_cap() -> None:
    """Честность: отрицательный резерв ЕКБ виден даже при переполнении капа (FIX 3)."""
    systems = [{"org": "ЕТК", "system_name": "СТ Екатеринбург дефицит", "reserve_gcal_h": -5.0}]
    # Забиваем кап 20 профицитными ЕКБ-системами.
    for i in range(20):
        systems.append(
            {"org": "ЕТК", "system_name": f"СТ Екатеринбург плюс {i}", "reserve_gcal_h": float(i)}
        )
    html = build_full_report_html_part_a(
        _synthetic_result(), cad="X", connection_capacity={"heat": {"systems": systems}}
    )
    assert "Екатеринбург дефицит" in html


def test_long_system_name_truncated() -> None:
    """Длинное бюрократическое имя системы усечено до ~120 символов с «…» (FIX 3)."""
    long_name = "Централизованная система теплоснабжения " * 6  # >200 симв.
    cap = {"heat": {"systems": [{"org": "ЕТК Екатеринбург", "system_name": long_name}]}}
    html = build_full_report_html_part_a(_synthetic_result(), cad="X", connection_capacity=cap)
    assert "…" in html
    assert long_name not in html  # полное имя не попало целиком


# ── 9. Каркас документа: титул / оглавление / опциональный Part B ────────────────


def test_document_frame_with_part_a_only() -> None:
    part_a = build_full_report_html_part_a(_synthetic_result(), cad="00:00:0000000:0000")
    doc = build_full_report_html(
        part_a,
        None,
        cad="00:00:0000000:0000",
        address="улица Примерная, 1",
        generated_at="03.07.2026",
    )
    assert doc.startswith("<!DOCTYPE html>")
    assert "00:00:0000000:0000" in doc
    assert "улица Примерная, 1" in doc
    assert "03.07.2026" in doc
    # Оглавление §1–§3 есть, пункт Part B (§4–§7) скрыт.
    assert f'href="#{_ANCHOR_S1}"' in doc
    assert "section-4" not in doc
    # Part A встроен.
    assert f'id="{_ANCHOR_S1}"' in doc


def test_document_frame_with_part_b_adds_toc_entry() -> None:
    part_a = build_full_report_html_part_a(_synthetic_result(), cad="X")
    part_b = '<div class="section" id="section-4"><h2>§4 test</h2></div>'
    doc = build_full_report_html(part_a, part_b, cad="X", address=None, generated_at="03.07.2026")
    assert "section-4" in doc
    assert part_b in doc


def test_document_frame_escapes_cad_and_address() -> None:
    doc = build_full_report_html(
        "<div></div>",
        None,
        cad="<script>x</script>",
        address="<b>addr</b>",
        generated_at="03.07.2026",
    )
    assert "<script>x</script>" not in doc
    assert "&lt;script&gt;x&lt;/script&gt;" in doc
    assert "&lt;b&gt;addr&lt;/b&gt;" in doc
