"""Unit tests for backfill_listings_coords_geoportal task.

Покрывает:
- Листинг с адресом «ул. Малышева, 30» + geoportal-строка → coords проставляются.
- Листинг без матча в geoportal → не трогается (no_match++).
- Листинг с lat уже заполненным (не попадает в SELECT WHERE lat IS NULL) → skip.
- Листинг с нераспарсимым адресом (NULL / мусор) → no_address++.
- Пустой SELECT → нулевой результат.
- limit/sources параметры прокидываются в SQL.
- Идемпотентность: UPDATE с WHERE lat IS NULL не затирает уже заполненное.
- SAVEPOINT откат батча при исключении в DB.execute.
- run_geoportal_coords_backfill (#1967): run-lifecycle wrapper mark_done/mark_failed.
- Migration 171 seed content.

Все сетевые вызовы и DB замоканы.
"""

from __future__ import annotations

import os
import sys
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

import pytest

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

_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)

from app.services.geocoder import GeocodeSuggestion  # noqa: E402
from app.tasks import backfill_listings_coords_geoportal as bcg  # noqa: E402
from app.tasks.backfill_listings_coords_geoportal import (  # noqa: E402
    BackfillCoordsResult,
    backfill_coords_from_geoportal,
)

_SQL_DIR = Path(__file__).resolve().parents[2] / "data" / "sql"
_MIGRATION_171 = _SQL_DIR / "171_scrape_schedules_seed_geoportal_coords_backfill.sql"

# ── helpers ───────────────────────────────────────────────────────────────────

_HIT = GeocodeSuggestion(
    label="Малышева, 30",
    full_address="Малышева, 30",
    lat=56.838,
    lon=60.605,
    kind="house",
)


def _make_db(select_pages: list[list[dict]], update_rowcount: int = 1) -> MagicMock:
    """Mock Session. SELECT-вызовы возвращают страницы из очереди по порядку.

    UPDATE и прочие execute()-вызовы возвращают generic-мок с rowcount=update_rowcount.
    Очередь SELECT определяется по наличию .mappings()-обращений внутри задачи:
    задача вызывает .mappings().all() только для SELECT-запроса (первый execute
    в каждой итерации батча). UPDATE-вызовы внутри begin_nested не используют
    .mappings() — это позволяет различить их.
    """
    db = MagicMock()

    nested_ctx = MagicMock()
    nested_ctx.__enter__ = MagicMock(return_value=None)
    nested_ctx.__exit__ = MagicMock(return_value=False)
    db.begin_nested.return_value = nested_ctx

    # SELECT-очередь (только для .mappings().all()-вызовов)
    select_queue: list[list[dict]] = list(select_pages)

    # generic UPDATE-мок
    upd = MagicMock()
    upd.rowcount = update_rowcount

    # Отдельный счётчик: первый execute каждого батча = SELECT (mappings)
    # Определяем по тексту SQL: SELECT vs UPDATE
    def _side(*args, **kwargs):
        sql = str(args[0]) if args else ""
        if "SELECT" in sql or "select" in sql:
            if select_queue:
                page = select_queue.pop(0)
            else:
                page = []
            sel = MagicMock()
            sel.mappings.return_value.all.return_value = page
            return sel
        # UPDATE / прочее
        return upd

    db.execute.side_effect = _side
    return db


# ── basic match ──────────────────────────────────────────────────────────────


