"""Unit tests for #2255 — segment multiplier by price band (default OFF).

Two layers:
  1. The pure ``_apply_segment_multiplier`` helper (no DB): flag-off no-op, per-band
     proportional scaling of point + range, out-of-band / премиум → 1.0, and the
     empty / broken multipliers-dict no-op (with a warning).
  2. Integration through ``_price_from_inputs`` with the flag toggled ON via
     monkeypatch — proves the multiplier fires BEFORE ``_apply_range_floor`` (the
     floor widens the ALREADY-multiplied range, never the other way round) and that
     flag-OFF leaves the priced result byte-identical.

NOTE: importing app.services.estimator pulls app.core.config.Settings which requires
DATABASE_URL. Set it BEFORE importing app modules (mirrors test_estimator_range_floor).
"""

from __future__ import annotations

import logging
import os

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

import pytest

from app.services import estimator
from app.services.estimator import (
    RANGE_MIN_HALFWIDTH_PCT,
    _apply_segment_multiplier,
    _segment_for_ppm2,
)
from app.services.geocoder import GeocodeResult

# Prod multipliers as calibrated in config (#2255, V2). комфорт=1.00 deliberately
# (predicted-band leakage into эконом — see config comment + test_flag_on_comfort_
# band_is_noop_end_to_end); бизнес/элит softened to 1.08/1.06 (v4's 1.12/1.10 gave
# +3.08pp overall MAPE, V2 gives +1.87pp). Kept local so integration tests reflect prod.
_BIZ = 1.08  # prod бизнес multiplier — one symbol so a future retune touches one place
_MULTS = {"эконом": 1.00, "комфорт": 1.00, "бизнес": _BIZ, "элит": 1.06}

# Synthetic multipliers for the PURE-helper math: proves _apply_segment_multiplier
# scales ANY band by its factor (independent of the prod config's комфорт=1.00 choice).
_MULTS_ALL_ACTIVE = {"эконом": 1.05, "комфорт": 1.03, "бизнес": 1.12, "элит": 1.10}


# ─────────────────────────────────────────────────────────────────────────────
# 1a. _segment_for_ppm2 — band boundaries (shared source of truth)
# ─────────────────────────────────────────────────────────────────────────────


def test_segment_for_ppm2_bands_and_boundaries() -> None:
    """Boundaries are upper-exclusive; +inf tail catches the top (premium)."""
    assert _segment_for_ppm2(100_000) == "эконом"
    assert _segment_for_ppm2(119_999) == "эконом"
    assert _segment_for_ppm2(120_000) == "комфорт"
    assert _segment_for_ppm2(159_999) == "комфорт"
    assert _segment_for_ppm2(160_000) == "бизнес"
    assert _segment_for_ppm2(219_999) == "бизнес"
    assert _segment_for_ppm2(220_000) == "элит"
    assert _segment_for_ppm2(299_999) == "элит"
    assert _segment_for_ppm2(300_000) == "премиум"
    assert _segment_for_ppm2(2_000_000) == "премиум"


# ─────────────────────────────────────────────────────────────────────────────
# 1b. _apply_segment_multiplier — pure helper
# ─────────────────────────────────────────────────────────────────────────────


def test_disabled_is_noop() -> None:
    """enabled=False → values returned untouched, band=None, factor=1.0."""
    out = _apply_segment_multiplier(
        median_price=10_000_000,
        median_ppm2=200_000.0,  # бизнес band
        range_low=9_000_000,
        range_high=11_000_000,
        enabled=False,
        multipliers=_MULTS,
    )
    assert out == (200_000.0, 10_000_000, 9_000_000, 11_000_000, None, 1.0)


