"""Тесты §22-форсайта в parcels API (3b-ii, #995).

Покрывает:
  • `horizon` query-param на POST /analyze: default 12; валидные {6,12,18,24} приняты
    (24 — ТЗ §12.1, Q1); невалидные (7, 0, 30) → 422 ДО любой работы с БД.
  • Best-effort enqueue после успешного analyze: `forecast_site_finder_report.delay`
    вызван ОДИН раз с (cad_num, horizon, created_by); ответ несёт forecast.status="pending".
  • Enqueue failure: `.delay` бросает → analyze всё равно 200, forecast.status="unavailable",
    исключение НЕ всплывает.
  • GET /{cad}/forecast: "1.0"-ран есть (mock latest_run_for) → 200 + status=ready + report;
    рана нет → 202 + status=pending.

Стратегия mock: как test_analyze_market_price.py — DB через dependency_overrides,
тяжёлые сервисы патчим. Celery НЕ дёргаем: патчим `.delay` на реальной таске
(lazy import внутри analyze_parcel резолвит атрибут модуля в момент вызова).
RBAC обходится через settings.testing=True (conftest).
"""

from __future__ import annotations

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

import pytest
from fastapi.testclient import TestClient

from app.main import app


def _weasyprint_native_libs_available() -> tuple[bool, str]:
    """Probe whether WeasyPrint can render (native GTK/Pango/GObject libs present).

    libpango/libgobject отсутствуют на macOS dev, присутствуют в Docker/CI-образе.
    Зеркало гварда из tests/services/exporters/test_report_pdf.py — пропускаем
    PDF-рендер на dev, но прогоняем на CI (#1739).
    """
    try:
        from weasyprint import HTML

        HTML(string="<p>x</p>").write_pdf()
    except (OSError, ImportError) as e:
        return False, str(e)
    return True, ""


_WP_OK, _WP_ERR = _weasyprint_native_libs_available()
_skip_if_no_weasyprint = pytest.mark.skipif(
    not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
)

# ── Константы ─────────────────────────────────────────────────────────────────

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

# ── Mock factories (зеркалят test_analyze_market_price.py) ──────────────────────


def _make_mapping(data: dict[str, Any]) -> MagicMock:
    """Mock-строка (mapping) с __getitem__ + .get() для dict-like доступа."""
    m = MagicMock()
    m.__getitem__ = lambda self, k: data[k]
    m.get = lambda k, default=None: data.get(k, default)
    return m


def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
    """Mock DB Session, достаточный чтобы analyze_parcel дошёл до persist + enqueue."""
    db = MagicMock()

    geom_row = (
        _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"})
        if geom_found
        else None
    )
    wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
    district_row = _make_mapping(
        {
            "district_name": "Октябрьский",
            "ekb_reference_median": 120000,
            "quarter_median_12m": None,
            "quarter_deals_count": 0,
            "dist_to_center": 1500.0,
        }
    )
    centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})

    def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
        sql = " ".join(str(args[0]).split()) if args else ""
        first_val: Any = None
        if "AS geom_geojson" in sql:
            first_val = geom_row
        elif "AS wkt" in sql:
            first_val = wkt_row
        elif "AS ekb_reference_median" in sql and "district_name" in sql:
            first_val = district_row
        elif "AS lon" in sql and "AS lat" in sql:
            first_val = centroid_row

        r = MagicMock()
        r.mappings.return_value.first.return_value = first_val
        r.mappings.return_value.all.return_value = []
        r.scalar.return_value = 0
        return r

    db.execute.side_effect = _execute_side_effect

    ctx = MagicMock()
    ctx.__enter__ = MagicMock(return_value=ctx)
    ctx.__exit__ = MagicMock(return_value=False)
    db.begin_nested.return_value = ctx

    return db


def _override_db(db: MagicMock):
    def _get_db_override():
        yield db

    return _get_db_override


