from typing import Any

from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage
from loguru import logger

from ..llm import get_llm_from_registry, is_llm_registered
from ..security.log_sanitizer import redact_secrets, sanitize_error_message
from ..utilities.search_utilities import remove_think_tags

# Import providers module to trigger auto-discovery. get_llm() has no
# fallback construction path: if this import fails (e.g. a broken
# langchain install), the module must fail loudly here rather than start
# with an empty registry and confusing per-call errors.
from ..llm.providers import discover_providers  # noqa: F401
from ..llm.providers.base import normalize_provider
from .thread_settings import get_setting_from_snapshot


def get_selected_llm_provider(settings_snapshot=None):
    return normalize_provider(
        get_setting_from_snapshot(
            "llm.provider", "ollama", settings_snapshot=settings_snapshot
        )
    )


def _get_context_window_for_provider(provider_type, settings_snapshot=None):
    """Resolve the context window size for a provider.

    Thin wrapper around the canonical
    ``llm/providers/_helpers.get_context_window_for_provider`` so the
    provider ``create_llm`` path and this path share a single source of
    truth (the two were previously identical copies). Kept as a named
    function because ``get_llm``/``wrap_llm_without_think_tags`` and tests
    reference it by name and signature.

    NOTE: the helper reads settings through
    ``thread_settings.get_setting_from_snapshot`` (a function-local import),
    so tests exercising context-window resolution must patch
    ``...config.thread_settings.get_setting_from_snapshot`` rather than
    ``...config.llm_config.get_setting_from_snapshot``.

    Returns:
        int or None: The context window size, or None for unrestricted cloud providers.
    """
    from ..llm.providers._helpers import get_context_window_for_provider

    return get_context_window_for_provider(
        provider_type, settings_snapshot=settings_snapshot
    )


