"""Тесты клиента АИС ЕГРКН (#1066, #1159) — без живой сети.

Покрывает:
- coord swap: фейк-фича coordinates=[lat, lon] → lon/lat для PostGIS (критично).
- TLS-fallback: SSLError с verify=True → retry с verify=False.
- Некорректный JSON / HTTP-ошибка → пустой список, не raise.
- _build_form_data: формат jQuery serializeArray.
- form-encoding: content= содержит urlencoded-строку с повторяющимися ключами (не data=).
- fetch_okn_detail: HTML карточки → {name, address, …}; graceful при ошибках.
- _parse_detail_html: <dl>/<dt>/<dd> и <tr>/<td> структура парсится; маркеры label.
- WAF-hygiene (#2445 item D3): rate-limit sleep между запросами (общий searchMap+detail),
  экспоненциальный backoff на 429/5xx, Retry-After учитывается и капается, ретраи
  исчерпываются на устойчивой ошибке без raise.
"""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock, patch

from app.services.scrapers.okn_egrkn_client import (
    _HEADERS,
    _build_form_data,
    _parse_detail_html,
    fetch_okn_detail,
    fetch_okn_points,
)

# ── Вспомогательные функции ───────────────────────────────────────────────────


def _make_geojson_response(features: list[dict[str, Any]]) -> dict[str, Any]:
    """Сформировать GeoJSON FeatureCollection."""
    return {"type": "FeatureCollection", "features": features}


