"""Unit tests for PR Q3 — backfill_houses_dadata.py.

Coverage:
    - Happy path: row с NULL coords + qc_geo=0 → UPDATE с полным набором полей.
    - qc_geo=3 (city-level) → tracking only, lat/lon не overwrite'аются.
    - DaData returns None → tracking only (qc_geo / qc_house = NULL).
    - Per-row SAVEPOINT: exception от clean_address не валит batch.
    - --dry-run: nothing written в DB.
    - --limit N: батч обрезается до N даже если кандидатов больше.
    - --priority coords / cadnum / both — фильтр source-rows работает.
    - _is_enriched_result — accept / reject правила.
    - main() с отсутствующими DADATA_API_TOKEN / SECRET → SystemExit.

No real Postgres. Session — MagicMock что записывает UPDATE и роутит SELECT
side-effects (тот же pattern что в test_backfill_house_coords.py /
test_backfill_listing_sources.py).
"""

from __future__ import annotations

import os
from unittest.mock import AsyncMock, MagicMock, patch

# Settings требует DSN на import — set dummy DSN до любого app.* import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")

import pytest

from app.services.dadata import DadataAddressResult
from scripts.backfill_houses_dadata import (
    HouseRow,
    Stats,
    _is_enriched_result,
    _parse_args,
    _result_in_ekb,
    _run_backfill,
    _select_candidates,
    _update_house_attempt,
    _update_house_enriched,
    _with_city_context,
    main,
)

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


def _make_dadata_result(
    *,
    qc_geo: int | None = 0,
    qc_house: int | None = 2,
    lat: float | None = 56.838,
    lon: float | None = 60.586,
    cadnum: str | None = "66:41:0704045:350",
    fias: str | None = "abcdef00-aaaa-bbbb-cccc-1234567890ab",
    canonical: str | None = "г Екатеринбург, ул Малышева, д 51",
) -> DadataAddressResult:
    """DadataAddressResult fixture с sensible defaults для tests."""
    return DadataAddressResult(
        canonical_address=canonical,
        house_cadnum=cadnum,
        house_fias_id=fias,
        lat=lat,
        lon=lon,
        qc_geo=qc_geo,
        qc_house=qc_house,
        kladr_id=None,
        okato=None,
        oktmo=None,
        metro=[],
        raw={"test": True},
    )


def _make_db_mock(
    coords_sample: list[dict] | None = None,
    cadnum_sample: list[dict] | None = None,
    listings_sample: list[dict] | None = None,
) -> tuple[MagicMock, list[dict]]:
    """MagicMock Session что:
      - возвращает coords_sample для coords-priority SELECT (`lat IS NULL OR lon IS NULL`)
      - возвращает listings_sample для listings-priority SELECT
        (WHERE содержит `cadastral_number IS NULL AND EXISTS ... listings`)
      - возвращает cadnum_sample для cadnum-priority SELECT (`cadastral_number IS NULL`)
      - записывает UPDATE houses в `updated` список (полные binds dict'ы)
      - корректно работает с `db.begin_nested()` контекст-менеджером

    Порядок проверок важен: все три SELECT'а теперь содержат
    `EXISTS ... listings` в projection (`AS has_active_listings`), поэтому
    listings-priority детектим по EXISTS *в WHERE* (`cadastral_number IS NULL
    AND EXISTS`) ДО общего `cadastral_number IS NULL` бранча.
    """
    coords_sample = coords_sample or []
    cadnum_sample = cadnum_sample or []
    listings_sample = listings_sample or []
    updated: list[dict] = []

    db = MagicMock()
    db.begin_nested.return_value.__enter__ = lambda self: self
    db.begin_nested.return_value.__exit__ = lambda self, *a: False

    def execute_side_effect(sql, params=None):
        sql_str = str(sql)
        result = MagicMock()
        if "FROM houses" in sql_str and "lat IS NULL OR lon IS NULL" in sql_str:
            lim = params.get("lim", len(coords_sample)) if params else len(coords_sample)
            result.mappings.return_value.all.return_value = coords_sample[:lim]
        elif "FROM houses" in sql_str and "cadastral_number IS NULL AND EXISTS" in sql_str:
            lim = params.get("lim", len(listings_sample)) if params else len(listings_sample)
            result.mappings.return_value.all.return_value = listings_sample[:lim]
        elif "FROM houses" in sql_str and "cadastral_number IS NULL" in sql_str:
            lim = params.get("lim", len(cadnum_sample)) if params else len(cadnum_sample)
            result.mappings.return_value.all.return_value = cadnum_sample[:lim]
        elif "UPDATE houses" in sql_str:
            updated.append(dict(params) if params else {})
        return result

    db.execute.side_effect = execute_side_effect
    db.commit = MagicMock()
    db.rollback = MagicMock()
    db.close = MagicMock()
    return db, updated


