"""Tests for SSRF response size check restructuring (PR #1942).

Verifies try/except/else pattern ensures ValueError from int() parsing
does not mask ValueError from legitimate size-exceeded checks.
"""

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

from io import BytesIO

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


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


class TestSafeGetResponseSize:
    """Verify safe_get Content-Length handling with try/except/else."""

    def test_large_content_length_raises(self, mock_validate_url):
        """A Content-Length exceeding MAX_RESPONSE_SIZE should raise ValueError."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": str(MAX_RESPONSE_SIZE + 1)}

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

    def test_valid_content_length_returns_response(self, mock_validate_url):
        """A Content-Length within limit should return the response."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": "1024"}

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=mock_resp,
        ):
            result = safe_get("https://example.com")
            assert result is mock_resp

    def test_invalid_content_length_ignored(self, mock_validate_url):
        """Non-numeric Content-Length should be silently ignored."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": "not-a-number"}

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=mock_resp,
        ):
            result = safe_get("https://example.com")
            assert result is mock_resp

    def test_missing_content_length_returns_response(self, mock_validate_url):
        """Missing Content-Length header should not affect response."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {}

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=mock_resp,
        ):
            result = safe_get("https://example.com")
            assert result is mock_resp

    def test_exact_max_size_allowed(self, mock_validate_url):
        """Content-Length exactly at MAX_RESPONSE_SIZE should be allowed."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": str(MAX_RESPONSE_SIZE)}

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=mock_resp,
        ):
            result = safe_get("https://example.com")
            assert result is mock_resp

    def test_comma_separated_identical_content_length(self, mock_validate_url):
        """Identical comma-separated Content-Length values should be accepted."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": "1024, 1024"}

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=mock_resp,
        ):
            result = safe_get("https://example.com")
            assert result is mock_resp

    def test_comma_separated_different_content_length_rejected(
        self, mock_validate_url
    ):
        """Differing comma-separated Content-Length values should be rejected."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": "100, 200"}

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=mock_resp,
        ):
            with pytest.raises(ValueError, match="Conflicting"):
                safe_get("https://example.com")
            mock_resp.close.assert_called_once()

    def test_comma_separated_oversized_content_length(self, mock_validate_url):
        """Identical but oversized comma-separated values should be rejected."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {
            "Content-Length": f"{MAX_RESPONSE_SIZE + 1}, {MAX_RESPONSE_SIZE + 1}"
        }

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

    def test_zero_content_length_accepted(self, mock_validate_url):
        """Zero Content-Length should be accepted."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": "0"}

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=mock_resp,
        ):
            result = safe_get("https://example.com")
            assert result is mock_resp

    def test_negative_content_length_ignored(self, mock_validate_url):
        """Negative Content-Length should be treated as absent."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": "-1"}

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=mock_resp,
        ):
            result = safe_get("https://example.com")
            assert result is mock_resp

    def test_trailing_comma_within_limit_accepted(self, mock_validate_url):
        """Trailing comma with valid size should be accepted."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": "100,"}

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=mock_resp,
        ):
            result = safe_get("https://example.com")
            assert result is mock_resp

    def test_trailing_comma_oversized_raises(self, mock_validate_url):
        """Trailing comma must not bypass size check for oversized values."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": f"{MAX_RESPONSE_SIZE + 1},"}

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

    def test_double_comma_conflicting_values_rejected(self, mock_validate_url):
        """Double comma between values must not mask a conflict."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": "100,,200"}

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=mock_resp,
        ):
            with pytest.raises(ValueError, match="Conflicting"):
                safe_get("https://example.com")
            mock_resp.close.assert_called_once()

    def test_only_commas_treated_as_absent(self, mock_validate_url):
        """Header containing only commas should be treated as absent."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": ","}

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=mock_resp,
        ):
            result = safe_get("https://example.com")
            assert result is mock_resp

    def test_trailing_comma_identical_values_accepted(self, mock_validate_url):
        """Identical values with trailing comma should be accepted."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": "100, 100,"}

        with patch(
            "local_deep_research.security.safe_requests.requests.get",
            return_value=mock_resp,
        ):
            result = safe_get("https://example.com")
            assert result is mock_resp


class TestSafePostResponseSize:
    """Verify safe_post Content-Length handling with try/except/else."""

    def test_large_content_length_raises(self, mock_validate_url):
        """A Content-Length exceeding MAX_RESPONSE_SIZE should raise ValueError."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": str(MAX_RESPONSE_SIZE + 1)}

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

    def test_valid_content_length_returns_response(self, mock_validate_url):
        """A Content-Length within limit should return the response."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": "512"}

        with patch(
            "local_deep_research.security.safe_requests.requests.post",
            return_value=mock_resp,
        ):
            result = safe_post("https://example.com")
            assert result is mock_resp

    def test_invalid_content_length_ignored(self, mock_validate_url):
        """Non-numeric Content-Length should be silently ignored."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": "abc"}

        with patch(
            "local_deep_research.security.safe_requests.requests.post",
            return_value=mock_resp,
        ):
            result = safe_post("https://example.com")
            assert result is mock_resp

    def test_trailing_comma_oversized_raises(self, mock_validate_url):
        """Trailing comma must not bypass size check for oversized values."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": f"{MAX_RESPONSE_SIZE + 1},"}

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


class TestSafeSessionResponseSize:
    """Verify SafeSession.send() size checks on final and redirect responses."""

    def test_session_large_content_length_raises(self, mock_validate_url):
        """Oversized Content-Length on final response raises ValueError."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": str(MAX_RESPONSE_SIZE + 1)}

        with patch(
            "local_deep_research.security.safe_requests.requests.Session.send",
            return_value=mock_resp,
        ):
            with SafeSession() as session:
                with pytest.raises(ValueError, match="Response too large"):
                    session.get("https://example.com")
                mock_resp.close.assert_called_once()

    def test_session_valid_content_length_returns(self, mock_validate_url):
        """Normal Content-Length returns the response."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {"Content-Length": "1024"}

        with patch(
            "local_deep_research.security.safe_requests.requests.Session.send",
            return_value=mock_resp,
        ):
            with SafeSession() as session:
                result = session.get("https://example.com")
                assert result is mock_resp

    def test_session_missing_content_length_returns(self, mock_validate_url):
        """No Content-Length header returns the response."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {}

        with patch(
            "local_deep_research.security.safe_requests.requests.Session.send",
            return_value=mock_resp,
        ):
            with SafeSession() as session:
                result = session.get("https://example.com")
                assert result is mock_resp

    def test_session_redirect_with_oversized_final(self, mock_validate_url):
        """302→200: size check on redirect hop is harmless, oversized final raises."""
        redirect_resp = MagicMock()
        redirect_resp.status_code = 302
        redirect_resp.headers = {"Location": "https://example.com/final"}

        final_resp = MagicMock()
        final_resp.status_code = 200
        final_resp.headers = {"Content-Length": str(MAX_RESPONSE_SIZE + 1)}

        session = SafeSession()
        prep = requests.Request("GET", "https://example.com").prepare()

        with patch(
            "local_deep_research.security.safe_requests.requests.Session.send",
            side_effect=[redirect_resp, final_resp],
        ):
            # Intermediate redirect: no CL, size check passes harmlessly
            result = session.send(prep)
            assert result is redirect_resp

            # Final response: oversized CL triggers ValueError
            with pytest.raises(ValueError, match="Response too large"):
                session.send(prep)
            final_resp.close.assert_called_once()

    def test_session_redirect_with_valid_final(self, mock_validate_url):
        """302→200: redirect hop passes, valid final returns normally."""
        redirect_resp = MagicMock()
        redirect_resp.status_code = 302
        redirect_resp.headers = {"Location": "https://example.com/final"}

        final_resp = MagicMock()
        final_resp.status_code = 200
        final_resp.headers = {"Content-Length": "512"}

        session = SafeSession()
        prep = requests.Request("GET", "https://example.com").prepare()

        with patch(
            "local_deep_research.security.safe_requests.requests.Session.send",
            side_effect=[redirect_resp, final_resp],
        ):
            result = session.send(prep)
            assert result is redirect_resp

            result = session.send(prep)
            assert result is final_resp

    def test_session_comma_separated_content_length(self, mock_validate_url):
        """Multi-value Content-Length handled correctly via session path."""
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.headers = {
            "Content-Length": f"{MAX_RESPONSE_SIZE + 1}, {MAX_RESPONSE_SIZE + 1}"
        }

        with patch(
            "local_deep_research.security.safe_requests.requests.Session.send",
            return_value=mock_resp,
        ):
            with SafeSession() as session:
                with pytest.raises(ValueError, match="Response too large"):
                    session.get("https://example.com")
                mock_resp.close.assert_called_once()


