"""
Coverage tests for SearXNGSearchEngine.

Targets uncovered paths in search_engine_searxng.py including:
- _get_search_results: non-200 response, BeautifulSoup import error,
  HTML parsing exception, cookie fetch failure, backend engine failure
  logging, engine parse error, result item selector fallback chain,
  URL from title element href
- _get_full_content: snippets-only mode, exception handling
- _normalize_list: non-string/non-list returns None, JSON non-list
  falls through to comma-separated
"""

from unittest.mock import Mock, patch


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

MODULE = "local_deep_research.web_search_engines.engines.search_engine_searxng"


def _make_engine(**kwargs):
    """Create a SearXNGSearchEngine with the network init call mocked out."""
    from local_deep_research.web_search_engines.engines.search_engine_searxng import (
        SearXNGSearchEngine,
    )

    with patch(f"{MODULE}.safe_get") as mock_get:
        mock_resp = Mock()
        mock_resp.status_code = 200
        mock_get.return_value = mock_resp

        defaults = dict(instance_url="http://localhost:8080", max_results=10)
        defaults.update(kwargs)
        engine = SearXNGSearchEngine(**defaults)

    return engine


def _make_search_response(html, status_code=200):
    """Build a mock HTTP response carrying *html* as body text."""
    resp = Mock()
    resp.status_code = status_code
    resp.text = html
    resp.cookies = {}
    return resp


# ---------------------------------------------------------------------------
# _get_search_results – non-200 response
# ---------------------------------------------------------------------------


class TestGetSearchResultsNon200:
    """_get_search_results returns [] when the search HTTP call is non-200."""

    def test_non_200_returns_empty(self):
        engine = _make_engine()

        cookie_resp = Mock()
        cookie_resp.cookies = {}
        search_resp = _make_search_response("", status_code=403)

        with patch(
            f"{MODULE}.safe_get", side_effect=[cookie_resp, search_resp]
        ):
            results = engine._get_search_results("test query")

        assert results == []


# ---------------------------------------------------------------------------
# _get_search_results – BeautifulSoup import error
# ---------------------------------------------------------------------------


class TestGetSearchResultsBSImportError:
    """_get_search_results returns [] when bs4 cannot be imported."""

    def test_bs4_import_error(self):
        engine = _make_engine()

        cookie_resp = Mock()
        cookie_resp.cookies = {}
        search_resp = _make_search_response("<html></html>", status_code=200)

        import builtins

        real_import = builtins.__import__

        def fake_import(name, *args, **kwargs):
            if name == "bs4":
                raise ImportError("no bs4")
            return real_import(name, *args, **kwargs)

        with patch(
            f"{MODULE}.safe_get", side_effect=[cookie_resp, search_resp]
        ):
            with patch("builtins.__import__", side_effect=fake_import):
                results = engine._get_search_results("test query")

        assert results == []


# ---------------------------------------------------------------------------
# _get_search_results – HTML parsing exception
# ---------------------------------------------------------------------------


class TestGetSearchResultsHTMLParseException:
    """_get_search_results returns [] on unexpected HTML parsing errors."""

    def test_parse_exception_returns_empty(self):
        engine = _make_engine()

        cookie_resp = Mock()
        cookie_resp.cookies = {}
        search_resp = _make_search_response("<html></html>", status_code=200)

        with patch(
            f"{MODULE}.safe_get", side_effect=[cookie_resp, search_resp]
        ):
            with patch(
                "bs4.BeautifulSoup", side_effect=RuntimeError("parse boom")
            ):
                results = engine._get_search_results("test query")

        assert results == []


# ---------------------------------------------------------------------------
# _get_search_results – cookie fetch failure
# ---------------------------------------------------------------------------


class TestGetSearchResultsCookieFailure:
    """_get_search_results still works when initial cookie fetch fails."""

    def test_cookie_failure_still_searches(self):
        engine = _make_engine()

        html = """
        <html><body>
        <article class="result">
            <h3><a href="https://example.com/p1">Title 1</a></h3>
            <p>Snippet 1</p>
        </article>
        </body></html>
        """
        search_resp = _make_search_response(html)

        def side_effect_fn(*args, **kwargs):
            # First call (cookie fetch) raises, second (search) succeeds
            if side_effect_fn.call_count == 0:
                side_effect_fn.call_count += 1
                raise ConnectionError("cookie fetch failed")
            side_effect_fn.call_count += 1
            return search_resp

        side_effect_fn.call_count = 0

        with patch(f"{MODULE}.safe_get", side_effect=side_effect_fn):
            results = engine._get_search_results("test query")

        assert len(results) == 1
        assert results[0]["url"] == "https://example.com/p1"


