"""Unit-тесты оркестратора полного PDF-отчёта (эпик #2259 PR-D) — `build_full_report`.

Без native WeasyPrint и без реальной БД: `render_full_report_pdf` и карты замоканы,
DB — MagicMock c подставными analyze/forecast/report ранами. Проверяем оркестрацию и
кэш-ключ (analyze_run_id, forecast_run_id):
  • нет analyze-рана → ValueError;
  • happy path: рендер вызван, файл записан, метадата-ран (schema report-pdf-1.0) персиснут;
  • нет forecast-рана → forecast_run_id=None, part_b собран из пустого форсайта (не падает);
  • cache-hit: тот же (analyze, forecast) ключ + файл на месте → PDF НЕ пере-рендерится;
  • cache-miss при другом forecast_run_id → пере-рендер;
  • концепция/connection-capacity best-effort: их сбой не роняет сборку.

DATABASE_URL до импорта app-модулей (зеркало test_report_pdf.py).
"""

from __future__ import annotations

import os

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

import datetime as dt
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

import pytest

from app.services.exporters import full_report_pdf as mod

_CAD = "66:41:0204016:10"
_GEOJSON = {
    "type": "Polygon",
    "coordinates": [
        [[60.6, 56.838], [60.61, 56.838], [60.61, 56.845], [60.6, 56.845], [60.6, 56.838]]
    ],
}


def _make_row(
    run_id: int, result: dict[str, Any], *, district: str | None = "Кировский"
) -> MagicMock:
    """Мок Row analysis_runs (id / result / district / created_at)."""
    row = MagicMock()
    row.id = run_id
    row.result = result
    row.district = district
    row.created_at = dt.datetime(2026, 7, 3, 10, 0, 0)
    return row


def _analyze_result() -> dict[str, Any]:
    return {
        "cad_num": _CAD,
        "geom_geojson": _GEOJSON,
        "egrn": {"address": "ЕКБ, ул. Примерная, 1"},
    }


def _forecast_result() -> dict[str, Any]:
    return {"schema_version": "1.0", "exec_summary": {"headline": "x"}}


class _RunTable:
    """Роутер latest_run_for по schema_version: подставляет заранее заданные раны.

    build_full_report вызывает latest_run_for трижды: analyze, forecast, report-cache
    (внутри _find_cached_report). Отдаём row по schema_version из словаря (None → нет рана).
    """

    def __init__(self, rows: dict[str | None, MagicMock | None]) -> None:
        self._rows = rows

    def __call__(self, db: Any, cad: str, *, schema_version: str | None = None) -> MagicMock | None:
        return self._rows.get(schema_version)


def _patch_renders(monkeypatch: pytest.MonkeyPatch, pdf_mock: MagicMock) -> None:
    """Замокать тяжёлые рендеры: PDF (native) + карты (matplotlib/OSM)."""
    monkeypatch.setattr(mod, "render_full_report_pdf", pdf_mock)
    monkeypatch.setattr(mod, "render_parcel_map_png", lambda *a, **k: b"PNGPARCEL")
    monkeypatch.setattr(mod, "render_concept_footprint_png", lambda *a, **k: b"PNGCONCEPT")
    # embed_map_png/HTML-сборка PURE — оставляем реальными (проверяют плейсхолдеры).
    # connection-capacity + концепция — заглушаем на None (best-effort, отдельные тесты ниже).
    monkeypatch.setattr(mod, "_get_connection_capacity", lambda db, cad: None)
    monkeypatch.setattr(mod, "_generate_concept_result", lambda db, analyze: None)
    # inline §22-форсайт (best-effort synchronous compute) — по умолчанию no-op, чтобы
    # тяжёлый orchestrator не тянулся; поведение «холодного» участка тестируем отдельно ниже.
    monkeypatch.setattr(mod, "_ensure_forecast_run", lambda db, cad, row, analyze: None)


def test_no_analyze_run_raises(monkeypatch: pytest.MonkeyPatch) -> None:
    """Нет analyze-рана → ValueError (отчётировать нечего)."""
    table = _RunTable({"analyze-1.0": None})
    monkeypatch.setattr(mod, "latest_run_for", table)
    with pytest.raises(ValueError, match="analyze"):
        mod.build_full_report(MagicMock(), _CAD)


