"""Единый движок адаптивной бисекции «комнатность × цена» для SERP-обхода.

Все четыре провайдера вторички (avito / cian / yandex / domclick) обходят
SERP-cap площадки одинаковым по СКЕЛЕТУ приёмом: для каждой комнатности берётся
ценовой диапазон [lo, hi], запрашивается «сколько всего результатов» (probe
page=1 или отдельный count-endpoint), и если результатов больше потолка бакета —
диапазон делится пополам (`mid = (lo + hi) // 2`) и обход рекурсивно спускается
в обе половины; иначе бакет пагинируется как leaf.

Исторически это было ЧЕТЫРЕ независимые копии (`_walk_price_range` ×3 +
`_sweep_bucket`), разошедшиеся в константах и краевых ветках. Здесь скелет
рекурсии вынесен в одну функцию `walk_price_range`, а РАЗЛИЧИЯ провайдеров
переданы в `BisectionConfig` (пороги, глубина, поведение на открытом брекете и
при неудачном probe) + три provider-callback'а. Ни одно различие не потеряно —
golden-parity тесты доказывают, что обход байт-в-байт совпадает со старой
боевой реализацией для каждого провайдера.

Provider-side (в callback'ах, движок их НЕ трогает):
  * фактический HTTP-fetch + retry/rotation;
  * парсинг счётчика (totalOffers / page-title-count / totalItems / snippetsCount);
  * пагинация leaf-бакета, дедуп (`seen`), on_bucket-callback.

Engine-side (здесь):
  * дерево рекурсии: какие [lo, hi] пробятся / делятся / пагинируются и в каком
    порядке;
  * арифметика split-решения (cap, min_bracket, max_depth, clamp None-границ);
  * применение probe-fail-политики и обработка открытого (hi=None) брекета.

Семантические различия провайдеров (задокументировано в конфигах-инстансах на
стороне провайдеров), которые движок воспроизводит через `BisectionConfig`:

| поле                    | avito     | cian      | yandex        | domclick        |
|-------------------------|-----------|-----------|---------------|-----------------|
| cap                     | 1400      | 1400      | 500           | 2000 (OFFSET)   |
| min_bracket             | 50_000    | 50_000    | 500_000       | 100_000         |
| min_bracket_inclusive   | True (>=) | True (>=) | True (>=)     | False (>)       |
| max_depth               | None      | None      | 8             | None            |
| probe_fail_policy       | SPLIT/SKIP| SKIP      | DEGRADE       | SKIP (не рвётся)|
| open_split_ceiling      | None(leaf)| None(leaf)| None(leaf)    | 1_000_000_000   |
| treat_zero_as_leaf      | False     | False     | True          | False           |

Различие «probe vs count»: avito/cian/yandex читают счётчик из probe page=1 и
ПЕРЕИСПОЛЬЗУЮТ HTML/лоты страницы 1 в leaf (payload); domclick дёргает отдельный
count-endpoint (payload=None, пагинация с offset=0). Это инкапсулировано в
provider-callback `probe` + `ProbeResult.payload`, движку прозрачно.
"""

from __future__ import annotations

from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from enum import Enum, auto
from typing import Any


class ProbeFailPolicy(Enum):
    """Что делать, когда probe не вернул счётчик (`ProbeResult.count is None`).

    * ``SKIP`` — бросить бакет целиком (cian: totalOffers недоступен → пропуск).
    * ``SPLIT_OR_SKIP`` — открытый брекет пагинируем best-effort; закрытый ещё
      делимый (span >= min_bracket) принудительно делим (тяжёлая страница станет
      загружаемой в узком диапазоне); иначе пропуск (avito).
    * ``DEGRADE`` — деградировать в «пагинировать-до-пустоты» без знания total
      (yandex): вызывается ``on_degraded``.

    domclick счётчик-endpoint при ошибке возвращает 0 (а не None), поэтому его
    политика формально ``SKIP``, но ветка probe-fail недостижима (count==0 →
    ранний return).
    """

    SKIP = auto()
    SPLIT_OR_SKIP = auto()
    DEGRADE = auto()


@dataclass
class ProbeResult:
    """Результат probe одного бакета.

    * ``count`` — сколько всего результатов в [lo, hi] (или ``None`` при
      captcha/ошибке probe). ``0`` — валидная пустая выборка.
    * ``payload`` — provider-specific данные страницы 1 для переиспользования в
      leaf-пагинации (avito: HTML; cian: HTML; yandex: list[ScrapedLot] page 1;
      domclick: None — count-endpoint без карточек).
    """

    count: int | None
    payload: Any = None


@dataclass(frozen=True)
class BisectionConfig:
    """Per-provider константы/политики бисекции (все различия провайдеров тут).

    Args:
        cap: потолок результатов в бакете; ``count > cap`` — кандидат на split.
        min_bracket: минимальный размах диапазона, ниже которого делить нельзя.
        min_bracket_inclusive: split разрешён при ``span >= min_bracket`` (True,
            avito/cian/yandex) или строго ``span > min_bracket`` (False,
            domclick — исходное `span <= MIN_PRICE_SPAN → paginate`).
        max_depth: жёсткий предел глубины рекурсии (yandex=8) либо ``None``.
        probe_fail_policy: см. :class:`ProbeFailPolicy`.
        open_split_ceiling: если ``None`` (avito/cian/yandex) — открытый брекет
            (hi=None) НЕ делится, пагинируется напрямую как leaf. Если задан
            (domclick=1e9) — открытые границы клампятся к
            ``[open_split_floor, open_split_ceiling]`` и диапазон делится.
        open_split_floor: нижний кламп для None-lo при делении (domclick=0).
        treat_zero_as_leaf: если True (yandex) — при ``count == 0`` всё равно
            вызвать ``on_leaf`` (yandex on_bucket-callback срабатывает и на 0);
            иначе (avito/cian/domclick) — ранний return без leaf.
    """

    cap: int
    min_bracket: int
    max_depth: int | None = None
    min_bracket_inclusive: bool = True
    probe_fail_policy: ProbeFailPolicy = ProbeFailPolicy.SKIP
    open_split_ceiling: int | None = None
    open_split_floor: int = 0
    treat_zero_as_leaf: bool = False


