"""Comprehensive tests for the Waterlight Python SDK.

Tests the client, error hierarchy, retry logic, streaming, and all resource
endpoints using mocked urllib.request.urlopen (zero network calls).
"""

from __future__ import annotations

import io
import json
import os
from email.message import Message as HTTPMessage
from http.client import HTTPResponse
from unittest.mock import MagicMock, patch, PropertyMock
from urllib.error import HTTPError, URLError

import pytest

from waterlight import (
    Waterlight,
    WaterlightError,
    AuthenticationError,
    RateLimitError,
    InsufficientCreditsError,
    APIError,
)
from waterlight.client import (
    ChatCompletion,
    ChatCompletionChunk,
    Choice,
    Delta,
    Embedding,
    EmbeddingResponse,
    Message,
    Model,
    ModelList,
    StreamChoice,
    Usage,
)
from waterlight.errors import _map_status


# =============================================================================
# Helpers
# =============================================================================

def make_mock_response(body: dict, status: int = 200, headers: dict | None = None):
    """Build a mock that behaves like the return value of urlopen (context manager)."""
    resp = MagicMock()
    resp.read.return_value = json.dumps(body).encode("utf-8")
    resp.status = status
    resp.__enter__ = MagicMock(return_value=resp)
    resp.__exit__ = MagicMock(return_value=False)

    # Make it iterable for streaming tests
    resp.__iter__ = MagicMock(return_value=iter([]))

    if headers:
        hdr = HTTPMessage()
        for k, v in headers.items():
            hdr[k] = v
        resp.headers = hdr
    else:
        resp.headers = HTTPMessage()

    return resp


def make_http_error(status: int, body: dict | str | None = None,
                    headers: dict | None = None) -> HTTPError:
    """Build a urllib.error.HTTPError with readable body and headers."""
    if body is None:
        raw = b""
    elif isinstance(body, str):
        raw = body.encode("utf-8")
    else:
        raw = json.dumps(body).encode("utf-8")

    fp = io.BytesIO(raw)

    hdr = HTTPMessage()
    if headers:
        for k, v in headers.items():
            hdr[k] = str(v)

    return HTTPError(
        url="https://api.waterlight.io/v1/chat/completions",
        code=status,
        msg="Error",
        hdrs=hdr,
        fp=fp,
    )


def make_streaming_response(events: list[str]):
    """Build a mock response that yields encoded SSE lines when iterated."""
    resp = MagicMock()
    encoded_lines = [line.encode("utf-8") for line in events]
    resp.__enter__ = MagicMock(return_value=resp)
    resp.__exit__ = MagicMock(return_value=False)
    resp.__iter__ = MagicMock(return_value=iter(encoded_lines))
    return resp


CHAT_RESPONSE = {
    "id": "chatcmpl-abc123",
    "object": "chat.completion",
    "created": 1700000000,
    "model": "mist-1-turbo",
    "choices": [
        {
            "index": 0,
            "message": {
                "role": "assistant",
                "content": "Hello! How can I help?",
            },
            "finish_reason": "stop",
        }
    ],
    "usage": {
        "prompt_tokens": 10,
        "completion_tokens": 8,
        "total_tokens": 18,
    },
}

EMBEDDING_RESPONSE = {
    "object": "list",
    "data": [
        {
            "object": "embedding",
            "embedding": [0.1, 0.2, 0.3],
            "index": 0,
        }
    ],
    "model": "mist-embed-1",
    "usage": {
        "prompt_tokens": 5,
        "completion_tokens": 0,
        "total_tokens": 5,
    },
}

MODELS_RESPONSE = {
    "object": "list",
    "data": [
        {"id": "mist-1-turbo", "object": "model", "created": 1700000000, "owned_by": "waterlight"},
        {"id": "mist-1-flash", "object": "model", "created": 1700000001, "owned_by": "waterlight"},
    ],
}

BILLING_RESPONSE = {
    "plan": "payg",
    "billing_mode": "payg",
    "spent_usd": 12.50,
    "balance_usd": 87.50,
    "daily_limit": 100.0,
    "daily_used": 12.50,
}


# =============================================================================
# Constructor Tests
# =============================================================================

