"""EPIC 961: typed AnalyzeResponse (#992) + run-history endpoints (#994).

Покрывает критичные инварианты PR (наивысший blast radius):

#992 — AnalyzeResponse(response_model):
  • EXTRA-PRESERVATION: ключ из result_payload, НЕ перечисленный в модели явно,
    ВСЁ РАВНО попадает в 200-ответ (extra="allow" → не silent-drop). Доказываем
    на живом эндпоинте, подсунув в payload синтетический неперечисленный ключ.
  • 202 fetch-stub: путь #93 (геометрии нет → enqueue → timeout) отдаёт 202 +
    {status:"fetching", job_id, eta_seconds, message} и НЕ падает 500 при
    сериализации через response_model (все поля Optional).

#994 — run-history:
  • GET /{cad_num}/runs → 200 {runs:[...]} (LIGHT), пустой список (НЕ 404) если ранов нет.
  • GET /runs/{run_id} → 200 full (incl result); 404 если нет.
  • ROUTE ORDERING: /runs/{run_id} НЕ перехватывается /{parcel_id} (числовой id
    резолвится в get_analysis_run, не в get_parcel-501).

Стратегия mock: как test_parcels_forecast.py — DB через dependency_overrides,
тяжёлые сервисы патчим, RBAC обходится settings.testing=True (conftest).
"""

from __future__ import annotations

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

from fastapi.testclient import TestClient

from app.main import app

# ── Константы / mock-helpers (зеркалят test_parcels_forecast.py) ────────────────

_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]]]}'


