"""Unit-тесты серверного рендера карт отчёта — `report_maps` (эпик #2259 PR-C).

Чистые тесты БЕЗ сети: OSM basemap мокается на каждом уровне (contextily.add_basemap
и/или сам `_add_basemap`), чтобы ни один кейс не тянул тайлы. Проверяем:
  • синтетика (участок + риск-зона/ЗОУИТ/красная линия/конкуренты) → валидный PNG (PIL verify);
  • basemap-no-op И basemap-exception → PNG всё равно рендерится (graceful fallback);
  • нет `geom_geojson` → None (нечего рисовать);
  • embed обе ветки (png → <img data-uri>, None → плашка «карта недоступна»);
  • концепция: 2 корпуса с подписью этажности → валидный PNG;
  • битая геометрия отдельного слоя пропускается, не валит всю карту;
  • неожиданное исключение в середине рендера НЕ течёт фигурой (get_fignums без изменений).

Детерминированно, headless (Agg). DATABASE_URL/TESTING выставлены в tests/conftest.py.
"""

from __future__ import annotations

import io
from typing import Any
from unittest import mock

import pytest
from PIL import Image

from app.services.exporters import report_maps as rm

_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"


# ── Синтетика (окрестность ЕКБ, EPSG:4326) ─────────────────────────────────────
def _parcel_geojson() -> dict[str, Any]:
    return {
        "type": "Polygon",
        "coordinates": [
            [
                [60.600, 56.830],
                [60.602, 56.830],
                [60.602, 56.831],
                [60.600, 56.831],
                [60.600, 56.830],
            ]
        ],
    }


def _full_result() -> dict[str, Any]:
    return {
        "geom_geojson": _parcel_geojson(),
        "nspd_risk_zones": [
            {
                "geom_wkt": (
                    "POLYGON((60.6005 56.8305,60.6015 56.8305,"
                    "60.6015 56.8312,60.6005 56.8312,60.6005 56.8305))"
                )
            }
        ],
        "nspd_red_lines": [{"geom_wkt": "LINESTRING(60.600 56.8305, 60.602 56.8305)"}],
        "nspd_opportunity_parcels": [
            {
                "geom_wkt": (
                    "POLYGON((60.6018 56.8302,60.6022 56.8302,"
                    "60.6022 56.8308,60.6018 56.8308,60.6018 56.8302))"
                )
            }
        ],
        "nspd_zouit_overlaps": [
            {
                "geometry_geojson": {
                    "type": "Polygon",
                    "coordinates": [
                        [
                            [60.6008, 56.8302],
                            [60.6018, 56.8302],
                            [60.6018, 56.8309],
                            [60.6008, 56.8309],
                            [60.6008, 56.8302],
                        ]
                    ],
                }
            }
        ],
        "competitors": [
            {"lat": 56.8308, "lng": 60.6012},
            {"lat": 56.8300, "lng": 60.6001},
        ],
    }


def _concept_variant(*, floors: tuple[int, int] = (9, 16)) -> dict[str, Any]:
    def _sq(x0: float, y0: float) -> dict[str, Any]:
        return {
            "type": "Polygon",
            "coordinates": [
                [
                    [x0, y0],
                    [x0 + 0.0004, y0],
                    [x0 + 0.0004, y0 + 0.0004],
                    [x0, y0 + 0.0004],
                    [x0, y0],
                ]
            ],
        }

    return {
        "buildings_geojson": {
            "type": "FeatureCollection",
            "features": [
                {
                    "type": "Feature",
                    "geometry": _sq(60.6002, 56.8302),
                    "properties": {"section_id": 1, "floors": floors[0], "footprint_sqm": 900.0},
                },
                {
                    "type": "Feature",
                    "geometry": _sq(60.6010, 56.8302),
                    "properties": {"section_id": 2, "floors": floors[1], "footprint_sqm": 1200.0},
                },
            ],
        }
    }


