"""Unit tests for circuit breaker implementation.

Tests cover all circuit breaker states, transitions, and integration with RetryPolicy.
Story 3-3: Implement Circuit Breaker Pattern
"""

import pytest
import time
from unittest.mock import Mock, MagicMock, patch
from pybreaker import CircuitBreakerError

from mca_sdk.resilience.circuit_breaker import CircuitBreakerRetryPolicy
from mca_sdk.buffering.retry import RetryPolicy


class TestCircuitBreakerInitialization:
    """Test circuit breaker initialization and configuration."""

    def test_initialization_with_defaults(self):
        """Test circuit breaker initializes with default parameters."""
        retry_policy = RetryPolicy(max_attempts=3)
        cb = CircuitBreakerRetryPolicy(retry_policy)

        assert cb.get_state() == "closed"
        stats = cb.get_stats()
        assert stats["fail_max"] == 5
        assert stats["reset_timeout"] == 60
        assert stats["failure_count"] == 0

    def test_initialization_with_custom_params(self):
        """Test circuit breaker with custom parameters."""
        retry_policy = RetryPolicy(max_attempts=3)
        cb = CircuitBreakerRetryPolicy(
            retry_policy, fail_max=3, reset_timeout=30, name="test_circuit"
        )

        stats = cb.get_stats()
        assert stats["fail_max"] == 3
        assert stats["reset_timeout"] == 30
        assert stats["name"] == "test_circuit"

    def test_invalid_fail_max_raises_error(self):
        """Test that invalid fail_max raises ValueError."""
        retry_policy = RetryPolicy(max_attempts=3)

        with pytest.raises(ValueError, match="fail_max must be positive"):
            CircuitBreakerRetryPolicy(retry_policy, fail_max=0)

        with pytest.raises(ValueError, match="fail_max must be positive"):
            CircuitBreakerRetryPolicy(retry_policy, fail_max=-1)

    def test_invalid_timeout_raises_error(self):
        """Test that invalid reset_timeout raises ValueError."""
        retry_policy = RetryPolicy(max_attempts=3)

        with pytest.raises(ValueError, match="reset_timeout must be positive"):
            CircuitBreakerRetryPolicy(retry_policy, reset_timeout=0)

        with pytest.raises(ValueError, match="reset_timeout must be positive"):
            CircuitBreakerRetryPolicy(retry_policy, reset_timeout=-1)

    def test_initialization_logging(self, caplog):
        """Test that circuit breaker initialization is logged with configuration details."""
        import logging

        caplog.set_level(logging.INFO)

        retry_policy = RetryPolicy(max_attempts=3)
        cb = CircuitBreakerRetryPolicy(
            retry_policy, fail_max=7, reset_timeout=120, name="test_logging_circuit"
        )

        # Verify initialization log message
        assert any(
            "Circuit breaker initialized" in record.message for record in caplog.records
        ), "Initialization message not logged"

        # Verify log contains circuit name
        log_messages = " ".join([record.message for record in caplog.records])
        assert "test_logging_circuit" in log_messages, "Circuit name not in log"

        # Verify extra fields contain configuration (check LogRecord attributes)
        config_logged = False
        for record in caplog.records:
            if hasattr(record, "circuit_name") and record.circuit_name == "test_logging_circuit":
                config_logged = True
                assert hasattr(record, "fail_max") and record.fail_max == 7
                assert hasattr(record, "reset_timeout") and record.reset_timeout == 120
                break

        assert config_logged, "Configuration details not logged in extra fields"

    def test_debug_mode_logging_detail(self, caplog):
        """Test that debug-level logging provides detailed operational info."""
        import logging

        caplog.set_level(logging.DEBUG)

        retry_policy = RetryPolicy(max_attempts=2, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=2, name="debug_test")

        # Initialize (should log at INFO level)
        assert any("initialized" in record.message.lower() for record in caplog.records)

        caplog.clear()

        # Execute successful operation
        func = Mock(return_value="success")
        result = cb.execute(func)

        # At DEBUG level, we should see detailed operational logs if implemented
        # Note: Current implementation uses INFO for state transitions, not DEBUG
        # This test documents expected behavior for future debug logging enhancement


