"""Cian.ru detail page scraper — single offer enrichment.

Different from SERP (cian.py):
- URL: https://ekb.cian.ru/sale/flat/<offer_id>/ or https://www.cian.ru/sale/flat/<offer_id>/
- MFE: 'frontend-offer-card'
- State KEY: 'defaultState' (NOT 'initialState' — SERP uses initialState)
- Sister containers in same _cianConfig: bti, priceChanges, stats, agent, newObject

Parses ~88 offer fields + sister data → DetailEnrichment dataclass.
Stage 5 of CianScraper v1.
"""

from __future__ import annotations

import json
import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

from sqlalchemy import text
from sqlalchemy.orm import Session

from scraper_kit.cian_state_parser import extract_all_states, extract_state
from scraper_kit.offer_price_history import clamp_diff_percent
from scraper_kit.providers._base import build_curl_cffi_session
from scraper_kit.providers._proxy import curl_proxy_url
from scraper_kit.repair_state_normalizer import (
    infer_repair_state_from_text,
    normalize_repair_state,
)
from scraper_kit.snapshot_writer import upsert_listing_snapshot

if TYPE_CHECKING:
    from curl_cffi.requests import AsyncSession

    from scraper_kit.browser_fetcher import BrowserFetcher
    from scraper_kit.contracts import HouseMatcher, ProxyProvider, ScraperConfig

logger = logging.getLogger(__name__)


@dataclass
class DetailEnrichment:
    """Result of cian_detail.fetch_detail() — fields для UPDATE listings + sister rows."""

    # Core listing enrichment (extend existing listings row)
    cian_id: int | None = None
    windows_view_type: str | None = None  # 'yard' / 'street' / 'yardAndStreet'
    separate_wcs_count: int | None = None
    combined_wcs_count: int | None = None
    ceiling_height: float | None = None  # meters — offer.building.ceilingHeight (#1791)
    repair_type: str | None = None  # raw Cian: offer.repairType OR offer.decoration (#1791)
    repair_state: str | None = None  # enum: needs_repair/standard/good/excellent
    kitchen_area_m2: float | None = None  # offer.kitchenArea (м²)
    description: str | None = None  # offer.description (текст объявления)
    views_total: int | None = None  # Cian stats.totalViewsFormattedString → int
    views_today: int | None = None

    # BTI (вторичка only — primary buildings don't have БТИ)
    bti_data: dict[str, Any] | None = None  # raw bti.houseData snapshot

    # Price history (offer.priceChanges)
    price_changes: list[dict[str, Any]] = field(default_factory=list)

    # Agent profile (offer.agent.* expanded)
    agent_profile: dict[str, Any] | None = None

    # Newbuilding link (if offer is from a newbuilding)
    newbuilding_data: dict[str, Any] | None = None

    # Raw payload для debugging / future-extraction
    raw_offer: dict[str, Any] | None = None
    raw_sister_states: dict[str, Any] = field(default_factory=dict)


