"""Unit-тесты relevance_weight конкурентов (#949 PR B, ТЗ §9.1, §16).

Чистые тесты pure-хелперов (без живой БД, без app.main):
  • _normalize_class — нормализация obj_class (RU/EN/суффиксы/plus-формы).
  • _geo_proximity — линейный decay расстояния (parcel→1, край радиуса→0).
  • _class_similarity — карта по шагам в _CLASS_ORDER (same/adjacent/2/far),
    нейтраль 0.5 при неизвестном классе.
  • _price_similarity — близость к локальной медиане; нейтраль при None.
  • _stage_at_horizon — horizon-aware проекция распродажи (sold-out→low,
    активный→high, тонкие данные→0.5).
  • _relevance_weight — взвешенное среднее (named weights, сумма 1.0).
  • _dominant_class / _median — детерминированные эталоны.

Детерминированно, без LLM. Каждый граничный/тонкий кейс проверяет graceful
поведение (нейтраль 0.5 / clamp, никогда не crash).
"""

from __future__ import annotations

import os

os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

import pytest

from app.services.site_finder.competitors import (
    _CLASS_ORDER,
    _NEUTRAL,
    _W_CLASS,
    _W_GEO,
    _W_PRICE,
    _W_STAGE,
    _class_similarity,
    _dominant_class,
    _geo_proximity,
    _median,
    _normalize_class,
    _price_similarity,
    _relevance_weight,
    _stage_at_horizon,
)

# ── weights sanity ────────────────────────────────────────────────────────────


def test_weights_sum_to_one() -> None:
    """Named-веса саб-скоров образуют выпуклую комбинацию (сумма = 1.0)."""
    assert _W_GEO + _W_CLASS + _W_PRICE + _W_STAGE == pytest.approx(1.0)


# ── _normalize_class ──────────────────────────────────────────────────────────


class TestNormalizeClass:
    def test_canonical_ru(self) -> None:
        assert _normalize_class("комфорт") == "комфорт"
        assert _normalize_class("Бизнес") == "бизнес"

    def test_case_insensitive(self) -> None:
        assert _normalize_class("КОМФОРТ") == "комфорт"

    def test_english_synonyms(self) -> None:
        # API-фильтр шлёт economy/comfort/business.
        assert _normalize_class("economy") == "эконом"
        assert _normalize_class("comfort") == "комфорт"
        assert _normalize_class("business") == "бизнес"
        assert _normalize_class("premium") == "премиум"

    def test_plus_forms(self) -> None:
        assert _normalize_class("комфорт+") == "комфорт+"
        assert _normalize_class("комфорт плюс") == "комфорт+"
        assert _normalize_class("Бизнес +") == "бизнес+"

    def test_substring_suffix(self) -> None:
        # «комфорт-класс», «класс «Бизнес»» из домрф aiDescription.
        assert _normalize_class("комфорт-класс") == "комфорт"
        assert _normalize_class("класс «Бизнес»") == "бизнес"

    def test_plus_substring_not_swallowed_to_base(self) -> None:
        # 'комфорт+' должен распознаться раньше 'комфорт' (длинный ключ первым).
        assert _normalize_class("жильё комфорт+ класса") == "комфорт+"

    def test_domrf_econom_synonyms(self) -> None:
        # домрф канон эконома: стандарт / типовой (см. _OBJ_CLASS_PATTERNS).
        assert _normalize_class("стандарт") == "стандарт"
        assert _normalize_class("типовой") == "типовой"

    def test_none_and_empty(self) -> None:
        assert _normalize_class(None) is None
        assert _normalize_class("") is None
        assert _normalize_class("   ") is None

    def test_unknown_returns_none(self) -> None:
        assert _normalize_class("люксовый-апарт") is None


# ── _geo_proximity ────────────────────────────────────────────────────────────


class TestGeoProximity:
    def test_at_parcel_is_one(self) -> None:
        assert _geo_proximity(0.0, 1000.0) == 1.0

    def test_at_radius_edge_is_zero(self) -> None:
        assert _geo_proximity(1000.0, 1000.0) == 0.0

    def test_midpoint(self) -> None:
        assert _geo_proximity(500.0, 1000.0) == pytest.approx(0.5)

    def test_beyond_radius_clamps_to_zero(self) -> None:
        # За радиусом (не должно случаться — SQL фильтрует) → clamp 0, не negative.
        assert _geo_proximity(1500.0, 1000.0) == 0.0

    def test_none_distance_neutral(self) -> None:
        assert _geo_proximity(None, 1000.0) == _NEUTRAL

    def test_zero_radius_neutral(self) -> None:
        assert _geo_proximity(100.0, 0.0) == _NEUTRAL


