"""Unit tests for the read-only backtest harness (issue #648).

Covers the PURE aggregation / metric helpers, factored out of the DB code so
they're testable without a live database:
  - _compute_metrics   — signed/abs error %, median bias, MAPE, per-rooms split
  - _errors_summary    — bias / MAPE / p25 / p75 of a signed-error list
  - _bucketize_rooms / _rooms_label — 4+ collapse, студия labelling
  - _derive_room_ratios — per-rooms asking→sold ratio, global fallback, guards
  - _apply_ratios + corrected metrics — ratios that cancel a known bias → ~0

No DB / network / mocks: these operate on plain lists/tuples.

NOTE: importing scripts.backtest_estimator pulls app.services.estimator →
app.core.config.Settings, which REQUIRES DATABASE_URL. Set a dummy value
BEFORE importing app modules (same pattern as tests/test_estimator_pure_units.py
and tests/test_audit_address_mismatch.py).
"""

import os

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

import math

import pytest

from scripts import backtest_estimator as bt

# --------------------------------------------------------------------------- #
# _bucketize_rooms / _rooms_label
# --------------------------------------------------------------------------- #


def test_bucketize_studio_and_negative_clamp_to_zero() -> None:
    assert bt._bucketize_rooms(0) == 0
    assert bt._bucketize_rooms(-3) == 0


def test_bucketize_four_plus_collapses() -> None:
    assert bt._bucketize_rooms(4) == 4
    assert bt._bucketize_rooms(5) == 4
    assert bt._bucketize_rooms(9) == 4


def test_bucketize_passthrough_for_one_to_three() -> None:
    assert bt._bucketize_rooms(1) == 1
    assert bt._bucketize_rooms(2) == 2
    assert bt._bucketize_rooms(3) == 3


def test_rooms_label() -> None:
    assert bt._rooms_label(0) == "студия"
    assert bt._rooms_label(1) == "1к"
    assert bt._rooms_label(3) == "3к"
    assert bt._rooms_label(4) == "4+"
    assert bt._rooms_label(7) == "4+"


# --------------------------------------------------------------------------- #
# _errors_summary
# --------------------------------------------------------------------------- #


def test_errors_summary_empty_returns_all_none() -> None:
    s = bt._errors_summary([])
    assert s["n"] == 0
    assert s["median_bias_pct"] is None
    assert s["mape_pct"] is None
    assert s["p25_pct"] is None
    assert s["p75_pct"] is None


def test_errors_summary_uses_median_abs_for_mape_not_mean() -> None:
    # signed errors with an asymmetric outlier: median |err| (=10) differs
    # sharply from the MEAN |err| (=40). The brief defines MAPE as the MEDIAN
    # absolute error, so we assert the robust median is used.
    signed = [10.0, 10.0, 10.0, 130.0]
    s = bt._errors_summary(signed)
    assert s["mape_pct"] == 10.0  # median(|10,10,10,130|) = 10, not mean 40
    assert s["median_bias_pct"] == 10.0  # median([10,10,10,130]) = 10


def test_errors_summary_signed_bias_can_be_negative() -> None:
    # Under-prediction → negative bias.
    s = bt._errors_summary([-20.0, -10.0, -30.0])
    assert s["median_bias_pct"] == -20.0
    assert s["mape_pct"] == 20.0  # median of |[-20,-10,-30]| = median[10,20,30]


# --------------------------------------------------------------------------- #
# _compute_metrics — signed/abs error %, bias, MAPE, per-rooms
# --------------------------------------------------------------------------- #


def test_compute_metrics_empty_overall_is_none_per_rooms_all_present() -> None:
    m = bt._compute_metrics([])
    assert m["overall"]["n"] == 0
    assert m["overall"]["median_bias_pct"] is None
    assert m["overall"]["mape_pct"] is None
    assert m["overall"]["n_no_analogs"] == 0
    # Every room bucket must still appear (with n=0) so the report renders.
    assert set(m["per_rooms"].keys()) == set(bt.ROOM_BUCKETS)
    for bucket in bt.ROOM_BUCKETS:
        assert m["per_rooms"][bucket]["n"] == 0
        assert m["per_rooms"][bucket]["median_bias_pct"] is None
        assert m["per_rooms"][bucket]["label"] == bt._rooms_label(bucket)


def test_compute_metrics_known_plus_22_pct_overprediction() -> None:
    # The headline finding: asking median over-predicts SOLD by ~+22%.
    # pred = 1.22 * sold for every row → signed error must be exactly +22%,
    # MAPE +22%, p25 == p75 == +22% (no spread).
    rows = [
        (122_000.0, 100_000.0, 1),
        (244_000.0, 200_000.0, 2),
        (366_000.0, 300_000.0, 3),
    ]
    m = bt._compute_metrics(rows)
    assert m["overall"]["n"] == 3
    assert m["overall"]["median_bias_pct"] == pytest.approx(22.0)
    assert m["overall"]["mape_pct"] == pytest.approx(22.0)
    assert m["overall"]["p25_pct"] == pytest.approx(22.0)
    assert m["overall"]["p75_pct"] == pytest.approx(22.0)


def test_compute_metrics_signed_error_formula() -> None:
    # Single row, hand-computed: 100*(150k-120k)/120k = +25.0%.
    m = bt._compute_metrics([(150_000.0, 120_000.0, 2)])
    assert m["overall"]["median_bias_pct"] == pytest.approx(25.0)
    assert m["overall"]["mape_pct"] == pytest.approx(25.0)


