"""Tests for retry and circuit breaker utilities."""

import time
from unittest.mock import patch

import pytest

from api.retry import (
    CircuitBreaker,
    CircuitBreakerOpenError,
    _calculate_delay,
    exponential_backoff,
)


class TestExponentialBackoff:
    """Tests for exponential backoff decorator."""

    def test_successful_call_no_retry(self):
        """Test successful call doesn't retry."""
        call_count = 0

        @exponential_backoff(max_retries=3, base_delay=0.01)
        def successful_func():
            nonlocal call_count
            call_count += 1
            return "success"

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

    def test_retries_on_connection_error(self):
        """Test retries on ConnectionError."""
        call_count = 0

        @exponential_backoff(max_retries=2, base_delay=0.01)
        def failing_func():
            nonlocal call_count
            call_count += 1
            if call_count < 3:
                raise ConnectionError("Connection failed")
            return "success"

        result = failing_func()
        assert result == "success"
        assert call_count == 3

    def test_max_retries_exceeded(self):
        """Test exception raised after max retries."""
        call_count = 0

        @exponential_backoff(max_retries=2, base_delay=0.01)
        def always_fails():
            nonlocal call_count
            call_count += 1
            raise ConnectionError("Connection failed")

        with pytest.raises(ConnectionError):
            always_fails()

        assert call_count == 3  # Initial + 2 retries

    def test_non_retryable_exception_not_retried(self):
        """Test non-retryable exceptions are not retried."""
        call_count = 0

        @exponential_backoff(max_retries=3, base_delay=0.01)
        def raises_value_error():
            nonlocal call_count
            call_count += 1
            raise ValueError("Not retryable")

        with pytest.raises(ValueError):
            raises_value_error()

        assert call_count == 1  # No retries

    def test_custom_retryable_exceptions(self):
        """Test custom retryable exceptions."""
        call_count = 0

        @exponential_backoff(
            max_retries=2,
            base_delay=0.01,
            retryable_exceptions=(ValueError,),
        )
        def raises_value_error():
            nonlocal call_count
            call_count += 1
            if call_count < 3:
                raise ValueError("Custom retryable")
            return "success"

        result = raises_value_error()
        assert result == "success"
        assert call_count == 3

    def test_retries_on_status_code(self):
        """Test retries on specific HTTP status codes."""
        call_count = 0

        class MockResponse:
            def __init__(self, status_code):
                self.status_code = status_code

        @exponential_backoff(
            max_retries=2,
            base_delay=0.01,
            retryable_status_codes=(503,),
        )
        def returns_503_then_200():
            nonlocal call_count
            call_count += 1
            if call_count < 3:
                return MockResponse(503)
            return MockResponse(200)

        result = returns_503_then_200()
        assert result.status_code == 200
        assert call_count == 3


class TestCalculateDelay:
    """Tests for delay calculation."""

    def test_base_delay(self):
        """Test first attempt uses base delay."""
        delay = _calculate_delay(
            attempt=0,
            base_delay=1.0,
            max_delay=30.0,
            exponential_base=2.0,
            jitter=False,
        )
        assert delay == 1.0

    def test_exponential_growth(self):
        """Test delay grows exponentially."""
        delays = []
        for attempt in range(4):
            delay = _calculate_delay(
                attempt=attempt,
                base_delay=1.0,
                max_delay=100.0,
                exponential_base=2.0,
                jitter=False,
            )
            delays.append(delay)

        assert delays == [1.0, 2.0, 4.0, 8.0]

    def test_max_delay_cap(self):
        """Test delay is capped at max_delay."""
        delay = _calculate_delay(
            attempt=10,
            base_delay=1.0,
            max_delay=5.0,
            exponential_base=2.0,
            jitter=False,
        )
        assert delay == 5.0

    def test_jitter_adds_randomness(self):
        """Test jitter adds randomness to delay."""
        delays = set()
        for _ in range(10):
            delay = _calculate_delay(
                attempt=1,
                base_delay=1.0,
                max_delay=30.0,
                exponential_base=2.0,
                jitter=True,
            )
            delays.add(round(delay, 2))

        # With jitter, we should get different values
        assert len(delays) > 1


