"""Tests for ``_close_base_llm`` covering both sync and async httpx clients.

Background (issue #3816): ``ChatOllama`` owns both ``_client`` (sync
``ollama.Client`` wrapping ``httpx.Client``) and ``_async_client``
(async ``ollama.AsyncClient`` wrapping ``httpx.AsyncClient``). Earlier
versions of ``_close_base_llm`` only closed the sync side, leaking the
async transport per ``ainvoke()`` call — visible as ``a_inode [eventpoll]``
FDs in the issue's lsof dump.
"""

import asyncio
import gc
import os
import resource
import sys
from unittest.mock import Mock

import httpx
import pytest

from local_deep_research.utilities.llm_utils import _close_base_llm


def _open_fd_count() -> int:
    """Test-local file-descriptor counter.

    Inlined here to avoid coupling these tests to a private helper in
    an unrelated production module. On Linux uses ``/proc/self/fd``
    (fast); on macOS falls back to scanning ``RLIMIT_NOFILE``.
    """
    try:
        return len(os.listdir("/proc/self/fd"))
    except (FileNotFoundError, OSError):
        soft_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[0]
        count = 0
        for fd in range(soft_limit):
            try:
                os.fstat(fd)
                count += 1
            except OSError:
                pass
        return count


def _make_fake_chat_ollama(*, sync_close=None, async_aclose=None):
    """Build a stand-in for ``ChatOllama`` with the same private-attr shape.

    The module-string check in ``_close_base_llm`` looks at
    ``type(ollama_client).__module__`` — we set ``__module__`` on the mock's
    type to "ollama" so the introspection treats them as real ollama clients.
    """
    llm = Mock(spec=[])  # spec=[] means hasattr returns False for everything

    if sync_close is not None:
        sync_httpx = Mock(spec=["close"])
        sync_httpx.close = sync_close
        sync_ollama = type("FakeSyncOllama", (), {})()
        sync_ollama._client = sync_httpx
        type(sync_ollama).__module__ = "ollama"
        llm._client = sync_ollama
    else:
        llm._client = None

    if async_aclose is not None:
        async_httpx = Mock(spec=["aclose"])
        async_httpx.aclose = async_aclose
        async_ollama = type("FakeAsyncOllama", (), {})()
        async_ollama._client = async_httpx
        type(async_ollama).__module__ = "ollama"
        llm._async_client = async_ollama
    else:
        llm._async_client = None

    return llm


class TestCloseBaseLLMSync:
    """The sync httpx client must be closed on every call until idempotent."""

    def test_closes_sync_httpx_client(self):
        sync_close = Mock()
        llm = _make_fake_chat_ollama(sync_close=sync_close)

        _close_base_llm(llm)

        sync_close.assert_called_once()

    def test_idempotent_via_ldr_closed_flag(self):
        sync_close = Mock()
        llm = _make_fake_chat_ollama(sync_close=sync_close)

        _close_base_llm(llm)
        _close_base_llm(llm)
        _close_base_llm(llm)

        # Despite three calls, close runs once — _ldr_closed sentinel guards.
        sync_close.assert_called_once()

    def test_swallows_close_exception_and_still_marks_closed(self):
        sync_close = Mock(side_effect=RuntimeError("boom"))
        llm = _make_fake_chat_ollama(sync_close=sync_close)

        # Must not propagate; logs at warning.
        _close_base_llm(llm)

        sync_close.assert_called_once()
        # Subsequent call is skipped by _ldr_closed (no infinite retry).
        _close_base_llm(llm)
        sync_close.assert_called_once()

    def test_handles_missing_async_client_gracefully(self):
        # No _async_client attr at all — must not crash.
        sync_close = Mock()
        llm = _make_fake_chat_ollama(sync_close=sync_close)
        del llm._async_client  # simulate older ollama versions

        _close_base_llm(llm)

        sync_close.assert_called_once()


