"""Unit-тесты для get_best_layouts (Fix SF-01: honest time_window velocity).

Проверяет, что разные time_window → разные deals_window → разный velocity_per_month.

Mock-стратегия: патчим db.execute с side_effect, повторяя порядок вызовов
в get_best_layouts:
  1. _PARCEL_CENTROID_SQL      → .mappings().first()
  2. _COMPETITORS_IN_RADIUS_SQL → .mappings().all()
  3. _INLINE_VELOCITY_SQL       → .mappings().all()
  4. db.scalar()                → MAX(snapshot_date) — через .return_value
  5. _SUPPLY_BATCH_SQL          → .mappings().all()

Ключевые asserts:
- last_month (1 мес) → velocity = deals_window / 1.0
- last_quarter (3 мес) → velocity = deals_window / 3.0
- last_year (12 мес) → velocity = deals_window / 12.0
- Разный deals_window при разных time_window → разный mix.
"""

from __future__ import annotations

import datetime as dt
from unittest.mock import MagicMock

import pytest

from app.schemas.parcel import BestLayoutsRequest
from app.services.site_finder.best_layouts import (
    _TIME_WINDOW_PARAMS,
    MAX_BUCKET_SHARE_PCT,
    _cap_and_redistribute,
    get_best_layouts,
)

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


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