class TestCircuitBreaker:
    """Tests for circuit breaker pattern."""

    def test_closed_state_allows_calls(self):
        """Test closed circuit allows calls through."""
        cb = CircuitBreaker(failure_threshold=3)

        @cb
        def successful_func():
            return "success"

        result = successful_func()
        assert result == "success"
        assert cb.state == CircuitBreaker.CLOSED

    def test_opens_after_threshold_failures(self):
        """Test circuit opens after failure threshold."""
        cb = CircuitBreaker(failure_threshold=2, recovery_timeout=60)

        @cb
        def failing_func():
            raise ConnectionError("Failed")

        # First failure
        with pytest.raises(ConnectionError):
            failing_func()
        assert cb.state == CircuitBreaker.CLOSED

        # Second failure - should open
        with pytest.raises(ConnectionError):
            failing_func()
        assert cb.state == CircuitBreaker.OPEN

    def test_open_circuit_rejects_calls(self):
        """Test open circuit rejects calls immediately."""
        cb = CircuitBreaker(failure_threshold=1, recovery_timeout=60)

        @cb
        def failing_func():
            raise ConnectionError("Failed")

        # Trigger open state
        with pytest.raises(ConnectionError):
            failing_func()

        # Next call should be rejected
        with pytest.raises(CircuitBreakerOpenError):
            failing_func()

    def test_half_open_after_timeout(self):
        """Test circuit enters half-open after recovery timeout."""
        cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1)

        @cb
        def failing_func():
            raise ConnectionError("Failed")

        # Trigger open state
        with pytest.raises(ConnectionError):
            failing_func()
        assert cb.state == CircuitBreaker.OPEN

        # Simulate recovery timeout by moving last_failure_time back
        cb._last_failure_time = time.time() - 0.2

        # Should now be half-open
        assert cb.state == CircuitBreaker.HALF_OPEN

    def test_half_open_closes_on_success(self):
        """Test half-open circuit closes on successful call."""
        cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1)
        call_count = 0

        @cb
        def sometimes_fails():
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise ConnectionError("First call fails")
            return "success"

        # Trigger open state
        with pytest.raises(ConnectionError):
            sometimes_fails()

        # Simulate recovery timeout by moving last_failure_time back
        cb._last_failure_time = time.time() - 0.2

        # Should succeed and close circuit
        result = sometimes_fails()
        assert result == "success"
        assert cb.state == CircuitBreaker.CLOSED

    def test_half_open_reopens_on_failure(self):
        """Test half-open circuit reopens on failure."""
        cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1)

        @cb
        def always_fails():
            raise ConnectionError("Always fails")

        # Trigger open state
        with pytest.raises(ConnectionError):
            always_fails()

        # Simulate recovery timeout by moving last_failure_time back
        cb._last_failure_time = time.time() - 0.2
        assert cb.state == CircuitBreaker.HALF_OPEN

        # Failure in half-open should reopen
        with pytest.raises(ConnectionError):
            always_fails()
        assert cb.state == CircuitBreaker.OPEN

    def test_manual_reset(self):
        """Test manual circuit reset."""
        cb = CircuitBreaker(failure_threshold=1, recovery_timeout=60)

        @cb
        def failing_func():
            raise ConnectionError("Failed")

        # Trigger open state
        with pytest.raises(ConnectionError):
            failing_func()
        assert cb.state == CircuitBreaker.OPEN

        # Manual reset
        cb.reset()
        assert cb.state == CircuitBreaker.CLOSED

    def test_custom_exceptions(self):
        """Test circuit breaker with custom exception types."""
        cb = CircuitBreaker(
            failure_threshold=1,
            expected_exception=(ValueError,),
        )

        @cb
        def raises_value_error():
            raise ValueError("Custom error")

        with pytest.raises(ValueError):
            raises_value_error()
        assert cb.state == CircuitBreaker.OPEN

    def test_non_expected_exception_doesnt_count(self):
        """Test non-expected exceptions don't trip circuit."""
        cb = CircuitBreaker(
            failure_threshold=1,
            expected_exception=(ConnectionError,),
        )

        @cb
        def raises_type_error():
            raise TypeError("Not expected")

        # TypeError is not in expected_exception, so it should raise but not trip
        with pytest.raises(TypeError):
            raises_type_error()

        # Circuit should still be closed
        assert cb.state == CircuitBreaker.CLOSED
