"""
Universal LLM Middleware - Works with ANY LLM provider
This is the CORE innovation - intercept ALL LLM calls regardless of framework

Supports:
- OpenAI (GPT-4, GPT-3.5, etc.)
- Anthropic (Claude)
- Google (Gemini)
- Local models (Ollama, LMStudio)
- LangChain chains
- CrewAI crews
- AutoGen agents
- Vercel AI SDK
"""

import functools
import json
import random
import re
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple


class LLMProvider(Enum):
    OPENAI = "openai"
    OPENROUTER = "openrouter"
    ANTHROPIC = "anthropic"
    GOOGLE = "google"
    OLLAMA = "ollama"
    LANGCHAIN = "langchain"
    CREWAI = "crewai"
    AUTOGEN = "autogen"
    VERCEL_AI = "vercel_ai"
    CUSTOM = "custom"


@dataclass
class InterceptionResult:
    """Result of an LLM call interception"""

    original_request: Dict[str, Any]
    modified_request: Dict[str, Any]
    response: Any
    entropy_before: float
    entropy_after: float
    interventions_applied: List[str]
    latency_ms: float

    # Optional telemetry fields (kept optional for backward compatibility).
    response_text: str = ""
    request_model: str = ""
    response_model: str = ""
    finish_reason: str = ""
    usage: Dict[str, int] = None
    cost_usd_est: float = 0.0
    is_stream: bool = False
    is_refusal: bool = False
    tool_calls_count: int = 0
    retry_count: int = 0
    tokens_saved: int = 0
    cost_saved: float = 0.0
    groundedness_summary: Optional[Dict[str, Any]] = None


@dataclass
class MiddlewareConfig:
    """Configuration for the middleware"""

    # Intervention triggers
    high_entropy_threshold: float = 0.7
    low_entropy_threshold: float = 0.2
    hallucination_threshold: float = 0.6

    # Feature toggles
    enable_prompt_injection: bool = True
    enable_temperature_control: bool = True
    enable_context_pruning: bool = True
    enable_hallucination_detection: bool = True
    enable_cost_optimization: bool = True
    enable_retry_logic: bool = True

    # Cost optimization
    max_context_tokens: int = 4000
    target_cost_reduction: float = 0.3

    # Retry settings
    max_retries: int = 3
    retry_temperature_decay: float = 0.8

    # Transport retries (rate limits / transient errors)
    enable_transport_retries: bool = True
    transport_max_retries: int = 4
    transport_backoff_base_s: float = 0.25
    transport_backoff_max_s: float = 8.0
    transport_jitter_s: float = 0.15

    # Optional cost estimation (best-effort; provider may not expose pricing)
    enable_cost_estimation: bool = True

    # Evidence audit (claim-level groundedness) for "closed-world" workflows.
    # Default off to avoid logging/processing content unexpectedly.
    enable_evidence_audit: bool = False
    evidence_audit_max_items: int = 64
    evidence_audit_include_assessments: bool = False

    # Runtime observability (structured events). If set, it must expose .emit(event).
    # Note: this is distinct from `entropic_core.telemetry` (opt-in anonymous usage telemetry).
    event_sink: Any = None
    run_id: str = ""