def get_llm(
    model_name=None,
    temperature=None,
    provider=None,
    openai_endpoint_url=None,
    research_id=None,
    research_context=None,
    settings_snapshot=None,
):
    """
    Get LLM instance based on model name and provider.

    Args:
        model_name: Name of the model to use (if None, uses database setting)
        temperature: Model temperature (if None, uses database setting)
        provider: Provider to use (if None, uses database setting)
        openai_endpoint_url: NON-FUNCTIONAL, kept for API compatibility.
            The endpoint URL is read exclusively from the
            ``llm.openai_endpoint.url`` setting by
            CustomOpenAIEndpointProvider. This parameter was already
            ignored before the procedural-chain removal (the registry
            dispatch never consumed it); honoring or removing it is
            tracked as a follow-up.
        research_id: Optional research ID for token tracking
        research_context: Optional research context for enhanced token tracking

    Returns:
        A LangChain LLM instance with automatic think-tag removal
    """

    # Use database values for parameters if not provided
    if model_name is None:
        model_name = get_setting_from_snapshot(
            "llm.model", "", settings_snapshot=settings_snapshot
        )
    if temperature is None:
        temperature = get_setting_from_snapshot(
            "llm.temperature", 0.7, settings_snapshot=settings_snapshot
        )
    if provider is None:
        provider = get_setting_from_snapshot(
            "llm.provider", "ollama", settings_snapshot=settings_snapshot
        )

    # Clean model name: remove quotes and extra whitespace
    if model_name:
        model_name = model_name.strip().strip("\"'").strip()

    # Clean provider: remove quotes and extra whitespace
    if provider:
        provider = provider.strip().strip("\"'").strip()

    # Normalize provider: convert to lowercase canonical form
    provider = normalize_provider(provider)

    # Egress policy PEP for LLM endpoints. Fires here (before the registered-
    # LLM dispatch) because all built-in providers are auto-registered via
    # discover_providers(), so the registered branch handles every real LLM
    # call.
    #
    # Two paths: snapshot-present runs the full PEP; snapshot-absent runs
    # an allow-list check so background helpers / scaffolding paths cannot
    # silently instantiate a cloud LLM. The allow-list (known-local) is
    # deliberately tight — any provider not in it (including ambiguous
    # ones like ``openai_endpoint`` and future cloud providers) fails
    # closed instead of bypassing the PEP.
    if provider:
        from ..security.egress.policy import (
            Decision,
            PolicyDeniedError,
            _LOCAL_DEFAULT_LLM_PROVIDERS,
            _is_user_registered_llm,
            context_from_snapshot,
            evaluate_llm_endpoint,
            resolve_run_primary_engine,
        )

        if settings_snapshot is None:
            # User-registered in-process LLMs are exempt here for the same
            # reason evaluate_llm_endpoint allows them: no endpoint to
            # classify, operator-injected, audit-hook backstopped.
            if provider not in _LOCAL_DEFAULT_LLM_PROVIDERS and not (
                _is_user_registered_llm(provider)
            ):
                logger.bind(policy_audit=True).warning(
                    "LLM constructed without policy snapshot; refusing "
                    "non-local provider",
                    provider=provider,
                )
                raise PolicyDeniedError(
                    Decision(False, "no_snapshot_for_provider"),
                    target=provider,
                )
        else:
            try:
                # Derive the run's primary the SAME way the search-engine
                # factory does (single source of truth), instead of the old
                # ``search.tool`` + searxng fallback. That fallback was a
                # fail-OPEN: a missing/blank primary defaulted to searxng ->
                # ADAPTIVE -> PUBLIC_ONLY -> require_local_llm stayed False ->
                # the endpoint check below was skipped, admitting a CLOUD LLM
                # for a run whose actual posture was private. resolve_run_
                # primary_engine raises on a missing/invalid primary, which we
                # treat as a hard stop here.
                primary_engine = resolve_run_primary_engine(settings_snapshot)
                ctx = context_from_snapshot(settings_snapshot, primary_engine)
            except ValueError as exc:
                # No configured primary, or an invalid policy config. Fail
                # closed: previously a missing primary silently fell back to
                # searxng/PUBLIC_ONLY and skipped the LLM endpoint check
                # entirely, opening a cloud-LLM bypass under the very
                # configuration the user asked to be strict about.
                logger.bind(policy_audit=True).warning(
                    "no/invalid egress policy primary; refusing LLM",
                    provider=provider,
                    reason=str(exc),
                )
                raise PolicyDeniedError(
                    Decision(False, "invalid_policy_config"),
                    target=provider,
                ) from exc

            if ctx is not None and ctx.require_local_llm:
                decision = evaluate_llm_endpoint(
                    provider, ctx, settings_snapshot=settings_snapshot
                )
                if not decision.allowed:
                    logger.bind(policy_audit=True).warning(
                        "LLM endpoint denied by egress policy",
                        provider=provider,
                        reason=decision.reason,
                    )
                    raise PolicyDeniedError(decision, target=provider)

    # Check if this is a registered custom LLM first
    if provider and is_llm_registered(provider):
        logger.info(f"Using registered custom LLM: {provider}")
        custom_llm = get_llm_from_registry(provider)

        # Check if it's a callable (factory function) or a BaseChatModel instance
        if callable(custom_llm) and not isinstance(custom_llm, BaseChatModel):
            # It's a callable (factory function), call it with parameters
            try:
                llm_instance = custom_llm(
                    model_name=model_name,
                    temperature=temperature,
                    settings_snapshot=settings_snapshot,
                )
            except TypeError as e:
                # Re-raise TypeError with better message
                raise TypeError(
                    f"Registered LLM factory '{provider}' has invalid signature. "
                    f"Factory functions must accept 'model_name', 'temperature', and 'settings_snapshot' parameters. "
                    f"Error: {e}"
                )

            # Validate the result is a BaseChatModel
            if not isinstance(llm_instance, BaseChatModel):
                raise ValueError(
                    f"Factory function for {provider} must return a BaseChatModel instance, "
                    f"got {type(llm_instance).__name__}"
                )
        elif isinstance(custom_llm, BaseChatModel):
            # It's already a proper LLM instance, use it directly
            llm_instance = custom_llm
        else:
            raise ValueError(
                f"Registered LLM {provider} must be either a BaseChatModel instance "
                f"or a callable factory function. Got: {type(custom_llm).__name__}"
            )

        return wrap_llm_without_think_tags(
            llm_instance,
            research_id=research_id,
            provider=provider,
            research_context=research_context,
            settings_snapshot=settings_snapshot,
        )

    # Validate the provider against the auto-discovered set — NOT a hardcoded
    # list. Auto-discovery (discover_providers, run at module import) registers
    # every llm/providers/implementations/*.py class, and the
    # is_llm_registered() check above already serves them, so the registry /
    # discovery IS the single source of truth for "valid provider".
    #
    # We deliberately do not keep a separate VALID_PROVIDERS constant: it was a
    # third copy of the provider list (besides the implementations directory
    # and the registry) and it silently drifted from auto-discovery (xai,
    # ionos and deepseek were valid+registered yet missing from it). Deriving
    # the set from discovery here means it can never drift. ('none' is the
    # explicit "unset" sentinel, handled by the guard further down.)
    from ..llm.providers import get_discovered_provider_options

    valid_providers = {
        normalize_provider(option["value"])
        for option in get_discovered_provider_options()
    } | {"none"}
    if provider not in valid_providers:
        logger.error(f"Invalid provider in settings: {provider}")
        raise ValueError(
            f"Invalid provider: {provider}. "
            f"Must be one of: {sorted(valid_providers)}"
        )

    # Require an explicit model for built-in providers. Mirrors the
    # API-key-not-configured pattern in openai_base.py and the URL-not-
    # configured pattern in providers/implementations/ollama.py: no silent
    # substitution to a hardcoded default model.
    if not model_name or not model_name.strip():
        logger.error("llm.model is not configured (empty/None after lookup)")
        raise ValueError(
            "LLM model not configured. Please open Settings, choose an LLM "
            "provider, and select a model name (e.g. 'gpt-4o-mini' for "
            "OpenAI, 'claude-3-5-sonnet-20241022' for Anthropic, "
            "'llama3.1:8b' for Ollama). The 'llm.model' setting is required."
        )
    logger.info(
        f"Getting LLM with model: {model_name}, temperature: {temperature}, provider: {provider}"
    )

    # Set context_limit on research_context for overflow detection. The
    # actual max_tokens calculation lives in each provider's create_llm
    # (via providers/_helpers.compute_max_tokens) so the cap is consistent
    # across the live registered-LLM path.
    context_window_size = _get_context_window_for_provider(
        provider, settings_snapshot
    )
    if research_context and context_window_size:
        research_context["context_limit"] = context_window_size
        logger.info(
            f"Set context_limit={context_window_size} in research_context"
        )
    else:
        logger.debug(
            f"Context limit not set: research_context={bool(research_context)}, "
            f"context_window_size={context_window_size}"
        )

    # Reaching here means the registered-LLM branch above did NOT fire,
    # which is unusual — auto-discovery normally registers all 6+ built-in
    # providers (anthropic, openai, openai_endpoint, ollama, lmstudio,
    # llamacpp + xai/ionos/openrouter via OpenAICompatibleProvider) at
    # import time. Two specific guards preserve the user-facing error
    # messages for known-bad cases.
    if provider == "none":
        raise ValueError(
            "No LLM provider configured. Please set llm.provider in settings "
            "to a valid provider (e.g., 'ollama', 'openai', 'anthropic')."
        )
    raise ValueError(
        f"Provider '{provider}' was not registered by auto-discovery. "
        f"This usually indicates an import error during startup — check the "
        f"logs for 'Error loading provider from <module>' messages. "
        f"Note that clear_llm_registry()/unregister_llm() also remove "
        f"built-in providers; discover_providers(force_refresh=True) "
        f"restores them."
    )


