"""Unit tests for the web-features enrichment of the estimate response.

Covers the ADDITIVE + OPTIONAL fields surfaced for the new web views (map /
comp distribution / price↔exposure / ppm² trend):

  - estimator._listing_to_analog / _deal_to_analog — now carry lat/lon
  - estimator._fetch_price_trend — monthly ppm² series shape (mock db)
  - estimator._compute_last_scraped_at — absolute freshness timestamp

No real DB / network: the trend test injects a fake Session whose execute()
returns canned mapping rows.

NOTE: importing app.services.estimator pulls app.core.config.Settings, which
requires DATABASE_URL — set BEFORE importing app modules (same pattern as the
sibling estimator unit tests).
"""

import os

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

from datetime import UTC, datetime

import pytest

from app.schemas.trade_in import AnalogLot, PriceTrendPoint
from app.services import estimator

# --------------------------------------------------------------------------- #
# Per-comp coords on analogs / deals (map + exposure views)
# --------------------------------------------------------------------------- #


def test_listing_to_analog_carries_lat_lon() -> None:
    row = {
        "address": "ул. Тестовая, 1",
        "area_m2": 50.0,
        "rooms": 2,
        "floor": 3,
        "total_floors": 9,
        "price_rub": 10_000_000,
        "price_per_m2": 200_000,
        "listing_date": None,
        "days_on_market": 14,
        "photo_urls": None,
        "source": "cian",
        "source_url": "https://example.test/1",
        "distance_m": 120.0,
        "lat": 56.8389,
        "lon": 60.6057,
    }
    lot = estimator._listing_to_analog(row)
    assert isinstance(lot, AnalogLot)
    assert lot.lat == pytest.approx(56.8389)
    assert lot.lon == pytest.approx(60.6057)
    # exposure + comp-distribution keys still present
    assert lot.days_on_market == 14
    assert lot.price_per_m2 == 200_000
    assert lot.area_m2 == 50.0
    assert lot.rooms == 2


def test_deal_to_analog_carries_lat_lon() -> None:
    row = {
        "address": "ул. Тестовая, 2",
        "area_m2": 60.0,
        "rooms": 2,
        "floor": 5,
        "total_floors": 10,
        "price_rub": 11_000_000,
        "price_per_m2": 183_333,
        "deal_date": None,
        "days_on_market": None,
        "cadastral_number": "66:41:0204016:10",
        "source": "rosreestr",
        "distance_m": 80.0,
        "lat": 56.84,
        "lon": 60.61,
    }
    lot = estimator._deal_to_analog(row)
    assert lot.lat == pytest.approx(56.84)
    assert lot.lon == pytest.approx(60.61)
    assert lot.tier == "T0_per_house"  # kadastr with участок → per-house tier


# --------------------------------------------------------------------------- #
# date_precision honesty flag (#1995) — rosreestr deals are quarter-granular,
# real listings are day-granular. See _date_precision_for_source docstring +
# data/sql/01_schema_rosreestr_deals.sql for the underlying data-source reason.
# --------------------------------------------------------------------------- #


def test_deal_to_analog_marks_rosreestr_source_as_quarter_precision() -> None:
    """#1995: deals.source='rosreestr' → date_precision='quarter' — deal_date is
    period_start_date (первый день квартала), НЕ реальная дата регистрации.
    Это честная маркировка данных, а не баг (live-аудит prod: 9 кварталов,
    ровно 1 distinct deal_date на квартал)."""
    row = {
        "address": "ул. Тестовая, 2",
        "area_m2": 60.0,
        "rooms": 2,
        "floor": 5,
        "total_floors": 10,
        "price_rub": 11_000_000,
        "price_per_m2": 183_333,
        "deal_date": None,
        "days_on_market": None,
        "cadastral_number": "66:41:0204016:10",
        "source": "rosreestr",
        "distance_m": 80.0,
        "lat": 56.84,
        "lon": 60.61,
    }
    lot = estimator._deal_to_analog(row)
    assert lot.date_precision == "quarter"


def test_listing_to_analog_marks_real_source_as_day_precision() -> None:
    """Listings (avito/cian/yandex/domklik) carry a real scrape/parse date →
    date_precision='day'."""
    row = {
        "address": "ул. Тестовая, 1",
        "area_m2": 50.0,
        "rooms": 2,
        "floor": 3,
        "total_floors": 9,
        "price_rub": 10_000_000,
        "price_per_m2": 200_000,
        "listing_date": None,
        "days_on_market": 14,
        "photo_urls": None,
        "source": "cian",
        "source_url": "https://example.test/1",
        "distance_m": 120.0,
        "lat": 56.8389,
        "lon": 60.6057,
    }
    lot = estimator._listing_to_analog(row)
    assert lot.date_precision == "day"


