"""Tests for ``mgf.apiprobe.transport.httpx_transport``.

Uses ``respx`` to mock httpx — no real network calls.
"""

from __future__ import annotations

import httpx
import pytest
import respx

from mgf.apiprobe.auth.flows import BearerAuth
from mgf.apiprobe.config import ApiprobeSettings
from mgf.apiprobe.core.context import Context
from mgf.apiprobe.core.probe import Probe
from mgf.apiprobe.exceptions import TransportError
from mgf.apiprobe.transport.httpx_transport import HttpxTransport


@pytest.mark.asyncio
class TestBasicSend:
    @respx.mock
    async def test_simple_get(self) -> None:
        respx.get("https://api.example.com/v1/users").mock(
            return_value=httpx.Response(
                status_code=200,
                headers={"Content-Type": "application/json"},
                json={"id": "abc"},
            )
        )
        transport = HttpxTransport()
        try:
            probe = Probe.get("https://api.example.com/v1/users").id("p1")
            response = await transport.send(probe, Context.empty())
            assert response.status == 200
            assert response.url == "https://api.example.com/v1/users"
            assert response.header("Content-Type") == "application/json"
            assert response.json() == {"id": "abc"}
            assert response.elapsed_ms >= 0
        finally:
            await transport.aclose()

    @respx.mock
    async def test_post_with_json_body(self) -> None:
        route = respx.post("https://api.example.com/v1/users").mock(
            return_value=httpx.Response(201, json={"id": "new"}),
        )
        transport = HttpxTransport()
        try:
            probe = Probe.post("https://api.example.com/v1/users").id("p1").body({"email": "a@b.c"})
            response = await transport.send(probe, Context.empty())
            assert response.status == 201
            # Confirm the body was sent (JSON encoding may include or
            # omit spaces; decode and compare structurally).
            import json as _json

            sent_request = route.calls.last.request
            assert _json.loads(sent_request.read()) == {"email": "a@b.c"}
        finally:
            await transport.aclose()

    @respx.mock
    async def test_query_params(self) -> None:
        route = respx.get("https://api.example.com/users").mock(
            return_value=httpx.Response(200, json=[]),
        )
        transport = HttpxTransport()
        try:
            probe = (
                Probe.get("https://api.example.com/users")
                .id("p1")
                .query("limit", 50)
                .query("page", "2")
            )
            await transport.send(probe, Context.empty())
            sent = route.calls.last.request.url
            assert "limit=50" in str(sent)
            assert "page=2" in str(sent)
        finally:
            await transport.aclose()

    @respx.mock
    async def test_custom_headers(self) -> None:
        route = respx.get("https://api.example.com/x").mock(
            return_value=httpx.Response(200),
        )
        transport = HttpxTransport()
        try:
            probe = (
                Probe.get("https://api.example.com/x")
                .id("p1")
                .header("X-Tenant", "acme")
                .header("Accept", "application/json")
            )
            await transport.send(probe, Context.empty())
            sent = route.calls.last.request
            assert sent.headers["x-tenant"] == "acme"
            assert sent.headers["accept"] == "application/json"
        finally:
            await transport.aclose()


@pytest.mark.asyncio
class TestAuthIntegration:
    @respx.mock
    async def test_bearer_auth_attaches_header(self) -> None:
        route = respx.get("https://api.example.com/private").mock(
            return_value=httpx.Response(200),
        )
        transport = HttpxTransport()
        try:
            probe = (
                Probe.get("https://api.example.com/private")
                .id("p1")
                .with_auth(BearerAuth(token="abc-xyz"))
            )
            await transport.send(probe, Context.empty())
            sent = route.calls.last.request
            assert sent.headers["authorization"] == "Bearer abc-xyz"
        finally:
            await transport.aclose()


@pytest.mark.asyncio
class TestUserAgent:
    @respx.mock
    async def test_default_user_agent(self) -> None:
        route = respx.get("https://x.test/y").mock(return_value=httpx.Response(200))
        transport = HttpxTransport()
        try:
            await transport.send(Probe.get("https://x.test/y").id("p1"), Context.empty())
            sent = route.calls.last.request
            assert sent.headers["user-agent"].startswith("mgf-apiprobe/")
        finally:
            await transport.aclose()

    @respx.mock
    async def test_custom_user_agent_via_settings(self) -> None:
        route = respx.get("https://x.test/y").mock(return_value=httpx.Response(200))
        settings = ApiprobeSettings(default_user_agent="my-suite/2.0")
        transport = HttpxTransport(settings=settings)
        try:
            await transport.send(Probe.get("https://x.test/y").id("p1"), Context.empty())
            assert route.calls.last.request.headers["user-agent"] == "my-suite/2.0"
        finally:
            await transport.aclose()


@pytest.mark.asyncio
class TestErrorHandling:
    @respx.mock
    async def test_timeout_raises_transport_error(self) -> None:
        respx.get("https://x.test/y").mock(side_effect=httpx.ReadTimeout("read timed out"))
        transport = HttpxTransport()
        try:
            with pytest.raises(TransportError, match="timeout"):
                await transport.send(Probe.get("https://x.test/y").id("p1"), Context.empty())
        finally:
            await transport.aclose()

    @respx.mock
    async def test_connect_error_raises_transport_error(self) -> None:
        respx.get("https://x.test/y").mock(side_effect=httpx.ConnectError("dns down"))
        transport = HttpxTransport()
        try:
            with pytest.raises(TransportError, match="connect error"):
                await transport.send(Probe.get("https://x.test/y").id("p1"), Context.empty())
        finally:
            await transport.aclose()

    @respx.mock
    async def test_network_error_raises_transport_error(self) -> None:
        respx.get("https://x.test/y").mock(side_effect=httpx.ReadError("read failed"))
        transport = HttpxTransport()
        try:
            with pytest.raises(TransportError, match="network error"):
                await transport.send(Probe.get("https://x.test/y").id("p1"), Context.empty())
        finally:
            await transport.aclose()

    @respx.mock
    async def test_other_httpx_error_hits_catch_all(self) -> None:
        # TS-08: the `except httpx.HTTPError` catch-all — an HTTPError that is NOT
        # Timeout/Connect/Network (ProtocolError) must still translate to TransportError.
        respx.get("https://x.test/y").mock(side_effect=httpx.ProtocolError("protocol boom"))
        transport = HttpxTransport()
        try:
            with pytest.raises(TransportError, match="httpx error"):
                await transport.send(Probe.get("https://x.test/y").id("p1"), Context.empty())
        finally:
            await transport.aclose()


