"""Тесты Celery-таски forecast_site_finder_report (#3b-i, workers/tasks/forecast.py).

Чистые тесты (mock session + mock читателя/оркестратора/persist, без живой БД/§9.x).
Покрывают:
  • happy path: read latest 'analyze-1.0' → build_site_finder_report → persist с
    schema_version='1.0', status='done', report-dict как result, верными
    confidence/segment/params; возврат нового id;
  • graceful: нет 'analyze-1.0' рана → return None, build НЕ зван, persist НЕ зван,
    без исключения;
  • read зовётся именно с schema_version=ANALYZE_SCHEMA_VERSION (база = site-analysis);
  • horizon домешивается в default-сетку горизонтов;
  • неожиданный сбой compute → return None (таска не валит воркер), db.close вызван.
"""

from __future__ import annotations

import os
from typing import Any
from unittest.mock import MagicMock, patch

os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

from app.services.analysis_runs.repository import ANALYZE_SCHEMA_VERSION


def _fake_analyze_row(
    *,
    result: dict[str, Any] | None = None,
    district: str | None = "Кировский",
    run_id: int = 101,
) -> MagicMock:
    """Fake Row последнего 'analyze-1.0' рана (атрибутный доступ row.result/.district/.id)."""
    row = MagicMock(name="analyze-row")
    row.id = run_id
    row.district = district
    row.result = (
        result
        if result is not None
        else {
            "district": {"district_name": "Кировский"},
            "competitors": [{"obj_class": "комфорт"}],
        }
    )
    return row


