"""Tests for retry logic and exponential backoff."""

import asyncio
import time
from unittest.mock import Mock, patch

import pytest

from cleave.core.retry import (
    RetryError,
    retry,
    retry_on_file_errors,
    retry_on_network_errors,
)


class TestRetryDecorator:
    """Test retry decorator basic functionality."""

    def test_retry_success_first_attempt(self):
        """Test that successful first attempt returns immediately."""
        call_count = 0

        @retry(max_attempts=3)
        def succeeds_first_time():
            nonlocal call_count
            call_count += 1
            return "success"

        result = succeeds_first_time()
        assert result == "success"
        assert call_count == 1

    def test_retry_success_after_failures(self):
        """Test that function succeeds after transient failures."""
        call_count = 0

        @retry(max_attempts=3, initial_delay=0.1)
        def succeeds_on_second_attempt():
            nonlocal call_count
            call_count += 1
            if call_count < 2:
                raise ConnectionError("Transient error")
            return "success"

        result = succeeds_on_second_attempt()
        assert result == "success"
        assert call_count == 2

    def test_retry_exhausts_attempts(self):
        """Test that retry gives up after max attempts."""
        call_count = 0

        @retry(max_attempts=3, initial_delay=0.1)
        def always_fails():
            nonlocal call_count
            call_count += 1
            raise ConnectionError("Permanent error")

        with pytest.raises(RetryError) as exc_info:
            always_fails()

        assert call_count == 3
        assert "failed after 3 attempts" in str(exc_info.value).lower()
        assert isinstance(exc_info.value.last_exception, ConnectionError)

    def test_retry_exponential_backoff(self):
        """Test that delays follow exponential backoff."""
        call_times = []

        @retry(max_attempts=3, initial_delay=0.1, exponential_base=2.0)
        def fails_with_timing():
            call_times.append(time.time())
            raise ConnectionError("Error")

        with pytest.raises(RetryError):
            fails_with_timing()

        # Check that delays increase exponentially
        assert len(call_times) == 3
        delay1 = call_times[1] - call_times[0]
        delay2 = call_times[2] - call_times[1]

        # Second delay should be ~2x first delay (within tolerance)
        assert delay2 > delay1 * 1.5  # Allow some tolerance

    def test_retry_respects_max_delay(self):
        """Test that delay doesn't exceed max_delay."""
        call_times = []

        @retry(max_attempts=4, initial_delay=1.0, max_delay=1.5, exponential_base=3.0)
        def fails_with_capped_delay():
            call_times.append(time.time())
            raise ConnectionError("Error")

        with pytest.raises(RetryError):
            fails_with_capped_delay()

        # All delays should be capped at max_delay
        for i in range(1, len(call_times)):
            delay = call_times[i] - call_times[i - 1]
            assert delay <= 1.6  # max_delay + small tolerance

    def test_retry_specific_exceptions(self):
        """Test that only specified exceptions are retried."""

        @retry(max_attempts=3, initial_delay=0.1, exceptions=(ConnectionError,))
        def raises_value_error():
            raise ValueError("Should not retry this")

        # ValueError should not be retried
        with pytest.raises(ValueError):
            raises_value_error()

    def test_retry_logging(self):
        """Test that retry attempts are logged."""
        call_count = 0

        @retry(max_attempts=3, initial_delay=0.1, log_errors=True)
        def fails_twice():
            nonlocal call_count
            call_count += 1
            if call_count < 3:
                raise ConnectionError("Transient")
            return "success"

        with patch("cleave.core.retry.logger") as mock_logger:
            result = fails_twice()
            assert result == "success"
            # Should have logged warnings for first 2 attempts
            assert mock_logger.warning.call_count == 2


class TestAsyncRetry:
    """Test retry decorator with async functions."""

    @pytest.mark.asyncio
    async def test_async_retry_success(self):
        """Test async retry succeeds after failures."""
        call_count = 0

        @retry(max_attempts=3, initial_delay=0.1)
        async def async_succeeds_on_second():
            nonlocal call_count
            call_count += 1
            if call_count < 2:
                raise ConnectionError("Transient")
            return "success"

        result = await async_succeeds_on_second()
        assert result == "success"
        assert call_count == 2

    @pytest.mark.asyncio
    async def test_async_retry_exhausts(self):
        """Test async retry exhausts attempts."""
        call_count = 0

        @retry(max_attempts=3, initial_delay=0.1)
        async def async_always_fails():
            nonlocal call_count
            call_count += 1
            raise TimeoutError("Always fails")

        with pytest.raises(RetryError) as exc_info:
            await async_always_fails()

        assert call_count == 3
        assert isinstance(exc_info.value.last_exception, TimeoutError)

    @pytest.mark.asyncio
    async def test_async_retry_backoff(self):
        """Test async retry uses asyncio.sleep."""
        call_times = []

        @retry(max_attempts=3, initial_delay=0.1, exponential_base=2.0)
        async def async_fails_with_timing():
            call_times.append(time.time())
            raise ConnectionError("Error")

        with pytest.raises(RetryError):
            await async_fails_with_timing()

        assert len(call_times) == 3
        # Delays should increase
        assert (call_times[2] - call_times[1]) > (call_times[1] - call_times[0])