class LLMMiddleware:
    """
    Universal middleware that intercepts ALL LLM calls

    Usage:
        middleware = LLMMiddleware()

        # OpenAI
        openai.chat.completions.create = middleware.wrap(openai.chat.completions.create)

        # Anthropic
        anthropic.messages.create = middleware.wrap(anthropic.messages.create)

        # LangChain
        chain.invoke = middleware.wrap(chain.invoke)
    """

    def __init__(self, config: MiddlewareConfig = None, brain=None):
        self.config = config or MiddlewareConfig()
        self.brain = brain
        self._event_sink = getattr(self.config, "event_sink", None)
        self._run_id = str(getattr(self.config, "run_id", "") or "")
        self.current_entropy = 0.5
        self.call_history: List[InterceptionResult] = []
        self.total_tokens_saved = 0
        self.total_cost_saved = 0.0
        self._call_count = 0

        # Provider-specific adapters
        self._adapters: Dict[LLMProvider, "ProviderAdapter"] = {}
        self._register_default_adapters()

        # Internal hook for tests to disable sleeping on retries.
        self._sleep = time.sleep

    def _register_default_adapters(self):
        """Register adapters for common providers"""
        self._adapters[LLMProvider.OPENAI] = OpenAIAdapter()
        self._adapters[LLMProvider.OPENROUTER] = OpenAIAdapter()  # OpenAI-compatible surface
        self._adapters[LLMProvider.ANTHROPIC] = AnthropicAdapter()
        self._adapters[LLMProvider.LANGCHAIN] = LangChainAdapter()
        self._adapters[LLMProvider.VERCEL_AI] = VercelAIAdapter()

    def wrap(self, func: Callable, provider: LLMProvider = None) -> Callable:
        """
        Wrap any LLM function with entropic middleware
        Auto-detects provider if not specified
        """
        detected_provider = provider or self._detect_provider(func)
        adapter = self._adapters.get(detected_provider, GenericAdapter())

        @functools.wraps(func)
        def wrapped(*args, **kwargs):
            return self._intercept_call(func, adapter, args, kwargs, detected_provider)

        return wrapped

    def _intercept_call(
        self,
        func: Callable,
        adapter: "ProviderAdapter",
        args: tuple,
        kwargs: dict,
        provider: LLMProvider,
    ) -> Any:
        """Core interception logic"""
        self._call_count += 1
        start_time = time.time()
        request_id = f"req_{self._call_count}_{int(start_time * 1000)}"

        # Extract request info
        original_request = adapter.extract_request(args, kwargs)
        entropy_before = self.current_entropy

        # Observability: emit a structured request event (best-effort).
        try:
            from .runtime_events import RuntimeEvent, safe_emit

            case_id = ""
            try:
                msgs = adapter.extract_messages(original_request) or []
                for m in msgs:
                    if m.get("role") != "system":
                        continue
                    for ln in str(m.get("content") or "").splitlines():
                        if ln.strip().startswith("EVAL_CASE_ID:"):
                            case_id = ln.split(":", 1)[1].strip()
                            break
                    if case_id:
                        break
            except Exception:
                case_id = ""

            safe_emit(
                self._event_sink,
                RuntimeEvent(
                    type="request",
                    run_id=self._run_id,
                    request_id=request_id,
                    provider=str(getattr(provider, "value", provider) or ""),
                    model=str(original_request.get("model") or ""),
                    payload={
                        "messages_count": len(adapter.extract_messages(original_request) or []),
                        "entropy_before": float(entropy_before)
                        if entropy_before is not None
                        else None,
                        "case_id": case_id,
                    },
                ),
            )
        except Exception:
            pass

        # PHASE 1: Pre-call modifications
        modified_request = self._apply_pre_call_interventions(original_request, adapter)

        # Observability: interventions applied (best-effort).
        try:
            from .runtime_events import RuntimeEvent, safe_emit

            interventions = list(modified_request.get("_interventions", []) or [])
            safe_emit(
                self._event_sink,
                RuntimeEvent(
                    type="intervention" if interventions else "intervention",
                    run_id=self._run_id,
                    request_id=request_id,
                    provider=str(getattr(provider, "value", provider) or ""),
                    model=str(modified_request.get("model") or original_request.get("model") or ""),
                    payload={
                        "interventions": interventions,
                        "tokens_saved": int(modified_request.get("_tokens_saved", 0) or 0),
                    },
                ),
            )
        except Exception:
            pass

        # PHASE 2: Execute the actual call (with transport retries)
        modified_args, modified_kwargs = adapter.rebuild_request(args, kwargs, modified_request)

        # Streaming calls return an iterator/generator. We must preserve streaming behavior.
        is_stream = bool(modified_kwargs.get("stream"))
        if is_stream:
            stream = self._call_with_transport_retries(func, modified_args, modified_kwargs)
            return self._wrap_stream_iterator(
                stream=stream,
                adapter=adapter,
                original_request=original_request,
                modified_request=modified_request,
                entropy_before=entropy_before,
                start_time=start_time,
                provider=provider,
                request_id=request_id,
            )

        try:
            response = self._call_with_transport_retries(func, modified_args, modified_kwargs)
        except BaseException as e:  # noqa: BLE001 - boundary for provider errors
            try:
                from .runtime_events import RuntimeEvent, safe_emit

                safe_emit(
                    self._event_sink,
                    RuntimeEvent(
                        type="error",
                        run_id=self._run_id,
                        request_id=request_id,
                        provider=str(getattr(provider, "value", provider) or ""),
                        model=str(
                            modified_request.get("model") or original_request.get("model") or ""
                        ),
                        payload={"error_type": type(e).__name__, "error": str(e)},
                    ),
                )
            except Exception:
                pass
            raise

        # PHASE 3: Post-call analysis and potential retry
        response, retry_count = self._apply_post_call_interventions(
            func, adapter, modified_args, modified_kwargs, response
        )

        # Update entropy based on response
        response_text = adapter.extract_response_text(response)
        response_entropy = self._analyze_response_entropy(response, adapter)
        self.current_entropy = response_entropy

        request_model = str(modified_request.get("model") or original_request.get("model") or "")
        response_model = self._extract_response_model(response) or request_model
        finish_reason = self._extract_finish_reason(response)
        usage = self._extract_usage(response)
        tool_calls_count = self._extract_tool_calls_count(response)
        is_refusal = self._is_refusal_response(response, response_text, finish_reason)
        cost_usd_est = (
            self._estimate_cost_usd(request_model, usage)
            if self.config.enable_cost_estimation
            else 0.0
        )

        groundedness_summary = None
        if bool(getattr(self.config, "enable_evidence_audit", False)):
            try:
                groundedness_summary = self._compute_groundedness_summary(
                    request=modified_request, adapter=adapter, response_text=response_text
                )
            except Exception:
                groundedness_summary = None

        # Record result
        latency_ms = (time.time() - start_time) * 1000
        result = InterceptionResult(
            original_request=original_request,
            modified_request=modified_request,
            response=response,
            response_text=response_text,
            entropy_before=entropy_before,
            entropy_after=response_entropy,
            interventions_applied=modified_request.get("_interventions", []),
            latency_ms=latency_ms,
            request_model=request_model,
            response_model=str(response_model or ""),
            finish_reason=str(finish_reason or ""),
            usage=usage,
            cost_usd_est=float(cost_usd_est or 0.0),
            is_stream=False,
            is_refusal=bool(is_refusal),
            tool_calls_count=int(tool_calls_count or 0),
            retry_count=int(retry_count or 0),
            tokens_saved=modified_request.get("_tokens_saved", 0),
            cost_saved=modified_request.get("_cost_saved", 0.0),
            groundedness_summary=groundedness_summary,
        )
        self.call_history.append(result)

        # Observability: emit a structured response event (best-effort).
        try:
            from .runtime_events import RuntimeEvent, safe_emit

            safe_emit(
                self._event_sink,
                RuntimeEvent(
                    type="response",
                    run_id=self._run_id,
                    request_id=request_id,
                    provider=str(getattr(provider, "value", provider) or ""),
                    model=str(request_model or ""),
                    payload={
                        "response_model": str(response_model or ""),
                        "finish_reason": str(finish_reason or ""),
                        "usage": usage,
                        "cost_usd_est": float(cost_usd_est or 0.0),
                        "latency_ms": float(latency_ms),
                        "entropy_before": float(entropy_before)
                        if entropy_before is not None
                        else None,
                        "entropy_after": float(response_entropy)
                        if response_entropy is not None
                        else None,
                        "interventions": list(modified_request.get("_interventions", []) or []),
                        "is_refusal": bool(is_refusal),
                        "tool_calls_count": int(tool_calls_count or 0),
                        "is_stream": False,
                        "retry_count": int(retry_count or 0),
                        "groundedness_summary": groundedness_summary,
                    },
                ),
            )
        except Exception:
            pass

        # Observability: tool-call signal (best-effort).
        try:
            from .runtime_events import RuntimeEvent, safe_emit

            if int(tool_calls_count or 0) > 0:
                safe_emit(
                    self._event_sink,
                    RuntimeEvent(
                        type="tool",
                        run_id=self._run_id,
                        request_id=request_id,
                        provider=str(getattr(provider, "value", provider) or ""),
                        model=str(request_model or ""),
                        payload={
                            "tool_calls_count": int(tool_calls_count or 0),
                            "tool_names": self._extract_tool_call_names(response),
                        },
                    ),
                )
        except Exception:
            pass

        return response

    def _call_with_transport_retries(self, func: Callable, args: tuple, kwargs: dict) -> Any:
        """
        Provider/network layer retries (rate limits, timeouts, transient 5xx).

        This is separate from "quality retries" (which are post-call and adjust temperature).
        """
        if not self.config.enable_transport_retries:
            return func(*args, **kwargs)

        attempt = 0
        last_exc: Optional[BaseException] = None

        while attempt <= self.config.transport_max_retries:
            try:
                return func(*args, **kwargs)
            except BaseException as e:  # noqa: BLE001 - this wrapper is the boundary for provider errors
                last_exc = e
                attempt += 1
                if attempt > self.config.transport_max_retries or not self._is_retryable_exception(
                    e
                ):
                    raise

                # Respect server guidance when present.
                retry_after = self._get_retry_after_s(e)
                sleep_s = (
                    retry_after if retry_after is not None else self._transport_backoff_s(attempt)
                )
                if sleep_s > 0:
                    self._sleep(sleep_s)

        # Should be unreachable.
        if last_exc is not None:
            raise last_exc
        return func(*args, **kwargs)

    def _transport_backoff_s(self, attempt: int) -> float:
        base = float(self.config.transport_backoff_base_s or 0.0)
        if base <= 0:
            return 0.0
        max_s = float(self.config.transport_backoff_max_s or base)
        jitter = float(self.config.transport_jitter_s or 0.0)
        exp = min(max_s, base * (2 ** max(0, attempt - 1)))
        return max(0.0, exp + random.uniform(0.0, jitter))

    def _get_retry_after_s(self, exc: BaseException) -> Optional[float]:
        """
        Best-effort extraction of Retry-After header or similar hints.
        """
        # OpenAI APIStatusError has `.response.headers` sometimes.
        resp = getattr(exc, "response", None)
        headers = getattr(resp, "headers", None) if resp is not None else None
        if headers is None:
            headers = getattr(exc, "headers", None)
        if headers:
            ra = headers.get("retry-after") or headers.get("Retry-After")
            if ra:
                try:
                    return float(ra)
                except Exception:
                    return None
        return None

    def _is_retryable_exception(self, exc: BaseException) -> bool:
        # 1) OpenAI python exceptions (optional dependency): classify by type when available.
        try:
            import openai  # type: ignore

            retry_types = tuple(
                t
                for t in (
                    getattr(openai, "RateLimitError", None),
                    getattr(openai, "APITimeoutError", None),
                    getattr(openai, "APIConnectionError", None),
                    getattr(openai, "InternalServerError", None),
                    getattr(openai, "APIStatusError", None),
                )
                if t is not None
            )
            if retry_types and isinstance(exc, retry_types):
                # For APIStatusError check status code.
                sc = getattr(exc, "status_code", None)
                if sc is None:
                    sc = getattr(getattr(exc, "response", None), "status_code", None)
                if sc is None:
                    return True
                try:
                    sc_i = int(sc)
                except Exception:
                    return True
                return sc_i in (408, 409, 429, 500, 502, 503, 504)
        except Exception:
            pass

        # 2) Generic heuristic: status_code attribute and common transient codes.
        sc = getattr(exc, "status_code", None)
        if sc is not None:
            try:
                return int(sc) in (408, 409, 429, 500, 502, 503, 504)
            except Exception:
                return True

        msg = str(exc).lower()
        transient_markers = (
            "rate limit",
            "too many requests",
            "timeout",
            "timed out",
            "temporarily unavailable",
            "connection reset",
            "connection aborted",
            "server error",
            "overloaded",
        )
        return any(m in msg for m in transient_markers)

    def _wrap_stream_iterator(
        self,
        *,
        stream: Iterable[Any],
        adapter: "ProviderAdapter",
        original_request: Dict[str, Any],
        modified_request: Dict[str, Any],
        entropy_before: float,
        start_time: float,
        provider: LLMProvider,
        request_id: str,
    ) -> Iterable[Any]:
        """
        Wrap a streaming iterator to preserve streaming to the caller while collecting evidence.

        Notes:
        - We record the InterceptionResult when the stream completes (or is closed early).
        - We do not run post-call "quality retries" for streaming.
        """
        middleware = self
        interventions = list(modified_request.get("_interventions", []))
        request_model = str(modified_request.get("model") or original_request.get("model") or "")

        aggregated: List[str] = []
        error: Optional[BaseException] = None
        tool_calls_count = 0

        def _chunk_text(chunk: Any) -> str:
            nonlocal tool_calls_count
            # OpenAI-style streaming: chunk.choices[0].delta.content
            try:
                if hasattr(chunk, "choices") and chunk.choices:
                    d = getattr(chunk.choices[0], "delta", None)
                    if d is not None and hasattr(d, "content") and d.content:
                        return str(d.content)
                    # Tool-call deltas can exist; keep them as text hints.
                    if d is not None and hasattr(d, "tool_calls") and d.tool_calls:
                        try:
                            tool_calls_count += len(d.tool_calls)
                        except Exception:
                            tool_calls_count += 1
                        return json.dumps({"tool_calls": d.tool_calls}, default=str)
            except Exception:
                pass
            # Dict fallback
            try:
                if isinstance(chunk, dict):
                    delta = chunk.get("choices", [{}])[0].get("delta", {})
                    if isinstance(delta, dict) and delta.get("content"):
                        return str(delta["content"])
                    if isinstance(delta, dict) and delta.get("tool_calls"):
                        try:
                            tool_calls_count += len(delta.get("tool_calls") or [])
                        except Exception:
                            tool_calls_count += 1
                        return json.dumps({"tool_calls": delta.get("tool_calls")}, default=str)
            except Exception:
                pass
            return ""

        def gen():
            nonlocal error
            try:
                for chunk in stream:
                    t = _chunk_text(chunk)
                    if t:
                        aggregated.append(t)
                    yield chunk
            except BaseException as e:  # noqa: BLE001
                error = e
                raise
            finally:
                response_text = "".join(aggregated)
                response_entropy = middleware._analyze_response_entropy(
                    response_text, adapter=GenericAdapter()
                )
                middleware.current_entropy = response_entropy

                latency_ms = (time.time() - start_time) * 1000.0
                is_refusal = middleware._is_refusal_response(None, response_text, finish_reason="")
                groundedness_summary = None
                if bool(getattr(middleware.config, "enable_evidence_audit", False)):
                    try:
                        groundedness_summary = middleware._compute_groundedness_summary(
                            request=modified_request,
                            adapter=adapter,
                            response_text=response_text,
                        )
                    except Exception:
                        groundedness_summary = None

                result = InterceptionResult(
                    original_request=original_request,
                    modified_request=modified_request,
                    response=None,
                    response_text=response_text,
                    entropy_before=entropy_before,
                    entropy_after=response_entropy,
                    interventions_applied=interventions,
                    latency_ms=latency_ms,
                    request_model=request_model,
                    response_model=request_model,
                    finish_reason="",
                    usage={"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
                    cost_usd_est=0.0,
                    is_stream=True,
                    is_refusal=bool(is_refusal),
                    tool_calls_count=int(tool_calls_count or 0),
                    retry_count=0,
                    groundedness_summary=groundedness_summary,
                )
                middleware.call_history.append(result)

                try:
                    from .runtime_events import RuntimeEvent, safe_emit

                    safe_emit(
                        middleware._event_sink,
                        RuntimeEvent(
                            type="response",
                            run_id=middleware._run_id,
                            request_id=request_id,
                            provider=str(getattr(provider, "value", provider) or ""),
                            model=str(request_model or ""),
                            payload={
                                "response_model": str(request_model or ""),
                                "finish_reason": "",
                                "usage": {
                                    "prompt_tokens": 0,
                                    "completion_tokens": 0,
                                    "total_tokens": 0,
                                },
                                "cost_usd_est": 0.0,
                                "latency_ms": float(latency_ms),
                                "entropy_before": float(entropy_before)
                                if entropy_before is not None
                                else None,
                                "entropy_after": float(response_entropy)
                                if response_entropy is not None
                                else None,
                                "interventions": interventions,
                                "is_refusal": bool(is_refusal),
                                "tool_calls_count": int(tool_calls_count or 0),
                                "is_stream": True,
                                "groundedness_summary": groundedness_summary,
                                "stream_error_type": type(error).__name__
                                if error is not None
                                else None,
                            },
                        ),
                    )
                except Exception:
                    pass

                try:
                    from .runtime_events import RuntimeEvent, safe_emit

                    if int(tool_calls_count or 0) > 0:
                        safe_emit(
                            middleware._event_sink,
                            RuntimeEvent(
                                type="tool",
                                run_id=middleware._run_id,
                                request_id=request_id,
                                provider=str(getattr(provider, "value", provider) or ""),
                                model=str(request_model or ""),
                                payload={
                                    "tool_calls_count": int(tool_calls_count or 0),
                                    "tool_names": [],
                                    "is_stream": True,
                                },
                            ),
                        )
                except Exception:
                    pass

        return gen()

    def _extract_usage(self, response: Any) -> Dict[str, int]:
        usage = getattr(response, "usage", None) if response is not None else None
        if usage is None and isinstance(response, dict):
            usage = response.get("usage")
        if usage is None:
            return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}

        def _get(k: str) -> int:
            if isinstance(usage, dict):
                return int(usage.get(k, 0) or 0)
            return int(getattr(usage, k, 0) or 0)

        pt = _get("prompt_tokens")
        ct = _get("completion_tokens")
        tt = _get("total_tokens") or (pt + ct)
        return {"prompt_tokens": pt, "completion_tokens": ct, "total_tokens": tt}

    def _extract_response_model(self, response: Any) -> str:
        if response is None:
            return ""
        m = getattr(response, "model", None)
        if m:
            return str(m)
        if isinstance(response, dict) and response.get("model"):
            return str(response["model"])
        return ""

    def _extract_finish_reason(self, response: Any) -> str:
        try:
            if hasattr(response, "choices") and response.choices:
                fr = getattr(response.choices[0], "finish_reason", None)
                return str(fr or "")
        except Exception:
            pass
        if isinstance(response, dict):
            try:
                return str(response.get("choices", [{}])[0].get("finish_reason", "") or "")
            except Exception:
                return ""
        return ""

    def _extract_tool_calls_count(self, response: Any) -> int:
        try:
            if hasattr(response, "choices") and response.choices:
                msg = getattr(response.choices[0], "message", None)
                tc = getattr(msg, "tool_calls", None) if msg is not None else None
                if tc:
                    return len(tc)
        except Exception:
            pass
        if isinstance(response, dict):
            try:
                msg = response.get("choices", [{}])[0].get("message", {})
                tc = msg.get("tool_calls")
                if isinstance(tc, list):
                    return len(tc)
            except Exception:
                return 0
        return 0

    def _extract_tool_call_names(self, response: Any) -> List[str]:
        names: List[str] = []
        try:
            if hasattr(response, "choices") and response.choices:
                msg = getattr(response.choices[0], "message", None)
                tc = getattr(msg, "tool_calls", None) if msg is not None else None
                if isinstance(tc, list):
                    for c in tc:
                        fn = getattr(c, "function", None)
                        nm = getattr(fn, "name", None) if fn is not None else None
                        if nm:
                            names.append(str(nm))
        except Exception:
            pass
        if isinstance(response, dict):
            try:
                msg = response.get("choices", [{}])[0].get("message", {})
                tc = msg.get("tool_calls")
                if isinstance(tc, list):
                    for c in tc:
                        fn = c.get("function") if isinstance(c, dict) else None
                        nm = fn.get("name") if isinstance(fn, dict) else None
                        if nm:
                            names.append(str(nm))
            except Exception:
                return []
        # stable unique order
        out: List[str] = []
        seen = set()
        for n in names:
            if n not in seen:
                out.append(n)
                seen.add(n)
        return out

    def _compute_groundedness_summary(
        self, *, request: Dict[str, Any], adapter: "ProviderAdapter", response_text: str
    ) -> Optional[Dict[str, Any]]:
        """
        Best-effort claim-level groundedness summary.

        Contract:
        - Only runs when MiddlewareConfig.enable_evidence_audit is True.
        - Does not emit/store raw evidence; only aggregated summary counts.
        - Supports the eval prompt format that embeds evidence lines: "- E1: ...".
        """
        try:
            from entropic_core.experimental.groundedness import analyze_groundedness
        except Exception:
            return None

        messages = adapter.extract_messages(request) or []
        evidence = self._parse_evidence_from_messages(messages)
        if not evidence:
            return None

        ans = response_text or ""
        obj = None
        try:
            obj = self._json_object_from_text(ans)
        except Exception:
            obj = None
        if isinstance(obj, dict) and isinstance(obj.get("answer"), str):
            ans_txt = str(obj.get("answer") or "")
            ev_ids = obj.get("evidence_ids")
            ev_ids2 = (
                [str(x) for x in ev_ids if isinstance(x, str)] if isinstance(ev_ids, list) else []
            )
            ans = self._text_with_citations_per_claim(ans_txt, ev_ids2)

        rep = analyze_groundedness(ans, [{"id": e["id"], "text": e["text"]} for e in evidence])
        s = rep.summary
        out = {
            "total_claims": int(s.total_claims),
            "supported_claims": int(s.supported_claims),
            "unsupported_claims": int(s.unsupported_claims),
            "citation_invented": int(s.citation_invented),
            "citation_misattributed": int(s.citation_misattributed),
            "numeric_mismatch": int(s.numeric_mismatch),
            "contradiction": int(s.contradiction),
            "unverifiable": bool(s.unverifiable),
            "supported_rate": float(s.supported_claims) / float(max(1, s.total_claims)),
        }
        if bool(getattr(self.config, "evidence_audit_include_assessments", False)):
            # Truncate to avoid huge payloads and accidental content leaks.
            items = []
            for a in list(getattr(rep, "assessments", ()) or ())[:16]:
                try:
                    items.append(
                        {
                            "claim": str(a.claim.text)[:240],
                            "supported": bool(a.supported),
                            "evidence_ids": list(a.evidence_ids),
                            "failure_types": [str(x) for x in (a.failure_types or ())],
                            "support_score": float(a.support_score),
                        }
                    )
                except Exception:
                    continue
            out["assessments"] = items
        return out

    def _text_with_citations_per_claim(self, answer: str, evidence_ids: List[str]) -> str:
        s = (answer or "").strip()
        if not s:
            return s
        cites = " ".join(f"[{x}]" for x in (evidence_ids or []) if isinstance(x, str) and x.strip())
        if not cites:
            return s
        out = []
        import re

        cite_re = re.compile(r"(?:\\[|\\()\\s*E\\d{1,4}\\s*(?:\\]|\\))")
        for ln in [ln.strip() for ln in s.splitlines() if ln.strip()]:
            out.append(ln if cite_re.search(ln) else f"{ln} {cites}".strip())
        return "\n".join(out)

    def _parse_evidence_from_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, str]]:
        max_items = int(getattr(self.config, "evidence_audit_max_items", 64) or 64)
        ev: List[Dict[str, str]] = []
        if not messages:
            return ev

        # Parse the common eval-style evidence block:
        # EVIDENCE:
        # - E1: ...
        # - E2: ...
        import re

        line_re = re.compile(r"^\s*-\s*(E\d{1,4})\s*:\s*(.+?)\s*$")
        for m in messages:
            if m.get("role") != "system":
                continue
            text = str(m.get("content") or "")
            for ln in text.splitlines():
                mm = line_re.match(ln)
                if not mm:
                    continue
                ev.append({"id": mm.group(1), "text": mm.group(2)})
                if len(ev) >= max_items:
                    return ev
        return ev

    def _json_object_from_text(self, text: str) -> Optional[dict]:
        s = (text or "").strip()
        if not s:
            return None
        if s.startswith("{") and s.endswith("}"):
            try:
                v = json.loads(s)
                return v if isinstance(v, dict) else None
            except Exception:
                pass
        m = re.search(r"\{.*\}", s, flags=re.DOTALL)
        if not m:
            return None
        try:
            v = json.loads(m.group(0))
            return v if isinstance(v, dict) else None
        except Exception:
            return None

    def _is_refusal_response(self, response: Any, response_text: str, finish_reason: str) -> bool:
        fr = (finish_reason or "").lower()
        if fr in {"content_filter", "safety", "refusal"}:
            return True
        if response_text.strip():
            return False
        # OpenAI python sometimes exposes message.refusal
        try:
            if hasattr(response, "choices") and response.choices:
                msg = getattr(response.choices[0], "message", None)
                if msg is not None and getattr(msg, "refusal", None):
                    return True
        except Exception:
            pass
        return False

    def _estimate_cost_usd(self, model: str, usage: Dict[str, int]) -> float:
        """
        Best-effort token cost estimation.

        For OpenRouter/Gemini this is typically unknown unless the caller provides pricing; this uses a fallback table
        from BusinessMetrics where available, otherwise it returns 0.0.
        """
        try:
            from ..business.metrics import TOKEN_COSTS  # type: ignore

            costs = TOKEN_COSTS.get(
                model, TOKEN_COSTS.get("default", {"input": 0.0, "output": 0.0})
            )
            pt = float(usage.get("prompt_tokens", 0))
            ct = float(usage.get("completion_tokens", 0))
            return (pt / 1000.0) * float(costs.get("input", 0.0)) + (ct / 1000.0) * float(
                costs.get("output", 0.0)
            )
        except Exception:
            return 0.0

    def _apply_pre_call_interventions(self, request: Dict, adapter: "ProviderAdapter") -> Dict:
        """Apply all pre-call interventions"""
        modified = request.copy()
        # Enable idempotency even if _apply_pre_call_interventions gets called more than once
        # for the same request object (nested wrappers / re-entrancy).
        try:
            from .intervention_contract import ensure_entropic_meta

            ensure_entropic_meta(modified)
        except Exception:
            modified.setdefault("_entropic", {}).setdefault("applied", [])
        interventions = []

        # 1. TEMPERATURE CONTROL based on entropy
        if self.config.enable_temperature_control:
            modified, applied = self._control_temperature(modified)
            if applied:
                interventions.append("temperature_adjusted")

        # 2. PROMPT INJECTION for stability
        if (
            self.config.enable_prompt_injection
            and self.current_entropy > self.config.high_entropy_threshold
        ):
            modified, applied = self._inject_stability_prompt_idempotent(modified, adapter)
            if applied:
                interventions.append("stability_prompt_injected")

        # 3. CONTEXT PRUNING for cost optimization
        if self.config.enable_context_pruning:
            modified, tokens_saved = self._prune_context_idempotent(modified, adapter)
            if tokens_saved > 0:
                interventions.append(f"context_pruned_{tokens_saved}_tokens")
                modified["_tokens_saved"] = tokens_saved

        # 4. HALLUCINATION PREVENTION
        if (
            self.config.enable_hallucination_detection
            and self.current_entropy > self.config.hallucination_threshold
        ):
            modified, applied = self._add_grounding_instructions_idempotent(modified, adapter)
            if applied:
                interventions.append("grounding_added")

        modified["_interventions"] = interventions
        return modified

    def _apply_post_call_interventions(
        self,
        func: Callable,
        adapter: "ProviderAdapter",
        args: tuple,
        kwargs: dict,
        response: Any,
    ) -> Tuple[Any, int]:
        """Apply post-call interventions, including retry logic"""
        retry_count = 0

        if not self.config.enable_retry_logic:
            return response, retry_count

        # Check if response needs retry
        response_text = adapter.extract_response_text(response)
        response_quality = self._assess_response_quality(response_text)

        current_temp = kwargs.get("temperature", 0.7)

        while response_quality < 0.5 and retry_count < self.config.max_retries:
            retry_count += 1

            # Reduce temperature and retry
            new_temp = current_temp * self.config.retry_temperature_decay
            kwargs["temperature"] = new_temp

            response = func(*args, **kwargs)
            response_text = adapter.extract_response_text(response)
            response_quality = self._assess_response_quality(response_text)
            current_temp = new_temp

        return response, retry_count

    def _control_temperature(self, request: Dict) -> Tuple[Dict, bool]:
        """Dynamically adjust temperature based on entropy"""
        current_temp = request.get("temperature", 0.7)
        applied = False

        if self.current_entropy > self.config.high_entropy_threshold:
            # High entropy -> reduce temperature for stability
            new_temp = max(0.1, current_temp * 0.6)
            request["temperature"] = new_temp
            applied = True
        elif self.current_entropy < self.config.low_entropy_threshold:
            # Low entropy -> increase temperature for creativity
            new_temp = min(1.0, current_temp * 1.4)
            request["temperature"] = new_temp
            applied = True

        return request, applied

    def _inject_stability_prompt(self, request: Dict, adapter: "ProviderAdapter") -> Dict:
        """Inject stability instructions into the prompt"""
        stability_instruction = """
[SYSTEM STABILITY PROTOCOL ACTIVE]
The conversation is showing signs of entropy drift. Please:
1. Stay focused on the specific task at hand
2. Reference and maintain consistency with prior context
3. Avoid speculative or tangential responses
4. If uncertain, acknowledge limitations rather than hallucinating
5. Provide structured, clear responses
        """
        return adapter.inject_system_message(request, stability_instruction)

    def _inject_system_message_once(
        self, request: Dict, adapter: "ProviderAdapter", *, marker: str, content: str
    ) -> Tuple[Dict, bool]:
        """Idempotently inject a system message.

        Guarantee: at most one system message containing `marker` exists after apply.
        """
        try:
            msgs = adapter.extract_messages(request) or []
        except Exception:
            msgs = []
        for m in msgs:
            if m.get("role") == "system" and marker in str(m.get("content", "")):
                return request, False
        payload = (marker + "\n" + (content or "").strip() + "\n").strip() + "\n"
        return adapter.inject_system_message(request, payload), True

    def _inject_stability_prompt_idempotent(
        self, request: Dict, adapter: "ProviderAdapter"
    ) -> Tuple[Dict, bool]:
        """Stability prompt injection with contract + idempotency."""
        try:
            from .intervention_contract import (
                InterventionKind,
                already_applied,
                make_idempotency_key,
                mark_applied,
            )
        except Exception:
            return self._inject_stability_prompt(request, adapter), True

        key = make_idempotency_key(
            InterventionKind.STABILITY_INJECTION,
            scope=str(request.get("model", "")) + ":precall",
            reason="entropy_high",
            params={"threshold": float(self.config.high_entropy_threshold)},
        )
        if already_applied(request, key):
            return request, False

        marker = f"[ENTROPIC_CORE:STABILITY key={key}]"
        modified, applied = self._inject_system_message_once(
            request,
            adapter,
            marker=marker,
            content=self._stability_instruction_text(),
        )
        if applied:
            mark_applied(modified, key)
        return modified, applied

    def _stability_instruction_text(self) -> str:
        # Keep the legacy instruction text intact (tests expect the tag line).
        return """
    [SYSTEM STABILITY PROTOCOL ACTIVE]
    The conversation is showing signs of entropy drift. Please:
    1. Stay focused on the specific task at hand
    2. Reference and maintain consistency with prior context
    3. Avoid speculative or tangential responses
    4. If uncertain, acknowledge limitations rather than hallucinating
    5. Provide structured, clear responses
    """

    def _prune_context(self, request: Dict, adapter: "ProviderAdapter") -> Tuple[Dict, int]:
        """Prune context to reduce tokens while preserving essential information"""
        messages = adapter.extract_messages(request)
        if not messages:
            return request, 0

        original_tokens = self._estimate_tokens(messages)

        if original_tokens <= self.config.max_context_tokens:
            return request, 0

        # Smart pruning strategy
        pruned_messages = self._smart_prune(messages, self.config.max_context_tokens)
        pruned_tokens = self._estimate_tokens(pruned_messages)

        tokens_saved = original_tokens - pruned_tokens
        self.total_tokens_saved += tokens_saved

        # Estimate cost saved (rough approximation)
        cost_saved = tokens_saved * 0.00002  # Approximate cost per token
        self.total_cost_saved += cost_saved

        return adapter.set_messages(request, pruned_messages), tokens_saved

    def _prune_context_idempotent(
        self, request: Dict, adapter: "ProviderAdapter"
    ) -> Tuple[Dict, int]:
        """Context pruning with contract + idempotency.

        Guarantees are implemented in _smart_prune_safe():
        - never remove tool outputs
        - never remove system messages
        - never remove messages marked keep or with evidence ids
        - keep last N exchanges
        - if applied twice to the same request object, the 2nd is a no-op
        """
        try:
            from .intervention_contract import (
                InterventionKind,
                already_applied,
                make_idempotency_key,
                mark_applied,
            )
        except Exception:
            return self._prune_context(request, adapter)

        key = make_idempotency_key(
            InterventionKind.CONTEXT_PRUNE_SAFE,
            scope=str(request.get("model", "")) + ":precall",
            reason="token_budget",
            params={"max_context_tokens": int(self.config.max_context_tokens)},
        )
        if already_applied(request, key):
            return request, int(request.get("_tokens_saved", 0) or 0)

        modified, saved = self._prune_context(request, adapter)
        if saved > 0:
            mark_applied(modified, key)
        return modified, saved

    def _smart_prune(self, messages: List[Dict], target_tokens: int) -> List[Dict]:
        """Intelligently prune messages while preserving important context.

        Implementation note: this delegates to _smart_prune_safe() which enforces
        "safe pruning" guarantees.
        """
        return self._smart_prune_safe(messages, target_tokens)

    def _smart_prune_safe(self, messages: List[Dict], target_tokens: int) -> List[Dict]:
        """Safely prune messages while preserving evidence and tool outputs."""
        if not messages:
            return messages

        pinned_idx = set()

        # Always keep system messages.
        pinned_idx |= {i for i, m in enumerate(messages) if m.get("role") == "system"}
        # Never prune tool outputs.
        pinned_idx |= {i for i, m in enumerate(messages) if m.get("role") == "tool"}

        # Keep explicit keep markers.
        for i, m in enumerate(messages):
            if m.get("entropic_keep") is True or m.get("_entropic_keep") is True:
                pinned_idx.add(i)
                continue
            md = m.get("metadata")
            if isinstance(md, dict) and md.get("entropic_keep") is True:
                pinned_idx.add(i)

        # Keep messages that look like they contain evidence ids (E1, [E1], etc).
        ev_re = re.compile(r"(?:\\[E\\d{1,4}\\]|\\bE\\d{1,4}\\b)")
        for i, m in enumerate(messages):
            c = str(m.get("content", "") or "")
            if ev_re.search(c):
                pinned_idx.add(i)

        # Keep last 4 exchanges (8 non-system messages) minimum.
        non_system = [i for i, m in enumerate(messages) if m.get("role") != "system"]
        tail = non_system[-8:] if len(non_system) > 8 else non_system
        pinned_idx |= set(tail)

        chosen_msgs = [messages[i] for i in range(len(messages)) if i in pinned_idx]
        current_tokens = self._estimate_tokens(chosen_msgs)
        if current_tokens >= target_tokens:
            # If pinned alone exceeds budget, still return pinned (safety > budget).
            return chosen_msgs

        remaining = target_tokens - current_tokens

        # Fill remaining budget with more messages, prioritizing recent ones.
        for i in reversed(range(len(messages))):
            if i in pinned_idx:
                continue
            msg = messages[i]
            t = self._estimate_tokens([msg])
            if t <= remaining:
                pinned_idx.add(i)
                remaining -= t

        return [messages[i] for i in range(len(messages)) if i in pinned_idx]

    def _add_grounding_instructions(self, request: Dict, adapter: "ProviderAdapter") -> Dict:
        """Add grounding instructions to reduce hallucination"""
        grounding_instruction = """
[FACTUAL GROUNDING REQUIRED]
High entropy detected - please ground your response in:
1. Explicitly stated information from the conversation
2. Verifiable facts you are confident about
3. If making inferences, clearly label them as such
4. Avoid speculation - say "I don't know" when uncertain
        """
        return adapter.inject_system_message(request, grounding_instruction)

    def _add_grounding_instructions_idempotent(
        self, request: Dict, adapter: "ProviderAdapter"
    ) -> Tuple[Dict, bool]:
        """Grounding injection with contract + idempotency."""
        try:
            from .intervention_contract import (
                InterventionKind,
                already_applied,
                make_idempotency_key,
                mark_applied,
            )
        except Exception:
            return self._add_grounding_instructions(request, adapter), True

        key = make_idempotency_key(
            InterventionKind.GROUNDING_INJECTION,
            scope=str(request.get("model", "")) + ":precall",
            reason="entropy_hallucination_risk",
            params={"threshold": float(self.config.hallucination_threshold)},
        )
        if already_applied(request, key):
            return request, False

        marker = f"[ENTROPIC_CORE:GROUNDING key={key}]"
        modified, applied = self._inject_system_message_once(
            request,
            adapter,
            marker=marker,
            content=self._grounding_instruction_text(),
        )
        if applied:
            mark_applied(modified, key)
        return modified, applied

    def _grounding_instruction_text(self) -> str:
        # Keep legacy tag intact, but add an explicit evidence-id rule for verifiability.
        return """
    [FACTUAL GROUNDING REQUIRED]
    High entropy detected - please ground your response in:
    1. Explicitly stated information from the conversation
    2. Verifiable facts you are confident about
    3. If making inferences, clearly label them as such
    4. Avoid speculation - say \"I don't know\" when uncertain
    5. If you cite evidence, use explicit ids (E1, E2, ...) that exist in the provided context/tool outputs.
    """

    def _analyze_response_entropy(self, response: Any, adapter: "ProviderAdapter") -> float:
        """Analyze entropy of the response"""
        text = adapter.extract_response_text(response)
        if not text:
            return 0.5

        # Calculate word-level Shannon entropy
        import math
        from collections import Counter

        words = text.lower().split()
        if not words:
            return 0.5

        word_counts = Counter(words)
        total = len(words)

        entropy = 0.0
        for count in word_counts.values():
            prob = count / total
            entropy -= prob * math.log2(prob)

        # Normalize
        max_entropy = math.log2(len(word_counts)) if word_counts else 1
        normalized = entropy / max_entropy if max_entropy > 0 else 0.5

        return normalized

    def _assess_response_quality(self, text: str) -> float:
        """Assess quality of response (0-1)"""
        if not text:
            return 0.0

        quality = 1.0

        # Penalize very short responses
        if len(text) < 50:
            quality -= 0.3

        # Penalize repetitive content
        words = text.split()
        unique_ratio = len(set(words)) / len(words) if words else 0
        if unique_ratio < 0.5:
            quality -= 0.3

        # Penalize uncertainty markers (might indicate hallucination avoidance)
        uncertainty_markers = ["i think", "maybe", "possibly", "not sure", "might be"]
        for marker in uncertainty_markers:
            if marker in text.lower():
                quality -= 0.1

        return max(0.0, min(1.0, quality))

    def _estimate_tokens(self, messages: List[Dict]) -> int:
        """Estimate token count (rough approximation)"""
        total_chars = sum(len(str(m.get("content", ""))) for m in messages)
        return total_chars // 4  # Rough estimate: 4 chars per token

    def _detect_provider(self, func: Callable) -> LLMProvider:
        """Auto-detect the LLM provider from function"""
        func_module = getattr(func, "__module__", "")
        getattr(func, "__qualname__", "")

        if "openai" in func_module.lower():
            return LLMProvider.OPENAI
        elif "anthropic" in func_module.lower():
            return LLMProvider.ANTHROPIC
        elif "langchain" in func_module.lower():
            return LLMProvider.LANGCHAIN
        elif "crewai" in func_module.lower():
            return LLMProvider.CREWAI
        elif "autogen" in func_module.lower():
            return LLMProvider.AUTOGEN
        else:
            return LLMProvider.CUSTOM

    def update_entropy(self, entropy: float):
        """Update current entropy from external source"""
        self.current_entropy = entropy

    def get_stats(self) -> Dict[str, Any]:
        """Get middleware statistics"""
        return {
            "total_calls": self._call_count,
            "total_tokens_saved": self.total_tokens_saved,
            "total_cost_saved": self.total_cost_saved,
            "average_entropy": (
                sum(r.entropy_after for r in self.call_history) / len(self.call_history)
                if self.call_history
                else 0.5
            ),
            "intervention_rate": sum(1 for r in self.call_history if r.interventions_applied)
            / max(1, len(self.call_history)),
        }


