"""Unit tests for #2043 (BE-1) sample-confidence metrics.

Covers, without DB/network:
  - estimator._cv_from_ppm2      — coefficient of variation ₽/м² (std/mean)
  - estimator._source_counts     — per-source analog counts
  - estimator._price_from_inputs — surfaces cv on radius- and anchor-paths

NOTE: importing app.services.estimator pulls app.core.config.Settings which
requires DATABASE_URL. Set it BEFORE importing app modules (same pattern as
tests/test_estimator_pure_units.py).
"""

import os

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

import math

import pytest

from app.services import estimator
from app.services.geocoder import GeocodeResult

# --------------------------------------------------------------------------- #
# _cv_from_ppm2
# --------------------------------------------------------------------------- #


def test_cv_from_ppm2_fewer_than_two_returns_none() -> None:
    assert estimator._cv_from_ppm2([]) is None
    assert estimator._cv_from_ppm2([100_000]) is None
    # A single usable value (the None/0 are dropped) → still < 2 → None.
    assert estimator._cv_from_ppm2([100_000, None, 0]) is None


def test_cv_from_ppm2_uniform_is_zero() -> None:
    # Zero variance → cv == 0.0 (non-None, valid "tight" sample).
    result = estimator._cv_from_ppm2([100_000, 100_000, 100_000])
    assert result == 0.0


def test_cv_from_ppm2_known_value() -> None:
    # values [90k, 100k, 110k]: mean=100k, population var = (100M+0+100M)/3
    # std = sqrt(200_000_000/3) ≈ 8164.9658 → cv ≈ 0.0816497
    result = estimator._cv_from_ppm2([90_000, 100_000, 110_000])
    assert result is not None
    assert math.isclose(result, 8164.9658 / 100_000, rel_tol=1e-4)


def test_cv_from_ppm2_drops_none_and_nonpositive() -> None:
    # None and 0 are skipped; result computed on [100k, 120k] only.
    with_noise = estimator._cv_from_ppm2([100_000, None, 0, 120_000])
    clean = estimator._cv_from_ppm2([100_000, 120_000])
    assert with_noise == clean
    assert clean is not None and clean > 0


# --------------------------------------------------------------------------- #
# _source_counts
# --------------------------------------------------------------------------- #


def test_source_counts_basic() -> None:
    counts = estimator._source_counts(["avito", "cian", "avito", "avito", "cian"])
    assert counts == {"avito": 3, "cian": 2}


def test_source_counts_skips_none_and_empty() -> None:
    counts = estimator._source_counts(["avito", None, "", "cian", None])
    assert counts == {"avito": 1, "cian": 1}


def test_source_counts_empty_input_is_empty_dict() -> None:
    assert estimator._source_counts([]) == {}
    assert estimator._source_counts([None, None]) == {}


def test_source_counts_keys_sorted() -> None:
    # Deterministic ordering for stable JSON output.
    counts = estimator._source_counts(["yandex", "avito", "cian"])
    assert list(counts.keys()) == ["avito", "cian", "yandex"]


# --------------------------------------------------------------------------- #
# _canonical_sources — #2087 (M1) single source of truth for sources_used
# --------------------------------------------------------------------------- #


def test_canonical_sources_listing_plus_valuation() -> None:
    # Listing sources come from analogs; valuation flags are unioned on top.
    result = estimator._canonical_sources(
        ["avito", "cian", "avito"], {"avito_imv", "yandex_valuation"}
    )
    assert result == ["avito", "avito_imv", "cian", "yandex_valuation"]


def test_canonical_sources_deterministic_same_inputs_same_output() -> None:
    # Same set of analogs + flags → identical, sorted sources_used (POST == GET).
    analogs = ["yandex", "avito", "domklik", "avito"]
    flags = {"cian_valuation", "avito_imv"}
    a = estimator._canonical_sources(analogs, flags)
    b = estimator._canonical_sources(list(reversed(analogs)), set(flags))
    assert a == b
    assert a == ["avito", "avito_imv", "cian_valuation", "domklik", "yandex"]


def test_canonical_sources_skips_none_and_empty_analogs() -> None:
    result = estimator._canonical_sources(["avito", None, "", "cian"], set())
    assert result == ["avito", "cian"]