# Тяжёлые внешние сервисы — заглушки (как в test_analyze_market_price.py).
_PATCHES = [
    patch("app.api.v1.parcels.get_air_quality_cached", return_value=None),
    patch("app.api.v1.parcels.get_weather_cached", return_value=None),
    patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None),
    patch(
        "app.api.v1.parcels.get_quarter_dump_data",
        return_value={
            "nspd_zoning": None,
            "nspd_zouit_overlaps": [],
            "nspd_engineering_nearby": [],
            "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False},
        },
    ),
    patch("app.api.v1.parcels.compute_velocity", return_value=None),
    patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}),
]


def _start_patches() -> None:
    for p in _PATCHES:
        p.start()


def _stop_patches() -> None:
    for p in _PATCHES:
        p.stop()


# ── horizon query-param ─────────────────────────────────────────────────────────


def test_horizon_default_12_and_enqueued() -> None:
    """Без horizon → default 12 прокидывается в enqueue, forecast.status=pending."""
    from app.core.db import get_db

    db = _make_db_for_analyze()
    app.dependency_overrides[get_db] = _override_db(db)
    _start_patches()
    delay_mock = MagicMock()
    try:
        with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
            client = TestClient(app)
            resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
            assert resp.status_code == 200, resp.text
            body = resp.json()
            assert body["forecast"] == {"status": "pending", "horizon": 12}
            delay_mock.assert_called_once_with(_CAD, 12, None)
    finally:
        app.dependency_overrides.clear()
        _stop_patches()


def test_horizon_valid_values_accepted() -> None:
    """horizon ∈ {6,12,18,24} принимается → 200 + enqueue с этим горизонтом.

    24 — ТЗ §12.1 (Q1): движок уже считает 24 на каждом ране (первый горизонт в
    _DEFAULT_HORIZONS всего §9.x-стека), здесь проверяем что API его ПРИНИМАЕТ как
    запрошенный primary и прокидывает в enqueue ровно как остальные.
    """
    from app.core.db import get_db

    for h in (6, 12, 18, 24):
        db = _make_db_for_analyze()
        app.dependency_overrides[get_db] = _override_db(db)
        _start_patches()
        delay_mock = MagicMock()
        try:
            with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
                client = TestClient(app)
                resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?horizon={h}")
                assert resp.status_code == 200, f"horizon={h}: {resp.text}"
                assert resp.json()["forecast"] == {"status": "pending", "horizon": h}
                delay_mock.assert_called_once_with(_CAD, h, None)
        finally:
            app.dependency_overrides.clear()
            _stop_patches()


def test_horizon_invalid_returns_422() -> None:
    """horizon вне {6,12,18,24} (7, 0, 30) → 422 ДО enqueue (delay не вызывается).

    30 (за новым потолком 24) тоже отклоняется — сетка дискретная, а не ge/le-диапазон:
    guard от случайного расширения до неограниченного горизонта.
    """
    from app.core.db import get_db

    for h in (7, 0, 30):
        db = _make_db_for_analyze()
        app.dependency_overrides[get_db] = _override_db(db)
        _start_patches()
        delay_mock = MagicMock()
        try:
            with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
                client = TestClient(app)
                resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?horizon={h}")
                assert resp.status_code == 422, f"horizon={h}: {resp.text}"
                delay_mock.assert_not_called()
        finally:
            app.dependency_overrides.clear()
            _stop_patches()


def test_enqueue_passes_created_by_header() -> None:
    """X-Authenticated-User прокидывается в .delay как created_by (3-й позиц. арг)."""
    from app.core.db import get_db

    db = _make_db_for_analyze()
    app.dependency_overrides[get_db] = _override_db(db)
    _start_patches()
    delay_mock = MagicMock()
    try:
        with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
            client = TestClient(app)
            resp = client.post(
                f"/api/v1/parcels/{_CAD}/analyze",
                headers={"X-Authenticated-User": "alice"},
            )
            assert resp.status_code == 200, resp.text
            delay_mock.assert_called_once_with(_CAD, 12, "alice")
    finally:
        app.dependency_overrides.clear()
        _stop_patches()


# ── Enqueue failure ──────────────────────────────────────────────────────────


