"""Avito detail page parser — Stage 2b.

Извлекает 30+ полей из страницы объявления:
- Identity (item_id, title, price, publish_date, views)
- Apartment params (rooms, area, floor, balcony_loggia, room_layout,
  bathroom_type, windows_view, repair_state, sale_type, mortgage_available)
- Location (lat, lon, avito_location_id, metro_stations[], address_full)
- House params (house_type, total_floors_house, lifts, concierge, closed_yard,
  house_catalog_url) — собираются но НЕ сохраняются в БД (Stage 2c)
- Description
- Domoteka 5 полей (owners_count, owners_at_least, last_owner_change_date,
  encumbrances_clean, registry_match)
- Gallery (photo_urls[])

Точка входа для batch-обогащения:
    enrichment = await fetch_detail(item_url)
    saved = save_detail_enrichment(db, enrichment)
"""

from __future__ import annotations

import asyncio
import json
import logging
import random
import re
from dataclasses import dataclass, field
from datetime import date
from typing import TYPE_CHECKING, Any
from urllib.parse import quote, urljoin, urlparse

from curl_cffi.requests import AsyncSession
from selectolax.parser import HTMLParser, Node
from sqlalchemy import text
from sqlalchemy.orm import Session

from scraper_kit.avito_exceptions import (
    AvitoBlockedError,
    AvitoListingGoneError,
    AvitoRateLimitedError,
)
from scraper_kit.geo import is_within_oblast66_bbox
from scraper_kit.providers._base import build_document_session
from scraper_kit.providers.avito.serp import _clean_address, _is_firewall_page
from scraper_kit.providers.avito.shared import RUS_MONTHS
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text

if TYPE_CHECKING:
    from scraper_kit.browser_fetcher import BrowserFetcher
    from scraper_kit.contracts import ScraperConfig

logger = logging.getLogger(__name__)

AVITO_BASE = "https://www.avito.ru"

# Живой прогрев сессии (#1551): GET Yandex SERP (органический referer-источник) -> GET
# Avito EKB search-страница (Referer=yandex) сеет антибот-куки, после чего прогретая
# curl_cffi-сессия держит батч detail-GET'ов без 403 (доказано пробами на проде: 22/22
# карточек подряд, 0 блоков). referer на detail-GET = URL avito-search страницы.
_AVITO_WARM_SEARCH_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam?q=" + quote(
    "купить квартиру"
)
_AVITO_WARM_YANDEX_URL = "https://yandex.ru/search/?text=" + quote(
    "купить квартиру екатеринбург авито"
)
_AVITO_WARM_YANDEX_REFERER = "https://yandex.ru/"

# Reconnect-retry на detail-странице под backconnect-прокси — зеркало SERP-фикса
# (#1765/#1769, avito.py): 403/firewall = залочен лишь текущий exit-IP backconnect-прокси;
# пересоздание curl_cffi-сессии = новый CONNECT-туннель = свежий exit-IP → escape.
# 429 = transient conn-limit: сначала короткий retry на ТОЙ ЖЕ сессии. Но keep-alive
# detail-fetch переиспользует ОДИН backconnect exit-IP на последовательные запросы →
# avito лочит этот IP, и short-retry той же сессии не помогает (run 217: 5 подряд 429,
# abort на 19 enriched). Поэтому при исчерпании short-retry под backconnect делаем ещё
# _AVITO_DETAIL_429_RECONNECT_RETRIES попыток через эфемерную сессию (свежий exit-IP).
_AVITO_DETAIL_403_MAX_RETRIES = 5
_AVITO_DETAIL_403_BACKOFF_SEC = 2.0
_AVITO_DETAIL_429_MAX_RETRIES = 8
_AVITO_DETAIL_429_BACKOFF_SEC = 1.5
_AVITO_DETAIL_429_RECONNECT_RETRIES = 3

# ── Regex ────────────────────────────────────────────────────────────────────
_ITEM_ID_RE = re.compile(r"№\s*(\d+)")
_VIEWS_TOTAL_RE = re.compile(r"(\d+)\s+просмотр")
_VIEWS_TODAY_RE = re.compile(r"\+\s*(\d+)\s+сегодня")
_PUBLISH_DATE_RE = re.compile(
    r"(\d{1,2})\s+(января|февраля|марта|апреля|мая|июня|"
    r"июля|августа|сентября|октября|ноября|декабря)\s+в",
    re.IGNORECASE,
)
_FLOAT_RE = re.compile(r"[\d.,]+")
_INT_RE = re.compile(r"\d+")
_FLOOR_SPLIT_RE = re.compile(r"(\d+)\s+из\s+(\d+)")

OWNERS_RE = re.compile(r"^(\d+)\s+собственник(?:\s+или\s+больше)?", re.IGNORECASE)
OWNER_CHANGE_RE = re.compile(r"смена собственника\s+(\d+)\s+(\w+)\s+(\d{4})", re.IGNORECASE)
ENCUMBRANCES_RE = re.compile(r"Не\s+найдены\s+ограничения", re.IGNORECASE)
REGISTRY_MATCH_RE = re.compile(r"Совпадают\s+площадь", re.IGNORECASE)

METRO_RE = re.compile(
    r"([А-ЯЁ][а-яё]+(?:ская|инская|итская|овская|енская))\s*"
    r"(?:(\d+)[–\-](\d+)|от\s+(\d+))\s*мин",
    re.IGNORECASE,
)