class TestCloseBaseLLMAsync:
    """The async httpx client must be closed via ``asyncio.run`` when no loop
    is running, and via a brief daemon thread when one is."""

    def test_closes_async_httpx_client_via_asyncio_run(self):
        called = {"count": 0}

        async def fake_aclose():
            called["count"] += 1

        llm = _make_fake_chat_ollama(async_aclose=fake_aclose)

        # No running loop here — _close_base_llm should spin one via
        # asyncio.run() and await aclose().
        _close_base_llm(llm)

        assert called["count"] == 1

    def test_async_close_is_idempotent(self):
        called = {"count": 0}

        async def fake_aclose():
            called["count"] += 1

        llm = _make_fake_chat_ollama(async_aclose=fake_aclose)

        _close_base_llm(llm)
        _close_base_llm(llm)

        # _ldr_closed sentinel prevents the second aclose.
        assert called["count"] == 1

    def test_closes_async_inside_running_loop_via_thread(self):
        """Regression for the v1.6.10 leak. ``_close_base_llm`` used to
        skip the async close when invoked inside a running asyncio loop
        and rely on a non-existent "loop owner" cleanup — so the inner
        ``httpx.AsyncClient`` (and its ``epoll_create`` FD) was silently
        abandoned. The current implementation must run the close in a
        brief daemon thread whose own ``asyncio.run`` is independent of
        the caller's loop.
        """
        called = {"count": 0}

        async def fake_aclose():
            called["count"] += 1

        llm = _make_fake_chat_ollama(async_aclose=fake_aclose)

        async def driver():
            _close_base_llm(llm)

        asyncio.run(driver())

        # aclose ran exactly once (via the cleanup thread, not skipped).
        assert called["count"] == 1
        # And _ldr_closed IS set on success — subsequent calls short-circuit.
        async_httpx = llm._async_client._client
        assert async_httpx._ldr_closed is True

    def test_in_loop_close_is_idempotent(self):
        """A close fired from inside a loop should set ``_ldr_closed`` just
        like the no-loop path, so repeat calls don't re-spawn the cleanup
        thread or re-run ``aclose``."""
        called = {"count": 0}

        async def fake_aclose():
            called["count"] += 1

        llm = _make_fake_chat_ollama(async_aclose=fake_aclose)

        async def driver():
            _close_base_llm(llm)
            _close_base_llm(llm)
            _close_base_llm(llm)

        asyncio.run(driver())

        assert called["count"] == 1

    def test_in_loop_close_timeout_does_not_mark_closed(self):
        """If the cleanup thread is still alive after the 5-second join
        (e.g. ``aclose`` is blocked on a stuck server), the sentinel must
        NOT be set so a later call can retry — and the FD leak is at
        least visible via WARNING log instead of silent drift.
        """
        import threading
        from unittest.mock import patch

        release = threading.Event()
        aclose_started = threading.Event()

        async def slow_aclose():
            aclose_started.set()
            # Block until released (or the test's shortened join fires).
            await asyncio.get_event_loop().run_in_executor(
                None, release.wait, 30
            )

        llm = _make_fake_chat_ollama(async_aclose=slow_aclose)

        original_thread = threading.Thread

        class _ShortJoinThread(original_thread):
            def join(self, timeout=None):
                # Tighten the production 5s wait to 200ms for the test
                # so we don't actually sit here for 5 seconds.
                return super().join(timeout=0.2)

        try:
            with patch("threading.Thread", _ShortJoinThread):

                async def driver():
                    _close_base_llm(llm)

                asyncio.run(driver())
        finally:
            release.set()

        # ``aclose`` started but the join timed out before it could
        # finish — sentinel must be unset so the FD is not silently
        # leaked (a subsequent _close_base_llm call should retry).
        assert aclose_started.is_set()
        async_httpx = llm._async_client._client
        assert not getattr(async_httpx, "_ldr_closed", False)

    def test_swallows_async_close_exception(self):
        async def fake_aclose():
            raise RuntimeError("boom")

        llm = _make_fake_chat_ollama(async_aclose=fake_aclose)

        # Must not propagate; logs at warning. _ldr_closed is set so we don't
        # retry endlessly on a known-broken close.
        _close_base_llm(llm)

        async_httpx = llm._async_client._client
        assert getattr(async_httpx, "_ldr_closed", False) is True


class TestCloseBaseLLMBoth:
    """Sync and async sides should both close in the common case."""

    def test_closes_both_sync_and_async(self):
        sync_close = Mock()
        async_called = {"count": 0}

        async def fake_aclose():
            async_called["count"] += 1

        llm = _make_fake_chat_ollama(
            sync_close=sync_close, async_aclose=fake_aclose
        )

        _close_base_llm(llm)

        sync_close.assert_called_once()
        assert async_called["count"] == 1