def test_enqueue_failure_returns_200_unavailable() -> None:
    """.delay бросает (broker down) → analyze всё равно 200, forecast.status=unavailable."""
    from app.core.db import get_db

    db = _make_db_for_analyze()
    app.dependency_overrides[get_db] = _override_db(db)
    _start_patches()
    delay_mock = MagicMock(side_effect=RuntimeError("redis down"))
    try:
        with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
            client = TestClient(app)
            resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
            assert resp.status_code == 200, resp.text
            body = resp.json()
            assert body["forecast"] == {"status": "unavailable", "horizon": 12}
            delay_mock.assert_called_once()
    finally:
        app.dependency_overrides.clear()
        _stop_patches()


def test_analyze_response_keys_unchanged_additive() -> None:
    """forecast — additive: ключевые существующие блоки analyze остаются на месте."""
    from app.core.db import get_db

    db = _make_db_for_analyze()
    app.dependency_overrides[get_db] = _override_db(db)
    _start_patches()
    try:
        with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()):
            client = TestClient(app)
            resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
            assert resp.status_code == 200, resp.text
            body = resp.json()
            # Существующие ключи (до 3b-ii) присутствуют + новый forecast добавлен.
            for key in ("cad_num", "district", "score", "score_breakdown", "risks", "forecast"):
                assert key in body, f"ключ {key} отсутствует в ответе analyze"
    finally:
        app.dependency_overrides.clear()
        _stop_patches()


# ── GET /{cad}/forecast ──────────────────────────────────────────────────────


def test_get_forecast_ready_when_run_present() -> None:
    """Есть "1.0"-ран → 200 + status=ready + run_id + report=run.result."""
    from app.core.db import get_db

    report_dict = {"schema_version": "1.0", "deficit": -1.0, "moi": 117}
    fake_run = MagicMock()
    fake_run.id = 66
    fake_run.created_at = dt.datetime(2026, 6, 5, 7, 32, 0)
    fake_run.result = report_dict

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        # 1-й вызов — §22-ран ("1.0"); 2-й — analyze-ран для gate_caveat (#1740),
        # здесь None (gate не применяется).
        with patch("app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]) as lrf:
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
            assert resp.status_code == 200, resp.text
            body = resp.json()
            assert body["status"] == "ready"
            assert body["run_id"] == 66
            assert body["report"] == report_dict
            # Читаем именно §22-схему "1.0" (3b-i schema-filtered seam) — первый вызов.
            _, kwargs = lrf.call_args_list[0]
            assert kwargs.get("schema_version") == "1.0"
    finally:
        app.dependency_overrides.clear()


def test_get_forecast_pending_when_no_run() -> None:
    """Рана нет (latest_run_for → None) → 202 + status=pending."""
    from app.core.db import get_db

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=None):
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
            assert resp.status_code == 202, resp.text
            assert resp.json() == {"status": "pending"}
    finally:
        app.dependency_overrides.clear()


def test_get_forecast_db_error_returns_pending_not_500() -> None:
    """Ошибка БД на read-only поллинге → 202 pending (graceful, не 500)."""
    from app.core.db import get_db

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", side_effect=RuntimeError("db gone")):
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
            assert resp.status_code == 202, resp.text
            assert resp.json() == {"status": "pending"}
    finally:
        app.dependency_overrides.clear()


# ── GET /{cad}/forecast/export (#959, EPIC export — Markdown + JSON download) ────

_REPORT_FIXTURE = {
    "schema_version": "1.0",
    "advisory": True,
    "exec_summary": {
        "headline": "Строить комфорт-класс.",
        "verdict": "Дефицит малых форматов на горизонте 12 мес.",
        "key_numbers": {"deficit_index": -1.0, "overall_score": 0.42},
        "overall_confidence": "low",
    },
    "future_market": {
        "forecasts_by_horizon": [
            {
                "horizon_months": 12,
                "deficit_index": -1.0,
                "projected_demand_units": 2330.8,
                "projected_supply_units": 42953.2,
                "confidence": "low",
            }
        ],
    },
    "meta": {"cad_num": _CAD, "district": "Кировский", "horizons": [6, 12, 18, 24]},
}


