# utilities/llm_utils.py
"""
LLM utilities for Local Deep Research.

This module provides utility functions for working with language models
when the user's llm_config.py is missing or incomplete.
"""

from loguru import logger
from typing import Any, Optional, Dict

from ..config.constants import DEFAULT_OLLAMA_URL
from ..config.thread_settings import get_setting_from_snapshot


__all__ = [
    "get_model_identifier",
    "get_ollama_base_url",
    "get_server_url",
    "fetch_ollama_models",
]


def get_model_identifier(llm: Any) -> str:
    """Return a stable string identifier for an LLM instance.

    The identifier is used as a cache key: `Journal.quality_model` records
    which LLM scored a cached journal, and the lookup predicate filters on
    it so scores from a superseded model don't get served.

    Discovery order:
      1. Unwrap `ProcessingLLMWrapper` (or any wrapper exposing `.base_llm`)
         so we key on the underlying model, not the wrapper identity.
      2. Prefer `model_name` (some LangChain classes). Then `model`
         (ChatOpenAI, ChatAnthropic, ChatOllama use this). Fallback to the
         class name so we never return an ephemeral `repr(object)` that
         poisons the cache.

    Returns a plain string; never None. Values written by `getattr(llm,
    "name", str(llm))` in earlier versions (e.g. `"<ProcessingLLMWrapper
    object at 0x…>"`) naturally miss this cache and re-score once.
    """
    base = getattr(llm, "base_llm", llm)
    for attr in ("model_name", "model"):
        val = getattr(base, attr, None)
        if val:
            return str(val)
    return type(base).__name__


def _close_base_llm(llm):
    """Close per-instance HTTP clients on a raw LLM. Internal use only.

    Only ChatOllama creates per-instance httpx.Client objects.
    ChatAnthropic and ChatOpenAI use @lru_cache'd shared httpx clients
    that must NOT be closed.

    ChatOllama owns both ``_client`` (sync ``ollama.Client`` wrapping
    ``httpx.Client``) and ``_async_client`` (async ``ollama.AsyncClient``
    wrapping ``httpx.AsyncClient``). Async paths via ``ainvoke()``
    (exercised by browsecomp_entity_strategy, llm_driven_modular_strategy,
    modular_strategy) leak the async transport per call if only the sync
    side is released — investigated in #3816 where ~72% of leaked FDs were
    ``a_inode [eventpoll]`` selectors bound to those async clients.

    Idempotent via an ``_ldr_closed`` sentinel on the inner httpx clients.
    The async path uses ``asyncio.run()`` only when no event loop is
    currently running; when called from inside async code (e.g. an
    ``async def`` finalizer hypothetically) we skip and rely on the loop
    owner to close it.
    """
    # If the llm is another wrapper with its own close(), delegate.
    # NOTE: if a future ChatOllama version adds a public close() method,
    # this short-circuit fires and the introspection below is skipped —
    # that future close() must then handle BOTH sync AND async clients.
    if hasattr(type(llm), "close"):
        llm.close()
        return

    # Sync side: ollama.Client._client is an httpx.Client.
    # ``_ldr_closed is True`` (not just truthy) so we don't trip on Mock
    # objects without a spec, where attribute access auto-generates a child
    # Mock that is truthy by default.
    sync_ollama = getattr(llm, "_client", None)
    if sync_ollama is not None and type(sync_ollama).__module__.startswith(
        "ollama"
    ):
        sync_httpx = getattr(sync_ollama, "_client", None)
        if (
            sync_httpx is not None
            and getattr(sync_httpx, "_ldr_closed", None) is not True
            and hasattr(sync_httpx, "close")
        ):
            try:
                sync_httpx.close()
            except Exception:
                logger.warning("Failed to close Ollama sync httpx client")
            sync_httpx._ldr_closed = True

    # Async side: ollama.AsyncClient._client is an httpx.AsyncClient
    async_ollama = getattr(llm, "_async_client", None)
    if async_ollama is not None and type(async_ollama).__module__.startswith(
        "ollama"
    ):
        async_httpx = getattr(async_ollama, "_client", None)
        if (
            async_httpx is not None
            and getattr(async_httpx, "_ldr_closed", None) is not True
            and hasattr(async_httpx, "aclose")
        ):
            import asyncio

            try:
                asyncio.get_running_loop()
                # Inside a running loop: skip. Fire-and-forget tasks are
                # unreliable from a finally block, and we cannot block here.
                # The loop owner is responsible for closing the client.
                # Don't mark _ldr_closed — a later sync call should still
                # try to close.
            except RuntimeError:
                # No running loop: spin a temporary one to await aclose.
                try:
                    asyncio.run(async_httpx.aclose())
                except Exception:
                    logger.warning("Failed to close Ollama async httpx client")
                async_httpx._ldr_closed = True


