"""Тесты для POST /api/v1/parcels/{cad_num}/best-layouts (Issue #113 Phase 2.1).

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

Порядок вызовов в get_best_layouts (Fix SF-01 — inline velocity):
  db.execute() calls:
  1. _PARCEL_CENTROID_SQL       → .mappings().first()
  2. _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
  3. _INLINE_VELOCITY_SQL       → .mappings().all()
  4. _SUPPLY_BATCH_SQL          → .mappings().all()

Fix #1956: supply берёт per-object последний snapshot внутри _SUPPLY_BATCH_SQL
(flats_latest CTE) — отдельного db.scalar(MAX(snapshot_date)) больше нет.
"""

from __future__ import annotations

import datetime as dt
from unittest.mock import MagicMock

import pytest
from fastapi.testclient import TestClient

from app.main import app
from app.services.site_finder.best_layouts import (
    _COMPETITORS_IN_RADIUS_SQL,
    _INLINE_VELOCITY_SQL,
    _OBJECTIVE_DEVELOPERS_SQL,
    _OBJECTIVE_PROJECT_NAMES_SQL,
    _PARCEL_CENTROID_SQL,
    _SUPPLY_BATCH_SQL,
)

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

CAD_NUM = "66:41:0303161:123"
_TODAY = dt.date.today()


def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
    """Центроид участка (EPSG:4326 lon/lat)."""
    r = MagicMock()
    r.__getitem__ = lambda self, k: {"center_lon": lon, "center_lat": lat}[k]
    return r


def _obj_id_row(obj_id: int) -> dict[str, object]:
    """Строка из _COMPETITORS_IN_RADIUS_SQL.

    Issue #2177: SQL возвращает comm_name/dev_name/гео для группировки obj_id в
    комплексы, а get_best_layouts делает dict(r). Возвращаем реальный dict (как
    SQLAlchemy RowMapping). Уникальное comm_name на obj_id → каждый = своя группа,
    поэтому существующие coverage-инварианты этих API-тестов не меняются.
    """
    return {
        "obj_id": obj_id,
        "comm_name": f"obj-{obj_id}",
        "dev_name": f"dev-{obj_id}",
        "latitude": None,
        "longitude": None,
    }


def _vel_row(
    room_bucket: str = "2",
    deals_window: float = 48.0,
    avg_area: float = 55.0,
    avg_price_rub: float | None = 120000.0,
    obj_ids: list[int] | None = None,
    window_start: dt.date | None = None,
    window_end: dt.date | None = None,
) -> MagicMock:
    """Строка из _INLINE_VELOCITY_SQL (Fix SF-01: deals_window за честный интервал)."""
    oids = obj_ids if obj_ids is not None else [1]
    ws = window_start or _TODAY - dt.timedelta(days=90)
    we = window_end or _TODAY

    r = MagicMock()
    r.__getitem__ = lambda self, k: {
        "room_bucket": room_bucket,
        "deals_window": deals_window,
        "avg_area_m2": avg_area,
        "avg_price_per_m2_rub": avg_price_rub,
        "competitor_obj_ids": oids,
        "competitor_count": len(oids),
        # Issue #2177 шаг 2b: project_name'ы с velocity в bucket'е (mapping ∪ core-match).
        "matched_project_names": [],
        "window_start": ws,
        "window_end": we,
    }[k]
    return r


def _supply_row(rb: str, ab: str, units: int) -> MagicMock:
    """Строка из _SUPPLY_BATCH_SQL."""
    r = MagicMock()
    r.__getitem__ = lambda self, k: {"rb": rb, "ab": ab, "units": units}[k]
    return r


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


