"""Celery task: посчитать §22 SiteFinderReport в фоне и сохранить в analysis_runs.

Step 3b-i проводки форсайта. §22-оркестратор ``build_site_finder_report`` (#1056,
forecasting/orchestrator.py) считает форсайт из §9.x-слоёв — это занимает ~30-180с
(ЕКБ-wide ~184с, scoped-сегмент меньше), поэтому НЕ может крутиться inline на
синхронном /analyze-эндпоинте. Здесь — фоновая Celery-таска, которая его запускает
и персистит результат рядом с site-analysis раном в той же таблице ``analysis_runs``.

ПОТОК (read → build → persist):
  1. Читаем ПОСЛЕДНИЙ site-analysis ран ('analyze-1.0') для cad_num через
     schema-фильтрованный ``latest_run_for(..., schema_version=ANALYZE_SCHEMA_VERSION)``
     (#3b-i, repository.py) — НЕ через view: поверх analyze-рана уже могут лежать
     §22-раны ('1.0'), view вернул бы их, а нам нужна именно site-analysis база.
     Нет такого рана → logger.warning + return None (нечего форсайтить — graceful,
     без падения воркера).
  2. ``analyze = row.result`` (полный персиснутый dict analyze — несёт district +
     competitors, которые нужны оркестратору). district — из денорм-колонки рана,
     fallback в analyze["district"]["district_name"].
  3. ``build_site_finder_report(db, analyze=…, cad_num=…, district=…, horizons=…)``
     — горизонты = §9.x default grid, с гарантией что запрошенный ``horizon`` в сетке.
  4. ``report.as_dict()`` → ``persist_analysis_run(... schema_version="1.0",
     status="done" ...)``. persist сам best-effort (SAVEPOINT + commit), нормализует
     status/confidence под CHECK'и миграции 127 (status='done' → нормализуется в
     'complete'; форма CHECK не знает 'done'). Возвращаем id новой строки (или None).

NB схема: site-analysis раны несут schema_version='analyze-1.0' (ANALYZE_SCHEMA_VERSION),
§22-форсайт-раны — '1.0' (собственный SiteFinderReport._SCHEMA_VERSION, прокидывается
из ``d["schema_version"]``). Оба сосуществуют в analysis_runs, различаются по
schema_version — поэтому read берёт именно 'analyze-1.0', а write кладёт '1.0'.

Mirror conventions (supply_layers_refresh.py / cbr_macro_sync.py): ``@celery_app.task``,
``SessionLocal()`` + try/finally close, ``logger`` (не print), синхронный ``def`` (НЕ
async внутри таски), ``CAST(:x AS type)`` живёт в repository (тут SQL не пишем).

TIME LIMIT: build_site_finder_report может занять минуты — ставим щедрый per-task лимит
(time_limit=900с / soft_time_limit=840с), чтобы таску не убили mid-compute. Глобального
task_time_limit в celery_app.py нет (только timezone + beat_schedule), так что override
тут — единственный лимит для этой таски.

REGISTRATION: добавлено в ``include=[...]`` celery_app.py (зеркало того, как
зарегистрирован supply_layers_refresh) — beat-расписания НЕТ намеренно (таска
enqueue-on-demand; триггер из /analyze — отдельный шаг 3b-ii, вне scope).
"""

from __future__ import annotations

import logging
from typing import Any

from app.core.db import SessionLocal
from app.services.analysis_runs.repository import (
    ANALYZE_SCHEMA_VERSION,
    latest_run_for,
    persist_analysis_run,
)
from app.services.forecasting.orchestrator import build_site_finder_report
from app.workers.celery_app import celery_app

logger = logging.getLogger(__name__)

# Горизонты прогноза по умолчанию (мес) — зеркало §9.x default grid (orchestrator.py
# _DEFAULT_HORIZONS / #952/#984/#986). Запрошенный horizon домешивается, если его нет.
_DEFAULT_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)


