"""Tests for resilience patterns (retry and circuit breaker)."""

import contextlib
import time
from threading import Thread
from unittest.mock import Mock

import pytest

from augint_library import (
    CircuitOpenError,
    circuit_breaker,
    get_circuit_breakers,
    reset_circuit_breaker,
    retry,
)


class TestRetryDecorator:
    """Test the retry decorator."""

    def test_retry_succeeds_first_attempt(self) -> None:
        """Test function that succeeds on first attempt."""
        mock_func = Mock(return_value="success")

        @retry(max_attempts=3)
        def test_func():
            return mock_func()

        result = test_func()
        assert result == "success"
        assert mock_func.call_count == 1

    def test_retry_succeeds_after_failures(self) -> None:
        """Test function that succeeds after some failures."""
        mock_func = Mock(side_effect=[ValueError("fail"), ValueError("fail"), "success"])

        @retry(max_attempts=3, initial_delay=0.01, retryable_exceptions=(ValueError,))
        def test_func():
            return mock_func()

        result = test_func()
        assert result == "success"
        assert mock_func.call_count == 3

    def test_retry_exhausts_attempts(self) -> None:
        """Test function that fails all retry attempts."""
        mock_func = Mock(side_effect=ValueError("always fails"))

        @retry(max_attempts=3, initial_delay=0.01, retryable_exceptions=(ValueError,))
        def test_func():
            return mock_func()

        with pytest.raises(ValueError, match="always fails"):
            test_func()

        assert mock_func.call_count == 3

    def test_retry_non_retryable_exception(self) -> None:
        """Test that non-retryable exceptions are raised immediately."""
        mock_func = Mock(side_effect=KeyError("not retryable"))

        @retry(max_attempts=3, retryable_exceptions=(ValueError,))
        def test_func():
            return mock_func()

        with pytest.raises(KeyError, match="not retryable"):
            test_func()

        # Should not retry for non-retryable exceptions
        assert mock_func.call_count == 1

    def test_retry_exponential_backoff(self) -> None:
        """Test exponential backoff timing."""
        attempts = []

        def track_time():
            attempts.append(time.time())
            if len(attempts) < 3:
                raise ValueError("retry me")
            return "success"

        @retry(max_attempts=3, initial_delay=0.1, exponential_base=2, jitter=False)
        def test_func():
            return track_time()

        result = test_func()
        assert result == "success"
        assert len(attempts) == 3

        # Check delays (approximately)
        delay1 = attempts[1] - attempts[0]
        delay2 = attempts[2] - attempts[1]

        assert 0.09 < delay1 < 0.11  # ~0.1s
        assert 0.19 < delay2 < 0.21  # ~0.2s (doubled)

    def test_retry_with_jitter(self) -> None:
        """Test that jitter adds randomness to delays."""
        delays = []

        for _ in range(5):
            attempts = []

            def track_time(attempts=attempts):
                attempts.append(time.time())
                if len(attempts) < 2:
                    raise ValueError("retry me")
                return "success"

            @retry(max_attempts=2, initial_delay=0.1, jitter=True)
            def test_func():
                return track_time()

            test_func()
            delays.append(attempts[1] - attempts[0])

        # With jitter, delays should vary
        assert len(set(delays)) > 1  # Not all delays are identical
        assert all(0.05 < d < 0.15 for d in delays)  # But within expected range

    def test_retry_preserves_function_signature(self) -> None:
        """Test that retry decorator preserves function metadata."""

        @retry(max_attempts=2)
        def original_func(x: int, y: str = "default") -> str:
            """Original docstring."""
            return f"{x} {y}"

        assert original_func.__name__ == "original_func"
        assert original_func.__doc__ == "Original docstring."