async def fetch_detail(
    offer_url: str,
    *,
    config: ScraperConfig | None = None,
    session: AsyncSession | None = None,
    browser_fetcher: BrowserFetcher | None = None,
    proxy_provider: ProxyProvider | None = None,
) -> DetailEnrichment | None:
    """Fetch detail page and extract all containers.

    Args:
        offer_url: e.g. 'https://ekb.cian.ru/sale/flat/327830237/'
        config: инжектируемый ScraperConfig (#2133) — читается только на curl_cffi-пути
            для прокси-egress (config.cian_proxy_url). None ⇒ прямое подключение
            (dev / browser_fetcher-путь, где прокси уже применён server-side).
        session: optional shared curl_cffi session (для batched scrapes).
            Ignored when browser_fetcher is provided.
        browser_fetcher: optional BrowserFetcher instance (camoufox HTTP service).
            When provided, fetches JS-rendered HTML via the browser service instead of
            curl_cffi — enables priceChanges extraction which requires JS rendering.
            Caller is responsible for the context-manager lifecycle of the fetcher.

    Returns: DetailEnrichment, or None если fetch / parse failed.
    """
    if browser_fetcher is not None:
        # Browser path: get fully JS-rendered HTML; same parse path follows.
        try:
            html = await browser_fetcher.fetch(offer_url)
        except Exception as exc:
            logger.warning("Cian detail browser fetch failed %s: %s", offer_url, exc)
            return None
    elif session is not None:
        # Shared curl_cffi-сессия (прокси уже применён caller'ом) — пул не трогаем.
        resp = await session.get(offer_url, allow_redirects=True)
        if resp.status_code != 200:
            logger.warning("Cian detail fetch %s → HTTP %d", offer_url, resp.status_code)
            return None
        html = resp.text
    else:
        # curl_cffi own-session path (legacy, back-compat).
        # proxies: mobile-proxy egress (#806) — без прокси datacenter-IP блокируется Cian.
        # Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env cian_proxy_url.
        # Пусто → прямое подключение (dev/no-op). curl_proxy_url: mark_health + release на выходе.
        _env = config.cian_proxy_url if config is not None else None
        with curl_proxy_url(config, proxy_provider, "cian", env_fallback_url=_env) as _proxy_url:
            own_session = build_curl_cffi_session(
                proxy_url=_proxy_url,
                timeout=25.0,
                headers={
                    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
                    "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
                },
            )
            try:
                resp = await own_session.get(offer_url, allow_redirects=True)
                if resp.status_code != 200:
                    logger.warning("Cian detail fetch %s → HTTP %d", offer_url, resp.status_code)
                    return None
                html = resp.text
            finally:
                await own_session.close()

    # ── Shared parse path (identical regardless of HTML source) ──────────────

    # Primary state container — defaultState in frontend-offer-card MFE
    # NOTE: detail pages use 'defaultState', SERP uses 'initialState'
    offer_state = extract_state(html, mfe="frontend-offer-card", key="defaultState")
    if offer_state is None:
        logger.warning("Cian detail %s: defaultState extraction failed", offer_url)
        return None

    offer_data = offer_state.get("offerData", {})
    offer = offer_data.get("offer", {})

    result = DetailEnrichment(
        cian_id=offer.get("cianId") or offer.get("id"),
        windows_view_type=_extract_windows_view(offer),
        separate_wcs_count=offer.get("separateWcsCount"),
        combined_wcs_count=offer.get("combinedWcsCount"),
        ceiling_height=_extract_ceiling_height(offer),
        repair_type=_extract_repair_type(offer),
        repair_state=_extract_repair_state(offer),
        kitchen_area_m2=_parse_float(offer.get("kitchenArea")),
        description=offer.get("description") or None,
        raw_offer=offer,
    )

    # Stats (views) — from stats key in offerData or top-level state
    stats = offer_data.get("stats") or offer_state.get("stats") or {}
    result.views_total = _parse_views(stats.get("totalViewsFormattedString"))
    result.views_today = _parse_views(stats.get("todayViewsFormattedString"))

    # Price changes — browser HTML: offerData.priceChanges (priceData.price format)
    # curl_cffi HTML: offer.priceChanges or state.priceChanges (flat price field)
    result.price_changes = _extract_price_changes(offer, offer_data, offer_state)

    # Agent profile expansion
    result.agent_profile = _extract_agent_profile(offer)

    # Sister state containers: bti, newObject, plus raw stash of everything else
    all_states = extract_all_states(html)
    offer_card_states = all_states.get("frontend-offer-card", {})

    bti_state = offer_card_states.get("bti")
    if bti_state:
        result.bti_data = bti_state.get("houseData")
        result.raw_sister_states["bti"] = bti_state

    # Newbuilding link (if applicable)
    newbuilding = offer.get("newbuilding")
    if newbuilding and newbuilding.get("id"):
        result.newbuilding_data = newbuilding

    # Stash all non-defaultState sister containers for debugging / future-extraction
    for k, v in offer_card_states.items():
        if k != "defaultState":
            result.raw_sister_states[k] = v

    return result


# ── Field extractors ──────────────────────────────────────────────────────────


def _extract_windows_view(offer: dict[str, Any]) -> str | None:
    return offer.get("windowsViewType")


def _extract_ceiling_height(offer: dict[str, Any]) -> float | None:
    """Высота потолков (м).

    На detail-странице (defaultState) значение лежит в ``offer.building.ceilingHeight``,
    а НЕ в ``offer.ceilingHeight`` (последнее всегда null) — #1791. Top-level оставлен
    как fallback на случай иной структуры карточки.
    """
    building = offer.get("building") or {}
    return _parse_float(building.get("ceilingHeight") or offer.get("ceilingHeight"))


def _extract_repair_type(offer: dict[str, Any]) -> str | None:
    # Cian: 'cosmetic' / 'design' / 'no' / 'euro' / ...
    # На detail-странице структурное поле чаще приходит как `decoration`
    # ('preFine'/'fine'/...), а `repairType` пустое (#1791) — читаем оба.
    return offer.get("repairType") or offer.get("decoration")


