"""Бэкфилл координат листингов из реестра зданий ЕКБ (ekb_geoportal_buildings).

Назначение: проставить lat/lon/geom для листингов, у которых геокодер не отработал
(lat IS NULL), используя точный street_norm+house_norm матч по таблице ekb_geoportal_buildings.

Нормализация совпадает с ekb_geoportal_ingest:
  street_norm = lower(trim(street))
  house_norm  = lower(house) без внутренних пробелов («7 б» → «7б»)

Парсинг адреса — _parse_street_house из app.services.geocoder (готовый парсер),
работающий с формами «г. Екатеринбург, ул. Малышева, 30, кв. 28».

Запуск:
  python -m app.tasks.backfill_listings_coords_geoportal
  python -m app.tasks.backfill_listings_coords_geoportal --limit 5000 --batch-size 200

Также запускается nightly через in-app scheduler (source='geoportal_coords_backfill',
migration 171) — run_geoportal_coords_backfill(). Local exact match, никакого внешнего
HTTP/rate-limit, поэтому окно ставится ПЕРЕД geocode_missing_listings (Nominatim/Yandex,
coarse city-centroid fallback): точный house-level матч должен получить шанс первым,
иначе Nominatim успевает проставить грубые coords и адрес выпадает из WHERE lat IS NULL
(#1967 — было единичным manual-прогоном #1841, здесь становится recurring).

Идемпотентность: UPDATE применяется только к строкам с lat IS NULL (WHERE id=:id AND lat IS NULL).
Повторный прогон не затирает уже проставленные координаты.
"""

from __future__ import annotations

import argparse
import logging
import time
from dataclasses import dataclass, field

from sqlalchemy import text
from sqlalchemy.orm import Session

from app.core.db import SessionLocal
from app.services import scrape_runs as runs_mod
from app.services.geocoder import _geoportal_house_match, _parse_street_house

logger = logging.getLogger(__name__)

# Размер батча на SELECT + commit-интервал.
DEFAULT_BATCH_SIZE = 500
DEFAULT_LIMIT: int | None = None  # None = без ограничений (весь backlog)


@dataclass
class BackfillCoordsResult:
    """Счётчики прогона бэкфилла."""

    candidates: int = 0  # листингов-кандидатов (lat IS NULL)
    matched: int = 0  # сматчено с ekb_geoportal_buildings
    updated: int = 0  # реально обновлено (UPDATE rowcount)
    no_address: int = 0  # listing.address IS NULL / не распарсился
    no_match: int = 0  # адрес распарсился, но в реестре здания нет
    errors: int = 0  # исключения при обработке отдельной записи
    duration_sec: float = field(default=0.0)

    def to_counters(self) -> dict[str, int]:
        return {
            "candidates": self.candidates,
            "matched": self.matched,
            "updated": self.updated,
            "no_address": self.no_address,
            "no_match": self.no_match,
            "errors": self.errors,
            "duration_sec": int(self.duration_sec),
        }


def _update_listing_coords(
    db: Session,
    listing_id: int,
    lat: float,
    lon: float,
) -> int:
    """UPDATE lat/lon/geom для одного листинга (только если lat IS NULL — идемпотентно).

    geom проставляется через ST_SetSRID(ST_MakePoint(...), 4326). Не тригерит
    отдельный trigger — явный UPDATE.

    Возвращает rowcount (1 = обновлено, 0 = уже было заполнено или не найдено).
    """
    result = db.execute(
        text("""
            UPDATE listings
            SET lat  = CAST(:lat AS double precision),
                lon  = CAST(:lon AS double precision),
                geom = ST_SetSRID(
                    ST_MakePoint(
                        CAST(:lon AS double precision),
                        CAST(:lat AS double precision)
                    ),
                    4326
                )
            WHERE id = CAST(:id AS bigint)
              AND lat IS NULL
        """),
        {"lat": lat, "lon": lon, "id": listing_id},
    )
    return result.rowcount  # type: ignore[return-value]


