"""DomClick.ru scraper — вторичка через JSON BFF API (#1968).

Strangler-копия (#2133) боевого `app.services.scrapers.domclick`, развязанная от
`app.*`: продуктовые зависимости инжектируются снаружи (`delay_provider`), общие
хелперы берутся из `scraper_kit.*`. Логика парсинга идентична оригиналу
(golden-parity — tests/test_scraper_kit_domclick_golden_parity.py).

Стратегия: GET https://bff-search-web.domclick.ru/api/offers/v1?... через
BrowserFetcher(source="domclick") (generic provider → shared mobile proxy).
QRATOR банит прямые datacenter-запросы, но пропускает через mobile proxy.

Ответ BrowserFetcher содержит JSON, обёрнутый в HTML (<pre> или bare body).
Парсинг: _extract_json() вытаскивает первый {...} из ответа.

Стратегия нумерации комнат (ROOM_BUCKETS):
  "st"  — студии (force rooms=0 в ScrapedLot)
  "1"–"4" — соответственно
  "5+"  — 5 и более комнат

Пагинация: offset 0, 20, 40, …, cap=2000. Если snippetsCount > 2000
для бакета, рекурсивно делим по цене (binary split) до тех пор пока
каждая ветка укладывается в cap.

# TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory
через detail-эндпоинт после первоначального сбора.
"""

from __future__ import annotations

import hashlib
import json
import logging
from collections.abc import Callable
from datetime import date
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode

from scraper_kit.base import BaseScraper, ScrapedLot
from scraper_kit.domclick_exceptions import DomClickBlockedError
from scraper_kit.pricing import BisectionConfig, ProbeResult, walk_price_range
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text

if TYPE_CHECKING:
    from scraper_kit.contracts import ScraperConfig

logger = logging.getLogger(__name__)

# ── API constants ─────────────────────────────────────────────────────────────

_BFF_BASE = "https://bff-search-web.domclick.ru"
_EKB_ADDRESS_GUID = "0d475b79-88de-4054-818c-37d8f9d0d440"
_EKB_AREA_ID = "20561"

# Buckets to sweep — порядок влияет на логи.
ROOM_BUCKETS: tuple[str, ...] = ("st", "1", "2", "3", "4", "5+")

OFFSET_CAP: int = 2000  # max offset принятый BFF API
PAGE_SIZE: int = 20  # items per page (жёстко задан API)
# Потолок цены для binary split. 1 млрд ₽ — безопасно выше любой ЕКБ-квартиры;
# выбран так чтобы НЕ отбрасывать листинги дороже потолка при сплите unbounded-бакета
# (родительский count не ограничен сверху → clamped _lte должен покрывать весь хвост).
LTE_MAX: int = 1_000_000_000
MIN_PRICE_SPAN: int = 100_000  # ниже этого span прекращаем делить

# Конфиг единого движка бисекции (scraper_kit.pricing) для domclick. Отличия от
# avito/cian/yandex закодированы явно:
#   * cap = OFFSET_CAP (2000) — потолок offset BFF API, не «результатов бакета»;
#   * count через отдельный count-endpoint (probe.payload=None, пагинация с offset=0);
#   * min_bracket_inclusive=False — исходное `span <= MIN_PRICE_SPAN → paginate`
#     (делим только пока span СТРОГО > MIN_PRICE_SPAN);
#   * open_split_ceiling=LTE_MAX / floor=0 — unbounded-бакет (price_gte/lte=None)
#     клампится к [0, LTE_MAX] и ДЕЛИТСЯ (в отличие от открытого seed-брекета
#     avito/cian/yandex, который пагинируется напрямую);
#   * seed-брекетов нет — каждый ROOM_BUCKET стартует с одного (None, None).
_DOMCLICK_BISECTION = BisectionConfig(
    cap=OFFSET_CAP,
    min_bracket=MIN_PRICE_SPAN,
    min_bracket_inclusive=False,
    open_split_ceiling=LTE_MAX,
    open_split_floor=0,
)

# ── QRATOR block detection ────────────────────────────────────────────────────

_QRATOR_MARKERS: tuple[str, ...] = (
    "qrator",
    "bot_mitigation",
    "система защиты",
    "403 | домклик",
    "captcha",
    "access denied",
)

# ── EKB geo guard ─────────────────────────────────────────────────────────────

