"""Оркестратор полного PDF-отчёта ПТИЦА (эпик #2259 PR-D).

Собирает единый PDF `/site-finder/analysis/{cad}` из уже-персистнутых ранов + дотянутых
на лету блоков (connection-capacity, концепция), рендерит карты (PR-C) и пишет файл на
volume `/app/reports/` с метадата-строкой в `analysis_runs` (schema `report-pdf-1.0`).

ПОТОК (:func:`build_full_report`):
  1. analyze-ран (`latest_run_for(..., schema_version=ANALYZE_SCHEMA_VERSION)`) — нет →
     ValueError (отчёт без базового анализа бессмысленен).
  2. forecast-ран (`latest_run_for(..., schema_version="1.0")`) — нет → best-effort
     СИНХРОННО считаем его тут же (`_ensure_forecast_run`, зеркало Celery-таски форсайта,
     ~20–30с) и перечитываем; всё ещё нет (сбой/тонкие данные) → Part B (§4–§6) деградирует
     «нет данных», отчёт всё равно валиден (передаём {} в part_b).
  3. КЭШ-ключ = (analyze_run_id, forecast_run_id). Если метадата-ран `report-pdf-1.0` с
     теми же id уже есть И файл на месте → cache-hit, PDF не пере-рендерим.
  4. connection-capacity (`get_connection_capacity`) — best-effort, для §3-резервов.
  5. концепция — server-side генерация (как POST /concepts): `ConceptInput` из geom
     участка (дефолт count=3 comfort) → `geometry.generate`. Best-effort: упала/нет geom
     → отчёт без §7-концепции (§5 деградирует в рыночный контекст).
  6. HTML (PR-A/B) + карты (PR-C: `render_parcel_map_png` / `render_concept_footprint_png`
     → `embed_map_png`, PNG max_px=1400) → PDF (:func:`render_full_report_pdf`).
  6b. DOCX-вариант (PR-F, `build_full_report_docx`) из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ
     карт-PNG (НЕ рендерим карты дважды) — рядом `.docx`-файл.
  7. Запись файлов (PDF + DOCX, атомарно tmp+os.replace) + метадата-ран `report-pdf-1.0`
     (result = pdf_path/docx_path/analyze_run_id/forecast_run_id/generated_at/size_bytes/
     docx_size_bytes). Старые раны без docx_path → download?format=docx отдаёт 404.

WeasyPrint импортируется ЛОКАЛЬНО внутри :func:`render_full_report_pdf` (тяжёлый native —
ломает pytest-сбор на хостах без GTK/Pango; образец `layout_tz_pdf.render_layout_tz_pdf`).
"""

from __future__ import annotations

import datetime as _dt
import logging
import os
import re
from pathlib import Path
from typing import Any

from sqlalchemy.orm import Session

from app.core.config import settings
from app.services.analysis_runs.repository import (
    ANALYZE_SCHEMA_VERSION,
    latest_run_for,
    persist_analysis_run,
)
from app.services.exporters.full_report_docx import build_full_report_docx
from app.services.exporters.full_report_html import (
    MAP_CONCEPT_PLACEHOLDER,
    MAP_PARCEL_PLACEHOLDER,
    build_full_report_html,
    build_full_report_html_part_a,
    build_full_report_html_part_b,
)
from app.services.exporters.report_maps import (
    embed_map_png,
    render_concept_footprint_png,
    render_parcel_map_png,
)

logger = logging.getLogger(__name__)

# schema_version метадата-строки отчёта в analysis_runs. Собственная схема (НЕ analyze /
# НЕ §22-форсайт "1.0") — по ней ищем существующий отчёт для cache-hit.
REPORT_SCHEMA_VERSION = "report-pdf-1.0"

# schema_version §22-форсайт-рана (зеркало `_FORECAST_SCHEMA_VERSION` в parcels.py — НЕ
# "forecast-1.0", а именно "1.0", это SiteFinderReport._SCHEMA_VERSION).
_FORECAST_SCHEMA_VERSION = "1.0"

