"""Tests for sluice._transport — HTTP transport with retries."""

from __future__ import annotations

import json
import threading
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock

import urllib3

from sluice._transport import (
    _DEFAULT_BASE_URL,
    _INGEST_PATH,
    SluiceTransport,
    _next_backoff,
)

if TYPE_CHECKING:
    import pytest

# Shared test connection ID (valid UUID format).
_TEST_CONN_ID = "550e8400-e29b-41d4-a716-446655440000"


class TestDefaultBaseUrl:
    """Ensure the SDK default URL matches the actual Sluice deployment."""

    def test_default_base_url_matches_deployment(self) -> None:
        """Default base URL must point to the real dashboard host (sluice.sh)."""
        assert _DEFAULT_BASE_URL == "https://sluice.sh"

    def test_default_ingest_url_is_reachable_path(self) -> None:
        """Transport constructed with defaults should target the correct ingest path."""
        transport = SluiceTransport(api_key="sk_test_123", connection_id="test-conn")
        assert transport._ingest_url == "https://sluice.sh/api/ingest"

    def test_bootstep_default_matches_transport_default(self) -> None:
        """Bootstep and transport must agree on the default base URL."""
        import sluice._bootstep as bootstep_mod

        assert bootstep_mod._DEFAULT_BASE_URL == _DEFAULT_BASE_URL  # type: ignore[attr-defined]


