"""curl_cffi 429 reconnect-retry на detail-странице под backconnect-прокси.

fetch_detail имел только short-retry той же сессии на 429 (_AVITO_DETAIL_429_MAX_RETRIES),
затем raise AvitoRateLimitedError. Но keep-alive detail-fetch переиспользует ОДИН
backconnect exit-IP на последовательные запросы → avito лочит этот IP, и short-retry той
же сессии не помогает (run 217: 5 подряд 429, abort на 19 enriched). Зеркало SERP-фикса
(#1769): при исчерпании short-retry под backconnect делаем ещё
_AVITO_DETAIL_429_RECONNECT_RETRIES попыток через эфемерную сессию (свежий exit-IP),
переиспользуя ту же retry_session-машинерию что и 403-путь.

Ownership-правило: caller-provided cffi_session НИКОГДА не закрывается/не пересоздаётся —
reconnect идёт через свою эфемерную retry_session (закрываем в finally).

Включаем backconnect через scraper_proxy_url_env (scraper_proxy_url — read-only property).
Зеркало fixture-стиля test_avito_detail_403_reconnect.py.
"""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from app.core.config import settings
from app.services.scrapers import avito_detail as detail_mod
from app.services.scrapers.avito_detail import fetch_detail
from app.services.scrapers.avito_exceptions import AvitoRateLimitedError

_SENTINEL = object()


def _resp(status_code: int, text: str = "") -> MagicMock:
    r = MagicMock()
    r.status_code = status_code
    r.text = text
    return r


def _enable_backconnect(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(settings, "scraper_proxy_url_env", "http://u:p@mproxy.site:14619")


def _disable_backconnect(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(settings, "scraper_proxy_url_env", None)


@pytest.fixture(autouse=True)
def _patch_common() -> object:
    """asyncio.sleep → no-op; parse_detail_html → sentinel (нет нужды в реальном HTML)."""
    with (
        patch("app.services.scrapers.avito_detail.asyncio.sleep", AsyncMock()),
        patch(
            "app.services.scrapers.avito_detail.parse_detail_html", return_value=_SENTINEL
        ) as parse,
    ):
        yield parse


@pytest.mark.asyncio
async def test_429_short_retry_exhausted_then_reconnect_200(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """429×(short-retry budget) → 429 → reconnect (свежая сессия) → 200.

    После _AVITO_DETAIL_429_MAX_RETRIES short-retry той же сессии ещё один 429 исчерпывает
    short-budget → reconnect через _build_detail_session → 200. Caller-session НЕ закрыта.
    """
    _enable_backconnect(monkeypatch)

    # short-budget short-retries (каждый получает 429 и continue) + ещё один 429, который
    # исчерпывает budget и триггерит reconnect. Итого budget+1 ответов 429 на caller-сессии.
    n429 = detail_mod._AVITO_DETAIL_429_MAX_RETRIES + 1
    caller = AsyncMock()
    caller.get = AsyncMock(side_effect=[_resp(429)] * n429)

    retry1 = AsyncMock()
    retry1.get = AsyncMock(return_value=_resp(200, "<html/>"))
    build = MagicMock(side_effect=[retry1])

    with patch.object(detail_mod, "_build_detail_session", build):
        result = await fetch_detail("/ekaterinburg/kvartiry/test-123", cffi_session=caller)

    assert result is _SENTINEL
    assert build.call_count == 1  # один reconnect перед 200
    assert caller.get.await_count == n429  # short-budget + исчерпавший 429
    caller.close.assert_not_called()  # caller-provided session НЕ закрывается
    retry1.close.assert_awaited()  # эфемерная retry-session закрыта в finally


@pytest.mark.asyncio
async def test_429_fully_exhausted_raises_rate_limited(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """429 на short-retry budget + всех reconnect-попытках → AvitoRateLimitedError."""
    _enable_backconnect(monkeypatch)

    # short-budget+1 (исчерпание short) на caller; затем каждая reconnect-сессия тоже 429.
    n429 = detail_mod._AVITO_DETAIL_429_MAX_RETRIES + 1
    caller = AsyncMock()
    caller.get = AsyncMock(side_effect=[_resp(429)] * n429)

    def _make_retry() -> AsyncMock:
        s = AsyncMock()
        s.get = AsyncMock(return_value=_resp(429))
        return s

    build = MagicMock(side_effect=lambda: _make_retry())

    with patch.object(detail_mod, "_build_detail_session", build):
        with pytest.raises(AvitoRateLimitedError, match="HTTP 429"):
            await fetch_detail("/x", cffi_session=caller)

    # Все reconnect-попытки исчерпаны.
    assert build.call_count == detail_mod._AVITO_DETAIL_429_RECONNECT_RETRIES
    caller.close.assert_not_called()


@pytest.mark.asyncio
async def test_429_short_retry_then_200_no_reconnect(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """429 → 200 в рамках short-retry budget: та же сессия, БЕЗ reconnect (regression guard)."""
    _enable_backconnect(monkeypatch)

    caller = AsyncMock()
    caller.get = AsyncMock(side_effect=[_resp(429), _resp(200, "<html/>")])
    build = MagicMock()

    with patch.object(detail_mod, "_build_detail_session", build):
        result = await fetch_detail("/x", cffi_session=caller)

    assert result is _SENTINEL
    build.assert_not_called()  # short-retry успешен → reconnect не дёрнут
    assert caller.get.await_count == 2  # та же сессия переиспользована
    caller.close.assert_not_called()


@pytest.mark.asyncio
async def test_no_proxy_429_short_exhausted_raises_immediately(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """scraper_proxy_url unset → нет reconnect-пути → исчерпание short-retry сразу raise."""
    _disable_backconnect(monkeypatch)

    n429 = detail_mod._AVITO_DETAIL_429_MAX_RETRIES + 1
    caller = AsyncMock()
    caller.get = AsyncMock(side_effect=[_resp(429)] * n429)
    build = MagicMock()

    with patch.object(detail_mod, "_build_detail_session", build):
        with pytest.raises(AvitoRateLimitedError, match="HTTP 429"):
            await fetch_detail("/x", cffi_session=caller)

    build.assert_not_called()  # no proxy → нет reconnect
    assert caller.get.await_count == n429
