"""Celery task: periodic catalog-FLAT scrape для DOM.РФ (issue #2442 Task 2).

Дополняет per-flat catalog-поля (price_rub, price_per_m2, status, finishing_type,
ceiling_height_m, section_no, catalog_updated_at) + plan-изображения квартир в
domrf_kn_flats из SSR-страниц каталога:
  https://наш.дом.рф/сервисы/каталог-квартир/квартира/{catalog_url_hash}

kn-API отдаёт цену для меньшинства квартир (~8.5% NULL-free); остальные поля
(отделка, потолки, дата обновления) живут только на публичной странице квартиры.
Оркестрация здесь: выборка batch'а из БД + вызов `scrape_catalog_batch`, которая
сама держит один BrowserSession + встроенный jitter_sleep (rate-limit/stealth).

Selector logic (аналог object-level scraper):
  - catalog_url_hash IS NOT NULL — берём только строки, где kn-sweep уже записал
    реальный hash. До #2442 Task 1 + свежего kn-sweep это 0 строк (hash хардкодился
    в NULL) — пустой batch ожидаем/не баг.
  - catalog_updated_at IS NULL — квартира ещё не скрапилась из каталога, грузим.
  - catalog_updated_at < CURRENT_DATE - INTERVAL '30 days' — данные протухли, refresh.
  - force=True — игнорирует «уже свежий» фильтр, грузит все строки последнего
    snapshot с непустым hash.

Sequencing dependency (важно): эта задача НЕ дублирует object-level scrape и
начнёт возвращать непустые batch'и ТОЛЬКО после того, как:
  1. #2442 Task 1 (elemId → catalog_url_hash) задеплоен, И
  2. прошёл свежий kn-sweep, наполнивший catalog_url_hash на новых snapshot'ах.
До этого SELECT вернёт 0 строк (см. выше). Beat-entry намеренно оставлен
ЗАКОММЕНТИРОВАННЫМ в beat_schedule.py (как и object-level) — WAF-cooldown на
VPS IP; включается вручную после проверки targeted-тестом.
"""

from __future__ import annotations

import asyncio
import logging
from datetime import date
from typing import Any

from sqlalchemy import text

from app.core.db import SessionLocal
from app.workers.celery_app import celery_app

logger = logging.getLogger(__name__)

# Выбор квартир на catalog-скрап одним запросом (obj-scraper-паттерн: obj_id+snapshot
# атомарно, здесь ods_id+catalog_url_hash).  snapshot_date = MAX(...) для того же
# региона фиксирует последний snapshot и не тянет исторические строки — UPSERT в
# scrape_catalog_batch пишет `WHERE ods_id = :ods_id` (без snapshot-скоупа), поэтому
# отбираем только актуальный snapshot, чтобы не скрапить устаревшие ods_id.
#
# catalog_url_hash IS NOT NULL — жёсткое условие: без hash URL страницы не собрать.
# Пока Task 1 не задеплоен и kn-sweep не наполнил hash — 0 строк (ожидаемо).
#
# Фильтр свежести (:force = false): catalog_updated_at IS NULL (никогда не скрапили)
# ИЛИ старше 30 дней.  При :force = true фильтр снимается — грузим все строки
# последнего snapshot с непустым hash.
#
# LIMIT :max_flats: в PostgreSQL `LIMIT NULL` == без лимита, поэтому при
# max_flats=None (force «Загрузить все» без явного потолка) грузим ВСЕ подходящие
# строки, а не молча режем до _DEFAULT_MAX_FLATS.
_SELECT_TARGETS_SQL = text(
    """
    SELECT ods_id, catalog_url_hash, snapshot_date
      FROM domrf_kn_flats
     WHERE region_cd = :region_code
       AND catalog_url_hash IS NOT NULL
       AND ods_id IS NOT NULL
       AND (
           CAST(:force AS boolean)
           OR catalog_updated_at IS NULL
           OR catalog_updated_at < CURRENT_DATE - CAST(:stale_days AS integer)
       )
       AND snapshot_date = (
           SELECT MAX(snapshot_date)
             FROM domrf_kn_flats
            WHERE region_cd = :region_code
       )
     ORDER BY catalog_updated_at NULLS FIRST
     LIMIT :max_flats
    """
)

# Лимит по умолчанию если max_flats не задан явно (обычный beat/ad-hoc pass).
_DEFAULT_MAX_FLATS = 300