# =============================================================================
# PROVIDER ADAPTERS - Handle provider-specific formats
# =============================================================================


class ProviderAdapter(ABC):
    """Base adapter for LLM providers"""

    @abstractmethod
    def extract_request(self, args: tuple, kwargs: dict) -> Dict[str, Any]:
        """Extract request info from function arguments"""

    @abstractmethod
    def rebuild_request(self, args: tuple, kwargs: dict, modified: Dict) -> Tuple[tuple, dict]:
        """Rebuild function arguments from modified request"""

    @abstractmethod
    def extract_messages(self, request: Dict) -> List[Dict]:
        """Extract messages from request"""

    @abstractmethod
    def set_messages(self, request: Dict, messages: List[Dict]) -> Dict:
        """Set messages in request"""

    @abstractmethod
    def inject_system_message(self, request: Dict, content: str) -> Dict:
        """Inject a system message"""

    @abstractmethod
    def extract_response_text(self, response: Any) -> str:
        """Extract text from response"""


class OpenAIAdapter(ProviderAdapter):
    """Adapter for OpenAI API"""

    def extract_request(self, args: tuple, kwargs: dict) -> Dict[str, Any]:
        return {
            "messages": kwargs.get("messages", []),
            "model": kwargs.get("model", "gpt-4"),
            "temperature": kwargs.get("temperature", 0.7),
            "max_tokens": kwargs.get("max_tokens"),
        }

    def rebuild_request(self, args: tuple, kwargs: dict, modified: Dict) -> Tuple[tuple, dict]:
        new_kwargs = kwargs.copy()
        new_kwargs["messages"] = modified.get("messages", kwargs.get("messages", []))
        new_kwargs["temperature"] = modified.get("temperature", kwargs.get("temperature", 0.7))
        return args, new_kwargs

    def extract_messages(self, request: Dict) -> List[Dict]:
        return request.get("messages", [])

    def set_messages(self, request: Dict, messages: List[Dict]) -> Dict:
        request["messages"] = messages
        return request

    def inject_system_message(self, request: Dict, content: str) -> Dict:
        messages = request.get("messages", [])
        # Insert after existing system messages
        insert_idx = 0
        for i, msg in enumerate(messages):
            if msg.get("role") == "system":
                insert_idx = i + 1
            else:
                break

        messages.insert(insert_idx, {"role": "system", "content": content})
        request["messages"] = messages
        return request

    def extract_response_text(self, response: Any) -> str:
        if hasattr(response, "choices") and response.choices:
            c0 = response.choices[0]
            msg = getattr(c0, "message", None)
            if msg is not None:
                content = getattr(msg, "content", None)
                if content:
                    return str(content)
                # Tool calls (function calling) can be present even when content is None.
                tool_calls = getattr(msg, "tool_calls", None)
                if tool_calls:
                    try:
                        return json.dumps({"tool_calls": tool_calls}, default=str)
                    except Exception:
                        return str(tool_calls)
                refusal = getattr(msg, "refusal", None)
                if refusal:
                    return str(refusal)
        return str(response)


