"""DaData clients — /clean/address (enrichment) + /suggest/address (autocomplete).

/clean/address — обогащение **целевого адреса** канонической формой,
kadastr_num, ФИАС, координаты, метро. Используется в estimator flow для
on-demand обогащения payload.address (PR Q1). Требует token + secret,
demo tier 100/день.

/suggest/address — автокомплит для /api/v1/geocode/suggest (PR Q2).
Заменяет заблокированный Yandex Suggest как второй tier после Cadastral FDW.
Token-only (X-Secret не нужен), 10k/день free. Возвращает список candidate'ов
с координатами + ФИАС + структурными полями.

ENV: DADATA_API_TOKEN, DADATA_API_SECRET.
- clean_address: требует оба.
- suggest_addresses: требует только DADATA_API_TOKEN.
Если требуемые credentials не заданы → graceful возврат None / [] (не break flow).

Docs:
- https://dadata.ru/api/clean/address/
- https://dadata.ru/api/suggest/address/
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Any

import httpx

from app.core.config import settings

logger = logging.getLogger(__name__)

DADATA_CLEAN_URL = "https://cleaner.dadata.ru/api/v1/clean/address"
DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
_DADATA_TIMEOUT_S = 8.0
_DADATA_SUGGEST_TIMEOUT_S = 5.0


@dataclass(frozen=True, slots=True)
class DadataAddressResult:
    """Parsed DaData enrichment payload — slots для compact representation.

    canonical_address — поле `result` из ответа («г Екатеринбург, ул Малышева, д 125»).
    house_cadnum     — кадастровый номер ДОМА (не участка). Формат «66:41:0704045:350».
    house_fias_id    — UUID ФИАС дома.
    lat / lon        — координаты от DaData (precision до дома).
    qc_geo           — quality code геокодинга: 0=exact, 1=street, 2=settlement…
    qc_house         — quality code дома: 2=в ФИАС, 10=на картах…
    kladr_id         — старый ОКРС-классификатор (legacy, может быть полезен).
    okato / oktmo    — admin codes для отчётности.
    metro            — список ближайших станций метро (если есть): [{name,line,distance}…].
    raw              — полный ответ для debugging / future fields.
    """

    canonical_address: str | None
    house_cadnum: str | None
    house_fias_id: str | None
    lat: float | None
    lon: float | None
    qc_geo: int | None
    qc_house: int | None
    kladr_id: str | None
    okato: str | None
    oktmo: str | None
    metro: list[dict[str, Any]]
    raw: dict[str, Any]


def _credentials() -> tuple[str, str] | None:
    """Returns (token, secret) если оба заданы, иначе None."""
    token = (settings.dadata_api_token or "").strip()
    secret = (settings.dadata_api_secret or "").strip()
    if not token or not secret:
        return None
    return token, secret


def _coerce_int(value: Any) -> int | None:
    """DaData отдаёт qc-коды строками иногда. Coerce → int безопасно."""
    if value is None:
        return None
    try:
        return int(value)
    except (TypeError, ValueError):
        return None


def _coerce_float(value: Any) -> float | None:
    if value is None:
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _parse_response(item: dict[str, Any]) -> DadataAddressResult:
    """Парсит single DaData payload item в DadataAddressResult.

    Не raise'ит — пропускает невалидные поля как None.
    """
    metro_raw = item.get("metro") or []
    if not isinstance(metro_raw, list):
        metro_raw = []

    return DadataAddressResult(
        canonical_address=item.get("result"),
        house_cadnum=item.get("house_cadnum"),
        house_fias_id=item.get("house_fias_id"),
        lat=_coerce_float(item.get("geo_lat")),
        lon=_coerce_float(item.get("geo_lon")),
        qc_geo=_coerce_int(item.get("qc_geo")),
        qc_house=_coerce_int(item.get("qc_house")),
        kladr_id=item.get("kladr_id"),
        okato=item.get("okato"),
        oktmo=item.get("oktmo"),
        metro=metro_raw,
        raw=item,
    )


async def clean_address(address: str) -> DadataAddressResult | None:
    """Обогащает один адрес через DaData /clean/address.

    Args:
        address: свободный текст адреса (как пришёл от пользователя или из geocoder).

    Returns:
        DadataAddressResult или None если:
          - credentials не заданы в ENV (graceful disable)
          - адрес пустой / слишком короткий
          - API вернул пустой массив / ошибку shape
          - сетевая / HTTP ошибка (5xx, 429 quota, timeout)
          - адрес не распознан (canonical_address пустой)
    """
    if not address or len(address.strip()) < 3:
        return None

    creds = _credentials()
    if creds is None:
        logger.debug("dadata: credentials не заданы — skip enrichment")
        return None

    token, secret = creds
    headers = {
        "Authorization": f"Token {token}",
        "X-Secret": secret,
        "Content-Type": "application/json",
        "Accept": "application/json",
    }
    body = [address.strip()]

    try:
        async with httpx.AsyncClient(timeout=_DADATA_TIMEOUT_S) as client:
            response = await client.post(DADATA_CLEAN_URL, headers=headers, json=body)
    except (httpx.TimeoutException, httpx.NetworkError) as exc:
        logger.warning("dadata: network error для %r: %s", address[:60], exc)
        return None
    except Exception as exc:  # pragma: no cover — defensive
        logger.warning("dadata: unexpected client error для %r: %s", address[:60], exc)
        return None

    status = response.status_code
    if status == 429:
        logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
        return None
    if status in (401, 403):
        body_preview = (response.text or "")[:200]
        # 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
        # но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
        # поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
        # (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена.
        if status == 403 and (
            "disabled" in body_preview.lower() or "feature" in body_preview.lower()
        ):
            logger.error(
                "dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
                "(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
                "полагайся на suggest-fallback (enrich_address). Ответ: %r",
                body_preview,
            )
        else:
            logger.error(
                "dadata: HTTP %d — auth/secret rejected. "
                "Проверь DADATA_API_TOKEN/SECRET. Ответ: %r",
                status,
                body_preview,
            )
        return None
    if status >= 500:
        logger.warning("dadata: HTTP %d — transient server error", status)
        return None
    if status >= 400:
        body_preview = (response.text or "")[:200]
        logger.warning("dadata: HTTP %d — bad request: %r", status, body_preview)
        return None

    try:
        payload = response.json()
    except ValueError as exc:
        logger.warning("dadata: невалидный JSON в ответе: %s", exc)
        return None

    if not isinstance(payload, list) or not payload:
        logger.info("dadata: пустой массив для %r", address[:60])
        return None

    item = payload[0]
    if not isinstance(item, dict):
        logger.warning("dadata: первый элемент не dict (%s)", type(item).__name__)
        return None

    result = _parse_response(item)

    if not result.canonical_address:
        # DaData вернул shape, но не распознал адрес — quality codes обычно qc_geo=5.
        logger.info(
            "dadata: адрес не распознан (qc_geo=%s qc_house=%s) для %r",
            result.qc_geo,
            result.qc_house,
            address[:60],
        )
        return None

    logger.info(
        "dadata: enriched %r → canonical=%r cadnum=%s qc_geo=%s qc_house=%s",
        address[:60],
        (result.canonical_address or "")[:60],
        result.house_cadnum,
        result.qc_geo,
        result.qc_house,
    )
    return result


# ── Suggest (autocomplete) ───────────────────────────────────────────────────


@dataclass(frozen=True, slots=True)
class DadataSuggestion:
    """Один candidate из ответа DaData /suggest/address.

    value             — короткая строка для отображения («ул Малышева, д 30»).
    unrestricted_value — полная строка с регионом/страной («г Екатеринбург, ул …»).
    lat / lon         — координаты (если DaData их вернул; для some сегментов None).
    fias_id           — UUID ФИАС матчнутого объекта (дома/улицы/города).
    house             — номер дома если distinguished (строкой: «30» / «30к2»).
    street            — название улицы.
    city              — locality.
    kind              — `house` (fias_level=8/9) / `street` (=7) / `city` (<=6) /
                        `plot` (=65 planning, =75 plot).
                        Маппится на GeocodeSuggestion.kind в geocoder.py.
    """

    value: str
    unrestricted_value: str
    lat: float | None
    lon: float | None
    fias_id: str | None
    house: str | None
    street: str | None
    city: str | None
    kind: str
    # #dadata-403 — доп. поля для suggest-fallback в enrich_address (когда CLEAN
    # выключен на аккаунте). house_fias_id — ФИАС ДОМА (для house-level == fias_id);
    # kladr_id / qc_geo дублируют то, что отдаёт /clean, чтобы деградация была равноценной.
    house_fias_id: str | None = None
    kladr_id: str | None = None
    qc_geo: int | None = None


def _classify_kind(fias_level: Any) -> str:
    """Маппит DaData `fias_level` → kind для GeocodeSuggestion.

    DaData fias_level (https://dadata.ru/api/suggest/address/#fias_level):
      0=country, 1=region, 3=area, 4=city, 5=settlement, 6=street_alt,
      7=street, 8=house, 9=flat, 65=planning, 75=plot.
    Для autocomplete нам важно: house / street / city.
    """
    level = _coerce_int(fias_level)
    if level is None:
        return "city"  # safe default
    if level in (65, 75):
        # 65=planning structure / 75=земельный участок — не дом.
        # geocoder._DADATA_KIND_MAP не знает 'plot' → fallback на 'locality'.
        return "plot"
    if level in (8, 9):
        # 8=house, 9=flat — оба привязаны к конкретному дому.
        return "house"
    if level == 7:
        return "street"
    return "city"


def _parse_suggestion(item: dict[str, Any]) -> DadataSuggestion | None:
    """Парсит один элемент `suggestions[]` → DadataSuggestion.

    Возвращает None если shape невалидный (нет value / нет data).
    """
    if not isinstance(item, dict):
        return None
    value = item.get("value")
    if not value or not isinstance(value, str):
        return None
    data = item.get("data") or {}
    if not isinstance(data, dict):
        data = {}

    return DadataSuggestion(
        value=value,
        unrestricted_value=str(item.get("unrestricted_value") or value),
        lat=_coerce_float(data.get("geo_lat")),
        lon=_coerce_float(data.get("geo_lon")),
        fias_id=data.get("fias_id"),
        house=data.get("house"),
        street=data.get("street"),
        city=data.get("city"),
        kind=_classify_kind(data.get("fias_level")),
        house_fias_id=data.get("house_fias_id"),
        kladr_id=data.get("kladr_id"),
        qc_geo=_coerce_int(data.get("qc_geo")),
    )


async def suggest_addresses(
    query: str,
    limit: int = 8,
    city: str | None = "Екатеринбург",
    region: str | None = None,
) -> list[DadataSuggestion]:
    """Автокомплит адресов через DaData /suggest/address.

    Args:
        query: текст для подсказки (минимум 2 символа).
        limit: сколько вариантов вернуть (DaData параметр `count`, max 20).
        city: locality для constraint (передаётся в `locations`). По умолчанию ЕКБ.
            Игнорируется, если задан `region`.
        region: область для hard-constraint в `locations` — используется вместо
            `city`, когда нужен охват шире одного города (например, вся
            Свердловская область / region 66). DaData `locations` сам по себе уже
            жёсткий фильтр (не boost) на уровне указанного admin-поля — доп.
            параметров не требуется. По умолчанию не задан — поведение (и body
            запроса) для существующих вызовов не меняется.

    Returns:
        list[DadataSuggestion] — пустой список если:
          - DADATA_API_TOKEN не задан (graceful disable)
          - query короче 2 символов
          - сетевая / HTTP ошибка / 429 quota / malformed response

    Token-only auth (X-Secret для suggest не нужен — отличается от clean_address).
    Quota: 10000/день на free tier (в 100x больше чем у clean_address).
    """
    if not query or len(query.strip()) < 2:
        return []

    token = (settings.dadata_api_token or "").strip()
    if not token:
        logger.debug("dadata suggest: DADATA_API_TOKEN не задан — skip")
        return []

    headers = {
        "Authorization": f"Token {token}",
        "Content-Type": "application/json",
        "Accept": "application/json",
    }
    body: dict[str, Any] = {
        "query": query.strip(),
        "count": max(1, min(int(limit), 20)),
    }
    if region:
        # `locations` с полем region — уже жёсткий фильтр сам по себе (DaData
        # ограничивает выдачу этим регионом). `restrict_value` — ТОП-LEVEL параметр
        # body (не ключ внутри объекта locations) — здесь он был бы silent no-op,
        # поэтому не добавляем.
        body["locations"] = [{"region": region}]
    elif city:
        body["locations"] = [{"city": city}]

    try:
        async with httpx.AsyncClient(timeout=_DADATA_SUGGEST_TIMEOUT_S) as client:
            response = await client.post(DADATA_SUGGEST_URL, headers=headers, json=body)
    except (httpx.TimeoutException, httpx.NetworkError) as exc:
        logger.warning("dadata suggest: network error для %r: %s", query[:60], exc)
        return []
    except Exception as exc:  # pragma: no cover — defensive
        logger.warning("dadata suggest: unexpected client error для %r: %s", query[:60], exc)
        return []

    status = response.status_code
    if status == 429:
        logger.warning("dadata suggest: HTTP 429 — quota exceeded (10k/день free?)")
        return []
    if status in (401, 403):
        logger.error("dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status)
        return []
    if status >= 500:
        logger.warning("dadata suggest: HTTP %d — transient server error", status)
        return []
    if status >= 400:
        body_preview = (response.text or "")[:200]
        logger.warning("dadata suggest: HTTP %d — bad request: %r", status, body_preview)
        return []

    try:
        payload = response.json()
    except ValueError as exc:
        logger.warning("dadata suggest: невалидный JSON в ответе: %s", exc)
        return []

    if not isinstance(payload, dict):
        logger.warning("dadata suggest: response не dict (%s)", type(payload).__name__)
        return []

    raw_suggestions = payload.get("suggestions")
    if not isinstance(raw_suggestions, list):
        logger.info("dadata suggest: нет ключа `suggestions` в ответе для %r", query[:60])
        return []

    out: list[DadataSuggestion] = []
    for item in raw_suggestions:
        parsed = _parse_suggestion(item)
        if parsed is not None:
            out.append(parsed)

    logger.info("dadata suggest: %r → %d вариантов", query[:60], len(out))
    return out


# ── Enrichment orchestrator (clean → suggest fallback) ───────────────────────


async def enrich_address(address: str) -> DadataAddressResult | None:
    """Обогащение целевого адреса с graceful-деградацией CLEAN → SUGGEST.

    Порядок:
      1. /clean/address (token+secret) — canonical + house_cadnum + house_fias_id +
         qc_geo + metro. Если вернул результат — используем его.
      2. Fallback: /suggest/address (token-only, доступен на free-tier 10k/день) —
         берём top house-level кандидата. Даёт house_fias_id + гео + kladr_id + qc_geo,
         но НЕ canonical_address / house_cadnum (их умеет только CLEAN) → они остаются
         None. Честная деградация: недостающие поля не выдумываем.

    Мотивация (#dadata-403, vault Mera_Hard_Audit_0712): на проде у аккаунта услуга
    CLEAN выключена (HTTP 403 «Feature CLEAN disabled»), из-за чего house_fias_id —
    ключ join с houses/ДОМ.РФ (year_built и пр.) — терялся, хотя /suggest его отдаёт
    бесплатно. Город НЕ ограничиваем (city="") — enrichment работает по всей обл. (66).
    """
    cleaned = await clean_address(address)
    if cleaned is not None:
        return cleaned

    suggestions = await suggest_addresses(address, limit=5, city="")
    for s in suggestions:
        fias = s.house_fias_id or s.fias_id
        if s.kind == "house" and fias:
            # house-level ФИАС → гео дома точное; qc_geo из ответа либо 0 (exact) по умолчанию.
            qc_geo = s.qc_geo if s.qc_geo is not None else 0
            logger.info(
                "dadata: CLEAN недоступен → suggest-fallback %r → house_fias_id=%s "
                "(canonical/cadnum недоступны без CLEAN)",
                address[:60],
                fias,
            )
            return DadataAddressResult(
                canonical_address=None,
                house_cadnum=None,
                house_fias_id=fias,
                lat=s.lat,
                lon=s.lon,
                qc_geo=qc_geo,
                qc_house=None,
                kladr_id=s.kladr_id,
                okato=None,
                oktmo=None,
                metro=[],
                raw={"_source": "suggest_fallback", "value": s.value},
            )
    return None
