"""Provider-agnostic model access layer.

The runtime never imports a provider SDK directly — it talks to a `ProviderClient`.
Implementations: `OpenAIResponsesProvider` (native OpenAI via `/v1/responses`),
`OpenAIProvider` (Chat Completions — the compat world), and the native
Anthropic/Gemini/Bedrock/Vertex providers, all selected by the registry/router.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Optional


@dataclass
class ToolCall:
    """A single tool call requested by the model, with parsed arguments."""

    id: str
    name: str
    arguments: dict[str, Any] = field(default_factory=dict)


@dataclass
class TokenUsage:
    """Normalized token counts for one model round-trip.

    `input` counts only fresh (uncached) prompt tokens; cached prompt tokens are
    split into `cache_read`/`cache_write`. Providers that don't report a cache
    split (Ollama, most compat vendors) leave the cache fields at 0. `output`
    includes thinking tokens where the vendor bills them as output (Gemini).
    """

    input: int = 0
    output: int = 0
    cache_read: int = 0
    cache_write: int = 0

    @property
    def context_tokens(self) -> int:
        """Prompt-side total — what actually occupied the context window."""
        return self.input + self.cache_read + self.cache_write

    def as_dict(self) -> dict[str, int]:
        return {
            "input": self.input,
            "output": self.output,
            "cache_read": self.cache_read,
            "cache_write": self.cache_write,
        }


@dataclass
class AssistantTurn:
    """One assistant response: free text and/or a set of tool calls."""

    text: Optional[str] = None
    tool_calls: list[ToolCall] = field(default_factory=list)
    finish_reason: Optional[str] = None
    raw: Any = field(default=None, repr=False, compare=False)
    # The model's thinking text (DeepSeek reasoning_content, Gemini thought summaries, …).
    # Display-only: persisted on the assistant message as the `reasoning` sidecar and shown
    # in the GUI, but stripped before every provider call — never replayed as context.
    reasoning: Optional[str] = None
    # Provider-private sidecars to persist on the canonical assistant message
    # (underscore-prefixed keys, e.g. `_gemini` thought signatures). Contract: the
    # owning provider consumes its own key when converting history; every other
    # provider must strip or ignore foreign underscore keys before its wire call.
    extras: dict[str, Any] = field(default_factory=dict)
    # Token counts for this round-trip, normalized across providers. None when the
    # backend didn't report usage (some compat servers) — never guessed.
    usage: Optional[TokenUsage] = None

    @property
    def has_tool_calls(self) -> bool:
        return bool(self.tool_calls)


@dataclass(frozen=True)
class ModelCapabilities:
    """What a given model/provider can do; used for graceful degradation."""

    tools: bool = True
    vision: bool = False
    # Native PDF ingestion (OpenAI `file` part / Anthropic document / Gemini inline_data).
    # Models without it get a local fallback: text extraction or page images (pdf_support.py).
    pdf: bool = False
    parallel_tool_calls: bool = True
    streaming: bool = True


@dataclass
class StreamChunk:
    """One streamed piece: a text and/or reasoning delta, and/or (final) the full turn."""

    text_delta: Optional[str] = None
    reasoning_delta: Optional[str] = None
    turn: Optional[AssistantTurn] = None


class ProviderClient(ABC):
    """Single-shot, provider-agnostic completion interface.

    Deliberately blocking (the turn engine wraps it in `asyncio.to_thread`) and
    deliberately without a `max_turns` loop — the runtime owns the agent loop.
    """

    @abstractmethod
    def complete(
        self,
        *,
        model: str,
        messages: list[dict[str, Any]],
        tools: Optional[list[dict[str, Any]]] = None,
        **settings: Any,
    ) -> AssistantTurn:
        """Return one assistant turn for the given messages/tools."""

    @abstractmethod
    def capabilities(self, model: str) -> ModelCapabilities:
        """Return capability flags for the given model."""

    def stream(
        self,
        *,
        model: str,
        messages: list[dict[str, Any]],
        tools: Optional[list[dict[str, Any]]] = None,
        **settings: Any,
    ):
        """Yield StreamChunks. Default: no token streaming — one final chunk with the
        full turn. Providers that support streaming (OpenAIProvider) override this."""
        yield StreamChunk(
            turn=self.complete(model=model, messages=messages, tools=tools, **settings)
        )