def test_compute_metrics_abs_error_distinct_from_signed() -> None:
    # Mixed over/under: signed bias near 0 but MAPE (median |err|) is positive.
    # rows: +50%, -50%, +50%, -50% → median signed in {-50,+50} band,
    # median |err| = 50.
    rows = [
        (150_000.0, 100_000.0, 1),  # +50
        (50_000.0, 100_000.0, 1),  # -50
        (150_000.0, 100_000.0, 1),  # +50
        (50_000.0, 100_000.0, 1),  # -50
    ]
    m = bt._compute_metrics(rows)
    assert m["overall"]["mape_pct"] == pytest.approx(50.0)
    # signed median of [-50,-50,50,50] = 0.0 (mean of two middles)
    assert m["overall"]["median_bias_pct"] == pytest.approx(0.0)


def test_compute_metrics_per_rooms_split_and_four_plus_collapse() -> None:
    rows = [
        (110_000.0, 100_000.0, 0),  # студия: +10
        (130_000.0, 100_000.0, 0),  # студия: +30  → median bucket 0 = +20
        (90_000.0, 100_000.0, 2),  # 2к: -10
        (200_000.0, 100_000.0, 5),  # 4+ (5 collapses): +100
        (300_000.0, 100_000.0, 4),  # 4+ : +200    → median bucket 4 = +150
    ]
    m = bt._compute_metrics(rows)

    assert m["per_rooms"][0]["n"] == 2
    assert m["per_rooms"][0]["median_bias_pct"] == pytest.approx(20.0)
    assert m["per_rooms"][0]["label"] == "студия"

    assert m["per_rooms"][2]["n"] == 1
    assert m["per_rooms"][2]["median_bias_pct"] == pytest.approx(-10.0)

    # rooms=5 and rooms=4 both land in bucket 4.
    assert m["per_rooms"][4]["n"] == 2
    assert m["per_rooms"][4]["median_bias_pct"] == pytest.approx(150.0)
    assert m["per_rooms"][4]["label"] == "4+"

    # buckets 1 and 3 had no rows.
    assert m["per_rooms"][1]["n"] == 0
    assert m["per_rooms"][3]["n"] == 0

    # overall n counts every matched row.
    assert m["overall"]["n"] == 5


def test_compute_metrics_drops_nonpositive_sold() -> None:
    # sold_ppm2 <= 0 cannot be divided → row dropped, not counted, no crash.
    rows = [
        (120_000.0, 0.0, 1),  # dropped
        (120_000.0, -5.0, 2),  # dropped
        (122_000.0, 100_000.0, 1),  # kept → +22
    ]
    m = bt._compute_metrics(rows)
    assert m["overall"]["n"] == 1
    assert m["overall"]["median_bias_pct"] == pytest.approx(22.0)


def test_compute_metrics_carries_no_analog_counts() -> None:
    rows = [(122_000.0, 100_000.0, 1)]
    m = bt._compute_metrics(
        rows,
        n_no_analogs=7,
        per_rooms_no_analogs={1: 4, 2: 3},
    )
    assert m["overall"]["n_no_analogs"] == 7
    assert m["per_rooms"][1]["n_no_analogs"] == 4
    assert m["per_rooms"][2]["n_no_analogs"] == 3
    # bucket with no skipped deals defaults to 0.
    assert m["per_rooms"][0]["n_no_analogs"] == 0


# --------------------------------------------------------------------------- #
# _derive_room_ratios — per-rooms asking→sold ratio, global fallback, guards.
# --------------------------------------------------------------------------- #


def _rows_for_bucket(
    bucket: int, *, n: int, ask: float, sold: float
) -> list[tuple[float, float, int]]:
    """n identical (ask, sold, bucket) rows — keeps per-bucket median == ask/sold."""
    return [(ask, sold, bucket) for _ in range(n)]


def test_derive_ratios_per_bucket_exact() -> None:
    # Each bucket ≥ MIN_BUCKET deals so every bucket gets its OWN ratio.
    # bucket 1: sold/ask = 80k/100k = 0.80 ; bucket 2: 150k/200k = 0.75.
    rows = _rows_for_bucket(1, n=bt.MIN_BUCKET, ask=100_000.0, sold=80_000.0) + _rows_for_bucket(
        2, n=bt.MIN_BUCKET, ask=200_000.0, sold=150_000.0
    )
    ratios, meta = bt._derive_room_ratios(rows)
    assert ratios[1] == pytest.approx(0.80)
    assert ratios[2] == pytest.approx(0.75)
    assert meta["fallback_buckets"] == []  # both buckets were big enough
    assert meta["bucket_n"][1] == bt.MIN_BUCKET
    assert meta["bucket_n"][2] == bt.MIN_BUCKET


def test_derive_ratios_median_not_mean() -> None:
    # A bucket whose ask/sold pairs vary: ratio must use the MEDIAN of each
    # series, not a per-row mean. asks median = 100k, solds median = 90k → 0.9.
    rows = [
        (80_000.0, 60_000.0, 1),
        (100_000.0, 90_000.0, 1),  # median row
        (300_000.0, 200_000.0, 1),
        *_rows_for_bucket(1, n=bt.MIN_BUCKET, ask=100_000.0, sold=90_000.0),
    ]
    ratios, _ = bt._derive_room_ratios(rows)
    # median ask and median sold are both pinned to 100k/90k by the padding.
    assert ratios[1] == pytest.approx(0.90)


