"""RetryPolicy and CircuitBreakerConfig domain models for CleverAgents.

Defines per-service retry policies and circuit breaker configurations
that integrate with the core retry patterns infrastructure.

Each service can have its own retry policy specifying max attempts,
backoff strategy, delay bounds, and jitter.  A circuit breaker config
governs the failure threshold, recovery timeout, and half-open recovery
behaviour.

Per-service overrides are resolved via ``ServiceRetryPolicyRegistry``
which maps a service name to a ``ServiceRetryPolicy``.  Defaults are
provided for each retry category (network, provider, database,
file_operation) and individual services can override via config.

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

from __future__ import annotations

import contextlib
import re
import threading
from enum import StrEnum
from typing import Any

import structlog
from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    ValidationError,
    field_validator,
    model_validator,
)

logger = structlog.get_logger(__name__)

__all__ = [
    "CATEGORY_DEFAULTS",
    "DEFAULT_CIRCUIT_BREAKER",
    "DEFAULT_DATABASE_RETRY",
    "DEFAULT_FILE_RETRY",
    "DEFAULT_NETWORK_RETRY",
    "DEFAULT_PROVIDER_RETRY",
    "CircuitBreakerConfig",
    "RetryCategory",
    "RetryPolicyConfig",
    "RetryStrategy",
    "ServiceRetryPolicy",
    "ServiceRetryPolicyRegistry",
]

# P2-28: Pattern for sanitizing service names before logging to
# prevent log injection (newlines, ANSI escape sequences).
_LOG_SAFE_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")

# U10: Pattern matching Unicode control characters, zero-width spaces,
# RTL/LTR overrides, and other invisible characters that could cause
# invisible key collisions in policy dicts and log files.
_UNICODE_CONTROL_RE = re.compile(
    r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f"
    r"\u200b-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff9-\ufffb]"
)


def _safe_service_name(name: str) -> str:
    """Return a log-safe version of a service name."""
    return _LOG_SAFE_RE.sub("?", name)[:255]


class RetryStrategy(StrEnum):
    """Backoff strategy for retry operations."""

    EXPONENTIAL = "exponential"
    LINEAR = "linear"
    FIXED = "fixed"
    JITTER = "jitter"
    NONE = "none"


class RetryCategory(StrEnum):
    """Categories of retry patterns mapping to service operations."""

    NETWORK = "network"
    PROVIDER = "provider"
    DATABASE = "database"
    FILE_OPERATION = "file_operation"


class RetryPolicyConfig(BaseModel):
    """Configuration for a retry policy.

    Defines how many times an operation should be retried, the backoff
    strategy, and delay bounds.  This model is used both as the
    per-service policy and as the global default when no service-specific
    override exists.

    Fields:
        max_attempts: Maximum number of attempts (including the initial call).
        base_delay: Initial delay in seconds before the first retry.
        max_delay: Upper bound on delay between retries in seconds.
        jitter: Whether to add random jitter to delays to avoid thundering herd.
        backoff_strategy: The strategy used to compute delay between retries.
        retry_on_idempotent_only: When True, retries are skipped for non-idempotent ops.
    """

    max_attempts: int = Field(
        default=3,
        ge=1,
        le=100,
        description="Maximum number of attempts including the initial call.",
    )
    base_delay: float = Field(
        default=1.0,
        ge=0.0,
        le=300.0,
        description="Initial delay in seconds before the first retry.",
    )
    max_delay: float = Field(
        default=60.0,
        ge=0.0,
        le=3600.0,
        description="Upper bound on delay between retries in seconds.",
    )
    jitter: bool = Field(
        default=True,
        description="Whether to add random jitter to retry delays.",
    )
    backoff_strategy: RetryStrategy = Field(
        default=RetryStrategy.EXPONENTIAL,
        description="Backoff strategy for computing delay between retries.",
    )
    retry_on_idempotent_only: bool = Field(
        default=True,
        description=(
            "When True, retries are only applied to idempotent operations "
            "(reads, validations). Non-idempotent operations (applies, writes) "
            "are never retried."
        ),
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
        use_enum_values=False,
        extra="forbid",
    )

    @model_validator(mode="after")
    def _check_max_delay_ge_base_delay(self) -> RetryPolicyConfig:
        """Ensure max_delay is not less than base_delay.

        Uses a model validator so the constraint fires on ANY field
        assignment (including ``base_delay``), not just when ``max_delay``
        is set.
        """
        if self.max_delay < self.base_delay:
            msg = (
                f"max_delay ({self.max_delay}) must be >= "
                f"base_delay ({self.base_delay})"
            )
            raise ValueError(msg)
        return self


class CircuitBreakerConfig(BaseModel):
    """Configuration for a circuit breaker protecting a service.

    After ``failure_threshold`` consecutive failures the circuit opens,
    blocking further calls for ``recovery_timeout`` seconds.  In
    half-open state, ``half_open_max_successes`` consecutive successes
    are required before the circuit fully closes.  ``cooldown_seconds``
    defines the minimum time between recovery attempts.

    Fields:
        failure_threshold: Number of consecutive failures before the circuit opens.
        recovery_timeout: Seconds to wait before entering half-open state.
        half_open_max_successes: Consecutive successes needed to close from half-open.
        cooldown_seconds: Minimum seconds between half-open recovery attempts.
        enabled: Whether the circuit breaker is active for this service.
    """

    failure_threshold: int = Field(
        default=5,
        ge=1,
        le=1000,
        description="Number of consecutive failures before the circuit opens.",
    )
    recovery_timeout: float = Field(
        default=60.0,
        ge=1.0,
        le=3600.0,
        description="Seconds to wait before entering half-open state.",
    )
    half_open_max_successes: int = Field(
        default=2,
        ge=1,
        le=100,
        description="Consecutive successes needed to close from half-open.",
    )
    cooldown_seconds: float = Field(
        default=30.0,
        ge=0.0,
        le=3600.0,
        description="Minimum seconds between half-open recovery attempts.",
    )
    enabled: bool = Field(
        default=True,
        description="Whether the circuit breaker is active for this service.",
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
        use_enum_values=False,
        extra="forbid",
    )

    @model_validator(mode="after")
    def _check_cooldown_le_recovery(self) -> CircuitBreakerConfig:
        """Ensure cooldown_seconds does not exceed recovery_timeout.

        A cooldown longer than the recovery timeout would mean the
        circuit breaker can never attempt recovery within the window.
        """
        if self.cooldown_seconds > self.recovery_timeout:
            msg = (
                f"cooldown_seconds ({self.cooldown_seconds}) must be <= "
                f"recovery_timeout ({self.recovery_timeout})"
            )
            raise ValueError(msg)
        return self


class ServiceRetryPolicy(BaseModel):
    """Combined retry and circuit breaker policy for a specific service.

    Associates a ``RetryPolicyConfig`` and ``CircuitBreakerConfig`` with
    a named service, enabling per-service customisation of resilience
    behaviour.

    Fields:
        service_name: Identifier of the target service (e.g. ``plan_service``).
        retry_category: The retry category this service belongs to.
        retry: Retry policy configuration for this service.
        circuit_breaker: Circuit breaker configuration for this service.
        description: Human-readable description of the policy.
    """

    service_name: str = Field(
        ...,
        min_length=1,
        max_length=255,
        description="Identifier of the target service.",
    )

    @field_validator("service_name")
    @classmethod
    def _reject_unicode_control_chars(cls, v: str) -> str:
        """U10: Reject service names containing invisible Unicode chars.

        Zero-width spaces, RTL overrides, and other control characters
        can cause invisible key collisions where two visually identical
        service names map to different dict keys.
        """
        if _UNICODE_CONTROL_RE.search(v):
            msg = f"service_name contains invisible Unicode control characters: {v!r}"
            raise ValueError(msg)
        return v

    retry_category: RetryCategory = Field(
        default=RetryCategory.DATABASE,
        description="The retry category this service belongs to.",
    )
    retry: RetryPolicyConfig = Field(
        default_factory=RetryPolicyConfig,
        description="Retry policy configuration for this service.",
    )
    circuit_breaker: CircuitBreakerConfig = Field(
        default_factory=CircuitBreakerConfig,
        description="Circuit breaker configuration for this service.",
    )
    description: str = Field(
        default="",
        max_length=1024,
        description="Human-readable description of the policy.",
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
        use_enum_values=False,
        extra="forbid",
    )


# -------------------------------------------------------------------
# Default policies per retry category
# -------------------------------------------------------------------

DEFAULT_NETWORK_RETRY = RetryPolicyConfig(
    max_attempts=5,
    base_delay=1.0,
    max_delay=30.0,
    jitter=True,
    backoff_strategy=RetryStrategy.EXPONENTIAL,
    retry_on_idempotent_only=True,
)

DEFAULT_PROVIDER_RETRY = RetryPolicyConfig(
    max_attempts=3,
    base_delay=1.0,
    max_delay=60.0,
    jitter=True,
    backoff_strategy=RetryStrategy.JITTER,
    retry_on_idempotent_only=True,
)

DEFAULT_DATABASE_RETRY = RetryPolicyConfig(
    max_attempts=3,
    base_delay=0.5,
    max_delay=5.0,
    jitter=False,
    backoff_strategy=RetryStrategy.FIXED,
    retry_on_idempotent_only=True,
)

DEFAULT_FILE_RETRY = RetryPolicyConfig(
    max_attempts=3,
    base_delay=0.1,
    max_delay=1.0,
    jitter=True,
    backoff_strategy=RetryStrategy.EXPONENTIAL,
    retry_on_idempotent_only=True,
)

DEFAULT_CIRCUIT_BREAKER = CircuitBreakerConfig(
    failure_threshold=5,
    recovery_timeout=60.0,
    half_open_max_successes=2,
    cooldown_seconds=30.0,
    enabled=True,
)

CATEGORY_DEFAULTS: dict[RetryCategory, RetryPolicyConfig] = {
    RetryCategory.NETWORK: DEFAULT_NETWORK_RETRY,
    RetryCategory.PROVIDER: DEFAULT_PROVIDER_RETRY,
    RetryCategory.DATABASE: DEFAULT_DATABASE_RETRY,
    RetryCategory.FILE_OPERATION: DEFAULT_FILE_RETRY,
}


# -------------------------------------------------------------------
# Per-service default policies (service_name -> ServiceRetryPolicy)
# -------------------------------------------------------------------

_SERVICE_DEFAULTS: dict[str, ServiceRetryPolicy] = {
    "plan_service": ServiceRetryPolicy(
        service_name="plan_service",
        retry_category=RetryCategory.PROVIDER,
        retry=DEFAULT_PROVIDER_RETRY.model_copy(deep=True),
        circuit_breaker=DEFAULT_CIRCUIT_BREAKER.model_copy(deep=True),
        description="Plan service — AI provider calls and DB reads.",
    ),
    "plan_lifecycle_service": ServiceRetryPolicy(
        service_name="plan_lifecycle_service",
        retry_category=RetryCategory.DATABASE,
        retry=DEFAULT_DATABASE_RETRY.model_copy(deep=True),
        circuit_breaker=DEFAULT_CIRCUIT_BREAKER.model_copy(deep=True),
        description="Plan lifecycle — DB transitions and events.",
    ),
    "context_service": ServiceRetryPolicy(
        service_name="context_service",
        retry_category=RetryCategory.DATABASE,
        retry=DEFAULT_DATABASE_RETRY.model_copy(deep=True),
        circuit_breaker=DEFAULT_CIRCUIT_BREAKER.model_copy(deep=True),
        description="Context service — file reads and vector store queries.",
    ),
    "session_service": ServiceRetryPolicy(
        service_name="session_service",
        retry_category=RetryCategory.DATABASE,
        retry=DEFAULT_DATABASE_RETRY.model_copy(deep=True),
        circuit_breaker=DEFAULT_CIRCUIT_BREAKER.model_copy(deep=True),
        description="Session service — DB persistence for sessions.",
    ),
    "checkpoint_service": ServiceRetryPolicy(
        service_name="checkpoint_service",
        retry_category=RetryCategory.DATABASE,
        retry=DEFAULT_DATABASE_RETRY.model_copy(deep=True),
        circuit_breaker=DEFAULT_CIRCUIT_BREAKER.model_copy(deep=True),
        description="Checkpoint service — DB-backed checkpoint operations.",
    ),
    "trace_service": ServiceRetryPolicy(
        service_name="trace_service",
        retry_category=RetryCategory.DATABASE,
        retry=DEFAULT_DATABASE_RETRY.model_copy(deep=True),
        circuit_breaker=DEFAULT_CIRCUIT_BREAKER.model_copy(deep=True),
        description="Trace service — LLM trace repository writes.",
    ),
    "actor_service": ServiceRetryPolicy(
        service_name="actor_service",
        retry_category=RetryCategory.DATABASE,
        retry=DEFAULT_DATABASE_RETRY.model_copy(deep=True),
        circuit_breaker=DEFAULT_CIRCUIT_BREAKER.model_copy(deep=True),
        description="Actor service — actor lookups from DB.",
    ),
    "resource_registry_service": ServiceRetryPolicy(
        service_name="resource_registry_service",
        retry_category=RetryCategory.DATABASE,
        retry=DEFAULT_DATABASE_RETRY.model_copy(deep=True),
        circuit_breaker=DEFAULT_CIRCUIT_BREAKER.model_copy(deep=True),
        description="Resource registry — resource DB operations.",
    ),
    "decision_service": ServiceRetryPolicy(
        service_name="decision_service",
        retry_category=RetryCategory.DATABASE,
        retry=DEFAULT_DATABASE_RETRY.model_copy(deep=True),
        circuit_breaker=DEFAULT_CIRCUIT_BREAKER.model_copy(deep=True),
        description="Decision service — decision repository via UoW.",
    ),
    "vector_store_service": ServiceRetryPolicy(
        service_name="vector_store_service",
        retry_category=RetryCategory.NETWORK,
        retry=DEFAULT_NETWORK_RETRY.model_copy(deep=True),
        circuit_breaker=DEFAULT_CIRCUIT_BREAKER.model_copy(deep=True),
        description="Vector store — FAISS indexing and embeddings API calls.",
    ),
    "error_recovery_service": ServiceRetryPolicy(
        service_name="error_recovery_service",
        retry_category=RetryCategory.DATABASE,
        retry=DEFAULT_DATABASE_RETRY.model_copy(deep=True),
        circuit_breaker=DEFAULT_CIRCUIT_BREAKER.model_copy(deep=True),
        description="Error recovery — lifecycle calls and metadata persistence.",
    ),
}


class ServiceRetryPolicyRegistry:
    """Registry of per-service retry policies with config-driven overrides.

    Provides default policies for known services and allows runtime
    overrides via a ``dict[str, dict[str, Any]]`` loaded from config.

    Thread-safe: all read and write operations are guarded by an
    internal lock.

    Usage::

        registry = ServiceRetryPolicyRegistry()
        policy = registry.get("plan_service")
        # Apply override from config
        registry.apply_overrides({"plan_service": {"retry": {"max_attempts": 5}}})
    """

    def __init__(self) -> None:
        # P2-15: Lock guards concurrent access to _policies.
        self._lock = threading.Lock()
        # Deep-copy defaults so mutation of one policy's sub-models
        # does not corrupt the shared default instances.
        self._policies: dict[str, ServiceRetryPolicy] = {
            k: v.model_copy(deep=True) for k, v in _SERVICE_DEFAULTS.items()
        }

    def get(self, service_name: str) -> ServiceRetryPolicy:
        """Return a deep copy of the policy for *service_name*.

        If the service is unknown a default policy is created *and
        cached* so that subsequent lookups return consistent defaults.

        P1-4: Returns a deep copy so that callers cannot corrupt the
        registry's internal state through mutation.

        Args:
            service_name: The identifier of the service whose retry
                policy should be retrieved.  Leading/trailing whitespace
                is stripped automatically.

        Returns:
            A deep copy of the ``ServiceRetryPolicy`` registered for the
            given service.  If no policy existed previously a default one
            is created, cached, and returned.
        """
        service_name = service_name.strip()
        with self._lock:
            if service_name in self._policies:
                return self._policies[service_name].model_copy(deep=True)
            # Create and cache a default policy for unknown services.
            policy = ServiceRetryPolicy(
                service_name=service_name,
                retry_category=RetryCategory.DATABASE,
                retry=DEFAULT_DATABASE_RETRY.model_copy(deep=True),
                circuit_breaker=DEFAULT_CIRCUIT_BREAKER.model_copy(deep=True),
                description=f"Auto-generated default policy for {service_name}.",
            )
            self._policies[service_name] = policy
            return policy.model_copy(deep=True)

    def register(self, policy: ServiceRetryPolicy) -> None:
        """Register or replace a policy for a service.

        A deep copy is stored to prevent external mutation of the
        policy from corrupting the registry.

        Args:
            policy: The ``ServiceRetryPolicy`` to register.  A deep copy
                is stored internally so that later mutations of the
                original object do not affect the registry.
        """
        with self._lock:
            self._policies[policy.service_name] = policy.model_copy(deep=True)

    def apply_overrides(self, overrides: dict[str, dict[str, Any]]) -> None:
        """Apply config-driven overrides on top of existing policies.

        *overrides* maps service names to partial dicts that are merged
        into the existing policy (or a fresh default if the service is
        unknown).  Only keys present in the override are changed.

        ``service_name`` is excluded from mergeable keys to prevent a
        key/value mismatch in the registry.  Invalid overrides that
        fail Pydantic validation are logged and skipped rather than
        crashing the application at startup.  Unrecognised top-level
        keys are logged as warnings so that typos are not silently
        ignored.

        Args:
            overrides: A mapping of service name to a partial dict of
                fields to merge.  Supported top-level keys are
                ``retry``, ``circuit_breaker``, ``retry_category``, and
                ``description``.  The ``retry`` and ``circuit_breaker``
                values must themselves be dicts whose entries are merged
                into the existing sub-model.  Leading/trailing whitespace
                in service name keys is stripped automatically.
        """
        _known_keys = {
            "retry",
            "circuit_breaker",
            "retry_category",
            "description",
            "service_name",
        }
        for service_name, override_data in overrides.items():
            service_name = service_name.strip()
            safe_name = _safe_service_name(service_name)
            if not isinstance(override_data, dict):
                with contextlib.suppress(TypeError):
                    logger.warning(
                        "Non-dict override data ignored",
                        service=safe_name,
                    )
                continue
            # Warn on unrecognised keys so typos are not silently ignored
            unknown = set(override_data.keys()) - _known_keys
            if unknown:
                with contextlib.suppress(TypeError):
                    logger.warning(
                        "Unknown override keys ignored",
                        service=safe_name,
                        keys=sorted(unknown),
                    )
            # P2-18: Wrap get() in try/except to handle invalid
            # service names (empty, too long) that fail Pydantic validation.
            try:
                base = self.get(service_name)
            except (ValidationError, ValueError):
                with contextlib.suppress(TypeError):
                    logger.warning(
                        "Invalid service name in retry overrides — skipping",
                        service=safe_name,
                    )
                continue
            merged = base.model_dump()
            # Deep merge retry and circuit_breaker sub-dicts
            for key in ("retry", "circuit_breaker"):
                if key in override_data:
                    # P2-30: Warn when sub-key value is not a dict.
                    if not isinstance(override_data[key], dict):
                        with contextlib.suppress(TypeError):
                            logger.warning(
                                "Non-dict override sub-key ignored",
                                service=safe_name,
                                key=key,
                            )
                        continue
                    merged_sub = merged.get(key, {})
                    if isinstance(merged_sub, dict):
                        merged_sub.update(override_data[key])
                        merged[key] = merged_sub
            # Merge top-level scalar keys — service_name is excluded to
            # prevent a key/value mismatch in the registry dict.
            for key in ("retry_category", "description"):
                if key in override_data:
                    merged[key] = override_data[key]
            try:
                with self._lock:
                    self._policies[service_name] = ServiceRetryPolicy.model_validate(
                        merged
                    )
            except ValidationError as exc:
                # P2-19: Include validation error details for diagnosis.
                with contextlib.suppress(TypeError):
                    logger.warning(
                        "Invalid retry override — skipping",
                        service=safe_name,
                        errors=str(exc),
                    )

    def all_policies(self) -> dict[str, ServiceRetryPolicy]:
        """Return a deep-copy snapshot of all registered policies.

        P1-4: Deep copies prevent external mutation of registry state.

        Returns:
            A new dict mapping service names to deep copies of every
            registered ``ServiceRetryPolicy``.
        """
        with self._lock:
            return {k: v.model_copy(deep=True) for k, v in self._policies.items()}

    def registered_services(self) -> list[str]:
        """Return sorted list of service names with registered policies.

        Returns:
            A sorted list of all service name strings currently held in
            the registry.
        """
        with self._lock:
            return sorted(self._policies.keys())
