"""Unit tests for GraphClient."""

from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch

import httpx
import pytest
import respx

from teams_phone.constants import DEFAULT_TIMEOUT, GRAPH_API_BASE, MAX_RETRIES
from teams_phone.exceptions import (
    AuthenticationError,
    AuthorizationError,
    ContractError,
    NotFoundError,
    RateLimitError,
    TeamsPhoneError,
    ValidationError,
)
from teams_phone.infrastructure import GraphClient
from teams_phone.infrastructure.graph_client import (
    _get_retry_after,
    _is_retryable_status,
)
from teams_phone.services import AuthService


@pytest.fixture
def mock_auth_service(tmp_path: Path) -> MagicMock:
    """Create a mock AuthService that returns a test token."""
    mock_service = MagicMock(spec=AuthService)
    mock_service.get_token.return_value = "test-access-token-12345"
    return mock_service


class TestGraphClientInit:
    """Tests for GraphClient initialization."""

    @pytest.mark.unit
    def test_accepts_auth_service(self, mock_auth_service: MagicMock) -> None:
        """GraphClient accepts AuthService in constructor."""
        client = GraphClient(mock_auth_service)

        assert client.auth_service is mock_auth_service

    @pytest.mark.unit
    def test_uses_default_timeout(self, mock_auth_service: MagicMock) -> None:
        """GraphClient uses DEFAULT_TIMEOUT when timeout not specified."""
        client = GraphClient(mock_auth_service)

        assert client._timeout == DEFAULT_TIMEOUT

    @pytest.mark.unit
    def test_accepts_custom_timeout(self, mock_auth_service: MagicMock) -> None:
        """GraphClient accepts custom timeout value."""
        client = GraphClient(mock_auth_service, timeout=60)

        assert client._timeout == 60

    @pytest.mark.unit
    def test_client_not_initialized_outside_context(
        self, mock_auth_service: MagicMock
    ) -> None:
        """GraphClient._client is None before entering context manager."""
        client = GraphClient(mock_auth_service)

        assert client._client is None


class TestGraphClientBuildUrl:
    """Tests for GraphClient._build_url method."""

    @pytest.mark.unit
    def test_builds_url_without_leading_slash(
        self, mock_auth_service: MagicMock
    ) -> None:
        """_build_url handles endpoint without leading slash."""
        client = GraphClient(mock_auth_service)

        url = client._build_url("users")

        assert url == f"{GRAPH_API_BASE}/users"

    @pytest.mark.unit
    def test_builds_url_with_leading_slash(self, mock_auth_service: MagicMock) -> None:
        """_build_url handles endpoint with leading slash."""
        client = GraphClient(mock_auth_service)

        url = client._build_url("/users")

        assert url == f"{GRAPH_API_BASE}/users"

    @pytest.mark.unit
    def test_builds_url_with_nested_path(self, mock_auth_service: MagicMock) -> None:
        """_build_url handles nested endpoint paths."""
        client = GraphClient(mock_auth_service)

        url = client._build_url("/admin/teams/phone/numbers")

        assert url == f"{GRAPH_API_BASE}/admin/teams/phone/numbers"


class TestGraphClientGetHeaders:
    """Tests for GraphClient._get_headers method."""

    @pytest.mark.unit
    def test_returns_authorization_header(self, mock_auth_service: MagicMock) -> None:
        """_get_headers includes Bearer token from AuthService."""
        client = GraphClient(mock_auth_service)

        headers = client._get_headers()

        assert headers["Authorization"] == "Bearer test-access-token-12345"
        mock_auth_service.get_token.assert_called_once()

    @pytest.mark.unit
    def test_returns_content_type_header(self, mock_auth_service: MagicMock) -> None:
        """_get_headers includes JSON content type."""
        client = GraphClient(mock_auth_service)

        headers = client._get_headers()

        assert headers["Content-Type"] == "application/json"


