"""Regression: a single cluster node's ConnectionError/TimeoutError must reset only that
node's connections, not tear down the whole cluster client for every other concurrent
caller. Live confirmation against a real 3-master local cluster (pausing one node with
CLIENT PAUSE) showed 100% of concurrent commands to the other two, untouched nodes
stalling for the full pause duration before this fix, and zero after -- these tests pin
the same behavior at the unit level so it can run without a live Redis Cluster."""

import asyncio
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, Mock, patch

import pytest
from redis.exceptions import (
    AskError,
    BusyLoadingError,
    ClusterDownError,
    ClusterError,
    MaxConnectionsError,
    MovedError,
    TryAgainError,
)
from redis.exceptions import (
    ConnectionError as RedisConnectionError,
)
from redis.exceptions import (
    TimeoutError as RedisTimeoutError,
)

from litellm.caching.redis_cluster_node_isolation import (
    get_litellm_async_redis_cluster_class,
)

if TYPE_CHECKING:
    from redis.asyncio.cluster import RedisCluster as _AsyncRedisClusterType


class _NodeClassWithPerConnectionRecovery:
    def update_active_connections_for_reconnect(self) -> None: ...


class _NodeClassWithoutPerConnectionRecovery:
    pass


class _FakeClusterNode:
    def __init__(self, name: str, raises: Exception | None = None, response: object = None) -> None:
        self.name = name

        async def execute_command(*args: object, **kwargs: object) -> object:
            await asyncio.sleep(0)
            if raises is not None:
                raise raises
            return response

        self.execute_command = AsyncMock(side_effect=execute_command)
        self.disconnect = AsyncMock()


class _Fake8xRedisCluster:
    def __init__(self) -> None:
        self._initialize = False

    async def _execute_command(
        self, target_node: _FakeClusterNode, *args: object, **kwargs: object
    ) -> object:
        try:
            return await target_node.execute_command(*args, **kwargs)
        except (RedisConnectionError, RedisTimeoutError):
            self._initialize = True
            await asyncio.sleep(0)
            raise

    async def aclose(self) -> None:
        self._initialize = True


class _FakeNodesManager:
    def __init__(self, node_to_return: _FakeClusterNode) -> None:
        self._moved_exception: object = None
        self._node_to_return = node_to_return

    def get_node_from_slot(
        self, slot: int, read_from_replicas: bool, load_balancing_strategy: object
    ) -> _FakeClusterNode:
        return self._node_to_return


def _build_cluster_instance() -> "_AsyncRedisClusterType":
    cluster_cls = get_litellm_async_redis_cluster_class(
        cluster_node_class=_NodeClassWithoutPerConnectionRecovery
    )
    instance = cluster_cls.__new__(cluster_cls)
    instance.RedisClusterRequestTTL = 1
    instance.reinitialize_counter = 0
    instance.reinitialize_steps = 5
    instance.read_from_replicas = False
    instance.load_balancing_strategy = None
    instance.aclose = AsyncMock()
    return instance


def _build_8x_cluster_instance() -> _Fake8xRedisCluster:
    cluster_cls = get_litellm_async_redis_cluster_class(
        cluster_node_class=_NodeClassWithPerConnectionRecovery,
        base_cluster_class=_Fake8xRedisCluster,
    )
    return cluster_cls()


def test_unverified_redis_version_logs_warning(caplog: pytest.LogCaptureFixture) -> None:
    import redis

    with patch.object(redis, "__version__", "8.0.1"):
        get_litellm_async_redis_cluster_class(cluster_node_class=_NodeClassWithoutPerConnectionRecovery)

    assert "not in the set this cluster-teardown-storm fix was verified against" in caplog.text


@pytest.mark.asyncio
async def test_single_timeout_does_not_request_topology_reinit() -> None:
    error = RedisTimeoutError("timeout")
    target_node = _FakeClusterNode("node-a")
    target_node.execute_command.side_effect = error
    instance = _build_8x_cluster_instance()

    with pytest.raises(RedisTimeoutError) as exc_info:
        await instance._execute_command(target_node, "GET", "k")

    assert exc_info.value is error
    assert instance._initialize is False


@pytest.mark.asyncio
async def test_connection_error_preserves_upstream_topology_reinit() -> None:
    error = RedisConnectionError("connection error")
    target_node = _FakeClusterNode("node-a")
    target_node.execute_command.side_effect = error
    instance = _build_8x_cluster_instance()

    with pytest.raises(RedisConnectionError) as exc_info:
        await instance._execute_command(target_node, "GET", "k")

    assert exc_info.value is error
    assert instance._initialize is True