def _coord_row(lon: float = 60.6, lat: float = 56.85) -> MagicMock:
    r = MagicMock()
    r.__getitem__ = lambda self, k: {"center_lon": lon, "center_lat": lat}[k]
    return r


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


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.

    deals_window — реальные сделки за честное окно (не 24 мес).
    """
    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),
        "window_start": ws,
        "window_end": we,
    }[k]
    return r


def _supply_row(rb: str, ab: str, units: int) -> MagicMock:
    r = MagicMock()
    r.__getitem__ = lambda self, k: {"rb": rb, "ab": ab, "units": units}[k]
    return r


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,
    latest_snap: dt.date | None = None,
) -> MagicMock:
    """Сконструировать mock Session.

    Порядок db.execute():
      1. centroid     → .mappings().first()
      2. competitors  → .mappings().all()
      3. velocity     → .mappings().all()
      4. supply       → .mappings().all()  (только если latest_snap is not None)
    db.scalar() → latest_snap (MAX snapshot_date).
    """
    db = MagicMock()
    db.scalar.return_value = latest_snap if latest_snap is not None else _TODAY

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

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

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

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

    db.execute.side_effect = [r0, r1, r2, r3]
    return db


def _request(**kwargs) -> BestLayoutsRequest:
    defaults: dict = {
        "radius_km": 1.0,
        "time_window": "last_quarter",
        "min_velocity_per_month": 0.0,
    }
    defaults.update(kwargs)
    return BestLayoutsRequest(**defaults)


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


def test_time_window_params_keys() -> None:
    """Все три time_window определены, months_in_window > 0."""
    for key in ("last_month", "last_quarter", "last_year"):
        assert key in _TIME_WINDOW_PARAMS
        interval_str, months = _TIME_WINDOW_PARAMS[key]
        assert isinstance(interval_str, str) and len(interval_str) > 0
        assert months > 0


# ── Тест SF-01: разный deals_window → разный velocity ────────────────────────


def test_last_month_velocity_divisor_1() -> None:
    """time_window=last_month: velocity = deals_window / 1.0."""
    deals = 30.0
    db = _make_db(
        coord=_coord_row(),
        id_rows=[_obj_id_row(1)],
        vel_rows=[_vel_row("1", deals_window=deals, obj_ids=[1])],
    )
    req = _request(time_window="last_month")
    resp = get_best_layouts(db, CAD_NUM, req)

    assert len(resp.top_layouts) == 1
    assert resp.top_layouts[0].velocity_per_month == pytest.approx(30.0, rel=1e-3)


def test_last_quarter_velocity_divisor_3() -> None:
    """time_window=last_quarter: velocity = deals_window / 3.0."""
    deals = 30.0
    db = _make_db(
        coord=_coord_row(),
        id_rows=[_obj_id_row(1)],
        vel_rows=[_vel_row("1", deals_window=deals, obj_ids=[1])],
    )
    req = _request(time_window="last_quarter")
    resp = get_best_layouts(db, CAD_NUM, req)

    assert len(resp.top_layouts) == 1
    assert resp.top_layouts[0].velocity_per_month == pytest.approx(10.0, rel=1e-3)


def test_last_year_velocity_divisor_12() -> None:
    """time_window=last_year: velocity = deals_window / 12.0."""
    deals = 60.0
    db = _make_db(
        coord=_coord_row(),
        id_rows=[_obj_id_row(1)],
        vel_rows=[_vel_row("1", deals_window=deals, obj_ids=[1])],
    )
    req = _request(time_window="last_year")
    resp = get_best_layouts(db, CAD_NUM, req)

    assert len(resp.top_layouts) == 1
    assert resp.top_layouts[0].velocity_per_month == pytest.approx(5.0, rel=1e-3)


def test_different_time_windows_produce_different_velocity() -> None:
    """Одни и те же deals_window → разная velocity_per_month для разных time_window.

    Главный acceptance-тест SF-01: time_window влияет на velocity, не только на масштаб.
    При одном и том же deals_window=30:
      last_month  → 30.0
      last_quarter → 10.0
      last_year   → 2.5
    """
    deals = 30.0

    velocities: dict[str, float] = {}
    for tw in ("last_month", "last_quarter", "last_year"):
        db = _make_db(
            coord=_coord_row(),
            id_rows=[_obj_id_row(1)],
            vel_rows=[_vel_row("2", deals_window=deals, obj_ids=[1])],
        )
        req = _request(time_window=tw)
        resp = get_best_layouts(db, CAD_NUM, req)
        assert len(resp.top_layouts) == 1, f"No layouts for {tw}"
        velocities[tw] = resp.top_layouts[0].velocity_per_month

    # Все три значения различаются
    vals = list(velocities.values())
    assert vals[0] != vals[1] != vals[2], f"Velocities must differ: {velocities}"
    # last_month > last_quarter > last_year (одинаковые deals, разный знаменатель)
    assert velocities["last_month"] > velocities["last_quarter"] > velocities["last_year"]


# ── Тест: ranking по velocity и sum pct = 100 ────────────────────────────────


def test_ranking_and_pct_sum_100() -> None:
    """3 room_buckets → ranking по velocity, sum pct = 100."""
    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]),  # 9/3=3.0
        _vel_row("1", deals_window=24.0, avg_area=40.0, obj_ids=[2]),  # 24/3=8.0
        _vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[3]),  # 48/3=16.0
    ]
    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)
    req = _request(time_window="last_quarter")
    resp = get_best_layouts(db, CAD_NUM, req)

    top = resp.top_layouts
    assert len(top) == 3
    # rank 1 = "2" (наибольший velocity 16.0)
    assert top[0].room_bucket == "2"
    assert top[0].rank == 1
    assert top[0].velocity_per_month == pytest.approx(16.0, rel=1e-3)
    # rank 2 = "1" (8.0)
    assert top[1].room_bucket == "1"
    assert top[1].velocity_per_month == pytest.approx(8.0, rel=1e-3)
    # ранги уникальны
    assert sorted(t.rank for t in top) == [1, 2, 3]
    # sum pct = 100
    mix = resp.recommendation_for_tz.mix
    assert sum(m.pct for m in mix) == 100


# ── Тест: пустые конкуренты ───────────────────────────────────────────────────


def test_no_competitors_returns_empty_response() -> None:
    """Нет конкурентов в радиусе → пустые top_layouts + confidence=low."""
    db = _make_db(coord=_coord_row(), id_rows=[], vel_rows=[])
    req = _request()
    resp = get_best_layouts(db, CAD_NUM, req)

    assert resp.top_layouts == []
    assert resp.data_quality.confidence == "low"
    assert resp.recommendation_for_tz.based_on_obj_count == 0


# ── Тест: centroid не найден ──────────────────────────────────────────────────


def test_centroid_not_found_raises_value_error() -> None:
    """Геометрия участка не найдена → ValueError."""
    db = _make_db(coord=None)
    req = _request()

    with pytest.raises(ValueError, match="не найдена"):
        get_best_layouts(db, "99:99:9999999:999", req)


# ── Тест: min_velocity фильтрует строки ──────────────────────────────────────


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

    last_quarter (3 мес):
      studio: 9 / 3 = 3.0  < 5.0 → отфильтрован
      1:      24 / 3 = 8.0  > 5.0 → остаётся
    """
    id_rows = [_obj_id_row(1), _obj_id_row(2)]
    vel_rows = [
        _vel_row("studio", deals_window=9.0, obj_ids=[1]),
        _vel_row("1", deals_window=24.0, obj_ids=[2]),
    ]
    db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
    req = _request(time_window="last_quarter", min_velocity_per_month=5.0)
    resp = get_best_layouts(db, CAD_NUM, req)

    top = resp.top_layouts
    assert len(top) == 1
    assert top[0].room_bucket == "1"
    assert top[0].velocity_per_month == pytest.approx(8.0, rel=1e-3)