class TestConstructor:
    """Tests for Waterlight.__init__."""

    def test_accepts_explicit_api_key(self):
        client = Waterlight(api_key="wl-test-key-123")
        assert client.api_key == "wl-test-key-123"

    def test_falls_back_to_env_var(self, monkeypatch):
        monkeypatch.setenv("WATERLIGHT_API_KEY", "wl-env-key-456")
        client = Waterlight()
        assert client.api_key == "wl-env-key-456"

    def test_raises_value_error_when_no_key(self, monkeypatch):
        monkeypatch.delenv("WATERLIGHT_API_KEY", raising=False)
        with pytest.raises(ValueError, match="API key required"):
            Waterlight()

    def test_default_base_url_timeout_retries(self):
        client = Waterlight(api_key="wl-test")
        assert client.base_url == "https://api.waterlight.io"
        assert client._timeout == 120.0
        assert client._max_retries == 2

    def test_strips_trailing_slash_from_base_url(self):
        client = Waterlight(api_key="wl-test", base_url="https://custom.api.com/")
        assert client.base_url == "https://custom.api.com"


# =============================================================================
# Non-Streaming Chat Completion Tests
# =============================================================================

class TestChatCompletion:
    """Tests for client.chat.completions.create (non-streaming)."""

    @patch("waterlight.client.request.urlopen")
    def test_sends_correct_request(self, mock_urlopen):
        mock_urlopen.return_value = make_mock_response(CHAT_RESPONSE)
        client = Waterlight(api_key="wl-test")
        client.chat.completions.create(
            model="mist-1-turbo",
            messages=[{"role": "user", "content": "Hello"}],
        )

        # Verify urlopen was called
        assert mock_urlopen.called
        call_args = mock_urlopen.call_args
        req = call_args[0][0]

        assert req.get_method() == "POST"
        assert req.full_url == "https://api.waterlight.io/v1/chat/completions"
        assert req.get_header("Authorization") == "Bearer wl-test"
        assert req.get_header("Content-type") == "application/json"

        body = json.loads(req.data.decode("utf-8"))
        assert body["model"] == "mist-1-turbo"
        assert body["messages"] == [{"role": "user", "content": "Hello"}]
        assert body["stream"] is False

    @patch("waterlight.client.request.urlopen")
    def test_returns_parsed_chat_completion(self, mock_urlopen):
        mock_urlopen.return_value = make_mock_response(CHAT_RESPONSE)
        client = Waterlight(api_key="wl-test")
        result = client.chat.completions.create(
            model="mist-1-turbo",
            messages=[{"role": "user", "content": "Hello"}],
        )

        assert isinstance(result, ChatCompletion)
        assert result.id == "chatcmpl-abc123"
        assert result.model == "mist-1-turbo"
        assert len(result.choices) == 1
        assert result.choices[0].message.role == "assistant"
        assert result.choices[0].message.content == "Hello! How can I help?"
        assert result.choices[0].finish_reason == "stop"
        assert result.usage.prompt_tokens == 10
        assert result.usage.completion_tokens == 8
        assert result.usage.total_tokens == 18


# =============================================================================
# Error Handling Tests
# =============================================================================

