"""#2012 — kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring.

Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
ceiling_height_m / is_apartments НЕ читал вовсе для отбора/скоринга аналогов.

Три независимых флага, все default OFF:
  - estimate_kitchen_area_signal_enabled / estimate_ceiling_height_signal_enabled
    ("мягкие корректировки") — pure-Python self-referential pool-median
    deviation penalty (см. _adjust_relevance_by_pool_deviation). НЕТ target-
    значения для сравнения (ни TradeInEstimateInput, ни `deals` его не несут),
    поэтому — в отличие от house_type/year_built — штраф считается от МЕДИАНЫ
    ПУЛА кандидатов, а не от target.
  - estimate_is_apartments_filter_enabled — hard-filter в _COMMON_WHERE (+ Tier W
    inline copy), симметричный novostroyki-guard #1186. SQL-фрагмент проверяется
    на сгенерированном тексте (mock db, паттерн test_estimator_radius_dedup_1871.py)
    — полный radius-путь требует PostGIS+БД.
"""

from __future__ import annotations

import os
from unittest.mock import MagicMock, patch

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

import pytest

import app.services.estimator as est

# --------------------------------------------------------------------------- #
# _adjust_relevance_by_pool_deviation — pure, no DB
# --------------------------------------------------------------------------- #


def _cands(values: list[float | None], key: str = "kitchen_area_m2") -> list[dict]:
    return [{key: v, "relevance_score": 0.0} for v in values]


def test_pool_deviation_null_safe_missing_key_not_penalized_or_counted() -> None:
    # 5 candidates carry a value (>= min_n), 2 don't -> those 2 stay untouched.
    cands = _cands([9.0, 9.0, 9.0, 9.0, 9.0, None, None])
    est._adjust_relevance_by_pool_deviation(
        cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
    )
    for c in cands[:5]:
        assert c["relevance_score"] == 0.0  # at the pool median -> no penalty
    for c in cands[5:]:
        assert c["relevance_score"] == 0.0  # missing value -> untouched, not 0-diff


def test_pool_deviation_sparse_safe_skips_when_below_min_n() -> None:
    # Only 3 candidates carry a value, min_n=5 -> signal skipped for the WHOLE pool.
    cands = _cands([5.0, 20.0, 5.0])
    est._adjust_relevance_by_pool_deviation(
        cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
    )
    assert all(c["relevance_score"] == 0.0 for c in cands)


def test_pool_deviation_penalizes_far_from_median() -> None:
    # Median of [8, 9, 9, 9, 10] = 9. Deviant 20 -> penalty = |20-9|/3.0 = 3.667,
    # clamped to max_penalty=1.0.
    cands = _cands([8.0, 9.0, 9.0, 9.0, 10.0, 20.0])
    est._adjust_relevance_by_pool_deviation(
        cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
    )
    assert cands[0]["relevance_score"] == pytest.approx(1.0 / 3.0)  # |8-9|/3
    assert cands[1]["relevance_score"] == 0.0  # at median
    assert cands[-1]["relevance_score"] == 1.0  # clamped


def test_pool_deviation_max_penalty_clamp() -> None:
    cands = _cands([1.0, 1.0, 1.0, 1.0, 1.0, 100.0])
    est._adjust_relevance_by_pool_deviation(
        cands, key="kitchen_area_m2", scale=1.0, max_penalty=0.5, min_n=5
    )
    assert cands[-1]["relevance_score"] == 0.5


def test_pool_deviation_additive_not_overwriting_existing_score() -> None:
    """Mutation ADDS to relevance_score (e.g. house_type SQL penalty already there),
    it never overwrites it."""
    cands = [
        {"kitchen_area_m2": 9.0, "relevance_score": 1.5},  # e.g. house_type mismatch
        {"kitchen_area_m2": 9.0, "relevance_score": 1.5},
        {"kitchen_area_m2": 9.0, "relevance_score": 0.0},
        {"kitchen_area_m2": 9.0, "relevance_score": 0.0},
        {"kitchen_area_m2": 20.0, "relevance_score": 0.0},
    ]
    est._adjust_relevance_by_pool_deviation(
        cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
    )
    assert cands[0]["relevance_score"] == pytest.approx(1.5)  # at median, +0
    assert cands[-1]["relevance_score"] == pytest.approx(1.0)  # median=9, |20-9|/3 clamped to 1.0


def test_pool_deviation_missing_relevance_score_key_defaults_to_zero() -> None:
    cands = [{"kitchen_area_m2": v} for v in [8.0, 9.0, 9.0, 9.0, 10.0]]
    est._adjust_relevance_by_pool_deviation(
        cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
    )
    assert all("relevance_score" in c for c in cands)


# --------------------------------------------------------------------------- #
# _apply_kitchen_ceiling_signal — flag wiring
# --------------------------------------------------------------------------- #


def _mixed_pool() -> list[dict]:
    return [
        {"kitchen_area_m2": 8.0, "ceiling_height_m": 2.7, "relevance_score": 0.0},
        {"kitchen_area_m2": 9.0, "ceiling_height_m": 2.7, "relevance_score": 0.0},
        {"kitchen_area_m2": 9.0, "ceiling_height_m": 2.7, "relevance_score": 0.0},
        {"kitchen_area_m2": 9.0, "ceiling_height_m": 2.7, "relevance_score": 0.0},
        {"kitchen_area_m2": 20.0, "ceiling_height_m": 4.5, "relevance_score": 0.0},
    ]


