"""Geocoder service — address → lat/lon.

Стратегия:
- Cache lookup в `geocode_cache` (Postgres) — TTL 90 дней
- Cache miss → Yandex Geocoder (если есть key) → fallback Nominatim
- Результат сохраняется в кэш для последующих вызовов

Используется в:
- /api/v1/trade-in/estimate (вход — адрес от пользователя)
- /api/v1/geocode/lookup (debug endpoint)
- scraper jobs (когда нужно по адресу определить координаты)
"""

from __future__ import annotations

import asyncio
import logging
import re
from dataclasses import dataclass
from typing import Literal

import httpx
from sqlalchemy import text
from sqlalchemy.orm import Session
from tenacity import retry, stop_after_attempt, wait_exponential

from app.core.config import settings
from app.services import dadata

logger = logging.getLogger(__name__)


# ── Result type ──────────────────────────────────────────────────────────────
@dataclass(frozen=True, slots=True)
class GeocodeResult:
    lat: float
    lon: float
    full_address: str
    provider: Literal["nominatim", "yandex", "cache"]
    confidence: Literal["exact", "approximate", "locality"] = "approximate"


# ── EKB bounding boxes ───────────────────────────────────────────────────────
# Два bbox с разным назначением:
#   * TIGHT — для geocoder-фильтрации результатов Yandex/Nominatim при опечатках
#     (не вернуть Челябинск/Пермь как «совпадение» по фуззи-матчу). Узкий по дизайну.
#   * WIDE  — для ingest-guard на координаты, ПРИШЕДШИЕ ИЗВНЕ (avito_detail
#     data-map-lat/lon). Чуть шире, чтобы не резать легитимное приграничье ЕКБ
#     (Верхняя Пышма / Среднеуральск / Берёзовский), при этом отсекая
#     Питер/Тюмень/Уфу (#1871). WIDE строго содержит TIGHT, поэтому всё, что
#     прошло бы tight-фильтр, проходит и wide-guard.
# bbox = (lat_min, lat_max, lon_min, lon_max).
EKB_BBOX_TIGHT = (56.65, 56.95, 60.40, 60.85)
EKB_BBOX_WIDE = (56.6, 57.1, 60.3, 60.9)


def is_within_ekb_bbox(
    lat: float, lon: float, bbox: tuple[float, float, float, float] = EKB_BBOX_TIGHT
) -> bool:
    """True если (lat, lon) внутри bbox (inclusive). bbox = (lat_min, lat_max, lon_min, lon_max)."""
    lat_min, lat_max, lon_min, lon_max = bbox
    return lat_min <= lat <= lat_max and lon_min <= lon <= lon_max


def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
    """Ingest-guard: True если координаты в широком ЕКБ-bbox (#1871).

    Используется для валидации координат из avito detail-страниц перед записью в БД.
    Шире geocoder-tight, поэтому не режет легитимное приграничье, но отсекает не-ЕКБ
    (Питер/Тюмень/Уфа).
    """
    return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE)


# ── Sverdlovsk oblast (region 66) ────────────────────────────────────────────
# Генеральный bbox всей области — от южной границы (ЕКБ/Полевской) до
# северных городов (Ивдель/Североуральск/Краснотурьинск). Специально щедрый:
# используется как fallback-accept для провайдеров БЕЗ структурного region-поля
# (сырые Point.pos от Yandex/Nominatim) — когда нет ФИАС/КЛАДР для точной
# проверки region==66. Дороже false-positive у самой границы соседних регионов,
# дешевле false-negative (выбросить корректный областной адрес — исходный баг).
# bbox = (lat_min, lat_max, lon_min, lon_max).
OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6)

# Nominatim/Yandex viewport для областного (не только ЕКБ) поиска — тот же
# формат что EKB_BBOX ниже, но покрывает всю область. viewbox — Nominatim
# (lon1,lat1,lon2,lat2, NW→SE); ll/spn — Yandex центр+охват (lon,lat).
OBLAST66_VIEWBOX = {
    "viewbox": "56.7,62.2,66.6,55.8",
    "ll": "61.65,59.0",
    "spn": "9.9,6.4",
}


def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
    """True если (lat, lon) в генеральном bbox Свердловской области (регион 66)."""
    return is_within_ekb_bbox(lat, lon, OBLAST66_BBOX)


# Крупные города области (кроме ЕКБ) — НЕ исчерпывающий список (в области ~30
# городов), покрывает основные региональные центры. Используется чтобы:
#   (a) не навязывать "Екатеринбург, " в запрос, если пользователь уже указал
#       другой город региона (геокодер forward/suggest prefix-логика);
#   (b) как known-locality set в _yandex_reverse при перестановке "улица, город".
SVERDLOVSK_OBLAST_CITIES = frozenset(
    {
        "екатеринбург",
        "нижний тагил",
        "каменск-уральский",
        "первоуральск",
        "серов",
        "новоуральск",
        "ревда",
        "полевской",
        "асбест",
        "богданович",
        "ирбит",
        "красноуфимск",
        "верхняя пышма",
        "берёзовский",
        "березовский",
        "заречный",
        "качканар",
        "краснотурьинск",
        "североуральск",
        "ивдель",
        "тавда",
        "туринск",
        "сысерть",
        "среднеуральск",
        "дегтярск",
        "верхняя салда",
        "нижняя салда",
        "невьянск",
        "артёмовский",
        "артемовский",
        "камышлов",
        "алапаевск",
        "сухой лог",
        "кушва",
        "красноуральск",
        "карпинск",
        "нижняя тура",
        "верхний тагил",
        "нижние серги",
        # НЕ добавляем "лесной": ekb_geoportal_buildings содержит реальную ЕКБ-улицу
        # «Лесной» (3 дома) — точное совпадение целого слова, не отличимое от
        # города ЗАТО Лесной word-boundary'ом (в отличие от «Серова»/«Серов»,
        # где разные словоформы). Бары-адрес "Лесной, 5" без "Екатеринбург" и
        # без district-префикса ложно ушёл бы в non-EKB gate.
    }
)
SVERDLOVSK_OBLAST_REGION = "Свердловская область"

# Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
# пер.» — «Невьянск», «Богдановича» — «Богданович» (все реальные EKB-адреса,
# substring-матч ложно ронял "Екатеринбург, "-префикс на них → неверный город
# → неверная цена). Python `\b` для str-паттернов юникод-осведомлён (Cyrillic
# входит в `\w`), поэтому «серова» корректно НЕ матчит `\bсеров\b` (после
# «серов» идёт «а» — тоже \w, границы нет).
_OBLAST_CITY_RE = re.compile(
    r"\b(?:" + "|".join(re.escape(city) for city in SVERDLOVSK_OBLAST_CITIES) + r")\b"
)
_OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
# Приставки, после которых токен обозначает РАЙОН/микрорайон города, а не сам
# город региона: «мкр Заречный» — микрорайон ВНУТРИ другого города (не ЗАТО
# Заречный). Без этого исключения точное совпадение слова "заречный" всё равно
# ложно сработало бы (это не substring-баг, а реальная омонимия имени).
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р-н", "р-он", "район", "жк"})


