"""
Test suite for rate limit (429/ResourceExhausted) handling.

Ensures the system gracefully handles Gemini API free tier rate limits
with proper exponential backoff and logging.
"""

import os
from unittest.mock import AsyncMock, patch

import pytest
from langchain_core.messages import AIMessage

from src.agents import invoke_with_rate_limit_handling

# Fixed jitter value for deterministic tests
FIXED_JITTER = 5.0


class Test429Detection:
    """Test detection of various rate limit error formats."""

    @pytest.mark.asyncio
    async def test_detects_429_in_error_message(self):
        """Test that 429 in error message triggers rate limit handling."""
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=[
                Exception("HTTP 429: Too Many Requests"),
                AIMessage(content="Success after retry"),
            ]
        )

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            with patch("src.agents.runtime.random.uniform", return_value=FIXED_JITTER):
                result = await invoke_with_rate_limit_handling(
                    runnable, {"input": "test"}, max_attempts=2
                )

        # Should have slept 60 + jitter seconds before retry
        mock_sleep.assert_called_once_with(60 + FIXED_JITTER)
        assert result.content == "Success after retry"

    @pytest.mark.asyncio
    async def test_detects_resourceexhausted(self):
        """Test detection of ResourceExhausted errors."""
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=[
                Exception("ResourceExhausted: Quota exceeded"),
                AIMessage(content="Success"),
            ]
        )

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            with patch("src.agents.runtime.random.uniform", return_value=FIXED_JITTER):
                result = await invoke_with_rate_limit_handling(
                    runnable, {"input": "test"}, max_attempts=2
                )

        mock_sleep.assert_called_once_with(60 + FIXED_JITTER)
        assert result.content == "Success"

    @pytest.mark.asyncio
    async def test_detects_rate_limit_text(self):
        """Test detection of 'rate limit' in error text."""
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=[
                Exception("rate limit exceeded for this API key"),
                AIMessage(content="Success"),
            ]
        )

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            result = await invoke_with_rate_limit_handling(
                runnable, {"input": "test"}, max_attempts=2
            )

        mock_sleep.assert_called_once()

    @pytest.mark.asyncio
    async def test_detects_quota_exceeded(self):
        """Test detection of quota exceeded errors."""
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=[
                Exception("Quota exceeded for requests per minute"),
                AIMessage(content="Success"),
            ]
        )

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            result = await invoke_with_rate_limit_handling(
                runnable, {"input": "test"}, max_attempts=2
            )

        mock_sleep.assert_called_once()

    @pytest.mark.asyncio
    async def test_detects_too_many_requests(self):
        """Test detection of 'too many requests' errors."""
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=[
                Exception("Too many requests, please slow down"),
                AIMessage(content="Success"),
            ]
        )

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            result = await invoke_with_rate_limit_handling(
                runnable, {"input": "test"}, max_attempts=2
            )

        mock_sleep.assert_called_once()


class TestExponentialBackoff:
    """Test exponential backoff behavior."""

    @pytest.mark.asyncio
    async def test_backoff_progression_60_120(self):
        """Test that backoff times progress correctly (60s, 120s) + jitter.

        Reduced from 3 attempts to 2 for faster test execution.
        """
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=[
                Exception("429 Too Many Requests"),
                Exception("429 Too Many Requests"),
                AIMessage(content="Success on 3rd attempt"),
            ]
        )

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            with patch("src.agents.runtime.random.uniform", return_value=FIXED_JITTER):
                result = await invoke_with_rate_limit_handling(
                    runnable, {"input": "test"}, max_attempts=3
                )

        # Check sleep was called with 60+jitter, 120+jitter
        assert mock_sleep.call_count == 2
        call_args = [call[0][0] for call in mock_sleep.call_args_list]
        assert call_args == [60 + FIXED_JITTER, 120 + FIXED_JITTER]

    @pytest.mark.asyncio
    @pytest.mark.skip(
        reason="Slow/expensive test - skipped to improve test suite performance"
    )
    async def test_raises_after_max_attempts(self):
        """Test that error is raised after max attempts exhausted.

        SKIPPED: This test is slow and expensive.
        Run explicitly with: pytest -m slow
        """
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(side_effect=Exception("429 Too Many Requests"))

        with pytest.raises(Exception, match="429"):
            await invoke_with_rate_limit_handling(
                runnable, {"input": "test"}, max_attempts=3
            )


