"""Тесты для ЕКБ/oblast66 bbox-хелперов geocoder (#1871, oblast-66 expansion).

Покрывают:
- is_within_ekb_bbox_wide — ingest-guard (avito_detail координаты);
- is_within_ekb_bbox — geocoder tight-фильтр (поведение НЕ изменилось после DRY-рефактора);
- инвариант WIDE ⊇ TIGHT;
- OBLAST66_BBOX / is_within_oblast66_bbox — генеральный bbox всей области (region 66),
  инвариант OBLAST66 ⊇ TIGHT, дальние города области, известное ограничение (Тюмень
  внутри bbox координатно — компенсируется region cross-check на accept-сайтах);
- _has_oblast_marker — word-boundary matching (не substring);
- _nominatim_query / _yandex_lookup accept-сайты — two-pass tie-break (tight ЕКБ
  приоритетнее) + region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
"""

from unittest.mock import patch

import httpx
import pytest

from app.services.geocoder import (
    EKB_BBOX_TIGHT,
    EKB_BBOX_WIDE,
    OBLAST66_BBOX,
    _has_oblast_marker,
    _names_non_ekb_city,
    _nominatim_query,
    _yandex_lookup,
    is_within_ekb_bbox,
    is_within_ekb_bbox_wide,
    is_within_oblast66_bbox,
)

# Центр ЕКБ (Плотинка) — внутри обоих bbox.
EKB_CENTER = (56.838, 60.605)


@pytest.mark.parametrize(
    "lat,lon,expected",
    [
        (*EKB_CENTER, True),  # центр ЕКБ
        (56.797713, 60.609135, True),  # реальная EKB-карточка из avito-fixture
        (59.9386, 30.3141, False),  # Питер (#1871)
        (57.1530, 65.5343, False),  # Тюмень (#1871)
        (54.7388, 55.9721, False),  # Уфа (#1871)
        (57.0, 60.5, True),  # приграничье севернее tight, но в wide (В.Пышма зона)
    ],
)
def test_is_within_ekb_bbox_wide(lat: float, lon: float, expected: bool) -> None:
    assert is_within_ekb_bbox_wide(lat, lon) is expected


@pytest.mark.parametrize(
    "lat,lon",
    [
        (56.6, 60.3),  # юго-западный угол wide
        (57.1, 60.9),  # северо-восточный угол wide
        (56.6, 60.9),  # юго-восточный угол
        (57.1, 60.3),  # северо-западный угол
    ],
)
def test_wide_bbox_boundary_inclusive(lat: float, lon: float) -> None:
    """Границы wide-bbox включаются (inclusive)."""
    assert is_within_ekb_bbox_wide(lat, lon) is True


def test_wide_bbox_just_outside() -> None:
    """Чуть за границей wide-bbox → False."""
    assert is_within_ekb_bbox_wide(56.59, 60.605) is False  # южнее
    assert is_within_ekb_bbox_wide(57.11, 60.605) is False  # севернее
    assert is_within_ekb_bbox_wide(56.838, 60.29) is False  # западнее
    assert is_within_ekb_bbox_wide(56.838, 60.91) is False  # восточнее


def test_tight_bbox_behavior_unchanged() -> None:
    """geocoder tight-фильтр: те же числа, что и до DRY-рефактора (60.40-60.85 / 56.65-56.95)."""
    # внутри
    assert is_within_ekb_bbox(56.838, 60.605) is True
    # границы tight inclusive
    assert is_within_ekb_bbox(56.65, 60.40) is True
    assert is_within_ekb_bbox(56.95, 60.85) is True
    # чуть за tight → False (но в wide)
    assert is_within_ekb_bbox(56.64, 60.605) is False
    assert is_within_ekb_bbox(56.96, 60.605) is False
    assert is_within_ekb_bbox(56.838, 60.39) is False
    assert is_within_ekb_bbox(56.838, 60.86) is False


def test_wide_strictly_contains_tight() -> None:
    """Инвариант: всё, что прошло бы tight, проходит и wide.

    Гарантирует, что ingest-guard не режет ничего, что geocoder сам бы пропустил.
    """
    t_lat_min, t_lat_max, t_lon_min, t_lon_max = EKB_BBOX_TIGHT
    w_lat_min, w_lat_max, w_lon_min, w_lon_max = EKB_BBOX_WIDE
    assert w_lat_min <= t_lat_min
    assert w_lat_max >= t_lat_max
    assert w_lon_min <= t_lon_min
    assert w_lon_max >= t_lon_max
    # точечная проверка на углах tight
    for lat in (t_lat_min, t_lat_max):
        for lon in (t_lon_min, t_lon_max):
            assert is_within_ekb_bbox(lat, lon) is True
            assert is_within_ekb_bbox_wide(lat, lon) is True


