"""Tests for the circuit breaker module."""

from __future__ import annotations

import threading
import time
from unittest.mock import Mock

import pytest

from oclawma.circuit_breaker import (
    CircuitBreaker,
    CircuitBreakerError,
    CircuitState,
    circuit_breaker,
)


class TestCircuitState:
    """Test CircuitState enum."""

    def test_states_exist(self):
        """Test that all three states exist."""
        assert CircuitState.CLOSED.value == "closed"
        assert CircuitState.OPEN.value == "open"
        assert CircuitState.HALF_OPEN.value == "half_open"

    def test_states_are_distinct(self):
        """Test that states have distinct values."""
        states = [CircuitState.CLOSED, CircuitState.OPEN, CircuitState.HALF_OPEN]
        assert len({s.value for s in states}) == 3


class TestCircuitBreakerInit:
    """Test CircuitBreaker initialization."""

    def test_default_initialization(self):
        """Test default initialization values."""
        cb = CircuitBreaker()
        assert cb.failure_threshold == 5
        assert cb.recovery_timeout == 30
        assert cb.half_open_max_calls == 3
        assert cb._circuits == {}

    def test_custom_initialization(self):
        """Test custom initialization values."""
        cb = CircuitBreaker(
            failure_threshold=3,
            recovery_timeout=60,
            half_open_max_calls=5,
        )
        assert cb.failure_threshold == 3
        assert cb.recovery_timeout == 60
        assert cb.half_open_max_calls == 5


class TestCircuitBreakerGetState:
    """Test get_state method."""

    def test_get_state_unknown_destination(self):
        """Test getting state for unknown destination returns CLOSED."""
        cb = CircuitBreaker()
        assert cb.get_state("unknown_service") == CircuitState.CLOSED

    def test_get_state_after_creation(self):
        """Test state is CLOSED after first call."""
        cb = CircuitBreaker()
        cb._get_or_create_circuit("test_service")
        assert cb.get_state("test_service") == CircuitState.CLOSED


class TestCircuitBreakerReset:
    """Test reset method."""

    def test_reset_unknown_destination(self):
        """Test reset for unknown destination creates new circuit."""
        cb = CircuitBreaker()
        cb.reset("new_service")
        assert cb.get_state("new_service") == CircuitState.CLOSED

    def test_reset_open_circuit(self):
        """Test reset brings OPEN circuit to CLOSED."""
        cb = CircuitBreaker(failure_threshold=1)

        # Force circuit to OPEN
        def failing_call():
            raise ValueError("fail")

        with pytest.raises(ValueError):
            cb.call("test_service", failing_call)

        assert cb.get_state("test_service") == CircuitState.OPEN

        # Reset should bring it back to CLOSED
        cb.reset("test_service")
        assert cb.get_state("test_service") == CircuitState.CLOSED

    def test_reset_clears_metrics(self):
        """Test reset clears failure count."""
        cb = CircuitBreaker(failure_threshold=5)

        def failing_call():
            raise ValueError("fail")

        # Record some failures
        for _ in range(3):
            with pytest.raises(ValueError):
                cb.call("test_service", failing_call)

        # Reset
        cb.reset("test_service")

        # Should be able to fail failure_threshold times again
        for _ in range(5):
            with pytest.raises(ValueError):
                cb.call("test_service", failing_call)

        # This should trigger open (total 5 failures)
        assert cb.get_state("test_service") == CircuitState.OPEN