def test_derive_ratios_thin_bucket_uses_global_fallback() -> None:
    # bucket 1 has plenty (own ratio 0.80); bucket 2 has only 1 deal (< MIN) →
    # must inherit the GLOBAL ratio, and be flagged as a fallback bucket.
    rows = [
        *_rows_for_bucket(1, n=bt.MIN_BUCKET, ask=100_000.0, sold=80_000.0),
        (200_000.0, 120_000.0, 2),  # lone bucket-2 deal
    ]
    ratios, meta = bt._derive_room_ratios(rows)
    assert ratios[1] == pytest.approx(0.80)
    assert 2 in meta["fallback_buckets"]
    # global = median(all sold) / median(all ask). With MIN_BUCKET copies of
    # (100k/80k) plus one (200k/120k), both medians stay at the dense point.
    assert ratios[2] == pytest.approx(meta["global_ratio"])
    assert meta["global_ratio"] is not None


def test_derive_ratios_respects_custom_min_bucket() -> None:
    # With min_bucket=2, a 1-deal bucket falls back; a 2-deal bucket keeps own.
    rows = [
        (100_000.0, 50_000.0, 1),  # lone bucket-1 deal → fallback
        (100_000.0, 90_000.0, 2),
        (100_000.0, 90_000.0, 2),  # 2 deals → own ratio 0.9
    ]
    ratios, meta = bt._derive_room_ratios(rows, min_bucket=2)
    assert 1 in meta["fallback_buckets"]
    assert 2 not in meta["fallback_buckets"]
    assert ratios[2] == pytest.approx(0.90)


def test_derive_ratios_empty_returns_empty_and_safe_meta() -> None:
    ratios, meta = bt._derive_room_ratios([])
    assert ratios == {}
    assert meta["global_ratio"] is None
    assert meta["fallback_buckets"] == []
    assert all(n == 0 for n in meta["bucket_n"].values())


def test_derive_ratios_skips_nonpositive_and_guards_div_by_zero() -> None:
    # pred<=0 or sold<=0 rows are dropped; a bucket left with only bad rows
    # gets neither its own ratio NOR a (here non-existent) global one → omitted,
    # and the function does not raise ZeroDivisionError.
    rows = [
        (0.0, 100_000.0, 1),  # pred 0 → dropped (would div-by-zero)
        (100_000.0, 0.0, 1),  # sold 0 → dropped
        (-5.0, 100_000.0, 2),  # pred <0 → dropped
    ]
    ratios, meta = bt._derive_room_ratios(rows)
    assert ratios == {}  # nothing valid survived
    assert meta["global_ratio"] is None  # no valid pred → no global ratio


def test_derive_ratios_bucket_zero_global_pred_no_ratio() -> None:
    # If a bucket's own pred median is 0 (all-zero preds) it can't form a ratio;
    # with no global fallback either, it must be omitted, not crash.
    rows = [(0.0, 100_000.0, 1) for _ in range(bt.MIN_BUCKET)]
    ratios, meta = bt._derive_room_ratios(rows)
    assert 1 not in ratios
    assert meta["global_ratio"] is None


# --------------------------------------------------------------------------- #
# _apply_ratios + corrected metrics — ratios that cancel a known bias → ~0.
# --------------------------------------------------------------------------- #


def test_apply_ratios_multiplies_pred_by_bucket_ratio() -> None:
    rows = [(100_000.0, 90_000.0, 1), (200_000.0, 150_000.0, 2)]
    out = bt._apply_ratios(rows, {1: 0.9, 2: 0.75})
    assert out[0] == (pytest.approx(90_000.0), 90_000.0, 1)
    assert out[1] == (pytest.approx(150_000.0), 150_000.0, 2)


def test_apply_ratios_missing_bucket_leaves_pred_unchanged() -> None:
    # bucket 3 absent from the ratios map → identity multiplier (×1.0).
    rows = [(123_456.0, 100_000.0, 3)]
    out = bt._apply_ratios(rows, {1: 0.9})
    assert out[0][0] == pytest.approx(123_456.0)


def test_corrected_metrics_cancel_plus_30_pct_bias_to_zero() -> None:
    # Construct a uniform +30% asking bias (pred = 1.30 * sold) across buckets
    # with enough deals that each bucket forms its OWN ratio. Deriving the ratio
    # in-sample and re-applying it MUST collapse the corrected bias to ~0.
    rows: list[tuple[float, float, int]] = []
    for bucket, sold in ((0, 80_000.0), (1, 100_000.0), (2, 150_000.0)):
        rows += _rows_for_bucket(bucket, n=bt.MIN_BUCKET, ask=1.30 * sold, sold=sold)

    # sanity: the ASKING block really is +30%.
    asking = bt._compute_metrics(rows)
    assert asking["overall"]["median_bias_pct"] == pytest.approx(30.0)

    ratios, meta = bt._derive_room_ratios(rows)
    # every per-bucket ratio == 1/1.30 ≈ 0.7692.
    for bucket in (0, 1, 2):
        assert ratios[bucket] == pytest.approx(1.0 / 1.30, rel=1e-6)
    assert meta["fallback_buckets"] == []

    corrected = bt._compute_metrics(bt._apply_ratios(rows, ratios))
    assert corrected["overall"]["median_bias_pct"] == pytest.approx(0.0, abs=1e-6)
    assert corrected["overall"]["mape_pct"] == pytest.approx(0.0, abs=1e-6)
    for bucket in (0, 1, 2):
        assert corrected["per_rooms"][bucket]["median_bias_pct"] == pytest.approx(0.0, abs=1e-6)