class AnthropicAdapter(ProviderAdapter):
    """Adapter for Anthropic API"""

    def extract_request(self, args: tuple, kwargs: dict) -> Dict[str, Any]:
        return {
            "messages": kwargs.get("messages", []),
            "model": kwargs.get("model", "claude-3-sonnet"),
            "system": kwargs.get("system", ""),
            "temperature": kwargs.get("temperature", 0.7),
            "max_tokens": kwargs.get("max_tokens", 1024),
        }

    def rebuild_request(self, args: tuple, kwargs: dict, modified: Dict) -> Tuple[tuple, dict]:
        new_kwargs = kwargs.copy()
        new_kwargs["messages"] = modified.get("messages", kwargs.get("messages", []))
        new_kwargs["system"] = modified.get("system", kwargs.get("system", ""))
        new_kwargs["temperature"] = modified.get("temperature", kwargs.get("temperature", 0.7))
        return args, new_kwargs

    def extract_messages(self, request: Dict) -> List[Dict]:
        return request.get("messages", [])

    def set_messages(self, request: Dict, messages: List[Dict]) -> Dict:
        request["messages"] = messages
        return request

    def inject_system_message(self, request: Dict, content: str) -> Dict:
        existing_system = request.get("system", "")
        request["system"] = existing_system + "\n\n" + content if existing_system else content
        return request

    def extract_response_text(self, response: Any) -> str:
        if hasattr(response, "content") and response.content:
            if isinstance(response.content, list):
                return " ".join(c.text for c in response.content if hasattr(c, "text"))
            return str(response.content)
        return str(response)


