"""Golden-parity бисекции: старый боевой обход `app.services.scrapers.<provider>`
≡ новый единый движок `scraper_kit.pricing.walk_price_range` (#2134).

Четыре независимые копии адаптивной бисекции «комнатность × цена» были
консолидированы в один движок (`scraper_kit/pricing/bisection.py`). Провайдеры
(avito/cian/yandex/domclick) теперь передают в движок свои probe/paginate + свой
`BisectionConfig`. Этот тест доказывает, что unification НЕ изменил ОБХОД: для
каждого провайдера при одинаковом входе (детерминированный мок счётчика по
диапазону) старая боевая реализация и новая (движок) посещают ИДЕНТИЧНУЮ
последовательность бакетов — probe-запросы, leaf-пагинации, деления, ранние
возвраты на нуле, probe-fail-ветки.

Механика: на ОБЕ реализации (старую и новую) ставятся ОДИНАКОВЫЕ моки на
низкоуровневые методы (probe-fetch + leaf-пагинатор), которые (а) возвращают
счётчик из общего оракула `count(lo, hi)` и (б) пишут событие в общий список.
Совпадение списков событий = идентичный обход.
"""

from __future__ import annotations

import os
from collections.abc import Callable
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

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

from scraper_kit.price_brackets import get_price_seed_brackets
from scraper_kit.providers.avito.serp import AvitoScraper as NewAvito
from scraper_kit.providers.cian.serp import CianScraper as NewCian
from scraper_kit.providers.domclick.serp import DomClickScraper as NewDomClick
from scraper_kit.providers.yandex.serp import YandexRealtyScraper as NewYandex

from app.services.scrapers.avito import AvitoScraper as OldAvito
from app.services.scrapers.cian import CianScraper as OldCian
from app.services.scrapers.domclick import DomClickScraper as OldDomClick
from app.services.scrapers.yandex_realty import YandexRealtyScraper as OldYandex

# ── Оракул счётчиков ─────────────────────────────────────────────────────────

Oracle = Callable[[Any, Any], int | None]


