"""Beat schedule builder for Celery.

Priority order:
1. DB-driven — читает таблицу job_settings при старте beat-контейнера.
2. Fallback — env-based расписание (SCRAPE_KN_CRON, OBJECTIVE_SYNC_CRON),
   используется если БД недоступна или таблица пустая.
3. Hardcoded entries — добавляются поверх DB-расписания (не в job_settings):
   - refresh-ekb-districts-medians (аналитика, ежемесячно 5-го в 04:00)
   - nspd-geo-zombie-cleanup (каждую минуту, замена нестабильного worker_ready)
   - poi-sync-weekly (OSM POI, понедельник 03:00)
   - noise-sync-weekly (OSM шум, понедельник 03:30)

Публичный API: build_beat_schedule() → dict.
"""

import logging

from celery.schedules import crontab

from app.core.config import settings

logger = logging.getLogger(__name__)

# Словарь job_type → celery task name (полное dotted name).
# Используется при построении beat_schedule из job_settings.
TASK_MAP: dict[str, str] = {
    "scrape_kn": "tasks.scrape_kn.scrape_kn_region",
    "objective_sync": "tasks.scrape_objective.sync_all_groups",
    # nspd_geo и objective_etl не имеют cron — запускаются вручную.
    # При необходимости можно добавить сюда.
}


def _parse_cron(spec: str) -> crontab:
    """Parse 'M H DoM Mon DoW' crontab string into Celery crontab. Empty fields default to '*'."""
    parts = spec.strip().split()
    if len(parts) != 5:
        raise ValueError(f"crontab spec must have 5 fields, got: {spec!r}")
    minute, hour, dom, month, dow = parts
    return crontab(
        minute=minute,
        hour=hour,
        day_of_month=dom,
        month_of_year=month,
        day_of_week=dow,
    )


def _default_regions() -> list[int]:
    return [int(x.strip()) for x in settings.scrape_kn_default_regions.split(",") if x.strip()]


def _build_beat_schedule_from_db() -> dict:
    """Строит beat_schedule из job_settings БД.

    Returns: schedule (может быть пустой если все enabled rows без cron или disabled).

    `get_all` маскирует DB-unreachable case через возврат _DEFAULTS (4 synthetic
    rows, все enabled=True с дефолтными cron'ами) — это значит "DB unreachable" и
    "fresh install (table empty)" неотличимы от "normal с _DEFAULTS". В обоих
    случаях schedule НЕ будет пустым — _DEFAULTS дадут scrape_kn + objective_sync.

    КРИТИЧНО для operator-disable scenario: когда DB reachable и оператор сделал
    `UPDATE job_settings SET enabled=false WHERE job_type IN (...)` — get_all
    вернёт реальные rows (НЕ _DEFAULTS), loop их пропустит → schedule={}. Caller
    (build_beat_schedule) НЕ должен срывался на env fallback в этом случае,
    иначе disable бесполезен. См. инцидент 2026-05-24 (WAF cooldown disable
    scrape_kn → old code fallback'ал scrape_kn обратно из env).
    """
    from app.core.db import SessionLocal
    from app.services.job_settings import get_all

    db = SessionLocal()
    try:
        rows = get_all(db)
    finally:
        db.close()

    schedule: dict = {}
    for row in rows:
        jt = row["job_type"]
        if not row.get("enabled"):
            continue
        cron_str = row.get("cron_schedule")
        if not cron_str:
            # NULL cron = только ручной запуск, не добавляем в beat
            continue
        if jt not in TASK_MAP:
            logger.warning(
                "build_beat_schedule: job_type '%s' не имеет TASK_MAP-записи — пропускаем", jt
            )
            continue
        try:
            cron = _parse_cron(cron_str)
        except Exception as e:
            logger.warning(
                "build_beat_schedule: невалидный cron '%s' для '%s': %s", cron_str, jt, e
            )
            continue

        queue = row.get("queue_name", "celery") or "celery"

        if jt == "scrape_kn":
            # scrape_kn запускается отдельной задачей на каждый регион
            extra = row.get("extra_config") or {}
            regions: list[int] = extra.get("default_regions") or _default_regions()
            for rc in regions:
                schedule[f"kn-region-{rc}"] = {
                    "task": TASK_MAP[jt],
                    "schedule": cron,
                    "args": [rc, None],
                    "options": {"queue": queue},
                }
            logger.info(
                "build_beat_schedule: scrape_kn cron=%r regions=%s queue=%s",
                cron_str,
                regions,
                queue,
            )
        else:
            kwargs: dict = {}
            if jt == "objective_sync":
                kwargs = {"triggered_by": "beat"}
            schedule[jt] = {
                "task": TASK_MAP[jt],
                "schedule": cron,
                "kwargs": kwargs,
                "options": {"queue": queue},
            }
            logger.info("build_beat_schedule: %s cron=%r queue=%s", jt, cron_str, queue)

    return schedule


