"""Unit tests for TMDB API rate limiting and error handling.

This module tests the TMDBClient's rate limiting detection, exponential backoff
retry logic, API quota handling, error response parsing, and timeout handling.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

import aiohttp
import pytest

from src.core.services.tmdb_client import TMDBAPIError, TMDBClient


def _make_ctx(response):
    """Create an async context manager mock wrapping a response."""
    ctx = AsyncMock()
    ctx.__aenter__.return_value = response
    ctx.__aexit__.return_value = None
    return ctx


def _make_session():
    """Create a properly configured mock session for TMDB tests."""
    session = MagicMock()
    session.closed = False
    session.close = AsyncMock()
    return session


class TestTMDBRateLimiting:
    """Test TMDB API rate limit detection and handling."""

    @pytest.mark.asyncio
    async def test_rate_limit_detection_429_response(self):
        """Test that 429 response triggers rate limit handling."""
        client = TMDBClient(api_key="test_key")

        mock_response = AsyncMock()
        mock_response.status = 429
        mock_response.headers = {"Retry-After": "2"}

        session = _make_session()
        session.get.return_value = _make_ctx(mock_response)
        client.session = session

        with pytest.raises(TMDBAPIError):
            await client._request("test/endpoint", max_retries=1)

        await client.close()

    @pytest.mark.asyncio
    async def test_rate_limit_retry_after_header(self):
        """Test respecting Retry-After header on 429 response."""
        client = TMDBClient(api_key="test_key")

        retry_after = 5
        mock_response_429 = AsyncMock()
        mock_response_429.status = 429
        mock_response_429.headers = {"Retry-After": str(retry_after)}

        mock_response_200 = AsyncMock()
        mock_response_200.status = 200
        mock_response_200.json = AsyncMock(return_value={"success": True})
        mock_response_200.raise_for_status = MagicMock()

        call_count = 0

        def mock_get_side_effect(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return _make_ctx(mock_response_429)
            return _make_ctx(mock_response_200)

        session = _make_session()
        session.get.side_effect = mock_get_side_effect
        client.session = session

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            result = await client._request("test/endpoint", max_retries=2)
            mock_sleep.assert_called_once_with(retry_after)
            assert result == {"success": True}

        await client.close()

    @pytest.mark.asyncio
    async def test_rate_limit_default_backoff_no_retry_after(self):
        """Test default exponential backoff when Retry-After header missing."""
        client = TMDBClient(api_key="test_key")

        mock_response_429 = AsyncMock()
        mock_response_429.status = 429
        mock_response_429.headers = {}

        mock_response_200 = AsyncMock()
        mock_response_200.status = 200
        mock_response_200.json = AsyncMock(return_value={"success": True})
        mock_response_200.raise_for_status = MagicMock()

        call_count = 0

        def mock_get_side_effect(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return _make_ctx(mock_response_429)
            return _make_ctx(mock_response_200)

        session = _make_session()
        session.get.side_effect = mock_get_side_effect
        client.session = session

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            result = await client._request("test/endpoint", max_retries=2)
            mock_sleep.assert_called_once_with(2)
            assert result == {"success": True}

        await client.close()

    @pytest.mark.asyncio
    async def test_rate_limit_multiple_retries(self):
        """Test multiple 429 responses trigger increasing delays."""
        client = TMDBClient(api_key="test_key")

        mock_response_429_1 = AsyncMock()
        mock_response_429_1.status = 429
        mock_response_429_1.headers = {"Retry-After": "2"}

        mock_response_429_2 = AsyncMock()
        mock_response_429_2.status = 429
        mock_response_429_2.headers = {"Retry-After": "4"}

        mock_response_200 = AsyncMock()
        mock_response_200.status = 200
        mock_response_200.json = AsyncMock(return_value={"success": True})
        mock_response_200.raise_for_status = MagicMock()

        responses = [mock_response_429_1, mock_response_429_2, mock_response_200]
        call_count = 0

        def mock_get_side_effect(*args, **kwargs):
            nonlocal call_count
            response = responses[call_count]
            call_count += 1
            return _make_ctx(response)

        session = _make_session()
        session.get.side_effect = mock_get_side_effect
        client.session = session

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            result = await client._request("test/endpoint", max_retries=3)
            assert mock_sleep.call_count == 2
            assert result == {"success": True}

        await client.close()


class TestTMDBExponentialBackoff:
    """Test exponential backoff retry logic."""

    @pytest.mark.asyncio
    async def test_exponential_backoff_on_timeout(self):
        """Test exponential backoff delays on timeout errors."""
        client = TMDBClient(api_key="test_key")

        session = _make_session()
        session.get.side_effect = asyncio.TimeoutError()
        client.session = session

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            with pytest.raises(TMDBAPIError):
                await client._request("test/endpoint", max_retries=3)

            assert mock_sleep.call_count == 2
            calls = [call[0][0] for call in mock_sleep.call_args_list]
            assert calls == [1, 2]

        await client.close()

    @pytest.mark.asyncio
    async def test_exponential_backoff_on_client_error(self):
        """Test exponential backoff on aiohttp ClientError."""
        client = TMDBClient(api_key="test_key")

        session = _make_session()
        session.get.side_effect = aiohttp.ClientError("Connection failed")
        client.session = session

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            with pytest.raises(TMDBAPIError):
                await client._request("test/endpoint", max_retries=3)

            assert mock_sleep.call_count == 2
            calls = [call[0][0] for call in mock_sleep.call_args_list]
            assert calls == [1, 2]

        await client.close()

    @pytest.mark.asyncio
    async def test_successful_retry_after_backoff(self):
        """Test successful request after exponential backoff retry."""
        client = TMDBClient(api_key="test_key")

        call_count = 0

        def mock_get_side_effect(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise asyncio.TimeoutError()
            mock_response = AsyncMock()
            mock_response.status = 200
            mock_response.json = AsyncMock(return_value={"data": "success"})
            mock_response.raise_for_status = MagicMock()
            return _make_ctx(mock_response)

        session = _make_session()
        session.get.side_effect = mock_get_side_effect
        client.session = session

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            result = await client._request("test/endpoint", max_retries=3)
            assert result == {"data": "success"}
            assert mock_sleep.call_count == 1
            mock_sleep.assert_called_once_with(1)

        await client.close()

    @pytest.mark.asyncio
    async def test_max_retries_exhausted(self):
        """Test that retries stop after max_retries attempts."""
        client = TMDBClient(api_key="test_key")

        session = _make_session()
        session.get.side_effect = asyncio.TimeoutError()
        client.session = session

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            max_retries = 5
            with pytest.raises(TMDBAPIError) as exc_info:
                await client._request("test/endpoint", max_retries=max_retries)

            assert mock_sleep.call_count == max_retries - 1
            assert "failed after" in str(exc_info.value)

        await client.close()


class TestTMDBQuotaExhaustion:
    """Test TMDB API quota exhaustion handling."""

    @pytest.mark.asyncio
    async def test_quota_exhausted_error_message(self):
        """Test handling of quota exhaustion error."""
        client = TMDBClient(api_key="test_key")

        mock_response = AsyncMock()
        mock_response.status = 429
        mock_response.headers = {"Retry-After": "3600"}

        session = _make_session()
        session.get.return_value = _make_ctx(mock_response)
        client.session = session

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            with pytest.raises(TMDBAPIError):
                await client._request("test/endpoint", max_retries=2)

            assert mock_sleep.call_count >= 1

        await client.close()

    @pytest.mark.asyncio
    async def test_invalid_api_key_401_response(self):
        """Test handling of invalid API key (401 response)."""
        client = TMDBClient(api_key="invalid_key")

        mock_response = AsyncMock()
        mock_response.status = 401

        session = _make_session()
        session.get.return_value = _make_ctx(mock_response)
        client.session = session

        with pytest.raises(TMDBAPIError) as exc_info:
            await client._request("test/endpoint", max_retries=1)

        assert "Invalid TMDB API key" in str(exc_info.value)

        await client.close()


class TestTMDBErrorParsing:
    """Test TMDB API error response parsing."""

    @pytest.mark.asyncio
    async def test_404_not_found_error(self):
        """Test handling of 404 Not Found response."""
        client = TMDBClient(api_key="test_key")

        mock_response = AsyncMock()
        mock_response.status = 404

        session = _make_session()
        session.get.return_value = _make_ctx(mock_response)
        client.session = session

        with pytest.raises(TMDBAPIError) as exc_info:
            await client._request("tv/999999", max_retries=1)

        assert "Resource not found" in str(exc_info.value)

        await client.close()

    @pytest.mark.asyncio
    async def test_500_server_error_retry(self):
        """Test retry on 500 server error."""
        client = TMDBClient(api_key="test_key")

        mock_response_500 = AsyncMock()
        mock_response_500.status = 500
        mock_response_500.raise_for_status = MagicMock(
            side_effect=aiohttp.ClientResponseError(
                request_info=MagicMock(),
                history=(),
                status=500,
            )
        )

        call_count = 0

        def mock_get_side_effect(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count < 3:
                return _make_ctx(mock_response_500)
            mock_response_200 = AsyncMock()
            mock_response_200.status = 200
            mock_response_200.json = AsyncMock(return_value={"recovered": True})
            mock_response_200.raise_for_status = MagicMock()
            return _make_ctx(mock_response_200)

        session = _make_session()
        session.get.side_effect = mock_get_side_effect
        client.session = session

        with patch("asyncio.sleep", new_callable=AsyncMock):
            result = await client._request("test/endpoint", max_retries=3)
            assert result == {"recovered": True}
            assert call_count == 3

        await client.close()

    @pytest.mark.asyncio
    async def test_network_error_parsing(self):
        """Test parsing of network connection errors."""
        client = TMDBClient(api_key="test_key")

        session = _make_session()
        session.get.side_effect = aiohttp.ClientConnectorError(
            connection_key=MagicMock(),
            os_error=OSError("Network unreachable"),
        )
        client.session = session

        with patch("asyncio.sleep", new_callable=AsyncMock):
            with pytest.raises(TMDBAPIError) as exc_info:
                await client._request("test/endpoint", max_retries=2)

            assert "failed after" in str(exc_info.value).lower()

        await client.close()


class TestTMDBTimeoutHandling:
    """Test TMDB API timeout handling."""

    @pytest.mark.asyncio
    async def test_request_timeout_error(self):
        """Test handling of request timeout."""
        client = TMDBClient(api_key="test_key")

        session = _make_session()
        session.get.side_effect = asyncio.TimeoutError()
        client.session = session

        with patch("asyncio.sleep", new_callable=AsyncMock):
            with pytest.raises(TMDBAPIError) as exc_info:
                await client._request("test/endpoint", max_retries=2)

            assert "failed after" in str(exc_info.value).lower()

        await client.close()

    @pytest.mark.asyncio
    async def test_timeout_with_successful_retry(self):
        """Test successful retry after timeout."""
        client = TMDBClient(api_key="test_key")

        call_count = 0

        def mock_get_side_effect(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise asyncio.TimeoutError()
            mock_response = AsyncMock()
            mock_response.status = 200
            mock_response.json = AsyncMock(return_value={"data": "recovered"})
            mock_response.raise_for_status = MagicMock()
            return _make_ctx(mock_response)

        session = _make_session()
        session.get.side_effect = mock_get_side_effect
        client.session = session

        with patch("asyncio.sleep", new_callable=AsyncMock):
            result = await client._request("test/endpoint", max_retries=3)
            assert result == {"data": "recovered"}
            assert call_count == 2

        await client.close()

    @pytest.mark.asyncio
    async def test_timeout_configuration(self):
        """Test that requests use configured timeout."""
        client = TMDBClient(api_key="test_key")

        mock_response = AsyncMock()
        mock_response.status = 200
        mock_response.json = AsyncMock(return_value={"data": "test"})
        mock_response.raise_for_status = MagicMock()

        session = _make_session()
        session.get.return_value = _make_ctx(mock_response)
        client.session = session

        await client._request("test/endpoint")

        assert session.get.called
        call_kwargs = session.get.call_args[1]
        assert "timeout" in call_kwargs
        assert isinstance(call_kwargs["timeout"], aiohttp.ClientTimeout)

        await client.close()

    @pytest.mark.asyncio
    async def test_multiple_timeout_retries(self):
        """Test handling of multiple consecutive timeouts."""
        client = TMDBClient(api_key="test_key")

        session = _make_session()
        session.get.side_effect = asyncio.TimeoutError()
        client.session = session

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            max_retries = 4
            with pytest.raises(TMDBAPIError):
                await client._request("test/endpoint", max_retries=max_retries)

            assert mock_sleep.call_count == max_retries - 1
            delays = [call[0][0] for call in mock_sleep.call_args_list]
            assert delays == [1, 2, 4]

        await client.close()


class TestTMDBCaching:
    """Test TMDB client caching behavior."""

    @pytest.mark.asyncio
    async def test_cache_hit_prevents_request(self):
        """Test that cached responses prevent duplicate requests."""
        client = TMDBClient(api_key="test_key")

        mock_response = AsyncMock()
        mock_response.status = 200
        mock_response.json = AsyncMock(return_value={"cached": "data"})
        mock_response.raise_for_status = MagicMock()

        session = _make_session()
        session.get.return_value = _make_ctx(mock_response)
        client.session = session

        result1 = await client._request("test/endpoint", {"param": "value"})
        assert result1 == {"cached": "data"}

        result2 = await client._request("test/endpoint", {"param": "value"})
        assert result2 == {"cached": "data"}

        assert session.get.call_count == 1

        await client.close()

    @pytest.mark.asyncio
    async def test_cache_miss_different_params(self):
        """Test that different parameters result in cache miss."""
        client = TMDBClient(api_key="test_key")

        mock_response = AsyncMock()
        mock_response.status = 200
        mock_response.json = AsyncMock(return_value={"data": "test"})
        mock_response.raise_for_status = MagicMock()

        session = _make_session()
        session.get.return_value = _make_ctx(mock_response)
        client.session = session

        await client._request("test/endpoint", {"param": "value1"})
        await client._request("test/endpoint", {"param": "value2"})

        assert session.get.call_count == 2

        await client.close()

    @pytest.mark.asyncio
    async def test_cache_clear(self):
        """Test clearing the cache."""
        client = TMDBClient(api_key="test_key")

        mock_response = AsyncMock()
        mock_response.status = 200
        mock_response.json = AsyncMock(return_value={"data": "test"})
        mock_response.raise_for_status = MagicMock()

        session = _make_session()
        session.get.return_value = _make_ctx(mock_response)
        client.session = session

        await client._request("test/endpoint")
        assert session.get.call_count == 1

        await client._request("test/endpoint")
        assert session.get.call_count == 1

        client.clear_cache()

        await client._request("test/endpoint")
        assert session.get.call_count == 2

        await client.close()


class TestTMDBSessionManagement:
    """Test TMDB client session management."""

    @pytest.mark.asyncio
    async def test_session_recreation_after_close(self):
        """Test that session is recreated after being closed."""
        client = TMDBClient(api_key="test_key")

        await client._ensure_session()
        assert client.session is not None

        await client.close()
        assert client.session is None or client.session.closed

        mock_response = AsyncMock()
        mock_response.status = 200
        mock_response.json = AsyncMock(return_value={"data": "test"})
        mock_response.raise_for_status = MagicMock()

        with patch("aiohttp.ClientSession") as mock_session_class,              patch("aiohttp.TCPConnector"):
            mock_session = MagicMock()
            mock_session.closed = False
            mock_session.close = AsyncMock()
            mock_session.get.return_value = _make_ctx(mock_response)
            mock_session_class.return_value = mock_session

            await client._request("test/endpoint")

            assert mock_session_class.called

    @pytest.mark.asyncio
    async def test_connector_closed_error_recovery(self):
        """Test recovery from Connector is closed error."""
        client = TMDBClient(api_key="test_key")

        call_count = 0

        def mock_get_side_effect(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise aiohttp.ClientError("Connector is closed")
            mock_response = AsyncMock()
            mock_response.status = 200
            mock_response.json = AsyncMock(return_value={"recovered": True})
            mock_response.raise_for_status = MagicMock()
            return _make_ctx(mock_response)

        session = _make_session()
        session.get.side_effect = mock_get_side_effect
        client.session = session

        with patch("aiohttp.ClientSession", return_value=session), \
             patch("aiohttp.TCPConnector"), \
             patch("asyncio.sleep", new_callable=AsyncMock):
            result = await client._request("test/endpoint", max_retries=3)
            assert result == {"recovered": True}
            assert call_count == 2

        await client.close()