class TestCircuitBreakerClosedState:
    """Test behavior when circuit is CLOSED (normal operation)."""

    def test_successful_call(self):
        """Test successful call in CLOSED state."""
        cb = CircuitBreaker()

        def success():
            return "success"

        result = cb.call("test_service", success)
        assert result == "success"
        assert cb.get_state("test_service") == CircuitState.CLOSED

    def test_failure_does_not_immediately_open(self):
        """Test failures below threshold keep circuit CLOSED."""
        cb = CircuitBreaker(failure_threshold=3)

        def failing_call():
            raise ValueError("fail")

        # 2 failures should keep circuit closed
        for _ in range(2):
            with pytest.raises(ValueError):
                cb.call("test_service", failing_call)

        assert cb.get_state("test_service") == CircuitState.CLOSED

    def test_failure_opens_circuit(self):
        """Test failure threshold opens circuit."""
        cb = CircuitBreaker(failure_threshold=3)

        def failing_call():
            raise ValueError("fail")

        # 3 failures should open circuit
        for _ in range(3):
            with pytest.raises(ValueError):
                cb.call("test_service", failing_call)

        assert cb.get_state("test_service") == CircuitState.OPEN

    def test_mixed_success_and_failure(self):
        """Test mixed success and failure handling."""
        cb = CircuitBreaker(failure_threshold=3)

        call_count = 0

        def mixed_call():
            nonlocal call_count
            call_count += 1
            if call_count % 2 == 0:
                raise ValueError("fail")
            return "success"

        # success, fail, success, fail (2 failures so far)
        cb.call("test_service", mixed_call)  # success (call_count=1)
        with pytest.raises(ValueError):
            cb.call("test_service", mixed_call)  # fail (call_count=2)
        cb.call("test_service", mixed_call)  # success (call_count=3)
        with pytest.raises(ValueError):
            cb.call("test_service", mixed_call)  # fail (call_count=4)

        # Still closed - only 2 failures
        assert cb.get_state("test_service") == CircuitState.CLOSED

        # success, fail (now we have 3 failures, circuit opens)
        cb.call("test_service", mixed_call)  # success (call_count=5)
        with pytest.raises(ValueError):
            cb.call("test_service", mixed_call)  # fail (call_count=6)

        assert cb.get_state("test_service") == CircuitState.OPEN


class TestCircuitBreakerOpenState:
    """Test behavior when circuit is OPEN (failing fast)."""

    def test_open_circuit_raises_error(self):
        """Test that OPEN circuit raises CircuitBreakerError."""
        cb = CircuitBreaker(failure_threshold=1)

        def failing_call():
            raise ValueError("fail")

        # Open the circuit
        with pytest.raises(ValueError):
            cb.call("test_service", failing_call)

        assert cb.get_state("test_service") == CircuitState.OPEN

        # Next call should raise CircuitBreakerError
        with pytest.raises(CircuitBreakerError) as exc_info:
            cb.call("test_service", lambda: "should not run")

        assert "test_service" in str(exc_info.value)
        assert "OPEN" in str(exc_info.value)

    def test_open_circuit_does_not_call_function(self):
        """Test that OPEN circuit prevents function execution."""
        cb = CircuitBreaker(failure_threshold=1)

        mock_fn = Mock(side_effect=ValueError("fail"))

        # Open the circuit
        with pytest.raises(ValueError):
            cb.call("test_service", mock_fn)

        mock_fn.reset_mock()

        # Next call should not execute the function
        with pytest.raises(CircuitBreakerError):
            cb.call("test_service", mock_fn)

        mock_fn.assert_not_called()

    def test_open_circuit_transitions_to_half_open_after_timeout(self):
        """Test OPEN circuit transitions to HALF_OPEN after recovery timeout."""
        cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1)

        def failing_call():
            raise ValueError("fail")

        # Open the circuit
        with pytest.raises(ValueError):
            cb.call("test_service", failing_call)

        assert cb.get_state("test_service") == CircuitState.OPEN

        # Wait for recovery timeout
        time.sleep(0.15)

        # Next call should transition to HALF_OPEN
        with pytest.raises(ValueError):
            cb.call("test_service", failing_call)

        assert cb.get_state("test_service") == CircuitState.OPEN  # Still open after another fail