def _make_db(
    coord: MagicMock | None = None,
    id_rows: list[MagicMock] | None = None,
    vel_rows: list[MagicMock] | None = None,
    supply_rows: list[MagicMock] | None = None,
    project_names: list[str] | None = None,
    developers_by_project: dict[str, list[str]] | None = None,
) -> MagicMock:
    """Сконструировать mock Session с диспатчем по SQL-стейтменту.

    Fix #1956: per-object последний снимок считается внутри _SUPPLY_BATCH_SQL.
    Issue #2177 шаг 2b добавил objective-projects/developers (core-attribution) перед
    velocity — роутим по идентичности SQL-констант, не позиционно. db.execute():
      • _PARCEL_CENTROID_SQL          → .mappings().first()
      • _COMPETITORS_IN_RADIUS_SQL    → .mappings().all()
      • _OBJECTIVE_PROJECT_NAMES_SQL  → .all()
      • _OBJECTIVE_DEVELOPERS_SQL     → .all()
      • _INLINE_VELOCITY_SQL          → .mappings().all()
      • _SUPPLY_BATCH_SQL             → .mappings().all()
    """
    db = MagicMock()

    r_centroid = MagicMock()
    r_centroid.mappings.return_value.first.return_value = coord

    r_comp = MagicMock()
    r_comp.mappings.return_value.all.return_value = id_rows or []

    r_proj = MagicMock()
    r_proj.all.return_value = [(n,) for n in (project_names or [])]

    r_dev = MagicMock()
    r_dev.all.return_value = [
        (pname, devs) for pname, devs in (developers_by_project or {}).items()
    ]

    r_vel = MagicMock()
    r_vel.mappings.return_value.all.return_value = vel_rows or []

    r_supply = MagicMock()
    r_supply.mappings.return_value.all.return_value = supply_rows or []

    dispatch = {
        _PARCEL_CENTROID_SQL: r_centroid,
        _COMPETITORS_IN_RADIUS_SQL: r_comp,
        _OBJECTIVE_PROJECT_NAMES_SQL: r_proj,
        _OBJECTIVE_DEVELOPERS_SQL: r_dev,
        _INLINE_VELOCITY_SQL: r_vel,
        _SUPPLY_BATCH_SQL: r_supply,
    }

    def _execute(stmt, *_args, **_kwargs):
        return dispatch[stmt]

    db.execute.side_effect = _execute
    return db


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

    return _get_db_override


def _post(client: TestClient, cad: str = CAD_NUM, **body_kwargs) -> dict:
    payload = {"radius_km": 1.0, "time_window": "last_quarter", **body_kwargs}
    resp = client.post(f"/api/v1/parcels/{cad}/best-layouts", json=payload)
    return resp


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


def test_parcel_not_found_404() -> None:
    """Если центроид не найден → 404."""
    db = _make_db(coord=None)
    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        resp = _post(TestClient(app), cad="99:99:9999999:999")
        assert resp.status_code == 404, resp.text
    finally:
        app.dependency_overrides.clear()


def test_empty_competitor_set_returns_low_confidence() -> None:
    """Нет конкурентов в радиусе → пустые top_layouts + confidence=low."""
    db = _make_db(coord=_coord_row(), id_rows=[])
    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        resp = _post(TestClient(app))
        assert resp.status_code == 200, resp.text
        body = resp.json()
        assert body["top_layouts"] == []
        assert body["data_quality"]["confidence"] == "low"
        assert body["data_quality"]["objects_total_in_radius"] == 0
        rec = body["recommendation_for_tz"]
        assert rec["based_on_obj_count"] == 0
        assert rec["based_on_total_deals"] == 0
        assert rec["mix"] == []
    finally:
        app.dependency_overrides.clear()


def test_three_obj_ids_ranking_and_pct_sum_100() -> None:
    """3 obj_id, 3 room_buckets — ranking по velocity, sum pct = 100.

    last_quarter (3 мес): velocity = deals_window / 3.0
      studio: 9/3=3.0, 1: 24/3=8.0, 2: 48/3=16.0 → rank1="2"
    """
    id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
    vel_rows = [
        _vel_row("studio", deals_window=9.0, avg_area=26.0, obj_ids=[1]),
        _vel_row("1", deals_window=24.0, avg_area=40.0, obj_ids=[2]),
        _vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[3]),
    ]
    supply_rows = [
        _supply_row("studio", "<25", 20),
        _supply_row("1", "40-60", 60),
        _supply_row("2", "40-60", 80),
    ]
    db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows)
    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        resp = _post(TestClient(app), time_window="last_quarter")
        assert resp.status_code == 200, resp.text
        body = resp.json()
        top = body["top_layouts"]
        assert len(top) == 3
        # rank 1 = самая высокая velocity (2-комн: 48/3=16.0 per month)
        assert top[0]["rank"] == 1
        assert top[0]["room_bucket"] == "2"
        # все ранги уникальны
        assert sorted(t["rank"] for t in top) == [1, 2, 3]
        # sum pct = 100
        mix = body["recommendation_for_tz"]["mix"]
        assert sum(m["pct"] for m in mix) == 100
    finally:
        app.dependency_overrides.clear()