# ---------------------------------------------------------------------------
# _is_enriched_result — accept / reject правила
# ---------------------------------------------------------------------------


def test_is_enriched_result_qc_geo_0_lat_lon_present_accepts():
    res = _make_dadata_result(qc_geo=0, lat=56.838, lon=60.586)
    assert _is_enriched_result(res) is True


def test_is_enriched_result_qc_geo_1_accepts():
    """qc_geo=1 (street-level) — также accept'аем для overwrite."""
    res = _make_dadata_result(qc_geo=1, lat=56.838, lon=60.586)
    assert _is_enriched_result(res) is True


def test_is_enriched_result_qc_geo_2_rejects():
    """qc_geo=2 (settlement) — слишком грубо для дома."""
    res = _make_dadata_result(qc_geo=2)
    assert _is_enriched_result(res) is False


def test_is_enriched_result_qc_geo_3_rejects():
    """qc_geo=3 (city) — точно не дом."""
    res = _make_dadata_result(qc_geo=3)
    assert _is_enriched_result(res) is False


def test_is_enriched_result_none_input_rejects():
    assert _is_enriched_result(None) is False


def test_is_enriched_result_qc_geo_ok_but_no_coords_rejects():
    """Defensive: qc_geo=0 но lat/lon отсутствуют → не overwrite."""
    res = _make_dadata_result(qc_geo=0, lat=None, lon=None)
    assert _is_enriched_result(res) is False


def test_is_enriched_result_qc_geo_ok_but_non_ekb_city_rejects():
    """Safety: qc=0 + coords но город НЕ Екатеринбург → не overwrite.

    DaData без явного города мапит тёзки в Москву (баг dry-run 2026-05-27).
    Защищаемся post-filter'ом по `canonical_address`/`raw.city`.
    """
    res = _make_dadata_result(
        qc_geo=0,
        lat=55.755,
        lon=37.617,
        canonical="г Москва, ул Просторная, д 1",
    )
    assert _is_enriched_result(res) is False


# ---------------------------------------------------------------------------
# _with_city_context — pre-pending «Екатеринбург, »
# ---------------------------------------------------------------------------


def test_with_city_context_prepends_when_missing():
    assert _with_city_context("улица Малышева, 125") == "Екатеринбург, улица Малышева, 125"


def test_with_city_context_is_idempotent_when_city_present():
    """Уже-полный адрес не префиксируется повторно."""
    full = "Екатеринбург, улица Малышева, 125"
    assert _with_city_context(full) == full


def test_with_city_context_case_insensitive():
    """ЕКАТЕРИНБУРГ / екатеринбург — тоже не префиксируем."""
    assert _with_city_context("ЕКАТЕРИНБУРГ, ул. Ленина") == "ЕКАТЕРИНБУРГ, ул. Ленина"
    assert _with_city_context("екатеринбург, ленина 1") == "екатеринбург, ленина 1"


def test_with_city_context_empty_passthrough():
    """Пустой / None не падает."""
    assert _with_city_context("") == ""


# ---------------------------------------------------------------------------
# _result_in_ekb — городской post-filter
# ---------------------------------------------------------------------------


def test_result_in_ekb_uses_canonical_address_fallback():
    """Если raw.city пустой — fallback на substring в canonical_address."""
    res = _make_dadata_result(canonical="г Екатеринбург, ул Малышева, д 125")
    assert _result_in_ekb(res) is True


