"""Unit tests for cross-source physical dedup of analogs (#2087 H4).

Проверяет:
  - физический ключ (_phys_dedup_key) — что образует / не образует;
  - _dedup_cross_source: кросс-source дубли схлопываются в 1 представителя
    (свежайший scraped_at), не-дубли не трогаются, флаг OFF = байт-идентично;
  - выбор представителя детерминирован (freshest scraped_at).

No DB / network: помощники оперируют plain-dict'ами. DATABASE_URL нужен только
для импорта app.core.config.Settings (тот же паттерн, что в test_estimator_pure_units).
"""

import os

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

from datetime import UTC, datetime

import pytest

from app.services import estimator


def _lot(
    *,
    source: str,
    address: str = "ул. Ленина 5",
    area: float = 67.0,
    price: int = 21_995_000,
    floor: int | None = 4,
    scraped_at: datetime | None = None,
    cad: str | None = None,
    source_url: str | None = None,
    photo_urls: list[str] | None = None,
) -> dict:
    return {
        "source": source,
        "address": address,
        "area_m2": area,
        "price_rub": price,
        "price_per_m2": int(price / area) if area else 0,
        "floor": floor,
        "scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC),
        "building_cadastral_number": cad,
        "source_url": source_url,
        "photo_urls": photo_urls,
    }


# --------------------------------------------------------------------------- #
# _phys_dedup_key
# --------------------------------------------------------------------------- #


def test_phys_key_prefers_cadastral_over_address() -> None:
    lot = _lot(source="avito", address="ул. Ленина 5", cad="66:41:0601025:94")
    key = estimator._phys_dedup_key(lot)
    assert key is not None
    assert key[0] == "66:41:0601025:94"  # building = cad, не address


def test_phys_key_normalizes_address_when_no_cad() -> None:
    # Тот же адрес, разное форматирование (регистр/пунктуация/пробелы) —
    # punctuation-only нормализация схлопывает их к одному building-компоненту.
    a = estimator._phys_dedup_key(_lot(source="avito", address="Екатеринбург, ул. Ленина, д. 5"))
    b = estimator._phys_dedup_key(_lot(source="cian", address="екатеринбург  ул ленина д 5"))
    assert a is not None and b is not None
    assert a[0] == b[0]


def test_phys_key_different_streets_do_not_merge() -> None:
    # Токенное отличие (разная улица) НЕ должно сливаться — защита от ложного merge.
    a = estimator._phys_dedup_key(_lot(source="avito", address="ул. Ленина 5"))
    b = estimator._phys_dedup_key(_lot(source="cian", address="ул. Мира 5"))
    assert a is not None and b is not None
    assert a[0] != b[0]


def test_phys_key_none_without_area_or_price() -> None:
    assert estimator._phys_dedup_key(_lot(source="avito", area=0)) is None
    assert estimator._phys_dedup_key(_lot(source="avito", price=0)) is None


def test_phys_key_area_bucket_tolerates_rounding() -> None:
    # 66.9 и 67.0 → один и тот же area_bucket (round → 67)
    a = estimator._phys_dedup_key(_lot(source="avito", area=66.9))
    b = estimator._phys_dedup_key(_lot(source="cian", area=67.0))
    assert a is not None and b is not None
    assert a[2] == b[2] == 67


# --------------------------------------------------------------------------- #
# Флаг: default ON (#2173) vs явный OFF (byte-identical)
# --------------------------------------------------------------------------- #


