"""Сервис для чтения и обновления централизованных настроек Celery-job'ов.

Таблица job_settings содержит по одной строке на job_type:
  'scrape_kn' | 'nspd_geo' | 'objective_etl' | 'objective_sync'

Используется:
  - workers.celery_app: строит beat_schedule при старте (один раз)
  - api.v1.admin_jobs: GET/PUT /admin/jobs/settings
  - workers.tasks.*: читают rate_ms / extra_config при каждом запуске

Безопасный fallback: если строка отсутствует или БД недоступна — возвращает
hardcoded defaults чтобы не ломать старый код.
"""

from __future__ import annotations

import logging
from datetime import UTC, datetime
from typing import Any

from sqlalchemy import text

logger = logging.getLogger(__name__)

# ── Fallback defaults (до инициализации БД / отсутствующие строки) ────────────

_DEFAULTS: dict[str, dict[str, Any]] = {
    "scrape_kn": {
        "enabled": True,
        "queue_name": "scrape_kn",
        "cron_schedule": "15 4 * * mon",
        "rate_ms": None,
        "max_retries": 2,
        "max_concurrency": 1,
        "extra_config": {"default_regions": [66]},
        "description": "Скрейпинг КН (fallback default)",
    },
    "nspd_geo": {
        "enabled": True,
        "queue_name": "geo",
        "cron_schedule": None,
        "rate_ms": 600,
        "max_retries": 2,
        "max_concurrency": 1,
        "extra_config": {},
        "description": "NSPD geo backfill (fallback default)",
    },
    "objective_etl": {
        "enabled": True,
        "queue_name": "celery",
        "cron_schedule": None,
        "rate_ms": None,
        "max_retries": 2,
        "max_concurrency": 1,
        "extra_config": {"sqlite_path": "/data/anton-sqlite/analysis.db"},
        "description": "ETL SQLite → PG (fallback default)",
    },
    "objective_sync": {
        "enabled": True,
        "queue_name": "celery",
        "cron_schedule": "0 6 * * tue",
        "rate_ms": None,
        "max_retries": 2,
        "max_concurrency": 1,
        "extra_config": {
            "groups_csv": "Свердловская область,Челябинск,Тюмень,Пермь",
            "use_ddu": True,
            "use_dkp": True,
            "period_months_back": 1,
            "inter_group_delay_s": 30,
            "rate_ms": 3000,
        },
        "description": "Objective sync (fallback default)",
    },
}

_FALLBACK_UPDATED_AT = datetime(2026, 1, 1, tzinfo=UTC)


def _row_to_dict(r: Any) -> dict[str, Any]:
    """Маппинг ORM / sqlalchemy row → dict."""
    return {
        "job_type": r["job_type"],
        "enabled": bool(r["enabled"]),
        "queue_name": r["queue_name"],
        "cron_schedule": r["cron_schedule"],
        "rate_ms": r["rate_ms"],
        "max_retries": int(r["max_retries"]),
        "max_concurrency": int(r["max_concurrency"]),
        "extra_config": r["extra_config"] or {},
        "updated_at": r["updated_at"],
        "updated_by": r["updated_by"],
        "description": r["description"],
    }


def _fallback(job_type: str) -> dict[str, Any]:
    """Возвращает fallback dict для job_type; логирует warning."""
    logger.warning("job_settings: строка '%s' не найдена — используем hardcoded defaults", job_type)
    base = _DEFAULTS.get(
        job_type,
        {
            "enabled": True,
            "queue_name": "celery",
            "cron_schedule": None,
            "rate_ms": None,
            "max_retries": 2,
            "max_concurrency": 1,
            "extra_config": {},
            "description": f"Unknown job_type '{job_type}' — fallback",
        },
    )
    return {
        "job_type": job_type,
        **base,
        "updated_at": _FALLBACK_UPDATED_AT,
        "updated_by": None,
    }


# ── Public API ────────────────────────────────────────────────────────────────