# ── Тест: exclude_competitor_obj_ids ─────────────────────────────────────────


def test_exclude_competitor_obj_ids() -> None:
    """exclude_competitor_obj_ids=[20] при единственном конкуренте → пустой ответ."""
    id_rows = [_obj_id_row(20)]
    db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=[])
    req = _request(exclude_competitor_obj_ids=[20])
    resp = get_best_layouts(db, CAD_NUM, req)

    assert resp.top_layouts == []
    assert resp.data_quality.objects_total_in_radius == 1


# ── Тест: total_sold_in_window совпадает с deals_window ──────────────────────


def test_total_sold_in_window_matches_deals_window() -> None:
    """total_sold_in_window в TopLayoutRow = deals_window (целое)."""
    deals = 37.0
    db = _make_db(
        coord=_coord_row(),
        id_rows=[_obj_id_row(5)],
        vel_rows=[_vel_row("3", deals_window=deals, obj_ids=[5])],
    )
    req = _request(time_window="last_quarter")
    resp = get_best_layouts(db, CAD_NUM, req)

    assert len(resp.top_layouts) == 1
    assert resp.top_layouts[0].total_sold_in_window == int(deals)


# ── Тесты _cap_and_redistribute (Fix SF-09 review) ───────────────────────────


@pytest.mark.parametrize(
    "pct_map, expect_pathological",
    [
        # 1. normal: одиночный bucket > 35, free достаточно capacity
        ({"1k": 50, "studio": 30, "2k": 20}, False),
        # 2. heavy skew (3-bucket): surplus=40, capacity=20+25=45 — помещается
        ({"1k": 75, "studio": 15, "2k": 10}, False),
        # 3. multiple buckets > 35
        ({"1k": 50, "studio": 40, "2k": 10}, False),
        # 4. all > 35 — pathological
        ({"1k": 50, "studio": 50}, True),
        # 5. граничный: один bucket ровно на cap — не clamp
        ({"1k": 35, "studio": 35, "2k": 30}, False),
        # 6. single bucket 100% — pathological (нет free)
        ({"1k": 100}, True),
        # 7. 2-bucket heavy: surplus=55, capacity=25 — pathological (не помещается)
        ({"1k": 90, "studio": 10}, True),
        # 8. все ≤ cap — fast-path без изменений
        ({"1k": 30, "studio": 35, "2k": 35}, False),
        # 9. 2-bucket: 70/30 → surplus=35, capacity=5 → pathological
        ({"1k": 70, "studio": 30}, True),
        # 10. 2-bucket: 99/1 → surplus=64, capacity=34 → pathological
        ({"1k": 99, "studio": 1}, True),
    ],
)
def test_cap_and_redistribute_invariants(
    pct_map: dict[str, int],
    expect_pathological: bool,
) -> None:
    """Invariant: max(pct) ≤ cap И sum(pct) == 100 (или cap_skipped=True в pathological).

    Pathological — `cap_skipped=True`, max МОЖЕТ быть > cap (геометрически surplus
    не вмещается в free capacity).
    """
    result, cap_skipped = _cap_and_redistribute(pct_map)

    assert (
        cap_skipped == expect_pathological
    ), f"cap_skipped={cap_skipped} но ожидали {expect_pathological} для {pct_map}"
    assert (
        sum(result.values()) == 100
    ), f"sum={sum(result.values())} != 100 для {pct_map} → {result}"
    if not expect_pathological:
        assert (
            max(result.values()) <= MAX_BUCKET_SHARE_PCT
        ), f"max={max(result.values())} > cap={MAX_BUCKET_SHARE_PCT} для {pct_map} → {result}"


