"""#1871 P2 — radius-tier (source, source_id) dedup в _fetch_analogs.

Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но
(source, source_id)-дубли делят один address и выживают на разных rn_addr рангах →
раздувают n_analogs. Anchor-путь дедупит по (source, source_id); radius — нет.

Эти тесты проверяют SQL-фрагмент (rn_dup-окно + `AND rn_dup = 1` фильтр) во ВСЕХ
четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику
окна на уровне Python-симуляции (freshest scraped_at, namespaced ключ, NULL-guard).
Полный radius-путь требует PostGIS+БД, поэтому SQL проверяется на сгенерированном
тексте (как test_estimator_price_trend_dedup.py).
"""

from __future__ import annotations

import os
from unittest.mock import MagicMock

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

import pytest

import app.services.estimator as est


def _capture_tier_sql() -> list[str]:
    """Прогоняет _fetch_analogs с mock db, возвращает SQL-текст каждого тира.

    target_house_id + short_addr + year/floors заданы так, чтобы SQL ВСЕХ четырёх
    тиров отрендерился (каждый тир возвращает [] → fallthrough к следующему).
    """
    captured: list[str] = []
    db = MagicMock()

    def side_effect(*args, **kwargs):  # type: ignore[no-untyped-def]
        captured.append(str(args[0].text))
        result = MagicMock()
        result.mappings.return_value.all.return_value = []
        return result

    db.execute.side_effect = side_effect

    est._fetch_analogs(
        db,
        lat=56.83,
        lon=60.6,
        rooms=2,
        area=50.0,
        radius_m=2000,
        full_address="г Екатеринбург, ул Малышева, д 30",
        year_built=2010,
        house_type="монолит",
        total_floors=20,
        target_house_id=123,
    )
    return captured


# ---------------------------------------------------------------------------
# SQL-фрагмент во всех тирах
# ---------------------------------------------------------------------------


def test_all_four_tiers_render() -> None:
    sqls = _capture_tier_sql()
    assert len(sqls) == 4, "ожидаем S-canonical, S-fallback, H, W"


def test_rn_dup_window_present_in_every_tier() -> None:
    sqls = _capture_tier_sql()
    for i, sql in enumerate(sqls):
        assert "AS rn_dup" in sql, f"tier#{i} без rn_dup-окна"
        assert "rn_dup = 1" in sql, f"tier#{i} без `AND rn_dup = 1` фильтра"


def test_rn_dup_key_namespaced_and_freshest() -> None:
    """Ключ окна namespaced (id:/url:/ctid:) + ORDER BY scraped_at DESC."""
    sql = _capture_tier_sql()[0]
    assert "PARTITION BY source" in sql
    assert "'id:' || source_id::text" in sql
    assert "'url:' || source_url" in sql
    assert "'ctid:' || ctid::text" in sql
    # freshest: окно сортируется по scraped_at DESC → rn_dup=1 это свежайшая строка
    win_start = sql.find("AS rn_dup")
    win_region = sql[max(0, win_start - 400) : win_start]
    assert "ORDER BY scraped_at DESC" in win_region


# ---------------------------------------------------------------------------
# psycopg3-инвариант: column::type OK, :bind::type запрещён
# ---------------------------------------------------------------------------


def test_no_bind_param_double_colon_cast() -> None:
    """В сгенерированном SQL нет :name::type (только column::type, что легально)."""
    import re

    bad = re.compile(r":[a-z_]+::[a-z]")
    for i, sql in enumerate(_capture_tier_sql()):
        assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type"


# ---------------------------------------------------------------------------
# Семантика дедупа окна (Python-симуляция row_number() OVER PARTITION/ORDER)
# ---------------------------------------------------------------------------


def _simulate_rn_dup(rows: list[dict]) -> list[dict]:
    """Эмулирует SQL-окно rn_dup=1: per (source, key) freshest scraped_at.

    Зеркалит SQL: ключ = source + namespaced(id:source_id | url:source_url |
    ctid:ctid). NULL source_id+source_url → разные ctid → разные группы (обе живут).
    """
    groups: dict[tuple, dict] = {}
    for r in rows:
        if r.get("source_id") is not None:
            key = (r["source"], "id", r["source_id"])
        elif r.get("source_url") is not None:
            key = (r["source"], "url", r["source_url"])
        else:
            key = (r["source"], "ctid", r["ctid"])
        cur = groups.get(key)
        if cur is None or r["scraped_at"] > cur["scraped_at"]:
            groups[key] = r
    return list(groups.values())


def test_same_source_source_id_collapses_to_freshest() -> None:
    rows = [
        {
            "source": "yandex",
            "source_id": "A1",
            "source_url": None,
            "ctid": "(0,1)",
            "scraped_at": 100,
            "price_per_m2": 150_000,
        },
        {
            "source": "yandex",
            "source_id": "A1",
            "source_url": None,
            "ctid": "(0,2)",
            "scraped_at": 200,
            "price_per_m2": 151_000,
        },
    ]
    out = _simulate_rn_dup(rows)
    assert len(out) == 1
    assert out[0]["scraped_at"] == 200, "должна выжить freshest (scraped_at=200)"


