"""Tests for safe_requests module - SSRF-protected HTTP requests."""

import pytest
from unittest.mock import patch, MagicMock
import requests

from local_deep_research.security import ssrf_validator
from local_deep_research.security.safe_requests import (
    safe_get,
    safe_post,
    SafeSession,
    DEFAULT_TIMEOUT,
    MAX_RESPONSE_SIZE,
)


class TestSafeGetFunction:
    """Tests for safe_get function."""

    def test_valid_url_makes_request(self):
        """Should make request to valid external URL."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.get"
            ) as mock_get:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_get.return_value = mock_response

                response = safe_get("https://example.com")

                mock_get.assert_called_once()
                assert response == mock_response

    def test_rejects_invalid_url(self):
        """Should raise ValueError for URLs failing SSRF validation."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=False,
        ):
            with pytest.raises(ValueError, match="SSRF"):
                safe_get("http://127.0.0.1/admin")

    def test_uses_default_timeout(self):
        """Should use default timeout when not specified."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.get"
            ) as mock_get:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_get.return_value = mock_response

                safe_get("https://example.com")

                _, kwargs = mock_get.call_args
                assert kwargs["timeout"] == DEFAULT_TIMEOUT

    def test_custom_timeout(self):
        """Should use custom timeout when provided."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.get"
            ) as mock_get:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_get.return_value = mock_response

                safe_get("https://example.com", timeout=60)

                _, kwargs = mock_get.call_args
                assert kwargs["timeout"] == 60

    def test_underlying_requests_always_gets_redirects_disabled(self):
        """The underlying requests.get() always receives allow_redirects=False;
        safe_get handles redirects manually with SSRF validation."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.get"
            ) as mock_get:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_get.return_value = mock_response

                safe_get("https://example.com")

                _, kwargs = mock_get.call_args
                assert kwargs["allow_redirects"] is False

    def test_can_enable_redirects(self):
        """With allow_redirects=True, redirects are still disabled at
        requests level (handled manually for SSRF validation)."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.get"
            ) as mock_get:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_get.return_value = mock_response

                safe_get("https://example.com", allow_redirects=True)

                _, kwargs = mock_get.call_args
                # Redirects are always disabled at the requests level;
                # safe_get handles them manually with SSRF validation
                assert kwargs["allow_redirects"] is False

    def test_passes_params(self):
        """Should pass URL parameters."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.get"
            ) as mock_get:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_get.return_value = mock_response

                params = {"q": "test", "page": "1"}
                safe_get("https://example.com", params=params)

                args, _ = mock_get.call_args
                assert args == ("https://example.com",)
                _, kwargs = mock_get.call_args
                assert "params" not in kwargs or kwargs.get("params") == params

    def test_oversized_response_raises_error(self):
        """Oversized responses are rejected with ValueError."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.get"
            ) as mock_get:
                mock_response = MagicMock()
                mock_response.headers = {
                    "Content-Length": str(MAX_RESPONSE_SIZE + 1)
                }
                mock_get.return_value = mock_response

                with pytest.raises(ValueError, match="Response too large"):
                    safe_get("https://example.com")

    def test_accepts_response_within_limit(self):
        """Should accept response within size limit."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.get"
            ) as mock_get:
                mock_response = MagicMock()
                mock_response.headers = {"Content-Length": str(1024)}
                mock_get.return_value = mock_response

                response = safe_get("https://example.com")
                assert response == mock_response

    def test_handles_invalid_content_length(self):
        """Should ignore invalid Content-Length values."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.get"
            ) as mock_get:
                mock_response = MagicMock()
                mock_response.headers = {"Content-Length": "not-a-number"}
                mock_get.return_value = mock_response

                # Should not raise
                response = safe_get("https://example.com")
                assert response == mock_response

    def test_reraises_timeout(self):
        """Should re-raise timeout exceptions."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.get",
                side_effect=requests.Timeout("timeout"),
            ):
                with pytest.raises(requests.Timeout):
                    safe_get("https://example.com")

    def test_reraises_request_exception(self):
        """Should re-raise request exceptions."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.get",
                side_effect=requests.RequestException("connection error"),
            ):
                with pytest.raises(requests.RequestException):
                    safe_get("https://example.com")

    def test_allow_localhost_parameter(self):
        """Should pass allow_localhost to validate_url."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ) as mock_validate:
            with patch(
                "local_deep_research.security.safe_requests.requests.get"
            ) as mock_get:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_get.return_value = mock_response

                safe_get("http://localhost:8080", allow_localhost=True)

                mock_validate.assert_called_once_with(
                    "http://localhost:8080",
                    allow_localhost=True,
                    allow_private_ips=False,
                )

    def test_allow_private_ips_parameter(self):
        """Should pass allow_private_ips to validate_url."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ) as mock_validate:
            with patch(
                "local_deep_research.security.safe_requests.requests.get"
            ) as mock_get:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_get.return_value = mock_response

                safe_get("http://192.168.1.1", allow_private_ips=True)

                mock_validate.assert_called_once_with(
                    "http://192.168.1.1",
                    allow_localhost=False,
                    allow_private_ips=True,
                )