@pytest.mark.parametrize(
    "deals, expect_pathological, label",
    [
        # 3-bucket с достаточной capacity — surplus помещается, cap соблюдён
        ({"1k": 75, "studio": 15, "2k": 10}, False, "{1k:75, studio:15, 2k:10}"),
        ({"1k": 80, "studio": 12, "2k": 8}, False, "{1k:80, studio:12, 2k:8}"),
        ({"1k": 60, "studio": 30, "2k": 10}, False, "{1k:60, studio:30, 2k:10}"),
        ({"a": 50, "b": 30, "c": 20}, False, "{50, 30, 20}"),
        # 2-bucket — surplus геометрически не помещается, cap_skipped=True
        ({"1k": 90, "studio": 10}, True, "{1k:90, studio:10}"),
        ({"1k": 70, "studio": 30}, True, "{1k:70, studio:30}"),
        ({"1k": 99, "studio": 1}, True, "{1k:99, studio:1}"),
    ],
)
def test_cap_reproduced_failing_cases(
    deals: dict[str, int], expect_pathological: bool, label: str
) -> None:
    """Review round-2 reproduced cases: 2-bucket — pathological, 3-bucket — fit cap."""
    result, cap_skipped = _cap_and_redistribute(deals)
    assert (
        cap_skipped == expect_pathological
    ), f"cap_skipped={cap_skipped} ожидали {expect_pathological} для {label}"
    assert sum(result.values()) == 100, f"sum != 100 для {label} → {result}"
    if not expect_pathological:
        assert (
            max(result.values()) <= MAX_BUCKET_SHARE_PCT
        ), f"max={max(result.values())} > {MAX_BUCKET_SHARE_PCT} для {label} → {result}"


def test_cap_iteration_count_bounded() -> None:
    """Round 2 regression: алгоритм завершается за ≤ len(pct_map)+1 итераций.

    Round 1 bag: на 2-bucket {1k:70, studio:30} цикл осциллировал бесконечно.
    Round 2 fix: capacity-aware redistribute + hard `for _ in range(N+1)` guard.
    Этот тест гарантирует что вызов не зависает (pytest-timeout не нужен).
    """
    import time

    pathological_cases = [
        {"1k": 70, "studio": 30},
        {"1k": 99, "studio": 1},
        {"1k": 90, "studio": 10},
        {"1k": 50, "studio": 50},
    ]
    for case in pathological_cases:
        start = time.perf_counter()
        result, cap_skipped = _cap_and_redistribute(case)
        elapsed_ms = (time.perf_counter() - start) * 1000
        assert elapsed_ms < 100, f"Завис ({elapsed_ms:.0f}ms) на {case}"
        assert sum(result.values()) == 100, f"sum != 100 для {case}"
        # 2-bucket с одним > cap всегда pathological (surplus > free capacity)
        if case != {"1k": 50, "studio": 50}:
            assert cap_skipped, f"Ожидали cap_skipped=True для {case}"


def test_cap_and_redistribute_no_dominant_unchanged() -> None:
    """Если все bucket'ы ≤ cap — результат идентичен входу (fast-path)."""
    pct_map = {"studio": 20, "1": 35, "2": 30, "3": 15}
    result, cap_skipped = _cap_and_redistribute(pct_map)
    assert not cap_skipped
    assert result == pct_map


def test_cap_and_redistribute_empty() -> None:
    """Пустой dict → возвращается как есть."""
    result, cap_skipped = _cap_and_redistribute({})
    assert result == {}
    assert not cap_skipped


def test_cap_skipped_flag_propagates_to_recommendation() -> None:
    """Pathological case → cap_skipped=True в recommendation_for_tz ответа."""
    # 2 bucket'а по 50% — pathological
    id_rows = [_obj_id_row(1), _obj_id_row(2)]
    vel_rows = [
        _vel_row("studio", deals_window=50.0, obj_ids=[1]),
        _vel_row("1", deals_window=50.0, obj_ids=[2]),
    ]
    db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
    req = _request(time_window="last_quarter")
    resp = get_best_layouts(db, CAD_NUM, req)

    # С deals 50/50 → normalize_pct даёт {studio:50, 1:50} — оба выше cap
    assert resp.recommendation_for_tz.cap_skipped is True


def test_cap_skipped_false_for_normal_case() -> None:
    """Normal case с capping → cap_skipped=False в recommendation_for_tz."""
    id_rows = [_obj_id_row(1), _obj_id_row(2), _obj_id_row(3)]
    vel_rows = [
        _vel_row("1k", deals_window=75.0, obj_ids=[1]),
        _vel_row("studio", deals_window=15.0, obj_ids=[2]),
        _vel_row("2k", deals_window=10.0, obj_ids=[3]),
    ]
    db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
    req = _request(time_window="last_quarter")
    resp = get_best_layouts(db, CAD_NUM, req)

    assert resp.recommendation_for_tz.cap_skipped is False
    mix = resp.recommendation_for_tz.mix
    assert all(row.pct <= MAX_BUCKET_SHARE_PCT for row in mix)
    assert sum(row.pct for row in mix) == 100
