"""Тесты для POST /api/v1/parcels/{cad_num}/competitors (Issue #112).

Mock-based — не требуют живой БД.
Паттерн mock DB: аналогично test_admin_cadastre.py — dependency_overrides[get_db].
"""

from __future__ import annotations

from unittest.mock import MagicMock

import pytest
from fastapi.testclient import TestClient

from app.main import app

# ── Фабрики mock-строк ────────────────────────────────────────────────────────


def _coord_row(lat: float = 56.838, lon: float = 60.605) -> MagicMock:
    """Строка центроида участка."""
    r = MagicMock()
    r.__getitem__ = lambda self, k: {"lat": lat, "lon": lon}[k]
    return r


def _obj_row(
    obj_id: int = 1,
    distance_m: float = 400.0,
    site_status: str = "Строящиеся",
    obj_class: str | None = "comfort",
    velocity: float = 5.0,
    flat_count: int | None = 200,
) -> MagicMock:
    r = MagicMock()
    r.__getitem__ = lambda self, k: {
        "obj_id": obj_id,
        "comm_name": f"ЖК-{obj_id}",
        "dev_name": "TestDev",
        "obj_class": obj_class,
        "latitude": 56.838 + distance_m / 1_000_000,
        "longitude": 60.605 + distance_m / 1_000_000,
        "flat_count": flat_count,
        "site_status": site_status,
        "distance_m": distance_m,
        "velocity_per_month": velocity,
    }[k]
    return r


def _price_row(obj_id: int, price: float) -> MagicMock:
    r = MagicMock()
    r.__getitem__ = lambda self, k: {"obj_id": obj_id, "avg_price_per_m2": price}[k]
    return r


def _sold_row(obj_id: int, flats_sold: int) -> MagicMock:
    """Строка sold-count (_SOLD_COUNT_SQL, #1926).

    flats_sold теперь COUNT проданных лотов из objective_lots.contract_date
    (выровнено с MarketMetrics), а не COUNT по domrf_kn_flats.status — последний
    заполнен ~0.2% и давал массовый недосчёт.
    """
    r = MagicMock()
    r.__getitem__ = lambda self, k: {"obj_id": obj_id, "flats_sold": flats_sold}[k]
    return r


def _obj_price_row(obj_id: int, price: float) -> MagicMock:
    """Строка objective-fallback цены (_OBJECTIVE_PRICE_FALLBACK_SQL, OBJ-3 #307)."""
    r = MagicMock()
    r.__getitem__ = lambda self, k: {"obj_id": obj_id, "median_price_per_m2": price}[k]
    return r


# ── Построение mock DB ────────────────────────────────────────────────────────


def _make_db(
    coord: MagicMock | None = None,
    obj_rows: list[MagicMock] | None = None,
    price_rows: list[MagicMock] | None = None,
    sold_rows: list[MagicMock] | None = None,
    obj_price_rows: list[MagicMock] | None = None,
) -> MagicMock:
    """Сконструировать mock Session.

    Порядок вызовов execute (#1926: добавлен отдельный sold-count запрос):
    1. centroid query → coord
    2. competitors query → obj_rows
    3. domrf avg_price query → price_rows
    4. sold-count query (_SOLD_COUNT_SQL, objective_lots.contract_date) → sold_rows
       (по умолчанию [] = sold неизвестен → flats_sold None, нейтральный stage)
    5. objective price fallback (OBJ-3 #307) → obj_price_rows
       (вызывается ТОЛЬКО если у части obj_id нет domrf-цены — почти всегда,
       т.к. domrf на последнем snapshot пуст; по умолчанию [] = fallback пуст)
    """
    db = MagicMock()

    results: list[MagicMock] = []
    for rows, is_first in [
        (coord, True),
        (obj_rows or [], False),
        (price_rows or [], False),
        (sold_rows or [], False),
        (obj_price_rows or [], False),
    ]:
        result = MagicMock()
        if is_first:
            # centroid → .mappings().first()
            result.mappings.return_value.first.return_value = rows
        else:
            result.mappings.return_value.all.return_value = rows
        results.append(result)

    db.execute.side_effect = results
    return db


def _override_db(db: MagicMock):
    def _get_db_override():
        yield db

    return _get_db_override


# ── Тесты ─────────────────────────────────────────────────────────────────────