def test_result_in_ekb_uses_raw_city_field():
    """raw['city']='Екатеринбург' → True (даже если canonical другое)."""
    res = _make_dadata_result(canonical="ул Малышева, д 125")
    res = DadataAddressResult(
        canonical_address=res.canonical_address,
        house_cadnum=res.house_cadnum,
        house_fias_id=res.house_fias_id,
        lat=res.lat,
        lon=res.lon,
        qc_geo=res.qc_geo,
        qc_house=res.qc_house,
        kladr_id=None,
        okato=None,
        oktmo=None,
        metro=[],
        raw={"city": "Екатеринбург"},
    )
    assert _result_in_ekb(res) is True


def test_result_in_ekb_rejects_moscow():
    res = _make_dadata_result(canonical="г Москва, ул Просторная, д 1")
    assert _result_in_ekb(res) is False


def test_result_in_ekb_none_input():
    assert _result_in_ekb(None) is False


# ---------------------------------------------------------------------------
# _update_house_enriched — bind shape
# ---------------------------------------------------------------------------


def test_update_house_enriched_passes_full_payload():
    """UPDATE houses содержит lat/lon/cadnum/fias/qc-codes и dadata_enriched_at = NOW()."""
    db = MagicMock()
    res = _make_dadata_result(
        qc_geo=0, qc_house=2, lat=56.838, lon=60.586,
        cadnum="66:41:0704045:350", fias="fias-uuid-here",
    )
    _update_house_enriched(db, house_id=42, result=res)

    args, _kw = db.execute.call_args
    sql_str = str(args[0])
    binds = args[1]
    assert "UPDATE houses" in sql_str
    assert "dadata_enriched_at = NOW()" in sql_str
    assert "cadastral_number" in sql_str
    assert "house_fias_id" in sql_str
    assert binds["id"] == 42
    assert binds["lat"] == 56.838
    assert binds["lon"] == 60.586
    assert binds["cadnum"] == "66:41:0704045:350"
    assert binds["fias"] == "fias-uuid-here"
    assert binds["qc_geo"] == 0
    assert binds["qc_house"] == 2


def test_update_house_attempt_writes_only_tracking_fields():
    """attempt-write не должен затрагивать lat/lon/cadnum/fias columns."""
    db = MagicMock()
    _update_house_attempt(db, house_id=7, qc_geo=3, qc_house=10)
    args, _kw = db.execute.call_args
    sql_str = str(args[0])
    binds = args[1]
    assert "UPDATE houses" in sql_str
    assert "dadata_enriched_at = NOW()" in sql_str
    # Не должно быть UPDATE на coord-payload поля
    assert "lat =" not in sql_str
    assert "lon =" not in sql_str
    assert "cadastral_number =" not in sql_str
    assert "house_fias_id =" not in sql_str
    assert binds == {"id": 7, "qc_geo": 3, "qc_house": 10}


# ---------------------------------------------------------------------------
# _run_backfill — happy path qc_geo=0
# ---------------------------------------------------------------------------


async def test_run_backfill_qc_geo_0_writes_enriched_update():
    """Happy path — qc_geo=0 → UPDATE с полным payload, stats.enriched=1."""
    row = HouseRow(id=1, address="ул Малышева 51", lat=None, lon=None, cadastral_number=None)
    db, updated = _make_db_mock()
    fake = _make_dadata_result(qc_geo=0)

    with patch(
        "scripts.backfill_houses_dadata.clean_address",
        new=AsyncMock(return_value=fake),
    ):
        stats = await _run_backfill(
            db, [(row, "coords")], batch="b1", dry_run=False
        )

    assert stats.enriched == 1
    assert stats.no_change == 0
    assert stats.failed == 0
    assert stats.processed == 1
    assert len(updated) == 1
    assert updated[0]["lat"] == 56.838
    assert updated[0]["lon"] == 60.586
    assert updated[0]["cadnum"] == "66:41:0704045:350"
    assert updated[0]["qc_geo"] == 0
    assert db.commit.call_count == 1
    assert stats.by_priority["coords"]["enriched"] == 1


# ---------------------------------------------------------------------------
# _run_backfill — qc_geo=3 city-level → tracking-only
# ---------------------------------------------------------------------------