# ── _class_similarity ─────────────────────────────────────────────────────────


class TestClassSimilarity:
    def test_same_class(self) -> None:
        assert _class_similarity("комфорт", "комфорт") == 1.0

    def test_adjacent_class(self) -> None:
        # комфорт(1) ↔ комфорт+(2) = 1 шаг → 0.6.
        assert _class_similarity("комфорт", "комфорт+") == pytest.approx(0.6)
        assert _class_similarity("комфорт+", "комфорт") == pytest.approx(0.6)

    def test_two_steps(self) -> None:
        # эконом(0) ↔ комфорт+(2) = 2 шага → 0.3.
        assert _class_similarity("эконом", "комфорт+") == pytest.approx(0.3)

    def test_far_class(self) -> None:
        # эконом(0) ↔ премиум(5) = 5 шагов → 0.1.
        assert _class_similarity("эконом", "премиум") == pytest.approx(0.1)
        # бизнес(3) ↔ эконом(0) = 3 шага → far 0.1.
        assert _class_similarity("бизнес", "эконом") == pytest.approx(0.1)

    def test_cross_language_match(self) -> None:
        # comp латиницей, эталон кириллицей → один класс → 1.0.
        assert _class_similarity("comfort", "Комфорт") == 1.0

    def test_unknown_competitor_neutral(self) -> None:
        assert _class_similarity(None, "комфорт") == _NEUTRAL
        assert _class_similarity("неведомый", "комфорт") == _NEUTRAL

    def test_unknown_reference_neutral(self) -> None:
        assert _class_similarity("комфорт", None) == _NEUTRAL


# ── _price_similarity ─────────────────────────────────────────────────────────


class TestPriceSimilarity:
    def test_on_median_is_one(self) -> None:
        assert _price_similarity(100_000.0, 100_000.0) == 1.0

    def test_fifty_pct_above(self) -> None:
        # |150k-100k|/100k = 0.5 → 1-0.5 = 0.5.
        assert _price_similarity(150_000.0, 100_000.0) == pytest.approx(0.5)

    def test_fifty_pct_below_symmetric(self) -> None:
        assert _price_similarity(50_000.0, 100_000.0) == pytest.approx(0.5)

    def test_double_or_more_clamps_to_zero(self) -> None:
        # +100% и больше → clamp 0 (не отрицательное).
        assert _price_similarity(200_000.0, 100_000.0) == 0.0
        assert _price_similarity(500_000.0, 100_000.0) == 0.0

    def test_none_competitor_price_neutral(self) -> None:
        assert _price_similarity(None, 100_000.0) == _NEUTRAL

    def test_none_or_zero_median_neutral(self) -> None:
        assert _price_similarity(100_000.0, None) == _NEUTRAL
        assert _price_similarity(100_000.0, 0.0) == _NEUTRAL


# ── _stage_at_horizon (horizon-aware ключевой фактор) ──────────────────────────


class TestStageAtHorizon:
    def test_projected_sold_out_is_low(self) -> None:
        # 10 доступно, velocity 5/мес × 12 = 60 → остаток 0 → score 0.
        assert _stage_at_horizon(10.0, 5.0, 12, 100.0) == 0.0

    def test_active_competitor_high(self) -> None:
        # 80 доступно, velocity 1/мес × 12 = 12 → остаток 68 из 100 → 0.68.
        assert _stage_at_horizon(80.0, 1.0, 12, 100.0) == pytest.approx(0.68)

    def test_partial_burn_down(self) -> None:
        # 60 доступно, velocity 2/мес × 12 = 24 → остаток 36 из 100 → 0.36.
        assert _stage_at_horizon(60.0, 2.0, 12, 100.0) == pytest.approx(0.36)

    def test_zero_velocity_keeps_inventory(self) -> None:
        # Нет продаж → остаток не уменьшается → доля доступного (50/100=0.5).
        assert _stage_at_horizon(50.0, 0.0, 12, 100.0) == pytest.approx(0.5)

    def test_thin_data_velocity_none_neutral(self) -> None:
        assert _stage_at_horizon(50.0, None, 12, 100.0) == _NEUTRAL

    def test_thin_data_available_none_neutral(self) -> None:
        assert _stage_at_horizon(None, 5.0, 12, 100.0) == _NEUTRAL

    def test_thin_data_total_none_neutral(self) -> None:
        assert _stage_at_horizon(50.0, 5.0, 12, None) == _NEUTRAL

    def test_zero_total_neutral_no_crash(self) -> None:
        assert _stage_at_horizon(0.0, 5.0, 12, 0.0) == _NEUTRAL

    def test_horizon_affects_projection(self) -> None:
        # Тот же конкурент: короткий горизонт → больше остаток → выше score.
        short = _stage_at_horizon(60.0, 2.0, 6, 100.0)  # 60-12=48 → 0.48
        long = _stage_at_horizon(60.0, 2.0, 24, 100.0)  # 60-48=12 → 0.12
        assert short > long
        assert short == pytest.approx(0.48)
        assert long == pytest.approx(0.12)

    def test_remaining_clamped_non_negative(self) -> None:
        # velocity огромная → projected_remaining clamp 0 (не отрицательный score).
        assert _stage_at_horizon(5.0, 100.0, 12, 100.0) == 0.0