class TestErrorHandling:
    """Tests for HTTP error mapping during requests."""

    @patch("waterlight.client.request.urlopen")
    def test_401_raises_authentication_error(self, mock_urlopen):
        mock_urlopen.side_effect = make_http_error(
            401, {"error": {"message": "Invalid API key"}},
        )
        client = Waterlight(api_key="wl-bad-key")
        with pytest.raises(AuthenticationError, match="Invalid API key") as exc_info:
            client.chat.completions.create(
                model="mist-1-turbo",
                messages=[{"role": "user", "content": "Hello"}],
            )
        assert exc_info.value.status_code == 401

    @patch("waterlight.client.request.urlopen")
    def test_402_raises_insufficient_credits_error(self, mock_urlopen):
        mock_urlopen.side_effect = make_http_error(
            402, {"error": {"message": "Insufficient credits"}},
        )
        client = Waterlight(api_key="wl-test")
        with pytest.raises(InsufficientCreditsError, match="Insufficient credits") as exc_info:
            client.chat.completions.create(
                model="mist-1-turbo",
                messages=[{"role": "user", "content": "Hello"}],
            )
        assert exc_info.value.status_code == 402

    @patch("waterlight.client.request.urlopen")
    def test_429_raises_rate_limit_error_with_retry_after(self, mock_urlopen):
        mock_urlopen.side_effect = make_http_error(
            429,
            {"error": {"message": "Rate limit exceeded"}},
            headers={"Retry-After": "30"},
        )
        client = Waterlight(api_key="wl-test", max_retries=0)
        with pytest.raises(RateLimitError, match="Rate limit exceeded") as exc_info:
            client.chat.completions.create(
                model="mist-1-turbo",
                messages=[{"role": "user", "content": "Hello"}],
            )
        assert exc_info.value.status_code == 429
        assert exc_info.value.retry_after == 30.0

    @patch("waterlight.client.request.urlopen")
    def test_500_raises_api_error(self, mock_urlopen):
        mock_urlopen.side_effect = make_http_error(
            500, {"error": {"message": "Internal server error"}},
        )
        client = Waterlight(api_key="wl-test", max_retries=0)
        with pytest.raises(APIError, match="Internal server error") as exc_info:
            client.chat.completions.create(
                model="mist-1-turbo",
                messages=[{"role": "user", "content": "Hello"}],
            )
        assert exc_info.value.status_code == 500

    @patch("waterlight.client.request.urlopen")
    def test_empty_error_body_falls_back_to_request_failed(self, mock_urlopen):
        mock_urlopen.side_effect = make_http_error(503, body="")
        client = Waterlight(api_key="wl-test", max_retries=0)
        with pytest.raises(APIError, match="Request failed"):
            client.chat.completions.create(
                model="mist-1-turbo",
                messages=[{"role": "user", "content": "Hello"}],
            )


# =============================================================================
# Request ID Tests
# =============================================================================

class TestRequestId:
    """Tests for X-Request-Id parsing from error responses."""

    @patch("waterlight.client.request.urlopen")
    def test_request_id_parsed_from_error_headers(self, mock_urlopen):
        mock_urlopen.side_effect = make_http_error(
            500,
            {"error": {"message": "Server error"}},
            headers={"X-Request-Id": "req-abc-123"},
        )
        client = Waterlight(api_key="wl-test", max_retries=0)
        with pytest.raises(APIError) as exc_info:
            client.chat.completions.create(
                model="mist-1-turbo",
                messages=[{"role": "user", "content": "Hello"}],
            )
        assert exc_info.value.request_id == "req-abc-123"

    @patch("waterlight.client.request.urlopen")
    def test_request_id_none_when_header_absent(self, mock_urlopen):
        mock_urlopen.side_effect = make_http_error(
            500, {"error": {"message": "Server error"}},
        )
        client = Waterlight(api_key="wl-test", max_retries=0)
        with pytest.raises(APIError) as exc_info:
            client.chat.completions.create(
                model="mist-1-turbo",
                messages=[{"role": "user", "content": "Hello"}],
            )
        assert exc_info.value.request_id is None


# =============================================================================
# Retry Logic Tests
# =============================================================================