async def test_run_backfill_qc_geo_3_records_attempt_only():
    """qc_geo=3 → НЕ overwrite coords, только tracking UPDATE."""
    row = HouseRow(
        id=2, address="Екатеринбург (без улицы)", lat=None, lon=None, cadastral_number=None
    )
    db, updated = _make_db_mock()
    fake = _make_dadata_result(qc_geo=3, qc_house=None, lat=None, lon=None, cadnum=None, fias=None)

    with patch(
        "scripts.backfill_houses_dadata.clean_address",
        new=AsyncMock(return_value=fake),
    ):
        stats = await _run_backfill(
            db, [(row, "coords")], batch="b2", dry_run=False
        )

    assert stats.enriched == 0
    assert stats.no_change == 1
    assert len(updated) == 1
    # Tracking-only UPDATE — нет lat/lon/cadnum binds
    assert "lat" not in updated[0]
    assert "lon" not in updated[0]
    assert updated[0]["qc_geo"] == 3
    assert stats.by_priority["coords"]["no_change"] == 1


# ---------------------------------------------------------------------------
# _run_backfill — DaData returns None (graceful)
# ---------------------------------------------------------------------------


async def test_run_backfill_dadata_returns_none_records_attempt():
    """clean_address возвращает None (HTTP error / unrecognized) → tracking only с NULL qc."""
    row = HouseRow(id=3, address="несуществующая 99", lat=None, lon=None, cadastral_number=None)
    db, updated = _make_db_mock()

    with patch(
        "scripts.backfill_houses_dadata.clean_address",
        new=AsyncMock(return_value=None),
    ):
        stats = await _run_backfill(
            db, [(row, "coords")], batch="b3", dry_run=False
        )

    assert stats.no_change == 1
    assert stats.enriched == 0
    assert len(updated) == 1
    assert updated[0]["qc_geo"] is None
    assert updated[0]["qc_house"] is None


# ---------------------------------------------------------------------------
# Per-row SAVEPOINT — exception в clean_address не валит batch
# ---------------------------------------------------------------------------


async def test_run_backfill_clean_address_exception_isolated_to_row():
    """Один clean_address raise → этот row failed, остальные processed normally."""
    rows = [
        (HouseRow(id=10, address="ул Bad", lat=None, lon=None, cadastral_number=None), "coords"),
        (HouseRow(id=11, address="ул Good 5", lat=None, lon=None, cadastral_number=None), "coords"),
    ]
    db, updated = _make_db_mock()
    good_result = _make_dadata_result(qc_geo=0)

    call_count = {"n": 0}

    async def fake_clean(addr):
        call_count["n"] += 1
        if call_count["n"] == 1:
            raise RuntimeError("dadata blew up")
        return good_result

    with patch("scripts.backfill_houses_dadata.clean_address", side_effect=fake_clean):
        stats = await _run_backfill(db, rows, batch="b4", dry_run=False)

    assert stats.processed == 1  # только 2-й row дошёл до DB-write success
    assert stats.failed == 1
    assert stats.enriched == 1
    # Только один UPDATE — для второго row
    assert len(updated) == 1
    assert updated[0]["id"] == 11


# ---------------------------------------------------------------------------
# --dry-run — no DB writes
# ---------------------------------------------------------------------------


async def test_run_backfill_dry_run_skips_db_writes():
    """dry_run=True → counters обновляются, но db.execute UPDATE не вызывается."""
    row = HouseRow(id=20, address="ул Test 1", lat=None, lon=None, cadastral_number=None)
    db, updated = _make_db_mock()
    fake = _make_dadata_result(qc_geo=0)

    with patch(
        "scripts.backfill_houses_dadata.clean_address",
        new=AsyncMock(return_value=fake),
    ):
        stats = await _run_backfill(
            db, [(row, "coords")], batch="dry", dry_run=True
        )

    assert stats.processed == 1
    assert stats.enriched == 1
    assert updated == []  # никаких UPDATE'ов
    assert db.commit.call_count == 0  # никаких commit'ов


# ---------------------------------------------------------------------------
# _select_candidates — priority filter работает
# ---------------------------------------------------------------------------


def test_select_candidates_coords_priority_only_fetches_null_coords():
    """priority=coords → запрашивается только `lat IS NULL OR lon IS NULL` SELECT."""
    coords_rows = [
        {"id": 1, "address": "ул A 1", "lat": None, "lon": None, "cadastral_number": None},
        {"id": 2, "address": "ул B 2", "lat": None, "lon": None, "cadastral_number": None},
    ]
    db, _updated = _make_db_mock(coords_sample=coords_rows)

    out = _select_candidates(db, priority="coords", limit=10)

    assert len(out) == 2
    assert all(p == "coords" for _row, p in out)
    assert out[0][0].id == 1
    assert out[1][0].id == 2