def test_happy_path_writes_file_and_persists(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    """Happy path: PDF отрендерен, файл записан на volume, метадата-ран персиснут."""
    analyze_row = _make_row(101, _analyze_result())
    forecast_row = _make_row(202, _forecast_result())
    table = _RunTable(
        {"analyze-1.0": analyze_row, "1.0": forecast_row, mod.REPORT_SCHEMA_VERSION: None}
    )
    monkeypatch.setattr(mod, "latest_run_for", table)
    monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path))
    pdf_mock = MagicMock(return_value=b"%PDF-1.7 fake")
    _patch_renders(monkeypatch, pdf_mock)
    persist_mock = MagicMock(return_value=999)
    monkeypatch.setattr(mod, "persist_analysis_run", persist_mock)

    result = mod.build_full_report(MagicMock(), _CAD)

    pdf_mock.assert_called_once()
    assert result["analyze_run_id"] == 101
    assert result["forecast_run_id"] == 202
    assert result["size_bytes"] == len(b"%PDF-1.7 fake")
    assert Path(result["pdf_path"]).exists()
    assert Path(result["pdf_path"]).read_bytes() == b"%PDF-1.7 fake"
    # Метадата-ран персиснут с нашей схемой + кэш-ключом.
    persist_mock.assert_called_once()
    _, kwargs = persist_mock.call_args
    assert kwargs["schema_version"] == mod.REPORT_SCHEMA_VERSION
    assert kwargs["result"]["analyze_run_id"] == 101
    assert kwargs["result"]["forecast_run_id"] == 202


def test_no_forecast_run_triggers_inline_generation(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    """Холодный участок: forecast-рана нет → _ensure_forecast_run вызван → ран перечитан.

    Генератор (mock) «персистит» §22-ран, поэтому повторный latest_run_for("1.0") ловит
    свежий forecast_row → его id попадает в отчёт и кэш-ключ (самодостаточность §4–§6).
    """
    analyze_row = _make_row(101, _analyze_result())
    forecast_row = _make_row(777, _forecast_result())  # «появляется» после генерации
    rows: dict[str | None, MagicMock | None] = {
        "analyze-1.0": analyze_row,
        "1.0": None,  # первый read: рана ещё нет
        mod.REPORT_SCHEMA_VERSION: None,
    }
    table = _RunTable(rows)
    monkeypatch.setattr(mod, "latest_run_for", table)
    monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path))
    _patch_renders(monkeypatch, MagicMock(return_value=b"%PDF"))
    monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))

    def _appear(db: Any, cad: str, row: Any, analyze: Any) -> None:
        rows["1.0"] = forecast_row  # генератор «персистит» § 22-ран

    ensure_mock = MagicMock(side_effect=_appear)
    monkeypatch.setattr(mod, "_ensure_forecast_run", ensure_mock)

    result = mod.build_full_report(MagicMock(), _CAD)

    # Генератор вызван ровно раз с cad + analyze-row (для district-fallback); ран перечитан.
    ensure_mock.assert_called_once()
    call_args, _ = ensure_mock.call_args
    assert call_args[1] == _CAD  # cad
    assert call_args[2] is analyze_row  # analyze_row
    assert result["forecast_run_id"] == 777
    assert result["analyze_run_id"] == 101


def test_inline_forecast_failure_degrades(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
    """Inline-форсайт ничего не собрал (сбой/тонкие данные) → отчёт без §4–§6.

    _ensure_forecast_run best-effort (сам глотает исключение → warning), поэтому здесь он
    просто no-op: повторный latest_run_for("1.0") снова None → forecast_run_id=None,
    Part B из пустого форсайта (отчёт валиден, как до фикса на холодном участке)."""
    analyze_row = _make_row(101, _analyze_result())
    table = _RunTable({"analyze-1.0": analyze_row, "1.0": None, mod.REPORT_SCHEMA_VERSION: None})
    monkeypatch.setattr(mod, "latest_run_for", table)
    monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path))
    _patch_renders(monkeypatch, MagicMock(return_value=b"%PDF"))  # ставит _ensure no-op
    monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))

    result = mod.build_full_report(MagicMock(), _CAD)
    assert result["forecast_run_id"] is None
    assert result["analyze_run_id"] == 101


