"""Regression: транзиентная ошибка НЕ должна отравлять asking→sold ratio-кэш.

Баг: `_get_asking_sold_ratio` gracefully ловит любую ошибку БД → (None, None), а
кэш писался БЕЗУСЛОВНО → один poisoned-tx / коннект-хиккап отравлял
`_asking_sold_ratio_cache[bucket] = (None, None)` на TTL (300с) и молча гасил
expected_sold («ожидаемая цена сделки» → «—») для ВСЕХ оценок этого rooms-бакета
на воркере до истечения TTL. Фикс: ранний return из except БЕЗ записи в кэш;
кэшируем только успешный lookup.

No DB: fake-Session (monkeypatched .execute/.rollback). DATABASE_URL нужен только
для импорта app.core.config.Settings (тот же паттерн, что в test_estimator_pure_units).
"""

import os
from collections.abc import Iterator
from contextlib import contextmanager
from types import SimpleNamespace
from typing import Any

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

import pytest

from app.services import estimator


class _Result:
    def __init__(self, row: Any) -> None:
        self._row = row

    def fetchone(self) -> Any:
        return self._row


class _OkDB:
    """Fake Session: первый SELECT (per-rooms bucket) возвращает строку с ratio."""

    def __init__(self, ratio: float, basis: str = "per_rooms") -> None:
        self._row = SimpleNamespace(ratio=ratio, basis=basis)
        self.rolled_back = False

    @contextmanager
    def begin_nested(self) -> Iterator[Any]:
        # #2265 D2: ratio-lookup обёрнут в SAVEPOINT — моделируем CM.
        yield SimpleNamespace()

    def execute(self, *_a: Any, **_k: Any) -> _Result:
        return _Result(self._row)

    def rollback(self) -> None:
        self.rolled_back = True


class _BoomDB:
    """Fake Session: execute всегда падает (poisoned tx / хиккап)."""

    def __init__(self) -> None:
        self.rolled_back = False

    @contextmanager
    def begin_nested(self) -> Iterator[Any]:
        yield SimpleNamespace()

    def execute(self, *_a: Any, **_k: Any) -> _Result:
        raise RuntimeError("InFailedSqlTransaction")

    def rollback(self) -> None:
        self.rolled_back = True


@pytest.fixture(autouse=True)
def _clear_ratio_cache() -> Any:
    """Чистим модульный кэш до/после каждого теста (глобальное состояние)."""
    estimator._asking_sold_ratio_cache.clear()
    yield
    estimator._asking_sold_ratio_cache.clear()


def test_transient_error_returns_none_and_does_not_cache() -> None:
    db = _BoomDB()
    ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2)  # type: ignore[arg-type]
    assert (ratio, basis) == (None, None)
    assert db.rolled_back is True
    # bucket=2 НЕ должен попасть в кэш — иначе следующая оценка застрянет на None.
    assert 2 not in estimator._asking_sold_ratio_cache


def test_transient_error_then_success_retries_not_poisoned() -> None:
    # 1) транзиентный сбой — None, кэш не отравлен
    r1, _ = estimator._get_asking_sold_ratio(_BoomDB(), rooms=2)  # type: ignore[arg-type]
    assert r1 is None
    assert 2 not in estimator._asking_sold_ratio_cache
    # 2) следующий вызов с рабочей БД должен РЕТРАИТЬ и вернуть реальный ratio
    r2, basis2 = estimator._get_asking_sold_ratio(_OkDB(0.84), rooms=2)  # type: ignore[arg-type]
    assert r2 == pytest.approx(0.84)
    assert basis2 == "per_rooms"
    # теперь успешный результат закэширован
    assert estimator._asking_sold_ratio_cache[2][0] == pytest.approx(0.84)


def test_successful_lookup_is_cached() -> None:
    r, basis = estimator._get_asking_sold_ratio(_OkDB(0.77), rooms=1)  # type: ignore[arg-type]
    assert r == pytest.approx(0.77)
    assert basis == "per_rooms"
    cached = estimator._asking_sold_ratio_cache.get(1)
    assert cached is not None and cached[0] == pytest.approx(0.77)
