"""Серверный статик-рендер карт для PDF-отчёта ПТИЦА (эпик #2259 PR-C).

Рендерит два вида PNG-карт БЕЗ браузера (headless matplotlib + geopandas + contextily):

  • :func:`render_parcel_map_png` — карта участка (§1/§3): полигон участка (заливка +
    контур), поверх — риск-зоны НСПД / красные линии / ЗОУИТ-пересечения (если геометрии
    присутствуют в persist-payload) + конкуренты точками. Подложка — OSM-тайлы через
    contextily; auto-bbox = участок + буфер.
  • :func:`render_concept_footprint_png` — карта концепции (§7): контур участка + пятна
    застройки (footprint'ы корпусов из ``ConceptVariant.buildings_geojson``) с подписью
    этажности (``properties.floors``).

Плюс :func:`embed_map_png` — подстановка ``<img src="data:image/png;base64,…">`` вместо
строки-плейсхолдера в готовом HTML (``png=None`` → плашка «карта недоступна»).

CRS-контракт: все входные геометрии — EPSG:4326 (GeoJSON ``geom_geojson`` /
``geometry_geojson``, WKT ``geom_wkt`` в 4326, конкуренты lat/lng). Рисуем в Web Mercator
(EPSG:3857), т.к. OSM-тайлы contextily идут в 3857 — иначе подложка не совпадёт со слоями.

GRACEFUL: нет ``geom_geojson`` → ``None`` (нечего рисовать). Тайлы OSM недоступны/таймаут →
белый фон + слои, PNG всё равно возвращается (никогда не роняем экспорт из-за сети).
Битые отдельные геометрии слоёв пропускаются с ``logger.warning``, не валят всю карту.

HEADLESS: ``matplotlib.use("Agg")`` вызывается на уровне модуля ДО импорта pyplot — импорт
``report_maps`` не требует X11/display (запускается в Celery-воркере/Docker без GUI).
"""

from __future__ import annotations

import base64
import io
import logging
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeoutError
from contextlib import contextmanager
from typing import Any

import matplotlib

# Headless-бэкенд ДО импорта pyplot — модуль импортируется в Celery-воркере без display.
matplotlib.use("Agg")

import matplotlib.pyplot as plt
from matplotlib.figure import Figure
from shapely.geometry import Point, shape
from shapely.geometry.base import BaseGeometry

logger = logging.getLogger(__name__)

# ── Константы рендера ──────────────────────────────────────────────────────────
_WGS84 = "EPSG:4326"
_WEB_MERCATOR = "EPSG:3857"

# Буфер вокруг участка при auto-bbox (доля от большей стороны bbox). Даёт «воздух»
# по краям, чтобы контур участка не липнул к рамке и был виден контекст подложки.
_BBOX_PAD_FRAC: float = 0.25

# Таймаут ожидания OSM-тайлов (сек). Тайлы тянутся синхронно внутри contextily —
# оборачиваем в поток с bounded-таймаутом, чтобы медленный/недоступный tile-server
# не подвешивал воркер. Превышение → graceful fallback на белый фон.
_BASEMAP_TIMEOUT_S: float = 10.0

# Базовый физический размер фигуры (дюймы). Итоговый пиксель-размер = fig_inches * dpi,
# ограничивается сверху ``max_px`` пересчётом dpi (см. ``_figure_to_png``).
_FIG_INCHES: float = 6.4

# Палитра слоёв (edgecolor, facecolor, alpha) — консистентно с семантикой отчёта.
_PARCEL_STYLE = {"edgecolor": "#1d4ed8", "facecolor": "#3b82f6", "alpha": 0.28, "linewidth": 1.8}
_RISK_STYLE = {"edgecolor": "#b91c1c", "facecolor": "#ef4444", "alpha": 0.30, "linewidth": 1.0}
_REDLINE_STYLE = {"color": "#dc2626", "linewidth": 1.6, "linestyle": "--"}
_ZOUIT_STYLE = {"edgecolor": "#b45309", "facecolor": "#f59e0b", "alpha": 0.22, "linewidth": 1.0}
_OPPORTUNITY_STYLE = {
    "edgecolor": "#15803d",
    "facecolor": "#22c55e",
    "alpha": 0.20,
    "linewidth": 0.9,
}
_COMPETITOR_STYLE = {"color": "#7c3aed", "marker": "o", "markersize": 5.0, "linestyle": "none"}
_CONCEPT_FOOTPRINT_STYLE = {
    "edgecolor": "#0f766e",
    "facecolor": "#14b8a6",
    "alpha": 0.55,
    "linewidth": 1.2,
}