def _has_oblast_marker(text_lower: str) -> bool:
    """True если текст уже содержит упоминание области/города региона 66.

    Используется чтобы НЕ навязывать "Екатеринбург, " в запрос, когда адрес
    уже привязан к другому городу/области — иначе получим двойной город
    ("Екатеринбург, Нижний Тагил, Ленина 10") и провайдер вернёт мусор/пусто.

    Матчинг — по границе слова/фразы (см. `_OBLAST_CITY_RE`), НЕ substring —
    и с исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
    """
    normalized = " ".join(text_lower.split())
    if _OBLAST_MARKER_RE.search(normalized):
        return True
    for m in _OBLAST_CITY_RE.finditer(normalized):
        prefix_words = normalized[: m.start()].split()
        if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
            continue  # «мкр Заречный» — район, не город-ЗАТО Заречный
        return True
    return False


# Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only
# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого
# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}.
_NON_EKB_OBLAST_CITIES = SVERDLOVSK_OBLAST_CITIES - {"екатеринбург"}
_NON_EKB_CITY_RE = re.compile(
    r"\b(?:" + "|".join(re.escape(c) for c in _NON_EKB_OBLAST_CITIES) + r")\b"
)
# Word-boundary, НЕ substring — «екатеринбургское шоссе» (реальная улица в
# городах-спутниках, напр. Первоуральске) не должно матчить как «Екатеринбург».
_EKATERINBURG_RE = re.compile(r"\bекатеринбург\b")


def _names_non_ekb_city(address: str) -> bool:
    """True если адрес явно называет город Свердловской области, ОТЛИЧНЫЙ от
    Екатеринбурга (по границе слова, с исключением district-приставок «мкр/р-н
    <город>»). Используется чтобы НЕ пускать EKB-only локальные тиры
    (geoportal/cadastral) на не-ЕКБ адрес: улица+дом, совпадающие с ЕКБ-домом
    (проспект Ленина 1 есть в обоих городах), иначе адрес снапается в ЕКБ (#11).

    Явное "Екатеринбург" в адресе имеет приоритет над любым омонимом другого
    города региона — иначе ЖК/посёлки с названием, совпадающим с другим
    городом области (ЖК «Заречный», пос. «Сысерть» внутри ЕКБ), ложно
    отсекали бы EKB-only тиры для реального ЕКБ-адреса.
    """
    normalized = " ".join(address.lower().split())
    # Явное "Екатеринбург" (отдельным словом — см. `_EKATERINBURG_RE`) в адресе
    # → это ЕКБ, даже если в тексте встречается омоним соседнего города (ЖК
    # «Заречный», пос. Сысерть в составе МО «город Екатеринбург» и т.п. —
    # реальные false-positive из прод-кэша). Word-boundary же не даёт слову
    # «Екатеринбургское» (шоссе в городах-спутниках) ложно сработать как город.
    if _EKATERINBURG_RE.search(normalized):
        return False
    for m in _NON_EKB_CITY_RE.finditer(normalized):
        prefix_words = normalized[: m.start()].split()
        if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
            continue  # «мкр Заречный» — район внутри другого города, не ЗАТО Заречный
        return True
    return False


# ── Address normalisation ───────────────────────────────────────────────────
def normalize_address(address: str) -> str:
    """Нормализация для cache lookup: lowercase + trim + collapse whitespace.

    «  Ул. МАЛЫШЕВА, 30 » → «ул. малышева, 30»
    """
    return " ".join(address.lower().strip().split())


# Согласные, которые часто пишут с одной буквой вместо двух (RU typos).
_DOUBLE_CONSONANTS = "лнмссккттпп"


def _typo_variants(query: str, limit: int = 6) -> list[str]:
    """Генерация вариантов с удвоением согласных — для случая когда пользователь
    написал «Цвилинга» вместо «Цвиллинга», «Толстова» вместо «Толстого» итд.

    Каждая позиция где есть одинокая согласная — кандидат на удвоение.
    Возвращаем до `limit` вариантов в порядке вероятности (ближе к началу слова → выше).
    """
    if len(query) < 3:
        return []
    variants: list[str] = []
    chars = list(query)
    for i in range(1, len(chars) - 1):
        c = chars[i].lower()
        if c not in _DOUBLE_CONSONANTS:
            continue
        prev_c = chars[i - 1].lower()
        next_c = chars[i + 1].lower()
        # Удваиваем только если соседи — гласные/'ь'/'ъ' (характерно для русских типо)
        if prev_c in "аеёиоуыэюяьъ" and next_c in "аеёиоуыэюяьъ":
            variant = query[: i + 1] + chars[i] + query[i + 1 :]
            if variant != query and variant not in variants:
                variants.append(variant)
        if len(variants) >= limit:
            break
    return variants


# ── Cache ────────────────────────────────────────────────────────────────────
def _cache_get(db: Session, address_norm: str) -> GeocodeResult | None:
    row = db.execute(
        text(
            """
            SELECT lat, lon, full_address, provider, confidence
            FROM geocode_cache
            WHERE address_normalized = :addr
              AND expires_at > NOW()
            """
        ),
        {"addr": address_norm},
    ).fetchone()
    if row is None:
        return None
    return GeocodeResult(
        lat=row.lat,
        lon=row.lon,
        full_address=row.full_address,
        provider="cache",
        confidence=row.confidence or "approximate",
    )


def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
    db.execute(
        text(
            """
            INSERT INTO geocode_cache
                (address_normalized, lat, lon, full_address, provider, confidence)
            VALUES (:addr, :lat, :lon, :full, :provider, :conf)
            ON CONFLICT (address_normalized) DO UPDATE
                SET lat = EXCLUDED.lat,
                    lon = EXCLUDED.lon,
                    full_address = EXCLUDED.full_address,
                    provider = EXCLUDED.provider,
                    confidence = EXCLUDED.confidence,
                    created_at = NOW(),
                    expires_at = NOW() + interval '90 days'
            """
        ),
        {
            "addr": address_norm,
            "lat": result.lat,
            "lon": result.lon,
            "full": result.full_address,
            "provider": result.provider,
            "conf": result.confidence,
        },
    )
    db.commit()


# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
def _nominatim_region_ok(item: dict) -> bool | None:
    """Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).

    True/False если state однозначно про/не про Свердловскую область. None если
    поле отсутствует/не строка — тогда accept-логика падает обратно на bbox.
    Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри генерального OBLAST66_BBOX
    (специально щедрого), но их state явно другой регион.
    """
    addr = item.get("address")
    if not isinstance(addr, dict):
        return None
    state = addr.get("state")
    if not isinstance(state, str) or not state:
        return None
    return "свердловск" in state.lower()


