"""Unit tests for issue #569 Step 2 — geocode_deals_from_houses.py.

Coverage:
    - _street_key normalization: the three spec examples collapse to one key,
      plus boundary cases (numeric streets '8 марта', names that start with a
      type-token substring like 'алмазная', district/apt/region stripping).
    - _build_centroid_map: AVG(lat)/AVG(lon) over multiple houses on one street.
    - _run_backfill happy path: a deal whose street matches → UPDATE with the
      centroid lat/lon, geocoded counter bumps.
    - _run_backfill miss: a deal whose street has no centroid → no UPDATE,
      counted as no_street_match + tracked for the dry-run report.
    - --dry-run: no UPDATE / no commit, counters still move.
    - per-row SAVEPOINT isolates a failing UPDATE.
    - main() wires SessionLocal, respects --limit, and returns geocoded count.

No real Postgres. Session is a MagicMock that routes SELECT side-effects by
SQL substring and records UPDATE binds (same pattern as
test_backfill_houses_dadata.py).
"""

from __future__ import annotations

import os
from unittest.mock import MagicMock, patch

import pytest

# Settings needs a DSN at import time — set a dummy before any app.* import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")

from scripts.geocode_deals_from_houses import (
    Centroid,
    DealRow,
    Stats,
    _build_centroid_map,
    _run_backfill,
    _street_key,
    _update_deal_coords,
    main,
)

# ---------------------------------------------------------------------------
# Mock-DB helper
# ---------------------------------------------------------------------------


def _make_db_mock(
    *,
    house_rows: list[dict] | None = None,
    deal_rows: list[dict] | None = None,
    null_count: int = 0,
) -> tuple[MagicMock, list[dict]]:
    """MagicMock Session that:

    - returns `house_rows` for the centroid SELECT (`FROM houses`)
    - returns `deal_rows` for the candidate SELECT (`FROM deals ... lat IS NULL`)
    - returns `null_count` for the `count(*)` SELECT
    - records UPDATE binds into the returned `updated` list
    - supports `db.begin_nested()` as a context manager
    """
    house_rows = house_rows or []
    deal_rows = deal_rows 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 "UPDATE deals" in sql_str:
            updated.append(dict(params) if params else {})
            return result
        if "count(*)" in sql_str:
            result.first.return_value = (null_count,)
            return result
        if "FROM houses" in sql_str:
            result.mappings.return_value.all.return_value = house_rows
            return result
        if "FROM deals" in sql_str:
            lim = params.get("lim", len(deal_rows)) if params else len(deal_rows)
            result.mappings.return_value.all.return_value = deal_rows[:lim]
            return result
        return result

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


# ---------------------------------------------------------------------------
# _street_key — normalization (the crux)
# ---------------------------------------------------------------------------


def test_street_key_three_spec_variants_collapse_to_same_key():
    """The spec's three Малышева variants must produce one identical key."""
    a = _street_key("Екатеринбург, ул. Малышева, 125")
    b = _street_key("г Екатеринбург, улица Малышева")
    c = _street_key("Екатеринбург, Малышева")
    assert a == b == c == "малышева"


def test_street_key_strips_region_and_house_number():
    assert (
        _street_key("Свердловская обл., Екатеринбург, ул. Большакова, 17")
        == "большакова"
    )


def test_street_key_strips_district_marker():
    assert _street_key("улица Яскина, 12 · р-н Октябрьский") == "яскина"


def test_street_key_handles_prospekt():
    assert _street_key("г. Екатеринбург, проспект Ленина, 50") == "ленина"


def test_street_key_preserves_numeric_street_name():
    """'8 марта' is a real street — the leading number must survive while the
    trailing house number is stripped."""
    assert _street_key("Екатеринбург, ул. 8 Марта, 100") == "8 марта"
    assert _street_key("Екатеринбург, 8 Марта") == "8 марта"


