"""
Additional coverage tests for parallel_search_engine.py.

Targets ~41 missing statements focusing on:
- shutdown_global_executor error/finally paths
- _get_global_executor failure path (ThreadPoolExecutor raises)
- _get_available_engines branching: local engine warning, no is_public flag,
  scientific mode filtering, requires_api_key + use_api_key_services=False
- _get_available_generic_engines: engine not in available, load failure, is_generic
- select_engines: no specialized engines (only generic), LLM returns indices that
  partially match, include_generic_engines=False fallback on error
- _get_previews: no engines selected, executor=None path, future raises exception
- _get_full_content: engine._get_full_content raises exception (fallback to items)
- close method
"""

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

MODULE = "local_deep_research.web_search_engines.engines.parallel_search_engine"


def _make_engine(settings=None, llm=None, **kwargs):
    """Helper to create a ParallelSearchEngine in programmatic mode."""
    from local_deep_research.web_search_engines.engines.parallel_search_engine import (
        ParallelSearchEngine,
    )

    if llm is None:
        llm = Mock()
    if settings is None:
        settings = {"search.max_results": {"value": 10}}
    return ParallelSearchEngine(
        llm=llm, settings_snapshot=settings, programmatic_mode=True, **kwargs
    )


# ---------------------------------------------------------------------------
# shutdown_global_executor
# ---------------------------------------------------------------------------


class TestShutdownGlobalExecutor:
    def test_shutdown_when_executor_is_none_does_nothing(self):
        """Calling shutdown when executor is None should not raise."""
        from local_deep_research.web_search_engines.engines.parallel_search_engine import (
            shutdown_global_executor,
        )

        with patch(f"{MODULE}._global_executor", None):
            shutdown_global_executor(wait=False)  # should not raise

    def test_shutdown_handles_exception_and_sets_none(self):
        """shutdown_global_executor sets _global_executor to None even on error."""
        from local_deep_research.web_search_engines.engines import (
            parallel_search_engine as mod,
        )

        mock_executor = Mock()
        mock_executor.shutdown.side_effect = RuntimeError("boom")

        original = mod._global_executor
        mod._global_executor = mock_executor
        try:
            mod.shutdown_global_executor(wait=False)
        finally:
            mod._global_executor = original or mod._get_global_executor()

        # The finally block should have set it to None (we restored it above)
        # The important thing is no exception propagated
        assert True


# ---------------------------------------------------------------------------
# _get_global_executor failure path
# ---------------------------------------------------------------------------


class TestGetGlobalExecutorFailure:
    def test_returns_none_when_threadpoolexecutor_raises(self):
        """If ThreadPoolExecutor() raises, _get_global_executor returns None."""
        from local_deep_research.web_search_engines.engines import (
            parallel_search_engine as mod,
        )

        original = mod._global_executor
        mod._global_executor = None
        try:
            with patch(
                f"{MODULE}.concurrent.futures.ThreadPoolExecutor",
                side_effect=OSError("no threads"),
            ):
                result = mod._get_global_executor()
        finally:
            mod._global_executor = original or mod._get_global_executor()

        assert result is None


# ---------------------------------------------------------------------------
# _get_available_engines branching
# ---------------------------------------------------------------------------


