"""Golden-parity: `scraper_kit.orchestration.scheduler` registry-dispatch ≡ старый if/elif.

Strangler-инвариант (#2136): registry-реестр (`build_registry` + `resolve_handler` +
`_dispatch`) должен маршрутизировать те же source'ы к тем же обработчикам, что и боевой
27-веточный `if/elif` в `app.services.scheduler.scheduler_loop`, и сохранить критичную
concurrency-логику (`_claim_run` advisory-lock + double-check, `reap_zombies` порог,
proxy_healthcheck sub-hourly post-claim).

Метод:
  1. ROUTING: для каждого source прогоняем ОДИН тик боевого `scheduler_loop` (все
     `trigger_*` замоканы) → фиксируем какой trigger сработал; параллельно `resolve_handler`
     kit-реестра обязан вернуть handler для того же source. Множества покрытых source'ов
     обязаны совпасть → таблица маршрутизации эквивалентна.
  2. CLAIM: `_claim_run` — happy-path, already-running skip, lock-busy skip, appeared-under-
     lock rollback. Мокаем БД + ctx.runs.
  3. ZOMBIE: `reap_zombies` возвращает число + commit.
  4. SUB-HOURLY: `reschedule_after_minutes` post_claim UPDATE + commit (proxy_healthcheck).
  5. DISPATCH e2e: `_dispatch` для kit-native sweep (мок pipeline) и продуктового handler —
     claim → свежая сессия → job вызван → close.

Без сети, без БД.
"""

from __future__ import annotations

import asyncio
import os
from contextlib import ExitStack
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

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

from scraper_kit.orchestration import scheduler as kit_sched
from scraper_kit.orchestration.scheduler import (
    Handler,
    SchedulerContext,
    _claim_run,
    _dispatch,
    build_registry,
    reap_zombies,
    reschedule_after_minutes,
    resolve_handler,
)

import app.services.scheduler as old_sched

# ── таблица маршрутизации: source → имя боевого trigger_* ─────────────────────
# (то, что `if/elif` в старом scheduler_loop выбирает для каждого source).
SOURCE_TO_OLD_TRIGGER: dict[str, str] = {
    "avito_city_sweep": "trigger_avito_city_sweep_run",
    "avito_full_load": "trigger_avito_full_load_run",
    "avito_full_load_exhaustive": "trigger_avito_full_load_exhaustive_run",
    "avito_newbuilding_sweep": "trigger_avito_newbuilding_sweep_run",
    "yandex_city_sweep": "trigger_yandex_city_sweep_run",
    "cian_history_backfill": "trigger_cian_backfill_run",
    "cian_city_sweep": "trigger_cian_city_sweep_run",
    "cian_full_load": "trigger_cian_full_load_run",
    "domclick_city_sweep": "trigger_domclick_city_sweep_run",
    "rosreestr_dkp_import": "trigger_rosreestr_dkp_run",
    "listing_source_snapshot": "trigger_listing_source_snapshot_run",
    "asking_to_sold_ratio_refresh": "trigger_asking_to_sold_ratio_run",
    "refresh_search_matview": "trigger_refresh_search_matview_run",
    "yandex_address_backfill": "trigger_yandex_address_backfill_run",
    "deactivate_stale_avito": "trigger_deactivate_stale_run",
    "deactivate_stale_yandex": "trigger_deactivate_stale_run",
    "deactivate_stale_cian": "trigger_deactivate_stale_run",
    "sber_index_pull": "trigger_sber_index_pull_run",
    "rosreestr_quarter_poll": "trigger_rosreestr_quarter_poll_run",
    "newbuilding_enrich": "trigger_newbuilding_enrich_run",
    "yandex_newbuilding_sweep": "trigger_yandex_newbuilding_sweep_run",
    "geocode_missing_listings": "trigger_geocode_missing_listings_run",
    "avito_detail_backfill": "trigger_avito_detail_backfill_run",
    "yandex_detail_backfill": "trigger_yandex_detail_backfill_run",
    "cadastral_geo_match": "trigger_cadastral_geo_match_run",
    "house_imv_backfill": "trigger_house_imv_backfill_run",
    "house_dedup_merge": "trigger_house_dedup_merge_run",
    "proxy_healthcheck": "trigger_proxy_healthcheck_run",
}

# все distinct trigger-имена, которые надо замокать в боевом scheduler_loop
_ALL_TRIGGER_NAMES = sorted(set(SOURCE_TO_OLD_TRIGGER.values()))

# kit-native (тело в scraper_kit.orchestration.pipeline) — регистрируются встроенно
_KIT_NATIVE_SOURCES = {
    "avito_city_sweep",
    "avito_full_load",
    "avito_full_load_exhaustive",
    "avito_newbuilding_sweep",
    "yandex_city_sweep",
    "cian_city_sweep",
    "cian_full_load",
    "domclick_city_sweep",
}