async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
    """Single Nominatim search. Возвращает лучший item или None.

    ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при
    опечатках не возвращать Пермский край / Челябинск — но не резать легитимные
    Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области).

    Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
    ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его
    не первым) — иначе первый кандидат внутри OBLAST66. Плюс region cross-check
    (`address.state`) — отсекает кандидатов ЯВНО из другого региона (Тюмень и
    т.п.), даже если координаты попали в генеральный bbox.
    """
    response = await client.get(
        "https://nominatim.openstreetmap.org/search",
        params={
            "q": address,
            "format": "json",
            "limit": "3",
            "countrycodes": "ru",
            "addressdetails": "1",
            "viewbox": OBLAST66_VIEWBOX["viewbox"],
            "bounded": "1",  # строго в пределах области (region 66)
        },
    )
    response.raise_for_status()
    data = response.json()
    oblast_fallback: dict | None = None
    for item in data:
        try:
            lat_f = float(item["lat"])
            lon_f = float(item["lon"])
        except Exception:
            continue
        if _nominatim_region_ok(item) is False:
            continue  # регион явно не Свердловская область — не рассматриваем
        if is_within_ekb_bbox(lat_f, lon_f):
            return item  # tight-ЕКБ приоритетнее — тот же результат, что и раньше
        if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
            oblast_fallback = item
    return oblast_fallback


@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def _nominatim_lookup(address: str) -> GeocodeResult | None:
    """OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.

    Бан-policy: User-Agent с email обязателен.
    Tier 1: bounded область (region 66) на оригинальный адрес.
    Tier 2: bounded область (region 66) на typo-варианты (Цвилинга → Цвиллинга).
    """
    headers = {
        "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
        "Accept": "application/json",
        "Accept-Language": "ru,en;q=0.8",
        "Referer": "https://tradein-mvp.local/",
    }
    async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
        # Tier 1: оригинал
        item = await _nominatim_query(client, address)

        # Tier 2: typo-variants
        if item is None:
            for variant in _typo_variants(address, limit=4):
                await asyncio.sleep(1.0)  # Nominatim 1 req/sec policy
                item = await _nominatim_query(client, variant)
                if item is not None:
                    logger.info("nominatim typo-fixed: %s → %s", address, variant)
                    break

        if item is None:
            return None

        confidence = "exact" if item.get("class") == "building" else "approximate"
        return GeocodeResult(
            lat=float(item["lat"]),
            lon=float(item["lon"]),
            full_address=item.get("display_name", address),
            provider="nominatim",
            confidence=confidence,
        )


def _yandex_region_ok(geo_object: dict) -> bool | None:
    """Кросс-чек региона по Yandex `AdministrativeAreaName`.

    True/False если Yandex вернул поле и оно однозначно про/не про Свердловскую
    область. None если поля нет / оно не строка — тогда accept-логика падает
    обратно на bbox. Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри
    генерального OBLAST66_BBOX (специально щедрого), но их регион явно другой.
    """
    try:
        admin_area = (
            geo_object.get("metaDataProperty", {})
            .get("GeocoderMetaData", {})
            .get("AddressDetails", {})
            .get("Country", {})
            .get("AdministrativeArea", {})
            .get("AdministrativeAreaName")
        )
    except AttributeError:
        return None
    if not isinstance(admin_area, str) or not admin_area:
        return None
    return "свердловск" in admin_area.lower()


# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ─────────────
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
    """Yandex Geocoder — 25K req/day free для самопод, лучше РФ.

    Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html

    Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов,
    но БЕЗ rspn — чтобы fuzzy matching работал при опечатках.
    """
    # Не навязываем "Екатеринбург, " если в адресе уже есть город/область региона 66
    # (типичный кейс из suggest, либо явный запрос по другому городу области).
    geocode_query = address if _has_oblast_marker(address.lower()) else f"Екатеринбург, {address}"
    async with httpx.AsyncClient(timeout=10.0) as client:
        response = await client.get(
            "https://geocode-maps.yandex.ru/1.x/",
            params={
                "apikey": api_key,
                "geocode": geocode_query,
                "format": "json",
                "results": 5,  # берем top-5, отфильтруем по ЕКБ bbox ниже
                "lang": "ru_RU",
                "ll": EKB_BBOX["ll"],
                "spn": EKB_BBOX["spn"],
            },
        )
        response.raise_for_status()
        data = response.json()

        members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
        if not members:
            return None

        # Фильтруем top-5 по bbox области (region 66) — игнорируем Челябинск/Уфу/Москву
        # при опечатке, но принимаем легитимные Нижний Тагил/Серов/etc. Two-pass
        # tie-break: tight-ЕКБ приоритетнее (byte-identical для ЕКБ-запросов, даже
        # если Yandex ранжировал его не первым), иначе первый oblast-bbox кандидат.
        # Region cross-check (AdministrativeAreaName) отсекает кандидатов ЯВНО из
        # другого региона (Тюмень и т.п. попадают в генеральный bbox координатно).
        best = None
        oblast_fallback = None
        for m in members:
            obj = m.get("GeoObject", {})
            try:
                lon_str, lat_str = obj["Point"]["pos"].split()
                lat_f, lon_f = float(lat_str), float(lon_str)
            except Exception:
                continue
            if _yandex_region_ok(obj) is False:
                continue  # регион явно не Свердловская область — не рассматриваем
            if is_within_ekb_bbox(lat_f, lon_f):
                best = obj
                break
            if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
                oblast_fallback = obj

        if best is None:
            best = oblast_fallback

        if best is None:
            # Никто из top-5 не попал ни в tight, ни в oblast bbox (или все явно
            # отсеяны по региону) → берём первый «как есть», НО только если его
            # регион не опровергнут явно — иначе весь смысл region cross-check
            # теряется на garbage-query, где top-1 — гарантированно не тот регион
            # (например, Тюмень).
            first_obj = members[0]["GeoObject"]
            if _yandex_region_ok(first_obj) is not False:
                best = first_obj

        if best is None:
            return None

        lon_str, lat_str = best["Point"]["pos"].split()
        precision_raw = (
            best.get("metaDataProperty", {}).get("GeocoderMetaData", {}).get("precision", "other")
        )
        confidence_map = {
            "exact": "exact",
            "number": "exact",
            "near": "approximate",
            "range": "approximate",
            "street": "approximate",
        }
        return GeocodeResult(
            lat=float(lat_str),
            lon=float(lon_str),
            full_address=best.get("metaDataProperty", {})
            .get("GeocoderMetaData", {})
            .get("text", address),
            provider="yandex",
            confidence=confidence_map.get(precision_raw, "approximate"),
        )


# ── Suggest (автокомплит) ───────────────────────────────────────────────────
# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85
# Это ЕКБ fast-path (Yandex rspn=1 bounded tier + soft-bias ll/spn). Для областного
# охвата (region 66) — см. OBLAST66_VIEWBOX выше.
EKB_BBOX = {
    "viewbox": "60.40,56.95,60.85,56.65",  # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE)
    "ll": "60.605,56.838",  # Yandex center (lon,lat)
    "spn": "0.45,0.30",  # Yandex span (lon,lat)
}


@dataclass(frozen=True, slots=True)
class GeocodeSuggestion:
    label: str  # формат "Малышева 30, Октябрьский район"
    full_address: str  # полный из геокодера
    lat: float
    lon: float
    kind: str  # 'house' / 'street' / 'locality'
    # ГАР OBJECTGUID (ФИАС) дома — заполняется ТОЛЬКО для house-level кандидатов
    # DaData-тира (fias_level 8/9). У Yandex/Nominatim/cadastral-тиров и у
    # street/locality-кандидатов остаётся None (нет стабильного house-fias).
    fias_id: str | None = None


