"""Tests for SSRF-validated redirect following (PR #1949).

Verifies:
- Manual redirect following with SSRF validation on each hop
- Redirect to internal IPs blocked
- Too many redirects raises ValueError
- Missing Location header stops redirect chain
- Both safe_get and safe_post
"""

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,
    _REDIRECT_STATUS_CODES,
    _MAX_REDIRECTS,
    _resolve_redirect_method,
    MAX_RESPONSE_SIZE,
)


@pytest.fixture
def mock_validate_url():
    with patch.object(
        ssrf_validator,
        "validate_url",
        return_value=True,
    ) as m:
        yield m


def _make_response(status_code=200, headers=None, url="https://example.com"):
    resp = MagicMock()
    resp.status_code = status_code
    resp.headers = headers or {}
    resp.url = url
    return resp


class TestSafeGetRedirectFollowing:
    """Test redirect following in safe_get."""

    def test_no_redirects_when_disabled(self, mock_validate_url):
        """With allow_redirects=False, redirect responses are returned as-is."""
        redirect_resp = _make_response(302, {"Location": "https://other.com"})

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=redirect_resp,
        ):
            result = safe_get("https://example.com", allow_redirects=False)
            assert result.status_code == 302

    def test_follows_valid_redirect(self, mock_validate_url):
        """With allow_redirects=True, follows redirect to validated target."""
        redirect_resp = _make_response(
            302, {"Location": "https://other.com"}, "https://example.com"
        )
        final_resp = _make_response(200)

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            side_effect=[redirect_resp, final_resp],
        ):
            result = safe_get("https://example.com", allow_redirects=True)
            assert result.status_code == 200

    def test_blocks_redirect_to_internal_ip(self, mock_validate_url):
        """Redirect to internal IP raises ValueError."""
        redirect_resp = _make_response(
            302,
            {"Location": "http://169.254.169.254/metadata"},
            "https://example.com",
        )

        # First call validates initial URL (True), second validates redirect target (False)
        mock_validate_url.side_effect = [True, False]

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=redirect_resp,
        ):
            with pytest.raises(ValueError, match="Redirect target failed SSRF"):
                safe_get("https://example.com", allow_redirects=True)

    def test_too_many_redirects_raises(self, mock_validate_url):
        """More than _MAX_REDIRECTS raises ValueError."""
        redirect_resp = _make_response(
            301, {"Location": "https://example.com/loop"}, "https://example.com"
        )

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=redirect_resp,
        ):
            with pytest.raises(ValueError, match="Too many redirects"):
                safe_get("https://example.com", allow_redirects=True)

    def test_missing_location_stops_following(self, mock_validate_url):
        """Redirect without Location header stops the chain."""
        redirect_resp = _make_response(302, {})  # No Location header

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=redirect_resp,
        ):
            result = safe_get("https://example.com", allow_redirects=True)
            assert result.status_code == 302

    def test_all_redirect_status_codes_followed(self, mock_validate_url):
        """All status codes in _REDIRECT_STATUS_CODES trigger redirect following."""
        for code in _REDIRECT_STATUS_CODES:
            redirect_resp = _make_response(
                code, {"Location": "https://final.com"}, "https://example.com"
            )
            final_resp = _make_response(200)

            with patch(
                "local_deep_research.security.safe_requests.requests.get",
                side_effect=[redirect_resp, final_resp],
            ):
                result = safe_get("https://example.com", allow_redirects=True)
                assert result.status_code == 200, f"Failed for status {code}"

    def test_each_hop_validated(self):
        """Each redirect hop is validated by the real SSRF validator.

        Replaces a prior version that asserted only on a mocked
        validate_url's call_count and call_args list — that pattern
        passed even if per-hop validation was silently disabled, as long
        as the mock was called the right number of times.

        Here the first two hops resolve to public IPs (via DNS mock) and
        the third hop is a private IP literal. A working per-hop
        validator catches the third hop and raises before requests.get
        is ever called for it.
        """
        resp1 = _make_response(
            302, {"Location": "https://hop2.com"}, "https://example.com"
        )
        resp2 = _make_response(
            302, {"Location": "http://10.0.0.5/internal"}, "https://hop2.com"
        )

        with (
            patch(
                "local_deep_research.security.ssrf_validator.socket.getaddrinfo",
                return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
            ),
            patch(
                "local_deep_research.security.safe_requests.requests.get",
                side_effect=[resp1, resp2],
            ) as mock_get,
        ):
            with pytest.raises(
                ValueError, match="Redirect target failed SSRF validation"
            ):
                safe_get("https://example.com", allow_redirects=True)

            # Only the first two hops were fetched; the third was caught
            # at validation time and never reached the wire.
            assert mock_get.call_count == 2

    def test_default_allow_redirects_follows(self, mock_validate_url):
        """Without explicit allow_redirects, safe_get follows redirects (default True)."""
        redirect_resp = _make_response(
            302, {"Location": "https://other.com"}, "https://example.com"
        )
        final_resp = _make_response(200)

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            side_effect=[redirect_resp, final_resp],
        ) as mock_get:
            # No allow_redirects kwarg — should still follow
            result = safe_get("https://example.com")
            assert result.status_code == 200
            assert mock_get.call_count == 2

    def test_whitespace_stripped_from_location(self, mock_validate_url):
        """Whitespace in Location header is stripped before following."""
        redirect_resp = _make_response(
            302,
            {"Location": "  https://other.com  "},
            "https://example.com",
        )
        final_resp = _make_response(200)

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            side_effect=[redirect_resp, final_resp],
        ) as mock_get:
            safe_get("https://example.com", allow_redirects=True)
            second_url = mock_get.call_args_list[1][0][0]
            assert second_url == "https://other.com"