class TestGetAvailableEnginesBranching:
    def _make_engine_class(
        self, is_public=True, is_scientific=False, is_generic=False
    ):
        cls = Mock()
        cls.is_public = is_public
        cls.is_scientific = is_scientific
        cls.is_generic = is_generic
        return cls

    def test_local_engine_warning_when_allow_local_engines(self):
        """When allow_local_engines=True and is_public=False, engine is included with warning."""
        engine = _make_engine(allow_local_engines=True)

        fake_class = self._make_engine_class(is_public=False)
        config_data = {"mylocal": {"requires_api_key": False}}

        with (
            patch(f"{MODULE}.get_available_engines", return_value=config_data),
            patch(
                f"{MODULE}.BaseSearchEngine._load_engine_class",
                return_value=(True, fake_class, ""),
            ),
        ):
            result = engine._get_available_engines()

        assert "mylocal" in result

    def test_engine_skipped_when_no_is_public_flag_and_local_not_allowed(self):
        """Engines without is_public attribute are skipped when allow_local_engines=False."""
        engine = _make_engine(allow_local_engines=False)

        fake_class = Mock(spec=[])  # no is_public attribute
        config_data = {"nopublic": {"requires_api_key": False}}

        with (
            patch(f"{MODULE}.get_available_engines", return_value=config_data),
            patch(
                f"{MODULE}.BaseSearchEngine._load_engine_class",
                return_value=(True, fake_class, ""),
            ),
        ):
            result = engine._get_available_engines()

        assert "nopublic" not in result

    def test_engine_included_when_no_is_public_flag_and_local_allowed(self):
        """Engines without is_public attribute are included when allow_local_engines=True."""
        engine = _make_engine(allow_local_engines=True)

        fake_class = Mock(spec=[])  # no is_public attribute
        config_data = {"nopublic": {"requires_api_key": False}}

        with (
            patch(f"{MODULE}.get_available_engines", return_value=config_data),
            patch(
                f"{MODULE}.BaseSearchEngine._load_engine_class",
                return_value=(True, fake_class, ""),
            ),
        ):
            result = engine._get_available_engines()

        assert "nopublic" in result

    def test_scientific_mode_skips_non_scientific_non_generic(self):
        """In SCIENTIFIC mode, engines that are neither scientific nor generic are skipped."""
        from local_deep_research.utilities.enums import SearchMode

        engine = _make_engine(search_mode=SearchMode.SCIENTIFIC)

        fake_class = self._make_engine_class(
            is_public=True, is_scientific=False, is_generic=False
        )
        config_data = {"specialised": {"requires_api_key": False}}

        with (
            patch(f"{MODULE}.get_available_engines", return_value=config_data),
            patch(
                f"{MODULE}.BaseSearchEngine._load_engine_class",
                return_value=(True, fake_class, ""),
            ),
        ):
            result = engine._get_available_engines()

        assert "specialised" not in result

    def test_scientific_mode_includes_scientific_engine(self):
        """In SCIENTIFIC mode, scientific engines are included."""
        from local_deep_research.utilities.enums import SearchMode

        engine = _make_engine(search_mode=SearchMode.SCIENTIFIC)

        fake_class = self._make_engine_class(
            is_public=True, is_scientific=True, is_generic=False
        )
        config_data = {"arxiv_like": {"requires_api_key": False}}

        with (
            patch(f"{MODULE}.get_available_engines", return_value=config_data),
            patch(
                f"{MODULE}.BaseSearchEngine._load_engine_class",
                return_value=(True, fake_class, ""),
            ),
        ):
            result = engine._get_available_engines()

        assert "arxiv_like" in result

    def test_skips_api_key_engine_when_use_api_key_services_false(self):
        """Engine requiring API key is filtered by shared get_available_engines."""
        engine = _make_engine(use_api_key_services=False)

        fake_class = self._make_engine_class(is_public=True)
        # Shared function already filters paid engines when use_api_key_services=False
        config_data = {"freeengine": {"requires_api_key": False}}

        with (
            patch(f"{MODULE}.get_available_engines", return_value=config_data),
            patch(
                f"{MODULE}.BaseSearchEngine._load_engine_class",
                return_value=(True, fake_class, ""),
            ),
        ):
            result = engine._get_available_engines()

        assert "paidengine" not in result
        assert "freeengine" in result


# ---------------------------------------------------------------------------
# _get_available_generic_engines branching
# ---------------------------------------------------------------------------