def test_export_forecast_md_returns_markdown() -> None:
    """format=md → 200 text/markdown + Content-Disposition .md, тело — Markdown отчёт."""
    from app.core.db import get_db

    fake_run = MagicMock()
    fake_run.result = _REPORT_FIXTURE

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=md")
            assert resp.status_code == 200, resp.text
            assert resp.headers["content-type"].startswith("text/markdown")
            cd = resp.headers["content-disposition"]
            assert "attachment;" in cd
            assert cd.endswith('.md"')
            # cad-двоеточия санитизированы в имени файла.
            assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd
            body = resp.text
            assert body.lstrip().startswith("# ")
            assert "Оценка advisory" in body
            # Читаем именно §22-схему "1.0".
            _, kwargs = lrf.call_args
            assert kwargs.get("schema_version") == "1.0"
    finally:
        app.dependency_overrides.clear()


def test_export_forecast_md_is_default_format() -> None:
    """Без format → default md (text/markdown)."""
    from app.core.db import get_db

    fake_run = MagicMock()
    fake_run.result = _REPORT_FIXTURE

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run):
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export")
            assert resp.status_code == 200, resp.text
            assert resp.headers["content-type"].startswith("text/markdown")
    finally:
        app.dependency_overrides.clear()


def test_export_forecast_json_returns_dict() -> None:
    """format=json → 200 application/json + attachment .json, тело = run.result dict."""
    from app.core.db import get_db

    fake_run = MagicMock()
    fake_run.result = _REPORT_FIXTURE

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run):
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=json")
            assert resp.status_code == 200, resp.text
            assert resp.headers["content-type"] == "application/json"
            cd = resp.headers["content-disposition"]
            assert "attachment;" in cd
            assert cd.endswith('.json"')
            assert resp.json() == _REPORT_FIXTURE
    finally:
        app.dependency_overrides.clear()


def test_export_forecast_tg_returns_inline_plain_text() -> None:
    """format=tg → 200 text/plain; charset=utf-8, INLINE (НЕТ attachment Content-Disposition)."""
    from app.core.db import get_db

    fake_run = MagicMock()
    fake_run.result = _REPORT_FIXTURE

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=tg")
            assert resp.status_code == 200, resp.text
            assert resp.headers["content-type"] == "text/plain; charset=utf-8"
            # Сниппет для копипаста — НЕ файл-download: attachment-заголовка быть не должно.
            assert "content-disposition" not in {k.lower() for k in resp.headers}
            body = resp.text
            # Краткая сводка несёт вердикт + advisory-caveat.
            assert "комфорт" in body
            assert "Оценка advisory" in body
            # Читаем именно §22-схему "1.0".
            _, kwargs = lrf.call_args
            assert kwargs.get("schema_version") == "1.0"
    finally:
        app.dependency_overrides.clear()


def test_export_forecast_tg_no_run_returns_404() -> None:
    """format=tg + рана нет → 404 (как md/json export-вариант, НЕ 202/500)."""
    from app.core.db import get_db

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=None):
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=tg")
            assert resp.status_code == 404, resp.text
            assert "посчитан" in resp.json()["detail"]
    finally:
        app.dependency_overrides.clear()


def test_export_forecast_docx_returns_word_document() -> None:
    """format=docx → 200 wordprocessingml + attachment .docx, тело — байты .docx (PK)."""
    from app.core.db import get_db

    fake_run = MagicMock()
    fake_run.result = _REPORT_FIXTURE

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=docx")
            assert resp.status_code == 200, resp.text
            assert resp.headers["content-type"] == (
                "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
            )
            cd = resp.headers["content-disposition"]
            assert "attachment;" in cd
            assert cd.endswith('.docx"')
            # cad-двоеточия санитизированы в имени файла.
            assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd
            # OOXML/.docx — zip: первые 2 байта «PK».
            assert resp.content[:2] == b"PK"
            # Читаем именно §22-схему "1.0".
            _, kwargs = lrf.call_args
            assert kwargs.get("schema_version") == "1.0"
    finally:
        app.dependency_overrides.clear()


