"""Tests for avito_houses dedup fix — _persist_house делегирует в match_or_create_house.

Проверяет:
  1. Два HouseInfo с разными ext_id но одинаковым адресом и координатами
     → _persist_house возвращает ОДИН house_id (не плодит дубль).
  2. Enrichment-поля (rating_score, developer_name) реально записываются в UPDATE.
  3. _persist_house не вызывает db.commit().
  4. upsert_house и _upsert_house_with_url делегируют в _persist_house.
  5. #1820: два id-less URL (ext_id=0, без адреса) НЕ создают house_sources(avito,"0").

Использует тот же паттерн mock-DB что в test_matching.py и test_same_building_match.py.
"""

import json
import os

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

from unittest.mock import MagicMock, patch

from app.services.scrapers.avito_houses import (
    HouseCatalogEnrichment,
    HouseInfo,
    _persist_house,
    _upsert_house_with_url,
    save_house_catalog_enrichment,
    upsert_house,
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_house(ext_id: int = 111, address: str = "ул. Ленина, 5") -> HouseInfo:
    return HouseInfo(
        ext_id=ext_id,
        ext_id_hash="hash_" + str(ext_id),
        short_address=address,
        full_address="Свердловская обл., Екатеринбург, " + address,
        lat=56.8378,
        lon=60.5946,
        year_built=2010,
        house_type="monolith",
        total_floors=16,
        passenger_elevators=2,
        cargo_elevators=1,
        has_concierge=True,
        closed_yard=False,
        material_floors="concrete",
        hot_water="central",
        parking_type="underground",
        has_playground=True,
        developer_name="ПИК",
        developer_key="pik",
        infrastructure_summary="Школа, детсад",
        infrastructure_walk_distance="5 мин",
        rating_score=4.5,
        rating_string="4.5 из 5",
        reviews_count=120,
        rating_distribution=[{"score": 5, "count": 80}],
        map_pins=[{"type": "metro", "name": "Уральская"}],
        raw_characteristics=[{"title": "Год постройки", "value": "2010"}],
    )


def _make_db_for_persist(canonical_id: int = 42) -> MagicMock:
    """Mock DB для _persist_house: match_or_create_house ходит через db.execute,
    поэтому патчим match_or_create_house целиком и проверяем вызов UPDATE.
    """
    db = MagicMock()
    # db.execute для UPDATE houses SET ... — просто возвращаем None (rowcount не важен)
    db.execute.return_value = MagicMock()
    return db


# ---------------------------------------------------------------------------
# Test 1: два HouseInfo с разными ext_id, одинаковый адрес → один house_id
# ---------------------------------------------------------------------------


def test_persist_house_same_address_returns_same_id():
    """Два HouseInfo с разными ext_id но одинаковым адресом → один canonical house_id.

    Моделируем случай: первый вызов (ext_id=111) → match_or_create_house вернул 42 (new).
    Второй вызов (ext_id=222, тот же адрес) → match_or_create_house вернул 42 (fingerprint).
    Оба _persist_house должны вернуть 42.
    """
    db = MagicMock()
    db.execute.return_value = MagicMock()

    h1 = _make_house(ext_id=111)
    h2 = _make_house(ext_id=222)  # другой ext_id, тот же адрес/координаты

    with patch(
        "app.services.scrapers.avito_houses.match_or_create_house",
        side_effect=[(42, 1.0, "new"), (42, 0.9, "fingerprint")],
    ) as mock_match:
        id1 = _persist_house(db, h1, "https://avito.ru/house/111")
        id2 = _persist_house(db, h2, "https://avito.ru/house/222")

    assert id1 == 42, f"Первый вызов должен вернуть 42, получили {id1}"
    assert id2 == 42, f"Второй вызов должен вернуть тот же 42, получили {id2}"
    assert id1 == id2, "Оба вызова должны вернуть один house_id (нет дубля)"
    assert mock_match.call_count == 2


# ---------------------------------------------------------------------------
# Test 2: enrichment-поля записываются в UPDATE
# ---------------------------------------------------------------------------


def test_persist_house_enrichment_fields_written():
    """Enrichment-поля (rating_score, developer_name) передаются в UPDATE-запрос."""
    db = MagicMock()
    db.execute.return_value = MagicMock()

    h = _make_house(ext_id=111)

    with patch(
        "app.services.scrapers.avito_houses.match_or_create_house",
        return_value=(42, 1.0, "source_exact"),
    ):
        _persist_house(db, h, "https://avito.ru/house/111")

    # db.execute должен быть вызван 1 раз (UPDATE houses SET ...)
    assert (
        db.execute.call_count == 1
    ), f"Ожидался 1 вызов db.execute (UPDATE), получили {db.execute.call_count}"

    # Проверяем параметры bind
    call_args = db.execute.call_args
    sql_obj = call_args[0][0]
    bind = call_args[0][1]

    assert "UPDATE houses" in str(sql_obj), "SQL должен содержать UPDATE houses"
    assert bind["rating_score"] == 4.5, f"rating_score={bind.get('rating_score')!r}"
    assert bind["developer_name"] == "ПИК", f"developer_name={bind.get('developer_name')!r}"
    assert bind["house_id"] == 42, f"house_id={bind.get('house_id')!r}"

    # avito_id_hash должен быть передан
    assert bind["avito_id_hash"] == "hash_111", f"avito_id_hash={bind.get('avito_id_hash')!r}"

    # rating_distribution должен быть JSON-строкой
    rd = bind.get("rating_distribution")
    assert isinstance(rd, str), f"rating_distribution должен быть str (JSON), получили {type(rd)}"
    assert json.loads(rd) == [{"score": 5, "count": 80}]


# ---------------------------------------------------------------------------
# Test 3: _persist_house не вызывает db.commit()
# ---------------------------------------------------------------------------


def test_persist_house_no_commit():
    """_persist_house не должен вызывать db.commit() — коммитит вызывающий."""
    db = MagicMock()
    db.execute.return_value = MagicMock()

    h = _make_house()

    with patch(
        "app.services.scrapers.avito_houses.match_or_create_house",
        return_value=(7, 1.0, "new"),
    ):
        _persist_house(db, h, "")

    db.commit.assert_not_called()


# ---------------------------------------------------------------------------
# Test 4: upsert_house делегирует в _persist_house (с пустым url)
# ---------------------------------------------------------------------------


def test_upsert_house_delegates_to_persist_house():
    """upsert_house(db, h) → _persist_house(db, h, "") → возвращает house_id."""
    db = MagicMock()
    db.execute.return_value = MagicMock()
    h = _make_house()

    with patch(
        "app.services.scrapers.avito_houses.match_or_create_house",
        return_value=(55, 1.0, "new"),
    ):
        result = upsert_house(db, h)

    assert result == 55

    # Проверяем что UPDATE был вызван с url="" (пустая строка — нет url в этом пути)
    bind = db.execute.call_args[0][1]
    assert bind["url"] == "", f"url должен быть пустой строкой, получили {bind.get('url')!r}"


# ---------------------------------------------------------------------------
# Test 5: _upsert_house_with_url делегирует в _persist_house (с реальным url)
# ---------------------------------------------------------------------------


def test_upsert_house_with_url_delegates_to_persist_house():
    """_upsert_house_with_url(db, h, url) → _persist_house(db, h, url) → house_id."""
    db = MagicMock()
    db.execute.return_value = MagicMock()
    h = _make_house()
    url = "https://www.avito.ru/catalog/houses/ekb/dom-1234"

    with patch(
        "app.services.scrapers.avito_houses.match_or_create_house",
        return_value=(99, 1.0, "new"),
    ):
        result = _upsert_house_with_url(db, h, url)

    assert result == 99

    # Проверяем что правильный url передан в UPDATE
    bind = db.execute.call_args[0][1]
    assert bind["url"] == url, f"url должен быть {url!r}, получили {bind.get('url')!r}"


# ---------------------------------------------------------------------------
# Test 6: match_or_create_house вызывается с правильными аргументами
# ---------------------------------------------------------------------------


def test_persist_house_calls_match_with_correct_args():
    """_persist_house передаёт правильные аргументы в match_or_create_house."""
    db = MagicMock()
    db.execute.return_value = MagicMock()

    h = _make_house(ext_id=333)
    url = "https://avito.ru/333"

    with patch(
        "app.services.scrapers.avito_houses.match_or_create_house",
        return_value=(10, 1.0, "new"),
    ) as mock_match:
        _persist_house(db, h, url)

    mock_match.assert_called_once_with(
        db,
        ext_source="avito",
        ext_id="333",
        address=h.short_address or h.full_address,
        lat=h.lat,
        lon=h.lon,
        year_built=h.year_built,
        source_url=url,
    )


# ---------------------------------------------------------------------------
# Test 7: COALESCE — адрес/координаты не затираются через UPDATE параметры
#   (проверяем что bind содержит address/lat/lon для COALESCE в SQL)
# ---------------------------------------------------------------------------


def test_persist_house_coalesce_fields_in_bind():
    """UPDATE передаёт address/lat/lon в bind для COALESCE(houses.<col>, CAST(:x AS ...))."""
    db = MagicMock()
    db.execute.return_value = MagicMock()

    h = _make_house()

    with patch(
        "app.services.scrapers.avito_houses.match_or_create_house",
        return_value=(1, 1.0, "new"),
    ):
        _persist_house(db, h, "")

    bind = db.execute.call_args[0][1]
    assert bind["address"] == h.short_address
    assert bind["lat"] == h.lat
    assert bind["lon"] == h.lon
    assert bind["year_built"] == h.year_built


# ---------------------------------------------------------------------------
# Test 8: #1820 — ext_id=0 graceful-skip гард
#   Два id-less URL (ext_id=0, без адреса) → match_or_create_house НЕ вызывается,
#   house_sources("avito","0") НЕ создаётся, возвращается sentinel dict с house_id=0.
# ---------------------------------------------------------------------------


def test_save_house_catalog_enrichment_skips_zero_ext_id_no_address():
    """#1820: ext_id=0 + нет адреса → гард пропускает персистирование canonical house."""
    db = MagicMock()

    # Создаём два id-less graceful-skip enrichment (как _parse_development_or_skip возвращает)
    enrichment_a = HouseCatalogEnrichment(
        house_url="https://www.avito.ru/catalog/novostroyki/dom-A/",
        house=HouseInfo(ext_id=0),
    )
    enrichment_b = HouseCatalogEnrichment(
        house_url="https://www.avito.ru/catalog/novostroyki/dom-B/",
        house=HouseInfo(ext_id=0),
    )

    with patch(
        "app.services.scrapers.avito_houses.match_or_create_house",
    ) as mock_match:
        result_a = save_house_catalog_enrichment(db, enrichment_a)
        result_b = save_house_catalog_enrichment(db, enrichment_b)

    # match_or_create_house НЕ должен вызываться — гард сработал до него
    mock_match.assert_not_called()

    # Возвращается sentinel dict с house_id=0, не плодятся дубли
    assert result_a["house_id"] == 0, f"ожидали house_id=0, получили {result_a['house_id']}"
    assert result_b["house_id"] == 0, f"ожидали house_id=0, получили {result_b['house_id']}"

    # db.commit() НЕ вызывается (нет реальной транзакции)
    db.commit.assert_not_called()


def test_save_house_catalog_enrichment_with_address_persists_despite_zero_ext_id():
    """#1820: ext_id=0 НО есть адрес → НЕ пропускаем (developmentPage с адресом, без avitoId)."""
    db = MagicMock()
    db.execute.return_value = MagicMock()

    enrichment = HouseCatalogEnrichment(
        house_url="https://www.avito.ru/catalog/novostroyki/dom-C/",
        house=HouseInfo(ext_id=0, short_address="ул. Ленина, 1"),
    )

    with patch(
        "app.services.scrapers.avito_houses.match_or_create_house",
        return_value=(77, 0.9, "fingerprint"),
    ) as mock_match:
        result = save_house_catalog_enrichment(db, enrichment)

    # Адрес есть → персистим через fingerprint tier
    mock_match.assert_called_once()
    assert result["house_id"] == 77