def make_oracle(
    *,
    divisor: int,
    open_ceiling: int,
    zeros: frozenset[tuple[Any, Any]] = frozenset(),
    nones: frozenset[tuple[Any, Any]] = frozenset(),
) -> Oracle:
    """Детерминированный счётчик от (lo, hi): count = span // divisor.

    span считается по клампнутым границам (None-lo → 0, None-hi → open_ceiling),
    чтобы широкие брекеты делились, а узкие уходили в leaf. `zeros` форсит 0 (ранний
    возврат / leaf-на-нуле у yandex), `nones` форсит None (probe-fail).
    """

    def oracle(lo: Any, hi: Any) -> int | None:
        if (lo, hi) in nones:
            return None
        if (lo, hi) in zeros:
            return 0
        eff_lo = lo if lo is not None else 0
        eff_hi = hi if hi is not None else open_ceiling
        span = eff_hi - eff_lo
        return max(0, span // divisor)

    return oracle


# ── Провайдер-специфичные «сборщики событий» ─────────────────────────────────
#
# Каждый ставит одинаковые моки на старый/новый экземпляр и возвращает список
# событий обхода. `("probe", lo, hi)` — запрос счётчика бакета; `("leaf", ...)` —
# пагинация leaf-бакета.


def _patch_avito(scraper: Any, oracle: Oracle, events: list[Any]) -> None:
    def probe(room_slug: str, min_price: Any, max_price: Any) -> tuple[str | None, int | None]:
        events.append(("probe", min_price, max_price))
        return "H", oracle(min_price, max_price)

    def leaf(**kw: Any) -> None:
        events.append(("leaf", kw["lo"], kw["hi"], kw["max_pages"]))

    scraper._probe_total = AsyncMock(side_effect=probe)
    scraper._paginate_leaf_bucket = AsyncMock(side_effect=leaf)
    scraper._rotate_ip = AsyncMock(return_value=False)


def _patch_cian(scraper: Any, oracle: Oracle, events: list[Any]) -> None:
    holder: dict[str, tuple[Any, Any]] = {}

    def fph(rooms: Any, page: int, min_price: Any, max_price: Any) -> str:
        events.append(("probe", min_price, max_price))
        holder["b"] = (min_price, max_price)
        return "H"

    def eto(html: str) -> int | None:
        return oracle(*holder["b"])

    def leaf(**kw: Any) -> None:
        events.append(("leaf", kw["lo"], kw["hi"], kw["total"]))

    scraper._fetch_page_html = AsyncMock(side_effect=fph)
    scraper._extract_total_offers = MagicMock(side_effect=eto)
    scraper._paginate_leaf_bucket = AsyncMock(side_effect=leaf)
    scraper._rotate_ip = AsyncMock(return_value=False)
    scraper.sleep_between_requests = AsyncMock()


def _patch_domclick(scraper: Any, oracle: Oracle, events: list[Any]) -> None:
    def count(fetcher: Any, rooms: Any, gte: Any, lte: Any) -> int:
        events.append(("probe", gte, lte))
        val = oracle(gte, lte)
        return val if val is not None else 0

    def paginate(
        fetcher: Any, rooms: Any, gte: Any, lte: Any, seen_ids: Any, out_lots: Any, pages: Any
    ) -> None:
        events.append(("leaf", gte, lte))

    scraper._count = AsyncMock(side_effect=count)
    scraper._paginate = AsyncMock(side_effect=paginate)


def _patch_yandex(
    scraper: Any, oracle: Oracle, events: list[Any], *, record_pages: bool = False
) -> None:
    """Yandex: leaf-пагинация встроена в walk (нет отдельного метода-хука).

    Стабим `_fetch_page_json` (page=1 отдаёт payload с totalItems из оракула;
    page>1 — пустой payload) и передаём `on_bucket`, который пишет завершение
    leaf/degraded бакета. probe = page-1-fetch; leaf-страницы 2..N идут через
    asyncio.gather (порядок недетерминирован) — их НЕ пишем в норм-режиме; факт
    leaf-обхода фиксируется через on_bucket. Для degraded-теста (пагинация
    последовательная) `record_pages=True` пишет все страницы.
    """

    def gate(total: int | None, entities: list[Any] | None = None) -> dict[str, Any]:
        pager: dict[str, Any] = {}
        if total is not None:
            pager["totalItems"] = total
        return {"response": {"search": {"offers": {"entities": entities or [], "pager": pager}}}}

    def fpj(rooms: Any, page: int, price_min: Any, price_max: Any, new_flat: str = "NO") -> Any:
        if page == 1:
            events.append(("probe", price_min, price_max))
            return gate(oracle(price_min, price_max))
        if record_pages:
            events.append(("page", price_min, price_max, page))
        return gate(0, entities=[])

    scraper._fetch_page_json = AsyncMock(side_effect=fpj)
    scraper._rotate_ip = AsyncMock(return_value=False)
    scraper.request_delay_sec = 0.0


def _yandex_on_bucket(events: list[Any]) -> Callable[[str, int], None]:
    def on_bucket(bucket_key: str, count: int) -> None:
        events.append(("bucket", bucket_key))

    return on_bucket


# ── Конструкторы старых/новых экземпляров (offline, без DB) ───────────────────

_KIT_CONFIG = SimpleNamespace(
    scraper_proxy_url=None,
    yandex_proxy_rotate_url=None,
    avito_proxy_rotate_url=None,
    avito_proxy_max_rotations=0,
    scraper_fetch_mode="browser",
    browser_http_endpoint="http://tradein-browser:9000",
    glitchtip_dsn=None,
    avito_serp_ekb_only=False,
)


def _old_avito() -> Any:
    with patch("app.services.scrapers.avito.get_scraper_delay", return_value=7.0):
        return OldAvito()


def _old_cian() -> Any:
    with patch("app.services.scrapers.cian.get_scraper_delay", return_value=5.0):
        return OldCian()


def _old_yandex() -> Any:
    with patch("app.services.scrapers.yandex_realty.get_scraper_delay", return_value=5.0):
        return OldYandex()


def _old_domclick() -> Any:
    with patch("app.services.scrapers.domclick.get_scraper_delay", return_value=8.0):
        return OldDomClick()


# ── avito / cian: одинаковый скелет (probe → split → leaf) ────────────────────


# Провайдер-специфичные kwargs для _walk_price_range (avito идентифицирует бакет
# room_slug/room_label; cian — кортежем rooms).
_AVITO_WALK_EXTRA = {"room_slug": "room1", "room_label": "1к"}
_CIAN_WALK_EXTRA = {"rooms": (1,)}


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "old_factory, new_factory, patch_fn, walk_extra",
    [
        (_old_avito, lambda: NewAvito(config=_KIT_CONFIG), _patch_avito, _AVITO_WALK_EXTRA),
        (_old_cian, lambda: NewCian(config=_KIT_CONFIG), _patch_cian, _CIAN_WALK_EXTRA),
    ],
)
async def test_bisection_parity_avito_cian(
    old_factory: Any, new_factory: Any, patch_fn: Any, walk_extra: dict[str, Any]
) -> None:
    """avito/cian: split-дерево + leaf + ранний возврат на нуле — обход идентичен.

    Оракул: divisor=1000, cap=1400 → брекет 8М делится 3 уровня (8М→4М→2М→1М-leaf);
    один под-брекет форсим в 0 (ранний возврат без leaf).
    """
    # После первого split (0,7_999_999): mid=3_999_999 → дети (None,3_999_999) и
    # (4_000_000, 7_999_999). Форсим 0 на (4_000_000, 5_999_999) — под-брекет после
    # второго split правой ветки.
    oracle = make_oracle(
        divisor=1000,
        open_ceiling=30_000_000,
        zeros=frozenset({(4_000_000, 5_999_999)}),
    )
    brackets: list[tuple[int, int | None]] = [(0, 7_999_999), (25_000_000, None)]

    async def run(scraper: Any) -> list[Any]:
        events: list[Any] = []
        patch_fn(scraper, oracle, events)
        for lo, hi in brackets:
            await scraper._walk_price_range(
                lo=lo,
                hi=hi,
                seen={},
                price_cap_per_bucket=1400,
                max_pages_per_bucket=100,
                concurrency=5,
                secondary_only=True,
                on_bucket=None,
                skip_buckets=None,
                **walk_extra,
            )
        return events

    old_events = await run(old_factory())
    new_events = await run(new_factory())
    assert old_events == new_events
    # Санити: обход реально нетривиален (несколько probe + несколько leaf).
    assert sum(1 for e in old_events if e[0] == "leaf") >= 4
    assert sum(1 for e in old_events if e[0] == "probe") >= 6


