"""Unit tests for `app.services.dadata.clean_address`.

Coverage:
- happy path: mocked httpx 200 → parsed DadataAddressResult
- no API key → None gracefully (no httpx call)
- network failure (TimeoutException) → None + warning log
- network failure (NetworkError) → None
- HTTP 429 quota exceeded → None
- HTTP 401 / 403 auth rejected → None
- HTTP 5xx → None
- empty response array → None
- non-list response → None
- response item missing canonical address → None
- short address (< 3 chars) → None
- credentials present but secret blank → None

NEVER calls real DaData API — все запросы mock'аются через httpx.MockTransport
(consistent с tests/test_backfill_house_coords.py + test_audit_address_mismatch.py).
"""

from __future__ import annotations

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

import httpx
import pytest

# DATABASE_URL required by config before any app import (см. test_cadastral_reverse.py).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

# WeasyPrint stub — not installed in CI без GTK
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)


# Sample response mimics real DaData payload для «Екатеринбург, ул. Малышева, 125»
# (см. PR Q1 task description в gendesign vault meta/00_credentials.md).
SAMPLE_OK_PAYLOAD = [
    {
        "source": "Екатеринбург, улица Малышева, 125",
        "result": "г Екатеринбург, ул Малышева, д 125",
        "house_cadnum": "66:41:0704045:350",
        "house_fias_id": "74457c2f-1234-5678-90ab-cdef01234567",
        "geo_lat": "56.840851",
        "geo_lon": "60.654071",
        "qc_geo": 0,
        "qc_house": 2,
        "kladr_id": "6600000100006480117",
        "okato": "65401000000",
        "oktmo": "65701000001",
        "metro": [
            {"name": "Площадь 1905 года", "line": "Первая", "distance": 3.4},
            {"name": "Геологическая", "line": "Первая", "distance": 4.1},
        ],
    }
]


def _mock_transport_returning(
    status_code: int = 200, payload: object = SAMPLE_OK_PAYLOAD
) -> httpx.MockTransport:
    """httpx MockTransport — returns desired status + JSON payload for every request."""

    def handler(request: httpx.Request) -> httpx.Response:
        # Sanity: запрос должен идти на DaData /clean/address с правильными headers
        assert "cleaner.dadata.ru" in str(request.url)
        assert request.headers.get("Authorization", "").startswith("Token ")
        assert request.headers.get("X-Secret")
        return httpx.Response(status_code, json=payload)

    return httpx.MockTransport(handler)


# Snapshot real httpx.AsyncClient ДО любых patch'ей — фабрика ниже использует
# именно его, чтобы patch'нутый `httpx.AsyncClient` не вызывал recursion
# (см. PR Q1 — fix recursion в test mocks).
_REAL_ASYNC_CLIENT = httpx.AsyncClient


def _async_client_factory(transport: httpx.MockTransport):
    """Returns drop-in replacement for httpx.AsyncClient that uses our MockTransport.

    Принимает любые kwargs конструктора (timeout=…) и игнорирует их —
    использует _REAL_ASYNC_CLIENT с подменённым transport.
    """

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

    return factory


def _patch_settings(token: str | None = "fake-token", secret: str | None = "fake-secret"):
    """Patch app.services.dadata.settings with given credentials."""
    fake = MagicMock()
    fake.dadata_api_token = token
    fake.dadata_api_secret = secret
    return patch("app.services.dadata.settings", fake)


def _patch_async_client(transport: httpx.MockTransport):
    """Patch the httpx.AsyncClient used inside dadata module."""
    return patch("app.services.dadata.httpx.AsyncClient", _async_client_factory(transport))


# ── happy path ────────────────────────────────────────────────────────────────


async def test_clean_address_happy_path_returns_parsed_result() -> None:
    """Mocked 200 → DadataAddressResult с canonical/cadnum/lat/lon/metro."""
    from app.services import dadata

    transport = _mock_transport_returning(200, SAMPLE_OK_PAYLOAD)
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.clean_address("Екатеринбург, ул. Малышева, 125")

    assert result is not None
    assert result.canonical_address == "г Екатеринбург, ул Малышева, д 125"
    assert result.house_cadnum == "66:41:0704045:350"
    assert result.house_fias_id == "74457c2f-1234-5678-90ab-cdef01234567"
    assert result.lat is not None
    assert abs(result.lat - 56.840851) < 1e-6
    assert result.lon is not None
    assert abs(result.lon - 60.654071) < 1e-6
    assert result.qc_geo == 0
    assert result.qc_house == 2
    assert result.kladr_id == "6600000100006480117"
    assert result.okato == "65401000000"
    assert result.oktmo == "65701000001"
    assert len(result.metro) == 2
    assert result.metro[0]["name"] == "Площадь 1905 года"
    # raw payload preserved для debugging
    assert result.raw["house_cadnum"] == "66:41:0704045:350"