class TestCircuitBreakerHalfOpenState:
    """Test behavior when circuit is HALF_OPEN (testing recovery)."""

    def test_half_open_success_closes_circuit(self):
        """Test successful call in HALF_OPEN closes circuit."""
        cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1)

        call_count = 0

        def eventually_succeeds():
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise ValueError("fail")
            return "success"

        # Open the circuit
        with pytest.raises(ValueError):
            cb.call("test_service", eventually_succeeds)

        # Wait for recovery timeout
        time.sleep(0.15)

        # This call should be in HALF_OPEN state and succeed
        result = cb.call("test_service", eventually_succeeds)

        assert result == "success"
        assert cb.get_state("test_service") == CircuitState.CLOSED

    def test_half_open_failure_reopens_circuit(self):
        """Test failed call in HALF_OPEN reopens circuit."""
        cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1)

        def always_fails():
            raise ValueError("fail")

        # Open the circuit
        with pytest.raises(ValueError):
            cb.call("test_service", always_fails)

        # Wait for recovery timeout
        time.sleep(0.15)

        # This call should be in HALF_OPEN state and fail
        with pytest.raises(ValueError):
            cb.call("test_service", always_fails)

        # Circuit should be OPEN again
        assert cb.get_state("test_service") == CircuitState.OPEN

    def test_half_open_max_calls(self):
        """Test half_open_max_calls limit."""
        cb = CircuitBreaker(
            failure_threshold=1,
            recovery_timeout=0.1,
            half_open_max_calls=2,
        )

        call_count = 0

        def eventually_succeeds():
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise ValueError("fail")
            return "success"

        # Open the circuit
        with pytest.raises(ValueError):
            cb.call("test_service", eventually_succeeds)

        # Wait for recovery timeout
        time.sleep(0.15)

        # First call in HALF_OPEN - succeeds
        result = cb.call("test_service", eventually_succeeds)
        assert result == "success"

        # Circuit should now be CLOSED
        assert cb.get_state("test_service") == CircuitState.CLOSED

    def test_multiple_half_open_calls(self):
        """Test multiple calls in HALF_OPEN state."""
        cb = CircuitBreaker(
            failure_threshold=2,
            recovery_timeout=0.1,
            half_open_max_calls=3,
        )

        fail_count = 0

        def mixed_call():
            nonlocal fail_count
            fail_count += 1
            if fail_count <= 2:
                raise ValueError("fail")
            return "success"

        # Open the circuit (2 failures)
        for _ in range(2):
            with pytest.raises(ValueError):
                cb.call("test_service", mixed_call)

        # Wait for recovery timeout
        time.sleep(0.15)

        # Now in HALF_OPEN - allow up to 3 calls
        result = cb.call("test_service", mixed_call)
        assert result == "success"

        # Circuit should be CLOSED
        assert cb.get_state("test_service") == CircuitState.CLOSED


class TestCircuitBreakerError:
    """Test CircuitBreakerError exception."""

    def test_error_message_contains_destination(self):
        """Test error message contains destination."""
        error = CircuitBreakerError("test_service", CircuitState.OPEN)
        assert "test_service" in str(error)

    def test_error_message_contains_state(self):
        """Test error message contains state."""
        error = CircuitBreakerError("test_service", CircuitState.OPEN)
        assert "OPEN" in str(error)

    def test_error_attributes(self):
        """Test error has destination and state attributes."""
        error = CircuitBreakerError("test_service", CircuitState.HALF_OPEN)
        assert error.destination == "test_service"
        assert error.state == CircuitState.HALF_OPEN


