"""Base OpenAI-compatible endpoint provider for Local Deep Research."""

from langchain_openai import ChatOpenAI
from loguru import logger

# get_setting_from_snapshot and NoSettingsContextError are imported inside
# the methods that use them so test patches at the source module
# (`local_deep_research.config.thread_settings`) are picked up by the
# function-local imports at call time. A module-level binding here would
# be unaffected by patching the source module.
from ...security.log_sanitizer import redact_secrets
from ...security.ssrf_validator import assert_base_url_safe
from ...utilities.url_utils import normalize_url
from .base import BaseLLMProvider


class OpenAICompatibleProvider(BaseLLMProvider):
    """Base class for OpenAI-compatible API providers.

    This class provides a common implementation for any service that offers
    an OpenAI-compatible API endpoint (Google, OpenRouter, Groq, Together, etc.)
    """

    # Override these in subclasses
    provider_name = "openai_endpoint"  # Name used in logs
    api_key_setting = "llm.openai_endpoint.api_key"  # Settings key for API key
    url_setting = None  # Settings key for URL (e.g., "llm.lmstudio.url")
    default_base_url = "https://api.openai.com/v1"  # Default endpoint URL
    default_model = (
        ""  # User must explicitly configure llm.model — no silent fallback
    )

    @classmethod
    def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
        """Factory function for OpenAI-compatible LLMs.

        Args:
            model_name: Name of the model to use
            temperature: Model temperature (0.0-1.0)
            **kwargs: Additional arguments including settings_snapshot

        Returns:
            A configured ChatOpenAI instance

        Raises:
            ValueError: If API key is not configured
        """
        from ...config.thread_settings import (
            _get_optional_setting,
            NoSettingsContextError,
        )

        settings_snapshot = kwargs.get("settings_snapshot")

        # Resolve API key. resolve_api_key_or_placeholder raises for required
        # providers when missing (matches legacy behavior) and falls back to
        # the unified OPTIONAL_API_KEY_PLACEHOLDER for optional providers.
        api_key = cls.resolve_api_key_or_placeholder(settings_snapshot)

        # Require an explicit model — no silent fallback to a hardcoded default.
        if not model_name or not model_name.strip():
            logger.error(f"{cls.provider_name} model name not provided")
            raise ValueError(
                f"{cls.provider_name} model not configured. "
                f"Please set llm.model in settings."
            )

        # Get endpoint URL (can be overridden in kwargs for flexibility)
        base_url = kwargs.get("base_url", cls.default_base_url)
        base_url = normalize_url(base_url) if base_url else cls.default_base_url

        # SSRF guard for operator-configurable base_url. Skip when
        # url_setting is None (providers like OpenAI/Anthropic with
        # hardcoded default_base_url have no operator URL to attack).
        # ALWAYS_BLOCKED_METADATA_IPS still fires under the permissive
        # flags below, so cloud-credential endpoints stay blocked.
        if cls.url_setting:
            base_url = assert_base_url_safe(
                base_url, setting_key=cls.url_setting
            )

        # Build parameters for OpenAI client
        llm_params = {
            "model": model_name,
            "api_key": api_key,
            "base_url": base_url,
            "temperature": temperature,
        }

        # Apply context-window-aware max_tokens cap (was previously only
        # applied in dead code in llm_config.get_llm). 80% of context window
        # leaves room for the prompt itself.
        from ._helpers import (
            compute_max_tokens,
            get_context_window_for_provider,
        )

        try:
            context_window_size = get_context_window_for_provider(
                getattr(cls, "provider_key", "").lower(),
                settings_snapshot=settings_snapshot,
            )
            max_tokens = compute_max_tokens(
                settings_snapshot=settings_snapshot,
                context_window_size=context_window_size,
            )
            if max_tokens:  # Treat 0 as unset (matches legacy behavior)
                llm_params["max_tokens"] = max_tokens
        except NoSettingsContextError:
            pass  # Optional parameter

        # Add streaming if specified
        _get_optional_setting(
            llm_params,
            "streaming",
            "llm.streaming",
            settings_snapshot,
        )

        # Add max_retries if specified
        _get_optional_setting(
            llm_params,
            "max_retries",
            "llm.max_retries",
            settings_snapshot,
        )

        # Add request_timeout if specified
        _get_optional_setting(
            llm_params,
            "request_timeout",
            "llm.request_timeout",
            settings_snapshot,
        )

        # Request usage stats on streamed responses (stream_options.
        # include_usage). Opt-in via subclass kwargs because some
        # OpenAI-compatible endpoints reject unknown request fields.
        if kwargs.get("stream_usage") is not None:
            llm_params["stream_usage"] = kwargs["stream_usage"]

        logger.info(
            f"Creating {cls.provider_name} LLM with model: {model_name}, "
            f"temperature: {temperature}, endpoint: {base_url}"
        )

        return ChatOpenAI(**llm_params)

    @classmethod
    def _create_llm_instance(cls, model_name=None, temperature=0.7, **kwargs):
        """Internal method to create LLM instance with provided parameters.

        This bypasses API key checking for providers that handle auth differently.
        """
        from ...config.thread_settings import NoSettingsContextError

        settings_snapshot = kwargs.get("settings_snapshot")

        # Require an explicit model — no silent fallback to a hardcoded default.
        if not model_name or not model_name.strip():
            logger.error(f"{cls.provider_name} model name not provided")
            raise ValueError(
                f"{cls.provider_name} model not configured. "
                f"Please set llm.model in settings."
            )

        # Get endpoint URL (can be overridden in kwargs for flexibility)
        base_url = kwargs.get("base_url", cls.default_base_url)
        base_url = normalize_url(base_url) if base_url else cls.default_base_url

        # SSRF guard (same posture as create_llm above).
        if cls.url_setting:
            base_url = assert_base_url_safe(
                base_url, setting_key=cls.url_setting
            )

        # Get API key from kwargs (caller is responsible for providing it).
        # Defensive default uses the unified OPTIONAL_API_KEY_PLACEHOLDER so
        # any future direct caller of _create_llm_instance without an
        # explicit api_key sees the same string as everywhere else.
        from .base import OPTIONAL_API_KEY_PLACEHOLDER

        api_key = kwargs.get("api_key", OPTIONAL_API_KEY_PLACEHOLDER)

        # Build parameters for OpenAI client
        llm_params = {
            "model": model_name,
            "api_key": api_key,
            "base_url": base_url,
            "temperature": temperature,
        }

        # Apply context-window-aware max_tokens cap (matches create_llm above).
        from ._helpers import (
            compute_max_tokens,
            get_context_window_for_provider,
        )

        try:
            context_window_size = get_context_window_for_provider(
                getattr(cls, "provider_key", "").lower(),
                settings_snapshot=settings_snapshot,
            )
            max_tokens = compute_max_tokens(
                settings_snapshot=settings_snapshot,
                context_window_size=context_window_size,
            )
            if max_tokens:  # Treat 0 as unset (matches legacy behavior)
                llm_params["max_tokens"] = max_tokens
        except NoSettingsContextError:
            pass

        return ChatOpenAI(**llm_params)

    @classmethod
    def is_available(cls, settings_snapshot=None):
        """Check if this provider is available.

        This base implementation is a *configuration* check only — it does
        not probe the server. Local optional-key providers (LM Studio,
        llama.cpp) override this with an HTTP reachability probe, so the
        ``api_key_optional`` branch below is effectively reached only by an
        optional-key provider that does NOT override is_available(); for
        such a provider "configured" reduces to "available" and the
        placeholder key is used at construction time.

        Args:
            settings_snapshot: Optional settings snapshot to use

        Returns:
            True if API key is configured (or not needed), False otherwise.
        """
        # Provider has no key concept at all → available.
        # Provider with optional key but no key configured → available
        # (the placeholder will be used at construction time).
        # Provider with required key → available iff a real key is set.
        if not cls.api_key_setting or cls.api_key_optional:
            return True
        return cls.has_api_key(settings_snapshot=settings_snapshot)

    @classmethod
    def requires_auth_for_models(cls):
        """Check if this provider requires authentication for listing models.

        Override in subclasses that don't require auth.

        Returns:
            True if authentication is required, False otherwise
        """
        return True

    # Resolves base URL from settings; called by list_models().
    @classmethod
    def _get_base_url_for_models(cls, settings_snapshot=None):
        """Get the base URL to use for listing models.

        Reads from url_setting if defined, otherwise uses default_base_url.

        Args:
            settings_snapshot: Optional settings snapshot dict

        Returns:
            The base URL string to use for model listing
        """
        from ...config.thread_settings import get_setting_from_snapshot

        if cls.url_setting:
            # Use get_setting_from_snapshot which handles both settings_snapshot
            # and thread-local context, with proper fallback
            url = get_setting_from_snapshot(
                cls.url_setting,
                default=None,
                settings_snapshot=settings_snapshot,
            )
            if url:
                return url.rstrip("/")

        return cls.default_base_url

    @classmethod
    def list_models_for_api(cls, api_key=None, base_url=None):
        """List available models for API endpoint use.

        This method is designed to be called from Flask routes.

        Args:
            api_key: Optional API key (if None and required, returns empty list)
            base_url: Optional base URL to use (if None, uses cls.default_base_url)

        Returns:
            List of model dictionaries with 'value' and 'label' keys
        """
        try:
            # Defense-in-depth: never send a non-string credential to the SDK.
            # The OpenAI client coerces the api_key into "Authorization: Bearer
            # <repr(api_key)>" — passing a dict would leak its contents to the
            # endpoint we're listing models from.
            if api_key is not None and not isinstance(api_key, str):
                logger.error(
                    f"{cls.provider_name}.list_models_for_api received "
                    f"non-string api_key of type {type(api_key).__name__}; "
                    f"refusing to send."
                )
                return []

            # Check if auth is required
            if cls.requires_auth_for_models():
                if not api_key:
                    logger.debug(
                        f"{cls.provider_name} requires API key for model listing"
                    )
                    return []
            else:
                # Use a dummy key for providers that don't require auth
                api_key = api_key or "dummy-key-for-models-list"

            from openai import OpenAI

            # Use provided base_url or fall back to class default
            if not base_url:
                base_url = cls.default_base_url

            # SSRF guard for operator-configurable base_url, symmetric with
            # the create_llm guard above. The OpenAI SDK client uses its own
            # httpx transport that bypasses safe_requests, so an attacker who
            # can edit cls.url_setting could otherwise point model-listing at
            # internal/cloud-credential endpoints. Skip when url_setting is
            # None (providers with a hardcoded default_base_url have no
            # operator URL to attack). On rejection, degrade gracefully and
            # return [] — model-listing should not 500.
            if base_url and cls.url_setting:
                try:
                    base_url = assert_base_url_safe(
                        base_url, setting_key=cls.url_setting
                    )
                except ValueError:
                    logger.warning(
                        f"{cls.provider_name} base_url failed SSRF "
                        f"validation; check {cls.url_setting} config"
                    )
                    return []

            # Create OpenAI client (uses library defaults for timeout)
            client = OpenAI(api_key=api_key, base_url=base_url)

            # Fetch models
            logger.debug(
                f"Fetching models from {cls.provider_name} at {base_url}"
            )
            models_response = client.models.list()

            models = []
            for model in models_response.data:
                if model.id:
                    models.append(
                        {
                            "value": model.id,
                            "label": model.id,
                        }
                    )

            logger.info(f"Found {len(models)} models from {cls.provider_name}")
            return models

        except Exception:
            # Use warning level since connection failures are expected
            # when the provider is not running (e.g., LM Studio not started)
            logger.warning(f"Could not list models from {cls.provider_name}")
            return []

    # High-level settings-aware wrapper around list_models_for_api().
    # Documented in docs/developing/EXTENDING.md as the provider interface
    # for custom providers.
    @classmethod
    def list_models(cls, settings_snapshot=None):
        """List available models from this provider.

        Args:
            settings_snapshot: Optional settings snapshot to use

        Returns:
            List of model dictionaries with 'value' and 'label' keys
        """
        from ...config.thread_settings import get_setting_from_snapshot

        try:
            # Get API key from settings if auth is required
            api_key = None
            if cls.requires_auth_for_models():
                api_key = get_setting_from_snapshot(
                    cls.api_key_setting,
                    default=None,
                    settings_snapshot=settings_snapshot,
                )

            # Get base URL from settings if provider has configurable URL
            base_url = cls._get_base_url_for_models(settings_snapshot)

            return cls.list_models_for_api(api_key, base_url)

        except Exception as e:
            # Upstream exception messages (e.g., requests.HTTPError, OpenAI
            # SDK errors from a subclass that builds the URL with the key in
            # a query parameter) can embed the api_key value. Use
            # logger.warning rather than logger.exception so the cause chain
            # (which may also carry the URL) is not written to log sinks.
            safe_msg = redact_secrets(str(e), api_key)
            logger.warning(
                f"Error listing models from {cls.provider_name}: {safe_msg}"
            )
            return []