class TestRetryLogic:
    """Tests for automatic retry on transient failures."""

    @patch("waterlight.client.time.sleep")
    @patch("waterlight.client.request.urlopen")
    def test_retries_on_429_then_succeeds(self, mock_urlopen, mock_sleep):
        mock_urlopen.side_effect = [
            make_http_error(429, {"error": {"message": "Rate limited"}},
                            headers={"Retry-After": "1"}),
            make_mock_response(CHAT_RESPONSE),
        ]
        client = Waterlight(api_key="wl-test", max_retries=2)
        result = client.chat.completions.create(
            model="mist-1-turbo",
            messages=[{"role": "user", "content": "Hello"}],
        )
        assert isinstance(result, ChatCompletion)
        assert result.id == "chatcmpl-abc123"
        assert mock_urlopen.call_count == 2
        mock_sleep.assert_called_once_with(1.0)

    @patch("waterlight.client.time.sleep")
    @patch("waterlight.client.request.urlopen")
    def test_retries_on_500_then_succeeds(self, mock_urlopen, mock_sleep):
        mock_urlopen.side_effect = [
            make_http_error(500, {"error": {"message": "Internal error"}}),
            make_mock_response(CHAT_RESPONSE),
        ]
        client = Waterlight(api_key="wl-test", max_retries=2)
        result = client.chat.completions.create(
            model="mist-1-turbo",
            messages=[{"role": "user", "content": "Hello"}],
        )
        assert isinstance(result, ChatCompletion)
        assert mock_urlopen.call_count == 2

    @patch("waterlight.client.time.sleep")
    @patch("waterlight.client.request.urlopen")
    def test_retries_on_502_503_504(self, mock_urlopen, mock_sleep):
        mock_urlopen.side_effect = [
            make_http_error(502, {"error": {"message": "Bad gateway"}}),
            make_http_error(503, {"error": {"message": "Service unavailable"}}),
            make_mock_response(CHAT_RESPONSE),
        ]
        client = Waterlight(api_key="wl-test", max_retries=3)
        result = client.chat.completions.create(
            model="mist-1-turbo",
            messages=[{"role": "user", "content": "Hello"}],
        )
        assert isinstance(result, ChatCompletion)
        assert mock_urlopen.call_count == 3

    @patch("waterlight.client.time.sleep")
    @patch("waterlight.client.request.urlopen")
    def test_respects_max_retries_then_raises(self, mock_urlopen, mock_sleep):
        mock_urlopen.side_effect = [
            make_http_error(500, {"error": {"message": "Still broken"}}),
            make_http_error(500, {"error": {"message": "Still broken"}}),
            make_http_error(500, {"error": {"message": "Still broken"}}),
        ]
        client = Waterlight(api_key="wl-test", max_retries=2)
        with pytest.raises(APIError, match="Still broken"):
            client.chat.completions.create(
                model="mist-1-turbo",
                messages=[{"role": "user", "content": "Hello"}],
            )
        # 1 initial + 2 retries = 3 attempts total
        assert mock_urlopen.call_count == 3

    @patch("waterlight.client.time.sleep")
    @patch("waterlight.client.request.urlopen")
    def test_does_not_retry_on_401(self, mock_urlopen, mock_sleep):
        mock_urlopen.side_effect = make_http_error(
            401, {"error": {"message": "Invalid key"}},
        )
        client = Waterlight(api_key="wl-bad", max_retries=2)
        with pytest.raises(AuthenticationError, match="Invalid key"):
            client.chat.completions.create(
                model="mist-1-turbo",
                messages=[{"role": "user", "content": "Hello"}],
            )
        assert mock_urlopen.call_count == 1
        mock_sleep.assert_not_called()


# =============================================================================
# Timeout Tests
# =============================================================================

class TestTimeout:
    """Tests for network timeout handling."""

    @patch("waterlight.client.request.urlopen")
    def test_url_error_wraps_as_api_error_503(self, mock_urlopen):
        mock_urlopen.side_effect = URLError("Connection timed out")
        client = Waterlight(api_key="wl-test")
        with pytest.raises(APIError, match="Network error") as exc_info:
            client.chat.completions.create(
                model="mist-1-turbo",
                messages=[{"role": "user", "content": "Hello"}],
            )
        assert exc_info.value.status_code == 503

    @patch("waterlight.client.request.urlopen")
    def test_timeout_parameter_passed_to_urlopen(self, mock_urlopen):
        mock_urlopen.return_value = make_mock_response(CHAT_RESPONSE)
        client = Waterlight(api_key="wl-test", timeout=42.0)
        client.chat.completions.create(
            model="mist-1-turbo",
            messages=[{"role": "user", "content": "Hello"}],
        )

        call_args = mock_urlopen.call_args
        assert call_args[1].get("timeout") == 42.0 or call_args.kwargs.get("timeout") == 42.0


# =============================================================================
# Streaming Tests
# =============================================================================