class LangChainAdapter(ProviderAdapter):
    """Adapter for LangChain"""

    def extract_request(self, args: tuple, kwargs: dict) -> Dict[str, Any]:
        # LangChain can have various input formats
        input_data = args[0] if args else kwargs.get("input", {})
        return {
            "input": input_data,
            "config": kwargs.get("config", {}),
        }

    def rebuild_request(self, args: tuple, kwargs: dict, modified: Dict) -> Tuple[tuple, dict]:
        new_args = (modified.get("input", args[0] if args else {}),) + args[1:]
        return new_args, kwargs

    def extract_messages(self, request: Dict) -> List[Dict]:
        input_data = request.get("input", {})
        if isinstance(input_data, dict) and "messages" in input_data:
            return input_data["messages"]
        return []

    def set_messages(self, request: Dict, messages: List[Dict]) -> Dict:
        if isinstance(request.get("input"), dict):
            request["input"]["messages"] = messages
        return request

    def inject_system_message(self, request: Dict, content: str) -> Dict:
        input_data = request.get("input", {})
        if isinstance(input_data, dict):
            if "system" in input_data:
                input_data["system"] = input_data["system"] + "\n\n" + content
            else:
                input_data["system"] = content
            request["input"] = input_data
        return request

    def extract_response_text(self, response: Any) -> str:
        if hasattr(response, "content"):
            return str(response.content)
        if isinstance(response, dict):
            return response.get("output", response.get("text", str(response)))
        return str(response)


