"""Unit-тесты DOCX-варианта полного отчёта ПТИЦА (эпик #2259 PR-F) — `build_full_report_docx`.

Чистые тесты БЕЗ БД / сети / native-libs (python-docx — чистый Python на lxml): билдер
только ПОТРЕБЛЯЕТ уже-собранные словари (analyze / forecast / concept / connection-
capacity) + карт-PNG. Проверяем:
  • полный payload → НЕПУСТЫЕ байты, начинаются с `b"PK"` (OOXML/.docx — zip);
  • документ открывается python-docx и несёт заголовки §1–§7 + альтернативы + титул;
  • деградации: нет forecast → §4–§6 «нет данных»; нет концепции → §7 плашка + §5 контекст;
    нет connection-capacity → §3 без резервов; пустой payload → валидный минимальный .docx;
  • карты вставлены (inline_shapes) при наличии PNG; None → плашка «Карта недоступна».

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

from __future__ import annotations

import io
import os
import struct
import zlib
from typing import Any

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

import pytest
from docx import Document

from app.services.exporters.full_report_docx import (
    _build_permits_nearby,
    _build_section_6,
    build_full_report_docx,
)

_CAD = "66:41:0204016:10"


def _png_1x1() -> bytes:
    """Минимальный валидный 1×1 PNG (белый пиксель) для проверки вставки карт.

    Собираем вручную (chunk = len + type + data + CRC32) — не тянем PIL в фикстуру.
    python-docx декодирует размеры прямо из IHDR, реальный растр ему не важен.
    """

    def _chunk(tag: bytes, data: bytes) -> bytes:
        body = tag + data
        crc = struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
        return struct.pack(">I", len(data)) + body + crc

    sig = b"\x89PNG\r\n\x1a\n"
    ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)  # 1×1, 8-bit, truecolor
    idat = zlib.compress(b"\x00\xff\xff\xff")  # one filtered scanline, white
    return sig + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"")


@pytest.fixture
def png() -> bytes:
    return _png_1x1()


def _analyze() -> dict[str, Any]:
    return {
        "cad_num": _CAD,
        "geom_geojson": {"type": "Polygon", "coordinates": [[[60.6, 56.8], [60.61, 56.8]]]},
        "egrn": {
            "address": "ЕКБ, ул. Примерная, 1",
            "area_m2": 7800,
            "land_category": "Земли населённых пунктов",
        },
        "nspd_zoning": {
            "zone_code": "Ж-4",
            "max_far": 2.5,
            "max_floors": 16,
            "main_vri": ["МКД", "Детсад"],
        },
        "encumbrance": {"has_zouit": True, "zouit_count": 1, "zouit_types": ["Приаэродромная"]},
        "gate_verdict": {"can_build_mkd": True, "verdict_label": "Можно"},
        "location": {"distance_to_center_km": 4.2},
        "metro": {"nearest_top3": [{"name": "Пл.1905", "distance_m": 1200}]},
        "utilities": {
            "nearest_substation_m": 300,
            "summary": [{"subtype": "power", "nearest_m": 300, "count_within_2km": 5}],
        },
        "neighbors_summary": {
            "neighbors": [
                {"building_name": "ЖК А", "purpose": "жилое", "floors": 12, "distance_m": 80}
            ]
        },
        "program_alternatives": {
            "any_viable": True,
            "grid_size": 9,
            "variants": [
                {
                    "housing_class": "comfort",
                    "floors": 16,
                    "development_type": "high_rise",
                    "npv_rub": 42_000_000,
                    "roi": 0.18,
                    "irr": 0.22,
                    "residential_sqm": 12_000,
                    "apartments_count": 240,
                    "price_per_sqm_used": 120_000,
                    "price_source": "objective_geo_radius",
                }
            ],
            "caveat": "Оценка по макс. застройке зоны.",
        },
    }


def _forecast() -> dict[str, Any]:
    return {
        "schema_version": "1.0",
        "market_now": {
            "summary": "Рынок активен.",
            "market_metrics": {"district": "Кировский", "n_lots": 540, "unit_velocity": 12.3},
            "competitors": [
                {
                    "comm_name": "ЖК Б",
                    "dev_name": "Дев1",
                    "obj_class": "comfort",
                    "distance_m": 500,
                    "flat_count": 120,
                }
            ],
        },
        "confidence": {
            "level": "high",
            "rationale": "много данных",
            "factors": {"deals": {"label": "Сделки", "level": "high", "note": "2000 сделок"}},
        },
        "future_market": {
            "summary": "Дефицит нарастает.",
            "forecasts_by_horizon": [
                {
                    "horizon_months": 12,
                    "projected_demand_units": 300,
                    "projected_supply_units": 200,
                    "deficit_index": 1.5,
                    "months_of_inventory": 8,
                    "confidence": "medium",
                }
            ],
        },
        "scoring": {
            "special_indices": {
                "indices": {
                    "cost_of_error": {
                        "key": "coe",
                        "label": "Цена ошибки",
                        "value": 0.3,
                        "detail": {"price_per_m2": 125_000, "avg_ticket_rub": 7_500_000},
                    }
                }
            }
        },
        "scenarios": {"scenarios_summary": {"base": 1.4, "optimistic": 1.8}},
    }


def _concept() -> dict[str, Any]:
    return {
        "variants": [
            {
                "strategy": "balanced",
                "teap": {
                    "built_area_sqm": 2000,
                    "total_floor_area_sqm": 24_000,
                    "residential_area_sqm": 18_000,
                    "apartments_count": 300,
                    "parking_spaces": 150,
                    "density": 2.4,
                },
                "financial": {
                    "revenue_rub": 3_600_000_000,
                    "cost_rub": 2_800_000_000,
                    "net_profit_rub": 800_000_000,
                    "npv_rub": 420_000_000,
                    "roi": 0.28,
                    "irr": 0.19,
                    "price_per_sqm_used": 125_000,
                    "price_source": "objective_geo_radius",
                    "payback_months": 36,
                    "construction_rub": 2_000_000_000,
                },
                "buildings_geojson": {
                    "features": [
                        {"properties": {"section_type": "tower", "floors": 16}},
                        {"properties": {"section_type": "tower", "floors": 16}},
                    ]
                },
            }
        ]
    }


def _capacity() -> dict[str, Any]:
    return {
        "power_summary": {
            "total_power_points": 3,
            "nearest_with_reserve": {
                "reserve_mva": 12.5,
                "voltage_class": "10кВ",
                "distance_m": 400,
                "reserve_unit": "МВА",
            },
        },
        "gas": {
            "city_grs": [
                {"grs_name": "ГРС-1", "free_capacity_th_m3_h": 50, "free_capacity_pct": 18.4}
            ]
        },
        "heat": {
            "systems": [
                {
                    "org": "ЕКБ ТЭЦ",
                    "system_name": "Система 1",
                    "reserve_gcal_h": 120,
                    "period": "2024",
                }
            ]
        },
        "water": [{"system_name": "ЦСВ ЕКБ", "reserve_thousand_m3_day": 30, "period": "2024"}],
        "nearby_network_zones": [{"human_label": "ЛЭП 10кВ", "distance_m": 50}],
    }


def _all_text(doc: Document) -> str:
    """Весь текст документа: абзацы + ячейки всех таблиц (для проверки содержимого)."""
    parts = [p.text for p in doc.paragraphs]
    for table in doc.tables:
        for row in table.rows:
            parts.extend(cell.text for cell in row.cells)
    return "\n".join(parts)


def _render(**overrides: Any) -> bytes:
    kwargs: dict[str, Any] = {
        "analyze_result": _analyze(),
        "forecast_result": _forecast(),
        "concept_result": _concept(),
        "connection_capacity": _capacity(),
        "cad": _CAD,
        "address": "ЕКБ, ул. Примерная, 1",
        "generated_at": "03.07.2026",
        "parcel_map_png": None,
        "concept_map_png": None,
    }
    kwargs.update(overrides)
    analyze = kwargs.pop("analyze_result")
    forecast = kwargs.pop("forecast_result")
    concept = kwargs.pop("concept_result")
    cap = kwargs.pop("connection_capacity")
    return build_full_report_docx(analyze, forecast, concept, cap, **kwargs)


# ── Happy path ──────────────────────────────────────────────────────────────────


def test_full_payload_returns_docx_bytes(png: bytes) -> None:
    """Полный payload → непустые байты, начинающиеся с b"PK" (OOXML zip)."""
    out = _render(parcel_map_png=png, concept_map_png=png)
    assert isinstance(out, bytes)
    assert len(out) > 0
    assert out[:2] == b"PK"  # zip-контейнер


def test_all_sections_present(png: bytes) -> None:
    """Документ несёт заголовки §1–§7 + альтернативы + титул (зеркало PDF-структуры)."""
    out = _render(parcel_map_png=png, concept_map_png=png)
    doc = Document(io.BytesIO(out))
    headings = {p.text for p in doc.paragraphs}
    for section in (
        "§1. Участок",
        "§2. Окружение",
        "§3. Инженерные сети",
        "§4. Рынок",
        "§5. Финансовая модель",
        "§6. Риски и дефицит",
        "§7. Концепция застройки",
    ):
        assert section in headings, f"нет секции {section}"
    assert "Как участок сходится (альтернативы программы)" in headings
    assert "Отчёт по участку — Site Finder ПТИЦА" in headings


def test_title_carries_cad_and_address(png: bytes) -> None:
    """Титул несёт кад.номер, адрес и дату формирования."""
    out = _render(parcel_map_png=png, concept_map_png=png)
    text = _all_text(Document(io.BytesIO(out)))
    assert _CAD in text
    assert "ЕКБ, ул. Примерная, 1" in text
    assert "03.07.2026" in text


def test_key_data_rendered(png: bytes) -> None:
    """Данные из исходных словарей попадают в таблицы (площадь / зона / NPV / метро)."""
    out = _render(parcel_map_png=png, concept_map_png=png)
    text = _all_text(Document(io.BytesIO(out)))
    assert "7 800" in text  # площадь через _fmt_int_ru
    assert "Ж-4" in text  # зона ПЗЗ
    assert "Пл.1905" in text  # станция метро
    assert "ЖК Б" in text  # конкурент рынка


# ── Карты ───────────────────────────────────────────────────────────────────────


def test_maps_inserted_when_png_present(png: bytes) -> None:
    """Обе карты (участок §1 + концепция §7) вставлены как inline-изображения."""
    out = _render(parcel_map_png=png, concept_map_png=png)
    doc = Document(io.BytesIO(out))
    assert len(doc.inline_shapes) == 2


def test_map_placeholder_when_png_none() -> None:
    """Карты None → плашка «Карта недоступна» вместо изображения (graceful)."""
    out = _render(parcel_map_png=None, concept_map_png=None)
    doc = Document(io.BytesIO(out))
    assert len(doc.inline_shapes) == 0
    assert "Карта недоступна" in _all_text(doc)


def test_broken_png_falls_back_to_placeholder() -> None:
    """Битый PNG → плашка «недоступна», отчёт не падает (best-effort вставка)."""
    out = _render(parcel_map_png=b"not-a-png", concept_map_png=b"also-broken")
    doc = Document(io.BytesIO(out))
    assert len(doc.inline_shapes) == 0
    assert "Карта недоступна" in _all_text(doc)


# ── Деградации ──────────────────────────────────────────────────────────────────


def test_no_forecast_degrades_part_b() -> None:
    """Нет forecast → §4–§6 «нет данных», документ валиден (не падает)."""
    out = _render(forecast_result=None)
    doc = Document(io.BytesIO(out))
    headings = {p.text for p in doc.paragraphs}
    assert "§4. Рынок" in headings
    assert "§6. Риски и дефицит" in headings
    assert "нет данных" in _all_text(doc)


def test_no_concept_shows_plashka_and_market_context() -> None:
    """Нет концепции → §7 честная плашка + §5 только рыночный контекст цены."""
    out = _render(concept_result=None)
    text = _all_text(Document(io.BytesIO(out)))
    assert "Концепция застройки не рассчитана" in text
    # §5 деградирует в рыночный контекст (cost_of_error detail в forecast).
    assert "Рыночный контекст цены" in text


def test_no_connection_capacity_omits_reserves() -> None:
    """Нет connection-capacity → §3 без блоков резервов (только OSM-сети)."""
    out = _render(connection_capacity=None)
    text = _all_text(Document(io.BytesIO(out)))
    # OSM-сети остаются, а connection-capacity-заголовки не появляются.
    assert "Инженерные сети рядом" in text
    assert "Электроснабжение — свободная мощность" not in text
    assert "Газоснабжение — свободная мощность ГРС" not in text


def test_no_alternatives_when_field_absent() -> None:
    """program_alternatives отсутствует → блок альтернатив не рисуется вовсе."""
    analyze = _analyze()
    analyze.pop("program_alternatives")
    out = _render(analyze_result=analyze)
    headings = {p.text for p in Document(io.BytesIO(out)).paragraphs}
    assert "Как участок сходится (альтернативы программы)" not in headings


def test_empty_payload_produces_valid_docx() -> None:
    """Пустой payload (всё {}) → валидный минимальный .docx БЕЗ падения (graceful)."""
    out = build_full_report_docx(
        {},
        {},
        {},
        {},
        cad="x",
        address=None,
        generated_at="03.07.2026",
        parcel_map_png=None,
        concept_map_png=None,
    )
    assert out[:2] == b"PK"
    doc = Document(io.BytesIO(out))
    headings = {p.text for p in doc.paragraphs}
    # Все секции присутствуют даже на пустом payload.
    assert "§1. Участок" in headings
    assert "§7. Концепция застройки" in headings
    assert "нет данных" in _all_text(doc)


def test_garbage_payload_does_not_crash() -> None:
    """Мусорный payload (не-dict) нормализуется к пустому → валидный .docx."""
    out = build_full_report_docx(
        "не словарь",  # type: ignore[arg-type]
        "тоже не словарь",  # type: ignore[arg-type]
        None,
        None,
        cad=_CAD,
        address=None,
        generated_at="03.07.2026",
        parcel_map_png=None,
        concept_map_png=None,
    )
    assert out[:2] == b"PK"


# ── §6 «Разрешения на строительство рядом (500 м)» (ГИСОГД-66, зеркало HTML) ──────


def _permits_result() -> dict[str, Any]:
    """analyze-payload с непустым permits_nearby (2 документа: РНС + РВЭ)."""
    return {
        "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,
                },
            ],
        }
    }


def _new_doc() -> Document:
    """Пустой python-docx Document для юнит-теста билдера в изоляции."""
    return Document()


def test_permits_nearby_empty_honest_full_sentence() -> None:
    """Нет permits_nearby → честная плашка-параграф полным предложением, БЕЗ таблицы."""
    doc = _new_doc()
    _build_permits_nearby(doc, {})
    text = _all_text(doc)
    assert "новых разрешений на строительство не найдено" in text
    assert "ГИСОГД Свердловской области" in text
    assert len(doc.tables) == 0  # плашка — параграф, не таблица


def test_permits_nearby_zero_total_honest_note() -> None:
    """total_count=0 → та же честная плашка (не аббревиатура), таблицы нет."""
    doc = _new_doc()
    _build_permits_nearby(doc, {"permits_nearby": {"total_count": 0, "items": []}})
    text = _all_text(doc)
    assert "новых разрешений на строительство не найдено" in text
    assert len(doc.tables) == 0


def test_permits_nearby_non_empty_summary_and_list() -> None:
    """Есть данные → сводка РНС/РВЭ/ближайшее (kv-таблица) + таблица документов."""
    doc = _new_doc()
    _build_permits_nearby(doc, _permits_result())
    text = _all_text(doc)
    assert "не найдено" not in text
    assert "Разрешений на строительство (РНС)" in text
    assert "Разрешений на ввод (РВЭ)" in text
    assert "Разрешение на строительство МКД" in text
    assert "Администрация ЕКБ" in text
    assert "123.5" in text
    # kv-сводка + таблица items = две таблицы.
    assert len(doc.tables) == 2


def test_permits_nearby_items_capped_at_ten() -> None:
    """items[:10] — показываются первые 10 строк документов (зеркало HTML).

    #2464 cluster B: сверх 10 добавляется honest-строка «и ещё N записей», поэтому
    таблица = шапка + 10 документов + 1 disclosure = 12 строк.
    """
    items = [
        {
            "doc_group": "RS",
            "doc_name": f"РНС-{i}",
            "date_doc": "2025-01-01",
            "approved_organization": "Орган",
            "distance_m": float(i),
        }
        for i in range(15)
    ]
    result = {
        "permits_nearby": {
            "total_count": 15,
            "rs_count": 15,
            "rv_count": 0,
            "nearest_distance_m": 0.0,
            "items": items,
        }
    }
    doc = _new_doc()
    _build_permits_nearby(doc, result)
    # Вторая таблица (документы): шапка + 10 показанных + 1 disclosure-строка.
    docs_table = doc.tables[1]
    assert len(docs_table.rows) == 12
    text = _all_text(doc)
    assert "РНС-0" in text
    assert "РНС-9" in text
    assert "РНС-10" not in text


def test_permits_nearby_truncation_disclosed_beyond_row_cap() -> None:
    """#2464 cluster B: total_count (честный COUNT апстрима, БЕЗ SQL LIMIT) > показанных
    10 → honest-строка «и ещё N записей (полный список в веб-версии §6)», а не молчаливый
    срез — как тепло/вода-таблицы §3 (`_build_connection_capacity`)."""
    items = [
        {
            "doc_group": "RS",
            "doc_name": f"РНС-{i}",
            "date_doc": "2025-01-01",
            "approved_organization": "Орган",
            "distance_m": float(i),
        }
        for i in range(12)
    ]
    result = {
        "permits_nearby": {
            # total_count=15 > показанных 10 → hidden = 5 (апстрим уже капнул items на 30,
            # здесь режем до 10; disclosure считается от честного total_count).
            "total_count": 15,
            "rs_count": 15,
            "rv_count": 0,
            "nearest_distance_m": 0.0,
            "items": items,
        }
    }
    doc = _new_doc()
    _build_permits_nearby(doc, result)
    text = _all_text(doc)
    assert "… и ещё 5 записей (полный список в веб-версии §6)" in text


def test_permits_nearby_no_disclosure_when_all_shown() -> None:
    """total_count <= 10 (всё влезло) → БЕЗ строки «и ещё» (zero behavior change для
    маленьких выборок): таблица = шапка + N документов, без disclosure-строки."""
    items = [
        {
            "doc_group": "RS",
            "doc_name": f"РНС-{i}",
            "date_doc": "2025-01-01",
            "approved_organization": "Орган",
            "distance_m": float(i),
        }
        for i in range(7)
    ]
    result = {
        "permits_nearby": {
            "total_count": 7,
            "rs_count": 7,
            "rv_count": 0,
            "nearest_distance_m": 0.0,
            "items": items,
        }
    }
    doc = _new_doc()
    _build_permits_nearby(doc, result)
    docs_table = doc.tables[1]
    assert len(docs_table.rows) == 8  # шапка + 7, без disclosure
    text = _all_text(doc)
    assert "и ещё" not in text


def test_section_6_wires_permits_nearby_from_analyze() -> None:
    """_build_section_6(doc, forecast, result) прокидывает result → блок разрешений виден."""
    doc = _new_doc()
    _build_section_6(doc, {}, _permits_result())
    text = _all_text(doc)
    headings = {p.text for p in doc.paragraphs}
    assert "Разрешения на строительство рядом (500 м)" in headings
    assert "Разрешение на строительство МКД" in text


def test_section_6_permits_nearby_absent_analyze_shows_honest_note() -> None:
    """§6 без permits_nearby в result всё равно рисует честную плашку (не падает)."""
    doc = _new_doc()
    _build_section_6(doc, {}, {})
    text = _all_text(doc)
    assert "Разрешения на строительство рядом (500 м)" in {p.text for p in doc.paragraphs}
    assert "новых разрешений на строительство не найдено" in text


def test_full_report_wires_permits_nearby_end_to_end() -> None:
    """build_full_report_docx прокидывает analyze.permits_nearby в §6 (интеграция)."""
    analyze = _analyze()
    analyze["permits_nearby"] = _permits_result()["permits_nearby"]
    out = _render(analyze_result=analyze)
    doc = Document(io.BytesIO(out))
    headings = {p.text for p in doc.paragraphs}
    assert "Разрешения на строительство рядом (500 м)" in headings
    assert "Разрешение на строительство МКД" in _all_text(doc)


def test_full_report_permits_nearby_honest_note_when_absent() -> None:
    """Дефолтный _analyze() без permits_nearby → честная плашка «не найдено» в §6."""
    out = _render()
    text = _all_text(Document(io.BytesIO(out)))
    assert "Разрешения на строительство рядом (500 м)" in {
        p.text for p in Document(io.BytesIO(out)).paragraphs
    }
    assert "новых разрешений на строительство не найдено" in text
