"""Unit tests for the anchored cadastral street+house matcher.

Covers:
- `_parse_street_house`: PURE parsing of DaData full form, bare «Street House»,
  корпус/квартира stripping, digit-leading street («8 Марта 204»). Plus None cases.
- `_cadastral_house_match`: mocked db session (FDW not in unit-test DB) → asserts
  GeocodeSuggestion built from row / None on no row / None on error.
- geocode() + suggest() wiring: parse → house-match called first; parse-fail or
  no-hit → falls back to legacy `_cadastral_forward_sync`.
"""

from __future__ import annotations

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

import pytest

# 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)


from app.services.geocoder import (  # noqa: E402
    GeocodeResult,
    GeocodeSuggestion,
    _cadastral_house_match,
    _parse_street_house,
    geocode,
    suggest,
)

# ── _parse_street_house: pure unit tests ─────────────────────────────────────


@pytest.mark.parametrize(
    ("address", "expected"),
    [
        # DaData full form with district
        (
            "620144, Свердловская обл, г Екатеринбург, Ленинский р-н, ул Серова, д 35",
            ("серова", "35"),
        ),
        # пр-кт + house with letter + квартира noise → drop кв, keep "26а"
        (
            "620091, Свердловская обл, г Екатеринбург, пр-кт Космонавтов, д 26а, кв 335",
            ("космонавтов", "26а"),
        ),
        # корпус noise "к 3" dropped
        ("ул Щербакова, д 77 к 3", ("щербакова", "77")),
        # bare typed form
        ("Серова 27", ("серова", "27")),
        # street starting with a digit
        ("8 Марта 204", ("8 марта", "204")),
        # human-typed full form with abbreviations
        (
            "Свердловская область, г. Екатеринбург, ул. Серова, д. 27",
            ("серова", "27"),
        ),
        # bare forms (validated prod addresses present in base)
        ("Малышева 30", ("малышева", "30")),
        ("Сурикова 31", ("сурикова", "31")),
        ("Шаумяна 28", ("шаумяна", "28")),
        ("Щербакова 77", ("щербакова", "77")),
        # not-in-base bare forms still must PARSE (matcher returns None, not parser)
        ("Космонавтов 7", ("космонавтов", "7")),
        ("Космонавтов 26", ("космонавтов", "26")),
        ("Педагогическая 15", ("педагогическая", "15")),
        ("Фрунзе 75", ("фрунзе", "75")),
        ("Белинского 83", ("белинского", "83")),
    ],
)
def test_parse_street_house_valid_forms(address: str, expected: tuple[str, str]) -> None:
    assert _parse_street_house(address) == expected


@pytest.mark.parametrize(
    "address",
    [
        "",
        "   ",
        "полный мусор без дома",
        "Екатеринбург",
        "Свердловская область",
    ],
)
def test_parse_street_house_returns_none_for_garbage(address: str) -> None:
    assert _parse_street_house(address) is None


# ── _cadastral_house_match: mocked FDW session ───────────────────────────────


def test_house_match_returns_suggestion_when_row_found() -> None:
    """DB returns a row → GeocodeSuggestion built from it."""
    row = MagicMock()
    row.readable_address = "Свердловская область, г. Екатеринбург, ул. Серова, д. 27"
    row.lat = 56.81188
    row.lon = 60.59739

    db = MagicMock()
    result = MagicMock()
    result.first.return_value = row
    db.execute.return_value = result

    hit = _cadastral_house_match(db, "серова", "27")

    assert hit is not None
    assert isinstance(hit, GeocodeSuggestion)
    assert hit.kind == "house"
    assert hit.lat == 56.81188
    assert hit.lon == 60.59739
    assert hit.full_address == row.readable_address


def test_house_match_returns_none_when_no_row() -> None:
    """No matching row → None."""
    db = MagicMock()
    result = MagicMock()
    result.first.return_value = None
    db.execute.return_value = result

    assert _cadastral_house_match(db, "космонавтов", "7") is None


def test_house_match_returns_none_on_db_error() -> None:
    """FDW raises → None, no exception bubbles out (graceful)."""
    db = MagicMock()
    db.execute.side_effect = RuntimeError("FDW connection failed")

    assert _cadastral_house_match(db, "серова", "27") is None


