"""Тесты для objective_backfill.py (#203).

Использует mock DB (unittest.mock) — не требует реального PostgreSQL.
"""

from __future__ import annotations

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

from app.services.etl.objective_backfill import (
    AUTO_ACCEPT_THRESHOLD,
    REVIEW_THRESHOLD,
    MatchCandidate,
    auto_apply_matches,
    find_match_candidates,
    trigger_mv_refresh,
)

# ── Helpers ───────────────────────────────────────────────────────────────────


def _make_candidate(
    obj_id: int,
    comm_name: str,
    project_name: str,
    score: float,
    dev_name: str | None = "ООО Девелопер",
) -> MatchCandidate:
    return MatchCandidate(
        domrf_obj_id=obj_id,
        domrf_comm_name=comm_name,
        domrf_dev_name=dev_name,
        objective_project_name=project_name,
        similarity_score=score,
    )


def _make_db_row(obj_id: int, comm_name: str, dev_name: str, project: str, score: float) -> Any:
    """Имитирует Row, возвращаемый SQLAlchemy db.execute().all()."""
    return (obj_id, comm_name, dev_name, project, score)


# ── Test 1: find_match_candidates возвращает правильные MatchCandidate ────────


def test_find_match_candidates_returns_candidates() -> None:
    """find_match_candidates корректно преобразует DB rows в MatchCandidate."""
    mock_rows = [
        _make_db_row(100, "Северный квартал", "Брусника", "Северный квартал", 1.0),
        _make_db_row(200, "АЛЛЕГРО", "СЗ ГОРЖИЛСТРОЙ", "АЛЛЕГРО", 0.95),
        _make_db_row(300, "Некий ЖК", None, "Близкий ЖК", 0.72),
    ]

    mock_execute = MagicMock()
    mock_execute.all.return_value = mock_rows
    mock_db = MagicMock()
    mock_db.execute.return_value = mock_execute

    candidates = find_match_candidates(mock_db, only_unmapped=True)

    assert len(candidates) == 3
    assert candidates[0].domrf_obj_id == 100
    assert candidates[0].similarity_score == 1.0
    assert candidates[0].domrf_dev_name == "Брусника"
    assert candidates[2].domrf_dev_name is None  # NULL dev_name → None
    assert candidates[2].similarity_score == 0.72

    # Убедимся, что db.execute вызывался с параметрами
    mock_db.execute.assert_called_once()
    call_kwargs = mock_db.execute.call_args[0][1]
    assert call_kwargs["only_unmapped"] is True
    assert call_kwargs["min_threshold"] == REVIEW_THRESHOLD


# ── Test 2: auto_apply_matches dry_run не вставляет ──────────────────────────


def test_auto_apply_matches_dry_run_no_inserts() -> None:
    """dry_run=True возвращает projected-счётчики без обращения к БД.

    auto_accepted = сколько кандидатов БЫЛО БЫ принято (preview), а не 0 —
    смысл dry-run в admin-endpoint'е именно показать оператору объём перед
    реальным insert. execute/commit при этом не вызываются.
    """
    mock_db = MagicMock()

    candidates = [
        _make_candidate(1, "ЖК А", "ЖК А", 0.95),  # auto-accept
        _make_candidate(2, "ЖК Б", "ЖК Б", 0.70),  # review queue
        _make_candidate(3, "ЖК В", "ЖК В", 0.65),  # review queue
    ]

    result = auto_apply_matches(mock_db, candidates, dry_run=True)

    assert result["auto_accepted"] == 1  # projected: 1 кандидат >= AUTO_ACCEPT_THRESHOLD
    assert result["review_queue"] == 2
    assert result["skipped"] == 0
    mock_db.execute.assert_not_called()
    mock_db.commit.assert_not_called()


# ── Test 3: auto_apply_matches high-score inserts, low-score → review_queue ──


def test_auto_apply_matches_inserts_high_score_only() -> None:
    """Только кандидаты >= AUTO_ACCEPT_THRESHOLD вставляются в БД."""
    # Мокируем begin_nested как context manager
    savepoint_cm = MagicMock()
    savepoint_cm.__enter__ = MagicMock(return_value=None)
    savepoint_cm.__exit__ = MagicMock(return_value=False)

    mock_execute_result = MagicMock()
    mock_execute_result.rowcount = 1

    mock_db = MagicMock()
    mock_db.begin_nested.return_value = savepoint_cm
    mock_db.execute.return_value = mock_execute_result

    high_score = AUTO_ACCEPT_THRESHOLD  # ровно на пороге — должен вставляться
    low_score = REVIEW_THRESHOLD  # ровно REVIEW_THRESHOLD — не auto-accept

    candidates = [
        _make_candidate(10, "ЖК Альфа", "ЖК Альфа", high_score),
        _make_candidate(11, "ЖК Бета", "ЖК Бета", high_score + 0.05),
        _make_candidate(20, "ЖК Гамма", "ЖК Гамма", low_score),  # review только
    ]

    result = auto_apply_matches(mock_db, candidates, threshold=AUTO_ACCEPT_THRESHOLD)

    assert result["auto_accepted"] == 2
    assert result["review_queue"] == 1
    assert mock_db.execute.call_count == 2  # вызывался только для high-score
    mock_db.commit.assert_called_once()


# ── Test 4: trigger_mv_refresh делегирует в layout_velocity_refresh ──────────


def test_trigger_mv_refresh_calls_helper() -> None:
    """trigger_mv_refresh вызывает refresh_layout_velocity с concurrently=True.

    refresh_layout_velocity импортируется лениво внутри trigger_mv_refresh,
    поэтому патчим по полному пути модуля-источника.
    """
    mock_db = MagicMock()

    with patch(
        "app.services.site_finder.layout_velocity_refresh.refresh_layout_velocity",
        return_value=512,
    ) as mock_refresh:
        count = trigger_mv_refresh(mock_db)

    assert count == 512
    mock_refresh.assert_called_once_with(mock_db, concurrently=True)


# ── Test 5: ON CONFLICT — rowcount=0 считается как skipped ───────────────────


def test_auto_apply_matches_conflict_counted_as_skipped() -> None:
    """Если INSERT вернул rowcount=0 (ON CONFLICT DO NOTHING), считается skipped."""
    savepoint_cm = MagicMock()
    savepoint_cm.__enter__ = MagicMock(return_value=None)
    savepoint_cm.__exit__ = MagicMock(return_value=False)

    mock_execute_result = MagicMock()
    mock_execute_result.rowcount = 0  # ON CONFLICT DO NOTHING

    mock_db = MagicMock()
    mock_db.begin_nested.return_value = savepoint_cm
    mock_db.execute.return_value = mock_execute_result

    candidates = [
        _make_candidate(99, "Уже существующий ЖК", "Уже существующий ЖК", 0.99),
    ]

    result = auto_apply_matches(mock_db, candidates)

    assert result["auto_accepted"] == 0
    assert result["skipped"] == 1
    assert result["review_queue"] == 0