def backfill_coords_from_geoportal(
    db: Session,
    *,
    limit: int | None = None,
    sources: list[str] | None = None,
    batch_size: int = DEFAULT_BATCH_SIZE,
) -> BackfillCoordsResult:
    """Бэкфилл координат листингов из ekb_geoportal_buildings.

    Алгоритм:
    1. SELECT листингов с lat IS NULL + ЕКБ-скоуп (опционально), батчами batch_size.
    2. Для каждого: распарсить street+house через _parse_street_house (geocoder),
       нормализовать (как ekb_geoportal_ingest) и сматчить в ekb_geoportal_buildings.
    3. Матч есть → UPDATE lat/lon/geom (только при lat IS NULL — идемпотентно).
    4. commit каждые batch_size записей, SAVEPOINT-защита на батч.

    Args:
        db: SQLAlchemy сессия (tradein БД).
        limit: ограничение общего числа обрабатываемых листингов (None = всё).
        sources: список source-значений для фильтрации (None = все источники).
        batch_size: размер батча SELECT + интервал commit.

    Returns:
        BackfillCoordsResult со счётчиками.
    """
    start = time.monotonic()
    res = BackfillCoordsResult()

    offset = 0
    total_limit = limit  # общий лимит листингов, None = без ограничений

    while True:
        # Сколько брать в этой итерации
        fetch_n = batch_size
        if total_limit is not None:
            remaining = total_limit - res.candidates
            if remaining <= 0:
                break
            fetch_n = min(batch_size, remaining)

        # Собираем WHERE с опциональным source-фильтром
        source_clause = ""
        params: dict = {"limit": fetch_n, "offset": offset}
        if sources:
            source_clause = " AND source = ANY(CAST(:sources AS text[]))"
            params["sources"] = sources

        rows = (
            db.execute(
                text(f"""
                    SELECT id, address
                    FROM listings
                    WHERE lat IS NULL
                      AND geom IS NULL
                      AND address IS NOT NULL
                      {source_clause}
                    ORDER BY id
                    LIMIT CAST(:limit AS integer) OFFSET CAST(:offset AS integer)
                """),
                params,
            )
            .mappings()
            .all()
        )

        if not rows:
            break

        res.candidates += len(rows)
        batch_updated = 0

        try:
            with db.begin_nested():
                for row in rows:
                    listing_id: int = row["id"]
                    address: str | None = row["address"]

                    if not address or not address.strip():
                        res.no_address += 1
                        continue

                    # Парсинг адреса — переиспользуем парсер geocoder'а
                    parsed = _parse_street_house(address)
                    if parsed is None:
                        res.no_address += 1
                        continue

                    street, house = parsed

                    # Матч в ekb_geoportal_buildings (точный по street_norm+house_norm)
                    try:
                        hit = _geoportal_house_match(db, street, house)
                    except Exception:
                        logger.warning(
                            "backfill_coords: geoportal match raised for listing_id=%d addr=%r",
                            listing_id,
                            address[:80],
                            exc_info=True,
                        )
                        res.errors += 1
                        continue

                    if hit is None:
                        res.no_match += 1
                        continue

                    res.matched += 1

                    # UPDATE (идемпотентно — WHERE lat IS NULL)
                    try:
                        rc = _update_listing_coords(db, listing_id, hit.lat, hit.lon)
                        res.updated += rc
                        batch_updated += rc
                    except Exception:
                        logger.warning(
                            "backfill_coords: UPDATE failed for listing_id=%d",
                            listing_id,
                            exc_info=True,
                        )
                        res.errors += 1

        except Exception:
            logger.warning(
                "backfill_coords: batch SAVEPOINT failed (offset=%d), batch rolled back",
                offset,
                exc_info=True,
            )
            res.errors += 1

        db.commit()

        logger.info(
            "backfill_coords: batch offset=%d rows=%d matched=%d updated=%d "
            "(total: candidates=%d matched=%d updated=%d no_addr=%d no_match=%d err=%d)",
            offset,
            len(rows),
            batch_updated,
            batch_updated,
            res.candidates,
            res.matched,
            res.updated,
            res.no_address,
            res.no_match,
            res.errors,
        )

        # Если вернули меньше чем просили — конец данных
        if len(rows) < fetch_n:
            break

        offset += len(rows)

    res.duration_sec = time.monotonic() - start

    logger.info(
        "backfill_coords: DONE — candidates=%d matched=%d updated=%d "
        "no_address=%d no_match=%d errors=%d duration=%.1fs",
        res.candidates,
        res.matched,
        res.updated,
        res.no_address,
        res.no_match,
        res.errors,
        res.duration_sec,
    )

    return res