def test_export_forecast_docx_no_run_returns_404() -> None:
    """format=docx + рана нет → 404 (как md/json export-вариант, НЕ 202/500)."""
    from app.core.db import get_db

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=None):
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=docx")
            assert resp.status_code == 404, resp.text
            assert "посчитан" in resp.json()["detail"]
    finally:
        app.dependency_overrides.clear()


def test_export_forecast_pptx_returns_presentation() -> None:
    """format=pptx → 200 presentationml + attachment .pptx, тело — байты .pptx (PK)."""
    from app.core.db import get_db

    fake_run = MagicMock()
    fake_run.result = _REPORT_FIXTURE

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pptx")
            assert resp.status_code == 200, resp.text
            assert resp.headers["content-type"] == (
                "application/vnd.openxmlformats-officedocument.presentationml.presentation"
            )
            cd = resp.headers["content-disposition"]
            assert "attachment;" in cd
            assert cd.endswith('.pptx"')
            # cad-двоеточия санитизированы в имени файла.
            assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd
            # OOXML/.pptx — zip: первые 2 байта «PK».
            assert resp.content[:2] == b"PK"
            # Читаем именно §22-схему "1.0".
            _, kwargs = lrf.call_args
            assert kwargs.get("schema_version") == "1.0"
    finally:
        app.dependency_overrides.clear()


def test_export_forecast_pptx_no_run_returns_404() -> None:
    """format=pptx + рана нет → 404 (как md/json/docx export-вариант, НЕ 202/500)."""
    from app.core.db import get_db

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=None):
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pptx")
            assert resp.status_code == 404, resp.text
            assert "посчитан" in resp.json()["detail"]
    finally:
        app.dependency_overrides.clear()


def test_export_forecast_no_run_returns_404() -> None:
    """Рана нет (latest_run_for → None) → 404 (download-вариант, НЕ 202/500)."""
    from app.core.db import get_db

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=None):
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export")
            assert resp.status_code == 404, resp.text
            assert "посчитан" in resp.json()["detail"]
    finally:
        app.dependency_overrides.clear()


@_skip_if_no_weasyprint
def test_export_forecast_pdf_returns_pdf() -> None:
    """format=pdf → 200 application/pdf + attachment .pdf, тело — PDF-байты (%PDF) (#1739)."""
    from app.core.db import get_db

    fake_run = MagicMock()
    fake_run.result = _REPORT_FIXTURE

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
            assert resp.status_code == 200, resp.text
            assert resp.headers["content-type"] == "application/pdf"
            cd = resp.headers["content-disposition"]
            assert "attachment;" in cd
            assert cd.endswith('.pdf"')
            # cad-двоеточия санитизированы в имени файла.
            assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd
            # WeasyPrint PDF начинается с маркера %PDF.
            assert resp.content[:4] == b"%PDF"
            # Читаем именно §22-схему "1.0".
            _, kwargs = lrf.call_args
            assert kwargs.get("schema_version") == "1.0"
    finally:
        app.dependency_overrides.clear()


def test_export_forecast_pdf_no_run_returns_404() -> None:
    """format=pdf + рана нет → 404 (как md/json/docx/pptx export-вариант, НЕ 202/500)."""
    from app.core.db import get_db

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=None):
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
            assert resp.status_code == 404, resp.text
            assert "посчитан" in resp.json()["detail"]
    finally:
        app.dependency_overrides.clear()


def test_export_forecast_invalid_format_returns_422() -> None:
    """format вне {md,json,tg,docx,pptx,pdf} → 422 (Literal-валидация ДО работы с БД)."""
    from app.core.db import get_db

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.latest_run_for", return_value=None) as lrf:
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=xml")
            assert resp.status_code == 422, resp.text
            lrf.assert_not_called()
    finally:
        app.dependency_overrides.clear()
