"""Circuit breaker pattern for preventing cascading failures.

Extracted from ``retry_patterns.py`` to satisfy the 500-line-per-file
guideline.  All public names are re-exported from ``retry_patterns``
so existing call-sites are unaffected.
"""

from __future__ import annotations

import contextlib
import threading
import time
from collections.abc import Awaitable, Callable
from enum import StrEnum
from typing import Any, TypeVar

import structlog

from cleveragents.core.exceptions import CleverAgentsError


class CircuitBreakerState(StrEnum):
    """State of a circuit breaker.

    Using a StrEnum instead of bare string literals prevents silent
    typo-induced state corruption (U6).
    """

    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half-open"


T = TypeVar("T")

logger = structlog.get_logger(__name__)


class CircuitBreakerOpen(CleverAgentsError):
    """Exception raised when circuit breaker is open."""


class CircuitBreaker:
    """Circuit breaker pattern for preventing cascading failures.

    Implements the circuit breaker pattern to prevent repeated calls
    to failing services. After a threshold of failures, the circuit
    "opens" and immediately fails subsequent calls without attempting them.

    Thread-safety is provided by a single ``threading.Lock`` that guards
    all state transitions for both sync and async paths.  The lock is
    *not* held during the actual function call, so long-running
    operations do not block other threads from checking circuit state.
    """

    def __init__(
        self,
        failure_threshold: int = 5,
        recovery_timeout: float = 60.0,
        expected_exception: type[BaseException] = Exception,
        half_open_max_successes: int = 2,
        cooldown_seconds: float = 30.0,
        name: str = "",
    ):
        """Initialise a new circuit breaker instance.

        Args:
            failure_threshold: Number of consecutive failures before the
                circuit opens.  Must be a positive integer.
            recovery_timeout: Seconds to wait in the open state before
                attempting a half-open probe.
            expected_exception: The exception type (or tuple) that counts
                as a monitored failure.  All other exceptions are
                propagated without affecting circuit state.
            half_open_max_successes: Number of consecutive successes
                required in half-open state to close the circuit.  Must
                be >= 1.
            cooldown_seconds: Minimum seconds between successive
                half-open probe windows to prevent rapid flickering.
            name: Human-readable service name included in all structured
                log entries for easier correlation.

        Raises:
            ValueError: If *half_open_max_successes* is less than 1.
        """
        if half_open_max_successes < 1:
            raise ValueError("half_open_max_successes must be >= 1")
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.expected_exception = expected_exception
        self.half_open_max_successes = half_open_max_successes
        self.cooldown_seconds = cooldown_seconds
        # S13: Store service name for structured log correlation.
        self.name = name
        self.failure_count = 0
        self.last_failure_time: float | None = None
        self.state: CircuitBreakerState = CircuitBreakerState.CLOSED
        self.success_count_half_open = 0
        self._half_open_permits = 0
        self._last_half_open_time: float | None = None
        # S1-S3: Generation counter incremented on each OPEN→HALF_OPEN
        # transition so that stale half-open callers are discarded.
        self._half_open_generation: int = 0
        # P1-1: Single lock guards all mutable state for both sync and
        # async paths, eliminating the dual-lock race condition.
        self._lock = threading.Lock()

    def call(self, func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
        """Execute *func* with circuit breaker protection.

        Args:
            func: The callable to invoke.
            *args: Positional arguments forwarded to *func*.
            **kwargs: Keyword arguments forwarded to *func*.

        Returns:
            The return value of *func*.

        Raises:
            CircuitBreakerOpen: If the circuit is open and the recovery
                timeout has not yet elapsed, or if the half-open probe
                limit has been reached.
            Exception: Any *expected_exception* raised by *func* is
                re-raised after recording the failure.
        """
        gen = -1  # S1-S3: generation for half-open tracking
        with self._lock:
            if self.state == CircuitBreakerState.OPEN:
                if self._should_attempt_reset():
                    self.state = CircuitBreakerState.HALF_OPEN
                    self.failure_count = 0
                    self.success_count_half_open = 0
                    self._half_open_permits = self.half_open_max_successes
                    self._last_half_open_time = time.monotonic()
                    self._half_open_generation += 1
                    gen = self._half_open_generation
                    # S12: Log half-open transition for observability.
                    with contextlib.suppress(TypeError):
                        logger.info(
                            "Circuit breaker entering half-open",
                            service=self.name,
                            permits=self._half_open_permits,
                            generation=gen,
                        )
                else:
                    raise CircuitBreakerOpen("Circuit breaker is open")
            elif self.state == CircuitBreakerState.HALF_OPEN:
                if self._half_open_permits <= 0:
                    raise CircuitBreakerOpen(
                        "Circuit breaker half-open probe limit reached"
                    )
                self._half_open_permits -= 1
                gen = self._half_open_generation

        try:
            result = func(*args, **kwargs)
        # S2: Prevent CircuitBreakerOpen from inner services cascading
        # to open this circuit breaker.
        except CircuitBreakerOpen:
            with self._lock:
                if (
                    self.state == CircuitBreakerState.HALF_OPEN
                    and gen == self._half_open_generation
                ):
                    self._half_open_permits = min(
                        self._half_open_permits + 1,
                        self.half_open_max_successes,
                    )
            raise
        except self.expected_exception:
            with self._lock:
                opened = self._on_failure(gen)
                fc = self.failure_count
            if opened:
                # S3: Wrap logger to prevent masking the original exception.
                with contextlib.suppress(TypeError):
                    logger.warning(
                        "Circuit breaker transitioned to open",
                        service=self.name,
                        failure_count=fc,
                        threshold=self.failure_threshold,
                    )
            raise
        # P1-6: Restore half-open permits on unexpected BaseException
        # subclasses (KeyboardInterrupt, SystemExit) to prevent
        # permanent permit leaks that would leave the circuit stuck open.
        except BaseException:
            with self._lock:
                if (
                    self.state == CircuitBreakerState.HALF_OPEN
                    and gen == self._half_open_generation
                ):
                    self._half_open_permits = min(
                        self._half_open_permits + 1,
                        self.half_open_max_successes,
                    )
            raise

        with self._lock:
            closed = self._on_success(gen)
        if closed:
            # S12: Log recovery to closed for observability.
            with contextlib.suppress(TypeError):
                logger.info(
                    "Circuit breaker recovered to closed",
                    service=self.name,
                )
        return result

    def is_open(self) -> bool:
        """Return ``True`` if the circuit breaker is in the open state."""
        with self._lock:
            return self.state == CircuitBreakerState.OPEN

    def reset(self) -> None:
        """Force the circuit breaker back to the closed state."""
        with self._lock:
            self.state = CircuitBreakerState.CLOSED
            self.failure_count = 0
            self.success_count_half_open = 0
            self._half_open_permits = 0
            self._last_half_open_time = None
            self.last_failure_time = None
        # S34: Log reset for operational visibility.
        with contextlib.suppress(TypeError):
            logger.info("Circuit breaker reset to closed", service=self.name)

    async def async_call(
        self, func: Callable[..., Awaitable[T]], *args: Any, **kwargs: Any
    ) -> T:
        """Execute an async *func* with circuit breaker protection.

        Uses the same ``threading.Lock`` as :meth:`call` so that sync
        and async callers on the same instance do not race.  The lock
        is only held for tiny state-transition critical sections (no I/O),
        so event-loop blocking is negligible.

        Args:
            func: The async callable to invoke.
            *args: Positional arguments forwarded to *func*.
            **kwargs: Keyword arguments forwarded to *func*.

        Returns:
            The return value of *func*.

        Raises:
            CircuitBreakerOpen: If the circuit is open and the recovery
                timeout has not yet elapsed, or if the half-open probe
                limit has been reached.
            Exception: Any *expected_exception* raised by *func* is
                re-raised after recording the failure.
        """
        gen = -1  # S1-S3: generation for half-open tracking
        with self._lock:
            if self.state == CircuitBreakerState.OPEN:
                if self._should_attempt_reset():
                    self.state = CircuitBreakerState.HALF_OPEN
                    self.failure_count = 0
                    self.success_count_half_open = 0
                    self._half_open_permits = self.half_open_max_successes
                    self._last_half_open_time = time.monotonic()
                    self._half_open_generation += 1
                    gen = self._half_open_generation
                    # S12: Log half-open transition for observability.
                    with contextlib.suppress(TypeError):
                        logger.info(
                            "Circuit breaker entering half-open",
                            service=self.name,
                            permits=self._half_open_permits,
                            generation=gen,
                        )
                else:
                    raise CircuitBreakerOpen("Circuit breaker is open")
            elif self.state == CircuitBreakerState.HALF_OPEN:
                if self._half_open_permits <= 0:
                    raise CircuitBreakerOpen(
                        "Circuit breaker half-open probe limit reached"
                    )
                self._half_open_permits -= 1
                gen = self._half_open_generation

        try:
            result: T = await func(*args, **kwargs)
        # S2: Prevent CircuitBreakerOpen from inner services cascading.
        except CircuitBreakerOpen:
            with self._lock:
                if (
                    self.state == CircuitBreakerState.HALF_OPEN
                    and gen == self._half_open_generation
                ):
                    self._half_open_permits = min(
                        self._half_open_permits + 1,
                        self.half_open_max_successes,
                    )
            raise
        except self.expected_exception:
            with self._lock:
                opened = self._on_failure(gen)
                fc = self.failure_count
            if opened:
                with contextlib.suppress(TypeError):
                    logger.warning(
                        "Circuit breaker transitioned to open",
                        service=self.name,
                        failure_count=fc,
                        threshold=self.failure_threshold,
                    )
            raise
        # P1-6: Restore permits on unexpected BaseException (includes
        # CancelledError, KeyboardInterrupt, SystemExit).
        except BaseException:
            with self._lock:
                if (
                    self.state == CircuitBreakerState.HALF_OPEN
                    and gen == self._half_open_generation
                ):
                    self._half_open_permits = min(
                        self._half_open_permits + 1,
                        self.half_open_max_successes,
                    )
            raise

        with self._lock:
            closed = self._on_success(gen)
        if closed:
            with contextlib.suppress(TypeError):
                logger.info(
                    "Circuit breaker recovered to closed",
                    service=self.name,
                )
        return result

    def _should_attempt_reset(self) -> bool:
        """Check if enough time has passed to attempt reset."""
        if self.last_failure_time is None:
            return False
        # S5: Use monotonic clock to avoid NTP clock-skew issues
        # that could leave the circuit stuck in open state.
        now = time.monotonic()
        if now - self.last_failure_time < self.recovery_timeout:
            return False
        return not (
            self._last_half_open_time is not None
            and now - self._last_half_open_time < self.cooldown_seconds
        )

    def _on_success(self, generation: int = -1) -> bool:
        """Handle a successful call.

        Returns ``True`` when the circuit transitioned to closed.

        Args:
            generation: The half-open generation at permit-consumption
                time.  Calls from a stale generation are silently
                discarded (S2-S3).
        """
        if self.state == CircuitBreakerState.HALF_OPEN:
            # S2-S3: Discard stale half-open callers from a prior generation.
            if generation != self._half_open_generation:
                return False
            self.success_count_half_open += 1
            self.failure_count = 0
            if self.success_count_half_open >= self.half_open_max_successes:
                self.state = CircuitBreakerState.CLOSED
                self.success_count_half_open = 0
                return True
        elif self.state == CircuitBreakerState.CLOSED:
            self.failure_count = 0
        return False

    def _on_failure(self, generation: int = -1) -> bool:
        """Handle failure and potentially open circuit.

        Returns ``True`` when the circuit just transitioned to open.

        Args:
            generation: The half-open generation at permit-consumption
                time.  Calls from a stale generation are silently
                discarded (S2-S3).
        """
        # S1: If already OPEN, discard stale-cycle stragglers so they
        # cannot push last_failure_time forward.
        if self.state == CircuitBreakerState.OPEN:
            return False
        if self.state == CircuitBreakerState.HALF_OPEN:
            # S2-S3: Discard stale half-open callers from a prior generation.
            if generation != self._half_open_generation:
                return False
            self.failure_count += 1
            # S5: Use monotonic clock for consistent timing.
            self.last_failure_time = time.monotonic()
            self.state = CircuitBreakerState.OPEN
            self.success_count_half_open = 0
            return True
        # CLOSED state
        self.failure_count += 1
        # S5: Use monotonic clock for consistent timing.
        self.last_failure_time = time.monotonic()
        if self.failure_count >= self.failure_threshold:
            self.state = CircuitBreakerState.OPEN
            return True
        return False


__all__ = [
    "CircuitBreaker",
    "CircuitBreakerOpen",
    "CircuitBreakerState",
]