def test_exclude_competitor_obj_ids_filter() -> None:
    """exclude_competitor_obj_ids исключает obj_id: при all excluded → пустой ответ."""
    # Если после исключения obj_id_list пуст → _empty_response → top_layouts=[]
    id_rows = [_obj_id_row(20)]  # единственный конкурент
    db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[])
    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        resp = _post(TestClient(app), exclude_competitor_obj_ids=[20])
        assert resp.status_code == 200, resp.text
        body = resp.json()
        # После исключения obj_id=20 список пуст → пустой ответ
        assert body["top_layouts"] == []
        assert body["data_quality"]["confidence"] == "low"
        # objects_total_in_radius = 1 (до исключения)
        assert body["data_quality"]["objects_total_in_radius"] == 1
    finally:
        app.dependency_overrides.clear()


def test_min_velocity_per_month_filters_low_rows() -> None:
    """min_velocity_per_month=5 → строки с velocity<5 не попадают в top_layouts.

    last_quarter (3 мес): studio=6/3=2.0 < 5.0 (убран), 1=30/3=10.0 > 5.0 (остаётся).
    """
    id_rows = [_obj_id_row(1), _obj_id_row(2)]
    vel_rows = [
        _vel_row("studio", deals_window=6.0, obj_ids=[1]),
        _vel_row("1", deals_window=30.0, obj_ids=[2]),
    ]
    db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        resp = _post(TestClient(app), min_velocity_per_month=5.0)
        assert resp.status_code == 200, resp.text
        body = resp.json()
        top = body["top_layouts"]
        assert len(top) == 1
        assert top[0]["room_bucket"] == "1"
        assert top[0]["velocity_per_month"] == pytest.approx(10.0)
    finally:
        app.dependency_overrides.clear()


def test_time_window_velocity_scaling() -> None:
    """last_month vs last_year дают разный velocity_per_month для одних deals.

    Fix SF-01: velocity_per_month = deals_window / months_in_window. Делители
    из _TIME_WINDOW_PARAMS: last_month=1.0, last_year=12.0. При одинаковом
    deals_window короткое окно даёт ВЫШЕ месячную velocity (делитель меньше).
    """
    # deals_window=24 → last_month: 24/1=24.0, last_year: 24/12=2.0
    id_rows = [_obj_id_row(1)]
    vel_rows_fixed = [_vel_row("2", deals_window=24.0, obj_ids=[1])]

    from app.core.db import get_db

    # last_month
    db_m = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows_fixed)
    app.dependency_overrides[get_db] = _override_db(db_m)
    try:
        resp_m = _post(TestClient(app), time_window="last_month")
        assert resp_m.status_code == 200, resp_m.text
        v_month = resp_m.json()["top_layouts"][0]["velocity_per_month"]
    finally:
        app.dependency_overrides.clear()

    # last_year
    db_y = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows_fixed)
    app.dependency_overrides[get_db] = _override_db(db_y)
    try:
        resp_y = _post(TestClient(app), time_window="last_year")
        assert resp_y.status_code == 200, resp_y.text
        v_year = resp_y.json()["top_layouts"][0]["velocity_per_month"]
    finally:
        app.dependency_overrides.clear()

    # last_month velocity выше (делитель меньше: 1 vs 12) при одинаковом deals_window
    assert v_month > v_year
    assert v_month == pytest.approx(24.0)
    assert v_year == pytest.approx(2.0)


def test_obj_class_filter_passes_through() -> None:
    """obj_class_filter передаётся в SQL — endpoint не ломается, возвращает 200."""
    db = _make_db(
        coord=_coord_row(),
        id_rows=[_obj_id_row(5)],
        vel_rows=[_vel_row("2", obj_ids=[5])],
        supply_rows=[],
    )
    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        resp = _post(TestClient(app), obj_class_filter="comfort")
        assert resp.status_code == 200, resp.text
        body = resp.json()
        assert len(body["top_layouts"]) > 0
    finally:
        app.dependency_overrides.clear()


