"""Тесты для market_price в POST /api/v1/parcels/{cad_num}/analyze (#33).

Покрывает:
1. analyze с known quarter в mv_quarter_price_per_m2 → возвращает median/p25/p75/source='quarter_mv'
2. analyze с quarter которого нет в MV → deals_count=0, source='no_data'
3. analyze с invalid cad → 404 (no regression)

Стратегия mock: аналогична test_analyze_inline_weights.py — DB mock через
dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch.

Порядок db.execute в analyze_parcel меняется при добавлении блоков (SF-B5 добавил
red_lines / district_price / risk_zones между market-trend и market-price), поэтому
жёсткая нумерация быстро устаревает. Mock диспетчеризует строки по СИГНАТУРЕ SQL
(а не по позиционному индексу) — устойчиво к реордерингу:
  • geom UNION ALL / WKT / district / centroid — по содержимому SELECT
  • market-price (#33) — по уникальной сигнатуре "median_6m, median_12m, median_24m"
Прочие запросы (POI/competitors/noise/red_lines/risk/permits/neighbors) обёрнуты в
try/except SAVEPOINT в коде → дефолтный пустой mock (first=None, all=[], scalar=0)
не валит обработчик.
"""

from __future__ import annotations

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

import pytest
from fastapi.testclient import TestClient

from app.main import app

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

_CAD = "66:41:0204016:10"  # cad_num с 4 частями — quarter = "66:41:0204016"
_CAD_3PARTS = "66:41:0204016"  # cad_num уже является quarter
_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 ─────────────────────────────────────────────────────────────


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,
    district_found: bool = True,
    market_price_row: dict[str, Any] | None = None,
    district_row_override: dict[str, Any] | None = None,
    success_rows: list[dict[str, Any]] | None = None,
    geo_radius_row: dict[str, Any] | None = None,
    district_price_row: dict[str, Any] | None = None,
) -> MagicMock:
    """Сконструировать mock DB Session для analyze_parcel.

    market_price_row=None → имитирует "нет данных в MV" (mp_row is None → source='no_data').
    market_price_row={...} → имитирует найденную строку в MV.
    district_row_override → переопределяет raw-district mapping (#1960 basis chain).
    success_rows → строки v_bucket_success_score (#1955 obj_class passthrough).
    geo_radius_row → строка geo_radius_price (objective lots в радиусе, #1960 basis #2).
    district_price_row → строка district_price_block (objective по имени, #1960 basis #1).
    """
    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_data = district_row_override or {
        "district_name": "Октябрьский",
        "ekb_reference_median": 120000,
        "quarter_median_12m": None,
        "quarter_deals_count": 0,
        "dist_to_center": 1500.0,
    }
    district_row = _make_mapping(_district_data) if district_found else None
    centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})

    mp_mock = _make_mapping(market_price_row) if market_price_row is not None else None
    success_mocks = [_make_mapping(r) for r in success_rows] if success_rows is not None else []
    geo_mock = _make_mapping(geo_radius_row) if geo_radius_row is not None else None
    dp_mock = _make_mapping(district_price_row) if district_price_row is not None else None

    def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
        # Нормализуем SQL для сигнатурного матчинга (collapse whitespace).
        sql = " ".join(str(args[0]).split()) if args else ""

        first_val: Any = None
        all_val: list[Any] = []

        # Структурные запросы (нужны чтобы обработчик дошёл до market-price без 404/202).
        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
        # market-price (#33) — уникальная сигнатура трёх скользящих медиан.
        elif "median_6m" in sql and "median_12m" in sql and "median_24m" in sql:
            first_val = mp_mock
        # #1955: success-ranking из v_bucket_success_score.
        elif "v_bucket_success_score" in sql:
            all_val = success_mocks
        # #1960 basis #1: district_price_block (Objective по имени района).
        elif "AS sample_size" in sql and "objective_lots" in sql:
            first_val = dp_mock
        # #1960 basis #2: geo_radius_price (Objective в радиусе).
        elif "AS n_complexes" in sql and "objective_lots" in sql:
            first_val = geo_mock

        r = MagicMock()
        r.mappings.return_value.first.return_value = first_val
        r.mappings.return_value.all.return_value = all_val
        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