class TestCircuitBreakerStateTransitions:
    """Test circuit breaker state transitions."""

    def test_closed_to_open_after_failures(self):
        """Test circuit opens after consecutive failures (AC#1)."""
        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=3, reset_timeout=60)

        failing_func = Mock(side_effect=Exception("failure"))

        # Execute 3 times to trigger circuit open
        for i in range(3):
            try:
                cb.execute(failing_func)
            except Exception:
                pass

        # Circuit should now be open
        assert cb.get_state() == "open"
        stats = cb.get_stats()
        assert stats["failure_count"] == 3

    def test_fast_fail_when_circuit_open(self):
        """Test fast-fail behavior when circuit is open (AC#2)."""
        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=2, reset_timeout=60)

        failing_func = Mock(side_effect=Exception("failure"))

        # Open the circuit
        for i in range(2):
            try:
                cb.execute(failing_func)
            except Exception:
                pass

        assert cb.get_state() == "open"

        # Next call should fast-fail with CircuitBreakerError
        start_time = time.time()
        with pytest.raises(CircuitBreakerError):
            cb.execute(failing_func)
        elapsed = time.time() - start_time

        # Fast-fail should be < 1ms (0.001 seconds)
        assert elapsed < 0.01, f"Fast-fail took {elapsed * 1000}ms, should be <10ms"

        # Failing function should NOT be called when circuit is open
        assert failing_func.call_count == 2  # Only called during circuit opening

    def test_open_to_half_open_after_timeout(self):
        """Test circuit enters half-open state after timeout (AC#3)."""
        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        # Use very short timeout for testing
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=2, reset_timeout=0.1)

        failing_func = Mock(side_effect=Exception("failure"))

        # Open the circuit
        for i in range(2):
            try:
                cb.execute(failing_func)
            except Exception:
                pass

        assert cb.get_state() == "open"

        # Wait for timeout
        time.sleep(0.15)

        # Next call should transition to half-open
        # The call will still fail, but state should have been half-open
        try:
            cb.execute(failing_func)
        except Exception:
            pass

        # After failed test in half-open, should be open again
        assert cb.get_state() == "open"

    def test_half_open_to_closed_on_success(self):
        """Test circuit closes after successful test request (AC#4)."""
        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=2, reset_timeout=0.1)

        # Create a function that fails then succeeds
        call_count = [0]

        def conditional_func():
            call_count[0] += 1
            if call_count[0] <= 2:
                raise Exception("failure")
            return "success"

        # Open the circuit
        for i in range(2):
            try:
                cb.execute(conditional_func)
            except Exception:
                pass

        assert cb.get_state() == "open"

        # Wait for timeout
        time.sleep(0.15)

        # Next call should succeed and close the circuit
        result = cb.execute(conditional_func)
        assert result == "success"
        assert cb.get_state() == "closed"


class TestCircuitBreakerWithRetryPolicy:
    """Test circuit breaker integration with RetryPolicy."""

    def test_retry_policy_executes_when_closed(self):
        """Test retry policy executes normally when circuit is closed."""
        retry_policy = RetryPolicy(max_attempts=3, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=5)

        # Function that succeeds on 2nd attempt
        call_count = [0]

        def flaky_func():
            call_count[0] += 1
            if call_count[0] < 2:
                raise Exception("transient failure")
            return "success"

        result = cb.execute(flaky_func)

        assert result == "success"
        assert call_count[0] == 2  # Retry policy retried once
        assert cb.get_state() == "closed"

    def test_circuit_breaker_prevents_retry_when_open(self):
        """Test circuit breaker prevents retry attempts when open."""
        retry_policy = RetryPolicy(max_attempts=3, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=2, reset_timeout=60)

        failing_func = Mock(side_effect=Exception("failure"))

        # Open the circuit (2 failures)
        for i in range(2):
            try:
                cb.execute(failing_func)
            except Exception:
                pass

        # Circuit is now open
        assert cb.get_state() == "open"
        initial_call_count = failing_func.call_count

        # Next attempt should fast-fail without calling the function
        with pytest.raises(CircuitBreakerError):
            cb.execute(failing_func)

        # Function should not have been called again
        assert failing_func.call_count == initial_call_count


