"""Audit-scrapers finding 1: Avito citywide/byrooms/exhaustive sweep DOM-drift detection.

Раньше 0 карточек на page=1 (обход всего города / категории комнатности / ценового
бакета exhaustive-сбора) молча трактовалось как «объявлений действительно нет» —
неотличимо от content-block/captcha или дрейфа DOM-маркера карточки (`data-marker=
"item-*"`). Фикс переиспользует существующий механизм `AvitoContentBlockedError`
(см. `fetch_around`, #754/#779) + новый `_is_unexpected_empty_page()` — независимый
сигнал `_extract_total_count` (счётчик `page-title/count` либо no-results маркер):

  - page=1, 0 карточек, НЕТ no-results маркера/счётчика → аномалия → raise.
  - page=1, 0 карточек, ЕСТЬ no-results маркер (total=0) → валидная пустая выборка.
  - page>1, 0 карточек → всегда graceful end-of-pagination (не regressed).
  - exhaustive leaf-бакет: probe независимо утверждал total>0, но после пагинации
    всех страниц собрано 0 карточек → аномалия → raise (даже без per-page проверки
    внутри _paginate_leaf_bucket, т.к. там нет break-on-empty цикла).

Refs: audit-scrapers 2026-07-26, finding 1 (medium).
"""

from __future__ import annotations

import os
from unittest.mock import AsyncMock, patch

import pytest

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

from scraper_kit.avito_exceptions import AvitoContentBlockedError
from scraper_kit.base import ScrapedLot
from scraper_kit.providers.avito.serp import ROOM_SLUGS, AvitoScraper

from app.services.scraper_adapters import RealScraperConfig

# HTML "успешно получен, разумного размера", но БЕЗ data-marker="item-*" карточек
# И без no-results маркера/счётчика — неотличимо от content-block/DOM-drift.
_NO_MARKER_HTML = "<html><body>" + ("x" * 500) + "</body></html>"

# Валидная пустая выборка: no-results маркер присутствует (_AVITO_NO_RESULTS_MARKERS).
_NO_RESULTS_HTML = (
    "<html><body>По вашему запросу ничего не найдено. Попробуйте изменить фильтры."
    + ("y" * 200)
    + "</body></html>"
)

# Firewall/captcha-страница (переиспользуем существующий fixture-паттерн из #754) —
# используется только для проверки, что page>1 остаётся graceful независимо от
# содержимого (проверка применяется ТОЛЬКО к page==1).
_BLOCKPAGE_HTML = "<html><body><h1>Доступ ограничен</h1></body></html>"


def _make_lot(source_id: str) -> ScrapedLot:
    return ScrapedLot(
        source="avito",
        source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/{source_id}",
        source_id=source_id,
        price_rub=6_000_000,
    )


# ── fetch_city_wide (_paginate_sweep) ────────────────────────────────────────


@pytest.mark.asyncio
async def test_citywide_page1_zero_cards_no_marker_raises() -> None:
    s = AvitoScraper(RealScraperConfig())
    with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)):
        with pytest.raises(AvitoContentBlockedError):
            await s.fetch_city_wide(pages=5, delay_override_sec=0)


@pytest.mark.asyncio
async def test_citywide_page1_zero_cards_with_no_results_marker_is_valid_empty() -> None:
    s = AvitoScraper(RealScraperConfig())
    with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)):
        result = await s.fetch_city_wide(pages=5, delay_override_sec=0)
    assert result == []


@pytest.mark.asyncio
async def test_citywide_page_gt1_zero_cards_stays_graceful() -> None:
    """page=1 реально возвращает карточки (mock _parse_html) — page=2 пустой
    firewall-текст без карточек НЕ должен поднимать исключение (только page==1)."""
    s = AvitoScraper(RealScraperConfig())
    call_n = 0

    async def _fetch(url: str, page: int) -> str:
        return "<html>page1</html>" if page == 1 else _BLOCKPAGE_HTML

    def _parse(html: str, source_url_base: str) -> list[ScrapedLot]:
        nonlocal call_n
        call_n += 1
        return [_make_lot("A"), _make_lot("B")] if call_n == 1 else []

    with patch.object(s, "_fetch_serp_html", AsyncMock(side_effect=_fetch)):
        with patch.object(s, "_parse_html", side_effect=_parse):
            with patch.object(s, "sleep_between_requests", AsyncMock(return_value=None)):
                result = await s.fetch_city_wide(pages=5, delay_override_sec=0)

    assert len(result) == 2
    assert call_n == 2  # page1(2 lots) + page2(0 lots) → stop, no raise