class VercelAIAdapter(ProviderAdapter):
    """Adapter for Vercel AI SDK"""

    def extract_request(self, args: tuple, kwargs: dict) -> Dict[str, Any]:
        return {
            "messages": kwargs.get("messages", []),
            "model": kwargs.get("model", ""),
            "temperature": kwargs.get("temperature", 0.7),
        }

    def rebuild_request(self, args: tuple, kwargs: dict, modified: Dict) -> Tuple[tuple, dict]:
        new_kwargs = kwargs.copy()
        new_kwargs["messages"] = modified.get("messages", kwargs.get("messages", []))
        new_kwargs["temperature"] = modified.get("temperature", kwargs.get("temperature", 0.7))
        return args, new_kwargs

    def extract_messages(self, request: Dict) -> List[Dict]:
        return request.get("messages", [])

    def set_messages(self, request: Dict, messages: List[Dict]) -> Dict:
        request["messages"] = messages
        return request

    def inject_system_message(self, request: Dict, content: str) -> Dict:
        messages = request.get("messages", [])
        messages.insert(0, {"role": "system", "content": content})
        request["messages"] = messages
        return request

    def extract_response_text(self, response: Any) -> str:
        if hasattr(response, "text"):
            return response.text
        return str(response)


class GenericAdapter(ProviderAdapter):
    """Generic adapter for unknown providers"""

    def extract_request(self, args: tuple, kwargs: dict) -> Dict[str, Any]:
        return dict(kwargs)

    def rebuild_request(self, args: tuple, kwargs: dict, modified: Dict) -> Tuple[tuple, dict]:
        return args, modified

    def extract_messages(self, request: Dict) -> List[Dict]:
        return request.get("messages", [])

    def set_messages(self, request: Dict, messages: List[Dict]) -> Dict:
        request["messages"] = messages
        return request

    def inject_system_message(self, request: Dict, content: str) -> Dict:
        messages = request.get("messages", [])
        messages.insert(0, {"role": "system", "content": content})
        request["messages"] = messages
        return request

    def extract_response_text(self, response: Any) -> str:
        if isinstance(response, str):
            return response
        if hasattr(response, "content"):
            return str(response.content)
        return str(response)


# =============================================================================
# CONVENIENCE FUNCTIONS
# =============================================================================


def create_middleware(config: Optional[MiddlewareConfig] = None) -> LLMMiddleware:
    """Factory function to create LLM middleware"""
    return LLMMiddleware(config)


def wrap_openai(client, middleware: LLMMiddleware = None):
    """Convenience function to wrap OpenAI client"""
    if middleware is None:
        middleware = create_middleware()

    client.chat.completions.create = middleware.wrap(
        client.chat.completions.create, provider=LLMProvider.OPENAI
    )
    return client


def wrap_openrouter(client, middleware: LLMMiddleware = None):
    """Convenience function to wrap an OpenAI-compatible OpenRouter client."""
    if middleware is None:
        middleware = create_middleware()

    client.chat.completions.create = middleware.wrap(
        client.chat.completions.create, provider=LLMProvider.OPENROUTER
    )
    return client


def wrap_anthropic(client, middleware: LLMMiddleware = None):
    """Convenience function to wrap Anthropic client"""
    if middleware is None:
        middleware = create_middleware()

    client.messages.create = middleware.wrap(client.messages.create, provider=LLMProvider.ANTHROPIC)
    return client