def _extract_repair_state(offer: dict[str, Any]) -> str | None:
    """Нормализованный enum из repairType/decoration → needs_repair/standard/good/excellent.

    Если структурное поле пустое — fallback на инференс из описания (#622).
    Структурное значение всегда в приоритете.
    """
    state = normalize_repair_state(offer.get("repairType") or offer.get("decoration"))
    if state is None:
        state = infer_repair_state_from_text(offer.get("description"))
    return state


def _parse_float(value: Any) -> float | None:
    if value is None:
        return None
    try:
        return float(value)
    except (ValueError, TypeError):
        return None


def _parse_views(formatted_str: str | None) -> int | None:
    """Cian's stats.totalViewsFormattedString — '1 234' → 1234."""
    if not formatted_str:
        return None
    try:
        return int(str(formatted_str).replace(" ", "").replace(",", ""))
    except (ValueError, TypeError):
        return None


def _extract_price_changes(
    offer: dict[str, Any],
    offer_data: dict[str, Any],
    state: dict[str, Any],
) -> list[dict[str, Any]]:
    """Normalize priceChanges from any of the known Cian locations/formats.

    Lookup priority:
      1. offer.priceChanges — curl_cffi flat format: {changeTime, price, diffPercent}
      2. offerData.priceChanges — browser-rendered format: {changeTime, priceData:{price}}
      3. state.priceChanges — top-level fallback (rare)

    Normalizes to: {change_time, price_rub, diff_percent}.
    """
    raw = (
        offer.get("priceChanges")
        or offer_data.get("priceChanges")
        or state.get("priceChanges")
        or []
    )
    if not isinstance(raw, list):
        return []
    result = []
    for entry in raw:
        if not isinstance(entry, dict):
            continue
        # price_rub: flat field (curl_cffi) OR nested priceData.price (browser)
        price_data = entry.get("priceData") or {}
        price_rub = entry.get("price") or entry.get("priceRub") or price_data.get("price")
        result.append(
            {
                "change_time": entry.get("changeTime") or entry.get("date"),
                "price_rub": price_rub,
                "diff_percent": entry.get("diffPercent") or entry.get("diff_percent"),
            }
        )
    return result


def _extract_agent_profile(offer: dict[str, Any]) -> dict[str, Any] | None:
    """offer.agent or offer.agency or offer.user — extract canonical profile shape."""
    agent = offer.get("agent") or {}
    user = offer.get("user") or {}
    if not agent and not user:
        return None
    return {
        "ext_agent_id": agent.get("id") or user.get("cianUserId"),
        "company_name": agent.get("companyName") or user.get("companyName"),
        "is_pro": agent.get("isPro") or user.get("isPro"),
        "skills": agent.get("skills") or [],
        "account_type": agent.get("accountType") or user.get("accountType"),
    }


# ── Save helpers ──────────────────────────────────────────────────────────────