class TestCircuitBreakerStats:
    """Test circuit breaker statistics and monitoring."""

    def test_get_state_returns_current_state(self):
        """Test get_state() returns correct state."""
        retry_policy = RetryPolicy(max_attempts=1)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=2)

        assert cb.get_state() == "closed"

        # Open circuit
        failing_func = Mock(side_effect=Exception("failure"))
        for i in range(2):
            try:
                cb.execute(failing_func)
            except Exception:
                pass

        assert cb.get_state() == "open"

    def test_get_stats_returns_complete_info(self):
        """Test get_stats() returns all required metrics."""
        retry_policy = RetryPolicy(max_attempts=1)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=5, reset_timeout=60, name="test_cb")

        stats = cb.get_stats()

        assert "state" in stats
        assert "failure_count" in stats
        assert "fail_max" in stats
        assert "reset_timeout" in stats
        assert "last_failure_time" in stats
        assert "name" in stats

        assert stats["state"] == "closed"
        assert stats["failure_count"] == 0
        assert stats["fail_max"] == 5
        assert stats["reset_timeout"] == 60
        assert stats["name"] == "test_cb"

    def test_failure_count_increments(self):
        """Test failure count increments correctly."""
        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=5)

        failing_func = Mock(side_effect=Exception("failure"))

        for i in range(1, 4):
            try:
                cb.execute(failing_func)
            except Exception:
                pass

            stats = cb.get_stats()
            assert stats["failure_count"] == i

    def test_last_failure_time_updates(self):
        """Test last_failure_time updates on state transitions."""
        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=2)

        stats_before = cb.get_stats()
        assert stats_before["last_failure_time"] is None

        failing_func = Mock(side_effect=Exception("failure"))

        # Trigger failures
        for i in range(2):
            try:
                cb.execute(failing_func)
            except Exception:
                pass
            time.sleep(0.01)

        stats_after = cb.get_stats()
        assert stats_after["last_failure_time"] is not None


class TestCircuitBreakerReset:
    """Test manual circuit breaker reset functionality."""

    def test_manual_reset_closes_circuit(self):
        """Test manual reset() closes the circuit."""
        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=2)

        failing_func = Mock(side_effect=Exception("failure"))

        # Open the circuit
        for i in range(2):
            try:
                cb.execute(failing_func)
            except Exception:
                pass

        assert cb.get_state() == "open"

        # Manual reset
        cb.reset()

        assert cb.get_state() == "closed"
        stats = cb.get_stats()
        assert stats["last_failure_time"] is None

    def test_reset_allows_normal_operation(self):
        """Test reset allows normal operation to resume."""
        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=2)

        failing_func = Mock(side_effect=Exception("failure"))
        success_func = Mock(return_value="success")

        # Open the circuit
        for i in range(2):
            try:
                cb.execute(failing_func)
            except Exception:
                pass

        # Reset and verify operation resumes
        cb.reset()

        result = cb.execute(success_func)
        assert result == "success"
        assert cb.get_state() == "closed"


class TestCircuitBreakerPerformance:
    """Test circuit breaker performance requirements."""

    def test_fast_fail_performance(self):
        """Test fast-fail meets <1ms requirement when circuit is open."""
        retry_policy = RetryPolicy(max_attempts=3, base_delay=1.0)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=2, reset_timeout=60)

        failing_func = Mock(side_effect=Exception("failure"))

        # Open the circuit
        for i in range(2):
            try:
                cb.execute(failing_func)
            except Exception:
                pass

        # Measure fast-fail performance
        fast_fail_times = []
        for i in range(10):
            start = time.time()
            try:
                cb.execute(failing_func)
            except CircuitBreakerError:
                pass
            elapsed = time.time() - start
            fast_fail_times.append(elapsed * 1000)  # Convert to ms

        avg_fast_fail = sum(fast_fail_times) / len(fast_fail_times)
        max_fast_fail = max(fast_fail_times)

        # Performance requirement: <1ms
        assert avg_fast_fail < 1.0, f"Average fast-fail: {avg_fast_fail:.3f}ms (should be <1ms)"
        assert (
            max_fast_fail < 10.0
        ), f"Max fast-fail: {max_fast_fail:.3f}ms (should be <10ms for test tolerance)"

    def test_overhead_in_closed_state(self):
        """Test circuit breaker overhead is acceptable (informational).

        Micro-benchmarks are sensitive to system load, so we log overhead
        rather than asserting a threshold. Real-world network I/O overhead
        is negligible compared to the I/O time itself.
        """
        retry_policy = RetryPolicy(max_attempts=1)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=5)

        success_func = Mock(return_value="success")

        # Measure with circuit breaker
        start = time.time()
        for i in range(100):
            cb.execute(success_func)
        with_cb_time = time.time() - start

        # Measure without circuit breaker
        start = time.time()
        for i in range(100):
            retry_policy.execute(success_func)
        without_cb_time = time.time() - start

        overhead_percent = ((with_cb_time - without_cb_time) / without_cb_time) * 100

        # Log for informational purposes (no assertion due to timing sensitivity)
        import logging

        logging.info(f"Circuit breaker overhead: {overhead_percent:.1f}%")

        # Verify basic functionality
        assert with_cb_time > 0
        assert without_cb_time > 0


