"""The unified LLM adapter interface.

Every provider integration subclasses :class:`LLMAdapter` and normalises its
native API to the contracts in :mod:`aop.llm.types`. Callers program against
this one interface and stay provider-agnostic; switching from a local Ollama
model to a cloud model is a config change, not a code change.

Design note: this is a deliberately thin, native abstraction over the providers'
HTTP APIs (see ``docs/adr/0002-native-llm-adapter.md``) rather than a wrapper
around LangChain/LlamaIndex. It owns one shared :class:`httpx.AsyncClient` per
adapter instance and exposes async context-manager semantics for clean shutdown.
"""

from __future__ import annotations

import abc
from collections.abc import AsyncIterator, Sequence
from types import TracebackType
from typing import TYPE_CHECKING, Any

import httpx

from aop.llm.errors import ProviderHTTPError
from aop.llm.types import (
    ChatChunk,
    ChatResponse,
    EmbeddingResponse,
    HealthStatus,
    Message,
    ModelInfo,
)

if TYPE_CHECKING:
    from aop.config.settings import Settings


class LLMAdapter(abc.ABC):
    """Abstract base for all provider adapters.

    Subclasses must set the class attribute :attr:`provider` and implement the
    abstract coroutines. The base class manages the shared HTTP client and a
    small set of helpers for issuing requests and raising typed errors.
    """

    provider: str = "base"
    #: Whether this provider supports native (structured) function calling.
    #: Agents only pass tool schemas to adapters where this is ``True``.
    supports_tools: bool = False

    def __init__(
        self,
        *,
        base_url: str,
        timeout_s: float = 60.0,
        default_headers: dict[str, str] | None = None,
    ) -> None:
        """Initialise the adapter and its HTTP client.

        Args:
            base_url: Root URL for the provider's API.
            timeout_s: Per-request timeout in seconds.
            default_headers: Headers attached to every request (e.g. auth).
        """
        self._base_url = base_url.rstrip("/")
        self._client = httpx.AsyncClient(
            base_url=self._base_url,
            timeout=timeout_s,
            headers=default_headers or {},
        )

    @classmethod
    def from_settings(cls, settings: Settings) -> LLMAdapter:
        """Construct an adapter instance from application settings.

        Every concrete adapter overrides this. The base implementation exists
        only so the factory can treat all adapters uniformly.

        Args:
            settings: Loaded application settings.

        Returns:
            A configured adapter instance.

        Raises:
            NotImplementedError: If a subclass fails to override it.
        """
        raise NotImplementedError(f"{cls.__name__} must implement from_settings()")

    # -- lifecycle ----------------------------------------------------------
    async def aclose(self) -> None:
        """Close the underlying HTTP client and release sockets."""
        await self._client.aclose()

    async def __aenter__(self) -> LLMAdapter:
        """Enter the async context manager.

        Returns:
            This adapter instance.
        """
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        """Exit the async context manager, closing the HTTP client."""
        await self.aclose()

    # -- HTTP helpers -------------------------------------------------------
    async def _post_json(
        self,
        path: str,
        payload: dict[str, Any],
        *,
        headers: dict[str, str] | None = None,
    ) -> dict[str, Any]:
        """POST a JSON body and return the decoded JSON response.

        Args:
            path: Path appended to ``base_url``.
            payload: JSON-serialisable request body.
            headers: Optional per-request header overrides.

        Returns:
            The decoded JSON object.

        Raises:
            ProviderHTTPError: If the response status is not 2xx.
        """
        resp = await self._client.post(path, json=payload, headers=headers)
        if resp.status_code >= 400:
            raise ProviderHTTPError(self.provider, resp.status_code, resp.text)
        return resp.json()  # type: ignore[no-any-return]

    async def _get_json(
        self,
        path: str,
        *,
        params: dict[str, Any] | None = None,
        headers: dict[str, str] | None = None,
    ) -> dict[str, Any]:
        """GET and return the decoded JSON response.

        Args:
            path: Path appended to ``base_url``.
            params: Optional query parameters.
            headers: Optional per-request header overrides.

        Returns:
            The decoded JSON object.

        Raises:
            ProviderHTTPError: If the response status is not 2xx.
        """
        resp = await self._client.get(path, params=params, headers=headers)
        if resp.status_code >= 400:
            raise ProviderHTTPError(self.provider, resp.status_code, resp.text)
        return resp.json()  # type: ignore[no-any-return]

    # -- abstract interface -------------------------------------------------
    @abc.abstractmethod
    async def health(self) -> HealthStatus:
        """Probe the provider and report readiness.

        Returns:
            A :class:`HealthStatus`; never raises for an unreachable endpoint —
            it reports ``DOWN``/``UNCONFIGURED`` with a fix path instead.
        """

    @abc.abstractmethod
    async def list_models(self) -> list[ModelInfo]:
        """List models advertised by the provider.

        Returns:
            Model metadata, normalised to :class:`ModelInfo`.
        """

    @abc.abstractmethod
    async def chat(
        self,
        messages: Sequence[Message],
        *,
        model: str,
        temperature: float = 0.7,
        max_tokens: int | None = None,
        tools: list[dict[str, Any]] | None = None,
        **kwargs: Any,
    ) -> ChatResponse:
        """Run a non-streaming chat completion.

        Args:
            messages: Conversation so far, oldest first.
            model: Provider-native model identifier.
            temperature: Sampling temperature.
            max_tokens: Optional cap on generated tokens.
            tools: Optional OpenAI-style function-call schemas
                (``{"type": "function", "function": {...}}``). Only honoured by
                adapters where :attr:`supports_tools` is ``True``; the resulting
                tool calls are returned on ``ChatResponse.tool_calls``.
            **kwargs: Provider-specific passthrough options.

        Returns:
            The normalised :class:`ChatResponse`.
        """

    @abc.abstractmethod
    def stream_chat(
        self,
        messages: Sequence[Message],
        *,
        model: str,
        temperature: float = 0.7,
        max_tokens: int | None = None,
        **kwargs: Any,
    ) -> AsyncIterator[ChatChunk]:
        """Run a streaming chat completion.

        Args:
            messages: Conversation so far, oldest first.
            model: Provider-native model identifier.
            temperature: Sampling temperature.
            max_tokens: Optional cap on generated tokens.
            **kwargs: Provider-specific passthrough options.

        Returns:
            An async iterator of :class:`ChatChunk` deltas, ending with a chunk
            whose ``done`` is ``True``.
        """

    @abc.abstractmethod
    async def embed(
        self,
        texts: Sequence[str],
        *,
        model: str,
        **kwargs: Any,
    ) -> EmbeddingResponse:
        """Embed one or more texts.

        Args:
            texts: Inputs to embed.
            model: Provider-native embedding model identifier.
            **kwargs: Provider-specific passthrough options.

        Returns:
            The normalised :class:`EmbeddingResponse`.

        Raises:
            FeatureNotSupported: If the provider has no embeddings endpoint.
        """