_EKB_LAT_MIN: float = 56.6
_EKB_LAT_MAX: float = 57.0
_EKB_LON_MIN: float = 60.2
_EKB_LON_MAX: float = 60.9
_EKB_REGION_NAME: str = "Екатеринбург"


# ── JSON extraction ───────────────────────────────────────────────────────────


def _extract_json(html: str) -> dict[str, Any]:
    """Извлекает JSON-объект из ответа BrowserFetcher.

    BrowserFetcher оборачивает JSON в HTML-страницу двумя способами:
    1. Bare body: ``{"result": ...}`` напрямую в <body>.
    2. <pre>-wrapped: ``<html><body><pre>{"result": ...}</pre></body></html>``.

    Стратегия: найти первый '{' и последний '}' — работает для обоих вариантов.

    Raises:
        DomClickBlockedError: если ответ содержит QRATOR/captcha маркеры.
        ValueError: если JSON не найден или не является dict.
    """
    # Сканируем ВЕСЬ ответ (а не только первые 4096B): block-маркер может
    # стоять за пределами head в крупных challenge-страницах.
    html_lower = html.lower()
    if any(m in html_lower for m in _QRATOR_MARKERS):
        raise DomClickBlockedError(
            f"DomClick BFF: QRATOR block page detected (markers checked: {_QRATOR_MARKERS[:2]})"
        )

    start = html.find("{")
    end = html.rfind("}")
    if start == -1 or end == -1 or end <= start:
        raise ValueError(f"No JSON object found in BFF response (len={len(html)})")

    data = json.loads(html[start : end + 1])
    if not isinstance(data, dict):
        raise ValueError(f"BFF response JSON is not a dict: {type(data)}")
    return data  # type: ignore[return-value]


# ── URL builders ──────────────────────────────────────────────────────────────


def _build_offers_url(
    rooms: str,
    price_gte: int | None,
    price_lte: int | None,
    offset: int,
) -> str:
    """Строит URL для GET /api/offers/v1 с пагинацией.

    urlencode кодирует "5+" → "5%2B" (literal '+' в query string = space → reject).
    """
    params: list[tuple[str, str]] = [
        ("address", _EKB_ADDRESS_GUID),
        ("aids", _EKB_AREA_ID),
        ("deal_type", "sale"),
        ("category", "living"),
        ("offer_type", "flat"),
        ("rooms", rooms),
        ("sort", "qi"),
        ("sort_dir", "desc"),
        ("offset", str(offset)),
        ("limit", str(PAGE_SIZE)),
    ]
    if price_gte is not None:
        params.append(("sale_price__gte", str(price_gte)))
    if price_lte is not None:
        params.append(("sale_price__lte", str(price_lte)))
    return f"{_BFF_BASE}/api/offers/v1?{urlencode(params)}"


def _build_count_url(
    rooms: str,
    price_gte: int | None,
    price_lte: int | None,
) -> str:
    """Строит URL для GET /api/offers/count/v1 (без offset/limit)."""
    params: list[tuple[str, str]] = [
        ("address", _EKB_ADDRESS_GUID),
        ("aids", _EKB_AREA_ID),
        ("deal_type", "sale"),
        ("category", "living"),
        ("offer_type", "flat"),
        ("rooms", rooms),
        ("sort", "qi"),
        ("sort_dir", "desc"),
    ]
    if price_gte is not None:
        params.append(("sale_price__gte", str(price_gte)))
    if price_lte is not None:
        params.append(("sale_price__lte", str(price_lte)))
    return f"{_BFF_BASE}/api/offers/count/v1?{urlencode(params)}"


def _parse_publish_date(iso: str | None) -> date | None:
    """Парсит ISO8601 дату публикации → date. None при ошибке."""
    if not iso:
        return None
    try:
        return date.fromisoformat(iso[:10])
    except (ValueError, TypeError):
        return None


def _extract_agency_name(seller: dict[str, Any]) -> str | None:
    """Извлечь читаемое имя агентства/агента из seller-блока BFF offer.

    seller.company / seller.agent могут быть строкой ИЛИ dict (defensive —
    реальная форма поля варьируется). Приоритет: company → agent. Для dict
    пробуем типичные name-поля. Возвращает None если ничего читаемого нет.
    """
    for key in ("company", "agent"):
        val = seller.get(key)
        if isinstance(val, str):
            name = val.strip()
            if name:
                return name
        elif isinstance(val, dict):
            for name_key in ("name", "title", "fullName"):
                nm = val.get(name_key)
                if isinstance(nm, str) and nm.strip():
                    return nm.strip()
    return None