def test_corrected_metrics_global_fallback_cancels_uniform_bias() -> None:
    # Even when buckets are too thin for their OWN ratio, the GLOBAL fallback
    # (uniform +30% here) still cancels the systematic bias to ~0.
    rows = [
        (130_000.0, 100_000.0, 1),  # +30
        (260_000.0, 200_000.0, 2),  # +30
        (104_000.0, 80_000.0, 0),  # +30
    ]
    ratios, meta = bt._derive_room_ratios(rows)  # all buckets < MIN_BUCKET
    assert set(meta["fallback_buckets"]) == {0, 1, 2}
    # meta["global_ratio"] is rounded to 4dp for the report; the APPLIED ratios
    # in `ratios` keep full precision, so the corrected bias still cancels to 0.
    assert meta["global_ratio"] == pytest.approx(0.7692, abs=5e-5)
    assert ratios[1] == pytest.approx(1.0 / 1.30, rel=1e-9)  # full precision applied
    corrected = bt._compute_metrics(bt._apply_ratios(rows, ratios))
    assert corrected["overall"]["median_bias_pct"] == pytest.approx(0.0, abs=1e-6)


# --------------------------------------------------------------------------- #
# Rendering smoke tests — table + empty render must not crash.
# --------------------------------------------------------------------------- #


def test_render_table_runs_on_real_metrics() -> None:
    m = bt._compute_metrics([(122_000.0, 100_000.0, 1)], n_no_analogs=2)
    headline = {
        "deal_median_ppm2": 100_000.0,
        "ask_median_ppm2": 122_000.0,
        "spread_pct": 22.0,
    }
    out = bt._render_table(m, headline)
    assert "BACKTEST" in out
    assert "OVERALL" in out
    assert "+22.0" in out  # bias rendered with sign
    assert "100 000" in out  # ppm2 formatted with space thousands separator


def test_render_table_handles_empty_sample() -> None:
    m = bt._compute_metrics([])
    headline = {"deal_median_ppm2": None, "ask_median_ppm2": None, "spread_pct": None}
    out = bt._render_table(m, headline)
    assert "n/a" in out  # None metrics render as n/a, no crash


def test_render_table_includes_corrected_block_and_in_sample_warning() -> None:
    # Build a metrics dict the way run_backtest does, with a corrected block.
    rows = [(130_000.0, 100_000.0, 1)] * bt.MIN_BUCKET
    m = bt._compute_metrics(rows)
    ratios, meta = bt._derive_room_ratios(rows)
    meta["holdout_split"] = False
    m["ratios"] = ratios
    m["ratios_meta"] = meta
    m["corrected"] = bt._compute_metrics(bt._apply_ratios(rows, ratios))
    headline = {
        "deal_median_ppm2": 100_000.0,
        "ask_median_ppm2": 130_000.0,
        "spread_pct": 30.0,
    }
    out = bt._render_table(m, headline)
    assert "ASKING" in out
    assert "CORRECTED" in out
    assert "ratio=" in out  # derived ratio line rendered
    assert "IN-SAMPLE" in out  # honesty caveat shown when not holdout


def test_render_table_corrected_block_holdout_message() -> None:
    rows = [(130_000.0, 100_000.0, 1)] * bt.MIN_BUCKET
    m = bt._compute_metrics(rows)
    ratios, meta = bt._derive_room_ratios(rows)
    meta["holdout_split"] = True
    m["ratios"] = ratios
    m["ratios_meta"] = meta
    m["corrected"] = bt._compute_metrics(bt._apply_ratios(rows, ratios))
    headline = {"deal_median_ppm2": None, "ask_median_ppm2": None, "spread_pct": None}
    out = bt._render_table(m, headline)
    assert "OUT-OF-SAMPLE" in out
    assert "IN-SAMPLE" not in out  # holdout path swaps the caveat


def test_render_table_no_corrected_block_when_absent() -> None:
    # Backward-compat: a metrics dict without "corrected" still renders (ASKING
    # only) and does not raise.
    m = bt._compute_metrics([(122_000.0, 100_000.0, 1)])
    headline = {
        "deal_median_ppm2": 100_000.0,
        "ask_median_ppm2": 122_000.0,
        "spread_pct": 22.0,
    }
    out = bt._render_table(m, headline)
    assert "ASKING" in out
    assert "CORRECTED" not in out


def test_fmt_helpers_handle_none_and_nan_safely() -> None:
    assert bt._fmt_pct(None) == "  n/a"
    assert bt._fmt_ppm2(None) == "n/a"
    # sanity: finite values format
    assert "+22" in bt._fmt_pct(22.0)
    assert not math.isnan(22.0)


# --------------------------------------------------------------------------- #
# argparse — defaults match the brief.
# --------------------------------------------------------------------------- #


def test_argparse_defaults() -> None:
    ns = bt._parse_args([])
    assert ns.sample == 300
    assert ns.since == "2025-06-01"
    assert ns.radius == 1000
    assert ns.rooms_tolerance == 0
    assert ns.json is False
    assert ns.holdout_split is False