class TestBodySizeEnforcement:
    """Verify _install_body_guard enforces size limits on responses without CL."""

    def _make_response(self, body: bytes, headers: dict | None = None):
        """Create a mock response with a real readable raw body."""
        resp = MagicMock(spec=requests.Response)
        resp.headers = headers or {}
        raw = MagicMock()
        stream = BytesIO(body)
        raw.read = stream.read
        resp.raw = raw
        return resp

    def test_chunked_response_within_limit(self):
        """Small body without Content-Length succeeds."""
        body = b"x" * 1024
        resp = self._make_response(body)
        _install_body_guard(resp)

        data = resp.raw.read()
        assert data == body

    def test_chunked_response_exceeds_limit(self):
        """Large body without Content-Length raises ValueError on read."""
        body = b"x" * (MAX_RESPONSE_SIZE + 1)
        resp = self._make_response(body)
        _install_body_guard(resp)

        with pytest.raises(ValueError, match="Response body too large"):
            resp.raw.read()
        resp.close.assert_called_once()

    def test_missing_cl_within_limit(self):
        """No Content-Length, small body succeeds."""
        body = b"hello world"
        resp = self._make_response(body)
        _install_body_guard(resp)

        data = resp.raw.read()
        assert data == body

    def test_missing_cl_exceeds_limit(self):
        """No Content-Length, oversized body raises ValueError."""
        body = b"x" * (MAX_RESPONSE_SIZE + 100)
        resp = self._make_response(body)
        _install_body_guard(resp)

        with pytest.raises(ValueError, match="Response body too large"):
            resp.raw.read()
        resp.close.assert_called_once()

    def test_guard_installed_even_when_cl_present(self):
        """Guard always installs — callers decide when to call it."""
        body = b"x" * 100
        resp = self._make_response(body, headers={"Content-Length": "100"})
        original_read = resp.raw.read
        _install_body_guard(resp)

        # read function should be wrapped
        assert resp.raw.read is not original_read
        # but small body still reads fine
        data = resp.raw.read()
        assert data == body

    def test_invalid_cl_gets_body_guard(self):
        """Invalid Content-Length must still get body guard via _check_response_size."""
        body = b"x" * (MAX_RESPONSE_SIZE + 1)
        resp = self._make_response(body, headers={"Content-Length": "abc"})
        _check_response_size(resp)

        with pytest.raises(ValueError, match="Response body too large"):
            resp.raw.read()
        resp.close.assert_called_once()

    def test_negative_cl_gets_body_guard(self):
        """Negative Content-Length must still get body guard via _check_response_size."""
        body = b"x" * (MAX_RESPONSE_SIZE + 1)
        resp = self._make_response(body, headers={"Content-Length": "-1"})
        _check_response_size(resp)

        with pytest.raises(ValueError, match="Response body too large"):
            resp.raw.read()
        resp.close.assert_called_once()

    def test_streamed_access_exceeds_limit(self):
        """Chunked reads that cumulatively exceed limit raise ValueError."""
        chunk_size = MAX_RESPONSE_SIZE // 2
        body = b"x" * (MAX_RESPONSE_SIZE + 1)
        resp = self._make_response(body)
        _install_body_guard(resp)

        # First chunk: within limit
        data1 = resp.raw.read(chunk_size)
        assert len(data1) == chunk_size

        # Second chunk: pushes over the limit
        with pytest.raises(ValueError, match="Response body too large"):
            resp.raw.read(chunk_size + 1)
        resp.close.assert_called_once()
