"""Backfill obj_class_fallback для domrf_kn_objects (issue #38, Demand D6).

kn-API не отдаёт obj_class для ~3032 ЖК Свердл
(Bug_Kn_API_Obj_Class_Always_Null) → per-class velocity/absorption/weight
ломаются. Workaround: производим класс из yandex_realty.

Двухступенчатый fallback (записывается только когда реальный obj_class = NULL):
  1. yandex_match    — trigram-match LOWER(comm_name) ↔ LOWER(yandex_realty_zk.name)
                       через similarity(); auto-accept >= AUTO_ACCEPT_THRESHOLD (0.6).
  2. price_inference — для оставшихся NULL с известным price_per_m2: класс по
                       вхождению цены в диапазон yandex_realty_class_prices.

Результат: obj_class_fallback + obj_class_source ('yandex_match'|'price_inference').

Schema facts (см. 169_obj_class_fallback.sql + 43_anton_import.sql):
- domrf_kn_objects: obj_class, obj_class_fallback, obj_class_source,
  comm_name, price_per_m2_min, price_per_m2_max, region_cd (66 = Свердл).
- yandex_realty_zk: name, obj_class (uppercase EN: 'COMFORT'/'BUSINESS'/…),
  price_from. ~12 строк сейчас, scrape расширяет до >500 (отдельный day).
- yandex_realty_class_prices: obj_class (PK), price_per_m2_min, price_per_m2_max.

Idempotent: пишет только WHERE obj_class IS NULL AND obj_class_fallback IS NULL
(yandex_match) либо AND obj_class_source IS NULL (price_inference). Batched,
SAVEPOINT per row (begin_nested) — падение одной строки не рушит outer tx.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass

from sqlalchemy import text
from sqlalchemy.orm import Session

logger = logging.getLogger(__name__)

# Порог auto-accept для trigram-match (issue #38 указывает similarity > 0.6).
AUTO_ACCEPT_THRESHOLD = 0.6

# Регион Свердловской обл. (ЕКБ) в domrf_kn_objects.region_cd.
SVERDL_REGION_CD = 66


@dataclass
class ClassMatchCandidate:
    """Один candidate производного класса DOM.РФ ↔ yandex_realty_zk."""

    domrf_obj_id: int
    domrf_comm_name: str
    yandex_name: str
    yandex_obj_class: str
    similarity_score: float  # 0.0..1.0


def find_class_candidates(
    db: Session,
    *,
    region_cd: int = SVERDL_REGION_CD,
    min_threshold: float = AUTO_ACCEPT_THRESHOLD,
    limit: int | None = None,
) -> list[ClassMatchCandidate]:
    """Поиск candidates класса через pg_trgm similarity.

    CROSS JOIN LATERAL + similarity() для fuzzy match comm_name (DOM.РФ) ↔
    name (yandex_realty_zk). Для каждого ЖК без класса берётся ЛУЧШИЙ
    (max similarity) yandex-кандидат с непустым obj_class.

    Берётся latest snapshot на obj_id (DISTINCT ON), чтобы не плодить дубли
    по версионированной domrf_kn_objects.

    Args:
        db: SQLAlchemy sync Session.
        region_cd: Регион-фильтр (по умолчанию 66 — Свердл).
        min_threshold: Нижняя граница similarity для кандидата (0.6 по issue).
        limit: Максимум строк результата (для тестирования / батчинга).

    Returns:
        Список ClassMatchCandidate, отсортированных по убыванию similarity.
    """
    # LIMIT добавляем через int() — SQL injection safe (только число).
    limit_clause = f"LIMIT {int(limit)}" if limit is not None else ""

    sql = text(
        f"""
        WITH domrf_unclassed AS (
            SELECT DISTINCT ON (o.obj_id)
                   o.obj_id, o.comm_name
              FROM domrf_kn_objects o
             WHERE o.region_cd = CAST(:region_cd AS integer)
               AND o.obj_class IS NULL
               AND o.obj_class_fallback IS NULL
               AND o.comm_name IS NOT NULL
               AND btrim(o.comm_name) <> ''
             ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
        )
        SELECT
            d.obj_id,
            d.comm_name,
            y.name        AS yandex_name,
            y.obj_class   AS yandex_obj_class,
            similarity(LOWER(d.comm_name), LOWER(y.name)) AS sim_score
        FROM domrf_unclassed d
        CROSS JOIN LATERAL (
            SELECT yz.name, yz.obj_class
              FROM yandex_realty_zk yz
             WHERE yz.obj_class IS NOT NULL
               AND yz.name IS NOT NULL
               AND similarity(LOWER(d.comm_name), LOWER(yz.name))
                       >= CAST(:min_threshold AS float)
             ORDER BY similarity(LOWER(d.comm_name), LOWER(yz.name)) DESC
             LIMIT 1
        ) y
        ORDER BY sim_score DESC
        {limit_clause}
        """
    )

    rows = db.execute(
        sql,
        {"region_cd": region_cd, "min_threshold": min_threshold},
    ).all()

    return [
        ClassMatchCandidate(
            domrf_obj_id=int(r[0]),
            domrf_comm_name=str(r[1]),
            yandex_name=str(r[2]),
            yandex_obj_class=str(r[3]),
            similarity_score=float(r[4]),
        )
        for r in rows
    ]


def apply_class_matches(
    db: Session,
    candidates: list[ClassMatchCandidate],
    *,
    threshold: float = AUTO_ACCEPT_THRESHOLD,
    dry_run: bool = False,
) -> dict[str, int]:
    """Записать obj_class_fallback + obj_class_source='yandex_match'.

    Пишет ТОЛЬКО строки latest-snapshot, у которых obj_class IS NULL и
    obj_class_fallback ещё не проставлен (idempotent — повторный прогон не
    перезаписывает уже найденный класс). SAVEPOINT per row.

    Args:
        db: SQLAlchemy sync Session.
        candidates: Список из find_class_candidates().
        threshold: Минимальный score для записи (default 0.6).
        dry_run: Если True — только логирует, не пишет в БД.

    Returns:
        dict с ключами updated, skipped.
    """
    accepted = [c for c in candidates if c.similarity_score >= threshold]

    if dry_run:
        logger.info("DRY RUN: would set obj_class_fallback (yandex_match) для %d", len(accepted))
        return {"updated": len(accepted), "skipped": 0}

    updated = 0
    skipped = 0
    for c in accepted:
        try:
            with db.begin_nested():
                result = db.execute(
                    text(
                        """
                        UPDATE domrf_kn_objects
                           SET obj_class_fallback = CAST(:cls AS text),
                               obj_class_source   = 'yandex_match'
                         WHERE obj_id = CAST(:obj_id AS bigint)
                           AND obj_class IS NULL
                           AND obj_class_fallback IS NULL
                        """
                    ),
                    {"cls": c.yandex_obj_class, "obj_id": c.domrf_obj_id},
                )
            if result.rowcount > 0:
                updated += 1
            else:
                skipped += 1
        except Exception as e:
            # Не глотаем молча: логируем строку и продолжаем (SAVEPOINT откатил
            # только её, outer tx чист). Re-raise не нужен — backfill best-effort.
            logger.warning(
                "obj_class yandex_match failed для obj_id=%s (%s ↔ %s): %s",
                c.domrf_obj_id,
                c.domrf_comm_name,
                c.yandex_name,
                e,
            )
            skipped += 1

    db.commit()
    logger.info("obj_class yandex_match backfill: updated=%d skipped=%d", updated, skipped)
    return {"updated": updated, "skipped": skipped}


def apply_price_inference(
    db: Session,
    *,
    region_cd: int = SVERDL_REGION_CD,
    dry_run: bool = False,
) -> dict[str, int]:
    """Price-inference fallback: класс по price_per_m2 vs class_prices.

    Для ЖК, оставшихся без obj_class И без obj_class_fallback после yandex_match,
    но с известной price_per_m2: класс = тот yandex_realty_class_prices, в чей
    диапазон [price_per_m2_min, price_per_m2_max] попадает средняя цена ЖК.

    Средняя цена ЖК — midpoint доступных price_per_m2_min/max (COALESCE, чтобы
    учесть строки, где задана только одна граница). При пересечении нескольких
    классовых диапазонов берётся диапазон с наименьшей серединой (детерминизм).

    Только set-based UPDATE одним запросом — нет per-row цикла (диапазоны
    непротиворечивы, конфликтов нет; idempotency через WHERE obj_class_source
    IS NULL). Возвращает счётчик обновлённых строк.

    Args:
        db: SQLAlchemy sync Session.
        region_cd: Регион-фильтр (66 — Свердл).
        dry_run: Если True — считает кандидатов SELECT'ом, не пишет.

    Returns:
        dict с ключом updated.
    """
    # Подзапрос: для каждого obj_id (latest snapshot) без класса и с ценой —
    # лучший классовый диапазон по price_per_m2.
    select_candidates = text(
        """
        WITH priced AS (
            SELECT DISTINCT ON (o.obj_id)
                   o.obj_id,
                   o.snapshot_date,
                   (COALESCE(o.price_per_m2_min, o.price_per_m2_max)
                    + COALESCE(o.price_per_m2_max, o.price_per_m2_min)) / 2.0 AS ppm2
              FROM domrf_kn_objects o
             WHERE o.region_cd = CAST(:region_cd AS integer)
               AND o.obj_class IS NULL
               AND o.obj_class_fallback IS NULL
               AND COALESCE(o.price_per_m2_min, o.price_per_m2_max) IS NOT NULL
             ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST
        ),
        matched AS (
            SELECT DISTINCT ON (p.obj_id)
                   p.obj_id,
                   p.snapshot_date,
                   cp.obj_class
              FROM priced p
              JOIN yandex_realty_class_prices cp
                ON p.ppm2 >= cp.price_per_m2_min
               AND p.ppm2 <= cp.price_per_m2_max
             ORDER BY p.obj_id,
                      (cp.price_per_m2_min + cp.price_per_m2_max) / 2.0 ASC
        )
        SELECT obj_id, snapshot_date, obj_class FROM matched
        """
    )

    if dry_run:
        n = len(db.execute(select_candidates, {"region_cd": region_cd}).all())
        logger.info("DRY RUN: would set obj_class_fallback (price_inference) для %d", n)
        return {"updated": n}

    rows = db.execute(select_candidates, {"region_cd": region_cd}).all()
    updated = 0
    for r in rows:
        try:
            with db.begin_nested():
                result = db.execute(
                    text(
                        """
                        UPDATE domrf_kn_objects
                           SET obj_class_fallback = CAST(:cls AS text),
                               obj_class_source   = 'price_inference'
                         WHERE obj_id = CAST(:obj_id AS bigint)
                           AND snapshot_date = CAST(:snap AS date)
                           AND obj_class IS NULL
                           AND obj_class_fallback IS NULL
                        """
                    ),
                    {"cls": str(r[2]), "obj_id": int(r[0]), "snap": r[1]},
                )
            updated += result.rowcount
        except Exception as e:
            logger.warning("obj_class price_inference failed для obj_id=%s: %s", r[0], e)

    db.commit()
    logger.info("obj_class price_inference backfill: updated=%d", updated)
    return {"updated": updated}


def run_backfill(
    db: Session,
    *,
    region_cd: int = SVERDL_REGION_CD,
    with_price_inference: bool = True,
    batch_limit: int | None = None,
    dry_run: bool = False,
) -> dict[str, int]:
    """Полный backfill: yandex_match → (опц.) price_inference.

    Args:
        db: SQLAlchemy sync Session.
        region_cd: Регион (66 — Свердл).
        with_price_inference: Запускать ли вторую ступень price-inference.
        batch_limit: Лимит кандидатов trigram-match (None — все).
        dry_run: Прогон без записи.

    Returns:
        dict: yandex_updated, yandex_skipped, price_updated.
    """
    candidates = find_class_candidates(db, region_cd=region_cd, limit=batch_limit)
    ym = apply_class_matches(db, candidates, dry_run=dry_run)

    price_updated = 0
    if with_price_inference:
        pi = apply_price_inference(db, region_cd=region_cd, dry_run=dry_run)
        price_updated = pi["updated"]

    return {
        "yandex_updated": ym["updated"],
        "yandex_skipped": ym["skipped"],
        "price_updated": price_updated,
    }
