"""Unit tests for the EKB geoportal building ingest pipeline.

Covers:
- ekb_geoportal_client: GeoJSON FeatureCollection → TopoBuilding parsing + centroid,
  skip of features without street/house, graceful empty on bad input.
- ekb_geoportal_ingest: house normalization ("7 б" → "7б"), part-feature grouping,
  tile grid bounds.
- geocoder._geoportal_house_match + geocode() wiring: mock db row → GeocodeResult
  (provider="cache", confidence="exact"); parse-fail / no-row → falls through.

All network + DB mocked.

Легаси `app.services.scrapers.ekb_geoportal_client` удалён (#2277 финальный шаг
scraper_kit-миграции, 0 runtime importers) — client-тесты переведены на kit-
эквивалент `scraper_kit.providers.ekb_geoportal.client` (уже используемый живым
`app.tasks.ekb_geoportal_ingest`), покрытие не изменилось.
"""

from __future__ import annotations

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

import pytest

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

_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)


from scraper_kit.providers.ekb_geoportal.client import (  # noqa: E402
    TopoBuilding,
    parse_feature_collection,
)

from app.tasks.ekb_geoportal_ingest import (  # noqa: E402
    _group_buildings,
    _tile_grid,
    normalize_house,
    normalize_street,
)

# ── client: GeoJSON parsing + centroid ───────────────────────────────────────


def _multipolygon(coords: list) -> dict:
    return {"type": "MultiPolygon", "coordinates": coords}


# A unit square [0,0]-[2,2] → centroid (1, 1) in (lon, lat).
_SQUARE = [[[[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0], [0.0, 0.0]]]]


def test_parse_feature_collection_basic() -> None:
    fc = {
        "type": "FeatureCollection",
        "features": [
            {
                "type": "Feature",
                "geometry": _multipolygon(_SQUARE),
                "properties": {
                    "topo_street": "Космонавтов",
                    "topo_num_house": "7б",
                    "topo_floor": 9,
                    "topo_purpose": "жилое",
                    "topo_material": "3",
                    "key": 12345,
                },
            }
        ],
    }
    out = parse_feature_collection(fc)
    assert len(out) == 1
    b = out[0]
    assert isinstance(b, TopoBuilding)
    assert b.street == "Космонавтов"
    assert b.house == "7б"
    assert b.floors == 9
    assert b.purpose == "жилое"
    assert b.material == "3"
    assert b.key == 12345
    # centroid of the [0,0]-[2,2] square = (1, 1): lon=x, lat=y
    assert b.lon == pytest.approx(1.0)
    assert b.lat == pytest.approx(1.0)


def test_parse_skips_features_without_street_or_house() -> None:
    fc = {
        "features": [
            {
                "geometry": _multipolygon(_SQUARE),
                "properties": {"topo_street": "", "topo_num_house": "7б"},
            },
            {
                "geometry": _multipolygon(_SQUARE),
                "properties": {"topo_street": "Ленина", "topo_num_house": "  "},
            },
            {
                "geometry": _multipolygon(_SQUARE),
                "properties": {"topo_num_house": "5"},  # no street key
            },
        ]
    }
    assert parse_feature_collection(fc) == []


def test_parse_skips_feature_without_geometry() -> None:
    fc = {
        "features": [
            {
                "geometry": None,
                "properties": {"topo_street": "Ленина", "topo_num_house": "5"},
            }
        ]
    }
    assert parse_feature_collection(fc) == []


def test_parse_empty_or_malformed_collection() -> None:
    assert parse_feature_collection({}) == []
    assert parse_feature_collection({"features": "nope"}) == []
    assert parse_feature_collection({"features": [None, 42]}) == []


# ── normalization ────────────────────────────────────────────────────────────


@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        ("7 б", "7б"),
        ("7Б", "7б"),
        ("  7б  ", "7б"),
        ("1в/1", "1в/1"),
        ("12", "12"),
        ("3 А", "3а"),
    ],
)
def test_normalize_house(raw: str, expected: str) -> None:
    assert normalize_house(raw) == expected


def test_normalize_street() -> None:
    assert normalize_street("  Космонавтов ") == "космонавтов"
    assert normalize_street("8 Марта") == "8 марта"


# ── grouping ─────────────────────────────────────────────────────────────────


def test_group_buildings_merges_parts() -> None:
    parts = [
        TopoBuilding("Космонавтов", "7 б", 9, "жилое", "3", 1, lat=2.0, lon=4.0),
        TopoBuilding("Космонавтов", "7б", None, None, None, 2, lat=4.0, lon=6.0),
        TopoBuilding("Космонавтов", "7а", 5, None, None, 3, lat=1.0, lon=1.0),
    ]
    grouped = _group_buildings(parts)
    # "7 б" and "7б" normalize to the same house → one merged entry
    assert ("космонавтов", "7б") in grouped
    assert ("космонавтов", "7а") in grouped
    assert len(grouped) == 2

    merged = grouped[("космонавтов", "7б")]
    # centroid = mean of (2,4) and (4,6)
    assert merged["lat_sum"] / merged["n"] == pytest.approx(3.0)
    assert merged["lon_sum"] / merged["n"] == pytest.approx(5.0)
    assert merged["floors"] == 9  # max across parts (other is None)
    assert merged["purpose"] == "жилое"
    assert merged["material"] == "3"
    assert sorted(merged["source_keys"]) == [1, 2]