def test_date_precision_none_when_source_unknown() -> None:
    """source отсутствует (устаревшая/неполная строка) → date_precision=None —
    не заявляем precision, которую не можем подтвердить."""
    assert estimator._date_precision_for_source(None) is None


def test_analog_lat_lon_optional_none_when_missing() -> None:
    # Tier S address-only Avito lots can lack geom → lat/lon absent → None (graceful).
    row = {
        "address": "ул. Без Координат, 3",
        "area_m2": 40.0,
        "rooms": 1,
        "floor": None,
        "total_floors": None,
        "price_rub": 8_000_000,
        "price_per_m2": 200_000,
        "listing_date": None,
        "days_on_market": None,
        "photo_urls": None,
        "source": "avito",
        "source_url": None,
        "distance_m": None,
    }
    lot = estimator._listing_to_analog(row)
    assert lot.lat is None
    assert lot.lon is None


# --------------------------------------------------------------------------- #
# _compute_last_scraped_at
# --------------------------------------------------------------------------- #


def test_last_scraped_at_returns_max_timestamp() -> None:
    older = datetime(2026, 5, 1, 10, 0, tzinfo=UTC)
    newer = datetime(2026, 5, 28, 9, 30, tzinfo=UTC)
    lots = [{"scraped_at": older}, {"scraped_at": newer}]
    assert estimator._compute_last_scraped_at(lots) == newer


def test_last_scraped_at_empty_returns_none() -> None:
    assert estimator._compute_last_scraped_at([]) is None


# --------------------------------------------------------------------------- #
# _fetch_price_trend (mock db)
# --------------------------------------------------------------------------- #


class _FakeResult:
    def __init__(self, rows: list[dict]) -> None:
        self._rows = rows

    def mappings(self) -> "_FakeResult":
        return self

    def all(self) -> list[dict]:
        return self._rows


class _FakeSession:
    """Minimal Session stub: returns canned rows per execute() call in order."""

    def __init__(self, *results: list[dict]) -> None:
        self._results = list(results)
        self.calls = 0

    def execute(self, *_args, **_kwargs) -> _FakeResult:
        rows = self._results[self.calls] if self.calls < len(self._results) else []
        self.calls += 1
        return _FakeResult(rows)

    def rollback(self) -> None:  # pragma: no cover — not exercised on happy path
        pass


def test_fetch_price_trend_none_when_no_house_id() -> None:
    db = _FakeSession()
    assert estimator._fetch_price_trend(db, target_house_id=None) is None
    assert db.calls == 0  # short-circuits before any query


def test_fetch_price_trend_prefers_houses_price_dynamics() -> None:
    hpd_rows = [
        {"month": "2025-01", "ppm2": 200_000},
        {"month": "2025-02", "ppm2": 205_000},
        {"month": "2025-03", "ppm2": 210_000},
    ]
    db = _FakeSession(hpd_rows)  # first query hits → no fallback needed
    trend = estimator._fetch_price_trend(db, target_house_id=123)
    assert trend is not None
    assert db.calls == 1  # did NOT touch the fallback source
    assert trend == [
        {"month": "2025-01", "ppm2": 200_000},
        {"month": "2025-02", "ppm2": 205_000},
        {"month": "2025-03", "ppm2": 210_000},
    ]
    # Shape contract: each point validates as PriceTrendPoint(month:str, ppm2:int).
    points = [PriceTrendPoint(**p) for p in trend]
    assert all(isinstance(p.month, str) and isinstance(p.ppm2, int) for p in points)


def test_fetch_price_trend_falls_back_to_placement_history() -> None:
    fallback_rows = [
        {"month": "2024-06", "ppm2": 190_000},
        {"month": "2024-07", "ppm2": 195_000},
        {"month": "2024-08", "ppm2": 198_000},
    ]
    # First query (houses_price_dynamics) empty → second (placement_history) hits.
    db = _FakeSession([], fallback_rows)
    trend = estimator._fetch_price_trend(db, target_house_id=235340)
    assert trend == fallback_rows
    assert db.calls == 2


def test_fetch_price_trend_none_when_below_min_points() -> None:
    # Both sources return < min_points (3) → None (graceful, frontend hides chart).
    db = _FakeSession([{"month": "2025-01", "ppm2": 200_000}], [])
    assert estimator._fetch_price_trend(db, target_house_id=999) is None


def test_price_trend_point_shape() -> None:
    p = PriceTrendPoint(month="2026-05", ppm2=251_429)
    assert p.month == "2026-05"
    assert p.ppm2 == 251_429
    dumped = p.model_dump()
    assert set(dumped) == {"month", "ppm2"}


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(pytest.main([__file__, "-q"]))