def test_select_candidates_cadnum_priority_only_fetches_null_cadnum():
    """priority=cadnum → только NOT NULL coords + NULL cadastral_number rows."""
    cadnum_rows = [
        {"id": 50, "address": "ул C 3", "lat": 56.8, "lon": 60.6, "cadastral_number": None},
    ]
    db, _updated = _make_db_mock(cadnum_sample=cadnum_rows)

    out = _select_candidates(db, priority="cadnum", limit=10)

    assert len(out) == 1
    assert out[0][1] == "cadnum"
    assert out[0][0].id == 50


def test_select_candidates_both_priority_fetches_coords_first_then_cadnum():
    """priority=both → сначала NULL coords, потом NULL cadnum (в пределах limit)."""
    coords_rows = [
        {"id": 1, "address": "ул A 1", "lat": None, "lon": None, "cadastral_number": None},
    ]
    cadnum_rows = [
        {"id": 100, "address": "ул C 3", "lat": 56.8, "lon": 60.6, "cadastral_number": None},
    ]
    db, _updated = _make_db_mock(coords_sample=coords_rows, cadnum_sample=cadnum_rows)

    out = _select_candidates(db, priority="both", limit=10)

    assert len(out) == 2
    # coords bucket первый
    assert out[0][1] == "coords"
    assert out[0][0].id == 1
    assert out[1][1] == "cadnum"
    assert out[1][0].id == 100


# ---------------------------------------------------------------------------
# accuracy-roadmap #2 — active-listings prioritization (EXISTS + ORDER BY)
# ---------------------------------------------------------------------------


def _capture_select_sql(db: MagicMock) -> list[str]:
    """Извлекает SQL-строки всех SELECT FROM houses из db.execute.call_args_list."""
    out: list[str] = []
    for call in db.execute.call_args_list:
        sql_str = str(call.args[0])
        if "FROM houses" in sql_str and "SELECT" in sql_str.upper():
            out.append(sql_str)
    return out


def test_select_candidates_coords_sql_has_active_listings_exists_and_order():
    """coords-priority SELECT включает has_active_listings EXISTS + ORDER BY DESC."""
    coords_rows = [
        {"id": 1, "address": "ул A 1", "lat": None, "lon": None, "cadastral_number": None},
    ]
    db, _updated = _make_db_mock(coords_sample=coords_rows)

    _select_candidates(db, priority="coords", limit=10)

    sqls = _capture_select_sql(db)
    assert len(sqls) == 1
    sql = sqls[0]
    # EXISTS на listings.house_id_fk → houses.id + is_active, aliased в projection
    assert "EXISTS (SELECT 1 FROM listings l" in sql
    assert "l.house_id_fk = houses.id" in sql
    assert "l.is_active" in sql
    assert "AS has_active_listings" in sql
    # active-listings дома сортируются первыми, затем стабильно по id
    assert "ORDER BY has_active_listings DESC, id" in sql


def test_select_candidates_cadnum_sql_has_active_listings_order():
    """cadnum-priority SELECT — тот же EXISTS + ORDER BY (приоритизация во всех режимах)."""
    cadnum_rows = [
        {"id": 50, "address": "ул C 3", "lat": 56.8, "lon": 60.6, "cadastral_number": None},
    ]
    db, _updated = _make_db_mock(cadnum_sample=cadnum_rows)

    _select_candidates(db, priority="cadnum", limit=10)

    sql = _capture_select_sql(db)[0]
    assert "AS has_active_listings" in sql
    assert "ORDER BY has_active_listings DESC, id" in sql


