# 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.
"""

import asyncio
import threading
from typing import Any, Optional, Dict

from loguru import logger

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 or embeddings instance.
    Internal use only.

    Applies to every ``langchain_ollama`` class — ``ChatOllama``,
    ``OllamaLLM``, and ``OllamaEmbeddings`` all carry the same
    ``_client`` / ``_async_client`` shape, eagerly constructed at instance
    init by a Pydantic ``@model_validator(mode="after")``. ``ChatAnthropic``
    and ``ChatOpenAI`` use ``@lru_cache``'d shared httpx clients that must
    NOT be closed; the ``ollama``-module check below short-circuits cleanly
    for them. Same skip applies to local providers like
    ``HuggingFaceEmbeddings`` / ``SentenceTransformerEmbeddings`` (no
    per-instance httpx client at all).

    Each Ollama class 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. The
    same shape reappeared for embeddings after the langchain_community →
    langchain_ollama migration (#4352/#4353); see the resource-cleanup doc
    for the post-mortem.

    Idempotent via an ``_ldr_closed`` sentinel on the inner httpx clients.

    The async path always runs ``aclose()`` to completion: when no event
    loop is currently running we use ``asyncio.run()`` directly; when a
    loop is running in the calling thread (e.g. ``_close_base_llm`` is
    invoked inside async code or in a ``finally`` block reached through
    LangGraph/LangChain async dispatch) we hand the close off to a brief
    daemon thread whose own ``asyncio.run()`` is unaffected by the
    caller's loop state. A prior implementation skipped the close in
    that case and relied on the "loop owner" to close — but no loop
    owner code actually does, so the ``httpx.AsyncClient`` and its
    ``epoll_create`` FD were silently leaked. See the regression history
    in ``docs/developing/resource-cleanup.md`` (this is the gap left by
    #3855 when reaching for the in-async-context close).
    """
    # 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")
        ):
            try:
                asyncio.get_running_loop()
            except RuntimeError:
                # No running loop in this thread: spin a temporary one
                # to await aclose() right here.
                try:
                    asyncio.run(async_httpx.aclose())
                except Exception:
                    logger.warning("Failed to close Ollama async httpx client")
                # Mark closed unconditionally — matches the sync-side
                # invariant: on a known-broken close we don't want to
                # retry endlessly. The WARNING above is the signal.
                async_httpx._ldr_closed = True
            else:
                # A loop is running in this thread. ``asyncio.run`` cannot
                # be called here and fire-and-forget tasks scheduled on the
                # caller's loop from a finally block are unreliable (the
                # loop may exit before the task is awaited). Hand the close
                # off to a brief daemon thread whose own loop is
                # independent of ours; cap with a bounded ``join`` so a
                # stuck server can't hold up shutdown.

                def _close_in_thread() -> None:
                    try:
                        asyncio.run(async_httpx.aclose())
                    except Exception:
                        logger.warning(
                            "Failed to close Ollama async httpx client "
                            "in cleanup thread"
                        )

                t = threading.Thread(
                    target=_close_in_thread,
                    daemon=True,
                    name="ldr-async-llm-close",
                )
                t.start()
                t.join(timeout=5)
                if t.is_alive():
                    # Don't set ``_ldr_closed`` — the cleanup thread is
                    # still running and the FD is therefore still open.
                    # Surface at WARNING so operators can correlate
                    # against rising eventpoll-FD counts. A subsequent
                    # call to _close_base_llm will retry the close.
                    logger.warning(
                        "Async httpx close exceeded 5s; abandoning to GC. "
                        "If this fires repeatedly, check Ollama server "
                        "responsiveness and look for rising "
                        "anon_inode:[eventpoll] FDs on the process."
                    )
                else:
                    # Thread completed (with or without an inner
                    # exception). Mark closed to match the sync-side
                    # invariant; the inner exception, if any, was
                    # already logged from inside the thread.
                    async_httpx._ldr_closed = True


def _close_inner_ollama_clients(sync_client, async_client):
    """Close just the inner sync/async ``ollama.Client`` pair.

    A ``weakref.finalize`` callback that strong-refs the wrapping LLM or
    embeddings instance would defeat its own purpose — the registry's
    reference would keep the instance alive forever. Callers (the Ollama
    provider factory's safety net) pass the inner clients directly
    instead. This shim wraps them in a ``_close_base_llm``-shaped proxy
    so we reuse the same idempotent sync+async close logic without
    duplicating its asyncio/eventpoll handling.
    """

    class _Proxy:
        pass

    proxy = _Proxy()
    proxy._client = sync_client
    proxy._async_client = async_client
    _close_base_llm(proxy)


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