# ── fetch_by_rooms ────────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_byrooms_category_page1_zero_cards_no_marker_raises() -> None:
    s = AvitoScraper(RealScraperConfig())
    with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_MARKER_HTML)):
        with pytest.raises(AvitoContentBlockedError):
            await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1])


@pytest.mark.asyncio
async def test_byrooms_category_page1_zero_cards_with_marker_is_valid_empty() -> None:
    s = AvitoScraper(RealScraperConfig())
    with patch.object(s, "_fetch_serp_html", AsyncMock(return_value=_NO_RESULTS_HTML)):
        result = await s.fetch_by_rooms(pages=5, delay_override_sec=0, room_slugs=ROOM_SLUGS[:1])
    assert result == []


# ── _paginate_leaf_bucket (exhaustive/fetch_all_secondary) ───────────────────


@pytest.mark.asyncio
async def test_leaf_bucket_expected_total_positive_but_zero_parsed_raises() -> None:
    """Probe независимо утверждал total=5 (bucket не может быть легитимно пустым),
    но парсинг всех страниц дал 0 карточек — DOM-drift, не пустой бакет."""
    s = AvitoScraper(RealScraperConfig())
    seen: dict[str, ScrapedLot] = {}

    with patch.object(s, "_parse_html", return_value=[]):
        with pytest.raises(AvitoContentBlockedError):
            await s._paginate_leaf_bucket(
                room_slug="studii-ASgBAgICAUSSA8YQ",
                room_label="studio",
                lo=0,
                hi=3_000_000,
                html="<html>probe-page-1</html>",
                max_pages=1,
                seen=seen,
                price_cap_per_bucket=1400,
                max_pages_per_bucket=100,
                concurrency=5,
                secondary_only=True,
                on_bucket=None,
                skip_buckets=None,
                expected_total=5,
            )
    assert seen == {}


@pytest.mark.asyncio
async def test_leaf_bucket_expected_total_none_zero_parsed_no_raise() -> None:
    """Probe провалился (expected_total=None, best-effort пагинация) — 0 карточек
    здесь НЕ аномалия (мы не знаем, есть ли реально данные в бакете)."""
    s = AvitoScraper(RealScraperConfig())
    seen: dict[str, ScrapedLot] = {}

    with patch.object(s, "_parse_html", return_value=[]):
        # Не должно поднимать исключение.
        await s._paginate_leaf_bucket(
            room_slug="studii-ASgBAgICAUSSA8YQ",
            room_label="studio",
            lo=0,
            hi=3_000_000,
            html=None,
            max_pages=1,
            seen=seen,
            price_cap_per_bucket=1400,
            max_pages_per_bucket=100,
            concurrency=5,
            secondary_only=True,
            on_bucket=None,
            skip_buckets=None,
            expected_total=None,
        )
    assert seen == {}


@pytest.mark.asyncio
async def test_leaf_bucket_expected_total_matches_collected_no_raise() -> None:
    """Нормальный путь: probe total=2, парсинг реально даёт 2 карточки — не аномалия."""
    s = AvitoScraper(RealScraperConfig())
    seen: dict[str, ScrapedLot] = {}
    lots = [_make_lot("L1"), _make_lot("L2")]

    with patch.object(s, "_parse_html", return_value=lots):
        await s._paginate_leaf_bucket(
            room_slug="studii-ASgBAgICAUSSA8YQ",
            room_label="studio",
            lo=0,
            hi=3_000_000,
            html="<html>probe-page-1</html>",
            max_pages=1,
            seen=seen,
            price_cap_per_bucket=1400,
            max_pages_per_bucket=100,
            concurrency=5,
            secondary_only=True,
            on_bucket=None,
            skip_buckets=None,
            expected_total=2,
        )
    assert set(seen.keys()) == {"L1", "L2"}