def test_select_candidates_active_listings_sort_first_within_bucket():
    """ORDER BY DESC: дом с active listings идёт перед домом без — within один bucket.

    Mock возвращает rows уже в order'е, в котором их вернул бы Postgres
    (has_active_listings DESC) — проверяем, что _select_candidates сохраняет
    порядок и не пересортировывает по id.
    """
    # id=2 имеет active listing → Postgres вернёт его первым несмотря на больший id
    coords_rows = [
        {"id": 2, "address": "ул B 2", "lat": None, "lon": None, "cadastral_number": None},
        {"id": 1, "address": "ул A 1", "lat": None, "lon": None, "cadastral_number": None},
    ]
    db, _updated = _make_db_mock(coords_sample=coords_rows)

    out = _select_candidates(db, priority="coords", limit=10)

    assert [r.id for r, _p in out] == [2, 1]


def test_select_candidates_listings_priority_filters_null_cadnum_and_active():
    """priority=listings → WHERE = NULL cadnum AND EXISTS active listing."""
    listings_rows = [
        {"id": 7, "address": "ул D 4", "lat": 56.8, "lon": 60.6, "cadastral_number": None},
        {"id": 8, "address": "ул E 5", "lat": None, "lon": None, "cadastral_number": None},
    ]
    db, _updated = _make_db_mock(listings_sample=listings_rows)

    out = _select_candidates(db, priority="listings", limit=10)

    assert len(out) == 2
    assert all(p == "listings" for _row, p in out)
    assert [r.id for r, _p in out] == [7, 8]

    sql = _capture_select_sql(db)[0]
    # Фильтр в WHERE: NULL cadnum AND active-listing EXISTS
    assert "cadastral_number IS NULL AND EXISTS (SELECT 1 FROM listings l" in sql
    assert "l.house_id_fk = houses.id" in sql
    assert "l.is_active" in sql
    assert "ORDER BY has_active_listings DESC, id" in sql


def test_select_candidates_listings_priority_skips_coords_and_cadnum_buckets():
    """priority=listings не должен трогать coords/cadnum SELECT'ы — только один fetch."""
    listings_rows = [
        {"id": 7, "address": "ул D 4", "lat": 56.8, "lon": 60.6, "cadastral_number": None},
    ]
    coords_rows = [
        {"id": 1, "address": "ул A 1", "lat": None, "lon": None, "cadastral_number": None},
    ]
    cadnum_rows = [
        {"id": 99, "address": "ул Z 9", "lat": 56.8, "lon": 60.6, "cadastral_number": None},
    ]
    db, _updated = _make_db_mock(
        coords_sample=coords_rows, cadnum_sample=cadnum_rows, listings_sample=listings_rows
    )

    out = _select_candidates(db, priority="listings", limit=10)

    # Ровно один SELECT FROM houses (listings bucket), coords/cadnum не запрошены
    assert len(_capture_select_sql(db)) == 1
    assert [r.id for r, _p in out] == [7]


def test_select_candidates_listings_priority_respects_limit():
    """priority=listings прокидывает limit в LIMIT bind."""
    listings_rows = [
        {"id": 7, "address": "ул D 4", "lat": 56.8, "lon": 60.6, "cadastral_number": None},
        {"id": 8, "address": "ул E 5", "lat": 56.8, "lon": 60.6, "cadastral_number": None},
        {"id": 9, "address": "ул F 6", "lat": 56.8, "lon": 60.6, "cadastral_number": None},
    ]
    db, _updated = _make_db_mock(listings_sample=listings_rows)

    out = _select_candidates(db, priority="listings", limit=2)

    assert len(out) == 2
    # bind lim передан в execute
    _sql, params = db.execute.call_args.args
    assert params["lim"] == 2


# ---------------------------------------------------------------------------
# --limit caps batch size
# ---------------------------------------------------------------------------


async def test_main_respects_limit(monkeypatch):
    """main(--limit 1) при 3 кандидатах → только 1 row processed."""
    coords_rows = [
        {"id": 1, "address": "ул A 1", "lat": None, "lon": None, "cadastral_number": None},
        {"id": 2, "address": "ул B 2", "lat": None, "lon": None, "cadastral_number": None},
        {"id": 3, "address": "ул C 3", "lat": None, "lon": None, "cadastral_number": None},
    ]
    db, updated = _make_db_mock(coords_sample=coords_rows)
    monkeypatch.setenv("DADATA_API_TOKEN", "TEST_TOKEN")
    monkeypatch.setenv("DADATA_API_SECRET", "TEST_SECRET")
    fake = _make_dadata_result(qc_geo=0)

    with (
        patch("scripts.backfill_houses_dadata.SessionLocal", return_value=db),
        patch(
            "scripts.backfill_houses_dadata.clean_address",
            new=AsyncMock(return_value=fake),
        ),
        # Уберём sleep чтобы test не висел 0.5s
        patch("scripts.backfill_houses_dadata.asyncio.sleep", new=AsyncMock()),
    ):
        n = await main(["--limit", "1", "--batch", "test_limit"])

    assert n == 1
    assert len(updated) == 1