class TestGraphClientContextManager:
    """Tests for GraphClient async context manager protocol."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_enter_creates_client(self, mock_auth_service: MagicMock) -> None:
        """__aenter__ creates httpx.AsyncClient."""
        client = GraphClient(mock_auth_service)

        async with client as entered:
            assert entered is client
            assert client._client is not None
            assert isinstance(client._client, httpx.AsyncClient)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_exit_closes_client(self, mock_auth_service: MagicMock) -> None:
        """__aexit__ closes httpx.AsyncClient."""
        client = GraphClient(mock_auth_service)

        async with client:
            assert client._client is not None

        assert client._client is None

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_client_uses_configured_timeout(
        self, mock_auth_service: MagicMock
    ) -> None:
        """Context manager creates client with configured timeout."""
        client = GraphClient(mock_auth_service, timeout=45)

        async with client:
            assert client._client is not None
            assert client._client.timeout.connect == 45


class TestGraphClientRequest:
    """Tests for GraphClient._request method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_raises_without_context_manager(
        self, mock_auth_service: MagicMock
    ) -> None:
        """_request raises RuntimeError when client not in context."""
        client = GraphClient(mock_auth_service)

        with pytest.raises(RuntimeError, match="async context manager"):
            await client._request("GET", f"{GRAPH_API_BASE}/users")

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_makes_request_with_headers(
        self, mock_auth_service: MagicMock
    ) -> None:
        """_request includes authentication headers."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(200, json={"value": []})
            )

            async with GraphClient(mock_auth_service) as client:
                response = await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert route.called
            assert response.status_code == 200
            # Verify headers were sent
            request = route.calls[0].request
            assert request.headers["Authorization"] == "Bearer test-access-token-12345"
            assert request.headers["Content-Type"] == "application/json"


class TestGraphClientGet:
    """Tests for GraphClient.get method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_returns_json(self, mock_auth_service: MagicMock) -> None:
        """get method returns parsed JSON response."""
        expected_data = {"value": [{"id": "user1"}, {"id": "user2"}]}

        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(200, json=expected_data)
            )

            async with GraphClient(mock_auth_service) as client:
                result = await client.get("/users")

            assert result == expected_data

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_with_params(self, mock_auth_service: MagicMock) -> None:
        """get method passes query parameters."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(200, json={"value": []})
            )

            async with GraphClient(mock_auth_service) as client:
                await client.get("/users", params={"$top": "10", "$filter": "active"})

            assert route.called
            request = route.calls[0].request
            # URL encodes $ as %24
            assert "top=10" in str(request.url)
            assert "filter=active" in str(request.url)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_raises_not_found_error(
        self, mock_auth_service: MagicMock
    ) -> None:
        """get method raises NotFoundError on 404 response."""
        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(
                    404,
                    json={
                        "error": {
                            "code": "ResourceNotFound",
                            "message": "User not found",
                        }
                    },
                )
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(NotFoundError) as exc_info:
                    await client.get("/users")

            assert "ResourceNotFound" in str(exc_info.value)
            assert "User not found" in str(exc_info.value)


class TestGraphClientPost:
    """Tests for GraphClient.post method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_post_returns_json(self, mock_auth_service: MagicMock) -> None:
        """post method returns parsed JSON response."""
        expected_response = {"id": "new-user", "displayName": "Test User"}
        request_body = {"displayName": "Test User"}

        with respx.mock:
            respx.post(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(201, json=expected_response)
            )

            async with GraphClient(mock_auth_service) as client:
                result = await client.post("/users", request_body)

            assert result == expected_response

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_post_sends_json_body(self, mock_auth_service: MagicMock) -> None:
        """post method sends request body as JSON."""
        request_body = {"displayName": "Test User", "mail": "test@example.com"}

        with respx.mock:
            route = respx.post(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(201, json={"id": "user1"})
            )

            async with GraphClient(mock_auth_service) as client:
                await client.post("/users", request_body)

            assert route.called
            request = route.calls[0].request
            # Verify JSON body was sent
            import json

            sent_body = json.loads(request.content)
            assert sent_body == request_body

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_post_raises_validation_error(
        self, mock_auth_service: MagicMock
    ) -> None:
        """post method raises ValidationError on 400 response."""
        with respx.mock:
            respx.post(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(
                    400,
                    json={
                        "error": {"code": "BadRequest", "message": "Invalid user data"}
                    },
                )
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(ValidationError) as exc_info:
                    await client.post("/users", {"invalid": "data"})

            assert "BadRequest" in str(exc_info.value)
            assert "Invalid user data" in str(exc_info.value)


class TestRetryHelperFunctions:
    """Tests for retry helper functions."""

    @pytest.mark.unit
    def test_is_retryable_status_429(self) -> None:
        """429 status code is retryable."""
        response = httpx.Response(429)
        assert _is_retryable_status(response) is True

    @pytest.mark.unit
    def test_is_retryable_status_500(self) -> None:
        """500 status code is retryable."""
        response = httpx.Response(500)
        assert _is_retryable_status(response) is True

    @pytest.mark.unit
    def test_is_retryable_status_502(self) -> None:
        """502 status code is retryable."""
        response = httpx.Response(502)
        assert _is_retryable_status(response) is True

    @pytest.mark.unit
    def test_is_retryable_status_503(self) -> None:
        """503 status code is retryable."""
        response = httpx.Response(503)
        assert _is_retryable_status(response) is True

    @pytest.mark.unit
    def test_is_retryable_status_504(self) -> None:
        """504 status code is retryable."""
        response = httpx.Response(504)
        assert _is_retryable_status(response) is True

    @pytest.mark.unit
    def test_is_retryable_status_400_not_retryable(self) -> None:
        """400 status code is not retryable."""
        response = httpx.Response(400)
        assert _is_retryable_status(response) is False

    @pytest.mark.unit
    def test_is_retryable_status_401_not_retryable(self) -> None:
        """401 status code is not retryable (handled separately)."""
        response = httpx.Response(401)
        assert _is_retryable_status(response) is False

    @pytest.mark.unit
    def test_is_retryable_status_403_not_retryable(self) -> None:
        """403 status code is not retryable."""
        response = httpx.Response(403)
        assert _is_retryable_status(response) is False

    @pytest.mark.unit
    def test_is_retryable_status_404_not_retryable(self) -> None:
        """404 status code is not retryable."""
        response = httpx.Response(404)
        assert _is_retryable_status(response) is False

    @pytest.mark.unit
    def test_get_retry_after_integer_seconds(self) -> None:
        """Parses Retry-After header with integer seconds."""
        response = httpx.Response(429, headers={"Retry-After": "5"})
        assert _get_retry_after(response) == 5.0

    @pytest.mark.unit
    def test_get_retry_after_float_seconds(self) -> None:
        """Parses Retry-After header with decimal seconds."""
        response = httpx.Response(429, headers={"Retry-After": "2.5"})
        assert _get_retry_after(response) == 2.5

    @pytest.mark.unit
    def test_get_retry_after_missing_header(self) -> None:
        """Returns None when Retry-After header is missing."""
        response = httpx.Response(429)
        assert _get_retry_after(response) is None

    @pytest.mark.unit
    def test_get_retry_after_http_date_format(self) -> None:
        """Parses Retry-After header with HTTP-date format."""
        # Use a fixed time 10 seconds in the future
        future_time = datetime(2026, 1, 29, 0, 0, 10, tzinfo=timezone.utc)
        date_str = future_time.strftime("%a, %d %b %Y %H:%M:%S GMT")
        response = httpx.Response(429, headers={"Retry-After": date_str})

        with patch("teams_phone.infrastructure.graph_client.datetime") as mock_datetime:
            mock_datetime.now.return_value = datetime(
                2026, 1, 29, 0, 0, 0, tzinfo=timezone.utc
            )
            mock_datetime.side_effect = lambda *args, **kwargs: datetime(
                *args, **kwargs
            )
            result = _get_retry_after(response)

        # Should be approximately 10 seconds
        assert result is not None
        assert 9.0 <= result <= 11.0

    @pytest.mark.unit
    def test_get_retry_after_invalid_format(self) -> None:
        """Returns None for invalid Retry-After format."""
        response = httpx.Response(429, headers={"Retry-After": "invalid"})
        assert _get_retry_after(response) is None


class TestGraphClientRetry:
    """Tests for GraphClient retry behavior."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_retries_on_429(self, mock_auth_service: MagicMock) -> None:
        """Request retries on 429 and succeeds."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.side_effect = [
                httpx.Response(429, headers={"Retry-After": "0"}),
                httpx.Response(200, json={"value": []}),
            ]

            async with GraphClient(mock_auth_service) as client:
                response = await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert response.status_code == 200
            assert route.call_count == 2

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_retries_on_500(self, mock_auth_service: MagicMock) -> None:
        """Request retries on 500 and succeeds."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.side_effect = [
                httpx.Response(500),
                httpx.Response(200, json={"value": []}),
            ]

            async with GraphClient(mock_auth_service) as client:
                response = await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert response.status_code == 200
            assert route.call_count == 2

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_retries_on_502(self, mock_auth_service: MagicMock) -> None:
        """Request retries on 502 and succeeds."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.side_effect = [
                httpx.Response(502),
                httpx.Response(200, json={"value": []}),
            ]

            async with GraphClient(mock_auth_service) as client:
                response = await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert response.status_code == 200
            assert route.call_count == 2

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_retries_on_503(self, mock_auth_service: MagicMock) -> None:
        """Request retries on 503 and succeeds."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.side_effect = [
                httpx.Response(503),
                httpx.Response(200, json={"value": []}),
            ]

            async with GraphClient(mock_auth_service) as client:
                response = await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert response.status_code == 200
            assert route.call_count == 2

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_retries_on_504(self, mock_auth_service: MagicMock) -> None:
        """Request retries on 504 and succeeds."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.side_effect = [
                httpx.Response(504),
                httpx.Response(200, json={"value": []}),
            ]

            async with GraphClient(mock_auth_service) as client:
                response = await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert response.status_code == 200
            assert route.call_count == 2

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_raises_rate_limit_error_after_max_retries(
        self, mock_auth_service: MagicMock
    ) -> None:
        """Raises RateLimitError after MAX_RETRIES on 429."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.side_effect = [
                httpx.Response(429, headers={"Retry-After": "0"})
                for _ in range(MAX_RETRIES)
            ]

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(RateLimitError) as exc_info:
                    await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert "Rate limit exceeded" in str(exc_info.value)
            assert route.call_count == MAX_RETRIES

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_raises_teams_phone_error_after_max_retries_on_5xx(
        self, mock_auth_service: MagicMock
    ) -> None:
        """Raises TeamsPhoneError after MAX_RETRIES on 503."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.side_effect = [httpx.Response(503) for _ in range(MAX_RETRIES)]

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(TeamsPhoneError) as exc_info:
                    await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert "HTTP 503" in str(exc_info.value)
            assert route.call_count == MAX_RETRIES

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_no_retry_on_400(self, mock_auth_service: MagicMock) -> None:
        """Does not retry on 400 status code."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.mock(return_value=httpx.Response(400, json={"error": "bad request"}))

            async with GraphClient(mock_auth_service) as client:
                response = await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert response.status_code == 400
            assert route.call_count == 1

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_no_retry_on_403(self, mock_auth_service: MagicMock) -> None:
        """Does not retry on 403 status code."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.mock(return_value=httpx.Response(403, json={"error": "forbidden"}))

            async with GraphClient(mock_auth_service) as client:
                response = await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert response.status_code == 403
            assert route.call_count == 1

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_no_retry_on_404(self, mock_auth_service: MagicMock) -> None:
        """Does not retry on 404 status code."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.mock(return_value=httpx.Response(404, json={"error": "not found"}))

            async with GraphClient(mock_auth_service) as client:
                response = await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert response.status_code == 404
            assert route.call_count == 1


class TestGraphClientTokenRefresh:
    """Tests for 401 token refresh behavior."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_refreshes_token_on_401_and_retries(
        self, mock_auth_service: MagicMock
    ) -> None:
        """Refreshes token on 401 and retries the request."""
        mock_auth_service.refresh_token.return_value = MagicMock(
            access_token="new-token"
        )

        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.side_effect = [
                httpx.Response(401, json={"error": "unauthorized"}),
                httpx.Response(200, json={"value": []}),
            ]

            async with GraphClient(mock_auth_service) as client:
                response = await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert response.status_code == 200
            mock_auth_service.refresh_token.assert_called_once()
            assert route.call_count == 2

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_raises_auth_error_after_401_double_failure(
        self, mock_auth_service: MagicMock
    ) -> None:
        """Raises AuthenticationError when 401 persists after token refresh."""
        mock_auth_service.refresh_token.return_value = MagicMock(
            access_token="new-token"
        )

        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.side_effect = [
                httpx.Response(401, json={"error": "unauthorized"}),
                httpx.Response(401, json={"error": "still unauthorized"}),
            ]

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(AuthenticationError) as exc_info:
                    await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert "Authentication failed" in str(exc_info.value)
            assert "teams-phone auth login" in str(exc_info.value)
            mock_auth_service.refresh_token.assert_called_once()
            assert route.call_count == 2

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_raises_auth_error_when_refresh_fails(
        self, mock_auth_service: MagicMock
    ) -> None:
        """Raises AuthenticationError when token refresh itself fails."""
        mock_auth_service.refresh_token.side_effect = AuthenticationError(
            "Token refresh failed"
        )

        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.mock(return_value=httpx.Response(401, json={"error": "unauthorized"}))

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(AuthenticationError) as exc_info:
                    await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert "Authentication failed" in str(exc_info.value)
            mock_auth_service.refresh_token.assert_called_once()
            assert route.call_count == 1

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_401_handling_separate_from_retry_logic(
        self, mock_auth_service: MagicMock
    ) -> None:
        """401 is handled separately from transient retry logic."""
        # 401 should not count against the MAX_RETRIES for transient errors
        mock_auth_service.refresh_token.return_value = MagicMock(
            access_token="new-token"
        )

        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            # 401 -> token refresh -> 503 (retry) -> 200 (success)
            route.side_effect = [
                httpx.Response(401, json={"error": "unauthorized"}),
                httpx.Response(503),
                httpx.Response(200, json={"value": []}),
            ]

            async with GraphClient(mock_auth_service) as client:
                response = await client._request("GET", f"{GRAPH_API_BASE}/users")

            assert response.status_code == 200
            mock_auth_service.refresh_token.assert_called_once()
            assert route.call_count == 3