# ── Маппинг RUS полей params-блока ───────────────────────────────────────────
# (rus_label) -> (field_name, conversion_type)
RUS_FIELD_MAP: dict[str, tuple[str, str | None]] = {
    "Количество комнат": ("rooms", "int"),
    "Общая площадь": ("area_m2", "float_m2"),
    "Площадь кухни": ("kitchen_area_m2", "float_m2"),
    "Жилая площадь": ("living_area_m2", "float_m2"),
    "Высота потолков": ("ceiling_height_m", "height_m"),
    "Отделка": ("finishing", "text_lower"),
    "Этаж": ("floor_split", None),
    "Балкон или лоджия": ("balcony_loggia", "balcony"),
    "Тип комнат": ("room_layout", "layout"),
    "Санузел": ("bathroom_type", "bathroom"),
    "Окна": ("windows_view", "windows"),
    "Ремонт": ("repair_state", "repair"),
    "Способ продажи": ("sale_type", "sale_type"),
    "Условия продажи": ("sale_conditions_raw", None),
    "Тип дома": ("house_type", "house_type"),
    "Этажей в доме": ("total_floors_house", "int"),
    "Пассажирский лифт": ("passenger_elevators", "int"),
    "Грузовой лифт": ("cargo_elevators", "int"),
    "В доме": ("in_house_features_raw", None),
    "Двор": ("yard_features_raw", None),
}

# ── Enum-маппинги ─────────────────────────────────────────────────────────────
_BALCONY_MAP: dict[str, str] = {
    "балкон": "balcony",
    "лоджия": "loggia",
    "балкон, лоджия": "both",
    "лоджия, балкон": "both",
    "нет": "none",
}

_LAYOUT_MAP: dict[str, str] = {
    "изолированные": "isolated",
    "смежные": "adjacent",
    "смешанные": "mixed",
}

_BATHROOM_MAP: dict[str, str] = {
    "совмещенный": "combined",
    "раздельный": "separate",
    "совмещенный, раздельный": "both",
    "раздельный, совмещенный": "both",
}

_WINDOWS_MAP: dict[str, str] = {
    "во двор": "yard",
    "на улицу": "street",
    "во двор и на улицу": "both",
    "на улицу и во двор": "both",
    "в парк": "park",
}

_REPAIR_MAP: dict[str, str] = {
    # Русские значения из Avito HTML → каноничный enum (needs_repair/standard/good/excellent)
    # Маппинг выровнен по repair_state_normalizer.normalize_repair_state().
    "косметический": "standard",
    "евро": "good",
    "дизайнерский": "excellent",
    "требуется": "needs_repair",
    "без ремонта": "needs_repair",
}

_SALE_TYPE_MAP: dict[str, str] = {
    "свободная": "free",
    "альтернатива": "alternative",
    "аукцион": "auction",
    "переуступка": "assignment",
}

_HOUSE_TYPE_MAP: dict[str, str] = {
    "монолитный": "monolith",
    "панельный": "panel",
    "кирпичный": "brick",
    "монолитно-кирпичный": "monolith_brick",
    "блочный": "block",
    "деревянный": "wood",
}


# ── DetailEnrichment dataclass ────────────────────────────────────────────────
@dataclass
class DetailEnrichment:
    """Результат парсинга detail page Avito.

    Содержит 30+ полей из страницы объявления.
    save_detail_enrichment() пишет подмножество в listings (house-level поля пропускаются
    — они canonical из Houses Catalog Stage 2c).
    """

    # Identity
    item_id: str
    source_url: str
    title: str | None = None
    price_rub: int | None = None
    publish_date: date | None = None
    views_total: int | None = None
    views_today: int | None = None

    # Apartment params
    rooms: int | None = None
    area_m2: float | None = None
    kitchen_area_m2: float | None = None
    living_area_m2: float | None = None
    ceiling_height_m: float | None = None
    finishing: str | None = None
    floor: int | None = None
    total_floors: int | None = None
    balcony_loggia: str | None = None
    room_layout: str | None = None
    bathroom_type: str | None = None
    windows_view: str | None = None
    repair_state: str | None = None
    sale_type: str | None = None
    mortgage_available: bool | None = None

    # House params (собираем, но НЕ сохраняем в listings — Stage 2c)
    house_type: str | None = None
    total_floors_house: int | None = None
    passenger_elevators: int | None = None
    cargo_elevators: int | None = None
    has_concierge: bool | None = None
    closed_yard: bool | None = None
    house_catalog_url: str | None = None

    # Location
    address_full: str | None = None
    lat: float | None = None
    lon: float | None = None
    avito_location_id: int | None = None
    metro_stations: list[dict[str, Any]] = field(default_factory=list)

    # Description
    description: str | None = None

    # Domoteka (5 полей)
    owners_count: int | None = None
    owners_at_least: bool | None = None
    last_owner_change_date: date | None = None
    encumbrances_clean: bool | None = None
    registry_match: bool | None = None

    # Gallery
    photo_urls: list[str] = field(default_factory=list)

    # Raw для retrofit / debug
    raw_html_meta: dict[str, Any] = field(default_factory=dict)


def _build_detail_session(config: ScraperConfig | None = None) -> AsyncSession:
    """Создать curl_cffi-сессию для detail-fetch (impersonate chrome120, через прокси
    если задан config.scraper_proxy_url).

    Вынесено из own-session-ветки fetch_detail чтобы reconnect-retry мог пересоздать
    эфемерную сессию (новый CONNECT-туннель = свежий backconnect exit-IP) под 403.
    proxy=None → прямое подключение.

    Strangler-инжекция (#2133): прокси приходит из инжектируемого `ScraperConfig`
    (scraper_kit.contracts) вместо прямого импорта app.core.config.settings.
    """
    proxy_url = config.scraper_proxy_url if config is not None else None
    return build_document_session(proxy_url=proxy_url, timeout=25)