# ── credentials missing → graceful None ─────────────────────────────────────


async def test_clean_address_returns_none_when_token_missing() -> None:
    """DADATA_API_TOKEN не задан → None, никакого httpx-вызова."""
    from app.services import dadata

    with _patch_settings(token=None, secret="fake-secret"):
        # Не используем mock_transport — если код всё-таки попробует httpx,
        # это упадёт на конкретной сети (мы это поймаем в assert ниже).
        with patch("app.services.dadata.httpx.AsyncClient") as mock_client:
            result = await dadata.clean_address("Малышева 125")

    assert result is None
    mock_client.assert_not_called()


async def test_clean_address_returns_none_when_secret_missing() -> None:
    """DADATA_API_SECRET пустая строка → None."""
    from app.services import dadata

    with _patch_settings(token="fake-token", secret=""):
        with patch("app.services.dadata.httpx.AsyncClient") as mock_client:
            result = await dadata.clean_address("Малышева 125")

    assert result is None
    mock_client.assert_not_called()


async def test_clean_address_returns_none_when_address_too_short() -> None:
    """Адрес < 3 символов → None, никакого вызова DaData."""
    from app.services import dadata

    with _patch_settings():
        with patch("app.services.dadata.httpx.AsyncClient") as mock_client:
            result = await dadata.clean_address("a")
            assert result is None
            result_blank = await dadata.clean_address("")
            assert result_blank is None
    mock_client.assert_not_called()


# ── network failures → None ─────────────────────────────────────────────────


async def test_clean_address_returns_none_on_timeout() -> None:
    """httpx TimeoutException → None gracefully."""
    from app.services import dadata

    def handler(request: httpx.Request) -> httpx.Response:
        raise httpx.TimeoutException("read timed out")

    transport = httpx.MockTransport(handler)
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.clean_address("Малышева 125")

    assert result is None


async def test_clean_address_returns_none_on_network_error() -> None:
    """httpx NetworkError (DNS / connection refused) → None."""
    from app.services import dadata

    def handler(request: httpx.Request) -> httpx.Response:
        raise httpx.NetworkError("connection refused")

    transport = httpx.MockTransport(handler)
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.clean_address("Малышева 125")

    assert result is None


# ── HTTP errors → None ──────────────────────────────────────────────────────


@pytest.mark.parametrize("status", [429, 401, 403, 500, 502, 503])
async def test_clean_address_returns_none_on_http_error(status: int) -> None:
    """Любая 4xx/5xx → None (gracefully degrade)."""
    from app.services import dadata

    transport = _mock_transport_returning(status, {"error": "rejected"})
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.clean_address("Малышева 125")

    assert result is None


# ── bad response shapes → None ──────────────────────────────────────────────


async def test_clean_address_returns_none_when_response_is_empty_array() -> None:
    """[] → None."""
    from app.services import dadata

    transport = _mock_transport_returning(200, [])
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.clean_address("Малышева 125")

    assert result is None


async def test_clean_address_returns_none_when_response_is_dict_not_list() -> None:
    """Dict вместо list → None (DaData всегда отдаёт list)."""
    from app.services import dadata

    transport = _mock_transport_returning(200, {"result": "wrong shape"})
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.clean_address("Малышева 125")

    assert result is None


async def test_clean_address_returns_none_when_item_is_not_dict() -> None:
    """[\"string\"] вместо [{...}] → None."""
    from app.services import dadata

    transport = _mock_transport_returning(200, ["not a dict"])
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.clean_address("Малышева 125")

    assert result is None


async def test_clean_address_returns_none_when_canonical_address_missing() -> None:
    """DaData отдал shape, но result=None → не распознан, None."""
    from app.services import dadata

    payload = [{"result": None, "qc_geo": 5, "qc_house": None}]
    transport = _mock_transport_returning(200, payload)
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.clean_address("какая-то ерунда")

    assert result is None