class TestGetAvailableGenericEnginesBranching:
    def test_skips_engine_not_in_available_engines(self):
        """Engine not in available_engines is skipped."""
        engine = _make_engine()
        engine.available_engines = ["eng_a"]  # eng_b is absent

        config_data = {"eng_b": {}}

        with patch.object(
            engine, "_get_search_config", return_value=config_data
        ):
            result = engine._get_available_generic_engines()

        assert "eng_b" not in result

    def test_skips_engine_when_load_class_fails(self):
        """Engine whose class cannot be loaded is skipped."""
        engine = _make_engine()
        engine.available_engines = ["faileng"]
        config_data = {"faileng": {}}

        with (
            patch(f"{MODULE}.get_available_engines", return_value=config_data),
            patch(
                f"{MODULE}.BaseSearchEngine._load_engine_class",
                return_value=(False, None, "load error"),
            ),
        ):
            result = engine._get_available_generic_engines()

        assert "faileng" not in result

    def test_includes_generic_engine(self):
        """Engine with is_generic=True is included."""
        engine = _make_engine()
        engine.available_engines = ["gen_engine"]
        config_data = {"gen_engine": {}}

        fake_class = Mock()
        fake_class.is_generic = True

        with (
            patch(f"{MODULE}.get_available_engines", return_value=config_data),
            patch(
                f"{MODULE}.BaseSearchEngine._load_engine_class",
                return_value=(True, fake_class, ""),
            ),
        ):
            result = engine._get_available_generic_engines()

        assert "gen_engine" in result

    def test_excludes_non_generic_engine(self):
        """Engine with is_generic=False is excluded."""
        engine = _make_engine()
        engine.available_engines = ["spec_engine"]
        config_data = {"spec_engine": {}}

        fake_class = Mock()
        fake_class.is_generic = False

        with (
            patch(f"{MODULE}.get_available_engines", return_value=config_data),
            patch(
                f"{MODULE}.BaseSearchEngine._load_engine_class",
                return_value=(True, fake_class, ""),
            ),
        ):
            result = engine._get_available_generic_engines()

        assert "spec_engine" not in result


# ---------------------------------------------------------------------------
# select_engines: only generic engines available
# ---------------------------------------------------------------------------


class TestSelectEnginesOnlyGeneric:
    def test_returns_generic_engines_when_no_specialized(self):
        """When all available engines are generic, returns them without LLM call."""
        engine = _make_engine(include_generic_engines=True)
        engine.available_engines = ["ddg", "brave"]

        with patch.object(
            engine,
            "_get_available_generic_engines",
            return_value=["ddg", "brave"],
        ):
            result = engine.select_engines("any query")

        # LLM should not be called; generic engines returned directly
        assert "ddg" in result
        assert "brave" in result
        engine.llm.invoke.assert_not_called()

    def test_select_engines_fallback_includes_generic_on_llm_error_with_include_generic_false(
        self,
    ):
        """When include_generic_engines=False and LLM errors, returns available engines."""
        mock_llm = Mock()
        mock_llm.invoke.side_effect = RuntimeError("LLM down")
        engine = _make_engine(llm=mock_llm, include_generic_engines=False)
        engine.available_engines = ["eng1", "eng2"]

        result = engine.select_engines("query")

        assert isinstance(result, list)
        # should contain available engines as fallback
        assert set(result).issubset({"eng1", "eng2"})

    def test_select_engines_llm_returns_out_of_range_indices(self):
        """LLM returns indices out of range; valid ones are used, rest ignored."""
        mock_llm = Mock()
        mock_llm.invoke.return_value = Mock(content="[0, 999]")
        engine = _make_engine(llm=mock_llm, include_generic_engines=False)
        engine.available_engines = ["pubmed", "arxiv"]

        result = engine.select_engines("science query")

        # Index 0 is valid (pubmed), 999 is out of range - only pubmed selected
        assert "pubmed" in result
        assert "arxiv" not in result  # index 1 was not in LLM response


# ---------------------------------------------------------------------------
# _get_previews: executor=None and future raises
# ---------------------------------------------------------------------------