def _log_llm_error(error: Exception) -> None:
    """Log an LLM call failure with credential redaction."""
    safe_msg = redact_secrets(sanitize_error_message(str(error)))
    logger.warning(f"LLM Request - Failed with error: {safe_msg}")


def wrap_llm_without_think_tags(
    llm,
    research_id=None,
    provider=None,
    research_context=None,
    settings_snapshot=None,
):
    """Create a wrapper class that processes LLM outputs with remove_think_tags and token counting"""

    # First apply rate limiting if enabled
    from ..web_search_engines.rate_limiting.llm import (
        create_rate_limited_llm_wrapper,
    )

    # Check if LLM rate limiting is enabled (independent of search rate limiting)
    # Use the thread-safe get_db_setting defined in this module
    if get_setting_from_snapshot(
        "rate_limiting.llm_enabled", False, settings_snapshot=settings_snapshot
    ):
        llm = create_rate_limited_llm_wrapper(llm, provider)

    # Set context_limit in research_context for overflow detection.
    # This is needed for providers that go through the registered provider path
    # (which returns before the code in get_llm that sets context_limit).
    if research_context is not None and provider is not None:
        if "context_limit" not in research_context:
            context_limit = _get_context_window_for_provider(
                provider, settings_snapshot
            )
            if context_limit is not None:
                research_context["context_limit"] = context_limit
                logger.info(
                    f"Set context_limit={context_limit} in wrap_llm for provider={provider}"
                )

    # Import token counting functionality if research_id is provided
    callbacks = []
    if research_id is not None:
        from ..metrics import TokenCounter

        token_counter = TokenCounter()
        token_callback = token_counter.create_callback(
            research_id, research_context
        )
        # Set provider and model info on the callback
        if provider:
            token_callback.preset_provider = provider
        # Try to extract model name from the LLM instance
        if hasattr(llm, "model_name"):
            token_callback.preset_model = llm.model_name
        elif hasattr(llm, "model"):
            token_callback.preset_model = llm.model
        callbacks.append(token_callback)

    # Add callbacks to the LLM if it supports them
    if callbacks and hasattr(llm, "callbacks"):
        if llm.callbacks is None:
            llm.callbacks = callbacks
        else:
            llm.callbacks.extend(callbacks)

    class ProcessingLLMWrapper:
        def __init__(self, base_llm):
            self.base_llm = base_llm

        @staticmethod
        def _normalize_response(response: Any) -> Any:
            """Strip <think> tags and normalize the response shape.

            This is the SINGLE authorized place that strips <think> tags from a
            fresh LLM response. Every LLM from get_llm() is wrapped here, so
            downstream code can use ``response.content`` directly — do NOT add
            per-site ``remove_think_tags`` / ``get_llm_response_text`` calls on
            fresh ``invoke``/``ainvoke`` results; they are redundant and hide
            bugs. (Exceptions: agent/bind_tools paths bypass this wrapper, and
            injected/unwrapped LLMs — those may still need explicit handling.)

            A message keeps its object identity (only ``.content`` is rewritten,
            so ``additional_kwargs``/``reasoning_content``/``tool_calls`` survive).
            A bare-string return (some providers/wrappers) is wrapped into an
            ``AIMessage`` so callers can always rely on ``.content``. Anything
            else is passed through unchanged.

            Only *string* ``.content`` is stripped: ``remove_think_tags`` is
            text-only, and the ``<think>...</think>`` artifact is only ever
            emitted as plain text by some local models. Non-string content
            (e.g. provider content-block lists like Anthropic's, or ``None``)
            is passed through untouched — running the regex on it would raise
            ``TypeError`` or corrupt the structured content.
            """
            if hasattr(response, "content"):
                if isinstance(response.content, str):
                    response.content = remove_think_tags(response.content)
            elif isinstance(response, str):
                response = AIMessage(content=remove_think_tags(response))
            return response

        @staticmethod
        def _log_llm_error(error: Exception) -> None:
            _log_llm_error(error)

        def invoke(self, *args: Any, **kwargs: Any) -> Any:
            try:
                response = self.base_llm.invoke(*args, **kwargs)
            except Exception as e:
                self._log_llm_error(e)
                raise
            return self._normalize_response(response)

        async def ainvoke(self, *args: Any, **kwargs: Any) -> Any:
            # Async counterpart of invoke(); without this, ainvoke() would fall
            # through __getattr__ to the base LLM and bypass think-tag stripping.
            try:
                response = await self.base_llm.ainvoke(*args, **kwargs)
            except Exception as e:
                self._log_llm_error(e)
                raise
            return self._normalize_response(response)

        # Pass through any other attributes to the base LLM
        def __getattr__(self, name):
            return getattr(self.base_llm, name)

        def close(self):
            """Close underlying HTTP clients held by this LLM. Idempotent."""
            try:
                from ..utilities.llm_utils import _close_base_llm

                _close_base_llm(self.base_llm)
            except Exception:
                logger.debug(
                    "best-effort cleanup of HTTP clients on shutdown",
                    exc_info=True,
                )

    return ProcessingLLMWrapper(llm)
