"""Service-level retry wiring patterns (M6 — #313).

Extracted from ``retry_patterns.py`` to satisfy the 500-line-per-file
guideline.  This module contains the ``retry_service_operation``
decorator, the nesting guard, and the ``is_read_only_plan_operation``
utility.

All public names are re-exported from ``retry_patterns`` so that
existing call-sites are not affected.

.. note:: S40 — ``contextlib.suppress(TypeError)`` rationale

   ``contextlib.suppress(TypeError)`` guards around structlog logger
   calls protect against structlog configurations that use PrintLogger
   or empty processor chains, which raise TypeError when keyword
   arguments are passed.  This is a defensive pattern — not a bug
   suppressor.  See structlog docs on processor chains.
"""

from __future__ import annotations

import asyncio
import contextlib
import contextvars
from collections.abc import Awaitable, Callable
from functools import partial, wraps
from typing import Any, TypeVar, cast

import structlog
from tenacity import (
    AsyncRetrying,
    Retrying,
    stop_after_attempt,
    stop_after_delay,
    wait_exponential,
    wait_exponential_jitter,
    wait_fixed,
)
from tenacity.retry import retry_if_exception_type, retry_if_not_exception_type

from cleveragents.core.retry_patterns import (
    _UNSET,
    DEFAULT_MAX_ATTEMPTS,
    DEFAULT_MAX_WAIT,
    DEFAULT_MIN_WAIT,
    CircuitBreaker,
    CircuitBreakerOpen,
    _sanitize_error_message,
)

T = TypeVar("T")

logger = structlog.get_logger(__name__)

# Retry amplification guard — shared by retry_service_operation decorator
# and ServiceRetryWiring.execute / async_execute.
_retry_depth: contextvars.ContextVar[int] = contextvars.ContextVar(
    "cleveragents_retry_depth", default=0
)
_MAX_RETRY_NESTING_DEPTH = 1

# F25: Module-scope frozenset instead of per-call reconstruction.
_READ_ONLY_PHASES = frozenset(
    {"strategize", "plan", "validate", "review", "preview", "check"}
)


def _is_async_callable(func: Any) -> bool:
    """Check whether *func* is an async callable.

    Handles plain coroutine functions, ``functools.partial`` wrapping
    async functions (including callable objects with async ``__call__``
    wrapped in ``partial``), and callable objects with an async
    ``__call__`` method.
    """
    if asyncio.iscoroutinefunction(func):
        return True
    # functools.partial wrapping an async function or async callable
    if isinstance(func, partial):
        inner = func.func
        if asyncio.iscoroutinefunction(inner):
            return True
        # partial wrapping a callable object with async __call__
        if callable(inner) and not isinstance(inner, type):
            return asyncio.iscoroutinefunction(type(inner).__call__)
        return False
    # Callable objects with async __call__
    if callable(func) and not isinstance(func, type):
        return asyncio.iscoroutinefunction(type(func).__call__)
    return False


def _build_wait_strategy(
    strategy: str,
    base_delay: float,
    max_delay: float,
    jitter: bool,
) -> Any:
    """Build a tenacity wait strategy from configuration values.

    Args:
        strategy: One of ``"exponential"``, ``"linear"``, ``"fixed"``,
            ``"jitter"``, or ``"none"``.  Unknown values are treated as
            ``"none"`` (no wait between attempts).
        base_delay: Minimum delay in seconds.  Clamped to a positive
            floor for strategies that require it (e.g. 0.1 s for
            exponential, 2.0 s for linear).
        max_delay: Maximum delay cap in seconds, passed to the
            underlying tenacity wait object.
        jitter: When *True* and *strategy* is ``"exponential"``, use
            ``wait_exponential_jitter`` instead of plain
            ``wait_exponential``.

    Returns:
        A tenacity wait object suitable for passing to ``Retrying`` or
        ``AsyncRetrying``.
    """
    if strategy == "exponential":
        effective_delay = max(base_delay, 0.1)
        if jitter:
            return wait_exponential_jitter(initial=effective_delay, max=max_delay)
        return wait_exponential(
            multiplier=effective_delay, min=effective_delay, max=max_delay
        )
    if strategy == "linear":
        effective = max(base_delay, 2.0) if base_delay > 0.0 else 2.0
        return wait_fixed(effective)
    if strategy == "fixed":
        return wait_fixed(max(base_delay, 0.1))
    if strategy == "jitter":
        effective_delay = max(base_delay, 0.1)
        return wait_exponential_jitter(
            initial=effective_delay, max=max_delay, jitter=effective_delay
        )
    # "none" or unknown — no wait
    return wait_fixed(0)