class TestSafePostFunction:
    """Tests for safe_post function."""

    def test_valid_url_makes_request(self):
        """Should make POST request to valid external URL."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.post"
            ) as mock_post:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_post.return_value = mock_response

                response = safe_post(
                    "https://example.com/api", json={"key": "value"}
                )

                mock_post.assert_called_once()
                assert response == mock_response

    def test_rejects_invalid_url(self):
        """Should raise ValueError for URLs failing SSRF validation."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=False,
        ):
            with pytest.raises(ValueError, match="SSRF"):
                safe_post("http://169.254.169.254/metadata")

    def test_passes_data_parameter(self):
        """Should pass data parameter for form data."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.post"
            ) as mock_post:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_post.return_value = mock_response

                safe_post("https://example.com", data="raw data")

                _, kwargs = mock_post.call_args
                assert kwargs.get("data") == "raw data"

    def test_passes_json_parameter(self):
        """Should pass json parameter for JSON data."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.post"
            ) as mock_post:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_post.return_value = mock_response

                json_data = {"key": "value"}
                safe_post("https://example.com", json=json_data)

                _, kwargs = mock_post.call_args
                assert kwargs.get("json") == json_data

    def test_uses_default_timeout(self):
        """Should use default timeout when not specified."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.post"
            ) as mock_post:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_post.return_value = mock_response

                safe_post("https://example.com")

                _, kwargs = mock_post.call_args
                assert kwargs["timeout"] == DEFAULT_TIMEOUT

    def test_underlying_requests_always_gets_redirects_disabled(self):
        """The underlying requests.post() always receives allow_redirects=False;
        safe_post handles redirects manually with SSRF validation."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.post"
            ) as mock_post:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_post.return_value = mock_response

                safe_post("https://example.com")

                _, kwargs = mock_post.call_args
                assert kwargs["allow_redirects"] is False

    def test_can_enable_redirects(self):
        """With allow_redirects=True, redirects are still disabled at
        requests level (handled manually for SSRF validation)."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.post"
            ) as mock_post:
                mock_response = MagicMock()
                mock_response.headers = {}
                mock_post.return_value = mock_response

                safe_post("https://example.com", allow_redirects=True)

                _, kwargs = mock_post.call_args
                # Redirects are always disabled at the requests level;
                # safe_post handles them manually with SSRF validation
                assert kwargs["allow_redirects"] is False

    def test_oversized_response_raises_error(self):
        """Oversized responses are rejected with ValueError."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.post"
            ) as mock_post:
                mock_response = MagicMock()
                mock_response.headers = {
                    "Content-Length": str(MAX_RESPONSE_SIZE + 1)
                }
                mock_post.return_value = mock_response

                with pytest.raises(ValueError, match="Response too large"):
                    safe_post("https://example.com")

    def test_reraises_timeout(self):
        """Should re-raise timeout exceptions."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch(
                "local_deep_research.security.safe_requests.requests.post",
                side_effect=requests.Timeout("timeout"),
            ):
                with pytest.raises(requests.Timeout):
                    safe_post("https://example.com")


class TestSafeSession:
    """Tests for SafeSession class."""

    def test_init_default_values(self):
        """Should initialize with default security settings."""
        session = SafeSession()
        assert session.allow_localhost is False
        assert session.allow_private_ips is False

    def test_init_allow_localhost(self):
        """Should accept allow_localhost parameter."""
        session = SafeSession(allow_localhost=True)
        assert session.allow_localhost is True
        assert session.allow_private_ips is False

    def test_init_allow_private_ips(self):
        """Should accept allow_private_ips parameter."""
        session = SafeSession(allow_private_ips=True)
        assert session.allow_localhost is False
        assert session.allow_private_ips is True

    def test_request_validates_url(self):
        """Should validate URL before making request."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=False,
        ):
            session = SafeSession()
            with pytest.raises(ValueError, match="SSRF"):
                session.request("GET", "http://127.0.0.1/admin")

    def test_request_makes_call_on_valid_url(self):
        """Should make request when URL is valid."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch.object(requests.Session, "request") as mock_request:
                mock_response = MagicMock()
                mock_request.return_value = mock_response

                session = SafeSession()
                response = session.request("GET", "https://example.com")

                mock_request.assert_called_once()
                assert response == mock_response

    def test_request_uses_default_timeout(self):
        """Should set default timeout if not provided."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch.object(requests.Session, "request") as mock_request:
                mock_response = MagicMock()
                mock_request.return_value = mock_response

                session = SafeSession()
                session.request("GET", "https://example.com")

                _, kwargs = mock_request.call_args
                assert kwargs["timeout"] == DEFAULT_TIMEOUT

    def test_request_respects_custom_timeout(self):
        """Should respect custom timeout when provided."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch.object(requests.Session, "request") as mock_request:
                mock_response = MagicMock()
                mock_request.return_value = mock_response

                session = SafeSession()
                session.request("GET", "https://example.com", timeout=120)

                _, kwargs = mock_request.call_args
                assert kwargs["timeout"] == 120

    def test_context_manager(self):
        """Should work as context manager."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with SafeSession() as session:
                assert isinstance(session, SafeSession)

    def test_passes_allow_localhost_to_validate(self):
        """Should pass allow_localhost to validate_url."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ) as mock_validate:
            with patch.object(requests.Session, "request"):
                session = SafeSession(allow_localhost=True)
                session.request("GET", "http://localhost:8080")

                mock_validate.assert_called_once_with(
                    "http://localhost:8080",
                    allow_localhost=True,
                    allow_private_ips=False,
                )

    def test_passes_allow_private_ips_to_validate(self):
        """Should pass allow_private_ips to validate_url."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ) as mock_validate:
            with patch.object(requests.Session, "request"):
                session = SafeSession(allow_private_ips=True)
                session.request("GET", "http://192.168.1.1")

                mock_validate.assert_called_once_with(
                    "http://192.168.1.1",
                    allow_localhost=False,
                    allow_private_ips=True,
                )