async def test_clean_address_returns_none_on_invalid_json() -> None:
    """Сервер ответил 200, но body не JSON → None."""
    from app.services import dadata

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, content=b"<html>not json</html>")

    transport = httpx.MockTransport(handler)
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.clean_address("Малышева 125")

    assert result is None


# ── partial payload handling ─────────────────────────────────────────────────


async def test_clean_address_handles_partial_payload_without_cadnum() -> None:
    """Адрес распознан (есть `result`), но дома нет в ФИАС → cadnum=None, всё равно success."""
    from app.services import dadata

    partial = [
        {
            "result": "г Екатеринбург, ул Несуществующая, д 999",
            "house_cadnum": None,
            "house_fias_id": None,
            "geo_lat": "56.84",
            "geo_lon": "60.65",
            "qc_geo": 1,  # only street found
            "qc_house": 5,  # дом не найден
            "metro": None,
        }
    ]
    transport = _mock_transport_returning(200, partial)
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.clean_address("Несуществующая 999")

    assert result is not None
    assert result.canonical_address == "г Екатеринбург, ул Несуществующая, д 999"
    assert result.house_cadnum is None
    assert result.house_fias_id is None
    assert result.qc_geo == 1
    assert result.qc_house == 5
    # metro=None coerce'нулся в пустой список
    assert result.metro == []


async def test_clean_address_coerces_string_quality_codes_to_int() -> None:
    """Иногда DaData отдаёт qc_geo строкой '0' → должно стать int."""
    from app.services import dadata

    payload = [
        {
            "result": "г Екатеринбург, ул Малышева, д 125",
            "qc_geo": "0",
            "qc_house": "2",
            "geo_lat": "56.84",
            "geo_lon": "60.65",
        }
    ]
    transport = _mock_transport_returning(200, payload)
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.clean_address("Малышева 125")

    assert result is not None
    assert result.qc_geo == 0
    assert result.qc_house == 2


async def test_clean_address_sends_proper_request_body() -> None:
    """Тело запроса — array из одного элемента: [address]. Содержит правильный текст."""
    from app.services import dadata

    captured: dict[str, object] = {}

    def handler(request: httpx.Request) -> httpx.Response:
        import json as _json

        captured["body"] = _json.loads(request.content.decode("utf-8"))
        captured["auth"] = request.headers.get("Authorization")
        captured["secret"] = request.headers.get("X-Secret")
        return httpx.Response(200, json=SAMPLE_OK_PAYLOAD)

    transport = httpx.MockTransport(handler)
    with _patch_settings(token="my-token", secret="my-secret"), _patch_async_client(transport):
        result = await dadata.clean_address("  Екатеринбург, Малышева 125  ")

    assert result is not None
    assert captured["body"] == ["Екатеринбург, Малышева 125"]  # trimmed
    assert captured["auth"] == "Token my-token"
    assert captured["secret"] == "my-secret"


# ─────────────────────────────────────────────────────────────────────────────
# DaData /suggest/address tests (PR Q2)
# ─────────────────────────────────────────────────────────────────────────────


SAMPLE_SUGGEST_PAYLOAD: dict = {
    "suggestions": [
        {
            "value": "г Екатеринбург, ул Малышева, д 30",
            "unrestricted_value": "620075, Свердловская обл, г Екатеринбург, ул Малышева, д 30",
            "data": {
                "geo_lat": "56.838011",
                "geo_lon": "60.601023",
                "fias_id": "00000000-aaaa-bbbb-cccc-000000000001",
                "fias_level": "8",
                "house": "30",
                "street": "Малышева",
                "city": "Екатеринбург",
            },
        },
        {
            "value": "г Екатеринбург, ул Малышева",
            "unrestricted_value": "Свердловская обл, г Екатеринбург, ул Малышева",
            "data": {
                "geo_lat": "56.838",
                "geo_lon": "60.605",
                "fias_id": "00000000-aaaa-bbbb-cccc-000000000002",
                "fias_level": "7",
                "house": None,
                "street": "Малышева",
                "city": "Екатеринбург",
            },
        },
        {
            "value": "г Екатеринбург",
            "unrestricted_value": "Свердловская обл, г Екатеринбург",
            "data": {
                "geo_lat": "56.838",
                "geo_lon": "60.605",
                "fias_id": "00000000-aaaa-bbbb-cccc-000000000003",
                "fias_level": "4",
                "house": None,
                "street": None,
                "city": "Екатеринбург",
            },
        },
    ]
}