def test_argparse_overrides() -> None:
    ns = bt._parse_args(
        [
            "--sample",
            "50",
            "--since",
            "2024-01-01",
            "--radius",
            "2000",
            "--rooms-tolerance",
            "1",
            "--holdout-split",
            "--json",
        ]
    )
    assert ns.sample == 50
    assert ns.since == "2024-01-01"
    assert ns.radius == 2000
    assert ns.rooms_tolerance == 1
    assert ns.holdout_split is True
    assert ns.json is True


# --------------------------------------------------------------------------- #
# #1966 full spine — --engine flag.
# --------------------------------------------------------------------------- #


def test_argparse_engine_defaults_to_full() -> None:
    assert bt._parse_args([]).engine == "full"


def test_argparse_engine_asking_core_override() -> None:
    assert bt._parse_args(["--engine", "asking-core"]).engine == "asking-core"
    assert bt._parse_args(["--engine", "full"]).engine == "full"


def test_argparse_engine_rejects_unknown() -> None:
    with pytest.raises(SystemExit):
        bt._parse_args(["--engine", "bogus"])


# --------------------------------------------------------------------------- #
# #2002 --resolve-house-id flag + its coverage accumulator.
# --------------------------------------------------------------------------- #


def test_argparse_resolve_house_id_defaults_off() -> None:
    # Default OFF keeps the harness byte-identical so the regression gate stands.
    assert bt._parse_args([]).resolve_house_id is False


def test_argparse_resolve_house_id_override() -> None:
    assert bt._parse_args(["--resolve-house-id"]).resolve_house_id is True


def test_house_id_resolution_as_json_shape() -> None:
    res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2)
    assert res.as_json() == {"resolved": 4, "total": 10, "imv_reachable": 2}


def test_house_id_resolution_defaults_zero() -> None:
    res = bt._HouseIdResolution()
    assert res.as_json() == {"resolved": 0, "total": 0, "imv_reachable": 0}


# --------------------------------------------------------------------------- #
# #1966 _bucketize_confidence / _segment_label — pure bucketing.
# --------------------------------------------------------------------------- #


def test_bucketize_confidence_canonical_passthrough() -> None:
    assert bt._bucketize_confidence("high") == "high"
    assert bt._bucketize_confidence("medium") == "medium"
    assert bt._bucketize_confidence("low") == "low"


def test_bucketize_confidence_unknown_maps_to_other() -> None:
    assert bt._bucketize_confidence("weird") == "other"
    assert bt._bucketize_confidence("") == "other"


def test_segment_label_bands_and_boundaries() -> None:
    # Boundaries are upper-exclusive: 120k falls into комфорт, not эконом.
    assert bt._segment_label(100_000) == "эконом"
    assert bt._segment_label(119_999) == "эконом"
    assert bt._segment_label(120_000) == "комфорт"
    assert bt._segment_label(150_000) == "комфорт"
    assert bt._segment_label(160_000) == "бизнес"
    assert bt._segment_label(219_999) == "бизнес"
    assert bt._segment_label(220_000) == "элит"
    assert bt._segment_label(300_000) == "премиум"
    assert bt._segment_label(2_000_000) == "премиум"  # +inf tail catches the top


# --------------------------------------------------------------------------- #
# #1966 _segment_metrics — per-price-segment signed error (band by SOLD ppm²).
# --------------------------------------------------------------------------- #


def test_segment_metrics_buckets_by_sold_price() -> None:
    rows = [
        (110_000.0, 100_000.0),  # sold эконом, +10
        (132_000.0, 110_000.0),  # sold эконом, +20  → median эконом bias +15
        (165_000.0, 150_000.0),  # sold комфорт, +10
        (330_000.0, 300_000.0),  # sold премиум, +10
    ]
    seg = bt._segment_metrics(rows)
    assert set(seg.keys()) == {label for label, _ in bt.PRICE_SEGMENTS_PPM2}
    assert seg["эконом"]["n"] == 2
    assert seg["эконом"]["median_bias_pct"] == pytest.approx(15.0)
    assert seg["комфорт"]["n"] == 1
    assert seg["комфорт"]["median_bias_pct"] == pytest.approx(10.0)
    assert seg["премиум"]["n"] == 1
    # bands with no rows are still present with n=0.
    assert seg["бизнес"]["n"] == 0
    assert seg["элит"]["n"] == 0


def test_segment_metrics_drops_nonpositive_sold() -> None:
    seg = bt._segment_metrics([(100_000.0, 0.0), (100_000.0, -1.0)])
    assert all(seg[label]["n"] == 0 for label, _ in bt.PRICE_SEGMENTS_PPM2)


# --------------------------------------------------------------------------- #
# #1966 _range_coverage — inside / outside / boundary inclusive.
# --------------------------------------------------------------------------- #


def test_range_coverage_inside_outside_and_boundary_inclusive() -> None:
    rows = [
        (100.0, 90.0, 110.0),  # inside
        (80.0, 90.0, 110.0),  # below low → outside
        (120.0, 90.0, 110.0),  # above high → outside
        (90.0, 90.0, 110.0),  # exactly on low → covered (inclusive)
        (110.0, 90.0, 110.0),  # exactly on high → covered (inclusive)
    ]
    cov = bt._range_coverage(rows)
    assert cov["n"] == 5
    assert cov["n_covered"] == 3
    assert cov["coverage_pct"] == pytest.approx(60.0)


