"""Unit-тесты атрибуции застройщика участка (#1088 «GG-форсайт»).

Тестируем get_developer_attribution с MagicMock-сессией (без живой БД, без app.main):
  • happy-path: rows резолвера → primary (топ-1) + nearby_developers;
  • дедуп по developer_inn: один застройщик в exact+spatial+quarter → один в выводе,
    сохраняется ЛУЧШИЙ (первый по ORDER BY резолвера) match_method;
  • nearby содержит только spatial_150m/quarter (не exact, не дубль primary);
  • пустой результат резолвера → None;
  • graceful-degrade при БД-ошибке (OperationalError/ProgrammingError/DataError —
    incl. ещё не задеплоенная миграция 149) → None, analyze не падает;
  • SQL использует CAST(:x AS type), НИКОГДА :x::type (psycopg v3).

Детерминированно, без LLM. rows мокаются как dict (RowMapping-совместимо через
__getitem__, как реальный .mappings().all()).
"""

from __future__ import annotations

import os

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

from typing import Any
from unittest.mock import MagicMock

import pytest
from sqlalchemy.exc import DataError, OperationalError, ProgrammingError

from app.services.site_finder.developer_attribution import (
    _DEVELOPER_FOR_PARCEL_SQL,
    get_developer_attribution,
)

# ── helpers ────────────────────────────────────────────────────────────────────


def _mock_db(rows: list[dict[str, Any]]) -> MagicMock:
    """Сессия с одним execute → .mappings().all() == rows (зеркало test_future_supply)."""
    db = MagicMock()
    result = MagicMock()
    result.mappings.return_value.all.return_value = rows
    db.execute.return_value = result
    return db


def _row(
    inn: str,
    *,
    name: str | None = "Канон Девелопмент",
    source: str = "permits",
    match_method: str = "exact_cadastral",
    distance_m: float | None = 0.0,
    project: str | None = "ЖК Тест",
    status: str | None = "RNS",
    obj_class: str | None = "Комфорт",
    permits_total: int | None = 5,
    domrf_objects_total: int | None = 3,
    domrf_active_objects: int | None = 2,
    in_both_sources: bool | None = True,
) -> dict[str, Any]:
    """Одна строка резолвера fn_developer_for_parcel (полный набор колонок)."""
    return {
        "developer_inn": inn,
        "developer_name": name,
        "source": source,
        "match_method": match_method,
        "distance_m": distance_m,
        "project": project,
        "status": status,
        "obj_class": obj_class,
        "permits_total": permits_total,
        "domrf_objects_total": domrf_objects_total,
        "domrf_active_objects": domrf_active_objects,
        "in_both_sources": in_both_sources,
    }


# ── SQL-конвенция (psycopg v3) ──────────────────────────────────────────────────


def test_sql_uses_cast_not_double_colon() -> None:
    """psycopg v3: запрещён :name::type (SQLAlchemy 2.0 молча роняет его → SyntaxError)."""
    sql = str(_DEVELOPER_FOR_PARCEL_SQL)
    assert "::" not in sql, f"double-colon cast найден в SQL: {sql!r}"
    assert "CAST(:radius AS double precision)" in sql


# ── happy-path ───────────────────────────────────────────────────────────────


class TestPrimaryAttribution:
    def test_single_developer_becomes_primary(self) -> None:
        db = _mock_db([_row("6671138299", name="ЕКБ Девелопмент")])
        result = get_developer_attribution(db, "66:41:0303161:123")

        assert result is not None
        assert result.primary.developer_inn == "6671138299"
        assert result.primary.developer_name == "ЕКБ Девелопмент"
        assert result.primary.match_method == "exact_cadastral"
        assert result.primary.distance_m == 0.0
        assert result.primary.permits_total == 5
        assert result.primary.in_both_sources is True
        assert result.nearby_developers == []

    def test_radius_passed_to_resolver(self) -> None:
        db = _mock_db([_row("6671138299")])
        get_developer_attribution(db, "66:41:0303161:123", radius_m=250.0)

        _args, _kwargs = db.execute.call_args
        params = _args[1]
        assert params == {"cad_num": "66:41:0303161:123", "radius": 250.0}

    def test_default_radius_is_150(self) -> None:
        db = _mock_db([_row("6671138299")])
        get_developer_attribution(db, "66:41:0303161:123")
        params = db.execute.call_args[0][1]
        assert params["radius"] == 150.0

    def test_first_row_is_primary_resolver_order_trusted(self) -> None:
        # Резолвер уже ORDER BY method (exact > spatial > quarter): первая строка = топ-1.
        rows = [
            _row("1111111111", match_method="exact_cadastral", distance_m=0.0),
            _row("2222222222", match_method="spatial_150m", distance_m=42.0),
        ]
        result = get_developer_attribution(db := _mock_db(rows), "66:41:0303161:1")
        assert db is not None
        assert result is not None
        assert result.primary.developer_inn == "1111111111"
        assert result.primary.match_method == "exact_cadastral"