class TestCloseBaseLLMNonOllama:
    """Non-Ollama LLMs must be left alone. ChatAnthropic/ChatOpenAI use
    @lru_cache'd shared httpx clients that must NOT be closed."""

    def test_skips_non_ollama_module(self):
        llm = Mock(spec=[])
        non_ollama = type("OpenAIClient", (), {})()
        non_ollama._client = Mock()
        type(non_ollama).__module__ = "openai"  # not "ollama"
        llm._client = non_ollama
        llm._async_client = None

        _close_base_llm(llm)

        non_ollama._client.close.assert_not_called()

    def test_delegates_to_wrapper_close_method(self):
        # If the LLM type defines close(), delegate to that and skip
        # introspection. (Wrappers like ProcessingLLMWrapper take this path.)
        class FakeWrapper:
            def __init__(self):
                self.closed = False

            def close(self):
                self.closed = True

        wrapper = FakeWrapper()
        _close_base_llm(wrapper)

        assert wrapper.closed is True


class TestCloseBaseLLMRealHttpxAsync:
    """Empirical validation against a real ``httpx.AsyncClient`` — covers the
    actual #3816 leak shape: a client created inside one ``asyncio.run``
    (loop A) survives loop A's close and must be released by
    ``_close_base_llm`` spinning a fresh loop B. No Ollama server required.
    """

    @pytest.mark.skipif(
        sys.platform == "win32",
        reason="Linux/macOS-specific FD semantics",
    )
    def test_real_async_client_created_in_closed_loop_is_closed(self):
        async def _make():
            return httpx.AsyncClient()

        async_httpx = asyncio.run(_make())  # loop A constructs, then closes
        assert async_httpx.is_closed is False  # client survived loop A

        async_ollama = type("FakeAsyncOllama", (), {})()
        async_ollama._client = async_httpx
        type(async_ollama).__module__ = "ollama"
        llm = Mock(spec=[])
        llm._client = None
        llm._async_client = async_ollama

        _close_base_llm(llm)

        assert async_httpx.is_closed is True
        assert getattr(async_httpx, "_ldr_closed", False) is True

    @pytest.mark.skipif(
        sys.platform == "win32",
        reason="Linux/macOS-specific FD semantics",
    )
    def test_real_async_client_close_is_idempotent(self):
        async def _make():
            return httpx.AsyncClient()

        async_httpx = asyncio.run(_make())
        async_ollama = type("FakeAsyncOllama", (), {})()
        async_ollama._client = async_httpx
        type(async_ollama).__module__ = "ollama"
        llm = Mock(spec=[])
        llm._client = None
        llm._async_client = async_ollama

        _close_base_llm(llm)
        _close_base_llm(llm)  # sentinel short-circuits; must not raise

        assert async_httpx.is_closed is True

    @pytest.mark.fd_canary
    @pytest.mark.timeout(180)
    @pytest.mark.skipif(
        sys.platform == "win32",
        reason="Linux/macOS-specific FD semantics",
    )
    def test_no_fd_growth_across_repeated_close_cycles(self):
        # Unit-level analogue of the PR's manual `lsof | grep -c eventpoll`
        # smoke. A real per-cycle leak (~1 FD/iter as observed in #3816)
        # would push the delta well past the +8 slack.
        #
        # Sizing rationale (#4214 + #3816 follow-up):
        # Each cycle invokes ``asyncio.run`` twice — once to construct the
        # client in a (then-closed) loop A, once inside ``_close_base_llm``
        # to spin loop B for ``aclose()``. On Py 3.14 these transient loops
        # leave ambient FDs from selector/self-pipe lazy release. CI on the
        # 5-iter version of this test showed a deterministic +3 delta (58
        # of 58 recent failures), i.e. ~0.6 FDs/cycle steady-state — not
        # absorbable by a single warmup since it's per-cycle, not init-only.
        # At 20 iterations the expected ceiling is ~0.6 × 20 = ~12 in the
        # worst case (observed ~3–4 locally); the +8 slack covers the
        # typical range without masking a real per-iteration leak, which
        # would land at ≥+20 (1 FD/iter, #3816 shape) + ambient → far
        # above the threshold.
        async def _make():
            return httpx.AsyncClient()

        def _one_cycle():
            async_httpx = asyncio.run(_make())
            async_ollama = type("FakeAsyncOllama", (), {})()
            async_ollama._client = async_httpx
            type(async_ollama).__module__ = "ollama"
            llm = Mock(spec=[])
            llm._client = None
            llm._async_client = async_ollama

            _close_base_llm(llm)

            del llm, async_ollama, async_httpx

        # Warmup: one cycle before measuring absorbs one-time init drift
        # (lazy imports, logging handlers, asyncio internals first-init).
        _one_cycle()
        gc.collect()

        before = _open_fd_count()

        for _ in range(20):
            _one_cycle()
            gc.collect()

        gc.collect()
        after = _open_fd_count()

        assert after - before <= 8, (
            f"FD count climbed across close cycles: "
            f"before={before}, after={after}"
        )

    @pytest.mark.fd_canary
    @pytest.mark.timeout(120)
    @pytest.mark.skipif(
        sys.platform == "win32",
        reason="Linux/macOS-specific FD semantics",
    )
    def test_no_fd_growth_when_closed_inside_running_loop(self):
        """The user-facing regression. Before the fix, calling
        ``_close_base_llm`` inside a running loop silently skipped the
        async close — every iteration leaked ~1 ``epoll_create`` FD.
        After the fix, the cleanup thread closes the client even when
        a loop is running, so the FD count stays flat across iterations.
        """

        async def _close_from_inside_loop():
            async_httpx = httpx.AsyncClient()
            async_ollama = type("FakeAsyncOllama", (), {})()
            async_ollama._client = async_httpx
            type(async_ollama).__module__ = "ollama"
            llm = Mock(spec=[])
            llm._client = None
            llm._async_client = async_ollama

            # _close_base_llm is invoked while this loop is running —
            # the exact scenario that leaked before the fix.
            _close_base_llm(llm)

            assert async_httpx.is_closed is True

        # Warmup cycle to absorb one-time init drift (cleanup-thread
        # startup, lazy imports) — see the sibling repeated-close test.
        asyncio.run(_close_from_inside_loop())
        gc.collect()

        before = _open_fd_count()

        for _ in range(5):
            asyncio.run(_close_from_inside_loop())
            gc.collect()

        gc.collect()
        after = _open_fd_count()

        assert after - before <= 2, (
            f"FD count climbed across in-loop close cycles "
            f"(this is the #3816-shaped leak): "
            f"before={before}, after={after}"
        )