class TestCircuitBreakerMetrics:
    """Test circuit breaker metrics."""

    def test_initial_metrics(self):
        """Test initial metrics state."""
        cb = CircuitBreaker()
        metrics = cb.get_metrics()
        assert metrics == {}

    def test_metrics_record_state_change_to_open(self):
        """Test metrics record transition to OPEN."""
        cb = CircuitBreaker(failure_threshold=1)

        def failing_call():
            raise ValueError("fail")

        with pytest.raises(ValueError):
            cb.call("test_service", failing_call)

        metrics = cb.get_metrics()
        assert "test_service" in metrics
        assert metrics["test_service"]["state_changes_to_open"] == 1

    def test_metrics_record_state_change_to_closed(self):
        """Test metrics record transition to CLOSED."""
        cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1)

        call_count = 0

        def eventually_succeeds():
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise ValueError("fail")
            return "success"

        # Open the circuit
        with pytest.raises(ValueError):
            cb.call("test_service", eventually_succeeds)

        time.sleep(0.15)

        # Close the circuit
        cb.call("test_service", eventually_succeeds)

        metrics = cb.get_metrics()
        assert metrics["test_service"]["state_changes_to_closed"] == 1

    def test_metrics_record_multiple_state_changes(self):
        """Test metrics record multiple state changes."""
        cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1)

        call_count = 0

        def alternating():
            nonlocal call_count
            call_count += 1
            if call_count % 2 == 1:
                raise ValueError("fail")
            return "success"

        # First cycle: fail, then succeed
        with pytest.raises(ValueError):
            cb.call("test_service", alternating)
        time.sleep(0.15)
        cb.call("test_service", alternating)

        # Second cycle: fail, then succeed
        with pytest.raises(ValueError):
            cb.call("test_service", alternating)
        time.sleep(0.15)
        cb.call("test_service", alternating)

        metrics = cb.get_metrics()
        assert metrics["test_service"]["state_changes_to_open"] == 2
        assert metrics["test_service"]["state_changes_to_closed"] == 2

    def test_metrics_record_failure_count(self):
        """Test metrics record failure count."""
        cb = CircuitBreaker(failure_threshold=5)

        def failing_call():
            raise ValueError("fail")

        for _ in range(3):
            with pytest.raises(ValueError):
                cb.call("test_service", failing_call)

        metrics = cb.get_metrics()
        assert metrics["test_service"]["failure_count"] == 3

    def test_metrics_record_success_count(self):
        """Test metrics record success count."""
        cb = CircuitBreaker()

        def success_call():
            return "success"

        for _ in range(5):
            cb.call("test_service", success_call)

        metrics = cb.get_metrics()
        assert metrics["test_service"]["success_count"] == 5

    def test_metrics_per_destination(self):
        """Test metrics are tracked per destination."""
        cb = CircuitBreaker(failure_threshold=1)

        def failing_call():
            raise ValueError("fail")

        # Open service1
        with pytest.raises(ValueError):
            cb.call("service1", failing_call)

        # service2 should still be closed
        assert cb.get_state("service2") == CircuitState.CLOSED

        metrics = cb.get_metrics()
        assert "service1" in metrics
        assert "service2" not in metrics


class TestCircuitBreakerDecorator:
    """Test the @circuit_breaker decorator."""

    def test_decorator_success(self):
        """Test decorator with successful function."""
        cb = CircuitBreaker()

        @circuit_breaker("test_service", breaker=cb)
        def success_func():
            return "success"

        result = success_func()
        assert result == "success"

    def test_decorator_failure(self):
        """Test decorator with failing function."""
        cb = CircuitBreaker(failure_threshold=1)

        @circuit_breaker("test_service", breaker=cb)
        def failing_func():
            raise ValueError("fail")

        # First call fails
        with pytest.raises(ValueError):
            failing_func()

        # Second call raises CircuitBreakerError
        with pytest.raises(CircuitBreakerError):
            failing_func()

    def test_decorator_with_args(self):
        """Test decorator with function arguments."""
        cb = CircuitBreaker()

        @circuit_breaker("test_service", breaker=cb)
        def func_with_args(a, b, c=None):
            return f"{a}-{b}-{c}"

        result = func_with_args(1, 2, c=3)
        assert result == "1-2-3"

    def test_decorator_preserves_function_metadata(self):
        """Test decorator preserves function name and docstring."""
        cb = CircuitBreaker()

        @circuit_breaker("test_service", breaker=cb)
        def my_function():
            """My docstring."""
            return "result"

        assert my_function.__name__ == "my_function"
        assert my_function.__doc__ == "My docstring."

    def test_decorator_default_breaker(self):
        """Test decorator with default global breaker."""

        @circuit_breaker("test_service")
        def success_func():
            return "success"

        # Should use global breaker
        result = success_func()
        assert result == "success"