# ── дедуп по developer_inn ───────────────────────────────────────────────────


class TestDedup:
    def test_same_inn_across_stages_dedups_keeping_best_method(self) -> None:
        # Один застройщик встречается в exact + spatial + quarter (резолвер не
        # взаимоисключающий). Дедуп оставляет ПЕРВЫЙ (лучший по ORDER BY) — exact.
        rows = [
            _row("6671138299", match_method="exact_cadastral", distance_m=0.0, project="ЖК A"),
            _row("6671138299", match_method="spatial_150m", distance_m=80.0, project="ЖК A-2"),
            _row("6671138299", match_method="quarter", distance_m=None, project="ЖК A-3"),
        ]
        result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")

        assert result is not None
        assert result.primary.developer_inn == "6671138299"
        # ЛУЧШИЙ метод сохранён (первый по ORDER BY), не перезаписан spatial/quarter.
        assert result.primary.match_method == "exact_cadastral"
        assert result.primary.project == "ЖК A"
        # Тот же ИНН не попадает в nearby (дедупнут целиком).
        assert result.nearby_developers == []

    def test_distinct_developers_split_primary_and_nearby(self) -> None:
        rows = [
            _row("1111111111", match_method="exact_cadastral", distance_m=0.0),
            _row("2222222222", match_method="spatial_150m", distance_m=55.0),
            _row("3333333333", match_method="quarter", distance_m=None),
        ]
        result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")

        assert result is not None
        assert result.primary.developer_inn == "1111111111"
        nearby_inns = [d.developer_inn for d in result.nearby_developers]
        assert nearby_inns == ["2222222222", "3333333333"]

    def test_nearby_developer_dedups_too(self) -> None:
        # Сосед, дважды встретившийся (spatial + quarter), в nearby один раз (лучший).
        rows = [
            _row("1111111111", match_method="exact_cadastral", distance_m=0.0),
            _row("2222222222", match_method="spatial_150m", distance_m=55.0),
            _row("2222222222", match_method="quarter", distance_m=None),
        ]
        result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")

        assert result is not None
        assert [d.developer_inn for d in result.nearby_developers] == ["2222222222"]
        assert result.nearby_developers[0].match_method == "spatial_150m"


class TestNearbyFiltering:
    def test_nearby_excludes_secondary_exact_method(self) -> None:
        # Второй (другой ИНН) с exact (multi-value РНС на тот же ЗУ) — не primary, но
        # в nearby кладём только spatial/quarter-контекст («строят рядом/в квартале»).
        rows = [
            _row("1111111111", match_method="exact_cadastral", distance_m=0.0),
            _row("2222222222", match_method="exact_cadastral", distance_m=0.0),
            _row("3333333333", match_method="spatial_150m", distance_m=60.0),
        ]
        result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")

        assert result is not None
        assert result.primary.developer_inn == "1111111111"
        # Вторичный exact (2222) отфильтрован; остаётся только spatial-сосед (3333).
        assert [d.developer_inn for d in result.nearby_developers] == ["3333333333"]

    def test_nearby_includes_both_spatial_and_quarter(self) -> None:
        rows = [
            _row("1111111111", match_method="exact_cadastral", distance_m=0.0),
            _row("2222222222", match_method="spatial_150m", distance_m=30.0),
            _row("3333333333", match_method="quarter", distance_m=None, source="domrf"),
        ]
        result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")

        assert result is not None
        methods = [d.match_method for d in result.nearby_developers]
        assert methods == ["spatial_150m", "quarter"]