def _parse_yandex_members(members: list[dict]) -> list[GeocodeSuggestion]:
    """Yandex geocode_members → list[GeocodeSuggestion]. Чистим описание от мусора."""
    out: list[GeocodeSuggestion] = []
    for m in members:
        obj = m.get("GeoObject", {})
        try:
            lon_str, lat_str = obj["Point"]["pos"].split()
            meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {})
            kind = meta.get("kind", "other")
            full = meta.get("text", obj.get("name", ""))
            name = obj.get("name", full)
            desc = obj.get("description", "")
            desc_parts = [
                p.strip()
                for p in desc.split(",")
                if p.strip() and p.strip() not in {"Россия", "Свердловская область"}
            ]
            label = name if not desc_parts else f"{name} · {', '.join(desc_parts)}"
            out.append(
                GeocodeSuggestion(
                    label=label,
                    full_address=full,
                    lat=float(lat_str),
                    lon=float(lon_str),
                    kind=kind,
                )
            )
        except Exception:
            continue
    return out


# ── DaData suggest wrapper ──────────────────────────────────────────────────
# Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется
# 'locality' вместо 'city' — consistent с Yandex/Nominatim ветками).
_DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}


async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
    """Обёртка над `dadata.suggest_addresses` — конвертит в GeocodeSuggestion.

    Дроп candidate'ов без координат (DaData возвращает их для широких categories
    типа город/район, для autocomplete с привязкой к карте они бесполезны).

    Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
    Constraint — вся область (region='Свердловская область', hard-filter внутри
    `suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/Серов/etc
    никогда не появились бы в подсказках.
    """
    raw = await dadata.suggest_addresses(
        query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
    )
    out: list[GeocodeSuggestion] = []
    for s in raw:
        if s.lat is None or s.lon is None:
            continue
        mapped_kind = _DADATA_KIND_MAP.get(s.kind, "locality")
        # ФИАС отдаём наружу ТОЛЬКО для house-level кандидата (DaData kind='house'
        # ⇔ fias_level 8/9). Для street/city/plot fias_id указывает не на дом —
        # не годится как ключ матчинга целевого дома, оставляем None.
        out.append(
            GeocodeSuggestion(
                label=s.value,
                full_address=s.unrestricted_value,
                lat=s.lat,
                lon=s.lon,
                kind=mapped_kind,
                fias_id=s.fias_id if mapped_kind == "house" else None,
            )
        )
    return out


async def _yandex_geocode_request(
    client: httpx.AsyncClient, api_key: str, query: str, limit: int, bounded: bool
) -> list[dict]:
    """Single Yandex Geocoder request — bounded=True → строго в ЕКБ через rspn=1."""
    params: dict[str, str] = {
        "apikey": api_key,
        "geocode": query,
        "format": "json",
        "results": str(limit),
        "lang": "ru_RU",
        "ll": EKB_BBOX["ll"],
        "spn": EKB_BBOX["spn"],
    }
    if bounded:
        params["rspn"] = "1"
    response = await client.get("https://geocode-maps.yandex.ru/1.x/", params=params)
    response.raise_for_status()
    data = response.json()
    return data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])


@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]:
    """Yandex Geocoder с авто-fallback на typo-tolerant режим.

    Tier 1: bounded ЕКБ (rspn=1) — быстрый путь для основного (ЕКБ) трафика.
    Tier 2: bounded ЕКБ на typo-variants (удвоение согласных).
    Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по bbox области
    (region 66) — ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2
    (bounded строго ЕКБ) структурно вернуть не могут.
    """
    prefixed_query = query if _has_oblast_marker(query.lower()) else f"Екатеринбург, {query}"
    async with httpx.AsyncClient(timeout=8.0) as client:
        # Tier 1: strict bounded на оригинал (ЕКБ fast path)
        members = await _yandex_geocode_request(
            client,
            api_key,
            prefixed_query,
            limit,
            bounded=True,
        )
        results = _parse_yandex_members(members)
        if results:
            return results

        # Tier 2: bounded на typo-варианты (тот же ЕКБ fast path)
        for variant in _typo_variants(query, limit=4):
            variant_query = (
                variant if _has_oblast_marker(variant.lower()) else f"Екатеринбург, {variant}"
            )
            members = await _yandex_geocode_request(
                client,
                api_key,
                variant_query,
                limit,
                bounded=True,
            )
            results = _parse_yandex_members(members)
            if results:
                return results

        # Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox области
        members = await _yandex_geocode_request(
            client,
            api_key,
            prefixed_query,
            limit,
            bounded=False,
        )
        results = _parse_yandex_members(members)
        in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)]
        return in_oblast