class TestGetPreviewsEdgeCases:
    def test_get_previews_returns_empty_when_executor_none(self):
        """Returns empty list when global executor is None."""
        engine = _make_engine()
        engine.available_engines = ["eng1"]

        with (
            patch.object(engine, "select_engines", return_value=["eng1"]),
            patch(f"{MODULE}._get_global_executor", return_value=None),
            patch(f"{MODULE}.SocketIOService"),
        ):
            result = engine._get_previews("query")

        assert result == []

    def test_get_previews_handles_future_exception(self):
        """Results from futures that raise are stored as failure."""
        engine = _make_engine()
        engine.available_engines = ["bad_eng"]

        # Build a future that raises when .result() is called
        future = concurrent.futures.Future()
        future.set_exception(RuntimeError("thread crash"))

        mock_executor = Mock()
        mock_executor.submit.return_value = future

        with (
            patch.object(engine, "select_engines", return_value=["bad_eng"]),
            patch(f"{MODULE}._get_global_executor", return_value=mock_executor),
            patch(
                f"{MODULE}.concurrent.futures.as_completed",
                return_value=[future],
            ),
            patch(f"{MODULE}.SocketIOService"),
        ):
            result = engine._get_previews("query")

        # No crash; bad engine counted as failure; all_results empty
        assert isinstance(result, list)
        assert result == []

    def test_get_previews_emits_socket_event_on_success(self):
        """Successful engine results get emitted via socket."""
        engine = _make_engine()
        engine.available_engines = ["good_eng"]

        future = concurrent.futures.Future()
        future.set_result(
            {
                "engine": "good_eng",
                "success": True,
                "results": [{"title": "T", "link": "http://x.com"}],
                "count": 1,
            }
        )

        mock_executor = Mock()
        mock_executor.submit.return_value = future

        mock_socket = Mock()

        with (
            patch.object(engine, "select_engines", return_value=["good_eng"]),
            patch(f"{MODULE}._get_global_executor", return_value=mock_executor),
            patch(
                f"{MODULE}.concurrent.futures.as_completed",
                return_value=[future],
            ),
            patch(f"{MODULE}.SocketIOService", return_value=mock_socket),
        ):
            result = engine._get_previews("query")

        assert len(result) == 1
        assert result[0]["search_engine"] == "good_eng"

    def test_get_previews_emits_failure_socket_event(self):
        """Failed engine emits failure socket event."""
        engine = _make_engine()
        engine.available_engines = ["fail_eng"]

        future = concurrent.futures.Future()
        future.set_result(
            {
                "engine": "fail_eng",
                "success": False,
                "error": "timeout",
                "results": [],
            }
        )

        mock_executor = Mock()
        mock_executor.submit.return_value = future

        mock_socket = Mock()

        with (
            patch.object(engine, "select_engines", return_value=["fail_eng"]),
            patch(f"{MODULE}._get_global_executor", return_value=mock_executor),
            patch(
                f"{MODULE}.concurrent.futures.as_completed",
                return_value=[future],
            ),
            patch(f"{MODULE}.SocketIOService", return_value=mock_socket),
        ):
            result = engine._get_previews("query")

        assert result == []


# ---------------------------------------------------------------------------
# _get_full_content: engine raises on _get_full_content
# ---------------------------------------------------------------------------


class TestGetFullContentException:
    def test_falls_back_to_items_when_engine_full_content_raises(self):
        """When engine._get_full_content raises, items are returned as-is."""
        engine = _make_engine()

        mock_sub_engine = Mock()
        mock_sub_engine._get_full_content.side_effect = RuntimeError(
            "full content error"
        )
        engine.engine_cache["eng_x"] = mock_sub_engine

        items = [
            {"title": "A", "url": "http://a.com", "search_engine": "eng_x"}
        ]

        with (
            patch(f"{MODULE}.get_setting_from_snapshot", return_value=False),
            patch.object(
                engine, "_get_engine_instance", return_value=mock_sub_engine
            ),
        ):
            result = engine._get_full_content(items)

        # Falls back to original items
        assert len(result) == 1
        assert result[0]["title"] == "A"

    def test_returns_items_when_no_search_engine_key(self):
        """Items without search_engine key are not grouped and not returned (no engine)."""
        engine = _make_engine()

        items = [{"title": "NoEngine", "url": "http://b.com"}]

        with patch(f"{MODULE}.get_setting_from_snapshot", return_value=False):
            result = engine._get_full_content(items)

        # Items with no search_engine key produce empty all_full_content
        assert result == []


# ---------------------------------------------------------------------------
# close method
# ---------------------------------------------------------------------------


class TestCloseMethod:
    def test_close_clears_engine_cache(self):
        """close() clears the engine cache."""
        engine = _make_engine()

        mock_child = Mock()
        engine.engine_cache["child1"] = mock_child

        with patch(
            "local_deep_research.utilities.resource_utils.safe_close"
        ) as mock_safe_close:
            engine.close()

        mock_safe_close.assert_called_once_with(
            mock_child, "child search engine"
        )
        assert engine.engine_cache == {}

    def test_close_calls_super(self):
        """close() calls parent close()."""
        engine = _make_engine()

        with (
            patch("local_deep_research.utilities.resource_utils.safe_close"),
            patch(
                "local_deep_research.web_search_engines.search_engine_base.BaseSearchEngine.close"
            ) as mock_super_close,
        ):
            engine.close()

        mock_super_close.assert_called_once()