# Горизонты best-effort синхронного форсайта (мес). Зеркало Celery-таски
# `forecast_site_finder_report(horizon=12)`: `_horizons_for(12)` = sorted({6,12,18,24}|{12})
# = [6,12,18,24] (forecast.py) = orchestrator._DEFAULT_HORIZONS. Держим тот же набор,
# чтобы «холодный» участок получил §4–§6 идентичные ленивому GET /forecast-пути.
_FORECAST_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)

# Верхняя граница длинной стороны карт-PNG (px) — печатный A4, 1400 достаточно для
# ~150 dpi на ширину колонки, но не раздувает PDF гигабайтными растрами.
_MAP_MAX_PX = 1400

# Радиус поиска ЦП для connection-capacity (м) — тот же дефолт, что у эндпоинта
# GET /{cad}/connection-capacity (parcels.py) и функции get_connection_capacity.
_CONNECTION_CAPACITY_RADIUS_M = 3000


def render_full_report_pdf(html: str) -> bytes:
    """Отрендерить готовый HTML полного отчёта в PDF-байты (WeasyPrint).

    Тонкая обёртка: WeasyPrint импортируется ЛОКАЛЬНО (тяжёлый native — не нужен при
    импорте модуля, иначе ломает pytest-сбор на macOS без GTK/Pango). Образец —
    `layout_tz_pdf.render_layout_tz_pdf`.

    Args:
        html: полный HTML-документ (`build_full_report_html`).

    Returns:
        PDF-байты (готовы к записи файла / FileResponse).
    """
    from weasyprint import HTML

    pdf_bytes: bytes = HTML(string=html).write_pdf()
    logger.info("render_full_report_pdf: %d bytes", len(pdf_bytes))
    return pdf_bytes


def _largest_polygon_geojson(geom: dict[str, Any]) -> dict[str, Any]:
    """MultiPolygon → GeoJSON крупнейшего полигона-контура; Polygon и прочее — как есть.

    Многоконтурный участок приходит как MultiPolygon, а concept-стек (`parse_parcel` +
    `_parcel_centroid_wkt`) принимает только Polygon → `ParcelGeometryError`. Берём
    контур с максимальной площадью (тот же приём, что generative-геометрия применяет к
    buildable-мультиполигону после буфера, geometry.py:263-265). Любой сбой парсинга /
    не-MultiPolygon → возвращаем geom без изменений (best-effort, не роняем концепцию).

    Args:
        geom: GeoJSON-геометрия участка (Polygon / MultiPolygon / Feature-обёртка).

    Returns:
        GeoJSON Polygon крупнейшего контура (если вход был MultiPolygon), иначе `geom`.
    """
    geom_dict = geom.get("geometry") if geom.get("type") == "Feature" else geom
    if not isinstance(geom_dict, dict) or geom_dict.get("type") != "MultiPolygon":
        return geom

    try:
        from shapely.geometry import mapping, shape

        multi = shape(geom_dict)
        if multi.is_empty or not hasattr(multi, "geoms"):
            return geom
        largest = max(multi.geoms, key=lambda g: g.area)
        logger.info(
            "build_full_report: multi-contour участок — взят крупнейший контур из %d",
            len(list(multi.geoms)),
        )
        return dict(mapping(largest))
    except Exception:
        # Вырожденная/битая геометрия — отдаём как есть, concept-стек сам решит (best-effort).
        logger.exception("build_full_report: не удалось выделить крупнейший контур MultiPolygon")
        return geom