class TestNonRateLimitErrors:
    """Test that non-rate-limit errors are handled correctly."""

    @pytest.mark.asyncio
    async def test_non_429_error_raised_immediately(self):
        """Test that non-429 errors are raised without retry."""
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(side_effect=ValueError("Invalid input"))

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            with pytest.raises(ValueError, match="Invalid input"):
                await invoke_with_rate_limit_handling(
                    runnable, {"input": "test"}, max_attempts=3
                )

        # Should not sleep for non-rate-limit errors
        mock_sleep.assert_not_called()

    @pytest.mark.asyncio
    async def test_non_retriable_error_raised_immediately(self):
        """Test that non-retriable errors (not rate limit, not transient) raise immediately."""
        runnable = AsyncMock()
        # Use an error that's neither rate limit nor transient
        runnable.ainvoke = AsyncMock(side_effect=ValueError("Invalid input format"))

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            with pytest.raises(ValueError):
                await invoke_with_rate_limit_handling(
                    runnable, {"input": "test"}, max_attempts=3
                )

        mock_sleep.assert_not_called()

    @pytest.mark.asyncio
    async def test_transient_error_retried_with_short_backoff(self):
        """Test that transient errors (timeout, connection) are retried with short backoff."""
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=[
                ConnectionError("Connection timeout"),
                ConnectionError("Connection timeout"),
                "success",  # Third attempt succeeds
            ]
        )

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            result = await invoke_with_rate_limit_handling(
                runnable,
                {"input": "test"},
                max_attempts=3,
                max_transient_attempts=3,
            )

        assert result == "success"
        assert mock_sleep.call_count == 2  # Two retries before success
        # Verify short backoff (5s base, not 60s like rate limits)
        # First retry: ~5-8s, Second retry: ~10-13s
        first_wait = mock_sleep.call_args_list[0][0][0]
        second_wait = mock_sleep.call_args_list[1][0][0]
        assert 5 <= first_wait <= 10, f"First wait {first_wait} not in expected range"
        assert (
            10 <= second_wait <= 15
        ), f"Second wait {second_wait} not in expected range"


class TestQuietMode:
    """Test quiet mode behaviour: warnings/errors always surface, info is suppressed."""

    @pytest.mark.asyncio
    async def test_quiet_mode_still_logs_warnings(self):
        """Warnings (retries, failures) must be emitted even in quiet mode.

        quiet_mode suppresses INFO chatter only.  Diagnostically important events
        (rate-limit retries, LLM failures, data-source errors) must reach the log
        file so operators can diagnose silent pipeline failures.
        """
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=[
                Exception("429 Too Many Requests"),
                AIMessage(content="Success"),
            ]
        )

        with patch("asyncio.sleep", new_callable=AsyncMock):
            with patch("src.agents.runtime.settings_config") as mock_config:
                mock_config.quiet_mode = True
                with patch("src.agents.runtime.logger") as mock_logger:
                    await invoke_with_rate_limit_handling(
                        runnable,
                        {"input": "test"},
                        max_attempts=2,
                        context="Test Agent",
                    )

                    # Retry warning must be logged even in quiet mode
                    mock_logger.warning.assert_called_once()
                    assert mock_logger.warning.call_args[0][0] == "llm_call_retry"
                    # INFO (start/success) must NOT be logged in quiet mode
                    mock_logger.info.assert_not_called()

    @pytest.mark.asyncio
    async def test_quiet_mode_still_logs_terminal_error(self):
        """Non-retryable failures must still surface as ERROR in quiet mode."""
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=Exception("401 Unauthorized: invalid api key")
        )

        with patch("src.agents.runtime.settings_config") as mock_config:
            mock_config.quiet_mode = True
            with patch("src.agents.runtime.logger") as mock_logger:
                with pytest.raises(Exception, match="401 Unauthorized"):
                    await invoke_with_rate_limit_handling(
                        runnable,
                        {"input": "test"},
                        max_attempts=2,
                        context="Test Agent",
                    )

                mock_logger.error.assert_called_once()
                assert mock_logger.error.call_args[0][0] == "llm_call_failed"
                mock_logger.info.assert_not_called()

    @pytest.mark.asyncio
    async def test_normal_mode_logs_rate_limits(self):
        """Test that rate limits are logged when not in quiet mode."""
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=[
                Exception("429 Too Many Requests"),
                AIMessage(content="Success"),
            ]
        )

        with patch("asyncio.sleep", new_callable=AsyncMock):
            with patch("src.agents.runtime.random.uniform", return_value=FIXED_JITTER):
                with patch("src.agents.runtime.settings_config") as mock_config:
                    mock_config.quiet_mode = False
                    with patch("src.agents.runtime.logger") as mock_logger:
                        result = await invoke_with_rate_limit_handling(
                            runnable,
                            {"input": "test"},
                            max_attempts=2,
                            context="Test Agent",
                        )

                        # Should log in normal mode
                        mock_logger.warning.assert_called_once()
                        call_args = mock_logger.warning.call_args[1]
                        assert call_args["context"] == "Test Agent"
                        # wait_seconds is now a formatted string with jitter
                        assert call_args["wait_seconds"] == f"{60 + FIXED_JITTER:.1f}"


