"""Тесты Fix 1: save_listings — SAVEPOINT вокруг INSERT + reconcile по (source,source_id).

Корень бага: при dedup_hash-дрейфе (контент ad изменился, новый source_id не меняется)
INSERT не находит existing row по dedup_hash → пробует свежий INSERT → UniqueViolation
по listings_source_source_id_uq (source, source_id) → весь прогон падает.

Фикс: per-lot SAVEPOINT + IntegrityError catch + UPDATE by (source, source_id).
"""

from __future__ import annotations

import os
from contextlib import contextmanager
from unittest.mock import MagicMock, patch

import psycopg.errors
import pytest
from sqlalchemy.exc import IntegrityError

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

from app.services.scrapers.base import ScrapedLot, save_listings

# ── Fixtures ─────────────────────────────────────────────────────────────────


@pytest.fixture(autouse=True)
def _patch_matching():
    """Stub matching + snapshot so tests focus on the upsert/reconcile logic."""
    with (
        patch("app.services.scrapers.base.match_or_create_house") as m_house,
        patch("app.services.scrapers.base.upsert_listing_source") as m_link,
        patch("app.services.scrapers.base.upsert_listing_snapshot") as m_snap,
    ):
        m_house.return_value = (101, 1.0, "new")
        m_link.return_value = None
        m_snap.return_value = None
        yield {"house": m_house, "link": m_link, "snap": m_snap}


def _base_lot(**overrides) -> ScrapedLot:
    defaults = {
        "source": "avito",
        "source_url": "https://www.avito.ru/ekaterinburg/test/1",
        "source_id": "7960764619",
        "price_rub": 5_000_000,
    }
    defaults.update(overrides)
    return ScrapedLot(**defaults)


@contextmanager
def _nested_ctx():
    yield MagicMock()


def _mock_db_normal(inserted: bool = True, listing_id: int = 42) -> MagicMock:
    """DB mock для нормального пути (нет UniqueViolation)."""
    insert_row = MagicMock()
    insert_row.id = listing_id
    insert_row.inserted = inserted

    db = MagicMock()

    def _execute(sql, params=None):
        s = str(sql)
        res = MagicMock()
        if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
            res.fetchone.return_value = None  # нет prior row → fresh insert
        elif "FROM listings_snapshots" in s:
            res.fetchone.return_value = None
        elif "INSERT INTO listings (" in s:
            res.fetchone.return_value = insert_row
        else:
            res.fetchone.return_value = None
        return res

    db.execute.side_effect = _execute
    db.begin_nested.side_effect = _nested_ctx
    return db


def _mock_db_with_unique_violation(reconcile_id: int = 77) -> MagicMock:
    """DB mock: первый INSERT вызывает UniqueViolation (dedup_hash drift).

    Симулирует кейс: INSERT по новому dedup_hash → коллизия по (source, source_id).
    Reconcile UPDATE by (source, source_id) → возвращает id=reconcile_id.
    """
    uv_orig = psycopg.errors.UniqueViolation()
    integrity_err = IntegrityError(
        statement="INSERT INTO listings ...",
        params={},
        orig=uv_orig,
    )

    rec_row = MagicMock()
    rec_row.id = reconcile_id

    db = MagicMock()

    def _execute(sql, params=None):
        s = str(sql)
        res = MagicMock()
        if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
            # prior_row=None → INSERT будет сделан (no prior dedup_hash match)
            res.fetchone.return_value = None
        elif "FROM listings_snapshots" in s:
            res.fetchone.return_value = None
        elif "INSERT INTO listings (" in s:
            # Имитируем исключение внутри begin_nested() context
            raise integrity_err
        elif "UPDATE listings" in s and "SET dedup_hash" in s:
            # reconcile UPDATE by (source, source_id)
            res.fetchone.return_value = rec_row
        elif "UPDATE listings" in s and "house_id_fk" in s:
            res.fetchone.return_value = None
        else:
            res.fetchone.return_value = None
        return res

    db.execute.side_effect = _execute

    # begin_nested() нужен для per-lot SAVEPOINT + snapshot + matching hook
    # Используем реальный contextmanager который НЕ перехватывает IntegrityError
    # (savepoint rollback симулируется тем, что db.execute после rollback'а
    # уже вызывает reconcile UPDATE, а не снова INSERT).
    savepoint_entered = [False]

    @contextmanager
    def _nested():
        savepoint_entered[0] = True
        try:
            yield MagicMock()
        except IntegrityError:
            # SAVEPOINT rollback — пробрасываем наверх (SQLAlchemy делает rollback)
            raise

    db.begin_nested.side_effect = _nested
    return db


# ── Tests ─────────────────────────────────────────────────────────────────────


def test_normal_insert_path_unchanged():
    """Нормальный путь (нет UniqueViolation) → inserted=1, updated=0."""
    db = _mock_db_normal(inserted=True, listing_id=10)
    lot = _base_lot()

    inserted, updated = save_listings(db, [lot])

    assert inserted == 1
    assert updated == 0


def test_normal_update_path_unchanged():
    """Нормальный update path (ON CONFLICT DO UPDATE) → inserted=0, updated=1."""
    insert_row = MagicMock()
    insert_row.id = 42
    insert_row.inserted = False  # xmax != 0 → update

    db = MagicMock()

    def _execute(sql, params=None):
        s = str(sql)
        res = MagicMock()
        if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
            res.fetchone.return_value = None
        elif "FROM listings_snapshots" in s:
            res.fetchone.return_value = None
        elif "INSERT INTO listings (" in s:
            res.fetchone.return_value = insert_row
        else:
            res.fetchone.return_value = None
        return res

    db.execute.side_effect = _execute
    db.begin_nested.side_effect = _nested_ctx

    lot = _base_lot(source_id="upd_test")
    inserted, updated = save_listings(db, [lot])

    assert inserted == 0
    assert updated == 1