class TestSafePostRedirectFollowing:
    """Test redirect following in safe_post."""

    def test_follows_valid_redirect(self, mock_validate_url):
        """safe_post follows redirects when allow_redirects=True."""
        redirect_resp = _make_response(
            307, {"Location": "https://other.com"}, "https://example.com"
        )
        final_resp = _make_response(200)

        with patch(
            "local_deep_research.security.safe_requests.requests.post",
            side_effect=[redirect_resp, final_resp],
        ):
            result = safe_post("https://example.com", allow_redirects=True)
            assert result.status_code == 200

    def test_blocks_redirect_to_internal_ip(self, mock_validate_url):
        """safe_post blocks redirect to internal IP."""
        redirect_resp = _make_response(
            307, {"Location": "http://10.0.0.1/internal"}, "https://example.com"
        )

        mock_validate_url.side_effect = [True, False]

        with patch(
            "local_deep_research.security.safe_requests.requests.post",
            return_value=redirect_resp,
        ):
            with pytest.raises(ValueError, match="Redirect target failed SSRF"):
                safe_post("https://example.com", allow_redirects=True)

    def test_no_redirects_when_disabled(self, mock_validate_url):
        """With allow_redirects=False, POST redirect responses returned as-is."""
        redirect_resp = _make_response(307, {"Location": "https://other.com"})

        with patch(
            "local_deep_research.security.safe_requests.requests.post",
            return_value=redirect_resp,
        ):
            result = safe_post("https://example.com", allow_redirects=False)
            assert result.status_code == 307

    def test_too_many_redirects_raises(self, mock_validate_url):
        """More than _MAX_REDIRECTS raises ValueError (307 loop)."""
        redirect_resp = _make_response(
            307, {"Location": "https://example.com/loop"}, "https://example.com"
        )

        with patch(
            "local_deep_research.security.safe_requests.requests.post",
            return_value=redirect_resp,
        ):
            with pytest.raises(ValueError, match="Too many redirects"):
                safe_post("https://example.com", allow_redirects=True)

    def test_missing_location_stops_following(self, mock_validate_url):
        """Redirect without Location header stops the chain."""
        redirect_resp = _make_response(307, {})  # No Location header

        with patch(
            "local_deep_research.security.safe_requests.requests.post",
            return_value=redirect_resp,
        ):
            result = safe_post("https://example.com", allow_redirects=True)
            assert result.status_code == 307


class TestRedirectConstants:
    """Verify redirect-related constants."""

    def test_redirect_status_codes(self):
        assert 301 in _REDIRECT_STATUS_CODES
        assert 302 in _REDIRECT_STATUS_CODES
        assert 303 in _REDIRECT_STATUS_CODES
        assert 307 in _REDIRECT_STATUS_CODES
        assert 308 in _REDIRECT_STATUS_CODES
        assert 200 not in _REDIRECT_STATUS_CODES

    def test_max_redirects_is_reasonable(self):
        assert _MAX_REDIRECTS == 10


