"""Service retry policy wiring for CleverAgents.

Integrates retry policies and circuit breakers into the service layer.
Each known service receives a ``ServiceRetryPolicy`` that governs retry
behaviour (max attempts, backoff strategy, delay bounds, jitter) and
circuit breaker protection (failure threshold, recovery timeout,
half-open recovery, cooldown).

The ``ServiceRetryWiring`` class initialises from ``Settings``, creates
``CircuitBreaker`` instances per service, and exposes helpers that
service code can call to execute operations with retry protection.

Per-service overrides are loaded from the ``retry_service_overrides``
config key (a JSON string).

Usage::

    wiring = ServiceRetryWiring(settings)
    result = wiring.execute(
        service_name="plan_service",
        operation_name="list_plans",
        func=lambda: plan_repo.list_all(),
        idempotent=True,
    )

Based on issue #313 (feat(async): wire retry policies into services).
"""

from __future__ import annotations

import asyncio
import contextlib
import json
import threading
from collections.abc import Callable
from typing import Any, TypeVar

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

# NOTE: config.Settings is a cross-cutting infrastructure concern that
# is intentionally imported directly.  A protocol-based abstraction
# (e.g. RetrySettingsPort) may be introduced in a future refactoring
# to fully decouple the application layer from config internals.
from cleveragents.config.settings import Settings
from cleveragents.core.retry_patterns import (
    _MAX_RETRY_NESTING_DEPTH,
    CircuitBreaker,
    CircuitBreakerOpen,
    _build_wait_strategy,
    _log_circuit_open,
    _log_service_retry_attempt,
    _retry_depth,
    _sanitize_error_message,
    retry_service_operation,
)
from cleveragents.domain.models.core.retry_policy import (
    RetryStrategy,
    ServiceRetryPolicy,
    ServiceRetryPolicyRegistry,
)

T = TypeVar("T")
logger = structlog.get_logger(__name__)

__all__ = [
    "ServiceRetryWiring",
]

# Re-exported from retry_patterns for backward compatibility
MAX_RETRY_NESTING_DEPTH = _MAX_RETRY_NESTING_DEPTH

# Total wall-clock timeout for all retry attempts combined.
MAX_RETRY_TOTAL_TIMEOUT = 300.0  # 5 minutes

# S21: Mapping from Settings field names to (policy_key, group) pairs
# used by _apply_settings_defaults to replace repetitive if-comparisons.
_RETRY_FIELD_MAP: dict[str, tuple[str, str]] = {
    "retry_max_attempts": ("max_attempts", "retry"),
    "retry_base_delay": ("base_delay", "retry"),
    "retry_max_delay": ("max_delay", "retry"),
    "retry_jitter": ("jitter", "retry"),
    "retry_backoff_strategy": ("backoff_strategy", "retry"),
    "circuit_breaker_failure_threshold": ("failure_threshold", "circuit_breaker"),
    "circuit_breaker_recovery_timeout": ("recovery_timeout", "circuit_breaker"),
    "circuit_breaker_cooldown": ("cooldown_seconds", "circuit_breaker"),
}