_MAP_UNAVAILABLE_HTML = (
    '<div class="map-placeholder">Карта недоступна — геоданные участка отсутствуют '
    "в отчёте</div>"
)


# ── Парсинг входных геометрий (EPSG:4326) ──────────────────────────────────────
def _geom_from_geojson(geojson: dict[str, Any] | None) -> BaseGeometry | None:
    """GeoJSON-geometry dict → shapely-geометрия (EPSG:4326). Битый вход → None+warning."""
    if not isinstance(geojson, dict):
        return None
    try:
        geom = shape(geojson)
    except Exception as exc:  # любой битый GeoJSON: пропустить слой, не валить всю карту
        logger.warning("report_maps: не разобрал GeoJSON-геометрию: %s", exc)
        return None
    if geom.is_empty:
        return None
    return geom


def _geom_from_wkt(wkt: str | None) -> BaseGeometry | None:
    """WKT-строка (EPSG:4326) → shapely-геометрия. Битый вход → None+warning."""
    if not wkt or not isinstance(wkt, str):
        return None
    try:
        from shapely import wkt as shapely_wkt

        geom = shapely_wkt.loads(wkt)
    except Exception as exc:  # битый WKT слоя: пропустить геометрию, не валить всю карту
        logger.warning("report_maps: не разобрал WKT-геометрию: %s", exc)
        return None
    if geom.is_empty:
        return None
    return geom


def _to_mercator(geoms: list[BaseGeometry]) -> Any:
    """Список shapely-геометрий (EPSG:4326) → GeoSeries в Web Mercator (EPSG:3857).

    Импорт geopandas — локальный (тяжёлый), вызывается только когда есть что рисовать.
    """
    import geopandas as gpd

    series = gpd.GeoSeries(geoms, crs=_WGS84)
    return series.to_crs(_WEB_MERCATOR)


# ── Basemap (OSM-тайлы) с bounded-таймаутом и graceful fallback ────────────────
def _add_basemap(ax: Any) -> bool:
    """Подложить OSM-тайлы под слои с таймаутом. True — легли, False — fallback (белый фон).

    contextily тянет тайлы синхронно (сеть) — оборачиваем в поток с таймаутом, чтобы
    недоступный tile-сервер не подвешивал воркер. Любая ошибка/таймаут → белый фон,
    слои остаются видны, PNG всё равно рендерится.
    """
    import contextily as cx

    def _fetch() -> None:
        cx.add_basemap(ax, crs=_WEB_MERCATOR, source=cx.providers.OpenStreetMap.Mapnik)

    try:
        with ThreadPoolExecutor(max_workers=1) as pool:
            pool.submit(_fetch).result(timeout=_BASEMAP_TIMEOUT_S)
        return True
    except FuturesTimeoutError:
        logger.warning(
            "report_maps: OSM basemap timeout (%.0fs) — fallback белый фон", _BASEMAP_TIMEOUT_S
        )
        return False
    except Exception as exc:  # тайлы недоступны: graceful fallback на белый фон, не валим экспорт
        logger.warning("report_maps: OSM basemap недоступен (%s) — fallback белый фон", exc)
        return False


# ── Общие хелперы фигуры ───────────────────────────────────────────────────────
@contextmanager
def _managed_figure() -> Iterator[tuple[Figure, Any]]:
    """Контекст-менеджер фигуры: создать axes, ГАРАНТИРОВАННО закрыть в finally.

    pyplot держит созданные фигуры в глобальном реестре (``plt.get_fignums()``) до
    явного ``plt.close``. В долгоживущем Celery-воркере любое исключение между
    созданием фигуры и ``savefig`` без close оставляло бы residual-окно → растущий
    RSS (утечка на 1 фигуру за каждый упавший рендер). ``finally`` закрывает фигуру
    на ЛЮБОМ пути выхода — успех, per-geometry-ошибка, неожиданный raise.
    """
    fig = plt.figure(figsize=(_FIG_INCHES, _FIG_INCHES))
    try:
        ax = fig.add_subplot(1, 1, 1)
        ax.set_axis_off()
        ax.set_aspect("equal")
        yield fig, ax
    finally:
        plt.close(fig)


