"""Unit-тесты OSM-геокода городских ТП/РП ЕКБ (#2119 B2, финал).

Pure / mock-based — без реальной сети и БД. Покрывает:
- extract_tp_number: «РП-101»→101, «ТП 1591»→1591, голый ref='101'→101,
  «110 кВ» БЕЗ ТП-токена/ref→None, traction-скип на уровне fetch.
- fetch_osm_substations: скип substation=traction (mock httpx.post).
- _index_osm_by_number: дубль-номер (разные координаты) → ambiguous, не матчится;
  дубль-теги одной точки (way+node, те же координаты) → НЕ ambiguous.
- geocode_tp_from_osm: dry-run НЕ пишет; apply=True шлёт UPDATE с CAST-формами
  и geom IS NULL-гейтом; sanity-bbox отсекает координату вне ЕКБ.
- Celery-регистрация таска sync_osm_tp_geocode + beat-строка.
"""

from __future__ import annotations

from contextlib import contextmanager
from typing import Any

from app.services.site_finder import osm_tp_geocoder as og

# ── extract_tp_number ────────────────────────────────────────────────────────


def test_extract_tp_number_rp_token() -> None:
    """«РП-101» в name → 101 (ТП-токен + число)."""
    assert og.extract_tp_number({"name": "РП-101"}) == "101"


def test_extract_tp_number_tp_hyphen() -> None:
    """«ТП-180» → 180."""
    assert og.extract_tp_number({"name": "ТП-180"}) == "180"


def test_extract_tp_number_tp_space() -> None:
    """«ТП 1591» (пробел-разделитель) → 1591."""
    assert og.extract_tp_number({"name": "ТП 1591"}) == "1591"


def test_extract_tp_number_bare_ref() -> None:
    """Голый ref='101' (без ТП-токена) → 101 — оператор проставил ref явно."""
    assert og.extract_tp_number({"ref": "101"}) == "101"


def test_extract_tp_number_ref_priority_over_name() -> None:
    """ref приоритетнее name: ref='42', name='ТП-999' → 42."""
    assert og.extract_tp_number({"ref": "42", "name": "ТП-999"}) == "42"


def test_extract_tp_number_voltage_without_token_is_none() -> None:
    """«110 кВ» БЕЗ ТП-токена и без ref → None (это класс напряжения, не номер)."""
    assert og.extract_tp_number({"name": "Подстанция 110 кВ"}) is None
    assert og.extract_tp_number({"name": "110 кВ", "voltage": "110000"}) is None


def test_extract_tp_number_empty_tags_is_none() -> None:
    """Нет ни ref, ни name → None."""
    assert og.extract_tp_number({}) is None


def test_extract_tp_number_latin_tp_token() -> None:
    """Латинский токен «TP-42» → 42."""
    assert og.extract_tp_number({"name": "TP-42"}) == "42"


# ── fetch_osm_substations: traction-скип ─────────────────────────────────────


class _FakeResp:
    def __init__(self, payload: dict) -> None:
        self._payload = payload

    def raise_for_status(self) -> None:
        pass

    def json(self) -> dict:
        return self._payload


def test_fetch_skips_traction(monkeypatch: Any) -> None:
    """substation=traction (тяговые ж/д) отсеиваются; обычные ТП остаются."""
    payload = {
        "elements": [
            {"type": "node", "id": 1, "lat": 56.8, "lon": 60.6, "tags": {"name": "ТП-1"}},
            {
                "type": "node",
                "id": 2,
                "lat": 56.8,
                "lon": 60.6,
                "tags": {"substation": "traction", "name": "Тяговая-2"},
            },
        ]
    }

    def _fake_post(url: str, data: Any = None, timeout: Any = None) -> _FakeResp:
        return _FakeResp(payload)

    monkeypatch.setattr(og.httpx, "post", _fake_post)
    kept = og.fetch_osm_substations()
    assert len(kept) == 1
    assert kept[0]["id"] == 1