def _make_sched(source: str) -> dict[str, Any]:
    return {
        "id": 1,
        "source": source,
        "enabled": True,
        "window_start_hour": 2,
        "window_end_hour": 5,
        "default_params": {},
        "last_run_id": None,
        "last_run_at": None,
        "next_run_at": None,
    }


# ── kit-реестр с продуктовыми handler'ами (recording stubs) ──────────────────


def _build_recording_registry() -> tuple[dict[str, Handler], dict[str, MagicMock]]:
    """Kit-native sweeps + продуктовые recording-handler'ы. Возвращает (registry, fired-map)."""
    fired: dict[str, MagicMock] = {}

    def _make_job(name: str) -> Any:
        rec = MagicMock(name=name)
        fired[name] = rec

        async def _job(db: Any, run_id: int, params: dict[str, Any], ctx: Any) -> None:
            rec(db, run_id, params)

        return _job

    product: dict[str, Handler] = {}
    # продуктовые exact-match источники
    for src in SOURCE_TO_OLD_TRIGGER:
        if src in _KIT_NATIVE_SOURCES or src.startswith("deactivate_stale_"):
            continue
        product[src] = Handler(_make_job(src), src)
    # deactivate_stale_* — одна wildcard-запись (как боевой startswith)
    product["deactivate_stale_*"] = Handler(_make_job("deactivate_stale"), "deactivate_stale")

    return build_registry(product), fired


# ── 1. ROUTING parity ────────────────────────────────────────────────────────


async def _drive_old_one_tick(source: str) -> list[str]:
    """Прогнать ОДИН тик боевого scheduler_loop для одного source, вернуть сработавшие trigger'ы."""
    sch = _make_sched(source)
    triggers = {name: AsyncMock(return_value=1) for name in _ALL_TRIGGER_NAMES}

    with ExitStack() as es:
        es.enter_context(patch.object(old_sched, "reap_zombies", MagicMock()))
        es.enter_context(
            patch.object(old_sched, "get_due_schedules", MagicMock(return_value=[sch]))
        )
        es.enter_context(patch.object(old_sched, "SessionLocal", MagicMock()))
        es.enter_context(patch.object(old_sched.asyncio, "sleep", AsyncMock()))
        # shutdown: False на входе while, затем True после первого dispatch → выход
        es.enter_context(
            patch.object(
                old_sched, "shutdown_requested", MagicMock(side_effect=[False] + [True] * 10)
            )
        )
        for name, mock in triggers.items():
            es.enter_context(patch.object(old_sched, name, mock))
        await old_sched.scheduler_loop()

    return [name for name, m in triggers.items() if m.await_count > 0]


@pytest.mark.parametrize("source", sorted(SOURCE_TO_OLD_TRIGGER))
async def test_routing_parity_per_source(source: str) -> None:
    """Для каждого source: старый if/elif зовёт ожидаемый trigger, kit-реестр резолвит handler."""
    expected_trigger = SOURCE_TO_OLD_TRIGGER[source]

    # старый роутинг
    fired = await _drive_old_one_tick(source)
    assert fired == [
        expected_trigger
    ], f"source={source}: old scheduler fired {fired}, expected [{expected_trigger}]"

    # kit роутинг — тот же source обязан резолвиться в handler
    registry, _ = _build_recording_registry()
    handler = resolve_handler(source, registry)
    assert handler is not None, f"kit registry has no handler for source={source}"


def test_routing_coverage_sets_match() -> None:
    """Множество source'ов kit-реестра ⊇ множество, покрытое боевым if/elif."""
    registry, _ = _build_recording_registry()
    for source in SOURCE_TO_OLD_TRIGGER:
        assert resolve_handler(source, registry) is not None, f"kit misses source={source}"


def test_kit_native_handler_set() -> None:
    """Ровно 8 kit-native sweep-обработчиков зарегистрированы встроенно."""
    registry = build_registry()
    assert set(registry) == _KIT_NATIVE_SOURCES
    for src in _KIT_NATIVE_SOURCES:
        assert resolve_handler(src, registry).log_name == src


def test_unknown_source_resolves_none() -> None:
    registry, _ = _build_recording_registry()
    assert resolve_handler("totally_unknown_source", registry) is None


def test_kit_scheduler_has_no_app_imports() -> None:
    """kit scheduler развязан от app.* — ни одного `import app` / `from app` (AST, не substring)."""
    import ast
    import inspect

    source = inspect.getsource(kit_sched)
    tree = ast.parse(source)
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for alias in node.names:
                assert not alias.name.startswith("app"), f"illegal import app: {alias.name}"
        elif isinstance(node, ast.ImportFrom):
            assert not (node.module or "").startswith("app"), f"illegal from app: {node.module}"