# ── OBLAST66_BBOX (region 66 expansion) ─────────────────────────────────────


def test_oblast66_strictly_contains_tight() -> None:
    """Инвариант: OBLAST66_BBOX ⊇ EKB_BBOX_TIGHT — генерализация не режет ЕКБ.

    Мирроор test_wide_strictly_contains_tight, но для областного bbox.
    """
    t_lat_min, t_lat_max, t_lon_min, t_lon_max = EKB_BBOX_TIGHT
    o_lat_min, o_lat_max, o_lon_min, o_lon_max = OBLAST66_BBOX
    assert o_lat_min <= t_lat_min
    assert o_lat_max >= t_lat_max
    assert o_lon_min <= t_lon_min
    assert o_lon_max >= t_lon_max
    for lat in (t_lat_min, t_lat_max):
        for lon in (t_lon_min, t_lon_max):
            assert is_within_ekb_bbox(lat, lon) is True
            assert is_within_oblast66_bbox(lat, lon) is True


@pytest.mark.parametrize(
    "lat,lon,label",
    [
        (60.6935, 60.4235, "Ивдель"),
        (59.6297, 60.5541, "Серов"),
        (58.0378, 65.2753, "Тавда"),
        (56.6152, 57.7666, "Красноуфимск"),
    ],
)
def test_oblast66_accepts_far_towns(lat: float, lon: float, label: str) -> None:
    """Дальние города области (вне EKB tight/wide) — приняты oblast66-bbox."""
    assert is_within_oblast66_bbox(lat, lon) is True, f"{label} должен быть внутри OBLAST66_BBOX"


def test_oblast66_bbox_admits_tyumen_by_design() -> None:
    """OBLAST66_BBOX — генеральный (щедрый) bbox, координатно admits Тюмень.

    Это ЗНАЕМОЕ и намеренное ограничение самого bbox-хелпера (дешевле
    false-positive у границы, чем false-negative на корректном адресе области).
    Реальное отсечение Тюмени происходит на accept-сайтах через region
    cross-check — см. test_nominatim_query_rejects_out_of_region_only_candidate /
    test_yandex_lookup_rejects_out_of_region_only_candidate ниже.
    """
    assert is_within_oblast66_bbox(57.1530, 65.5343) is True


# ── _has_oblast_marker — word/phrase-boundary matching (не substring) ───────


@pytest.mark.parametrize(
    "text,expected",
    [
        ("екатеринбург, малышева 30", True),
        ("нижний тагил, ленина 10", True),
        ("свердловская область, серова 27", True),
        ("каменск-уральский, мира 1", True),
        ("заречный, мира 5", True),  # ЗАТО-город (без district-префикса)
        # false-positives, которые substring-match ловил ошибочно (#oblast66 bug):
        ("серова 27", False),  # НЕ город "серов"
        ("ирбитская 5", False),  # НЕ город "ирбит"
        ("асбестовский пер., 3", False),  # НЕ город "асбест"
        ("невьянский пер., 10", False),  # НЕ город "невьянск"
        ("богдановича, 22", False),  # НЕ город "богданович"
        ("мкр заречный, мира 5", False),  # район внутри города, не город-ЗАТО
        ("микрорайон заречный, мира 5", False),
        ("малышева 30", False),  # обычный ЕКБ bare-адрес
    ],
)
def test_has_oblast_marker(text: str, expected: bool) -> None:
    assert _has_oblast_marker(text) is expected


# ── _names_non_ekb_city — gate для EKB-only локальных tier'ов (#11) ─────────


@pytest.mark.parametrize(
    "address,expected",
    [
        ("Нижний Тагил, проспект Ленина, 1", True),
        ("г. Екатеринбург, проспект Ленина, 1", False),
        ("проспект Ленина, 1", False),
        ("Серова 27", False),  # word-boundary — НЕ город "Серов"
        ("мкр Заречный, ул. Ленина 5", False),  # район внутри города, не ЗАТО Заречный
        ("Каменск-Уральский, ул. Ленина 1", True),
        # явное "Екатеринбург" перевешивает омоним соседнего города в названии
        # ЖК/района внутри города (реальный false-positive из прод-кэша, #11):
        ("24, опалихинская улица, заречный, Верх-Исетский район, Екатеринбург", False),
        # word-boundary на "екатеринбург" — "Екатеринбургское шоссе" НЕ город
        # Екатеринбург (реальная улица в городах-спутниках, напр. Первоуральске):
        ("Первоуральск, Екатеринбургское шоссе, 5", True),
        # расширенный gazetteer (deep-review fast-follow):
        ("Верхняя Пышма, ул. Ленина 5", True),
        ("Сухой Лог, ул. Мира 3", True),
    ],
)
def test_names_non_ekb_city(address: str, expected: bool) -> None:
    assert _names_non_ekb_city(address) is expected


