"""Synthetic round-trip test for the hermetic fixture replay (#1966 PR 3/3).

Proves the ``--dump-fixture`` / ``replay_fixture`` machinery end-to-end WITHOUT a
DB: a hand-crafted in-memory fixture (the exact JSON shape ``--dump-fixture``
writes) is replayed through the full pricing spine via ``bt.replay_fixture`` and
the resulting metrics dict is asserted for structure + determinism.

The deals are crafted so every call ``_price_from_inputs`` makes to the 3 injected
callables is recorded up-front, and so each headline ``median_ppm2`` is an exact,
predictable value (3 listings → the middle ₽/m²; no anchor / quarter-index / ДКП
mutation), which is what the recorded ``ratio_calls`` key must match.

NOTE: importing scripts.backtest_estimator → app.services.estimator →
app.core.config.Settings REQUIRES DATABASE_URL. Set a dummy value BEFORE importing
app modules (same pattern as tests/test_backtest_estimator.py:19-21). The dummy URL
is never connected to — replay_fixture opens NO session.
"""

import os

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

import gzip
import json
import tempfile
from pathlib import Path
from typing import Any

import pytest

from scripts import backtest_estimator as bt

# --------------------------------------------------------------------------- #
# Fixture builders — hand-crafted, fully predictable per-deal records.
# --------------------------------------------------------------------------- #


def _geo(address: str) -> dict[str, Any]:
    """A synthetic GeocodeResult dict (the asdict shape replay rebuilds)."""
    return {
        "lat": 56.84,
        "lon": 60.60,
        "full_address": address,
        "provider": "cache",
        "confidence": "exact",
    }


def _deal_record(
    *,
    deal_id: int,
    sold_ppm2: float,
    area_m2: float,
    rooms: int,
    listings: list[dict[str, Any]],
    anchor_comps: list[dict[str, Any]],
    anchor_tier_fetched: str | None,
    ratio_calls: list[Any],
    qi_calls: list[Any],
    qis_calls: list[Any],
    address: str,
) -> dict[str, Any]:
    """Assemble one fixture deal record in the exact ``--dump-fixture`` schema."""
    return {
        "deal_id": deal_id,
        "sold_ppm2": sold_ppm2,
        "area_m2": area_m2,
        "rooms": rooms,
        "deal_date": "2025-06-15",
        "kwargs": {
            "listings": listings,
            "area_m2": area_m2,
            "rooms": rooms,
            "repair_state": None,
            "floor": 3,
            "total_floors": 9,
            "target_year": 2010,
            "analog_tier": "W",
            "fallback_used": False,
            "area_widened": False,
            "anchor_comps": anchor_comps,
            "anchor_tier_fetched": anchor_tier_fetched,
            "dkp_raw": None,
            "imv_anchor": None,
            "imv_eval": None,
            "yandex_val_present": False,
            "cian_val_present": False,
            "target_house_cadnum": None,
            "dadata_coarse": False,
            "geo": _geo(address),
            "dadata_qc_geo": None,
        },
        "ratio_calls": ratio_calls,
        "qi_calls": qi_calls,
        "qis_calls": qis_calls,
    }


def _build_fixture() -> dict[str, Any]:
    """3 hand-crafted deals spanning эконом / бизнес / элит SOLD segments.

    deal 1: plain — no anchor, no quarter index → single ratio call.
    deal 2: a listing carries a cadastral number → one recorded quarter-index
            call (returns null, leaves the median untouched) + a ratio call.
    deal 3: carries non-empty anchor_comps (2 comps < min_comps=4 → anchor never
            fires, so the median stays the radius median) + a ratio call.
    """
    # ── deal 1 — median of [90k, 100k, 110k] = 100k → ratio_resolver(100000.0). ──
    deal1 = _deal_record(
        deal_id=1,
        sold_ppm2=100_000.0,  # SOLD эконом (< 120k)
        area_m2=50.0,
        rooms=1,
        listings=[
            {"price_per_m2": 90_000.0, "source": "avito"},
            {"price_per_m2": 100_000.0, "source": "avito"},
            {"price_per_m2": 110_000.0, "source": "avito"},
        ],
        anchor_comps=[],
        anchor_tier_fetched=None,
        ratio_calls=[[100_000.0, [0.95, "per_rooms_all"]]],
        qi_calls=[],
        qis_calls=[],
        address="ул. Тестовая, 1",
    )

    # ── deal 2 — median 150k; first lot's cadnum → quarter "66:41:0204016". ──
    deal2 = _deal_record(
        deal_id=2,
        sold_ppm2=200_000.0,  # SOLD бизнес (160k..220k)
        area_m2=60.0,
        rooms=2,
        listings=[
            {
                "price_per_m2": 140_000.0,
                "source": "cian",
                "building_cadastral_number": "66:41:0204016:350",
            },
            {"price_per_m2": 150_000.0, "source": "cian"},
            {"price_per_m2": 160_000.0, "source": "cian"},
        ],
        anchor_comps=[],
        anchor_tier_fetched=None,
        # quarter-index lookup returns null → spine leaves the median untouched.
        qi_calls=[["66:41:0204016", None]],
        qis_calls=[],
        ratio_calls=[[150_000.0, [0.90, "per_rooms_all"]]],
        address="ул. Тестовая, 2",
    )

    # ── deal 3 — median 310k; anchor_comps present but below min_comps → no fire. ──
    deal3 = _deal_record(
        deal_id=3,
        sold_ppm2=290_000.0,  # SOLD элит (220k..300k)
        area_m2=80.0,
        rooms=3,
        listings=[
            {"price_per_m2": 300_000.0, "source": "yandex"},
            {"price_per_m2": 310_000.0, "source": "yandex"},
            {"price_per_m2": 320_000.0, "source": "yandex"},
        ],
        anchor_comps=[
            {"price_per_m2": 305_000.0, "area_m2": 80.0, "rooms": 3, "floor": 5, "total_floors": 9},
            {"price_per_m2": 308_000.0, "area_m2": 82.0, "rooms": 3, "floor": 6, "total_floors": 9},
        ],
        anchor_tier_fetched=None,
        ratio_calls=[[310_000.0, [0.92, "per_rooms"]]],
        qi_calls=[],
        qis_calls=[],
        address="ул. Тестовая, 3",
    )

    deals = [deal1, deal2, deal3]
    return {
        "schema_version": bt.FIXTURE_SCHEMA_VERSION,
        "engine": "full",
        "since": "2025-06-01",
        "n_deals": len(deals),
        "settings_at_capture": {},
        "deals": deals,
    }