def test_competitors_basic() -> None:
    """3 конкурента → корректная форма ответа, сортировка по distance."""
    rows = [
        _obj_row(obj_id=1, distance_m=200.0, velocity=4.0),
        _obj_row(obj_id=2, distance_m=500.0, velocity=6.0),
        _obj_row(obj_id=3, distance_m=900.0, velocity=2.0),
    ]
    db = _make_db(coord=_coord_row(), obj_rows=rows)

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:123/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter"},
        )
        assert resp.status_code == 200, resp.text
        body = resp.json()
        assert "competitors" in body
        assert "summary" in body
        assert len(body["competitors"]) == 3
        # первый должен быть ближайшим
        assert body["competitors"][0]["obj_id"] == 1
        assert body["competitors"][0]["distance_m"] == pytest.approx(200.0)
        # все поля присутствуют
        first = body["competitors"][0]
        for key in (
            "obj_id",
            "comm_name",
            "dev_name",
            "obj_class",
            "distance_m",
            "lat",
            "lng",
            "stage",
            "flats_total",
            "flats_sold",
            "sold_pct",
            "velocity_per_month",
            "avg_price_per_m2",
            "is_active",
        ):
            assert key in first, f"missing key: {key}"
    finally:
        app.dependency_overrides.clear()


def test_competitors_summary_calc() -> None:
    """summary: total_competitors, active_count, weighted_avg_velocity корректны.

    site_status — реальные русские значения как у домрф (#1213 fix).
    «Строящиеся» считаются активными, «Сданные» — нет.
    """
    rows = [
        _obj_row(obj_id=1, site_status="Строящиеся", velocity=10.0),
        _obj_row(obj_id=2, site_status="Строящиеся", velocity=6.0),
        _obj_row(obj_id=3, site_status="Сданные", velocity=2.0),
    ]
    db = _make_db(coord=_coord_row(), obj_rows=rows)

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter"},
        )
        assert resp.status_code == 200, resp.text
        summary = resp.json()["summary"]
        assert summary["total_competitors"] == 3
        assert summary["active_count"] == 2  # sales + construction
        # equal flats_total (200 each) → size-weighted AVG == naive: (10+6+2)/3 = 6.0
        assert summary["weighted_avg_velocity"] == pytest.approx(6.0)
        assert summary["radius_km"] == pytest.approx(1.0)
        assert summary["time_window"] == "last_quarter"
    finally:
        app.dependency_overrides.clear()


def test_competitors_weighted_velocity_by_size() -> None:
    """weighted_avg_velocity взвешен по flats_total — крупный ЖК весит больше (#949)."""
    rows = [
        _obj_row(obj_id=1, velocity=40.0, flat_count=500),
        _obj_row(obj_id=2, velocity=2.0, flat_count=20),
    ]
    db = _make_db(coord=_coord_row(), obj_rows=rows)

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter"},
        )
        assert resp.status_code == 200, resp.text
        wav = resp.json()["summary"]["weighted_avg_velocity"]
        # size-weighted = (40*500 + 2*20) / (500+20) = 20040/520 ≈ 38.54,
        # НЕ наивное среднее (40+2)/2 = 21.0 — доказывает взвешивание по flats_total.
        assert wav == pytest.approx(38.54, abs=0.05)
        assert wav != pytest.approx(21.0, abs=0.1)
    finally:
        app.dependency_overrides.clear()


def test_competitors_exclude_obj_ids() -> None:
    """exclude_obj_ids исключает указанные ЖК из результата."""
    rows = [
        _obj_row(obj_id=1, distance_m=100.0),
        _obj_row(obj_id=2, distance_m=200.0),
        _obj_row(obj_id=3, distance_m=300.0),
    ]
    db = _make_db(coord=_coord_row(), obj_rows=rows)

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter", "exclude_obj_ids": [2]},
        )
        assert resp.status_code == 200, resp.text
        ids = [c["obj_id"] for c in resp.json()["competitors"]]
        assert 2 not in ids
        assert 1 in ids
        assert 3 in ids
    finally:
        app.dependency_overrides.clear()


def test_competitors_obj_class_filter() -> None:
    """obj_class_filter=economy — SQL получает параметр; Python-сторона не ломается."""
    rows = [_obj_row(obj_id=10, obj_class="economy")]
    db = _make_db(coord=_coord_row(), obj_rows=rows)

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter", "obj_class_filter": "economy"},
        )
        assert resp.status_code == 200, resp.text
        comps = resp.json()["competitors"]
        assert len(comps) == 1
        assert comps[0]["obj_class"] == "economy"
    finally:
        app.dependency_overrides.clear()