@pytest.mark.parametrize(
    ("ppm2", "band", "mult"),
    [
        (200_000.0, "бизнес", 1.12),
        (250_000.0, "элит", 1.10),
        (140_000.0, "комфорт", 1.03),
        (100_000.0, "эконом", 1.05),
    ],
)
def test_each_band_scales_point_and_range_proportionally(
    ppm2: float, band: str, mult: float
) -> None:
    """The pure helper multiplies point + range by the SAME factor for ANY band.

    Uses synthetic all-active multipliers (комфорт≠1.0, эконом≠1.0) to prove the
    helper math independent of the prod config's комфорт=1.00 leakage choice.
    """
    price, low, high = 10_000_000, 9_000_000, 11_000_000
    new_ppm2, new_price, new_low, new_high, out_band, factor = _apply_segment_multiplier(
        median_price=price,
        median_ppm2=ppm2,
        range_low=low,
        range_high=high,
        enabled=True,
        multipliers=_MULTS_ALL_ACTIVE,
    )
    assert out_band == band
    assert factor == mult
    assert new_ppm2 == pytest.approx(ppm2 * mult)
    assert new_price == round(price * mult)
    assert new_low == round(low * mult)
    assert new_high == round(high * mult)
    # Geometric consistency: the range scales by exactly the same factor as the point.
    assert new_low / low == pytest.approx(new_high / high)
    assert new_price / price == pytest.approx(new_ppm2 / ppm2)


def test_econom_neutral_multiplier_is_noop() -> None:
    """эконом=1.00 → no mutation, band=None (nothing to attribute)."""
    out = _apply_segment_multiplier(
        median_price=5_000_000,
        median_ppm2=100_000.0,  # эконом → 1.00
        range_low=4_500_000,
        range_high=5_500_000,
        enabled=True,
        multipliers=_MULTS,
    )
    assert out == (100_000.0, 5_000_000, 4_500_000, 5_500_000, None, 1.0)


def test_premium_band_has_no_multiplier_and_is_noop() -> None:
    """премиум is intentionally absent from the dict → no-op (missing key)."""
    out = _apply_segment_multiplier(
        median_price=40_000_000,
        median_ppm2=400_000.0,  # премиум band, no entry
        range_low=36_000_000,
        range_high=44_000_000,
        enabled=True,
        multipliers=_MULTS,
    )
    assert out == (400_000.0, 40_000_000, 36_000_000, 44_000_000, None, 1.0)


def test_zero_ppm2_is_noop() -> None:
    """median_ppm2<=0 (no headline) → no-op regardless of flag."""
    out = _apply_segment_multiplier(
        median_price=0,
        median_ppm2=0.0,
        range_low=0,
        range_high=0,
        enabled=True,
        multipliers=_MULTS,
    )
    assert out == (0.0, 0, 0, 0, None, 1.0)


def test_empty_multipliers_dict_warns_and_noops(caplog: pytest.LogCaptureFixture) -> None:
    """Empty multipliers with flag ON → no-op + a warning (bad config, don't crash)."""
    with caplog.at_level(logging.WARNING, logger="app.services.estimator"):
        out = _apply_segment_multiplier(
            median_price=10_000_000,
            median_ppm2=200_000.0,
            range_low=9_000_000,
            range_high=11_000_000,
            enabled=True,
            multipliers={},
        )
    assert out == (200_000.0, 10_000_000, 9_000_000, 11_000_000, None, 1.0)
    assert any("segment_mult" in r.message and "пустой" in r.message for r in caplog.records)


def test_broken_multiplier_value_warns_and_noops(caplog: pytest.LogCaptureFixture) -> None:
    """Non-numeric band value → no-op + warning (don't blow up a real estimate)."""
    with caplog.at_level(logging.WARNING, logger="app.services.estimator"):
        out = _apply_segment_multiplier(
            median_price=10_000_000,
            median_ppm2=200_000.0,  # бизнес
            range_low=9_000_000,
            range_high=11_000_000,
            enabled=True,
            multipliers={"бизнес": "oops"},  # type: ignore[dict-item]
        )
    assert out == (200_000.0, 10_000_000, 9_000_000, 11_000_000, None, 1.0)
    assert any("битый множитель" in r.message for r in caplog.records)


