"""Direct unit tests for ``relevance_filter`` module.

These exercise ``filter_previews_for_relevance`` and ``_unwrap_llm``
without going through ``BaseSearchEngine`` so we can pin down behaviors
that are hard to assert through the wrapper:

- duplicate-index dedup
- cap alignment with kept_indices
- the LLM-exception capped fallback
- ``max_filtered_results=None`` (no cap)
- text parsing tolerates wrapper text ("Indices: 0, 2, 5")
- ``_unwrap_llm`` walks a wrapper chain (locks in Ollama optimization)
"""

import threading
import time
from unittest.mock import Mock

from local_deep_research.web_search_engines import relevance_filter
from local_deep_research.web_search_engines.relevance_filter import (
    _unwrap_llm,
    filter_previews_for_relevance,
)


def _previews(n):
    return [
        {"title": f"P{i}", "snippet": f"Snippet {i}", "url": f"https://x/{i}"}
        for i in range(n)
    ]


def _llm_returning(text):
    """Mock LLM whose ``invoke()`` returns the given text response."""
    mock_llm = Mock()
    mock_llm.invoke.return_value = text
    return mock_llm


def _llm_raising(exc):
    """Mock LLM whose ``invoke()`` raises ``exc``."""
    mock_llm = Mock()
    mock_llm.invoke.side_effect = exc
    return mock_llm


# ---------- 1. dedup ----------


def test_dedup_handles_duplicate_indices():
    """LLM returning '0, 0, 2' should yield previews[0] and previews[2]
    once each, in original order."""
    previews = _previews(4)
    llm = _llm_returning("0, 0, 2")

    result = filter_previews_for_relevance(llm, previews, "q")

    assert result == [previews[0], previews[2]]


# ---------- 2. cap alignment ----------


def test_kept_indices_align_after_cap():
    """With max_filtered_results=2 and indices [3,1,2,0], the result must
    be [previews[3], previews[1]] — the first two kept entries in order."""
    previews = _previews(4)
    llm = _llm_returning("3, 1, 2, 0")

    result = filter_previews_for_relevance(
        llm, previews, "q", max_filtered_results=2
    )

    assert result == [previews[3], previews[1]]


# ---------- 3. parsing tolerates wrapper text ----------


def test_text_parsing_tolerates_wrapper_text():
    """LLM responses like 'Indices: [0, 2, 5]' should still parse cleanly."""
    previews = _previews(8)
    llm = _llm_returning("The relevant indices are: [0, 2, 5].")

    result = filter_previews_for_relevance(llm, previews, "q")

    assert result == [previews[0], previews[2], previews[5]]


def test_text_parsing_skips_out_of_range_indices():
    """Integers in the response that are out of range get filtered."""
    previews = _previews(3)
    # 99 is out of range, should be dropped silently
    llm = _llm_returning("0, 2, 99")

    result = filter_previews_for_relevance(llm, previews, "q")

    assert result == [previews[0], previews[2]]


def test_message_object_response_uses_content_attribute():
    """Chat models return a Message-like object with a ``.content`` attr."""
    previews = _previews(4)
    fake_message = Mock()
    fake_message.content = "1, 3"
    mock_llm = Mock()
    mock_llm.invoke.return_value = fake_message

    result = filter_previews_for_relevance(mock_llm, previews, "q")

    assert result == [previews[1], previews[3]]


# ---------- 5. LLM exception → capped fallback ----------


def test_llm_exception_returns_capped_fallback():
    """Network/provider exceptions raised inside _invoke_structured are
    caught by the outer try/except and return the capped fallback slice."""
    previews = _previews(8)
    llm = _llm_raising(ConnectionError("network down"))

    result = filter_previews_for_relevance(
        llm, previews, "q", max_filtered_results=4
    )

    assert result == previews[:4]


# ---------- 6. max_filtered_results=None ----------


def test_max_filtered_results_none_keeps_all_relevant():
    """When max_filtered_results is None, no cap is applied to the
    LLM-selected results."""
    previews = _previews(8)
    llm = _llm_returning("0, 1, 2, 3, 4, 5, 6, 7")

    result = filter_previews_for_relevance(
        llm, previews, "q", max_filtered_results=None
    )

    assert result == previews


# ---------- 7. _unwrap_llm walks wrapper chain ----------


def test_unwrap_llm_walks_wrapper_chain():
    """_unwrap_llm should follow .base_llm chains and stop when an
    attribute is None or self-referential. With a 2-level real chain it
    returns the innermost object."""
    inner = object()  # not a Mock — real terminal
    middle = Mock(spec=["base_llm"])
    middle.base_llm = inner
    outer = Mock(spec=["base_llm"])
    outer.base_llm = middle

    assert _unwrap_llm(outer) is inner


def test_unwrap_llm_terminates_on_self_reference():
    """A self-referential .base_llm should terminate immediately."""
    obj = Mock(spec=["base_llm"])
    obj.base_llm = obj

    assert _unwrap_llm(obj) is obj


def test_unwrap_llm_terminates_on_plain_mock():
    """A plain ``Mock()`` lazily creates an infinite chain of child mocks.
    The depth limit should ensure ``_unwrap_llm`` returns within bounded
    time instead of hanging."""
    mock = Mock()

    # Should not hang. The exact return value doesn't matter — we just
    # want to confirm termination.
    result = _unwrap_llm(mock)
    assert result is not None