class TestSluiceTransport:
    """Tests for the SluiceTransport class."""

    def _make_transport(self, **kwargs: Any) -> SluiceTransport:
        """Create a transport with a mocked connection pool."""
        if "connection_id" not in kwargs:
            kwargs["connection_id"] = _TEST_CONN_ID
        transport = SluiceTransport(
            api_key="sk_test_123",
            base_url="https://sluice.sh",
            **kwargs,
        )
        transport._pool = MagicMock()
        return transport

    def test_send_batch_success(self) -> None:
        """Successful 200 response returns True."""
        transport = self._make_transport()
        resp = MagicMock()
        resp.status = 200
        transport._pool.urlopen.return_value = resp  # type: ignore[attr-defined]

        result = transport.send_batch([{"type": "job.state_change"}])
        assert result is True

        # Verify the request was made correctly.
        transport._pool.urlopen.assert_called_once()  # type: ignore[attr-defined]
        call_args = transport._pool.urlopen.call_args  # type: ignore[attr-defined]
        assert call_args[0][0] == "POST"
        assert call_args[0][1] == _INGEST_PATH

        body = json.loads(call_args[1]["body"])
        assert body == {
            "events": [{"type": "job.state_change", "connectionId": _TEST_CONN_ID}]
        }

    def test_connection_id_injected_into_events(self) -> None:
        """send_batch() should inject connectionId into every event."""
        transport = self._make_transport()
        resp = MagicMock()
        resp.status = 200
        transport._pool.urlopen.return_value = resp  # type: ignore[attr-defined]

        events = [
            {"type": "job.state_change", "framework": "celery"},
            {"type": "worker.state_change", "framework": "celery"},
        ]
        transport.send_batch(events)

        call_args = transport._pool.urlopen.call_args  # type: ignore[attr-defined]
        body = json.loads(call_args[1]["body"])
        for event in body["events"]:
            assert event["connectionId"] == _TEST_CONN_ID

    def test_send_batch_does_not_mutate_input(self) -> None:
        """send_batch() must not mutate the caller's event dicts."""
        transport = self._make_transport()
        resp = MagicMock()
        resp.status = 200
        transport._pool.urlopen.return_value = resp  # type: ignore[attr-defined]

        event = {"type": "job.state_change", "framework": "celery"}
        transport.send_batch([event])

        # Original dict should not have connectionId.
        assert "connectionId" not in event

    def test_no_connection_id_when_empty(self) -> None:
        """send_batch() should not inject connectionId when it's empty."""
        transport = self._make_transport(connection_id="")
        resp = MagicMock()
        resp.status = 200
        transport._pool.urlopen.return_value = resp  # type: ignore[attr-defined]

        events = [{"type": "job.state_change"}]
        transport.send_batch(events)

        call_args = transport._pool.urlopen.call_args  # type: ignore[attr-defined]
        body = json.loads(call_args[1]["body"])
        assert "connectionId" not in body["events"][0]

    def test_bearer_auth_header(self) -> None:
        """API key should be sent as Bearer token."""
        transport = self._make_transport()
        resp = MagicMock()
        resp.status = 200
        transport._pool.urlopen.return_value = resp  # type: ignore[attr-defined]

        transport.send_batch([{"type": "test"}])

        call_args = transport._pool.urlopen.call_args  # type: ignore[attr-defined]
        headers = call_args[1]["headers"]
        assert headers["Authorization"] == "Bearer sk_test_123"
        assert headers["Content-Type"] == "application/json"

    def test_send_empty_batch_returns_true(self) -> None:
        """Empty batch should return True without making a request."""
        transport = self._make_transport()
        result = transport.send_batch([])
        assert result is True
        transport._pool.urlopen.assert_not_called()  # type: ignore[attr-defined]

    def test_retry_on_429(self) -> None:
        """429 should trigger retries with backoff."""
        transport = self._make_transport(max_retries=3)

        resp_429 = MagicMock()
        resp_429.status = 429
        resp_429.headers = {}

        resp_200 = MagicMock()
        resp_200.status = 200

        transport._pool.urlopen.side_effect = [resp_429, resp_200]  # type: ignore[attr-defined]

        result = transport.send_batch([{"type": "test"}])

        assert result is True
        assert transport._pool.urlopen.call_count == 2  # type: ignore[attr-defined]

    def test_retry_on_500(self) -> None:
        """500 should trigger retries."""
        transport = self._make_transport(max_retries=3)

        resp_500 = MagicMock()
        resp_500.status = 500
        resp_500.headers = {}

        resp_200 = MagicMock()
        resp_200.status = 200

        transport._pool.urlopen.side_effect = [resp_500, resp_200]  # type: ignore[attr-defined]

        result = transport.send_batch([{"type": "test"}])

        assert result is True

    def test_retry_on_502(self) -> None:
        """502 should trigger retries."""
        transport = self._make_transport(max_retries=2)

        resp_502 = MagicMock()
        resp_502.status = 502
        resp_502.headers = {}

        resp_200 = MagicMock()
        resp_200.status = 200

        transport._pool.urlopen.side_effect = [resp_502, resp_200]  # type: ignore[attr-defined]

        result = transport.send_batch([{"type": "test"}])

        assert result is True

    def test_all_retries_exhausted(self, caplog: pytest.LogCaptureFixture) -> None:
        """Should return False after exhausting retries."""
        transport = self._make_transport(max_retries=2)

        resp_500 = MagicMock()
        resp_500.status = 500
        resp_500.headers = {}

        transport._pool.urlopen.return_value = resp_500  # type: ignore[attr-defined]

        result = transport.send_batch([{"type": "test"}])

        assert result is False
        assert transport._pool.urlopen.call_count == 2  # type: ignore[attr-defined]

    def test_non_retryable_error(self, caplog: pytest.LogCaptureFixture) -> None:
        """4xx errors (except 429) should not be retried."""
        transport = self._make_transport(max_retries=3)

        resp_400 = MagicMock()
        resp_400.status = 400

        transport._pool.urlopen.return_value = resp_400  # type: ignore[attr-defined]

        result = transport.send_batch([{"type": "test"}])
        assert result is False
        # Only one call — no retries.
        assert transport._pool.urlopen.call_count == 1  # type: ignore[attr-defined]

    def test_network_error_retried(self) -> None:
        """Network exceptions should trigger retries."""
        transport = self._make_transport(max_retries=3)

        resp_200 = MagicMock()
        resp_200.status = 200

        transport._pool.urlopen.side_effect = [  # type: ignore[attr-defined]
            ConnectionError("refused"),
            resp_200,
        ]

        result = transport.send_batch([{"type": "test"}])

        assert result is True

    def test_network_error_all_retries_exhausted(self) -> None:
        """Should return False after all network retries fail."""
        transport = self._make_transport(max_retries=2)

        transport._pool.urlopen.side_effect = ConnectionError("refused")  # type: ignore[attr-defined]

        result = transport.send_batch([{"type": "test"}])

        assert result is False

    def test_retry_after_header_respected(self) -> None:
        """Retry-After header should set the wait time on 429."""
        transport = self._make_transport(max_retries=3)

        resp_429 = MagicMock()
        resp_429.status = 429
        resp_429.headers = {"Retry-After": "3"}

        resp_200 = MagicMock()
        resp_200.status = 200

        transport._pool.urlopen.side_effect = [resp_429, resp_200]  # type: ignore[attr-defined]

        # Mock the cancel event's wait to capture the timeout arg.
        wait_calls: list[float] = []

        def _capturing_wait(timeout: float | None = None) -> bool:
            if timeout is not None:
                wait_calls.append(timeout)
            return False  # Not cancelled.

        transport._cancel.wait = _capturing_wait  # type: ignore[method-assign]
        transport.send_batch([{"type": "test"}])

        assert wait_calls == [3.0]

    def test_close_prevents_further_sends(self) -> None:
        """After close(), send_batch() returns False immediately."""
        transport = self._make_transport()
        transport.close()

        result = transport.send_batch([{"type": "test"}])
        assert result is False
        transport._pool.urlopen.assert_not_called()  # type: ignore[attr-defined]

    def test_201_is_success(self) -> None:
        """Any 2xx response should be treated as success."""
        transport = self._make_transport()
        resp = MagicMock()
        resp.status = 201
        transport._pool.urlopen.return_value = resp  # type: ignore[attr-defined]

        assert transport.send_batch([{"type": "test"}]) is True

    def test_cancel_event_interrupts_retry(self) -> None:
        """Setting the cancel event should abort retries immediately."""
        cancel = threading.Event()
        transport = SluiceTransport(
            api_key="sk_test_123",
            base_url="https://sluice.sh",
            connection_id=_TEST_CONN_ID,
            max_retries=3,
            cancel_event=cancel,
        )
        transport._pool = MagicMock()

        resp_500 = MagicMock()
        resp_500.status = 500
        resp_500.headers = {}

        transport._pool.urlopen.return_value = resp_500

        # Pre-set cancel so the first retry wait returns immediately.
        cancel.set()
        result = transport.send_batch([{"type": "test"}])

        assert result is False
        # Only one attempt — cancelled during first backoff wait.
        assert transport._pool.urlopen.call_count == 1

    def test_http_url_uses_http_pool(self) -> None:
        """http:// URL should use HTTPConnectionPool, not HTTPS."""
        transport = SluiceTransport(
            api_key="sk_test_123",
            base_url="http://localhost:8080",
        )
        assert isinstance(transport._pool, urllib3.HTTPConnectionPool)
        assert not isinstance(transport._pool, urllib3.HTTPSConnectionPool)

    def test_https_url_uses_https_pool(self) -> None:
        """https:// URL should use HTTPSConnectionPool."""
        transport = SluiceTransport(
            api_key="sk_test_123",
            base_url="https://sluice.sh",
        )
        assert isinstance(transport._pool, urllib3.HTTPSConnectionPool)