@pytest.mark.asyncio
async def test_bisection_parity_avito_probe_fail() -> None:
    """avito probe-fail (total=None): SPLIT_OR_SKIP — принудительный split делимого
    закрытого брекета, best-effort leaf открытого — идентичен старому."""
    # (0, 7_999_999): probe None → делим (bracket>=MIN). Дети (None,4M),(4000001,...):
    # им дадим реальные счётчики. Открытый (25M, None) probe None → best-effort leaf.
    oracle = make_oracle(
        divisor=1000,
        open_ceiling=30_000_000,
        nones=frozenset({(None, 7_999_999), (25_000_000, None)}),
    )
    brackets: list[tuple[int, int | None]] = [(0, 7_999_999), (25_000_000, None)]

    async def run(scraper: Any) -> list[Any]:
        events: list[Any] = []
        _patch_avito(scraper, oracle, events)
        for lo, hi in brackets:
            await scraper._walk_price_range(
                room_slug="room1",
                room_label="1к",
                lo=lo,
                hi=hi,
                seen={},
                price_cap_per_bucket=1400,
                max_pages_per_bucket=100,
                concurrency=5,
                secondary_only=True,
                on_bucket=None,
                skip_buckets=None,
            )
        return events

    old_events = await run(_old_avito())
    new_events = await run(NewAvito(config=_KIT_CONFIG))
    assert old_events == new_events
    # Открытый брекет с probe-fail → ровно один best-effort leaf на (25M, None).
    assert ("leaf", 25_000_000, None, 100) in old_events


# ── domclick: count-endpoint + None-both-ends + split открытого корня ─────────


@pytest.mark.asyncio
async def test_bisection_parity_domclick() -> None:
    """domclick: unbounded-корень (None,None) клампится к [0,1e9] и делится;
    count-endpoint; span<=MIN_PRICE_SPAN строго → leaf. Обход идентичен старому."""
    oracle = make_oracle(divisor=100_000, open_ceiling=1_000_000_000)

    async def run(scraper: Any) -> list[Any]:
        events: list[Any] = []
        _patch_domclick(scraper, oracle, events)
        await scraper._sweep_bucket(
            fetcher=None,
            rooms="1",
            price_gte=None,
            price_lte=None,
            seen_ids=set(),
            out_lots=[],
            pages=100,
        )
        return events

    old_events = await run(_old_domclick())
    new_events = await run(NewDomClick(_make_domclick_config()))
    assert old_events == new_events
    assert sum(1 for e in old_events if e[0] == "leaf") >= 4
    # Корень (None,None) обязательно пробится первым.
    assert old_events[0] == ("probe", None, None)