def _make_mapping(data: dict[str, Any]) -> MagicMock:
    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 дошёл до result_payload / fallback."""
    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


_HEAVY_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_heavy() -> None:
    for p in _HEAVY_PATCHES:
        p.start()


def _stop_heavy() -> None:
    for p in _HEAVY_PATCHES:
        p.stop()


# ── #992: AnalyzeResponse extra-preservation (анти silent-drop) ─────────────────


def test_analyze_response_preserves_unmodeled_extra_key() -> None:
    """Синтетический ключ, НЕ перечисленный в AnalyzeResponse, доходит до 200-ответа.

    Доказательство ключевого инварианта extra="allow": response_model НЕ выкидывает
    незнакомые ключи. Инъектируем ключ через patch result_payload (post-build hook
    не нужен — патчим persist_analysis_run как seam, где payload уже собран? Нет:
    проще проверить, что РЕАЛЬНЫЕ ключи payload, которых НЕТ в модели, сохранены —
    напр. market_pulse / district_price_per_m2_median / weights_profile — они в
    result_payload, но как nested dict; extra="allow" их пропускает as-is).
    """
    from app.core.db import get_db

    db = _make_db_for_analyze()
    app.dependency_overrides[get_db] = _override_db(db)
    _start_heavy()
    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()
            # Эти ключи присутствуют в result_payload, но НЕ как обязательные —
            # некоторые (market_pulse/weights_profile/district_price_*) попадают через
            # модель-поля или extra; все ДОЛЖНЫ быть в ответе (ни один не выкинут).
            for key in (
                "market_pulse",
                "weights_profile",
                "district_price_per_m2_median",
                "custom_poi_score_items",
                "gate_verdict",
                "nspd_dump",
            ):
                assert key in body, f"response_model выкинул ключ {key} (silent drop!)"
    finally:
        app.dependency_overrides.clear()
        _stop_heavy()


def test_analyze_response_includes_permits_nearby_honest_zero() -> None:
    """#105 Phase 3: новый ключ permits_nearby присутствует в 200-ответе.

    Mock-БД отдаёт [] на радиус-запрос gisogd_permits → честный ноль (total_count=0,
    items=[]), НЕ отсутствие ключа. Доказывает wiring get_permits_nearby → result_payload
    → AnalyzeResponse (рядом со старым permits_summary, который тоже остаётся).
    """
    from app.core.db import get_db

    db = _make_db_for_analyze()
    app.dependency_overrides[get_db] = _override_db(db)
    _start_heavy()
    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()
            assert "permits_nearby" in body, "permits_nearby выкинут из ответа"
            assert "permits_summary" in body, "старый permits_summary не должен исчезнуть"
            nearby = body["permits_nearby"]
            assert nearby["total_count"] == 0
            assert nearby["items"] == []
            assert nearby["source"] == "gisogd66"
            assert nearby["radius_m"] == 500
    finally:
        app.dependency_overrides.clear()
        _stop_heavy()


def test_analyze_response_does_not_drop_keys_vs_raw_payload() -> None:
    """Жёсткая гарантия: key-set 200-ответа ⊇ key-set собранного result_payload.

    Перехватываем фактический dict, который analyze отдаёт в persist (он же
    возвращается клиенту), и сверяем: каждый его top-level ключ присутствует в JSON
    HTTP-ответе. Это и есть «200 response key-set ИДЕНТИЧЕН прежнему» — response_model
    ничего не теряет.
    """
    from app.core.db import get_db

    captured_payload: dict[str, Any] = {}

    def _capture(db_: Any, *, result: dict[str, Any], **kwargs: Any) -> int:
        captured_payload.update(result)
        return 1

    db = _make_db_for_analyze()
    app.dependency_overrides[get_db] = _override_db(db)
    _start_heavy()
    try:
        with (
            patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", MagicMock()),
            patch("app.api.v1.parcels.persist_analysis_run", side_effect=_capture),
        ):
            client = TestClient(app)
            resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
            assert resp.status_code == 200, resp.text
            body = resp.json()
            assert captured_payload, "persist не получил result_payload — тест не сработал"
            missing = sorted(set(captured_payload) - set(body))
            assert not missing, f"response_model выкинул ключи из payload: {missing}"
    finally:
        app.dependency_overrides.clear()
        _stop_heavy()


# ── #992: 202 fetch-stub проходит через response_model без 500 ──────────────────


def test_analyze_202_fetch_stub_not_500_through_response_model() -> None:
    """Геометрии нет → enqueue → timeout → 202 + stub; response_model НЕ роняет 500.

    Все поля AnalyzeResponse Optional → стаб {status,cad_num,job_id,eta_seconds,message}
    без score/competitors сериализуется без ValidationError. Frontend ветвится по
    HTTP-коду 202 (useSiteAnalysis.ts), поэтому additive null-дефолты безвредны.
    """
    from app.core.db import get_db

    db = _make_db_for_analyze(geom_found=False)
    app.dependency_overrides[get_db] = _override_db(db)
    _start_heavy()
    try:
        with (
            patch(
                "app.api.v1.parcels.find_or_enqueue_fetch",
                return_value=("fetching", 4242, None),
            ),
            patch("app.api.v1.parcels.cad_exists_in_db", return_value=False),
            # схлопываем inline-wait, чтобы сразу уйти в 202-timeout (без 15s сна)
            patch("app.api.v1.parcels._INLINE_FETCH_WAIT_S", 0),
        ):
            client = TestClient(app)
            resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
            assert resp.status_code == 202, resp.text
            body = resp.json()
            # Стаб-поля сохранены (через явные поля модели — все Optional).
            assert body["status"] == "fetching"
            assert body["job_id"] == 4242
            assert body["eta_seconds"] == 15
            assert body["message"]  # непустое сообщение
            assert body["cad_num"] == _CAD
    finally:
        app.dependency_overrides.clear()
        _stop_heavy()


# ── #994: GET /{cad_num}/runs (LIGHT list) ─────────────────────────────────────


def _summary_row(**over: Any) -> MagicMock:
    base = {
        "id": 7,
        "cad_num": _CAD,
        "created_at": dt.datetime(2026, 6, 5, 7, 32, 0),
        "status": "complete",
        "schema_version": "analyze-1.0",
        "district": "Октябрьский",
        "confidence": "high",
        "created_by": "alice",
    }
    base.update(over)
    row = MagicMock()
    for k, v in base.items():
        setattr(row, k, v)
    return row


def test_list_runs_returns_light_list() -> None:
    """GET /{cad}/runs → 200 {runs:[summary...]} с метаданными (без result)."""
    from app.core.db import get_db

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch(
            "app.api.v1.parcels.list_runs_for",
            return_value=[_summary_row(id=9), _summary_row(id=8)],
        ) as lrf:
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/runs")
            assert resp.status_code == 200, resp.text
            body = resp.json()
            assert [r["id"] for r in body["runs"]] == [9, 8]
            assert body["runs"][0]["cad_num"] == _CAD
            assert body["runs"][0]["confidence"] == "high"
            assert "result" not in body["runs"][0], "summary не должен нести result"
            # cad_num прокинут в repo, default limit=20
            args, kwargs = lrf.call_args
            assert args[1] == _CAD
            assert kwargs.get("limit") == 20
    finally:
        app.dependency_overrides.clear()


def test_list_runs_empty_is_200_not_404() -> None:
    """Нет ранов → 200 + {runs: []} (НЕ 404)."""
    from app.core.db import get_db

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


def test_list_runs_respects_limit_query() -> None:
    """?limit=5 прокидывается в list_runs_for."""
    from app.core.db import get_db

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.list_runs_for", return_value=[]) as lrf:
            client = TestClient(app)
            resp = client.get(f"/api/v1/parcels/{_CAD}/runs?limit=5")
            assert resp.status_code == 200, resp.text
            assert lrf.call_args.kwargs.get("limit") == 5
    finally:
        app.dependency_overrides.clear()


# ── #994: GET /runs/{run_id} (full) + route ordering ───────────────────────────


def _detail_row() -> MagicMock:
    data = {
        "id": 123,
        "cad_num": _CAD,
        "created_at": dt.datetime(2026, 6, 5, 7, 32, 0),
        "status": "complete",
        "schema_version": "analyze-1.0",
        "district": "Октябрьский",
        "confidence": "high",
        "created_by": "alice",
        "advisory": True,
        "params": {"profile_id": None},
        "segment": None,
        "result": {"cad_num": _CAD, "score": 42.5, "risks": {"k": "v"}},
    }
    row = MagicMock()
    for k, v in data.items():
        setattr(row, k, v)
    return row


def test_get_run_returns_full_with_result() -> None:
    """GET /runs/{id} → 200 full row incl result."""
    from app.core.db import get_db

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.get_run", return_value=_detail_row()) as gr:
            client = TestClient(app)
            resp = client.get("/api/v1/parcels/runs/123")
            assert resp.status_code == 200, resp.text
            body = resp.json()
            assert body["id"] == 123
            assert body["result"] == {"cad_num": _CAD, "score": 42.5, "risks": {"k": "v"}}
            assert body["advisory"] is True
            assert gr.call_args.args[1] == 123  # int run_id прокинут
    finally:
        app.dependency_overrides.clear()


def test_get_run_404_when_missing() -> None:
    """Рана нет → 404 (graceful HTTPException, не 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.get_run", return_value=None):
            client = TestClient(app)
            resp = client.get("/api/v1/parcels/runs/999999")
            assert resp.status_code == 404, resp.text
    finally:
        app.dependency_overrides.clear()


def test_route_ordering_runs_not_shadowed_by_parcel_id() -> None:
    """/runs/{run_id} резолвится в get_analysis_run, НЕ в get_parcel (/{parcel_id}-501).

    Если бы /{parcel_id} стоял выше, /runs/123 ушёл бы в get_parcel (501 stub) с
    parcel_id='runs' (или числовым). Доказываем: запрос реально доходит до get_run
    (мокнут) и НЕ возвращает 501.
    """
    from app.core.db import get_db

    db = MagicMock()
    app.dependency_overrides[get_db] = _override_db(db)
    try:
        with patch("app.api.v1.parcels.get_run", return_value=_detail_row()) as gr:
            client = TestClient(app)
            resp = client.get("/api/v1/parcels/runs/123")
            # get_parcel вернул бы 501; get_analysis_run → 200. И get_run был вызван.
            assert resp.status_code != 501, "shadowed by /{parcel_id} 501 stub!"
            assert resp.status_code == 200, resp.text
            gr.assert_called_once()
    finally:
        app.dependency_overrides.clear()
