"""Тесты для obj_class_backfill.py (#38, Demand D6).

Mock DB (unittest.mock) — без реального PostgreSQL. Проверяют преобразование
DB-rows в кандидаты, порог auto-accept, idempotent-skip и price-inference.
"""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock

from app.services.etl.obj_class_backfill import (
    AUTO_ACCEPT_THRESHOLD,
    ClassMatchCandidate,
    apply_class_matches,
    apply_price_inference,
    find_class_candidates,
    run_backfill,
)

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


def _make_candidate(
    obj_id: int,
    comm_name: str,
    yandex_name: str,
    yandex_class: str,
    score: float,
) -> ClassMatchCandidate:
    return ClassMatchCandidate(
        domrf_obj_id=obj_id,
        domrf_comm_name=comm_name,
        yandex_name=yandex_name,
        yandex_obj_class=yandex_class,
        similarity_score=score,
    )


def _make_db_row(
    obj_id: int, comm_name: str, yandex_name: str, yandex_class: str, score: float
) -> Any:
    """Имитирует Row из find_class_candidates SELECT."""
    return (obj_id, comm_name, yandex_name, yandex_class, score)


def _savepoint_cm() -> MagicMock:
    cm = MagicMock()
    cm.__enter__ = MagicMock(return_value=None)
    cm.__exit__ = MagicMock(return_value=False)
    return cm


# ── Test 1: find_class_candidates маппит rows в ClassMatchCandidate ──────────


def test_find_class_candidates_maps_rows() -> None:
    """find_class_candidates корректно преобразует DB rows в кандидаты."""
    mock_rows = [
        _make_db_row(100, "Северный квартал", "ЖК Северный квартал", "COMFORT", 0.91),
        _make_db_row(200, "АЛЛЕГРО", "Аллегро", "BUSINESS", 0.74),
    ]
    mock_execute = MagicMock()
    mock_execute.all.return_value = mock_rows
    mock_db = MagicMock()
    mock_db.execute.return_value = mock_execute

    candidates = find_class_candidates(mock_db)

    assert len(candidates) == 2
    assert candidates[0].domrf_obj_id == 100
    assert candidates[0].yandex_obj_class == "COMFORT"
    assert candidates[0].similarity_score == 0.91
    assert candidates[1].yandex_obj_class == "BUSINESS"

    mock_db.execute.assert_called_once()
    params = mock_db.execute.call_args[0][1]
    assert params["region_cd"] == 66
    assert params["min_threshold"] == AUTO_ACCEPT_THRESHOLD


# ── Test 2: dry_run не пишет в БД ────────────────────────────────────────────


def test_apply_class_matches_dry_run_no_writes() -> None:
    """dry_run=True возвращает projected-счётчик без обращения к БД."""
    mock_db = MagicMock()
    candidates = [
        _make_candidate(1, "ЖК А", "ЖК А", "COMFORT", 0.95),
        _make_candidate(2, "ЖК Б", "ЖК Б", "BUSINESS", 0.40),  # ниже порога
    ]

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

    assert result["updated"] == 1  # только >= 0.6
    assert result["skipped"] == 0
    mock_db.execute.assert_not_called()
    mock_db.commit.assert_not_called()


# ── Test 3: пишутся только кандидаты >= threshold ────────────────────────────


def test_apply_class_matches_threshold_gate() -> None:
    """Только кандидаты >= AUTO_ACCEPT_THRESHOLD проходят в UPDATE."""
    mock_result = MagicMock()
    mock_result.rowcount = 1
    mock_db = MagicMock()
    mock_db.begin_nested.return_value = _savepoint_cm()
    mock_db.execute.return_value = mock_result

    candidates = [
        _make_candidate(10, "ЖК Альфа", "Альфа", "COMFORT", AUTO_ACCEPT_THRESHOLD),  # ровно порог
        _make_candidate(11, "ЖК Бета", "Бета", "BUSINESS", 0.80),
        _make_candidate(12, "ЖК Гамма", "Гамма", "ELITE", 0.59),  # ниже порога
    ]

    result = apply_class_matches(mock_db, candidates)

    assert result["updated"] == 2
    assert result["skipped"] == 0
    assert mock_db.execute.call_count == 2  # только два >= порога
    mock_db.commit.assert_called_once()