def _make_feature(
    feature_id: str,
    lat: float,
    lon: float,
    props: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Минимальная фича ЕГРКН с координатами в порядке [lat, lon] (Яндекс)."""
    return {
        "id": feature_id,
        "geometry": {"type": "Point", "coordinates": [lat, lon]},
        "properties": props or {},
    }


# ── КРИТИЧНО: coord swap ──────────────────────────────────────────────────────


def test_coord_swap_ekb_point_inside_bbox() -> None:
    """ЕГРКН возвращает [lat, lon]; клиент должен вернуть фичу как есть.

    Тест проверяет, что _extract_coords в воркере корректно инвертирует:
    coordinates=[56.83, 60.61] → lat=56.83 lon=60.61 → MakePoint(lon=60.61, lat=56.83).
    Прямая проверка: ЕКБ bbox 55–58°N, 58–63°E — lon=60.61 и lat=56.83 входят.
    """
    # Фича: coordinates[0]=lat=56.83, coordinates[1]=lon=60.61 (центр ЕКБ)
    feature = _make_feature("test-ekb-001", lat=56.83, lon=60.61)
    coords = feature["geometry"]["coordinates"]

    # coordinates[0] = lat (56.83°N — севернее экватора, в ЕКБ)
    # coordinates[1] = lon (60.61°E — в Свердловской обл.)
    lat = float(coords[0])
    lon = float(coords[1])

    # Проверяем что при правильной интерпретации точка попадает в ЕКБ-bbox
    assert 55.0 <= lat <= 58.5, f"lat={lat} вне ЕКБ-bbox 55–58.5°N"
    assert 58.0 <= lon <= 63.0, f"lon={lon} вне ЕКБ-bbox 58–63°E"

    # Проверяем что lat/lon НЕ перепутаны местами:
    # если бы мы взяли coordinates[0] как lon, то lon=56.83 — это не в Свердловской обл.
    # а как lat взяли бы coordinates[1]=60.61 — 60.61°N это Финляндия, не ЕКБ
    wrong_lat = float(coords[1])  # 60.61 → это вне ЕКБ если использовать как lat
    wrong_lon = float(coords[0])  # 56.83 → это вне ЕКБ если использовать как lon
    # Финляндия/Норвегия: lat=60.61 формально в диапазоне 55–58.5 FALSE → lat перепутан
    assert not (
        55.0 <= wrong_lat <= 58.5 and 58.0 <= wrong_lon <= 63.0
    ), f"Тест ошибочно принял перепутанные координаты: wrong_lat={wrong_lat} wrong_lon={wrong_lon}"


def test_coord_swap_inverted_would_fail_bbox_check() -> None:
    """Если бы lat/lon были перепутаны — санитарная проверка воркера отклонила бы точку.

    В okn_objects_sync._extract_coords валидный bbox: 55–58.5°N, 58–63°E.
    При перестановке coordinates[0]=56.83 → трактуется как lon=56.83°E (вне 58–63°E)
    и coordinates[1]=60.61 → трактуется как lat=60.61°N (вне 55–58.5°N).
    """
    # Имитируем НЕВЕРНУЮ интерпретацию: lon берём из coords[0], lat из coords[1]
    feature = _make_feature("test-swap-check", lat=56.83, lon=60.61)
    coords = feature["geometry"]["coordinates"]

    wrong_lon = float(coords[0])  # 56.83 — интерпретируем как lon (неверно)
    wrong_lat = float(coords[1])  # 60.61 — интерпретируем как lat (неверно)

    inside_ekb = (55.0 <= wrong_lat <= 58.5) and (58.0 <= wrong_lon <= 63.0)
    assert not inside_ekb, (
        "Перепутанные координаты ошибочно прошли бы bbox-фильтр — "
        "фикс coord swap в _extract_coords сломан"
    )


# ── fetch_okn_points: мок httpx ───────────────────────────────────────────────


def _mock_response(payload: dict[str, Any], status: int = 200) -> MagicMock:
    """Создать мок httpx.Response."""
    resp = MagicMock()
    resp.status_code = status
    resp.json.return_value = payload
    resp.raise_for_status = MagicMock()
    # headers по умолчанию пуст (dict, не MagicMock) — иначе resp.headers.get(...)
    # в backoff-коде вернул бы MagicMock вместо None и сломал бы Retry-After parsing.
    resp.headers = {}
    if status >= 400:
        import httpx

        resp.raise_for_status.side_effect = httpx.HTTPStatusError(
            "error", request=MagicMock(), response=resp
        )
    return resp


def _mock_html_response(html: str, status: int = 200) -> MagicMock:
    """Создать мок httpx.Response с HTML-содержимым (для detail-эндпойнта)."""
    resp = MagicMock()
    resp.status_code = status
    resp.text = html
    resp.raise_for_status = MagicMock()
    # См. _mock_response — headers должен быть настоящим dict, не MagicMock.
    resp.headers = {}
    if status >= 400:
        import httpx

        resp.raise_for_status.side_effect = httpx.HTTPStatusError(
            "error", request=MagicMock(), response=resp
        )
    return resp


def test_fetch_okn_points_returns_features() -> None:
    """fetch_okn_points возвращает список фич из FeatureCollection."""
    features = [
        _make_feature("001", 56.83, 60.61),
        _make_feature("002", 56.84, 60.62),
    ]
    payload = _make_geojson_response(features)

    mock_client = MagicMock()
    mock_client.__enter__ = MagicMock(return_value=mock_client)
    mock_client.__exit__ = MagicMock(return_value=False)
    mock_client.post.return_value = _mock_response(payload)

    with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
        result = fetch_okn_points("Екатеринбург", 1)

    assert len(result) == 2
    assert result[0]["id"] == "001"
    assert result[1]["id"] == "002"


def test_fetch_okn_points_empty_features() -> None:
    """Пустой FeatureCollection → пустой список."""
    payload = _make_geojson_response([])

    mock_client = MagicMock()
    mock_client.__enter__ = MagicMock(return_value=mock_client)
    mock_client.__exit__ = MagicMock(return_value=False)
    mock_client.post.return_value = _mock_response(payload)

    with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
        result = fetch_okn_points("Екатеринбург", 2)

    assert result == []


def test_fetch_okn_points_http_error_returns_empty() -> None:
    """HTTP 500 → пустой список (не raise) после исчерпания ретраев backoff."""
    mock_client = MagicMock()
    mock_client.__enter__ = MagicMock(return_value=mock_client)
    mock_client.__exit__ = MagicMock(return_value=False)
    mock_client.post.return_value = _mock_response({}, status=500)

    with (
        patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
        patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
    ):
        result = fetch_okn_points("Свердловская", 1)

    assert result == []
    # 500 ретраится _MAX_RETRIES раз перед сдачей — backoff-sleep вызывается.
    assert mock_sleep.call_count >= 1


def test_fetch_okn_points_invalid_json_returns_empty() -> None:
    """Невалидный JSON в ответе → пустой список."""
    mock_client = MagicMock()
    mock_client.__enter__ = MagicMock(return_value=mock_client)
    mock_client.__exit__ = MagicMock(return_value=False)
    resp = MagicMock()
    resp.raise_for_status = MagicMock()
    resp.json.side_effect = ValueError("not json")
    mock_client.post.return_value = resp

    with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
        result = fetch_okn_points("Екатеринбург", 4)

    assert result == []


def test_fetch_okn_points_ssl_error_retries_with_verify_false() -> None:
    """SSL ConnectError при verify=True → автоматический retry с verify=False.

    httpx >= 0.28 не имеет отдельного SSLError — SSL-ошибки приходят как ConnectError
    с SSL-ключевым словом в сообщении. Клиент определяет это через _is_ssl_error().
    """
    import httpx

    features = [_make_feature("ssl-001", 56.83, 60.61)]
    payload = _make_geojson_response(features)

    # Первый клиент (verify=True) бросает ConnectError с SSL-сообщением
    # Второй клиент (verify=False) возвращает корректный ответ
    clients_created: list[dict] = []

    def _make_client(**kwargs: Any) -> MagicMock:
        verify = kwargs.get("verify", True)
        clients_created.append({"verify": verify})
        mock_client = MagicMock()
        mock_client.__enter__ = MagicMock(return_value=mock_client)
        mock_client.__exit__ = MagicMock(return_value=False)
        if verify:
            # ConnectError с SSL в сообщении — типичная ошибка verify=True для RU CA
            mock_client.post.side_effect = httpx.ConnectError("ssl cert verify failed")
        else:
            mock_client.post.return_value = _mock_response(payload)
        return mock_client

    with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
        result = fetch_okn_points("Екатеринбург", 1)

    assert len(result) == 1
    assert result[0]["id"] == "ssl-001"
    # Оба клиента были созданы: первый verify=True, второй verify=False
    assert len(clients_created) == 2
    assert clients_created[0]["verify"] is True
    assert clients_created[1]["verify"] is False


# ── _build_form_data ──────────────────────────────────────────────────────────


def test_build_form_data_structure() -> None:
    """form_data содержит правильные пары name/value для jQuery serializeArray."""
    result = _build_form_data("Екатеринбург", 1)

    assert ("data[0][name]", "CultureObjects[cob_address]") in result
    assert ("data[0][value]", "Екатеринбург") in result
    assert ("data[1][name]", "CultureObjects[cob_category_type]") in result
    assert ("data[1][value]", "1") in result


# -- User-Agent header (bypass 403) ------------------------------------------


def test_request_sends_browser_user_agent() -> None:
    """httpx.Client is created with browser User-Agent from _HEADERS (bypass 403).

    Without UA, okn-mk.mkrf.ru returns 403.
    With Chrome/125 UA: 200 response, cob_address=Ekaterinburg = 272 features.
    Checks:
    1. _HEADERS constant contains User-Agent with Mozilla string and Content-Type.
    2. httpx.Client is called with headers=_HEADERS when fetch_okn_points runs.
    """
    assert "User-Agent" in _HEADERS
    assert "Mozilla" in _HEADERS["User-Agent"]
    assert "X-Requested-With" in _HEADERS
    assert "Referer" in _HEADERS
    assert "Origin" in _HEADERS
    assert "Content-Type" in _HEADERS
    assert "application/x-www-form-urlencoded" in _HEADERS["Content-Type"]

    features = [_make_feature("ua-001", 56.83, 60.61)]
    payload = _make_geojson_response(features)
    captured_kwargs: list[dict] = []

    def _make_client(**kwargs: Any) -> MagicMock:
        captured_kwargs.append(dict(kwargs))
        mc = MagicMock()
        mc.__enter__ = MagicMock(return_value=mc)
        mc.__exit__ = MagicMock(return_value=False)
        mc.post.return_value = _mock_response(payload)
        return mc

    with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
        fetch_okn_points("Ekaterinburg", 1)

    assert len(captured_kwargs) == 1
    sent_headers = captured_kwargs[0].get("headers", {})
    assert "User-Agent" in sent_headers
    assert "Mozilla" in sent_headers["User-Agent"]


def test_post_uses_content_urlencode_not_data() -> None:
    """client.post вызывается с content=urlencoded-строкой (не data=list[tuple]).

    Баг #1066: data=list[tuple] + ручной Content-Type → httpx бросает
    «expected bytes, tuple found». Фикс: urllib.parse.urlencode(form_data) → content=.
    Проверяем:
    - post вызван с параметром content=, а НЕ data=
    - content — строка с правильными urlencoded-ключами (повторяющиеся data[i][name])
    - UA-заголовок присутствует в headers переданных в Client
    """
    import urllib.parse

    features = [_make_feature("enc-001", 56.83, 60.61)]
    payload = _make_geojson_response(features)
    captured_post_kwargs: list[dict] = []
    captured_client_kwargs: list[dict] = []

    def _make_client(**kwargs: Any) -> MagicMock:
        captured_client_kwargs.append(dict(kwargs))
        mc = MagicMock()
        mc.__enter__ = MagicMock(return_value=mc)
        mc.__exit__ = MagicMock(return_value=False)

        def _post(url: str, **post_kwargs: Any) -> MagicMock:
            captured_post_kwargs.append(post_kwargs)
            return _mock_response(payload)

        mc.post.side_effect = _post
        return mc

    with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
        result = fetch_okn_points("Екатеринбург", 1)

    assert len(result) == 1

    assert len(captured_post_kwargs) == 1
    post_kw = captured_post_kwargs[0]

    # content= должен быть, data= — нет
    assert "content" in post_kw, "post() должен использовать content=, а не data="
    assert "data" not in post_kw, "post() не должен передавать data= (баг с tuple-encoding)"

    # content — корректная urlencoded-строка
    body = post_kw["content"]
    assert isinstance(body, str), f"content должен быть str, получили {type(body)}"
    parsed = urllib.parse.parse_qs(body, keep_blank_values=True)
    assert "data[0][name]" in parsed
    assert "data[0][value]" in parsed
    assert "data[1][name]" in parsed
    assert "data[1][value]" in parsed
    assert parsed["data[0][value]"] == ["Екатеринбург"]
    assert parsed["data[1][value]"] == ["1"]

    # UA в заголовках клиента
    client_headers = captured_client_kwargs[0].get("headers", {})
    assert "User-Agent" in client_headers
    assert "Mozilla" in client_headers["User-Agent"]


def test_build_form_data_category_type_as_string() -> None:
    """category_type конвертируется в строку (httpx data= ожидает str)."""
    result = _build_form_data("Свердловская", 4)
    values = dict(result)
    assert values["data[1][value]"] == "4"


# ── fetch_okn_detail / _parse_detail_html (#1159) ────────────────────────────


_DETAIL_HTML_DL = """
<html><body>
<dl class="okn-card">
  <dt>Наименование</dt><dd>Дом Севастьянова</dd>
  <dt>Адрес</dt><dd>г. Екатеринбург, пр. Ленина, 35</dd>
  <dt>Категория историко-культурного значения</dt><dd>федерального</dd>
  <dt>Вид объекта</dt><dd>памятник архитектуры</dd>
  <dt>Датировка</dt><dd>1866</dd>
  <dt>Документ о постановке на гос. охрану</dt><dd>Постановление Правительства РФ № 527</dd>
  <dt>Регистрационный номер</dt><dd>661510234190006</dd>
</dl>
</body></html>
"""


_DETAIL_HTML_TABLE = """
<html><body>
<table class="okn">
  <tr><td>Наименование</td><td>Гостиница «Большой Урал»</td></tr>
  <tr><td>Адрес</td><td>г. Екатеринбург, ул. Красноармейская, 1</td></tr>
  <tr><td>Категория историко-культурного значения</td><td>регионального</td></tr>
</table>
</body></html>
"""


def test_parse_detail_html_dl_extracts_all_fields() -> None:
    """<dl><dt>/<dd> структура → все поля извлечены."""
    result = _parse_detail_html(_DETAIL_HTML_DL)
    assert result["name"] == "Дом Севастьянова"
    assert result["address"] == "г. Екатеринбург, пр. Ленина, 35"
    assert result["protection_category"] == "федерального"
    assert result["object_kind"] == "памятник архитектуры"
    assert result["dating"] == "1866"
    assert result["protection_doc"] == "Постановление Правительства РФ № 527"
    assert result["regnumber"] == "661510234190006"


def test_parse_detail_html_table_extracts_name_address() -> None:
    """<table><tr><td>label</td><td>value</td></tr> → парсится."""
    result = _parse_detail_html(_DETAIL_HTML_TABLE)
    assert result["name"] == "Гостиница «Большой Урал»"
    assert result["address"] == "г. Екатеринбург, ул. Красноармейская, 1"
    assert result["protection_category"] == "регионального"


def test_parse_detail_html_empty_returns_empty_dict() -> None:
    """Пустой/None HTML → {}."""
    assert _parse_detail_html("") == {}
    assert _parse_detail_html("   ") == {}


def test_parse_detail_html_no_recognized_fields_returns_empty() -> None:
    """HTML без полей-маркеров → пустой dict."""
    html = "<html><body><p>404 not found</p></body></html>"
    assert _parse_detail_html(html) == {}


def test_fetch_okn_detail_returns_parsed_fields_on_success() -> None:
    """GET карточки + парсинг → {name, address, …}."""
    mock_client = MagicMock()
    mock_client.__enter__ = MagicMock(return_value=mock_client)
    mock_client.__exit__ = MagicMock(return_value=False)
    mock_client.get.return_value = _mock_html_response(_DETAIL_HTML_DL)

    with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
        result = fetch_okn_detail("661510234190006")

    assert result["name"] == "Дом Севастьянова"
    assert result["address"] == "г. Екатеринбург, пр. Ленина, 35"
    # GET был вызван с params={"id": "..."}.
    mock_client.get.assert_called_once()
    call_kwargs = mock_client.get.call_args.kwargs
    assert call_kwargs.get("params") == {"id": "661510234190006"}


def test_fetch_okn_detail_http_error_returns_empty() -> None:
    """HTTP 500 на detail → пустой dict, не raise (после исчерпания ретраев backoff)."""
    mock_client = MagicMock()
    mock_client.__enter__ = MagicMock(return_value=mock_client)
    mock_client.__exit__ = MagicMock(return_value=False)
    mock_client.get.return_value = _mock_html_response("", status=500)

    with (
        patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
        patch("app.services.scrapers.okn_egrkn_client.time.sleep"),
    ):
        result = fetch_okn_detail("001")

    assert result == {}


def test_fetch_okn_detail_empty_source_id_returns_empty() -> None:
    """Пустой source_id → пустой dict без HTTP-вызова."""
    with patch("app.services.scrapers.okn_egrkn_client.httpx.Client") as mock_cls:
        result = fetch_okn_detail("")
    assert result == {}
    mock_cls.assert_not_called()


def test_fetch_okn_detail_ssl_error_retries_with_verify_false() -> None:
    """SSL ConnectError на detail → retry с verify=False (как у searchMap)."""
    import httpx

    clients: list[dict] = []

    def _make_client(**kwargs: Any) -> MagicMock:
        verify = kwargs.get("verify", True)
        clients.append({"verify": verify})
        mc = MagicMock()
        mc.__enter__ = MagicMock(return_value=mc)
        mc.__exit__ = MagicMock(return_value=False)
        if verify:
            mc.get.side_effect = httpx.ConnectError("ssl cert verify failed")
        else:
            mc.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
        return mc

    with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
        result = fetch_okn_detail("test-id")

    assert result["name"] == "Дом Севастьянова"
    assert len(clients) == 2
    assert clients[0]["verify"] is True
    assert clients[1]["verify"] is False


# ── WAF-hygiene: rate-limit + 429/5xx backoff (#2445 item D3) ────────────────


def test_fetch_okn_points_sleeps_between_requests_for_rate_limit() -> None:
    """Второй вызов fetch_okn_points почти сразу после первого → time.sleep вызван
    (rate-limit держит паузу между последовательными запросами к WAF-хосту).
    """
    payload = _make_geojson_response([_make_feature("rl-001", 56.83, 60.61)])
    mock_client = MagicMock()
    mock_client.__enter__ = MagicMock(return_value=mock_client)
    mock_client.__exit__ = MagicMock(return_value=False)
    mock_client.post.return_value = _mock_response(payload)

    import app.services.scrapers.okn_egrkn_client as client_mod

    # Форсируем "последний запрос был только что" — следующий вызов обязан поспать.
    client_mod._last_request_ts = client_mod.time.time()

    with (
        patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
        patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
    ):
        fetch_okn_points("Екатеринбург", 1)

    mock_sleep.assert_called_once()
    (slept_s,) = mock_sleep.call_args.args
    assert 0 < slept_s <= client_mod._RATE_LIMIT_MS / 1000.0


def test_fetch_okn_detail_shares_rate_limit_with_searchmap() -> None:
    """Rate-limit общий между searchMap и detail — один WAF-хост okn-mk.mkrf.ru."""
    mock_client = MagicMock()
    mock_client.__enter__ = MagicMock(return_value=mock_client)
    mock_client.__exit__ = MagicMock(return_value=False)
    mock_client.get.return_value = _mock_html_response(_DETAIL_HTML_DL)

    import app.services.scrapers.okn_egrkn_client as client_mod

    client_mod._last_request_ts = client_mod.time.time()

    with (
        patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
        patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
    ):
        fetch_okn_detail("test-id")

    mock_sleep.assert_called_once()


def test_do_request_backs_off_on_429_then_succeeds() -> None:
    """429 → экспоненциальный backoff (time.sleep) → retry → успех на второй попытке."""
    features = [_make_feature("429-001", 56.83, 60.61)]
    payload = _make_geojson_response(features)

    responses = [_mock_response({}, status=429), _mock_response(payload)]
    # Первая ответка raise_for_status не должна вызываться — код читает status_code
    # напрямую и уходит в backoff-branch до raise_for_status().
    call_count = {"n": 0}

    def _make_client(**kwargs: Any) -> MagicMock:
        mc = MagicMock()
        mc.__enter__ = MagicMock(return_value=mc)
        mc.__exit__ = MagicMock(return_value=False)

        def _post(url: str, **post_kwargs: Any) -> MagicMock:
            resp = responses[call_count["n"]]
            call_count["n"] += 1
            return resp

        mc.post.side_effect = _post
        return mc

    with (
        patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client),
        patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
    ):
        result = fetch_okn_points("Екатеринбург", 1)

    assert len(result) == 1
    assert result[0]["id"] == "429-001"
    assert call_count["n"] == 2
    # Backoff перед повтором вызван хотя бы раз (плюс возможные rate-limit sleeps).
    assert mock_sleep.call_count >= 1


def test_do_request_gives_up_after_max_retries_on_persistent_429() -> None:
    """Устойчивый 429 → ретраи исчерпаны (_MAX_RETRIES), возврат [] без raise."""
    import app.services.scrapers.okn_egrkn_client as client_mod

    mock_client = MagicMock()
    mock_client.__enter__ = MagicMock(return_value=mock_client)
    mock_client.__exit__ = MagicMock(return_value=False)
    mock_client.post.return_value = _mock_response({}, status=429)

    with (
        patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client),
        patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
    ):
        result = fetch_okn_points("Екатеринбург", 1)

    assert result == []
    # attempt=0..._MAX_RETRIES-1 включительно ретраятся → _MAX_RETRIES backoff-sleeps
    # (плюс rate-limit sleeps на каждую из _MAX_RETRIES+1 попыток).
    assert mock_sleep.call_count >= client_mod._MAX_RETRIES


def test_do_request_retry_after_header_respected_and_capped() -> None:
    """Retry-After заголовок уважается для расчёта backoff (капается _MAX_BACKOFF_S)."""
    import app.services.scrapers.okn_egrkn_client as client_mod

    huge_retry_after = str(int(client_mod._MAX_BACKOFF_S) + 500)
    resp_429 = _mock_response({}, status=429)
    resp_429.headers = {"Retry-After": huge_retry_after}
    resp_ok = _mock_response(_make_geojson_response([_make_feature("ra-001", 56.83, 60.61)]))
    resp_ok.headers = {}

    responses = [resp_429, resp_ok]
    call_count = {"n": 0}

    def _make_client(**kwargs: Any) -> MagicMock:
        mc = MagicMock()
        mc.__enter__ = MagicMock(return_value=mc)
        mc.__exit__ = MagicMock(return_value=False)

        def _post(url: str, **post_kwargs: Any) -> MagicMock:
            resp = responses[call_count["n"]]
            call_count["n"] += 1
            return resp

        mc.post.side_effect = _post
        return mc

    with (
        patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client),
        patch("app.services.scrapers.okn_egrkn_client.time.sleep") as mock_sleep,
    ):
        result = fetch_okn_points("Екатеринбург", 1)

    assert len(result) == 1
    # Backoff-sleep вызывался хотя бы раз с длительностью <= _MAX_BACKOFF_S
    # (Retry-After капается потолком, а не используется как есть).
    backoff_calls = [c.args[0] for c in mock_sleep.call_args_list]
    assert any(s <= client_mod._MAX_BACKOFF_S for s in backoff_calls)
    assert all(s <= client_mod._MAX_BACKOFF_S for s in backoff_calls)
