"""#1790 — ban-triggered IP rotation в enrichment-фазах scrape_pipeline.

Тест 1: AvitoBlockedError при доступных ротациях → вызывается _rotate_proxy_ip
        (changeip) + consecutive счётчик сбрасывается → enrichment продолжается.

Тест 2: Ротаций больше нет (бюджет исчерпан) → abort enrichment (bounded N ротаций).

Тест 3: Graceful per-item (#1820) сохранён — один блок без порога ротации НЕ вызывает.

Без сети, без БД.
"""

from __future__ import annotations

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

import pytest

os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")

from app.services.scrape_pipeline import (
    _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT,
    PipelineResult,
    run_avito_pipeline,
)
from app.services.scrapers.avito_exceptions import AvitoBlockedError


def _make_lot(
    item_id: str,
    house_url: str | None = None,
) -> object:
    from app.services.scrapers.base import ScrapedLot

    return ScrapedLot(
        source="avito",
        source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/test_{item_id}",
        source_id=item_id,
        price_rub=5_000_000,
        address="Test address",
        house_source="avito" if house_url else None,
        house_ext_id=house_url.rstrip("/").split("/")[-1] if house_url else None,
        house_url=house_url,
        listing_segment="vtorichka",
    )


def _block_scraper(lots: list) -> MagicMock:  # type: ignore[type-arg]
    scraper = MagicMock()
    scraper.__aenter__ = AsyncMock(return_value=scraper)
    scraper.__aexit__ = AsyncMock(return_value=None)
    scraper.fetch_around = AsyncMock(return_value=lots)
    scraper._cffi = None
    return scraper


# ---------------------------------------------------------------------------
# Тест 1: ротация срабатывает при consecutive blocks == порог
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_rotation_called_on_consecutive_blocks() -> None:
    """При N подряд-блоках (порог) вызывается _rotate_proxy_ip — IP меняется.

    После успешной ротации consecutive счётчик сбрасывается и enrichment продолжается:
    следующий (N+1)-й дом фетчится успешно.
    """
    n = _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
    lots = [
        _make_lot(str(i), f"https://www.avito.ru/catalog/houses/ekb/h{i}/{100 + i}")
        for i in range(n + 2)  # n блокируются, потом ротация, потом успех
    ]
    scraper = _block_scraper(lots)

    # Первые n обращений — блок, (n+1)-й — успех
    fetch_house = AsyncMock(side_effect=[AvitoBlockedError("banned")] * n + [MagicMock()])
    rotate_mock = AsyncMock(return_value=True)  # ротация успешна

    with (
        patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
        patch("app.services.scrape_pipeline.save_listings", return_value=(len(lots), 0)),
        patch("app.services.scrape_pipeline.fetch_house_catalog", fetch_house),
        patch(
            "app.services.scrape_pipeline.save_house_catalog_enrichment",
            return_value={"house_id": 99},
        ),
        patch("app.services.scrape_pipeline._rotate_proxy_ip", rotate_mock),
        patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()),
        patch(
            "curl_cffi.requests.AsyncSession",
            return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
        ),
    ):
        result = await run_avito_pipeline(
            MagicMock(), 56.8, 60.6, 1000, enrich_houses=True, enrich_detail_top_n=0
        )

    assert isinstance(result, PipelineResult)
    # _rotate_proxy_ip вызвана хотя бы один раз
    assert rotate_mock.await_count >= 1
    # Enrichment продолжился после ротации — (n+1)-й дом обогащён
    assert result.counters.houses_enriched >= 1
    # Listings сохранены
    assert result.counters.lots_inserted == len(lots)


