"""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"),
    [
        # --- контрольные (существующая ветка «тип ПЕРЕД именем») — БАЙТ-В-БАЙТ
        # тот же токен, что дают новые ветки, иначе клейка развалится.
        # #2423 (частично устарело после #2436): «6к1» (cian-стиль, литера
        # корпуса приклеена без пробела к цифре) даёт house — тот же canonical
        # «6/1», что и у пробельного «6 к1» ниже (домклик-стиль). #2423 сам по
        # себе лишь остановил ложный захват буквы (`(?!\w)`), но давал
        # усечённое «6» — #2436 добавил `_normalize_house_no`, восстанавливающий
        # отброшенную цифру корпуса и канонизирующий её к слэш-форме.
        ("улица Рассветная, 6к1", ("рассветная", "6/1")),
        ("ул. Белинского 86", ("белинского", "86")),
        ("улица 8 Марта, 46", ("8 марта", "46")),  # нумерованная, ведущая цифра в токене
        # --- (а) имя ПЕРЕД типом (формат domklik)
        ("Рассветная улица, 6 к1", ("рассветная", "6/1")),
        ("Россия, Екатеринбург, Рассветная улица, 6", ("рассветная", "6")),
        ("Красных Командиров улица, 10", ("красных командиров", "10")),
        ("8 Марта улица, 46", ("8 марта", "46")),  # нумерованная, имя-перед-типом
        # --- (б) тип-суффикс приклеен запятой/точкой без пробела (avito)
        # #2436: канонизируется к «6/1» — тот же токен, что у веток (а)/контрольной.
        ("Рассветная ул.,6к1", ("рассветная", "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-композиту схлопывает в один. Оба
    # адреса используют одну и ту же глухую («к1») family корпуса, и после
    # #2436 нормализуются к одинаковой «6/1» — house-guard видит равные номера
    # независимо от пробела перед «к» (см. _normalize_house_no).
    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_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPatch) -> None:
    # #2291 класс 3: тот же физлот «Рассветная 6к1», но area округляется в
    # СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному),
    # 39.0 → 39. До фикса разные area_bucket → street-owner lookup никогда не
    # находил друг друга → лот считался дважды. ±1-bucket допуск на street-union
    # должен склеить их в один физлот. Оба адреса — глухая («к1») family
    # корпуса → после #2436 нормализуются к одинаковой «6/1» (см.
    # _normalize_house_no), house-guard не блокирует merge независимо от пробела.
    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=39.0,
            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  # склеились несмотря на area straddle 38.5/39.0


def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
    # (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная,
    # 6к1» → тот же токен «рассветная», house у обоих канонизируется в «6/1»
    # (см. #2436 _normalize_house_no) → guard видит равные номера → склейка.
    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


# --------------------------------------------------------------------------- #
# #2291 — bare-street ветка (класс 1) + ё/е нормализация (класс 2)
# --------------------------------------------------------------------------- #


def test_parse_street_house_bare_street_before_house_no() -> None:
    # Класс 1 (#2291): DomClick-формат «<район без типа>, <улица без типа>,
    # <номер>» — «Заречный м-н» (район) БЕЗ уличного типа перед реальной
    # улицей «Готвальда». Ветки 1/2 требуют тип-слово и молчат, mkr-fallback
    # раньше неверно ключевал по району («mkr заречный»). Bare-street ветка
    # берёт сегмент ПЕРЕД номером дома — реальную улицу «готвальда».
    assert estimator._parse_street_house("Заречный м-н, Готвальда, 24") == ("готвальда", "24")


def test_parse_street_house_bare_street_regression_guards() -> None:
    # Регрессия: «мкр Пионерский, 5» — сегмент перед номером («мкр пионерский»)
    # содержит мкр-слово → bare-street ветка ДОЛЖНА пропустить, mkr-fallback
    # резолвит как раньше (mkr-префикс).
    assert estimator._parse_street_house("мкр Пионерский, 5") == ("mkr пионерский", "5")
    # Регрессия #2265: явный уличный тип «улица» присутствует → резолвится
    # ВЕТКОЙ 1, bare-street ветка (после веток 1/2) даже не достигается.
    assert estimator._parse_street_house("мкр. ЖБИ, улица Сыромолотова") == ("сыромолотова", "")


@pytest.mark.parametrize(
    "addr",
    [
        # Deep-review over-merge risk (#2291): голый 2-сегментный адрес БЕЗ
        # какого-либо mkr/район-контекста — частый неформальный ЕКБ-шорткат в
        # объявлениях (район/ЖК + номер дома, без слова «улица»/«мкр»/«м-н»).
        # Bare-street ветка НЕ должна фабриковать уличный токен из района/ЖК —
        # нет более раннего сегмента с mkr-словом → должно остаться ("", "").
        "ВИЗ, 24",
        "Академический, 24",
        "ЖК Некий, 24",
    ],
)
def test_parse_street_house_bare_street_requires_earlier_mkr_segment(addr: str) -> None:
    # Тот же bare-2-сегментный паттерн, что у целевого кейса («<имя>, <номер>»),
    # но БЕЗ более раннего mkr-сегмента — ветка должна самоограничиться и
    # вернуть безопасный ("", ""), а не сфабрикованный уличный токен.
    assert estimator._parse_street_house(addr) == ("", "")


def test_dedup_collapses_domclick_bare_street_mkr_misfire(monkeypatch: pytest.MonkeyPatch) -> None:
    # Класс 1 (#2291) e2e: DomClick «Заречный м-н, Готвальда, 24» раньше
    # ключевался по району и никогда не сливался с avito/cian, где «готвальда»
    # распознаётся явной веткой (тип-слово «улица»).
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(
            source="domclick",
            address="Заречный м-н, Готвальда, 24",
            area=45.0,
            price=6_000_000,
            floor=2,
            cad=None,
        ),
        _lot(
            source="avito",
            address="улица Готвальда, 24",
            area=45.0,
            price=6_000_000,
            floor=2,
            cad=None,
        ),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 1


def test_parse_street_house_yo_ye_fold() -> None:
    # Класс 2 (#2291): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/
    # «Минометчиков» должны давать один и тот же канонический токен.
    assert estimator._parse_street_house("улица Шевелёва, 12") == estimator._parse_street_house(
        "улица Шевелева, 12"
    )
    assert estimator._parse_street_house("улица Миномётчиков, 5") == estimator._parse_street_house(
        "улица Минометчиков, 5"
    )


def test_dedup_collapses_yo_ye_variant(monkeypatch: pytest.MonkeyPatch) -> None:
    # Класс 2 (#2291) e2e: один и тот же физлот в двух source-форматах —
    # «Шевелёва» (avito) и «Шевелева» (cian) — теперь схлопывается в один.
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(source="avito", address="улица Шевелёва, 12", area=42.0, price=5_200_000, floor=4),
        _lot(source="cian", address="улица Шевелева, 12", area=42.0, price=5_200_000, floor=4),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 1


# --------------------------------------------------------------------------- #
# #2423 avito glued-metro-name house-number extraction
# --------------------------------------------------------------------------- #


def test_parse_street_house_avito_glued_metro_suffix() -> None:
    # Root cause прод-кейса: станция метро приклеена к номеру дома без
    # разделителя («2Чкаловская»). Trailing `\b` в старом regex ложно отменял
    # матч цифры целиком (Cyrillic-буква и цифра — оба `\w`, границы нет), и
    # .search() уезжал вправо до «11-15» (дистанция до метро), где дефис даёт
    # настоящую границу → дом ошибочно распознавался как «15» вместо «2».
    addr = "ул. Амундсена, 2Чкаловская11-15 мин. пешком"
    assert estimator._parse_street_house(addr) == ("амундсена", "2")


def test_parse_street_house_avito_glued_metro_hyphenated_name() -> None:
    # Приклеенное название станции само содержит дефис — фикс не должен
    # зависеть от того, есть ли внутри глухого слова дефис или нет.
    addr = "ул. Космонавтов, 14Ботаническая-Уктус7-12 мин. пешком"
    assert estimator._parse_street_house(addr) == ("космонавтов", "14")


def test_parse_street_house_avito_glued_metro_short_name() -> None:
    # Адверсариальный минимум: приклеенное слово всего 2 буквы — lookahead
    # должен отклонить суффикс-букву даже на короткой глухой строке, не
    # только на длинных названиях станций.
    addr = "ул. Мира, 9ВЗ5-8 мин. пешком"
    assert estimator._parse_street_house(addr) == ("мира", "9")


@pytest.mark.parametrize(
    ("house_part", "expected_house"),
    [
        ("24г", "24г"),
        ("32а", "32а"),
        ("5б", "5б"),
    ],
)
def test_parse_street_house_letter_suffix_not_regressed(
    house_part: str, expected_house: str
) -> None:
    # Настоящая литера дома (не продолжается ещё одной кириллической буквой,
    # т.е. это последний word-символ токена) — поведение не должно измениться.
    addr = f"ул. Ленина, {house_part}"
    assert estimator._parse_street_house(addr) == ("ленина", expected_house)


def test_parse_street_house_corpus_slash_not_regressed() -> None:
    # «12/3» (корпус через слэш) — вторая альтернатива (`/\d+`) не тронута фиксом.
    assert estimator._parse_street_house("ул. Ленина, 12/3") == ("ленина", "12/3")
    # #2436: глухая нотация того же корпуса («12к3») канонизируется к ТОЙ ЖЕ
    # «12/3» — кросс-нотационная эквивалентность закреплена на unit-уровне, не
    # только в e2e dedup-тесте (см. test_parse_street_house_all_corpus_notations_agree).
    assert estimator._parse_street_house("ул. Ленина, 12к3") == ("ленина", "12/3")


def test_parse_street_house_glued_corpus_canonicalizes_to_slash() -> None:
    # #2436 fix (регрессия post-#2429): «10к1» (avito/cian глухая нотация
    # корпуса) должна канонизироваться к ТОЙ ЖЕ «N/M» строке, что и родная
    # slash-нотация domklik «10/1» — иначе guard_house's plain string equality
    # (#2429 регрессия, 226 дублирующих групп на проде 2026-07-04) видит
    # «10» != «10/1» и блокирует кросс-source слияние того же физического
    # здания.
    # Superseded assumption (до этого фикса): этот тест раньше утверждал
    # «10к1» -> «10», т.е. цифра корпуса молча отбрасывалась — это и был баг,
    # а не спецификация.
    assert estimator._parse_street_house("ул. Ленина, 10к1") == ("ленина", "10/1")
    assert estimator._parse_street_house("ул. Ленина, 10 к1") == ("ленина", "10/1")


def test_parse_street_house_ambiguous_glued_digit_runs_limitation() -> None:
    # #2423 limitation: два приклеенных друг к другу чисто-числовых прогона
    # без буквы/пунктуации между ними принципиально неразличимы регэкспом —
    # какое число дом, а какое дистанция, знает только семантика. В реальных
    # avito-адресах между домом и дистанцией всегда есть «мин»/имя станции
    # (см. тесты выше), так что этот кейс не встречается в проде; закрепляем
    # текущий (не «исправленный») результат, чтобы будущее изменение было
    # осознанным видимым диффом, а не тихой регрессией.
    assert estimator._parse_street_house("ул. Ленина, 7500 м до метро") == ("ленина", "7500")


def test_dedup_collapses_avito_glued_metro_suffix_house_match(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # e2e: avito-лот с приклеенным названием станции (дом «2» до фикса терялся,
    # извлекалось «15») + чистый cian-адрес того же дома «2» → house-guard
    # больше не блокирует street-union (оба извлекают «2») → один физлот.
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(
            source="avito",
            address="ул. Амундсена, 2Чкаловская11-15 мин. пешком",
            area=45.0,
            price=6_100_000,
            floor=5,
            cad=None,
        ),
        _lot(
            source="cian",
            address="ул. Амундсена, 2",
            area=45.0,
            price=6_100_000,
            floor=5,
            cad=None,
        ),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 1


def test_dedup_glued_metro_noise_does_not_cause_over_merge(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Анти-регрессия: фикс не должен сделать guard_house снисходительнее для
    # адресов с глухим метро-шумом в общем случае — ДЕЙСТВИТЕЛЬНО разные дома
    # («24» vs «26»), каждый одет в приклеенное название станции разной длины,
    # ДОЛЖНЫ остаться раздельными (guard всё ещё блокирует both-present-different).
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(
            source="avito",
            address="ул. Гагарина, 24Чкаловская5-8 мин. пешком",
            area=45.0,
            price=6_100_000,
            floor=5,
            cad=None,
        ),
        _lot(
            source="cian",
            address="ул. Гагарина, 26Ботаническая11-15 мин. пешком",
            area=45.0,
            price=6_100_000,
            floor=5,
            cad=None,
        ),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 2


# --------------------------------------------------------------------------- #
# #2436 — house-number corpus normalization (post-#2429 regression fix)
#
# #2429 stopped the wrong-letter-capture false-negative-match problem, but
# left the corpus DIGIT itself silently dropped for glued/spaced notations
# («65к4»/«65 к4» -> «65», not «65/4») — guard_house's plain string equality
# then saw «65» (avito/cian) != «65/4» (domklik) for the SAME physical
# building and blocked the cross-source merge (226 dup-groups in prod
# 2026-07-04). `_normalize_house_no` canonicalizes every corpus notation to
# a single «N/M» token; genuine liters («24г») are left untouched.
# --------------------------------------------------------------------------- #


def test_normalize_house_no_glued_corpus_to_slash() -> None:
    assert estimator._normalize_house_no("65к4") == "65/4"


def test_normalize_house_no_spaced_corpus_to_slash() -> None:
    assert estimator._normalize_house_no("65 к4") == "65/4"
    assert estimator._normalize_house_no("65 к 4") == "65/4"


def test_normalize_house_no_word_corpus_to_slash() -> None:
    assert estimator._normalize_house_no("65, корпус 4") == "65/4"
    assert estimator._normalize_house_no("65 корп.4") == "65/4"
    # Case-insensitivity — defensive: _parse_street_house обычно уже приводит
    # к нижнему регистру, но сам helper не должен полагаться на этот инвариант.
    assert estimator._normalize_house_no("65 КОРПУС 4") == "65/4"


def test_normalize_house_no_slash_is_idempotent() -> None:
    assert estimator._normalize_house_no("65/4") == "65/4"


def test_normalize_house_no_liter_untouched() -> None:
    # Литер («24г») — другой физический суффикс («дом 24, литер Г»), НЕ
    # схлопывается в слэш-форму и НЕ обрезается до голой цифры.
    assert estimator._normalize_house_no("24г") == "24г"


@pytest.mark.parametrize(
    "notation",
    ["65к4", "65 к4", "65, корпус 4", "65/4"],
)
def test_parse_street_house_all_corpus_notations_agree(notation: str) -> None:
    # Все четыре реальные source-нотации одного и того же корпуса резолвятся
    # в ОДИН и тот же canonical house_no — кросс-source guard видит равенство
    # независимо от того, как источник записал корпус.
    assert estimator._parse_street_house(f"ул. Ленина, {notation}") == ("ленина", "65/4")


def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Точный репортированный баг: avito-подобный адрес («...65к4...», глухая
    # нотация) + domklik-подобный адрес («...65/4...», slash-нотация) — тот же
    # физлот, без кадастра ни у кого, тот же этаж/площадь/цена-бакет — ДОЛЖНЫ
    # схлопнуться в один. union() зависит от порядка (кто станет root) —
    # проверяем ОБА порядка следования лотов.
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)

    def _pair(first_source: str, second_source: str) -> list[dict]:
        by_source = {
            "avito": _lot(
                source="avito",
                address="ул. Ленина, 65к4",
                area=54.0,
                price=7_200_000,
                floor=9,
                cad=None,
                scraped_at=datetime(2026, 7, 1, tzinfo=UTC),
            ),
            "domklik": _lot(
                source="domklik",
                address="ул. Ленина, 65/4",
                area=54.0,
                price=7_200_000,
                floor=9,
                cad=None,
                scraped_at=datetime(2026, 7, 3, tzinfo=UTC),
            ),
        }
        return [by_source[first_source], by_source[second_source]]

    out_avito_first = estimator._dedup_cross_source(_pair("avito", "domklik"))
    assert len(out_avito_first) == 1

    out_domklik_first = estimator._dedup_cross_source(_pair("domklik", "avito"))
    assert len(out_domklik_first) == 1


def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Адверсариальный кейс: нормализация НЕ должна расширять сеть слияний за
    # пределы genuine corpus-notation эквивалентности. «65к4» (-> «65/4») и
    # «65г» (литер, не тронут) — РАЗНЫЕ дома на одной улице, слияния быть не
    # должно.
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(
            source="avito",
            address="ул. Ленина, 65к4",
            area=54.0,
            price=7_200_000,
            floor=9,
            cad=None,
        ),
        _lot(
            source="cian",
            address="ул. Ленина, 65г",
            area=54.0,
            price=7_200_000,
            floor=9,
            cad=None,
        ),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 2


def test_dedup_house_no_guard_blocks_different_corpus_numbers(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    # Нормализация — не blanket same-street merge: «65/4» vs «65/5» (или
    # «65к4» vs «65к5») — genuinely разные здания, guard_house обязан
    # по-прежнему их разделять после нормализации.
    monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
    lots = [
        _lot(
            source="domklik",
            address="ул. Ленина, 65/4",
            area=54.0,
            price=7_200_000,
            floor=9,
            cad=None,
        ),
        _lot(
            source="cian",
            address="ул. Ленина, 65/5",
            area=54.0,
            price=7_200_000,
            floor=9,
            cad=None,
        ),
    ]
    out = estimator._dedup_cross_source(lots)
    assert len(out) == 2


def test_parse_street_house_apartment_marker_not_confused_with_corpus() -> None:
    # Edge case: «кв.»/«квартира» (маркер квартиры) тоже начинается с буквы
    # «к» — corpus-word ветка (`к(?:орп(?:ус)?)?`) НЕ должна на неё сработать,
    # т.к. паттерн требует цифру СРАЗУ после буквенного корпус-маркера, а
    # «кв. 12» между «к» и «12» содержит «в. » — не матчится. Дом = «65», а
    # не «65в12»/сфабрикованный корпус.
    assert estimator._parse_street_house("ул. Ленина, 65, кв. 12") == ("ленина", "65")


def test_parse_street_house_dangling_corpus_letter_no_digit_is_liter() -> None:
    # Edge case: «65к» без цифры корпуса ВООБЩЕ (усечённый/битый маркер, либо
    # редкая литера «К») — консервативно трактуется как литер (нет цифры
    # сразу после буквы ⇒ литер, см. design §3), остаётся «65к» — НЕ
    # схлопывается ни во что и не отбрасывается.
    assert estimator._parse_street_house("ул. Ленина, 65к") == ("ленина", "65к")