def get_ollama_base_url(
    settings_snapshot: Optional[Dict[str, Any]] = None,
) -> str:
    """
    Get Ollama base URL from settings with normalization.

    Checks both embeddings.ollama.url and llm.ollama.url settings,
    falling back to http://localhost:11434.

    Args:
        settings_snapshot: Optional settings snapshot

    Returns:
        Normalized Ollama base URL
    """
    from .url_utils import normalize_url

    raw_base_url = get_setting_from_snapshot(
        "embeddings.ollama.url",
        default=get_setting_from_snapshot(
            "llm.ollama.url",  # Fall back to LLM setting
            default=DEFAULT_OLLAMA_URL,
            settings_snapshot=settings_snapshot,
        ),
        settings_snapshot=settings_snapshot,
    )
    return normalize_url(raw_base_url) if raw_base_url else DEFAULT_OLLAMA_URL


def get_server_url(settings_snapshot: Optional[Dict[str, Any]] = None) -> str:
    """
    Get server URL from settings with fallback logic.

    Checks multiple sources in order:
    1. Direct server_url in settings snapshot
    2. system.server_url in settings
    3. Constructs from web.host, web.port, and web.use_https
    4. Fallback to http://127.0.0.1:5000/

    Args:
        settings_snapshot: Optional settings snapshot

    Returns:
        Server URL with trailing slash
    """

    server_url = None

    if settings_snapshot:
        # Try to get server URL from research metadata first (where we added it)
        server_url = settings_snapshot.get("server_url")

        # If not found, try system settings
        if not server_url:
            system_settings = settings_snapshot.get("system", {})
            server_url = system_settings.get("server_url")

        # If not found, try web.host and web.port settings
        if not server_url:
            host = get_setting_from_snapshot(
                "web.host", settings_snapshot, "127.0.0.1"
            )
            port = get_setting_from_snapshot(
                "web.port", settings_snapshot, 5000
            )
            use_https = get_setting_from_snapshot(
                "web.use_https", settings_snapshot, True
            )

            # Use localhost for 0.0.0.0 bindings as that's what users will use
            if host == "0.0.0.0":
                host = "127.0.0.1"

            scheme = "https" if use_https else "http"
            server_url = f"{scheme}://{host}:{port}/"

    # Fallback to default if still not found
    if not server_url:
        server_url = "http://127.0.0.1:5000/"
        logger.warning("Could not determine server URL, using default")

    return server_url


def fetch_ollama_models(
    base_url: str,
    timeout: float = 3.0,
    auth_headers: Optional[Dict[str, str]] = None,
) -> list[Dict[str, str]]:
    """
    Fetch available models from Ollama API.

    Centralized function to avoid duplication between LLM and embedding providers.

    Args:
        base_url: Ollama base URL (should be normalized)
        timeout: Request timeout in seconds
        auth_headers: Optional authentication headers

    Returns:
        List of model dicts with 'value' (model name) and 'label' (display name) keys.
        Returns empty list on error.
    """
    from ..security import safe_get

    models = []

    try:
        response = safe_get(
            f"{base_url}/api/tags",
            timeout=timeout,
            headers=auth_headers or {},
            allow_localhost=True,
            allow_private_ips=True,
        )

        if response.status_code == 200:
            data = response.json()

            # Handle both newer and older Ollama API formats
            ollama_models = (
                data.get("models", []) if isinstance(data, dict) else data
            )

            for model_data in ollama_models:
                model_name = model_data.get("name", "")
                if model_name:
                    models.append({"value": model_name, "label": model_name})

            logger.info(f"Found {len(models)} Ollama models")
        else:
            logger.warning(
                f"Failed to fetch Ollama models: HTTP {response.status_code}"
            )

    except Exception:
        logger.exception("Error fetching Ollama models")

    return models