# Считаем данные протухшими старше этого числа дней (refresh-порог).
_STALE_DAYS = 30


@celery_app.task(
    bind=True,
    name="tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
    time_limit=3600,
)
def scrape_kn_catalog_flats(
    self: Any,
    region_code: int = 66,
    max_flats: int | None = None,
    force: bool = False,
) -> dict[str, Any]:
    """Periodic catalog-flat scrape (цены/отделка/планировки квартир из SSR каталога).

    Args:
        region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
        max_flats: Максимум квартир за один run. Если не задан: при force=True
            лимита нет (грузим все), при force=False — _DEFAULT_MAX_FLATS (300).
        force: Если True — игнорирует фильтр свежести («catalog_updated_at свежий»)
            и (при не заданном max_flats) снимает лимит, грузя ВСЕ квартиры
            последнего snapshot с непустым catalog_url_hash (admin «Загрузить все»).
            По умолчанию False — пропускает то, что скраплено < 30 дней назад, и
            режет batch до 300.

    Returns:
        dict с ключами: region_code, force, snapshot_date, flats_count + поля из
        scrape_catalog_batch (total, success, updated, failed, fields_total).

    Concurrency:
        No Redis lock — consistent с sibling tasks (scrape_kn_catalog_objects и т.д.).
        Beat отключён (WAF cooldown); при ручном/ad-hoc запуске пересечение крайне
        маловероятно.  Если случится:
        - UPDATE идемпотентен (COALESCE в upsert_catalog_data, WHERE ods_id).
        - Max risk: 2x WAF-нагрузка на DOM.РФ для того же batch.
        Add Redis lock если WAF блокирует наблюдается или beat включается на overlap.
    """
    from app.services.scrapers.domrf_catalog import scrape_catalog_batch

    # Явный max_flats всегда уважается. Без него:
    #   force=True («Загрузить все») → лимита нет (limit=None → SQL LIMIT NULL = все строки);
    #   force=False (beat / ad-hoc pass) → дефолтный batch _DEFAULT_MAX_FLATS.
    if max_flats is not None:
        limit: int | None = max_flats
    elif force:
        limit = None
    else:
        limit = _DEFAULT_MAX_FLATS

    db = SessionLocal()
    try:
        rows = (
            db.execute(
                _SELECT_TARGETS_SQL,
                {
                    "region_code": region_code,
                    "max_flats": limit,
                    "force": force,
                    "stale_days": _STALE_DAYS,
                },
            )
            .mappings()
            .all()
        )
        flats: list[dict[str, Any]] = [
            {"ods_id": r["ods_id"], "catalog_url_hash": r["catalog_url_hash"]} for r in rows
        ]
    except Exception as exc:
        logger.error("scrape_kn_catalog_flats: failed to fetch flats: %s", exc)
        db.close()
        raise

    if not flats:
        logger.info(
            "scrape_kn_catalog_flats: nothing to do for region=%d (force=%s) — "
            "likely no catalog_url_hash populated yet (see #2442 sequencing)",
            region_code,
            force,
        )
        db.close()
        return {
            "region_code": region_code,
            "force": force,
            "flats_count": 0,
            "total": 0,
            "success": 0,
            "updated": 0,
            "failed": 0,
            "fields_total": 0,
        }

    # snapshot_date из первой строки — все строки одинаковые (WHERE snapshot_date =
    # MAX(snapshot_date)). Атомарно: один SELECT, без race с kn-scraper.
    snapshot_date_val: date = rows[0]["snapshot_date"]

    logger.info(
        "scrape_kn_catalog_flats: region=%d snapshot_date=%s flats=%d limit=%s force=%s",
        region_code,
        snapshot_date_val,
        len(flats),
        "ALL" if limit is None else limit,
        force,
    )

    try:
        stats = asyncio.run(
            scrape_catalog_batch(
                db=db,
                flats=flats,
                region_code=region_code,
            )
        )
    except Exception as exc:
        logger.error("scrape_kn_catalog_flats: scrape failed: %s", exc)
        raise
    finally:
        db.close()

    result: dict[str, Any] = {
        "region_code": region_code,
        "force": force,
        "snapshot_date": str(snapshot_date_val),
        "flats_count": len(flats),
        **stats,
    }
    logger.info("scrape_kn_catalog_flats done: %s", result)
    return result