async def warm_up_session(session: AsyncSession) -> bool:
    """Органический прогрев сессии: Yandex SERP (best-effort) -> Avito EKB search
    (Referer=yandex). Сеет антибот-куки, после чего сессия держит батч detail-GET'ов
    без 403. Возвращает True если search-страница загрузилась (не firewall).
    """
    try:
        await session.get(_AVITO_WARM_YANDEX_URL)  # органический referer-источник, best-effort
    except Exception as exc:
        logger.debug("avito warm-up: yandex GET failed (non-fatal): %s", exc)
    await asyncio.sleep(random.uniform(2.0, 3.5))
    try:
        r = await session.get(
            _AVITO_WARM_SEARCH_URL, headers={"Referer": _AVITO_WARM_YANDEX_REFERER}
        )
    except Exception as exc:
        logger.warning("avito warm-up: search fetch error: %s", exc)
        return False
    ok = r.status_code == 200 and not _is_firewall_page(r.text)
    if not ok:
        logger.warning("avito warm-up: search page blocked sc=%s", r.status_code)
    return ok


async def research_in_session(session: AsyncSession) -> bool:
    """Лёгкий in-session перепоиск: re-GET avito search-страницы (Referer=yandex) на
    ТЕКУЩЕЙ сессии — освежает антибот-куки БЕЗ смены exit-IP. Best-effort (search-страница
    иногда 429, это не ломает detail-фетчи). Возвращает True если страница не firewall.
    """
    try:
        r = await session.get(
            _AVITO_WARM_SEARCH_URL, headers={"Referer": _AVITO_WARM_YANDEX_REFERER}
        )
    except Exception as exc:
        logger.warning("avito re-search: fetch error: %s", exc)
        return False
    ok = r.status_code == 200 and not _is_firewall_page(r.text)
    if not ok:
        logger.info("avito re-search: search page sc=%s (non-fatal)", r.status_code)
    return ok


async def build_warmed_session(config: ScraperConfig | None = None) -> AsyncSession:
    """Построить detail-сессию (_build_detail_session) и прогреть её. Возвращает
    прогретую сессию (даже если warm-up soft-failed — caller может всё равно пробовать).

    Strangler-инжекция (#2330): config пробрасывается в _build_detail_session тем же
    способом, каким это делает fetch_detail — до этого фикса build_warmed_session
    вообще не принимал config (был бы сломан любой caller, попытавшийся его передать).
    Сегодняшний единственный caller (`avito_detail_backfill.py`) всё ещё импортирует
    build_warmed_session из legacy-модуля, так что прод-путь этим фиксом не затронут —
    это advance-prep для будущей миграции call-site на kit (avito_detail_backfill_use_curl=True
    станет прод-дефолтом ТОГДА, когда caller переключится). См. issue #2330 / vault
    Scraper_Kit_Legacy_Dependency_Audit_0703.
    """
    session = _build_detail_session(config)
    await warm_up_session(session)
    return session


# ── Soft-block detection (#1967 / #1950) ──────────────────────────────────────
# Под нагрузкой Avito иногда вместо detail-страницы отдаёт HTTP 200 с ОБЩЕЙ
# витриной (<title>Авито — Объявления на сайте Авито</title>) вместо запрошенного
# объявления — мягкий анти-бот deflect. У такой страницы НЕТ ни firewall-маркеров
# (_is_firewall_page=False), ни item-view → parse_detail_html падал
# ValueError("Cannot extract item_id") → generic failed, IP НЕ ротировался, enriched
# каскадно падал к 0 (прод runs 431→437: 115→82→8→2→1→0 при blocked=0). Детектим
# как блок → reconnect свежей exit-IP сессией / loop-ротация.
_DETAIL_SOFT_BLOCK_TITLE_MARKERS = ("объявления на сайте авито",)


def _is_detail_soft_block(html: str) -> bool:
    """True если detail-fetch вернул HTTP 200, но это анти-бот deflect — общая
    витрина Авито вместо объявления.

    Сигнатура строго специфична deflect-витрине, чтобы НЕ задевать легитимные
    не-блок ответы:
      * нормальное объявление содержит item-view-маркеры → False;
      * снятое объявление Avito рендерит как item-view + баннер «снято» → False;
      * 404 / не-200 обрабатывается выше (ValueError → failed), сюда не попадает.
    Положительный сигнал: НЕТ item-view-структуры И generic-витринный <title>.
    """
    if "item-view/item-id" in html or "item-view/title-info" in html:
        return False
    head = html[:16384].lower()
    return any(m in head for m in _DETAIL_SOFT_BLOCK_TITLE_MARKERS)


def _is_detail_not_found(html: str) -> bool:
    """True if the page is Avito's 404 / removed-listing page (not an anti-bot deflect).
    Guard: a real listing has item-view → False. Keyed on the specific 404 title so it
    does NOT catch the soft-block deflect ("объявления на сайте авито" without 404 text)."""
    if "item-view/item-id" in html or "item-view/title-info" in html:
        return False
    head = html[:16384].lower()
    return "страница не найдена" in head or "ошибка 404" in head


