"""
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"

    def test_whitespace_padded_title_href_survives_gate(self):
        """Regression: a whitespace-padded title-href must survive the
        _is_valid_search_result() http(s):// prefix gate instead of being
        silently dropped.

        That gate runs before the SSRF validator's internal strip, so without
        the extraction-time strip the padded href fails the prefix check and
        the result is dropped (len would be 0). BeautifulSoup preserves the
        surrounding whitespace, so this exercises the real path.
        """
        engine = _make_engine()

        html = """
        <html><body>
        <article class="result">
            <a class="result-title" href="  https://example.com/padded  ">Title</a>
            <span class="url"></span>
            <p class="content">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  # would be 0 before the strip fix
        assert results[0]["url"] == "https://example.com/padded"


# ---------------------------------------------------------------------------
# _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"}]

        # 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"]