class TestResolveRedirectMethod:
    """Unit tests for _resolve_redirect_method helper."""

    def test_303_converts_post_to_get(self):
        assert _resolve_redirect_method("POST", 303) == "GET"

    def test_302_converts_post_to_get(self):
        assert _resolve_redirect_method("POST", 302) == "GET"

    def test_301_converts_post_to_get(self):
        assert _resolve_redirect_method("POST", 301) == "GET"

    def test_307_preserves_post(self):
        assert _resolve_redirect_method("POST", 307) == "POST"

    def test_308_preserves_post(self):
        assert _resolve_redirect_method("POST", 308) == "POST"

    def test_301_preserves_get(self):
        assert _resolve_redirect_method("GET", 301) == "GET"

    def test_302_preserves_put(self):
        assert _resolve_redirect_method("PUT", 302) == "PUT"

    def test_303_preserves_head(self):
        assert _resolve_redirect_method("HEAD", 303) == "HEAD"


class TestSafePostMethodConversion:
    """Test HTTP method conversion in safe_post redirect loop."""

    def test_post_302_converts_to_get(self, mock_validate_url):
        """POST with 302 redirect should convert to GET."""
        redirect_resp = _make_response(
            302, {"Location": "https://other.com"}, "https://example.com"
        )
        final_resp = _make_response(200)

        with (
            patch(
                "local_deep_research.security.safe_requests.requests.post",
                return_value=redirect_resp,
            ) as mock_post,
            patch(
                "local_deep_research.security.safe_requests.requests.get",
                return_value=final_resp,
            ) as mock_get,
        ):
            result = safe_post("https://example.com", allow_redirects=True)
            assert result.status_code == 200
            assert mock_post.call_count == 1
            assert mock_get.call_count == 1

    def test_post_303_converts_to_get(self, mock_validate_url):
        """POST with 303 redirect should convert to GET."""
        redirect_resp = _make_response(
            303, {"Location": "https://other.com"}, "https://example.com"
        )
        final_resp = _make_response(200)

        with (
            patch(
                "local_deep_research.security.safe_requests.requests.post",
                return_value=redirect_resp,
            ) as mock_post,
            patch(
                "local_deep_research.security.safe_requests.requests.get",
                return_value=final_resp,
            ) as mock_get,
        ):
            result = safe_post("https://example.com", allow_redirects=True)
            assert result.status_code == 200
            assert mock_post.call_count == 1
            assert mock_get.call_count == 1

    def test_post_307_preserves_method_and_body(self, mock_validate_url):
        """POST with 307 redirect should preserve method and body."""
        test_data = b"form-data"
        redirect_resp = _make_response(
            307, {"Location": "https://other.com"}, "https://example.com"
        )
        final_resp = _make_response(200)

        with patch(
            "local_deep_research.security.safe_requests.requests.post",
            side_effect=[redirect_resp, final_resp],
        ) as mock_post:
            result = safe_post(
                "https://example.com", data=test_data, allow_redirects=True
            )
            assert result.status_code == 200
            assert mock_post.call_count == 2
            # Verify body was forwarded on the redirect hop
            assert mock_post.call_args_list[1].kwargs.get("data") == test_data

    def test_post_308_preserves_method_and_body(self, mock_validate_url):
        """POST with 308 redirect should preserve method and body."""
        test_json = {"key": "value"}
        redirect_resp = _make_response(
            308, {"Location": "https://other.com"}, "https://example.com"
        )
        final_resp = _make_response(200)

        with patch(
            "local_deep_research.security.safe_requests.requests.post",
            side_effect=[redirect_resp, final_resp],
        ) as mock_post:
            result = safe_post(
                "https://example.com", json=test_json, allow_redirects=True
            )
            assert result.status_code == 200
            assert mock_post.call_count == 2
            assert mock_post.call_args_list[1].kwargs.get("json") == test_json

    def test_post_multi_hop_302_then_301(self, mock_validate_url):
        """POST→302→GET→301→GET: both hops use GET."""
        resp_302 = _make_response(
            302, {"Location": "https://hop2.com"}, "https://example.com"
        )
        resp_301 = _make_response(
            301, {"Location": "https://hop3.com"}, "https://hop2.com"
        )
        final_resp = _make_response(200)

        with (
            patch(
                "local_deep_research.security.safe_requests.requests.post",
                return_value=resp_302,
            ) as mock_post,
            patch(
                "local_deep_research.security.safe_requests.requests.get",
                side_effect=[resp_301, final_resp],
            ) as mock_get,
        ):
            result = safe_post("https://example.com", allow_redirects=True)
            assert result.status_code == 200
            assert mock_post.call_count == 1
            assert mock_get.call_count == 2