def _make_domclick_config() -> Any:
    return SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000")


# ── yandex: max_depth=8, min_bracket=500k, count-based on_bucket ──────────────


@pytest.mark.asyncio
async def test_bisection_parity_yandex_normal() -> None:
    """yandex: split-дерево (порог 500k, cap 500) + leaf-on-bucket + zero-as-leaf.

    Оракул divisor=1000, cap=500 → 4М→2М→1М→500k-leaf. Один под-брекет=0
    (yandex вызывает on_bucket даже на нуле — treat_zero_as_leaf).
    """
    oracle = make_oracle(
        divisor=1000,
        open_ceiling=30_000_000,
        zeros=frozenset({(2_000_000, 2_999_999)}),
    )
    brackets: list[tuple[int, int | None]] = [(0, 3_999_999), (25_000_000, None)]

    async def run(scraper: Any) -> list[Any]:
        events: list[Any] = []
        _patch_yandex(scraper, oracle, events)
        for lo, hi in brackets:
            await scraper._walk_price_range(
                rooms="1",
                lo=lo,
                hi=hi,
                seen={},
                price_cap_per_bucket=500,
                max_pages_per_bucket=50,
                concurrency=4,
                on_bucket=_yandex_on_bucket(events),
                skip_buckets=None,
            )
        return events

    old_events = await run(_old_yandex())
    new_events = await run(NewYandex(config=_KIT_CONFIG))
    assert old_events == new_events
    assert sum(1 for e in old_events if e[0] == "bucket") >= 4
    assert sum(1 for e in old_events if e[0] == "probe") >= 6


@pytest.mark.asyncio
async def test_bisection_parity_yandex_degraded() -> None:
    """yandex probe-fail (totalItems неизвестен) → degraded paginate-until-empty.

    Оракул форсит None на бакете → probe page1 payload без totalItems → total=None
    → degraded последовательно пагинирует. record_pages=True фиксирует все страницы.
    """
    oracle = make_oracle(
        divisor=1000,
        open_ceiling=30_000_000,
        nones=frozenset({(None, 3_999_999)}),
    )

    async def run(scraper: Any) -> list[Any]:
        events: list[Any] = []
        _patch_yandex(scraper, oracle, events, record_pages=True)
        await scraper._walk_price_range(
            rooms="1",
            lo=0,
            hi=3_999_999,
            seen={},
            price_cap_per_bucket=500,
            max_pages_per_bucket=50,
            concurrency=4,
            on_bucket=_yandex_on_bucket(events),
            skip_buckets=None,
        )
        return events

    old_events = await run(_old_yandex())
    new_events = await run(NewYandex(config=_KIT_CONFIG))
    assert old_events == new_events
    # degraded: probe page1 + затем последовательная пагинация страниц 1..K до пустой.
    assert ("probe", None, 3_999_999) in old_events
    assert any(e[0] == "bucket" for e in old_events)


# ── Full seed-grid обход (все seed-брекеты одной комнатности) ─────────────────


@pytest.mark.asyncio
@pytest.mark.parametrize(
    "old_factory, new_factory, patch_fn, walk_extra",
    [
        (_old_avito, lambda: NewAvito(config=_KIT_CONFIG), _patch_avito, _AVITO_WALK_EXTRA),
        (_old_cian, lambda: NewCian(config=_KIT_CONFIG), _patch_cian, _CIAN_WALK_EXTRA),
    ],
)
async def test_bisection_parity_full_seed_grid(
    old_factory: Any, new_factory: Any, patch_fn: Any, walk_extra: dict[str, Any]
) -> None:
    """Полный обход всех seed-брекетов (как в fetch_all_secondary) — включая
    открытый catch-all — идентичен между старым и новым для avito/cian."""
    oracle = make_oracle(divisor=1500, open_ceiling=300_000_000)

    async def run(scraper: Any) -> list[Any]:
        events: list[Any] = []
        patch_fn(scraper, oracle, events)
        for br_lo, br_hi in get_price_seed_brackets():
            walk_hi = br_hi - 1 if br_hi is not None else None
            await scraper._walk_price_range(
                lo=br_lo,
                hi=walk_hi,
                seen={},
                price_cap_per_bucket=1400,
                max_pages_per_bucket=100,
                concurrency=5,
                secondary_only=True,
                on_bucket=None,
                skip_buckets=None,
                **walk_extra,
            )
        return events

    old_events = await run(old_factory())
    new_events = await run(new_factory())
    assert old_events == new_events