class TestCloseBaseLLMRealLangchain:
    """End-to-end regression coverage against the real langchain LLM classes
    users actually instantiate. Guards against:

    - A future ``langchain_ollama`` version reshaping ``_client`` /
      ``_async_client`` so the introspection silently no-ops and the
      eventpoll-FD leak from #3816 returns.
    - The introspection accidentally tripping on a non-Ollama LLM and
      closing a shared, lru_cache'd httpx client (which would brick the
      module-global pool for all other instances).

    Construction is no-network: ``ChatOllama(host=…)`` and
    ``ChatOpenAI(api_key=…)`` are both inert until first ``invoke`` /
    ``ainvoke``.
    """

    def test_real_chatollama_through_wrapper_closes_both_clients(self):
        from langchain_ollama import ChatOllama

        from local_deep_research.config.llm_config import (
            wrap_llm_without_think_tags,
        )
        from local_deep_research.utilities.resource_utils import safe_close

        llm = ChatOllama(model="test", base_url="http://localhost:1")
        sync_httpx = llm._client._client
        async_httpx = llm._async_client._client
        assert sync_httpx.is_closed is False
        assert async_httpx.is_closed is False

        wrapper = wrap_llm_without_think_tags(llm, settings_snapshot={})

        safe_close(wrapper, "regression test ChatOllama")

        # Both inner httpx clients must be closed by the chain
        # (safe_close → ProcessingLLMWrapper.close → _close_base_llm →
        # sync close + asyncio.run(aclose)). This is the load-bearing
        # assertion for #3816.
        assert sync_httpx.is_closed is True
        assert async_httpx.is_closed is True

    def test_real_chatopenai_through_wrapper_does_not_close_shared_client(
        self,
    ):
        from langchain_openai import ChatOpenAI

        from local_deep_research.config.llm_config import (
            wrap_llm_without_think_tags,
        )
        from local_deep_research.utilities.resource_utils import safe_close

        llm = ChatOpenAI(model="gpt-4", api_key="sk-test")

        # ChatOpenAI's underlying httpx clients live behind the openai SDK
        # (langchain_openai's lru_cache'd ``_SyncHttpxClientWrapper`` /
        # ``_AsyncHttpxClientWrapper``). They are shared across every
        # ChatOpenAI instance — closing one would brick all the others.
        sync_httpx = llm.root_client._client
        async_httpx = llm.root_async_client._client
        assert sync_httpx.is_closed is False
        assert async_httpx.is_closed is False

        wrapper = wrap_llm_without_think_tags(llm, settings_snapshot={})

        safe_close(wrapper, "regression test ChatOpenAI")

        # ChatOpenAI exposes neither ``_client`` nor ``_async_client``;
        # `_close_base_llm`'s introspection short-circuits cleanly and
        # the shared cached httpx clients must remain open.
        assert sync_httpx.is_closed is False
        assert async_httpx.is_closed is False

    def test_full_wrapper_stack_via_rate_limited_closes_both_clients(self):
        """Regression: when rate limiting is enabled, the production
        wrapper stack is ``ProcessingLLMWrapper(RateLimitedLLMWrapper(
        ChatOllama))`` and ``safe_close`` has to recurse through *both*
        wrapper layers to reach ``_close_base_llm``'s introspection.

        The other ``Real Langchain`` tests only exercise the
        ``ProcessingLLMWrapper`` layer. This one specifically guards
        the ``RateLimitedLLMWrapper.close → _close_base_llm`` hop so a
        future change to that wrapper's ``close()`` doesn't silently
        break the chain and let the #3816 leak come back.
        """
        from langchain_ollama import ChatOllama

        from local_deep_research.config.llm_config import (
            wrap_llm_without_think_tags,
        )
        from local_deep_research.utilities.resource_utils import safe_close
        from local_deep_research.web_search_engines.rate_limiting.llm import (
            create_rate_limited_llm_wrapper,
        )

        llm = ChatOllama(model="test", base_url="http://localhost:1")
        sync_httpx = llm._client._client
        async_httpx = llm._async_client._client
        assert sync_httpx.is_closed is False
        assert async_httpx.is_closed is False

        # Build the stack manually — wrap_llm_without_think_tags only
        # adds the rate-limited layer when ``rate_limiting.llm_enabled``
        # is True in the settings, and we want this test to hold
        # regardless of that setting.
        rate_limited = create_rate_limited_llm_wrapper(llm, provider="ollama")
        full = wrap_llm_without_think_tags(rate_limited, settings_snapshot={})

        safe_close(full, "regression test full wrapper stack")

        # Recursion: safe_close(Processing) → Processing.close →
        # _close_base_llm(RateLimited) → hasattr(type, "close") fires →
        # RateLimited.close() → _close_base_llm(ChatOllama) →
        # introspection closes both inner httpx clients.
        assert sync_httpx.is_closed is True
        assert async_httpx.is_closed is True