class TestCircuitBreakerThreadSafety:
    """Test circuit breaker thread safety and atomic state transitions."""

    def test_concurrent_access(self):
        """Test circuit breaker handles concurrent access safely."""
        import threading

        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=10)

        results = []
        errors = []

        def worker(should_fail):
            try:
                if should_fail:
                    func = Mock(side_effect=Exception("failure"))
                else:
                    func = Mock(return_value="success")
                result = cb.execute(func)
                results.append(result)
            except Exception as e:
                errors.append(e)

        # Create threads that will execute concurrently
        threads = []
        for i in range(20):
            t = threading.Thread(target=worker, args=(i % 3 == 0,))  # Some fail, some succeed
            threads.append(t)
            t.start()

        for t in threads:
            t.join()

        # Verify state is consistent
        state = cb.get_state()
        assert state in ["closed", "open", "half_open"]

        # Verify we got some results (circuit didn't deadlock)
        assert len(results) + len(errors) == 20

    def test_atomic_failure_counting_under_load(self):
        """Test that failure counting is atomic under concurrent load."""
        import threading
        import time

        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        fail_threshold = 5
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=fail_threshold, reset_timeout=1)

        failure_count = []
        state_observations = []
        func = Mock(side_effect=Exception("concurrent failure"))

        def worker():
            """Worker that attempts execution and records failure count."""
            try:
                cb.execute(func)
            except (Exception, CircuitBreakerError):
                # Record failure count and state atomically
                stats = cb.get_stats()
                failure_count.append(stats["failure_count"])
                state_observations.append(cb.get_state())

        # Launch concurrent threads that will all fail
        threads = []
        num_threads = 10
        for _ in range(num_threads):
            t = threading.Thread(target=worker)
            threads.append(t)
            t.start()

        for t in threads:
            t.join()

        # Verify atomicity: failure count should never exceed fail_max
        for count in failure_count:
            assert (
                count <= fail_threshold
            ), f"Failure count {count} exceeded threshold {fail_threshold} - not atomic!"

        # Verify circuit eventually opened
        assert "open" in state_observations, "Circuit should have opened under concurrent failures"

        # Verify no invalid state transitions occurred
        for state in state_observations:
            assert state in [
                "closed",
                "open",
                "half_open",
            ], f"Invalid state observed: {state}"

    def test_state_transition_atomicity(self):
        """Test that state transitions are atomic - no intermediate or inconsistent states."""
        import threading

        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=3, reset_timeout=0.1)

        observed_transitions = []
        func = Mock(side_effect=Exception("failure"))

        def observer():
            """Thread that continuously observes state for inconsistencies."""
            for _ in range(50):
                state = cb.get_state()
                stats = cb.get_stats()
                # Record state + failure count pairs
                observed_transitions.append((state, stats["failure_count"]))
                time.sleep(0.001)

        def failure_generator():
            """Thread that generates failures to trigger state transitions."""
            for _ in range(5):
                try:
                    cb.execute(func)
                except (Exception, CircuitBreakerError):
                    pass
                time.sleep(0.01)

        # Start observer and failure generator concurrently
        observer_thread = threading.Thread(target=observer)
        generator_thread = threading.Thread(target=failure_generator)

        observer_thread.start()
        generator_thread.start()

        observer_thread.join()
        generator_thread.join()

        # Validate state/count consistency
        for state, count in observed_transitions:
            # Closed state: count should be < fail_max
            if state == "closed":
                assert count < 3, f"Closed state with count {count} >= fail_max is inconsistent"
            # Open state: count should be >= fail_max OR circuit was manually opened
            # (Note: pybreaker resets count when opening, so this may be 0)
            # The important check is that we never see impossible combinations

        # Verify we saw state transitions
        states_seen = set(state for state, _ in observed_transitions)
        assert len(states_seen) > 1, "Should have observed state transitions"