# ── fetch_detail ──────────────────────────────────────────────────────────────
async def fetch_detail(
    item_url: str,
    *,
    cffi_session: AsyncSession | None = None,
    browser_fetcher: BrowserFetcher | None = None,
    referer: str | None = None,
    reconnect_on_block: bool = True,
    config: ScraperConfig | None = None,
) -> DetailEnrichment:
    """GET <avito>/{item_url} → parse HTML via selectolax → DetailEnrichment.

    Если browser_fetcher передан — использует браузерный fetch (browser mode).
    Если cffi_session не передана — создаёт новую (impersonate='chrome120').
    referer — если задан, шлётся в Referer-заголовке detail-GET'а (warm-batch #1551:
    referer = URL avito-search страницы, на которой прогрелась сессия).
    reconnect_on_block — если False, 403/firewall поднимает AvitoBlockedError СРАЗУ
    (без холодных reconnect'ов — ими займётся outer-loop через rebuild+rewarm прогретой
    сессии); 429 после short-retry поднимает AvitoRateLimitedError. Дефолт True
    сохраняет старое поведение own-session / shared-session (run_avito_city_sweep) путей.
    Raises:
        httpx.HTTPError — если status != 200 (через raise_for_status-like).
        ValueError — если item_id не извлечён из HTML.
    """
    if browser_fetcher is not None:
        # Browser-only режим (#1814): когда передан browser_fetcher, fetch идёт
        # СТРОГО через camoufox-сервис. Никакой curl_cffi-сессии и никакого
        # curl-fallback — avito-антибот банит curl-фингерпринт под нагрузкой
        # (429 CONNECT tunnel failed), а browser проходит. Любой firewall/ошибка
        # браузера → AvitoBlockedError; caller (backfill) пометит failed и пойдёт
        # дальше, НЕ трогая curl.
        full_url = item_url if item_url.startswith("http") else urljoin(AVITO_BASE, item_url)
        try:
            html = await browser_fetcher.fetch(full_url)
        except (AvitoBlockedError, AvitoRateLimitedError):
            raise
        except Exception as exc:
            # browser-сервис вернул HTTPError / ConnectError / 502 (firewall, dead
            # listing, redirect). НЕ откатываемся в curl — поднимаем block, чтобы
            # caller пометил listing и продолжил без curl-фингерпринта.
            raise AvitoBlockedError(
                f"Avito detail browser fetch failed for {full_url}: {exc}"
            ) from exc
        # 404/removed-listing (#2034): мёртвый листинг рендерит «Ошибка 404» без
        # item-view. Это НЕ анти-бот deflect — отдельный сигнал, чтобы backfill
        # пометил листинг is_active=FALSE и НЕ крутил consecutive-block breaker
        # (run 458: 5 dead подряд → abort до live-листингов). Проверяем ДО
        # firewall/soft-block (иначе 404-страница ловится как soft-block).
        if _is_detail_not_found(html):
            raise AvitoListingGoneError(
                f"Avito detail 404/listing-gone (browser-mode) for {full_url}"
            )
        if _is_firewall_page(html) or _is_detail_soft_block(html):
            raise AvitoBlockedError(
                f"Avito detail firewall/soft-block (browser-mode) for {full_url}"
            )
        return parse_detail_html(html, full_url)

    own_session = cffi_session is None
    if cffi_session is None:
        # Mobile proxy wiring (#806 follow-up): own-session path (no shared session passed,
        # e.g. admin endpoint). Caller-provided sessions (run_avito_city_sweep) already have
        # proxy from AvitoScraper.__aenter__ — don't override. proxy=None → прямое подключение.
        session = _build_detail_session(config)
    else:
        session = cffi_session

    full_url = item_url if item_url.startswith("http") else urljoin(AVITO_BASE, item_url)
    # curl_cffi egress всегда через scraper_proxy_url (backconnect mproxy). 403/firewall =
    # залочен лишь текущий exit-IP → reconnect новой сессией = свежий IP. Гейтим reconnect
    # ТОЛЬКО на наличие proxy (зеркало SERP-фикса #1765). Без proxy — старое поведение
    # (raise на первом 403). Caller-provided сессию НЕ трогаем: reconnect использует
    # эфемерную retry_session, которую сами закрываем в finally.
    # reconnect_on_block=False (warm-batch #1551): не делаем холодных per-card
    # reconnect'ов — блок поднимает AvitoBlockedError сразу, outer-loop пересоздаёт+
    # прогревает сессию (свежий exit-IP). reconnect_on_block=True (own-session /
    # run_avito_city_sweep) — старое поведение: backconnect-reconnect эфемерной сессией.
    backconnect = (
        bool(config.scraper_proxy_url if config is not None else None) and reconnect_on_block
    )
    req_headers = {"Referer": referer} if referer else None
    r403 = 0
    r429 = 0
    r429_reconnect = 0
    attempt_session = session
    retry_session: AsyncSession | None = None
    try:
        while True:
            response = await attempt_session.get(full_url, headers=req_headers)
            sc = response.status_code
            is_firewall = sc == 200 and (
                _is_firewall_page(response.text) or _is_detail_soft_block(response.text)
            )

            # 429 через backconnect = transient conn-limit, НЕ IP-ban: короткий retry той
            # же сессии без reconnect (зеркало SERP). Только при исчерпании — raise.
            if sc == 429 and r429 < _AVITO_DETAIL_429_MAX_RETRIES:
                r429 += 1
                logger.info(
                    "avito detail HTTP 429 (backconnect conn-limit) — short retry %d/%d",
                    r429,
                    _AVITO_DETAIL_429_MAX_RETRIES,
                )
                await asyncio.sleep(_AVITO_DETAIL_429_BACKOFF_SEC)
                continue

            if sc == 403 or is_firewall:
                if backconnect and r403 < _AVITO_DETAIL_403_MAX_RETRIES:
                    r403 += 1
                    # Эфемерная свежая сессия (новый CONNECT-туннель = свежий exit-IP).
                    # Caller-provided/own session НЕ пересоздаём — только retry_session.
                    if retry_session is not None:
                        try:
                            await retry_session.close()
                        except Exception:
                            pass
                    retry_session = _build_detail_session()
                    attempt_session = retry_session
                    logger.info(
                        "avito detail 403/firewall/soft-block %s — backconnect reconnect "
                        "retry %d/%d",
                        full_url,
                        r403,
                        _AVITO_DETAIL_403_MAX_RETRIES,
                    )
                    await asyncio.sleep(_AVITO_DETAIL_403_BACKOFF_SEC)
                    continue
                raise AvitoBlockedError(f"Avito detail HTTP 403 for {full_url}")

            if sc == 429:
                # short-retry (та же сессия) исчерпан. Под backconnect — ещё несколько
                # попыток через эфемерную сессию (свежий exit-IP): keep-alive держал ОДИН
                # залоченный IP, новый CONNECT-туннель уходит на другой. Делит ту же
                # retry_session-машинерию что и 403-путь (close-prev → rebuild → retry,
                # cleanup в finally). Только потом raise.
                if backconnect and r429_reconnect < _AVITO_DETAIL_429_RECONNECT_RETRIES:
                    r429_reconnect += 1
                    # Caller-provided/own session НЕ пересоздаём — только retry_session.
                    if retry_session is not None:
                        try:
                            await retry_session.close()
                        except Exception:
                            pass
                    retry_session = _build_detail_session()
                    attempt_session = retry_session
                    logger.info(
                        "avito detail HTTP 429 short-retry exhausted %s — "
                        "backconnect reconnect retry %d/%d",
                        full_url,
                        r429_reconnect,
                        _AVITO_DETAIL_429_RECONNECT_RETRIES,
                    )
                    await asyncio.sleep(_AVITO_DETAIL_403_BACKOFF_SEC)
                    continue
                raise AvitoRateLimitedError(f"Avito detail HTTP 429 for {full_url}")
            if sc != 200:
                raise ValueError(f"avito detail HTTP {sc} for {full_url}")
            return parse_detail_html(response.text, full_url)
    finally:
        if own_session:
            await session.close()
        if retry_session is not None and retry_session is not session:
            try:
                await retry_session.close()
            except Exception:
                pass