class TestStreaming:
    """Tests for client.chat.completions.create(stream=True)."""

    @patch("waterlight.client.request.urlopen")
    def test_yields_chat_completion_chunks(self, mock_urlopen):
        chunk1 = json.dumps({
            "id": "chatcmpl-stream1",
            "object": "chat.completion.chunk",
            "created": 1700000000,
            "model": "mist-1-turbo",
            "choices": [{"index": 0, "delta": {"content": "Hello"}, "finish_reason": None}],
        })
        chunk2 = json.dumps({
            "id": "chatcmpl-stream1",
            "object": "chat.completion.chunk",
            "created": 1700000000,
            "model": "mist-1-turbo",
            "choices": [{"index": 0, "delta": {"content": " world"}, "finish_reason": None}],
        })
        events = [
            f"data: {chunk1}\n",
            f"data: {chunk2}\n",
            "data: [DONE]\n",
        ]
        mock_urlopen.return_value = make_streaming_response(events)
        client = Waterlight(api_key="wl-test")
        chunks = list(client.chat.completions.create(
            model="mist-1-turbo",
            messages=[{"role": "user", "content": "Hello"}],
            stream=True,
        ))

        assert len(chunks) == 2
        assert isinstance(chunks[0], ChatCompletionChunk)
        assert chunks[0].choices[0].delta.content == "Hello"
        assert chunks[1].choices[0].delta.content == " world"

    @patch("waterlight.client.request.urlopen")
    def test_stops_on_done(self, mock_urlopen):
        chunk1 = json.dumps({
            "id": "chatcmpl-x",
            "choices": [{"index": 0, "delta": {"content": "hi"}, "finish_reason": None}],
        })
        extra = json.dumps({
            "id": "chatcmpl-x",
            "choices": [{"index": 0, "delta": {"content": "SHOULD NOT APPEAR"}, "finish_reason": None}],
        })
        events = [
            f"data: {chunk1}\n",
            "data: [DONE]\n",
            f"data: {extra}\n",  # After DONE, should not be yielded
        ]
        mock_urlopen.return_value = make_streaming_response(events)
        client = Waterlight(api_key="wl-test")
        chunks = list(client.chat.completions.create(
            model="mist-1-turbo",
            messages=[{"role": "user", "content": "Hello"}],
            stream=True,
        ))
        assert len(chunks) == 1
        assert chunks[0].choices[0].delta.content == "hi"

    @patch("waterlight.client.request.urlopen")
    def test_skips_malformed_json_chunks(self, mock_urlopen):
        good_chunk = json.dumps({
            "id": "chatcmpl-x",
            "choices": [{"index": 0, "delta": {"content": "ok"}, "finish_reason": None}],
        })
        events = [
            "data: {this is not valid json\n",
            f"data: {good_chunk}\n",
            "data: [DONE]\n",
        ]
        mock_urlopen.return_value = make_streaming_response(events)
        client = Waterlight(api_key="wl-test")
        chunks = list(client.chat.completions.create(
            model="mist-1-turbo",
            messages=[{"role": "user", "content": "Hello"}],
            stream=True,
        ))
        assert len(chunks) == 1
        assert chunks[0].choices[0].delta.content == "ok"

    @patch("waterlight.client.request.urlopen")
    def test_sends_accept_text_event_stream_header(self, mock_urlopen):
        events = ["data: [DONE]\n"]
        mock_urlopen.return_value = make_streaming_response(events)
        client = Waterlight(api_key="wl-test")
        list(client.chat.completions.create(
            model="mist-1-turbo",
            messages=[{"role": "user", "content": "Hello"}],
            stream=True,
        ))

        call_args = mock_urlopen.call_args
        req = call_args[0][0]
        assert req.get_header("Accept") == "text/event-stream"

    @patch("waterlight.client.request.urlopen")
    def test_http_error_during_streaming_raises_appropriate_error(self, mock_urlopen):
        mock_urlopen.side_effect = make_http_error(
            401, {"error": {"message": "Bad API key for stream"}},
        )
        client = Waterlight(api_key="wl-bad")
        with pytest.raises(AuthenticationError, match="Bad API key for stream"):
            list(client.chat.completions.create(
                model="mist-1-turbo",
                messages=[{"role": "user", "content": "Hello"}],
                stream=True,
            ))


# =============================================================================
# Error Classes Tests
# =============================================================================