def _build_beat_schedule_fallback() -> dict:
    """Fallback-расписание из env vars — используется если БД недоступна на старте."""
    logger.warning("beat_schedule: строим fallback из env vars")
    schedule: dict = {}

    # scrape_kn
    try:
        for rc in _default_regions():
            schedule[f"kn-region-{rc}"] = {
                "task": "tasks.scrape_kn.scrape_kn_region",
                "schedule": _parse_cron(settings.scrape_kn_cron),
                "args": [rc, None],
            }
    except Exception as e:
        logger.warning("beat_schedule fallback: scrape_kn failed: %s", e)

    # objective_sync
    try:
        from app.core.db import SessionLocal
        from app.services.objective_sync_config import get_cron_schedule_safe

        cron_str = get_cron_schedule_safe(SessionLocal)
    except Exception:
        cron_str = settings.objective_sync_cron

    try:
        schedule["objective-sync"] = {
            "task": "tasks.scrape_objective.sync_all_groups",
            "schedule": _parse_cron(cron_str),
            "kwargs": {"triggered_by": "beat"},
        }
    except Exception as e:
        logger.warning("beat_schedule fallback: objective_sync failed: %s", e)

    # refresh ekb-districts medians — ежемесячно 5-го числа в 04:00 МСК
    try:
        schedule["refresh-ekb-districts-medians"] = {
            "task": "tasks.refresh_analytics.refresh_ekb_districts_medians",
            "schedule": _parse_cron("0 4 5 * *"),
            "kwargs": {"window_months": 24, "min_deals": 50},
        }
    except Exception as e:
        logger.warning("beat_schedule fallback: refresh_analytics failed: %s", e)

    return schedule