def test_competitors_time_window_velocity() -> None:
    """time_window влияет на velocity_per_month (last_month vs last_year)."""
    # Здесь проверяем, что endpoint принимает оба варианта без ошибок
    # и возвращает velocity из mock-строки (DB-расчёт мокирован).
    rows_month = [_obj_row(obj_id=1, velocity=12.0)]
    rows_year = [_obj_row(obj_id=1, velocity=3.0)]

    from app.core.db import get_db

    # last_month
    db_m = _make_db(coord=_coord_row(), obj_rows=rows_month)
    app.dependency_overrides[get_db] = _override_db(db_m)
    try:
        client = TestClient(app)
        resp_m = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_month"},
        )
        assert resp_m.status_code == 200, resp_m.text
    finally:
        app.dependency_overrides.clear()

    # last_year
    db_y = _make_db(coord=_coord_row(), obj_rows=rows_year)
    app.dependency_overrides[get_db] = _override_db(db_y)
    try:
        client = TestClient(app)
        resp_y = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_year"},
        )
        assert resp_y.status_code == 200, resp_y.text
        v_month = resp_m.json()["competitors"][0]["velocity_per_month"]
        v_year = resp_y.json()["competitors"][0]["velocity_per_month"]
        # month velocity выше чем year в нашем моке
        assert v_month > v_year
    finally:
        app.dependency_overrides.clear()


def test_competitors_parcel_not_found_404() -> None:
    """Если центроид участка не найден → 404."""
    db = _make_db(coord=None)  # first() вернёт None

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/99:99:9999999:999/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter"},
        )
        assert resp.status_code == 404, resp.text
    finally:
        app.dependency_overrides.clear()


def test_competitors_empty_radius() -> None:
    """Нет конкурентов в радиусе → пустой список + summary с нулями."""
    db = _make_db(coord=_coord_row(), obj_rows=[])

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 0.1, "time_window": "last_quarter"},
        )
        assert resp.status_code == 200, resp.text
        body = resp.json()
        assert body["competitors"] == []
        assert body["summary"]["total_competitors"] == 0
        assert body["summary"]["active_count"] == 0
        assert body["summary"]["weighted_avg_velocity"] == pytest.approx(0.0)
    finally:
        app.dependency_overrides.clear()


def test_competitors_sold_pct_null() -> None:
    """sold_pct и flats_sold — None, когда sold-count запрос пуст (graceful).

    #1926: flats_sold берётся из objective_lots.contract_date (_SOLD_COUNT_SQL).
    Если у obj_id нет маппинга в objective_complex_mapping (coverage ~2.5%) →
    sold-count строки нет → flats_sold/sold_pct None, stage → нейтраль 0.5.
    """
    rows = [_obj_row(obj_id=1, flat_count=200)]
    db = _make_db(coord=_coord_row(), obj_rows=rows)  # sold_rows default [] → None

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter"},
        )
        assert resp.status_code == 200, resp.text
        comp = resp.json()["competitors"][0]
        assert comp["flats_sold"] is None
        assert comp["sold_pct"] is None
        assert comp["flats_total"] == 200
    finally:
        app.dependency_overrides.clear()


def test_competitors_flats_sold_from_objective_lots() -> None:
    """flats_sold/sold_pct заполняются из objective_lots (#1926).

    Регрессия аудита #1871/#1926: РАНЬШЕ flats_sold считался из
    domrf_kn_flats.status (LIKE '%прод%'), заполненного ~0.2% → массовый
    недосчёт и расхождение с MarketMetrics. Теперь источник И предикат — как
    в market_metrics._STOCK_SQL (is_sold OR contract_date OR status='продан',
    premise_kind='квартира'). Здесь sold-count запрос возвращает 120 проданных
    из 200 → sold_pct 60.0%. (SQL-структуру предиката/fan-out/premise_kind
    проверяют test_sold_count_sql_* ниже — мок тут SQL не исполняет.)
    """
    rows = [_obj_row(obj_id=1, flat_count=200)]
    sold_rows = [_sold_row(obj_id=1, flats_sold=120)]
    db = _make_db(coord=_coord_row(), obj_rows=rows, sold_rows=sold_rows)

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter"},
        )
        assert resp.status_code == 200, resp.text
        comp = resp.json()["competitors"][0]
        assert comp["flats_sold"] == 120
        assert comp["sold_pct"] == pytest.approx(60.0)
        assert comp["flats_total"] == 200
    finally:
        app.dependency_overrides.clear()