def test_deactivate_stale_wildcard_prefix() -> None:
    """deactivate_stale_{avito,yandex,cian} → одна wildcard-запись (как боевой startswith)."""
    registry, _ = _build_recording_registry()
    h_avito = resolve_handler("deactivate_stale_avito", registry)
    h_yandex = resolve_handler("deactivate_stale_yandex", registry)
    h_cian = resolve_handler("deactivate_stale_cian", registry)
    assert h_avito is not None
    assert h_avito is h_yandex is h_cian  # один и тот же handler на всё семейство


# ── 2. _claim_run advisory-lock parity ───────────────────────────────────────


class _FakeResult:
    def __init__(self, *, scalar: Any = None, fetchone: Any = None, fetchall: Any = None) -> None:
        self._scalar = scalar
        self._fetchone = fetchone
        self._fetchall = fetchall or []

    def scalar(self) -> Any:
        return self._scalar

    def fetchone(self) -> Any:
        return self._fetchone

    def fetchall(self) -> Any:
        return self._fetchall


class _FakeClaimDB:
    """Мок Session для _claim_run: сценарий has_running_run + advisory-lock."""

    def __init__(self, *, running_states: list[bool], lock: bool = True) -> None:
        # running_states: очередь ответов has_running_run (по вызовам SELECT 1 FROM scrape_runs)
        self._running = list(running_states)
        self._lock = lock
        self.committed = False
        self.rolled_back = False
        self.update_calls = 0

    def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
        sql = str(stmt)
        if "SELECT 1 FROM scrape_runs" in sql:
            is_running = self._running.pop(0)
            return _FakeResult(fetchone=(1,) if is_running else None)
        if "pg_try_advisory_xact_lock" in sql:
            return _FakeResult(scalar=self._lock)
        if "UPDATE scrape_schedules" in sql:
            self.update_calls += 1
            return _FakeResult()
        return _FakeResult()

    def commit(self) -> None:
        self.committed = True

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


def _ctx_with_runs(create_run_ret: int = 42) -> SchedulerContext:
    runs = MagicMock()
    runs.create_run = MagicMock(return_value=create_run_ret)
    return SchedulerContext(
        config=MagicMock(),
        matcher=MagicMock(),
        enrichment=MagicMock(),
        session_factory=MagicMock(),
        runs=runs,
    )


def test_claim_run_happy_path() -> None:
    db = _FakeClaimDB(running_states=[False, False], lock=True)
    ctx = _ctx_with_runs(create_run_ret=42)
    run_id = _claim_run(db, _make_sched("avito_city_sweep"), ctx)
    assert run_id == 42
    ctx.runs.create_run.assert_called_once()
    assert db.update_calls == 1
    assert db.committed is True


def test_claim_run_skip_already_running() -> None:
    db = _FakeClaimDB(running_states=[True], lock=True)
    ctx = _ctx_with_runs()
    run_id = _claim_run(db, _make_sched("avito_city_sweep"), ctx)
    assert run_id is None
    ctx.runs.create_run.assert_not_called()


def test_claim_run_skip_lock_busy() -> None:
    # pre-check clean, но advisory-lock занят конкурентным тиком → skip
    db = _FakeClaimDB(running_states=[False], lock=False)
    ctx = _ctx_with_runs()
    run_id = _claim_run(db, _make_sched("avito_city_sweep"), ctx)
    assert run_id is None
    ctx.runs.create_run.assert_not_called()


def test_claim_run_running_appeared_under_lock() -> None:
    # pre-check clean, lock взят, но под локом появился running → rollback + skip (double-check)
    db = _FakeClaimDB(running_states=[False, True], lock=True)
    ctx = _ctx_with_runs()
    run_id = _claim_run(db, _make_sched("avito_city_sweep"), ctx)
    assert run_id is None
    assert db.rolled_back is True
    ctx.runs.create_run.assert_not_called()


# ── 3. reap_zombies ──────────────────────────────────────────────────────────


def test_reap_zombies_counts_and_commits() -> None:
    db = MagicMock()
    db.execute.return_value = _FakeResult(fetchall=[MagicMock(id=1), MagicMock(id=2)])
    n = reap_zombies(db)
    assert n == 2
    db.commit.assert_called_once()
    # порог 6 часов передан в SQL-параметр
    _stmt, params = db.execute.call_args[0]
    assert params["interval"] == "6 hours"


def test_reap_zombies_threshold_constant() -> None:
    assert kit_sched.ZOMBIE_THRESHOLD_HOURS == old_sched.ZOMBIE_THRESHOLD_HOURS == 6


# ── 4. proxy_healthcheck sub-hourly post_claim ───────────────────────────────