class TestCircuitBreakerThreadSafety:
    """Test thread safety of circuit breaker."""

    def test_concurrent_calls(self):
        """Test concurrent calls to the same circuit."""
        cb = CircuitBreaker(failure_threshold=100)
        counter = 0
        errors = []

        def increment():
            nonlocal counter
            try:
                result = cb.call("test_service", lambda: "success")
                if result == "success":
                    counter += 1
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=increment) for _ in range(50)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(errors) == 0
        assert counter == 50

    def test_concurrent_failures(self):
        """Test concurrent failures don't cause race conditions."""
        cb = CircuitBreaker(failure_threshold=10)
        errors = []

        def failing_call():
            raise ValueError("fail")

        def make_call():
            try:
                cb.call("test_service", failing_call)
            except ValueError:
                pass  # Expected
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=make_call) for _ in range(20)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # Only CircuitBreakerError should be in errors (not ValueError)
        assert all(isinstance(e, CircuitBreakerError) for e in errors)

    def test_concurrent_mixed_calls(self):
        """Test mix of success and failure under concurrency."""
        cb = CircuitBreaker(failure_threshold=50)
        success_count = 0
        error_count = 0
        lock = threading.Lock()

        counter = 0

        def mixed_call():
            nonlocal counter
            counter += 1
            if counter % 2 == 0:
                raise ValueError("fail")
            return "success"

        def make_call():
            nonlocal success_count, error_count
            try:
                cb.call("test_service", mixed_call)
                with lock:
                    success_count += 1
            except ValueError:
                with lock:
                    error_count += 1
            except CircuitBreakerError:
                pass

        threads = [threading.Thread(target=make_call) for _ in range(20)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert success_count + error_count == 20


class TestCircuitBreakerPerDestination:
    """Test per-destination circuit isolation."""

    def test_destinations_are_isolated(self):
        """Test that different destinations have separate circuits."""
        cb = CircuitBreaker(failure_threshold=3)

        def failing_call():
            raise ValueError("fail")

        # Fail service1 3 times - should open
        for _ in range(3):
            with pytest.raises(ValueError):
                cb.call("service1", failing_call)

        assert cb.get_state("service1") == CircuitState.OPEN

        # service2 should still be CLOSED
        assert cb.get_state("service2") == CircuitState.CLOSED

        # service2 should still work
        result = cb.call("service2", lambda: "success")
        assert result == "success"

    def test_reset_only_affects_one_destination(self):
        """Test reset only affects the specified destination."""
        cb = CircuitBreaker(failure_threshold=1)

        def failing_call():
            raise ValueError("fail")

        # Open both circuits
        with pytest.raises(ValueError):
            cb.call("service1", failing_call)
        with pytest.raises(ValueError):
            cb.call("service2", failing_call)

        assert cb.get_state("service1") == CircuitState.OPEN
        assert cb.get_state("service2") == CircuitState.OPEN

        # Reset only service1
        cb.reset("service1")

        assert cb.get_state("service1") == CircuitState.CLOSED
        assert cb.get_state("service2") == CircuitState.OPEN


class TestCircuitBreakerEdgeCases:
    """Test edge cases."""

    def test_zero_failure_threshold(self):
        """Test with failure_threshold of 0 (always open after first failure)."""
        cb = CircuitBreaker(failure_threshold=0)

        # Actually, with threshold 0, we need to be careful
        # Let's test threshold of 1
        cb = CircuitBreaker(failure_threshold=1)

        def failing_call():
            raise ValueError("fail")

        with pytest.raises(ValueError):
            cb.call("test_service", failing_call)

        assert cb.get_state("test_service") == CircuitState.OPEN

    def test_very_long_recovery_timeout(self):
        """Test with very long recovery timeout."""
        cb = CircuitBreaker(failure_threshold=1, recovery_timeout=3600)

        def failing_call():
            raise ValueError("fail")

        with pytest.raises(ValueError):
            cb.call("test_service", failing_call)

        assert cb.get_state("test_service") == CircuitState.OPEN

        # Should still be open immediately after
        with pytest.raises(CircuitBreakerError):
            cb.call("test_service", lambda: "success")

    def test_exception_propagation(self):
        """Test that original exceptions are propagated."""
        cb = CircuitBreaker()

        class CustomError(Exception):
            pass

        def raise_custom():
            raise CustomError("custom")

        with pytest.raises(CustomError) as exc_info:
            cb.call("test_service", raise_custom)

        assert str(exc_info.value) == "custom"

    def test_callable_returning_none(self):
        """Test callable that returns None."""
        cb = CircuitBreaker()

        def return_none():
            return None

        result = cb.call("test_service", return_none)
        assert result is None

    def test_callable_with_exception_message(self):
        """Test that exception messages are preserved."""
        cb = CircuitBreaker()

        def raise_with_message():
            raise ValueError("specific error message")

        with pytest.raises(ValueError) as exc_info:
            cb.call("test_service", raise_with_message)

        assert "specific error message" in str(exc_info.value)


class TestCircuitBreakerIntegration:
    """Integration tests for circuit breaker."""

    def test_full_lifecycle(self):
        """Test full circuit breaker lifecycle."""
        cb = CircuitBreaker(
            failure_threshold=2,
            recovery_timeout=0.1,
            half_open_max_calls=1,
        )

        call_count = 0

        def flaky_service():
            nonlocal call_count
            call_count += 1
            if call_count <= 2:
                raise ValueError("service down")
            return "service up"

        # Phase 1: CLOSED -> failures accumulate
        with pytest.raises(ValueError):
            cb.call("api", flaky_service)
        assert cb.get_state("api") == CircuitState.CLOSED

        with pytest.raises(ValueError):
            cb.call("api", flaky_service)
        assert cb.get_state("api") == CircuitState.OPEN

        # Phase 2: OPEN -> fast fail
        with pytest.raises(CircuitBreakerError):
            cb.call("api", flaky_service)

        # Phase 3: Wait for timeout -> HALF_OPEN
        time.sleep(0.15)

        # Phase 4: HALF_OPEN -> success -> CLOSED
        result = cb.call("api", flaky_service)
        assert result == "service up"
        assert cb.get_state("api") == CircuitState.CLOSED

        # Phase 5: CLOSED -> normal operation
        result = cb.call("api", flaky_service)
        assert result == "service up"

    def test_multiple_destinations_lifecycle(self):
        """Test lifecycle with multiple destinations."""
        cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0.1)

        services = {
            "api1": {"fails": 2, "calls": 0},
            "api2": {"fails": 0, "calls": 0},
        }

        def make_call(service_name):
            def call():
                services[service_name]["calls"] += 1
                if services[service_name]["calls"] <= services[service_name]["fails"]:
                    raise ValueError("fail")
                return f"{service_name} success"

            return call

        # Open api1
        for _ in range(2):
            with pytest.raises(ValueError):
                cb.call("api1", make_call("api1"))

        assert cb.get_state("api1") == CircuitState.OPEN
        assert cb.get_state("api2") == CircuitState.CLOSED

        # api2 should work fine
        result = cb.call("api2", make_call("api2"))
        assert result == "api2 success"