def _log_service_retry_attempt(
    service_name: str,
    operation_name: str,
    attempt: int,
    error: BaseException | None,
) -> None:
    """Emit a structured log entry for a service retry attempt."""
    try:
        logger.warning(
            "Service operation retry",
            service=service_name,
            operation=operation_name,
            attempt=attempt,
            error=_sanitize_error_message(error),
        )
    except TypeError:
        msg = (
            f"Retry: service={service_name} operation={operation_name} "
            f"attempt={attempt} error={_sanitize_error_message(error)}"
        )
        with contextlib.suppress(Exception):
            logger.warning(msg)


def _log_circuit_open(service_name: str, failure_count: int) -> None:
    """Emit a structured log entry when a circuit breaker opens."""
    try:
        logger.warning(
            "Circuit breaker opened",
            service=service_name,
            failure_count=failure_count,
        )
    except TypeError:
        msg = f"Circuit breaker opened: service={service_name} failures={failure_count}"
        with contextlib.suppress(Exception):
            logger.warning(msg)


def retry_service_operation(
    service_name: str,
    operation_name: str,
    *,
    max_attempts: int = DEFAULT_MAX_ATTEMPTS,
    base_delay: float = DEFAULT_MIN_WAIT,
    max_delay: float = DEFAULT_MAX_WAIT,
    jitter: bool = True,
    backoff_strategy: str = "exponential",
    idempotent: bool = True,
    circuit_breaker: CircuitBreaker | None = None,
    total_timeout: float = 300.0,
) -> Callable[[Callable[..., T]], Callable[..., T]]:
    """Decorator: retry + circuit breaker for service methods.

    When *idempotent* is ``False`` the decorator is a no-op.
    A nesting guard prevents retry amplification.
    All attempts are bounded by *total_timeout* seconds.
    """

    def decorator(func: Callable[..., T]) -> Callable[..., T]:
        if not idempotent:
            return func

        wait = _build_wait_strategy(backoff_strategy, base_delay, max_delay, jitter)

        if _is_async_callable(func):
            async_func = cast(Callable[..., Awaitable[T]], func)

            @wraps(func)
            async def async_wrapper(*args: Any, **kwargs: Any) -> T:
                depth = _retry_depth.get()
                if depth >= _MAX_RETRY_NESTING_DEPTH:
                    if circuit_breaker is not None:
                        return await circuit_breaker.async_call(
                            async_func, *args, **kwargs
                        )
                    return await async_func(*args, **kwargs)

                token = _retry_depth.set(depth + 1)
                try:
                    last_error: BaseException | None = None
                    async for attempt_obj in AsyncRetrying(
                        stop=(
                            stop_after_attempt(max_attempts)
                            | stop_after_delay(total_timeout)
                        ),
                        wait=wait,
                        reraise=True,
                        # F1/S1: Restrict retries to Exception subclasses
                        # so that KeyboardInterrupt, SystemExit, and
                        # CancelledError propagate immediately.
                        retry=(
                            retry_if_exception_type(Exception)
                            & retry_if_not_exception_type(CircuitBreakerOpen)
                        ),
                    ):
                        with attempt_obj:
                            attempt_num = attempt_obj.retry_state.attempt_number
                            if attempt_num > 1:
                                _log_service_retry_attempt(
                                    service_name,
                                    operation_name,
                                    attempt_num,
                                    last_error,
                                )
                            try:
                                if circuit_breaker is not None:
                                    return await circuit_breaker.async_call(
                                        async_func, *args, **kwargs
                                    )
                                return await async_func(*args, **kwargs)
                            except CircuitBreakerOpen:
                                _log_circuit_open(
                                    service_name,
                                    circuit_breaker.failure_count
                                    if circuit_breaker
                                    else 0,
                                )
                                raise
                            except Exception as exc:
                                last_error = exc
                                raise
                    raise RuntimeError(  # pragma: no cover
                        "AsyncRetrying exhausted without result"
                    )
                finally:
                    _retry_depth.reset(token)

            return cast(Callable[..., T], async_wrapper)

        @wraps(func)
        def wrapper(*args: Any, **kwargs: Any) -> T:
            depth = _retry_depth.get()
            if depth >= _MAX_RETRY_NESTING_DEPTH:
                if circuit_breaker is not None:
                    return circuit_breaker.call(func, *args, **kwargs)
                return func(*args, **kwargs)

            token = _retry_depth.set(depth + 1)
            try:
                last_error: BaseException | None = None
                for attempt_obj in Retrying(
                    stop=(
                        stop_after_attempt(max_attempts)
                        | stop_after_delay(total_timeout)
                    ),
                    wait=wait,
                    reraise=True,
                    # F1/S1: Restrict retries to Exception subclasses
                    # so that KeyboardInterrupt, SystemExit, and
                    # CancelledError propagate immediately.
                    retry=(
                        retry_if_exception_type(Exception)
                        & retry_if_not_exception_type(CircuitBreakerOpen)
                    ),
                ):
                    with attempt_obj:
                        attempt_num = attempt_obj.retry_state.attempt_number
                        if attempt_num > 1:
                            _log_service_retry_attempt(
                                service_name, operation_name, attempt_num, last_error
                            )
                        try:
                            if circuit_breaker is not None:
                                return circuit_breaker.call(func, *args, **kwargs)
                            return func(*args, **kwargs)
                        except CircuitBreakerOpen:
                            _log_circuit_open(
                                service_name,
                                circuit_breaker.failure_count if circuit_breaker else 0,
                            )
                            raise
                        except Exception as exc:
                            last_error = exc
                            raise
                raise RuntimeError(  # pragma: no cover
                    "Retrying exhausted without result"
                )
            finally:
                _retry_depth.reset(token)

        return cast(Callable[..., T], wrapper)

    return decorator