def test_match_updates_coords() -> None:
    """Листинг с адресом Малышева 30 → geoportal матч → lat/lon/geom UPDATE."""
    rows = [{"id": 1, "address": "г. Екатеринбург, ул. Малышева, 30, кв. 28"}]
    db = _make_db([rows, []])  # второй SELECT пустой → конец

    with (
        patch(
            "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
            return_value=_HIT,
        ) as mock_geo,
        patch(
            "app.tasks.backfill_listings_coords_geoportal._parse_street_house",
            return_value=("малышева", "30"),
        ),
    ):
        res = backfill_coords_from_geoportal(db, batch_size=500)

    assert res.candidates == 1
    assert res.matched == 1
    assert res.updated == 1
    assert res.no_address == 0
    assert res.no_match == 0
    assert res.errors == 0
    mock_geo.assert_called_once_with(db, "малышева", "30")
    # UPDATE должен быть вызван с правильными lat/lon
    update_calls = [c for c in db.execute.call_args_list if "UPDATE" in str(c.args[0])]
    assert len(update_calls) == 1
    params = update_calls[0].args[1]
    assert params["lat"] == pytest.approx(56.838)
    assert params["lon"] == pytest.approx(60.605)
    assert params["id"] == 1


def test_no_match_does_not_update() -> None:
    """Geoportal не нашёл → no_match++, UPDATE не вызывается."""
    rows = [{"id": 2, "address": "ул. Несуществующая, 999"}]
    db = _make_db([rows, []])

    with (
        patch(
            "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
            return_value=None,
        ),
        patch(
            "app.tasks.backfill_listings_coords_geoportal._parse_street_house",
            return_value=("несуществующая", "999"),
        ),
    ):
        res = backfill_coords_from_geoportal(db, batch_size=500)

    assert res.candidates == 1
    assert res.matched == 0
    assert res.updated == 0
    assert res.no_match == 1
    # UPDATE не вызывался (только SELECT)
    update_calls = [c for c in db.execute.call_args_list if "UPDATE" in str(c.args[0])]
    assert len(update_calls) == 0


def test_listing_with_coords_excluded_by_where() -> None:
    """SELECT WHERE lat IS NULL не вернёт листинги с координатами.

    Моделируем это пустым SELECT → нулевой результат.
    """
    db = _make_db([[]])  # SELECT сразу пустой

    with patch("app.tasks.backfill_listings_coords_geoportal._geoportal_house_match") as mock_geo:
        res = backfill_coords_from_geoportal(db, batch_size=500)

    assert res.candidates == 0
    assert res.matched == 0
    assert res.updated == 0
    mock_geo.assert_not_called()


def test_null_address_counted_as_no_address() -> None:
    """Листинг с адресом None (NULL в БД) → no_address++."""
    rows = [{"id": 3, "address": None}]
    db = _make_db([rows, []])

    with patch("app.tasks.backfill_listings_coords_geoportal._geoportal_house_match") as mock_geo:
        res = backfill_coords_from_geoportal(db, batch_size=500)

    assert res.no_address == 1
    assert res.matched == 0
    mock_geo.assert_not_called()


def test_unparseable_address_counted_as_no_address() -> None:
    """_parse_street_house вернул None → no_address++, geoportal не вызывается."""
    rows = [{"id": 4, "address": "полный мусор без дома"}]
    db = _make_db([rows, []])

    with (
        patch(
            "app.tasks.backfill_listings_coords_geoportal._parse_street_house",
            return_value=None,
        ),
        patch("app.tasks.backfill_listings_coords_geoportal._geoportal_house_match") as mock_geo,
    ):
        res = backfill_coords_from_geoportal(db, batch_size=500)

    assert res.no_address == 1
    assert res.matched == 0
    mock_geo.assert_not_called()


# ── idempotency ───────────────────────────────────────────────────────────────