_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"}),
    # enable_ird_analyze=True by default — мокаем чтобы не делать сетевые вызовы в тестах
    patch(
        "app.api.v1.parcels.build_ird_analyze_block",
        return_value={
            "ird_overlaps": [],
            "ird_by_kind": {},
            "opportunity_overlaps": [],
            "planning_projects": [],
            "functional_zone": None,
            "krt": [],
            "zone_regulation": None,
        },
    ),
]


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


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


# ── Тесты ─────────────────────────────────────────────────────────────────────


def test_market_price_found_in_mv() -> None:
    """analyze с known quarter → market_price содержит median/p25/p75, source='quarter_mv'."""
    from app.core.db import get_db

    mv_data: dict[str, Any] = {
        "p25": Decimal("85000.00"),
        "median": Decimal("102000.00"),
        "p75": Decimal("118000.00"),
        "mean": Decimal("103500.00"),
        "deals_count": 47,
        "median_6m": Decimal("105000.00"),
        "median_12m": Decimal("100000.00"),
        "median_24m": Decimal("102000.00"),
        "last_deal_date": dt.date(2026, 3, 15),
    }
    db = _make_db_for_analyze(market_price_row=mv_data)
    app.dependency_overrides[get_db] = _override_db(db)
    _start_patches()
    try:
        client = TestClient(app)
        resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
        assert resp.status_code == 200, resp.text
        body = resp.json()
        assert "market_price" in body, "market_price отсутствует в ответе"
        mp = body["market_price"]
        assert mp["source"] == "quarter_mv"
        assert mp["deals_count"] == 47
        assert mp["median"] == pytest.approx(102000.0)
        assert mp["p25"] == pytest.approx(85000.0)
        assert mp["p75"] == pytest.approx(118000.0)
        assert mp["mean"] == pytest.approx(103500.0)
        assert mp["median_6m"] == pytest.approx(105000.0)
        assert mp["median_12m"] == pytest.approx(100000.0)
        assert mp["median_24m"] == pytest.approx(102000.0)
        assert mp["last_deal_date"] == "2026-03-15"
    finally:
        app.dependency_overrides.clear()
        _stop_patches()


def test_market_price_quarter_not_in_mv() -> None:
    """analyze с quarter которого нет в MV → deals_count=0, source='no_data'."""
    from app.core.db import get_db

    # market_price_row=None → mp_mock=None → ветка else {"deals_count": 0, "source": "no_data"}
    db = _make_db_for_analyze(market_price_row=None)
    app.dependency_overrides[get_db] = _override_db(db)
    _start_patches()
    try:
        client = TestClient(app)
        resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
        assert resp.status_code == 200, resp.text
        body = resp.json()
        assert "market_price" in body
        mp = body["market_price"]
        assert mp["source"] == "no_data"
        assert mp["deals_count"] == 0
        # price fields absent или None
        assert mp.get("median") is None
        assert mp.get("p25") is None
    finally:
        app.dependency_overrides.clear()
        _stop_patches()


def test_market_price_invalid_cad_returns_404() -> None:
    """analyze с несуществующим cad → 404 (no regression)."""
    from app.core.db import get_db

    # geom_found=False → geom_row=None → endpoint вернёт 202 (inline fetch) или 404
    # В тестовой среде on-demand fetch задизейблен (нет Celery/Redis) → 404 expected
    db = _make_db_for_analyze(geom_found=False)
    app.dependency_overrides[get_db] = _override_db(db)
    _start_patches()
    try:
        client = TestClient(app)
        resp = client.post("/api/v1/parcels/00:00:0000000:0/analyze")
        # При отсутствии геометрии возвращается 202 (on-demand fetch enqueue) или 404
        assert resp.status_code in (
            202,
            404,
        ), f"Ожидали 202 или 404 для неизвестного cad, получили {resp.status_code}: {resp.text}"
    finally:
        app.dependency_overrides.clear()
        _stop_patches()


