"""Regression tests for the synchronous-wrapper event-loop guard.

``LightRAG``'s synchronous wrappers (``insert``, ``query``,
``delete_by_entity`` …) all delegate to :func:`lightrag.lightrag._run_sync`,
which drives the matching ``a*`` coroutine via ``loop.run_until_complete()``.

That call is only valid when (a) no event loop is already running on the
current thread, and (b) the loop it drives is the same one the instance's
storages were initialized on (``LightRAG._owning_loop``).  Two misuse modes
break this:

* Called from within a running loop it raises ``RuntimeError: This event loop
  is already running`` — a fail-fast error, *not* a deadlock.
* Driven from a different — but still alive — loop, e.g.
  ``loop.run_in_executor(None, rag.insert, …)`` runs the wrapper on a pool
  thread that spins up a fresh loop while the app's loop keeps running — binds
  the shared ``asyncio.Lock`` objects in ``lightrag.kg.shared_storage`` to the
  wrong loop (``<Lock> is bound to a different event loop`` / a stall).

The cross-loop check only fires while ``owning_loop`` is still open.  A *closed*
owning loop (the ``rag = asyncio.run(initialize_rag())`` pattern, where
``asyncio.run`` closes the loop after ``initialize_storages()``) is let through
to run on a fresh loop, preserving long-standing behavior.

Both misuse modes have the same fix: use the ``a*`` coroutine directly.
``_run_sync`` detects the running loop up front and compares the loop it is
about to drive against ``owning_loop``, raising one clear, actionable error for
each.  These tests pin that behaviour (and guard against regressing back to the
misleading "deadlock" wording / un-awaited-coroutine leak). See
HKUDS/LightRAG #1968.
"""

import asyncio
from concurrent.futures import ThreadPoolExecutor

import pytest

from lightrag.lightrag import _run_sync


@pytest.mark.offline
def test_run_sync_runs_coroutine_when_no_loop_running():
    """With no running loop, the coroutine runs to completion and returns."""

    def factory():
        async def _coro():
            return 42

        return _coro()

    result = _run_sync(factory, sync_name="insert", async_name="ainsert")
    assert result == 42


@pytest.mark.offline
def test_run_sync_raises_clear_error_inside_running_loop():
    """Inside a running loop the guard raises a RuntimeError pointing at the
    async alternative — and the message is honest (no "deadlock" claim)."""

    def factory():  # pragma: no cover - must never be reached
        raise AssertionError("coro_factory must not be called inside a loop")

    async def _inside_loop():
        return _run_sync(factory, sync_name="insert", async_name="ainsert")

    with pytest.raises(RuntimeError) as exc_info:
        asyncio.run(_inside_loop())

    message = str(exc_info.value)
    assert "insert()" in message
    assert "await ainsert(" in message
    # The previous fix (PR #3245) mislabelled this as a deadlock; it is an
    # immediate RuntimeError.  Keep the message free of that wording.
    assert "deadlock" not in message.lower()


@pytest.mark.offline
def test_run_sync_does_not_create_coroutine_when_it_raises():
    """The factory is invoked lazily, so a guard failure never leaves an
    un-awaited coroutine behind (which would emit a RuntimeWarning)."""

    calls: list[bool] = []

    def factory():
        calls.append(True)

        async def _coro():
            return None

        return _coro()

    async def _inside_loop():
        return _run_sync(factory, sync_name="query", async_name="aquery")

    with pytest.raises(RuntimeError):
        asyncio.run(_inside_loop())

    assert calls == [], "coro_factory should not run when the guard rejects"


@pytest.mark.offline
def test_run_sync_runs_when_owning_loop_matches():
    """When the loop being driven is the instance's owning loop, the coroutine
    runs to completion — the cross-loop guard only fires on a mismatch."""

    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)

    def factory():
        async def _coro():
            return 42

        return _coro()

    try:
        # always_get_an_event_loop() returns the loop set above, which is the
        # one we declare as owning -> match -> the coroutine runs.
        result = _run_sync(
            factory, sync_name="insert", async_name="ainsert", owning_loop=loop
        )
        assert result == 42
    finally:
        loop.close()
        asyncio.set_event_loop(None)


@pytest.mark.offline
def test_run_sync_raises_when_driven_from_a_different_loop():
    """Driving a sync wrapper from a thread with no event loop of its own
    (the ``loop.run_in_executor(None, rag.insert, …)`` anti-pattern) lands on a
    freshly created loop, not the instance's owning loop. The guard must fail
    fast pointing at the async alternative, without running the coroutine."""

    owning_loop = asyncio.new_event_loop()
    calls: list[bool] = []

    def factory():  # pragma: no cover - must never be reached
        calls.append(True)
        raise AssertionError("coro_factory must not run on the wrong loop")

    def call_off_thread():
        # No loop is set on this worker thread, so always_get_an_event_loop()
        # creates a brand-new one — different from owning_loop.
        return _run_sync(
            factory,
            sync_name="insert",
            async_name="ainsert",
            owning_loop=owning_loop,
        )

    try:
        with ThreadPoolExecutor(max_workers=1) as executor:
            with pytest.raises(RuntimeError) as exc_info:
                executor.submit(call_off_thread).result()
    finally:
        owning_loop.close()

    message = str(exc_info.value)
    assert "insert()" in message
    assert "await ainsert(" in message
    assert "deadlock" not in message.lower()
    assert calls == [], "coro_factory should not run on a mismatched loop"


@pytest.mark.offline
def test_run_sync_runs_when_owning_loop_is_closed():
    """A *closed* owning loop means the loop the storages were initialized on is
    gone — the common ``rag = asyncio.run(initialize_rag())`` pattern, where
    ``asyncio.run`` closes the loop after ``initialize_storages()``. There is no
    live loop to clash with, so the guard must let the call through and run the
    coroutine on a fresh loop (preserving long-standing behavior), not reject
    it."""

    closed_owning_loop = asyncio.new_event_loop()
    closed_owning_loop.close()

    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)

    def factory():
        async def _coro():
            return 42

        return _coro()

    try:
        result = _run_sync(
            factory,
            sync_name="insert",
            async_name="ainsert",
            owning_loop=closed_owning_loop,
        )
        assert result == 42
    finally:
        loop.close()
        asyncio.set_event_loop(None)