def _assert_valid_png(png: bytes | None) -> tuple[int, int]:
    assert png is not None
    assert png[:8] == _PNG_MAGIC
    img = Image.open(io.BytesIO(png))
    img.verify()  # структурная валидация PNG
    return img.size


# ── Кейс 1: полная синтетика + basemap no-op → валидный PNG ─────────────────────
def test_render_parcel_map_all_layers_basemap_noop() -> None:
    """Участок + все слои НСПД + конкуренты, contextily.add_basemap замокан no-op'ом."""
    with mock.patch("contextily.add_basemap") as add_basemap:
        png = rm.render_parcel_map_png(_full_result(), max_px=1200)
    add_basemap.assert_called_once()  # basemap пытались подложить (no-op мок)
    w, h = _assert_valid_png(png)
    assert w > 0 and h > 0


# ── Кейс 2: basemap кидает исключение → graceful fallback, PNG всё равно есть ────
def test_render_parcel_map_basemap_exception_fallback() -> None:
    """Тайлы недоступны (add_basemap raise) → белый фон + слои, PNG рендерится."""
    with mock.patch("contextily.add_basemap", side_effect=RuntimeError("tiles down")):
        png = rm.render_parcel_map_png(_full_result(), max_px=1000)
    _assert_valid_png(png)


# ── Кейс 3: нет geom_geojson → None ─────────────────────────────────────────────
def test_render_parcel_map_no_geom_returns_none() -> None:
    """Пустой payload и payload без geom_geojson → None (нечего рисовать)."""
    assert rm.render_parcel_map_png({}) is None
    assert rm.render_parcel_map_png({"competitors": [{"lat": 56.8, "lng": 60.6}]}) is None
    # битый geom_geojson → тоже None (shape() падает, _geom_from_geojson вернёт None)
    assert rm.render_parcel_map_png({"geom_geojson": {"type": "Nonsense"}}) is None


# ── Кейс 4: битая геометрия отдельного слоя не валит карту ──────────────────────
def test_render_parcel_map_broken_layer_geom_survives() -> None:
    """Мусорный geom_wkt в риск-зоне пропускается, участок всё равно рендерится."""
    result = _full_result()
    result["nspd_risk_zones"] = [{"geom_wkt": "NOT A WKT"}, {"geom_wkt": None}]
    with mock.patch("contextily.add_basemap"):
        png = rm.render_parcel_map_png(result, max_px=900)
    _assert_valid_png(png)


# ── Кейс 5: концепция — 2 корпуса с подписью этажности → валидный PNG ───────────
def test_render_concept_footprint_two_buildings() -> None:
    """buildings_geojson с 2 корпусами + участок-контур → валидный PNG."""
    with mock.patch("contextily.add_basemap"):
        png = rm.render_concept_footprint_png(_concept_variant(), _parcel_geojson(), max_px=1200)
    _assert_valid_png(png)


def test_render_concept_footprint_no_parcel_uses_footprints_bbox() -> None:
    """parcel_geojson=None → bbox по корпусам, PNG всё равно рендерится."""
    with mock.patch("contextily.add_basemap"):
        png = rm.render_concept_footprint_png(_concept_variant(), None, max_px=800)
    _assert_valid_png(png)


def test_render_concept_footprint_empty_returns_none() -> None:
    """Ни участка, ни корпусов → None."""
    assert rm.render_concept_footprint_png({"buildings_geojson": {"features": []}}, None) is None
    assert rm.render_concept_footprint_png({}, None) is None


# ── Кейс 6: embed обе ветки ─────────────────────────────────────────────────────
def test_embed_map_png_with_png_produces_data_uri() -> None:
    """png != None → <img src=data:image/png;base64,…> вместо плейсхолдера."""
    html = "<section>{{MAP_PARCEL}}</section>"
    out = rm.embed_map_png(html, "{{MAP_PARCEL}}", b"\x89PNG\r\n\x1a\nFAKE")
    assert "{{MAP_PARCEL}}" not in out
    assert "data:image/png;base64," in out
    assert "<img" in out