def test_house_match_returns_none_for_non_numeric_house() -> None:
    """House with no leading digits → None before any DB call."""
    db = MagicMock()

    assert _cadastral_house_match(db, "серова", "abc") is None
    db.execute.assert_not_called()


def test_house_match_passes_only_digits_as_regex_param() -> None:
    """house='26а' → :house_digits bound param is '26' (letter stripped for regex)."""
    row = MagicMock()
    row.readable_address = "г. Екатеринбург, пр-кт Космонавтов, д. 26а"
    row.lat = 56.9
    row.lon = 60.6
    db = MagicMock()
    result = MagicMock()
    result.first.return_value = row
    db.execute.return_value = result

    _cadastral_house_match(db, "космонавтов", "26а")

    # second positional arg to execute() is the bound-params dict
    params = db.execute.call_args.args[1]
    assert params["house_digits"] == "26"
    assert params["house_full"] == "26а"
    assert params["street"] == "космонавтов"


# ── geocode() wiring ─────────────────────────────────────────────────────────


async def test_geocode_uses_house_match_before_legacy_forward() -> None:
    """Parse succeeds + house-match hits → returns result, legacy forward NOT called."""
    db = MagicMock()
    hit = GeocodeSuggestion(
        label="ул. Серова, д. 27, Екатеринбург",
        full_address="ул. Серова, д. 27, Екатеринбург",
        lat=56.81188,
        lon=60.59739,
        kind="house",
    )

    with (
        patch("app.services.geocoder._cache_get", return_value=None),
        patch("app.services.geocoder._geoportal_house_match", return_value=None),
        patch(
            "app.services.geocoder._cadastral_house_match",
            return_value=hit,
        ) as mock_house,
        patch(
            "app.services.geocoder._cadastral_forward_sync",
        ) as mock_forward,
        patch("app.services.geocoder._cache_put"),
        patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
    ):
        result = await geocode("Серова 27", db)

    assert result is not None
    assert result.lat == 56.81188
    assert result.lon == 60.59739
    assert result.confidence == "exact"
    mock_house.assert_called_once()
    # house-match hit → legacy raw-ILIKE forward never invoked
    mock_forward.assert_not_called()
    mock_yandex.assert_not_called()


async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() -> None:
    """Parse succeeds but house-match returns None → legacy forward IS called."""
    db = MagicMock()
    legacy_hit = GeocodeSuggestion(
        label="ул. Дублёр, 1",
        full_address="ул. Дублёр, 1, Екатеринбург",
        lat=56.84,
        lon=60.61,
        kind="house",
    )

    with (
        patch("app.services.geocoder._cache_get", return_value=None),
        patch("app.services.geocoder._geoportal_house_match", return_value=None),
        patch(
            "app.services.geocoder._cadastral_house_match",
            return_value=None,
        ) as mock_house,
        patch(
            "app.services.geocoder._cadastral_forward_sync",
            return_value=[legacy_hit],
        ) as mock_forward,
        patch("app.services.geocoder._cache_put"),
        patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex,
    ):
        result = await geocode("Серова 27", db)

    assert result is not None
    assert result.lat == 56.84
    mock_house.assert_called_once()
    mock_forward.assert_called_once()
    mock_yandex.assert_not_called()


async def test_geocode_skips_house_match_when_parse_fails() -> None:
    """Unparseable address → house-match NOT called, legacy forward used."""
    db = MagicMock()

    with (
        patch("app.services.geocoder._cache_get", return_value=None),
        patch(
            "app.services.geocoder._cadastral_house_match",
        ) as mock_house,
        patch(
            "app.services.geocoder._cadastral_forward_sync",
            return_value=[],
        ) as mock_forward,
        patch("app.services.geocoder._cache_put"),
        patch("app.services.geocoder.settings") as mock_settings,
        patch(
            "app.services.geocoder._nominatim_lookup",
            new_callable=AsyncMock,
            return_value=None,
        ),
    ):
        mock_settings.yandex_geocoder_api_key = None
        result = await geocode("полный мусор без дома", db)

    assert result is None
    mock_house.assert_not_called()
    mock_forward.assert_called_once()


# ── geocode() non-EKB city gate (#11) ────────────────────────────────────────