# ── _index_osm_by_number: ambiguous ──────────────────────────────────────────


def _node(el_id: int, lat: float, lon: float, tags: dict) -> dict:
    return {"type": "node", "id": el_id, "lat": lat, "lon": lon, "tags": tags}


def test_index_ambiguous_duplicate_number() -> None:
    """Один номер несут 2 фичи с РАЗНЫМИ координатами → ambiguous, из матча выпадает."""
    elements = [
        _node(1, 56.80, 60.60, {"name": "ТП-100"}),
        _node(2, 56.85, 60.65, {"name": "ТП-100"}),  # тот же номер, другая точка
        _node(3, 56.82, 60.62, {"name": "ТП-200"}),  # уникальный
    ]
    by_number, ambiguous = og._index_osm_by_number(elements)
    assert "100" in ambiguous
    assert "100" not in by_number
    assert "200" in by_number


def test_index_same_point_not_ambiguous() -> None:
    """Дубль-теги ОДНОЙ точки (way+node на тех же координатах) → НЕ ambiguous."""
    elements = [
        _node(1, 56.80, 60.60, {"name": "ТП-100"}),
        {"type": "way", "id": 2, "center": {"lat": 56.80, "lon": 60.60}, "tags": {"ref": "100"}},
    ]
    by_number, ambiguous = og._index_osm_by_number(elements)
    assert "100" not in ambiguous
    assert by_number["100"] == (56.80, 60.60)


def test_index_out_of_bbox_skipped() -> None:
    """Координата вне bbox ЕКБ (sanity-гейт) → не индексируется."""
    elements = [_node(1, 55.75, 37.61, {"name": "ТП-300"})]  # Москва
    by_number, ambiguous = og._index_osm_by_number(elements)
    assert by_number == {}
    assert ambiguous == set()


def test_index_way_center_coords() -> None:
    """way без lat/lon, но с center → берём center."""
    elements = [
        {"type": "way", "id": 9, "center": {"lat": 56.83, "lon": 60.63}, "tags": {"ref": "555"}}
    ]
    by_number, _ = og._index_osm_by_number(elements)
    assert by_number["555"] == (56.83, 60.63)


# ── Fake DB session (execute + begin_nested), как в test_eesk_reserve_loader ──


class _FakeResult:
    def __init__(self, rows: list[Any] | None = None, rowcount: int = 1) -> None:
        self._rows = rows or []
        self.rowcount = rowcount

    def all(self) -> list[Any]:
        return self._rows


class _FakeSession:
    """Мини-заглушка Session: собирает execute-вызовы, поддерживает begin_nested."""

    def __init__(self, candidate_norms: list[str], rowcount: int = 1) -> None:
        self.calls: list[tuple[str, Any]] = []
        self._candidates = [(n,) for n in candidate_norms]
        self._rowcount = rowcount
        self.committed = False

    @contextmanager
    def begin_nested(self):  # type: ignore[no-untyped-def]
        yield

    def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
        sql = str(stmt)
        self.calls.append((sql, params))
        if "SELECT" in sql and "power_tp_rp_reserves" in sql:
            return _FakeResult(rows=self._candidates)
        return _FakeResult(rowcount=self._rowcount)

    def commit(self) -> None:
        self.committed = True

    def rollback(self) -> None:
        pass


def _patch_fetch(monkeypatch: Any, elements: list[dict]) -> None:
    monkeypatch.setattr(og, "fetch_osm_substations", lambda: elements)


# ── geocode_tp_from_osm ──────────────────────────────────────────────────────


def test_geocode_dry_run_does_not_write(monkeypatch: Any) -> None:
    """Dry-run (apply=False): matched считается, но UPDATE не шлётся и commit не зовётся."""
    _patch_fetch(monkeypatch, [_node(1, 56.80, 60.60, {"name": "ТП-101"})])
    db = _FakeSession(candidate_norms=["101"])
    result = og.geocode_tp_from_osm(db=db, apply=False)

    assert result["matched"] == 1
    assert result["updated"] == 0
    assert result["applied"] is False
    assert not db.committed
    # Только SELECT-кандидатов, никаких UPDATE.
    assert all("UPDATE" not in sql for sql, _ in db.calls)