class TestCircuitBreakerDecorator:
    """Test the circuit breaker decorator."""

    def setup_method(self) -> None:
        """Reset circuit breakers before each test."""
        # Clear all circuit breakers
        breakers = get_circuit_breakers()
        for name in breakers:
            reset_circuit_breaker(name)

    def test_circuit_breaker_closed_state(self) -> None:
        """Test circuit breaker in closed (normal) state."""
        mock_func = Mock(return_value="success")

        @circuit_breaker(failure_threshold=3, name="test_cb_closed")
        def test_func():
            return mock_func()

        # Should work normally when closed
        for _ in range(5):
            result = test_func()
            assert result == "success"

        assert mock_func.call_count == 5

        # Check state
        breakers = get_circuit_breakers()
        assert breakers["test_cb_closed"]["state"] == "closed"
        assert breakers["test_cb_closed"]["failure_count"] == 0

    def test_circuit_breaker_opens_after_failures(self) -> None:
        """Test circuit breaker opens after threshold failures."""
        call_count = 0

        @circuit_breaker(failure_threshold=3, recovery_timeout=60, name="test_cb_open")
        def test_func():
            nonlocal call_count
            call_count += 1
            raise ValueError("simulated failure")

        # First 3 calls should go through and fail
        for _i in range(3):
            with pytest.raises(ValueError):
                test_func()

        assert call_count == 3

        # Circuit should now be open
        breakers = get_circuit_breakers()
        assert breakers["test_cb_open"]["state"] == "open"
        assert breakers["test_cb_open"]["failure_count"] == 3

        # Next call should be rejected by circuit breaker
        with pytest.raises(CircuitOpenError) as exc_info:
            test_func()

        assert call_count == 3  # Function not called
        assert "Circuit breaker is OPEN" in str(exc_info.value)

    def test_circuit_breaker_half_open_recovery(self) -> None:
        """Test circuit breaker recovery through half-open state."""
        attempts = []

        @circuit_breaker(
            failure_threshold=2,
            recovery_timeout=0.1,  # Short timeout for testing
            success_threshold=2,
            name="test_cb_recovery",
        )
        def test_func():
            attempts.append(time.time())
            if len(attempts) <= 2:
                raise ValueError("fail")
            return "success"

        # Trigger circuit open
        for _ in range(2):
            with pytest.raises(ValueError):
                test_func()

        # Circuit is now open
        with pytest.raises(CircuitOpenError):
            test_func()

        # Wait for recovery timeout
        time.sleep(0.15)

        # Circuit should transition to half-open and allow test
        result = test_func()
        assert result == "success"

        # One success in half-open state
        breakers = get_circuit_breakers()
        assert breakers["test_cb_recovery"]["state"] == "half_open"

        # Second success should close the circuit
        result = test_func()
        assert result == "success"

        breakers = get_circuit_breakers()
        assert breakers["test_cb_recovery"]["state"] == "closed"

    def test_circuit_breaker_half_open_failure(self) -> None:
        """Test circuit breaker returns to open on half-open failure."""

        @circuit_breaker(failure_threshold=1, recovery_timeout=0.1, name="test_cb_half_open_fail")
        def test_func():
            raise ValueError("always fails")

        # Open the circuit
        with pytest.raises(ValueError):
            test_func()

        # Wait for recovery timeout
        time.sleep(0.15)

        # Half-open test fails, should return to open
        with pytest.raises(ValueError):
            test_func()

        breakers = get_circuit_breakers()
        assert breakers["test_cb_half_open_fail"]["state"] == "open"

    def test_circuit_breaker_thread_safety(self) -> None:
        """Test circuit breaker is thread-safe."""
        results = []
        errors = []

        @circuit_breaker(failure_threshold=5, name="test_cb_thread_safe")
        def test_func(should_fail):
            if should_fail:
                raise ValueError("fail")
            return "success"

        def worker(should_fail, count):
            for _ in range(count):
                try:
                    result = test_func(should_fail)
                    results.append(result)
                except (ValueError, CircuitOpenError) as e:
                    errors.append(type(e).__name__)
                time.sleep(0.001)

        # Start multiple threads
        threads = []
        for i in range(5):
            # Some threads succeed, some fail
            t = Thread(target=worker, args=(i % 2 == 0, 10))
            threads.append(t)
            t.start()

        # Wait for all threads
        for t in threads:
            t.join()

        # Should have seen both successes and failures
        assert len(results) > 0
        assert len(errors) > 0

        # Circuit breaker state should be consistent
        breakers = get_circuit_breakers()
        state = breakers["test_cb_thread_safe"]
        assert state["failure_count"] >= 0