def test_dedup_default_is_on() -> None:
    # #2173: default флага теперь ON (в проде дедуп активен без ENV-override).
    # Без monkeypatch settings отражает config-default → дедуп схлопывает
    # кросс-пост-тройку одного физлота. Явно НЕ монкипатчим — проверяем именно
    # поведение по умолчанию.
    assert estimator.settings.estimate_dedup_analogs_enabled is True
    lots = [
        _lot(source="avito"),
        _lot(source="cian"),
        _lot(source="domklik"),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 1  # дедуп активен по умолчанию


def test_dedup_flag_off_is_noop(monkeypatch: pytest.MonkeyPatch) -> None:
    # OFF-путь проверяем ЯВНЫМ override (не полагаясь на default, который теперь ON):
    # при флаге OFF проход байт-идентичен — возвращается тот же объект.
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
    lots = [
        _lot(source="avito"),
        _lot(source="cian"),
        _lot(source="domklik"),
    ]
    out = estimator._dedup_cross_source(lots)
    assert out is lots  # тот же объект → нулевое влияние на выборку


# --------------------------------------------------------------------------- #
# _dedup_cross_source — флаг ON
# --------------------------------------------------------------------------- #


def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    # Один физлот 67м²/21.995М на floor 4 — кросс-пост avito+cian+domklik.
    lots = [
        _lot(source="avito", scraped_at=datetime(2026, 6, 1, tzinfo=UTC)),
        _lot(source="cian", scraped_at=datetime(2026, 6, 3, tzinfo=UTC)),
        _lot(source="domklik", scraped_at=datetime(2026, 6, 2, tzinfo=UTC)),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 1
    # представитель = свежайший scraped_at (cian, 06-03)
    assert out[0]["source"] == "cian"


def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    # Разные физлоты: разный этаж / площадь / цена / здание → не дубли.
    lots = [
        _lot(source="avito", floor=4, area=67.0, price=21_995_000),
        _lot(source="cian", floor=7, area=67.0, price=21_995_000),  # другой этаж
        _lot(source="yandex", floor=4, area=80.0, price=21_200_000),  # другая площадь
        _lot(source="avito", floor=4, area=67.0, price=25_500_000),  # другая цена
        _lot(source="cian", address="ул. Мира 3", floor=4, area=67.0),  # другой дом
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 5  # ничего не схлопнулось


def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    # relevance-порядок из _fetch_analogs должен сохраниться: группа занимает
    # позицию первого появления.
    lots = [
        _lot(source="avito", address="ул. Мира 3", area=50.0, price=10_000_000),
        _lot(source="avito", address="ул. Ленина 5", area=67.0, price=21_995_000),
        _lot(source="cian", address="ул. Ленина 5", area=67.0, price=21_995_000),  # dup #2
        _lot(source="yandex", address="ул. Гагарина 9", area=90.0, price=30_000_000),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 3
    assert [o["address"] for o in out] == ["ул. Мира 3", "ул. Ленина 5", "ул. Гагарина 9"]


def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    # Нет площади → ключ None → не дедупим, даже если оба «пустые».
    lots = [
        _lot(source="avito", area=0),
        _lot(source="cian", area=0),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 2


# --------------------------------------------------------------------------- #
# #2265 — устойчивый адресный street-токен (кросс-source форматы)
# --------------------------------------------------------------------------- #


def test_extract_street_token_across_source_formats() -> None:
    # Три РЕАЛЬНЫХ прод-формата одного дома (ул. Сыромолотова) → один токен.
    cian = estimator._extract_street_token("р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11")
    domklik = estimator._extract_street_token("Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова")
    yandex = estimator._extract_street_token("Екатеринбург, улица Сыромолотова, 11")
    assert cian == domklik == yandex == "сыромолотова"


def test_extract_street_token_strips_house_and_noise() -> None:
    assert estimator._extract_street_token("Екатеринбург, ул. Ленина, д. 5") == "ленина"
    assert estimator._extract_street_token("проспект Космонавтов 15") == "космонавтов"
    assert estimator._extract_street_token("пер. Банковский, 8а") == "банковский"
    # Нет типа улицы → распознать нельзя → пусто (безопасный fallback, лот уникален).
    assert estimator._extract_street_token("Екатеринбург, ЖК Дружба") == ""
    assert estimator._extract_street_token("") == ""
    assert estimator._extract_street_token(None) == ""


def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -> None:
    # Прод-дефект (оценка d5fc3841): один физлот 33.6 м²/этаж 3/4.0М показан
    # как 3 аналога — cadnum пуст у всех троих, адреса в трёх форматах.
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(
            source="cian",
            address="р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11",
            area=33.6,
            price=4_000_000,
            floor=3,
            cad=None,
        ),
        _lot(
            source="domklik",
            address="Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова",
            area=33.6,
            price=4_000_000,
            floor=3,
            cad=None,
        ),
        _lot(
            source="yandex",
            address="Екатеринбург, улица Сыромолотова, 11",
            area=33.6,
            price=4_000_000,
            floor=3,
            cad=None,
        ),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 1  # один физлот, не три


def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPatch) -> None:
    # У одного cadnum есть, у другого нет, адрес совпадает → сливаются по
    # street-композиту (#2265: union по любому совпавшему ключу).
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(
            source="cian",
            address="улица Сыромолотова, 11",
            area=33.6,
            price=4_000_000,
            floor=3,
            cad="66:41:0601011:1234",
        ),
        _lot(
            source="domklik",
            address="ЖБИ м-н, улица Сыромолотова",
            area=33.6,
            price=4_000_000,
            floor=3,
            cad=None,
        ),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 1


def test_dedup_distinct_cadnums_same_metrics_do_not_merge(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Две башни на одной улице с одинаковыми floor/area/price, но РАЗНЫМИ
    # кадастрами — авторитетный сигнал разных зданий → НЕ сливаем.
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(
            source="cian",
            address="улица Сыромолотова, 11",
            area=33.6,
            price=4_000_000,
            floor=3,
            cad="66:41:0601011:1234",
        ),
        _lot(
            source="avito",
            address="улица Сыромолотова, 13",
            area=33.6,
            price=4_000_000,
            floor=3,
            cad="66:41:0601011:9999",
        ),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 2


def test_dedup_same_street_different_metrics_stay_distinct(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Разные лоты на одной улице с разной ценой/площадью/этажом → остаются
    # разными (защита от over-merge при отсутствии cadnum).
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
        _lot(source="avito", address="улица Сыромолотова, 11", area=45.0, price=6_500_000, floor=8),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 2


# --------------------------------------------------------------------------- #
# #2265 review follow-up — нумерованные улицы + house-no guard
# --------------------------------------------------------------------------- #


def test_parse_numbered_streets_and_house_no() -> None:
    # Нумерованная улица: ведущая цифра остаётся в токене, номер дома отдельно.
    assert estimator._parse_street_house("улица 8 Марта, 46") == ("8 марта", "46")
    assert estimator._parse_street_house("улица 40 лет Октября, 12") == ("40 лет октября", "12")
    assert estimator._parse_street_house("ул. 22 Партсъезда 7") == ("22 партсъезда", "7")
    # Обычная улица + номер дома: цифра ПОСЛЕ названия не попадает в токен.
    assert estimator._parse_street_house("ул. Белинского 86") == ("белинского", "86")
    assert estimator._parse_street_house("улица Сыромолотова, 11") == ("сыромолотова", "11")
    # Без номера дома → house пуст (cian/domklik часто так).
    assert estimator._parse_street_house("улица Сыромолотова") == ("сыромолотова", "")


def test_extract_street_token_numbered_not_degraded() -> None:
    # Ведущая цифра нумерованной улицы разрешена, номер дома — нет.
    assert estimator._extract_street_token("улица 8 Марта, 46") == "8 марта"
    assert estimator._extract_street_token("ул. Белинского 86") == "белинского"


def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.MonkeyPatch) -> None:
    # «8 Марта» в трёх source-форматах, cadnum пуст → один физлот.
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(
            source="cian",
            address="р-н Ленинский, улица 8 Марта, 46",
            area=52.0,
            price=7_800_000,
            floor=5,
            cad=None,
        ),
        _lot(
            source="domklik",
            address="Россия, Екатеринбург, улица 8 Марта, 46",
            area=52.0,
            price=7_800_000,
            floor=5,
            cad=None,
        ),
        _lot(
            source="yandex",
            address="Екатеринбург, улица 8 Марта, 46",
            area=52.0,
            price=7_800_000,
            floor=5,
            cad=None,
        ),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 1


def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatch) -> None:
    # Пустой cadnum, ОДНА улица/этаж/area/price, но РАЗНЫЕ извлечённые номера
    # дома → house-guard не даёт слить (both-present-different).
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
        _lot(source="avito", address="улица Сыромолотова, 13", area=33.6, price=4_000_000, floor=3),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 2


def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> None:
    # Устойчивость к порядку: домклик без номера сливается с одним из номеров,
    # два разных номера остаются раздельными независимо от позиции «пустого».
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    base = dict(area=33.6, price=4_000_000, floor=3, cad=None)
    lots = [
        _lot(source="domklik", address="улица Сыромолотова", **base),  # house ""
        _lot(source="yandex", address="улица Сыромолотова, 11", **base),  # house 11
        _lot(source="avito", address="улица Сыромолотова, 13", **base),  # house 13
    ]
    out = estimator._dedup_cross_source(lots)
    # 11 и 13 — разные дома → минимум 2 группы; «пустой» примыкает к одному из них.
    assert len(out) == 2


def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyPatch) -> None:
    # Анти-катастрофа: пустой cadnum И нераспознанный адрес (нет типа улицы) при
    # идентичных метриках → НЕ сливаем (нет ни одного ключа → каждый уникален).
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(source="cian", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
        _lot(source="avito", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 2


# --------------------------------------------------------------------------- #
# #2269 — расширенный парсер адреса (три source-формата, ронявшие ('',''))
# --------------------------------------------------------------------------- #


@pytest.mark.parametrize(
    ("addr", "expected"),
    [
        # --- контрольные (существующая ветка «тип ПЕРЕД именем») — БАЙТ-В-БАЙТ
        # тот же токен, что дают новые ветки, иначе клейка развалится.
        ("улица Рассветная, 6к1", ("рассветная", "")),  # cian-стиль, house не извлекается
        ("ул. Белинского 86", ("белинского", "86")),
        ("улица 8 Марта, 46", ("8 марта", "46")),  # нумерованная, ведущая цифра в токене
        # --- (а) имя ПЕРЕД типом (формат domklik)
        ("Рассветная улица, 6 к1", ("рассветная", "6")),
        ("Россия, Екатеринбург, Рассветная улица, 6", ("рассветная", "6")),
        ("Красных Командиров улица, 10", ("красных командиров", "10")),
        ("8 Марта улица, 46", ("8 марта", "46")),  # нумерованная, имя-перед-типом
        # --- (б) тип-суффикс приклеен запятой/точкой без пробела (avito)
        ("Рассветная ул.,6к1", ("рассветная", "")),
        ("Белинского ул., 86", ("белинского", "86")),
        # --- (в) fallback без уличного типа — микрорайон, префикс «mkr »
        ("Екатеринбург, Светлый микрорайон, 10", ("mkr светлый", "10")),
        ("микрорайон Светлый, 10", ("mkr светлый", "10")),
        ("мкр Пионерский, 5", ("mkr пионерский", "5")),
        ("м-н ЖБИ, 3", ("mkr жби", "3")),
        # --- всё ещё пусто, если ни улицы, ни микрорайона
        ("Екатеринбург, ЖК Дружба", ("", "")),
        ("улица, 5", ("", "")),  # тип без имени
    ],
)
def test_parse_street_house_extended_formats(addr: str, expected: tuple[str, str]) -> None:
    assert estimator._parse_street_house(addr) == expected


def test_parse_street_house_branch1_wins_over_mkr_noise() -> None:
    # #2265 регрессия: когда в адресе ЕСТЬ уличный тип, ветка-перед-именем берёт
    # улицу, а «мкр/м-н» остаётся шумом (НЕ префикс mkr) — токен = «сыромолотова».
    assert estimator._parse_street_house("р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11") == (
        "сыромолотова",
        "11",
    )
    assert estimator._parse_street_house("Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова") == (
        "сыромолотова",
        "",
    )


def test_extract_street_token_extended_same_token_across_formats() -> None:
    # Все три source-формата дома «Рассветная» → один и тот же токен → склейка.
    domklik = estimator._extract_street_token("Рассветная улица, 6 к1")
    avito = estimator._extract_street_token("Рассветная ул.,6к1")
    cian = estimator._extract_street_token("улица Рассветная, 6к1")
    assert domklik == avito == cian == "рассветная"


def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch) -> None:
    # Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1»
    # + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М),
    # cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен
    # «рассветная» → union-find по street-композиту схлопывает в один.
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(
            source="domklik",
            address="Рассветная улица, 6 к1",
            area=38.5,
            price=6_599_000,
            floor=13,
            cad=None,
            scraped_at=datetime(2026, 7, 1, tzinfo=UTC),
        ),
        _lot(
            source="cian",
            address="улица Рассветная, 6к1",
            area=38.5,
            price=6_599_000,
            floor=13,
            cad=None,
            scraped_at=datetime(2026, 7, 3, tzinfo=UTC),
        ),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 1  # две записи → одна
    assert out[0]["source"] == "cian"  # представитель — свежайший scraped_at


def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
    # (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная,
    # 6к1» → тот же токен «рассветная», house не извлекается ни у одного (guard
    # equal-or-missing) → склейка.
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(source="avito", address="Рассветная ул.,6к1", area=38.5, price=6_599_000, floor=13),
        _lot(source="cian", address="улица Рассветная, 6к1", area=38.5, price=6_599_000, floor=13),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 1


def test_dedup_mkr_fallback_does_not_cross_with_street(monkeypatch: pytest.MonkeyPatch) -> None:
    # Префикс «mkr » изолирует микрорайонный токен от одноимённого уличного:
    # «Светлый микрорайон» (mkr) и «улица Светлая» (street) — РАЗНЫЕ ключи, не
    # сливаются даже при совпадении метрик.
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(source="cian", address="Светлый микрорайон, 10", area=40.0, price=5_000_000, floor=6),
        _lot(source="avito", address="улица Светлая, 10", area=40.0, price=5_000_000, floor=6),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 2