class TestErrorClasses:
    """Tests for the error hierarchy in waterlight.errors."""

    def test_waterlight_error_has_message_status_code_request_id(self):
        err = WaterlightError("something broke", status_code=418, request_id="req-xyz")
        assert err.message == "something broke"
        assert err.status_code == 418
        assert err.request_id == "req-xyz"
        assert str(err) == "something broke"

    def test_authentication_error_is_subclass_of_waterlight_error(self):
        err = AuthenticationError("bad key", status_code=401)
        assert isinstance(err, WaterlightError)
        assert isinstance(err, AuthenticationError)
        assert err.status_code == 401

    def test_rate_limit_error_has_retry_after_attribute(self):
        err = RateLimitError("too fast", status_code=429, retry_after=30.0)
        assert isinstance(err, WaterlightError)
        assert err.retry_after == 30.0
        assert err.status_code == 429

    def test_insufficient_credits_error_has_status_code_402(self):
        err = InsufficientCreditsError("no money", status_code=402)
        assert isinstance(err, WaterlightError)
        assert err.status_code == 402

    def test_map_status_returns_correct_error_types(self):
        auth_err = _map_status(401, "auth fail")
        assert isinstance(auth_err, AuthenticationError)
        assert auth_err.status_code == 401

        credits_err = _map_status(402, "no credits")
        assert isinstance(credits_err, InsufficientCreditsError)
        assert credits_err.status_code == 402

        rate_err = _map_status(429, "rate limited", retry_after=10.0)
        assert isinstance(rate_err, RateLimitError)
        assert rate_err.retry_after == 10.0

        api_err = _map_status(500, "server error")
        assert isinstance(api_err, APIError)
        assert api_err.status_code == 500

        # Unknown status falls through to APIError
        unknown_err = _map_status(418, "teapot")
        assert isinstance(unknown_err, APIError)
        assert unknown_err.status_code == 418


# =============================================================================
# Embeddings, Models, Billing Tests
# =============================================================================

class TestEmbeddings:
    """Tests for client.embeddings.create."""

    @patch("waterlight.client.request.urlopen")
    def test_embeddings_post_and_response(self, mock_urlopen):
        mock_urlopen.return_value = make_mock_response(EMBEDDING_RESPONSE)
        client = Waterlight(api_key="wl-test")
        result = client.embeddings.create(input="Hello world")

        # Verify request
        call_args = mock_urlopen.call_args
        req = call_args[0][0]
        assert req.get_method() == "POST"
        assert req.full_url == "https://api.waterlight.io/v1/embeddings"
        body = json.loads(req.data.decode("utf-8"))
        assert body["input"] == "Hello world"

        # Verify response
        assert isinstance(result, EmbeddingResponse)
        assert len(result.data) == 1
        assert result.data[0].embedding == [0.1, 0.2, 0.3]
        assert result.data[0].index == 0
        assert result.model == "mist-embed-1"
        assert result.usage.prompt_tokens == 5


class TestModels:
    """Tests for client.models.list."""

    @patch("waterlight.client.request.urlopen")
    def test_models_get_and_response(self, mock_urlopen):
        mock_urlopen.return_value = make_mock_response(MODELS_RESPONSE)
        client = Waterlight(api_key="wl-test")
        result = client.models.list()

        # Verify request is GET with no Content-Type body
        call_args = mock_urlopen.call_args
        req = call_args[0][0]
        assert req.get_method() == "GET"
        assert req.full_url == "https://api.waterlight.io/v1/models"
        assert req.data is None  # GET has no body

        # Verify response
        assert isinstance(result, ModelList)
        assert len(result.data) == 2
        assert result.data[0].id == "mist-1-turbo"
        assert result.data[1].id == "mist-1-flash"
        assert result.data[0].owned_by == "waterlight"


class TestBilling:
    """Tests for client.billing.get."""

    @patch("waterlight.client.request.urlopen")
    def test_billing_get_and_response(self, mock_urlopen):
        mock_urlopen.return_value = make_mock_response(BILLING_RESPONSE)
        client = Waterlight(api_key="wl-test")
        result = client.billing.get()

        # Verify request is GET
        call_args = mock_urlopen.call_args
        req = call_args[0][0]
        assert req.get_method() == "GET"
        assert req.full_url == "https://api.waterlight.io/v1/billing"

        # Verify response is a plain dict
        assert isinstance(result, dict)
        assert result["plan"] == "payg"
        assert result["spent_usd"] == 12.50
        assert result["balance_usd"] == 87.50


# =============================================================================
# Additional Edge Case Tests
# =============================================================================