# ── parse_detail_html ─────────────────────────────────────────────────────────
def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
    """Pure parser — отдельно для тестов на fixtures без сети.

    Raises:
        ValueError — если item_id не извлечён.
    """
    tree = HTMLParser(html)

    # ── Identity ────────────────────────────────────────────────────────────
    item_id = _extract_item_id(tree)
    if not item_id:
        raise ValueError(f"Cannot extract item_id from {source_url}")

    title = _text(tree, "[data-marker='item-view/title-info']")
    price_rub = _extract_price(tree)
    publish_date, views_total, views_today = _extract_meta(tree)

    # ── Location ────────────────────────────────────────────────────────────
    map_el = tree.css_first("[data-marker='item-map-wrapper']")
    lat: float | None = None
    lon: float | None = None
    avito_location_id: int | None = None
    if map_el is not None:
        lat = _try_float(map_el.attributes.get("data-map-lat"))
        lon = _try_float(map_el.attributes.get("data-map-lon"))
        avito_location_id = _try_int(map_el.attributes.get("data-location-id"))

    # bbox-guard (#1871, расширен до oblast66 — B1 rollout): detail-страница иногда
    # отдаёт координаты вне Свердловской области (Питер/Тюмень/Уфа — кросс-региональные
    # дубли объявлений). Эти координаты попадают в UPDATE COALESCE без bbox-валидации
    # (в отличие от geocoder.py, где bbox-фильтр везде). Сбрасываем в None → листинг
    # уходит в geocode-cron путь, который сам отфильтрует по адресу. SERP-ingest
    # координат не даёт вовсе. Guard расширен с EKB-only (is_within_ekb_bbox_wide) до
    # oblast-wide (is_within_oblast66_bbox), чтобы не резать легитимные Н.Тагил/Каменск/
    # Первоуральск/В.Пышма/Серов координаты (city-sweep за пределами ЕКБ).
    if lat is not None and lon is not None and not is_within_oblast66_bbox(lat, lon):
        logger.warning(
            "avito_detail #1871: dropped non-oblast66 coords item=%s lat=%s lon=%s",
            item_id,
            lat,
            lon,
        )
        lat = lon = None

    address_full = _clean_address(_text(tree, "[itemprop='address']"))

    # ── Description ─────────────────────────────────────────────────────────
    description = _text(tree, "[data-marker='item-view/item-description']")

    # ── Metro from description ───────────────────────────────────────────────
    metro_stations: list[dict[str, Any]] = []
    if description:
        metro_stations = _extract_metro(description)

    # ── House catalog link ───────────────────────────────────────────────────
    # Новостройки отдают кнопку ЖК (nd-jk-details-button); вторичка — ссылку
    # «узнать больше о доме» под другим маркером. Fallback на любой /catalog/houses/
    # якорь: verified 2026-05-31 (#871) — эта ссылка есть в SSR detail-страницы,
    # даже когда отсутствует в JS-рендеренной SERP-карточке (откуда мы брали 0).
    house_catalog_url: str | None = None
    house_link = tree.css_first("[data-marker='nd-jk-details-button']")
    if house_link is None:
        house_link = tree.css_first("a[href*='/catalog/houses/']")
    if house_link is not None:
        href = house_link.attributes.get("href", "")
        if href:
            # отбрасываем ?context-трекинг → чистый catalog-путь
            house_catalog_url = urljoin(AVITO_BASE, urlparse(href).path)

    # ── Photo gallery ────────────────────────────────────────────────────────
    photo_urls: list[str] = []
    for img in tree.css("[data-marker='image-preview/item'] img"):
        src = img.attributes.get("src") or ""
        if src and src not in photo_urls:
            photo_urls.append(src)

    # ── Params blocks ────────────────────────────────────────────────────────
    params_block = tree.css_first("[data-marker='item-view/item-params']")
    apt_params: dict[str, str] = {}
    house_params: dict[str, str] = {}
    if params_block is not None:
        ul_els = params_block.css("ul")
        if len(ul_els) >= 1:
            apt_params = _parse_params_ul(ul_els[0])
        if len(ul_els) >= 2:
            house_params = _parse_params_ul(ul_els[1])

    # Merge house_params into apt_params — некоторые страницы совмещают всё в одном ul
    all_params = {**apt_params, **house_params}

    # ── Parse apartment fields ───────────────────────────────────────────────
    rooms: int | None = None
    area_m2: float | None = None
    kitchen_area_m2: float | None = None
    living_area_m2: float | None = None
    ceiling_height_m: float | None = None
    finishing: str | None = None
    floor: int | None = None
    total_floors: int | None = None
    balcony_loggia: str | None = None
    room_layout: str | None = None
    bathroom_type: str | None = None
    windows_view: str | None = None
    repair_state: str | None = None
    sale_type: str | None = None
    mortgage_available: bool | None = None
    house_type: str | None = None
    total_floors_house: int | None = None
    passenger_elevators: int | None = None
    cargo_elevators: int | None = None
    has_concierge: bool | None = None
    closed_yard: bool | None = None

    for rus_label, val in all_params.items():
        mapping = RUS_FIELD_MAP.get(rus_label)
        if mapping is None:
            continue
        field_name, _conv = mapping

        if field_name == "rooms":
            rooms = _try_int(val)
        elif field_name == "area_m2":
            area_m2 = _parse_float_m2(val)
        elif field_name == "kitchen_area_m2":
            kitchen_area_m2 = _parse_float_m2(val)
        elif field_name == "living_area_m2":
            living_area_m2 = _parse_float_m2(val)
        elif field_name == "ceiling_height_m":
            ceiling_height_m = _parse_height_m(val)
        elif field_name == "finishing":
            finishing = val.strip().lower() or None
        elif field_name == "floor_split":
            floor, total_floors = _parse_floor_split(val)
        elif field_name == "balcony_loggia":
            balcony_loggia = _map_lower(val, _BALCONY_MAP)
        elif field_name == "room_layout":
            room_layout = _map_lower(val, _LAYOUT_MAP)
        elif field_name == "bathroom_type":
            bathroom_type = _map_lower(val, _BATHROOM_MAP)
        elif field_name == "windows_view":
            windows_view = _map_lower(val, _WINDOWS_MAP)
        elif field_name == "repair_state":
            repair_state = _map_lower(val, _REPAIR_MAP)
        elif field_name == "sale_type":
            sale_type = _map_lower(val, _SALE_TYPE_MAP)
        elif field_name == "sale_conditions_raw":
            if "возможна ипотека" in val.lower():
                mortgage_available = True
        elif field_name == "house_type":
            house_type = _map_lower(val, _HOUSE_TYPE_MAP)
        elif field_name == "total_floors_house":
            total_floors_house = _try_int(val)
        elif field_name == "passenger_elevators":
            passenger_elevators = _try_int(val)
        elif field_name == "cargo_elevators":
            cargo_elevators = _try_int(val)
        elif field_name == "in_house_features_raw":
            if "консьерж" in val.lower():
                has_concierge = True
        elif field_name == "yard_features_raw":
            if "закрытая территория" in val.lower():
                closed_yard = True

    # ── Fallback: repair_state из описания, если структурное поле пустое (#622) ──
    if repair_state is None and description:
        repair_state = infer_repair_state_from_text(description)

    # ── Domoteka ─────────────────────────────────────────────────────────────
    owners_count: int | None = None
    owners_at_least: bool | None = None
    last_owner_change_date: date | None = None
    encumbrances_clean: bool | None = None
    registry_match: bool | None = None

    domoteka_block = tree.css_first("[data-marker='domoteka-entry-block']")
    if domoteka_block is not None:
        badges = domoteka_block.css("p[data-marker='TeaserData.item']")
        for badge in badges:
            badge_text = badge.text(strip=True)
            if not badge_text:
                continue

            m_owners = OWNERS_RE.match(badge_text)
            if m_owners:
                owners_count = int(m_owners.group(1))
                owners_at_least = "или больше" in badge_text.lower()
                continue

            m_change = OWNER_CHANGE_RE.search(badge_text)
            if m_change:
                try:
                    day = int(m_change.group(1))
                    month_word = m_change.group(2).lower()
                    year = int(m_change.group(3))
                    month = RUS_MONTHS.get(month_word)
                    if month:
                        last_owner_change_date = date(year, month, day)
                except (ValueError, KeyError):
                    logger.warning("Cannot parse owner change date: %s", badge_text)
                continue

            if ENCUMBRANCES_RE.search(badge_text):
                encumbrances_clean = True
                continue

            if REGISTRY_MATCH_RE.search(badge_text):
                registry_match = True
                continue

    return DetailEnrichment(
        item_id=item_id,
        source_url=source_url,
        title=title,
        price_rub=price_rub,
        publish_date=publish_date,
        views_total=views_total,
        views_today=views_today,
        rooms=rooms,
        area_m2=area_m2,
        kitchen_area_m2=kitchen_area_m2,
        living_area_m2=living_area_m2,
        ceiling_height_m=ceiling_height_m,
        finishing=finishing,
        floor=floor,
        total_floors=total_floors,
        balcony_loggia=balcony_loggia,
        room_layout=room_layout,
        bathroom_type=bathroom_type,
        windows_view=windows_view,
        repair_state=repair_state,
        sale_type=sale_type,
        mortgage_available=mortgage_available,
        house_type=house_type,
        total_floors_house=total_floors_house,
        passenger_elevators=passenger_elevators,
        cargo_elevators=cargo_elevators,
        has_concierge=has_concierge,
        closed_yard=closed_yard,
        house_catalog_url=house_catalog_url,
        address_full=address_full,
        lat=lat,
        lon=lon,
        avito_location_id=avito_location_id,
        metro_stations=metro_stations,
        description=description,
        owners_count=owners_count,
        owners_at_least=owners_at_least,
        last_owner_change_date=last_owner_change_date,
        encumbrances_clean=encumbrances_clean,
        registry_match=registry_match,
        photo_urls=photo_urls,
    )