# ── Run lifecycle wrapper (scheduler entrypoint) ─────────────────────────────
def run_geoportal_coords_backfill(
    db: Session,
    *,
    run_id: int,
    params: dict,
) -> BackfillCoordsResult:
    """Run-lifecycle wrapper for backfill_coords_from_geoportal (sync, DB-only task).

    Launched by the kit scheduler (source='geoportal_coords_backfill') via
    product_handlers._job_geoportal_coords_backfill, или вручную. Pure local exact
    street+house match против ekb_geoportal_buildings — никакого внешнего HTTP, поэтому
    весь backlog (или ограниченный `limit`) отрабатывается за один прогон без wall-clock
    budget (в отличие от geocode_missing_listings, который rate-limited Nominatim'ом).

    Params (default_params jsonb):
        limit:       максимум листингов за прогон (default None = весь backlog).
        batch_size:  размер батча SELECT+commit (default 500).
        sources:     фильтр по source (default None = все источники).

    Finalises scrape_runs (mark_done / mark_failed) с counters.
    """
    limit = params.get("limit")
    batch_size = int(params.get("batch_size", DEFAULT_BATCH_SIZE))
    sources = params.get("sources")

    counters: dict[str, int] = BackfillCoordsResult().to_counters()

    try:
        runs_mod.update_heartbeat(db, run_id, counters)

        res = backfill_coords_from_geoportal(
            db,
            limit=limit,
            sources=sources,
            batch_size=batch_size,
        )

        counters = res.to_counters()
        runs_mod.mark_done(db, run_id, counters)
        logger.info(
            "run_geoportal_coords_backfill: run_id=%d DONE candidates=%d matched=%d "
            "updated=%d no_address=%d no_match=%d errors=%d duration=%.1fs",
            run_id,
            res.candidates,
            res.matched,
            res.updated,
            res.no_address,
            res.no_match,
            res.errors,
            res.duration_sec,
        )
        return res
    except Exception as exc:
        logger.exception("run_geoportal_coords_backfill: run_id=%d FAILED", run_id)
        try:
            db.rollback()
        except Exception:
            pass
        runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
        raise


def main() -> None:
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
    )
    parser = argparse.ArgumentParser(
        description="Бэкфилл координат листингов из ekb_geoportal_buildings"
    )
    parser.add_argument(
        "--limit",
        type=int,
        default=None,
        help="Максимальное число листингов (default: без ограничений)",
    )
    parser.add_argument(
        "--batch-size",
        type=int,
        default=DEFAULT_BATCH_SIZE,
        help=f"Размер батча (default: {DEFAULT_BATCH_SIZE})",
    )
    parser.add_argument(
        "--sources",
        nargs="*",
        default=None,
        help="Фильтр по source (avito/cian/yandex); без флага — все источники",
    )
    args = parser.parse_args()

    db = SessionLocal()
    try:
        result = backfill_coords_from_geoportal(
            db,
            limit=args.limit,
            sources=args.sources,
            batch_size=args.batch_size,
        )
    finally:
        db.close()

    logger.info(
        "Готово: кандидатов=%d сматчено=%d обновлено=%d "
        "без_адреса=%d без_матча=%d ошибок=%d время=%.1fs",
        result.candidates,
        result.matched,
        result.updated,
        result.no_address,
        result.no_match,
        result.errors,
        result.duration_sec,
    )


if __name__ == "__main__":
    main()
