"""Unit tests for SAVEPOINT + retry вокруг ratio-lookup (#2265 D2).

Транзиентный сбой первого ratio-SELECT (обычно poisoned tx,
InFailedSqlTransaction, от вышестоящего graceful-except) раньше гасил
expected_sold для КОНКРЕТНОЙ оценки (3/224 за 14 дней при живых бакетах).
Фикс: begin_nested + 1 retry на чистом savepoint. Проверяем:
  - первый lookup падает → второй проходит → возвращается ratio (recover);
  - оба захода падают → (None, None) и кэш НЕ отравлен (#2175 держится);
  - happy-path — ровно один begin_nested, без rollback.

No DB / network: session — лёгкий fake с side-effect'ами на execute.
DATABASE_URL нужен только для импорта app.core.config.Settings.
"""

import os

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

from contextlib import contextmanager
from typing import Any
from unittest.mock import MagicMock

from app.services import estimator


class _Row:
    """Строка результата с полями .ratio/.basis (как row из fetchone())."""

    def __init__(self, ratio: float | None, basis: str | None) -> None:
        self.ratio = ratio
        self.basis = basis


class _FakeSession:
    """Session-заглушка: execute отдаёт side-effect'ы по очереди.

    Exception в списке → execute бросает; иначе — result, чей fetchone() вернёт
    заготовленную строку. begin_nested — CM, rollback — счётчик.
    """

    def __init__(self, execute_effects: list[Any]) -> None:
        self._effects = list(execute_effects)
        self.rollback_count = 0
        self.begin_nested_count = 0

    @contextmanager
    def begin_nested(self):  # type: ignore[no-untyped-def]
        self.begin_nested_count += 1
        yield MagicMock()

    def execute(self, *_args: Any, **_kwargs: Any) -> Any:
        eff = self._effects.pop(0)
        if isinstance(eff, Exception):
            raise eff
        result = MagicMock()
        result.fetchone.return_value = eff
        return result

    def rollback(self) -> None:
        self.rollback_count += 1


def test_ratio_retry_recovers_after_first_lookup_fails() -> None:
    estimator._asking_sold_ratio_cache.clear()
    poisoned = RuntimeError("current transaction is aborted (InFailedSqlTransaction)")
    db = _FakeSession([poisoned, _Row(0.84, "per_rooms")])

    ratio, basis = estimator._get_asking_sold_ratio(db, rooms=1)  # type: ignore[arg-type]

    assert ratio == 0.84
    assert basis == "per_rooms"
    assert db.begin_nested_count == 2  # первый заход упал, второй прошёл
    assert db.rollback_count >= 1  # чистка tx перед retry
    assert 1 in estimator._asking_sold_ratio_cache  # успех кэшируется


def test_ratio_both_attempts_fail_returns_none_and_not_cached() -> None:
    estimator._asking_sold_ratio_cache.clear()
    db = _FakeSession([RuntimeError("boom-1"), RuntimeError("boom-2")])

    ratio, basis = estimator._get_asking_sold_ratio(db, rooms=2)  # type: ignore[arg-type]

    assert ratio is None
    assert basis is None
    assert 2 not in estimator._asking_sold_ratio_cache  # транзиент НЕ отравляет кэш (#2175)
    assert db.begin_nested_count == 2
    assert db.rollback_count >= 2


def test_ratio_happy_path_single_attempt_no_rollback() -> None:
    estimator._asking_sold_ratio_cache.clear()
    db = _FakeSession([_Row(0.9, "global_fallback")])

    ratio, basis = estimator._get_asking_sold_ratio(db, rooms=0)  # type: ignore[arg-type]

    assert ratio == 0.9
    assert basis == "global_fallback"
    assert db.begin_nested_count == 1  # ни одного retry
    assert db.rollback_count == 0
    cached = estimator._asking_sold_ratio_cache.get(0)
    assert cached is not None
    assert cached[0] == 0.9
    assert cached[1] == "global_fallback"
