"""Middleware infrastructure for cross-cutting agent concerns."""

import asyncio
import inspect
import json
import logging
import re
import time
from abc import ABC, abstractmethod
from collections import deque
from collections.abc import AsyncGenerator, Awaitable, Callable
from enum import Enum
from typing import Any, Dict, List, Optional, cast

from pydantic import BaseModel, Field

from agentbyte.context import AgentContext
from agentbyte.context import ToolApprovalRequest
from agentbyte.messages import ToolCallRequest
from agentbyte.types import ToolResult


class OperationType(str, Enum):
    """Canonical operation type identifiers for middleware routing.

    Using ``str, Enum`` keeps values as plain strings so existing code that
    passes ``"model_call"`` directly to ``MiddlewareContext`` continues to
    work without modification.
    """

    MODEL_CALL = "model_call"
    TOOL_CALL = "tool_call"
    MEMORY_ACCESS = "memory_access"
    APPROVAL = "approval"
    EMBEDDING_CALL = "embedding_call"


class MiddlewareContext(BaseModel):
    """Context passed through middleware chain."""

    operation: str = Field(
        ...,
        description="Operation type: model_call, tool_call, memory_access, embedding_call, etc.",
    )
    agent_name: str = Field(..., description="Name of the agent or client")
    agent_context: Optional[AgentContext] = Field(
        default=None,
        description="Agent execution context. None for non-agent callers (e.g. embedding clients).",
    )
    data: Any = Field(..., description="Operation-specific input payload")
    metadata: Dict[str, Any] = Field(
        default_factory=dict,
        description="Additional middleware metadata",
    )


class BaseMiddleware(ABC):
    """Base class for middleware components."""

    @abstractmethod
    async def process_request(self, context: MiddlewareContext) -> MiddlewareContext:
        """Process request before operation execution.

        Implementations may return either:
        - MiddlewareContext (simple non-streaming middleware)
        - async generator yielding events and a final MiddlewareContext
        """

    @abstractmethod
    async def process_response(self, context: MiddlewareContext, result: Any) -> Any:
        """Process successful operation result.

        Implementations may return either:
        - transformed result value
        - async generator yielding events and a final result value
        """

    @abstractmethod
    async def process_error(
        self,
        context: MiddlewareContext,
        error: Exception,
    ) -> Optional[Any]:
        """Handle operation errors and optionally return recovery value.

        Implementations may return either:
        - recovery value (or None to propagate)
        - async generator yielding events and optionally a recovery value
        """


def _check_retryable(retry_mw: Any, error: Exception) -> bool:
    """Return True if *error* matches the retry middleware's ``retry_on`` types."""
    return isinstance(error, retry_mw.retry_on)