def is_read_only_plan_operation(kwargs: dict[str, Any]) -> bool:
    """Check whether the operation context indicates a read-only plan.

    The function inspects *kwargs* for two indicators:

    * ``read_only`` — if explicitly ``True``, the operation is
      considered read-only regardless of ``plan_phase``.
    * ``plan_phase`` — compared (case-insensitively) against an
      allowlist of known read-only phases.

    Only string values of ``plan_phase`` are evaluated.

    Args:
        kwargs: Arbitrary keyword arguments from the call-site.  The
            keys ``read_only`` (bool) and ``plan_phase`` (str) are
            inspected.

    Returns:
        ``True`` if the operation is read-only, ``False`` otherwise.

    P2-10: Uses an allowlist of known read-only phases instead of a
    denylist, so that unknown or future destructive phases (e.g.
    ``"destroy"``, ``"delete"``) default to non-read-only (no retries).
    """
    if kwargs.get("read_only") is True:
        return True
    phase = kwargs.get("plan_phase", "")
    if not isinstance(phase, str):
        return False
    return phase.lower() in _READ_ONLY_PHASES


class RetryContext:
    """Context manager for retry operations with state tracking."""

    def __init__(
        self,
        operation_name: str,
        max_attempts: int = DEFAULT_MAX_ATTEMPTS,
        wait_strategy: Any = None,
    ):
        """Initialise a :class:`RetryContext`.

        Args:
            operation_name: Human-readable label used in log messages to
                identify which operation is being retried.
            max_attempts: Maximum number of attempts before giving up.
                Defaults to :data:`DEFAULT_MAX_ATTEMPTS`.
            wait_strategy: A tenacity wait object (e.g.
                ``wait_exponential()``) controlling the delay between
                retries.  When *None*, ``wait_exponential()`` is used.
        """
        self.operation_name = operation_name
        self.max_attempts = max_attempts
        self.wait_strategy = wait_strategy or wait_exponential()
        # U5: Default to 1 so that __exit__ logs a meaningful attempt
        # number even when used as a bare context manager without
        # calling execute()/async_execute().
        self.attempt_count = 1
        self.errors: list[BaseException | None] = []

    def __enter__(self) -> RetryContext:
        logger.debug(f"Starting retry context for {self.operation_name}")
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: Any | None,
    ) -> bool:
        if exc_type:
            self.errors.append(exc_val)
            with contextlib.suppress(Exception):
                logger.warning(
                    f"Operation {self.operation_name} failed",
                    attempt=self.attempt_count,
                    error=_sanitize_error_message(exc_val),
                )
        return False

    async def __aenter__(self) -> RetryContext:
        logger.debug(f"Starting async retry context for {self.operation_name}")
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: Any | None,
    ) -> bool:
        if exc_type:
            self.errors.append(exc_val)
            with contextlib.suppress(Exception):
                logger.warning(
                    f"Operation {self.operation_name} failed",
                    attempt=self.attempt_count,
                    error=_sanitize_error_message(exc_val),
                )
        return False

    def execute(self, func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
        """Execute *func* synchronously with retry logic.

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

        Returns:
            The return value of *func* on a successful attempt.

        Raises:
            TypeError: If *func* is an async callable — use
                :meth:`async_execute` instead.
            RuntimeError: If retrying is exhausted without producing a
                result (should not happen with ``reraise=True``).
        """
        # S8: Reject async callables — they must use async_execute().
        if _is_async_callable(func):
            raise TypeError(
                "Use async_execute() for async callables; "
                f"got coroutine function {func!r}"
            )

        # S9: Nesting guard — skip retry wrapping at depth.
        depth = _retry_depth.get()
        if depth >= _MAX_RETRY_NESTING_DEPTH:
            self.attempt_count = 1
            return func(*args, **kwargs)

        token = _retry_depth.set(depth + 1)
        try:
            result: Any = _UNSET
            # S10: stop_after_delay(300.0) bounds total wall-clock time.
            for attempt in Retrying(
                stop=stop_after_attempt(self.max_attempts) | stop_after_delay(300.0),
                wait=self.wait_strategy,
                reraise=True,
                # F1: Only retry Exception subclasses so that
                # KeyboardInterrupt / SystemExit propagate immediately.
                retry=retry_if_exception_type(Exception),
            ):
                with attempt:
                    attempt_num = attempt.retry_state.attempt_number
                    self.attempt_count = attempt_num
                    # S39: Log on retry attempts (attempt_num > 1).
                    if attempt_num > 1:
                        # contextlib.suppress(TypeError) — see S40 note
                        # in module docstring.
                        with contextlib.suppress(TypeError):
                            logger.warning(
                                "RetryContext operation retry",
                                operation=self.operation_name,
                                attempt=attempt_num,
                            )
                    result = func(*args, **kwargs)
            if result is _UNSET:
                raise RuntimeError("Retrying must execute at least once")
            return cast(T, result)
        finally:
            _retry_depth.reset(token)

    async def async_execute(
        self, func: Callable[..., Awaitable[T]], *args: Any, **kwargs: Any
    ) -> T:
        """Execute *func* asynchronously with retry logic.

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

        Returns:
            The return value of *func* on a successful attempt.

        Raises:
            RuntimeError: If retrying is exhausted without producing a
                result (should not happen with ``reraise=True``).
        """
        # S9: Nesting guard — skip retry wrapping at depth.
        depth = _retry_depth.get()
        if depth >= _MAX_RETRY_NESTING_DEPTH:
            self.attempt_count = 1
            return await func(*args, **kwargs)

        token = _retry_depth.set(depth + 1)
        try:
            result: Any = _UNSET
            # S10: stop_after_delay(300.0) bounds total wall-clock time.
            async for attempt in AsyncRetrying(
                stop=stop_after_attempt(self.max_attempts) | stop_after_delay(300.0),
                wait=self.wait_strategy,
                reraise=True,
                # F1: Only retry Exception subclasses so that
                # CancelledError / KeyboardInterrupt propagate immediately.
                retry=retry_if_exception_type(Exception),
            ):
                with attempt:
                    attempt_num = attempt.retry_state.attempt_number
                    self.attempt_count = attempt_num
                    # S39: Log on retry attempts (attempt_num > 1).
                    if attempt_num > 1:
                        # contextlib.suppress(TypeError) — see S40 note
                        # in module docstring.
                        with contextlib.suppress(TypeError):
                            logger.warning(
                                "RetryContext operation retry",
                                operation=self.operation_name,
                                attempt=attempt_num,
                            )
                    result = await func(*args, **kwargs)
            if result is _UNSET:
                raise RuntimeError("AsyncRetrying must execute at least once")
            return cast(T, result)
        finally:
            _retry_depth.reset(token)


def retry_auto_debug(
    max_debug_attempts: int = 3,
    debug_callback: Callable[..., Awaitable[Any]] | None = None,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
    """Retry with auto-debug capability for plan execution.

    Wraps an **async** callable so that it is retried up to
    *max_debug_attempts* times.  Between attempts an optional
    *debug_callback* is invoked to attempt automated repair.

    Args:
        max_debug_attempts: Maximum number of attempts (including the
            initial call).  Defaults to ``3``.
        debug_callback: An optional async callable
            ``(error, attempt) -> dict`` invoked after a failure.  If
            the callback returns ``{"fixed": True}`` the next attempt
            proceeds immediately; otherwise the decorator sleeps with
            exponential back-off.

    Returns:
        A decorator that wraps an async function with retry / auto-debug
        logic.

    Raises:
        TypeError: If the decorated callable is synchronous.
    """

    def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
        @wraps(func)
        async def wrapper(*args: Any, **kwargs: Any) -> Any:
            # S11: retry_auto_debug only supports async callables.
            if not _is_async_callable(func):
                raise TypeError(
                    "retry_auto_debug only supports async callables; "
                    f"got sync function {func!r}"
                )

            last_error: Exception | str | None = None

            for attempt in range(max_debug_attempts):
                try:
                    logger.info(
                        f"Auto-debug attempt {attempt + 1}/{max_debug_attempts}"
                    )
                    result = await func(*args, **kwargs)

                    if isinstance(result, dict):
                        error_value: Any = result.get("error")
                        if error_value is not None:
                            last_error = str(error_value)
                            if debug_callback:
                                debug_result: Any = await debug_callback(
                                    last_error, attempt
                                )
                                if isinstance(debug_result, dict) and debug_result.get(
                                    "fixed"
                                ):
                                    continue
                            else:
                                # S12: No debug callback — return the
                                # result as-is instead of discarding it.
                                return result
                        else:
                            return result
                    else:
                        return result

                except Exception as e:
                    last_error = e
                    logger.error(
                        "Auto-debug attempt failed",
                        attempt=attempt + 1,
                        error=_sanitize_error_message(e),
                    )

                    if debug_callback and attempt < max_debug_attempts - 1:
                        try:
                            debug_result = await debug_callback(e, attempt)
                            if isinstance(debug_result, dict) and debug_result.get(
                                "fixed"
                            ):
                                continue
                        except Exception as debug_error:
                            logger.error(
                                "Debug callback failed",
                                error=_sanitize_error_message(debug_error),
                            )

                # S17: Do not sleep after the final failed attempt.
                if attempt < max_debug_attempts - 1:
                    await asyncio.sleep(min(2**attempt, 60))

            if last_error is not None:
                if isinstance(last_error, Exception):
                    raise last_error
                raise Exception(last_error)
            return None

        return wrapper

    return decorator


__all__ = [
    "RetryContext",
    "is_read_only_plan_operation",
    "retry_auto_debug",
    "retry_service_operation",
]