class TestConstants:
    """Tests for module constants."""

    def test_default_timeout_reasonable(self):
        """DEFAULT_TIMEOUT should be a reasonable value."""
        assert DEFAULT_TIMEOUT == 30
        assert isinstance(DEFAULT_TIMEOUT, int)

    def test_max_response_size_reasonable(self):
        """MAX_RESPONSE_SIZE should be a reasonable value (1GB)."""
        assert MAX_RESPONSE_SIZE == 1024 * 1024 * 1024  # 1GB
        assert isinstance(MAX_RESPONSE_SIZE, int)


class TestUserAgentInjection:
    """The project User-Agent should be injected automatically when the
    caller doesn't supply one, and preserved when they do.

    This locks in the behavior added by PR #3081 — academic API endpoints
    (arXiv, OpenAlex, PubMed, …) need to identify the caller, and we
    don't want every call site to remember to set the header manually.
    """

    def _make_response(self):
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.headers = {}
        mock_response.url = "https://example.com"
        return mock_response

    def test_safe_get_injects_user_agent_when_missing(self):
        """safe_get with no headers should auto-inject the project UA."""
        from local_deep_research.constants import USER_AGENT

        with (
            patch.object(ssrf_validator, "validate_url", return_value=True),
            patch(
                "local_deep_research.security.safe_requests.requests.get",
                return_value=self._make_response(),
            ) as mock_get,
        ):
            safe_get("https://example.com")

            _, kwargs = mock_get.call_args
            assert "headers" in kwargs
            assert kwargs["headers"]["User-Agent"] == USER_AGENT

    def test_safe_get_preserves_explicit_user_agent(self):
        """A caller-supplied User-Agent must NOT be overwritten."""
        custom_ua = "Mozilla/5.0 Custom/1.0"

        with (
            patch.object(ssrf_validator, "validate_url", return_value=True),
            patch(
                "local_deep_research.security.safe_requests.requests.get",
                return_value=self._make_response(),
            ) as mock_get,
        ):
            safe_get("https://example.com", headers={"User-Agent": custom_ua})

            _, kwargs = mock_get.call_args
            assert kwargs["headers"]["User-Agent"] == custom_ua

    def test_safe_get_user_agent_check_is_case_insensitive(self):
        """A `user-agent` (lowercase) header should be respected."""
        custom_ua = "Mozilla/5.0 Custom/1.0"

        with (
            patch.object(ssrf_validator, "validate_url", return_value=True),
            patch(
                "local_deep_research.security.safe_requests.requests.get",
                return_value=self._make_response(),
            ) as mock_get,
        ):
            safe_get("https://example.com", headers={"user-agent": custom_ua})

            _, kwargs = mock_get.call_args
            # Original lowercase key preserved; no second User-Agent added
            assert kwargs["headers"]["user-agent"] == custom_ua
            assert "User-Agent" not in kwargs["headers"]

    def test_safe_get_does_not_mutate_caller_headers_dict(self):
        """safe_get must not add User-Agent to the caller's dict."""
        caller_headers = {"X-Custom": "value"}

        with (
            patch.object(ssrf_validator, "validate_url", return_value=True),
            patch(
                "local_deep_research.security.safe_requests.requests.get",
                return_value=self._make_response(),
            ),
        ):
            safe_get("https://example.com", headers=caller_headers)

            # Caller's dict is untouched — User-Agent injection is on a
            # copy, not the original.
            assert "User-Agent" not in caller_headers
            assert caller_headers == {"X-Custom": "value"}

    def test_safe_post_injects_user_agent_when_missing(self):
        """safe_post should auto-inject the project UA."""
        from local_deep_research.constants import USER_AGENT

        with (
            patch.object(ssrf_validator, "validate_url", return_value=True),
            patch(
                "local_deep_research.security.safe_requests.requests.post",
                return_value=self._make_response(),
            ) as mock_post,
        ):
            safe_post("https://example.com", data={"k": "v"})

            _, kwargs = mock_post.call_args
            assert kwargs["headers"]["User-Agent"] == USER_AGENT

    def test_safe_post_preserves_explicit_user_agent(self):
        custom_ua = "Mozilla/5.0 Custom/1.0"

        with (
            patch.object(ssrf_validator, "validate_url", return_value=True),
            patch(
                "local_deep_research.security.safe_requests.requests.post",
                return_value=self._make_response(),
            ) as mock_post,
        ):
            safe_post(
                "https://example.com",
                data={"k": "v"},
                headers={"User-Agent": custom_ua},
            )

            _, kwargs = mock_post.call_args
            assert kwargs["headers"]["User-Agent"] == custom_ua