# ---------------------------------------------------------------------------
# _get_search_results – backend engine failure logging
# ---------------------------------------------------------------------------


class TestGetSearchResultsBackendEngineFailure:
    """When a result URL contains /stats?engine=, it logs a warning."""

    def test_backend_engine_failure_logged(self):
        engine = _make_engine()

        html = """
        <html><body>
        <article class="result">
            <h3><a href="http://localhost:8080/stats?engine=google&reason=timeout">Google Error</a></h3>
            <p>Engine failed</p>
        </article>
        </body></html>
        """
        cookie_resp = Mock()
        cookie_resp.cookies = {}
        search_resp = _make_search_response(html)

        with patch(
            f"{MODULE}.safe_get", side_effect=[cookie_resp, search_resp]
        ):
            with patch(f"{MODULE}.logger") as mock_logger:
                results = engine._get_search_results("test query")

        # The result should be filtered out (points to the instance itself)
        assert results == []
        # A warning about the failed engine should have been logged
        warning_calls = [
            call
            for call in mock_logger.warning.call_args_list
            if "google" in str(call).lower()
        ]
        assert len(warning_calls) >= 1


# ---------------------------------------------------------------------------
# _get_search_results – engine name parse error in /stats?engine= URL
# ---------------------------------------------------------------------------


class TestGetSearchResultsEngineParseError:
    """When /stats?engine= URL has no engine name, IndexError is silently caught."""

    def test_engine_parse_error_handled(self):
        engine = _make_engine()

        # URL ends right after /stats?engine= with nothing after it;
        # split("&")[0] yields empty string but IndexError won't happen here.
        # Use a URL where split("/stats?engine=") gives a list of length 1
        # so [1] raises IndexError.
        html = """
        <html><body>
        <div id="result1" class="result">
            <a href="http://localhost:8080/stats?engine=">Bad engine ref</a>
            <p>Content</p>
        </div>
        </body></html>
        """
        cookie_resp = Mock()
        cookie_resp.cookies = {}
        search_resp = _make_search_response(html)

        with patch(
            f"{MODULE}.safe_get", side_effect=[cookie_resp, search_resp]
        ):
            # Should not raise; the result is simply filtered
            results = engine._get_search_results("test query")

        assert results == []


# ---------------------------------------------------------------------------
# _get_search_results – selector fallback chain (div[id^="result"])
# ---------------------------------------------------------------------------


class TestGetSearchResultsSelectorFallback:
    """When .result-item, .result, article are absent, falls back to div[id^='result']."""

    def test_div_id_result_fallback(self):
        engine = _make_engine()

        html = """
        <html><body>
        <div id="result_1">
            <a href="https://example.com/fallback">Fallback Result</a>
            <p>Fallback snippet</p>
        </div>
        </body></html>
        """
        cookie_resp = Mock()
        cookie_resp.cookies = {}
        search_resp = _make_search_response(html)

        with patch(
            f"{MODULE}.safe_get", side_effect=[cookie_resp, search_resp]
        ):
            results = engine._get_search_results("test query")

        assert len(results) == 1
        assert results[0]["title"] == "Fallback Result"
        assert results[0]["url"] == "https://example.com/fallback"


# ---------------------------------------------------------------------------
# _get_search_results – URL extracted from title element href
# ---------------------------------------------------------------------------


class TestGetSearchResultsURLFromTitleHref:
    """When url_element has no href, URL falls back to title_element href."""

    def test_url_from_title_element_href(self):
        engine = _make_engine()

        # Craft HTML where url_element exists but has no href and empty text,
        # so url stays empty. Then title_element href is used as fallback.
        html = """
        <html><body>
        <article class="result">
            <a class="result-title" href="https://example.com/from-title">Title Text</a>
            <span class="url"></span>
            <p class="content">Some content</p>
        </article>
        </body></html>
        """
        cookie_resp = Mock()
        cookie_resp.cookies = {}
        search_resp = _make_search_response(html)

        with patch(
            f"{MODULE}.safe_get", side_effect=[cookie_resp, search_resp]
        ):
            results = engine._get_search_results("test query")

        assert len(results) == 1
        # url_element (.url span) has no href and empty text => url is "".
        # Fallback: title_element (.result-title) has href => url becomes that.
        assert results[0]["url"] == "https://example.com/from-title"