# ---------------------------------------------------------------------------
# Тест 2: бюджет ротаций исчерпан → abort
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_rotation_bounded_abort_when_budget_exhausted() -> None:
    """После max_rotations попыток ротации enrichment abort'ится (bounded).

    Используем avito_proxy_max_rotations=1 → только одна попытка ротации.
    После неё блоки продолжаются → abort.
    """
    n = _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
    # Достаточно домов чтобы вызвать и ротацию и повторные блоки
    lots = [
        _make_lot(str(i), f"https://www.avito.ru/catalog/houses/ekb/h{i}/{200 + i}")
        for i in range(n * 3)
    ]
    scraper = _block_scraper(lots)

    # Все дома блокируются (не будет успешного фетча)
    fetch_house = AsyncMock(side_effect=AvitoBlockedError("banned"))
    # Ротация успешна, но дом после неё тоже блокируется
    rotate_mock = AsyncMock(return_value=True)

    with (
        patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
        patch("app.services.scrape_pipeline.save_listings", return_value=(len(lots), 0)),
        patch("app.services.scrape_pipeline.fetch_house_catalog", fetch_house),
        patch("app.services.scrape_pipeline.save_house_catalog_enrichment", return_value={}),
        patch("app.services.scrape_pipeline._rotate_proxy_ip", rotate_mock),
        patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()),
        patch("app.services.scrape_pipeline.settings") as mock_settings,
        patch(
            "curl_cffi.requests.AsyncSession",
            return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
        ),
    ):
        # Лимит 1 ротация: после неё бюджет исчерпан → abort на следующей серии блоков
        mock_settings.avito_proxy_max_rotations = 1
        mock_settings.avito_proxy_rotate_url = "http://rotate.test/"
        mock_settings.avito_proxy_rotate_settle_s = 0.0
        mock_settings.scraper_fetch_mode = "curl_cffi"
        mock_settings.scraper_proxy_url = None
        mock_settings.scraper_skip_seen_today = False

        result = await run_avito_pipeline(
            MagicMock(), 56.8, 60.6, 1000, enrich_houses=True, enrich_detail_top_n=0
        )

    assert isinstance(result, PipelineResult)
    # Ровно 1 ротация (бюджет = 1)
    assert rotate_mock.await_count == 1
    # Enrichment abort'нут (есть AVITO_BLOCKED в ошибках)
    assert any("AVITO_BLOCKED" in e for e in result.counters.errors)
    # Listings всё равно сохранены
    assert result.counters.lots_inserted == len(lots)


# ---------------------------------------------------------------------------
# Тест 3: один блок (ниже порога) — ротация НЕ вызывается (#1820 graceful)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_single_block_below_threshold_no_rotation() -> None:
    """Один блок (< порога) — _rotate_proxy_ip НЕ вызывается.

    Graceful per-item #1820: единственный блок пишется в errors, enrichment
    продолжается, ротация не тратится.
    """
    n = _AVITO_PIPELINE_CONSECUTIVE_BLOCK_ABORT
    # Только 2 дома: первый блокируется, второй успешен
    lots = [
        _make_lot("1", "https://www.avito.ru/catalog/houses/ekb/a/100"),
        _make_lot("2", "https://www.avito.ru/catalog/houses/ekb/b/200"),
    ]
    assert n >= 2, "Порог должен быть >= 2 чтобы тест был корректен"
    scraper = _block_scraper(lots)

    fetch_house = AsyncMock(side_effect=[AvitoBlockedError("HTTP 403"), MagicMock()])
    rotate_mock = AsyncMock(return_value=True)

    with (
        patch("app.services.scrape_pipeline.AvitoScraper", return_value=scraper),
        patch("app.services.scrape_pipeline.save_listings", return_value=(2, 0)),
        patch("app.services.scrape_pipeline.fetch_house_catalog", fetch_house),
        patch(
            "app.services.scrape_pipeline.save_house_catalog_enrichment",
            return_value={"house_id": 55},
        ),
        patch("app.services.scrape_pipeline._rotate_proxy_ip", rotate_mock),
        patch("app.services.scrape_pipeline.asyncio.sleep", AsyncMock()),
        patch(
            "curl_cffi.requests.AsyncSession",
            return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock()),
        ),
    ):
        result = await run_avito_pipeline(
            MagicMock(), 56.8, 60.6, 1000, enrich_houses=True, enrich_detail_top_n=0
        )

    # Ротация НЕ вызвана — один блок ниже порога
    assert rotate_mock.await_count == 0
    # Первый дом упал, второй обогащён
    assert result.counters.houses_failed == 1
    assert result.counters.houses_enriched == 1
    assert any("BLOCKED_HOUSE" in e for e in result.counters.errors)