async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None:
    """Non-EKB oblast-адрес (Нижний Тагил) → EKB-only локальные тиры (geoportal/
    cadastral) НЕ вызываются, результат идёт от oblast-aware Nominatim (#11).

    Без гейта «проспект Ленина, 1» коллизирует с одноимённым ЕКБ-домом через
    geoportal/cadastral и снапается в ЕКБ вместо Нижнего Тагила.
    """
    db = MagicMock()
    nominatim_result = GeocodeResult(
        lat=57.905,
        lon=59.950,
        full_address="проспект Ленина, 1, Нижний Тагил",
        provider="nominatim",
        confidence="approximate",
    )

    with (
        patch("app.services.geocoder._cache_get", return_value=None),
        patch("app.services.geocoder._geoportal_house_match") as mock_geoportal,
        patch("app.services.geocoder._cadastral_house_match") as mock_house,
        patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
        patch("app.services.geocoder._cache_put"),
        patch("app.services.geocoder.settings") as mock_settings,
        patch(
            "app.services.geocoder._nominatim_lookup",
            new_callable=AsyncMock,
            return_value=nominatim_result,
        ) as mock_nominatim,
    ):
        mock_settings.yandex_geocoder_api_key = None
        result = await geocode("Нижний Тагил, проспект Ленина, 1", db)

    assert result is not None
    assert result.lat == pytest.approx(57.905)
    assert result.lon == pytest.approx(59.950)
    mock_geoportal.assert_not_called()
    mock_house.assert_not_called()
    mock_forward.assert_not_called()
    mock_nominatim.assert_called_once()


# ── suggest() wiring ─────────────────────────────────────────────────────────


async def test_suggest_uses_house_match_before_legacy_forward() -> None:
    """Parse + house-match hit → returns single suggestion, legacy forward not called."""
    db = MagicMock()
    hit = GeocodeSuggestion(
        label="ул. Серова, д. 27",
        full_address="ул. Серова, д. 27, Екатеринбург",
        lat=56.81188,
        lon=60.59739,
        kind="house",
    )

    with (
        patch(
            "app.services.geocoder._cadastral_house_match",
            return_value=hit,
        ) as mock_house,
        patch(
            "app.services.geocoder._cadastral_forward_sync",
        ) as mock_forward,
        patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
    ):
        results = await suggest("Серова 27", db=db, limit=8)

    assert len(results) == 1
    assert results[0].lat == 56.81188
    mock_house.assert_called_once()
    mock_forward.assert_not_called()
    mock_yandex.assert_not_called()


async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() -> None:
    """Parse succeeds, house-match None → legacy forward IS called."""
    db = MagicMock()
    legacy = [
        GeocodeSuggestion(
            label="ул. Дублёр, 1",
            full_address="ул. Дублёр, 1, Екатеринбург",
            lat=56.84,
            lon=60.61,
            kind="house",
        )
    ]

    with (
        patch(
            "app.services.geocoder._cadastral_house_match",
            return_value=None,
        ) as mock_house,
        patch(
            "app.services.geocoder._cadastral_forward_sync",
            return_value=legacy,
        ) as mock_forward,
        patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
    ):
        results = await suggest("Серова 27", db=db, limit=8)

    assert len(results) == 1
    assert results[0].lat == 56.84
    mock_house.assert_called_once()
    mock_forward.assert_called_once()
    mock_yandex.assert_not_called()


async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
    """Non-EKB oblast query (Нижний Тагил) → EKB-only cadastral Tier 1
    (`_cadastral_house_match` / `_cadastral_forward_sync`) NOT called; falls
    through to external tiers, same root cause as `geocode()` (#11)."""
    db = MagicMock()
    nominatim_hit = [
        GeocodeSuggestion(
            label="проспект Ленина, 1",
            full_address="проспект Ленина, 1, Нижний Тагил",
            lat=57.905,
            lon=59.950,
            kind="house",
        )
    ]

    with (
        patch("app.services.geocoder._cadastral_house_match") as mock_house,
        patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
        patch("app.services.geocoder.settings") as mock_settings,
        patch(
            "app.services.geocoder._nominatim_suggest",
            new_callable=AsyncMock,
            return_value=nominatim_hit,
        ) as mock_nominatim,
    ):
        mock_settings.dadata_api_token = None
        mock_settings.yandex_geocoder_api_key = None
        results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8)

    assert len(results) == 1
    assert results[0].lat == pytest.approx(57.905)
    mock_house.assert_not_called()
    mock_forward.assert_not_called()
    mock_nominatim.assert_called_once()