def test_cache_hit_skips_render(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
    """Тот же (analyze, forecast) ключ + файл на месте → PDF НЕ пере-рендерится."""
    analyze_row = _make_row(101, _analyze_result())
    forecast_row = _make_row(202, _forecast_result())
    # Существующий метадата-ран с тем же ключом + реальный файл.
    cached_pdf = tmp_path / "cached.pdf"
    cached_pdf.write_bytes(b"%PDF-cached")
    report_row = _make_row(
        500,
        {
            "pdf_path": str(cached_pdf),
            "analyze_run_id": 101,
            "forecast_run_id": 202,
            "generated_at": "2026-07-03T09:00:00+00:00",
            "size_bytes": 10,
        },
    )
    table = _RunTable(
        {"analyze-1.0": analyze_row, "1.0": forecast_row, mod.REPORT_SCHEMA_VERSION: report_row}
    )
    monkeypatch.setattr(mod, "latest_run_for", table)
    pdf_mock = MagicMock(return_value=b"%PDF-fresh")
    _patch_renders(monkeypatch, pdf_mock)
    persist_mock = MagicMock()
    monkeypatch.setattr(mod, "persist_analysis_run", persist_mock)

    result = mod.build_full_report(MagicMock(), _CAD)

    pdf_mock.assert_not_called()  # cache-hit: не рендерим
    persist_mock.assert_not_called()  # и не персистим повторно
    assert result["cache_hit"] is True
    assert result["pdf_path"] == str(cached_pdf)


def test_cache_miss_on_different_forecast_id(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    """Другой forecast_run_id (свежий форсайт) → cache-miss, пере-рендер."""
    analyze_row = _make_row(101, _analyze_result())
    forecast_row = _make_row(303, _forecast_result())  # новый форсайт-ран
    cached_pdf = tmp_path / "stale.pdf"
    cached_pdf.write_bytes(b"%PDF-stale")
    report_row = _make_row(
        500,
        {
            "pdf_path": str(cached_pdf),
            "analyze_run_id": 101,
            "forecast_run_id": 202,  # СТАРЫЙ forecast id → ключ не совпал
            "generated_at": "2026-07-03T09:00:00+00:00",
            "size_bytes": 10,
        },
    )
    table = _RunTable(
        {"analyze-1.0": analyze_row, "1.0": forecast_row, mod.REPORT_SCHEMA_VERSION: report_row}
    )
    monkeypatch.setattr(mod, "latest_run_for", table)
    monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path))
    pdf_mock = MagicMock(return_value=b"%PDF-fresh")
    _patch_renders(monkeypatch, pdf_mock)
    monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))

    result = mod.build_full_report(MagicMock(), _CAD)
    pdf_mock.assert_called_once()  # ключ разошёлся → пере-рендерим
    assert result.get("cache_hit") is None
    assert result["forecast_run_id"] == 303


def test_cache_miss_when_file_missing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
    """Метадата-ран с тем же ключом ЕСТЬ, но файл на диске исчез → пере-рендер."""
    analyze_row = _make_row(101, _analyze_result())
    forecast_row = _make_row(202, _forecast_result())
    report_row = _make_row(
        500,
        {
            "pdf_path": str(tmp_path / "gone.pdf"),  # файла нет
            "analyze_run_id": 101,
            "forecast_run_id": 202,
            "generated_at": "2026-07-03T09:00:00+00:00",
            "size_bytes": 10,
        },
    )
    table = _RunTable(
        {"analyze-1.0": analyze_row, "1.0": forecast_row, mod.REPORT_SCHEMA_VERSION: report_row}
    )
    monkeypatch.setattr(mod, "latest_run_for", table)
    monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path))
    pdf_mock = MagicMock(return_value=b"%PDF-fresh")
    _patch_renders(monkeypatch, pdf_mock)
    monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))

    result = mod.build_full_report(MagicMock(), _CAD)
    pdf_mock.assert_called_once()
    assert result.get("cache_hit") is None