def test_different_source_id_both_survive() -> None:
    rows = [
        {
            "source": "cian",
            "source_id": "X",
            "source_url": None,
            "ctid": "(0,1)",
            "scraped_at": 100,
            "price_per_m2": 140_000,
        },
        {
            "source": "cian",
            "source_id": "Y",
            "source_url": None,
            "ctid": "(0,2)",
            "scraped_at": 100,
            "price_per_m2": 145_000,
        },
    ]
    out = _simulate_rn_dup(rows)
    assert len(out) == 2


def test_same_source_id_different_source_both_survive() -> None:
    """(source, source_id) — PARTITION включает source: одинаковый id у разных
    источников НЕ схлопывается."""
    rows = [
        {
            "source": "cian",
            "source_id": "Z",
            "source_url": None,
            "ctid": "(0,1)",
            "scraped_at": 100,
            "price_per_m2": 140_000,
        },
        {
            "source": "yandex",
            "source_id": "Z",
            "source_url": None,
            "ctid": "(0,2)",
            "scraped_at": 100,
            "price_per_m2": 145_000,
        },
    ]
    out = _simulate_rn_dup(rows)
    assert len(out) == 2


def test_null_source_id_null_url_both_survive_via_ctid() -> None:
    """CASE-guard: 2 строки с NULL source_id И NULL source_url → ctid-tiebreak →
    обе сохранены (не схлопываются по NULL)."""
    rows = [
        {
            "source": "rosreestr",
            "source_id": None,
            "source_url": None,
            "ctid": "(0,1)",
            "scraped_at": 100,
            "price_per_m2": 130_000,
        },
        {
            "source": "rosreestr",
            "source_id": None,
            "source_url": None,
            "ctid": "(0,2)",
            "scraped_at": 100,
            "price_per_m2": 132_000,
        },
    ]
    out = _simulate_rn_dup(rows)
    assert len(out) == 2


def test_null_source_id_falls_back_to_source_url() -> None:
    """source_id NULL, но одинаковый source_url → схлопываются (url-ключ)."""
    rows = [
        {
            "source": "avito",
            "source_id": None,
            "source_url": "http://a/1",
            "ctid": "(0,1)",
            "scraped_at": 100,
            "price_per_m2": 120_000,
        },
        {
            "source": "avito",
            "source_id": None,
            "source_url": "http://a/1",
            "ctid": "(0,2)",
            "scraped_at": 300,
            "price_per_m2": 121_000,
        },
    ]
    out = _simulate_rn_dup(rows)
    assert len(out) == 1
    assert out[0]["scraped_at"] == 300


# ---------------------------------------------------------------------------
# n_analogs отражает дедупнутое число (Python-симуляция)
# ---------------------------------------------------------------------------


def test_n_analogs_reflects_deduped_count() -> None:
    """3 строки одного объекта (source_id) схлопываются в 1 → n_analogs=1.

    Прямое требование спека: «n_analogs отражает дедупнутое число» — без дедупа
    3 дубля раздули бы счётчик, с дедупом выживает 1 (freshest).
    Отдельные объекты (разные source_id) не схлопываются → n_analogs=3.
    """
    # 3 дубля одного объекта (один source_id, разные scraped_at)
    dupes = [
        {
            "source": "yandex",
            "source_id": "Y1",
            "source_url": None,
            "ctid": "(0,1)",
            "scraped_at": 100,
            "price_per_m2": 150_000,
        },
        {
            "source": "yandex",
            "source_id": "Y1",
            "source_url": None,
            "ctid": "(0,2)",
            "scraped_at": 200,
            "price_per_m2": 151_000,
        },
        {
            "source": "yandex",
            "source_id": "Y1",
            "source_url": None,
            "ctid": "(0,3)",
            "scraped_at": 300,
            "price_per_m2": 152_000,
        },
    ]
    out = _simulate_rn_dup(dupes)
    n_analogs = len(out)
    assert n_analogs == 1, "3 дубля → должен выжить 1"
    assert out[0]["scraped_at"] == 300, "выживает freshest"
    assert out[0]["price_per_m2"] == 152_000

    # 3 разных объекта — не схлопываются
    distinct = [
        {
            "source": "yandex",
            "source_id": "Y1",
            "source_url": None,
            "ctid": "(0,1)",
            "scraped_at": 100,
            "price_per_m2": 150_000,
        },
        {
            "source": "cian",
            "source_id": "C1",
            "source_url": None,
            "ctid": "(0,2)",
            "scraped_at": 100,
            "price_per_m2": 145_000,
        },
        {
            "source": "avito",
            "source_id": None,
            "source_url": "http://a/1",
            "ctid": "(0,3)",
            "scraped_at": 100,
            "price_per_m2": 155_000,
        },
    ]
    out_d = _simulate_rn_dup(distinct)
    assert len(out_d) == 3, "3 разных объекта → все 3 живут"


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