def test_street_key_preserves_multiword_numeric_street():
    assert _street_key("Екатеринбург, ул. 40 лет Октября") == "40 лет октября"


def test_street_key_does_not_eat_name_starting_like_type_token():
    """'алмазная' must NOT lose 'ал', 'пришвина' must NOT lose 'пр' — the
    street-type strip is \\b-bounded."""
    assert _street_key("ул. Алмазная, 7") == "алмазная"
    assert _street_key("ул. Пришвина, 3") == "пришвина"


def test_street_key_strips_korpus_and_kv_suffix():
    assert _street_key("Екатеринбург, ул. Крауля, 48, корп. 2") == "крауля"
    assert (
        _street_key("РФ, Свердловская обл., Екатеринбург, ул. Ленина, 5, кв. 12")
        == "ленина"
    )


def test_street_key_does_not_eat_names_starting_like_apt_suffix():
    """Regression (pre-push review): un-anchored _APT_SUFFIX_RE collapsed real
    streets starting with кв/корп/оф/пом/стр («Строителей», «Офицеров»,
    «Корпусная», «Помолова») to the garbage key 'ул.' → wrong centroid → wrong
    coords. Suffix strip now requires a DIGIT after the token."""
    assert _street_key("Екатеринбург, ул. Строителей, 5") == "строителей"
    assert _street_key("Екатеринбург, ул. Офицеров") == "офицеров"
    assert _street_key("Екатеринбург, ул. Корпусная, 14") == "корпусная"
    assert _street_key("Екатеринбург, ул. Помолова, 3") == "помолова"
    # deals-form (no house number) must still resolve to the street, not 'ул.'
    assert _street_key("Екатеринбург, Стрелочников") == "стрелочников"


def test_street_key_normalizes_yo_to_e():
    """Рабочей Молодёжи (ё) and Молодежи (е) must collapse — sources differ."""
    assert _street_key("Екатеринбург, наб. Рабочей Молодёжи, 2") == "рабочей молодежи"
    assert _street_key("Екатеринбург, Рабочей Молодежи") == "рабочей молодежи"


def test_street_key_house_prefix_dom():
    assert _street_key("Екатеринбург, пр. Ленина, д. 5") == "ленина"


def test_street_key_none_and_empty_and_city_only():
    assert _street_key(None) == ""
    assert _street_key("") == ""
    # City alone has no street segment → empty key (caller treats as no-match).
    assert _street_key("Екатеринбург") == ""


# ---------------------------------------------------------------------------
# _build_centroid_map — AVG over multiple houses on a street
# ---------------------------------------------------------------------------