def test_nonpositive_multiplier_warns_and_noops(caplog: pytest.LogCaptureFixture) -> None:
    """A ≤0 multiplier is nonsensical → no-op + warning (never zero out a price)."""
    with caplog.at_level(logging.WARNING, logger="app.services.estimator"):
        out = _apply_segment_multiplier(
            median_price=10_000_000,
            median_ppm2=200_000.0,
            range_low=9_000_000,
            range_high=11_000_000,
            enabled=True,
            multipliers={"бизнес": 0.0},
        )
    assert out == (200_000.0, 10_000_000, 9_000_000, 11_000_000, None, 1.0)
    assert any("неположительный множитель" in r.message for r in caplog.records)


# ─────────────────────────────────────────────────────────────────────────────
# 2. Integration through _price_from_inputs — order vs _apply_range_floor + flag OFF
# ─────────────────────────────────────────────────────────────────────────────


def _geo() -> GeocodeResult:
    return GeocodeResult(
        lat=56.838,
        lon=60.597,
        full_address="ул. Тестовая, 1",
        provider="nominatim",
        confidence="approximate",
    )


def _lots(ppm2: float, n: int = 7) -> list[dict]:
    return [
        {"price_per_m2": ppm2, "address": f"ул. Тестовая, {i + 1}", "source": "avito"}
        for i in range(n)
    ]


def _call(
    *, listings: list[dict], area_m2: float = 50.0, ratio: float | None = None
) -> estimator.PricingResult:
    _ratio = ratio
    _basis = "per_rooms" if ratio is not None else None

    def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]:
        return _ratio, _basis

    return estimator._price_from_inputs(
        listings=listings,
        area_m2=area_m2,
        rooms=2,
        repair_state=None,
        floor=5,
        total_floors=10,
        target_year=None,
        analog_tier="W",
        fallback_used=False,
        area_widened=False,
        anchor_comps=[],
        anchor_tier_fetched=None,
        dkp_raw=None,
        imv_anchor=None,
        imv_eval=None,
        yandex_val_present=False,
        cian_val_present=False,
        ratio_resolver=ratio_resolver,
        quarter_index_lookup=lambda q: None,
        quarter_indexes_lookup=lambda qs: {},
        target_house_cadnum=None,
        dadata_coarse=False,
        geo=_geo(),
        dadata_qc_geo=None,
    )


def test_flag_off_priced_result_is_unchanged() -> None:
    """Default (flag OFF): a бизнес-band estimate is NOT multiplied — byte-identical."""
    # 7 uniform lots at 200k ₽/m² (бизнес band), wide enough spread avoided → point stays.
    pr = _call(listings=_lots(200_000.0, n=7))
    assert pr.median_ppm2 == 200_000.0
    assert pr.median_price == 200_000 * 50  # 10_000_000, untouched
    assert "segment_multiplier" not in (pr.sources_used_pre or [])