def test_update_idempotent_where_lat_is_null() -> None:
    """UPDATE содержит WHERE lat IS NULL — повторный прогон не затрёт существующие coords."""
    rows = [{"id": 5, "address": "ул. Малышева, 30"}]
    db = _make_db([rows, []])
    # rowcount=0 симулирует «уже было заполнено»
    db.execute.side_effect = None
    calls_log: list = []

    nested_ctx = MagicMock()
    nested_ctx.__enter__ = MagicMock(return_value=None)
    nested_ctx.__exit__ = MagicMock(return_value=False)
    db.begin_nested.return_value = nested_ctx

    select_page1 = MagicMock()
    select_page1.mappings.return_value.all.return_value = rows
    select_page2 = MagicMock()
    select_page2.mappings.return_value.all.return_value = []

    upd_already_filled = MagicMock()
    upd_already_filled.rowcount = 0  # lat уже был заполнен

    execute_returns = [select_page1, upd_already_filled, select_page2]

    def _side(*args, **kwargs):
        calls_log.append(str(args[0]))
        return execute_returns.pop(0)

    db.execute.side_effect = _side

    with (
        patch(
            "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
            return_value=_HIT,
        ),
        patch(
            "app.tasks.backfill_listings_coords_geoportal._parse_street_house",
            return_value=("малышева", "30"),
        ),
    ):
        res = backfill_coords_from_geoportal(db, batch_size=500)

    # matched=1 (geoportal нашёл), но updated=0 (WHERE lat IS NULL вернул 0)
    assert res.matched == 1
    assert res.updated == 0
    # Проверяем что UPDATE SQL содержит WHERE lat IS NULL
    update_sqls = [s for s in calls_log if "UPDATE" in s]
    assert len(update_sqls) == 1
    assert "lat IS NULL" in update_sqls[0]


# ── limit / sources ───────────────────────────────────────────────────────────


def test_limit_stops_processing() -> None:
    """limit=1 → берём не более 1 листинга."""
    rows = [{"id": 10, "address": "ул. Малышева, 30"}]
    db = _make_db([rows])  # только одна страница

    with (
        patch(
            "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
            return_value=None,
        ),
        patch(
            "app.tasks.backfill_listings_coords_geoportal._parse_street_house",
            return_value=("малышева", "30"),
        ),
    ):
        res = backfill_coords_from_geoportal(db, limit=1, batch_size=500)

    assert res.candidates == 1


def test_sources_filter_passed_to_sql() -> None:
    """sources=['avito'] → SQL содержит source = ANY(...)."""
    db = MagicMock()
    sel = MagicMock()
    sel.mappings.return_value.all.return_value = []
    db.execute.return_value = sel

    nested_ctx = MagicMock()
    nested_ctx.__enter__ = MagicMock(return_value=None)
    nested_ctx.__exit__ = MagicMock(return_value=False)
    db.begin_nested.return_value = nested_ctx

    backfill_coords_from_geoportal(db, sources=["avito"], batch_size=500)

    first_call = db.execute.call_args_list[0]
    sql_text = str(first_call.args[0])
    params = first_call.args[1]
    assert "ANY" in sql_text
    assert params["sources"] == ["avito"]


# ── error handling ────────────────────────────────────────────────────────────


def test_geoportal_match_exception_counted_as_error() -> None:
    """_geoportal_house_match raises → errors++, обработка продолжается."""
    rows = [
        {"id": 20, "address": "ул. Сломанная, 1"},
        {"id": 21, "address": "ул. Малышева, 30"},
    ]
    db = _make_db([rows, []])

    side_effects = [RuntimeError("DB error"), _HIT]

    with (
        patch(
            "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
            side_effect=side_effects,
        ),
        patch(
            "app.tasks.backfill_listings_coords_geoportal._parse_street_house",
            return_value=("малышева", "30"),
        ),
    ):
        res = backfill_coords_from_geoportal(db, batch_size=500)

    assert res.errors == 1
    assert res.matched == 1
    assert res.candidates == 2


def test_empty_select_returns_zero_result() -> None:
    """Нет листингов с lat IS NULL → нулевой результат."""
    db = _make_db([[]])

    with patch("app.tasks.backfill_listings_coords_geoportal._geoportal_house_match") as mock_geo:
        res = backfill_coords_from_geoportal(db, batch_size=500)

    assert isinstance(res, BackfillCoordsResult)
    assert res.candidates == 0
    assert res.matched == 0
    assert res.updated == 0
    mock_geo.assert_not_called()


# ── batch pagination ──────────────────────────────────────────────────────────