async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
    """Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
    response = await client.get(
        "https://nominatim.openstreetmap.org/search",
        params={
            "q": query,
            "format": "json",
            "limit": str(limit),
            "countrycodes": "ru",
            "viewbox": OBLAST66_VIEWBOX["viewbox"],
            "bounded": "1",
            "addressdetails": "1",
        },
    )
    response.raise_for_status()
    data = response.json()
    return data if isinstance(data, list) else []


@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
    """Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен).

    Суффикс ", Екатеринбург" навязывается ТОЛЬКО если в запросе ещё нет города/области
    региона 66 — иначе не режем явные запросы по другим городам области.
    """
    headers = {
        "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
        "Accept": "application/json",
        "Accept-Language": "ru,en;q=0.8",
    }
    suffixed_query = query if _has_oblast_marker(query.lower()) else f"{query}, Екатеринбург"
    async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
        # Tier 1: оригинальный query
        data = await _nominatim_query_multi(client, suffixed_query, limit)

        # Tier 2: typo-варианты если оригинал пустой
        if not data:
            for variant in _typo_variants(query, limit=3):
                await asyncio.sleep(1.0)  # Nominatim 1 req/sec
                variant_query = (
                    variant if _has_oblast_marker(variant.lower()) else f"{variant}, Екатеринбург"
                )
                data = await _nominatim_query_multi(client, variant_query, limit)
                if data:
                    logger.info("nominatim suggest typo-fixed: %s → %s", query, variant)
                    break

        out: list[GeocodeSuggestion] = []
        for item in data:
            display = item.get("display_name", "")
            addr = item.get("address", {}) or {}
            # Компактный лейбл: street + house_number / locality / district
            street = addr.get("road") or addr.get("street") or ""
            house = addr.get("house_number", "")
            district = addr.get("suburb") or addr.get("city_district") or addr.get("borough") or ""
            parts = []
            if street:
                parts.append(f"{street}{f', {house}' if house else ''}")
            elif item.get("name"):
                parts.append(item["name"])
            if district:
                parts.append(district)
            label = " · ".join(parts) if parts else display[:80]
            kind = "house" if house else ("street" if street else "locality")
            out.append(
                GeocodeSuggestion(
                    label=label,
                    full_address=display,
                    lat=float(item["lat"]),
                    lon=float(item["lon"]),
                    kind=kind,
                )
            )
        return out


def _cadastral_forward_sync(db: Session, query: str, limit: int = 5) -> list[GeocodeSuggestion]:
    """Forward search via gendesign_cad_buildings FDW.

    Uses ILIKE prefilter (FDW-pushable) + pg_trgm similarity ranking locally.
    Returns up to `limit` suggestions, or [] on error / no results.
    """
    try:
        rows = (
            db.execute(
                text("""
                SELECT cad_num, readable_address, lat, lon,
                       similarity(readable_address, CAST(:q AS text)) AS sim
                FROM gendesign_cad_buildings
                WHERE readable_address ILIKE CAST('%' || :q || '%' AS text)
                ORDER BY sim DESC, length(readable_address) ASC
                LIMIT CAST(:lim AS integer)
            """),
                {"q": query, "lim": limit * 4},
            )
            .mappings()
            .all()
        )
    except Exception:
        logger.warning("cadastral forward sync failed for query=%r", query, exc_info=True)
        return []

    out: list[GeocodeSuggestion] = []
    for r in rows[:limit]:
        out.append(
            GeocodeSuggestion(
                label=str(r["readable_address"]),
                full_address=str(r["readable_address"]),
                lat=float(r["lat"]),
                lon=float(r["lon"]),
                kind="house",
            )
        )
    return out


# ── Street+house parser (для anchored cadastral match) ──────────────────────
# Маркеры типов улиц (для извлечения street-токена). Порядок длинных перед
# короткими чтобы 'проспект' матчился раньше 'пр'.
_STREET_TYPE = (
    r"улица|проспект|переулок|бульвар|шоссе|набережная|площадь|"
    r"пр-?кт|пр-?т|б-?р|наб|тракт|пл|аллея|туп|проезд|ул|пер|ш"
)
# Дом-маркеры: «д 35», «дом 35», «строение 5», «стр 5», «сооружение 1»
_HOUSE_MARKER = r"(?:д|дом|строение|стр|сооружение|соор)"
# Номер дома: цифры + опционально ОДНА кириллическая буква («26а», «77»).
_HOUSE_NUM = r"\d+\s*-?\s*[а-яё]?"

_RE_POSTAL = re.compile(r"\b\d{6}\b")
_RE_OBLAST = re.compile(r"свердловск(?:ая)?\s*(?:обл(?:асть)?\.?)")
_RE_CITY = re.compile(r"(?:г\.?|город)\s*екатеринбург|екатеринбург")
# Район: одно (опц. дефисное) слово перед «р-н/район». Узко — чтобы не съесть улицу.
_RE_DISTRICT = re.compile(r"\b[а-яё-]+\s+(?:р-?н|район)\b")
# Хвостовой мусор: квартира/корпус/литера — маркер ОБЯЗАН быть с числом и отделён
# запятой/пробелом (иначе «к» съел бы «Космонавтов»).
_RE_APT_TAIL = re.compile(
    r"[,\s]\s*(?:кв|квартира|оф|офис|пом|помещение|корп|корпус|к|лит)\.?\s*\d.*$"
)
_RE_HOUSE_WITH_MARKER = re.compile(rf"\b{_HOUSE_MARKER}\.?\s*({_HOUSE_NUM})", re.IGNORECASE)
_RE_STREET_AFTER_TYPE = re.compile(
    rf"\b(?:{_STREET_TYPE})\.?\s+([а-яё0-9][а-яё0-9 .'-]*?)"
    rf"(?=\s*,|\s*\b{_HOUSE_MARKER}\b|\s+\d|$)",
    re.IGNORECASE,
)
_RE_NORM_NUM = re.compile(r"\s*-?\s*")


def _norm_house(num: str) -> str:
    """«26 а» / «26-а» → «26а»; «77» → «77». Сжимает пробел/дефис перед литерой."""
    num = num.strip().lower()
    m = re.match(r"(\d+)\s*-?\s*([а-яё]?)", num)
    if not m:
        return num
    return m.group(1) + m.group(2)


def _parse_street_house(address: str) -> tuple[str, str] | None:
    """Парсит адрес в (street_lower, house_lower) или None если не разобрать.

    Поддерживает DaData-форму, bare «Улица Дом», корпус/квартира-мусор,
    улицы начинающиеся с цифры («8 Марта 204»).

    Defensive: при неоднозначности возвращает None, а не гадает.
    """
    if not address or not address.strip():
        return None
    s = " ".join(address.lower().strip().split())
    # 1. Чистим geo-префиксы и хвостовой мусор (квартира/корпус)
    s = _RE_POSTAL.sub(" ", s)
    s = _RE_OBLAST.sub(" ", s)
    s = _RE_DISTRICT.sub(" ", s)
    s = _RE_CITY.sub(" ", s)
    s = _RE_APT_TAIL.sub(" ", s)
    s = " ".join(s.split()).strip(" ,.")

    if not s:
        return None

    # 2. Дом: сначала из «д./дом N» группы, иначе — трейлинг-номер
    house: str | None = None
    m_house = _RE_HOUSE_WITH_MARKER.search(s)
    if m_house:
        house = _norm_house(m_house.group(1))
    else:
        m_tail = re.search(r"(\d+\s*-?\s*[а-яё]?)\s*$", s)
        if m_tail:
            house = _norm_house(m_tail.group(1))
    if not house:
        return None

    # 3. Улица: токен после street-type маркера, иначе bare-форма (всё до номера)
    street: str | None = None
    m_street = _RE_STREET_AFTER_TYPE.search(s)
    if m_street:
        street = m_street.group(1).strip(" ,.'-")
    else:
        # bare-форма: «серова 27», «8 марта 204» — улица = всё до дом-группы/номера
        head = s
        if m_house:
            head = s[: m_house.start()]
        else:
            head = re.sub(r"\s*\d+\s*-?\s*[а-яё]?\s*$", "", s)
        street = head.strip(" ,.'-")

    if not street:
        return None
    # Защита: улица не должна быть голым числом/мусором
    if not re.search(r"[а-яё]", street):
        return None
    street = " ".join(street.split())
    return (street, house)


def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
    """Anchored cadastral match: ILIKE по улице + regex-anchor на дом-маркер.

    SQL validated на проде (11/16 hits, 0 false positives). Anchor на
    «д./дом/строение» убивает ложный матч номера внутри «(1-83)»-диапазона.

    `street` идёт ТОЛЬКО в bound-param ILIKE (безопасно). Для regex берём
    только ЦИФРЫ дома (regex-safe) — конкатенируем bound-param внутри SQL.
    Литеру (если есть) используем лишь для tie-break сортировки.
    """
    house_digits_m = re.match(r"\d+", house)
    if not house_digits_m:
        return None
    house_digits = house_digits_m.group(0)
    try:
        row = db.execute(
            text(r"""
                SELECT readable_address, lat, lon
                FROM gendesign_cad_buildings
                WHERE readable_address ILIKE CAST('%' || :street || '%' AS text)
                  AND readable_address ~* (
                      '(д\.?|дом|строение|стр\.?|сооружение|соор\.?)\s*'
                      || CAST(:house_digits AS text)
                      || '\s*[-./ ]?\s*[а-яё]?(\D|$)'
                  )
                  AND readable_address ~* 'екатеринбург'
                  AND readable_address !~* (
                      '(п\.\s|пос[. ]|посёлок|поселок|северка|шабровский'
                      || '| км|снт|гараж|коллективный сад)'
                  )
                ORDER BY
                  (CASE WHEN CAST(:house_full AS text) ~ '[а-яё]'
                        AND readable_address ~* (CAST(:house_full AS text) || '(\D|$)')
                        THEN 0 ELSE 1 END),
                  length(readable_address) ASC
                LIMIT 1
            """),
            {"street": street, "house_digits": house_digits, "house_full": house},
        ).first()
    except Exception:
        logger.warning(
            "cadastral house match failed for street=%r house=%r",
            street,
            house,
            exc_info=True,
        )
        return None

    if row is None:
        return None
    return GeocodeSuggestion(
        label=str(row.readable_address),
        full_address=str(row.readable_address),
        lat=float(row.lat),
        lon=float(row.lon),
        kind="house",
    )


_RE_HOUSE_INTERNAL_SPACES = re.compile(r"\s+")


def _normalize_geoportal_house(house: str) -> str:
    """house_norm как в ekb_geoportal_buildings: lower + удаление внутренних пробелов.

    «7 б» → «7б». ДОЛЖНО совпадать с app.tasks.ekb_geoportal_ingest.normalize_house.
    """
    return _RE_HOUSE_INTERNAL_SPACES.sub("", house.strip().lower())


def _geoportal_house_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
    """Точный матч по реестру зданий ЕКБ (городской геопортал, ekb_geoportal_buildings).

    Полнее чем NSPD cad_buildings (~70% зданий ЕКБ отсутствуют в NSPD) — поэтому это
    ПЕРВЫЙ локальный tier геокодера, до cad_buildings.

    Нормализация: street_norm = lower(trim), house_norm = lower без внутренних пробелов
    — ровно как наполняет лоадер. Параметры идут только bound-param'ами (без инъекций).
    """
    street_norm = street.strip().lower()
    house_norm = _normalize_geoportal_house(house)
    if not street_norm or not house_norm:
        return None
    try:
        row = db.execute(
            text("""
                SELECT street, house, lat, lon
                FROM ekb_geoportal_buildings
                WHERE street_norm = lower(trim(CAST(:street AS text)))
                  AND house_norm = CAST(:house AS text)
                LIMIT 1
            """),
            {"street": street_norm, "house": house_norm},
        ).first()
    except Exception:
        logger.warning(
            "geoportal house match failed for street=%r house=%r",
            street,
            house,
            exc_info=True,
        )
        return None

    if row is None:
        return None
    label = f"{row.street}, {row.house}"
    return GeocodeSuggestion(
        label=label,
        full_address=label,
        lat=float(row.lat),
        lon=float(row.lon),
        kind="house",
    )


def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int = 200) -> str | None:
    """Reverse lookup via gendesign_cad_buildings FDW.

    bbox prefilter (±0.0025° lat, ±0.005° lon ≈ 280m) is FDW-pushable.
    Distance calculated locally on small result set. Filters гаражи/СНТ.
    Returns None on no result or error.
    """
    try:
        row = db.execute(
            text("""
                WITH candidates AS (
                  SELECT cad_num, readable_address, lat, lon,
                         111320.0 * sqrt(
                             pow(CAST(:lat AS double precision) - lat, 2) +
                             pow(
                                 cos(radians(CAST(:lat AS double precision)))
                                 * (CAST(:lon AS double precision) - lon), 2
                             )
                         ) AS dist_m
                  FROM gendesign_cad_buildings
                  WHERE lat BETWEEN CAST(:lat AS double precision) - 0.0025
                                AND CAST(:lat AS double precision) + 0.0025
                    AND lon BETWEEN CAST(:lon AS double precision) - 0.005
                                AND CAST(:lon AS double precision) + 0.005
                    AND readable_address !~* '(гараж|снт|садовод|товарищ|уч\\.)'
                )
                SELECT readable_address, dist_m
                FROM candidates
                WHERE dist_m < CAST(:radius AS double precision)
                ORDER BY dist_m ASC
                LIMIT 1
            """),
            {"lat": lat, "lon": lon, "radius": float(radius_m)},
        ).first()
    except Exception:
        logger.warning("cadastral reverse sync failed for (%.5f, %.5f)", lat, lon, exc_info=True)
        return None

    if row is None:
        return None
    return str(row.readable_address)


async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list[GeocodeSuggestion]:
    """Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
    остаётся быстрым fast-path). Cadastral FDW → DaData → Yandex → Nominatim → [].

    db: если передан — cadastral lookup через gendesign_cad_buildings (первый tier).
    DaData /suggest (PR Q2) — token-only, 10k/день, заменяет Yandex который
    заблокирован (1k/день demo limit исчерпан).
    Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
    """
    if not query or len(query.strip()) < 2:
        return []

    # Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
    # EKB-only (см. `geocode()` #11) — пропускаем, если query явно называет
    # другой город области, иначе не-ЕКБ автокомплит может всплыть ЕКБ-домом
    # с совпадающими улица+дом. Внешние тиры (2/3/4 ниже) не гейтим — они уже
    # oblast-aware.
    if db is not None and not _names_non_ekb_city(query):
        # 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
        # Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
        parsed = _parse_street_house(query.strip())
        if parsed is not None:
            street, house = parsed
            hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
            if hit is not None:
                return [hit]
        # 1b. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
        cad_results = await asyncio.to_thread(_cadastral_forward_sync, db, query.strip(), limit)
        if cad_results:
            return cad_results

    # Tier 2: DaData /suggest — token-only (X-Secret не нужен), 10k/день free,
    # лучший fit для РФ адресов. Заменил Yandex как primary external provider
    # после того как demo-key Yandex (1k/день) был исчерпан.
    if settings.dadata_api_token:
        try:
            dadata_results = await _dadata_suggest(query, limit)
            if dadata_results:
                return dadata_results
        except Exception:
            logger.exception("dadata suggest failed, falling back to yandex")

    # Tier 3: Yandex (legacy — оставляем как fallback, если key есть)
    if settings.yandex_geocoder_api_key:
        try:
            results = await _yandex_suggest(query, settings.yandex_geocoder_api_key, limit)
            if results:
                return results
        except Exception:
            logger.exception("yandex suggest failed, falling back to nominatim")

    # Tier 4: Nominatim (последний fallback — OSM, без ключа)
    try:
        return await _nominatim_suggest(query, limit)
    except Exception:
        logger.exception("nominatim suggest failed")
        return []


# ── Public API ───────────────────────────────────────────────────────────────
async def geocode(address: str, db: Session) -> GeocodeResult | None:
    """Геокодинг с кэшем. Cadastral FDW → Yandex → Nominatim → None.

    Args:
        address: пользовательский ввод (может быть грязным — нормализуем).
        db: сессия Postgres для cache lookup/write и cadastral FDW lookup.

    Returns:
        GeocodeResult или None если ни один провайдер не отвечает.
    """
    if not address or len(address.strip()) < 3:
        return None

    addr_norm = normalize_address(address)

    # 1. Cache (sync DB-IO → offload в threadpool, чтобы не блокировать event loop)
    cached = await asyncio.to_thread(_cache_get, db, addr_norm)
    if cached is not None:
        logger.info("geocode cache hit: %s", addr_norm)
        return cached

    # 2. Локальные источники по street+house (без внешнего API).
    parsed = _parse_street_house(address.strip())

    # #11: EKB-only локальные тиры (geoportal/cadastral) не должны отвечать на
    # адрес другого города области — иначе улица+дом, коллизящие с ЕКБ-домом
    # (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), снапаются в
    # ЕКБ. Пропускаем сразу к oblast-aware внешним провайдерам ниже (3/4).
    use_local_ekb = not _names_non_ekb_city(address)

    # 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
    if use_local_ekb and parsed is not None:
        street, house = parsed
        try:
            hit = await asyncio.to_thread(_geoportal_house_match, db, street, house)
        except Exception:
            logger.warning("geoportal house-match raised — fall through", exc_info=True)
            hit = None
        if hit is not None:
            result = GeocodeResult(
                lat=hit.lat,
                lon=hit.lon,
                full_address=hit.full_address,
                provider="cache",
                confidence="exact",
            )
            await asyncio.to_thread(_cache_put, db, addr_norm, result)
            logger.info(
                "geocode geoportal house-match: %s → (%.5f, %.5f)",
                addr_norm,
                result.lat,
                result.lon,
            )
            return result

    # 2c. Cadastral FDW (прямой запрос к gendesign_cad_buildings — без внешнего API)
    # Anchored house-match: парсим street+house → точный матч по дом-маркеру.
    # raw-ILIKE по полному readable_address давал 0 hits для «Серова 27» / DaData-форм
    # (литеральная подстрока не совпадает).
    if use_local_ekb and parsed is not None:
        street, house = parsed
        hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
        if hit is not None:
            result = GeocodeResult(
                lat=hit.lat,
                lon=hit.lon,
                full_address=hit.full_address,
                provider="nominatim",  # treat as "local" — same confidence as nominatim
                confidence="exact",
            )
            await asyncio.to_thread(_cache_put, db, addr_norm, result)
            logger.info(
                "geocode cadastral house-match: %s → (%.5f, %.5f)",
                addr_norm,
                result.lat,
                result.lon,
            )
            return result

    # 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
    if use_local_ekb:
        cad_suggestions = await asyncio.to_thread(
            _cadastral_forward_sync, db, address.strip(), limit=1
        )
        if cad_suggestions:
            s = cad_suggestions[0]
            result = GeocodeResult(
                lat=s.lat,
                lon=s.lon,
                full_address=s.full_address,
                provider="nominatim",  # treat as "local" — same confidence as nominatim
                confidence="exact",
            )
            await asyncio.to_thread(_cache_put, db, addr_norm, result)
            logger.info(
                "geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
            )
            return result

    # 3. Yandex (если есть key) с typo-fallback
    if settings.yandex_geocoder_api_key:
        try:
            result = await _yandex_lookup(address, settings.yandex_geocoder_api_key)
            # Если результат вне области (region 66) — пробуем typo-варианты
            in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon)
            if result is not None and in_oblast:
                await asyncio.to_thread(_cache_put, db, addr_norm, result)
                logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
                return result
            # Tier 2: typo-variants
            for variant in _typo_variants(address, limit=4):
                try:
                    result = await _yandex_lookup(variant, settings.yandex_geocoder_api_key)
                except Exception:
                    continue
                if result is None:
                    continue
                if is_within_oblast66_bbox(result.lat, result.lon):
                    await asyncio.to_thread(_cache_put, db, addr_norm, result)
                    logger.info(
                        "geocode yandex typo-fixed: %s → %s → (%.5f, %.5f)",
                        addr_norm,
                        variant,
                        result.lat,
                        result.lon,
                    )
                    return result
        except Exception:
            logger.exception("yandex geocoder failed, falling back to nominatim")

    # 4. Nominatim fallback
    try:
        result = await _nominatim_lookup(address)
        if result is not None:
            await asyncio.to_thread(_cache_put, db, addr_norm, result)
            logger.info("geocode nominatim: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
            # Nominatim rate-limit policy: 1 req/sec — спим после успешного запроса
            await asyncio.sleep(1.0)
            return result
    except Exception:
        logger.exception("nominatim geocoder failed")

    return None


# ── Reverse: координаты → адрес (для map-picker'а) ──────────────────────────
# Precision levels which we treat as "снап к зданию имеет смысл":
# - exact     — точный матч на здание (Yandex)
# - number    — найден дом с номером (то что нам надо для квартирного оценщика)
# - cadastral — Cadastral FDW row (то же по точности что Yandex "number")
# Остальные (street/range/near/locality/other) → не снапаем, marker остаётся на клике.
_SNAP_PRECISIONS = {"exact", "number", "cadastral"}


@dataclass(frozen=True, slots=True)
class ReverseGeocodeResult:
    """Reverse-геокодинг с snapped координатами matched здания.

    - `address`      — текстовый адрес (улица + дом + город).
    - `snapped_lat`  — координата центра здания если provider дал её,
                       иначе echo `lat` входной точки (для precision=street/locality).
    - `snapped_lon`  — то же.
    - `precision`    — yandex-style: `exact`/`number`/`street`/`range`/`near`/
                       `locality`/`other`/`cadastral`. Используется фронтом чтобы
                       решить — двигать marker (exact/number) или нет.
    - `provider`     — кто дал результат (`yandex`/`nominatim`/`cadastral`).

    Фронт MapPicker'а после клика смотрит на precision: если `exact`/`number`
    и snapped >5m от click point — пересаживает marker на snapped point
    (чтобы пользователь видел центр дома по Яндексу, а не свой клик во дворе).
    Для остальных precision marker остаётся где кликнули — не врём что нашли
    точное здание.
    """

    address: str
    snapped_lat: float
    snapped_lon: float
    precision: str
    provider: Literal["yandex", "nominatim", "cadastral"]


def _format_reverse_address(addr: dict) -> str | None:
    """Собирает чистый уличный адрес из Nominatim address-объекта.

    Nominatim `display_name` ведёт с названия ближайшего POI/организации
    («NataliOlympic, 2к1, Трамвайный переулок, …»). Для map-picker'а это
    ломает кейс: такой адрес нельзя forward-геокодировать обратно и оценка
    не находит аналогов. Берём именно улицу + дом + город.

    Возвращает None если в объекте нет улицы — тогда вызывающий код
    откатывается на display_name.
    """
    road = addr.get("road") or addr.get("pedestrian") or addr.get("footway")
    if not road:
        return None
    house = addr.get("house_number")
    city = addr.get("city") or addr.get("town") or addr.get("village") or addr.get("municipality")
    parts: list[str] = [str(road)]
    if house:
        parts.append(str(house))
    if city:
        parts.append(str(city))
    return ", ".join(parts)


@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def _yandex_reverse(lat: float, lon: float, api_key: str) -> ReverseGeocodeResult | None:
    """Yandex Geocoder /reverse — возвращает snapped Point.pos здания + precision.

    Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html
    Параметр `geocode` принимает `lon,lat` (важно — обратный порядок!).
    """
    async with httpx.AsyncClient(timeout=10.0) as client:
        response = await client.get(
            "https://geocode-maps.yandex.ru/1.x/",
            params={
                "apikey": api_key,
                "geocode": f"{lon},{lat}",
                "format": "json",
                "results": "1",
                "kind": "house",  # просим именно здание (house), не улицу
                "lang": "ru_RU",
            },
        )
        response.raise_for_status()
        data = response.json()

    members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
    if not members:
        return None
    obj = members[0].get("GeoObject", {})
    try:
        lon_str, lat_str = obj["Point"]["pos"].split()
        snapped_lat = float(lat_str)
        snapped_lon = float(lon_str)
    except (KeyError, ValueError):
        return None
    meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {})
    precision = str(meta.get("precision", "other"))
    address_text = str(meta.get("text") or obj.get("name") or "")
    # Yandex address text начинается с «Россия, Свердловская область, …» — режем prefix,
    # оставляем «улица, дом, город» для consistency с Nominatim/cadastral.
    if address_text:
        # «Россия, Свердловская область, Екатеринбург, улица Малышева, 51»
        # → «улица Малышева, 51, Екатеринбург» (drop country/oblast, swap city/street)
        parts = [p.strip() for p in address_text.split(",") if p.strip()]
        filtered = [
            p
            for p in parts
            if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ")
        ]
        # Найдём locality (Екатеринбург / Нижний Тагил / Берёзовский / …) и переставим
        # в конец — любой город региона 66, не только ЕКБ (case-insensitive match).
        locality = None
        rest: list[str] = []
        for p in filtered:
            if locality is None and p.lower() in SVERDLOVSK_OBLAST_CITIES:
                locality = p
            else:
                rest.append(p)
        if locality and rest:
            address_text = ", ".join([*rest, locality])
        else:
            address_text = ", ".join(filtered)

    if not address_text:
        return None
    return ReverseGeocodeResult(
        address=address_text,
        snapped_lat=snapped_lat,
        snapped_lon=snapped_lon,
        precision=precision,
        provider="yandex",
    )


@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
    """Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.

    Nominatim возвращает координаты центра matched feature (building/way/node).
    precision выводим из наличия `house_number` в addressdetails.
    """
    headers = {
        "User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
        "Accept": "application/json",
        "Accept-Language": "ru,en;q=0.8",
    }
    async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
        response = await client.get(
            "https://nominatim.openstreetmap.org/reverse",
            params={
                "lat": str(lat),
                "lon": str(lon),
                "format": "json",
                "addressdetails": "1",
                "zoom": "18",
            },
        )
        response.raise_for_status()
        data = response.json()
    if not isinstance(data, dict) or "error" in data:
        return None
    addr = data.get("address")
    address_text: str | None = None
    if isinstance(addr, dict):
        address_text = _format_reverse_address(addr)
    if not address_text:
        display = data.get("display_name")
        if not display:
            return None
        address_text = str(display)
    # snapped coords — то что вернул Nominatim (центр matched feature)
    try:
        snapped_lat = float(data["lat"])
        snapped_lon = float(data["lon"])
    except (KeyError, ValueError, TypeError):
        snapped_lat, snapped_lon = lat, lon
    has_house = isinstance(addr, dict) and bool(addr.get("house_number"))
    precision = "number" if has_house else "street"
    return ReverseGeocodeResult(
        address=address_text,
        snapped_lat=snapped_lat,
        snapped_lon=snapped_lon,
        precision=precision,
        provider="nominatim",
    )


def _cadastral_reverse_sync_full(
    db: Session, lat: float, lon: float, radius_m: int = 200
) -> tuple[str, float, float] | None:
    """Полный вариант cadastral reverse — возвращает (address, snapped_lat, snapped_lon).

    Отдельная функция чтобы старый `_cadastral_reverse_sync` (только адрес)
    остался backward-compatible — его держит `tests/services/test_cadastral_reverse.py`.
    """
    try:
        row = db.execute(
            text("""
                WITH candidates AS (
                  SELECT cad_num, readable_address, lat, lon,
                         111320.0 * sqrt(
                             pow(CAST(:lat AS double precision) - lat, 2) +
                             pow(
                                 cos(radians(CAST(:lat AS double precision)))
                                 * (CAST(:lon AS double precision) - lon), 2
                             )
                         ) AS dist_m
                  FROM gendesign_cad_buildings
                  WHERE lat BETWEEN CAST(:lat AS double precision) - 0.0025
                                AND CAST(:lat AS double precision) + 0.0025
                    AND lon BETWEEN CAST(:lon AS double precision) - 0.005
                                AND CAST(:lon AS double precision) + 0.005
                    AND readable_address !~* '(гараж|снт|садовод|товарищ|уч\\.)'
                )
                SELECT readable_address, lat, lon, dist_m
                FROM candidates
                WHERE dist_m < CAST(:radius AS double precision)
                ORDER BY dist_m ASC
                LIMIT 1
            """),
            {"lat": lat, "lon": lon, "radius": float(radius_m)},
        ).first()
    except Exception:
        logger.warning("cadastral reverse full failed for (%.5f, %.5f)", lat, lon, exc_info=True)
        return None
    if row is None:
        return None
    return (str(row.readable_address), float(row.lat), float(row.lon))


async def reverse_geocode(
    lat: float, lon: float, db: Session | None = None
) -> ReverseGeocodeResult | None:
    """Cadastral FDW → Yandex (если key) → Nominatim. Возвращает snapped coords.

    Возвращает None если ни один источник не дал адрес. Endpoint
    api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError
    наверх — раньше Nominatim 403 → RetryError → FastAPI 500.

    Snapped lat/lon — это центр matched здания (от provider'а), не echo
    входных координат. Фронт по precision решает — двигать marker (exact/number)
    или оставить на клике (street/locality).

    db: если передан — cadastral lookup через gendesign_cad_buildings FDW (первый tier).
    """
    # 1. Cadastral FDW primary (без внешнего API, возвращает жилой дом not POI)
    if db is not None:
        cad = await asyncio.to_thread(_cadastral_reverse_sync_full, db, lat, lon)
        if cad is not None:
            address, snap_lat, snap_lon = cad
            return ReverseGeocodeResult(
                address=address,
                snapped_lat=snap_lat,
                snapped_lon=snap_lon,
                precision="number",  # cadastral row = здание с house number
                provider="cadastral",
            )

    # 2. Yandex — основной источник snap'а (его Point.pos = центр здания)
    if settings.yandex_geocoder_api_key:
        try:
            result = await _yandex_reverse(lat, lon, settings.yandex_geocoder_api_key)
            if result is not None:
                return result
        except Exception:
            logger.exception("yandex reverse failed for (%.5f, %.5f)", lat, lon)

    # 3. Nominatim fallback (wrap to prevent 500 on ban/rate-limit)
    try:
        return await _nominatim_reverse(lat, lon)
    except Exception:
        logger.exception("nominatim reverse failed for (%.5f, %.5f)", lat, lon)
        return None


def snap_precision_useful(precision: str) -> bool:
    """True если precision означает «нашли точное здание» — фронт двигает marker."""
    return precision in _SNAP_PRECISIONS
