"""Regression tests for #654 — POST /estimate 502 on null floor / slow upstream.

Two contracts locked here:
  1. estimate with floor=None & total_floors=None returns an AggregatedEstimate
     (no exception) AND the floor-gated sources (Avito IMV + Cian valuation) are
     NOT awaited — this is the gating contract that makes null-floor safe.
  2. A Yandex-valuation timeout (asyncio.TimeoutError / httpx.TimeoutException)
     degrades gracefully: estimate WITHOUT 'yandex_valuation' in sources_used,
     no 5xx. Locks the #654 time-budget → None mapping.

Style mirrors test_estimator_cian_integration.py: MagicMock db, geocode /
_fetch_analogs / _fetch_deals patched, scrapers stubbed.
"""

import os

# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")

from unittest.mock import AsyncMock, MagicMock, patch

import anyio
import httpx


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

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


def _make_payload_null_floor():
    """Simplified-form payload: floor + total_floors are None (the #654 trigger)."""
    from app.schemas.trade_in import TradeInEstimateInput

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


def _make_payload_full():
    from app.schemas.trade_in import TradeInEstimateInput

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


def test_estimate_null_floor_skips_imv_and_cian() -> None:
    """floor=None & total_floors=None → AggregatedEstimate, IMV+Cian NOT awaited.

    Locks the gating contract: the two floor-required sources must be skipped
    (not called) when floor data is absent — confirming the null path is safe
    and the 502 was never a null-floor crash (#654).
    """
    from app.services.estimator import estimate_quality

    db = MagicMock()
    payload = _make_payload_null_floor()

    imv_mock = AsyncMock(return_value=None)
    cian_mock = AsyncMock(return_value=None)
    yandex_mock = AsyncMock(return_value=None)

    async def _run() -> None:
        with (
            patch("app.services.estimator.geocode",
                  new=AsyncMock(return_value=_make_fake_geo())),
            patch("app.services.estimator.get_house_metadata",
                  new=AsyncMock(return_value=None)),
            patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
            patch("app.services.estimator._fetch_deals", return_value=[]),
            patch("app.services.estimator._get_or_fetch_imv_cached", new=imv_mock),
            patch("app.services.estimator._get_or_fetch_yandex_valuation_cached",
                  new=yandex_mock),
            patch("app.services.estimator.estimate_via_cian_valuation", new=cian_mock),
            patch("app.services.estimator._get_asking_sold_ratio",
                  return_value=(None, None)),
        ):
            result = await estimate_quality(payload, db)

        # No exception — got a real estimate back.
        assert result.estimate_id is not None
        # Gating contract: floor-required sources never awaited.
        imv_mock.assert_not_awaited()
        cian_mock.assert_not_awaited()
        assert "avito_imv" not in result.sources_used
        assert "cian_valuation" not in result.sources_used

    anyio.run(_run)


def test_estimate_full_floor_awaits_imv_and_cian() -> None:
    """Sanity counter-test: with floor present, IMV+Cian ARE awaited.

    Guards against a regression where the gating predicate gets inverted /
    broken — proves the null-floor skip above is the gate, not a no-op.
    """
    from app.services.estimator import estimate_quality

    db = MagicMock()
    payload = _make_payload_full()

    imv_mock = AsyncMock(return_value=None)
    cian_mock = AsyncMock(return_value=None)
    yandex_mock = AsyncMock(return_value=None)

    async def _run() -> None:
        with (
            patch("app.services.estimator.geocode",
                  new=AsyncMock(return_value=_make_fake_geo())),
            patch("app.services.estimator.get_house_metadata",
                  new=AsyncMock(return_value=None)),
            patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
            patch("app.services.estimator._fetch_deals", return_value=[]),
            patch("app.services.estimator._get_or_fetch_imv_cached", new=imv_mock),
            patch("app.services.estimator._get_or_fetch_yandex_valuation_cached",
                  new=yandex_mock),
            patch("app.services.estimator.estimate_via_cian_valuation", new=cian_mock),
            patch("app.services.estimator._get_asking_sold_ratio",
                  return_value=(None, None)),
        ):
            result = await estimate_quality(payload, db)

        assert result.estimate_id is not None
        cian_mock.assert_awaited_once()
        # IMV is additionally gated on house_type/renovation mapping; with
        # house_type=None it is correctly skipped — so we assert on Cian only,
        # which is gated purely on floor/total_floors presence.

    anyio.run(_run)


def test_estimate_yandex_timeout_degrades_no_5xx() -> None:
    """Yandex valuation raising TimeoutError → estimate without it, no exception.

    The _with_budget() guard wraps the call; asyncio.wait_for surfaces a
    TimeoutError which must map to the same graceful None path as a network
    error. Estimator returns an AggregatedEstimate; 'yandex_valuation' absent.
    """
    from app.services.estimator import estimate_quality

    db = MagicMock()
    payload = _make_payload_null_floor()

    # Yandex call hangs/raises a timeout — simulate the slow-upstream case.
    yandex_mock = AsyncMock(side_effect=TimeoutError("yandex slow"))

    async def _run() -> None:
        with (
            patch("app.services.estimator.geocode",
                  new=AsyncMock(return_value=_make_fake_geo())),
            patch("app.services.estimator.get_house_metadata",
                  new=AsyncMock(return_value=None)),
            patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
            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=yandex_mock),
            patch("app.services.estimator.estimate_via_cian_valuation",
                  new=AsyncMock(return_value=None)),
            patch("app.services.estimator._get_asking_sold_ratio",
                  return_value=(None, None)),
        ):
            result = await estimate_quality(payload, db)

        assert result.estimate_id is not None  # no 5xx — degraded gracefully
        assert "yandex_valuation" not in result.sources_used

    anyio.run(_run)


def test_estimate_yandex_httpx_timeout_at_scraper_degrades_no_5xx() -> None:
    """Real network path: scraper raises httpx.TimeoutException on cache-miss fetch.

    Here we DON'T patch _get_or_fetch_yandex_valuation_cached — we let the real
    cache helper run with a cache MISS, then make the underlying scraper raise
    httpx.TimeoutException. The helper's internal `except Exception` maps it to
    None (graceful), so the estimate completes without 'yandex_valuation' and
    no 5xx. This is the production-realistic timeout path (#654).
    """
    from app.services.estimator import estimate_quality

    db = MagicMock()
    # Cache MISS so the helper proceeds to the (timing-out) fresh fetch.
    db.execute.return_value.mappings.return_value.first.return_value = None
    payload = _make_payload_null_floor()

    fake_scraper = MagicMock()
    fake_scraper.__aenter__ = AsyncMock(return_value=fake_scraper)
    fake_scraper.__aexit__ = AsyncMock(return_value=False)
    fake_scraper.fetch_house_history = AsyncMock(
        side_effect=httpx.TimeoutException("read timeout")
    )

    async def _run() -> None:
        with (
            patch("app.services.estimator.geocode",
                  new=AsyncMock(return_value=_make_fake_geo())),
            patch("app.services.estimator.get_house_metadata",
                  new=AsyncMock(return_value=None)),
            patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")),
            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.YandexValuationScraper",
                  return_value=fake_scraper),
            patch("app.services.estimator.estimate_via_cian_valuation",
                  new=AsyncMock(return_value=None)),
            patch("app.services.estimator._get_asking_sold_ratio",
                  return_value=(None, None)),
        ):
            result = await estimate_quality(payload, db)

        assert result.estimate_id is not None
        assert "yandex_valuation" not in result.sources_used

    anyio.run(_run)
