"""Tests for geo_precision city-fallback flag (#769 Part E).

Two assertions:
(a) A city-fallback geocode (no house number in full_address) causes
    geocode_missing_listings() to set geo_precision='city'.
(b) The radius-analog SQL in estimator._fetch_analogs contains the
    geo_precision exclusion filter (AND (geo_precision IS DISTINCT FROM 'city')).
"""

from __future__ import annotations

import inspect
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch

# DATABASE_URL required by config before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

# WeasyPrint stub — not installed in CI without GTK.
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)

import pytest  # noqa: E402

from app.services.geocoder import GeocodeResult  # noqa: E402

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


def _make_geo(full_address: str, confidence: str = "approximate") -> GeocodeResult:
    return GeocodeResult(
        lat=56.838,
        lon=60.605,
        full_address=full_address,
        provider="nominatim",  # type: ignore[arg-type]
        confidence=confidence,  # type: ignore[arg-type]
    )


# ── (a) geocode_missing sets geo_precision='city' for coarse geocode ─────────


@pytest.mark.asyncio
async def test_geocode_missing_sets_city_precision_for_bare_city_address() -> None:
    """Bare city address geocoded to centroid → geo_precision='city' in UPDATE.

    Simulates "Екатеринбург (Cian)" address: geocoder returns a result whose
    full_address has no house-number token → _geocode_is_coarse() → True.
    The UPDATE should include geo_precision='city'.
    """
    from app.tasks.geocode_missing import geocode_missing_listings

    # City-centroid result: full_address has no 1-3-digit house number.
    city_geo = _make_geo("Екатеринбург", confidence="approximate")

    rows = [{"address": "Екатеринбург (Cian)", "listings_count": 3}]

    db = MagicMock()
    select_result = MagicMock()
    select_result.mappings.return_value.all.return_value = rows
    update_result = MagicMock()
    update_result.rowcount = 3
    db.execute.side_effect = [select_result, update_result]

    with patch(
        "app.tasks.geocode_missing.geocode",
        new_callable=AsyncMock,
        return_value=city_geo,
    ):
        result = await geocode_missing_listings(db, batch_size=50)

    assert result.addresses_geocoded == 1
    assert result.listings_updated == 3

    # Inspect the UPDATE call parameters: geo_precision must be 'city'.
    update_call = db.execute.call_args_list[1]
    update_params = update_call[0][1]  # positional arg[1] = params dict
    assert (
        update_params.get("precision") == "city"
    ), f"Expected precision='city' for bare city address, got {update_params.get('precision')!r}"


@pytest.mark.asyncio
async def test_geocode_missing_sets_none_precision_for_precise_address() -> None:
    """Address with house number → _geocode_is_coarse() → False → geo_precision=None."""
    from app.tasks.geocode_missing import geocode_missing_listings

    # Precise result: full_address contains house number "30".
    precise_geo = _make_geo("Екатеринбург, улица Малышева, 30", confidence="exact")

    rows = [{"address": "ул. Малышева, 30", "listings_count": 2}]

    db = MagicMock()
    select_result = MagicMock()
    select_result.mappings.return_value.all.return_value = rows
    update_result = MagicMock()
    update_result.rowcount = 2
    db.execute.side_effect = [select_result, update_result]

    with patch(
        "app.tasks.geocode_missing.geocode",
        new_callable=AsyncMock,
        return_value=precise_geo,
    ):
        await geocode_missing_listings(db, batch_size=50)

    update_call = db.execute.call_args_list[1]
    update_params = update_call[0][1]
    assert (
        update_params.get("precision") is None
    ), f"Expected precision=None for precise address, got {update_params.get('precision')!r}"


@pytest.mark.asyncio
async def test_geocode_missing_sets_city_precision_for_locality_confidence() -> None:
    """confidence='locality' → _geocode_is_coarse() → True → geo_precision='city'."""
    from app.tasks.geocode_missing import geocode_missing_listings

    # confidence='locality' is an explicit centroid marker (even if address has digits).
    locality_geo = _make_geo("Екатеринбург, Свердловская область", confidence="locality")

    rows = [{"address": "Екатеринбург (N1)", "listings_count": 1}]

    db = MagicMock()
    select_result = MagicMock()
    select_result.mappings.return_value.all.return_value = rows
    update_result = MagicMock()
    update_result.rowcount = 1
    db.execute.side_effect = [select_result, update_result]

    with patch(
        "app.tasks.geocode_missing.geocode",
        new_callable=AsyncMock,
        return_value=locality_geo,
    ):
        await geocode_missing_listings(db, batch_size=50)

    update_call = db.execute.call_args_list[1]
    update_params = update_call[0][1]
    assert (
        update_params.get("precision") == "city"
    ), f"Expected precision='city' for locality confidence, got {update_params.get('precision')!r}"


# ── (b) estimator radius-analog SQL contains geo_precision exclusion ──────────


def test_fetch_analogs_tier_w_excludes_city_precision() -> None:
    """Tier W (wide radius) query must filter out geo_precision='city' listings.

    Asserts the SQL fragment is present in _fetch_analogs source code so that
    city-centroid listings are excluded from radius analog matching.
    """
    from app.services import estimator

    source = inspect.getsource(estimator._fetch_analogs)
    assert "geo_precision IS DISTINCT FROM 'city'" in source, (
        "Tier W radius-analog query must contain "
        "AND (geo_precision IS DISTINCT FROM 'city') to exclude city-centroid listings"
    )


def test_fetch_analogs_tier_h_excludes_city_precision() -> None:
    """Tier H (same class, radius) query must also filter out geo_precision='city'."""
    from app.services import estimator

    source = inspect.getsource(estimator._fetch_analogs)
    # The filter appears in both Tier H and Tier W sub-queries.
    # Count occurrences to verify both are patched.
    occurrences = source.count("geo_precision IS DISTINCT FROM 'city'")
    assert occurrences >= 2, (
        f"Expected geo_precision filter in both Tier H and Tier W queries, "
        f"found {occurrences} occurrence(s)"
    )


def test_scraped_lot_has_geo_precision_field() -> None:
    """ScrapedLot has geo_precision field with default None."""
    from scraper_kit.base import ScrapedLot

    lot = ScrapedLot(
        source="cian",
        source_url="https://cian.ru/sale/flat/123/",
        price_rub=5_000_000,
    )
    assert hasattr(lot, "geo_precision")
    assert lot.geo_precision is None


def test_scraped_lot_accepts_city_geo_precision() -> None:
    """ScrapedLot accepts geo_precision='city'."""
    from scraper_kit.base import ScrapedLot

    lot = ScrapedLot(
        source="n1",
        source_url="https://n1.ru/123/",
        price_rub=3_000_000,
        geo_precision="city",
    )
    assert lot.geo_precision == "city"