class TestSafeSessionRedirectFollowing:
    """Test redirect validation via SafeSession.send() override.

    SafeSession validates redirect targets in send(), which the requests
    library calls for each redirect hop via resolve_redirects().
    """

    def test_send_validates_redirect_target(self):
        """SafeSession.send() validates each URL against SSRF rules."""
        session = SafeSession()

        # Build a PreparedRequest pointing to an internal IP
        prep = requests.PreparedRequest()
        prep.prepare_url("http://169.254.169.254/metadata", {})
        prep.prepare_method("GET")

        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=False,
        ):
            with pytest.raises(ValueError, match="Redirect target failed"):
                session.send(prep)

    def test_send_allows_valid_url(self):
        """SafeSession.send() allows valid external URLs."""
        session = SafeSession()

        prep = requests.PreparedRequest()
        prep.prepare_url("https://example.com", {})
        prep.prepare_method("GET")

        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch.object(
                requests.Session, "send", return_value=_make_response(200)
            ):
                result = session.send(prep)
                assert result.status_code == 200

    def test_send_blocks_localhost_redirect(self):
        """SafeSession.send() blocks redirect to localhost."""
        session = SafeSession()

        prep = requests.PreparedRequest()
        prep.prepare_url("http://127.0.0.1/admin", {})
        prep.prepare_method("GET")

        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=False,
        ):
            with pytest.raises(ValueError, match="SSRF"):
                session.send(prep)

    def test_send_respects_allow_localhost(self):
        """SafeSession with allow_localhost=True actually lets a loopback
        URL through to the underlying Session.send.

        The previous version of this test mocked validate_url to always
        return True, so the test could not detect a regression in the
        allow_localhost code path inside ssrf_validator. Here we exercise
        the real validator: 127.0.0.1 is an IP literal that is rejected
        by default, allowed only when allow_localhost is honored.
        """
        session = SafeSession(allow_localhost=True)

        prep = requests.PreparedRequest()
        prep.prepare_url("http://127.0.0.1:8080/api", {})
        prep.prepare_method("GET")

        with patch.object(
            requests.Session, "send", return_value=_make_response(200)
        ) as mock_send:
            session.send(prep)
            mock_send.assert_called_once()

    def test_send_blocks_loopback_without_allow_localhost(self):
        """Loopback must still be rejected when allow_localhost is False —
        proves the previous test isn't passing just because Session.send
        is mocked.
        """
        session = SafeSession()  # default: allow_localhost=False

        prep = requests.PreparedRequest()
        prep.prepare_url("http://127.0.0.1:8080/api", {})
        prep.prepare_method("GET")

        with patch.object(
            requests.Session, "send", return_value=_make_response(200)
        ) as mock_send:
            with pytest.raises(ValueError, match="SSRF"):
                session.send(prep)
            mock_send.assert_not_called()

    def test_send_respects_allow_private_ips(self):
        """SafeSession with allow_private_ips=True lets an RFC1918 URL
        through to Session.send. Uses the real validator so a regression
        in is_ip_blocked's allow_private_ips handling would surface.
        """
        session = SafeSession(allow_private_ips=True)

        prep = requests.PreparedRequest()
        prep.prepare_url("http://192.168.1.1/api", {})
        prep.prepare_method("GET")

        with patch.object(
            requests.Session, "send", return_value=_make_response(200)
        ) as mock_send:
            session.send(prep)
            mock_send.assert_called_once()

    def test_request_validates_initial_url(self):
        """SafeSession.request() validates the initial URL."""
        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=False,
        ):
            session = SafeSession()
            with pytest.raises(ValueError, match="SSRF"):
                session.request("GET", "http://169.254.169.254/metadata")

    def test_send_handles_none_url(self):
        """SafeSession.send() skips SSRF validation when URL is None.

        A PreparedRequest with url=None is unreachable in normal ``requests``
        flow — both ``request()`` and ``resolve_redirects()`` always populate
        the URL before calling ``send()``.  The ``if request.url`` guard is a
        defensive measure; this test documents that it does not raise.
        """
        session = SafeSession()

        prep = requests.PreparedRequest()
        # URL is None by default — unreachable in normal flow

        with patch.object(
            requests.Session, "send", return_value=_make_response(200)
        ):
            # Should not raise — None URL skips validation (defensive guard)
            result = session.send(prep)
            assert result.status_code == 200


