"""Tests for POI weighted score service (B6).

Юнит-тесты для чистой функции — без DB.
"""

import pytest

from app.services.site_finder import ors_client, poi_score
from app.services.site_finder.poi_score import (
    _MAX_ROUTING_SCORE,
    _MAX_STRAIGHT_SCORE,
    CATEGORY_WEIGHTS,
    PoiScoreResponse,
    _category_radius_min,
    _category_weight,
    _decay_linear,
    _decay_piecewise,
    _decay_step,
    compute_poi_routing_decay,
    compute_poi_weighted_top7,
)

# ── unit: _category_weight ─────────────────────────────────────────────────────


def test_category_weight_metro():
    """Метро имеет наибольший вес из всех категорий."""
    metro_w = _category_weight("metro_stop")
    for cat in CATEGORY_WEIGHTS:
        if cat != "metro_stop" and cat != "default":
            assert metro_w >= _category_weight(
                cat
            ), f"metro_stop weight {metro_w} должен быть >= {cat} weight {_category_weight(cat)}"


def test_category_weight_unknown_returns_default():
    w = _category_weight("unknown_category_xyz")
    assert w == CATEGORY_WEIGHTS["default"]


def test_category_weight_all_positive():
    """Все веса в CATEGORY_WEIGHTS должны быть положительными (B6 — ranking, не штраф)."""
    for cat, w in CATEGORY_WEIGHTS.items():
        assert w > 0, f"Вес {cat}={w} должен быть > 0"


# ── unit: weight formula ratio ─────────────────────────────────────────────────


def test_weight_formula_ratio():
    """Ближний объект той же категории должен иметь больший вес."""
    cat = "school"
    cw = _category_weight(cat)
    w_near = (1.0 / (100.0 + 100.0)) * cw  # 100м
    w_far = (1.0 / (1000.0 + 100.0)) * cw  # 1000м
    assert w_near > w_far


def test_weight_formula_category_dominates_at_equal_distance():
    """При одинаковом расстоянии метро должно быть впереди автобусной остановки."""
    dist = 500.0
    w_metro = (1.0 / (dist + 100.0)) * _category_weight("metro_stop")
    w_bus = (1.0 / (dist + 100.0)) * _category_weight("bus_stop")
    assert w_metro > w_bus


# ── unit: compute_poi_weighted_top7 with mock DB ───────────────────────────────


class _MockMappings:
    def __init__(self, data: list[dict]) -> None:
        self._data = data

    def all(self) -> list[dict]:
        return self._data  # type: ignore[return-value]


class _MockResult:
    def __init__(self, data: list[dict]) -> None:
        self._data = data

    def mappings(self) -> "_MockMappings":
        return _MockMappings(self._data)


class _MockDb:
    """Минимальный мок SQLAlchemy Session для тестирования без БД."""

    def __init__(self, rows: list[dict]) -> None:
        self._rows = rows

    def execute(self, *_args: object, **_kwargs: object) -> _MockResult:
        return _MockResult(self._rows)


def _make_row(name: str, category: str, distance_m: float) -> dict:
    return {
        "name": name,
        "category": category,
        "tags": {},
        "distance_m": distance_m,
    }


def test_top7_returns_at_most_7():
    rows = [_make_row(f"POI {i}", "school", float(i * 50)) for i in range(1, 20)]
    db = _MockDb(rows)
    result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
    assert isinstance(result, PoiScoreResponse)
    assert len(result.top_poi) <= 7


def test_top7_sorted_by_weight_desc():
    rows = [
        _make_row("Дальняя школа", "school", 1500.0),
        _make_row("Метро", "metro_stop", 300.0),
        _make_row("Близкая школа", "school", 100.0),
    ]
    db = _MockDb(rows)
    result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
    weights = [item.weight for item in result.top_poi]
    assert weights == sorted(weights, reverse=True), "top_poi должны быть по weight DESC"


def test_metro_beats_school_at_equal_distance():
    """Метро в 300м должно быть на первом месте перед школой в 300м (равное расстояние)."""
    rows = [
        _make_row("Школа №1", "school", 300.0),
        _make_row("Метро Чкаловская", "metro_stop", 300.0),
    ]
    db = _MockDb(rows)
    result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
    assert (
        result.top_poi[0].category == "metro_stop"
    ), "При равном расстоянии метро (category_weight=6.0) должно быть выше школы (5.0)"


def test_metro_first_when_close():
    """Метро в 50м должно быть на первом месте перед школой в 300м."""
    rows = [
        _make_row("Школа №1", "school", 300.0),
        _make_row("Метро Чкаловская", "metro_stop", 50.0),
    ]
    db = _MockDb(rows)
    result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
    assert result.top_poi[0].category == "metro_stop", (
        "Метро (weight=6.0) в 50м должно быть впереди школы (weight=5.0) в 300м — "
        f"metro_weight={(1/(50+100))*6:.5f} vs school_weight={(1/(300+100))*5:.5f}"
    )