def save_detail_enrichment(
    db: Session,
    listing_id: int,
    enrichment: DetailEnrichment,
    *,
    matcher: HouseMatcher | None = None,
) -> None:
    """Persist DetailEnrichment to DB:

    - UPDATE listings SET windows_view_type, ceiling_height, ... WHERE id = listing_id
    - INSERT INTO offer_price_history rows (if price_changes non-empty)
    - INSERT INTO agents (if agent_profile) and link via agent_id_fk
    - bti_data → канонический houses-ряд (#2435), best-effort, только если `matcher`
      передан вызывающим (инжектируемый `HouseMatcher`, тот же паттерн, что
      avito/houses.py::_persist_house). `matcher=None` (default) — backward-compat
      no-op для существующих вызовов, которые ещё не прокинули matcher.

    Idempotency: re-running безопасно (UPSERT / ON CONFLICT DO NOTHING semantics).
    """
    # Extend listings row with detail-enriched fields
    db.execute(
        text("""
            UPDATE listings SET
                windows_view_type  = COALESCE(:wvt, windows_view_type),
                separate_wcs_count = COALESCE(:swc, separate_wcs_count),
                combined_wcs_count = COALESCE(:cwc, combined_wcs_count),
                ceiling_height     = COALESCE(:ch,  ceiling_height),
                repair_type        = COALESCE(:rt,  repair_type),
                repair_state       = COALESCE(:rs,  repair_state),
                kitchen_area_m2    = COALESCE(CAST(:ka AS double precision), kitchen_area_m2),
                description        = COALESCE(:descr, description),
                views_total        = COALESCE(:vt,  views_total),
                views_today        = COALESCE(:vd,  views_today),
                detail_enriched_at = NOW()
            WHERE id = CAST(:lid AS bigint)
        """),
        {
            "lid": listing_id,
            "wvt": enrichment.windows_view_type,
            "swc": enrichment.separate_wcs_count,
            "cwc": enrichment.combined_wcs_count,
            "ch": enrichment.ceiling_height,
            "rt": enrichment.repair_type,
            "rs": enrichment.repair_state,
            "ka": enrichment.kitchen_area_m2,
            "descr": enrichment.description,
            "vt": enrichment.views_total,
            "vd": enrichment.views_today,
        },
    )

    # Snapshot: point-in-time observation в listings_snapshots.
    # Берём price_rub / price_per_m2 из listings строки (только что обновлённой).
    # Используем COALESCE — на случай если listings.price_rub NULL (не должно быть, но safe).
    _snap_row = db.execute(
        text("""
            SELECT price_rub, price_per_m2
            FROM listings
            WHERE id = CAST(:lid AS bigint)
        """),
        {"lid": listing_id},
    ).fetchone()
    _snap_written = False
    if _snap_row is not None and _snap_row.price_rub is not None:
        try:
            with db.begin_nested():
                upsert_listing_snapshot(
                    db,
                    listing_id=listing_id,
                    price_rub=int(_snap_row.price_rub),
                    price_per_m2=int(_snap_row.price_per_m2) if _snap_row.price_per_m2 else None,
                    run_id=None,
                    status="active",
                )
            _snap_written = True
        except Exception as exc:
            logger.warning(
                "save_detail_enrichment: snapshot write failed listing_id=%s: %s",
                listing_id,
                exc,
            )

    # Price changes — INSERT new rows, de-dup by listing_id + change_time
    for change in enrichment.price_changes:
        if not change.get("change_time") or not change.get("price_rub"):
            continue
        db.execute(
            text("""
                INSERT INTO offer_price_history
                    (listing_id, change_time, price_rub, diff_percent, source)
                VALUES (
                    CAST(:lid AS bigint),
                    CAST(:ct AS timestamptz),
                    CAST(:price AS numeric),
                    CAST(:diff AS numeric),
                    'cian'
                )
                ON CONFLICT ON CONSTRAINT offer_price_history_listing_change_uq DO NOTHING
            """),
            {
                "lid": listing_id,
                "ct": change["change_time"],
                "price": change["price_rub"],
                "diff": clamp_diff_percent(change.get("diff_percent")),
            },
        )

    # Agent profile UPSERT — store agent row and link to listing
    if enrichment.agent_profile and enrichment.agent_profile.get("ext_agent_id"):
        ap = enrichment.agent_profile
        row = db.execute(
            text("""
                INSERT INTO agents
                    (ext_source, ext_agent_id, company_name, is_pro, skills, account_type)
                VALUES (
                    'cian',
                    :eai,
                    :cn,
                    :ip,
                    CAST(:sk AS jsonb),
                    :at
                )
                ON CONFLICT (ext_source, ext_agent_id) DO UPDATE SET
                    company_name = COALESCE(EXCLUDED.company_name, agents.company_name),
                    is_pro       = COALESCE(EXCLUDED.is_pro,       agents.is_pro),
                    skills       = COALESCE(EXCLUDED.skills,       agents.skills),
                    account_type = COALESCE(EXCLUDED.account_type, agents.account_type)
                RETURNING id
            """),
            {
                "eai": str(ap["ext_agent_id"]),
                "cn": ap.get("company_name"),
                "ip": ap.get("is_pro"),
                "sk": json.dumps(ap.get("skills") or []),
                "at": ap.get("account_type"),
            },
        )
        agent_db_id = row.scalar_one_or_none()
        if agent_db_id is not None:
            db.execute(
                text("""
                    UPDATE listings
                    SET agent_id_fk = CAST(:aid AS bigint)
                    WHERE id = CAST(:lid AS bigint)
                      AND agent_id_fk IS NULL
                """),
                {"aid": agent_db_id, "lid": listing_id},
            )

    # BTI house-persist (#2435): bti_data распарсен (fetch_detail), но раньше выбрасывался —
    # ранее здесь стоял комментарий "это задача Stage 6 (houses)". matcher резолвит
    # канонический дом по address/geo листинга (см. _persist_cian_bti_house) — best-effort,
    # SAVEPOINT изолирует сбой от уже выполненных выше UPDATE/INSERT (тот же паттерн, что
    # estimator.py::_backfill_house_fias).
    if enrichment.bti_data and matcher is not None:
        try:
            with db.begin_nested():
                _persist_cian_bti_house(db, listing_id, enrichment.bti_data, matcher=matcher)
        except Exception as exc:
            logger.warning(
                "Cian BTI house-persist failed for listing_id=%s (graceful): %s",
                listing_id,
                exc,
                exc_info=True,
            )

    db.commit()
    logger.info(
        "Cian detail enrichment saved for listing_id=%s (price_changes=%d, agent=%s, snapshot=%s)",
        listing_id,
        len(enrichment.price_changes),
        bool(enrichment.agent_profile),
        _snap_written,
    )