# ── Accept-site two-pass tie-break + region cross-check ─────────────────────


async def test_nominatim_query_prefers_tight_ekb_over_oblast_rank0() -> None:
    """rank0 — Серов (внутри OBLAST66, вне tight), rank1 — ЕКБ (tight).

    Two-pass tie-break должен выбрать rank1 (tight), а не rank0, несмотря на то
    что Nominatim ранжировал его вторым.
    """
    payload = [
        {"lat": "59.6297", "lon": "60.5541", "address": {"state": "Свердловская область"}},
        {"lat": "56.838", "lon": "60.605", "address": {"state": "Свердловская область"}},
    ]

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json=payload)

    transport = httpx.MockTransport(handler)
    async with httpx.AsyncClient(transport=transport) as client:
        item = await _nominatim_query(client, "Серова 27")

    assert item is not None
    assert float(item["lat"]) == pytest.approx(56.838)
    assert float(item["lon"]) == pytest.approx(60.605)


async def test_nominatim_query_rejects_out_of_region_only_candidate() -> None:
    """Единственный кандидат — Тюмень (координатно внутри OBLAST66, но регион другой)
    → region cross-check отсекает его → None (не просто "первый как есть")."""
    payload = [{"lat": "57.1522", "lon": "65.5272", "address": {"state": "Тюменская область"}}]

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json=payload)

    transport = httpx.MockTransport(handler)
    async with httpx.AsyncClient(transport=transport) as client:
        item = await _nominatim_query(client, "какой-то мусорный адрес")

    assert item is None


async def test_nominatim_query_accepts_oblast_wide_when_region_absent() -> None:
    """Нет tight-совпадения, `address.state` отсутствует (Nominatim не всегда его
    отдаёт) — fallback на oblast-bbox (Серов), region cross-check не блокирует."""
    payload = [{"lat": "59.6297", "lon": "60.5541"}]

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json=payload)

    transport = httpx.MockTransport(handler)
    async with httpx.AsyncClient(transport=transport) as client:
        item = await _nominatim_query(client, "Серов, Ленина 1")

    assert item is not None
    assert float(item["lat"]) == pytest.approx(59.6297)


_REAL_ASYNC_CLIENT = httpx.AsyncClient


def _yandex_client_factory(transport: httpx.MockTransport):
    """Drop-in replacement для httpx.AsyncClient внутри geocoder._yandex_lookup."""

    def factory(*_: object, **__: object) -> httpx.AsyncClient:
        return _REAL_ASYNC_CLIENT(transport=transport)

    return factory


def _yandex_geo_object(lat: str, lon: str, admin_area: str | None) -> dict:
    meta: dict = {"precision": "exact", "text": "тест"}
    if admin_area is not None:
        meta["AddressDetails"] = {
            "Country": {"AdministrativeArea": {"AdministrativeAreaName": admin_area}}
        }
    return {
        "GeoObject": {
            "Point": {"pos": f"{lon} {lat}"},
            "metaDataProperty": {"GeocoderMetaData": meta},
        }
    }


def _yandex_payload(members: list[dict]) -> dict:
    return {"response": {"GeoObjectCollection": {"featureMember": members}}}


async def test_yandex_lookup_prefers_tight_ekb_over_oblast_rank0() -> None:
    """Тот же tie-break сценарий, что и для Nominatim, но для Yandex top-5."""
    members = [
        _yandex_geo_object("59.6297", "60.5541", "Свердловская область"),  # Серов, rank0
        _yandex_geo_object("56.838", "60.605", "Свердловская область"),  # ЕКБ tight, rank1
    ]
    payload = _yandex_payload(members)

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json=payload)

    transport = httpx.MockTransport(handler)
    with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
        result = await _yandex_lookup("Серова 27", "fake-key")

    assert result is not None
    assert result.lat == pytest.approx(56.838)
    assert result.lon == pytest.approx(60.605)


async def test_yandex_lookup_rejects_out_of_region_only_candidate() -> None:
    """Единственный кандидат — Тюмень (bbox admits координатно, регион другой) →
    region cross-check отсекает даже в ultimate as-is fallback → None."""
    members = [_yandex_geo_object("57.1522", "65.5272", "Тюменская область")]
    payload = _yandex_payload(members)

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json=payload)

    transport = httpx.MockTransport(handler)
    with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
        result = await _yandex_lookup("какой-то мусорный адрес", "fake-key")

    assert result is None