class TestParseHost:
    """Tests for URL parsing helpers."""

    def test_https_url(self) -> None:
        assert SluiceTransport._parse_host("https://sluice.sh") == "sluice.sh"

    def test_url_with_port(self) -> None:
        host = SluiceTransport._parse_host("https://sluice.sh:8443")
        assert host == "sluice.sh"

    def test_url_with_path(self) -> None:
        host = SluiceTransport._parse_host("https://sluice.sh/v1")
        assert host == "sluice.sh"


class TestParsePort:
    """Tests for port parsing."""

    def test_default_https_port(self) -> None:
        assert SluiceTransport._parse_port("https://sluice.sh") == 443

    def test_explicit_port(self) -> None:
        assert SluiceTransport._parse_port("https://sluice.sh:8443") == 8443

    def test_http_default_port(self) -> None:
        assert SluiceTransport._parse_port("http://localhost") == 80


class TestNextBackoff:
    """Tests for backoff calculation."""

    def test_doubles_backoff(self) -> None:
        """Backoff should approximately double each time."""
        val = _next_backoff(1.0)
        # With ±20% jitter, 2.0 * (1 ± 0.2) = 1.6 to 2.4.
        assert 1.5 <= val <= 2.5

    def test_respects_max(self) -> None:
        """Backoff should not exceed the max."""
        val = _next_backoff(20.0)
        # Max is 30, so capped. With jitter: 30 * (1 ± 0.2) = 24 to 36.
        assert val <= 36.0

    def test_never_negative(self) -> None:
        """Backoff should never be negative."""
        for _ in range(100):
            val = _next_backoff(0.1)
            assert val >= 0.1
