"""Тесты: epic #2464 cluster B — silent-cap honesty fixes в analyze_parcel.

Mock-based — не требуют живой БД.

Покрывает:
  1. `_build_market_pulse` (market_pulse.competitors_total / coverage_pct) —
     раньше competitors_total = len(competitor_rows) (LIMIT 20 в SQL), теперь
     принимает честный total отдельным параметром (см. analyze_parcel 5a —
     COUNT(*) БЕЗ LIMIT) и считает coverage_pct против него.
  2. `_neighbors_summary` (count_buildings_100m / neighbors_truncated) —
     раньше count_buildings_100m = len(neighbor_rows) (LIMIT 30 в CTE
     `neighbors`), теперь честный total из CTE `neighbors_total` (БЕЗ LIMIT).

Reference fix pattern: `features_truncated` в
`app/services/site_finder/utility_infrastructure_loader.py` (epic #2445 A2).
"""

from __future__ import annotations

from unittest.mock import MagicMock

from app.api.v1 import parcels as parcels_module

# ── Вспомогательные фабрики ───────────────────────────────────────────────────


def _competitor_row(
    obj_id: int,
    avg_price_per_m2_rub: float | None,
    units_sold: int | None = None,
) -> dict:
    return {
        "obj_id": obj_id,
        "comm_name": f"ЖК-{obj_id}",
        "dev_name": "TestDev",
        "avg_price_per_m2_rub": avg_price_per_m2_rub,
        "units_sold": units_sold,
    }


# ── _build_market_pulse: competitors_total / coverage_pct honesty ────────────


class TestBuildMarketPulseHonesty:
    """market_pulse.competitors_total / coverage_pct — честный total (#2464 cluster B)."""

    def test_below_cap_total_matches_len(self) -> None:
        """Ниже LIMIT (мало конкурентов в радиусе) — true total == len(rows), zero
        behavior change: старое поведение (len(competitor_rows)) совпадает с новым."""
        rows = [_competitor_row(i, 150_000.0) for i in range(1, 6)]  # 5 конкурентов
        pulse = parcels_module._build_market_pulse(rows, competitors_total=5, velocity_data=None)

        assert pulse["competitors_total"] == 5
        assert pulse["competitors_with_price"] == 5
        assert pulse["coverage_pct"] == 100.0

    def test_at_cap_total_is_honest_not_len_of_rows(self) -> None:
        """#2464 cluster B core regression: competitor_rows капнут LIMIT 20 (детальный
        список), но реальных ЖК в радиусе 45 (честный COUNT). competitors_total ДОЛЖЕН
        отражать 45, а НЕ len(rows)=20 (старый баг — silent cap репортился как total)."""
        rows = [_competitor_row(i, 150_000.0) for i in range(1, 21)]  # ровно LIMIT 20
        true_total = 45
        pulse = parcels_module._build_market_pulse(
            rows, competitors_total=true_total, velocity_data=None
        )

        assert pulse["competitors_total"] == true_total
        assert pulse["competitors_total"] != len(
            rows
        ), "regression guard: competitors_total НЕ должен деградировать до len(competitor_rows)"

    def test_coverage_pct_computed_against_true_total_not_capped_list(self) -> None:
        """coverage_pct = priced / TRUE total — раньше делилось на len(rows) (капнутый
        список), что давало ложные 100% покрытия даже когда реальное покрытие ниже."""
        # Все 20 показанных строк имеют цену → раньше coverage_pct считался бы 100%.
        rows = [_competitor_row(i, 150_000.0) for i in range(1, 21)]
        true_total = 40  # реально в радиусе 40 ЖК, из них с ценой только эти 20.
        pulse = parcels_module._build_market_pulse(
            rows, competitors_total=true_total, velocity_data=None
        )

        assert pulse["competitors_with_price"] == 20
        # 20/40*100 = 50.0, НЕ 100.0 (старая формула len(rows)-денутый would дать 100%).
        assert pulse["coverage_pct"] == 50.0

    def test_zero_competitors_honest_zero_coverage(self) -> None:
        """Нет конкурентов в радиусе → coverage_pct=0.0 (не деление на ноль)."""
        pulse = parcels_module._build_market_pulse([], competitors_total=0, velocity_data=None)

        assert pulse["competitors_total"] == 0
        assert pulse["competitors_with_price"] == 0
        assert pulse["coverage_pct"] == 0.0
        assert pulse["market_avg_price_per_m2"] is None
        assert pulse["top_sellers"] == []

    def test_unpriced_competitors_excluded_from_market_avg_but_counted_in_total(self) -> None:
        """ЖК без маппинга (avg_price_per_m2_rub=None) остаются в competitor_rows
        (детальный список для карты), но не влияют на market_avg_price_per_m2."""
        rows = [
            _competitor_row(1, 100_000.0),
            _competitor_row(2, None),
            _competitor_row(3, 200_000.0),
        ]
        pulse = parcels_module._build_market_pulse(rows, competitors_total=3, velocity_data=None)

        assert pulse["competitors_with_price"] == 2
        assert pulse["market_avg_price_per_m2"] == 150_000

    def test_velocity_data_feeds_avg_velocity_m2(self) -> None:
        """avg_velocity_m2 берётся из velocity_data.monthly_velocity_sqm, если есть."""
        rows = [_competitor_row(1, 100_000.0)]
        pulse = parcels_module._build_market_pulse(
            rows, competitors_total=1, velocity_data={"monthly_velocity_sqm": 750.5}
        )
        assert pulse["avg_velocity_m2"] == 750.5

    def test_top_sellers_top5_sorted_by_units_sold(self) -> None:
        """top_sellers — топ-5 по units_sold среди приценённых конкурентов."""
        rows = [_competitor_row(i, 100_000.0, units_sold=i * 10) for i in range(1, 8)]
        pulse = parcels_module._build_market_pulse(rows, competitors_total=7, velocity_data=None)

        assert len(pulse["top_sellers"]) == 5
        sold = [s["units_sold"] for s in pulse["top_sellers"]]
        assert sold == sorted(sold, reverse=True)
        assert sold[0] == 70  # obj_id=7 продал больше всех