class TestCloseBaseLLMRealOllamaEmbeddings:
    """End-to-end regression coverage for ``OllamaEmbeddings``.

    After the langchain_community → langchain_ollama migration
    (#4352/#4353), ``OllamaEmbeddings`` carries the same
    ``_client`` / ``_async_client`` shape as ``ChatOllama`` — eagerly
    constructed by a Pydantic ``@model_validator(mode="after")``, with no
    ``close()`` / ``aclose()`` / ``__del__`` of its own. The deprecated
    ``langchain_community.embeddings.OllamaEmbeddings`` was FD-safe by
    accident (``requests.post()`` per call, no persistent client); the
    new class is not. The resource-cleanup doc predicted exactly this
    leak shape; this class is the canary that catches a recurrence if
    a future migration breaks the close path again.

    Construction is no-network: ``OllamaEmbeddings(base_url=…)`` is inert
    until the first ``embed_query`` / ``embed_documents`` call.
    """

    def test_real_ollama_embeddings_closes_both_clients(self):
        from langchain_ollama import OllamaEmbeddings

        embeddings = OllamaEmbeddings(
            model="test", base_url="http://localhost:1"
        )
        sync_httpx = embeddings._client._client
        async_httpx = embeddings._async_client._client
        assert sync_httpx.is_closed is False
        assert async_httpx.is_closed is False

        _close_base_llm(embeddings)

        # Both inner httpx clients must be closed. Sync side is closed
        # synchronously; async side via the ``asyncio.run`` branch (no
        # running loop in this thread).
        assert sync_httpx.is_closed is True
        assert async_httpx.is_closed is True

    def test_real_ollama_embeddings_close_is_idempotent(self):
        from langchain_ollama import OllamaEmbeddings

        embeddings = OllamaEmbeddings(
            model="test", base_url="http://localhost:1"
        )
        async_httpx = embeddings._async_client._client

        _close_base_llm(embeddings)
        _close_base_llm(embeddings)  # sentinel short-circuits; must not raise

        assert async_httpx.is_closed is True
        assert getattr(async_httpx, "_ldr_closed", False) is True

    def test_local_embedding_manager_close_closes_ollama_embeddings(self):
        """Integration: ``LocalEmbeddingManager.close()`` must flow through
        to ``_close_base_llm`` for an Ollama-backed embeddings instance.

        Without this, the ``LibraryRAGService`` close path — which
        cascades to the manager's ``close()`` — leaks the underlying
        httpx clients per RAG request (the #3816-shaped FD ramp, now on
        the embeddings side).
        """
        from langchain_ollama import OllamaEmbeddings

        from local_deep_research.web_search_engines.engines.local_embedding_manager import (
            LocalEmbeddingManager,
        )

        mgr = LocalEmbeddingManager(
            embedding_model="test",
            embedding_model_type="ollama",
            ollama_base_url="http://localhost:1",
            settings_snapshot={},
        )
        # Inject a real embeddings instance directly to bypass the lazy
        # ``embeddings`` property — its provider lookup would touch the
        # settings / database layers, which aren't relevant to this test.
        mgr._embeddings = OllamaEmbeddings(
            model="test", base_url="http://localhost:1"
        )
        sync_httpx = mgr._embeddings._client._client
        async_httpx = mgr._embeddings._async_client._client
        assert sync_httpx.is_closed is False
        assert async_httpx.is_closed is False

        mgr.close()

        assert sync_httpx.is_closed is True
        assert async_httpx.is_closed is True

    def test_local_embedding_manager_close_is_safe_for_non_ollama(self):
        """The close path must not blow up for non-Ollama providers. The
        module-prefix check inside ``_close_base_llm`` ensures the
        ``HuggingFaceEmbeddings`` fallback (and any other non-Ollama
        provider) is a no-op rather than an AttributeError.
        """
        from local_deep_research.web_search_engines.engines.local_embedding_manager import (
            LocalEmbeddingManager,
        )

        mgr = LocalEmbeddingManager(
            embedding_model="test",
            embedding_model_type="sentence_transformers",
            settings_snapshot={},
        )
        # Stand-in with neither ``_client`` nor ``_async_client``.
        non_ollama = type("FakeHFEmbeddings", (), {})()
        mgr._embeddings = non_ollama

        # Must complete cleanly; the introspection short-circuits when
        # the attributes are absent. ``hasattr(type(non_ollama), "close")``
        # is False for our minimal type, so the wrapper-delegation path
        # is also skipped — exactly the behaviour we want.
        mgr.close()

        assert mgr._embeddings is None
        assert mgr._closed is True