def test_canonical_sources_filters_non_canonical_valuation_flags() -> None:
    # Passing a raw persisted column (listing sorts + quarter_index) as the
    # valuation arg must NOT leak listing/service labels — only the 3 canonical
    # valuation sources survive. Listing part comes exclusively from analogs.
    raw_column = ["avito", "domklik", "quarter_index", "avito_imv", "yandex"]
    result = estimator._canonical_sources(["cian"], raw_column)
    # From analogs: cian. From column: only avito_imv is a canonical valuation.
    assert result == ["avito_imv", "cian"]
    assert "quarter_index" not in result
    assert "domklik" not in result  # listing label in the column is ignored


def test_canonical_source_counts_keys_subset_of_sources_used() -> None:
    # Invariant #2087: every key of source_counts (derived from the same analog
    # sources) is present in sources_used.
    analog_sources = ["avito", "cian", "avito", "yandex", None, ""]
    sources_used = estimator._canonical_sources(analog_sources, {"avito_imv"})
    source_counts = estimator._source_counts(analog_sources)
    assert set(source_counts.keys()) <= set(sources_used)
    # And every listing source present in analogs is reflected in sources_used.
    for s in analog_sources:
        if s:
            assert s in sources_used


def test_canonical_sources_empty_is_empty_list() -> None:
    assert estimator._canonical_sources([], []) == []
    assert estimator._canonical_sources([None, ""], ["quarter_index"]) == []


# --------------------------------------------------------------------------- #
# _price_from_inputs — cv surfaced on both paths
# --------------------------------------------------------------------------- #


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


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


def _anchor_comp(ppm2: float, area: float = 50.0, rooms: int = 2) -> dict:
    return {"price_per_m2": ppm2, "area_m2": area, "rooms": rooms}


def _call(
    *,
    listings: list[dict],
    anchor_comps: list[dict] | None = None,
    anchor_tier_fetched: str | None = None,
) -> estimator.PricingResult:
    return estimator._price_from_inputs(
        listings=listings,
        area_m2=50.0,
        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_comps or [],
        anchor_tier_fetched=anchor_tier_fetched,
        dkp_raw=None,
        imv_anchor=None,
        imv_eval=None,
        yandex_val_present=False,
        cian_val_present=False,
        ratio_resolver=lambda _appm2: (None, None),
        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_radius_path_surfaces_nonempty_cv() -> None:
    # 7 tight-but-varied radius lots → cv is a small positive float (non-None).
    ppm2s = [95_000, 98_000, 100_000, 102_000, 105_000, 103_000, 97_000]
    listings = [_lot(p, addr=f"ул. Тестовая, {i}") for i, p in enumerate(ppm2s)]
    pr = _call(listings=listings)

    assert pr.anchor_tier is None  # radius path
    assert pr.cv is not None
    assert pr.cv > 0
    # Matches the direct CV of the surviving (outlier-filtered) ppm² sample.
    survivors = [lot["price_per_m2"] for lot in pr.listings_clean]
    assert math.isclose(pr.cv, estimator._cv_from_ppm2(survivors), rel_tol=1e-9)


def test_empty_listings_cv_is_none() -> None:
    pr = _call(listings=[])
    assert pr.n_analogs == 0
    assert pr.cv is None


def test_anchor_path_surfaces_anchor_cv() -> None:
    # Anchor fires (Tier A) → cv reflects the anchor comps, not the radius lots.
    comps = [_anchor_comp(p) for p in (190_000, 200_000, 210_000, 205_000, 195_000)]
    pr = _call(
        listings=[_lot(100_000, addr=f"ул. Тестовая, {i}") for i in range(5)],
        anchor_comps=comps,
        anchor_tier_fetched="A",
    )
    assert pr.anchor_tier == "A"
    assert pr.cv is not None
    assert pr.cv > 0
    # cv computed on the anchor comps ppm², independent of the 100k radius lots.
    expected = estimator._cv_from_ppm2([c["price_per_m2"] for c in comps])
    assert math.isclose(pr.cv, expected, rel_tol=1e-9)


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