"""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)

    # КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
    # раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
    schedule["ekb-krt-sync-weekly"] = {
        "task": "tasks.ekb_krt_sync.sync_krt_sites",
        "schedule": _parse_cron("0 5 * * wed"),
        "options": {"queue": "celery"},
    }

    # РИАСУРТ Свердл (#108): региональные градостроительные зоны агломерации ЕКБ меняются
    # медленно → ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК). NB: harvest_all_*
    # пока на ПЛЕЙСХОЛДЕР-bbox 5 МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO) — фактический
    # прод-прогон только после уточнения границ.
    schedule["riasurt-sverdl-harvest-quarterly"] = {
        "task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
        "schedule": _parse_cron("0 4 1 1,4,7,10 *"),
        "options": {"queue": "celery"},
    }

    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)

    # КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
    # раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
    schedule["ekb-krt-sync-weekly"] = {
        "task": "tasks.ekb_krt_sync.sync_krt_sites",
        "schedule": _parse_cron("0 5 * * wed"),
        "options": {"queue": "celery"},
    }

    # РИАСУРТ Свердл (#108): ежеквартально (1-е число янв/апр/июл/окт, 04:00 МСК).
    # NB: harvest_all_* на ПЛЕЙСХОЛДЕР-bbox 5 МО (riasurt_sverdl_harvest.MO_BBOXES TODO).
    schedule["riasurt-sverdl-harvest-quarterly"] = {
        "task": "tasks.riasurt_sverdl_harvest.harvest_all_riasurt_sverdl",
        "schedule": _parse_cron("0 4 1 1,4,7,10 *"),
        "options": {"queue": "celery"},
    }

    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"},
    }

    # Bug #1236: парный cleanup для cadastre_jobs. Переводит застрявшие
    # 'running' jobs (heartbeat > 5 мин) в 'paused', чтобы оператор мог
    # resume через admin API. Раз в 2 минуты — cadastre fanout меньше nspd_geo,
    # per-minute опрос DB не оправдан.
    schedule["cadastre-zombie-cleanup"] = {
        "task": "tasks.cadastre.cleanup_zombies",
        "schedule": _parse_cron("*/2 * * * *"),
        "options": {"queue": "geo"},
    }

    # 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"},
    }

    # OSM инженерные сети (#1746) sync — еженедельно в понедельник в 04:00 МСК
    # (через 30 мин после noise-sync, чтобы не нагружать Overpass одновременно).
    # No-B2B open-data источник (Overpass): ЛЭП/подстанции/трубопроводы/ЦТП/вышки.
    schedule["utility-infrastructure-sync-weekly"] = {
        "task": "tasks.utility_infrastructure_sync.sync_osm_utility_infrastructure_ekb",
        "schedule": _parse_cron("0 4 * * mon"),
        "options": {"queue": "celery"},
    }

    # Точки подключения со свободной мощностью (Forgejo #2119 Phase A):
    # электро — Россети WFS (~488 ЦП) + xlsx-резервы (ЦП + 10.7k ТП/РП);
    # вода — Водоканал ЕКБ docx (ЦСВ/ЦСК, city-level).
    # Источники ГЕО-БЛОКИРУЮТ non-RU IP → RUN-ON-PROD (Celery worker на VPS).
    # Меняются медленно (раскрытие ФАС квартальное) — еженедельно с запасом.
    # Вторник 04:00 МСК — НАМЕРЕННО вне monday-кластера тяжёлых Overpass/site_finder
    # рефрешей (не конкурируем за БД/сеть). Электро (WFS+xlsx) и вода (docx) —
    # разные хосты, ставим с интервалом 30 мин, чтобы не бить оба одновременно.
    schedule["power-capacity-sync-weekly"] = {
        "task": "tasks.connection_capacity_sync.sync_power_supply_centers",
        "schedule": _parse_cron("0 4 * * tue"),  # 04:00 МСК, вторник
        "options": {"queue": "celery"},
    }
    schedule["water-capacity-sync-weekly"] = {
        "task": "tasks.connection_capacity_sync.sync_water_reserves",
        "schedule": _parse_cron("30 4 * * tue"),  # 04:30 МСК, вторник
        "options": {"queue": "celery"},
    }
    # Газовая вертикаль (Forgejo #2119 Фаза B1): свободная мощность ГРС «Газпром
    # трансгаз Екатеринбург» из инлайн HTML-таблицы раскрытия. Источник ГЕО-БЛОКИРУЕТ
    # non-RU IP → RUN-ON-PROD. 05:00 МСК вторник — через 30 мин после воды (разные
    # хосты, не бьём одновременно), в том же вторник-кластере вне monday-нагрузки.
    schedule["gas-grs-capacity-sync-weekly"] = {
        "task": "tasks.connection_capacity_sync.sync_gas_grs_capacity",
        "schedule": _parse_cron("0 5 * * tue"),  # 05:00 МСК, вторник
        "options": {"queue": "celery"},
    }
    # Точки выхода ГРС АО «Екатеринбурггаз» (форма 6 ФАС №960 → gas_grs_outlet_points +
    # догеокод адресов → geom). Forgejo #2119 Фаза B2, PR-4. Источник ГЕО-БЛОКИРУЕТ
    # non-RU IP + DaData-ключи на проде → RUN-ON-PROD. Источник публикуется ~5-го числа
    # месяца, НО beat не поддерживает «раз в месяц по факту публикации» надёжно (jitter
    # даты) → еженедельно вторник, идемпотентно: помесячный UPSERT + geom сохраняется,
    # не-новый период просто пере-UPSERT-ится без эффекта. 05:15 МСК — в газовом суб-
    # кластере сразу после gas-grs (05:00, тот же apex-родственник ekgas/ГТЕ), до ЕЭСК
    # (05:30); намеренно вне monday-нагрузки. Целимся ~05:30-окно спеки, но 05:30 занят
    # ЕЭСК → 05:15 (не бьём оба газовых источника ekgas одновременно).
    schedule["gas-outlet-points-sync-weekly"] = {
        "task": "tasks.connection_capacity_sync.sync_gas_outlet_points",
        "schedule": _parse_cron("15 5 * * tue"),  # 05:15 МСК, вторник
        "options": {"queue": "celery"},
    }
    # ЕЭСК-резервы (Forgejo #2119 Фаза B2): ПС 35-220 (МВт) + ТП/РП «ниже 35» из
    # xlsx-раскрытия eesk.ru. Обычный SSL БЕЗ гео-блока, но держим в том же вторник-
    # кластере рядом с rosseti/gas/vodokanal. 05:30 МСК — через 30 мин после газа
    # (разные хосты, не бьём одновременно), вне monday-нагрузки.
    schedule["eesk-reserves-sync-weekly"] = {
        "task": "tasks.connection_capacity_sync.sync_eesk_reserves",
        "schedule": _parse_cron("30 5 * * tue"),  # 05:30 МСК, вторник
        "options": {"queue": "celery"},
    }
    # OSM-геокод городских ТП/РП ЕКБ → geom (Forgejo #2119 Фаза B2, финал). Матч
    # name_norm (чистый номер) ↔ OSM substation ref/name через Overpass →
    # power_tp_rp_reserves.geom. Источник (Overpass) БЕЗ гео-блока, но держим в том же
    # вторник-кластере рядом с ЕЭСК. 05:45 МСК — после газа/ЕЭСК (05:30), до тепла
    # (06:00); идемпотентно (geom IS NULL-гейт), вне monday-Overpass-нагрузки.
    schedule["osm-tp-geocode-weekly"] = {
        "task": "tasks.connection_capacity_sync.sync_osm_tp_geocode",
        "schedule": _parse_cron("45 5 * * tue"),  # 05:45 МСК, вторник
        "options": {"queue": "celery"},
    }
    # Тепло-резервы (Forgejo #2119 Фаза B2): резерв мощности источников тепла из
    # реестра раскрытия ФАС (ri.eias.ru, ЕТК + Екатеринбургэнерго). Источник ГЕО-
    # БЛОКИРУЕТ non-RU IP + российский УЦ → RUN-ON-PROD. 06:00 МСК — через 30 мин
    # после ЕЭСК (разные хосты), в том же вторник-кластере вне monday-нагрузки.
    schedule["heat-reserves-sync-weekly"] = {
        "task": "tasks.connection_capacity_sync.sync_heat_reserves",
        "schedule": _parse_cron("0 6 * * tue"),  # 06:00 МСК, вторник
        "options": {"queue": "celery"},
    }

    # #105 Phase 4: ЕКБ РНС/РВЭ — ежемесячно 1-го числа в 05:00 МСК.
    # Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
    schedule["ekburg-permits-monthly"] = {
        "task": "tasks.ekburg_permits_sync.refresh_all",
        "schedule": _parse_cron("0 5 1 * *"),
        "options": {"queue": "celery"},
    }

    # #2367: реестр РНС/РВЭ Свердловской области из открытого API ГИСОГД-СО
    # (gisogd66.midural.ru, раздел 13). Источник обновляется ежедневно, но лоадер
    # инкрементален (карточки тянутся только для новых/изменившихся) → еженедельно с
    # запасом. Источник БЕЗ гео-блока и БЕЗ авторизации — не привязан к прод-IP. 06:30
    # МСК вторник — в том же вторник-кластере, что и capacity-sync (последний — heat в
    # 06:00), сразу после него, вне monday-нагрузки. Celery conf.timezone=Europe/Moscow.
    schedule["gisogd-permits-weekly"] = {
        "task": "tasks.gisogd_permits_sync.sync_gisogd_permits",
        "schedule": _parse_cron("30 6 * * tue"),  # 06:30 МСК, вторник
        "options": {"queue": "celery"},
    }

    # Developer registry refresh (#1088 «GG-форсайт») — ежемесячно 1-го числа в 05:30 МСК,
    # через 30 мин ПОСЛЕ ekburg-permits-monthly (05:00), чтобы permits-сторона реестра
    # обновлялась на свежих РНС/РВЭ. fn_refresh_developer_registry() → REFRESH MATERIALIZED
    # VIEW CONCURRENTLY developer_registry (миграция 149; не блокирует читателей analyze).
    # Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233). Техническая
    # infra-задача (не в job_settings, как refresh-quarter-price-index). domrf-сторона
    # обновляется ежедневно (scrape_kn), но ИНН/track-record меняются редко — monthly хватает.
    schedule["developer-registry-refresh-monthly"] = {
        "task": "tasks.developer_registry_refresh.refresh_developer_registry",
        "schedule": _parse_cron("30 5 1 * *"),  # 05:30 MSK, 1st of month
        "options": {"queue": "celery"},
    }

    # ПЗЗ территориальных зон ЕКБ из PKK6 ArcGIS — DISABLED 2026-06-13 (issue #259).
    #
    # ПОЧЕМУ ОТКЛЮЧЕНО: источник pkk.rosreestr.ru/.../PKK6/ZONES deprecated (Росреестр
    # вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни
    # разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём:
    # zone_regulation_cache (#1059, beat zone-regulation-refresh-monthly) + NSPD
    # territorial_zones dumps (Phase 2.5 bulk_harvest, layer 875838). Активный beat
    # каждый месяц дёргал deprecated PKK6 → broad except в pzz_sync.py логировал
    # ошибку как error → рекуррентил GlitchTip BACKEND-1B.
    #
    # Ручной перезапуск (если появится рабочий источник) остаётся через admin-эндпоинт
    # POST /api/v1/admin/scrape/pzz-sync. Ref: #233 (исходно «0 rows»), #259 (disable).
    # schedule["pzz-sync-monthly"] = {
    #     "task": "tasks.pzz_sync.sync_pzz_zones_ekb",
    #     "schedule": _parse_cron("0 3 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"},
    # }

    # Catalog-FLAT scrape (#2442 Task 2) — наполняет per-flat catalog-поля
    # (цена/статус/отделка/потолки/дата обновления) + plan-изображения квартир из
    # SSR-страниц каталога.  Селектит domrf_kn_flats WHERE catalog_url_hash IS NOT NULL.
    #
    # DISABLED (как и object-level выше): 1) тот же DOM.РФ WAF hard-ban на VPS IP —
    # каталог-квартиры используют тот же BrowserSession + /сервисы/* paths.
    # 2) Sequencing: пока #2442 Task 1 (elemId → catalog_url_hash) не задеплоен и
    # свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет.
    # Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом).
    # Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку —
    # напр. четверг 04:00 UTC.
    # schedule["scrape-kn-catalog-flats-weekly"] = {
    #     "task": "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats",
    #     "schedule": _parse_cron("0 4 * * 4"),  # Thursday 04:00 UTC
    #     "kwargs": {"region_code": 66, "max_flats": 300},
    #     "options": {"queue": "celery"},
    # }

    # Quarter price index refresh (#762) — monthly on the 5th at 05:00 MSK.
    # Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
    # 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 5 5 * *"),  # 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 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233).
    # 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 4 * * mon"),  # 04:00 МСК
        "kwargs": {"region_code": 66, "max_age_days": 90, "batch_size": 50},
        "options": {"queue": "celery"},
    }

    # CBR макро-показатели (ключевая ставка) → macro_indicator (#945 PR B,
    # GG-форсайт). Ставка меняется редко (8 заседаний ЦБ в год) — еженедельного
    # прогона с запасом достаточно. ON CONFLICT делает прогон идемпотентным
    # (полный бэкфилл с 2019-01-01 каждый раз; дёшево — ~1 SOAP-запрос + ~1500
    # upsert'ов). Понедельник 05:30 МСК (Celery conf.timezone=Europe/Moscow, т.е.
    # crontab трактуется в МСК) — после publish итогов пятничного заседания. Задача
    # техническая, не управляется через job_settings — поэтому добавлена сюда (как
    # poi-sync / refresh-analytics). Оператор может перенести расписание правкой crontab.
    schedule["cbr-macro-sync-weekly"] = {
        "task": "tasks.cbr_macro_sync.cbr_macro_sync",
        "schedule": _parse_cron("30 5 * * mon"),
        "options": {"queue": "celery"},
    }

    # Росстат макро-показатели (демография региона 65 / ЕКБ) → macro_indicator
    # (#946 EPIC2). Ряды Росстата (open-data) обновляются редко (годовые) — ежемесячного
    # прогона с большим запасом достаточно. ON CONFLICT делает прогон идемпотентным
    # (полный ре-fetch каждый раз; дёшево — пара CSV-файлов). 1-е число месяца 05:45 МСК
    # (Celery conf.timezone=Europe/Moscow → crontab в МСК); по нагрузке не пересекается с
    # cbr-macro (пн 05:30). ИПЦ/доходы/СМР-цены — EMISS-only за WAF, пока не тянутся
    # (см. rosstat_emiss docstring). Задача техническая, не управляется через job_settings —
    # добавлена сюда (как cbr/poi/refresh). Оператор может перенести расписание правкой crontab.
    schedule["rosstat-macro-sync-monthly"] = {
        "task": "tasks.rosstat_macro_sync.rosstat_macro_sync",
        "schedule": _parse_cron("45 5 1 * *"),
        "options": {"queue": "celery"},
    }

    # Supply-layers пересчёт (#950 EPIC 6 Step5, GG-форсайт) → supply_layers (м.125).
    # Детерминированно UPSERT-ит L1(open)/L2(hidden)/L3(future) по всем районам Свердл.
    # обл. через supply_layers.compute_all_layers. ON CONFLICT идемпотентен в рамках
    # одного snapshot_date (same-day re-run обновляет мутабельные колонки).
    #
    # Понедельник 06:00 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК).
    # ORDERING ASSUMPTION: scrape_kn (наполняет domrf_kn_objects.free_flats, м.124 —
    # источник L2 hidden) идёт Mon 04:15 МСК (settings.scrape_kn_cron='15 4 * * mon').
    # 06:00 даёт ~1.75 ч запаса, чтобы supply-layers считались по СВЕЖИМ free_flats.
    # Если scrape_kn перенесут позже/он затянется — сдвинуть этот cron соответственно
    # (или сделать chain). Задача техническая, не в job_settings (как cbr/poi/refresh).
    schedule["supply-layers-refresh-weekly"] = {
        "task": "tasks.supply_layers_refresh.supply_layers_refresh",
        "schedule": _parse_cron("0 6 * * mon"),
        "options": {"queue": "celery"},
    }

    # ИРД-overlay harvest (#1067 B5): grid-walk готовых НСПД ИРД-слоёв по кварталам ЕКБ →
    # ird_overlays (м.132). Понедельник 05:00 МСК — до supply-layers (06:00), не конкурируя
    # с scrape_kn (04:15). Геометрия зон стабильна, еженедельно достаточно. Техническая,
    # не в job_settings (как cbr/poi/supply-layers).
    schedule["ird-harvest-weekly"] = {
        "task": "tasks.ird_harvest.harvest_ird_overlays",
        "schedule": _parse_cron("0 5 * * mon"),
        "options": {"queue": "celery"},
    }

    # ЗОУИТ-harvest ЕКБ-геопортала (#1063/#1064/#1065/#1066, #1067 GG-форсайт): WFS-фетч
    # gknspecial_zone по full_name-префиксам → ird_overlays (layer_kind=servitude/water_*/
    # airfield/okn_protection_zone/zrz). Понедельник 05:30 МСК — после ird-harvest (05:00),
    # до supply-layers (06:00). Геометрия зон стабильна, еженедельно достаточно.
    schedule["gknspecial-harvest-weekly"] = {
        "task": "tasks.gknspecial_harvest.harvest_gknspecial_zones",
        "schedule": _parse_cron("30 5 * * mon"),
        "options": {"queue": "celery"},
    }

    # Opportunity-ЗУ harvest (#1086): grid-walk НСПД opportunity-слоёв по кварталам ЕКБ →
    # ird_overlays (layer_kind='opportunity_*'). 1-е число месяца 05:30 МСК — реже ИРД (weekly),
    # opportunity-земля меняется медленнее ограничений. Тот же движок, что ird-harvest.
    schedule["opportunity-harvest-monthly"] = {
        "task": "tasks.opportunity_harvest.harvest_opportunity_overlays",
        "schedule": _parse_cron("30 5 1 * *"),
        "options": {"queue": "celery"},
    }

    # Cross-load ETL tradein→gendesign (#976 950-E5): tradein.houses → newbuilding_listings.
    # Ночной запуск: 00:30 UTC = 03:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК).
    # Не в job_settings (технический ETL, не требует конфигурации UI).
    # Идемпотентен через ON CONFLICT (source, ext_house_id).
    # Если TRADEIN_DATABASE_URL не задан → warn-log, {"disabled": True} без исключения.
    schedule["newbuilding-crossload-nightly"] = {
        "task": "tasks.etl_newbuilding_crossload.etl_newbuilding_crossload",
        "schedule": _parse_cron("30 0 * * *"),  # 00:30 UTC = 03:30 МСК
        "options": {"queue": "celery"},
    }

    # ППТ/ПМТ harvest (#1085): WFS-фетч геопортала ЕКБ по bbox агломерации → planning_projects.
    # 1-е число месяца 06:00 МСК — после opportunity (05:30). Документы планировки стабильны,
    # ежемесячно достаточно. WFS BBOX (не grid-walk) — один запрос на слой.
    schedule["planning-harvest-monthly"] = {
        "task": "tasks.planning_harvest.harvest_planning_projects",
        "schedule": _parse_cron("0 6 1 * *"),
        "options": {"queue": "celery"},
    }

    # Прогрев кэша ПЗЗ-регламента зон ЕКБ (#1059/C8b): WFS-перечисление терзон → urbanCard →
    # zone_regulation_cache. 1-е число месяца 06:30 МСК — после planning (06:00). Регламент
    # меняется только при правке ПЗЗ, ежемесячно достаточно. Наполняет кэш C8b проактивно.
    schedule["zone-regulation-refresh-monthly"] = {
        "task": "tasks.zone_regulation_refresh.refresh_zone_regulations",
        "schedule": _parse_cron("30 6 1 * *"),
        "options": {"queue": "celery"},
    }

    # КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
    # раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
    schedule["ekb-krt-sync-weekly"] = {
        "task": "tasks.ekb_krt_sync.sync_krt_sites",
        "schedule": _parse_cron("0 5 * * wed"),
        "options": {"queue": "celery"},
    }

    # КРТ-геометрии ЕКБ (#1130): WFS-фичи portal_geo_oks_krt → ekb_krt_geometry.
    # Геометрия КРТ-территорий стабильна (новые площадки — квартальные решения ПАГЕ),
    # раз в неделю достаточно. Среда 05:30 МСК — сразу после ekb-krt-sync-weekly (05:00),
    # одна сессия геопортала, не дублируем нагрузку.
    schedule["ekb-krt-geometry-sync-weekly"] = {
        "task": "tasks.krt_geometry_sync.sync_krt_geometry",
        "schedule": _parse_cron("30 5 * * wed"),
        "options": {"queue": "celery"},
    }

    # Функциональные зоны генплана ЕКБ-2045 (#1058): ГИСОГД-СО WFS →
    # ekb_genplan_functional_zone (м.139). ~6436 полигонов, EPSG:4326. Генплан
    # меняется редко (раз в год+), еженедельно избыточно но дёшево — WFS-фетч +
    # upsert по globalid идемпотентен. Понедельник 06:30 МСК — после supply-layers
    # (06:00). Техническая, не в job_settings.
    schedule["genplan-zones-sync-weekly"] = {
        "task": "tasks.genplan_zones_sync.sync_functional_zones",
        "schedule": _parse_cron("30 6 * * mon"),
        "options": {"queue": "celery"},
    }

    # ОКН-объекты ЕКБ/Свердл.обл из АИС ЕГРКН (#1066): POST searchMap →
    # okn_objects (м.141). ~800+ фич (54 фед + 213 рег + remainder по ЕКБ;
    # область добавляет дополнительные). Воскресенье 04:30 МСК — не конкурирует
    # с ird-harvest (понедельник 05:00) и gknspecial (05:30). Реестр ОКН меняется
    # медленно — еженедельно достаточно. Техническая, не в job_settings.
    schedule["okn-objects-sync-weekly"] = {
        "task": "tasks.okn_objects_sync.sync_okn_objects",
        "schedule": _parse_cron("30 4 * * sun"),
        "options": {"queue": "celery"},
    }

    # ОКН-анализ ЕКБ (#1062): OCR PDF-сканов изъятия с екатеринбург.рф → land_reservation.
    # Постановления об изъятии выходят редко (2-4 раза в год) → еженедельного прогона хватает.
    # Пятница 07:00 МСК — не пересекается с monday-кластером (ird/gkn/supply/cbr).
    # Tesseract rus: каждый PDF ~30 стр., OCR ~1-2 мин/PDF → итого <10 мин/прогон.
    schedule["izyatie-ocr-ingest-weekly"] = {
        "task": "tasks.izyatie_ocr_ingest.ingest_izyatie_ocr",
        "schedule": _parse_cron("0 7 * * fri"),
        "options": {"queue": "celery"},
    }

    # District-level индексы локаций (#948 Part B, §8.2) → location (м.146): reuse
    # per-district forecast-функций (market_metrics/future_supply/POI), нормализация
    # 0..1, upsert по 8 районам ЕКБ. Понедельник 07:00 МСК — ПОСЛЕ supply-layers
    # (06:00) и genplan (06:30), чтобы future_supply-индекс считался по СВЕЖЕМУ складу
    # предложения. Индексы меняются медленно (рынок поглощает сток неделями) —
    # еженедельно достаточно. ON CONFLICT идемпотентен. Техническая, не в job_settings
    # (как cbr/supply-layers). Оператор может перенести расписание правкой crontab.
    schedule["location-refresh-weekly"] = {
        "task": "tasks.location_refresh.location_refresh",
        "schedule": _parse_cron("0 7 * * mon"),
        "options": {"queue": "celery"},
    }

    # Refresh mv_layout_velocity (#1666) — питает «лучшие планировки» best_layouts (#113).
    # До этого MV рефрешился ТОЛЬКО ручным вызовом refresh_layout_velocity() → в проде
    # данные молча устаревали. REFRESH ... CONCURRENTLY (non-blocking) по mv_layout_velocity.
    #
    # Воскресенье 03:00 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233).
    # Намеренно ВНЕ monday-кластера тяжёлых site_finder-рефрешей (scrape_kn 04:15,
    # ird 05:00, gknspecial 05:30, supply-layers 06:00, genplan 06:30, location 07:00) и
    # вне воскресного okn-objects (04:30) — час запаса, не конкурирует за БД/CPU.
    # MV агрегирует темпы вымывания планировок (меняются медленно) → еженедельно хватает.
    # Техническая infra-задача, не в job_settings (как refresh-quarter-price-index / supply-layers).
    schedule["refresh-layout-velocity"] = {
        "task": "tasks.refresh_layout_velocity.refresh_layout_velocity",
        "schedule": _parse_cron("0 3 * * sun"),  # 03:00 MSK, воскресенье
        "options": {"queue": "celery"},
    }

    # Sales-tracker MVs (#61) — питают Site Finder Velocity Score (4-й критерий) +
    # recommend_mix / sellout-forecast. Оба MV (mv_sales_tracker_velocity_by_district,
    # mv_sales_tracker_absorption_curves) рефрешатся CONCURRENTLY (non-blocking, требуют
    # unique-индексы из миграции 161). Источник — objective_lots / objective_lots_history
    # (Объектив-шахматки), наполняются objective_sync (Mon 04:15 МСК по умолчанию).
    #
    # Понедельник 04:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233) —
    # ПОСЛЕ objective_sync (04:15), чтобы агрегаты считались по свежему снапшоту; в
    # окне до тяжёлого monday-кластера site_finder-рефрешей (ird 05:00, gknspecial 05:30,
    # supply-layers 06:00). Refresh лёгкий (~6с на 1.1M lots). Техническая infra-задача,
    # не в job_settings (как refresh-quarter-price-index / refresh-layout-velocity).
    schedule["mv-sales-tracker-refresh-weekly"] = {
        "task": "tasks.mv_sales_tracker_refresh.refresh_sales_tracker_mvs",
        "schedule": _parse_cron("30 4 * * mon"),  # 04:30 MSK, понедельник
        "options": {"queue": "celery"},
    }

    # Data-freshness monitor: ежедневно 09:00 МСК (Celery conf.timezone=Europe/Moscow →
    # crontab в МСК, #1233). Запускает compute_freshness по 5 run-таблицам и шлёт
    # GlitchTip-алёрт, если критичный источник устарел/упал. 09:00 — после ночного
    # кластера скрейперов (03:00–07:00) и в начале рабочего дня, чтобы оператор увидел
    # деградацию свежести данных утром. Только читает + алертит (не пишет), дёшево.
    # Техническая, не в job_settings (как refresh-analytics / mv-sales-tracker).
    schedule["scrape-freshness-check-daily"] = {
        "task": "tasks.scrape_freshness_check.scrape_freshness_check",
        "schedule": _parse_cron("0 9 * * *"),  # 09:00 MSK ежедневно
        "options": {"queue": "celery"},
    }

    return schedule