def test_concept_generation_failure_is_best_effort(monkeypatch: pytest.MonkeyPatch) -> None:
    """Генерация концепции упала → None (отчёт валиден без §7-концепции)."""
    db = MagicMock()
    # geometry.generate бросает → _generate_concept_result возвращает None.
    with patch("app.services.generative.geometry.generate", side_effect=RuntimeError("degenerate")):
        result = mod._generate_concept_result(db, _analyze_result())
    assert result is None


def test_concept_generation_no_geom_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
    """analyze-payload без geom_geojson → концепция пропущена (None), без обращения к БД."""
    assert mod._generate_concept_result(MagicMock(), {"egrn": {}}) is None


def test_connection_capacity_failure_is_best_effort(monkeypatch: pytest.MonkeyPatch) -> None:
    """get_connection_capacity бросает → None (§3 деградирует, сборка не падает)."""
    with patch(
        "app.services.site_finder.connection_capacity_lookup.get_connection_capacity",
        side_effect=RuntimeError("no geom"),
    ):
        assert mod._get_connection_capacity(MagicMock(), _CAD) is None


def test_ensure_forecast_run_computes_and_persists(monkeypatch: pytest.MonkeyPatch) -> None:
    """_ensure_forecast_run: считает §22 через orchestrator и персистит как Celery-таска.

    Сверка с forecast_site_finder_report: build_site_finder_report(horizons=[6,12,18,24]) →
    report.as_dict() → persist_analysis_run(schema_version="1.0", status="done", ...).
    """
    db = MagicMock()
    analyze_row = _make_row(101, _analyze_result(), district="Кировский")
    report = MagicMock()
    report.as_dict.return_value = {
        "schema_version": "1.0",
        "confidence": {"level": "medium"},
        "meta": {"segment": {"obj_class": "comfort"}},
    }
    build_mock = MagicMock(return_value=report)
    persist_mock = MagicMock(return_value=888)
    monkeypatch.setattr(
        "app.services.forecasting.orchestrator.build_site_finder_report", build_mock
    )
    monkeypatch.setattr(mod, "persist_analysis_run", persist_mock)

    mod._ensure_forecast_run(db, _CAD, analyze_row, _analyze_result())

    # build_site_finder_report вызван с дефолт-горизонтами таски и district из денорм-колонки.
    build_mock.assert_called_once()
    _, bkwargs = build_mock.call_args
    assert bkwargs["cad_num"] == _CAD
    assert bkwargs["district"] == "Кировский"
    assert list(bkwargs["horizons"]) == [6, 12, 18, 24]
    # persist той же схемой "1.0" + confidence/segment из as_dict.
    persist_mock.assert_called_once()
    _, pkwargs = persist_mock.call_args
    assert pkwargs["schema_version"] == "1.0"
    assert pkwargs["status"] == "done"
    assert pkwargs["confidence"] == "medium"
    assert pkwargs["segment"] == {"obj_class": "comfort"}


def test_ensure_forecast_run_district_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
    """district=None в денорм-колонке → fallback на analyze['district']['district_name']."""
    db = MagicMock()
    analyze_row = _make_row(101, _analyze_result(), district=None)
    analyze = {**_analyze_result(), "district": {"district_name": "Ленинский"}}
    report = MagicMock()
    report.as_dict.return_value = {"schema_version": "1.0"}
    build_mock = MagicMock(return_value=report)
    monkeypatch.setattr(
        "app.services.forecasting.orchestrator.build_site_finder_report", build_mock
    )
    monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1))

    mod._ensure_forecast_run(db, _CAD, analyze_row, analyze)

    _, bkwargs = build_mock.call_args
    assert bkwargs["district"] == "Ленинский"


def test_ensure_forecast_run_failure_is_best_effort(monkeypatch: pytest.MonkeyPatch) -> None:
    """orchestrator бросает → _ensure_forecast_run глотает (warning, НЕ пробрасывает)."""
    db = MagicMock()
    analyze_row = _make_row(101, _analyze_result())
    monkeypatch.setattr(
        "app.services.forecasting.orchestrator.build_site_finder_report",
        MagicMock(side_effect=RuntimeError("§9.x seam failed")),
    )
    persist_mock = MagicMock()
    monkeypatch.setattr(mod, "persist_analysis_run", persist_mock)

    # Не должно бросить наружу — best-effort деградация в отчёт без §4–§6.
    mod._ensure_forecast_run(db, _CAD, analyze_row, _analyze_result())
    persist_mock.assert_not_called()  # compute упал ДО персиста