def test_embed_map_png_none_produces_unavailable_placeholder() -> None:
    """png=None → плашка «карта недоступна» (класс map-placeholder)."""
    html = "<section>{{MAP_CONCEPT}}</section>"
    out = rm.embed_map_png(html, "{{MAP_CONCEPT}}", None)
    assert "{{MAP_CONCEPT}}" not in out
    assert "map-placeholder" in out
    assert "недоступна" in out


# ── Кейс 7: basemap timeout → fallback (bounded, без реальной сети) ─────────────
def test_add_basemap_timeout_returns_false() -> None:
    """add_basemap висит дольше таймаута → _add_basemap возвращает False (fallback)."""
    import time

    def _slow(*_a: Any, **_k: Any) -> None:
        time.sleep(5.0)

    ax = object()  # _add_basemap не трогает ax до вызова cx.add_basemap
    with (
        mock.patch("contextily.add_basemap", side_effect=_slow),
        mock.patch.object(rm, "_BASEMAP_TIMEOUT_S", 0.1),
    ):
        assert rm._add_basemap(ax) is False


def test_add_basemap_success_returns_true() -> None:
    """add_basemap отработал в срок → True (тайлы легли)."""
    ax = object()
    with mock.patch("contextily.add_basemap") as add_basemap:
        assert rm._add_basemap(ax) is True
    add_basemap.assert_called_once()


# ── max_px реально ограничивает размер PNG ─────────────────────────────────────
@pytest.mark.parametrize("max_px", [600, 1600])
def test_render_parcel_map_respects_max_px(max_px: int) -> None:
    """Длинная сторона PNG не превышает max_px (dpi подбирается под потолок)."""
    with mock.patch("contextily.add_basemap"):
        png = rm.render_parcel_map_png(_full_result(), max_px=max_px)
    w, h = _assert_valid_png(png)
    # tight-bbox может слегка ужать; проверяем именно верхнюю границу.
    assert max(w, h) <= max_px + 2


# ── Регресс: неожиданное исключение НЕ течёт фигурой (residual-окно pyplot) ─────
# Инжектим raise в _apply_bbox — вызывается в ОБЕИХ функциях ПОСЛЕ создания фигуры
# (plt.figure уже отработал). Без try/finally в _managed_figure упавший рендер оставил
# бы фигуру в глобальном реестре pyplot (get_fignums 0→1) → рост RSS в Celery-воркере.
def test_render_parcel_map_no_figure_leak_on_exception() -> None:
    """Raise в середине parcel-рендера → фигура закрыта, get_fignums без изменений."""
    import matplotlib.pyplot as plt

    before = set(plt.get_fignums())
    with (
        mock.patch("contextily.add_basemap"),
        mock.patch.object(rm, "_apply_bbox", side_effect=RuntimeError("boom mid-render")),
        pytest.raises(RuntimeError, match="boom mid-render"),
    ):
        rm.render_parcel_map_png(_full_result(), max_px=800)
    assert set(plt.get_fignums()) == before  # residual-окно закрыто finally


def test_render_concept_footprint_no_figure_leak_on_exception() -> None:
    """Raise в середине concept-рендера → фигура закрыта, get_fignums без изменений."""
    import matplotlib.pyplot as plt

    before = set(plt.get_fignums())
    with (
        mock.patch("contextily.add_basemap"),
        mock.patch.object(rm, "_apply_bbox", side_effect=RuntimeError("boom mid-render")),
        pytest.raises(RuntimeError, match="boom mid-render"),
    ):
        rm.render_concept_footprint_png(_concept_variant(), _parcel_geojson(), max_px=800)
    assert set(plt.get_fignums()) == before  # residual-окно закрыто finally