def test_competitors_avg_price_populated() -> None:
    """avg_price_per_m2 не None если domrf_kn_flats возвращает строки с ценой.

    Регрессионный тест для Issue #227: фильтр status='sold' давал 0 строк
    (поле status в domrf_kn_flats 99.8% NULL). После фикса — убран, AVG
    считается по всем квартирам с known price_per_m2.
    """
    rows = [_obj_row(obj_id=1)]
    price_rows = [_price_row(obj_id=1, price=150_000.0)]
    db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=price_rows)

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter"},
        )
        assert resp.status_code == 200, resp.text
        comp = resp.json()["competitors"][0]
        assert comp["avg_price_per_m2"] == pytest.approx(
            150_000.0
        ), "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
        # OBJ-3 #307: domrf-hit → price_source='domrf'.
        assert comp["price_source"] == "domrf"
    finally:
        app.dependency_overrides.clear()


# ── OBJ-3 (#307) additive fallback из objective_lots ────────────────────────────


def test_competitors_objective_price_fallback_fills_gap() -> None:
    """domrf-цены НЕТ → objective_lots заполняет avg_price_per_m2 (OBJ-3 #307).

    Прод 2026-06: domrf_kn_flats.price_per_m2 на последнем snapshot = 0 строк,
    поэтому domrf даёт None почти всем. objective_lots (через
    objective_complex_mapping) покрывает ~80% — этот fallback восстанавливает цену.
    """
    rows = [_obj_row(obj_id=1)]
    # domrf price пуст → fallback на objective median.
    obj_price_rows = [_obj_price_row(obj_id=1, price=136_000.0)]
    db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows)

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter"},
        )
        assert resp.status_code == 200, resp.text
        comp = resp.json()["competitors"][0]
        assert comp["avg_price_per_m2"] == pytest.approx(136_000.0)
        assert comp["price_source"] == "objective"
    finally:
        app.dependency_overrides.clear()


def test_competitors_domrf_price_wins_over_objective() -> None:
    """domrf-цена приоритетна: objective НЕ перетирает существующий domrf-вывод.

    Additive-контракт OBJ-3: где domrf есть → не трогаем; objective только пробелы.
    """
    rows = [_obj_row(obj_id=1)]
    price_rows = [_price_row(obj_id=1, price=150_000.0)]
    # objective тоже вернул бы цену, но domrf уже есть → fallback её игнорирует.
    obj_price_rows = [_obj_price_row(obj_id=1, price=99_000.0)]
    db = _make_db(
        coord=_coord_row(),
        obj_rows=rows,
        price_rows=price_rows,
        obj_price_rows=obj_price_rows,
    )

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter"},
        )
        assert resp.status_code == 200, resp.text
        comp = resp.json()["competitors"][0]
        assert comp["avg_price_per_m2"] == pytest.approx(150_000.0)
        assert comp["price_source"] == "domrf"
    finally:
        app.dependency_overrides.clear()


def test_competitors_no_price_anywhere_is_none() -> None:
    """Ни domrf, ни objective → avg_price_per_m2 None, price_source None (graceful)."""
    rows = [_obj_row(obj_id=1)]
    db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=[])

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter"},
        )
        assert resp.status_code == 200, resp.text
        comp = resp.json()["competitors"][0]
        assert comp["avg_price_per_m2"] is None
        assert comp["price_source"] is None
    finally:
        app.dependency_overrides.clear()


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