# ── _neighbors_summary: count_buildings_100m / neighbors_truncated honesty ───


def _neighbor_row(cad_num: str, distance_m: float = 10.0) -> dict:
    return {
        "cad_num": cad_num,
        "building_name": None,
        "floors": None,
        "year_built": None,
        "cost_value": None,
        "area": None,
        "readable_address": None,
        "purpose": None,
        "status": None,
        "distance_m": distance_m,
    }


def _make_neighbors_db(neighbors_json: list, neighbors_total_count: int, overlap_json=None):
    row = {
        "neighbors": neighbors_json,
        "overlap_rows": overlap_json or [],
        "neighbors_total_count": neighbors_total_count,
    }
    result = MagicMock()
    result.mappings.return_value.first.return_value = row
    db = MagicMock()
    db.execute.return_value = result
    return db


class TestNeighborsSummaryHonesty:
    """count_buildings_100m / neighbors_truncated — честный total (#2464 cluster B)."""

    def test_below_cap_count_matches_len_no_truncation(self) -> None:
        """Ниже LIMIT 30 — true total == len(neighbors), neighbors_truncated=False
        (zero behavior change для типичного случая)."""
        neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(5)]
        db = _make_neighbors_db(neighbors, neighbors_total_count=5)

        summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")

        assert summary["data_available"] is True
        assert summary["count_buildings_100m"] == 5
        assert summary["neighbors_truncated"] is False

    def test_at_cap_count_is_honest_total_not_len_of_list(self) -> None:
        """#2464 cluster B core regression: `neighbors` CTE капнут LIMIT 30, но
        реальных соседей в радиусе 47 (честный COUNT из `neighbors_total`).
        count_buildings_100m ДОЛЖЕН отражать 47, а НЕ len(neighbors)=30."""
        neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(30)]  # ровно LIMIT 30
        true_total = 47
        db = _make_neighbors_db(neighbors, neighbors_total_count=true_total)

        summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")

        assert summary["count_buildings_100m"] == true_total
        assert summary["count_buildings_100m"] != len(
            neighbors
        ), "regression guard: count_buildings_100m НЕ должен деградировать до len(neighbor_rows)"
        assert summary["neighbors_truncated"] is True

    def test_neighbors_list_itself_unaffected_by_count_fix(self) -> None:
        """Constraint: детальный список `neighbors` (payload для UI, уже капнут до 20
        в существующем коде — отдельно от SQL LIMIT 30) остаётся прежним по составу/
        порядку — фикс трогает только count/флаг, НЕ список."""
        neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(30)]
        db = _make_neighbors_db(neighbors, neighbors_total_count=47)

        summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")

        assert len(summary["neighbors"]) == 20
        assert summary["neighbors"][0]["cad_num"] == "66:41:0000000:0"

    def test_neighbors_truncated_reflects_actual_shown_list_not_intermediate_30(self) -> None:
        """neighbors_truncated должен сравнивать честный total с тем, что РЕАЛЬНО
        показано в `neighbors` (капнуто до 20), а НЕ с промежуточным neighbor_rows
        (до 30 из SQL) — иначе флаг может ложно молчать (False) на 21..30-м соседе,
        где список уже обрезан до 20, а SQL ещё не упёрся в свой LIMIT 30."""
        # SQL вернул 25 строк (не уперся в LIMIT 30) — но список для UI режется до 20.
        neighbors = [_neighbor_row(f"66:41:0000000:{i}") for i in range(25)]
        db = _make_neighbors_db(neighbors, neighbors_total_count=25)

        summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")

        assert len(summary["neighbors"]) == 20
        assert summary["count_buildings_100m"] == 25
        # 25 (total) > 20 (реально показано) → truncated=True, даже хотя SQL LIMIT 30
        # не был исчерпан.
        assert summary["neighbors_truncated"] is True

    def test_query_failure_degrades_gracefully(self) -> None:
        """DB-ошибка → data_available=False, note с текстом ошибки (unchanged behavior)."""
        db = MagicMock()
        db.execute.side_effect = RuntimeError("boom")

        summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")

        assert summary == {"data_available": False, "note": "neighbors query failed: boom"}