def _generate_concept_result(db: Session, analyze: dict[str, Any]) -> dict[str, Any] | None:
    """Сгенерировать концепцию server-side как это делает POST /concepts (best-effort).

    Концепция НЕ персистится — генерируем на лету из геометрии участка с ДЕФОЛТНОЙ
    программой (count=3 comfort — три стратегии жадной раскладки). Повторяем поток
    `create_concept` (concepts.py): рыночная цена через `_lookup_market_price` по
    центроиду → `geometry.generate` → сериализуем `ConceptOutput`. Любая ошибка (нет
    geom / вырожденный участок / сбой БД) → None: отчёт валиден без §7-концепции,
    §5 деградирует в рыночный контекст.

    Args:
        db: SQLAlchemy session (для lookup рыночной цены).
        analyze: persist-payload analyze-рана (несёт `geom_geojson` участка).

    Returns:
        Сериализованный `ConceptOutput` (dict `{"variants": [...]}`) или None.
    """
    geom = analyze.get("geom_geojson")
    if not isinstance(geom, dict):
        logger.info("build_full_report: analyze-payload без geom_geojson → §7-концепция пропущена")
        return None

    # Многоконтурный участок → geom = MultiPolygon, а concept-стек (parse_parcel +
    # _parcel_centroid_wkt через _parse_polygon) принимает ТОЛЬКО Polygon и роняет
    # ParcelGeometryError("expected Polygon, got MultiPolygon"). Берём крупнейший контур —
    # ровно как generative-геометрия после буфера (geometry.py:263-265). Иначе §7 и
    # market-price молча деградируют на любом мультиконтуре.
    geom = _largest_polygon_geojson(geom)

    try:
        # Lazy import — тяжёлый generative-стек не нужен на module-load; concepts-хелперы
        # цены живут в API-слое (он знает БД), переиспользуем ИМЕННО их (single source).
        from app.api.v1.concepts import _lookup_market_price, _parcel_centroid_wkt
        from app.schemas.concept import ConceptInput, ConceptOutput
        from app.services.generative import geometry

        payload = ConceptInput(parcel_geojson=geom)  # дефолт: housing_class="comfort", 3 стратегии

        market_price: float | None = None
        price_source = "class_norm"
        try:
            wkt_point = _parcel_centroid_wkt(payload)
            market_price, price_source = _lookup_market_price(db, wkt_point)
        except Exception as exc:
            # Рыночная цена best-effort — деградируем в норматив класса, не роняем концепцию.
            logger.warning("build_full_report: market-price lookup упал, class_norm: %s", exc)

        variants = geometry.generate(
            payload,
            market_price_per_sqm=market_price,
            price_source=price_source,
        )
        result: dict[str, Any] = ConceptOutput(variants=variants).model_dump()
        logger.info("build_full_report: концепция сгенерирована (%d вариантов)", len(variants))
        return result
    except Exception:
        # Вырожденный участок / сбой generative-стека — отчёт валиден БЕЗ §7-концепции.
        logger.exception("build_full_report: генерация концепции упала → §7 без концепции")
        return None


def _get_connection_capacity(db: Session, cad: str) -> dict[str, Any] | None:
    """Дотянуть connection-capacity для §3-резервов (best-effort).

    connection-capacity НЕ в persist-payload (ленивый эндпоинт) — тянем `get_connection_capacity`
    как это делает GET /{cad}/connection-capacity. Ошибка (участок без geom / сбой БД) → None:
    §3 деградирует к тому, что есть в analyze-payload (OSM-сети + НСПД-сооружения).
    """
    try:
        from app.services.site_finder.connection_capacity_lookup import get_connection_capacity

        return get_connection_capacity(db, cad, _CONNECTION_CAPACITY_RADIUS_M)
    except Exception:
        logger.exception("build_full_report: connection-capacity lookup упал → §3 без резервов")
        return None