def _mock_suggest_transport(
    status_code: int = 200, payload: object = SAMPLE_SUGGEST_PAYLOAD
) -> httpx.MockTransport:
    """MockTransport для /suggest endpoint — отличается URL и headers от /clean."""

    def handler(request: httpx.Request) -> httpx.Response:
        assert "suggestions.dadata.ru" in str(request.url)
        assert request.headers.get("Authorization", "").startswith("Token ")
        # Suggest endpoint НЕ требует X-Secret (отличие от /clean)
        assert request.headers.get("X-Secret") is None
        return httpx.Response(status_code, json=payload)

    return httpx.MockTransport(handler)


async def test_suggest_addresses_happy_path() -> None:
    """200 → list из 3 DadataSuggestion с правильным kind для каждого fias_level."""
    from app.services import dadata

    transport = _mock_suggest_transport(200, SAMPLE_SUGGEST_PAYLOAD)
    with _patch_settings(), _patch_async_client(transport):
        results = await dadata.suggest_addresses("Малышева")

    assert len(results) == 3
    # First — house (fias_level=8)
    assert results[0].value == "г Екатеринбург, ул Малышева, д 30"
    assert results[0].lat is not None
    assert abs(results[0].lat - 56.838011) < 1e-6
    assert results[0].lon is not None
    assert abs(results[0].lon - 60.601023) < 1e-6
    assert results[0].fias_id == "00000000-aaaa-bbbb-cccc-000000000001"
    assert results[0].house == "30"
    assert results[0].street == "Малышева"
    assert results[0].city == "Екатеринбург"
    assert results[0].kind == "house"
    # Second — street (fias_level=7)
    assert results[1].kind == "street"
    # Third — city (fias_level=4)
    assert results[2].kind == "city"


async def test_suggest_addresses_returns_empty_on_no_token() -> None:
    """DADATA_API_TOKEN не задан → [], никакого httpx-вызова."""
    from app.services import dadata

    with _patch_settings(token=None, secret="anything"):
        with patch("app.services.dadata.httpx.AsyncClient") as mock_client:
            results = await dadata.suggest_addresses("Малышева")

    assert results == []
    mock_client.assert_not_called()


async def test_suggest_addresses_returns_empty_on_short_query() -> None:
    """Query < 2 символов → [], никакого вызова DaData."""
    from app.services import dadata

    with _patch_settings():
        with patch("app.services.dadata.httpx.AsyncClient") as mock_client:
            assert await dadata.suggest_addresses("") == []
            assert await dadata.suggest_addresses("a") == []
            assert await dadata.suggest_addresses("   ") == []
    mock_client.assert_not_called()


async def test_suggest_addresses_returns_empty_on_network_error() -> None:
    """httpx TimeoutException → [] gracefully."""
    from app.services import dadata

    def handler(request: httpx.Request) -> httpx.Response:
        raise httpx.TimeoutException("read timed out")

    transport = httpx.MockTransport(handler)
    with _patch_settings(), _patch_async_client(transport):
        results = await dadata.suggest_addresses("Малышева")

    assert results == []


@pytest.mark.parametrize("status", [429, 401, 403, 500, 502, 503])
async def test_suggest_addresses_returns_empty_on_http_error(status: int) -> None:
    """4xx/5xx → []."""
    from app.services import dadata

    transport = _mock_suggest_transport(status, {"error": "rejected"})
    with _patch_settings(), _patch_async_client(transport):
        results = await dadata.suggest_addresses("Малышева")

    assert results == []


async def test_suggest_addresses_returns_empty_on_malformed_response() -> None:
    """Нет ключа `suggestions` / не dict / список вместо dict → []."""
    from app.services import dadata

    # Case 1: dict без ключа `suggestions`
    transport = _mock_suggest_transport(200, {"other": "key"})
    with _patch_settings(), _patch_async_client(transport):
        results = await dadata.suggest_addresses("Малышева")
    assert results == []

    # Case 2: пустой suggestions list
    transport2 = _mock_suggest_transport(200, {"suggestions": []})
    with _patch_settings(), _patch_async_client(transport2):
        results = await dadata.suggest_addresses("Малышева")
    assert results == []

    # Case 3: response — list вместо dict
    transport3 = _mock_suggest_transport(200, ["wrong shape"])
    with _patch_settings(), _patch_async_client(transport3):
        results = await dadata.suggest_addresses("Малышева")
    assert results == []