def test_competitors_avg_price_failure_does_not_poison_later_queries() -> None:
    """avg_price query падает → sold-count и objective-fallback (следом на ТОЙ ЖЕ
    Session) всё равно отрабатывают, а не ловят "current transaction is aborted".

    Раньше avg_price/sold-count/objective-fallback ловили db.execute-сбой без
    SAVEPOINT (db.begin_nested()) — сбой ЛЮБОГО из трёх отравлял shared db для
    ОСТАЛЬНЫХ двух (и для persist_analysis_run дальше по /analyze). Фикс оборачивает
    каждый в свою begin_nested(); здесь ловим именно avg_price и проверяем, что
    objective-fallback (третий execute) всё равно наполняет цену.
    """
    coord_result = MagicMock()
    coord_result.mappings.return_value.first.return_value = _coord_row()
    obj_result = MagicMock()
    obj_result.mappings.return_value.all.return_value = [_obj_row(obj_id=1)]
    sold_result = MagicMock()
    sold_result.mappings.return_value.all.return_value = []
    obj_price_result = MagicMock()
    obj_price_result.mappings.return_value.all.return_value = [
        _obj_price_row(obj_id=1, price=142_000.0)
    ]

    db = MagicMock()
    db.execute.side_effect = [
        coord_result,
        obj_result,
        RuntimeError("simulated avg_price DB failure"),  # avg_price query сбоит
        sold_result,
        obj_price_result,  # objective-fallback ДОЛЖЕН всё равно выполниться
    ]

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter"},
        )
        assert resp.status_code == 200, resp.text
        comp = resp.json()["competitors"][0]
        # avg_price сбойнул (без domrf-цены), но objective-fallback наполнил её —
        # это возможно ТОЛЬКО если сбой avg_price не отравил сессию для fallback.
        assert comp["avg_price_per_m2"] == pytest.approx(142_000.0)
        assert comp["price_source"] == "objective"
        # Все 5 запланированных execute реально дошли (sold-count и fallback не
        # были пропущены/не упали каскадом из-за отравленной сессии).
        assert db.execute.call_count == 5
        # avg_price/sold-count/objective-fallback — каждый под своим SAVEPOINT.
        assert db.begin_nested.call_count == 3
    finally:
        app.dependency_overrides.clear()


def test_competitors_multi_object_radius_yields_per_object_prices() -> None:
    """Регрессия #2445 A5 (byte-identical к best_layouts #1956): несколько
    конкурентов в радиусе → КАЖДЫЙ получает свою domrf-цену, а не «1 объект на
    global-max снимке, у остальных NULL».

    Мок здесь не исполняет реальный SQL (_AVG_PRICE_SQL — строковые инварианты
    см. test_competitors_avg_price_sql.py), но фиксирует ОЖИДАЕМЫЙ КОНТРАКТ:
    _make_db может вернуть price_rows для ВСЕХ obj_id разом (per-object latest
    snapshot делает это возможным на живой БД) — раньше баг гарантировал, что
    price_rows содержал не более 1 строки на весь набор конкурентов.
    """
    rows = [
        _obj_row(obj_id=1, distance_m=100.0),
        _obj_row(obj_id=2, distance_m=200.0),
        _obj_row(obj_id=3, distance_m=300.0),
    ]
    price_rows = [
        _price_row(obj_id=1, price=140_000.0),
        _price_row(obj_id=2, price=155_000.0),
        _price_row(obj_id=3, price=162_000.0),
    ]
    db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=price_rows)

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter"},
        )
        assert resp.status_code == 200, resp.text
        comps = {c["obj_id"]: c for c in resp.json()["competitors"]}
        assert len(comps) == 3
        expected = {1: 140_000.0, 2: 155_000.0, 3: 162_000.0}
        for obj_id, price in expected.items():
            assert comps[obj_id]["avg_price_per_m2"] == pytest.approx(price), (
                f"obj_id={obj_id}: все конкуренты должны получить СВОЮ domrf-цену, "
                "не only-one-object-on-global-max-snapshot регрессия"
            )
            assert comps[obj_id]["price_source"] == "domrf"
    finally:
        app.dependency_overrides.clear()


def test_competitors_is_active_flag() -> None:
    """is_active=True для русского 'Строящиеся' (реальная prod-форма #1213),
    False для 'Сданные'/null/прочих."""
    rows = [
        _obj_row(obj_id=1, site_status="Строящиеся"),
        _obj_row(obj_id=2, site_status="Строящиеся"),
        _obj_row(obj_id=3, site_status="Сданные"),
    ]
    db = _make_db(coord=_coord_row(), obj_rows=rows)

    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        client = TestClient(app)
        resp = client.post(
            "/api/v1/parcels/66:41:0303161:5/competitors",
            json={"radius_km": 1.0, "time_window": "last_quarter"},
        )
        assert resp.status_code == 200, resp.text
        comps = {c["obj_id"]: c for c in resp.json()["competitors"]}
        assert comps[1]["is_active"] is True
        assert comps[2]["is_active"] is True
        assert comps[3]["is_active"] is False
    finally:
        app.dependency_overrides.clear()