# ── save_detail_enrichment ────────────────────────────────────────────────────
def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool:
    """UPDATE listings SET <25+ cols> WHERE source='avito' AND source_id=:item_id.

    Returns True если строка обновлена, False если listing не найден.
    Устанавливает detail_enriched_at=NOW().

    house_type СОХРАНЯЕТСЯ (уже нормализован парсером → канон panel/brick/monolith/
    monolith_brick/block/wood) — нужен estimator soft-penalty по типу дома.
    house_catalog_url СОХРАНЯЕТСЯ в listings.house_url (линковка дома); отдельной
    house_catalog_url колонки нет. house_type — COALESCE existing-first (не затираем
    canonical из Houses Catalog Stage 2c); house_url — new-first additive (avito-owned
    поле, нет конкурирующего canonical-писателя).
    Остальные house-level поля (passenger_elevators, cargo_elevators, has_concierge,
    closed_yard, total_floors_house) по-прежнему опускаются — нет колонок в listings,
    canonical приходит из Houses Catalog Stage 2c (отдельный follow-up).
    """
    result = db.execute(
        text("""
            UPDATE listings SET
                kitchen_area_m2 = COALESCE(:kitchen_area_m2, kitchen_area_m2),
                living_area_m2 = COALESCE(:living_area_m2, living_area_m2),
                ceiling_height_m = COALESCE(:ceiling_height_m, ceiling_height_m),
                finishing = COALESCE(:finishing, finishing),
                balcony_loggia = COALESCE(:balcony_loggia, balcony_loggia),
                room_layout = COALESCE(:room_layout, room_layout),
                bathroom_type = COALESCE(:bathroom_type, bathroom_type),
                windows_view = COALESCE(:windows_view, windows_view),
                sale_type = COALESCE(:sale_type, sale_type),
                mortgage_available = COALESCE(:mortgage_available, mortgage_available),
                views_total = COALESCE(:views_total, views_total),
                views_today = COALESCE(:views_today, views_today),
                publish_date = COALESCE(:publish_date, publish_date),
                description = COALESCE(:description, description),
                owners_count = COALESCE(:owners_count, owners_count),
                owners_at_least = COALESCE(:owners_at_least, owners_at_least),
                last_owner_change_date = COALESCE(:last_owner_change_date, last_owner_change_date),
                encumbrances_clean = COALESCE(:encumbrances_clean, encumbrances_clean),
                registry_match = COALESCE(:registry_match, registry_match),
                metro_stations = COALESCE(CAST(:metro_stations AS jsonb), metro_stations),
                avito_location_id = COALESCE(:avito_location_id, avito_location_id),
                lat = COALESCE(:lat, lat),
                lon = COALESCE(:lon, lon),
                address = COALESCE(:address, address),
                rooms = COALESCE(:rooms, rooms),
                area_m2 = COALESCE(:area_m2, area_m2),
                floor = COALESCE(:floor, floor),
                total_floors = COALESCE(:total_floors, total_floors),
                repair_state = COALESCE(:repair_state, repair_state),
                price_rub = COALESCE(:price_rub, price_rub),
                house_type = COALESCE(house_type, :house_type),
                house_url = COALESCE(:house_catalog_url, house_url),
                detail_enriched_at = NOW()
            WHERE source = 'avito' AND source_id = :item_id
        """),
        {
            "item_id": e.item_id,
            "kitchen_area_m2": e.kitchen_area_m2,
            "living_area_m2": e.living_area_m2,
            "ceiling_height_m": e.ceiling_height_m,
            "finishing": e.finishing,
            "balcony_loggia": e.balcony_loggia,
            "room_layout": e.room_layout,
            "bathroom_type": e.bathroom_type,
            "windows_view": e.windows_view,
            "sale_type": e.sale_type,
            "mortgage_available": e.mortgage_available,
            "views_total": e.views_total,
            "views_today": e.views_today,
            "publish_date": e.publish_date,
            "description": e.description,
            "owners_count": e.owners_count,
            "owners_at_least": e.owners_at_least,
            "last_owner_change_date": e.last_owner_change_date,
            "encumbrances_clean": e.encumbrances_clean,
            "registry_match": e.registry_match,
            "metro_stations": (
                json.dumps(e.metro_stations, ensure_ascii=False) if e.metro_stations else None
            ),
            "avito_location_id": e.avito_location_id,
            "lat": e.lat,
            "lon": e.lon,
            "address": e.address_full,
            "rooms": e.rooms,
            "area_m2": e.area_m2,
            "floor": e.floor,
            "total_floors": e.total_floors,
            "repair_state": e.repair_state,
            "price_rub": e.price_rub,
            "house_type": e.house_type,
            "house_catalog_url": e.house_catalog_url,
        },
    )
    db.commit()
    found = result.rowcount > 0
    if not found:
        logger.warning("save_detail_enrichment: listing not found in DB for item_id=%s", e.item_id)
    else:
        logger.info("save_detail_enrichment: updated listing item_id=%s", e.item_id)
    return found