def test_flag_on_business_band_multiplies_point_and_range(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Flag ON: бизнес-band point + range scaled ×_BIZ; range still brackets point.

    Also proves the multiplier ran BEFORE _apply_range_floor: the floor may only
    WIDEN, so range_high/point must be at least the multiplied point ± floor. A
    naive "floor then multiply" order would instead leave the point unmultiplied.
    """
    monkeypatch.setattr(estimator.settings, "estimate_segment_multiplier_enabled", True)
    monkeypatch.setattr(estimator.settings, "estimate_segment_multipliers", _MULTS)

    pr = _call(listings=_lots(200_000.0, n=7))
    # Point multiplied: 200k → 200k×_BIZ ₽/m²; total 10M → 10M×_BIZ.
    assert pr.median_ppm2 == pytest.approx(200_000.0 * _BIZ)
    assert pr.median_price == round(10_000_000 * _BIZ)
    assert "segment_multiplier" in pr.sources_used_pre
    # Range brackets the MULTIPLIED point (floor only widens around the lifted point).
    assert pr.range_low <= pr.median_price <= pr.range_high
    # ppm² point stays consistent with the multiplied total.
    assert pr.median_ppm2 == pytest.approx(pr.median_price / 50.0)


def test_flag_on_order_multiplier_before_range_floor(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """n=1 degenerate range: multiplier moves the point FIRST, floor then widens it.

    A single analog collapses Q1==Q3==median → zero-width asking range. With the
    flag ON the бизнес point is multiplied to 10M×_BIZ, THEN the ±12 % floor widens
    the (still zero-width) range symmetrically around that lifted point. If the floor
    ran first, it would bracket the pre-multiply 10M point and the edges would not be
    point±12 % of the multiplied point — this asserts they are.
    """
    monkeypatch.setattr(estimator.settings, "estimate_segment_multiplier_enabled", True)
    monkeypatch.setattr(estimator.settings, "estimate_segment_multipliers", _MULTS)

    pr = _call(listings=_lots(200_000.0, n=1))
    assert pr.n_analogs == 1
    point = pr.median_price
    assert point == round(10_000_000 * _BIZ)  # multiplied point
    half = round(RANGE_MIN_HALFWIDTH_PCT * point)
    # Floor widened AROUND the multiplied point (proves multiplier-before-floor).
    assert pr.range_low == point - half
    assert pr.range_high == point + half


def test_flag_on_econom_band_is_noop_end_to_end(monkeypatch: pytest.MonkeyPatch) -> None:
    """Flag ON but эконом band (mult 1.00): priced result stays unmultiplied."""
    monkeypatch.setattr(estimator.settings, "estimate_segment_multiplier_enabled", True)
    monkeypatch.setattr(estimator.settings, "estimate_segment_multipliers", _MULTS)

    pr = _call(listings=_lots(100_000.0, n=7))  # эконом
    assert pr.median_ppm2 == 100_000.0
    assert pr.median_price == 100_000 * 50
    assert "segment_multiplier" not in (pr.sources_used_pre or [])


def test_flag_on_comfort_band_is_noop_end_to_end(monkeypatch: pytest.MonkeyPatch) -> None:
    """DELIBERATE: комфорт=1.00 in prod config → no-op even with the flag ON.

    комфорт was dropped to 1.00 (from a раннего 1.03) because the band is keyed on
    PREDICTED ppm² while accuracy is measured by SOLD ppm²: a ×1.03 on комфорт-
    predicted deals leaks onto эконом-sold deals (already over-predicted), inflating
    overall MAPE for negligible комфорт benefit (live OFF/ON sample=300, 2026-07-03).
    This test guards that decision — a future retune to комфорт≠1.0 must be conscious.
    """
    monkeypatch.setattr(estimator.settings, "estimate_segment_multipliers", _MULTS)

    # Baseline OFF vs flag ON on the SAME комфорт deal — both must be identical.
    monkeypatch.setattr(estimator.settings, "estimate_segment_multiplier_enabled", False)
    off = _call(listings=_lots(140_000.0, n=7), ratio=0.90)  # комфорт band
    monkeypatch.setattr(estimator.settings, "estimate_segment_multiplier_enabled", True)
    on = _call(listings=_lots(140_000.0, n=7), ratio=0.90)

    assert estimator.settings.estimate_segment_multipliers["комфорт"] == 1.00
    assert on.median_ppm2 == 140_000.0  # headline untouched
    assert on.median_price == 140_000 * 50
    # Both headline and выкуп identical OFF vs ON (комфорт=1.00 → true no-op).
    assert on.median_price == off.median_price
    assert on.expected_sold_price == off.expected_sold_price
    assert "segment_multiplier" not in (on.sources_used_pre or [])


# ─────────────────────────────────────────────────────────────────────────────
# 3. expected_sold (выкуп) must ALSO be multiplied — the acceptance-critical path
#    (backtest scores expected_sold; #2255 requires per-segment bias to shrink).
# ─────────────────────────────────────────────────────────────────────────────


def test_flag_off_expected_sold_unchanged() -> None:
    """OFF: expected_sold is the plain ratio-derived выкуп (hedonic/PI, no multiplier).

    The exact value is median×ratio×hedonic (≈0.889 of median here, not 0.90 —
    hedonic's ln(area) term shifts it), but the point is: NO segment multiplier is
    attributed and выкуп ≤ headline.
    """
    pr = _call(listings=_lots(200_000.0, n=7), ratio=0.90)
    assert pr.expected_sold_price is not None
    assert pr.expected_sold_price <= pr.median_price  # выкуп ≤ headline
    assert pr.expected_sold_price < pr.median_price  # ratio<1 → strictly below
    assert "segment_multiplier" not in (pr.sources_used_pre or [])


def test_flag_on_expected_sold_multiplied_by_same_factor(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """ON: expected_sold (point + per_m2 + range) scaled by the SAME бизнес factor.

    This is the fix for #2255: the multiplier reaches the выкуп (client offer),
    not only the asking headline. expected_sold is derived earlier in the pipeline
    (median×ratio + hedonic/PI) and frozen before the multiplier, so the call site
    re-applies the identical factor to it explicitly.
    """
    # Baseline with the flag OFF (multipliers set but flag disabled → no-op).
    monkeypatch.setattr(estimator.settings, "estimate_segment_multiplier_enabled", False)
    monkeypatch.setattr(estimator.settings, "estimate_segment_multipliers", _MULTS)
    off = _call(listings=_lots(200_000.0, n=7), ratio=0.90)

    # Now flip the flag ON for the same deal.
    monkeypatch.setattr(estimator.settings, "estimate_segment_multiplier_enabled", True)
    on = _call(listings=_lots(200_000.0, n=7), ratio=0.90)

    assert "segment_multiplier" not in (off.sources_used_pre or [])
    assert "segment_multiplier" in on.sources_used_pre
    # выкуп point ×_BIZ vs the flag-OFF derivation of the SAME deal.
    assert off.expected_sold_price is not None and on.expected_sold_price is not None
    assert on.expected_sold_price == round(off.expected_sold_price * _BIZ)
    assert on.expected_sold_per_m2 == round(off.expected_sold_per_m2 * _BIZ)
    # Range scaled too (floor may only widen; ×_BIZ keeps it ≥ scaled edges).
    assert on.expected_sold_range_low >= round(off.expected_sold_range_low * _BIZ) - 1
    assert on.expected_sold_range_high >= round(off.expected_sold_range_high * _BIZ) - 1


def test_flag_on_expected_sold_le_headline_invariant_preserved(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """ON: выкуп ≤ headline holds after the multiplier (both ×same factor)."""
    monkeypatch.setattr(estimator.settings, "estimate_segment_multipliers", _MULTS)

    # ratio 0.90 < 1 → expected_sold < median before AND after the ×_BIZ lift.
    monkeypatch.setattr(estimator.settings, "estimate_segment_multiplier_enabled", False)
    off = _call(listings=_lots(200_000.0, n=7), ratio=0.90)
    monkeypatch.setattr(estimator.settings, "estimate_segment_multiplier_enabled", True)
    on = _call(listings=_lots(200_000.0, n=7), ratio=0.90)
    assert on.expected_sold_price is not None
    # выкуп ≤ headline holds after the lift (both ×_BIZ → monotonic).
    assert on.expected_sold_price <= on.median_price
    # honest ratio (expected/median) is invariant to the common factor: same as OFF.
    assert on.expected_sold_price / on.median_price == pytest.approx(
        off.expected_sold_price / off.median_price, abs=1e-3
    )