async def test_suggest_addresses_filters_null_coords() -> None:
    """Suggest должен парсить запись даже без координат — фильтр координат
    отвечает _dadata_suggest в geocoder.py. Сам suggest_addresses возвращает
    DadataSuggestion с lat=None/lon=None (не отбрасывает на этом уровне)."""
    from app.services import dadata

    payload = {
        "suggestions": [
            {
                "value": "г Екатеринбург, район Кировский",
                "unrestricted_value": "Свердловская обл, г Екатеринбург, р-н Кировский",
                "data": {
                    "geo_lat": None,
                    "geo_lon": None,
                    "fias_level": "6",
                    "city": "Екатеринбург",
                },
            },
            {
                "value": "г Екатеринбург, ул Малышева, д 30",
                "unrestricted_value": "620075, Свердловская обл, г Екатеринбург, ул Малышева, д 30",
                "data": {
                    "geo_lat": "56.838",
                    "geo_lon": "60.601",
                    "fias_level": "8",
                    "house": "30",
                    "street": "Малышева",
                    "city": "Екатеринбург",
                },
            },
        ]
    }
    transport = _mock_suggest_transport(200, payload)
    with _patch_settings(), _patch_async_client(transport):
        results = await dadata.suggest_addresses("Кировский")

    # suggest_addresses вернёт оба — фильтр координат в _dadata_suggest (geocoder)
    assert len(results) == 2
    assert results[0].lat is None
    assert results[0].lon is None
    assert results[1].lat is not None


async def test_suggest_addresses_classifies_kind() -> None:
    """fias_level → kind. 8→house, 7→street, ≤6→city. Также coerces str→int."""
    from app.services import dadata

    payload = {
        "suggestions": [
            {
                "value": "Дом",
                "unrestricted_value": "Дом",
                "data": {"geo_lat": "56.84", "geo_lon": "60.6", "fias_level": "8"},
            },
            {
                "value": "Улица",
                "unrestricted_value": "Улица",
                "data": {"geo_lat": "56.84", "geo_lon": "60.6", "fias_level": "7"},
            },
            {
                "value": "Город",
                "unrestricted_value": "Город",
                "data": {"geo_lat": "56.84", "geo_lon": "60.6", "fias_level": "4"},
            },
            {
                "value": "Регион",
                "unrestricted_value": "Регион",
                "data": {"geo_lat": "56.84", "geo_lon": "60.6", "fias_level": "1"},
            },
            {
                "value": "Квартира",  # fias_level=9 (>= 8) → house bucket
                "unrestricted_value": "Квартира",
                "data": {"geo_lat": "56.84", "geo_lon": "60.6", "fias_level": "9"},
            },
            {
                "value": "Без уровня",  # fias_level отсутствует → city safe default
                "unrestricted_value": "Без уровня",
                "data": {"geo_lat": "56.84", "geo_lon": "60.6"},
            },
        ]
    }
    transport = _mock_suggest_transport(200, payload)
    with _patch_settings(), _patch_async_client(transport):
        results = await dadata.suggest_addresses("test")

    assert len(results) == 6
    assert results[0].kind == "house"  # 8
    assert results[1].kind == "street"  # 7
    assert results[2].kind == "city"  # 4
    assert results[3].kind == "city"  # 1
    assert results[4].kind == "house"  # 9 (>=8)
    assert results[5].kind == "city"  # missing → default