# ── Internal helpers ─────────────────────────────────────────────────────────
def _text(tree: HTMLParser, selector: str) -> str | None:
    """Вернуть stripped text первого совпавшего элемента или None."""
    el = tree.css_first(selector)
    if el is None:
        return None
    t = el.text(strip=True)
    return t or None


def _try_float(val: Any) -> float | None:
    if val is None:
        return None
    try:
        return float(str(val).replace(",", "."))
    except (ValueError, TypeError):
        return None


def _try_int(val: Any) -> int | None:
    if val is None:
        return None
    s = str(val).strip()
    m = _INT_RE.search(s)
    if m:
        try:
            return int(m.group())
        except ValueError:
            return None
    return None


def _parse_float_m2(val: str) -> float | None:
    """'90.1 м²' → 90.1. '75 м²' → 75.0."""
    m = _FLOAT_RE.search(val)
    if m:
        try:
            return float(m.group().replace(",", "."))
        except ValueError:
            return None
    return None


def _parse_height_m(val: str) -> float | None:
    """'2.7 м' / '2,7\xa0м' / '3 м' → float метры. Junk → None."""
    m = _FLOAT_RE.search(val)
    if m:
        try:
            return float(m.group().replace(",", "."))
        except ValueError:
            return None
    return None


def _parse_floor_split(val: str) -> tuple[int | None, int | None]:
    """'20 из 26' → (20, 26)."""
    m = _FLOOR_SPLIT_RE.search(val)
    if m:
        return _try_int(m.group(1)), _try_int(m.group(2))
    return None, None