# ---------- Bonus: log assertions via loguru_caplog ----------


def test_empty_judgment_does_not_warn_on_2_previews(loguru_caplog):
    """The "all irrelevant" warning is suppressed for batches of 2 to
    avoid noise on niche queries with few results."""
    previews = _previews(2)
    llm = _llm_returning("none")  # no integers in text

    with loguru_caplog.at_level("WARNING"):
        result = filter_previews_for_relevance(llm, previews, "q")

    assert result == []
    assert "judged all" not in loguru_caplog.text


def test_empty_judgment_warns_on_3_or_more_previews(loguru_caplog):
    """The "all irrelevant" warning fires when the LLM rejects 3+ previews
    so the user notices a misbehaving model."""
    previews = _previews(3)
    llm = _llm_returning("none")  # no integers in text

    with loguru_caplog.at_level("WARNING"):
        result = filter_previews_for_relevance(llm, previews, "q")

    assert result == []
    assert "judged all 3 results irrelevant" in loguru_caplog.text


# ---------- Batching ----------


def test_batch_size_splits_into_chunks_with_local_indices():
    """When batch_size=2, the LLM is called once per pair of previews
    with local indices [0..1]. Local indices map back to global preview
    indices in original batch order."""
    previews = _previews(6)

    # Each batch returns the local index 0 — i.e. previews[0], [2], [4]
    llm = _llm_returning("0")

    result = filter_previews_for_relevance(
        llm, previews, "q", batch_size=2, max_parallel_batches=1
    )

    assert result == [previews[0], previews[2], previews[4]]
    # 3 batches of 2 means 3 LLM invocations
    assert llm.invoke.call_count == 3


def test_batch_size_none_is_single_call():
    """batch_size=None falls back to a single LLM call regardless of
    preview count, preserving the original non-batched behavior."""
    previews = _previews(20)
    llm = _llm_returning("0, 1, 2")

    result = filter_previews_for_relevance(llm, previews, "q", batch_size=None)

    assert result == [previews[0], previews[1], previews[2]]
    assert llm.invoke.call_count == 1


def test_batch_with_empty_judgment_keeps_other_batches():
    """If one batch returns an empty judgment (no integers in text), that
    batch contributes nothing — the other batches' results still come
    through."""
    previews = _previews(4)

    # First call: '0, 1' → keeps batch[0] and [1]; second call: 'none'
    # → no integers → empty judgment for batch 2.
    llm = Mock()
    llm.invoke.side_effect = ["0, 1", "none"]

    result = filter_previews_for_relevance(
        llm, previews, "q", batch_size=2, max_parallel_batches=1
    )

    # First batch yields previews[0] and [1]; second batch contributed nothing.
    assert result == [previews[0], previews[1]]


def test_batch_parallel_dispatches_concurrently():
    """With max_parallel_batches > 1, batches are dispatched via a
    thread pool. Result order must still mirror original preview order."""
    previews = _previews(6)
    llm = _llm_returning("0, 1")

    result = filter_previews_for_relevance(
        llm,
        previews,
        "q",
        batch_size=2,
        max_parallel_batches=4,
    )

    # 3 batches × 2 kept each = 6 results, in original order.
    assert result == previews
    assert llm.invoke.call_count == 3


# ---------- 7. parallel-batch timeout ----------


def _blocking_llm(release_event, max_block_s: float = 3.0):
    """Stub whose ``.invoke()`` blocks on ``release_event`` (bounded).

    Returns ``"0"`` when released. ``max_block_s`` caps how long a stuck
    thread will actually block — enough slack for the filter's timeout
    to trip, short enough that leaked test threads release on their own
    well within any pytest-timeout.
    """
    llm = Mock()

    def _invoke(prompt, **kwargs):
        release_event.wait(timeout=max_block_s)
        return "0"

    llm.invoke.side_effect = _invoke
    return llm


def test_parallel_batch_timeout_does_not_hang(monkeypatch):
    """A stuck batch must not block the pipeline past the wall timeout.

    Regression for PR #3476: before the fix, ``as_completed(timeout=...)``
    raised correctly but ``ThreadPoolExecutor.__exit__`` called
    ``shutdown(wait=True)`` which waited for the stuck worker, so the
    advertised timeout was unenforced. With the ``shutdown(wait=False,
    cancel_futures=True)`` fix this test returns promptly.
    """
    monkeypatch.setattr(relevance_filter, "_FILTER_WALL_TIMEOUT_S", 0.5)

    release = threading.Event()
    previews = _previews(4)
    llm = _blocking_llm(release)

    t0 = time.monotonic()
    try:
        result = filter_previews_for_relevance(
            llm,
            previews,
            "q",
            batch_size=2,
            max_parallel_batches=2,
            max_filtered_results=3,
        )
        elapsed = time.monotonic() - t0
    finally:
        # Let stuck worker threads exit cleanly regardless of what
        # happened in the call — otherwise pytest's global teardown
        # blocks on them.
        release.set()

    # Must return within a small multiple of the patched timeout.
    # Slack accounts for thread-pool teardown and CI jitter.
    assert elapsed < 3.0, f"filter did not honor timeout: took {elapsed:.2f}s"

    # All batches timed out → capped fallback slice.
    assert result == previews[:3]