def _horizons_for(horizon: int) -> list[int]:
    """Default-сетка горизонтов с ГАРАНТИЕЙ что запрошенный ``horizon`` в ней. PURE.

    §9.x single-горизонтные сервисы (score_card/overlay/future_supply) берут primary
    из сетки (orchestrator._primary_horizon → 12 если есть, иначе первый). Чтобы
    запрошенный horizon реально влиял на прогноз, добавляем его в сетку (отсортировано,
    без дублей). horizon ≤ 0 (бессмысленный) → возвращаем чистую default-сетку.
    """
    if horizon <= 0:
        return list(_DEFAULT_HORIZONS)
    return sorted(set(_DEFAULT_HORIZONS) | {horizon})


@celery_app.task(
    bind=True,
    name="tasks.forecast.forecast_site_finder_report",
    time_limit=900,
    soft_time_limit=840,
)
def forecast_site_finder_report(
    self: Any,
    cad_num: str,
    horizon: int = 12,
    created_by: str | None = None,
) -> int | None:
    """Посчитать §22 SiteFinderReport для участка в фоне и сохранить в analysis_runs.

    Берёт последний site-analysis ран ('analyze-1.0') как базу, считает §22-форсайт
    через оркестратор (#1056) и персистит ``SiteFinderReport.as_dict()`` отдельной
    строкой ('1.0') в той же таблице. Best-effort на каждом шаге: нет базы → None
    (warning, не crash); неожиданная ошибка compute → logger.exception + None (таска
    не валит воркер — persist сам best-effort, и форсайт может честно не собраться).

    Args:
        cad_num: кадастровый номер участка.
        horizon: основной горизонт прогноза (мес); домешивается в default-сетку.
        created_by: автор рана (прокидывается в persist; None допустимо).

    Returns:
        id новой строки analysis_runs (§22-форсайт, schema_version='1.0') или None,
        если базы для прогноза нет / персист не удался / compute упал.
    """
    db = SessionLocal()
    try:
        # 1. База прогноза — последний site-analysis ран ('analyze-1.0'), НЕ через view.
        row = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
        if row is None:
            logger.warning(
                "forecast_site_finder_report: no %s run for cad=%s — nothing to forecast "
                "from (run /analyze first); returning None",
                ANALYZE_SCHEMA_VERSION,
                cad_num,
            )
            return None

        # 2. analyze-dict (несёт district + competitors) + district (денорм → fallback).
        analyze: dict[str, Any] = row.result or {}
        district = row.district or (analyze.get("district") or {}).get("district_name")

        horizons = _horizons_for(horizon)
        logger.info(
            "forecast_site_finder_report: building §22 report cad=%s district=%s "
            "horizon=%s horizons=%s (base run id=%s)",
            cad_num,
            district,
            horizon,
            horizons,
            row.id,
        )

        # 3. §22-форсайт через оркестратор (#1056) — может занять минуты (time_limit=900).
        report = build_site_finder_report(
            db,
            analyze=analyze,
            cad_num=cad_num,
            district=district,
            horizons=horizons,
        )
        d = report.as_dict()

        # 4. Персист отдельной строкой '1.0'. status='done' нормализуется repository'ем
        # под CHECK миграции 127 (→ 'complete'); confidence/segment — из as_dict().
        new_id = persist_analysis_run(
            db,
            cad_num=cad_num,
            result=d,
            params={"horizon": horizon, "source": "forecast-task"},
            district=district,
            confidence=(d.get("confidence") or {}).get("level"),
            status="done",
            schema_version=d["schema_version"],  # "1.0" (SiteFinderReport._SCHEMA_VERSION)
            created_by=created_by,
            segment=(d.get("meta") or {}).get("segment"),
        )
        logger.info(
            "forecast_site_finder_report: persisted §22 report cad=%s run_id=%s schema_version=%s",
            cad_num,
            new_id,
            d.get("schema_version"),
        )
        return new_id
    except Exception:
        # Неожиданный сбой compute/чтения: логируем traceback и НЕ роняем воркер —
        # форсайт может честно не собраться (тонкие данные / сбой §9.x-шва), а persist
        # сам best-effort. Возвращаем None — таска failed gracefully.
        logger.exception("forecast_site_finder_report failed for cad=%s (returning None)", cad_num)
        return None
    finally:
        db.close()
