"""Тесты мемоизации coord→zone_index в get_or_fetch_zone_regulation (#1850 Fix 1).

Маппинг «координата → индекс терзоны» статичен → единственный живой WFS-вызов резолвера
(``client.zone_index_at``) кэшируется процесс-локально. Проверяем:
  - повтор по тем же координатам → zone_index_at вызван ОДИН раз (memo hit),
  - негативное кэширование: координата без зоны (None) тоже кэшируется (вне ЕКБ не дёргаем WFS),
  - округление до 5 знаков: близкие координаты (<1e-5) → один ключ,
  - _reset_zone_index_memo() очищает кэш между тестами.

Сеть/БД не дёргаются: фейковый клиент считает вызовы zone_index_at; кэш регламента
(get_cached_zone_regulation) замокан так, чтобы зона считалась уже закэшированной
(short-circuit до urbanCard).
"""

from __future__ import annotations

from typing import Any

import pytest

from app.services.site_finder import zone_regulation as zr


class _CountingClient:
    """Фейковый WFS-клиент: считает вызовы zone_index_at, отдаёт заданный индекс (или None)."""

    def __init__(self, zone_index: str | None) -> None:
        self._zone_index = zone_index
        self.index_calls: list[tuple[float, float]] = []
        self.regulation_calls: int = 0

    def zone_index_at(self, lon: float, lat: float) -> str | None:
        self.index_calls.append((lon, lat))
        return self._zone_index

    def zone_regulation_at(self, lon: float, lat: float) -> Any:
        self.regulation_calls += 1
        return None


@pytest.fixture(autouse=True)
def _reset_memo() -> Any:
    """Чистый memo до и после каждого теста (процесс-локальный кэш течёт между тестами)."""
    zr._reset_zone_index_memo()
    yield
    zr._reset_zone_index_memo()


def test_zone_index_memoized_across_calls(monkeypatch: Any) -> None:
    """Два вызова с теми же координатами → zone_index_at вызван РОВНО один раз (memo hit)."""
    # Зона "Ц-1" уже в кэше регламента → short-circuit до urbanCard, изолируем memo.
    monkeypatch.setattr(
        zr,
        "get_cached_zone_regulation",
        lambda db, zone_index, **kw: {"zone_index": zone_index},
    )
    client = _CountingClient("Ц-1")

    r1 = zr.get_or_fetch_zone_regulation(object(), 60.6, 56.8, client=client)
    r2 = zr.get_or_fetch_zone_regulation(object(), 60.6, 56.8, client=client)

    assert r1 == {"zone_index": "Ц-1"}
    assert r2 == {"zone_index": "Ц-1"}
    assert len(client.index_calls) == 1  # второй вызов — memo hit, без WFS


def test_negative_result_is_memoized(monkeypatch: Any) -> None:
    """Координата без терзоны (None) кэшируется → второй вызов не дёргает WFS (вне ЕКБ)."""
    # get_cached не должен влиять: zone_index=None → ветка кэша не выполняется,
    # zone_regulation_at вернёт None → результат None.
    monkeypatch.setattr(zr, "get_cached_zone_regulation", lambda *a, **kw: None)
    client = _CountingClient(None)

    r1 = zr.get_or_fetch_zone_regulation(object(), 1.0, 1.0, client=client)
    r2 = zr.get_or_fetch_zone_regulation(object(), 1.0, 1.0, client=client)

    assert r1 is None
    assert r2 is None
    assert len(client.index_calls) == 1  # негативный результат закэширован


def test_nearby_coords_share_memo_key(monkeypatch: Any) -> None:
    """Координаты, совпадающие после round(,5), делят ключ → один WFS-вызов."""
    monkeypatch.setattr(
        zr,
        "get_cached_zone_regulation",
        lambda db, zone_index, **kw: {"zone_index": zone_index},
    )
    client = _CountingClient("Ж-2")

    # Различие в 6-м знаке → одинаковый ключ после round(lon, 5)/round(lat, 5).
    zr.get_or_fetch_zone_regulation(object(), 60.600001, 56.800002, client=client)
    zr.get_or_fetch_zone_regulation(object(), 60.600002, 56.800001, client=client)

    assert len(client.index_calls) == 1


def test_distinct_coords_each_hit_wfs(monkeypatch: Any) -> None:
    """Координаты, различимые на уровне 5 знаков, бьют WFS отдельно (round не слишком грубый)."""
    monkeypatch.setattr(
        zr,
        "get_cached_zone_regulation",
        lambda db, zone_index, **kw: {"zone_index": zone_index},
    )
    client = _CountingClient("Ц-2")

    zr.get_or_fetch_zone_regulation(object(), 60.60000, 56.80000, client=client)
    zr.get_or_fetch_zone_regulation(object(), 60.60002, 56.80000, client=client)  # ~1.2 м восточнее

    assert len(client.index_calls) == 2


def test_reset_memo_clears_cache(monkeypatch: Any) -> None:
    """_reset_zone_index_memo() сбрасывает кэш → следующий вызов снова бьёт WFS."""
    monkeypatch.setattr(
        zr,
        "get_cached_zone_regulation",
        lambda db, zone_index, **kw: {"zone_index": zone_index},
    )
    client = _CountingClient("Ц-1")

    zr.get_or_fetch_zone_regulation(object(), 60.6, 56.8, client=client)
    assert len(client.index_calls) == 1

    zr._reset_zone_index_memo()
    zr.get_or_fetch_zone_regulation(object(), 60.6, 56.8, client=client)
    assert len(client.index_calls) == 2  # после reset — снова WFS


def test_memo_evicts_oldest_over_maxsize(monkeypatch: Any) -> None:
    """LRU-эвикция: при превышении maxsize выбрасывается самый старый ключ."""
    monkeypatch.setattr(zr, "_ZONE_INDEX_MEMO_MAXSIZE", 3)
    monkeypatch.setattr(zr, "get_cached_zone_regulation", lambda *a, **kw: None)
    client = _CountingClient(None)

    # 4 различных ключа при maxsize=3 → первый (самый старый) эвиктится.
    for i in range(4):
        zr.get_or_fetch_zone_regulation(object(), 60.0 + i * 0.001, 56.0, client=client)
    assert len(client.index_calls) == 4

    # Повтор самого старого (60.000) → cache miss (эвиктнут) → ещё один WFS-вызов.
    zr.get_or_fetch_zone_regulation(object(), 60.0, 56.0, client=client)
    assert len(client.index_calls) == 5


def test_memo_concurrent_access_no_error(monkeypatch: Any) -> None:
    """#1850 thread-safety: N потоков сквозь _memoized_zone_index при tiny maxsize →
    мутации под Lock'ом не дают KeyError/гонок (memo делится между threadpool-потоками)."""
    import threading

    monkeypatch.setattr(zr, "_ZONE_INDEX_MEMO_MAXSIZE", 2)  # форсим частую эвикцию
    client = _CountingClient("Ц-1")
    errors: list[BaseException] = []
    barrier = threading.Barrier(8)

    def worker(n: int) -> None:
        try:
            barrier.wait()  # стартуем одновременно → максимум контеншена
            for i in range(200):
                zr._memoized_zone_index(client, 60.0 + (i % 5) * 0.001, 56.0)
        except BaseException as exc:  # фиксируем любую гонку (KeyError/RuntimeError)
            errors.append(exc)

    threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

    assert not errors, f"concurrent memo access raised: {errors[:3]}"