class TestParserDifferentialEndToEnd:
    """
    End-to-end integration tests for the parser-differential SSRF bypass
    fix (GHSA-g23j-2vwm-5c25).

    Approach: bind a TCP socket on 127.0.0.1:<random> WITHOUT calling
    listen() — the kernel responds RST to any incoming connect.  If the
    fix regresses and ``safe_get`` actually attempts to connect to the
    bound port, ``requests`` raises ``ConnectionError`` (kernel RST), so
    a strict ``pytest.raises(ValueError, match=...)`` distinguishes
    "validator caught it" from "validator failed and the kernel saved us".
    """

    @staticmethod
    def _bind_unused_port():
        import socket as _socket

        sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
        sock.bind(("127.0.0.1", 0))
        return sock

    def test_safe_get_blocks_parser_differential_no_socket_connect(self):
        from local_deep_research.security.safe_requests import safe_get

        sock = self._bind_unused_port()
        try:
            port = sock.getsockname()[1]
            bypass_url = f"http://127.0.0.1:{port}\\@1.1.1.1"
            with pytest.raises(ValueError, match="SSRF|security validation"):
                safe_get(bypass_url, timeout=2)
        finally:
            sock.close()

    def test_safe_post_blocks_parser_differential(self):
        from local_deep_research.security.safe_requests import safe_post

        sock = self._bind_unused_port()
        try:
            port = sock.getsockname()[1]
            bypass_url = f"http://127.0.0.1:{port}\\@1.1.1.1"
            with pytest.raises(ValueError, match="SSRF|security validation"):
                safe_post(bypass_url, data={"k": "v"}, timeout=2)
        finally:
            sock.close()

    def test_safesession_blocks_parser_differential(self):
        """SafeSession validates at both request() and send() — exercises
        the double-validation path. This URL contains ``\\`` so Layer 1
        catches it at request() before .prepare() canonicalises it."""
        from local_deep_research.security.safe_requests import SafeSession

        sock = self._bind_unused_port()
        try:
            port = sock.getsockname()[1]
            bypass_url = f"http://127.0.0.1:{port}\\@1.1.1.1"
            with SafeSession() as sess:
                with pytest.raises(
                    ValueError, match="SSRF|security validation"
                ):
                    sess.get(bypass_url, timeout=2)
        finally:
            sock.close()

    def test_safesession_send_blocks_canonicalised_form(self):
        """
        Layer-2 verification: ``SafeSession.send()`` is called with a
        ``PreparedRequest`` whose URL contains ``%5C`` (the canonicalised
        form of ``\\``).  Layer 1 doesn't match ``%5C``, so Layer 2's
        urllib3-based hostname extraction is what blocks this — proving
        Layer 2 carries the load on this path.
        """
        from local_deep_research.security.safe_requests import SafeSession

        with SafeSession() as sess:
            req = requests.Request("GET", "http://127.0.0.1:6666/%5C@1.1.1.1")
            prepared = sess.prepare_request(req)
            with pytest.raises(ValueError, match="SSRF|security validation"):
                sess.send(prepared, timeout=2)