# ── nullable track-record (FULL OUTER JOIN → None-колонки) ───────────────────


class TestNullableFields:
    def test_developer_known_only_to_one_source(self) -> None:
        # Застройщик из domrf без РНС: permits_total может быть 0, distance_m None.
        row = _row(
            "7777777777",
            source="domrf",
            match_method="quarter",
            distance_m=None,
            status="Строящиеся",
            permits_total=0,
            in_both_sources=False,
        )
        result = get_developer_attribution(_mock_db([row]), "66:41:0303161:1")

        assert result is not None
        assert result.primary.distance_m is None
        assert result.primary.permits_total == 0
        assert result.primary.in_both_sources is False

    def test_null_track_record_columns_pass_through(self) -> None:
        row = _row(
            "8888888888",
            name=None,
            domrf_objects_total=None,
            domrf_active_objects=None,
            in_both_sources=None,
        )
        result = get_developer_attribution(_mock_db([row]), "66:41:0303161:1")

        assert result is not None
        assert result.primary.developer_name is None
        assert result.primary.domrf_objects_total is None
        assert result.primary.in_both_sources is None


# ── пустой результат → None ──────────────────────────────────────────────────


def test_empty_resolver_result_returns_none() -> None:
    result = get_developer_attribution(_mock_db([]), "66:41:0303161:1")
    assert result is None


def test_all_rows_null_inn_returns_none() -> None:
    # Резолвер гарантирует NOT NULL inn (WHERE c.inn IS NOT NULL), но защищаемся:
    # все строки с NULL-ИНН → нечего атрибутировать → None.
    rows = [_row("ignored")]
    rows[0]["developer_inn"] = None
    result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")
    assert result is None


# ── graceful-degrade при БД-ошибке ───────────────────────────────────────────


@pytest.mark.parametrize(
    "exc",
    [
        OperationalError("stmt", {}, Exception("connection lost")),
        ProgrammingError("stmt", {}, Exception("function fn_developer_for_parcel does not exist")),
        DataError("stmt", {}, Exception("invalid input syntax")),
    ],
    ids=["operational", "programming_pre_migration", "data"],
)
def test_db_error_degrades_to_none(exc: Exception) -> None:
    # Миграция 149 не задеплоена (ProgrammingError) / коннект упал (OperationalError) /
    # битый ввод (DataError) → None, analyze не падает.
    db = MagicMock()
    db.execute.side_effect = exc
    result = get_developer_attribution(db, "66:41:0303161:1")
    assert result is None


def test_unexpected_error_also_degrades_to_none() -> None:
    # Любая иная ошибка (не из перечисленных) тоже не валит analyze — None.
    db = MagicMock()
    db.execute.side_effect = RuntimeError("boom")
    result = get_developer_attribution(db, "66:41:0303161:1")
    assert result is None


# ── SAVEPOINT-регрессия (#2464 cluster A finding 1) ──────────────────────────


def test_db_error_uses_savepoint_and_session_stays_usable() -> None:
    """Фикс: db.execute обёрнут в db.begin_nested() (SAVEPOINT), НЕ голый rollback.

    db здесь — shared request-scoped Session (Depends(get_db) в parcels.py analyze_parcel);
    без SAVEPOINT сбой fn_developer_for_parcel отравил бы persist_analysis_run сразу
    после (parcels.py:4116). Мокаем db.execute: 1-й вызов (внутри
    get_developer_attribution) кидает, 2-й (имитация persist_analysis_run следом на
    том же db) отрабатывает нормально.
    """
    db = MagicMock()
    ok_result = MagicMock()
    ok_result.mappings.return_value.all.return_value = [{"marker": "persist-ok"}]
    db.execute.side_effect = [
        ProgrammingError("stmt", {}, Exception("fn_developer_for_parcel does not exist")),
        ok_result,
    ]

    result = get_developer_attribution(db, "66:41:0303161:1")

    assert result is None
    assert db.begin_nested.call_count == 1  # SAVEPOINT вокруг резолвера
    # Сессия осталась usable для следующего (не связанного) db.execute.
    next_call = db.execute("SELECT 1")
    assert next_call.mappings().all() == [{"marker": "persist-ok"}]
    assert db.execute.call_count == 2