def test_reschedule_after_minutes_updates_next_run() -> None:
    db = MagicMock()
    hook = reschedule_after_minutes(param="interval_minutes", default=30)
    ctx = _ctx_with_runs()
    hook(db, run_id=7, params={"interval_minutes": 15}, ctx=ctx)
    db.commit.assert_called_once()
    _stmt, params = db.execute.call_args[0]
    assert params["mins"] == 15
    assert params["rid"] == 7


def test_reschedule_after_minutes_default() -> None:
    db = MagicMock()
    hook = reschedule_after_minutes()
    hook(db, run_id=9, params={}, ctx=_ctx_with_runs())
    _stmt, params = db.execute.call_args[0]
    assert params["mins"] == 30


# ── 5. _dispatch end-to-end (claim → fresh session → job → close) ────────────


async def test_dispatch_kit_native_sweep_invokes_pipeline() -> None:
    """kit-native handler: _dispatch клеймит, открывает сессию и зовёт pipeline.run_*."""
    run_db = MagicMock()
    ctx = SchedulerContext(
        config=MagicMock(),
        matcher=MagicMock(),
        enrichment=MagicMock(),
        session_factory=MagicMock(return_value=run_db),
        runs=MagicMock(),
    )
    ctx.runs.create_run = MagicMock(return_value=100)

    db = _FakeClaimDB(running_states=[False, False], lock=True)
    registry = build_registry()
    handler = registry["avito_city_sweep"]

    with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
        run_id = await _dispatch(handler, db, _make_sched("avito_city_sweep"), ctx)
        assert run_id == 100
        # дождаться detached run-задачи
        await asyncio.gather(*list(ctx._inflight_tasks))

    mock_run.assert_awaited_once()
    _args, kwargs = mock_run.call_args
    assert kwargs["run_id"] == 100
    assert kwargs["config"] is ctx.config
    assert kwargs["matcher"] is ctx.matcher
    run_db.close.assert_called_once()


async def test_dispatch_product_handler_invokes_job() -> None:
    """Продуктовый handler: _dispatch клеймит и зовёт инжектированный job на свежей сессии."""
    run_db = MagicMock()
    ctx = SchedulerContext(
        config=MagicMock(),
        matcher=MagicMock(),
        enrichment=MagicMock(),
        session_factory=MagicMock(return_value=run_db),
        runs=MagicMock(),
    )
    ctx.runs.create_run = MagicMock(return_value=200)

    db = _FakeClaimDB(running_states=[False, False], lock=True)
    registry, fired = _build_recording_registry()
    handler = resolve_handler("sber_index_pull", registry)

    run_id = await _dispatch(handler, db, _make_sched("sber_index_pull"), ctx)
    assert run_id == 200
    await asyncio.gather(*list(ctx._inflight_tasks))

    fired["sber_index_pull"].assert_called_once()
    called_args = fired["sber_index_pull"].call_args[0]
    assert called_args[0] is run_db  # свежая сессия
    assert called_args[1] == 200  # run_id
    run_db.close.assert_called_once()


async def test_dispatch_pre_claim_gate_skips() -> None:
    """pre_claim → False: _dispatch пропускает claim (cian cookie-gate семантика)."""
    ctx = _ctx_with_runs()

    async def _deny(db: Any, sch: dict[str, Any], c: Any) -> bool:
        return False

    job = AsyncMock()

    async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
        job()

    handler = Handler(_job, "cian_history_backfill", pre_claim=_deny)
    db = _FakeClaimDB(running_states=[], lock=True)
    run_id = await _dispatch(handler, db, _make_sched("cian_history_backfill"), ctx)
    assert run_id is None
    ctx.runs.create_run.assert_not_called()
    job.assert_not_called()


async def test_dispatch_post_claim_hook_runs() -> None:
    """post_claim вызывается сразу после claim (proxy_healthcheck sub-hourly reschedule)."""
    run_db = MagicMock()
    ctx = SchedulerContext(
        config=MagicMock(),
        matcher=MagicMock(),
        enrichment=MagicMock(),
        session_factory=MagicMock(return_value=run_db),
        runs=MagicMock(),
    )
    ctx.runs.create_run = MagicMock(return_value=300)
    post = MagicMock()

    async def _job(db: Any, run_id: int, params: dict[str, Any], c: Any) -> None:
        pass

    handler = Handler(_job, "proxy_healthcheck", post_claim=post)
    db = _FakeClaimDB(running_states=[False, False], lock=True)
    run_id = await _dispatch(handler, db, _make_sched("proxy_healthcheck"), ctx)
    assert run_id == 300
    await asyncio.gather(*list(ctx._inflight_tasks))
    post.assert_called_once()
    assert post.call_args[0][1] == 300  # run_id
