"""Tests for ParallelSearchEngine engine selection and concurrent execution.

Covers SearchMode filtering, is_public exclusion, self-referential engine
filtering, and ThreadPoolExecutor result merging.
"""

import concurrent.futures
from unittest.mock import Mock, patch

import pytest

from local_deep_research.utilities.enums import SearchMode
from local_deep_research.web_search_engines.engines.parallel_search_engine import (
    ParallelSearchEngine,
)


def make_engine_class(is_public=True, is_generic=False, is_scientific=False):
    """Create a mock engine class with the given flags."""
    cls = Mock()
    cls.is_public = is_public
    cls.is_generic = is_generic
    cls.is_scientific = is_scientific
    return cls


@pytest.fixture
def mock_load_engine_class():
    """Patch _load_engine_class on BaseSearchEngine."""
    with patch(
        "local_deep_research.web_search_engines.search_engine_base.BaseSearchEngine._load_engine_class"
    ) as mock:
        yield mock


@pytest.fixture
def mock_executor():
    """Patch the global executor."""
    with patch(
        "local_deep_research.web_search_engines.engines.parallel_search_engine._get_global_executor"
    ) as mock:
        yield mock


@pytest.fixture
def mock_get_available():
    """Patch the shared get_available_engines function."""
    with patch(
        "local_deep_research.web_search_engines.engines.parallel_search_engine.get_available_engines"
    ) as mock:
        yield mock


class TestSearchModeFiltering:
    """Tests for engine selection based on SearchMode."""

    def test_scientific_mode_includes_scientific_and_generic(
        self, mock_load_engine_class, mock_executor, mock_get_available
    ):
        """SearchMode.SCIENTIFIC includes scientific AND generic engines,
        excludes others."""
        engine_classes = {
            "arxiv": make_engine_class(
                is_public=True, is_scientific=True, is_generic=False
            ),
            "searxng": make_engine_class(
                is_public=True, is_scientific=False, is_generic=True
            ),
            "guardian": make_engine_class(
                is_public=True, is_scientific=False, is_generic=False
            ),
        }

        def load_side_effect(name, config):
            if name in engine_classes:
                return (True, engine_classes[name], None)
            return (False, None, f"Unknown engine {name}")

        mock_load_engine_class.side_effect = load_side_effect
        mock_executor.return_value = Mock()
        mock_get_available.return_value = {
            "arxiv": {},
            "searxng": {},
            "guardian": {},
        }

        engine = ParallelSearchEngine(
            llm=Mock(),
            search_mode=SearchMode.SCIENTIFIC,
            settings_snapshot={"search.max_results": 10},
            programmatic_mode=True,
        )

        assert "arxiv" in engine.available_engines
        assert "searxng" in engine.available_engines
        assert "guardian" not in engine.available_engines

    def test_private_engine_excluded_by_default(
        self, mock_load_engine_class, mock_executor, mock_get_available
    ):
        """is_public=False excluded when allow_local_engines=False."""
        engine_classes = {
            "local_rag": make_engine_class(is_public=False),
            "searxng": make_engine_class(is_public=True, is_generic=True),
        }

        def load_side_effect(name, config):
            if name in engine_classes:
                return (True, engine_classes[name], None)
            return (False, None, f"Unknown engine {name}")

        mock_load_engine_class.side_effect = load_side_effect
        mock_executor.return_value = Mock()
        mock_get_available.return_value = {
            "local_rag": {},
            "searxng": {},
        }

        engine = ParallelSearchEngine(
            llm=Mock(),
            allow_local_engines=False,
            settings_snapshot={"search.max_results": 10},
            programmatic_mode=True,
        )

        assert "local_rag" not in engine.available_engines
        assert "searxng" in engine.available_engines

    def test_meta_auto_parallel_always_excluded(
        self, mock_load_engine_class, mock_executor, mock_get_available
    ):
        """Self-referential engines (meta, auto, parallel) are already excluded
        by the shared get_available_engines function."""
        mock_load_engine_class.return_value = (
            True,
            make_engine_class(is_public=True, is_generic=True),
            None,
        )
        mock_executor.return_value = Mock()
        # Shared function excludes meta/auto/parallel, only returns searxng
        mock_get_available.return_value = {"searxng": {}}

        engine = ParallelSearchEngine(
            llm=Mock(),
            settings_snapshot={"search.max_results": 10},
            programmatic_mode=True,
        )

        assert "meta" not in engine.available_engines
        assert "auto" not in engine.available_engines
        # 'parallel' key in config is also excluded by name check
        assert "searxng" in engine.available_engines