def _apply_bbox(ax: Any, parcel_merc: Any) -> None:
    """Выставить границы axes по bbox участка + буфер (``_BBOX_PAD_FRAC``)."""
    minx, miny, maxx, maxy = parcel_merc.total_bounds
    span = max(maxx - minx, maxy - miny) or 1.0
    pad = span * _BBOX_PAD_FRAC
    ax.set_xlim(minx - pad, maxx + pad)
    ax.set_ylim(miny - pad, maxy + pad)


def _figure_to_png(fig: Figure, max_px: int) -> bytes:
    """Фигуру → PNG-bytes с dpi, подобранным так, чтобы длинная сторона ≤ ``max_px``.

    Закрытие фигуры — НЕ здесь: им владеет ``_managed_figure`` (finally покрывает и
    путь, где ``savefig`` сам кинул исключение).
    """
    dpi = max(72.0, min(300.0, float(max_px) / _FIG_INCHES))
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", pad_inches=0.05)
    return buf.getvalue()


# ── Публичный API ──────────────────────────────────────────────────────────────
def render_parcel_map_png(analyze_result: dict[str, Any], *, max_px: int = 1600) -> bytes | None:
    """Карта участка + слои НСПД + конкуренты → PNG-bytes. Нет ``geom_geojson`` → None.

    Args:
        analyze_result: persist-payload ``analysis_runs.result`` (schema analyze-1.0).
            Читаемые ключи с геометрией: ``geom_geojson`` (обязателен), ``nspd_risk_zones``,
            ``nspd_red_lines``, ``nspd_opportunity_parcels`` (WKT ``geom_wkt``),
            ``nspd_zouit_overlaps`` (``geometry_geojson``), ``competitors`` (``lat``/``lng``).
        max_px: верхняя граница длинной стороны PNG в пикселях.

    Returns:
        PNG-bytes, либо None если геометрии участка нет (нечего рисовать).
    """
    parcel = _geom_from_geojson(analyze_result.get("geom_geojson"))
    if parcel is None:
        return None

    with _managed_figure() as (fig, ax):
        # Участок — базовый слой, задаёт bbox.
        parcel_merc = _to_mercator([parcel])
        parcel_merc.plot(ax=ax, **_PARCEL_STYLE)
        _apply_bbox(ax, parcel_merc)

        # Риск-зоны (WKT).
        _plot_wkt_layer(ax, analyze_result.get("nspd_risk_zones"), _RISK_STYLE)
        # ЗОУИТ-пересечения (GeoJSON geometry_geojson).
        _plot_geojson_layer(ax, analyze_result.get("nspd_zouit_overlaps"), _ZOUIT_STYLE)
        # Opportunity-участки (WKT).
        _plot_wkt_layer(ax, analyze_result.get("nspd_opportunity_parcels"), _OPPORTUNITY_STYLE)
        # Красные линии (WKT, линии — без заливки: _REDLINE_STYLE несёт color, не facecolor).
        _plot_wkt_layer(ax, analyze_result.get("nspd_red_lines"), _REDLINE_STYLE)
        # Конкуренты — точки lat/lng.
        _plot_competitors(ax, analyze_result.get("competitors"))

        _add_basemap(ax)  # graceful: False → просто белый фон, PNG всё равно вернётся
        return _figure_to_png(fig, max_px)


def render_concept_footprint_png(
    concept_variant: dict[str, Any],
    parcel_geojson: dict[str, Any] | None,
    *,
    max_px: int = 1600,
) -> bytes | None:
    """Карта концепции: контур участка + пятна корпусов с подписью этажности → PNG-bytes.

    Args:
        concept_variant: dict-представление ``ConceptVariant`` с ключом ``buildings_geojson``
            (FeatureCollection, ``properties.floors`` / ``footprint_sqm`` / ``section_id``).
        parcel_geojson: GeoJSON-геометрия участка (контур-подложка). None → рисуем только
            корпуса (bbox по ним).
        max_px: верхняя граница длинной стороны PNG в пикселях.

    Returns:
        PNG-bytes, либо None если нет ни участка, ни корпусов (нечего рисовать).
    """
    fc = concept_variant.get("buildings_geojson") if isinstance(concept_variant, dict) else None
    features = fc.get("features", []) if isinstance(fc, dict) else []

    footprints: list[BaseGeometry] = []
    floors_labels: list[int | None] = []
    for feat in features:
        if not isinstance(feat, dict):
            continue
        geom = _geom_from_geojson(feat.get("geometry"))
        if geom is None:
            continue
        footprints.append(geom)
        props = feat.get("properties") or {}
        raw_floors = props.get("floors") if isinstance(props, dict) else None
        floors_labels.append(int(raw_floors) if isinstance(raw_floors, int | float) else None)

    parcel = _geom_from_geojson(parcel_geojson)
    if parcel is None and not footprints:
        return None

    with _managed_figure() as (fig, ax):
        # bbox: по участку если есть, иначе по совокупности footprint'ов.
        bbox_source = _to_mercator([parcel]) if parcel is not None else _to_mercator(footprints)

        if parcel is not None:
            # Участок — только контур (без заливки), корпуса рисуются поверх.
            _to_mercator([parcel]).plot(ax=ax, facecolor="none", edgecolor="#1d4ed8", linewidth=1.8)

        if footprints:
            fp_merc = _to_mercator(footprints)
            fp_merc.plot(ax=ax, **_CONCEPT_FOOTPRINT_STYLE)
            # Подпись этажности в центроиде каждого корпуса.
            for geom_merc, floors in zip(fp_merc.geometry, floors_labels, strict=True):
                if floors is None:
                    continue
                c = geom_merc.centroid
                ax.annotate(
                    f"{floors} эт.",
                    (c.x, c.y),
                    ha="center",
                    va="center",
                    fontsize=7.0,
                    color="#0f172a",
                )

        _apply_bbox(ax, bbox_source)
        _add_basemap(ax)
        return _figure_to_png(fig, max_px)