# ── Test 4: idempotent skip (rowcount=0) ─────────────────────────────────────


def test_apply_class_matches_idempotent_skip() -> None:
    """UPDATE rowcount=0 (класс уже проставлен) считается skipped."""
    mock_result = MagicMock()
    mock_result.rowcount = 0  # WHERE obj_class IS NULL не сматчил → уже заполнено
    mock_db = MagicMock()
    mock_db.begin_nested.return_value = _savepoint_cm()
    mock_db.execute.return_value = mock_result

    candidates = [_make_candidate(99, "ЖК уже размечен", "уже", "COMFORT", 0.99)]

    result = apply_class_matches(mock_db, candidates)

    assert result["updated"] == 0
    assert result["skipped"] == 1
    mock_db.commit.assert_called_once()


# ── Test 5: failure одной строки не рушит цикл (SAVEPOINT) ───────────────────


def test_apply_class_matches_row_failure_isolated() -> None:
    """Исключение на одной строке логируется, цикл продолжается (skipped++)."""
    mock_ok = MagicMock()
    mock_ok.rowcount = 1
    mock_db = MagicMock()
    mock_db.begin_nested.return_value = _savepoint_cm()
    # Первая строка кидает, вторая — успех.
    mock_db.execute.side_effect = [RuntimeError("boom"), mock_ok]

    candidates = [
        _make_candidate(1, "ЖК падающий", "пад", "COMFORT", 0.9),
        _make_candidate(2, "ЖК норм", "норм", "BUSINESS", 0.9),
    ]

    result = apply_class_matches(mock_db, candidates)

    assert result["updated"] == 1
    assert result["skipped"] == 1
    mock_db.commit.assert_called_once()


# ── Test 6: price_inference dry_run считает кандидатов ───────────────────────


def test_apply_price_inference_dry_run_counts() -> None:
    """dry_run=True считает кандидатов SELECT'ом, ничего не пишет."""
    mock_execute = MagicMock()
    mock_execute.all.return_value = [(1, "2026-01-01", "COMFORT"), (2, "2026-01-01", "BUSINESS")]
    mock_db = MagicMock()
    mock_db.execute.return_value = mock_execute

    result = apply_price_inference(mock_db, dry_run=True)

    assert result["updated"] == 2
    mock_db.commit.assert_not_called()


# ── Test 7: price_inference пишет per-row с SAVEPOINT ────────────────────────


def test_apply_price_inference_writes_rows() -> None:
    """price_inference UPDATE'ит каждого кандидата, суммирует rowcount."""
    select_result = MagicMock()
    select_result.all.return_value = [(1, "2026-01-01", "COMFORT"), (2, "2026-01-01", "ELITE")]
    update_result = MagicMock()
    update_result.rowcount = 1
    mock_db = MagicMock()
    mock_db.begin_nested.return_value = _savepoint_cm()
    # Первый execute — SELECT кандидатов, далее — UPDATE per row.
    mock_db.execute.side_effect = [select_result, update_result, update_result]

    result = apply_price_inference(mock_db)

    assert result["updated"] == 2
    mock_db.commit.assert_called_once()


# ── Test 8: run_backfill оркестрирует обе ступени ────────────────────────────


def test_run_backfill_orchestrates_both_stages() -> None:
    """run_backfill: trigram-match → price_inference, агрегирует счётчики."""
    # SELECT кандидатов (find) → пусто, чтобы apply_class_matches ничего не писал;
    # затем price_inference SELECT вернёт 1 кандидата + UPDATE.
    find_result = MagicMock()
    find_result.all.return_value = []  # нет trigram-кандидатов
    price_select = MagicMock()
    price_select.all.return_value = [(5, "2026-01-01", "COMFORT")]
    update_result = MagicMock()
    update_result.rowcount = 1
    mock_db = MagicMock()
    mock_db.begin_nested.return_value = _savepoint_cm()
    mock_db.execute.side_effect = [find_result, price_select, update_result]

    result = run_backfill(mock_db)

    assert result["yandex_updated"] == 0
    assert result["yandex_skipped"] == 0
    assert result["price_updated"] == 1