def get_all(db) -> list[dict[str, Any]]:
    """Вернуть все строки job_settings. При ошибке БД — fallback на _DEFAULTS."""
    try:
        rows = (
            db.execute(
                text(
                    """
                    SELECT job_type, enabled, queue_name, cron_schedule, rate_ms,
                           max_retries, max_concurrency, extra_config,
                           updated_at, updated_by, description
                      FROM job_settings
                     ORDER BY job_type
                    """
                )
            )
            .mappings()
            .all()
        )
    except Exception as e:
        logger.warning("get_all job_settings: БД недоступна — fallback. %s", e)
        return [_fallback(jt) for jt in _DEFAULTS]

    if not rows:
        logger.warning("get_all job_settings: таблица пустая — fallback")
        return [_fallback(jt) for jt in _DEFAULTS]

    return [_row_to_dict(r) for r in rows]


def get_one(job_type: str, db) -> dict[str, Any]:
    """Вернуть одну строку по job_type. При отсутствии — fallback с warning."""
    try:
        row = (
            db.execute(
                text(
                    """
                    SELECT job_type, enabled, queue_name, cron_schedule, rate_ms,
                           max_retries, max_concurrency, extra_config,
                           updated_at, updated_by, description
                      FROM job_settings
                     WHERE job_type = :jt
                    """
                ),
                {"jt": job_type},
            )
            .mappings()
            .first()
        )
    except Exception as e:
        logger.warning("get_one job_settings '%s': БД недоступна — fallback. %s", job_type, e)
        return _fallback(job_type)

    if row is None:
        return _fallback(job_type)

    return _row_to_dict(row)


