"""Unit tests for UserService."""

from typing import Any, AsyncIterator
from unittest.mock import AsyncMock, MagicMock

import pytest

# Import infrastructure first to avoid circular import
# (graph_client imports auth_service which imports infrastructure)
from teams_phone.infrastructure import GraphClient  # noqa: F401

from teams_phone.exceptions import NotFoundError, ValidationError
from teams_phone.models import AccountType, UserConfiguration
from teams_phone.services.user_service import UserService


def make_user_dict(
    user_id: str = "user-1",
    upn: str = "user1@contoso.com",
    tenant_id: str = "tenant-123",
    display_name: str = "Test User",
    account_type: str = "user",
    enterprise_voice: bool = True,
    phone_numbers: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
    """Create a user configuration dictionary for testing.

    Args:
        user_id: User's Entra object ID.
        upn: User principal name (email).
        tenant_id: Tenant ID.
        display_name: User's display name.
        account_type: Account type value.
        enterprise_voice: Whether enterprise voice is enabled.
        phone_numbers: List of phone number dicts.

    Returns:
        Dictionary matching Graph API user configuration response format.
    """
    return {
        "id": user_id,
        "userPrincipalName": upn,
        "tenantId": tenant_id,
        "displayName": display_name,
        "accountType": account_type,
        "isEnterpriseVoiceEnabled": enterprise_voice,
        "featureTypes": ["Teams"],
        "telephoneNumbers": phone_numbers or [],
        "effectivePolicyAssignments": [],
    }


class TestUserServiceInit:
    """Tests for UserService initialization."""

    @pytest.mark.unit
    def test_accepts_graph_client(self, mock_graph_client: MagicMock) -> None:
        """UserService accepts GraphClient in constructor."""
        service = UserService(mock_graph_client)

        assert service.graph_client is mock_graph_client


class TestUserServiceListUsers:
    """Tests for UserService.list_users method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_users_returns_empty_list(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_users returns empty list when no users exist."""

        async def mock_paginated(
            *args: Any, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            return
            yield  # pragma: no cover - makes this an async generator

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        users = await service.list_users()

        assert users == []

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_users_returns_user_configurations(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_users returns list of UserConfiguration objects."""
        user_data = [
            make_user_dict("user-1", "user1@contoso.com"),
            make_user_dict("user-2", "user2@contoso.com"),
        ]

        async def mock_paginated(
            *args: Any, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            for item in user_data:
                yield item

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        users = await service.list_users()

        assert len(users) == 2
        assert all(isinstance(u, UserConfiguration) for u in users)
        assert users[0].id == "user-1"
        assert users[1].id == "user-2"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_users_uses_correct_endpoint(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_users calls get_paginated with correct endpoint."""

        async def mock_paginated(
            endpoint: str, *args: Any, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            assert endpoint == "/admin/teams/userConfigurations"
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        await service.list_users()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_users_with_licensed_filter_true(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_users passes licensed=true filter."""
        captured_params: dict[str, Any] = {}

        async def mock_paginated(
            endpoint: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            captured_params.update(params or {})
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        await service.list_users(licensed=True)

        assert "$filter" in captured_params
        assert "isEnterpriseVoiceEnabled eq true" in captured_params["$filter"]

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_users_with_licensed_filter_false(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_users passes licensed=false filter."""
        captured_params: dict[str, Any] = {}

        async def mock_paginated(
            endpoint: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            captured_params.update(params or {})
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        await service.list_users(licensed=False)

        assert "$filter" in captured_params
        assert "isEnterpriseVoiceEnabled eq false" in captured_params["$filter"]

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_users_with_assigned_filter_true(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_users with assigned=True filters client-side to users with phone numbers."""

        async def mock_paginated(
            endpoint: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            # Return users with and without phone numbers
            yield {
                "id": "user-1",
                "userPrincipalName": "user1@test.com",
                "tenantId": "tenant-id",
                "accountType": "user",
                "isEnterpriseVoiceEnabled": True,
                "featureTypes": ["Teams"],
                "telephoneNumbers": [
                    {"telephoneNumber": "+15551234567", "assignmentCategory": "primary"}
                ],
                "effectivePolicyAssignments": [],
            }
            yield {
                "id": "user-2",
                "userPrincipalName": "user2@test.com",
                "tenantId": "tenant-id",
                "accountType": "user",
                "isEnterpriseVoiceEnabled": True,
                "featureTypes": ["Teams"],
                "telephoneNumbers": [],  # No phone number
                "effectivePolicyAssignments": [],
            }

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        users = await service.list_users(assigned=True)

        # Only user with phone number should be returned
        assert len(users) == 1
        assert users[0].id == "user-1"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_users_with_assigned_filter_false(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_users with assigned=False filters client-side to users without phone numbers."""

        async def mock_paginated(
            endpoint: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            # Return users with and without phone numbers
            yield {
                "id": "user-1",
                "userPrincipalName": "user1@test.com",
                "tenantId": "tenant-id",
                "accountType": "user",
                "isEnterpriseVoiceEnabled": True,
                "featureTypes": ["Teams"],
                "telephoneNumbers": [
                    {"telephoneNumber": "+15551234567", "assignmentCategory": "primary"}
                ],
                "effectivePolicyAssignments": [],
            }
            yield {
                "id": "user-2",
                "userPrincipalName": "user2@test.com",
                "tenantId": "tenant-id",
                "accountType": "user",
                "isEnterpriseVoiceEnabled": False,
                "featureTypes": ["Teams"],
                "telephoneNumbers": [],  # No phone number
                "effectivePolicyAssignments": [],
            }

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        users = await service.list_users(assigned=False)

        # Only user without phone number should be returned
        assert len(users) == 1
        assert users[0].id == "user-2"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_users_with_account_type_filter(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_users passes account_type filter."""
        captured_params: dict[str, Any] = {}

        async def mock_paginated(
            endpoint: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            captured_params.update(params or {})
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        await service.list_users(account_type=AccountType.RESOURCE_ACCOUNT)

        assert "$filter" in captured_params
        assert "accountType eq 'resourceAccount'" in captured_params["$filter"]

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_users_with_multiple_filters(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_users combines server-side filters with 'and', assigned is client-side."""
        captured_params: dict[str, Any] = {}

        async def mock_paginated(
            endpoint: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            captured_params.update(params or {})
            # Return a user that matches all criteria
            yield {
                "id": "user-1",
                "userPrincipalName": "user1@test.com",
                "tenantId": "tenant-id",
                "accountType": "user",
                "isEnterpriseVoiceEnabled": True,
                "featureTypes": ["Teams"],
                "telephoneNumbers": [
                    {"telephoneNumber": "+15551234567", "assignmentCategory": "primary"}
                ],
                "effectivePolicyAssignments": [],
            }

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        users = await service.list_users(
            licensed=True, assigned=True, account_type=AccountType.USER
        )

        # Server-side filters (licensed and account_type)
        filter_str = captured_params["$filter"]
        assert "isEnterpriseVoiceEnabled eq true" in filter_str
        assert "accountType eq 'user'" in filter_str
        assert " and " in filter_str
        # assigned is NOT in server filter (it's client-side)
        assert "telephoneNumbers" not in filter_str

        # Verify user is returned (client-side assigned filter passed)
        assert len(users) == 1

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_users_with_page_size(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_users passes page_size as $top parameter."""
        captured_params: dict[str, Any] = {}

        async def mock_paginated(
            endpoint: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            captured_params.update(params or {})
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        await service.list_users(page_size=50)

        assert captured_params["$top"] == "50"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_users_caps_page_size_at_999(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_users caps page_size at 999."""
        captured_params: dict[str, Any] = {}

        async def mock_paginated(
            endpoint: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            captured_params.update(params or {})
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        await service.list_users(page_size=2000)

        assert captured_params["$top"] == "999"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_users_with_max_items(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_users passes max_items to get_paginated."""
        captured_kwargs: dict[str, Any] = {}

        async def mock_paginated(
            endpoint: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            captured_kwargs.update(kwargs)
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        await service.list_users(max_items=10)

        assert captured_kwargs["max_items"] == 10


class TestUserServiceGetUser:
    """Tests for UserService.get_user method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_user_returns_user_configuration(
        self, mock_graph_client: MagicMock
    ) -> None:
        """get_user returns UserConfiguration object."""
        user_data = make_user_dict("user-123", "test@contoso.com")
        mock_graph_client.get = AsyncMock(return_value=user_data)

        service = UserService(mock_graph_client)
        user = await service.get_user("user-123")

        assert isinstance(user, UserConfiguration)
        assert user.id == "user-123"
        assert user.user_principal_name == "test@contoso.com"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_user_uses_correct_endpoint(
        self, mock_graph_client: MagicMock
    ) -> None:
        """get_user calls correct endpoint with user ID."""
        user_data = make_user_dict("user-abc")
        mock_graph_client.get = AsyncMock(return_value=user_data)

        service = UserService(mock_graph_client)
        await service.get_user("user-abc")

        mock_graph_client.get.assert_called_once_with(
            "/admin/teams/userConfigurations/user-abc"
        )

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_user_propagates_not_found_error(
        self, mock_graph_client: MagicMock
    ) -> None:
        """get_user propagates NotFoundError from GraphClient."""
        mock_graph_client.get = AsyncMock(side_effect=NotFoundError("User not found"))

        service = UserService(mock_graph_client)
        with pytest.raises(NotFoundError):
            await service.get_user("nonexistent-user")


class TestUserServiceSearchUsers:
    """Tests for UserService.search_users method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_by_phone_number_e164(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_users searches by phone number in E.164 format."""
        captured_params: dict[str, Any] = {}

        async def mock_paginated(
            endpoint: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            captured_params.update(params or {})
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        await service.search_users("+13235533696")

        assert "$filter" in captured_params
        assert (
            "telephoneNumbers/any(t: t/telephoneNumber eq '+13235533696')"
            in captured_params["$filter"]
        )

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_by_phone_number_digits_only(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_users normalizes digit-only phone numbers to E.164."""
        captured_params: dict[str, Any] = {}

        async def mock_paginated(
            endpoint: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            captured_params.update(params or {})
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        await service.search_users("13235533696")

        assert "$filter" in captured_params
        assert (
            "telephoneNumbers/any(t: t/telephoneNumber eq '+13235533696')"
            in captured_params["$filter"]
        )

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_by_upn(self, mock_graph_client: MagicMock) -> None:
        """search_users searches by UPN with exact match."""
        captured_params: dict[str, Any] = {}

        async def mock_paginated(
            endpoint: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            captured_params.update(params or {})
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        await service.search_users("user@contoso.com")

        assert "$filter" in captured_params
        assert "userPrincipalName eq 'user@contoso.com'" in captured_params["$filter"]

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_by_display_name(self, mock_graph_client: MagicMock) -> None:
        """search_users searches by display name via v1.0 /users endpoint."""
        captured_url: str = ""
        captured_params: dict[str, Any] = {}

        async def mock_paginated_url(
            url: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            nonlocal captured_url
            captured_url = url
            captured_params.update(params or {})
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated_url = mock_paginated_url

        service = UserService(mock_graph_client)
        await service.search_users("John")

        # Should use v1.0 /users endpoint for display name search
        assert "graph.microsoft.com/v1.0/users" in captured_url
        assert "$filter" in captured_params
        assert "startsWith(displayName, 'John')" in captured_params["$filter"]
        # Should request both id and displayName
        assert "$select" in captured_params
        assert "id" in captured_params["$select"]
        assert "displayName" in captured_params["$select"]

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_users_respects_max_results(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_users respects max_results parameter for display name search."""
        captured_params: dict[str, Any] = {}
        captured_kwargs: dict[str, Any] = {}

        async def mock_paginated_url(
            url: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            captured_params.update(params or {})
            captured_kwargs.update(kwargs)
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated_url = mock_paginated_url

        service = UserService(mock_graph_client)
        await service.search_users("John", max_results=5)

        assert captured_params["$top"] == "5"
        assert captured_kwargs["max_items"] == 5

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_users_returns_matching_users(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_users returns list of matching UserConfiguration objects."""
        # Directory users returned by v1.0 /users (has displayName)
        directory_users = [
            {"id": "user-1", "displayName": "John Doe"},
            {"id": "user-2", "displayName": "John Smith"},
        ]

        # User configurations from Teams API (displayName is None)
        user_configs = {
            "user-1": make_user_dict(
                "user-1", "john.doe@contoso.com", display_name=None
            ),
            "user-2": make_user_dict(
                "user-2", "john.smith@contoso.com", display_name=None
            ),
        }

        # Mock v1.0 /users search to return user IDs with displayName
        async def mock_paginated_url(
            url: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            for dir_user in directory_users:
                yield dir_user

        mock_graph_client.get_paginated_url = mock_paginated_url

        # Mock get_user to return configurations (without displayName)
        async def mock_get(endpoint: str) -> dict[str, Any]:
            user_id = endpoint.split("/")[-1]
            return user_configs[user_id]

        mock_graph_client.get = mock_get

        service = UserService(mock_graph_client)
        users = await service.search_users("John")

        assert len(users) == 2
        assert all(isinstance(u, UserConfiguration) for u in users)
        # displayName should be injected from directory search
        assert users[0].display_name == "John Doe"
        assert users[1].display_name == "John Smith"


class TestUserServiceResolveUser:
    """Tests for UserService.resolve_user method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_resolve_user_by_guid(self, mock_graph_client: MagicMock) -> None:
        """resolve_user looks up user by GUID directly."""
        user_data = make_user_dict(
            "12345678-1234-1234-1234-123456789abc",
            "test@contoso.com",
        )
        mock_graph_client.get = AsyncMock(return_value=user_data)

        service = UserService(mock_graph_client)
        user = await service.resolve_user("12345678-1234-1234-1234-123456789abc")

        assert user.id == "12345678-1234-1234-1234-123456789abc"
        mock_graph_client.get.assert_called_once_with(
            "/admin/teams/userConfigurations/12345678-1234-1234-1234-123456789abc"
        )

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_resolve_user_by_upn(self, mock_graph_client: MagicMock) -> None:
        """resolve_user searches by UPN when identifier contains @."""
        user_data = [make_user_dict("user-1", "test@contoso.com")]

        async def mock_paginated(
            *args: Any, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            for item in user_data:
                yield item

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        user = await service.resolve_user("test@contoso.com")

        assert user.user_principal_name == "test@contoso.com"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_resolve_user_by_upn_not_found(
        self, mock_graph_client: MagicMock
    ) -> None:
        """resolve_user raises NotFoundError when UPN not found."""

        async def mock_paginated(
            *args: Any, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated = mock_paginated

        service = UserService(mock_graph_client)
        with pytest.raises(NotFoundError) as exc_info:
            await service.resolve_user("nonexistent@contoso.com")

        assert "nonexistent@contoso.com" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_resolve_user_by_display_name_single_match(
        self, mock_graph_client: MagicMock
    ) -> None:
        """resolve_user returns user when single display name match."""
        # Teams API config doesn't have displayName
        user_config = make_user_dict("user-1", "john@contoso.com", display_name=None)

        # Mock v1.0 /users search to return one user with displayName
        async def mock_paginated_url(
            url: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            yield {"id": "user-1", "displayName": "John"}

        mock_graph_client.get_paginated_url = mock_paginated_url

        # Mock get_user to return the configuration (without displayName)
        async def mock_get(endpoint: str) -> dict[str, Any]:
            return user_config

        mock_graph_client.get = mock_get

        service = UserService(mock_graph_client)
        user = await service.resolve_user("John")

        # displayName should be injected from directory search
        assert user.display_name == "John"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_resolve_user_by_display_name_not_found(
        self, mock_graph_client: MagicMock
    ) -> None:
        """resolve_user raises NotFoundError when display name not found."""

        async def mock_paginated_url(
            url: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            return
            yield  # pragma: no cover

        mock_graph_client.get_paginated_url = mock_paginated_url

        service = UserService(mock_graph_client)
        with pytest.raises(NotFoundError) as exc_info:
            await service.resolve_user("Nonexistent")

        assert "Nonexistent" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_resolve_user_by_display_name_multiple_matches(
        self, mock_graph_client: MagicMock
    ) -> None:
        """resolve_user raises ValidationError when multiple display name matches."""
        # Directory users with displayName
        directory_users = [
            {"id": "user-1", "displayName": "John Doe"},
            {"id": "user-2", "displayName": "John Smith"},
        ]

        # Teams API configs don't have displayName
        user_configs = {
            "user-1": make_user_dict(
                "user-1", "john.doe@contoso.com", display_name=None
            ),
            "user-2": make_user_dict(
                "user-2", "john.smith@contoso.com", display_name=None
            ),
        }

        # Mock v1.0 /users search to return multiple users with displayName
        async def mock_paginated_url(
            url: str, *, params: dict[str, Any] | None = None, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            for dir_user in directory_users:
                yield dir_user

        mock_graph_client.get_paginated_url = mock_paginated_url

        # Mock get_user to return configurations (without displayName)
        async def mock_get(endpoint: str) -> dict[str, Any]:
            user_id = endpoint.split("/")[-1]
            return user_configs[user_id]

        mock_graph_client.get = mock_get

        service = UserService(mock_graph_client)
        with pytest.raises(ValidationError) as exc_info:
            await service.resolve_user("John")

        error_msg = str(exc_info.value)
        assert "Multiple users match" in error_msg
        # displayName should be injected from directory search
        assert "John Doe" in error_msg
        assert "John Smith" in error_msg

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_resolve_user_guid_not_found_propagates(
        self, mock_graph_client: MagicMock
    ) -> None:
        """resolve_user propagates NotFoundError for invalid GUID."""
        mock_graph_client.get = AsyncMock(side_effect=NotFoundError("User not found"))

        service = UserService(mock_graph_client)
        with pytest.raises(NotFoundError):
            await service.resolve_user("12345678-1234-1234-1234-123456789abc")
