"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1.

Покрывает:
  1. Чистую логику evaluate_sber_freshness (frozen now, без БД):
     - fresh:  age <= max_age_days (алерта нет);
     - stale:  age > max_age_days (алерт);
     - граница порога (== max_age_days → нет алерта; +1 день → алерт).
  2. check_sber_freshness с FakeDB (fresh / stale / empty→mark_failed / кастомный lag).
  3. Свойства миграции 180 (по образцу test_deals_freshness_monitor).
  4. Регистрацию в kit product_handlers (registry остаётся зелёным).
"""

from __future__ import annotations

import os
import re
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from typing import Any

import pytest

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

from app.core.config import settings
from app.services.product_handlers import build_product_handlers
from app.tasks import sber_freshness_monitor as mon

_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql"

# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12).
_MAY_2026 = date(2026, 5, 1)


# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────


def _now(y: int, m: int, d: int) -> datetime:
    return datetime(y, m, d, tzinfo=UTC)


def test_fresh_within_max_age() -> None:
    """age=30 ≤ max_age_days=60 — свежий, алерта нет."""
    v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60)
    assert v.stale is False
    assert v.age_days == 30
    assert v.latest_period == _MAY_2026


def test_stale_beyond_max_age() -> None:
    """Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт."""
    v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60)
    assert v.stale is True
    assert v.age_days == 72


def test_threshold_boundary_exact_no_alert() -> None:
    """Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >)."""
    v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60)
    assert v.age_days == 60
    assert v.stale is False


def test_threshold_boundary_next_day_alert() -> None:
    """Порог + 1 день (age=61) — первый алерт."""
    v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60)
    assert v.age_days == 61
    assert v.stale is True


# ── check_sber_freshness (FakeDB) ─────────────────────────────────────────────


class _Row:
    def __init__(self, latest: date | None) -> None:
        self.latest = latest


class _FakeResult:
    def __init__(self, latest: date | None) -> None:
        self._latest = latest

    def first(self) -> _Row:
        return _Row(self._latest)


class _FakeDB:
    def __init__(self, latest: date | None) -> None:
        self._latest = latest
        self.rolled_back = False

    def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
        return _FakeResult(self._latest)

    def rollback(self) -> None:
        self.rolled_back = True


def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
    calls: dict[str, Any] = {"done": None, "failed": None, "heartbeat": 0}
    monkeypatch.setattr(
        mon.runs_mod,
        "update_heartbeat",
        lambda *a, **k: calls.__setitem__("heartbeat", calls["heartbeat"] + 1),
    )
    monkeypatch.setattr(
        mon.runs_mod,
        "mark_done",
        lambda _db, run_id, counters: calls.__setitem__("done", dict(counters)),
    )
    monkeypatch.setattr(
        mon.runs_mod,
        "mark_failed",
        lambda _db, run_id, err, counters: calls.__setitem__("failed", err),
    )
    return calls


def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None:
    calls = _patch_runs(monkeypatch)
    db = _FakeDB(_MAY_2026)
    # @2026-05-31: age=30 ≤ 35+25=60 → нет алерта.
    out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31))  # type: ignore[arg-type]
    assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0}
    assert calls["done"] == out
    assert calls["failed"] is None


def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None:
    calls = _patch_runs(monkeypatch)
    db = _FakeDB(_MAY_2026)
    # @2026-07-12 (прод): age=72 > 60 → алерт.
    out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12))  # type: ignore[arg-type]
    assert out["alert"] == 1
    assert out["age_days"] == 72
    assert out["latest_month"] == 5
    # Монитор НЕ падает при алерте — прогон done, а не failed.
    assert calls["done"] == out
    assert calls["failed"] is None


def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
    calls = _patch_runs(monkeypatch)
    db = _FakeDB(None)
    out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12))  # type: ignore[arg-type]
    assert out["alert"] == 0
    assert calls["done"] is None
    assert calls["failed"] is not None


def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None:
    _patch_runs(monkeypatch)
    db = _FakeDB(_MAY_2026)
    # lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено.
    out = mon.check_sber_freshness(
        db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12)
    )  # type: ignore[arg-type]
    assert out["alert"] == 1


def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None:
    """Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS."""
    _patch_runs(monkeypatch)
    db = _FakeDB(_MAY_2026)
    threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
    # Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт.
    exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold)
    out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact)  # type: ignore[arg-type]
    assert out_exact["age_days"] == threshold
    assert out_exact["alert"] == 0
    out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1))  # type: ignore[arg-type]
    assert out_over["alert"] == 1


# ── Миграция 180 ──────────────────────────────────────────────────────────────


def test_migration_180_exists() -> None:
    assert _MIGRATION_180.is_file(), f"missing migration: {_MIGRATION_180}"


def test_migration_180_seeds_source() -> None:
    sql = _MIGRATION_180.read_text("utf-8")
    assert "'sber_freshness_monitor'" in sql


def test_migration_180_enabled_true() -> None:
    sql = _MIGRATION_180.read_text("utf-8")
    assert "true" in sql


def test_migration_180_is_idempotent() -> None:
    sql = _MIGRATION_180.read_text("utf-8")
    assert "ON CONFLICT (source) DO NOTHING" in sql


def test_migration_180_is_transactional() -> None:
    sql = _MIGRATION_180.read_text("utf-8")
    assert "BEGIN;" in sql
    assert "COMMIT;" in sql


def test_migration_180_window_9_to_10_utc() -> None:
    sql = _MIGRATION_180.read_text("utf-8")
    assert re.search(r"\b9\b", sql), "window_start_hour 9 missing"
    assert re.search(r"\b10\b", sql), "window_end_hour 10 missing"


def test_migration_180_lag_allowance_25() -> None:
    sql = _MIGRATION_180.read_text("utf-8")
    assert "lag_allowance_days" in sql
    assert "25" in sql


def test_migration_180_no_psycopg_trap() -> None:
    sql = _MIGRATION_180.read_text("utf-8")
    assert not re.search(r":\w+::", sql)


# ── Регистрация в kit registry ─────────────────────────────────────────────────


def test_kit_product_handler_registered() -> None:
    # ctx не нужен для сборки dict ключей — build_product_handlers его не замыкает.
    handlers = build_product_handlers(ctx=None)  # type: ignore[arg-type]
    assert "sber_freshness_monitor" in handlers