async def test_suggest_addresses_sends_proper_request() -> None:
    """Body содержит query/count/locations, header только Authorization (без X-Secret)."""
    from app.services import dadata

    captured: dict[str, object] = {}

    def handler(request: httpx.Request) -> httpx.Response:
        import json as _json

        assert "suggestions.dadata.ru" in str(request.url)
        captured["body"] = _json.loads(request.content.decode("utf-8"))
        captured["auth"] = request.headers.get("Authorization")
        captured["x_secret"] = request.headers.get("X-Secret")
        return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD)

    transport = httpx.MockTransport(handler)
    with _patch_settings(token="my-token"), _patch_async_client(transport):
        results = await dadata.suggest_addresses("  Малышева  ", limit=5, city="Екатеринбург")

    assert len(results) > 0
    body = captured["body"]
    assert isinstance(body, dict)
    assert body["query"] == "Малышева"  # trimmed
    assert body["count"] == 5
    assert body["locations"] == [{"city": "Екатеринбург"}]
    assert captured["auth"] == "Token my-token"
    # Critical: suggest endpoint should NOT require X-Secret
    assert captured["x_secret"] is None


async def test_suggest_addresses_region_body_shape_no_restrict_value() -> None:
    """region= constrains `locations` by region, WITHOUT a restrict_value key.

    `restrict_value` is a top-level DaData body param (not a per-location dict
    key) — putting it inside the locations object would be a silent no-op.
    The region constraint in `locations` is already a hard filter on its own.
    """
    from app.services import dadata

    captured: dict[str, object] = {}

    def handler(request: httpx.Request) -> httpx.Response:
        import json as _json

        captured["body"] = _json.loads(request.content.decode("utf-8"))
        return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD)

    transport = httpx.MockTransport(handler)
    with _patch_settings(token="my-token"), _patch_async_client(transport):
        await dadata.suggest_addresses("Ленина", limit=5, city=None, region="Свердловская область")

    body = captured["body"]
    assert isinstance(body, dict)
    assert body["locations"] == [{"region": "Свердловская область"}]
    assert "restrict_value" not in body["locations"][0]
    assert "restrict_value" not in body


async def test_suggest_addresses_region_overrides_city() -> None:
    """Когда задан и `city`, и `region` — region побеждает, city игнорируется."""
    from app.services import dadata

    captured: dict[str, object] = {}

    def handler(request: httpx.Request) -> httpx.Response:
        import json as _json

        captured["body"] = _json.loads(request.content.decode("utf-8"))
        return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD)

    transport = httpx.MockTransport(handler)
    with _patch_settings(), _patch_async_client(transport):
        await dadata.suggest_addresses("Ленина", city="Екатеринбург", region="Свердловская область")

    body = captured["body"]
    assert isinstance(body, dict)
    assert body["locations"] == [{"region": "Свердловская область"}]


async def test_suggest_addresses_clamps_limit() -> None:
    """count должен быть clamped в [1, 20] — DaData ограничение."""
    from app.services import dadata

    captured: dict[str, object] = {}

    def handler(request: httpx.Request) -> httpx.Response:
        import json as _json

        captured["body"] = _json.loads(request.content.decode("utf-8"))
        return httpx.Response(200, json={"suggestions": []})

    transport = httpx.MockTransport(handler)
    with _patch_settings(), _patch_async_client(transport):
        await dadata.suggest_addresses("Малышева", limit=100)

    body = captured["body"]
    assert isinstance(body, dict)
    assert body["count"] == 20  # clamped to max


# ─────────────────────────────────────────────────────────────────────────────
# #dadata-403 — clean feature-disabled log + enrich_address (clean → suggest fallback)
# ─────────────────────────────────────────────────────────────────────────────


import logging as _logging  # noqa: E402

# Real prod 403 body when услуга CLEAN не подключена (не отклонённый токен).
CLEAN_FEATURE_DISABLED_BODY = {
    "timestamp": "2026-07-12T16:37:54.585+00:00",
    "status": 403,
    "error": "Forbidden",
    "message": (
        "Feature 'CLEAN' disabled for token 'xxx'. "
        "See https://dadata.userecho.com/topics/7784 for help."
    ),
    "path": "/api/v1/clean/address",
}

# House-level suggest candidate carrying house_fias_id/kladr_id/qc_geo (что нужно fallback'у).
SUGGEST_HOUSE_PAYLOAD = {
    "suggestions": [
        {
            "value": "г Екатеринбург, ул Малышева, д 4",
            "unrestricted_value": "620014, Свердловская обл, г Екатеринбург, ул Малышева, д 4",
            "data": {
                "geo_lat": "56.831463",
                "geo_lon": "60.580943",
                "fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
                "house_fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
                "kladr_id": "6600000100006480017",
                "qc_geo": "0",
                "fias_level": "8",
                "house": "4",
                "street": "Малышева",
                "city": "Екатеринбург",
            },
        }
    ]
}