def test_range_coverage_empty_returns_none_pct() -> None:
    cov = bt._range_coverage([])
    assert cov["n"] == 0
    assert cov["n_covered"] == 0
    assert cov["coverage_pct"] is None


def test_range_coverage_full_and_zero() -> None:
    assert bt._range_coverage([(100.0, 50.0, 150.0)])["coverage_pct"] == pytest.approx(100.0)
    assert bt._range_coverage([(10.0, 50.0, 150.0)])["coverage_pct"] == pytest.approx(0.0)


# --------------------------------------------------------------------------- #
# #1966 _sharpness — median relative range width (guards coverage gaming).
# --------------------------------------------------------------------------- #


def test_sharpness_median_relative_width() -> None:
    rows = [
        (100.0, 90.0, 110.0),  # width 20 / point 100 = 0.20
        (200.0, 150.0, 250.0),  # width 100 / point 200 = 0.50
    ]
    sh = bt._sharpness(rows)
    assert sh["n"] == 2
    assert sh["median_rel_width"] == pytest.approx(0.35)  # median(0.20, 0.50)


def test_sharpness_drops_nonpositive_point_and_empty() -> None:
    assert bt._sharpness([(0.0, 1.0, 2.0)])["median_rel_width"] is None
    assert bt._sharpness([(-5.0, 1.0, 2.0)])["n"] == 0
    assert bt._sharpness([])["median_rel_width"] is None


# --------------------------------------------------------------------------- #
# #1966 _calibration_metrics — per-confidence n / coverage% / MAPE%.
# --------------------------------------------------------------------------- #


def test_calibration_metrics_per_confidence_n_coverage_mape() -> None:
    rows = [
        ("high", 5.0, True),
        ("high", 15.0, True),  # high: n=2, covered 2/2=100%, mape median(5,15)=10
        ("low", 40.0, False),
        ("low", 60.0, True),  # low: n=2, covered 1/2=50%, mape median(40,60)=50
    ]
    cal = bt._calibration_metrics(rows)
    # canonical buckets always present.
    assert set(("high", "medium", "low")).issubset(cal.keys())
    assert cal["high"]["n"] == 2
    assert cal["high"]["coverage_pct"] == pytest.approx(100.0)
    assert cal["high"]["mape_pct"] == pytest.approx(10.0)
    assert cal["low"]["n"] == 2
    assert cal["low"]["coverage_pct"] == pytest.approx(50.0)
    assert cal["low"]["mape_pct"] == pytest.approx(50.0)
    # empty canonical bucket renders with n=0 / None metrics, not missing.
    assert cal["medium"]["n"] == 0
    assert cal["medium"]["coverage_pct"] is None
    assert cal["medium"]["mape_pct"] is None


def test_calibration_metrics_handles_none_signed_and_covered() -> None:
    # A prediction with no expected_sold (signed None) and no range (covered None)
    # still counts toward n but not toward coverage/MAPE.
    rows: list[tuple[str, float | None, bool | None]] = [
        ("high", None, None),
        ("high", 10.0, True),
    ]
    cal = bt._calibration_metrics(rows)
    assert cal["high"]["n"] == 2
    assert cal["high"]["coverage_pct"] == pytest.approx(100.0)  # only the 1 with covered
    assert cal["high"]["mape_pct"] == pytest.approx(10.0)  # only the 1 with signed


def test_calibration_metrics_appends_other_bucket() -> None:
    cal = bt._calibration_metrics([("exotic", 5.0, True)])
    assert "other" in cal
    assert cal["other"]["n"] == 1
    # canonical three still present even though empty.
    assert cal["high"]["n"] == 0


# --------------------------------------------------------------------------- #
# #1966 Prediction + _compute_full_metrics — integration of the new blocks.
# --------------------------------------------------------------------------- #


def _pred(
    *,
    rooms: int = 2,
    area: float = 50.0,
    sold_ppm2: float = 100_000.0,
    median_ppm2: float = 120_000.0,
    confidence: str = "high",
    es_ppm2: float | None = 100_000.0,
    es_price: float | None = 5_000_000.0,
    range_low: float | None = 4_500_000.0,
    range_high: float | None = 5_500_000.0,
    anchor_tier: str | None = None,
    deal_id: int = 1,
) -> bt.Prediction:
    return bt.Prediction(
        deal_id=deal_id,
        rooms=rooms,
        area_m2=area,
        sold_ppm2=sold_ppm2,
        median_ppm2=median_ppm2,
        confidence=confidence,
        anchor_tier=anchor_tier,
        expected_sold_ppm2=es_ppm2,
        expected_sold_price=es_price,
        range_low=range_low,
        range_high=range_high,
    )


def test_prediction_sold_total_property() -> None:
    p = _pred(sold_ppm2=100_000.0, area=50.0)
    assert p.sold_total == pytest.approx(5_000_000.0)