def _fake_report_dict(
    *,
    schema_version: str = "1.0",
    confidence_level: str | None = "low",
    segment: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Стаб SiteFinderReport.as_dict() — только поля, которые читает таска для persist."""
    return {
        "schema_version": schema_version,
        "advisory": True,
        "confidence": {"level": confidence_level},
        "meta": {"segment": segment if segment is not None else {"obj_class": "комфорт"}},
        "exec_summary": {"headline": "stub"},
    }


def _fake_report(d: dict[str, Any]) -> MagicMock:
    """Стаб отчёта с as_dict() → d."""
    report = MagicMock(name="report")
    report.as_dict.return_value = d
    return report


# ── happy path ───────────────────────────────────────────────────────────────


def test_task_happy_path_persists_forecast_run() -> None:
    """Read 'analyze-1.0' → build → persist('1.0', status='done', report-dict, right
    confidence/segment/params); возвращает новый id."""
    from app.workers.tasks import forecast as task_mod

    db = MagicMock()
    report_dict = _fake_report_dict(confidence_level="low", segment={"obj_class": "комфорт"})

    with (
        patch.object(task_mod, "SessionLocal", return_value=db),
        patch.object(task_mod, "latest_run_for", return_value=_fake_analyze_row()) as mock_read,
        patch.object(
            task_mod, "build_site_finder_report", return_value=_fake_report(report_dict)
        ) as mock_build,
        patch.object(task_mod, "persist_analysis_run", return_value=777) as mock_persist,
    ):
        result = task_mod.forecast_site_finder_report.run("66:41:0000000:1", 12, "tester")

    assert result == 777

    # read взят именно по site-analysis схеме
    mock_read.assert_called_once()
    assert mock_read.call_args.kwargs["schema_version"] == ANALYZE_SCHEMA_VERSION

    # build получил analyze-dict + district + cad_num
    mock_build.assert_called_once()
    bkw = mock_build.call_args.kwargs
    assert bkw["cad_num"] == "66:41:0000000:1"
    assert bkw["district"] == "Кировский"
    assert bkw["analyze"]["competitors"] == [{"obj_class": "комфорт"}]
    assert 12 in bkw["horizons"]

    # persist получил отчёт-dict как result + контракт §22-рана
    mock_persist.assert_called_once()
    pkw = mock_persist.call_args.kwargs
    assert pkw["cad_num"] == "66:41:0000000:1"
    assert pkw["result"] is report_dict
    assert pkw["schema_version"] == "1.0"
    assert pkw["status"] == "done"
    assert pkw["confidence"] == "low"
    assert pkw["segment"] == {"obj_class": "комфорт"}
    assert pkw["params"] == {"horizon": 12, "source": "forecast-task"}
    assert pkw["district"] == "Кировский"
    assert pkw["created_by"] == "tester"

    db.close.assert_called_once()


def test_task_district_fallback_to_analyze_dict() -> None:
    """district из row.district = None → fallback в analyze['district']['district_name']."""
    from app.workers.tasks import forecast as task_mod

    db = MagicMock()
    row = _fake_analyze_row(
        district=None,
        result={"district": {"district_name": "Чкаловский"}, "competitors": []},
    )
    report_dict = _fake_report_dict()

    with (
        patch.object(task_mod, "SessionLocal", return_value=db),
        patch.object(task_mod, "latest_run_for", return_value=row),
        patch.object(
            task_mod, "build_site_finder_report", return_value=_fake_report(report_dict)
        ) as mock_build,
        patch.object(task_mod, "persist_analysis_run", return_value=5),
    ):
        task_mod.forecast_site_finder_report.run("66:41:0000000:2", 12, None)

    assert mock_build.call_args.kwargs["district"] == "Чкаловский"


def test_task_horizon_merged_into_default_grid() -> None:
    """Запрошенный horizon (вне default-сетки) домешивается в horizons, отсортировано."""
    from app.workers.tasks import forecast as task_mod

    db = MagicMock()
    with (
        patch.object(task_mod, "SessionLocal", return_value=db),
        patch.object(task_mod, "latest_run_for", return_value=_fake_analyze_row()),
        patch.object(
            task_mod, "build_site_finder_report", return_value=_fake_report(_fake_report_dict())
        ) as mock_build,
        patch.object(task_mod, "persist_analysis_run", return_value=1),
    ):
        task_mod.forecast_site_finder_report.run("66:41:0000000:1", 9, None)

    horizons = mock_build.call_args.kwargs["horizons"]
    assert 9 in horizons
    assert horizons == sorted(horizons)
    # default-сетка сохранена
    assert {6, 12, 18, 24}.issubset(set(horizons))


# ── graceful: no base run ──────────────────────────────────────────────────────


def test_task_graceful_when_no_analyze_run() -> None:
    """Нет 'analyze-1.0' рана → return None, build НЕ зван, persist НЕ зван, без исключения."""
    from app.workers.tasks import forecast as task_mod

    db = MagicMock()
    with (
        patch.object(task_mod, "SessionLocal", return_value=db),
        patch.object(task_mod, "latest_run_for", return_value=None) as mock_read,
        patch.object(task_mod, "build_site_finder_report") as mock_build,
        patch.object(task_mod, "persist_analysis_run") as mock_persist,
    ):
        result = task_mod.forecast_site_finder_report.run("66:41:0000000:9", 12, None)

    assert result is None
    mock_read.assert_called_once()
    mock_build.assert_not_called()
    mock_persist.assert_not_called()
    db.close.assert_called_once()


# ── resilience: compute blows up ───────────────────────────────────────────────


def test_task_returns_none_on_compute_error() -> None:
    """Неожиданный сбой build_site_finder_report → return None (таска не валит воркер),
    persist НЕ зван, db закрыта."""
    from app.workers.tasks import forecast as task_mod

    db = MagicMock()
    with (
        patch.object(task_mod, "SessionLocal", return_value=db),
        patch.object(task_mod, "latest_run_for", return_value=_fake_analyze_row()),
        patch.object(
            task_mod, "build_site_finder_report", side_effect=RuntimeError("§9.x blew up")
        ),
        patch.object(task_mod, "persist_analysis_run") as mock_persist,
    ):
        result = task_mod.forecast_site_finder_report.run("66:41:0000000:1", 12, None)

    assert result is None
    mock_persist.assert_not_called()
    db.close.assert_called_once()


def test_task_returns_none_when_persist_returns_none() -> None:
    """persist best-effort вернул None (write провалился) → таска возвращает None, без краша."""
    from app.workers.tasks import forecast as task_mod

    db = MagicMock()
    with (
        patch.object(task_mod, "SessionLocal", return_value=db),
        patch.object(task_mod, "latest_run_for", return_value=_fake_analyze_row()),
        patch.object(
            task_mod, "build_site_finder_report", return_value=_fake_report(_fake_report_dict())
        ),
        patch.object(task_mod, "persist_analysis_run", return_value=None),
    ):
        result = task_mod.forecast_site_finder_report.run("66:41:0000000:1", 12, None)

    assert result is None
    db.close.assert_called_once()


# ── registration / time limit ──────────────────────────────────────────────────


def test_task_registered_in_celery_include() -> None:
    """Таска зарегистрирована (module в include-списке celery_app)."""
    from app.workers.celery_app import celery_app

    assert "app.workers.tasks.forecast" in celery_app.conf.include


def test_task_has_generous_time_limit() -> None:
    """build_site_finder_report идёт минуты → щедрый per-task лимит (time_limit=900)."""
    from app.workers.tasks.forecast import forecast_site_finder_report

    assert forecast_site_finder_report.time_limit == 900
    assert forecast_site_finder_report.soft_time_limit == 840


def test_task_name_matches_convention() -> None:
    """Имя таски следует house-style 'tasks.<module>.<func>'."""
    from app.workers.tasks.forecast import forecast_site_finder_report

    assert forecast_site_finder_report.name == "tasks.forecast.forecast_site_finder_report"