class TestCircuitBreakerGetMetricsSnapshot:
    """Test get_metrics_snapshot method."""

    def test_get_metrics_snapshot_structure(self):
        """Test metrics snapshot structure."""
        cb = CircuitBreaker(failure_threshold=1)

        def failing_call():
            raise ValueError("fail")

        with pytest.raises(ValueError):
            cb.call("test_service", failing_call)

        snapshot = cb.get_metrics_snapshot()
        assert "circuits" in snapshot
        assert "test_service" in snapshot["circuits"]
        assert "state" in snapshot["circuits"]["test_service"]
        assert snapshot["circuits"]["test_service"]["state"] == "open"

    def test_get_metrics_snapshot_all_states(self):
        """Test metrics snapshot with all circuit states."""
        cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1)

        # Create circuits in different states
        def fail():
            raise ValueError("fail")

        def success():
            return "ok"

        # OPEN
        with pytest.raises(ValueError):
            cb.call("open_service", fail)

        # CLOSED (default)
        cb._get_or_create_circuit("closed_service")

        # HALF_OPEN (will transition quickly)
        with pytest.raises(ValueError):
            cb.call("half_service", fail)

        snapshot = cb.get_metrics_snapshot()
        assert snapshot["circuits"]["open_service"]["state"] == "open"
        assert snapshot["circuits"]["closed_service"]["state"] == "closed"