def test_compute_full_metrics_structure_and_blocks() -> None:
    preds = [
        # sold_total = 100k*50 = 5.0M, range [4.5M, 5.5M] → covered; es +0%
        _pred(deal_id=1, confidence="high", es_ppm2=100_000.0, sold_ppm2=100_000.0),
        # sold_total = 200k*50 = 10.0M, range [4.5M,5.5M] → NOT covered; es -50%
        _pred(
            deal_id=3,
            confidence="low",
            es_ppm2=100_000.0,
            sold_ppm2=200_000.0,
            median_ppm2=120_000.0,
        ),
    ]
    m = bt._compute_full_metrics(preds, n_no_prediction=4, per_rooms_no_prediction={2: 4})

    # expected_sold block: overall + per_rooms + per_segment, carries no-pred count.
    assert m["expected_sold"]["overall"]["n"] == 2
    assert m["expected_sold"]["overall"]["n_no_analogs"] == 4  # repurposed = no_pred
    assert "per_segment" in m["expected_sold"]
    assert set(m["expected_sold"]["per_segment"].keys()) == {
        label for label, _ in bt.PRICE_SEGMENTS_PPM2
    }

    # range coverage: 1 of 2 inside → 50% overall.
    assert m["range_coverage"]["overall"]["n"] == 2
    assert m["range_coverage"]["overall"]["n_covered"] == 1
    assert m["range_coverage"]["overall"]["coverage_pct"] == pytest.approx(50.0)
    # per-confidence: high covered 100%, low covered 0%.
    assert m["range_coverage"]["per_confidence"]["high"]["coverage_pct"] == pytest.approx(100.0)
    assert m["range_coverage"]["per_confidence"]["low"]["coverage_pct"] == pytest.approx(0.0)

    # calibration: high tighter/accurate, low not.
    assert m["calibration"]["high"]["n"] == 1
    assert m["calibration"]["high"]["coverage_pct"] == pytest.approx(100.0)
    assert m["calibration"]["high"]["mape_pct"] == pytest.approx(0.0)
    assert m["calibration"]["low"]["coverage_pct"] == pytest.approx(0.0)
    assert m["calibration"]["low"]["mape_pct"] == pytest.approx(50.0)

    # sharpness present.
    assert m["sharpness"]["n"] == 2
    assert m["sharpness"]["median_rel_width"] is not None

    # confidence order: canonical three first.
    assert m["confidence_order"][:3] == ["high", "medium", "low"]


def test_compute_full_metrics_empty_is_safe() -> None:
    m = bt._compute_full_metrics([])
    assert m["expected_sold"]["overall"]["n"] == 0
    assert m["range_coverage"]["overall"]["coverage_pct"] is None
    assert m["calibration"]["high"]["n"] == 0
    assert m["sharpness"]["median_rel_width"] is None


def test_compute_full_metrics_no_expected_sold_counts_in_calibration_only() -> None:
    # A priced deal with no expected_sold (ratio unresolved) and no range:
    # counts in calibration n but contributes nothing to expected_sold / coverage.
    preds = [
        _pred(
            deal_id=1,
            confidence="medium",
            es_ppm2=None,
            es_price=None,
            range_low=None,
            range_high=None,
        )
    ]
    m = bt._compute_full_metrics(preds)
    assert m["expected_sold"]["overall"]["n"] == 0  # no es row
    assert m["range_coverage"]["overall"]["n"] == 0  # no range row
    assert m["calibration"]["medium"]["n"] == 1  # still counted
    assert m["calibration"]["medium"]["coverage_pct"] is None


# --------------------------------------------------------------------------- #
# #1966 _render_full_table — smoke (must not crash, renders all blocks).
# --------------------------------------------------------------------------- #


def test_render_full_table_runs_on_real_metrics() -> None:
    preds = [
        _pred(deal_id=1, confidence="high", es_ppm2=100_000.0, sold_ppm2=100_000.0),
        _pred(deal_id=3, confidence="low", es_ppm2=100_000.0, sold_ppm2=200_000.0),
    ]
    m = bt._compute_full_metrics(preds, n_no_prediction=1, per_rooms_no_prediction={2: 1})
    m["headline"] = {
        "deal_median_ppm2": 100_000.0,
        "ask_median_ppm2": 120_000.0,
        "spread_pct": 20.0,
    }
    out = bt._render_full_table(m)
    assert "full spine" in out
    assert "EXPECTED_SOLD" in out
    assert "RANGE COVERAGE" in out
    assert "CONFIDENCE CALIBRATION" in out
    assert "SHARPNESS" in out
    assert "per price-segment" in out
    assert "эконом" in out  # segment band rendered
    assert "regression baseline" in out  # caveat present


def test_render_full_table_handles_empty_sample() -> None:
    m = bt._compute_full_metrics([])
    m["headline"] = {"deal_median_ppm2": None, "ask_median_ppm2": None, "spread_pct": None}
    out = bt._render_full_table(m)
    assert "n/a" in out  # None metrics render as n/a, no crash
    assert "BACKTEST" in out


# --------------------------------------------------------------------------- #
# Oblast D — `--city` deal-sample scoping (parse + SQL predicate + PPM2 band).
# --------------------------------------------------------------------------- #


def test_argparse_city_defaults_none() -> None:
    assert bt._parse_args([]).city is None


def test_argparse_city_override() -> None:
    ns = bt._parse_args(["--city", "Нижний Тагил"])
    assert ns.city == "Нижний Тагил"


def test_sample_sql_default_is_unscoped_and_same_object() -> None:
    # city=None must return the SAME _SAMPLE_SQL object used before oblast D —
    # literal identity, not just equal text — so the default CLI invocation (and
    # the frozen EKB regression gate, which never calls this path at all) see a
    # byte-identical query.
    assert bt._sample_sql(None) is bt._SAMPLE_SQL