# TODO: This class conflates factory/config, execution, and caching
# responsibilities. Consider extracting config/factory into a builder
# in a future refactoring (ref: review S23).
class ServiceRetryWiring:
    """Integrates retry policies and circuit breakers into service operations.

    Constructed from ``Settings``, this class creates per-service
    ``CircuitBreaker`` instances and provides an ``execute`` helper that
    wraps arbitrary callables with the retry + circuit breaker logic
    defined by each service's ``ServiceRetryPolicy``.

    Structured logs are emitted for every retry attempt and circuit-open
    event.

    .. note::

       Wait-strategy and decorator caches are populated at construction
       time and during lazy lookups.  The ``registry`` property provides
       read access for inspection; direct mutation of the returned
       registry will **not** invalidate the caches.
    """

    def __init__(self, settings: Settings) -> None:
        """Initialise retry wiring from application settings.

        Args:
            settings: Application ``Settings`` instance. Global retry and
                circuit-breaker defaults are extracted from it, then
                per-service JSON overrides (``retry_service_overrides``)
                are layered on top.
        """
        self._settings = settings
        self._registry = ServiceRetryPolicyRegistry()
        self._circuit_breakers: dict[str, CircuitBreaker] = {}
        self._wait_strategies: dict[str, Any] = {}
        self._decorator_cache: dict[tuple[str, str, bool], Any] = {}
        self._cache_lock = threading.Lock()

        # Apply global defaults from settings onto the registry
        self._apply_settings_defaults(settings)

        # P1-2: Track which services existed before config overrides so
        # the second _apply_settings_defaults only touches new services.
        before_services = set(self._registry.registered_services())

        # Apply per-service overrides from JSON config
        self._apply_config_overrides(settings)

        # Re-apply global defaults *only* for services that were introduced
        # by _apply_config_overrides but were not in the registry when
        # _apply_settings_defaults first ran.
        new_services = set(self._registry.registered_services()) - before_services
        if new_services:
            self._apply_settings_defaults(settings, only_services=new_services)

        # Pre-create circuit breakers and cache wait strategies for all
        # registered services so that execute() avoids per-call allocation.
        for name in self._registry.registered_services():
            policy = self._registry.get(name)
            if policy.circuit_breaker.enabled:
                self._circuit_breakers[name] = self._create_cb(policy, name)
            self._wait_strategies[name] = self._build_cached_wait(policy)

        # S33: Log effective configuration at startup for observability.
        with contextlib.suppress(TypeError):
            logger.info(
                "ServiceRetryWiring initialized",
                registered_services=len(self._registry.registered_services()),
                circuit_breakers=len(self._circuit_breakers),
            )

    @staticmethod
    def _create_cb(
        policy: ServiceRetryPolicy, service_name: str = ""
    ) -> CircuitBreaker:
        """Create a ``CircuitBreaker`` from a service policy."""
        return CircuitBreaker(
            failure_threshold=policy.circuit_breaker.failure_threshold,
            recovery_timeout=policy.circuit_breaker.recovery_timeout,
            expected_exception=Exception,
            half_open_max_successes=policy.circuit_breaker.half_open_max_successes,
            cooldown_seconds=policy.circuit_breaker.cooldown_seconds,
            name=service_name,
        )

    def _apply_settings_defaults(
        self,
        settings: Settings,
        only_services: set[str] | None = None,
    ) -> None:
        """Merge global retry/CB settings into registered policies.

        Only fields whose value in ``Settings`` differs from the
        ``Settings`` field default are pushed onto the registry.  This
        preserves the per-category defaults (e.g. database services keep
        their tuned ``base_delay`` / ``max_delay``) while still allowing
        operators to override globally via environment variables.

        When *only_services* is provided, only those services are updated.
        """
        retry_overrides: dict[str, Any] = {}
        cb_overrides: dict[str, Any] = {}

        # Detect which global settings the operator explicitly changed.
        # S21: Uses _RETRY_FIELD_MAP to avoid repetitive if-comparisons.
        field_defaults = Settings.model_fields
        for settings_key, (policy_key, group) in _RETRY_FIELD_MAP.items():
            current = getattr(settings, settings_key)
            default = field_defaults[settings_key].default
            if current != default:
                target = retry_overrides if group == "retry" else cb_overrides
                target[policy_key] = current

        if not retry_overrides and not cb_overrides:
            return

        global_overrides: dict[str, dict[str, Any]] = {}
        override_payload: dict[str, Any] = {}
        if retry_overrides:
            override_payload["retry"] = retry_overrides
        if cb_overrides:
            override_payload["circuit_breaker"] = cb_overrides

        target_services = (
            only_services
            if only_services is not None
            else set(self._registry.registered_services())
        )
        for svc_name in target_services:
            global_overrides[svc_name] = override_payload

        self._registry.apply_overrides(global_overrides)

    def _apply_config_overrides(self, settings: Settings) -> None:
        """Parse and apply per-service overrides from the JSON config key."""
        raw = settings.retry_service_overrides
        if not raw or raw.strip() in ("", "{}"):
            return
        try:
            overrides = json.loads(raw)
            if isinstance(overrides, dict):
                self._registry.apply_overrides(overrides)
            else:
                # P2-29: Warn when parsed JSON is not a dict.
                with contextlib.suppress(TypeError):
                    logger.warning(
                        "retry_service_overrides is not a JSON object — ignoring",
                        type=type(overrides).__name__,
                    )
        except (json.JSONDecodeError, TypeError, RecursionError):
            try:
                # P2-17: Truncate raw JSON to prevent secret leakage.
                logger.warning(
                    "Invalid retry_service_overrides JSON — ignoring",
                    raw=raw[:80] + "..." if len(raw) > 80 else raw,
                )
            except TypeError:
                with contextlib.suppress(Exception):
                    logger.warning("Invalid retry_service_overrides JSON — ignoring")

    @staticmethod
    def _build_cached_wait(policy: ServiceRetryPolicy) -> Any:
        """Build a tenacity wait strategy from a service policy."""
        backoff = (
            policy.retry.backoff_strategy.value
            if isinstance(policy.retry.backoff_strategy, RetryStrategy)
            else str(policy.retry.backoff_strategy)
        )
        return _build_wait_strategy(
            backoff,
            policy.retry.base_delay,
            policy.retry.max_delay,
            policy.retry.jitter,
        )

    def _get_wait_strategy(self, service_name: str) -> Any:
        """Return the cached wait strategy for *service_name*.

        Uses ``_cache_lock`` to eliminate TOCTOU races on the
        check-then-populate path.
        """
        # P2-16: Lock the check-then-populate to prevent races.
        with self._cache_lock:
            cached = self._wait_strategies.get(service_name)
            if cached is not None:
                return cached
            policy = self._registry.get(service_name)
            wait = self._build_cached_wait(policy)
            self._wait_strategies[service_name] = wait
            return wait

    def _get_or_create_cb(self, service_name: str) -> CircuitBreaker | None:
        """Return (or lazily create) the circuit breaker for *service_name*.

        P1-3: Services first used after ``__init__`` can have
        ``circuit_breaker.enabled=True`` in their auto-generated policy
        yet lack a ``CircuitBreaker`` instance.  This method creates one
        on demand so the runtime matches the policy declaration.
        """
        # NOTE: The dict read outside the lock relies on the GIL for atomic
        # dict lookups.  Under PEP 703 (free-threaded Python), this would
        # need to be moved inside the lock or use a concurrent dict.
        cb = self._circuit_breakers.get(service_name)
        if cb is not None:
            return cb
        policy = self._registry.get(service_name)
        if not policy.circuit_breaker.enabled:
            return None
        with self._cache_lock:
            # Double-check after acquiring lock
            cb = self._circuit_breakers.get(service_name)
            if cb is not None:
                return cb
            cb = self._create_cb(policy, service_name)
            self._circuit_breakers[service_name] = cb
            return cb

    @property
    def registry(self) -> ServiceRetryPolicyRegistry:
        """Return the underlying policy registry for inspection."""
        return self._registry

    def get_policy(self, service_name: str) -> ServiceRetryPolicy:
        """Return the effective retry policy for *service_name*.

        Args:
            service_name: Registry key identifying the service.

        Returns:
            The ``ServiceRetryPolicy`` (may be the auto-generated default
            if no explicit registration or override was applied).
        """
        return self._registry.get(service_name)

    def get_circuit_breaker(self, service_name: str) -> CircuitBreaker | None:
        """Return the circuit breaker instance for *service_name*.

        Args:
            service_name: Registry key identifying the service.

        Returns:
            The ``CircuitBreaker`` if the service's policy has circuit
            breaking enabled, otherwise ``None``.
        """
        return self._circuit_breakers.get(service_name)

    def execute(
        self,
        service_name: str,
        operation_name: str,
        func: Callable[..., T],
        *args: Any,
        idempotent: bool = True,
        **kwargs: Any,
    ) -> T:
        """Execute a service operation with retry and circuit breaker protection.

        If *idempotent* is ``False`` the function runs exactly once with
        no retry.  This guards against retrying applies and writes.

        Uses ``Retrying`` directly instead of the decorator pattern so
        that no inner function or decorator object is created per call.

        A nesting guard prevents retry amplification when service A
        (retrying) calls service B (also retrying).  The total wall-clock
        time for all attempts is approximately bounded by
        ``MAX_RETRY_TOTAL_TIMEOUT`` seconds (the check occurs after each
        attempt completes, so actual elapsed time may exceed this by the
        duration of a single attempt).

        Args:
            service_name: Name of the service (matches registry key).
            operation_name: Human-readable name of the operation.
            func: The callable to execute.
            *args: Positional arguments forwarded to *func*.
            idempotent: Whether the operation is safe to retry.
            **kwargs: Keyword arguments forwarded to *func*.

        Returns:
            The return value of *func*.

        Raises:
            TypeError: If *func* is an async callable (use
                :meth:`async_execute` instead).
            CircuitBreakerOpen: If the service's circuit breaker is open.
            Exception: Any exception from *func* after retries are exhausted.
        """
        # P1-5: Guard against async callables passed to sync execute().
        if asyncio.iscoroutinefunction(func):
            raise TypeError(
                f"Use async_execute() for async callables; "
                f"got coroutine function {func!r}"
            )

        if not idempotent:
            return func(*args, **kwargs)

        # Guard against retry amplification in nested service calls
        depth = _retry_depth.get()
        if depth >= MAX_RETRY_NESTING_DEPTH:
            # S13: Log nesting guard bypass for observability.
            with contextlib.suppress(TypeError):
                logger.debug(
                    "Retry nesting guard active — skipping retry wrapping",
                    service=service_name,
                    operation=operation_name,
                    depth=depth,
                )
            cb = self._get_or_create_cb(service_name)
            if cb is not None:
                return cb.call(func, *args, **kwargs)
            return func(*args, **kwargs)

        token = _retry_depth.set(depth + 1)
        try:
            policy = self._registry.get(service_name)
            cb = self._get_or_create_cb(service_name)
            wait = self._get_wait_strategy(service_name)

            last_error: BaseException | None = None
            try:
                for attempt_obj in Retrying(
                    stop=(
                        stop_after_attempt(policy.retry.max_attempts)
                        | stop_after_delay(MAX_RETRY_TOTAL_TIMEOUT)
                    ),
                    wait=wait,
                    reraise=True,
                    # S1: Restrict retries to Exception subclasses only so
                    # that KeyboardInterrupt and SystemExit propagate
                    # immediately instead of being retried.
                    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 cb is not None:
                                return cb.call(func, *args, **kwargs)
                            return func(*args, **kwargs)
                        except CircuitBreakerOpen:
                            _log_circuit_open(
                                service_name,
                                cb.failure_count if cb else 0,
                            )
                            raise
                        except Exception as exc:
                            last_error = exc
                            raise
            except CircuitBreakerOpen:
                raise
            except Exception as exc:
                with contextlib.suppress(TypeError):
                    logger.error(
                        "All retries exhausted",
                        service=service_name,
                        operation=operation_name,
                        error=_sanitize_error_message(exc),
                    )
                raise
            raise RuntimeError(  # pragma: no cover
                "Retrying exhausted without result"
            )
        finally:
            _retry_depth.reset(token)

    async def async_execute(
        self,
        service_name: str,
        operation_name: str,
        func: Callable[..., Any],
        *args: Any,
        idempotent: bool = True,
        **kwargs: Any,
    ) -> Any:
        """Execute an async service operation with retry and circuit breaker.

        Async counterpart of :meth:`execute`.  Uses ``AsyncRetrying`` to
        correctly ``await`` the callable and fire retries for async code.

        A nesting guard prevents retry amplification when service A
        (retrying) calls service B (also retrying).  The total wall-clock
        time for all attempts is approximately bounded by
        ``MAX_RETRY_TOTAL_TIMEOUT`` seconds (the check occurs after each
        attempt completes, so actual elapsed time may exceed this by the
        duration of a single attempt).

        Args:
            service_name: Name of the service (matches registry key).
            operation_name: Human-readable name of the operation.
            func: The async callable to execute.
            *args: Positional arguments forwarded to *func*.
            idempotent: Whether the operation is safe to retry.
            **kwargs: Keyword arguments forwarded to *func*.

        Returns:
            The return value of *func*.

        Raises:
            CircuitBreakerOpen: If the service's circuit breaker is open.
            Exception: Any exception from *func* after retries are exhausted.
        """
        if not idempotent:
            return await func(*args, **kwargs)

        # Guard against retry amplification in nested service calls
        depth = _retry_depth.get()
        if depth >= MAX_RETRY_NESTING_DEPTH:
            # S13: Log nesting guard bypass for observability.
            with contextlib.suppress(TypeError):
                logger.debug(
                    "Retry nesting guard active — skipping retry wrapping",
                    service=service_name,
                    operation=operation_name,
                    depth=depth,
                )
            cb = self._get_or_create_cb(service_name)
            if cb is not None:
                return await cb.async_call(func, *args, **kwargs)
            return await func(*args, **kwargs)

        token = _retry_depth.set(depth + 1)
        try:
            policy = self._registry.get(service_name)
            cb = self._get_or_create_cb(service_name)
            wait = self._get_wait_strategy(service_name)

            last_error: BaseException | None = None
            try:
                async for attempt_obj in AsyncRetrying(
                    stop=(
                        stop_after_attempt(policy.retry.max_attempts)
                        | stop_after_delay(MAX_RETRY_TOTAL_TIMEOUT)
                    ),
                    wait=wait,
                    reraise=True,
                    # S1: Same fix as execute() — restrict to Exception only.
                    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 cb is not None:
                                return await cb.async_call(func, *args, **kwargs)
                            return await func(*args, **kwargs)
                        except CircuitBreakerOpen:
                            _log_circuit_open(
                                service_name,
                                cb.failure_count if cb else 0,
                            )
                            raise
                        except Exception as exc:
                            last_error = exc
                            raise
            except CircuitBreakerOpen:
                raise
            except Exception as exc:
                with contextlib.suppress(TypeError):
                    logger.error(
                        "All retries exhausted",
                        service=service_name,
                        operation=operation_name,
                        error=_sanitize_error_message(exc),
                    )
                raise
            raise RuntimeError(  # pragma: no cover
                "AsyncRetrying exhausted without result"
            )
        finally:
            _retry_depth.reset(token)

    def wrap_service_method(
        self,
        service_name: str,
        operation_name: str,
        *,
        idempotent: bool = True,
    ) -> Any:
        """Return a decorator that wraps a service method with retry policy.

        The decorator is cached per ``(service_name, operation_name,
        idempotent)`` tuple.  The entire lookup-or-create is performed
        under the cache lock to eliminate TOCTOU races.

        Usage::

            wiring = ServiceRetryWiring(settings)

            @wiring.wrap_service_method("plan_service", "list_plans")
            def list_plans(self):
                ...

        Args:
            service_name: Name of the service.
            operation_name: Human-readable name of the operation.
            idempotent: Whether the operation is safe to retry.

        Returns:
            A decorator function.
        """
        cache_key = (service_name, operation_name, idempotent)
        with self._cache_lock:
            cached = self._decorator_cache.get(cache_key)
            if cached is not None:
                return cached
            policy = self._registry.get(service_name)
            cb = self._get_or_create_cb(service_name)
            decorator = retry_service_operation(
                service_name=service_name,
                operation_name=operation_name,
                max_attempts=policy.retry.max_attempts,
                base_delay=policy.retry.base_delay,
                max_delay=policy.retry.max_delay,
                jitter=policy.retry.jitter,
                backoff_strategy=policy.retry.backoff_strategy.value
                if isinstance(policy.retry.backoff_strategy, RetryStrategy)
                else str(policy.retry.backoff_strategy),
                idempotent=idempotent,
                circuit_breaker=cb,
                total_timeout=MAX_RETRY_TOTAL_TIMEOUT,
            )
            self._decorator_cache[cache_key] = decorator
            return decorator

    def is_circuit_open(self, service_name: str) -> bool:
        """Check whether the circuit breaker for a service is open."""
        cb = self._circuit_breakers.get(service_name)
        if cb is None:
            return False
        return cb.is_open()

    def reset_circuit(self, service_name: str) -> None:
        """Manually reset a service's circuit breaker to closed state."""
        cb = self._circuit_breakers.get(service_name)
        if cb is not None:
            cb.reset()