# ── tile grid ────────────────────────────────────────────────────────────────


def test_tile_grid_covers_bbox() -> None:
    tiles = _tile_grid(60.45, 56.70, 60.85, 56.95)
    assert len(tiles) > 0
    # every tile within bbox bounds
    for lon_lo, lat_lo, lon_hi, lat_hi in tiles:
        assert 60.45 <= lon_lo < lon_hi <= 60.85 + 1e-9
        assert 56.70 <= lat_lo < lat_hi <= 56.95 + 1e-9
    # first tile anchored at SW corner
    assert tiles[0][0] == pytest.approx(60.45)
    assert tiles[0][1] == pytest.approx(56.70)


# ── geocoder._geoportal_house_match + geocode() wiring ───────────────────────


def test_geoportal_house_match_returns_suggestion() -> None:
    from app.services.geocoder import GeocodeSuggestion, _geoportal_house_match

    row = MagicMock()
    row.street = "Космонавтов"
    row.house = "7б"
    row.lat = 56.864
    row.lon = 60.611

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

    hit = _geoportal_house_match(db, "Космонавтов", "7 б")
    assert isinstance(hit, GeocodeSuggestion)
    assert hit.kind == "house"
    assert hit.lat == 56.864
    assert hit.lon == 60.611
    # house param normalized: "7 б" → "7б"
    params = db.execute.call_args.args[1]
    assert params["house"] == "7б"
    assert params["street"] == "космонавтов"


def test_geoportal_house_match_none_when_no_row() -> None:
    from app.services.geocoder import _geoportal_house_match

    db = MagicMock()
    result = MagicMock()
    result.first.return_value = None
    db.execute.return_value = result
    assert _geoportal_house_match(db, "Космонавтов", "7б") is None


def test_geoportal_house_match_none_on_db_error() -> None:
    from app.services.geocoder import _geoportal_house_match

    db = MagicMock()
    db.execute.side_effect = RuntimeError("table missing")
    assert _geoportal_house_match(db, "Космонавтов", "7б") is None


def test_geoportal_house_match_none_for_empty_house() -> None:
    from app.services.geocoder import _geoportal_house_match

    db = MagicMock()
    assert _geoportal_house_match(db, "Космонавтов", "   ") is None
    db.execute.assert_not_called()


async def test_geocode_uses_geoportal_first() -> None:
    """Parse + geoportal hit → GeocodeResult(provider=cache, exact); cad NOT called."""
    from app.services.geocoder import GeocodeSuggestion, geocode

    db = MagicMock()
    hit = GeocodeSuggestion(
        label="Космонавтов, 7б",
        full_address="Космонавтов, 7б",
        lat=56.864,
        lon=60.611,
        kind="house",
    )

    with (
        patch("app.services.geocoder._cache_get", return_value=None),
        patch("app.services.geocoder._geoportal_house_match", return_value=hit) as mock_geo,
        patch("app.services.geocoder._cadastral_house_match") as mock_cad,
        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("Космонавтов 7б", db)

    assert result is not None
    assert result.provider == "cache"
    assert result.confidence == "exact"
    assert result.lat == 56.864
    assert result.lon == 60.611
    mock_geo.assert_called_once()
    # geoportal hit short-circuits everything downstream
    mock_cad.assert_not_called()
    mock_forward.assert_not_called()
    mock_yandex.assert_not_called()


async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> None:
    """Geoportal None → cadastral house-match IS called next."""
    from app.services.geocoder import GeocodeSuggestion, geocode

    db = MagicMock()
    cad_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) as mock_geo,
        patch("app.services.geocoder._cadastral_house_match", return_value=cad_hit) as mock_cad,
        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
    mock_geo.assert_called_once()
    mock_cad.assert_called_once()
    mock_forward.assert_not_called()
    mock_yandex.assert_not_called()


async def test_geocode_skips_geoportal_when_parse_fails() -> None:
    """Unparseable address → geoportal NOT called."""
    from app.services.geocoder import geocode

    db = MagicMock()

    with (
        patch("app.services.geocoder._cache_get", return_value=None),
        patch("app.services.geocoder._geoportal_house_match") as mock_geo,
        patch("app.services.geocoder._cadastral_house_match") as mock_cad,
        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_geo.assert_not_called()
    mock_cad.assert_not_called()
    mock_forward.assert_called_once()