class TestResponseCloseOnRedirect:
    """Verify response.close() is called on intermediate and error paths."""

    def test_intermediate_responses_closed_in_chain(self, mock_validate_url):
        """Each intermediate response is closed during a multi-hop redirect."""
        resp1 = _make_response(
            302, {"Location": "https://hop2.com"}, "https://example.com"
        )
        resp2 = _make_response(
            302, {"Location": "https://hop3.com"}, "https://hop2.com"
        )
        final = _make_response(200)

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            side_effect=[resp1, resp2, final],
        ):
            safe_get("https://example.com", allow_redirects=True)

        resp1.close.assert_called_once()
        resp2.close.assert_called_once()
        final.close.assert_not_called()

    def test_response_closed_on_ssrf_failure(self, mock_validate_url):
        """Response is closed when a redirect target fails SSRF validation."""
        redirect_resp = _make_response(
            302,
            {"Location": "http://169.254.169.254/metadata"},
            "https://example.com",
        )

        mock_validate_url.side_effect = [True, False]

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=redirect_resp,
        ):
            with pytest.raises(ValueError, match="Redirect target failed SSRF"):
                safe_get("https://example.com", allow_redirects=True)

        redirect_resp.close.assert_called_once()

    def test_response_closed_on_too_many_redirects(self, mock_validate_url):
        """Response is closed when too-many-redirects limit is exceeded."""
        redirect_resp = _make_response(
            301,
            {"Location": "https://example.com/loop"},
            "https://example.com",
        )

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=redirect_resp,
        ):
            with pytest.raises(ValueError, match="Too many redirects"):
                safe_get("https://example.com", allow_redirects=True)

        # The last response in the chain should be closed
        assert redirect_resp.close.called

    def test_response_closed_on_oversized_content_length(
        self, mock_validate_url
    ):
        """Response is closed when Content-Length exceeds MAX_RESPONSE_SIZE."""
        resp = _make_response(200)
        resp.headers = {"Content-Length": str(MAX_RESPONSE_SIZE + 1)}

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=resp,
        ):
            with pytest.raises(ValueError, match="Response too large"):
                safe_get("https://example.com")

        resp.close.assert_called_once()

    def test_post_response_closed_on_ssrf_failure(self, mock_validate_url):
        """safe_post closes response when redirect target fails SSRF validation."""
        redirect_resp = _make_response(
            307,
            {"Location": "http://169.254.169.254/metadata"},
            "https://example.com",
        )

        mock_validate_url.side_effect = [True, False]

        with patch(
            "local_deep_research.security.safe_requests.requests.post",
            return_value=redirect_resp,
        ):
            with pytest.raises(ValueError, match="Redirect target failed SSRF"):
                safe_post("https://example.com", allow_redirects=True)

        redirect_resp.close.assert_called_once()

    def test_post_response_closed_on_too_many_redirects(
        self, mock_validate_url
    ):
        """safe_post closes response when too-many-redirects limit is exceeded."""
        redirect_resp = _make_response(
            307,
            {"Location": "https://example.com/loop"},
            "https://example.com",
        )

        with patch(
            "local_deep_research.security.safe_requests.requests.post",
            return_value=redirect_resp,
        ):
            with pytest.raises(ValueError, match="Too many redirects"):
                safe_post("https://example.com", allow_redirects=True)

        assert redirect_resp.close.called

    def test_post_response_closed_on_oversized_content_length(
        self, mock_validate_url
    ):
        """safe_post closes response when Content-Length exceeds MAX_RESPONSE_SIZE."""
        resp = _make_response(200)
        resp.headers = {"Content-Length": str(MAX_RESPONSE_SIZE + 1)}

        with patch(
            "local_deep_research.security.safe_requests.requests.post",
            return_value=resp,
        ):
            with pytest.raises(ValueError, match="Response too large"):
                safe_post("https://example.com")

        resp.close.assert_called_once()


