##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################

"""Base LLM client interface and factory."""

from abc import ABC, abstractmethod
from collections.abc import Generator
from typing import Optional, Union

from pgadmin.llm.models import (
    Message, Tool, LLMResponse, LLMError
)


class LLMClient(ABC):
    """
    Abstract base class for LLM clients.

    All LLM provider implementations should inherit from this class
    and implement the required methods.
    """

    @property
    @abstractmethod
    def provider_name(self) -> str:
        """Return the name of the LLM provider."""
        pass

    @property
    @abstractmethod
    def model_name(self) -> str:
        """Return the name of the model being used."""
        pass

    @abstractmethod
    def is_available(self) -> bool:
        """
        Check if the LLM client is properly configured and available.

        Returns:
            True if the client can be used, False otherwise.
        """
        pass

    @abstractmethod
    def chat(
        self,
        messages: list[Message],
        tools: Optional[list[Tool]] = None,
        system_prompt: Optional[str] = None,
        max_tokens: int = 4096,
        **kwargs
    ) -> LLMResponse:
        """
        Send a chat request to the LLM.

        Args:
            messages: List of conversation messages.
            tools: Optional list of tools the LLM can use.
            system_prompt: Optional system prompt to set context.
            max_tokens: Maximum tokens in the response.
            **kwargs: Additional provider-specific parameters.

        Returns:
            LLMResponse containing the model's response.

        Raises:
            LLMError: If the request fails.
        """
        pass

    def chat_stream(
        self,
        messages: list[Message],
        tools: Optional[list[Tool]] = None,
        system_prompt: Optional[str] = None,
        max_tokens: int = 4096,
        temperature: float = 0.0,
        **kwargs
    ) -> Generator[Union[str, LLMResponse], None, None]:
        """
        Stream a chat response from the LLM.

        Yields text chunks (str) as they arrive, then yields
        a final LLMResponse with the complete response metadata.

        The default implementation falls back to non-streaming chat().

        Args:
            messages: List of conversation messages.
            tools: Optional list of tools the LLM can use.
            system_prompt: Optional system prompt to set context.
            max_tokens: Maximum tokens in the response.
            temperature: Sampling temperature (0.0 = deterministic).
            **kwargs: Additional provider-specific parameters.

        Yields:
            str: Text content chunks as they arrive.
            LLMResponse: Final response with complete metadata (last item).
        """
        # Default: fall back to non-streaming
        response = self.chat(
            messages=messages,
            tools=tools,
            system_prompt=system_prompt,
            max_tokens=max_tokens,
            temperature=temperature,
            **kwargs
        )
        if response.content:
            yield response.content
        yield response

    def validate_connection(self) -> tuple[bool, Optional[str]]:
        """
        Validate the connection to the LLM provider.

        Returns:
            Tuple of (success, error_message).
            If success is True, error_message is None.
        """
        try:
            # Try a minimal request to validate the connection
            self.chat(
                messages=[Message.user("Hello")],
                max_tokens=10
            )
            return True, None
        except LLMError as e:
            return False, str(e)
        except Exception as e:
            return False, f"Connection failed: {str(e)}"


class LLMClientError(Exception):
    """Exception raised for LLM client errors."""

    def __init__(self, error: LLMError):
        self.error = error
        super().__init__(str(error))


def get_llm_client(
    provider: Optional[str] = None,
    model: Optional[str] = None
) -> Optional[LLMClient]:
    """
    Get an LLM client instance for the specified or default provider.

    Args:
        provider: Optional provider name ('anthropic', 'openai', 'ollama',
                  'docker'). If not specified, uses the configured default
                  provider.
        model: Optional model name to use. If not specified, uses the
               configured default model for the provider.

    Returns:
        An LLMClient instance, or None if no provider is configured.

    Raises:
        ValueError: If an invalid provider is specified.
        LLMClientError: If the client cannot be initialized.
    """
    from pgadmin.llm.utils import (
        get_default_provider,
        get_anthropic_api_url, get_anthropic_api_key, get_anthropic_model,
        get_openai_api_url, get_openai_api_key, get_openai_model,
        get_ollama_api_url, get_ollama_model,
        get_docker_api_url, get_docker_model,
        is_pref_api_url_rejected,
        is_pref_api_key_path_rejected,
    )

    # Determine which provider to use
    if provider is None:
        provider = get_default_provider()
        if provider is None:
            return None

    provider = provider.lower()

    def _rejected_url_error(prov):
        return LLMClientError(LLMError(
            message=(
                f"The configured {prov} API URL is not in the "
                "allowed list (ALLOWED_LLM_API_URLS). Add it to "
                "config_local.py, or clear the API URL preference "
                "to use the system default."
            ),
            provider=prov,
        ))

    def _rejected_key_file_error(prov):
        return LLMClientError(LLMError(
            message=(
                f"The configured {prov} API key file is not within "
                "your private user storage. Move the key file to "
                "your private storage directory, or clear the API "
                "Key File preference to use the system default."
            ),
            provider=prov,
        ))

    if provider == 'anthropic':
        from pgadmin.llm.providers.anthropic import AnthropicClient
        if is_pref_api_url_rejected('anthropic_api_url'):
            raise _rejected_url_error('anthropic')
        if is_pref_api_key_path_rejected('anthropic_api_key_file'):
            raise _rejected_key_file_error('anthropic')
        api_key = get_anthropic_api_key()
        api_url = get_anthropic_api_url()
        if not api_key and not api_url:
            raise LLMClientError(LLMError(
                message="Anthropic API key not configured",
                provider="anthropic"
            ))
        model_name = model or get_anthropic_model()
        return AnthropicClient(
            api_key=api_key, model=model_name, api_url=api_url
        )

    elif provider == 'openai':
        from pgadmin.llm.providers.openai import OpenAIClient
        if is_pref_api_url_rejected('openai_api_url'):
            raise _rejected_url_error('openai')
        if is_pref_api_key_path_rejected('openai_api_key_file'):
            raise _rejected_key_file_error('openai')
        api_key = get_openai_api_key()
        api_url = get_openai_api_url()
        if not api_key and not api_url:
            raise LLMClientError(LLMError(
                message="OpenAI API key not configured",
                provider="openai"
            ))
        model_name = model or get_openai_model()
        return OpenAIClient(
            api_key=api_key, model=model_name, api_url=api_url
        )

    elif provider == 'ollama':
        from pgadmin.llm.providers.ollama import OllamaClient
        if is_pref_api_url_rejected('ollama_api_url'):
            raise _rejected_url_error('ollama')
        api_url = get_ollama_api_url()
        if not api_url:
            raise LLMClientError(LLMError(
                message="Ollama API URL not configured",
                provider="ollama"
            ))
        model_name = model or get_ollama_model()
        return OllamaClient(api_url=api_url, model=model_name)

    elif provider == 'docker':
        from pgadmin.llm.providers.docker import DockerClient
        if is_pref_api_url_rejected('docker_api_url'):
            raise _rejected_url_error('docker')
        api_url = get_docker_api_url()
        if not api_url:
            raise LLMClientError(LLMError(
                message="Docker Model Runner API URL not configured",
                provider="docker"
            ))
        model_name = model or get_docker_model()
        return DockerClient(api_url=api_url, model=model_name)

    else:
        raise ValueError(f"Unknown LLM provider: {provider}")


def is_llm_available() -> bool:
    """
    Check if an LLM client is available and properly configured.

    Returns:
        True if an LLM client can be created, False otherwise.
    """
    try:
        client = get_llm_client()
        return client is not None and client.is_available()
    except (LLMClientError, ValueError):
        return False