def test_apply_signal_noop_when_both_flags_off() -> None:
    cands = _mixed_pool()
    with (
        patch.object(est.settings, "estimate_kitchen_area_signal_enabled", False),
        patch.object(est.settings, "estimate_ceiling_height_signal_enabled", False),
    ):
        est._apply_kitchen_ceiling_signal(cands)
    assert all(c["relevance_score"] == 0.0 for c in cands)


def test_apply_signal_kitchen_only() -> None:
    cands = _mixed_pool()
    with (
        patch.object(est.settings, "estimate_kitchen_area_signal_enabled", True),
        patch.object(est.settings, "estimate_ceiling_height_signal_enabled", False),
    ):
        est._apply_kitchen_ceiling_signal(cands)
    assert cands[-1]["relevance_score"] > 0.0  # kitchen outlier (20) penalized
    for c in cands[1:4]:
        assert c["relevance_score"] == 0.0  # at pool median (9.0) -> untouched


def test_apply_signal_ceiling_only() -> None:
    cands = _mixed_pool()
    with (
        patch.object(est.settings, "estimate_kitchen_area_signal_enabled", False),
        patch.object(est.settings, "estimate_ceiling_height_signal_enabled", True),
    ):
        est._apply_kitchen_ceiling_signal(cands)
    assert cands[-1]["relevance_score"] > 0.0  # ceiling outlier penalized
    for c in cands[:4]:
        assert c["relevance_score"] == 0.0


# --------------------------------------------------------------------------- #
# Defaults
# --------------------------------------------------------------------------- #


def test_new_flags_default_off() -> None:
    from app.core.config import settings

    assert settings.estimate_kitchen_area_signal_enabled is False
    assert settings.estimate_ceiling_height_signal_enabled is False
    assert settings.estimate_is_apartments_filter_enabled is False


# --------------------------------------------------------------------------- #
# is_apartments hard-filter — SQL-fragment (mock db, no PostGIS/DB needed)
# --------------------------------------------------------------------------- #


def _capture_tier_sql_and_params(*, is_apartments_filter: bool) -> list[tuple[str, dict]]:
    """Runs _fetch_analogs with a mock db, returns (sql_text, params) per tier.

    target_house_id + short_addr + year/floors are all set so SQL for ALL four
    tiers renders (each tier returns [] -> fallthrough to the next).
    """
    captured: list[tuple[str, dict]] = []
    db = MagicMock()

    def side_effect(*args, **kwargs):  # type: ignore[no-untyped-def]
        params = args[1] if len(args) > 1 else {}
        captured.append((str(args[0].text), dict(params)))
        result = MagicMock()
        result.mappings.return_value.all.return_value = []
        return result

    db.execute.side_effect = side_effect

    with patch.object(est.settings, "estimate_is_apartments_filter_enabled", is_apartments_filter):
        est._fetch_analogs(
            db,
            lat=56.83,
            lon=60.6,
            rooms=2,
            area=50.0,
            radius_m=2000,
            full_address="г Екатеринбург, ул Малышева, д 30",
            year_built=2010,
            house_type="монолит",
            total_floors=20,
            target_house_id=123,
        )
    return captured


def test_all_four_tiers_render_with_is_apartments_guard() -> None:
    calls = _capture_tier_sql_and_params(is_apartments_filter=False)
    assert len(calls) == 4, "ожидаем S-canonical, S-fallback, H, W"


def test_is_apartments_bind_param_present_in_every_tier() -> None:
    calls = _capture_tier_sql_and_params(is_apartments_filter=False)
    for i, (sql, params) in enumerate(calls):
        assert ":is_apartments_filter" in sql, f"tier#{i} без is_apartments-гварда"
        assert "is_apartments_filter" in params, f"tier#{i}: параметр не передан"


def test_is_apartments_null_safe_guard_sql_present() -> None:
    calls = _capture_tier_sql_and_params(is_apartments_filter=True)
    for i, (sql, _params) in enumerate(calls):
        assert "IS NOT TRUE" in sql, f"tier#{i}: гейт по флагу отсутствует"
        assert "is_apartments IS NULL" in sql, f"tier#{i}: NULL-safe пропуск отсутствует"
        assert "is_apartments = false" in sql, f"tier#{i}: явный False-пропуск отсутствует"


def test_is_apartments_param_value_reflects_setting() -> None:
    calls_off = _capture_tier_sql_and_params(is_apartments_filter=False)
    for i, (_sql, params) in enumerate(calls_off):
        assert params["is_apartments_filter"] is False, f"tier#{i}"

    calls_on = _capture_tier_sql_and_params(is_apartments_filter=True)
    for i, (_sql, params) in enumerate(calls_on):
        assert params["is_apartments_filter"] is True, f"tier#{i}"


def test_no_bind_param_double_colon_cast() -> None:
    """psycopg3-инвариант: только column::type (не :bind::type)."""
    import re

    bad = re.compile(r":[a-z_]+::[a-z]")
    for i, (sql, _params) in enumerate(_capture_tier_sql_and_params(is_apartments_filter=True)):
        assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type"


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(pytest.main([__file__, "-q"]))