def _map_lower(val: str, mapping: dict[str, str]) -> str | None:
    """Нижний регистр + strip → lookup в mapping. None если не найдено."""
    key = val.strip().lower()
    return mapping.get(key)


def _extract_item_id(tree: HTMLParser) -> str | None:
    """Извлечь item_id из [data-marker='item-view/item-id'] text."""
    el = tree.css_first("[data-marker='item-view/item-id']")
    if el is None:
        return None
    text_val = el.text(strip=True)
    m = _ITEM_ID_RE.search(text_val)
    return m.group(1) if m else None


def _extract_price(tree: HTMLParser) -> int | None:
    """Извлечь price из [itemprop='price'] content attribute."""
    el = tree.css_first("[itemprop='price']")
    if el is None:
        return None
    content = el.attributes.get("content")
    return _try_int(content)


def _extract_meta(tree: HTMLParser) -> tuple[date | None, int | None, int | None]:
    """Извлечь publish_date, views_total, views_today из item-id блока."""
    el = tree.css_first("[data-marker='item-view/item-id']")
    if el is None:
        return None, None, None

    # Поиск по всему тексту включая дочерние элементы
    full_text = el.text(strip=False)

    publish_date: date | None = None
    m_date = _PUBLISH_DATE_RE.search(full_text)
    if m_date:
        try:
            day = int(m_date.group(1))
            month_word = m_date.group(2).lower()
            month = RUS_MONTHS.get(month_word)
            if month:
                # Avito не показывает год для свежих объявлений — берём текущий.
                # audit-scrapers finding 3: если объявление опубликовано в декабре,
                # а страница парсится в январе СЛЕДУЮЩЕГО года, "текущий год" даёт
                # дату в будущем (завышает свежесть лота). Если получившаяся дата
                # оказалась в будущем относительно момента парсинга — откатываем
                # на год назад (это дата из прошлого года).
                today = date.today()
                candidate = date(today.year, month, day)
                if candidate > today:
                    candidate = date(today.year - 1, month, day)
                publish_date = candidate
        except (ValueError, KeyError):
            pass

    views_total: int | None = None
    m_total = _VIEWS_TOTAL_RE.search(full_text)
    if m_total:
        views_total = int(m_total.group(1))

    views_today: int | None = None
    m_today = _VIEWS_TODAY_RE.search(full_text)
    if m_today:
        views_today = int(m_today.group(1))

    # Попробуем найти views в отдельных data-marker элементах (новый Avito layout)
    if views_total is None:
        v_el = tree.css_first("[data-marker='item-view/total-views']")
        if v_el is not None:
            m = _VIEWS_TOTAL_RE.search(v_el.text(strip=True))
            if m:
                views_total = int(m.group(1))

    if views_today is None:
        t_el = tree.css_first("[data-marker='item-view/today-views']")
        if t_el is not None:
            m = _VIEWS_TODAY_RE.search(t_el.text(strip=True))
            if m:
                views_today = int(m.group(1))

    return publish_date, views_total, views_today


def _parse_params_ul(ul: Node) -> dict[str, str]:
    """Парсим <ul> с <li>Label: Value</li> → {label: value} dict."""
    result: dict[str, str] = {}
    for li in ul.css("li"):
        text_val = li.text(strip=True)
        if ":" in text_val:
            label, _, value = text_val.partition(":")
            label = label.strip()
            value = value.strip()
            if label and value:
                result[label] = value
    return result


def _extract_metro(text_val: str) -> list[dict[str, Any]]:
    """Извлечь станции метро из текста описания.

    Примеры: 'Чкаловская 11-15 мин пешком', 'от 5 мин пешком'.
    """
    stations: list[dict[str, Any]] = []
    for m in METRO_RE.finditer(text_val):
        name = m.group(1)
        if m.group(2) and m.group(3):
            # диапазон: 11-15
            min_from = int(m.group(2))
            min_to = int(m.group(3))
        elif m.group(4):
            # "от N мин"
            min_from = None
            min_to = int(m.group(4))
        else:
            continue

        stations.append(
            {
                "name": name,
                "min_to": min_to,
                "min_from": min_from,
                "mode": "walk",
            }
        )
    return stations