class TestRetryConvenienceFunctions:
    """Test convenience retry decorators."""

    def test_retry_on_network_errors_basic(self):
        """Test network errors retry decorator."""
        call_count = 0

        @retry_on_network_errors(max_attempts=3, initial_delay=0.1)
        def network_operation():
            nonlocal call_count
            call_count += 1
            if call_count < 2:
                raise ConnectionError("Network issue")
            return "success"

        result = network_operation()
        assert result == "success"
        assert call_count == 2

    def test_retry_on_network_errors_timeout(self):
        """Test network retry handles TimeoutError."""
        call_count = 0

        @retry_on_network_errors(max_attempts=2, initial_delay=0.1)
        def timeout_operation():
            nonlocal call_count
            call_count += 1
            if call_count < 2:
                raise TimeoutError("Request timeout")
            return "success"

        result = timeout_operation()
        assert result == "success"

    def test_retry_on_file_errors_basic(self):
        """Test file errors retry decorator."""
        call_count = 0

        @retry_on_file_errors(max_attempts=2, initial_delay=0.1)
        def file_operation():
            nonlocal call_count
            call_count += 1
            if call_count < 2:
                raise OSError("File locked")
            return "success"

        result = file_operation()
        assert result == "success"

    def test_retry_on_file_errors_permission(self):
        """Test file retry handles PermissionError."""
        call_count = 0

        @retry_on_file_errors(max_attempts=2, initial_delay=0.1)
        def permission_operation():
            nonlocal call_count
            call_count += 1
            if call_count < 2:
                raise PermissionError("Access denied")
            return "success"

        result = permission_operation()
        assert result == "success"

    @pytest.mark.asyncio
    async def test_retry_on_network_errors_async(self):
        """Test network retry with async functions."""
        call_count = 0

        @retry_on_network_errors(max_attempts=3, initial_delay=0.1)
        async def async_network_op():
            nonlocal call_count
            call_count += 1
            if call_count < 2:
                raise ConnectionError("Network error")
            return "success"

        result = await async_network_op()
        assert result == "success"
        assert call_count == 2


class TestRetryEdgeCases:
    """Test edge cases and error conditions."""

    def test_retry_with_zero_delay(self):
        """Test retry with zero initial delay."""
        call_count = 0

        @retry(max_attempts=2, initial_delay=0.0)
        def immediate_retry():
            nonlocal call_count
            call_count += 1
            if call_count < 2:
                raise ConnectionError("Error")
            return "success"

        result = immediate_retry()
        assert result == "success"

    def test_retry_with_single_attempt(self):
        """Test retry with max_attempts=1 (no retries)."""
        call_count = 0

        @retry(max_attempts=1, initial_delay=0.1)
        def single_attempt():
            nonlocal call_count
            call_count += 1
            raise ConnectionError("Error")

        with pytest.raises(RetryError):
            single_attempt()

        assert call_count == 1

    def test_retry_preserves_return_value(self):
        """Test that retry preserves complex return values."""

        @retry(max_attempts=2, initial_delay=0.1)
        def returns_dict():
            return {"key": "value", "nested": {"data": 123}}

        result = returns_dict()
        assert result == {"key": "value", "nested": {"data": 123}}

    def test_retry_preserves_function_metadata(self):
        """Test that retry preserves function name and docstring."""

        @retry(max_attempts=2)
        def documented_function():
            """This is the docstring."""
            return "result"

        assert documented_function.__name__ == "documented_function"
        assert documented_function.__doc__ == "This is the docstring."


class TestRetryRealWorldScenarios:
    """Test retry with real-world-like scenarios."""

    @pytest.mark.asyncio
    async def test_api_call_with_transient_failure(self):
        """Test API call that fails transiently then succeeds."""
        attempt_results = [
            ConnectionError("Connection reset"),
            TimeoutError("Request timeout"),
            "success",
        ]
        call_count = 0

        @retry_on_network_errors(max_attempts=4, initial_delay=0.1)
        async def api_call():
            nonlocal call_count
            result = attempt_results[call_count]
            call_count += 1
            if isinstance(result, Exception):
                raise result
            return result

        result = await api_call()
        assert result == "success"
        assert call_count == 3

    def test_file_write_with_permission_flap(self):
        """Test file write that has permission issues then succeeds."""
        attempt_results = [
            PermissionError("Access denied"),
            "written",
        ]
        call_count = 0

        @retry_on_file_errors(max_attempts=3, initial_delay=0.1)
        def write_file():
            nonlocal call_count
            result = attempt_results[call_count]
            call_count += 1
            if isinstance(result, Exception):
                raise result
            return result

        result = write_file()
        assert result == "written"
        assert call_count == 2