# ── #1960: «Медиана рынка» = newbuild-consistent basis ──────────────────────────


def test_district_median_uses_geo_radius_not_quarter_rosreestr() -> None:
    """#1960: при тонкой росреестровской квартальной медиане (deals<5) карточка
    district.median_price_per_m2 берёт newbuild geo_radius медиану, НЕ 64k.

    Репродукция бага для 66:41:0205010:287: quarter_median_12m=63694 (1 ДКП),
    geo_radius медиана=132690. Objective-по-имени отсутствует (name-match gap) →
    basis должен упасть на geo_radius_price.
    """
    from app.core.db import get_db

    db = _make_db_for_analyze(
        district_row_override={
            "district_name": "Железнодорожный",
            "ekb_reference_median": 69687,
            "quarter_median_12m": 63694,  # загрязнённая росреестровская квартальная
            "quarter_deals_count": 6,  # 24-мес окно ≥5, но 12-мес — тонкое
            "dist_to_center": 0.0,
        },
        # Objective по имени района отсутствует (5/9 районов ЕКБ без name-match).
        district_price_row=None,
        # geo_radius — newbuild basis (что и остальная страница).
        geo_radius_row={"median": 132690.0, "n": 12085, "n_complexes": 14},
    )
    app.dependency_overrides[get_db] = _override_db(db)
    _start_patches()
    try:
        client = TestClient(app)
        resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
        assert resp.status_code == 200, resp.text
        body = resp.json()
        district = body["district"]
        assert district["median_price_basis"] == "objective_geo_radius"
        assert district["median_price_per_m2"] == pytest.approx(132690.0)
        # КЛЮЧЕВОЕ: загрязнённая 64k росреестровская медиана НЕ выигрывает.
        assert district["median_price_per_m2"] != pytest.approx(63694.0)
        # И значение в newbuild-диапазоне (~130-200k).
        assert 130000 <= district["median_price_per_m2"] <= 200000
    finally:
        app.dependency_overrides.clear()
        _stop_patches()


def test_district_median_prefers_objective_district_when_available() -> None:
    """#1960: если есть Objective-медиана по имени района — она имеет приоритет
    над geo_radius и квартальной росреестровской."""
    from app.core.db import get_db

    db = _make_db_for_analyze(
        district_row_override={
            "district_name": "Октябрьский",
            "ekb_reference_median": 120000,
            "quarter_median_12m": 64000,
            "quarter_deals_count": 6,
            "dist_to_center": 1500.0,
        },
        district_price_row={
            "price_min": 90000,
            "price_max": 250000,
            "price_median": 165000,
            "sample_size": 42,
        },
        geo_radius_row={"median": 132690.0, "n": 12085, "n_complexes": 14},
    )
    app.dependency_overrides[get_db] = _override_db(db)
    _start_patches()
    try:
        client = TestClient(app)
        resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
        assert resp.status_code == 200, resp.text
        district = resp.json()["district"]
        assert district["median_price_basis"] == "objective_district"
        assert district["median_price_per_m2"] == pytest.approx(165000.0)
    finally:
        app.dependency_overrides.clear()
        _stop_patches()


