"""Unit-тесты §22 DOCX-экспортёра (#959, EPIC export) — `render_report_docx`.

Чистые тесты БЕЗ БД/сети (экспортёр только ПОТРЕБЛЯЕТ уже-собранный отчёт):
  • реальный fixture-отчёт (frontend mock `parcel-forecast.json` → `report`) рендерится
    в НЕПУСТЫЕ байты, начинающиеся с `b"PK"` (OOXML/.docx — это zip-контейнер);
  • документ открываемый python-docx и несёт заголовки всех секций + advisory-дисклеймер
    + кадастр из meta;
  • экспортёр принимает КАК `SiteFinderReport`-инстанс, ТАК и его `as_dict()`-словарь;
  • пустой / частичный / мусорный отчёт → валидный минимальный .docx БЕЗ падения (graceful).

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

from __future__ import annotations

import io
import json
import os
from pathlib import Path
from typing import Any

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

from docx import Document

from app.services.exporters.report_docx import (
    _ADVISORY_DISCLAIMER,
    _TITLE_CONFIDENCE,
    _TITLE_FUTURE_MARKET,
    _TITLE_MARKET_NOW,
    _TITLE_PRODUCT_TZ,
    _TITLE_SCENARIOS,
    _TITLE_SCORING,
    _TITLE_SUMMARY,
    render_report_docx,
)
from app.services.forecasting.report import (
    ReportExecSummary,
    ReportMeta,
    SiteFinderReport,
)

# Реальный fixture: frontend mock §22-форсайта — берём вложенный `report` (форма #987
# `as_dict()`). Так тест не разойдётся с реальной формой отчёта.
_FIXTURE_PATH = (
    Path(__file__).resolve().parents[4]
    / "frontend"
    / "src"
    / "lib"
    / "mocks"
    / "parcel-forecast.json"
)

# OOXML / .docx — это zip-контейнер: первые два байта — сигнатура zip «PK».
_ZIP_SIGNATURE = b"PK"

_EXPECTED_TITLES: tuple[str, ...] = (
    _TITLE_SUMMARY,
    _TITLE_MARKET_NOW,
    _TITLE_FUTURE_MARKET,
    _TITLE_SCENARIOS,
    _TITLE_PRODUCT_TZ,
    _TITLE_SCORING,
    _TITLE_CONFIDENCE,
)


def _fixture_report() -> dict[str, Any]:
    """`report`-словарь из frontend-мока §22 (форма SiteFinderReport.as_dict())."""
    data = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8"))
    report = data["report"]
    assert isinstance(report, dict)
    return report


def _docx_text(docx_bytes: bytes) -> str:
    """Весь текст документа (абзацы + ячейки таблиц) одной строкой — для ассертов."""
    doc = Document(io.BytesIO(docx_bytes))
    parts: list[str] = [p.text for p in doc.paragraphs]
    for table in doc.tables:
        for row in table.rows:
            parts.extend(cell.text for cell in row.cells)
    return "\n".join(parts)


# ── Реальный fixture-отчёт: байты-сигнатура + структура + ключевые значения ──────


class TestFixtureReportRender:
    def test_returns_non_empty_bytes(self) -> None:
        out = render_report_docx(_fixture_report())
        assert isinstance(out, bytes)
        # Нетривиальный документ — далеко не пустой.
        assert len(out) > 1000

    def test_starts_with_zip_signature(self) -> None:
        # OOXML/.docx — zip: первые 2 байта обязаны быть «PK».
        out = render_report_docx(_fixture_report())
        assert out[:2] == _ZIP_SIGNATURE

    def test_openable_by_python_docx(self) -> None:
        # Результат — валидный .docx (открывается python-docx без ошибки).
        out = render_report_docx(_fixture_report())
        doc = Document(io.BytesIO(out))
        assert len(doc.paragraphs) > 0

    def test_advisory_disclaimer_present(self) -> None:
        text = _docx_text(render_report_docx(_fixture_report()))
        assert _ADVISORY_DISCLAIMER in text

    def test_all_section_headers_present(self) -> None:
        text = _docx_text(render_report_docx(_fixture_report()))
        for title in _EXPECTED_TITLES:
            assert title in text, f"отсутствует заголовок секции «{title}»"

    def test_meta_cad_num_present(self) -> None:
        text = _docx_text(render_report_docx(_fixture_report()))
        # Кадастр из meta фикстуры попадает в карточку.
        assert "66:41:0702048:27" in text

    def test_headline_present(self) -> None:
        report = _fixture_report()
        headline = report["exec_summary"]["headline"]
        text = _docx_text(render_report_docx(report))
        assert headline.split(".")[0] in text


# ── Вход: dataclass-инстанс ИЛИ as_dict()-словарь ───────────────────────────────


class TestInputForms:
    def test_accepts_dataclass_instance(self) -> None:
        report = SiteFinderReport(
            exec_summary=ReportExecSummary(headline="Строить комфорт-класс."),
            meta=ReportMeta(cad_num="66:41:0000000:1", horizons=[12]),
        )
        text = _docx_text(render_report_docx(report))
        assert "Строить комфорт" in text
        assert "66:41:0000000:1" in text

    def test_dataclass_and_dict_equivalent_text(self) -> None:
        # Байты могут расходиться (zip-метаданные/порядок), но ТЕКСТ — идентичен.
        report = SiteFinderReport(
            exec_summary=ReportExecSummary(headline="Один заголовок."),
            meta=ReportMeta(cad_num="66:41:0000000:9"),
        )
        assert _docx_text(render_report_docx(report)) == _docx_text(
            render_report_docx(report.as_dict())
        )


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


class TestGraceful:
    def test_empty_dict_does_not_crash(self) -> None:
        # Пустой отчёт {} → валидный .docx (no KeyError), сигнатура PK, есть дисклеймер.
        out = render_report_docx({})
        assert isinstance(out, bytes)
        assert out[:2] == _ZIP_SIGNATURE
        text = _docx_text(out)
        assert _ADVISORY_DISCLAIMER in text
        assert "нет данных" in text
        # Все секции всё равно отрисованы (стабильный контракт).
        for title in _EXPECTED_TITLES:
            assert title in text

    def test_none_does_not_crash(self) -> None:
        # None → нормализуется в {} → валидный минимальный .docx, не падает.
        out = render_report_docx(None)
        assert isinstance(out, bytes)
        assert out[:2] == _ZIP_SIGNATURE
        assert _ADVISORY_DISCLAIMER in _docx_text(out)

    def test_default_report_does_not_crash(self) -> None:
        # Полностью дефолтный (пустой) SiteFinderReport — валидный .docx.
        out = render_report_docx(SiteFinderReport())
        assert out[:2] == _ZIP_SIGNATURE
        text = _docx_text(out)
        assert _ADVISORY_DISCLAIMER in text
        assert "нет данных" in text

    def test_garbage_input_returns_valid_docx(self) -> None:
        # Мусор (int / строка / list) → нормализуется в {} → валидный .docx, не падает.
        for junk in (123, "not a report", []):
            out = render_report_docx(junk)
            assert isinstance(out, bytes)
            assert out[:2] == _ZIP_SIGNATURE

    def test_partial_report_some_sections(self) -> None:
        report = SiteFinderReport(
            exec_summary=ReportExecSummary(headline="Тонкий анализ — только заголовок."),
            meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]),
        )
        out = render_report_docx(report)
        assert out[:2] == _ZIP_SIGNATURE
        text = _docx_text(out)
        assert "Тонкий анализ" in text
        assert "66:41:0000000:2" in text
        # Незаполненные секции рисуют «нет данных», но .docx валиден.
        assert "нет данных" in text
