"""Тесты для 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,
    CORE_MATCH_METHOD,
    CORE_MATCH_SCORE,
    GEO_DADATA_CALL_LIMIT,
    GEO_MATCH_METHOD,
    GEO_MATCH_SCORE,
    REVIEW_THRESHOLD,
    CoreMatchReport,
    GeoMatchReport,
    MatchCandidate,
    _dev_confirms,
    _haversine_m,
    _norm_dev,
    apply_core_matches,
    apply_geo_matches,
    auto_apply_matches,
    find_core_matches,
    find_geo_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


# ═════════════════════════════════════════════════════════════════════════════
# Core-pass (#2177 шаг 2) — auto_core_dev_v5
# ═════════════════════════════════════════════════════════════════════════════


def _make_core_db(
    domrf_rows: list[tuple[int, str, str | None]],
    objective_rows: list[tuple[str, list[str] | None, int | None]],
    taken_names: list[str],
) -> MagicMock:
    """Mock Session где 3 подряд db.execute().all() отдают
    objective-проекты, taken-имена, domrf-объекты (в порядке find_core_matches).

    find_core_matches вызывает execute в фиксированном порядке:
      1) _OBJECTIVE_PROJECTS_SQL, 2) _TAKEN_NAMES_SQL, 3) _DOMRF_UNMAPPED_SQL.
    """
    obj_result = MagicMock()
    obj_result.all.return_value = objective_rows
    taken_result = MagicMock()
    taken_result.all.return_value = [(n,) for n in taken_names]
    domrf_result = MagicMock()
    domrf_result.all.return_value = domrf_rows

    mock_db = MagicMock()
    mock_db.execute.side_effect = [obj_result, taken_result, domrf_result]
    return mock_db


# ── _norm_dev / _dev_confirms ────────────────────────────────────────────────


def test_norm_dev_strips_org_form() -> None:
    """_norm_dev срезает орг-форму, кавычки, «СЗ», схлопывает пробелы."""
    assert _norm_dev('ООО "СЗ "Малевич"') == "малевич"
    assert _norm_dev("PRINZIP") == "prinzip"
    assert _norm_dev("ООО Брозэкс") == "брозэкс"
    assert _norm_dev("ООО Специализированный застройщик Арсенал") == "арсенал"
    assert _norm_dev(None) == ""
    assert _norm_dev("   ") == ""


def test_dev_confirms_matches_any_spv() -> None:
    """dev-подтверждение по ЛЮБОМУ из нескольких objective developers (SPV)."""
    # SPV «СЗ Малевич» подтверждает, хотя PRINZIP — нет.
    assert _dev_confirms('ООО "СЗ "Малевич"', {"PRINZIP", 'СЗ "Малевич"'}) is True
    assert _dev_confirms('ООО "СЗ "Малевич"', {"PRINZIP"}) is False
    # разные застройщики — не подтверждает
    assert _dev_confirms("ООО СЗ Арсенал", {"Брозэкс"}) is False
    # пустой domrf-dev — нет сигнала
    assert _dev_confirms("", {"Брозэкс"}) is False


def test_dev_confirms_short_substring_guard() -> None:
    """Короткий (<4 симв.) корень не подтверждает подстрокой чужое длинное имя.

    Ревью 2026-07-03: «ООО Дом»→«дом» как подстрока «Домострой» — ложное
    подтверждение; dev-confirm — последняя защита от generic-core коллизий.
    """
    assert _dev_confirms("ООО Дом", {"Домострой"}) is False
    assert _dev_confirms("ООО СЗ Юг", {"Югстройинвест"}) is False
    # ≥4 символов подстрокой — по-прежнему подтверждает
    assert _dev_confirms("Брусника", {"Брусника-Урал"}) is True


# ── find_core_matches: классификация tier_a / tier_b / ambiguous / skip ──────


def test_find_core_matches_tier_a_dev_confirmed() -> None:
    """Малевич: 1 кандидат + SPV-подтверждение → tier_a."""
    mock_db = _make_core_db(
        domrf_rows=[(101, 'Жилой комплекс "Малевич" (ЖК "Малевич")', 'ООО "СЗ "Малевич"')],
        objective_rows=[("Малевич", ["PRINZIP", 'СЗ "Малевич"'], 7001)],
        taken_names=[],
    )
    report = find_core_matches(mock_db)

    assert len(report.tier_a) == 1
    assert report.tier_a[0].domrf_obj_id == 101
    assert report.tier_a[0].objective_project_name == "Малевич"
    assert report.tier_a[0].objective_project_id == 7001
    assert report.tier_a[0].dev_confirmed is True
    assert report.tier_b == []
    assert report.ambiguous == []


def test_find_core_matches_tier_b_dev_not_confirmed() -> None:
    """Арсенал↔Брозэкс: 1 кандидат, dev НЕ совпал → tier_b (не в БД)."""
    mock_db = _make_core_db(
        domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")],
        objective_rows=[("Арсенал", ["Брозэкс"], 7002)],
        taken_names=[],
    )
    report = find_core_matches(mock_db)

    assert report.tier_a == []
    assert len(report.tier_b) == 1
    assert report.tier_b[0].domrf_obj_id == 202
    assert report.tier_b[0].dev_confirmed is False


def test_find_core_matches_ambiguous_two_candidates() -> None:
    """Меридиан: 2 objective project_name делят одно ядро → ambiguous."""
    mock_db = _make_core_db(
        domrf_rows=[(303, 'ЖК "Меридиан"', "ООО Девелопер")],
        objective_rows=[
            ("Меридиан", ["Девелопер"], 7003),
            ("Меридиан ", ["Другой"], 7004),  # то же ядро «меридиан»
        ],
        taken_names=[],
    )
    report = find_core_matches(mock_db)

    assert report.tier_a == []
    assert report.tier_b == []
    assert len(report.ambiguous) == 1
    assert report.ambiguous[0].domrf_obj_id == 303


def test_find_core_matches_skips_taken_name() -> None:
    """objective_complex_name уже в mapping → skipped_taken, не tier_a."""
    mock_db = _make_core_db(
        domrf_rows=[(404, 'ЖК "Симфония"', "ООО Симфония")],
        objective_rows=[("Симфония", ["Симфония"], 7005)],
        taken_names=["Симфония"],  # уже занято
    )
    report = find_core_matches(mock_db)

    assert report.tier_a == []
    assert len(report.skipped_taken) == 1
    assert report.skipped_taken[0].objective_project_name == "Симфония"


def test_find_core_matches_no_objective_candidate_dropped() -> None:
    """domrf-ядро без objective-проекта — молча пропускается (не наш случай)."""
    mock_db = _make_core_db(
        domrf_rows=[(505, 'ЖК "Несуществующий"', "ООО X")],
        objective_rows=[("Малевич", ["PRINZIP"], 7001)],
        taken_names=[],
    )
    report = find_core_matches(mock_db)

    assert report.counts() == {
        "tier_a": 0,
        "tier_b": 0,
        "ambiguous": 0,
        "skipped_taken": 0,
    }


# ── apply_core_matches: dry-run / apply / ON CONFLICT ────────────────────────


def _tier_a_report() -> CoreMatchReport:
    """Отчёт с ровно одним tier_a (Малевич + SPV-подтверждение)."""
    mock_db = _make_core_db(
        domrf_rows=[(101, 'Жилой комплекс "Малевич"', 'ООО "СЗ "Малевич"')],
        objective_rows=[("Малевич", ['СЗ "Малевич"'], 7001)],
        taken_names=[],
    )
    return find_core_matches(mock_db)


def test_apply_core_matches_dry_run_no_write() -> None:
    """dry_run=True → только counts, execute/commit не вызываются."""
    report = _tier_a_report()
    assert len(report.tier_a) == 1

    mock_db = MagicMock()
    result = apply_core_matches(mock_db, report, dry_run=True)

    assert result["inserted"] == 0
    assert result["tier_a_total"] == 1
    mock_db.execute.assert_not_called()
    mock_db.commit.assert_not_called()


def test_apply_core_matches_writes_tier_a_with_method_score() -> None:
    """apply пишет tier_a с match_method='auto_core_dev_v5' и score=0.90."""
    report = _tier_a_report()

    savepoint_cm = MagicMock()
    savepoint_cm.__enter__ = MagicMock(return_value=None)
    savepoint_cm.__exit__ = MagicMock(return_value=False)

    exec_result = MagicMock()
    exec_result.rowcount = 1

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

    result = apply_core_matches(mock_db, report, dry_run=False)

    assert result["inserted"] == 1
    assert result["conflict_skipped"] == 0
    mock_db.commit.assert_called_once()

    # проверяем переданные в INSERT параметры
    params = mock_db.execute.call_args[0][1]
    assert params["method"] == CORE_MATCH_METHOD
    assert params["score"] == CORE_MATCH_SCORE
    assert params["reviewed"] is False
    assert params["group"] == "Екатеринбург"
    assert params["name"] == "Малевич"
    assert params["obj_id"] == 101
    assert params["project_id"] == 7001


def test_apply_core_matches_on_conflict_counts_skip() -> None:
    """ON CONFLICT DO NOTHING (rowcount=0) → conflict_skipped, не падает."""
    report = _tier_a_report()

    savepoint_cm = MagicMock()
    savepoint_cm.__enter__ = MagicMock(return_value=None)
    savepoint_cm.__exit__ = MagicMock(return_value=False)

    exec_result = MagicMock()
    exec_result.rowcount = 0  # ON CONFLICT

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

    result = apply_core_matches(mock_db, report, dry_run=False)

    assert result["inserted"] == 0
    assert result["conflict_skipped"] == 1
    mock_db.commit.assert_called_once()


def test_apply_core_matches_ignores_non_tier_a() -> None:
    """tier_b / ambiguous / skipped НЕ пишутся — только tier_a."""
    report = CoreMatchReport()
    # только tier_b и ambiguous, tier_a пуст
    tb_db = _make_core_db(
        domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")],
        objective_rows=[("Арсенал", ["Брозэкс"], 7002)],
        taken_names=[],
    )
    report = find_core_matches(tb_db)
    assert len(report.tier_b) == 1
    assert report.tier_a == []

    mock_db = MagicMock()
    result = apply_core_matches(mock_db, report, dry_run=False)

    assert result["inserted"] == 0
    assert result["tier_a_total"] == 0
    mock_db.execute.assert_not_called()
    # commit всё равно вызывается (пустой tier_a — no-op транзакция)
    mock_db.commit.assert_called_once()


# ═════════════════════════════════════════════════════════════════════════════
# Geo-pass (#2177) — auto_core_geo_v6
# ═════════════════════════════════════════════════════════════════════════════

# Патч-путь: clean_address импортирован в namespace objective_backfill.
_CLEAN_ADDRESS_PATH = "app.services.etl.objective_backfill.clean_address"

# Опорная domrf-точка для тестов (центр ЕКБ, произвольная).
_DOMRF_LAT = 56.8389
_DOMRF_LON = 60.6057


def _offset_coords(base_lat: float, base_lon: float, meters_north: float) -> tuple[float, float]:
    """Точка в meters_north к северу от base (для управляемой дистанции)."""
    # 1° широты ≈ 111_320 м. Сдвиг только по широте — долготу оставляем.
    return (base_lat + meters_north / 111_320.0, base_lon)


def _make_geo_db(
    objective_rows: list[tuple[str, list[str] | None, int | None]],
    taken_names: list[str],
    domrf_rows: list[tuple[int, str, str | None]],
    address_rows: list[tuple[str, str]],
    coord_rows: list[tuple[int, float, float]],
) -> MagicMock:
    """Mock Session для find_geo_matches — 6 db.execute().all() по порядку:

    find_core_matches: 1) _OBJECTIVE_PROJECTS_SQL, 2) _TAKEN_NAMES_SQL,
                       3) _DOMRF_UNMAPPED_SQL
    geo:               4) _OBJECTIVE_ADDRESS_SQL (адреса per project),
                       5) _OBJECTIVE_PROJECTS_SQL (повтор, _load_objective_by_core),
                       6) _DOMRF_COORDS_SQL (координаты domrf per obj_id).
    """

    def _result(rows: list[Any]) -> MagicMock:
        r = MagicMock()
        r.all.return_value = rows
        return r

    mock_db = MagicMock()
    mock_db.execute.side_effect = [
        _result(objective_rows),
        _result([(n,) for n in taken_names]),
        _result(domrf_rows),
        _result(address_rows),
        _result(objective_rows),
        _result(coord_rows),
    ]
    return mock_db


# ── haversine ────────────────────────────────────────────────────────────────


def test_haversine_zero_and_known_distance() -> None:
    """haversine: одна точка → 0; сдвиг 120 м → ≈120 м (±5 м)."""
    assert _haversine_m(_DOMRF_LAT, _DOMRF_LON, _DOMRF_LAT, _DOMRF_LON) == 0.0
    lat2, lon2 = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 120.0)
    dist = _haversine_m(_DOMRF_LAT, _DOMRF_LON, lat2, lon2)
    assert abs(dist - 120.0) < 5.0


# ── tier_b confirm / reject по дистанции ─────────────────────────────────────


def test_geo_tier_b_confirm_within_radius() -> None:
    """tier_b, адрес в 120 м от domrf → confirm."""
    addr_lat, addr_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 120.0)
    mock_db = _make_geo_db(
        objective_rows=[("Арсенал", ["Брозэкс"], 7002)],
        taken_names=[],
        domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")],  # dev не совпал → tier_b
        address_rows=[("Арсенал", "г Екатеринбург, ул Арсенальная, 1")],
        coord_rows=[(202, _DOMRF_LAT, _DOMRF_LON)],
    )

    with patch(_CLEAN_ADDRESS_PATH, return_value=(addr_lat, addr_lon)) as mock_geo:
        report = find_geo_matches(mock_db, max_distance_m=500)

    assert len(report.confirmed) == 1
    gm = report.confirmed[0]
    assert gm.domrf_obj_id == 202
    assert gm.objective_project_name == "Арсенал"
    assert gm.origin == "tier_b"
    assert abs(gm.distance_m - 120.0) < 5.0
    assert report.rejected == []
    assert report.dadata_calls == 1
    mock_geo.assert_called_once()


def test_geo_tier_b_reject_too_far() -> None:
    """tier_b, адрес в 900 м → reject с дистанцией (за пределами 500 м)."""
    addr_lat, addr_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 900.0)
    mock_db = _make_geo_db(
        objective_rows=[("Арсенал", ["Брозэкс"], 7002)],
        taken_names=[],
        domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")],
        address_rows=[("Арсенал", "г Екатеринбург, ул Дальняя, 99")],
        coord_rows=[(202, _DOMRF_LAT, _DOMRF_LON)],
    )

    with patch(_CLEAN_ADDRESS_PATH, return_value=(addr_lat, addr_lon)):
        report = find_geo_matches(mock_db, max_distance_m=500)

    assert report.confirmed == []
    assert len(report.rejected) == 1
    rej = report.rejected[0]
    assert rej.reason == "too_far"
    assert rej.distance_m is not None and abs(rej.distance_m - 900.0) < 10.0


# ── ambiguous: резолв на ближнего / остаётся при двух в радиусе ──────────────


def test_geo_ambiguous_resolves_to_nearest() -> None:
    """ambiguous, 2 кандидата ядра: один в 150 м, другой в 5 км → резолв на ближнего."""
    near_lat, near_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 150.0)
    far_lat, far_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 5000.0)

    mock_db = _make_geo_db(
        objective_rows=[
            ("Меридиан", ["Девелопер"], 7003),
            ("Меридиан ", ["Другой"], 7004),  # то же ядро «меридиан»
        ],
        taken_names=[],
        domrf_rows=[(303, 'ЖК "Меридиан"', "ООО Девелопер")],
        address_rows=[
            ("Меридиан", "г Екатеринбург, ул Близкая, 1"),
            ("Меридиан ", "г Екатеринбург, ул Далёкая, 2"),
        ],
        coord_rows=[(303, _DOMRF_LAT, _DOMRF_LON)],
    )

    def _fake_geocode(addr: str) -> tuple[float, float]:
        if "Близкая" in addr:
            return (near_lat, near_lon)
        return (far_lat, far_lon)

    with patch(_CLEAN_ADDRESS_PATH, side_effect=_fake_geocode):
        report = find_geo_matches(mock_db, max_distance_m=500)

    assert len(report.confirmed) == 1
    gm = report.confirmed[0]
    assert gm.objective_project_name == "Меридиан"  # ближний
    assert gm.objective_project_id == 7003
    assert gm.origin == "ambiguous"
    assert abs(gm.distance_m - 150.0) < 10.0


def test_geo_geocode_prefixes_city_when_missing() -> None:
    """Адреса без города префиксуются «г Екатеринбург, » перед DaData (qc_geo=5 фикс);
    адрес с городом уходит как есть."""
    near_lat, near_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 100.0)
    calls: list[str] = []

    mock_db = _make_geo_db(
        objective_rows=[("Уникальный", ["Девелопер"], 7001)],
        taken_names=[],
        domrf_rows=[(301, 'ЖК "Уникальный"', "ООО Другой")],
        address_rows=[("Уникальный", "ул. Гидрострой, д. 2а")],
        coord_rows=[(301, _DOMRF_LAT, _DOMRF_LON)],
    )

    def _fake_geocode(addr: str) -> tuple[float, float]:
        calls.append(addr)
        return (near_lat, near_lon)

    with patch(_CLEAN_ADDRESS_PATH, side_effect=_fake_geocode):
        report = find_geo_matches(mock_db, max_distance_m=500)

    assert calls == ["г Екатеринбург, ул. Гидрострой, д. 2а"]
    assert len(report.confirmed) == 1


def test_geo_ambiguous_partial_geocode_does_not_blind_confirm() -> None:
    """ambiguous, один кандидат геокодится в 100 м, второй НЕ геокодится вовсе →
    НЕ confirm (reject partial_geocode): «один в радиусе» — артефакт отсутствия
    данных, сосед мог быть ближе (ревью 2026-07-03)."""
    near_lat, near_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 100.0)

    mock_db = _make_geo_db(
        objective_rows=[
            ("Меридиан", ["Девелопер"], 7003),
            ("Меридиан ", ["Другой"], 7004),  # то же ядро
        ],
        taken_names=[],
        domrf_rows=[(303, 'ЖК "Меридиан"', "ООО Девелопер")],
        address_rows=[
            ("Меридиан", "г Екатеринбург, ул Близкая, 1"),
            ("Меридиан ", "г Екатеринбург, ул Негеокодимая, 2"),
        ],
        coord_rows=[(303, _DOMRF_LAT, _DOMRF_LON)],
    )

    def _fake_geocode(addr: str) -> tuple[float, float] | None:
        if "Близкая" in addr:
            return (near_lat, near_lon)
        return None  # сосед не геокодится

    with patch(_CLEAN_ADDRESS_PATH, side_effect=_fake_geocode):
        report = find_geo_matches(mock_db, max_distance_m=500)

    assert report.confirmed == []
    reasons = {r.reason for r in report.rejected}
    assert "partial_geocode" in reasons


def test_geo_ambiguous_two_in_radius_stays_ambiguous() -> None:
    """ambiguous, оба кандидата в радиусе → НЕ резолвим, остаётся ambiguous."""
    a_lat, a_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 100.0)
    b_lat, b_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 200.0)

    mock_db = _make_geo_db(
        objective_rows=[
            ("Меридиан", ["Девелопер"], 7003),
            ("Меридиан ", ["Другой"], 7004),
        ],
        taken_names=[],
        domrf_rows=[(303, 'ЖК "Меридиан"', "ООО Девелопер")],
        address_rows=[
            ("Меридиан", "г Екатеринбург, ул А, 1"),
            ("Меридиан ", "г Екатеринбург, ул Б, 2"),
        ],
        coord_rows=[(303, _DOMRF_LAT, _DOMRF_LON)],
    )

    def _fake_geocode(addr: str) -> tuple[float, float]:
        return (a_lat, a_lon) if "ул А" in addr else (b_lat, b_lon)

    with patch(_CLEAN_ADDRESS_PATH, side_effect=_fake_geocode):
        report = find_geo_matches(mock_db, max_distance_m=500)

    assert report.confirmed == []
    assert len(report.rejected) == 1
    assert report.rejected[0].reason == "ambiguous_multi"


# ── no address / no geocode → reject, не падаем ──────────────────────────────


def test_geo_no_address_rejects_without_geocode_call() -> None:
    """tier_b без адреса → reject 'no_address', DaData не дёргается."""
    mock_db = _make_geo_db(
        objective_rows=[("Арсенал", ["Брозэкс"], 7002)],
        taken_names=[],
        domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")],
        address_rows=[],  # адреса нет
        coord_rows=[(202, _DOMRF_LAT, _DOMRF_LON)],
    )

    with patch(_CLEAN_ADDRESS_PATH, return_value=(1.0, 2.0)) as mock_geo:
        report = find_geo_matches(mock_db, max_distance_m=500)

    assert report.confirmed == []
    assert len(report.rejected) == 1
    assert report.rejected[0].reason == "no_address"
    assert report.dadata_calls == 0
    mock_geo.assert_not_called()


def test_geo_null_geocode_rejects_no_crash() -> None:
    """tier_b, DaData вернул None → reject 'no_geocode', не падаем."""
    mock_db = _make_geo_db(
        objective_rows=[("Арсенал", ["Брозэкс"], 7002)],
        taken_names=[],
        domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")],
        address_rows=[("Арсенал", "нераспознаваемый адрес")],
        coord_rows=[(202, _DOMRF_LAT, _DOMRF_LON)],
    )

    with patch(_CLEAN_ADDRESS_PATH, return_value=None):
        report = find_geo_matches(mock_db, max_distance_m=500)

    assert report.confirmed == []
    assert len(report.rejected) == 1
    assert report.rejected[0].reason == "no_geocode"
    assert report.dadata_calls == 1


# ── лимит вызовов DaData соблюдается ─────────────────────────────────────────


def test_geo_call_limit_respected() -> None:
    """При GEO_DADATA_CALL_LIMIT+2 tier_b с уникальными адресами делаем ровно LIMIT
    вызовов, остаток reject c call_limit_hit=True."""
    n = GEO_DADATA_CALL_LIMIT + 2
    objective_rows = [(f"Проект{i}", [f"Dev{i}"], 8000 + i) for i in range(n)]
    domrf_rows = [(9000 + i, f'ЖК "Проект{i}"', f"ООО Иное{i}") for i in range(n)]
    address_rows = [(f"Проект{i}", f"г Екатеринбург, ул {i}, дом {i}") for i in range(n)]
    coord_rows = [(9000 + i, _DOMRF_LAT, _DOMRF_LON) for i in range(n)]

    mock_db = _make_geo_db(
        objective_rows=objective_rows,
        taken_names=[],
        domrf_rows=domrf_rows,
        address_rows=address_rows,
        coord_rows=coord_rows,
    )

    call_counter = {"n": 0}

    def _fake_geocode(addr: str) -> tuple[float, float]:
        call_counter["n"] += 1
        return (_DOMRF_LAT, _DOMRF_LON)  # всё в 0 м → confirm

    with patch(_CLEAN_ADDRESS_PATH, side_effect=_fake_geocode):
        report = find_geo_matches(mock_db, max_distance_m=500)

    assert report.dadata_calls == GEO_DADATA_CALL_LIMIT
    assert call_counter["n"] == GEO_DADATA_CALL_LIMIT
    assert report.call_limit_hit is True
    # 2 кандидата за лимитом → reject 'call_limit'
    call_limit_rejects = [r for r in report.rejected if r.reason == "call_limit"]
    assert len(call_limit_rejects) == 2


# ── apply_geo_matches: dry-run / apply ───────────────────────────────────────


def _confirmed_geo_report() -> GeoMatchReport:
    """Отчёт с одним confirmed tier_b гео-матчем (150 м)."""
    addr_lat, addr_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 150.0)
    mock_db = _make_geo_db(
        objective_rows=[("Арсенал", ["Брозэкс"], 7002)],
        taken_names=[],
        domrf_rows=[(202, 'ЖК "Арсенал"', "ООО СЗ Арсенал")],
        address_rows=[("Арсенал", "г Екатеринбург, ул Арсенальная, 1")],
        coord_rows=[(202, _DOMRF_LAT, _DOMRF_LON)],
    )
    with patch(_CLEAN_ADDRESS_PATH, return_value=(addr_lat, addr_lon)):
        return find_geo_matches(mock_db, max_distance_m=500)


def test_apply_geo_matches_dry_run_no_write() -> None:
    """dry_run=True → только counts, execute/commit не вызываются."""
    report = _confirmed_geo_report()
    assert len(report.confirmed) == 1

    mock_db = MagicMock()
    result = apply_geo_matches(mock_db, report, dry_run=True)

    assert result["inserted"] == 0
    assert result["confirmed_total"] == 1
    mock_db.execute.assert_not_called()
    mock_db.commit.assert_not_called()


def test_apply_geo_matches_writes_v6_score_note() -> None:
    """apply пишет confirmed с method=auto_core_geo_v6, score=0.80, note-дистанцией."""
    report = _confirmed_geo_report()

    savepoint_cm = MagicMock()
    savepoint_cm.__enter__ = MagicMock(return_value=None)
    savepoint_cm.__exit__ = MagicMock(return_value=False)

    exec_result = MagicMock()
    exec_result.rowcount = 1

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

    result = apply_geo_matches(mock_db, report, dry_run=False)

    assert result["inserted"] == 1
    assert result["conflict_skipped"] == 0
    mock_db.commit.assert_called_once()

    params = mock_db.execute.call_args[0][1]
    assert params["method"] == GEO_MATCH_METHOD
    assert params["score"] == GEO_MATCH_SCORE
    assert params["reviewed"] is False
    assert params["group"] == "Екатеринбург"
    assert params["name"] == "Арсенал"
    assert params["obj_id"] == 202
    assert params["project_id"] == 7002
    # note несёт дистанцию в метрах + origin
    assert "м" in params["note"]
    assert "tier_b" in params["note"]


def test_apply_geo_matches_on_conflict_counts_skip() -> None:
    """ON CONFLICT DO NOTHING (rowcount=0) → conflict_skipped, не падает."""
    report = _confirmed_geo_report()

    savepoint_cm = MagicMock()
    savepoint_cm.__enter__ = MagicMock(return_value=None)
    savepoint_cm.__exit__ = MagicMock(return_value=False)

    exec_result = MagicMock()
    exec_result.rowcount = 0

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

    result = apply_geo_matches(mock_db, report, dry_run=False)

    assert result["inserted"] == 0
    assert result["conflict_skipped"] == 1
    mock_db.commit.assert_called_once()