def embed_map_png(html: str, placeholder: str, png: bytes | None) -> str:
    """Заменить строку-``placeholder`` в HTML на ``<img>`` с data-URI PNG.

    ``png=None`` → плашка «карта недоступна» (тот же класс ``.map-placeholder``, что и
    в full_report_html — стиль уже есть в шаблоне).
    """
    if png is None:
        return html.replace(placeholder, _MAP_UNAVAILABLE_HTML)
    b64 = base64.b64encode(png).decode("ascii")
    img = (
        f'<img class="report-map" src="data:image/png;base64,{b64}" '
        'style="width:100%;max-width:100%;height:auto;display:block;margin:8pt 0;'
        'border:1px solid #e5e7eb;border-radius:6pt;" alt="Карта участка" />'
    )
    return html.replace(placeholder, img)


# ── Приватные слой-плоттеры ────────────────────────────────────────────────────
def _plot_wkt_layer(ax: Any, items: Any, style: dict[str, Any]) -> None:
    """Отрисовать слой из списка dict'ов с ключом ``geom_wkt`` (EPSG:4326).

    Заливка/линия задаётся ``style`` (наличие ``facecolor`` → полигон, ``color`` без
    ``facecolor`` → линия) — geopandas ``plot`` разбирает это сам по типу геометрии.
    """
    geoms = _collect_wkt_geoms(items)
    if not geoms:
        return
    _to_mercator(geoms).plot(ax=ax, **style)


def _plot_geojson_layer(ax: Any, items: Any, style: dict[str, Any]) -> None:
    """Отрисовать слой из списка dict'ов с ключом ``geometry_geojson`` (EPSG:4326)."""
    if not isinstance(items, list):
        return
    geoms = [
        g
        for it in items
        if isinstance(it, dict)
        and (g := _geom_from_geojson(it.get("geometry_geojson"))) is not None
    ]
    if not geoms:
        return
    _to_mercator(geoms).plot(ax=ax, **style)


def _collect_wkt_geoms(items: Any) -> list[BaseGeometry]:
    """Собрать shapely-геометрии из списка dict'ов по ключу ``geom_wkt``."""
    if not isinstance(items, list):
        return []
    out: list[BaseGeometry] = []
    for it in items:
        if not isinstance(it, dict):
            continue
        geom = _geom_from_wkt(it.get("geom_wkt"))
        if geom is not None:
            out.append(geom)
    return out


def _plot_competitors(ax: Any, competitors: Any) -> None:
    """Отрисовать конкурентов точками из ``lat``/``lng`` (EPSG:4326).

    Внимание: у ``Competitor`` долгота в ключе ``lng`` (не ``lon``) — см. schemas/parcel.py.
    """
    if not isinstance(competitors, list):
        return
    pts: list[BaseGeometry] = []
    for c in competitors:
        if not isinstance(c, dict):
            continue
        lat, lng = c.get("lat"), c.get("lng")
        if isinstance(lat, int | float) and isinstance(lng, int | float):
            pts.append(Point(float(lng), float(lat)))
    if not pts:
        return
    _to_mercator(pts).plot(ax=ax, **_COMPETITOR_STYLE)