def _ensure_forecast_run(
    db: Session,
    cad: str,
    analyze_row: Any,
    analyze: dict[str, Any],
) -> None:
    """Best-effort синхронно посчитать §22-форсайт для «холодного» участка (#2259 gap).

    §22-форсайт-ран ("1.0") существует ТОЛЬКО если пользователь открывал страницу участка
    (§22 строится лениво GET /forecast-поллингом Celery-таской `forecast_site_finder_report`).
    На холодном участке полный отчёт выходил БЕЗ §4–§6. Здесь — тот же compute+persist, что
    делает Celery-таска, но СИНХРОННО и ПРЯМЫМ вызовом: мы УЖЕ внутри worker'а
    (build_full_report_task), поэтому НЕ .delay — считаем inline (~20–30с) и персистим тем же
    контрактом, чтобы последующий `latest_run_for("1.0")` в build_full_report поймал свежий ран
    (его новый id корректно войдёт в кэш-ключ отчёта → cache-miss → §4–§6 попадут в PDF).

    Зеркало `forecast_site_finder_report(horizon=12)` (workers/tasks/forecast.py):
      • horizons = `_FORECAST_HORIZONS` (= `_horizons_for(12)` = [6,12,18,24]);
      • district = денорм-колонка рана → fallback analyze["district"]["district_name"];
      • `build_site_finder_report(...)` → `report.as_dict()` → `persist_analysis_run(...,
        schema_version=d["schema_version"], status="done", ...)`.

    Best-effort: ЛЮБОЙ сбой/долгий compute → logger.warning + return (НЕ exception — форсайт
    может честно не собраться на тонких данных, GlitchTip-шум не нужен). Тогда отчёт, как и
    раньше, выйдет без §4–§6 (part_b деградирует «нет данных»).

    Args:
        db: SQLAlchemy session (та же, что у build_full_report — свою НЕ открываем).
        cad: кадастровый номер участка.
        analyze_row: Row analyze-рана (несёт денорм `district`).
        analyze: persist-payload analyze-рана (district-fallback + competitors для сегмента).
    """
    try:
        # Lazy import — тяжёлый forecasting-стек не нужен на module-load (как concept-стек).
        from app.services.forecasting.orchestrator import build_site_finder_report

        district = analyze_row.district or (analyze.get("district") or {}).get("district_name")
        logger.info(
            "build_full_report: холодный участок cad=%s — best-effort синхронный §22-форсайт "
            "(district=%s horizons=%s)",
            cad,
            district,
            _FORECAST_HORIZONS,
        )
        report = build_site_finder_report(
            db,
            analyze=analyze,
            cad_num=cad,
            district=district,
            horizons=_FORECAST_HORIZONS,
        )
        d = report.as_dict()
        new_id = persist_analysis_run(
            db,
            cad_num=cad,
            result=d,
            params={"horizon": 12, "source": "full-report-inline-forecast"},
            district=district,
            confidence=(d.get("confidence") or {}).get("level"),
            status="done",
            schema_version=d["schema_version"],  # "1.0" (SiteFinderReport._SCHEMA_VERSION)
            created_by=None,
            segment=(d.get("meta") or {}).get("segment"),
        )
        logger.info("build_full_report: §22-форсайт посчитан inline cad=%s run_id=%s", cad, new_id)
    except Exception:
        # Форсайт может честно не собраться (тонкие данные / сбой §9.x-шва) или занять
        # слишком долго — деградируем в отчёт без §4–§6 (warning, НЕ exception: держим
        # GlitchTip-шум в узде, ведёт себя как ленивая Celery-таска, которая тоже best-effort).
        logger.warning(
            "build_full_report: inline §22-форсайт не собрался cad=%s → отчёт без §4–§6",
            cad,
            exc_info=True,
        )


def _find_cached_report(
    db: Session,
    cad: str,
    analyze_run_id: int | None,
    forecast_run_id: int | None,
) -> dict[str, Any] | None:
    """Найти готовый метадата-ран `report-pdf-1.0` с тем же кэш-ключом + существующим файлом.

    Кэш-ключ = (analyze_run_id, forecast_run_id). Совпали ОБА id И файл `pdf_path` на
    месте → возвращаем result метадата-рана (cache-hit, не пере-рендерим). Иначе None.
    """
    row = latest_run_for(db, cad, schema_version=REPORT_SCHEMA_VERSION)
    if row is None:
        return None
    result = row.result if isinstance(row.result, dict) else {}
    if (
        result.get("analyze_run_id") == analyze_run_id
        and result.get("forecast_run_id") == forecast_run_id
    ):
        pdf_path = result.get("pdf_path")
        if isinstance(pdf_path, str) and os.path.exists(pdf_path):
            return result
    return None