# Provider-callback типы.
ProbeFn = Callable[[int | None, int | None, int], Awaitable[ProbeResult]]
LeafFn = Callable[[int | None, int | None, ProbeResult], Awaitable[None]]
DegradedFn = Callable[[int | None, int | None], Awaitable[None]]


def _can_split_span(span: int, config: BisectionConfig) -> bool:
    """Разрешает ли размах диапазона дальнейшее деление (по min_bracket)."""
    if config.min_bracket_inclusive:
        return span >= config.min_bracket
    return span > config.min_bracket


async def walk_price_range(
    *,
    lo: int | None,
    hi: int | None,
    config: BisectionConfig,
    probe: ProbeFn,
    on_leaf: LeafFn,
    on_degraded: DegradedFn | None = None,
    depth: int = 0,
) -> None:
    """Рекурсивно обойти ценовой диапазон [lo, hi] адаптивной бисекцией.

    Скелет (общий для всех провайдеров):
      1. ``result = probe(lo, hi, depth)`` — provider I/O (fetch + retry).
      2. ``result.count is None`` → применить ``probe_fail_policy``.
      3. ``count == 0`` → return (кроме ``treat_zero_as_leaf``).
      4. Открытый брекет (hi=None) при ``open_split_ceiling is None`` → leaf.
      5. Иначе: если ``count > cap`` и размах делим и глубина в пределах —
         ``mid = (lo + hi) // 2``, рекурсия в обе половины; иначе leaf.

    Args:
        lo / hi: границы диапазона (``None`` = без границы; клампится при делении
            для провайдеров с ``open_split_ceiling``).
        config: per-provider пороги/политики.
        probe: async ``(lo, hi, depth) -> ProbeResult`` — счётчик + payload.
        on_leaf: async ``(lo, hi, result) -> None`` — пагинация leaf-бакета
            (fetch/parse/дедуп/on_bucket целиком на стороне провайдера).
        on_degraded: async ``(lo, hi) -> None`` — обязателен для
            ``ProbeFailPolicy.DEGRADE`` (yandex «пагинировать-до-пустоты»).
        depth: текущая глубина рекурсии (для ``max_depth``).
    """
    result = await probe(lo, hi, depth)
    total = result.count

    # ── Probe-fail: счётчик недоступен ────────────────────────────────────────
    if total is None:
        policy = config.probe_fail_policy
        if policy is ProbeFailPolicy.SKIP:
            return
        if policy is ProbeFailPolicy.DEGRADE:
            if on_degraded is not None:
                await on_degraded(lo, hi)
            return
        # SPLIT_OR_SKIP (avito): открытый брекет пагинируем best-effort; закрытый
        # ещё делимый — принудительно делим; иначе пропускаем (хвост теряем).
        if hi is None:
            await on_leaf(lo, hi, result)
            return
        _lo = lo if lo is not None else config.open_split_floor
        if _can_split_span(hi - _lo, config):
            mid = (_lo + hi) // 2
            await walk_price_range(
                lo=_lo,
                hi=mid,
                config=config,
                probe=probe,
                on_leaf=on_leaf,
                on_degraded=on_degraded,
                depth=depth + 1,
            )
            await walk_price_range(
                lo=mid + 1,
                hi=hi,
                config=config,
                probe=probe,
                on_leaf=on_leaf,
                on_degraded=on_degraded,
                depth=depth + 1,
            )
        return

    # ── Счётчик получен ───────────────────────────────────────────────────────
    if total == 0 and not config.treat_zero_as_leaf:
        return

    # Открытый брекет без split-потолка (avito/cian/yandex): делить нельзя —
    # пагинируем leaf напрямую (хвост люкса крошечный, tail-loss accepted).
    if hi is None and config.open_split_ceiling is None:
        await on_leaf(lo, hi, result)
        return

    # Эффективные границы для арифметики деления: None-границы клампятся к
    # [open_split_floor, open_split_ceiling] (domclick unbounded root).
    eff_lo = lo if lo is not None else config.open_split_floor
    eff_hi = hi if hi is not None else config.open_split_ceiling

    can_split = (
        total > config.cap
        and eff_hi is not None
        and _can_split_span(eff_hi - eff_lo, config)
        and (config.max_depth is None or depth < config.max_depth)
    )

    if can_split:
        assert eff_hi is not None  # для mypy: гарантировано can_split
        mid = (eff_lo + eff_hi) // 2
        await walk_price_range(
            lo=eff_lo,
            hi=mid,
            config=config,
            probe=probe,
            on_leaf=on_leaf,
            on_degraded=on_degraded,
            depth=depth + 1,
        )
        await walk_price_range(
            lo=mid + 1,
            hi=eff_hi,
            config=config,
            probe=probe,
            on_leaf=on_leaf,
            on_degraded=on_degraded,
            depth=depth + 1,
        )
        return

    # Leaf-бакет: пагинация + дедуп + on_bucket — целиком на стороне провайдера.
    await on_leaf(lo, hi, result)
