"""Tests for Fix 1: asking_to_sold ratio tier resolved from FINAL headline ppm².

Проверяем, что _get_asking_sold_ratio вызывается ПОСЛЕ всех headline-мутаций
(anchor/IMV-blend/quarter-index/corridor-clamp) с финальным median_ppm2, а не с
pre-anchor радиусной медианой. Это устраняет tier-несовпадение (#928 audit fix).

Два слоя:
1. Стохастический: _get_asking_sold_ratio не вызывается в ранней части estimate_quality
   (только после corridor-clamp/radius-floor).
2. Функциональный: когда anchor поднял headline в другой tier, ratio берётся
   по финальному ppm² (из нового tier), а не по исходной радиусной медиане.
   Graceful: нет ratio → expected_sold_* == None, headline не изменён.
"""

from __future__ import annotations

import os
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import anyio

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


# ── helpers ──────────────────────────────────────────────────────────────────


def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]:
    return {
        "source": "cian",
        "source_url": "https://cian.ru/offer/1",
        "address": "ЕКБ, ул. Учителей, 18",
        "lat": 56.838,
        "lon": 60.595,
        "rooms": 2,
        "area_m2": area_m2,
        "floor": 5,
        "total_floors": 16,
        "price_rub": price_per_m2 * area_m2,
        "price_per_m2": price_per_m2,
        "listing_date": datetime(2026, 5, 1),
        "days_on_market": 10,
        "photo_urls": [],
        "scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
        "distance_m": 150.0,
        "relevance_score": 0.1,
    }


_ANALOGS_LOW: list[dict[str, Any]] = [
    _make_listing(price_per_m2=100_000.0),
    _make_listing(price_per_m2=110_000.0),
    _make_listing(price_per_m2=105_000.0),
]

_ANALOGS_HIGH: list[dict[str, Any]] = [
    _make_listing(price_per_m2=300_000.0),
    _make_listing(price_per_m2=310_000.0),
    _make_listing(price_per_m2=305_000.0),
]


def _make_geo():
    from app.services.geocoder import GeocodeResult

    return GeocodeResult(
        lat=56.838,
        lon=60.595,
        full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
        provider="nominatim",
    )


def _make_payload(rooms: int = 2):
    from app.schemas.trade_in import TradeInEstimateInput

    return TradeInEstimateInput(
        address="ЕКБ, ул. Учителей, 18",
        area_m2=50.0,
        rooms=rooms,
        floor=5,
        total_floors=16,
    )


def _run_estimate_with_ratio_spy(
    analogs: list[dict[str, Any]],
    ratio_return: tuple[float | None, str | None],
) -> tuple[Any, list[Any]]:
    """Запускает estimate_quality с отслеживанием вызовов _get_asking_sold_ratio.

    Возвращает (estimate_result, list_of_call_args).
    """
    from app.services.estimator import estimate_quality

    db = MagicMock()
    payload = _make_payload()

    ratio_calls: list[Any] = []

    def _spy_ratio(db_inner: Any, rooms: Any, anchor_ppm2: Any = None) -> Any:
        ratio_calls.append({"rooms": rooms, "anchor_ppm2": anchor_ppm2})
        return ratio_return

    async def _run() -> Any:
        with (
            patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())),
            patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
            patch("app.services.estimator.match_house_readonly", return_value=None),
            patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
            patch(
                "app.services.estimator._fetch_analogs",
                return_value=(list(analogs), False, "S"),
            ),
            patch("app.services.estimator._fetch_deals", return_value=[]),
            patch(
                "app.services.estimator._get_or_fetch_imv_cached",
                new=AsyncMock(return_value=None),
            ),
            patch(
                "app.services.estimator._get_or_fetch_yandex_valuation_cached",
                new=AsyncMock(return_value=None),
            ),
            patch(
                "app.services.estimator.estimate_via_cian_valuation",
                new=AsyncMock(return_value=None),
            ),
            patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
            patch("app.services.estimator._get_asking_sold_ratio", side_effect=_spy_ratio),
        ):
            return await estimate_quality(payload, db)

    est = anyio.run(_run)
    return est, ratio_calls


# ── тест 1: ratio вызывается РОВНО ОДИН РАЗ (не дважды — до и после headline) ──


def test_ratio_called_exactly_once() -> None:
    """_get_asking_sold_ratio должен вызываться ровно один раз — после headline."""
    _est, calls = _run_estimate_with_ratio_spy(_ANALOGS_LOW, (0.80, "per_rooms"))
    assert (
        len(calls) == 1
    ), f"_get_asking_sold_ratio должен вызываться 1 раз, вызван {len(calls)} раз"


# ── тест 2: anchor поднял headline → ratio вызван с финальным (high) ppm² ────