# ── BTI house-persist (#2435) ─────────────────────────────────────────────────


def _persist_cian_bti_house(
    db: Session,
    listing_id: int,
    bti_data: dict[str, Any],
    *,
    matcher: HouseMatcher,
) -> int | None:
    """Резолвит канонический дом и пишет BTI-поля дома (#2435).

    `bti_data` — raw ``offerData.bti.houseData`` снапшот (см. `DetailEnrichment.bti_data`),
    shape (Schema_Cian_SERP_Inventory sec 20.2):
        {yearRelease, houseMaterialType, floorMax, entrances, flatCount, isEmergency,
         demolishedInMoscowProgramm, heatIndex, houseHeatSupplyType, houseOverhaulFundType,
         houseGasSupplyType, houseOverlapType, lifts, seriesName}

    Персистятся только BTI-эксклюзивные колонки из `020_houses_alter_cian.sql`
    (cian_bti — единственный источник для них, ни один другой скраппер их не пишет):
        seriesName          → series_name
        entrances            → entrances
        flatCount            → flat_count
        isEmergency          → is_emergency
        houseHeatSupplyType  → heat_supply_type
        houseGasSupplyType   → gas_supply_type
        houseOverlapType     → overlap_type
    COALESCE(new, existing) — свежий BTI-снапшот побеждает, но не затирает существующее
    значение NULL'ом (напр. если повторный fetch вернул неполный houseData).

    NOT persisted (gap — нет чистого маппинга на существующую колонку, см. summary):
        yearRelease/houseMaterialType/floorMax (генерик-колонки year_built/house_type/
        total_floors существуют, но вне explicit scope БTI-колонок #2435 — future work),
        demolishedInMoscowProgramm, heatIndex, houseOverhaulFundType, lifts (нет колонки
        под недифференцированное «лифтов всего» — passenger/cargo split недоступен в BTI).

    address/lat/lon листинга — bti_data адреса не несёт, читаем из `listings` по listing_id.

    Returns:
        houses.id, либо None если у листинга нет адреса, либо matcher отказал
        (безномерный адрес без кадастра — метод 'no_house_number', #1772/P1).
    """
    row = (
        db.execute(
            text("SELECT address, lat, lon FROM listings WHERE id = CAST(:lid AS bigint)"),
            {"lid": listing_id},
        )
        .mappings()
        .first()
    )
    if row is None or not row["address"]:
        logger.info(
            "_persist_cian_bti_house: listing_id=%s missing address — skip house resolve",
            listing_id,
        )
        return None

    house_id, _conf, method = matcher.match_or_create_house(
        db,
        ext_source="cian_bti",
        ext_id=str(listing_id),
        address=row["address"],
        lat=row["lat"],
        lon=row["lon"],
    )
    if house_id is None:
        logger.info(
            "_persist_cian_bti_house: skip listing_id=%s addr=%r (method=%s)",
            listing_id,
            row["address"],
            method,
        )
        return None

    db.execute(
        text("""
            UPDATE houses SET
                series_name      = COALESCE(CAST(:series_name AS text), series_name),
                entrances        = COALESCE(CAST(:entrances AS int), entrances),
                flat_count       = COALESCE(CAST(:flat_count AS int), flat_count),
                is_emergency     = COALESCE(CAST(:is_emergency AS boolean), is_emergency),
                heat_supply_type = COALESCE(CAST(:heat_supply_type AS text), heat_supply_type),
                gas_supply_type  = COALESCE(CAST(:gas_supply_type AS text), gas_supply_type),
                overlap_type     = COALESCE(CAST(:overlap_type AS text), overlap_type)
            WHERE id = CAST(:house_id AS bigint)
        """),
        {
            "house_id": house_id,
            "series_name": bti_data.get("seriesName"),
            "entrances": bti_data.get("entrances"),
            "flat_count": bti_data.get("flatCount"),
            "is_emergency": bti_data.get("isEmergency"),
            "heat_supply_type": bti_data.get("houseHeatSupplyType"),
            "gas_supply_type": bti_data.get("houseGasSupplyType"),
            "overlap_type": bti_data.get("houseOverlapType"),
        },
    )

    logger.info(
        "_persist_cian_bti_house: listing_id=%s → house_id=%s (method=%s)",
        listing_id,
        house_id,
        method,
    )
    return int(house_id)