# ── DomClickScraper ──────────────────────────────────────────────────────────


class DomClickScraper(BaseScraper):
    """DomClick вторичка через JSON BFF API. Источник = 'domklik'.

    Использует BrowserFetcher(source="domclick") (generic provider → shared mobile
    proxy), который обходит QRATOR. Прямые curl/httpx-запросы с datacenter-IP
    блокируются QRATOR.

    Основной метод: fetch_city(city_id, rooms, pages).
    fetch_around() не реализован: DomClick не поддерживает geo-radius в URL.

    Strangler-инжекция (#2133): продуктовые зависимости приходят снаружи вместо
    прямого импорта `app.*`:
      - `config: ScraperConfig` — источник `browser_http_endpoint` для
        BrowserFetcher (kit не читает `app.core.config.settings`);
      - `delay_provider` — замена прямого импорта get_scraper_delay.

    Counters (публичные после fetch_city):
        parse_failures  — офферы с ошибкой маппинга
        geo_filtered    — офферы вне ЕКБ bbox или с неверным offerRegionName
        blocked         — True если sweep был прерван QRATOR-блоком
        fetch_errors    — не-block ошибки извлечения JSON (truncated/garbled/bad shape)
    """

    name = "domklik"
    source = "domklik"
    base_url = "https://domclick.ru"
    # Консервативная задержка между страницами (fallback до init)
    request_delay_sec = 8.0

    def __init__(
        self,
        config: ScraperConfig,
        *,
        delay_provider: Callable[[str], float] | None = None,
    ) -> None:
        super().__init__()
        # Strangler-инжекция (#2133): config даёт browser_http_endpoint для
        # BrowserFetcher, delay_provider заменяет прямой импорт
        # app.services.scraper_settings.get_scraper_delay. Kit не знает про app / БД.
        self._config = config
        if delay_provider is not None:
            self.request_delay_sec = delay_provider(self.name)
        self.parse_failures: int = 0
        self.geo_filtered: int = 0
        self.blocked: bool = False
        # Не-block ошибки извлечения JSON (truncated/garbled response, неверная
        # структура). В отличие от parse_failures (per-item), это per-fetch ошибки,
        # которые ограничивают сбор бакета. Учитываются в honest-status pipeline.
        self.fetch_errors: int = 0

    async def __aenter__(self) -> DomClickScraper:
        await super().__aenter__()
        return self

    # ── fetch_around — stub ───────────────────────────────────────────────────

    async def fetch_around(self, lat: float, lon: float, radius_m: int = 1000) -> list[ScrapedLot]:
        """DomClick не поддерживает geo-radius. Используй fetch_city()."""
        raise NotImplementedError(
            "DomClick не поддерживает geo-radius; используй fetch_city(city_id=...)"
        )

    # ── Основной метод ────────────────────────────────────────────────────────

    async def fetch_city(
        self,
        city_id: int,
        rooms: list[int] | None = None,
        pages: int = 100,
    ) -> list[ScrapedLot]:
        """Citywide sweep через BFF JSON API.

        Аргументы city_id и rooms принимаются для совместимости сигнатуры с
        вызывающим кодом (run_domclick_city_sweep), но:
        - city_id — vestigial (EКБ захардкожен через GUID).
        - rooms — игнорируется; всегда обходятся все ROOM_BUCKETS внутри.

        Args:
            city_id: игнорируется (EKB захардкожен).
            rooms: игнорируется (ROOM_BUCKETS перебирается всегда).
            pages: максимальное число страниц на бакет (safety cap).

        Returns:
            Дедуплицированный по source_id список ScrapedLot.
        """
        # TODO Layer B (#1846 follow-up): backfill renovation/wallType/priceHistory

        from scraper_kit.providers._base import build_browser_fetcher

        out_lots: list[ScrapedLot] = []
        seen_ids: set[str] = set()

        # build_browser_fetcher(config, source) без proxy_provider (дефолт None) —
        # поведенчески идентично прежнему прямому BrowserFetcher(source=..., endpoint=...):
        # use_pool теперь читает config.use_proxy_pool_browser (#2364 caveat, тот же
        # класс что и avito) вместо хардкода False, но BrowserFetcher._pool_proxy
        # вычисляет `use_pool and proxy_provider is not None` — при proxy_provider=None
        # результат всегда False независимо от значения флага (см. _base.py
        # build_browser_fetcher docstring: domclick пока не подключён к browser-proxy-
        # пулу, #2160 P4 wiring gap). Поведение НЕ меняется.
        async with build_browser_fetcher(self._config, "domclick") as fetcher:
            for bucket in ROOM_BUCKETS:
                logger.info(
                    "domklik: BFF sweep rooms=%r city_id=%d pages_cap=%d",
                    bucket,
                    city_id,
                    pages,
                )
                try:
                    await self._sweep_bucket(
                        fetcher=fetcher,
                        rooms=bucket,
                        price_gte=None,
                        price_lte=None,
                        seen_ids=seen_ids,
                        out_lots=out_lots,
                        pages=pages,
                    )
                except DomClickBlockedError:
                    self.blocked = True
                    logger.error(
                        "domklik: QRATOR block during rooms=%r — aborting all buckets",
                        bucket,
                    )
                    break
                except (ValueError, TypeError) as exc:
                    # Defensive: bucket-level ошибка не должна убивать весь sweep.
                    # _count/_paginate уже глотают эти ошибки per-fetch (fetch_errors++),
                    # но если что-то всё же всплыло — переходим к следующему бакету.
                    self.fetch_errors += 1
                    logger.warning(
                        "domklik: bucket rooms=%r failed (%s) — skipping to next bucket",
                        bucket,
                        exc,
                        exc_info=True,
                    )
                    continue

        logger.info(
            "domklik: fetch_city done city_id=%d total=%d "
            "parse_failures=%d geo_filtered=%d fetch_errors=%d blocked=%s",
            city_id,
            len(out_lots),
            self.parse_failures,
            self.geo_filtered,
            self.fetch_errors,
            self.blocked,
        )
        return out_lots

    # ── Internal sweep helpers ────────────────────────────────────────────────

    async def _count(
        self,
        fetcher: Any,
        rooms: str,
        price_gte: int | None,
        price_lte: int | None,
    ) -> int:
        """Запрашивает snippetsCount для данного бакета (rooms + price range).

        DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
        извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и return 0
        (бакет пропускается, sweep продолжается).
        """
        url = _build_count_url(rooms, price_gte, price_lte)
        logger.debug("domklik: count url=%s", url)
        html = await fetcher.fetch(url)
        try:
            data = _extract_json(html)
        except DomClickBlockedError:
            raise
        except (ValueError, TypeError, AttributeError):
            self.fetch_errors += 1
            logger.warning(
                "domklik: _count JSON extract failed rooms=%r price=[%s,%s] — skip bucket",
                rooms,
                price_gte,
                price_lte,
                exc_info=True,
            )
            return 0
        # {"result": null} → None, {"snippetsCount": null} → None: harden обе.
        res = data.get("result") or {}
        raw = res.get("snippetsCount")
        return int(raw) if raw else 0

    async def _sweep_bucket(
        self,
        fetcher: Any,
        rooms: str,
        price_gte: int | None,
        price_lte: int | None,
        seen_ids: set[str],
        out_lots: list[ScrapedLot],
        pages: int,
    ) -> None:
        """Рекурсивный sweep бакета (rooms, price_gte, price_lte).

        Если snippetsCount > OFFSET_CAP — делим диапазон цен пополам и
        рекурсируем в каждую половину. Остановка рекурсии:
        - span <= MIN_PRICE_SPAN → пагинируем как есть (с предупреждением о truncation)
        - snippetsCount == 0 → пропускаем
        - snippetsCount <= OFFSET_CAP → пагинируем

        Raises:
            DomClickBlockedError: если QRATOR-блок — propagate наверх.

        Реализация: единый движок `scraper_kit.pricing.walk_price_range`. Domclick-spec
        count-endpoint (probe) и offset-пагинация (leaf) переданы callback'ами; отличия
        (OFFSET_CAP, MIN_PRICE_SPAN строго-больше, clamp unbounded к [0, LTE_MAX] с
        делением) закодированы в _DOMCLICK_BISECTION.
        """

        async def _probe(plo: int | None, phi: int | None, depth: int) -> ProbeResult:
            count = await self._count(fetcher, rooms, plo, phi)
            return ProbeResult(count=count, payload=None)

        async def _leaf(plo: int | None, phi: int | None, result: ProbeResult) -> None:
            await self._paginate(fetcher, rooms, plo, phi, seen_ids, out_lots, pages)

        await walk_price_range(
            lo=price_gte,
            hi=price_lte,
            config=_DOMCLICK_BISECTION,
            probe=_probe,
            on_leaf=_leaf,
        )

    async def _paginate(
        self,
        fetcher: Any,
        rooms: str,
        price_gte: int | None,
        price_lte: int | None,
        seen_ids: set[str],
        out_lots: list[ScrapedLot],
        pages: int,
    ) -> None:
        """Пагинирует один бакет (rooms, price range) до исчерпания или cap.

        DomClickBlockedError пробрасывается наверх (abort sweep). Прочие ошибки
        извлечения JSON (truncated/garbled/bad shape) → fetch_errors++ и break
        (трактуем как конец бакета — уже собранные lots сохраняются).

        Raises:
            DomClickBlockedError: propagate из _extract_json при QRATOR-блоке.
        """
        force_rooms = 0 if rooms == "st" else None
        page_idx = 0
        while page_idx < pages:
            offset = page_idx * PAGE_SIZE
            if offset >= OFFSET_CAP:
                break
            url = _build_offers_url(rooms, price_gte, price_lte, offset)
            logger.debug("domklik: offers url=%s", url)
            html = await fetcher.fetch(url)
            try:
                data = _extract_json(html)
            except DomClickBlockedError:
                raise
            except (ValueError, TypeError, AttributeError):
                self.fetch_errors += 1
                logger.warning(
                    "domklik: _paginate JSON extract failed rooms=%r offset=%d "
                    "— ending bucket (lots so far preserved)",
                    rooms,
                    offset,
                    exc_info=True,
                )
                break

            # {"result": null} → None; harden до .get("items").
            items: list[dict[str, Any]] = (data.get("result") or {}).get("items") or []
            if not items:
                break

            for item in items:
                lot = self._map_item(item, force_rooms=force_rooms)
                if lot is None:
                    continue
                if not self._is_geo_ok(item):
                    self.geo_filtered += 1
                    continue
                key = lot.source_id or lot.source_url
                if key in seen_ids:
                    continue
                seen_ids.add(key)
                out_lots.append(lot)

            page_idx += 1
            if page_idx < pages and len(items) == PAGE_SIZE:
                await self.sleep_between_requests()
            elif not items or len(items) < PAGE_SIZE:
                break

    def _is_geo_ok(self, item: dict[str, Any]) -> bool:
        """Гео-гард: пропускает только листинги ЕКБ в bbox.

        aids=20561 даёт чистый ЕКБ, но гард оставляем как defensive проверку.
        """
        region = item.get("offerRegionName", "")
        if region != _EKB_REGION_NAME:
            return False
        loc = item.get("location") or {}
        lat = loc.get("lat")
        lon = loc.get("lon")
        if lat is None or lon is None:
            return False
        return (
            _EKB_LAT_MIN <= float(lat) <= _EKB_LAT_MAX
            and _EKB_LON_MIN <= float(lon) <= _EKB_LON_MAX
        )

    def _map_item(
        self, item: dict[str, Any], *, force_rooms: int | None = None
    ) -> ScrapedLot | None:
        """Маппинг offer-item из BFF API → ScrapedLot.

        Возвращает None если price <= 0 или при ошибке парсинга.
        """
        try:
            price = item.get("price", 0) or 0
            if price <= 0:
                return None

            item_id = item.get("id")
            source_id = str(item_id) if item_id is not None else None
            source_url: str = item.get("path", "") or ""

            loc = item.get("location") or {}
            lat: float | None = loc.get("lat")
            lon: float | None = loc.get("lon")

            address_obj = item.get("address") or {}
            address: str | None = address_obj.get("displayName")

            # 0 — sentinel «неизвестно» для area/floor/total_floors/buildYear
            # (DomClick эмитит 0 для under-construction / unknown) → None.
            obj_info = item.get("objectInfo") or {}
            area_raw = obj_info.get("area")
            area_m2: float | None = float(area_raw) if area_raw else None
            floor_raw = obj_info.get("floor")
            floor: int | None = int(floor_raw) if floor_raw else None

            house = item.get("house") or {}
            floors_raw = house.get("floors")
            total_floors: int | None = int(floors_raw) if floors_raw else None
            year_raw = house.get("buildYear")
            year_built: int | None = int(year_raw) if year_raw else None

            # rooms: 0 — ВАЛИДНОЕ значение (студия), поэтому проверка is not None,
            # а не truthy. force_rooms=0 (st-бакет) перебивает objectInfo.
            if force_rooms is not None:
                rooms: int | None = force_rooms
            else:
                rooms_raw = obj_info.get("rooms")
                rooms = int(rooms_raw) if rooms_raw is not None else None

            square_price_raw = item.get("squarePrice")
            price_per_m2: int | None = int(square_price_raw) if square_price_raw else None
            if price_per_m2 is None and area_m2 and area_m2 > 0:
                price_per_m2 = int(price / area_m2)

            # ── Описание + repair-инференс + minhash (Layer A enrichment) ─────
            # DomClick search не отдаёт renovation-enum: repair_state выводим из
            # текста описания (как cian #622 fallback — «евроремонт»/«без отделки»).
            # description_minhash — sha1 от описания (mirror cian) для cross-source
            # дедупа.
            description_raw = item.get("description")
            description: str | None = None
            if isinstance(description_raw, str):
                description = description_raw.strip() or None

            repair_state: str | None = None
            description_minhash: str | None = None
            if description:
                repair_state = infer_repair_state_from_text(description)
                description_minhash = hashlib.sha1(
                    description.lower().encode("utf-8", errors="replace")
                ).hexdigest()[:32]

            # ── Продавец / агентство ──────────────────────────────────────────
            seller = item.get("seller")
            if not isinstance(seller, dict):
                seller = {}
            agency_name = _extract_agency_name(seller)

            flat_complex = item.get("flatComplex") or {}
            raw_payload: dict[str, Any] = {
                "isRosreestrApproved": item.get("isRosreestrApproved"),
                "squarePrice": square_price_raw,
                "lastPriceHistoryState": item.get("lastPriceHistoryState"),
                "flatComplex": {
                    "name": flat_complex.get("name"),
                    "id": flat_complex.get("id"),
                    "slug": flat_complex.get("slug"),
                }
                if flat_complex
                else None,
                "updatedDate": item.get("updatedDate"),
            }

            # ── Доп. SERP-поля (Layer A enrichment) — мержим в raw_payload, ────
            # сбрасывая None-значения чтобы payload оставался компактным; ничего
            # из существующих ключей не теряем.
            seller_compact = {
                k: seller.get(k) for k in ("agent", "company") if seller.get(k) is not None
            }
            _enrich: dict[str, Any] = {
                "is_sber_collateral": item.get("isSberCollateral"),
                "has_discount": item.get("hasDiscount"),
                "discount_value": item.get("discountValue"),
                "duplicates_offer_count": item.get("duplicatesOfferCount"),
                "seller": seller_compact or None,
            }
            raw_payload.update({k: v for k, v in _enrich.items() if v is not None})

            publish_date = _parse_publish_date(item.get("publishedDate"))

            return ScrapedLot(
                source="domklik",
                source_url=source_url,
                source_id=source_id,
                address=address,
                lat=lat,
                lon=lon,
                rooms=rooms,
                area_m2=area_m2,
                floor=floor,
                total_floors=total_floors,
                year_built=year_built,
                price_rub=int(price),
                price_per_m2=price_per_m2,
                listing_segment="vtorichka",
                publish_date=publish_date,
                description=description,
                repair_state=repair_state,
                description_minhash=description_minhash,
                agency_name=agency_name,
                raw_payload=raw_payload,
            )
        except Exception:
            self.parse_failures += 1
            logger.warning(
                "domklik: _map_item failed for item id=%r (parse_failures=%d)",
                item.get("id"),
                self.parse_failures,
                exc_info=True,
            )
            return None


# ── Convenience runner (для Celery tasks) ────────────────────────────────────


async def scrape_domclick_city(
    config: ScraperConfig,
    city_id: int,
    rooms: list[int] | None = None,
    pages: int = 100,
    *,
    delay_provider: Callable[[str], float] | None = None,
) -> list[ScrapedLot]:
    """Удобная точка входа для вызова из Celery tasks.

    Пример::

        import asyncio
        lots = asyncio.run(scrape_domclick_city(config, city_id=4, rooms=None))
    """
    async with DomClickScraper(config, delay_provider=delay_provider) as scraper:
        return await scraper.fetch_city(city_id=city_id, rooms=rooms, pages=pages)