def test_null_source_id_no_savepoint_needed():
    """source_id=None (yandex) → обычный INSERT, UniqueViolation не может возникнуть.

    Проверяем, что нормальный путь не изменился при source_id=None.
    """
    db = _mock_db_normal(inserted=True, listing_id=99)
    lot = _base_lot(
        source="yandex",
        source_url="https://realty.yandex.ru/offer/123/",
        source_id=None,
    )

    inserted, updated = save_listings(db, [lot])

    assert inserted == 1
    assert updated == 0


def test_unique_violation_triggers_reconcile_update(caplog, _patch_matching):
    """UniqueViolation по (source, source_id) → SAVEPOINT rollback → UPDATE by (s,sid).

    После reconcile UPDATE: listing_id определён, matching hook вызывается.
    inserted=0, updated=0 (reconciled не входит в возврат, но лог есть).
    """
    import logging

    db = _mock_db_with_unique_violation(reconcile_id=77)
    lot = _base_lot(source="avito", source_id="7960764619")

    with caplog.at_level(logging.WARNING, logger="app.services.scrapers.base"):
        inserted, updated = save_listings(db, [lot])

    # Основные counters: reconcile не добавляет к inserted/updated
    assert inserted == 0
    assert updated == 0

    # Лог предупреждения о reconcile
    warns = [r for r in caplog.records if "dedup_hash_drift" in r.getMessage()]
    assert len(warns) == 1
    assert "7960764619" in warns[0].getMessage()

    # Matching hook должен вызваться (listing_id=77 из reconcile UPDATE)
    assert _patch_matching["link"].call_count == 1


def test_unique_violation_reconcile_uses_update_by_source_source_id():
    """Reconcile UPDATE содержит WHERE source = :source AND source_id = :source_id."""
    db = _mock_db_with_unique_violation(reconcile_id=55)
    lot = _base_lot(source="cian", source_id="330200428")

    save_listings(db, [lot])

    # Ищем вызов reconcile UPDATE (содержит SET dedup_hash)
    reconcile_calls = [
        c
        for c in db.execute.call_args_list
        if "UPDATE listings" in str(c.args[0]) and "SET dedup_hash" in str(c.args[0])
    ]
    assert len(reconcile_calls) == 1
    _sql, params = str(reconcile_calls[0].args[0]), reconcile_calls[0].args[1]
    assert "WHERE source = :source" in _sql
    assert "AND source_id = :source_id" in _sql
    assert params["source"] == "cian"
    assert params["source_id"] == "330200428"


def test_reconcile_includes_new_dedup_hash():
    """Reconcile UPDATE обновляет dedup_hash на новый (дрейфнувший) хэш."""
    db = _mock_db_with_unique_violation(reconcile_id=66)
    lot = _base_lot(source="avito", source_id="7960764619", price_rub=5_500_000)

    expected_dedup = lot.compute_dedup_hash()
    save_listings(db, [lot])

    reconcile_calls = [
        c
        for c in db.execute.call_args_list
        if "UPDATE listings" in str(c.args[0]) and "SET dedup_hash" in str(c.args[0])
    ]
    assert len(reconcile_calls) == 1
    params = reconcile_calls[0].args[1]
    assert params["dedup"] == expected_dedup


def test_reconcile_run_does_not_crash_full_batch(_patch_matching):
    """Один лот с UniqueViolation в batch из 3 → остальные 2 успешно обрабатываются.

    Ключевая проверка что один битый lot НЕ роняет прогон.
    """
    rec_row = MagicMock()
    rec_row.id = 77

    ok_insert_row = MagicMock()
    ok_insert_row.id = 88
    ok_insert_row.inserted = True

    uv_orig = psycopg.errors.UniqueViolation()
    integrity_err = IntegrityError("INSERT", {}, uv_orig)

    def _execute(sql, params=None):
        s = str(sql)
        res = MagicMock()
        if "SELECT card_hash" in s and "WHERE dedup_hash" in s:
            res.fetchone.return_value = None
        elif "FROM listings_snapshots" in s:
            res.fetchone.return_value = None
        elif "INSERT INTO listings (" in s:
            if params and params.get("source_id") == "CONFLICT_ID":
                raise integrity_err
            res.fetchone.return_value = ok_insert_row
        elif "UPDATE listings" in s and "SET dedup_hash" in s:
            res.fetchone.return_value = rec_row
        elif "UPDATE listings" in s and "house_id_fk" in s:
            res.fetchone.return_value = None
        else:
            res.fetchone.return_value = None
        return res

    db = MagicMock()
    db.execute.side_effect = _execute

    @contextmanager
    def _nested():
        try:
            yield MagicMock()
        except IntegrityError:
            raise

    db.begin_nested.side_effect = _nested

    lot_conflict = _base_lot(source_id="CONFLICT_ID")
    lot_ok1 = _base_lot(source_id="OK_1", source_url="https://www.avito.ru/ok1")
    lot_ok2 = _base_lot(source_id="OK_2", source_url="https://www.avito.ru/ok2")

    inserted, updated = save_listings(db, [lot_conflict, lot_ok1, lot_ok2])

    # 2 нормальных INSERT + 1 reconcile (не считается в inserted/updated)
    assert inserted == 2
    assert updated == 0
    # Все 3 лота дошли до matching hook
    assert _patch_matching["link"].call_count == 3