def test_build_centroid_map_averages_houses_on_one_street():
    """Two houses on Малышева → centroid is the mean of their coords."""
    house_rows = [
        {"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50},
        {"address": "Екатеринбург, ул. Малышева, 20", "lat": 56.90, "lon": 60.70},
    ]
    db, _ = _make_db_mock(house_rows=house_rows)

    centroids = _build_centroid_map(db)

    assert set(centroids) == {"малышева"}
    c = centroids["малышева"]
    assert c.lat == pytest.approx(56.85)
    assert c.lon == pytest.approx(60.60)
    assert c.house_count == 2


def test_build_centroid_map_groups_distinct_streets():
    house_rows = [
        {"address": "Екатеринбург, ул. Малышева, 10", "lat": 56.80, "lon": 60.50},
        {"address": "г Екатеринбург, Малышева", "lat": 56.82, "lon": 60.54},
        {"address": "Екатеринбург, проспект Ленина, 5", "lat": 56.84, "lon": 60.60},
    ]
    db, _ = _make_db_mock(house_rows=house_rows)

    centroids = _build_centroid_map(db)

    assert set(centroids) == {"малышева", "ленина"}
    assert centroids["малышева"].house_count == 2
    assert centroids["ленина"].house_count == 1
    # Малышева centroid = mean of the two Малышева rows.
    assert centroids["малышева"].lat == pytest.approx(56.81)
    assert centroids["ленина"].lat == pytest.approx(56.84)


def test_build_centroid_map_skips_unparseable_address():
    """A house whose address yields too-short a key is dropped, not crashed."""
    house_rows = [
        {"address": "Екатеринбург", "lat": 56.8, "lon": 60.6},  # city only → ''
        {"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.84, "lon": 60.6},
    ]
    db, _ = _make_db_mock(house_rows=house_rows)

    centroids = _build_centroid_map(db)
    assert set(centroids) == {"малышева"}


# ---------------------------------------------------------------------------
# _run_backfill — happy path: street match → UPDATE with centroid coords
# ---------------------------------------------------------------------------


def test_run_backfill_matched_street_issues_update_with_centroid():
    deal = DealRow(id=42, address="Екатеринбург, ул. Малышева, 125")
    centroids = {"малышева": Centroid(lat=56.838, lon=60.586, house_count=3)}
    db, updated = _make_db_mock()

    stats = _run_backfill(db, [deal], centroids, batch="b1", dry_run=False)

    assert stats.geocoded == 1
    assert stats.no_street_match == 0
    assert stats.failed == 0
    assert stats.processed == 1
    assert len(updated) == 1
    assert updated[0]["id"] == 42
    assert updated[0]["lat"] == 56.838
    assert updated[0]["lon"] == 60.586
    assert db.commit.call_count == 1


def test_run_backfill_deal_with_only_street_name_matches():
    """Deal address with no house number / no type word still matches."""
    deal = DealRow(id=7, address="Екатеринбург, Малышева")
    centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
    db, updated = _make_db_mock()

    stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)

    assert stats.geocoded == 1
    assert len(updated) == 1
    assert updated[0]["lat"] == 56.8


# ---------------------------------------------------------------------------
# _run_backfill — miss: no centroid → no UPDATE, counted as no_street_match
# ---------------------------------------------------------------------------


def test_run_backfill_unmatched_street_no_update():
    deal = DealRow(id=99, address="Екатеринбург, ул. Несуществующая, 1")
    centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
    db, updated = _make_db_mock()

    stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)

    assert stats.geocoded == 0
    assert stats.no_street_match == 1
    assert stats.processed == 1
    assert updated == []
    assert db.commit.call_count == 0
    # Unmatched street tracked for the dry-run report.
    assert stats.unmatched_streets["несуществующая"] == 1


def test_run_backfill_unparseable_deal_tracked_as_sentinel():
    """A deal whose address yields an empty key is a no-match under a sentinel."""
    deal = DealRow(id=5, address="Екатеринбург")
    centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
    db, updated = _make_db_mock()

    stats = _run_backfill(db, [deal], centroids, batch="b", dry_run=False)

    assert stats.no_street_match == 1
    assert updated == []
    assert stats.unmatched_streets["<no-street-parsed>"] == 1


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


def test_run_backfill_dry_run_issues_no_update():
    deal = DealRow(id=1, address="Екатеринбург, ул. Малышева, 1")
    centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
    db, updated = _make_db_mock()

    stats = _run_backfill(db, [deal], centroids, batch="dry", dry_run=True)

    assert stats.geocoded == 1
    assert stats.processed == 1
    assert updated == []  # no UPDATE
    assert db.commit.call_count == 0  # no commit


# ---------------------------------------------------------------------------
# per-row SAVEPOINT — one bad UPDATE doesn't abort the batch
# ---------------------------------------------------------------------------