def test_district_median_quarter_rosreestr_gated_by_min_deals() -> None:
    """#1960: квартальная росреестровская медиана может стать basis ТОЛЬКО как
    последний fallback и ТОЛЬКО при deals_count ≥ 5. Тонкий квартал (deals<5)
    → median None (не показываем загрязнённое значение)."""
    from app.core.db import get_db

    # Нет Objective-данных вообще, нет ekb_reference, тонкий квартал (1 сделка).
    db = _make_db_for_analyze(
        district_row_override={
            "district_name": "Глухой",
            "ekb_reference_median": None,
            "quarter_median_12m": 64000,
            "quarter_deals_count": 1,  # < 5 → не проходит guard
            "dist_to_center": 3000.0,
        },
        district_price_row=None,
        geo_radius_row=None,
    )
    app.dependency_overrides[get_db] = _override_db(db)
    _start_patches()
    try:
        client = TestClient(app)
        resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
        assert resp.status_code == 200, resp.text
        district = resp.json()["district"]
        assert district["median_price_basis"] == "none"
        assert district["median_price_per_m2"] is None
    finally:
        app.dependency_overrides.clear()
        _stop_patches()


def test_district_median_quarter_rosreestr_selected_as_last_resort() -> None:
    """#1960: если ВСЕ newbuild-fallback'и None (нет Objective-по-имени, нет
    geo_radius, нет ekb_reference), но квартальная росреестровская медиана имеет
    deals_count ≥ 5 — она выбирается как basis (4-й fallback) с явным источником."""
    from app.core.db import get_db

    db = _make_db_for_analyze(
        district_row_override={
            "district_name": "Глухой",
            "ekb_reference_median": None,  # нет справочной DDU-медианы
            "quarter_median_12m": 88000,
            "quarter_deals_count": 7,  # ≥ 5 → проходит guard
            "dist_to_center": 3000.0,
        },
        district_price_row=None,  # нет Objective по имени
        geo_radius_row=None,  # нет geo_radius
    )
    app.dependency_overrides[get_db] = _override_db(db)
    _start_patches()
    try:
        client = TestClient(app)
        resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
        assert resp.status_code == 200, resp.text
        district = resp.json()["district"]
        assert district["median_price_basis"] == "quarter_rosreestr"
        assert district["median_price_per_m2"] == pytest.approx(88000.0)
    finally:
        app.dependency_overrides.clear()
        _stop_patches()


# ── #1955: obj_class passthrough в success_recommendation ────────────────────────


def test_success_recommendation_carries_obj_class() -> None:
    """#1955: ranking-строки несут obj_class — UI может различать строки с
    одинаковым area-label (Комфорт vs Типовой vs «не указан»)."""
    from app.core.db import get_db

    success = [
        {
            "bucket": "Студии 15-30",
            "obj_class": "Комфорт",
            "success_score": 1.2,
            "n_deals": 4857,
            "avg_price_per_m2": 194000,
            "avg_area_m2": 25.0,
            "velocity_z": 0.5,
            "price_z": 0.3,
            "area_z": -0.2,
        },
        {
            "bucket": "Студии 15-30",
            "obj_class": "не указан",
            "success_score": 0.4,
            "n_deals": 165,
            "avg_price_per_m2": 142000,
            "avg_area_m2": 26.0,
            "velocity_z": 0.1,
            "price_z": -0.1,
            "area_z": 0.0,
        },
    ]
    db = _make_db_for_analyze(success_rows=success)
    app.dependency_overrides[get_db] = _override_db(db)
    _start_patches()
    try:
        client = TestClient(app)
        resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
        assert resp.status_code == 200, resp.text
        rec = resp.json()["success_recommendation"]
        assert rec is not None
        ranking = rec["ranking"]
        assert len(ranking) == 2
        assert ranking[0]["obj_class"] == "Комфорт"
        assert ranking[1]["obj_class"] == "не указан"
        # Один area-label, но разные классы → больше не визуально-дубль.
        assert ranking[0]["bucket"] == ranking[1]["bucket"]
        assert ranking[0]["obj_class"] != ranking[1]["obj_class"]
        # И никакого английского фантома 'Comfort'.
        assert all(r["obj_class"] != "Comfort" for r in ranking)
    finally:
        app.dependency_overrides.clear()
        _stop_patches()