def test_ratio_tier_uses_final_headline_after_anchor() -> None:
    """Когда anchor поднял median_ppm2 с ~105k до ~300k, ratio вызывается с ~300k."""
    from app.services.estimator import estimate_quality

    db = MagicMock()
    payload = _make_payload()

    # Симулируем: radius median = 105k, anchor поднимает до 300k.
    # _get_asking_sold_ratio должен получить anchor_ppm2 ≈ 300k (не 105k).
    captured_anchor_ppm2: list[float | None] = []

    def _spy(db_inner: Any, rooms: Any, anchor_ppm2: Any = None) -> tuple:
        captured_anchor_ppm2.append(anchor_ppm2)
        return (0.78, "per_rooms")

    async def _run() -> Any:
        with (
            patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())),
            patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
            patch("app.services.estimator.match_house_readonly", return_value=None),
            patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
            # Radius analogs дают медиану ~105k.
            patch(
                "app.services.estimator._fetch_analogs",
                return_value=(list(_ANALOGS_LOW), False, "S"),
            ),
            patch("app.services.estimator._fetch_deals", return_value=[]),
            patch(
                "app.services.estimator._get_or_fetch_imv_cached",
                new=AsyncMock(return_value=None),
            ),
            patch(
                "app.services.estimator._get_or_fetch_yandex_valuation_cached",
                new=AsyncMock(return_value=None),
            ),
            patch(
                "app.services.estimator.estimate_via_cian_valuation",
                new=AsyncMock(return_value=None),
            ),
            patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
            # Same-building anchor поднимает headline до ~300k.
            patch(
                "app.services.estimator._fetch_anchor_comps",
                return_value=(
                    [
                        {
                            "price_per_m2": 300_000,
                            "area_m2": 50.0,
                            "rooms": 2,
                            "source": "cian",
                            "source_url": "u",
                            "address": "a",
                            "lat": 56.838,
                            "lon": 60.595,
                            "floor": 5,
                            "total_floors": 16,
                            "listing_date": datetime(2026, 5, 1),
                            "photo_urls": [],
                            "scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
                            "distance_m": 5.0,
                            "relevance_score": 0.9,
                            "price_rub": 15_000_000,
                            "building_cadastral_number": None,
                            "days_on_market": 5,
                        },
                        {
                            "price_per_m2": 305_000,
                            "area_m2": 50.0,
                            "rooms": 2,
                            "source": "cian",
                            "source_url": "u2",
                            "address": "a",
                            "lat": 56.838,
                            "lon": 60.595,
                            "floor": 6,
                            "total_floors": 16,
                            "listing_date": datetime(2026, 5, 1),
                            "photo_urls": [],
                            "scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
                            "distance_m": 6.0,
                            "relevance_score": 0.9,
                            "price_rub": 15_250_000,
                            "building_cadastral_number": None,
                            "days_on_market": 5,
                        },
                        {
                            "price_per_m2": 298_000,
                            "area_m2": 50.0,
                            "rooms": 2,
                            "source": "cian",
                            "source_url": "u3",
                            "address": "a",
                            "lat": 56.838,
                            "lon": 60.595,
                            "floor": 4,
                            "total_floors": 16,
                            "listing_date": datetime(2026, 5, 1),
                            "photo_urls": [],
                            "scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
                            "distance_m": 7.0,
                            "relevance_score": 0.9,
                            "price_rub": 14_900_000,
                            "building_cadastral_number": None,
                            "days_on_market": 5,
                        },
                        {
                            "price_per_m2": 302_000,
                            "area_m2": 50.0,
                            "rooms": 2,
                            "source": "cian",
                            "source_url": "u4",
                            "address": "a",
                            "lat": 56.838,
                            "lon": 60.595,
                            "floor": 7,
                            "total_floors": 16,
                            "listing_date": datetime(2026, 5, 1),
                            "photo_urls": [],
                            "scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
                            "distance_m": 8.0,
                            "relevance_score": 0.9,
                            "price_rub": 15_100_000,
                            "building_cadastral_number": None,
                            "days_on_market": 5,
                        },
                    ],
                    "A",
                ),
            ),
            patch("app.services.estimator._get_asking_sold_ratio", side_effect=_spy),
        ):
            return await estimate_quality(payload, db)

    anyio.run(_run)

    assert len(captured_anchor_ppm2) == 1
    ppm2_used = captured_anchor_ppm2[0]
    assert ppm2_used is not None
    # Финальный headline должен быть в зоне anchor (~300k), а НЕ в зоне radius (~105k).
    assert (
        ppm2_used > 200_000
    ), f"ratio должен вызываться с anchor ppm2 (~300k), получено {ppm2_used}"


# ── тест 3: graceful — нет ratio → expected_sold_* = None, headline не изменён ──


def test_ratio_graceful_none_no_expected_sold() -> None:
    """Если ratio = None → все expected_sold_* == None, headline в норме."""
    est, _calls = _run_estimate_with_ratio_spy(_ANALOGS_LOW, (None, None))

    assert est.expected_sold_price_rub is None
    assert est.expected_sold_per_m2 is None
    assert est.expected_sold_range_low_rub is None
    assert est.expected_sold_range_high_rub is None
    assert est.asking_to_sold_ratio is None
    assert est.ratio_basis is None
    # Headline не изменён.
    assert est.median_price_rub > 0