def test_run_backfill_db_write_failure_isolated_to_row():
    deals = [
        DealRow(id=10, address="Екатеринбург, ул. Малышева, 1"),
        DealRow(id=11, address="Екатеринбург, ул. Малышева, 2"),
    ]
    centroids = {"малышева": Centroid(lat=56.8, lon=60.5, house_count=1)}
    db, updated = _make_db_mock()

    # Make the FIRST UPDATE raise, the rest succeed.
    call = {"n": 0}
    real_side_effect = db.execute.side_effect

    def failing_execute(sql, params=None):
        if "UPDATE deals" in str(sql):
            call["n"] += 1
            if call["n"] == 1:
                raise RuntimeError("constraint blew up")
        return real_side_effect(sql, params)

    db.execute.side_effect = failing_execute

    stats = _run_backfill(db, deals, centroids, batch="b", dry_run=False)

    assert stats.failed == 1
    assert stats.geocoded == 1
    assert db.rollback.call_count == 1
    # Only the second deal's UPDATE was recorded.
    assert [u["id"] for u in updated] == [11]


# ---------------------------------------------------------------------------
# _update_deal_coords — bind shape + geom NOT set manually
# ---------------------------------------------------------------------------


def test_update_deal_coords_sets_lat_lon_tried_at_not_geom():
    db = MagicMock()
    _update_deal_coords(db, deal_id=3, lat=56.1, lon=60.2)
    args, _kw = db.execute.call_args
    sql_str = str(args[0])
    binds = args[1]
    assert "UPDATE deals" in sql_str
    assert "geocode_tried_at = NOW()" in sql_str
    # geom must NOT be set manually — the deals_set_geom_trg trigger fills it.
    assert "geom" not in sql_str
    assert binds == {"id": 3, "lat": 56.1, "lon": 60.2}


# ---------------------------------------------------------------------------
# main() — wiring, --limit, return value
# ---------------------------------------------------------------------------


def test_main_respects_limit_and_returns_geocoded():
    house_rows = [
        {"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50},
        {"address": "Екатеринбург, ул. Малышева, 2", "lat": 56.90, "lon": 60.70},
    ]
    deal_rows = [
        {"id": 1, "address": "Екатеринбург, Малышева"},
        {"id": 2, "address": "Екатеринбург, ул. Малышева, 9"},
        {"id": 3, "address": "Екатеринбург, ул. Малышева, 99"},
    ]
    db, updated = _make_db_mock(house_rows=house_rows, deal_rows=deal_rows)

    with patch("scripts.geocode_deals_from_houses.SessionLocal", return_value=db):
        n = main(["--limit", "1", "--batch", "test_limit"])

    assert n == 1
    assert len(updated) == 1
    assert updated[0]["id"] == 1
    # Centroid used = mean of the two house rows.
    assert updated[0]["lat"] == pytest.approx(56.85)
    assert updated[0]["lon"] == pytest.approx(60.60)


def test_main_dry_run_writes_nothing():
    house_rows = [
        {"address": "Екатеринбург, ул. Малышева, 1", "lat": 56.80, "lon": 60.50},
    ]
    deal_rows = [
        {"id": 1, "address": "Екатеринбург, Малышева"},
        {"id": 2, "address": "Екатеринбург, ул. Неизвестная, 5"},
    ]
    db, updated = _make_db_mock(house_rows=house_rows, deal_rows=deal_rows, null_count=2)

    with patch("scripts.geocode_deals_from_houses.SessionLocal", return_value=db):
        n = main(["--dry-run"])

    # dry-run returns geocoded count (would-match), writes nothing.
    assert n == 1
    assert updated == []
    assert db.commit.call_count == 0


def test_main_no_centroids_returns_zero():
    """Empty houses table → nothing to join against → graceful 0."""
    db, updated = _make_db_mock(house_rows=[], deal_rows=[{"id": 1, "address": "x"}])

    with patch("scripts.geocode_deals_from_houses.SessionLocal", return_value=db):
        n = main([])

    assert n == 0
    assert updated == []


# ---------------------------------------------------------------------------
# Stats dataclass
# ---------------------------------------------------------------------------


def test_stats_unmatched_streets_counter_defaults_empty():
    s = Stats()
    assert s.processed == 0
    assert s.geocoded == 0
    assert s.no_street_match == 0
    assert s.failed == 0
    assert s.unmatched_streets.most_common(3) == []