def build_beat_schedule() -> dict:
    """Строит beat_schedule из DB (job_settings) + добавляет hardcoded entries.

    `_build_beat_schedule_from_db` использует `get_all` который сам fallback'ает на
    _DEFAULTS при DB unreachable / fresh install → возвращает 4 synthetic rows
    (scrape_kn + objective_sync enabled). То есть schedule из DB-builder ПУСТ
    только когда оператор явно disabled все cron-able jobs через UPDATE
    job_settings — это **намерение**, респектим без env-fallback.

    Hardcoded entries (refresh-analytics, OSM POI/noise, nspd cleanup) добавляются
    ВСЕГДА — они не управляются через job_settings.
    """
    schedule = _build_beat_schedule_from_db()

    # Всегда добавляем refresh_analytics (этот job нет в job_settings,
    # он технический и не требует конфигурации через UI).
    if "refresh-ekb-districts-medians" not in schedule:
        try:
            schedule["refresh-ekb-districts-medians"] = {
                "task": "tasks.refresh_analytics.refresh_ekb_districts_medians",
                "schedule": _parse_cron("0 4 5 * *"),
                "kwargs": {"window_months": 24, "min_deals": 50},
                "options": {"queue": "celery"},
            }
        except Exception as e:
            logger.warning("beat_schedule: refresh_analytics failed: %s", e)

    # Zombie cleanup: каждую минуту проверяет nspd_geo_jobs с heartbeat > 2 мин
    # и re-enqueue'ит их. Замена worker_ready signal handler'а — он не fires
    # стабильно в нашем setup, beat-task надёжнее.
    schedule["nspd-geo-zombie-cleanup"] = {
        "task": "tasks.nspd_geo.cleanup_zombies",
        "schedule": _parse_cron("* * * * *"),
        "options": {"queue": "celery"},
    }

    # OSM POI sync — еженедельно в понедельник в 03:00 МСК
    schedule["poi-sync-weekly"] = {
        "task": "tasks.poi_sync.sync_osm_poi_ekb",
        "schedule": _parse_cron("0 3 * * mon"),
        "options": {"queue": "celery"},
    }

    # OSM noise sources sync — еженедельно в понедельник в 03:30 МСК
    # (через 30 мин после poi-sync, чтобы не нагружать Overpass одновременно)
    schedule["noise-sync-weekly"] = {
        "task": "tasks.noise_sync.sync_osm_noise_sources_ekb",
        "schedule": _parse_cron("30 3 * * mon"),
        "options": {"queue": "celery"},
    }

    # #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК (02:00 UTC)
    schedule["ekburg-permits-monthly"] = {
        "task": "tasks.ekburg_permits_sync.refresh_all",
        "schedule": _parse_cron("0 2 1 * *"),
        "options": {"queue": "celery"},
    }

    # ПЗЗ территориальных зон ЕКБ из PKK6 ArcGIS — ежемесячно 1-го числа в 03:00 МСК (00:00 UTC).
    # PKK6 нестабилен под нагрузкой, но данные ПЗЗ меняются редко — раз в месяц достаточно.
    # Task: tasks/pzz_sync.py → sync_pzz_zones_ekb.
    # Admin trigger: POST /api/v1/admin/scrape/pzz-sync.
    # Ref: issue #233 (pzz_zones_ekb = 0 rows, задача никогда не запускалась автоматически).
    schedule["pzz-sync-monthly"] = {
        "task": "tasks.pzz_sync.sync_pzz_zones_ekb",
        "schedule": _parse_cron("0 0 1 * *"),
        "options": {"queue": "celery"},
    }

    # Catalog-object scrape — наполняет ~25 NULL колонок domrf_kn_objects из SSR-страниц.
    # kn-API не отдаёт wall_type, energy_eff, ceiling_height_m, parking_* и т.д.
    # Вторник 04:00 UTC. batch 300/run → 1532 объекта за ~5 недель полного обновления.
    #
    # DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed
    # extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession
    # + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 UTC) насыпет
    # 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после
    # cooldown 24-48h (проверить через targeted test).
    # schedule["scrape-kn-catalog-objects-weekly"] = {
    #     "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects",
    #     "schedule": _parse_cron("0 4 * * 2"),  # Tuesday 04:00 UTC
    #     "kwargs": {"region_code": 66, "max_objects": 300},
    #     "options": {"queue": "celery"},
    # }

    # Quarter price index refresh (#762) — monthly on the 5th at 05:00 MSK (02:00 UTC).
    # Chains: mv_quarter_price_per_m2 → mv_quarter_price_index (in order, both CONCURRENTLY).
    # Runs 1 h after refresh-ekb-districts-medians (04:00 MSK) so deals data is settled.
    # Not in job_settings (technical infra task, no UI config needed).
    schedule["refresh-quarter-price-index"] = {
        "task": "tasks.refresh_quarter_price_index.refresh_quarter_price_index_chain",
        "schedule": _parse_cron("0 2 5 * *"),  # 02:00 UTC = 05:00 MSK, 5th of month
        "options": {"queue": "celery"},
    }

    # NSPD quarter dump refresh — re-enabled 2026-05-17 после Sub-PR B (#260)
    # переключения search_by_quarter на grid-walk. Foundation (#247) + integration
    # (#260) теперь возвращают полноценные dumps (territorial_zones, ЗОУИТ, risk
    # zones, engineering structures) вместо 0-3 features из single-pixel WMS.
    #
    # Schedule: Mon 04:00 МСК (01:00 UTC). batch_size=50 ограничивает fanout per
    # tick — при 11k+ кварталов в cad_quarters_geom полный backfill займёт
    # несколько недель, но защищает от WAF rate-limit burst.
    # max_age_days=90 — refresh свежее квартала; новые / отсутствующие dumps
    # тоже попадают через harvest_stale_quarters (берёт NULL fetched_at).
    schedule["nspd-harvest-stale-quarters"] = {
        "task": "tasks.nspd_sync.harvest_stale_quarters",
        "schedule": _parse_cron("0 1 * * mon"),  # 01:00 UTC = 04:00 МСК
        "kwargs": {"region_code": 66, "max_age_days": 90, "batch_size": 50},
        "options": {"queue": "celery"},
    }

    return schedule