class TestConcurrentExecution:
    """Tests for ThreadPoolExecutor concurrent execution and result merging."""

    def test_concurrent_execution_merges_results_with_engine_tag(
        self, mock_load_engine_class, mock_executor
    ):
        """Real ThreadPoolExecutor run -> results tagged with search_engine key."""
        mock_load_engine_class.return_value = (
            True,
            make_engine_class(is_public=True, is_generic=True),
            None,
        )

        # Use a real ThreadPoolExecutor
        real_executor = concurrent.futures.ThreadPoolExecutor(max_workers=2)
        mock_executor.return_value = real_executor

        settings = {
            "search.engine.web.engine_a.module_path": {"value": "some.module"},
            "search.engine.web.engine_a.class_name": {"value": "EngineA"},
        }

        engine = ParallelSearchEngine(
            llm=Mock(),
            settings_snapshot=settings,
            programmatic_mode=True,
        )

        # Mock select_engines to return specific engines
        engine.select_engines = Mock(return_value=["engine_a"])

        # Mock _execute_single_engine to return tagged results
        def mock_execute(engine_name, query):
            return {
                "engine": engine_name,
                "success": True,
                "results": [
                    {
                        "title": f"Result from {engine_name}",
                        "url": "http://a.com",
                    }
                ],
                "count": 1,
            }

        engine._execute_single_engine = mock_execute

        with patch(
            "local_deep_research.web_search_engines.engines.parallel_search_engine.SocketIOService"
        ):
            results = engine._get_previews("test query")

        real_executor.shutdown(wait=False)

        assert len(results) >= 1
        assert results[0]["search_engine"] == "engine_a"

    def test_future_exception_records_failure(
        self, mock_load_engine_class, mock_executor
    ):
        """Engine failure in thread -> recorded, other engines still return results."""
        mock_load_engine_class.return_value = (
            True,
            make_engine_class(is_public=True, is_generic=True),
            None,
        )

        real_executor = concurrent.futures.ThreadPoolExecutor(max_workers=2)
        mock_executor.return_value = real_executor

        settings = {
            "search.engine.web.good_engine.module_path": {
                "value": "some.module"
            },
            "search.engine.web.good_engine.class_name": {"value": "GoodEngine"},
            "search.engine.web.bad_engine.module_path": {
                "value": "some.module"
            },
            "search.engine.web.bad_engine.class_name": {"value": "BadEngine"},
        }

        engine = ParallelSearchEngine(
            llm=Mock(),
            settings_snapshot=settings,
            programmatic_mode=True,
        )

        engine.select_engines = Mock(return_value=["good_engine", "bad_engine"])

        def mock_execute(engine_name, query):
            if engine_name == "bad_engine":
                raise RuntimeError("Engine crashed")
            return {
                "engine": engine_name,
                "success": True,
                "results": [{"title": "Good result", "url": "http://good.com"}],
                "count": 1,
            }

        engine._execute_single_engine = mock_execute

        with patch(
            "local_deep_research.web_search_engines.engines.parallel_search_engine.SocketIOService"
        ):
            results = engine._get_previews("test query")

        real_executor.shutdown(wait=False)

        # Good engine results should still be present
        assert any(r.get("title") == "Good result" for r in results)


class TestSelectEnginesEdgeCases:
    """Tests for edge cases in engine selection."""

    def test_select_engines_llm_out_of_range_index_ignored(
        self, mock_load_engine_class, mock_executor
    ):
        """Invalid LLM-returned index is silently skipped."""
        mock_load_engine_class.return_value = (
            True,
            make_engine_class(is_public=True),
            None,
        )
        mock_executor.return_value = Mock()

        settings = {
            "search.engine.web.arxiv.module_path": {"value": "some.module"},
            "search.engine.web.arxiv.class_name": {"value": "ArxivEngine"},
        }

        mock_llm = Mock()
        # LLM returns out-of-range indices
        mock_llm.invoke.return_value = Mock(content="[0, 99, -1]")

        engine = ParallelSearchEngine(
            llm=mock_llm,
            settings_snapshot=settings,
            programmatic_mode=True,
            include_generic_engines=False,
        )

        selected = engine.select_engines("test query")

        # Only index 0 is valid (arxiv), indices 99 and -1 are out of range
        # The engine should handle this gracefully
        assert isinstance(selected, list)
        # Valid index 0 should be included if it maps to an available engine
        if engine.available_engines:
            assert all(e in engine.available_engines for e in selected)