# ── _SOLD_COUNT_SQL структура (#1926 / code-review #1927) ───────────────────────
# Mock-тесты выше НЕ исполняют реальный SQL (db.execute замокан) → не ловят
# fan-out маппинга, отсутствие premise_kind-фильтра и рассинхрон sold-предиката
# с MarketMetrics. Эти проверки — статический guard на текст _SOLD_COUNT_SQL:
# регресс к COUNT(*) / потеря premise_kind / усечение предиката упадёт здесь.
# (Полноценный fan-out-тест с двумя mapping-строками на один obj_id требует
# живой БД — он в tests/integration/test_phantom_columns.py, skip без TEST_DB.)


def _sold_sql_text() -> str:
    from app.services.site_finder.competitors import _SOLD_COUNT_SQL

    return str(_SOLD_COUNT_SQL)


def test_sold_count_sql_is_fanout_safe() -> None:
    """COUNT(DISTINCT objective_lot_id) — маппинг 1:N по obj_id не задваивает.

    objective_complex_mapping UNIQUE = (objective_complex_name, objective_group),
    НЕ domrf_obj_id → один obj_id может иметь несколько mapping-строк (то же имя,
    разный objective_group) → JOIN по project_name задвоил бы лоты. DISTINCT по
    objective_lot_id дедуплицирует на уровне лота. Регресс к COUNT(*) → fail.

    #1964: DISTINCT СОХРАНЯЕТСЯ и в inline-варианте (hunt #1953): будущий fan-out
    маппинга задвоил бы строки, и COUNT(*) перестал бы быть fan-out-safe. В новом
    SQL final-SELECT агрегирует по CTE `latest` (без ol-алиаса) → COUNT(DISTINCT
    objective_lot_id).
    """
    sql = _sold_sql_text()
    assert (
        "COUNT(DISTINCT objective_lot_id)" in sql
    ), "fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
    # COUNT(*) допустим внутри как агрегат? нет — sold-count агрегирует только distinct lot.
    assert "COUNT(*)" not in sql, "COUNT(*) задвоит лоты при 1:N маппинге"


def test_sold_count_sql_reads_raw_objective_lots_inline() -> None:
    """hunt #1953 (регрессия #1964): sold-count читает СЫРУЮ objective_lots inline с
    DISTINCT ON по physflat-ключу, НЕ v_objective_lots_latest.

    Через view DISTINCT ON материализует ВСЮ 1.76M-таблицу ДО фильтра маппинга (qual
    не проталкивается ниже view-DISTINCT ON) → Parallel Seq Scan + external sort
    (~6896 ms, ~213 MB temp на каждый cold /analyze). Inline-pushdown: фильтр
    маппинга в CTE → DISTINCT ON по сырой objective_lots с JOIN ON project_name
    (objective_lots_project_idx) → ~437 ms, вывод побайтово идентичен. Регресс к
    `v_objective_lots_latest` → fail.
    """
    sql = _sold_sql_text()
    assert "v_objective_lots_latest" not in sql, "не должен читать view (#1953 regression)"
    assert "JOIN objective_lots" in sql, "должен джойнить СЫРУЮ objective_lots inline"
    # physflat-ключ inline DISTINCT ON.
    assert "DISTINCT ON (" in sql
    for col in ("project_name", "corpus_name", "section", "floor", "lot_number"):
        assert col in sql, f"physflat-ключ должен включать {col}"
    assert "snapshot_date DESC" in sql and "id DESC" in sql


def test_sold_count_sql_filters_premise_kind() -> None:
    """premise_kind-фильтр (как в MarketMetrics._STOCK_SQL) — без него проданные
    паркинги/кладовые/нежилое считались бы проданными квартирами (over-count)."""
    sql = _sold_sql_text()
    assert "ol.premise_kind = :premise_kind" in sql


def test_sold_count_sql_predicate_matches_market_metrics() -> None:
    """sold-предикат побайтово совпадает с MarketMetrics: is_sold OR contract_date
    OR status='продан' (Объектив заполняет поля неконсистентно → берём их ИЛИ)."""
    sql = _sold_sql_text()
    assert "ol.is_sold IS TRUE" in sql
    assert "ol.contract_date IS NOT NULL" in sql
    assert "LOWER(COALESCE(ol.status, '')) = 'продан'" in sql