@pytest.mark.asyncio
async def test_three_consecutive_timeouts_request_topology_reinit_and_reset_counter() -> None:
    errors = [
        RedisTimeoutError("timeout-1"),
        RedisTimeoutError("timeout-2"),
        RedisTimeoutError("timeout-3"),
    ]
    fourth_error = RedisTimeoutError("timeout-4")
    target_node = _FakeClusterNode("node-a")
    target_node.execute_command.side_effect = [*errors, fourth_error]
    instance = _build_8x_cluster_instance()

    for error in errors:
        with pytest.raises(RedisTimeoutError) as exc_info:
            await instance._execute_command(target_node, "GET", "k")
        assert exc_info.value is error

    assert instance._initialize is True
    instance._initialize = False

    with pytest.raises(RedisTimeoutError) as exc_info:
        await instance._execute_command(target_node, "GET", "k")

    assert exc_info.value is fourth_error
    assert instance._initialize is False


@pytest.mark.asyncio
async def test_success_resets_consecutive_timeout_counter() -> None:
    errors = [RedisTimeoutError("timeout-1"), RedisTimeoutError("timeout-2")]
    final_error = RedisTimeoutError("timeout-3")
    target_node = _FakeClusterNode("node-a")
    target_node.execute_command.side_effect = [*errors, b"value", final_error]
    instance = _build_8x_cluster_instance()

    for error in errors:
        with pytest.raises(RedisTimeoutError) as exc_info:
            await instance._execute_command(target_node, "GET", "k")
        assert exc_info.value is error
        assert instance._initialize is False

    result = await instance._execute_command(target_node, "GET", "k")
    assert result == b"value"
    assert instance._initialize is False

    with pytest.raises(RedisTimeoutError) as exc_info:
        await instance._execute_command(target_node, "GET", "k")

    assert exc_info.value is final_error
    assert instance._initialize is False


@pytest.mark.asyncio
async def test_timeout_counters_are_per_node() -> None:
    node_a_errors = [RedisTimeoutError("node-a-1"), RedisTimeoutError("node-a-2")]
    node_b_error = RedisTimeoutError("node-b-1")
    node_a = _FakeClusterNode("node-a")
    node_b = _FakeClusterNode("node-b")
    node_a.execute_command.side_effect = node_a_errors
    node_b.execute_command.side_effect = node_b_error
    instance = _build_8x_cluster_instance()

    for target_node, error in (
        (node_a, node_a_errors[0]),
        (node_b, node_b_error),
        (node_a, node_a_errors[1]),
    ):
        with pytest.raises(RedisTimeoutError) as exc_info:
            await instance._execute_command(target_node, "GET", "k")
        assert exc_info.value is error

    assert instance._initialize is False


@pytest.mark.asyncio
async def test_timeout_does_not_clear_concurrent_topology_reinit_request() -> None:
    error = RedisTimeoutError("timeout")
    instance = _build_8x_cluster_instance()

    async def request_reinit(*args: object, **kwargs: object) -> object:
        await instance.aclose()
        raise error

    target_node = _FakeClusterNode("node-a")
    target_node.execute_command.side_effect = request_reinit

    with pytest.raises(RedisTimeoutError) as exc_info:
        await instance._execute_command(target_node, "GET", "k")

    assert exc_info.value is error
    assert instance._initialize is True


@pytest.mark.asyncio
async def test_tolerated_timeout_does_not_erase_concurrent_connection_error_reinit() -> None:
    instance = _build_8x_cluster_instance()
    failing_node = _FakeClusterNode("node-a", raises=RedisConnectionError("gone"))
    slow_node = _FakeClusterNode("node-b", raises=RedisTimeoutError("slow"))

    results = await asyncio.gather(
        instance._execute_command(failing_node, "GET", "a"),
        instance._execute_command(slow_node, "GET", "b"),
        return_exceptions=True,
    )

    assert isinstance(results[0], RedisConnectionError)
    assert isinstance(results[1], RedisTimeoutError)
    assert instance._initialize is True


@pytest.mark.asyncio
async def test_overlapping_tolerated_timeouts_do_not_request_topology_reinit() -> None:
    instance = _build_8x_cluster_instance()
    node_a = _FakeClusterNode("node-a", raises=RedisTimeoutError("slow-a"))
    node_b = _FakeClusterNode("node-b", raises=RedisTimeoutError("slow-b"))

    results = await asyncio.gather(
        instance._execute_command(node_a, "GET", "a"),
        instance._execute_command(node_b, "GET", "b"),
        return_exceptions=True,
    )

    assert all(isinstance(result, RedisTimeoutError) for result in results)
    assert instance._initialize is False


@pytest.mark.asyncio
async def test_tolerated_timeout_does_not_clear_pending_reinit() -> None:
    instance = _build_8x_cluster_instance()
    instance._initialize = True
    target_node = _FakeClusterNode("node-a", raises=RedisTimeoutError("slow"))

    with pytest.raises(RedisTimeoutError):
        await instance._execute_command(target_node, "GET", "k")

    assert instance._initialize is True


@pytest.mark.asyncio
async def test_success_returns_value_without_topology_reinit() -> None:
    target_node = _FakeClusterNode("node-a", response=b"value")
    instance = _build_8x_cluster_instance()

    result = await instance._execute_command(target_node, "GET", "k")

    assert result == b"value"
    assert instance._initialize is False