class TestContextLogging:
    """Test that context information is properly logged."""

    @pytest.mark.asyncio
    async def test_context_included_in_logs(self):
        """Test that agent context is included in log messages."""

        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=[
                Exception("429 Too Many Requests"),
                AIMessage(content="Success"),
            ]
        )

        with patch("asyncio.sleep", new_callable=AsyncMock):
            with patch("src.agents.runtime.logger") as mock_logger:
                await invoke_with_rate_limit_handling(
                    runnable,
                    {"input": "test"},
                    max_attempts=2,
                    context="Market Analyst",
                )

                call_args = mock_logger.warning.call_args[1]
                assert call_args["context"] == "Market Analyst"
                assert call_args["attempt"] == 1
                assert call_args["max_attempts"] == 2

    @pytest.mark.asyncio
    async def test_error_type_logged(self):
        """Test that error type is captured in logs."""
        os.environ.pop("QUIET_MODE", None)

        class CustomRateLimitError(Exception):
            pass

        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=[
                CustomRateLimitError("429 Too Many Requests"),
                AIMessage(content="Success"),
            ]
        )

        with patch("asyncio.sleep", new_callable=AsyncMock):
            with patch("src.agents.runtime.logger") as mock_logger:
                await invoke_with_rate_limit_handling(
                    runnable, {"input": "test"}, max_attempts=2
                )

                call_args = mock_logger.warning.call_args[1]
                assert call_args["error_type"] == "CustomRateLimitError"


class TestSuccessWithoutRetry:
    """Test that successful calls don't trigger retries."""

    @pytest.mark.asyncio
    async def test_success_on_first_try(self):
        """Test that successful calls complete without any retries."""
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(return_value=AIMessage(content="Success"))

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            result = await invoke_with_rate_limit_handling(
                runnable, {"input": "test"}, max_attempts=3
            )

        # Should not sleep if no errors
        mock_sleep.assert_not_called()
        assert result.content == "Success"
        # Should only invoke once
        assert runnable.ainvoke.call_count == 1


class TestEdgeCases:
    """Test edge cases and boundary conditions."""

    @pytest.mark.asyncio
    async def test_max_attempts_one(self):
        """Test with max_attempts=1 (no retries)."""
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(side_effect=Exception("429 Too Many Requests"))

        with pytest.raises(Exception, match="429"):
            await invoke_with_rate_limit_handling(
                runnable, {"input": "test"}, max_attempts=1
            )

    @pytest.mark.asyncio
    async def test_case_insensitive_detection(self):
        """Test that rate limit detection is case-insensitive."""
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=[
                Exception("HTTP 429: TOO MANY REQUESTS"),  # Uppercase
                AIMessage(content="Success"),
            ]
        )

        with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
            with patch("src.agents.runtime.random.uniform", return_value=FIXED_JITTER):
                result = await invoke_with_rate_limit_handling(
                    runnable, {"input": "test"}, max_attempts=2
                )

        mock_sleep.assert_called_once_with(60 + FIXED_JITTER)

    @pytest.mark.asyncio
    async def test_error_message_truncation(self):
        """Test that long error messages are truncated in logs."""
        os.environ.pop("QUIET_MODE", None)

        long_error = "429 " + "x" * 500  # Very long error message
        runnable = AsyncMock()
        runnable.ainvoke = AsyncMock(
            side_effect=[Exception(long_error), AIMessage(content="Success")]
        )

        with patch("asyncio.sleep", new_callable=AsyncMock):
            with patch("src.agents.runtime.logger") as mock_logger:
                await invoke_with_rate_limit_handling(
                    runnable, {"input": "test"}, max_attempts=2
                )

                call_args = mock_logger.warning.call_args[1]
                # Error message should be truncated to 200 chars
                assert len(call_args["error_message"]) <= 200


if __name__ == "__main__":
    pytest.main([__file__, "-v", "-s"])