def _cad_safe(cad: str) -> str:
    """Кад.номер → безопасный компонент имени файла (whitelist).

    Кад.номер приходит из URL-path — жёстко ограничиваем алфавит `[0-9:]` (цифры +
    разделители кад.номера РФ), ВСЁ прочее вырезаем, затем `:` → `_`. Так в имя файла
    физически не попадут `/`, `..`, пробелы или спецсимволы (path-traversal / инъекция
    в путь), даже если path-param окажется мусорным.
    """
    return re.sub(r"[^0-9:]", "", cad).replace(":", "_")


def _atomic_write(path: Path, data: bytes) -> None:
    """Атомарно записать байты в `path`: `.<pid>.tmp` рядом → `os.replace`.

    Два конкурентных POST в один день целятся в ОДИН путь (имя несёт только дату) —
    прямой `write_bytes` мог бы interleave-писать байты обоих рендеров в один файл
    (битый вывод). `os.replace` атомарен в пределах одной FS → download всегда видит
    целый файл (свой или чужой). Общий для PDF и DOCX (тот же приём).
    """
    tmp_path = path.with_suffix(f".{os.getpid()}.tmp")
    tmp_path.write_bytes(data)
    os.replace(tmp_path, path)


def build_full_report(db: Session, cad: str) -> dict[str, Any]:
    """Собрать (или вернуть из кэша) полный PDF-отчёт участка + метадата-ран. #2259 PR-D.

    Оркестрация: analyze-ран (нет → ValueError) → forecast-ран (нет → Part B «нет данных»)
    → cache-check по (analyze_run_id, forecast_run_id) → connection-capacity (§3-резервы,
    best-effort) → концепция server-side (best-effort) → HTML (PR-A/B) + карты (PR-C) →
    PDF (WeasyPrint) → запись файла на volume + метадата-ран `report-pdf-1.0`.

    Args:
        db: SQLAlchemy session.
        cad: кадастровый номер участка.

    Returns:
        Метадата отчёта: {pdf_path, analyze_run_id, forecast_run_id, generated_at,
        size_bytes} (+ `cache_hit: True` если вернули существующий файл).

    Raises:
        ValueError: нет analyze-рана для участка (нечего отчётировать).
    """
    analyze_row = latest_run_for(db, cad, schema_version=ANALYZE_SCHEMA_VERSION)
    if analyze_row is None:
        raise ValueError(f"нет analyze-рана для {cad!r} — сначала запустите /analyze")
    analyze: dict[str, Any] = analyze_row.result or {}
    analyze_run_id = int(analyze_row.id)

    forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION)
    if forecast_row is None:
        # Холодный участок: §22-форсайт-ран ("1.0") строится лениво GET /forecast-поллингом
        # и на не-открытом участке отсутствует → отчёт выходил без §4–§6. Best-effort
        # считаем его СИНХРОННО прямо тут (мы в worker'е) и перечитываем — самодостаточность
        # отчёта важнее +20–30с (как §7-концепция генерится в оркестраторе). Сбой/долго →
        # forecast_row остаётся None, part_b деградирует «нет данных» (как раньше).
        _ensure_forecast_run(db, cad, analyze_row, analyze)
        forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION)
    forecast: dict[str, Any] = (forecast_row.result or {}) if forecast_row is not None else {}
    forecast_run_id = int(forecast_row.id) if forecast_row is not None else None

    # Cache-hit: тот же (analyze, forecast) ран-ключ + файл на месте → не пере-рендерим.
    cached = _find_cached_report(db, cad, analyze_run_id, forecast_run_id)
    if cached is not None:
        logger.info(
            "build_full_report: cache-hit cad=%s analyze=%s forecast=%s path=%s",
            cad,
            analyze_run_id,
            forecast_run_id,
            cached.get("pdf_path"),
        )
        return {**cached, "cache_hit": True}

    # connection-capacity (§3-резервы) + концепция (§7) — оба best-effort.
    connection_capacity = _get_connection_capacity(db, cad)
    concept = _generate_concept_result(db, analyze)

    # Адрес участка для титула (egrn.address; None → строка адреса не рисуется).
    egrn = analyze.get("egrn") if isinstance(analyze.get("egrn"), dict) else {}
    address = egrn.get("address") if isinstance(egrn, dict) else None
    generated_at = _dt.datetime.now(_dt.UTC)
    generated_at_ru = generated_at.strftime("%d.%m.%Y")

    # HTML (PR-A/B): §1–§3 (+ connection-capacity в §3) и §4–§7 (пустой forecast → «нет данных»).
    part_a = build_full_report_html_part_a(
        analyze, cad=cad, connection_capacity=connection_capacity
    )
    part_b = build_full_report_html_part_b(forecast, concept, cad=cad, analyze_result=analyze)
    html = build_full_report_html(
        part_a,
        part_b,
        cad=cad,
        address=address if isinstance(address, str) else None,
        generated_at=generated_at_ru,
    )

    # Карты (PR-C): участок (§1/§3) + концепция (§7, лучший вариант). Оба graceful:
    # None → embed_map_png ставит плашку «карта недоступна».
    parcel_png = render_parcel_map_png(analyze, max_px=_MAP_MAX_PX)
    html = embed_map_png(html, MAP_PARCEL_PLACEHOLDER, parcel_png)

    concept_png: bytes | None = None
    if isinstance(concept, dict):
        variants = concept.get("variants")
        if isinstance(variants, list) and variants and isinstance(variants[0], dict):
            concept_png = render_concept_footprint_png(
                variants[0], analyze.get("geom_geojson"), max_px=_MAP_MAX_PX
            )
    html = embed_map_png(html, MAP_CONCEPT_PLACEHOLDER, concept_png)

    pdf_bytes = render_full_report_pdf(html)

    # DOCX-вариант (PR-F): из ТЕХ ЖЕ исходных словарей + ТЕХ ЖЕ карт-PNG (parcel_png /
    # concept_png уже отрендерены выше — НЕ рендерим карты дважды). Зеркалит §1–§7 PDF.
    docx_bytes = build_full_report_docx(
        analyze,
        forecast,
        concept,
        connection_capacity,
        cad=cad,
        address=address if isinstance(address, str) else None,
        generated_at=generated_at_ru,
        parcel_map_png=parcel_png,
        concept_map_png=concept_png,
    )

    # Запись файлов на volume + метадата-ран. Каталог создаём (parents, exist_ok).
    reports_dir = Path(settings.reports_dir)
    reports_dir.mkdir(parents=True, exist_ok=True)
    base_name = f"gendesign_report_{_cad_safe(cad)}_{generated_at_ru}"
    pdf_path = reports_dir / f"{base_name}.pdf"
    docx_path = reports_dir / f"{base_name}.docx"
    # Атомарная запись обоих файлов (tmp+os.replace — см. `_atomic_write`).
    _atomic_write(pdf_path, pdf_bytes)
    _atomic_write(docx_path, docx_bytes)
    size_bytes = len(pdf_bytes)
    docx_size_bytes = len(docx_bytes)

    result: dict[str, Any] = {
        "pdf_path": str(pdf_path),
        "docx_path": str(docx_path),
        "analyze_run_id": analyze_run_id,
        "forecast_run_id": forecast_run_id,
        "generated_at": generated_at.isoformat(),
        "size_bytes": size_bytes,
        "docx_size_bytes": docx_size_bytes,
    }

    # Метадата-ран `report-pdf-1.0` (best-effort persist; провал не роняет отчёт — файлы
    # уже записаны, просто следующий вызов не поймает cache-hit и пере-рендерит).
    persist_analysis_run(
        db,
        cad_num=cad,
        result=result,
        params={"source": "report-pdf-task"},
        district=analyze_row.district,
        confidence=None,
        status="done",
        schema_version=REPORT_SCHEMA_VERSION,
        created_by=None,
    )
    logger.info(
        "build_full_report: cad=%s pdf=%s (%d B) docx=%s (%d B) analyze=%s forecast=%s",
        cad,
        pdf_path,
        size_bytes,
        docx_path,
        docx_size_bytes,
        analyze_run_id,
        forecast_run_id,
    )
    return result