# ---------------------------------------------------------------------------
# _get_full_content – snippets-only mode
# ---------------------------------------------------------------------------


class TestGetFullContentSnippetsOnly:
    """_get_full_content returns items unchanged in snippets-only mode."""

    def test_snippets_only_skips_full_content(self):
        engine = _make_engine()
        items = [{"title": "T", "link": "https://example.com", "snippet": "S"}]

        with patch(f"{MODULE}.search_config") as mock_cfg:
            mock_cfg.SEARCH_SNIPPETS_ONLY = True
            result = engine._get_full_content(items)

        assert result is items


# ---------------------------------------------------------------------------
# _get_full_content – exception handling
# ---------------------------------------------------------------------------


class TestGetFullContentException:
    """_get_full_content returns original items on exception."""

    def test_exception_returns_original_items(self):
        engine = _make_engine()
        items = [{"title": "T", "link": "https://example.com", "snippet": "S"}]

        with patch(f"{MODULE}.search_config") as mock_cfg:
            mock_cfg.SEARCH_SNIPPETS_ONLY = False
            # Make full_search._get_full_content raise
            engine.full_search = Mock()
            engine.full_search._get_full_content.side_effect = RuntimeError(
                "boom"
            )

            result = engine._get_full_content(items)

        assert result is items


# ---------------------------------------------------------------------------
# _normalize_list – non-string / non-list returns None
# ---------------------------------------------------------------------------


class TestNormalizeListNonStringNonList:
    """_normalize_list returns None for types that are not str, list, or None."""

    def test_integer_returns_none(self):
        from local_deep_research.web_search_engines.engines.search_engine_searxng import (
            SearXNGSearchEngine,
        )

        assert SearXNGSearchEngine._normalize_list(42) is None

    def test_dict_returns_none(self):
        from local_deep_research.web_search_engines.engines.search_engine_searxng import (
            SearXNGSearchEngine,
        )

        assert SearXNGSearchEngine._normalize_list({"a": 1}) is None

    def test_float_returns_none(self):
        from local_deep_research.web_search_engines.engines.search_engine_searxng import (
            SearXNGSearchEngine,
        )

        assert SearXNGSearchEngine._normalize_list(3.14) is None

    def test_bool_returns_none(self):
        from local_deep_research.web_search_engines.engines.search_engine_searxng import (
            SearXNGSearchEngine,
        )

        assert SearXNGSearchEngine._normalize_list(True) is None


# ---------------------------------------------------------------------------
# _normalize_list – JSON non-list falls through to comma-separated
# ---------------------------------------------------------------------------


class TestNormalizeListJSONNonList:
    """When JSON parses successfully but result is not a list, fall through."""

    def test_json_object_falls_through_to_comma_split(self):
        from local_deep_research.web_search_engines.engines.search_engine_searxng import (
            SearXNGSearchEngine,
        )

        # Valid JSON but it's an object, not a list
        result = SearXNGSearchEngine._normalize_list('{"key": "value"}')
        # Falls through to comma-separated split
        assert result == ['{"key": "value"}']

    def test_json_string_falls_through_to_comma_split(self):
        from local_deep_research.web_search_engines.engines.search_engine_searxng import (
            SearXNGSearchEngine,
        )

        # Valid JSON but it's a bare string, not a list
        result = SearXNGSearchEngine._normalize_list('"just a string"')
        # Falls through to comma-separated split
        assert result == ['"just a string"']

    def test_json_number_falls_through_to_comma_split(self):
        from local_deep_research.web_search_engines.engines.search_engine_searxng import (
            SearXNGSearchEngine,
        )

        # Valid JSON but it's a number, not a list
        result = SearXNGSearchEngine._normalize_list("42")
        # Falls through to comma-separated split
        assert result == ["42"]