class MiddlewareChain:
    """Sequential middleware chain executor."""

    def __init__(self, middlewares: Optional[List[BaseMiddleware]] = None):
        self.middlewares: List[BaseMiddleware] = middlewares or []

    def add(self, middleware: BaseMiddleware) -> None:
        """Add middleware to chain."""
        self.middlewares.append(middleware)

    def remove(self, middleware: BaseMiddleware) -> None:
        """Remove middleware from chain."""
        if middleware in self.middlewares:
            self.middlewares.remove(middleware)

    @staticmethod
    def _is_event_item(item: Any) -> bool:
        """Best-effort check for agent event payloads emitted by middleware."""
        return hasattr(item, "event_type") and hasattr(item, "source")

    async def _iter_hook_items(self, value: Any) -> AsyncGenerator[Any, None]:
        """Normalize coroutine/async-generator middleware outputs into a single iterator."""
        if inspect.isasyncgen(value):
            async for item in cast(AsyncGenerator[Any, None], value):
                yield item
            return

        yield await cast(Awaitable[Any], value)

    async def _invoke_operation(
        self,
        func: Callable[..., Awaitable[Any]],
        data: Any,
    ) -> Any:
        """Invoke operation with transformed middleware data when supported by func."""
        signature = inspect.signature(func)
        parameters = list(signature.parameters.values())

        if not parameters:
            return await func()

        return await func(data)

    async def _execute_error_handlers(
        self, context: MiddlewareContext, error: Exception
    ) -> tuple[bool, Any]:
        """Run error handlers in reverse middleware order and return recovery if any."""
        for middleware in reversed(self.middlewares):
            try:
                recovery: Any = None
                has_recovery = False

                async for item in self._iter_hook_items(
                    middleware.process_error(context, error)
                ):
                    if self._is_event_item(item):
                        continue

                    recovery = item
                    has_recovery = True

                if has_recovery and recovery is not None:
                    return True, recovery
            except Exception:
                continue

        return False, None

    @staticmethod
    def _get_retry_target(context: MiddlewareContext) -> str:
        """Derive a stable retry target label for observability."""
        if context.operation == OperationType.TOOL_CALL.value and isinstance(
            context.data, dict
        ):
            tool_name = context.data.get("tool_name")
            if isinstance(tool_name, str) and tool_name:
                return tool_name

        if context.operation == OperationType.MEMORY_ACCESS.value:
            return "memory"

        return context.agent_name

    def _record_retry_attempt(
        self,
        context: MiddlewareContext,
        attempt_number: int,
        computed_delay_seconds: float | None,
        will_retry: bool,
        error: Exception,
    ) -> None:
        """Append retry attempt metadata to ``AgentContext.metadata`` when available."""
        agent_context = context.agent_context
        if agent_context is None:
            return

        observability = agent_context.metadata.setdefault("retry_observability", {})
        if not isinstance(observability, dict):
            observability = {}
            agent_context.metadata["retry_observability"] = observability

        history = observability.setdefault("history", [])
        if not isinstance(history, list):
            history = []
            observability["history"] = history

        history.append(
            {
                "operation": context.operation,
                "target": self._get_retry_target(context),
                "attempt_number": attempt_number,
                "computed_delay_seconds": computed_delay_seconds,
                "will_retry": will_retry,
                "exception_type": type(error).__name__,
                "exception_message": str(error),
            }
        )

    def _finalize_retry_sequence(
        self,
        context: MiddlewareContext,
        attempts: int,
        final_outcome: str,
    ) -> None:
        """Update the latest retry summary for the current operation."""
        agent_context = context.agent_context
        if agent_context is None:
            return

        observability = agent_context.metadata.setdefault("retry_observability", {})
        if not isinstance(observability, dict):
            observability = {}
            agent_context.metadata["retry_observability"] = observability

        observability["latest"] = {
            "operation": context.operation,
            "target": self._get_retry_target(context),
            "attempts": attempts,
            "final_outcome": final_outcome,
        }

    async def execute(
        self,
        operation: str,
        agent_name: str,
        agent_context: Optional[AgentContext],
        data: Any,
        func: Callable[..., Awaitable[Any]],
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Any:
        """Execute operation through middleware request/response/error lifecycle."""
        context = MiddlewareContext(
            operation=operation,
            agent_name=agent_name,
            agent_context=agent_context,
            data=data,
            metadata=metadata or {},
        )

        for middleware in self.middlewares:
            try:
                final_context: Optional[MiddlewareContext] = None
                async for item in self._iter_hook_items(
                    middleware.process_request(context)
                ):
                    if isinstance(item, MiddlewareContext):
                        final_context = item

                if final_context is not None:
                    context = final_context
            except Exception as error:
                recovered, recovery = await self._execute_error_handlers(context, error)
                if recovered:
                    return recovery
                raise

        # Retry config is stamped by RetryMiddleware.process_request when applicable.
        _retry_mw = context.metadata.get("_retry_middleware")
        _attempt = 0
        while True:
            try:
                result = await self._invoke_operation(func, context.data)
                break
            except Exception as error:
                retry_delay: float | None = None
                will_retry = (
                    _retry_mw is not None
                    and _attempt < _retry_mw.max_retries - 1
                    and _check_retryable(_retry_mw, error)
                )
                if will_retry:
                    retry_delay = _retry_mw._compute_delay(_attempt + 1)

                if _retry_mw is not None:
                    self._record_retry_attempt(
                        context=context,
                        attempt_number=_attempt + 1,
                        computed_delay_seconds=retry_delay,
                        will_retry=will_retry,
                        error=error,
                    )

                if will_retry:
                    await asyncio.sleep(retry_delay)
                    _attempt += 1
                    continue
                recovered, recovery = await self._execute_error_handlers(context, error)
                if recovered:
                    if _retry_mw is not None:
                        self._finalize_retry_sequence(
                            context=context,
                            attempts=_attempt + 1,
                            final_outcome="recovered",
                        )
                    result = recovery
                    break
                if _retry_mw is not None:
                    self._finalize_retry_sequence(
                        context=context,
                        attempts=_attempt + 1,
                        final_outcome="failed",
                    )
                raise

        if _retry_mw is not None and _attempt > 0:
            self._finalize_retry_sequence(
                context=context,
                attempts=_attempt + 1,
                final_outcome="succeeded",
            )

        for middleware in reversed(self.middlewares):
            try:
                transformed_result = result
                async for item in self._iter_hook_items(
                    middleware.process_response(context, result)
                ):
                    if self._is_event_item(item):
                        continue
                    transformed_result = item

                result = transformed_result
            except Exception as error:
                recovered, recovery = await self._execute_error_handlers(context, error)
                if recovered:
                    return recovery
                raise

        return result

    async def execute_stream(
        self,
        operation: str,
        agent_name: str,
        agent_context: Optional[AgentContext],
        data: Any,
        func: Callable[..., Awaitable[Any]],
        metadata: Optional[Dict[str, Any]] = None,
    ) -> AsyncGenerator[Any, None]:
        """Execute middleware chain with event-aware streaming support.

        Yields middleware-emitted events and final operation result/recovery value.
        """
        context = MiddlewareContext(
            operation=operation,
            agent_name=agent_name,
            agent_context=agent_context,
            data=data,
            metadata=metadata or {},
        )

        for middleware in self.middlewares:
            try:
                final_context: Optional[MiddlewareContext] = None
                async for item in self._iter_hook_items(
                    middleware.process_request(context)
                ):
                    if isinstance(item, MiddlewareContext):
                        final_context = item
                    elif self._is_event_item(item):
                        yield item
                        if getattr(item, "event_type", None) == "tool_approval":
                            return

                if final_context is None:
                    return

                context = final_context
            except Exception as error:
                recovered = False
                for error_middleware in reversed(self.middlewares):
                    try:
                        async for item in self._iter_hook_items(
                            error_middleware.process_error(context, error)
                        ):
                            if self._is_event_item(item):
                                yield item
                            else:
                                yield item
                                recovered = True
                                return
                    except Exception:
                        continue

                if not recovered:
                    raise

        # Retry config is stamped by RetryMiddleware.process_request when applicable.
        _retry_mw = context.metadata.get("_retry_middleware")
        _attempt = 0
        while True:
            try:
                result = await self._invoke_operation(func, context.data)
                break
            except Exception as error:
                retry_delay: float | None = None
                will_retry = (
                    _retry_mw is not None
                    and _attempt < _retry_mw.max_retries - 1
                    and _check_retryable(_retry_mw, error)
                )
                if will_retry:
                    retry_delay = _retry_mw._compute_delay(_attempt + 1)

                if _retry_mw is not None:
                    self._record_retry_attempt(
                        context=context,
                        attempt_number=_attempt + 1,
                        computed_delay_seconds=retry_delay,
                        will_retry=will_retry,
                        error=error,
                    )

                if will_retry:
                    await asyncio.sleep(retry_delay)
                    _attempt += 1
                    continue

                # Retries exhausted — run inline error handlers (must yield events).
                recovered = False
                for middleware in reversed(self.middlewares):
                    try:
                        async for item in self._iter_hook_items(
                            middleware.process_error(context, error)
                        ):
                            if self._is_event_item(item):
                                yield item
                            elif item is not None:
                                if _retry_mw is not None:
                                    self._finalize_retry_sequence(
                                        context=context,
                                        attempts=_attempt + 1,
                                        final_outcome="recovered",
                                    )
                                yield item
                                recovered = True
                                return
                    except Exception:
                        continue

                if not recovered:
                    if _retry_mw is not None:
                        self._finalize_retry_sequence(
                            context=context,
                            attempts=_attempt + 1,
                            final_outcome="failed",
                        )
                    raise
                return

        if _retry_mw is not None and _attempt > 0:
            self._finalize_retry_sequence(
                context=context,
                attempts=_attempt + 1,
                final_outcome="succeeded",
            )

        for middleware in reversed(self.middlewares):
            try:
                final_result = result
                async for item in self._iter_hook_items(
                    middleware.process_response(context, result)
                ):
                    if self._is_event_item(item):
                        yield item
                    else:
                        final_result = item

                result = final_result
            except Exception as error:
                recovered = False
                for error_middleware in reversed(self.middlewares):
                    try:
                        async for item in self._iter_hook_items(
                            error_middleware.process_error(context, error)
                        ):
                            if self._is_event_item(item):
                                yield item
                            else:
                                yield item
                                recovered = True
                                return
                    except Exception:
                        continue

                if not recovered:
                    raise

        yield result

    async def execute_tool_stream(
        self,
        operation: str,
        agent_name: str,
        agent_context: Optional[AgentContext],
        data: Any,
        func: Callable[..., AsyncGenerator[Any, None]],
        metadata: Optional[Dict[str, Any]] = None,
    ) -> AsyncGenerator[Any, None]:
        """Execute a streaming tool operation through the shared middleware chain.

        Retries are allowed only before any non-final streamed output has been
        emitted. Once user-visible output starts, retry is suppressed to avoid
        duplicating partial tool output.
        """
        context = MiddlewareContext(
            operation=operation,
            agent_name=agent_name,
            agent_context=agent_context,
            data=data,
            metadata=metadata or {},
        )

        for middleware in self.middlewares:
            try:
                final_context: Optional[MiddlewareContext] = None
                async for item in self._iter_hook_items(
                    middleware.process_request(context)
                ):
                    if isinstance(item, MiddlewareContext):
                        final_context = item
                    elif self._is_event_item(item):
                        yield item
                        if getattr(item, "event_type", None) == "tool_approval":
                            return

                if final_context is None:
                    return

                context = final_context
            except Exception as error:
                recovered = False
                for error_middleware in reversed(self.middlewares):
                    try:
                        async for item in self._iter_hook_items(
                            error_middleware.process_error(context, error)
                        ):
                            if self._is_event_item(item):
                                yield item
                            elif item is not None:
                                yield item
                                recovered = True
                                return
                    except Exception:
                        continue

                if not recovered:
                    raise

        _retry_mw = context.metadata.get("_retry_middleware")
        _attempt = 0
        while True:
            emitted_stream_output = False
            try:
                result: ToolResult | None = None
                async for item in func(context.data):
                    if isinstance(item, ToolResult):
                        result = item
                    else:
                        emitted_stream_output = True
                        yield item

                if result is None:
                    raise RuntimeError("Streaming tool returned no final result")
                break
            except Exception as error:
                retry_delay: float | None = None
                will_retry = (
                    _retry_mw is not None
                    and not emitted_stream_output
                    and _attempt < _retry_mw.max_retries - 1
                    and _check_retryable(_retry_mw, error)
                )
                if will_retry:
                    retry_delay = _retry_mw._compute_delay(_attempt + 1)

                if _retry_mw is not None:
                    self._record_retry_attempt(
                        context=context,
                        attempt_number=_attempt + 1,
                        computed_delay_seconds=retry_delay,
                        will_retry=will_retry,
                        error=error,
                    )

                if will_retry:
                    await asyncio.sleep(retry_delay)
                    _attempt += 1
                    continue

                recovered = False
                for middleware in reversed(self.middlewares):
                    try:
                        async for item in self._iter_hook_items(
                            middleware.process_error(context, error)
                        ):
                            if self._is_event_item(item):
                                yield item
                            elif item is not None:
                                if _retry_mw is not None:
                                    self._finalize_retry_sequence(
                                        context=context,
                                        attempts=_attempt + 1,
                                        final_outcome="recovered",
                                    )
                                yield item
                                recovered = True
                                return
                    except Exception:
                        continue

                if not recovered:
                    if _retry_mw is not None:
                        self._finalize_retry_sequence(
                            context=context,
                            attempts=_attempt + 1,
                            final_outcome="failed",
                        )
                    raise
                return

        if _retry_mw is not None and _attempt > 0:
            self._finalize_retry_sequence(
                context=context,
                attempts=_attempt + 1,
                final_outcome="succeeded",
            )

        for middleware in reversed(self.middlewares):
            try:
                final_result = result
                async for item in self._iter_hook_items(
                    middleware.process_response(context, result)
                ):
                    if self._is_event_item(item):
                        yield item
                    else:
                        final_result = item

                result = final_result
            except Exception as error:
                recovered = False
                for error_middleware in reversed(self.middlewares):
                    try:
                        async for item in self._iter_hook_items(
                            error_middleware.process_error(context, error)
                        ):
                            if self._is_event_item(item):
                                yield item
                            elif item is not None:
                                yield item
                                recovered = True
                                return
                    except Exception:
                        continue

                if not recovered:
                    raise

        yield result


class LoggingMiddleware(BaseMiddleware):
    """Middleware for operation logging and timing."""

    def __init__(self, logger: Optional[logging.Logger] = None):
        self.logger = logger or logging.getLogger("agentbyte.middleware")

    async def process_request(self, context: MiddlewareContext) -> MiddlewareContext:
        context.metadata["_start_time"] = time.perf_counter()
        self.logger.info(
            "[%s] request operation=%s",
            context.agent_name,
            context.operation,
        )
        return context

    async def process_response(self, context: MiddlewareContext, result: Any) -> Any:
        start_time = context.metadata.get("_start_time")
        elapsed_ms = 0.0
        if isinstance(start_time, (int, float)):
            elapsed_ms = (time.perf_counter() - start_time) * 1000

        self.logger.info(
            "[%s] response operation=%s elapsed_ms=%.2f",
            context.agent_name,
            context.operation,
            elapsed_ms,
        )
        return result

    async def process_error(
        self,
        context: MiddlewareContext,
        error: Exception,
    ) -> Optional[Any]:
        self.logger.exception(
            "[%s] error operation=%s: %s",
            context.agent_name,
            context.operation,
            error,
        )
        return None


class PIIRedactionMiddleware(BaseMiddleware):
    """Redact common PII patterns from middleware request/response payloads."""

    def __init__(self, patterns: Optional[Dict[str, str]] = None):
        self.patterns = patterns or {
            r"\b\d{3}-\d{2}-\d{4}\b": "[SSN-REDACTED]",
            r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b": "[EMAIL-REDACTED]",
            r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b": "[PHONE-REDACTED]",
            r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b": "[CC-REDACTED]",
        }

    def _redact_text(self, text: str) -> str:
        redacted = text
        for pattern, replacement in self.patterns.items():
            redacted = re.sub(pattern, replacement, redacted)
        return redacted

    async def process_request(self, context: MiddlewareContext) -> MiddlewareContext:
        if context.operation == "model_call" and isinstance(context.data, dict):
            payload = dict(context.data)
            messages = payload.get("messages")
            if isinstance(messages, list):
                redacted_messages = []
                for message in messages:
                    if hasattr(message, "content") and isinstance(message.content, str):
                        redacted_content = self._redact_text(message.content)
                        if redacted_content != message.content and hasattr(
                            message, "model_copy"
                        ):
                            message = message.model_copy(
                                update={"content": redacted_content}
                            )
                    redacted_messages.append(message)
                payload["messages"] = redacted_messages
                context.data = payload

        if context.operation == "embedding_call" and isinstance(context.data, dict):
            payload = dict(context.data)
            inputs = payload.get("input")
            if isinstance(inputs, list):
                payload["input"] = [
                    self._redact_text(value) if isinstance(value, str) else value
                    for value in inputs
                ]
                context.data = payload

        if context.operation == "tool_call" and isinstance(context.data, dict):
            payload = dict(context.data)
            parameters = payload.get("parameters")
            if isinstance(parameters, dict):
                redacted_parameters: Dict[str, Any] = {}
                for key, value in parameters.items():
                    if isinstance(value, str):
                        redacted_parameters[key] = self._redact_text(value)
                    else:
                        redacted_parameters[key] = value
                payload["parameters"] = redacted_parameters
                context.data = payload

        return context

    async def process_response(self, context: MiddlewareContext, result: Any) -> Any:
        if context.operation == "model_call" and hasattr(result, "message"):
            message = getattr(result, "message")
            if hasattr(message, "content") and isinstance(message.content, str):
                redacted_content = self._redact_text(message.content)
                if redacted_content != message.content and hasattr(
                    message, "model_copy"
                ):
                    redacted_message = message.model_copy(
                        update={"content": redacted_content}
                    )
                    if hasattr(result, "model_copy"):
                        return result.model_copy(update={"message": redacted_message})

        if context.operation == "tool_call" and hasattr(result, "result"):
            output = getattr(result, "result")
            if isinstance(output, str):
                redacted_output = self._redact_text(output)
                if redacted_output != output and hasattr(result, "model_copy"):
                    return result.model_copy(update={"result": redacted_output})

        return result

    async def process_error(
        self,
        context: MiddlewareContext,
        error: Exception,
    ) -> Optional[Any]:
        return None


class GuardrailMiddleware(BaseMiddleware):
    """Policy guardrails for tool and model inputs."""

    def __init__(
        self,
        blocked_tools: Optional[List[str]] = None,
        blocked_patterns: Optional[List[str]] = None,
    ):
        self.blocked_tools = set(blocked_tools or [])
        self.blocked_patterns = [
            re.compile(pattern, flags=re.IGNORECASE)
            for pattern in (blocked_patterns or [])
        ]

    async def process_request(self, context: MiddlewareContext) -> MiddlewareContext:
        if context.operation == "tool_call" and isinstance(context.data, dict):
            tool_name = context.data.get("tool_name")
            if isinstance(tool_name, str) and tool_name in self.blocked_tools:
                raise ValueError(f"Tool '{tool_name}' is blocked by guardrails")

            parameters = context.data.get("parameters", {})
            parameters_text = json.dumps(parameters, default=str)
            for pattern in self.blocked_patterns:
                if pattern.search(parameters_text):
                    raise ValueError(
                        f"Tool parameters match blocked pattern: {pattern.pattern}",
                    )

        if context.operation == "model_call" and isinstance(context.data, dict):
            messages = context.data.get("messages", [])
            if isinstance(messages, list):
                for message in messages:
                    content = getattr(message, "content", None)
                    if not isinstance(content, str):
                        continue
                    for pattern in self.blocked_patterns:
                        if pattern.search(content):
                            raise ValueError(
                                f"Message contains blocked pattern: {pattern.pattern}",
                            )

        return context

    async def process_response(self, context: MiddlewareContext, result: Any) -> Any:
        return result

    async def process_error(
        self,
        context: MiddlewareContext,
        error: Exception,
    ) -> Optional[Any]:
        return None


class MetricsMiddleware(BaseMiddleware):
    """Collect operation counters and durations."""

    def __init__(self):
        self.metrics: Dict[str, Any] = {
            "total_operations": 0,
            "operations_by_type": {},
            "errors_by_type": {},
            "total_duration": 0.0,
            "operation_durations": [],
        }

    async def process_request(self, context: MiddlewareContext) -> MiddlewareContext:
        self.metrics["total_operations"] += 1
        operations_by_type = self.metrics["operations_by_type"]
        operations_by_type[context.operation] = (
            operations_by_type.get(context.operation, 0) + 1
        )
        context.metadata["_metrics_start_time"] = time.perf_counter()
        return context

    async def process_response(self, context: MiddlewareContext, result: Any) -> Any:
        start_time = context.metadata.get("_metrics_start_time")
        if isinstance(start_time, (int, float)):
            duration = time.perf_counter() - start_time
            self.metrics["total_duration"] += duration
            durations = self.metrics["operation_durations"]
            durations.append((context.operation, duration))
            if len(durations) > 100:
                del durations[:-100]
        return result

    async def process_error(
        self,
        context: MiddlewareContext,
        error: Exception,
    ) -> Optional[Any]:
        error_type = type(error).__name__
        errors_by_type = self.metrics["errors_by_type"]
        errors_by_type[error_type] = errors_by_type.get(error_type, 0) + 1
        return None

    def get_metrics(self) -> Dict[str, Any]:
        total_operations = self.metrics["total_operations"]
        average_duration = (
            self.metrics["total_duration"] / total_operations
            if total_operations > 0
            else 0.0
        )
        return {
            **self.metrics,
            "average_duration": average_duration,
        }


class RateLimitMiddleware(BaseMiddleware):
    """In-memory fixed-window rate limiting middleware with wait/throttle semantics."""

    def __init__(self, max_requests: int, window_seconds: float):
        if max_requests <= 0:
            raise ValueError("max_requests must be greater than zero")
        if window_seconds <= 0:
            raise ValueError("window_seconds must be greater than zero")

        self.max_requests = max_requests
        self.window_seconds = window_seconds
        self._timestamps: deque[float] = deque()

    async def process_request(self, context: MiddlewareContext) -> MiddlewareContext:
        now = time.monotonic()

        while self._timestamps and now - self._timestamps[0] >= self.window_seconds:
            self._timestamps.popleft()

        if len(self._timestamps) >= self.max_requests:
            wait_time = self.window_seconds - (now - self._timestamps[0])
            if wait_time > 0:
                await asyncio.sleep(wait_time)
            now = time.monotonic()
            while self._timestamps and now - self._timestamps[0] >= self.window_seconds:
                self._timestamps.popleft()

        self._timestamps.append(now)
        return context

    async def process_response(self, context: MiddlewareContext, result: Any) -> Any:
        return result

    async def process_error(
        self,
        context: MiddlewareContext,
        error: Exception,
    ) -> Optional[Any]:
        return None


class ContextCompactionMiddleware(BaseMiddleware):
    """Trim accumulated model-call context to stay within token budget.

    When the estimated token count of the message list exceeds ``max_tokens``,
    the middleware keeps the system message (index 0), inserts a summary
    placeholder, and retains the most recent ``keep_last`` messages.  This
    mirrors the compaction strategy described in Chapter 4.12.3 and integrates
    cleanly into the middleware chain without touching agent logic.

    Token estimation uses a simple character-count ÷ 4 heuristic so that no
    heavy tokenisation dependency is required.  Swap ``_estimate_tokens`` with
    a tiktoken-backed implementation for production deployments.
    """

    def __init__(self, max_tokens: int = 32_000, keep_last: int = 10):
        if max_tokens <= 0:
            raise ValueError("max_tokens must be greater than zero")
        if keep_last <= 0:
            raise ValueError("keep_last must be greater than zero")
        self.max_tokens = max_tokens
        self.keep_last = keep_last

    @staticmethod
    def _estimate_tokens(messages: list) -> int:
        """Rough token estimate: total characters divided by 4."""
        total_chars = 0
        for msg in messages:
            content = getattr(msg, "content", None)
            if isinstance(content, str):
                total_chars += len(content)
            elif isinstance(content, list):  # multimodal parts
                for part in content:
                    if isinstance(part, dict):
                        total_chars += len(str(part.get("text", "")))
        return total_chars // 4

    def _compact(self, messages: list) -> list:
        """Return a compacted copy of *messages*.

        Structure: [system_message, summary_placeholder, *messages[-keep_last:]]
        Falls back gracefully when there is no system message.
        """
        if len(messages) <= 1:
            return messages

        from agentbyte.messages import SystemMessage

        summary = SystemMessage(
            content="[Previous context summarized to fit context window]",
            source="system",
        )
        tail = messages[-self.keep_last :]

        # Avoid double-including the system message in the tail.
        if messages[0] is not tail[0]:
            return [messages[0], summary, *tail]
        return [summary, *tail]

    async def process_request(self, context: MiddlewareContext) -> MiddlewareContext:
        if context.operation != OperationType.MODEL_CALL:
            return context

        payload = context.data
        if not isinstance(payload, dict):
            return context

        messages = payload.get("messages", [])
        if not isinstance(messages, list) or len(messages) <= 1:
            return context

        if self._estimate_tokens(messages) <= self.max_tokens:
            return context

        compacted = self._compact(messages)
        context.data = {**payload, "messages": compacted}
        context.metadata["compaction_applied"] = True
        context.metadata["original_message_count"] = len(messages)
        context.metadata["compacted_message_count"] = len(compacted)
        return context

    async def process_response(self, context: MiddlewareContext, result: Any) -> Any:
        return result

    async def process_error(
        self,
        context: MiddlewareContext,
        error: Exception,
    ) -> Optional[Any]:
        return None


class ApprovalMiddleware(BaseMiddleware):
    """Middleware that emits approval events for configured tool names."""

    def __init__(self, tool_names: List[str]):
        self._tool_names = set(tool_names)

    async def process_request(
        self,
        context: MiddlewareContext,
    ) -> AsyncGenerator[Any, None]:
        if context.operation != "tool_call":
            yield context
            return

        payload = context.data if isinstance(context.data, dict) else {}
        tool_name = payload.get("tool_name")

        if not isinstance(tool_name, str) or tool_name not in self._tool_names:
            yield context
            return

        call_id = payload.get("call_id")
        if not isinstance(call_id, str) or not call_id:
            call_id = f"mw_{tool_name}_approval"

        parameters = payload.get("parameters")
        if not isinstance(parameters, dict):
            parameters = {}

        request = ToolApprovalRequest(
            request_id=f"approval_{call_id}",
            tool_call_id=call_id,
            tool_name=tool_name,
            parameters=parameters,
            original_tool_call=ToolCallRequest(
                tool_name=tool_name,
                parameters=parameters,
                call_id=call_id,
            ),
        )

        from agentbyte.agents.types import ToolApprovalEvent

        yield ToolApprovalEvent(source=context.agent_name, approval_request=request)

    async def process_response(self, context: MiddlewareContext, result: Any) -> Any:
        return result

    async def process_error(
        self,
        context: MiddlewareContext,
        error: Exception,
    ) -> Optional[Any]:
        return None