def test_empty_db_returns_empty_top_poi():
    db = _MockDb([])
    result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
    assert result.top_poi == []
    assert result.cad_num == "66:41:0204016:10"
    assert result.radius_m == 2000
    assert result.poi_weighted_score == 0.0


# ── #1486: normalization 0..100 ────────────────────────────────────────────────


def test_max_straight_score_constant():
    """_MAX_STRAIGHT_SCORE = Σ(top-7 category_weights) / 100 ≈ 0.315."""
    top7_sum = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
    assert _MAX_STRAIGHT_SCORE == pytest.approx(top7_sum / 100.0)


def test_max_routing_score_constant():
    """_MAX_ROUTING_SCORE = Σ(top-7 category_weights) = 31.5."""
    top7_sum = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
    assert _MAX_ROUTING_SCORE == pytest.approx(top7_sum)


def test_poi_weighted_score_in_range():
    """poi_weighted_score должен быть в диапазоне 0..100 для реалистичных данных."""
    rows = [
        _make_row("Метро", "metro_stop", 400.0),
        _make_row("Школа", "school", 500.0),
        _make_row("Детсад", "kindergarten", 300.0),
    ]
    db = _MockDb(rows)
    result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
    assert 0.0 <= result.poi_weighted_score <= 100.0


def test_score_contribution_in_range():
    """Каждый score_contribution должен быть в 0..100."""
    rows = [
        _make_row("Метро", "metro_stop", 200.0),
        _make_row("Школа", "school", 800.0),
    ]
    db = _MockDb(rows)
    result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
    for item in result.top_poi:
        assert (
            0.0 <= item.score_contribution <= 100.0
        ), f"{item.category} score_contribution={item.score_contribution} вне 0..100"


def test_metro_at_zero_distance_scores_high():
    """Метро в 0м должно дать poi_weighted_score близко к max (≥19.0/100)."""
    # metro weight = 6.0 / (0 + 100) = 0.06; normalized = 0.06 / _MAX_STRAIGHT_SCORE * 100
    rows = [_make_row("Метро у дома", "metro_stop", 0.0)]
    db = _MockDb(rows)
    result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
    assert (
        result.poi_weighted_score >= 19.0
    ), f"Метро у дома (d=0) должно давать ≥19/100, получили {result.poi_weighted_score}"


def test_score_contribution_sum_equals_total():
    """Сумма score_contribution по top_poi должна совпадать с poi_weighted_score.

    Допуск 0.5 — оба значения округлены независимо до 1 знака, накопленная
    ошибка при N POI ≤ N * 0.05. Для top-7 это ≤ 0.35 → допуск 0.5 достаточен.
    """
    rows = [
        _make_row("Метро", "metro_stop", 300.0),
        _make_row("Школа", "school", 600.0),
        _make_row("Парк", "park", 200.0),
    ]
    db = _MockDb(rows)
    result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
    total_from_contributions = sum(i.score_contribution for i in result.top_poi)
    assert total_from_contributions == pytest.approx(result.poi_weighted_score, abs=0.5)


def test_address_built_from_tags():
    rows = [
        {
            "name": "Магазин",
            "category": "shop_small",
            "tags": {"addr:street": "ул. Ленина", "addr:housenumber": "10"},
            "distance_m": 200.0,
        }
    ]
    db = _MockDb(rows)
    result = compute_poi_weighted_top7(db, "test", 56.838, 60.605)
    assert result.top_poi[0].address == "ул. Ленина, 10"


def test_address_none_when_no_tags():
    rows = [_make_row("Парк", "park", 400.0)]
    db = _MockDb(rows)
    result = compute_poi_weighted_top7(db, "test", 56.838, 60.605)
    assert result.top_poi[0].address is None


# ── #41: per-category decay curves (pure) ────────────────────────────────────


def test_decay_linear_bounds():
    assert _decay_linear(0.0, 20.0) == pytest.approx(1.0)
    assert _decay_linear(10.0, 20.0) == pytest.approx(0.5)
    assert _decay_linear(20.0, 20.0) == 0.0
    assert _decay_linear(25.0, 20.0) == 0.0  # за порогом → 0


def test_decay_piecewise_full_then_drop():
    """Школа: полный вес близко, спад к краю, 0 за порогом."""
    r = 15.0
    assert _decay_piecewise(2.0, r) == pytest.approx(1.0)  # 0..5 мин полный
    assert _decay_piecewise(5.0, r) == pytest.approx(1.0)
    mid = _decay_piecewise(10.0, r)
    assert 0.0 < mid < 1.0
    assert _decay_piecewise(15.0, r) == 0.0
    assert _decay_piecewise(99.0, r) == 0.0


def test_decay_step_binary():
    """Парк: есть в радиусе (1.0) либо нет (0.0)."""
    assert _decay_step(9.0, 10.0) == 1.0
    assert _decay_step(10.0, 10.0) == 1.0
    assert _decay_step(10.1, 10.0) == 0.0


def test_category_radius_min_defaults():
    assert _category_radius_min("metro_stop") == 20.0
    assert _category_radius_min("park") == 10.0
    # неизвестная категория → дефолт
    assert _category_radius_min("unknown_xyz") == poi_score._DEFAULT_RADIUS_MIN


