"""Hermetic unit tests for the #2209 min-width range floor.

Two layers:
  1. The pure ``_apply_range_floor`` helper (no DB, no imports beyond estimator).
  2. Integration through ``_price_from_inputs`` — a degenerate n=1 analog sample
     (q1==q3==median → zero-width asking range) must surface a non-zero ±12 %
     range; a naturally-wide range must be left untouched; the floor must run
     AFTER the IMV blend (a narrow post-blend range gets widened, never shrunk).

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

import os

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

from app.services import estimator
from app.services.estimator import RANGE_MIN_HALFWIDTH_PCT, _apply_range_floor
from app.services.geocoder import GeocodeResult

# ── helpers (mirror tests/test_estimator_price_spine.py) ──────────────────────


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


def _lot(ppm2: float, address: str = "ул. Тестовая, 1", source: str = "avito") -> dict:
    return {"price_per_m2": ppm2, "address": address, "source": source}


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


def _call(
    *,
    listings: list[dict],
    area_m2: float = 50.0,
    rooms: int | None = 2,
    imv_anchor: dict | None = None,
    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 if _ratio is not None else None

    return estimator._price_from_inputs(
        listings=listings,
        area_m2=area_m2,
        rooms=rooms,
        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=imv_anchor,
        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,
    )


# ── 1. Pure helper ────────────────────────────────────────────────────────────


def test_floor_expands_zero_width_symmetrically() -> None:
    """point=5M, low==high==5M (n=1 collapse) → symmetric ±12 % around point."""
    half = round(RANGE_MIN_HALFWIDTH_PCT * 5_000_000)  # 600_000
    low, high = _apply_range_floor(5_000_000, 5_000_000, 5_000_000)
    assert low == 5_000_000 - half
    assert high == 5_000_000 + half
    # Point is the exact midpoint — floor never moves the point.
    assert (low + high) // 2 == 5_000_000


def test_floor_leaves_wide_range_untouched() -> None:
    """A range already wider than 2×12 % of point is returned verbatim."""
    # width = 3M, point 5M → rel 0.60 >> 0.24 floor → no-op.
    assert _apply_range_floor(3_500_000, 6_500_000, 5_000_000) == (3_500_000, 6_500_000)


def test_floor_clamps_low_to_zero() -> None:
    """When point < floor_half the lower edge clamps at 0 (never negative)."""
    # point=1M → floor_half=120_000; 1M-120k=880k stays positive, so pick a tiny point.
    half = round(RANGE_MIN_HALFWIDTH_PCT * 100_000)  # 12_000
    low, _high = _apply_range_floor(50_000, 50_000, 100_000)
    assert low == 100_000 - half  # 88_000 — still positive here
    # Now a point whose floor_half exceeds it → low clamps to 0.
    low2, high2 = _apply_range_floor(5, 5, 10)
    floor_half2 = round(RANGE_MIN_HALFWIDTH_PCT * 10)  # ~1
    assert low2 == max(0, 10 - floor_half2)
    assert low2 >= 0
    assert high2 == 10 + floor_half2


def test_floor_noop_when_point_nonpositive() -> None:
    """point<=0 → range returned unchanged (no division/expansion)."""
    assert _apply_range_floor(0, 0, 0) == (0, 0)
    assert _apply_range_floor(100, 200, 0) == (100, 200)


def test_floor_only_widens_never_shrinks() -> None:
    """A range exactly at the floor boundary is preserved (not shrunk)."""
    point = 1_000_000
    half = round(RANGE_MIN_HALFWIDTH_PCT * point)  # 120_000
    # width == 2*half exactly → NOT < 2*half → untouched.
    assert _apply_range_floor(point - half, point + half, point) == (
        point - half,
        point + half,
    )


# ── 2. Integration through _price_from_inputs ────────────────────────────────


def test_single_analog_asking_range_gets_nonzero_width() -> None:
    """n=1 analog: q1==q3==median → zero-width asking range → floored to ±12 %."""
    pr = _call(listings=[_lot(100_000)])
    assert pr.n_analogs == 1
    point = pr.median_price  # 100_000 × 50 = 5_000_000
    assert point == 5_000_000
    half = round(RANGE_MIN_HALFWIDTH_PCT * point)
    assert pr.range_low == point - half
    assert pr.range_high == point + half
    # Non-degenerate: the range is no longer a single point.
    assert pr.range_high > pr.range_low
    # Point (median) itself is untouched — floor never moves it.
    assert pr.median_ppm2 == 100_000.0


def test_wide_analog_range_not_floored() -> None:
    """A radius sample with real spread keeps its Q1..Q3 asking range unchanged."""
    # Spread wide enough that (q3-q1)/median > 0.24.
    lots = [_lot(p) for p in (60_000, 80_000, 100_000, 120_000, 140_000)]
    pr = _call(listings=lots)
    point = pr.median_price
    half = round(RANGE_MIN_HALFWIDTH_PCT * point)
    # Real IQR width exceeds the floor → floor is a no-op here.
    assert (pr.range_high - pr.range_low) >= 2 * half
    # And the edges are the genuine Q1/Q3 totals, not point±half.
    assert pr.range_low != point - half


def test_expected_sold_range_floored_around_expected_point() -> None:
    """Degenerate n=1 with a ratio: expected_sold range also gets ±12 % of its point."""
    pr = _call(listings=[_lot(100_000)], ratio=0.90)
    assert pr.expected_sold_price is not None
    assert pr.expected_sold_range_low is not None
    assert pr.expected_sold_range_high is not None
    esp = pr.expected_sold_price
    # Calibrated PI band (0.649..1.392 of point) is far wider than the 12 % floor,
    # so the expected_sold range is NOT degenerate and the floor is a no-op on it —
    # but it must still bracket the point and stay non-negative.
    assert pr.expected_sold_range_low <= esp <= pr.expected_sold_range_high
    assert pr.expected_sold_range_low >= 0
    assert (pr.expected_sold_range_high - pr.expected_sold_range_low) >= round(
        2 * RANGE_MIN_HALFWIDTH_PCT * esp
    )


def test_ppm2_point_consistent_with_floored_range() -> None:
    """Serialized ppm² point stays the analog median; floor only widens rub range.

    The response serializes a single ppm² point (median_ppm2 / expected_sold_per_m2),
    not a ppm² range, so цена↔ppm² consistency means the point ppm² must equal
    median_price/area even after the range floor widens the rub band.
    """
    pr = _call(listings=[_lot(100_000)], area_m2=50.0)
    assert pr.median_ppm2 == pr.median_price / 50.0
    # Floor widened the rub range but did not touch the ppm² point.
    assert pr.median_ppm2 == 100_000.0


def test_floor_applied_after_imv_blend() -> None:
    """Floor runs LAST: a narrow post-IMV-blend asking range is widened, not shrunk.

    Uniform lots → zero-width Q1..Q3. IMV anchor just above threshold (no higher_price)
    blends the median up and lifts range_high to the anchor total, leaving a range that
    is still narrower than the 12 % floor. The floor must then widen it symmetrically
    around the POST-BLEND point — proving the floor sees post-blend values.
    """
    imv_anchor = {
        "recommended_price": 5_800_000,  # > 5M × 1.15 = 5.75M → blends
        "lower_price": 5_500_000,
        "higher_price": None,  # so range_top_candidate == anchor_total (5.8M)
        "market_count": 50,
    }
    pr = _call(listings=_lots(100_000, n=5), imv_anchor=imv_anchor)
    # Blend fired: point moved to round(5M×0.5 + 5.8M×0.5) = 5.4M.
    point = pr.median_price
    assert point == 5_400_000
    half = round(RANGE_MIN_HALFWIDTH_PCT * point)  # 648_000
    # Pre-floor the post-blend range was [5.0M, 5.8M] (width 0.8M < 2×648k) → floored.
    assert pr.range_low == point - half  # 4_752_000 < 5.0M pre-floor low
    assert pr.range_high == point + half  # 6_048_000 > 5.8M pre-floor high
    # Floor only WIDENED: high moved above the blend's 5.8M, low below the 5.0M q1.
    assert pr.range_high > 5_800_000
    assert pr.range_low < 5_000_000