class TestLibraryRAGServiceCloseOwnership:
    """``LibraryRAGService`` may be constructed with a caller-supplied
    ``embedding_manager`` (test fixtures, multi-service callers reusing
    one manager). Closing the service must NOT close a manager it didn't
    create — that would burn the caller's reference and surface as a
    use-after-close error in the next call site.
    """

    def test_close_does_not_touch_caller_supplied_manager(self):
        from unittest.mock import Mock

        from local_deep_research.research_library.services.library_rag_service import (
            LibraryRAGService,
        )

        external_manager = Mock(spec=["close"])

        # Bypass the heavy ``__init__`` (DB session, FileIntegrityManager,
        # text splitter) — only the ownership + close branch matters here.
        svc = LibraryRAGService.__new__(LibraryRAGService)
        svc._closed = False
        svc.embedding_manager = external_manager
        svc._owns_embedding_manager = False
        svc.faiss_index = None
        svc.rag_index_record = None
        svc.integrity_manager = None
        svc.text_splitter = None

        svc.close()

        external_manager.close.assert_not_called()
        assert svc.embedding_manager is None

    def test_close_tears_down_owned_manager(self):
        from unittest.mock import Mock

        from local_deep_research.research_library.services.library_rag_service import (
            LibraryRAGService,
        )

        owned_manager = Mock(spec=["close"])

        svc = LibraryRAGService.__new__(LibraryRAGService)
        svc._closed = False
        svc.embedding_manager = owned_manager
        svc._owns_embedding_manager = True
        svc.faiss_index = None
        svc.rag_index_record = None
        svc.integrity_manager = None
        svc.text_splitter = None

        svc.close()

        owned_manager.close.assert_called_once()
        assert svc.embedding_manager is None