class TestPostBodyNotForwardedOnConversion:
    """POST body must not leak when method converts to GET on 302/303."""

    def test_post_302_then_307_keeps_get_no_body(self, mock_validate_url):
        """POST→302→GET→307→GET: body is dropped and method stays GET.

        The 302 converts POST to GET and drops the body. A subsequent 307
        preserves the (now-GET) method. The body must not reappear on any
        GET hop — this is the real invariant that ``data = None; json = None``
        in safe_post protects.
        """
        resp_302 = _make_response(
            302, {"Location": "https://hop2.com"}, "https://example.com"
        )
        resp_307 = _make_response(
            307, {"Location": "https://hop3.com"}, "https://hop2.com"
        )
        final_resp = _make_response(200)

        with (
            patch(
                "local_deep_research.security.safe_requests.requests.post",
                return_value=resp_302,
            ) as mock_post,
            patch(
                "local_deep_research.security.safe_requests.requests.get",
                side_effect=[resp_307, final_resp],
            ) as mock_get,
        ):
            safe_post(
                "https://example.com",
                data=b"secret",
                json={"password": "x"},
                allow_redirects=True,
            )

            # Only one POST (the initial request)
            assert mock_post.call_count == 1
            # Two GETs: the 302→GET hop and the 307→GET hop
            assert mock_get.call_count == 2

            # Neither GET hop should carry body kwargs
            for i, call in enumerate(mock_get.call_args_list):
                _, kwargs = call
                assert "data" not in kwargs, f"GET hop {i} leaked 'data'"
                assert "json" not in kwargs, f"GET hop {i} leaked 'json'"

    def test_post_303_drops_body(self, mock_validate_url):
        """On 303, the converted GET request must not carry data or json."""
        resp_303 = _make_response(
            303, {"Location": "https://other.com"}, "https://example.com"
        )
        final_resp = _make_response(200)

        with (
            patch(
                "local_deep_research.security.safe_requests.requests.post",
                return_value=resp_303,
            ),
            patch(
                "local_deep_research.security.safe_requests.requests.get",
                return_value=final_resp,
            ) as mock_get,
        ):
            safe_post(
                "https://example.com",
                data=b"secret",
                json={"password": "x"},
                allow_redirects=True,
            )

            # requests.get is called without data/json positional or keyword args
            assert mock_get.call_count == 1
            call_args, call_kwargs = mock_get.call_args
            # Positional: only the URL
            assert call_args == ("https://other.com",)
            assert "data" not in call_kwargs
            assert "json" not in call_kwargs


class TestSafePostPerHopValidation:
    """Verify validate_url is called for each hop in safe_post."""

    def test_each_hop_validated(self):
        """Each redirect hop in safe_post is validated by the real SSRF
        validator. See ``TestSafeGetRedirectFollowing.test_each_hop_validated``
        for the design rationale (real validator + private IP in the
        third hop, so per-hop validation is actually exercised).
        """
        resp1 = _make_response(
            307, {"Location": "https://hop2.com"}, "https://example.com"
        )
        resp2 = _make_response(
            307, {"Location": "http://10.0.0.5/internal"}, "https://hop2.com"
        )

        with (
            patch(
                "local_deep_research.security.ssrf_validator.socket.getaddrinfo",
                return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
            ),
            patch(
                "local_deep_research.security.safe_requests.requests.post",
                side_effect=[resp1, resp2],
            ) as mock_post,
        ):
            with pytest.raises(
                ValueError, match="Redirect target failed SSRF validation"
            ):
                safe_post("https://example.com", allow_redirects=True)

            assert mock_post.call_count == 2


class TestRelativeRedirectURL:
    """Test redirect with relative Location header."""

    def test_relative_location_resolved(self, mock_validate_url):
        """Location: /path should be resolved relative to the current URL."""
        redirect_resp = _make_response(
            302, {"Location": "/new-path"}, "https://example.com/old"
        )
        final_resp = _make_response(200)

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            side_effect=[redirect_resp, final_resp],
        ) as mock_get:
            safe_get("https://example.com/old", allow_redirects=True)

            # The second call should use the resolved absolute URL
            second_call_url = mock_get.call_args_list[1][0][0]
            assert second_call_url == "https://example.com/new-path"

    def test_response_url_none_fallback(self, mock_validate_url):
        """When response.url is None, current_url is used as the base."""
        redirect_resp = _make_response(302, {"Location": "/path"}, url=None)
        final_resp = _make_response(200)

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            side_effect=[redirect_resp, final_resp],
        ) as mock_get:
            safe_get("https://example.com/start", allow_redirects=True)

            second_call_url = mock_get.call_args_list[1][0][0]
            assert second_call_url == "https://example.com/path"