def update(
    job_type: str,
    db,
    *,
    enabled: bool | None = None,
    queue_name: str | None = None,
    cron_schedule: str | None = None,
    rate_ms: int | None = None,
    max_retries: int | None = None,
    max_concurrency: int | None = None,
    extra_config: dict | None = None,
    updated_by: str | None = None,
) -> dict[str, Any]:
    """PATCH-style upsert — обновляет только переданные поля.

    Если строки с таким job_type ещё нет, она создаётся (с дефолтами таблицы из
    data/sql/81_job_settings.sql для непереданных колонок). Раньше UPDATE без
    проверки rowcount молча терял изменения для несуществующих job_type, а PUT
    отвечал 200 c fallback-дефолтами (issue #1223).

    cron_schedule='' → NULL (manual-only). updated_at выставляется в NOW().
    """
    import json

    # Нормализация значений по той же логике, что и в исходном UPDATE.
    # cron_schedule: None = не трогать; '' = явный NULL (manual-only)
    cron_provided = cron_schedule is not None
    norm_cron: str | None = cron_schedule if cron_schedule else None

    # rate_ms: None = не трогать; 0 = явно обнулить (no throttle)
    rate_provided = rate_ms is not None
    norm_rate: int | None = rate_ms if rate_provided and rate_ms and rate_ms > 0 else None

    extra_provided = extra_config is not None
    norm_extra_json = json.dumps(extra_config, ensure_ascii=False) if extra_provided else None

    # ── UPDATE для существующей строки (PATCH-семантика без изменений) ───────
    sets: list[str] = ["updated_at = NOW()"]
    params: dict[str, Any] = {"jt": job_type}

    for field, value in [
        ("enabled", enabled),
        ("queue_name", queue_name),
        ("max_retries", max_retries),
        ("max_concurrency", max_concurrency),
        ("updated_by", updated_by),
    ]:
        if value is not None:
            sets.append(f"{field} = :{field}")
            params[field] = value

    if cron_provided:
        sets.append("cron_schedule = :cron_schedule")
        params["cron_schedule"] = norm_cron

    if rate_provided:
        sets.append("rate_ms = :rate_ms")
        params["rate_ms"] = norm_rate

    if extra_provided:
        sets.append("extra_config = CAST(:extra_config AS jsonb)")
        params["extra_config"] = norm_extra_json

    result = db.execute(
        text(f"UPDATE job_settings SET {', '.join(sets)} WHERE job_type = :jt"),
        params,
    )

    # Если строки не было — INSERT с явными значениями только для переданных колонок.
    # Остальные колонки получат defaults таблицы (enabled=true, queue_name='celery',
    # max_retries=2, max_concurrency=1, extra_config='{}'::jsonb, updated_at=now()).
    if (result.rowcount or 0) == 0:
        insert_cols: list[str] = ["job_type"]
        insert_vals: list[str] = [":jt"]
        insert_params: dict[str, Any] = {"jt": job_type}

        for field, value in [
            ("enabled", enabled),
            ("queue_name", queue_name),
            ("max_retries", max_retries),
            ("max_concurrency", max_concurrency),
            ("updated_by", updated_by),
        ]:
            if value is not None:
                insert_cols.append(field)
                insert_vals.append(f":{field}")
                insert_params[field] = value

        if cron_provided:
            insert_cols.append("cron_schedule")
            insert_vals.append(":cron_schedule")
            insert_params["cron_schedule"] = norm_cron

        if rate_provided:
            insert_cols.append("rate_ms")
            insert_vals.append(":rate_ms")
            insert_params["rate_ms"] = norm_rate

        if extra_provided:
            insert_cols.append("extra_config")
            insert_vals.append("CAST(:extra_config AS jsonb)")
            insert_params["extra_config"] = norm_extra_json

        # ON CONFLICT DO NOTHING — защита от гонки: между нашими UPDATE и INSERT
        # параллельный PUT мог вставить строку. В этом случае повторим UPDATE,
        # чтобы наши изменения не потерялись.
        insert_result = db.execute(
            text(
                f"INSERT INTO job_settings ({', '.join(insert_cols)}) "
                f"VALUES ({', '.join(insert_vals)}) "
                "ON CONFLICT (job_type) DO NOTHING"
            ),
            insert_params,
        )
        if (insert_result.rowcount or 0) == 0:
            logger.info(
                "job_settings.update: гонка по '%s' — повторный UPDATE после INSERT race",
                job_type,
            )
            db.execute(
                text(f"UPDATE job_settings SET {', '.join(sets)} WHERE job_type = :jt"),
                params,
            )
        else:
            logger.info("job_settings.update: вставлена новая строка '%s'", job_type)

    db.commit()
    return get_one(job_type, db)


def get_setting_value(job_type: str, key: str, default: Any = None, db=None) -> Any:
    """Читает значение из top-level полей строки или из extra_config[key].

    Порядок поиска:
      1. Прямое поле строки (enabled, rate_ms, queue_name, ...)
      2. extra_config[key]
      3. default

    Если db=None — возвращает default из _DEFAULTS[job_type].extra_config[key].
    """
    top_level_fields = {
        "enabled",
        "queue_name",
        "cron_schedule",
        "rate_ms",
        "max_retries",
        "max_concurrency",
        "updated_at",
        "updated_by",
        "description",
    }
    if db is None:
        defaults = _DEFAULTS.get(job_type, {})
        if key in top_level_fields:
            return defaults.get(key, default)
        return (defaults.get("extra_config") or {}).get(key, default)

    row = get_one(job_type, db)
    if key in top_level_fields:
        return row.get(key, default)
    return (row.get("extra_config") or {}).get(key, default)


def get_all_safe(db_factory) -> list[dict[str, Any]]:
    """Безопасное чтение всех строк при старте Celery beat — открывает свою сессию.

    db_factory: callable () -> Session (обычно SessionLocal)
    При любой ошибке — fallback на hardcoded defaults.
    """
    try:
        db = db_factory()
        try:
            return get_all(db)
        finally:
            db.close()
    except Exception as e:
        logger.warning("get_all_safe job_settings: fallback на defaults: %s", e)
        return [_fallback(jt) for jt in _DEFAULTS]