# ---------------------------------------------------------------------------
# --priority listings — CLI choice + end-to-end routing
# ---------------------------------------------------------------------------


def test_parse_args_default_priority_is_both():
    """Дефолт priority остаётся 'both' (не ломаем существующий cron)."""
    assert _parse_args([]).priority == "both"


def test_parse_args_accepts_priority_listings():
    """--priority listings — валидный choice."""
    assert _parse_args(["--priority", "listings"]).priority == "listings"


def test_parse_args_still_accepts_legacy_priorities():
    """coords / cadnum / both продолжают работать без изменений."""
    assert _parse_args(["--priority", "coords"]).priority == "coords"
    assert _parse_args(["--priority", "cadnum"]).priority == "cadnum"
    assert _parse_args(["--priority", "both"]).priority == "both"


def test_parse_args_rejects_unknown_priority():
    """Несуществующий priority → argparse SystemExit (choices guard)."""
    with pytest.raises(SystemExit):
        _parse_args(["--priority", "garbage"])


async def test_main_priority_listings_enriches_active_listing_house(monkeypatch):
    """main(--priority listings) гонит listings-bucket rows через enrichment."""
    listings_rows = [
        {"id": 7, "address": "ул D 4", "lat": 56.8, "lon": 60.6, "cadastral_number": None},
    ]
    db, updated = _make_db_mock(listings_sample=listings_rows)
    monkeypatch.setenv("DADATA_API_TOKEN", "TEST_TOKEN")
    monkeypatch.setenv("DADATA_API_SECRET", "TEST_SECRET")
    fake = _make_dadata_result(qc_geo=0)

    with (
        patch("scripts.backfill_houses_dadata.SessionLocal", return_value=db),
        patch(
            "scripts.backfill_houses_dadata.clean_address",
            new=AsyncMock(return_value=fake),
        ),
        patch("scripts.backfill_houses_dadata.asyncio.sleep", new=AsyncMock()),
    ):
        n = await main(["--priority", "listings", "--batch", "test_listings"])

    assert n == 1
    assert len(updated) == 1
    assert updated[0]["id"] == 7
    assert updated[0]["cadnum"] == "66:41:0704045:350"


# ---------------------------------------------------------------------------
# Missing creds → SystemExit
# ---------------------------------------------------------------------------


async def test_main_requires_dadata_creds(monkeypatch):
    """Без DADATA_API_TOKEN + DADATA_API_SECRET → SystemExit ещё до DB connect."""
    monkeypatch.delenv("DADATA_API_TOKEN", raising=False)
    monkeypatch.delenv("DADATA_API_SECRET", raising=False)
    with pytest.raises(SystemExit):
        await main(["--limit", "1"])


async def test_main_requires_secret_not_just_token(monkeypatch):
    """Только TOKEN (без SECRET) → /clean/address не сработает → fail fast."""
    monkeypatch.setenv("DADATA_API_TOKEN", "TEST_TOKEN")
    monkeypatch.delenv("DADATA_API_SECRET", raising=False)
    with pytest.raises(SystemExit):
        await main(["--limit", "1"])


# ---------------------------------------------------------------------------
# Stats summary contains by_priority breakdown
# ---------------------------------------------------------------------------


def test_stats_bump_buckets_by_priority():
    """Stats.bump поддерживает несколько priority bucket'ов одновременно."""
    s = Stats()
    s.bump("coords", "enriched")
    s.bump("coords", "enriched")
    s.bump("cadnum", "no_change")
    assert s.by_priority["coords"]["enriched"] == 2
    assert s.by_priority["cadnum"]["no_change"] == 1
    # Не существующие outcome'ы должны быть 0
    assert s.by_priority["coords"]["failed"] == 0