def _mock_enrich_transport(
    clean_status: int, clean_body: object, suggest_body: object
) -> httpx.MockTransport:
    """Routes /clean vs /suggest by URL — enrich_address зовёт оба через один AsyncClient."""

    def handler(request: httpx.Request) -> httpx.Response:
        url = str(request.url)
        if "cleaner.dadata.ru" in url:
            return httpx.Response(clean_status, json=clean_body)
        if "suggestions.dadata.ru" in url:
            return httpx.Response(200, json=suggest_body)
        return httpx.Response(404, json={"error": "unexpected url"})

    return httpx.MockTransport(handler)


async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
    """403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен)."""
    from app.services import dadata

    transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
    with _patch_settings(), _patch_async_client(transport):
        with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
            result = await dadata.clean_address("Екатеринбург, Малышева 4")

    assert result is None
    text = caplog.text
    assert "Стандартизация" in text or "выключена" in text
    # Не должны обвинять токен при feature-disabled.
    assert "auth/secret rejected" not in text


async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
    """401 (или 403 без 'disabled') → сообщение про креды."""
    from app.services import dadata

    transport = _mock_transport_returning(401, {"message": "Unauthorized"})
    with _patch_settings(), _patch_async_client(transport):
        with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
            result = await dadata.clean_address("Екатеринбург, Малышева 4")

    assert result is None
    assert "auth/secret rejected" in caplog.text


async def test_suggest_addresses_captures_house_fias_kladr_qc() -> None:
    """_parse_suggestion теперь тянет house_fias_id / kladr_id / qc_geo из data."""
    from app.services import dadata

    transport = _mock_suggest_transport(200, SUGGEST_HOUSE_PAYLOAD)
    with _patch_settings(), _patch_async_client(transport):
        results = await dadata.suggest_addresses("Малышева 4")

    assert len(results) == 1
    assert results[0].house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
    assert results[0].kladr_id == "6600000100006480017"
    assert results[0].qc_geo == 0


async def test_enrich_address_uses_clean_when_available() -> None:
    """CLEAN 200 → берём его результат (canonical/cadnum), suggest не подменяет."""
    from app.services import dadata

    transport = _mock_enrich_transport(200, SAMPLE_OK_PAYLOAD, {"suggestions": []})
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 125")

    assert result is not None
    assert result.canonical_address == "г Екатеринбург, ул Малышева, д 125"
    assert result.house_cadnum == "66:41:0704045:350"
    assert result.raw.get("_source") != "suggest_fallback"


async def test_enrich_address_falls_back_to_suggest_when_clean_unavailable() -> None:
    """CLEAN 403 disabled → suggest-fallback даёт house_fias_id/гео; canonical/cadnum=None."""
    from app.services import dadata

    transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, SUGGEST_HOUSE_PAYLOAD)
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 4")

    assert result is not None
    assert result.house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
    assert result.kladr_id == "6600000100006480017"
    assert result.qc_geo == 0
    assert result.lat is not None and abs(result.lat - 56.831463) < 1e-6
    # Честная деградация: без CLEAN нет canonical/cadnum — не выдумываем.
    assert result.canonical_address is None
    assert result.house_cadnum is None
    assert result.raw.get("_source") == "suggest_fallback"


async def test_enrich_address_ignores_non_house_suggestions() -> None:
    """Если suggest вернул только street/city (не house) → fallback не даёт house_fias_id → None."""
    from app.services import dadata

    street_only = {
        "suggestions": [
            {
                "value": "г Екатеринбург, ул Малышева",
                "unrestricted_value": "Свердловская обл, г Екатеринбург, ул Малышева",
                "data": {"geo_lat": "56.83", "geo_lon": "60.58", "fias_id": "s", "fias_level": "7"},
            }
        ]
    }
    transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, street_only)
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.enrich_address("Екатеринбург, ул. Малышева")

    assert result is None


async def test_enrich_address_returns_none_when_clean_and_suggest_both_fail() -> None:
    """CLEAN недоступен + suggest пустой → None (graceful)."""
    from app.services import dadata

    transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, {"suggestions": []})
    with _patch_settings(), _patch_async_client(transport):
        result = await dadata.enrich_address("Малышева 4")

    assert result is None