# ── _relevance_weight (взвешенная комбинация) ──────────────────────────────────


class TestRelevanceWeight:
    def test_all_ones(self) -> None:
        assert _relevance_weight(1.0, 1.0, 1.0, 1.0) == pytest.approx(1.0)

    def test_all_zeros(self) -> None:
        assert _relevance_weight(0.0, 0.0, 0.0, 0.0) == 0.0

    def test_geo_only_equals_geo_weight(self) -> None:
        assert _relevance_weight(1.0, 0.0, 0.0, 0.0) == pytest.approx(_W_GEO)

    def test_stage_only_equals_stage_weight(self) -> None:
        assert _relevance_weight(0.0, 0.0, 0.0, 1.0) == pytest.approx(_W_STAGE)

    def test_weighted_mix(self) -> None:
        # geo 0.30*1 + class 0.25*0.6 + price 0.20*0.5 + stage 0.25*0 = 0.55.
        expected = _W_GEO * 1.0 + _W_CLASS * 0.6 + _W_PRICE * 0.5 + _W_STAGE * 0.0
        assert _relevance_weight(1.0, 0.6, 0.5, 0.0) == pytest.approx(expected)

    def test_result_clamped(self) -> None:
        # Даже при «грязных» входах результат остаётся в [0,1].
        assert 0.0 <= _relevance_weight(2.0, 2.0, 2.0, 2.0) <= 1.0
        assert _relevance_weight(2.0, 2.0, 2.0, 2.0) == 1.0

    def test_neutral_all_subscores_gives_neutral(self) -> None:
        # Полностью тонкие данные (все 0.5) → итог 0.5 (не штраф, не завышение).
        assert _relevance_weight(_NEUTRAL, _NEUTRAL, _NEUTRAL, _NEUTRAL) == pytest.approx(0.5)


# ── _dominant_class / _median ──────────────────────────────────────────────────


class TestDominantClass:
    def test_most_common(self) -> None:
        assert _dominant_class(["комфорт", "комфорт", "бизнес"]) == "комфорт"

    def test_mixed_languages_counted_together(self) -> None:
        # comfort + комфорт = один нормализованный класс → доминирует.
        assert _dominant_class(["comfort", "комфорт", "бизнес"]) == "комфорт"

    def test_ignores_unknown_and_none(self) -> None:
        assert _dominant_class([None, "ерунда", "бизнес"]) == "бизнес"

    def test_all_unknown_returns_none(self) -> None:
        assert _dominant_class([None, "ерунда", ""]) is None

    def test_empty_returns_none(self) -> None:
        assert _dominant_class([]) is None

    def test_tie_breaks_by_lowest_order_deterministic(self) -> None:
        # Ничья эконом(0) vs бизнес(3) → меньший order-индекс (эконом).
        assert _dominant_class(["эконом", "бизнес"]) == "эконом"
        # Порядок входа не влияет — детерминированно.
        assert _dominant_class(["бизнес", "эконом"]) == "эконом"


class TestMedian:
    def test_odd(self) -> None:
        assert _median([3.0, 1.0, 2.0]) == 2.0

    def test_even(self) -> None:
        assert _median([1.0, 2.0, 3.0, 4.0]) == pytest.approx(2.5)

    def test_single(self) -> None:
        assert _median([42.0]) == 42.0

    def test_empty_none(self) -> None:
        assert _median([]) is None


# ── class-order карта (документируется в отчёте) ───────────────────────────────


def test_class_order_is_monotonic_ladder() -> None:
    """эконом < комфорт < комфорт+ < бизнес < бизнес+ < премиум."""
    assert _CLASS_ORDER["эконом"] < _CLASS_ORDER["комфорт"] < _CLASS_ORDER["комфорт+"]
    assert _CLASS_ORDER["комфорт+"] < _CLASS_ORDER["бизнес"] < _CLASS_ORDER["бизнес+"]
    assert _CLASS_ORDER["бизнес+"] < _CLASS_ORDER["премиум"]