def test_mv_empty_for_competitors_returns_empty_top_layouts() -> None:
    """Конкуренты есть в радиусе, но MV пустой → top_layouts=[], data_quality.confidence=low."""
    id_rows = [_obj_id_row(1), _obj_id_row(2)]
    db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[])
    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        resp = _post(TestClient(app))
        assert resp.status_code == 200, resp.text
        body = resp.json()
        assert body["top_layouts"] == []
        dq = body["data_quality"]
        assert dq["objects_total_in_radius"] == 2
        assert dq["objects_with_velocity_data"] == 0
        assert dq["confidence"] == "low"
    finally:
        app.dependency_overrides.clear()


def test_target_total_flats_fills_abs_units() -> None:
    """target_total_flats=100 → abs_units заполнен в mix, sum примерно = 100."""
    id_rows = [_obj_id_row(1), _obj_id_row(2)]
    vel_rows = [
        _vel_row("1", deals_window=60.0, obj_ids=[1]),
        _vel_row("2", deals_window=40.0, obj_ids=[2]),
    ]
    db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        resp = _post(TestClient(app), target_total_flats=100)
        assert resp.status_code == 200, resp.text
        mix = resp.json()["recommendation_for_tz"]["mix"]
        # все abs_units заполнены
        for m in mix:
            assert m["abs_units"] is not None
        # сумма abs_units близка к 100 (round-off ±1)
        total_abs = sum(m["abs_units"] for m in mix)
        assert 98 <= total_abs <= 102
    finally:
        app.dependency_overrides.clear()


def test_sold_pct_clamped_at_100_and_is_oversold_flag() -> None:
    """raw sold_pct > 100 → returned sold_pct_of_supply=100.0, is_oversold=True."""
    id_rows = [_obj_id_row(1)]
    # deals_window=199, supply=100 → raw = 199% (несопоставимые окна)
    vel_rows = [_vel_row("2", deals_window=199.0, obj_ids=[1])]
    supply_rows = [_supply_row("2", "40-60", 100)]
    db = _make_db(
        coord=_coord_row(),
        id_rows=id_rows,
        vel_rows=vel_rows,
        supply_rows=supply_rows,
    )
    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        resp = _post(TestClient(app))
        assert resp.status_code == 200, resp.text
        body = resp.json()
        top = body["top_layouts"]
        assert len(top) == 1
        row = top[0]
        assert row["sold_pct_of_supply"] == 100.0, "sold_pct_of_supply должен быть clamped до 100"
        assert row["is_oversold"] is True, "is_oversold должен быть True когда raw > 100"
    finally:
        app.dependency_overrides.clear()


def test_sold_pct_below_100_is_not_oversold() -> None:
    """raw sold_pct <= 100 → sold_pct_of_supply возвращается as-is, is_oversold=False."""
    id_rows = [_obj_id_row(1)]
    # deals_window=50, supply=100 → raw = 50%
    vel_rows = [_vel_row("1", deals_window=50.0, obj_ids=[1])]
    supply_rows = [_supply_row("1", "40-60", 100)]
    db = _make_db(
        coord=_coord_row(),
        id_rows=id_rows,
        vel_rows=vel_rows,
        supply_rows=supply_rows,
    )
    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        resp = _post(TestClient(app))
        assert resp.status_code == 200, resp.text
        body = resp.json()
        top = body["top_layouts"]
        assert len(top) == 1
        row = top[0]
        assert row["sold_pct_of_supply"] == pytest.approx(50.0)
        assert row["is_oversold"] is False
    finally:
        app.dependency_overrides.clear()


def test_filter_competitor_obj_ids_applied() -> None:
    """filter_competitor_obj_ids=[1] оставляет только obj_id=1."""
    id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
    # После фильтрации остаётся только obj_id=1, velocity запрос получит [1]
    vel_rows = [_vel_row("2", deals_window=24.0, obj_ids=[1])]
    db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
    from app.core.db import get_db

    app.dependency_overrides[get_db] = _override_db(db)
    try:
        resp = _post(TestClient(app), filter_competitor_obj_ids=[1])
        assert resp.status_code == 200, resp.text
        body = resp.json()
        top = body["top_layouts"]
        assert len(top) >= 1
        # competitor_obj_ids должен содержать только 1
        for row in top:
            for oid in row["competitor_obj_ids"]:
                assert oid == 1
    finally:
        app.dependency_overrides.clear()