@pytest.mark.asyncio
class TestServerSideErrorsAreNotTransportErrors:
    """5xx / 4xx responses ARE responses, not transport errors."""

    @respx.mock
    async def test_500_returns_response(self) -> None:
        respx.get("https://x.test/y").mock(
            return_value=httpx.Response(500, text="Internal Server Error"),
        )
        transport = HttpxTransport()
        try:
            response = await transport.send(
                Probe.get("https://x.test/y").id("p1"),
                Context.empty(),
            )
            assert response.status == 500
            assert response.body == b"Internal Server Error"
        finally:
            await transport.aclose()

    @respx.mock
    async def test_404_returns_response(self) -> None:
        respx.get("https://x.test/y").mock(return_value=httpx.Response(404))
        transport = HttpxTransport()
        try:
            response = await transport.send(
                Probe.get("https://x.test/y").id("p1"),
                Context.empty(),
            )
            assert response.status == 404
        finally:
            await transport.aclose()


@pytest.mark.asyncio
class TestPerProbeTimeout:
    @respx.mock
    async def test_probe_timeout_overrides_settings(self) -> None:
        # We can't easily inspect httpx's per-call timeout via respx,
        # so this is a smoke test that probe.with_timeout doesn't break
        # the send path.
        respx.get("https://x.test/y").mock(return_value=httpx.Response(200))
        transport = HttpxTransport(settings=ApiprobeSettings(timeout_seconds=30))
        try:
            probe = Probe.get("https://x.test/y").id("p1").with_timeout(2.0)
            response = await transport.send(probe, Context.empty())
            assert response.status == 200
        finally:
            await transport.aclose()


@pytest.mark.asyncio
class TestCrlfInjectionRejection:
    """SH-04 / RA-04: outbound header + cookie values are CR/LF-validated
    at the transport boundary. A CR/LF in a credential or consumer-
    supplied value would otherwise let an attacker inject a second
    header (request-smuggling class). The transport refuses to send
    such requests — raises TransportError — so the suite's transport-
    error path converts it to a synthetic Finding."""

    async def test_header_value_with_crlf_raises_transport_error(self) -> None:
        transport = HttpxTransport()
        try:
            probe = (
                Probe.get("https://api.example.com/v1/users")
                .id("p1")
                .header("X-Custom", "value\r\nInjected: yes")
            )
            with pytest.raises(TransportError) as exc_info:
                await transport.send(probe, Context.empty())
            # Error message mentions the offending header name.
            assert "X-Custom" in exc_info.value.cause
            assert "CR/LF" in exc_info.value.cause
        finally:
            await transport.aclose()

    async def test_header_value_with_lf_only_raises(self) -> None:
        """LF alone is also rejected — some smuggling shapes use \\n
        without the leading \\r."""
        transport = HttpxTransport()
        try:
            probe = (
                Probe.get("https://api.example.com/v1/users")
                .id("p1")
                .header("X-Custom", "value\nInjected: yes")
            )
            with pytest.raises(TransportError):
                await transport.send(probe, Context.empty())
        finally:
            await transport.aclose()

    async def test_cookie_value_with_crlf_raises_transport_error(self) -> None:
        from mgf.apiprobe.auth.flows import CookieAuth

        # Use a custom AuthFlow that returns a malformed cookie; we
        # can't go through Probe.header because cookies aren't an
        # exposed builder. Instead use CookieAuth direct.
        transport = HttpxTransport()
        try:
            probe = (
                Probe.get("https://api.example.com/v1/users")
                .id("p1")
                .with_auth(CookieAuth(cookie_name="session", value="abc\r\nInjected: yes"))
            )
            with pytest.raises(TransportError) as exc_info:
                await transport.send(probe, Context.empty())
            assert "session" in exc_info.value.cause
            assert "CR/LF" in exc_info.value.cause
        finally:
            await transport.aclose()

    @respx.mock
    async def test_clean_header_value_passes(self) -> None:
        """Sanity: non-CRLF values go through unchanged."""
        respx.get("https://api.example.com/v1/users").mock(return_value=httpx.Response(200))
        transport = HttpxTransport()
        try:
            probe = (
                Probe.get("https://api.example.com/v1/users")
                .id("p1")
                .header("X-Custom", "perfectly normal value")
            )
            response = await transport.send(probe, Context.empty())
            assert response.status == 200
        finally:
            await transport.aclose()


@pytest.mark.asyncio
class TestContextManager:
    @respx.mock
    async def test_async_context_manager_closes_client(self) -> None:
        respx.get("https://x.test/y").mock(return_value=httpx.Response(200))
        async with HttpxTransport() as transport:
            response = await transport.send(
                Probe.get("https://x.test/y").id("p1"),
                Context.empty(),
            )
            assert response.status == 200
        # If aclose was called, the client is closed; calling again is a no-op.
        await transport.aclose()
