"""Unit-тесты §13 PDF-экспортёра (#989, 955-A3) — `export_report_pdf`.

Чистые тесты БЕЗ БД/сети (экспортёр только ПОТРЕБЛЯЕТ уже-собранный отчёт):
  • полный `SiteFinderReport` → байты начинаются с `b"%PDF"` + нетривиальная длина;
  • ожидаемый текст секций присутствует в HTML-источнике (заголовок вердикта, класс
    продукта, RU-заголовки блоков) — рендерим тот же HTML, что уходит в WeasyPrint;
  • экспортёр принимает КАК dataclass-инстанс, ТАК и его `as_dict()`-словарь;
  • частичный / пустой / мусорный отчёт → всё равно валидный PDF (graceful, без падения);
  • ADVISORY-маркер присутствует.

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

from __future__ import annotations

import os

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

import datetime as dt

import pytest

from app.services.exporters.report_pdf import (
    _ADVISORY_MARKER,
    _TITLE_CONFIDENCE,
    _TITLE_FUTURE_MARKET,
    _TITLE_MARKET_NOW,
    _TITLE_PRODUCT_TZ,
    _TITLE_SCENARIOS,
    _TITLE_SCORING,
    _TITLE_SUMMARY,
    _build_html,
    _fmt,
    _normalize,
    _scenario_deficit_index,
    export_report_pdf,
)
from app.services.forecasting.demand_supply_forecast import DemandSupplyForecast
from app.services.forecasting.report import (
    ReportConfidence,
    ReportExecSummary,
    ReportFutureMarket,
    ReportMarketNow,
    ReportMeta,
    ReportProductTz,
    ReportScenarios,
    ReportScoring,
    SiteFinderReport,
)
from app.services.forecasting.scenarios import ScenarioForecast


def _forecast(horizon: int, demand: float, supply: float, deficit: float) -> dict[str, object]:
    """Реальный `DemandSupplyForecast.as_dict()` — ключи гарантированно из контракта #952.

    Строим frozen-dataclass БЕЗ БД и сериализуем через его собственный `as_dict()`: так
    тест не может разойтись с реальной формой (projected_demand_units / projected_supply_
    units), как это случилось со старой hand-typed фикстурой (demand/supply — #989/#991).
    """
    return DemandSupplyForecast(
        segment={"obj_class": "комфорт"},
        horizon_months=horizon,
        base_pace_units_per_mo=8.0,
        demand_norm_coefficient=1.0,
        macro_coefficient=1.0,
        projected_demand_units=demand,
        open_units=300,
        hidden_release_units=80.0,
        future_online_units=20.0,
        projected_supply_units=supply,
        balance_units=demand - supply,
        balance_ratio=(demand / supply if supply else None),
        deficit_index=deficit,
        months_of_inventory=(supply / (demand / horizon) if demand else None),
        rate_future=18.0,
        rate_sensitivity_phrase="спрос эластичен к ставке",
        future_competitors=[{"obj_id": 3, "comm_name": "ЖК Гамма"}],
        advisory=True,
        confidence="medium",
    ).as_dict()


def _scenario(name: str, deficit_12mo: float) -> dict[str, object]:
    """Реальный `ScenarioForecast.as_dict()` — ключи из контракта #984 (НЕ hand-typed).

    У сценария НЕТ скалярного «overall»: есть `forecasts` (по горизонтам) с deficit_index.
    Экспортёр тянет сводный дефицит 12-мес горизонта — здесь даём именно его.
    """
    return ScenarioForecast(
        scenario=name,  # type: ignore[arg-type]  # Literal в проде; в тесте — str-имя
        rate_path={6: 18.0, 12: 18.0},
        forecasts=[
            DemandSupplyForecast(
                segment={"obj_class": "комфорт"},
                horizon_months=12,
                base_pace_units_per_mo=8.0,
                demand_norm_coefficient=1.0,
                macro_coefficient=1.0,
                projected_demand_units=920.0,
                open_units=300,
                hidden_release_units=80.0,
                future_online_units=20.0,
                projected_supply_units=700.0,
                balance_units=220.0,
                balance_ratio=1.314,
                deficit_index=deficit_12mo,
                months_of_inventory=9.1,
                rate_future=18.0,
                rate_sensitivity_phrase=None,
                future_competitors=[],
                advisory=True,
                confidence="medium",
            )
        ],
        advisory=True,
    ).as_dict()


def _weasyprint_native_libs_available() -> tuple[bool, str]:
    """Probe whether WeasyPrint can render (native GTK/Pango/GObject libs present).

    WeasyPrint is lazy-imported inside export_report_pdf, so the missing-native-lib
    OSError fires only at write_pdf() time (libpango/libgobject absent on macOS dev,
    present in the Docker/CI image). We probe by rendering a trivial document; failure
    ⇒ skip the PDF-rendering tests on dev, while the HTML-only tests still run and the
    PDF path still runs on CI. NB: the HTML-content tests (_build_html only) are NOT
    skipped — they never touch WeasyPrint.
    """
    try:
        from weasyprint import HTML

        HTML(string="<p>x</p>").write_pdf()
    except (OSError, ImportError) as e:
        return False, str(e)
    return True, ""


_WP_OK, _WP_ERR = _weasyprint_native_libs_available()
_skip_if_no_weasyprint = pytest.mark.skipif(
    not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
)

# Семь ожидаемых заголовков блоков (по одному на содержательную секцию §13, включая
# §13.7 «Уверенность» — parity fix #2445 C2, зеркало _EXPECTED_TITLES в test_report_docx/md).
_EXPECTED_TITLES: tuple[str, ...] = (
    _TITLE_SUMMARY,
    _TITLE_MARKET_NOW,
    _TITLE_FUTURE_MARKET,
    _TITLE_PRODUCT_TZ,
    _TITLE_SCENARIOS,
    _TITLE_SCORING,
    _TITLE_CONFIDENCE,
)


def _full_report() -> SiteFinderReport:
    """Полный SiteFinderReport из реалистичных sample-данных (форма #987 `as_dict()`)."""
    return SiteFinderReport(
        exec_summary=ReportExecSummary(
            headline="Строить комфорт-класс, акцент на студии и 1-к.",
            verdict="Дефицит малых форматов на горизонте 12 мес; рынок поглощает быстро.",
            key_numbers={"overall_score": 0.71, "deficit_index": 0.34, "horizon_months": 12},
            overall_confidence="medium",
        ),
        market_now=ReportMarketNow(
            market_metrics={"unit_velocity": 8.2, "overstock_index": 0.21, "confidence": "medium"},
            competitors=[
                {"obj_id": 1, "comm_name": "ЖК Альфа", "relevance_weight": 0.8},
                {"obj_id": 2, "comm_name": "ЖК Бета", "relevance_weight": 0.5},
            ],
            supply_layers={"open_units": 1200, "hidden_units": 800},
            summary="Текущий рынок: умеренная абсорбция, средняя плотность конкурентов.",
        ),
        future_market=ReportFutureMarket(
            forecasts_by_horizon=[
                _forecast(6, demand=410.0, supply=380.0, deficit=0.2),
                _forecast(12, demand=920.0, supply=700.0, deficit=0.34),
            ],
            future_supply={
                "district": "Верх-Исетский",
                "horizon_months": 12,
                "premise_kind": "квартира",
                "confidence": "low",
                "index": 0.42,
                "breakdown": {
                    "open_units": 1200,
                    "hidden_units": 800,
                    "future_units_by_horizon": 450.0,
                    "monthly_absorption_units": 8.2,
                    "months_of_pressure": 9.0,
                    "index": 0.42,
                },
            },
            future_competitors=[{"obj_id": 3, "stage": "котлован"}],
            scenarios_summary={"base": 0.34, "conservative": 0.18, "aggressive": 0.49},
            summary="Будущий рынок: дефицит сохраняется на 12 мес.",
        ),
        product_tz=ReportProductTz(
            obj_class="комфорт",
            mix=[
                {"bucket": "1-Студия", "pct": 35},
                {"bucket": "2-1-к", "pct": 40},
                {"bucket": "3-2-к", "pct": 25},
            ],
            commercial={"available": False, "caveat": "коммерция: нет достаточных данных"},
            usp=[{"segment": "1-Студия", "usp_text": "Дефицит студий — стройте их."}],
            reasons=[{"why": "deficit_index +0.34 на горизонте 12 мес", "advisory": True}],
            summary="Рекомендован комфорт-класс с акцентом на малые форматы.",
        ),
        scenarios=ReportScenarios(
            by_scenario={
                "conservative": _scenario("conservative", deficit_12mo=0.18),
                "base": _scenario("base", deficit_12mo=0.34),
                "aggressive": _scenario("aggressive", deficit_12mo=0.49),
            },
            summary="Разброс сценариев умеренный.",
        ),
        scoring=ReportScoring(
            product_scores={
                "overall": 0.71,
                "scores": {"market_fit": {"value": 0.67}, "demand": {"value": 0.51}},
                "advisory": True,
            },
            special_indices={
                "indices": {"launch_window": {"value": 0.6, "label": "12 мес"}},
                "advisory": True,
            },
            overall=0.71,
        ),
        confidence=ReportConfidence(
            level="medium",
            rationale="Источники advisory-capped; данные средней плотности.",
            factors={"data_coverage": 0.6, "engine_validated": False},
        ),
        meta=ReportMeta(
            cad_num="66:41:0000000:1",
            district="Верх-Исетский",
            segment={"obj_class": "комфорт", "room_bucket": "1-к 30-45"},
            horizons=[6, 12, 18, 24],
            generated_at=dt.date(2026, 6, 3),
        ),
    )


# ── Полный отчёт: PDF-байты (магия %PDF), нетривиальная длина ───────────────────


@_skip_if_no_weasyprint
class TestFullReportExport:
    def test_returns_pdf_magic_bytes(self) -> None:
        payload = export_report_pdf(_full_report())
        assert isinstance(payload, bytes)
        assert payload.startswith(b"%PDF")

    def test_returns_non_trivial_length(self) -> None:
        # Полный отчёт с таблицами — заметно больше пустого «скелета».
        payload = export_report_pdf(_full_report())
        assert len(payload) > 2000

    def test_accepts_as_dict_input(self) -> None:
        # Экспортёр принимает уже-словарь `as_dict()` (не только dataclass).
        payload = export_report_pdf(_full_report().as_dict())
        assert payload.startswith(b"%PDF")


# ── Содержимое HTML-источника: ожидаемый текст секций присутствует ─────────────
# Рендерим ТОТ ЖЕ HTML, что уходит в WeasyPrint (быстрее и точнее, чем парсить PDF).


class TestRenderedHtmlContent:
    def test_all_section_titles_present(self) -> None:
        html_str = _build_html(_normalize(_full_report()))
        for title in _EXPECTED_TITLES:
            assert title in html_str, f"отсутствует заголовок блока {title}"

    def test_advisory_marker_present(self) -> None:
        html_str = _build_html(_normalize(_full_report()))
        assert _ADVISORY_MARKER in html_str
        assert "ADVISORY" in html_str

    def test_exec_headline_and_context_present(self) -> None:
        html_str = _build_html(_normalize(_full_report()))
        assert "Строить комфорт" in html_str
        assert "66:41:0000000:1" in html_str

    def test_product_class_and_mix_present(self) -> None:
        html_str = _build_html(_normalize(_full_report()))
        assert "комфорт" in html_str
        assert "1-Студия" in html_str

    def test_scoring_values_present(self) -> None:
        html_str = _build_html(_normalize(_full_report()))
        assert "market_fit" in html_str
        assert "0.71" in html_str

    def test_dynamic_fields_are_escaped(self) -> None:
        # html.escape применён ко всем динамическим строкам: вредоносный ввод обезврежен.
        report = SiteFinderReport(
            exec_summary=ReportExecSummary(headline="<script>alert(1)</script> & co"),
        )
        html_str = _build_html(_normalize(report))
        assert "<script>alert(1)</script>" not in html_str
        assert "&lt;script&gt;alert(1)&lt;/script&gt;" in html_str
        assert "&amp; co" in html_str


# ── Regression #989: реальные ключи контракта попадают в вывод (НЕ "—") ─────────
# Эти тесты ловят key-drift экспортёра: они тянут значения из РЕАЛЬНОГО
# `SiteFinderReport.as_dict()` (а не hand-typed строк), поэтому если экспортёр снова
# начнёт читать несуществующий ключ (как demand/supply / per-scenario overall до фикса),
# значение пропадёт из HTML → тест упадёт. Так контракт и тест не разойдутся молча.


class TestContractKeysRendered:
    def test_demand_supply_units_rendered_not_dash(self) -> None:
        # Берём числа спроса/предложения из самого as_dict() (форма #952), затем
        # проверяем, что они ЕСТЬ в HTML — старый bug читал demand/supply → было "—".
        data = _full_report().as_dict()
        forecasts = data["future_market"]["forecasts_by_horizon"]
        assert forecasts, "фикстура должна содержать прогнозы по горизонтам"
        html_str = _build_html(_normalize(_full_report()))
        for f in forecasts:
            demand = f["projected_demand_units"]
            supply = f["projected_supply_units"]
            assert demand is not None and supply is not None
            assert str(_fmt(demand)) in html_str, f"спрос {demand} не отрисован (читается ключ?)"
            assert str(_fmt(supply)) in html_str, f"предложение {supply} не отрисован"

    def test_scenario_deficit_rendered_not_dash(self) -> None:
        # Сводный дефицит 12-мес горизонта каждого сценария должен попасть в HTML.
        # Старый bug читал per-scenario data.get("overall") (нет такого ключа) → "—".
        data = _full_report().as_dict()
        by_scenario = data["scenarios"]["by_scenario"]
        assert by_scenario, "фикстура должна содержать сценарии"
        html_str = _build_html(_normalize(_full_report()))
        for payload in by_scenario.values():
            di = _scenario_deficit_index(payload)
            assert di is not None
            assert str(_fmt(di)) in html_str, f"дефицит сценария {di} не отрисован"

    def test_overall_score_rendered(self) -> None:
        # scoring.overall (ReportScoring) — корректный ключ; убеждаемся, что он в HTML.
        data = _full_report().as_dict()
        overall = data["scoring"]["overall"]
        assert overall is not None
        html_str = _build_html(_normalize(_full_report()))
        assert str(_fmt(overall)) in html_str

    def test_future_supply_section_rendered(self) -> None:
        # #991/parity: давление будущего предложения (index + breakdown) должно
        # попасть в HTML — раньше PDF не разворачивал breakdown по слоям.
        data = _full_report().as_dict()
        fs = data["future_market"]["future_supply"]
        assert fs is not None
        html_str = _build_html(_normalize(_full_report()))
        assert "Давление будущего предложения" in html_str
        assert str(_fmt(fs["index"])) in html_str
        assert str(_fmt(fs["breakdown"]["open_units"])) in html_str

    def test_confidence_factors_rendered(self) -> None:
        # confidence.factors должны попасть в HTML (ключ→значение) через §13.7-секцию.
        html_str = _build_html(_normalize(_full_report()))
        assert "Факторы уверенности" in html_str
        assert "data_coverage" in html_str


# ── Parity fix #2445 C2: §13.7 «Уверенность» — дедикейтед секция (было только в docx/md) ─
# report_docx.py / report_md.py уже несли полную секцию с 4-колоночной таблицей
# (Фактор/Значение/Уровень/Комментарий) и defensive dict-vs-flat разбором факторов;
# PDF был единственным экспортёром без неё (только тонкий 2-строчный уровень/rationale
# в «Сводке» + плоский dict-дамп факторов). Эти тесты ловят регресс парности.


class TestConfidenceSectionParity:
    def test_confidence_section_title_present(self) -> None:
        # Секция §13.7 присутствует как отдельный заголовок (не только внутри «Сводки»).
        html_str = _build_html(_normalize(_full_report()))
        assert _TITLE_CONFIDENCE in html_str
        assert 'id="confidence"' in html_str

    def test_confidence_section_has_four_column_table(self) -> None:
        # 4-колоночная таблица факторов — зеркало report_docx/report_md._build_confidence.
        html_str = _build_html(_normalize(_full_report()))
        assert "Фактор" in html_str
        assert "Значение" in html_str
        assert "Уровень" in html_str
        assert "Комментарий" in html_str

    def test_confidence_level_and_rationale_rendered(self) -> None:
        report = SiteFinderReport(
            confidence=ReportConfidence(
                level="medium",
                rationale="Источники advisory-capped; данные средней плотности.",
                factors={"data_coverage": 0.6},
            ),
        )
        html_str = _build_html(_normalize(report))
        assert "средняя" in html_str  # _level_ru("medium")
        assert "Источники advisory-capped" in html_str

    def test_dict_shaped_factor_renders_without_crashing(self) -> None:
        # Ровно баг, от которого защищает docx isinstance(payload, dict)-ветка: значение
        # фактора — САМО dict {value, level, note} (реальная форма §15/#990, см. frontend
        # mock parcel-forecast.json), а не плоский скаляр. Плоский _dict_kv_table в старой
        # версии PDF-«Сводки» такое не ожидал — падал/рендерил мусор. Новая §13.7-секция
        # (_build_confidence) разбирает dict-payload как в report_docx/report_md.
        report = SiteFinderReport(
            confidence=ReportConfidence(
                level="low",
                rationale="Low потому что мало истории.",
                factors={
                    "history_months": {"value": 6, "level": "low", "note": "6 мес — мало"},
                    "analog_count": {"value": 32, "level": "high", "note": "32 ЖК — достаточно"},
                    "advisory_capped": False,  # плоский (не-dict) фактор — тоже должен уцелеть
                },
            ),
        )
        # Не должно бросить исключение (регресс-guard); плюс проверяем реальный рендер.
        html_str = _build_html(_normalize(report))
        assert "history_months" in html_str
        assert "6 мес — мало" in html_str
        assert "analog_count" in html_str
        assert "advisory_capped" in html_str

    @_skip_if_no_weasyprint
    def test_dict_shaped_factor_produces_valid_pdf(self) -> None:
        # То же самое, но конец-в-конец через WeasyPrint (не только HTML-строка).
        report = SiteFinderReport(
            confidence=ReportConfidence(
                level="low",
                rationale="Low потому что мало истории.",
                factors={
                    "history_months": {"value": 6, "level": "low", "note": "6 мес — мало"},
                },
            ),
        )
        payload = export_report_pdf(report)
        assert payload.startswith(b"%PDF")
        assert len(payload) > 0

    def test_summary_no_longer_duplicates_confidence_rationale(self) -> None:
        # Тонкий 2-строчный «уровень+rationale» блок убран из «Сводки» — теперь живёт
        # только в §13.7, чтобы не было двух версий одних и тех же данных в документе.
        # Rationale-текст должен встретиться РОВНО один раз (в секции confidence).
        report = SiteFinderReport(
            confidence=ReportConfidence(
                level="medium",
                rationale="Уникальная-строка-обоснования-для-проверки-дублей",
                factors={"data_coverage": 0.6},
            ),
        )
        html_str = _build_html(_normalize(report))
        assert html_str.count("Уникальная-строка-обоснования-для-проверки-дублей") == 1


# ── Graceful: частичный / пустой / мусорный вход → валидный PDF без падения ─────


class TestGracefulPartialReport:
    @_skip_if_no_weasyprint
    def test_empty_report_still_valid_pdf(self) -> None:
        # Полностью дефолтный (пустой) отчёт — валидный PDF, не падает.
        payload = export_report_pdf(SiteFinderReport())
        assert payload.startswith(b"%PDF")
        assert len(payload) > 0

    def test_empty_report_shows_no_data_marker(self) -> None:
        # Пустые секции рисуют заглушку «нет данных» (graceful), а не падают.
        html_str = _build_html(_normalize(SiteFinderReport()))
        assert "нет данных" in html_str
        # ADVISORY-маркер остаётся даже у пустого отчёта.
        assert "ADVISORY" in html_str

    @_skip_if_no_weasyprint
    def test_partial_report_some_sections(self) -> None:
        # Заполнены только meta + exec_summary — остальные пусты, PDF валиден.
        report = SiteFinderReport(
            exec_summary=ReportExecSummary(headline="Тонкий анализ — только заголовок."),
            meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]),
        )
        payload = export_report_pdf(report)
        assert payload.startswith(b"%PDF")
        html_str = _build_html(_normalize(report))
        assert "Тонкий анализ" in html_str
        assert "66:41:0000000:2" in html_str

    @_skip_if_no_weasyprint
    def test_garbage_input_does_not_crash(self) -> None:
        # Мусор (None / не-отчёт) → пустой, но валидный PDF (нормализация в {}).
        for junk in (None, 123, "not a report"):
            payload = export_report_pdf(junk)
            assert payload.startswith(b"%PDF")
            assert len(payload) > 0
