"""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
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"


# ── 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, без ключа) ────────────────────────────────────
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
    """Single Nominatim search. Возвращает первый item или None.

    ВАЖНО: фильтруем результаты по ЕКБ bbox прямо тут, чтобы при опечатках
    не возвращать Пермский край / Челябинск.
    """
    response = await client.get(
        "https://nominatim.openstreetmap.org/search",
        params={
            "q": address,
            "format": "json",
            "limit": "3",
            "countrycodes": "ru",
            "addressdetails": "1",
            "viewbox": EKB_BBOX["viewbox"],
            "bounded": "1",  # строго в ЕКБ bbox
        },
    )
    response.raise_for_status()
    data = response.json()
    for item in data:
        try:
            lat_f = float(item["lat"])
            lon_f = float(item["lon"])
            if 60.40 <= lon_f <= 60.85 and 56.65 <= lat_f <= 56.95:
                return item
        except Exception:
            continue
    return None


@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 ЕКБ на оригинальный адрес.
    Tier 2: bounded ЕКБ на 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,
        )


# ── 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 работал при опечатках.
    """
    # Не запихиваем "Екатеринбург" если оно уже есть в адресе (типичный кейс из suggest)
    geocode_query = address if "екатеринбург" in 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 — игнорируем Челябинск/Уфу/Москву при опечатке
        best = 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)
                if 60.40 <= lon_f <= 60.85 and 56.65 <= lat_f <= 56.95:
                    best = obj
                    break
            except Exception:
                continue

        if best is None:
            # Никто из top-5 не попал в ЕКБ → берем первый «как есть» (вне ЕКБ — но хоть что-то)
            best = members[0]["GeoObject"]

        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
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'


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»).
    Без city suffix — query уже ограничен `locations=[{city: 'Екатеринбург'}]`.
    """
    raw = await dadata.suggest_addresses(query, limit=limit)
    out: list[GeocodeSuggestion] = []
    for s in raw:
        if s.lat is None or s.lon is None:
            continue
        out.append(GeocodeSuggestion(
            label=s.value,
            full_address=s.unrestricted_value,
            lat=s.lat,
            lon=s.lon,
            kind=_DADATA_KIND_MAP.get(s.kind, "locality"),
        ))
    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) на оригинальный query.
    Tier 2: bounded ЕКБ на typo-variants (удвоение согласных).
    Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по ЕКБ bbox.
    """
    async with httpx.AsyncClient(timeout=8.0) as client:
        # Tier 1: strict bounded на оригинал
        members = await _yandex_geocode_request(
            client, api_key, f"Екатеринбург, {query}", limit, bounded=True,
        )
        results = _parse_yandex_members(members)
        if results:
            return results

        # Tier 2: bounded на typo-варианты
        for variant in _typo_variants(query, limit=4):
            members = await _yandex_geocode_request(
                client, api_key, f"Екатеринбург, {variant}", 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, f"Екатеринбург, {query}", limit, bounded=False,
        )
        results = _parse_yandex_members(members)
        in_ekb = [
            r for r in results
            if 60.40 <= r.lon <= 60.85 and 56.65 <= r.lat <= 56.95
        ]
        return in_ekb


async def _nominatim_query_multi(
    client: httpx.AsyncClient, query: str, limit: int
) -> list[dict]:
    """Один Nominatim search с фильтром по ЕКБ bbox. Возвращает up to N items."""
    response = await client.get(
        "https://nominatim.openstreetmap.org/search",
        params={
            "q": query,
            "format": "json",
            "limit": str(limit),
            "countrycodes": "ru",
            "viewbox": EKB_BBOX["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 недоступен)."""
    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=8.0, headers=headers) as client:
        # Tier 1: оригинальный query
        data = await _nominatim_query_multi(client, f"{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
                data = await _nominatim_query_multi(client, f"{variant}, Екатеринбург", 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


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]:
    """Автокомплит адресов в ЕКБ. 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 доступна) — самый быстрый, без внешних запросов
    if db is not None:
        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. Cadastral FDW (прямой запрос к gendesign_cad_buildings — без внешнего API)
    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)
            # Если результат вне ЕКБ — пробуем typo-варианты
            in_ekb = (
                result is not None
                and 60.40 <= result.lon <= 60.85
                and 56.65 <= result.lat <= 56.95
            )
            if result is not None and in_ekb:
                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 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95:
                    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 (Екатеринбург / Берёзовский / …) и переставим в конец
        locality = None
        rest: list[str] = []
        for p in filtered:
            if locality is None and p in {"Екатеринбург"}:
                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