class TestCircuitBreakerSecurity:
    """Test circuit breaker security and PHI/PII sanitization (HIPAA compliance)."""

    def test_exception_messages_not_logged_in_failure_callback(self, caplog):
        """Test that exception messages are NOT logged in failure callback to prevent PHI leakage."""
        import logging

        caplog.set_level(logging.DEBUG)

        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=1)

        # Create exception with sensitive data
        sensitive_data = "Patient SSN: 123-45-6789, DOB: 1980-01-01"
        func = Mock(side_effect=Exception(sensitive_data))

        # Execute and expect failure
        with pytest.raises(Exception):
            cb.execute(func)

        # Verify sensitive data is NOT in any log messages
        all_logs = " ".join([record.message for record in caplog.records])
        assert "123-45-6789" not in all_logs, "Sensitive SSN leaked in logs"
        assert "DOB" not in all_logs, "PHI marker leaked in logs"

    def test_circuit_state_transitions_dont_leak_exception_content(self, caplog):
        """Test that state transition logs don't include exception content."""
        import logging

        caplog.set_level(logging.INFO)

        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=2, name="test_security")

        # Create exception with sensitive data
        sensitive_exception = Exception("Credit Card: 4532-1234-5678-9010")
        func = Mock(side_effect=sensitive_exception)

        # Trigger state transitions by failing multiple times
        for _ in range(2):
            try:
                cb.execute(func)
            except Exception:
                pass

        # Verify circuit opened (state transition occurred)
        assert cb.get_state() == "open"

        # Verify NO sensitive data in logs
        all_logs = " ".join([record.message for record in caplog.records])
        assert "4532-1234-5678-9010" not in all_logs, "Credit card number leaked in logs"
        assert "Credit Card" not in all_logs, "Sensitive label leaked in logs"

        # Verify expected metadata IS logged (circuit_name, states, etc.)
        assert "test_security" in all_logs or any(
            "test_security" in str(record.extra)
            for record in caplog.records
            if hasattr(record, "extra")
        ), "Circuit name should be logged for observability"

    def test_circuit_breaker_open_log_excludes_exception_details(self, caplog):
        """Test that fast-fail log when circuit is open doesn't include exception details."""
        import logging

        caplog.set_level(logging.INFO)

        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=1)

        # Open the circuit
        func = Mock(side_effect=Exception("API Key: sk_live_abc123"))
        try:
            cb.execute(func)
        except Exception:
            pass

        assert cb.get_state() == "open"

        # Clear previous logs
        caplog.clear()

        # Try to execute again (fast-fail)
        with pytest.raises(CircuitBreakerError):
            cb.execute(func)

        # Verify sensitive data NOT in fast-fail logs
        all_logs = " ".join([record.message for record in caplog.records])
        assert "sk_live_abc123" not in all_logs, "API key leaked in fast-fail log"
        assert "API Key" not in all_logs, "Sensitive label leaked in log"

    def test_get_stats_excludes_exception_content(self):
        """Test that get_stats() doesn't expose exception content."""
        retry_policy = RetryPolicy(max_attempts=1, base_delay=0.001)
        cb = CircuitBreakerRetryPolicy(retry_policy, fail_max=2)

        # Fail with sensitive exception
        func = Mock(side_effect=Exception("Database password: SuperSecret123!"))
        try:
            cb.execute(func)
        except Exception:
            pass

        # Get stats
        stats = cb.get_stats()

        # Verify stats don't contain exception content
        stats_str = str(stats)
        assert "SuperSecret123" not in stats_str, "Password leaked in stats"
        assert "Database password" not in stats_str, "Sensitive label leaked in stats"

        # Verify stats contain expected metadata
        assert "state" in stats
        assert "failure_count" in stats
        assert "fail_max" in stats