class TestGraphClientGetPaginated:
    """Tests for GraphClient.get_paginated method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_single_page_yields_all_items(
        self, mock_auth_service: MagicMock
    ) -> None:
        """get_paginated yields all items from single page response."""
        response_data = {"value": [{"id": "user1"}, {"id": "user2"}, {"id": "user3"}]}

        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(200, json=response_data)
            )

            async with GraphClient(mock_auth_service) as client:
                items = [item async for item in client.get_paginated("/users")]

            assert len(items) == 3
            assert items[0] == {"id": "user1"}
            assert items[1] == {"id": "user2"}
            assert items[2] == {"id": "user3"}

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_multi_page_follows_nextlink(
        self, mock_auth_service: MagicMock
    ) -> None:
        """get_paginated follows @odata.nextLink for multiple pages."""
        next_link = f"{GRAPH_API_BASE}/users?$skip=2"
        page1_data = {
            "value": [{"id": "user1"}, {"id": "user2"}],
            "@odata.nextLink": next_link,
        }
        page2_data = {
            "value": [{"id": "user3"}, {"id": "user4"}],
        }

        with respx.mock:
            # Use url__eq for exact matching to avoid prefix matching issues
            respx.get(url__eq=f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(200, json=page1_data)
            )
            respx.get(url__eq=next_link).mock(
                return_value=httpx.Response(200, json=page2_data)
            )

            async with GraphClient(mock_auth_service) as client:
                items = [item async for item in client.get_paginated("/users")]

            assert len(items) == 4
            assert items[0] == {"id": "user1"}
            assert items[3] == {"id": "user4"}

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_max_items_stops_mid_page(self, mock_auth_service: MagicMock) -> None:
        """get_paginated stops yielding when max_items reached mid-page."""
        response_data = {"value": [{"id": "user1"}, {"id": "user2"}, {"id": "user3"}]}

        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(200, json=response_data)
            )

            async with GraphClient(mock_auth_service) as client:
                items = [
                    item async for item in client.get_paginated("/users", max_items=2)
                ]

            assert len(items) == 2
            assert items[0] == {"id": "user1"}
            assert items[1] == {"id": "user2"}

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_max_items_stops_before_fetching_unnecessary_page(
        self, mock_auth_service: MagicMock
    ) -> None:
        """get_paginated doesn't fetch next page if max_items reached."""
        next_link = f"{GRAPH_API_BASE}/users?$skip=2"
        page1_data = {
            "value": [{"id": "user1"}, {"id": "user2"}],
            "@odata.nextLink": next_link,
        }

        with respx.mock:
            route1 = respx.get(url__eq=f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(200, json=page1_data)
            )
            route2 = respx.get(url__eq=next_link).mock(
                return_value=httpx.Response(200, json={"value": [{"id": "user3"}]})
            )

            async with GraphClient(mock_auth_service) as client:
                items = [
                    item async for item in client.get_paginated("/users", max_items=2)
                ]

            assert len(items) == 2
            assert route1.call_count == 1
            assert route2.call_count == 0  # Second page not fetched

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_empty_value_yields_nothing(
        self, mock_auth_service: MagicMock
    ) -> None:
        """get_paginated handles empty value array gracefully."""
        response_data: dict[str, list[dict[str, str]]] = {"value": []}

        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(200, json=response_data)
            )

            async with GraphClient(mock_auth_service) as client:
                items = [item async for item in client.get_paginated("/users")]

            assert items == []

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_missing_value_raises_contract_error(
        self, mock_auth_service: MagicMock
    ) -> None:
        """get_paginated raises ContractError if 'value' key is missing."""
        response_data = {"data": [{"id": "user1"}]}  # Wrong key

        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(200, json=response_data)
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(ContractError) as exc_info:
                    async for _ in client.get_paginated("/users"):
                        pass

            assert "missing 'value' key" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_max_items_zero_returns_immediately(
        self, mock_auth_service: MagicMock
    ) -> None:
        """get_paginated with max_items=0 returns without making requests."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(200, json={"value": [{"id": "user1"}]})
            )

            async with GraphClient(mock_auth_service) as client:
                items = [
                    item async for item in client.get_paginated("/users", max_items=0)
                ]

            assert items == []
            assert route.call_count == 0

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_retry_logic_applies_to_paginated_request(
        self, mock_auth_service: MagicMock
    ) -> None:
        """get_paginated retries on transient failures."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.side_effect = [
                httpx.Response(429, headers={"Retry-After": "0"}),
                httpx.Response(200, json={"value": [{"id": "user1"}]}),
            ]

            async with GraphClient(mock_auth_service) as client:
                items = [item async for item in client.get_paginated("/users")]

            assert len(items) == 1
            assert route.call_count == 2

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_retry_on_second_page(self, mock_auth_service: MagicMock) -> None:
        """get_paginated retries transient failures on subsequent pages."""
        next_link = f"{GRAPH_API_BASE}/users?$skip=1"
        page1_data = {
            "value": [{"id": "user1"}],
            "@odata.nextLink": next_link,
        }

        with respx.mock:
            respx.get(url__eq=f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(200, json=page1_data)
            )
            route2 = respx.get(url__eq=next_link)
            route2.side_effect = [
                httpx.Response(503),
                httpx.Response(200, json={"value": [{"id": "user2"}]}),
            ]

            async with GraphClient(mock_auth_service) as client:
                items = [item async for item in client.get_paginated("/users")]

            assert len(items) == 2
            assert route2.call_count == 2

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_passes_params_to_initial_request(
        self, mock_auth_service: MagicMock
    ) -> None:
        """get_paginated passes query parameters to initial request."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(200, json={"value": []})
            )

            async with GraphClient(mock_auth_service) as client:
                items = [
                    item
                    async for item in client.get_paginated(
                        "/users", params={"$top": "10", "$filter": "active eq true"}
                    )
                ]

            assert items == []
            assert route.called
            request = route.calls[0].request
            assert "top=10" in str(request.url)
            assert "filter=active" in str(request.url)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_raises_not_found_on_404_response(
        self, mock_auth_service: MagicMock
    ) -> None:
        """get_paginated raises NotFoundError on 404 response."""
        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(
                    404,
                    json={
                        "error": {
                            "code": "ResourceNotFound",
                            "message": "Users not found",
                        }
                    },
                )
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(NotFoundError) as exc_info:
                    async for _ in client.get_paginated("/users"):
                        pass

            assert "ResourceNotFound" in str(exc_info.value)
            assert "Users not found" in str(exc_info.value)


class TestGraphClientHandleError:
    """Tests for GraphClient._handle_error method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_400_raises_validation_error(
        self, mock_auth_service: MagicMock
    ) -> None:
        """400 status code raises ValidationError."""
        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(
                    400,
                    json={"error": {"code": "BadRequest", "message": "Invalid filter"}},
                )
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(ValidationError) as exc_info:
                    await client.get("/users")

            assert "Bad request" in str(exc_info.value)
            assert "BadRequest" in str(exc_info.value)
            assert "Invalid filter" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_401_raises_authentication_error(
        self, mock_auth_service: MagicMock
    ) -> None:
        """401 status code raises AuthenticationError with remediation."""
        # Make refresh_token also fail to prevent retry
        mock_auth_service.refresh_token.side_effect = AuthenticationError(
            "Token refresh failed"
        )

        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(
                    401,
                    json={
                        "error": {
                            "code": "InvalidAuthenticationToken",
                            "message": "Token expired",
                        }
                    },
                )
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(AuthenticationError) as exc_info:
                    await client.get("/users")

            assert "teams-phone auth login" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_403_raises_authorization_error(
        self, mock_auth_service: MagicMock
    ) -> None:
        """403 status code raises AuthorizationError with remediation."""
        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(
                    403,
                    json={
                        "error": {
                            "code": "Authorization_RequestDenied",
                            "message": "Insufficient privileges",
                        }
                    },
                )
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(AuthorizationError) as exc_info:
                    await client.get("/users")

            assert "Access denied" in str(exc_info.value)
            assert "Authorization_RequestDenied" in str(exc_info.value)
            assert "Graph API permissions" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_404_raises_not_found_error(
        self, mock_auth_service: MagicMock
    ) -> None:
        """404 status code raises NotFoundError."""
        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users/123").mock(
                return_value=httpx.Response(
                    404,
                    json={
                        "error": {
                            "code": "Request_ResourceNotFound",
                            "message": "User '123' not found",
                        }
                    },
                )
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(NotFoundError) as exc_info:
                    await client.get("/users/123")

            assert "Resource not found" in str(exc_info.value)
            assert "User '123' not found" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_409_raises_validation_error(
        self, mock_auth_service: MagicMock
    ) -> None:
        """409 status code raises ValidationError for conflicts."""
        with respx.mock:
            respx.post(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(
                    409,
                    json={
                        "error": {"code": "Conflict", "message": "User already exists"}
                    },
                )
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(ValidationError) as exc_info:
                    await client.post("/users", {"displayName": "Test"})

            assert "Conflict" in str(exc_info.value)
            assert "User already exists" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_500_raises_teams_phone_error(
        self, mock_auth_service: MagicMock
    ) -> None:
        """500 status code raises TeamsPhoneError after retry exhaustion."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.side_effect = [
                httpx.Response(
                    500,
                    json={
                        "error": {
                            "code": "InternalServerError",
                            "message": "Server error",
                        }
                    },
                )
                for _ in range(MAX_RETRIES)
            ]

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(TeamsPhoneError) as exc_info:
                    await client.get("/users")

            # After retry exhaustion, _raise_for_exhausted_retries raises
            assert "HTTP 500" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_extracts_odata_error_code_and_message(
        self, mock_auth_service: MagicMock
    ) -> None:
        """Error message extraction includes OData code and message."""
        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(
                    400,
                    json={
                        "error": {
                            "code": "Request_BadRequest",
                            "message": "The query is invalid",
                        }
                    },
                )
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(ValidationError) as exc_info:
                    await client.get("/users")

            error_str = str(exc_info.value)
            assert "Request_BadRequest" in error_str
            assert "The query is invalid" in error_str

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_handles_non_json_error_response(
        self, mock_auth_service: MagicMock
    ) -> None:
        """Non-JSON error response uses response text."""
        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(
                    400,
                    content=b"Plain text error message",
                    headers={"Content-Type": "text/plain"},
                )
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(ValidationError) as exc_info:
                    await client.get("/users")

            assert "Plain text error message" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_handles_empty_error_response(
        self, mock_auth_service: MagicMock
    ) -> None:
        """Empty error response uses reason phrase."""
        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(400, content=b"")
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(ValidationError) as exc_info:
                    await client.get("/users")

            # Should contain "Bad request" from our exception message
            assert "Bad request" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_handles_malformed_json_error(
        self, mock_auth_service: MagicMock
    ) -> None:
        """Malformed JSON error falls back gracefully."""
        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(
                    403,
                    content=b"{invalid json",
                    headers={"Content-Type": "application/json"},
                )
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(AuthorizationError) as exc_info:
                    await client.get("/users")

            # Should still raise the correct exception type
            assert "Access denied" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_handles_error_without_code(
        self, mock_auth_service: MagicMock
    ) -> None:
        """Error response without code still extracts message."""
        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(
                    404, json={"error": {"message": "Not found"}}
                )
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(NotFoundError) as exc_info:
                    await client.get("/users")

            assert "Not found" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_unknown_4xx_raises_teams_phone_error(
        self, mock_auth_service: MagicMock
    ) -> None:
        """Unknown 4xx status codes raise TeamsPhoneError."""
        with respx.mock:
            respx.get(f"{GRAPH_API_BASE}/users").mock(
                return_value=httpx.Response(
                    418, json={"error": {"message": "I'm a teapot"}}
                )
            )

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(TeamsPhoneError) as exc_info:
                    await client.get("/users")

            assert "HTTP 418" in str(exc_info.value)
            assert "I'm a teapot" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_502_raises_after_retry_exhaustion(
        self, mock_auth_service: MagicMock
    ) -> None:
        """502 status raises TeamsPhoneError after retries exhausted."""
        with respx.mock:
            route = respx.get(f"{GRAPH_API_BASE}/users")
            route.side_effect = [httpx.Response(502) for _ in range(MAX_RETRIES)]

            async with GraphClient(mock_auth_service) as client:
                with pytest.raises(TeamsPhoneError) as exc_info:
                    await client.get("/users")

            assert "HTTP 502" in str(exc_info.value)
            assert route.call_count == MAX_RETRIES