class TestSafeSessionContentLength:
    """Verify SafeSession.send() rejects oversized responses."""

    def test_send_rejects_oversized_response(self):
        """SafeSession.send() raises ValueError for oversized Content-Length."""
        session = SafeSession()

        prep = requests.PreparedRequest()
        prep.prepare_url("https://example.com", {})
        prep.prepare_method("GET")

        oversized_resp = _make_response(200)
        oversized_resp.headers = {"Content-Length": str(MAX_RESPONSE_SIZE + 1)}

        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch.object(
                requests.Session, "send", return_value=oversized_resp
            ):
                with pytest.raises(ValueError, match="Response too large"):
                    session.send(prep)

        oversized_resp.close.assert_called_once()

    def test_send_allows_normal_response(self):
        """SafeSession.send() allows responses within size limit."""
        session = SafeSession()

        prep = requests.PreparedRequest()
        prep.prepare_url("https://example.com", {})
        prep.prepare_method("GET")

        normal_resp = _make_response(200)
        normal_resp.headers = {"Content-Length": "1024"}

        with patch.object(
            ssrf_validator,
            "validate_url",
            return_value=True,
        ):
            with patch.object(
                requests.Session, "send", return_value=normal_resp
            ):
                result = session.send(prep)
                assert result.status_code == 200


class TestNonWhitelistedStatusCodes:
    """Verify that non-whitelisted 3xx codes are NOT followed as redirects."""

    def test_304_not_followed(self, mock_validate_url):
        """304 Not Modified with Location header is returned as-is, not followed."""
        resp_304 = _make_response(
            304, {"Location": "https://other.com"}, "https://example.com"
        )

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=resp_304,
        ) as mock_get:
            result = safe_get("https://example.com", allow_redirects=True)
            assert result.status_code == 304
            # Only one request — the redirect was NOT followed
            assert mock_get.call_count == 1

    def test_300_not_followed(self, mock_validate_url):
        """300 Multiple Choices with Location header is returned as-is, not followed."""
        resp_300 = _make_response(
            300, {"Location": "https://other.com"}, "https://example.com"
        )

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=resp_300,
        ) as mock_get:
            result = safe_get("https://example.com", allow_redirects=True)
            assert result.status_code == 300
            assert mock_get.call_count == 1


class TestRedirectParserDifferentialBypass:
    """
    Redirect-path coverage of the parser-differential SSRF fix
    (GHSA-g23j-2vwm-5c25). The redirect handler in ``safe_get`` calls
    ``ssrf_validator.validate_url`` on each ``Location`` header, so the
    fix propagates to redirects automatically. These tests lock that in.
    """

    def test_redirect_to_backslash_bypass_blocked(self):
        """Initial URL is fine; Location: header has the parser-differential
        payload — must be blocked by validate_url on hop 2."""
        # Don't mock validate_url here — exercise the real validator.
        redirect_resp = _make_response(
            302,
            {"Location": "http://127.0.0.1:6666\\@1.1.1.1"},
            "https://example.com",
        )
        final_resp = _make_response(200)

        # Mock DNS for the initial URL validation only.
        with (
            patch(
                "socket.getaddrinfo",
                return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
            ),
            patch(
                "local_deep_research.security.safe_requests.requests.get",
                side_effect=[redirect_resp, final_resp],
            ),
        ):
            with pytest.raises(ValueError, match="Redirect target failed SSRF"):
                safe_get("https://example.com", allow_redirects=True)

    def test_redirect_to_canonicalised_percent5c_blocked(self):
        """Location: with the post-prepare ``%5C`` form — Layer-2 verifies
        the urllib3-based hostname extraction blocks the redirect target."""
        redirect_resp = _make_response(
            302,
            {"Location": "http://127.0.0.1:6666/%5C@1.1.1.1"},
            "https://example.com",
        )
        final_resp = _make_response(200)

        with (
            patch(
                "socket.getaddrinfo",
                return_value=[(2, 1, 6, "", ("93.184.216.34", 0))],
            ),
            patch(
                "local_deep_research.security.safe_requests.requests.get",
                side_effect=[redirect_resp, final_resp],
            ),
        ):
            with pytest.raises(ValueError, match="Redirect target failed SSRF"):
                safe_get("https://example.com", allow_redirects=True)