class TestEdgeCases:
    """Additional tests for edge cases and integration patterns."""

    def test_base_url_from_env_var(self, monkeypatch):
        monkeypatch.setenv("WATERLIGHT_BASE_URL", "https://custom.waterlight.dev/")
        client = Waterlight(api_key="wl-test")
        assert client.base_url == "https://custom.waterlight.dev"

    def test_explicit_base_url_overrides_env(self, monkeypatch):
        monkeypatch.setenv("WATERLIGHT_BASE_URL", "https://env.example.com")
        client = Waterlight(api_key="wl-test", base_url="https://explicit.example.com/")
        assert client.base_url == "https://explicit.example.com"

    def test_resource_namespaces_exist(self):
        client = Waterlight(api_key="wl-test")
        assert hasattr(client, "chat")
        assert hasattr(client.chat, "completions")
        assert hasattr(client, "embeddings")
        assert hasattr(client, "models")
        assert hasattr(client, "billing")

    @patch("waterlight.client.request.urlopen")
    def test_error_body_with_string_error_field(self, mock_urlopen):
        """When error body has {"error": "string"} instead of {"error": {"message": ...}}."""
        mock_urlopen.side_effect = make_http_error(
            400, {"error": "Bad request format"},
        )
        client = Waterlight(api_key="wl-test", max_retries=0)
        with pytest.raises(APIError, match="Bad request format"):
            client.chat.completions.create(
                model="mist-1-turbo",
                messages=[{"role": "user", "content": "Hello"}],
            )

    @patch("waterlight.client.request.urlopen")
    def test_error_body_with_non_json_text(self, mock_urlopen):
        """When error body is plain text, not JSON."""
        mock_urlopen.side_effect = make_http_error(
            502, body="Bad Gateway",
        )
        client = Waterlight(api_key="wl-test", max_retries=0)
        with pytest.raises(APIError, match="Bad Gateway"):
            client.chat.completions.create(
                model="mist-1-turbo",
                messages=[{"role": "user", "content": "Hello"}],
            )

    @patch("waterlight.client.time.sleep")
    @patch("waterlight.client.request.urlopen")
    def test_retry_uses_exponential_backoff_without_retry_after(self, mock_urlopen, mock_sleep):
        """When no Retry-After header, uses 0.5 * 2^attempt backoff."""
        mock_urlopen.side_effect = [
            make_http_error(500, {"error": {"message": "err"}}),
            make_http_error(500, {"error": {"message": "err"}}),
            make_mock_response(CHAT_RESPONSE),
        ]
        client = Waterlight(api_key="wl-test", max_retries=3)
        client.chat.completions.create(
            model="mist-1-turbo",
            messages=[{"role": "user", "content": "Hello"}],
        )

        # First retry: 0.5 * 2^0 = 0.5, second: 0.5 * 2^1 = 1.0
        sleep_calls = [c.args[0] for c in mock_sleep.call_args_list]
        assert sleep_calls == [0.5, 1.0]

    @patch("waterlight.client.request.urlopen")
    def test_custom_timeout_and_retries(self, mock_urlopen):
        """Custom timeout and max_retries propagate correctly."""
        client = Waterlight(api_key="wl-test", timeout=5.0, max_retries=0)
        assert client._timeout == 5.0
        assert client._max_retries == 0

    @patch("waterlight.client.request.urlopen")
    def test_chat_completion_with_optional_params(self, mock_urlopen):
        """temperature, max_tokens, tools, tool_choice passed in payload."""
        mock_urlopen.return_value = make_mock_response(CHAT_RESPONSE)
        client = Waterlight(api_key="wl-test")
        client.chat.completions.create(
            model="mist-1-turbo",
            messages=[{"role": "user", "content": "Hello"}],
            temperature=0.7,
            max_tokens=100,
            tools=[{"type": "function", "function": {"name": "test"}}],
            tool_choice="auto",
        )

        call_args = mock_urlopen.call_args
        req = call_args[0][0]
        body = json.loads(req.data.decode("utf-8"))
        assert body["temperature"] == 0.7
        assert body["max_tokens"] == 100
        assert body["tools"] == [{"type": "function", "function": {"name": "test"}}]
        assert body["tool_choice"] == "auto"