_MULTIPOLYGON = {
    "type": "MultiPolygon",
    "coordinates": [
        # Мелкий контур (площадь ~0.0001 в градусах²).
        [[[60.60, 56.838], [60.61, 56.838], [60.61, 56.839], [60.60, 56.839], [60.60, 56.838]]],
        # Крупный контур (площадь ~0.00007*0.007 — намеренно шире по обеим осям).
        [[[60.62, 56.840], [60.70, 56.840], [60.70, 56.850], [60.62, 56.850], [60.62, 56.840]]],
    ],
}


def test_largest_polygon_passthrough_for_polygon() -> None:
    """Обычный Polygon → возвращается без изменений (FIX 1: только MultiPolygon трогаем)."""
    assert mod._largest_polygon_geojson(_GEOJSON) is _GEOJSON


def test_largest_polygon_extracts_biggest_contour() -> None:
    """MultiPolygon → GeoJSON Polygon КРУПНЕЙШЕГО контура (FIX 1, эпик #2259)."""
    out = mod._largest_polygon_geojson(_MULTIPOLYGON)
    assert out["type"] == "Polygon"
    # Крупнейший контур — второй (60.62..60.70 × 56.840..56.850): его первая вершина x≈60.62.
    xs = [pt[0] for pt in out["coordinates"][0]]
    assert min(xs) >= 60.62


def test_largest_polygon_unwraps_feature() -> None:
    """Feature-обёртка над MultiPolygon → крупнейший Polygon (FIX 1)."""
    feature = {"type": "Feature", "geometry": _MULTIPOLYGON, "properties": {}}
    out = mod._largest_polygon_geojson(feature)
    assert out["type"] == "Polygon"


def test_largest_polygon_bad_geometry_returns_input() -> None:
    """Битая MultiPolygon-геометрия → best-effort вернуть вход как есть (FIX 1)."""
    broken = {"type": "MultiPolygon", "coordinates": "не координаты"}
    assert mod._largest_polygon_geojson(broken) is broken


def test_concept_multipolygon_does_not_crash(monkeypatch: pytest.MonkeyPatch) -> None:
    """Многоконтурный участок (MultiPolygon) НЕ роняет генерацию концепции (FIX 1).

    Корень прод-бага: concept-стек (_parse_polygon / parse_parcel) принимал только
    Polygon и падал `ParcelGeometryError: expected Polygon, got MultiPolygon`. После
    FIX 1 geom нормализуется к крупнейшему контуру ДО передачи в стек — generate
    получает Polygon-payload и отрабатывает.
    """
    analyze = {"geom_geojson": _MULTIPOLYGON, "egrn": {}}
    captured: dict[str, Any] = {}

    def _fake_generate(payload: Any, **kwargs: Any) -> list[Any]:
        captured["geom_type"] = payload.parcel_geojson.get("type")
        return []

    monkeypatch.setattr("app.services.generative.geometry.generate", _fake_generate)
    monkeypatch.setattr(
        "app.api.v1.concepts._parcel_centroid_wkt", lambda payload: "POINT(60.66 56.845)"
    )
    monkeypatch.setattr(
        "app.api.v1.concepts._lookup_market_price", lambda db, wkt: (None, "class_norm")
    )

    result = mod._generate_concept_result(MagicMock(), analyze)
    # generate вызвана с уже-нормализованным Polygon (не MultiPolygon) → не упала.
    assert captured["geom_type"] == "Polygon"
    assert result == {"variants": []}


def test_cad_safe_whitelist() -> None:
    """_cad_safe: только `[0-9:]`, `:`→`_`; path-traversal/спецсимволы вырезаны (п.4)."""
    assert mod._cad_safe("66:41:0204016:10") == "66_41_0204016_10"
    # Path-traversal / инъекция в путь — всё нецифровое/неколонное вырезается.
    assert mod._cad_safe("../../etc/passwd") == ""
    assert mod._cad_safe("66:41/../x") == "66_41"
    assert mod._cad_safe("66:41:1 ; rm -rf") == "66_41_1"