def test_batch_pagination_two_pages() -> None:
    """batch_size=2: 2+1 листингов → 2 страницы SELECT, 3 листинга кандидата."""
    page1 = [
        {"id": 30, "address": "ул. Малышева, 30"},
        {"id": 31, "address": "ул. Ленина, 10"},
    ]
    page2 = [{"id": 32, "address": "ул. Горького, 5"}]
    db = _make_db([page1, page2, []])

    with (
        patch(
            "app.tasks.backfill_listings_coords_geoportal._geoportal_house_match",
            return_value=None,
        ),
        patch(
            "app.tasks.backfill_listings_coords_geoportal._parse_street_house",
            return_value=("малышева", "30"),
        ),
    ):
        res = backfill_coords_from_geoportal(db, batch_size=2)

    assert res.candidates == 3
    assert res.no_match == 3


# ── run_geoportal_coords_backfill (run-lifecycle wrapper, #1967) ──────────────


def test_run_wrapper_marks_done_with_counters(monkeypatch: pytest.MonkeyPatch) -> None:
    """run_geoportal_coords_backfill: delegates to backfill_coords_from_geoportal, mark_done."""
    marked: dict[str, Any] = {}
    monkeypatch.setattr(bcg.runs_mod, "update_heartbeat", lambda *a, **k: None)
    monkeypatch.setattr(
        bcg.runs_mod,
        "mark_done",
        lambda _db, run_id, counters: marked.update(run_id=run_id, counters=dict(counters)),
    )
    monkeypatch.setattr(bcg.runs_mod, "mark_failed", lambda *a, **k: None)

    fake_result = BackfillCoordsResult(
        candidates=10, matched=3, updated=3, no_address=2, no_match=5, errors=0
    )
    monkeypatch.setattr(
        bcg,
        "backfill_coords_from_geoportal",
        lambda _db, **k: fake_result,
    )

    out = bcg.run_geoportal_coords_backfill(
        MagicMock(),
        run_id=42,
        params={"batch_size": 500},
    )

    assert out is fake_result
    assert marked["run_id"] == 42
    assert marked["counters"]["candidates"] == 10
    assert marked["counters"]["matched"] == 3
    assert marked["counters"]["updated"] == 3


def test_run_wrapper_marks_failed_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
    """run_geoportal_coords_backfill: exception → mark_failed + re-raise (no silent failure)."""
    failed: dict[str, Any] = {}
    monkeypatch.setattr(bcg.runs_mod, "update_heartbeat", lambda *a, **k: None)
    monkeypatch.setattr(bcg.runs_mod, "mark_done", lambda *a, **k: None)
    monkeypatch.setattr(
        bcg.runs_mod,
        "mark_failed",
        lambda _db, run_id, err, counters: failed.update(run_id=run_id, err=err),
    )

    def _boom(_db: Any, **_k: Any) -> BackfillCoordsResult:
        raise RuntimeError("db down")

    monkeypatch.setattr(bcg, "backfill_coords_from_geoportal", _boom)

    class _DB:
        def rollback(self) -> None:
            pass

    with pytest.raises(RuntimeError):
        bcg.run_geoportal_coords_backfill(_DB(), run_id=9, params={})  # type: ignore[arg-type]

    assert failed["run_id"] == 9
    assert "db down" in failed["err"]


# ── Migration content (#1967) ──────────────────────────────────────────────────


def test_migration_171_seeds_schedule_before_geocode_missing_listings_window() -> None:
    sql = _MIGRATION_171.read_text(encoding="utf-8")
    assert "'geoportal_coords_backfill'" in sql
    assert "ON CONFLICT (source) DO NOTHING" in sql
    assert "BEGIN;" in sql and "COMMIT;" in sql
    # Window 5-6 UTC — before geocode_missing_listings' 06:00-09:00 window (migration 110).
    assert "\n    5,\n    6,\n" in sql
    # enabled=true — safe by default (pure internal DB op, no external HTTP).
    assert "'geoportal_coords_backfill',\n    true," in sql