class TestInLoopCleanupThreadContract:
    """The in-running-loop branch of ``_close_base_llm`` hands the
    ``aclose()`` off to a daemon thread (see the
    ``else: # A loop is running in this thread`` block that spawns
    a ``ldr-async-llm-close`` thread). Two properties of that thread
    are load-bearing but not pinned by the other tests in this file:

    1. ``daemon=True`` — without this, a cleanup thread blocked on a
       stuck server would prevent Python interpreter shutdown.
    2. The thread is given a recognizable name so operators chasing FD
       leaks can correlate ``threading.enumerate()`` / ``top`` output
       against the leak source.

    Plus the third gap: when the cleanup thread completes BUT its inner
    ``asyncio.run(aclose())`` raised, the main thread still sets
    ``_ldr_closed = True`` in the ``else`` branch reached when
    ``t.is_alive()`` is False. The pre-existing
    ``test_swallows_async_close_exception`` covers this for the no-loop
    branch; this class covers the same invariant for the in-loop branch.
    """

    def test_cleanup_thread_is_daemon_so_shutdown_is_not_blocked(self):
        """Capture the thread object created by the running-loop branch
        and assert ``daemon`` is True. A non-daemon cleanup thread
        blocked on a slow server would hang interpreter shutdown until
        the OS kills the process — exactly the failure mode the
        docstring of ``_close_base_llm`` warns about when motivating
        the brief daemon thread.
        """
        import threading
        from unittest.mock import patch

        async def fake_aclose():
            return None

        llm = _make_fake_chat_ollama(async_aclose=fake_aclose)

        captured = {}
        original_thread = threading.Thread

        class _CapturingThread(original_thread):
            def __init__(self, *args, **kwargs):
                captured["daemon"] = kwargs.get("daemon")
                captured["name"] = kwargs.get("name")
                super().__init__(*args, **kwargs)

        async def driver():
            with patch("threading.Thread", _CapturingThread):
                _close_base_llm(llm)

        asyncio.run(driver())

        assert captured["daemon"] is True, (
            "cleanup thread must be a daemon — otherwise a stuck "
            "aclose() would block Python interpreter shutdown"
        )
        # Name is operator-facing for FD-leak triage; pin a stable
        # prefix rather than the exact string so future tweaks can
        # extend without breaking the test.
        assert captured["name"] is not None
        assert "ldr" in captured["name"].lower()

    def test_in_loop_close_marks_closed_even_when_inner_aclose_raises(
        self,
    ):
        """The cleanup thread's ``_close_in_thread`` runs
        ``asyncio.run(aclose())`` inside a ``try/except Exception`` — if
        ``aclose`` raises, the thread logs a warning and exits cleanly.
        The main thread sees ``t.is_alive() == False`` and falls into
        the ``else`` branch that sets ``_ldr_closed = True``. Pin that
        invariant: the sentinel is the signal to never retry a
        known-broken close, regardless of the branch (no-loop or
        in-loop) that performed it.
        """

        async def boom_aclose():
            raise RuntimeError("simulated upstream failure")

        llm = _make_fake_chat_ollama(async_aclose=boom_aclose)

        async def driver():
            _close_base_llm(llm)

        asyncio.run(driver())

        async_httpx = llm._async_client._client
        assert async_httpx._ldr_closed is True, (
            "Even when aclose raises inside the cleanup thread, the "
            "main thread must mark the client closed to prevent "
            "indefinite retries on a known-broken close"
        )