# ── #41: compute_poi_routing_decay (mock DB + monkeypatched ORS) ─────────────


def _make_routing_row(name: str, category: str, distance_m: float, lon=60.6, lat=56.8) -> dict:
    return {
        "name": name,
        "category": category,
        "tags": {},
        "lon": lon,
        "lat": lat,
        "distance_m": distance_m,
    }


def test_routing_decay_drops_poi_beyond_radius(monkeypatch):
    """POI за порогом доступности (минут) выбывает из результата."""
    rows = [
        _make_routing_row("Близкое метро", "metro_stop", 400.0),
        _make_routing_row("Далёкая школа", "school", 1800.0),
    ]
    db = _MockDb(rows)

    # ORS: метро 5 мин (в пределах 20), школа 30 мин (за порогом 15) → школа выбывает.
    def fake_matrix(_lon, _lat, dests, **_kw):
        return [5.0, 30.0]

    monkeypatch.setattr(ors_client, "matrix_durations_min", fake_matrix)
    monkeypatch.setattr(ors_client, "is_configured", lambda: True)

    result = compute_poi_routing_decay(db, "66:41:0204016:10", 56.838, 60.605)
    cats = [i.category for i in result.top_poi]
    assert "metro_stop" in cats
    assert "school" not in cats, "школа в 30 мин (> 15 мин порог) должна выбыть"


def test_routing_decay_closer_time_higher_weight(monkeypatch):
    """При одинаковой категории меньшее время в пути → больший вес."""
    rows = [
        _make_routing_row("Школа дальняя", "school", 900.0),
        _make_routing_row("Школа ближняя", "school", 200.0),
    ]
    db = _MockDb(rows)

    def fake_matrix(_lon, _lat, dests, **_kw):
        return [12.0, 3.0]  # дальняя 12 мин, ближняя 3 мин

    monkeypatch.setattr(ors_client, "matrix_durations_min", fake_matrix)
    monkeypatch.setattr(ors_client, "is_configured", lambda: True)

    result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
    assert result.top_poi[0].name == "Школа ближняя"


def test_routing_decay_falls_back_to_straight_line(monkeypatch):
    """ORS недоступен → straight-line fallback, без исключения наружу."""
    rows = [_make_routing_row("Метро", "metro_stop", 300.0)]
    db = _MockDb(rows)

    def raising_matrix(*_a, **_kw):
        raise ors_client.OrsUnavailableError("no key")

    monkeypatch.setattr(ors_client, "matrix_durations_min", raising_matrix)

    result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
    # 300м / 83 м/мин ≈ 3.6 мин — в пределах 20 мин радиуса метро → остаётся.
    assert len(result.top_poi) == 1
    assert result.top_poi[0].category == "metro_stop"


def test_routing_decay_none_route_fallback(monkeypatch):
    """ORS вернул None (нет маршрута) для точки → straight-line оценка времени."""
    rows = [_make_routing_row("Парк", "park", 400.0)]
    db = _MockDb(rows)

    def fake_matrix(_lon, _lat, dests, **_kw):
        return [None]  # ORS не построил маршрут

    monkeypatch.setattr(ors_client, "matrix_durations_min", fake_matrix)
    monkeypatch.setattr(ors_client, "is_configured", lambda: True)

    result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
    # 400м / 83 ≈ 4.8 мин < 10 мин (park step) → парк остаётся.
    assert len(result.top_poi) == 1


def test_routing_decay_empty_db(monkeypatch):
    db = _MockDb([])
    monkeypatch.setattr(ors_client, "is_configured", lambda: True)
    result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
    assert result.top_poi == []
    assert result.poi_weighted_score == 0.0


def test_routing_decay_score_spread_wider_than_straight_line(monkeypatch):
    """#41 acceptance: routing-decay даёт более широкий разброс между хорошим и
    плохим участком, чем straight-line (1/(d+100)).

    Хороший участок: метро 4 мин, школа 6 мин. Плохой: метро 18 мин, школа 14 мин.
    """

    def total(rows, times):
        db = _MockDb(rows)
        monkeypatch.setattr(ors_client, "is_configured", lambda: True)
        monkeypatch.setattr(ors_client, "matrix_durations_min", lambda *_a, **_k: list(times))
        res = compute_poi_routing_decay(db, "cad", 56.8, 60.6)
        return sum(i.weight for i in res.top_poi)

    good_rows = [
        _make_routing_row("Метро", "metro_stop", 300.0),
        _make_routing_row("Школа", "school", 450.0),
    ]
    bad_rows = [
        _make_routing_row("Метро", "metro_stop", 1400.0),
        _make_routing_row("Школа", "school", 1100.0),
    ]
    good = total(good_rows, [4.0, 6.0])
    bad = total(bad_rows, [18.0, 14.0])

    assert good > 0 and bad > 0
    # routing-decay усиливает контраст: хороший минимум вдвое выше плохого.
    assert good / bad > 2.0, f"ожидали разброс >2×, получили {good / bad:.2f}×"
