"""Тесты для backend/app/workers/tasks/scrape_kn_catalog_flats.py (#2442 Task 2).

Покрывают: batch-selection bind params (force / max_flats / stale_days),
маппинг rows → flats-dicts для scrape_catalog_batch, пустой batch (ожидаемо до
наполнения catalog_url_hash), и жёсткие предикаты SELECT (hash IS NOT NULL, CAST).
"""

from __future__ import annotations

from datetime import date
from typing import Any
from unittest.mock import MagicMock, patch


def _make_mock_db(rows: list[dict[str, Any]]) -> tuple[MagicMock, list[dict[str, Any]]]:
    """Mock DB session. Возвращает (db, captured_params)."""
    captured: list[dict[str, Any]] = []

    def execute_router(stmt: Any, params: Any = None) -> MagicMock:
        if params is not None:
            captured.append(params)
        result = MagicMock()
        result.mappings.return_value.all.return_value = rows
        return result

    db = MagicMock()
    db.execute = execute_router
    return db, captured


def test_skip_fresh_filter_active_by_default() -> None:
    """force=False (default) → :force = False → SQL применит фильтр свежести."""
    from app.workers.tasks import scrape_kn_catalog_flats as task_mod

    db, captured = _make_mock_db(rows=[])

    with patch.object(task_mod, "SessionLocal", return_value=db):
        result = task_mod.scrape_kn_catalog_flats.run(region_code=66)

    assert len(captured) == 1
    assert captured[0]["force"] is False
    assert captured[0]["region_code"] == 66
    assert captured[0]["stale_days"] == task_mod._STALE_DAYS
    # Пустой результат → ранний выход (ожидаемо до наполнения catalog_url_hash).
    assert result["flats_count"] == 0
    assert result["force"] is False


def test_force_true_passes_through_to_sql() -> None:
    """force=True → :force = True → SQL грузит все строки с непустым hash."""
    from app.workers.tasks import scrape_kn_catalog_flats as task_mod

    db, captured = _make_mock_db(rows=[])

    with patch.object(task_mod, "SessionLocal", return_value=db):
        result = task_mod.scrape_kn_catalog_flats.run(region_code=66, force=True)

    assert len(captured) == 1
    assert captured[0]["force"] is True
    assert result["force"] is True


def test_max_flats_passed_as_limit() -> None:
    """max_flats явный — прилетает в SQL bind как :max_flats."""
    from app.workers.tasks import scrape_kn_catalog_flats as task_mod

    db, captured = _make_mock_db(rows=[])

    with patch.object(task_mod, "SessionLocal", return_value=db):
        task_mod.scrape_kn_catalog_flats.run(region_code=66, max_flats=10)

    assert captured[0]["max_flats"] == 10


def test_default_limit_when_not_forced() -> None:
    """Без max_flats и без force → дефолтный лимит _DEFAULT_MAX_FLATS."""
    from app.workers.tasks import scrape_kn_catalog_flats as task_mod

    db, captured = _make_mock_db(rows=[])

    with patch.object(task_mod, "SessionLocal", return_value=db):
        task_mod.scrape_kn_catalog_flats.run(region_code=66)

    assert captured[0]["max_flats"] == task_mod._DEFAULT_MAX_FLATS


def test_force_without_max_flats_is_unlimited() -> None:
    """force=True без max_flats → limit=None (SQL LIMIT NULL = все строки)."""
    from app.workers.tasks import scrape_kn_catalog_flats as task_mod

    db, captured = _make_mock_db(rows=[])

    with patch.object(task_mod, "SessionLocal", return_value=db):
        task_mod.scrape_kn_catalog_flats.run(region_code=66, force=True)

    assert captured[0]["max_flats"] is None


def test_processes_rows_when_returned() -> None:
    """SELECT вернул строки → вызывается scrape_catalog_batch с flats-dicts
    (ods_id + catalog_url_hash), snapshot_date из первой строки."""
    from app.workers.tasks import scrape_kn_catalog_flats as task_mod

    rows = [
        {
            "ods_id": "65136/1/1.4.3",
            "catalog_url_hash": "hash-a",
            "snapshot_date": date(2026, 7, 5),
        },
        {
            "ods_id": "65136/1/1.4.4",
            "catalog_url_hash": "hash-b",
            "snapshot_date": date(2026, 7, 5),
        },
    ]
    db, _ = _make_mock_db(rows=rows)

    mock_stats = {"total": 2, "success": 2, "updated": 2, "failed": 0, "fields_total": 8}
    with (
        patch.object(task_mod, "SessionLocal", return_value=db),
        patch(
            "app.services.scrapers.domrf_catalog.scrape_catalog_batch",
            return_value=mock_stats,
        ) as mock_scrape,
    ):
        result = task_mod.scrape_kn_catalog_flats.run(region_code=66)

    mock_scrape.assert_called_once()
    call_kwargs = mock_scrape.call_args[1]
    assert call_kwargs["flats"] == [
        {"ods_id": "65136/1/1.4.3", "catalog_url_hash": "hash-a"},
        {"ods_id": "65136/1/1.4.4", "catalog_url_hash": "hash-b"},
    ]
    assert call_kwargs["region_code"] == 66
    assert result["flats_count"] == 2
    assert result["snapshot_date"] == "2026-07-05"
    # stats из scrape_catalog_batch прокинуты в result.
    assert result["updated"] == 2
    assert result["total"] == 2


def test_empty_batch_returns_zeroed_stats() -> None:
    """Пустой SELECT → все счётчики 0, scrape_catalog_batch НЕ вызывается."""
    from app.workers.tasks import scrape_kn_catalog_flats as task_mod

    db, _ = _make_mock_db(rows=[])

    with (
        patch.object(task_mod, "SessionLocal", return_value=db),
        patch(
            "app.services.scrapers.domrf_catalog.scrape_catalog_batch",
        ) as mock_scrape,
    ):
        result = task_mod.scrape_kn_catalog_flats.run(region_code=66)

    mock_scrape.assert_not_called()
    assert result["flats_count"] == 0
    assert result["updated"] == 0
    assert result["failed"] == 0
    assert result["total"] == 0


def test_sql_contains_hash_notnull_and_cast_predicates() -> None:
    """Smoke: SELECT требует catalog_url_hash IS NOT NULL + использует CAST(...)
    (psycopg v3 — никогда :x::type) для force/stale_days."""
    from app.workers.tasks.scrape_kn_catalog_flats import _SELECT_TARGETS_SQL

    sql = str(_SELECT_TARGETS_SQL)
    upper = sql.upper()
    assert "CATALOG_URL_HASH IS NOT NULL" in upper
    assert "CAST(:FORCE AS BOOLEAN)" in upper
    assert "CAST(:STALE_DAYS AS INTEGER)" in upper
    # psycopg v3: никаких ::type после bind-имени.
    assert ":force::" not in sql
    assert ":stale_days::" not in sql