class TestCircuitBreakerManagement:
    """Test circuit breaker management functions."""

    def setup_method(self) -> None:
        """Reset circuit breakers before each test."""
        breakers = get_circuit_breakers()
        for name in breakers:
            reset_circuit_breaker(name)

    def test_get_circuit_breakers_empty(self) -> None:
        """Test getting circuit breakers when none registered."""
        # Clear any existing breakers
        import augint_library.resilience

        augint_library.resilience._circuit_breakers.clear()

        breakers = get_circuit_breakers()
        assert breakers == {}

    def test_get_circuit_breakers_with_data(self) -> None:
        """Test getting circuit breaker status."""

        @circuit_breaker(name="test_breaker_1")
        def func1():
            return "ok"

        @circuit_breaker(name="test_breaker_2")
        def func2():
            raise ValueError("fail")

        # Call functions to register breakers
        func1()
        with contextlib.suppress(ValueError):
            func2()

        breakers = get_circuit_breakers()
        assert "test_breaker_1" in breakers
        assert "test_breaker_2" in breakers
        assert breakers["test_breaker_1"]["failure_count"] == 0
        assert breakers["test_breaker_2"]["failure_count"] == 1

    def test_reset_circuit_breaker(self) -> None:
        """Test resetting a circuit breaker."""

        @circuit_breaker(failure_threshold=1, name="test_reset")
        def test_func():
            raise ValueError("fail")

        # Open the circuit
        with pytest.raises(ValueError):
            test_func()

        # Circuit is open
        with pytest.raises(CircuitOpenError):
            test_func()

        # Reset it
        success = reset_circuit_breaker("test_reset")
        assert success is True

        # Should work again (will fail with ValueError, not CircuitOpenError)
        with pytest.raises(ValueError):
            test_func()

    def test_reset_nonexistent_circuit_breaker(self) -> None:
        """Test resetting a non-existent circuit breaker."""
        success = reset_circuit_breaker("does_not_exist")
        assert success is False


class TestCombinedResilience:
    """Test combining retry and circuit breaker patterns."""

    def setup_method(self) -> None:
        """Reset circuit breakers before each test."""
        # Clear all circuit breakers
        import augint_library.resilience

        augint_library.resilience._circuit_breakers.clear()

    def test_retry_with_circuit_breaker(self) -> None:
        """Test using retry and circuit breaker together."""
        call_count = 0

        @retry(max_attempts=3, initial_delay=0.01)
        @circuit_breaker(failure_threshold=6, name="test_combined")  # Increased threshold
        def test_func() -> str:
            nonlocal call_count
            call_count += 1
            if call_count < 7:
                raise ValueError("fail")
            return "success"

        # First call will retry 3 times, all fail
        with pytest.raises(ValueError):
            test_func()
        assert call_count == 3

        # Second call will retry 3 times, totaling 6 failures, opening circuit
        with pytest.raises(ValueError):
            test_func()
        assert call_count == 6  # Circuit opens after 6 failures

        # Circuit is now open, retry won't help
        with pytest.raises(CircuitOpenError):
            test_func()
        assert call_count == 6  # Function not called

    def test_circuit_breaker_before_retry(self) -> None:
        """Test circuit breaker preventing retry attempts."""
        call_count = 0

        @retry(max_attempts=10, initial_delay=0.01, retryable_exceptions=(ValueError,))
        @circuit_breaker(failure_threshold=3, name="test_cb_first")
        def test_func() -> None:
            nonlocal call_count
            call_count += 1
            raise ValueError("fail")

        # First call: retry will attempt up to 10 times, but circuit opens after 3
        # Once circuit opens, it raises CircuitOpenError which is not retryable
        with pytest.raises(CircuitOpenError):
            test_func()

        # Circuit opened after 3 failures
        assert call_count == 3

        # Second call should be blocked by open circuit immediately
        with pytest.raises(CircuitOpenError):
            test_func()

        # Function shouldn't have been called again
        assert call_count == 3