def test_sample_sql_default_has_no_city_predicate() -> None:
    assert ":city" not in bt._SAMPLE_SQL.text


def test_sample_sql_city_scoped_adds_city_predicate() -> None:
    sql = bt._sample_sql("Нижний Тагил")
    assert sql is not bt._SAMPLE_SQL
    built = sql.text
    assert "AND city = CAST(:city AS text)" in built
    # City-scoping is additive — the base predicates are still present.
    assert "source = 'rosreestr'" in built
    assert "deal_date >= CAST(:since AS date)" in built
    assert "ORDER BY id DESC" in built


# --------------------------------------------------------------------------- #
# Oblast D — _resolve_city_ppm2_band (per-city PPM2 sanity band, no live DB).
# --------------------------------------------------------------------------- #


class _FakeBandResult:
    """Minimal stand-in for a SQLAlchemy Result exposing .mappings().first()."""

    def __init__(self, row: dict[str, object] | None) -> None:
        self._row = row

    def mappings(self) -> "_FakeBandResult":
        return self

    def first(self) -> dict[str, object] | None:
        return self._row


class _FakeBandSession:
    """Minimal stand-in for a Session — only .execute() is exercised here."""

    def __init__(self, row: dict[str, object] | None = None, *, raise_exc: bool = False) -> None:
        self._row = row
        self._raise = raise_exc

    def execute(self, *_args: object, **_kwargs: object) -> _FakeBandResult:
        if self._raise:
            raise RuntimeError("boom")
        return _FakeBandResult(self._row)


def test_resolve_city_ppm2_band_none_city_returns_globals_no_query() -> None:
    # city=None must not even touch the DB (no _FakeBandSession.execute call).
    class _NoExecuteSession:
        def execute(self, *_a: object, **_kw: object) -> None:
            raise AssertionError("must not query DB when city is None")

    assert bt._resolve_city_ppm2_band(_NoExecuteSession(), None) == (
        float(bt.PPM2_MIN),
        float(bt.PPM2_MAX),
    )


def test_resolve_city_ppm2_band_found_row() -> None:
    db = _FakeBandSession({"ppm2_min": 16955, "ppm2_max": 108175})
    assert bt._resolve_city_ppm2_band(db, "Нижний Тагил") == (16955.0, 108175.0)


def test_resolve_city_ppm2_band_no_row_falls_back_to_globals() -> None:
    db = _FakeBandSession(None)
    assert bt._resolve_city_ppm2_band(db, "Екатеринбург") == (
        float(bt.PPM2_MIN),
        float(bt.PPM2_MAX),
    )


def test_resolve_city_ppm2_band_db_error_falls_back_to_globals() -> None:
    db = _FakeBandSession(raise_exc=True)
    assert bt._resolve_city_ppm2_band(db, "Нижний Тагил") == (
        float(bt.PPM2_MIN),
        float(bt.PPM2_MAX),
    )


# --------------------------------------------------------------------------- #
# Oblast C2 parity — _predict_full_spine must resolve + pass a target city to
# _fetch_dkp_corridor, mirroring estimate_quality (estimator.py:3210-3220).
# --------------------------------------------------------------------------- #


def test_predict_full_spine_passes_resolved_city_to_corridor(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    from types import SimpleNamespace

    from app.services import estimator as est_mod

    captured: dict[str, object] = {}

    def _fake_resolve_target_city(address: str | None) -> str | None:
        return "нижний тагил"

    def _fake_fetch_dkp_corridor(
        _db: object,
        *,
        address: object,
        rooms: object,
        area: object,
        city: object = None,
        **_kw: object,
    ) -> None:
        captured["called"] = True
        captured["city"] = city
        return None

    def _fake_price_from_inputs(**_kwargs: object) -> SimpleNamespace:
        return SimpleNamespace(
            median_price=100_000.0,
            median_ppm2=100_000.0,
            confidence="low",
            anchor_tier=None,
            expected_sold_per_m2=95_000.0,
            expected_sold_price=4_750_000.0,
            expected_sold_range_low=4_000_000.0,
            expected_sold_range_high=5_500_000.0,
        )

    monkeypatch.setattr(est_mod, "_resolve_target_city", _fake_resolve_target_city)
    monkeypatch.setattr(est_mod, "_fetch_dkp_corridor", _fake_fetch_dkp_corridor)
    monkeypatch.setattr(est_mod, "_fetch_anchor_comps", lambda *a, **kw: ([], None))
    monkeypatch.setattr(est_mod, "_fetch_house_imv_anchor", lambda *a, **kw: None)
    monkeypatch.setattr(est_mod, "_price_from_inputs", _fake_price_from_inputs)
    monkeypatch.setattr(bt, "_select_analogs_full", lambda *a, **kw: ([], "W", False, False))

    est = bt._import_estimator_full()
    deal = bt.DealSample(
        id=1,
        lon=60.6,
        lat=58.05,
        rooms=2,
        sold_ppm2=90_000.0,
        deal_date=None,
        area_m2=50.0,
        address="Нижний Тагил, ул. Ленина, 5",
        floor=3,
        total_floors=9,
        year_built=2000,
        house_type="панель",
    )

    pred = bt._predict_full_spine(None, deal, est)

    assert captured.get("called") is True
    assert captured.get("city") == "нижний тагил"
    assert pred is not None