# --------------------------------------------------------------------------- #
# Tests
# --------------------------------------------------------------------------- #


def test_replay_fixture_structure_and_keys() -> None:
    fixture = _build_fixture()
    metrics = bt.replay_fixture(fixture)

    # Keeps the non-volatile metric blocks ...
    for key in (
        "expected_sold",
        "range_coverage",
        "calibration",
        "sharpness",
        "confidence_order",
        "headline",
    ):
        assert key in metrics, f"missing metric block: {key}"
    # ... and DROPS the volatile params block.
    assert "params" not in metrics

    # Every crafted deal resolves a ratio → an expected_sold row, so overall n
    # equals the number of deals in the fixture.
    assert metrics["expected_sold"]["overall"]["n"] == len(fixture["deals"])


def test_replay_fixture_is_deterministic() -> None:
    fixture = _build_fixture()
    first = bt.replay_fixture(fixture)
    second = bt.replay_fixture(fixture)
    # Byte-identical across two independent replays (no DB, no RNG, no caches).
    assert json.dumps(first, ensure_ascii=False, sort_keys=True) == json.dumps(
        second, ensure_ascii=False, sort_keys=True
    )


def test_replay_fixture_segments_span_multiple_bands() -> None:
    # The 3 deals sit in distinct SOLD price-segments (эконом / бизнес / элит),
    # so the per-segment expected_sold breakdown must show ≥ 3 non-empty bands.
    metrics = bt.replay_fixture(_build_fixture())
    per_segment = metrics["expected_sold"]["per_segment"]
    non_empty = [label for label, m in per_segment.items() if m["n"] > 0]
    assert len(non_empty) >= 3


def test_replay_is_arg_insensitive_order_based(monkeypatch: pytest.MonkeyPatch) -> None:
    # Order-based (FIFO) replay returns the recorded ratio REGARDLESS of the arg
    # value the spine actually computes — so a recorded arg that can never equal
    # the live median (999_999.0) still replays cleanly. This is the cross-platform
    # robustness contract: a Linux-captured fixture must replay off-Linux even when
    # libm last-ulp jitter shifts the computed median_ppm2 by an ulp.
    # #2002: this asserts the recorded ratio drives the result (bias -5%). Hold the
    # orthogonal hedonic correction OFF so expected_sold stays exactly headline × ratio.
    from app.core.config import settings

    monkeypatch.setattr(settings, "estimate_hedonic_correction_enabled", False)
    fixture = _build_fixture()
    fixture["deals"][0]["ratio_calls"] = [[999_999.0, [0.95, "per_rooms_all"]]]
    metrics = bt.replay_fixture(fixture)
    # Replay succeeded (no KeyError) and still priced every deal.
    assert metrics["expected_sold"]["overall"]["n"] == len(fixture["deals"])
    # The recorded ratio (0.95) was applied to deal 1 (median 100k → expected_sold
    # ppm² 95k), so its signed error vs SOLD 100k is -5% — proving the recorded
    # RETURN drove the result, not the (mismatched) arg.
    econom = metrics["expected_sold"]["per_segment"]["эконом"]
    assert econom["n"] == 1
    assert econom["median_bias_pct"] == pytest.approx(-5.0)


def test_replay_exhaustion_raises_runtime_error() -> None:
    # If a callable's recorded list is SHORTER than the spine's call count (here:
    # the spine calls ratio_resolver once but nothing is recorded), the exhaustion
    # guard must raise a clear RuntimeError — control flow diverged from capture,
    # NOT a silent wrong answer.
    fixture = _build_fixture()
    fixture["deals"][0]["ratio_calls"] = []  # spine will still request the ratio
    with pytest.raises(RuntimeError) as exc:
        bt.replay_fixture(fixture)
    assert "ratio_resolver" in str(exc.value)
    assert "diverged from capture" in str(exc.value)


def test_load_fixture_plain_and_gzip_roundtrip() -> None:
    # load_fixture is gzip-transparent: a .gz path is decompressed, a plain .json
    # path is read directly. Both must yield a replay-able fixture dict.
    fixture = _build_fixture()
    payload = json.dumps(fixture, ensure_ascii=False)
    with tempfile.TemporaryDirectory() as d:
        plain = Path(d) / "fx.json"
        plain.write_text(payload, encoding="utf-8")
        gz = Path(d) / "fx.json.gz"
        with gzip.open(gz, "wt", encoding="utf-8") as fh:
            fh.write(payload)

        loaded_plain = bt.load_fixture(str(plain))
        loaded_gz = bt.load_fixture(str(gz))
        assert loaded_plain == loaded_gz == fixture
        # Both load paths produce identical replay metrics.
        m_plain = bt.replay_fixture(loaded_plain)
        m_gz = bt.replay_fixture(loaded_gz)
        assert json.dumps(m_plain, sort_keys=True) == json.dumps(m_gz, sort_keys=True)