def test_geocode_apply_updates_with_cast(monkeypatch: Any) -> None:
    """apply=True: UPDATE шлётся с CAST-формами, geom IS NULL-гейтом, commit зовётся."""
    _patch_fetch(monkeypatch, [_node(1, 56.80, 60.60, {"name": "ТП-101"})])
    db = _FakeSession(candidate_norms=["101"], rowcount=1)
    result = og.geocode_tp_from_osm(db=db, apply=True)

    assert result["matched"] == 1
    assert result["updated"] == 1
    assert result["applied"] is True
    assert db.committed

    update_calls = [(sql, p) for sql, p in db.calls if "UPDATE" in sql]
    assert len(update_calls) == 1
    sql, params = update_calls[0]
    # CAST-формы (psycopg3-канон), НИКОГДА :x::type.
    assert "CAST(:lon AS float)" in sql
    assert "CAST(:lat AS float)" in sql
    assert "CAST(:name_norm AS text)" in sql
    assert "::float" not in sql and "::text" not in sql
    # Идемпотентность + скоуп ЕКБ.
    assert "geom IS NULL" in sql
    assert "geocode_status = 'osm'" in sql
    assert "municipality = 'Екатеринбург'" in sql
    assert params == {"name_norm": "101", "lat": 56.80, "lon": 60.60}


def test_geocode_ambiguous_not_matched(monkeypatch: Any) -> None:
    """Дубль-номер (ambiguous) не матчится: candidate '100' есть, но geom не пишется."""
    _patch_fetch(
        monkeypatch,
        [
            _node(1, 56.80, 60.60, {"name": "ТП-100"}),
            _node(2, 56.85, 60.65, {"name": "ТП-100"}),  # коллизия
        ],
    )
    db = _FakeSession(candidate_norms=["100"], rowcount=1)
    result = og.geocode_tp_from_osm(db=db, apply=True)

    assert result["ambiguous"] == 1
    assert result["matched"] == 0
    assert result["updated"] == 0
    assert all("UPDATE" not in sql for sql, _ in db.calls)


def test_geocode_unmatched_candidate_skipped(monkeypatch: Any) -> None:
    """Кандидат без OSM-номера просто скипается (matched=0), без падения."""
    _patch_fetch(monkeypatch, [_node(1, 56.80, 60.60, {"name": "ТП-777"})])
    db = _FakeSession(candidate_norms=["101"], rowcount=1)  # 101 нет в OSM
    result = og.geocode_tp_from_osm(db=db, apply=True)
    assert result["matched"] == 0
    assert result["updated"] == 0


# ── Celery / beat регистрация ────────────────────────────────────────────────


def test_celery_task_registered() -> None:
    """Таск sync_osm_tp_geocode регистрируется при импорте модуля тасков.

    Модуль в celery_app.include — на worker-старте импортируется и таск попадает в
    celery_app.tasks. В тесте импорт делаем явно (Celery не бутит воркер).
    """
    import app.workers.tasks.connection_capacity_sync  # noqa: F401 — импорт регистрирует таск
    from app.workers.celery_app import celery_app

    assert "tasks.connection_capacity_sync.sync_osm_tp_geocode" in celery_app.tasks


def test_beat_schedule_entry() -> None:
    """Beat содержит еженедельную вторник-05:45 запись osm-tp-geocode."""
    from app.workers.beat_schedule import build_beat_schedule

    schedule = build_beat_schedule()
    assert "osm-tp-geocode-weekly" in schedule
    entry = schedule["osm-tp-geocode-weekly"]
    assert entry["task"] == "tasks.connection_capacity_sync.sync_osm_tp_geocode"