@pytest.mark.asyncio
@pytest.mark.parametrize("error_cls", [RedisConnectionError, RedisTimeoutError])
async def test_node_level_error_resets_only_that_node_not_the_whole_client(error_cls: type[Exception]) -> None:
    """The fix: a ConnectionError/TimeoutError must disconnect only the failing node
    and must NOT call the client-wide aclose() that tears down every node."""
    target_node = _FakeClusterNode("node-a", raises=error_cls("boom"))
    instance = _build_cluster_instance()

    with pytest.raises(error_cls):
        await instance._execute_command(target_node, "GET", "k")

    target_node.disconnect.assert_awaited_once()
    instance.aclose.assert_not_awaited()


@pytest.mark.asyncio
async def test_moved_error_retries_without_full_reinit_before_threshold() -> None:
    moved_error = MovedError("1 127.0.0.1:7001")
    target_node = _FakeClusterNode("node-a")
    target_node.execute_command = AsyncMock(side_effect=[moved_error, b"value"])
    instance = _build_cluster_instance()
    instance.RedisClusterRequestTTL = 2
    instance.nodes_manager = _FakeNodesManager(node_to_return=target_node)
    instance._determine_slot = AsyncMock(return_value=0)

    result = await instance._execute_command(target_node, "GET", "k")

    assert result == b"value"
    assert instance.nodes_manager._moved_exception is moved_error
    instance.aclose.assert_not_awaited()


@pytest.mark.asyncio
async def test_ask_error_sends_asking_and_retries_on_redirected_node() -> None:
    ask_error = AskError("0 127.0.0.1:7001")
    target_node = _FakeClusterNode("node-a")
    target_node.execute_command = AsyncMock(side_effect=[ask_error, None, b"value"])
    instance = _build_cluster_instance()
    instance.RedisClusterRequestTTL = 2
    instance.get_node = Mock(return_value=target_node)

    result = await instance._execute_command(target_node, "GET", "k")

    assert result == b"value"
    instance.get_node.assert_called_once_with(node_name="127.0.0.1:7001")


@pytest.mark.asyncio
async def test_try_again_error_exhausts_ttl() -> None:
    target_node = _FakeClusterNode("node-a", raises=TryAgainError("try again"))
    instance = _build_cluster_instance()
    instance.RedisClusterRequestTTL = 2

    with pytest.raises(ClusterError):
        await instance._execute_command(target_node, "GET", "k")


@pytest.mark.asyncio
async def test_successful_command_touches_neither_disconnect_nor_aclose() -> None:
    target_node = _FakeClusterNode("node-a", response=b"v")
    instance = _build_cluster_instance()

    result = await instance._execute_command(target_node, "GET", "k")

    assert result == b"v"
    target_node.disconnect.assert_not_awaited()
    instance.aclose.assert_not_awaited()


@pytest.mark.asyncio
@pytest.mark.parametrize("error_cls", [BusyLoadingError, MaxConnectionsError])
async def test_busy_loading_and_max_connections_reraise_without_any_reset(error_cls: type[Exception]) -> None:
    """Unchanged from upstream: these say nothing about node health, so neither the
    node nor the client should be reset."""
    target_node = _FakeClusterNode("node-a", raises=error_cls("boom"))
    instance = _build_cluster_instance()

    with pytest.raises(error_cls):
        await instance._execute_command(target_node, "GET", "k")

    target_node.disconnect.assert_not_awaited()
    instance.aclose.assert_not_awaited()


@pytest.mark.asyncio
async def test_cluster_down_error_still_triggers_a_full_reinit() -> None:
    """Unchanged from upstream: ClusterDownError is real evidence the topology
    changed, so a full-client reinit (unlike a plain timeout) is still correct here."""
    target_node = _FakeClusterNode("node-a", raises=ClusterDownError("boom"))
    instance = _build_cluster_instance()

    with pytest.raises(ClusterDownError):
        await instance._execute_command(target_node, "GET", "k")

    instance.aclose.assert_awaited_once()


@pytest.mark.asyncio
async def test_moved_error_still_triggers_reinit_after_reinitialize_steps() -> None:
    """Unchanged from upstream: repeated MOVED responses are real evidence of a
    slot migration, so they should still force a full reinit every `reinitialize_steps`."""
    target_node = _FakeClusterNode("node-a", raises=MovedError("1 127.0.0.1:7001"))
    instance = _build_cluster_instance()
    instance.reinitialize_steps = 1
    instance.RedisClusterRequestTTL = 2
    instance.nodes_manager = _FakeNodesManager(node_to_return=target_node)
    instance._determine_slot = AsyncMock(return_value=0)

    target_node.execute_command = AsyncMock(side_effect=[MovedError("1 127.0.0.1:7001"), b"v"])

    result = await instance._execute_command(target_node, "GET", "k")

    assert result == b"v"
    instance.aclose.assert_awaited_once()
    assert instance.reinitialize_counter == 0
