"""Unit tests for NumberService."""

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

import pytest

# Import infrastructure first to avoid circular import
from teams_phone.infrastructure import GraphClient  # noqa: F401

from teams_phone.exceptions import NotFoundError, ValidationError
from teams_phone.models import (
    AssignmentStatus,
    AsyncOperation,
    NumberAssignment,
    NumberType,
    OperationStatus,
)
from teams_phone.services.number_service import NumberService


def make_operation_dict(
    operation_id: str = "op-123",
    status: str = "succeeded",
    created_date_time: str = "2025-02-03T22:03:26Z",
    numbers: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
    """Create an async operation dictionary for testing.

    Args:
        operation_id: Unique identifier for the operation.
        status: Operation status (notStarted, running, succeeded, failed, skipped).
        created_date_time: ISO 8601 timestamp of operation creation.
        numbers: List of number status dictionaries.

    Returns:
        Dictionary matching Graph API async operation response format.
    """
    if numbers is None:
        numbers = [
            {
                "resourceLocation": f"https://graph.microsoft.com/beta/.../numberAssignments/{operation_id}",
                "status": status,
            }
        ]
    return {
        "@odata.type": "#microsoft.graph.teamsAdministration.telephoneNumberLongRunningOperation",
        "id": operation_id,
        "createdDateTime": created_date_time,
        "status": status,
        "numbers": numbers,
    }


def make_number_dict(
    number_id: str = "num-1",
    telephone_number: str = "+14255551234",
    number_type: str = "directRouting",
    activation_state: str = "activated",
    assignment_status: str = "unassigned",
    city: str | None = "Seattle",
    location_id: str | None = None,
    assignment_target_id: str | None = None,
) -> dict[str, Any]:
    """Create a number assignment dictionary for testing.

    Args:
        number_id: Unique identifier for the number.
        telephone_number: Phone number in E.164 format.
        number_type: Type of phone number.
        activation_state: Activation state of the number.
        assignment_status: Assignment status.
        city: City associated with the number.
        location_id: Emergency location ID.
        assignment_target_id: Assigned user or resource ID.

    Returns:
        Dictionary matching Graph API number assignment response format.
    """
    return {
        "id": number_id,
        "telephoneNumber": telephone_number,
        "numberType": number_type,
        "activationState": activation_state,
        "assignmentStatus": assignment_status,
        "capabilities": ["userAssignment"],
        "city": city,
        "locationId": location_id,
        "assignmentTargetId": assignment_target_id,
    }


class TestNumberServiceInit:
    """Tests for NumberService initialization."""

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

        assert service.graph_client is mock_graph_client


class TestNumberServiceListNumbers:
    """Tests for NumberService.list_numbers method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_numbers_returns_empty_list(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_numbers returns empty list when no numbers 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 = NumberService(mock_graph_client)
        numbers = await service.list_numbers()

        assert numbers == []

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_numbers_returns_number_assignments(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_numbers returns list of NumberAssignment objects."""
        number_data = [
            make_number_dict("num-1", "+14255551001"),
            make_number_dict("num-2", "+14255551002"),
        ]

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        numbers = await service.list_numbers()

        assert len(numbers) == 2
        assert all(isinstance(n, NumberAssignment) for n in numbers)
        assert numbers[0].id == "num-1"
        assert numbers[1].id == "num-2"

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

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        await service.list_numbers()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_numbers_with_status_filter(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_numbers passes status filter in OData $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 = NumberService(mock_graph_client)
        await service.list_numbers(status=AssignmentStatus.UNASSIGNED)

        assert "$filter" in captured_params
        assert "assignmentStatus eq 'unassigned'" in captured_params["$filter"]

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_numbers_with_number_type_filter(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_numbers passes number_type filter in OData $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 = NumberService(mock_graph_client)
        await service.list_numbers(number_type=NumberType.DIRECT_ROUTING)

        assert "$filter" in captured_params
        assert "numberType eq 'directRouting'" in captured_params["$filter"]

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_numbers_with_city_filter(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_numbers applies city filter client-side."""
        number_data = [
            make_number_dict("num-1", "+14255551001", city="Seattle"),
            make_number_dict("num-2", "+14255551002", city="Redmond"),
            make_number_dict("num-3", "+14255551003", city="Seattle"),
        ]

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        numbers = await service.list_numbers(city="Seattle")

        assert len(numbers) == 2
        assert all(n.city == "Seattle" for n in numbers)
        assert numbers[0].id == "num-1"
        assert numbers[1].id == "num-3"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_numbers_with_city_filter_no_odata(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_numbers does not include city in OData $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 = NumberService(mock_graph_client)
        await service.list_numbers(city="Seattle")

        # City should NOT be in $filter - it's client-side only
        assert "$filter" not in captured_params

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_numbers_with_multiple_filters(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_numbers combines multiple filters with ' and '."""
        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 = NumberService(mock_graph_client)
        await service.list_numbers(
            status=AssignmentStatus.USER_ASSIGNED, number_type=NumberType.CALLING_PLAN
        )

        assert "$filter" in captured_params
        filter_str = captured_params["$filter"]
        assert "assignmentStatus eq 'userAssigned'" in filter_str
        assert "numberType eq 'callingPlan'" in filter_str
        assert " and " in filter_str

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_numbers_with_page_size(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_numbers 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 = NumberService(mock_graph_client)
        await service.list_numbers(page_size=50)

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

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_numbers_caps_page_size_at_999(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_numbers 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 = NumberService(mock_graph_client)
        await service.list_numbers(page_size=5000)

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

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_numbers_with_max_items(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_numbers 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 = NumberService(mock_graph_client)
        await service.list_numbers(max_items=25)

        assert captured_kwargs.get("max_items") == 25

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_list_numbers_default_page_size(
        self, mock_graph_client: MagicMock
    ) -> None:
        """list_numbers uses default page_size of 100."""
        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 = NumberService(mock_graph_client)
        await service.list_numbers()

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


class TestNumberServiceGetNumber:
    """Tests for NumberService.get_number method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_number_returns_number_assignment(
        self, mock_graph_client: MagicMock
    ) -> None:
        """get_number returns NumberAssignment object."""
        number_data = make_number_dict("num-1", "+12065551234")

        async def mock_paginated(
            *args: Any, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            yield number_data

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        result = await service.get_number("+12065551234")

        assert isinstance(result, NumberAssignment)
        assert result.id == "num-1"
        assert result.telephone_number == "+12065551234"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_number_normalizes_e164_input(
        self, mock_graph_client: MagicMock
    ) -> None:
        """get_number normalizes E.164 format input."""
        captured_params: dict[str, Any] = {}
        number_data = make_number_dict("num-1", "+12065551234")

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        await service.get_number("+12065551234")

        assert captured_params["$filter"] == "telephoneNumber eq '+12065551234'"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_number_normalizes_digits_only_input(
        self, mock_graph_client: MagicMock
    ) -> None:
        """get_number normalizes digits-only input to E.164 format."""
        captured_params: dict[str, Any] = {}
        number_data = make_number_dict("num-1", "+12065551234")

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        await service.get_number("12065551234")

        assert captured_params["$filter"] == "telephoneNumber eq '+12065551234'"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_number_normalizes_formatted_input(
        self, mock_graph_client: MagicMock
    ) -> None:
        """get_number normalizes formatted input to E.164 format."""
        captured_params: dict[str, Any] = {}
        number_data = make_number_dict("num-1", "+12065551234")

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        await service.get_number("+1-206-555-1234")

        assert captured_params["$filter"] == "telephoneNumber eq '+12065551234'"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_number_uses_correct_endpoint(
        self, mock_graph_client: MagicMock
    ) -> None:
        """get_number calls get_paginated with correct endpoint."""
        captured_endpoint: str = ""
        number_data = make_number_dict("num-1", "+12065551234")

        async def mock_paginated(
            endpoint: str, *args: Any, **kwargs: Any
        ) -> AsyncIterator[dict[str, Any]]:
            nonlocal captured_endpoint
            captured_endpoint = endpoint
            yield number_data

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        await service.get_number("+12065551234")

        assert (
            captured_endpoint
            == "/admin/teams/telephoneNumberManagement/numberAssignments"
        )

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_number_uses_correct_filter(
        self, mock_graph_client: MagicMock
    ) -> None:
        """get_number uses telephoneNumber eq filter."""
        captured_params: dict[str, Any] = {}
        number_data = make_number_dict("num-1", "+14255559999")

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        await service.get_number("+14255559999")

        assert "$filter" in captured_params
        assert captured_params["$filter"] == "telephoneNumber eq '+14255559999'"

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

        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 = NumberService(mock_graph_client)
        with pytest.raises(NotFoundError) as exc_info:
            await service.get_number("+12065559999")

        assert "+12065559999" in str(exc_info.value)
        assert exc_info.value.remediation is not None
        assert "teams-phone numbers list" in exc_info.value.remediation

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_number_passes_max_items_one(
        self, mock_graph_client: MagicMock
    ) -> None:
        """get_number passes max_items=1 to get_paginated."""
        captured_kwargs: dict[str, Any] = {}
        number_data = make_number_dict("num-1", "+12065551234")

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        await service.get_number("+12065551234")

        assert captured_kwargs.get("max_items") == 1


class TestNumberServiceSearchNumbers:
    """Tests for NumberService.search_numbers method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_numbers_prefix_pattern(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_numbers matches prefix patterns (206*)."""
        number_data = [
            make_number_dict("num-1", "+12065551001"),
            make_number_dict("num-2", "+14255551002"),
            make_number_dict("num-3", "+12065551003"),
        ]

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        results = await service.search_numbers("206*")

        assert len(results) == 2
        assert all("206" in r.telephone_number for r in results)
        assert results[0].id == "num-1"
        assert results[1].id == "num-3"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_numbers_suffix_pattern(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_numbers matches suffix patterns (*1234)."""
        number_data = [
            make_number_dict("num-1", "+12065551234"),
            make_number_dict("num-2", "+14255555678"),
            make_number_dict("num-3", "+13035551234"),
        ]

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        results = await service.search_numbers("*1234")

        assert len(results) == 2
        assert all(r.telephone_number.endswith("1234") for r in results)
        assert results[0].id == "num-1"
        assert results[1].id == "num-3"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_numbers_contains_pattern(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_numbers matches contains patterns (*555*)."""
        number_data = [
            make_number_dict("num-1", "+12065551234"),
            make_number_dict("num-2", "+14256661002"),
            make_number_dict("num-3", "+13035559999"),
        ]

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        results = await service.search_numbers("*555*")

        assert len(results) == 2
        assert all("555" in r.telephone_number for r in results)
        assert results[0].id == "num-1"
        assert results[1].id == "num-3"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_numbers_exact_substring(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_numbers matches exact substring patterns (no wildcards)."""
        number_data = [
            make_number_dict("num-1", "+12065551234"),
            make_number_dict("num-2", "+14255551002"),
            make_number_dict("num-3", "+13036661234"),
        ]

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        results = await service.search_numbers("1234")

        assert len(results) == 2
        assert results[0].id == "num-1"
        assert results[1].id == "num-3"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_numbers_empty_pattern_returns_empty(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_numbers returns empty list for empty pattern."""
        number_data = [
            make_number_dict("num-1", "+12065551234"),
        ]

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        results = await service.search_numbers("")

        assert results == []

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_numbers_no_matches(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_numbers returns empty list when no numbers match."""
        number_data = [
            make_number_dict("num-1", "+12065551234"),
            make_number_dict("num-2", "+14255551002"),
        ]

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        results = await service.search_numbers("999*")

        assert results == []

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_numbers_max_results_limits_output(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_numbers respects max_results parameter."""
        number_data = [
            make_number_dict("num-1", "+12065551001"),
            make_number_dict("num-2", "+12065551002"),
            make_number_dict("num-3", "+12065551003"),
            make_number_dict("num-4", "+12065551004"),
            make_number_dict("num-5", "+12065551005"),
        ]

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        results = await service.search_numbers("206*", max_results=3)

        assert len(results) == 3
        assert results[0].id == "num-1"
        assert results[1].id == "num-2"
        assert results[2].id == "num-3"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_numbers_default_max_results(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_numbers uses default max_results of 100."""
        # Create 150 numbers
        number_data = [
            make_number_dict(f"num-{i}", f"+1206555{1000 + i}") for i in range(150)
        ]

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        results = await service.search_numbers("206*")

        assert len(results) == 100

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_numbers_wildcard_only_matches_all(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_numbers with '*' pattern matches all numbers."""
        number_data = [
            make_number_dict("num-1", "+12065551001"),
            make_number_dict("num-2", "+14255551002"),
            make_number_dict("num-3", "+13035551003"),
        ]

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        results = await service.search_numbers("*")

        assert len(results) == 3

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_numbers_returns_number_assignment_objects(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_numbers returns list of NumberAssignment objects."""
        number_data = [
            make_number_dict("num-1", "+12065551234"),
        ]

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        results = await service.search_numbers("206*")

        assert len(results) == 1
        assert isinstance(results[0], NumberAssignment)
        assert results[0].id == "num-1"
        assert results[0].telephone_number == "+12065551234"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_numbers_empty_inventory(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_numbers returns empty list when inventory is empty."""

        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 = NumberService(mock_graph_client)
        results = await service.search_numbers("206*")

        assert results == []

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_search_numbers_calls_list_numbers(
        self, mock_graph_client: MagicMock
    ) -> None:
        """search_numbers fetches numbers via list_numbers."""
        captured_endpoint: str = ""

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

        mock_graph_client.get_paginated = mock_paginated

        service = NumberService(mock_graph_client)
        await service.search_numbers("206*")

        assert (
            captured_endpoint
            == "/admin/teams/telephoneNumberManagement/numberAssignments"
        )


class TestNumberServicePollOperation:
    """Tests for NumberService.poll_operation method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_poll_operation_returns_on_success(
        self, mock_graph_client: MagicMock
    ) -> None:
        """poll_operation returns AsyncOperation when status is succeeded."""
        from unittest.mock import AsyncMock

        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="succeeded")
        )

        service = NumberService(mock_graph_client)
        result = await service.poll_operation("op-123")

        assert isinstance(result, AsyncOperation)
        assert result.status == OperationStatus.SUCCEEDED
        assert result.id == "op-123"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_poll_operation_returns_on_failed(
        self, mock_graph_client: MagicMock
    ) -> None:
        """poll_operation returns AsyncOperation when status is failed."""
        from unittest.mock import AsyncMock

        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="failed")
        )

        service = NumberService(mock_graph_client)
        result = await service.poll_operation("op-123")

        assert isinstance(result, AsyncOperation)
        assert result.status == OperationStatus.FAILED

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_poll_operation_returns_on_skipped(
        self, mock_graph_client: MagicMock
    ) -> None:
        """poll_operation returns AsyncOperation when status is skipped."""
        from unittest.mock import AsyncMock

        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="skipped")
        )

        service = NumberService(mock_graph_client)
        result = await service.poll_operation("op-123")

        assert isinstance(result, AsyncOperation)
        assert result.status == OperationStatus.SKIPPED

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_poll_operation_polls_until_success(
        self, mock_graph_client: MagicMock
    ) -> None:
        """poll_operation polls multiple times until terminal state."""
        from unittest.mock import AsyncMock, patch

        responses = [
            make_operation_dict(status="notStarted"),
            make_operation_dict(status="running"),
            make_operation_dict(status="succeeded"),
        ]
        mock_graph_client.get = AsyncMock(side_effect=responses)

        service = NumberService(mock_graph_client)

        # Patch asyncio.sleep to avoid actual delays
        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            result = await service.poll_operation("op-123")

        assert result.status == OperationStatus.SUCCEEDED
        assert mock_graph_client.get.call_count == 3

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_poll_operation_uses_correct_endpoint(
        self, mock_graph_client: MagicMock
    ) -> None:
        """poll_operation calls get with correct endpoint."""
        from unittest.mock import AsyncMock

        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="succeeded")
        )

        service = NumberService(mock_graph_client)
        await service.poll_operation("my-operation-id")

        mock_graph_client.get.assert_called_once_with(
            "/admin/teams/telephoneNumberManagement/operations/my-operation-id"
        )

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_poll_operation_timeout_raises_error(
        self, mock_graph_client: MagicMock
    ) -> None:
        """poll_operation raises TimeoutError when timeout exceeded."""
        from unittest.mock import AsyncMock, patch

        # Always return running status
        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="running")
        )

        service = NumberService(mock_graph_client)

        # Mock time.time() to simulate timeout
        start_time = 1000.0
        call_count = 0

        def mock_time() -> float:
            nonlocal call_count
            call_count += 1
            # First call: start time, subsequent calls: beyond timeout
            if call_count == 1:
                return start_time
            return start_time + 61  # Beyond 60s timeout

        with (
            patch(
                "teams_phone.services.number_service.time.time", side_effect=mock_time
            ),
            patch("teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()),
        ):
            with pytest.raises(TimeoutError) as exc_info:
                await service.poll_operation("op-123", timeout=60)

        assert "op-123" in str(exc_info.value)
        assert "60s" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_poll_operation_invokes_progress_callback(
        self, mock_graph_client: MagicMock
    ) -> None:
        """poll_operation invokes on_progress callback on each iteration."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        responses = [
            make_operation_dict(status="running"),
            make_operation_dict(status="succeeded"),
        ]
        mock_graph_client.get = AsyncMock(side_effect=responses)
        progress_callback = SyncMock()

        service = NumberService(mock_graph_client)

        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            await service.poll_operation("op-123", on_progress=progress_callback)

        assert progress_callback.call_count == 2
        # First call with running status
        first_call_arg = progress_callback.call_args_list[0][0][0]
        assert isinstance(first_call_arg, AsyncOperation)
        assert first_call_arg.status == OperationStatus.RUNNING
        # Second call with succeeded status
        second_call_arg = progress_callback.call_args_list[1][0][0]
        assert second_call_arg.status == OperationStatus.SUCCEEDED

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_poll_operation_no_callback_when_none(
        self, mock_graph_client: MagicMock
    ) -> None:
        """poll_operation works correctly when on_progress is None."""
        from unittest.mock import AsyncMock

        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="succeeded")
        )

        service = NumberService(mock_graph_client)
        # Should not raise any errors
        result = await service.poll_operation("op-123", on_progress=None)

        assert result.status == OperationStatus.SUCCEEDED

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_poll_operation_custom_timeout(
        self, mock_graph_client: MagicMock
    ) -> None:
        """poll_operation respects custom timeout parameter."""
        from unittest.mock import AsyncMock, patch

        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="running")
        )

        service = NumberService(mock_graph_client)

        start_time = 1000.0
        call_count = 0

        def mock_time() -> float:
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return start_time
            return start_time + 11  # Beyond 10s custom timeout

        with (
            patch(
                "teams_phone.services.number_service.time.time", side_effect=mock_time
            ),
            patch("teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()),
        ):
            with pytest.raises(TimeoutError) as exc_info:
                await service.poll_operation("op-123", timeout=10)

        assert "10s" in str(exc_info.value)


class TestNumberServiceExtractOperationId:
    """Tests for NumberService._extract_operation_id method."""

    @pytest.mark.unit
    def test_extracts_id_from_full_url(self, mock_graph_client: MagicMock) -> None:
        """_extract_operation_id extracts ID from full Graph API URL."""
        service = NumberService(mock_graph_client)
        header = (
            "https://graph.microsoft.com/beta/admin/teams/"
            "telephoneNumberManagement/operations('op-123-abc')"
        )

        result = service._extract_operation_id(header)

        assert result == "op-123-abc"

    @pytest.mark.unit
    def test_extracts_id_from_base64_encoded_id(
        self, mock_graph_client: MagicMock
    ) -> None:
        """_extract_operation_id extracts base64-encoded operation ID."""
        service = NumberService(mock_graph_client)
        # Example from API docs - base64 encoded operation ID
        header = (
            "https://graph.microsoft.com/beta/admin/teams/"
            "telephoneNumberManagement/operations('QXNzaWdubWVudHw2Y2E4Yjc0Ni0=')"
        )

        result = service._extract_operation_id(header)

        assert result == "QXNzaWdubWVudHw2Y2E4Yjc0Ni0="

    @pytest.mark.unit
    def test_extracts_id_from_minimal_path(self, mock_graph_client: MagicMock) -> None:
        """_extract_operation_id extracts ID from minimal path."""
        service = NumberService(mock_graph_client)
        header = "operations('simple-id')"

        result = service._extract_operation_id(header)

        assert result == "simple-id"

    @pytest.mark.unit
    def test_extracts_id_with_query_params(self, mock_graph_client: MagicMock) -> None:
        """_extract_operation_id extracts ID when URL has query parameters."""
        service = NumberService(mock_graph_client)
        header = (
            "https://graph.microsoft.com/beta/admin/teams/"
            "telephoneNumberManagement/operations('op-123')?api-version=beta"
        )

        result = service._extract_operation_id(header)

        assert result == "op-123"

    @pytest.mark.unit
    def test_extracts_id_with_special_characters(
        self, mock_graph_client: MagicMock
    ) -> None:
        """_extract_operation_id handles IDs with special characters."""
        service = NumberService(mock_graph_client)
        # Operation IDs may contain pipes, equals, dashes
        header = "operations('Assignment|6ca8b746-1234-5678|abc=')"

        result = service._extract_operation_id(header)

        assert result == "Assignment|6ca8b746-1234-5678|abc="

    @pytest.mark.unit
    def test_raises_validation_error_for_empty_string(
        self, mock_graph_client: MagicMock
    ) -> None:
        """_extract_operation_id raises ValidationError for empty string."""
        service = NumberService(mock_graph_client)

        with pytest.raises(ValidationError) as exc_info:
            service._extract_operation_id("")

        assert "Invalid Location header format" in str(exc_info.value)
        assert exc_info.value.remediation is not None
        assert "operations('{operation_id}')" in exc_info.value.remediation

    @pytest.mark.unit
    def test_raises_validation_error_for_missing_quotes(
        self, mock_graph_client: MagicMock
    ) -> None:
        """_extract_operation_id raises ValidationError when quotes are missing."""
        service = NumberService(mock_graph_client)
        # Wrong format - no quotes around ID
        header = "operations(op-123)"

        with pytest.raises(ValidationError) as exc_info:
            service._extract_operation_id(header)

        assert "Invalid Location header format" in str(exc_info.value)

    @pytest.mark.unit
    def test_raises_validation_error_for_wrong_parentheses(
        self, mock_graph_client: MagicMock
    ) -> None:
        """_extract_operation_id raises ValidationError for wrong format."""
        service = NumberService(mock_graph_client)
        # Wrong format - uses brackets instead of parentheses
        header = "operations['op-123']"

        with pytest.raises(ValidationError) as exc_info:
            service._extract_operation_id(header)

        assert "Invalid Location header format" in str(exc_info.value)

    @pytest.mark.unit
    def test_raises_validation_error_for_no_operations_keyword(
        self, mock_graph_client: MagicMock
    ) -> None:
        """_extract_operation_id raises ValidationError when operations keyword missing."""
        service = NumberService(mock_graph_client)
        header = "https://graph.microsoft.com/beta/some/other/path"

        with pytest.raises(ValidationError) as exc_info:
            service._extract_operation_id(header)

        assert "Invalid Location header format" in str(exc_info.value)

    @pytest.mark.unit
    def test_raises_validation_error_for_empty_id(
        self, mock_graph_client: MagicMock
    ) -> None:
        """_extract_operation_id raises ValidationError when ID is empty."""
        service = NumberService(mock_graph_client)
        # Pattern requires at least one character between quotes
        header = "operations('')"

        with pytest.raises(ValidationError) as exc_info:
            service._extract_operation_id(header)

        assert "Invalid Location header format" in str(exc_info.value)

    @pytest.mark.unit
    def test_validation_error_includes_header_in_message(
        self, mock_graph_client: MagicMock
    ) -> None:
        """_extract_operation_id includes the malformed header in error message."""
        service = NumberService(mock_graph_client)
        bad_header = "some/bad/path"

        with pytest.raises(ValidationError) as exc_info:
            service._extract_operation_id(bad_header)

        assert bad_header in str(exc_info.value)

    @pytest.mark.unit
    def test_validation_error_suggests_api_change(
        self, mock_graph_client: MagicMock
    ) -> None:
        """_extract_operation_id remediation suggests API format change."""
        service = NumberService(mock_graph_client)

        with pytest.raises(ValidationError) as exc_info:
            service._extract_operation_id("invalid")

        assert exc_info.value.remediation is not None
        assert "API response format change" in exc_info.value.remediation


class TestNumberServiceAssignNumber:
    """Tests for NumberService.assign_number method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_calling_plan_without_location_raises_validation_error(
        self, mock_graph_client: MagicMock
    ) -> None:
        """assign_number raises ValidationError for CallingPlan without location_id."""
        service = NumberService(mock_graph_client)

        with pytest.raises(ValidationError) as exc_info:
            await service.assign_number(
                user_id="user-123",
                phone_number="+12065551234",
                number_type=NumberType.CALLING_PLAN,
            )

        assert "Emergency location required" in str(exc_info.value)
        assert "+12065551234" in str(exc_info.value)
        assert exc_info.value.remediation is not None
        assert "--location" in exc_info.value.remediation

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_calling_plan_with_location_succeeds(
        self, mock_graph_client: MagicMock
    ) -> None:
        """assign_number succeeds for CallingPlan when location_id provided."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        # Mock response with Location header
        mock_response = SyncMock()
        mock_response.headers = {
            "location": "https://graph.microsoft.com/beta/admin/teams/"
            "telephoneNumberManagement/operations('op-123')"
        }
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="succeeded")
        )

        service = NumberService(mock_graph_client)

        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            result = await service.assign_number(
                user_id="user-123",
                phone_number="+12065551234",
                number_type=NumberType.CALLING_PLAN,
                location_id="loc-456",
            )

        assert result.status == OperationStatus.SUCCEEDED
        # Verify POST was called with correct body
        call_args = mock_graph_client.post_with_response.call_args
        body = call_args[0][1]
        assert body["telephoneNumber"] == "12065551234"  # Digits only
        assert body["numberType"] == "callingPlan"
        assert body["assignmentTargetId"] == "user-123"
        assert body["locationId"] == "loc-456"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_direct_routing_without_location_succeeds(
        self, mock_graph_client: MagicMock
    ) -> None:
        """assign_number succeeds for DirectRouting without location_id."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-123')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="succeeded")
        )

        service = NumberService(mock_graph_client)

        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            result = await service.assign_number(
                user_id="user-123",
                phone_number="+12065551234",
                number_type=NumberType.DIRECT_ROUTING,
            )

        assert result.status == OperationStatus.SUCCEEDED
        # Verify locationId was not included in body
        call_args = mock_graph_client.post_with_response.call_args
        body = call_args[0][1]
        assert "locationId" not in body

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_operator_connect_without_location_succeeds(
        self, mock_graph_client: MagicMock
    ) -> None:
        """assign_number succeeds for OperatorConnect without location_id."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-123')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="succeeded")
        )

        service = NumberService(mock_graph_client)

        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            result = await service.assign_number(
                user_id="user-123",
                phone_number="+12065551234",
                number_type=NumberType.OPERATOR_CONNECT,
            )

        assert result.status == OperationStatus.SUCCEEDED

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_phone_number_normalized_via_format_for_assign(
        self, mock_graph_client: MagicMock
    ) -> None:
        """assign_number normalizes phone number using format_for_assign."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-123')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="succeeded")
        )

        service = NumberService(mock_graph_client)

        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            await service.assign_number(
                user_id="user-123",
                phone_number="+1 (206) 555-1234",  # Formatted input
                number_type=NumberType.DIRECT_ROUTING,
            )

        # Verify POST body has digits-only phone number
        call_args = mock_graph_client.post_with_response.call_args
        body = call_args[0][1]
        assert body["telephoneNumber"] == "12065551234"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_request_body_includes_all_required_fields(
        self, mock_graph_client: MagicMock
    ) -> None:
        """assign_number constructs correct request body."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-123')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="succeeded")
        )

        service = NumberService(mock_graph_client)

        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            await service.assign_number(
                user_id="user-abc-123",
                phone_number="+14255551234",
                number_type=NumberType.DIRECT_ROUTING,
                location_id="loc-xyz",
            )

        # Verify endpoint
        call_args = mock_graph_client.post_with_response.call_args
        endpoint = call_args[0][0]
        assert (
            endpoint
            == "/admin/teams/telephoneNumberManagement/numberAssignments/assignNumber"
        )

        # Verify body
        body = call_args[0][1]
        assert body["telephoneNumber"] == "14255551234"
        assert body["numberType"] == "directRouting"
        assert body["assignmentTargetId"] == "user-abc-123"
        assert body["locationId"] == "loc-xyz"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_extracts_operation_id_from_location_header(
        self, mock_graph_client: MagicMock
    ) -> None:
        """assign_number extracts operation ID from 202 Location header."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        mock_response = SyncMock()
        mock_response.headers = {
            "location": "https://graph.microsoft.com/beta/admin/teams/"
            "telephoneNumberManagement/operations('my-operation-id-abc')"
        }
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(
                operation_id="my-operation-id-abc", status="succeeded"
            )
        )

        service = NumberService(mock_graph_client)

        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            result = await service.assign_number(
                user_id="user-123",
                phone_number="+12065551234",
                number_type=NumberType.DIRECT_ROUTING,
            )

        # poll_operation should have been called with extracted ID
        mock_graph_client.get.assert_called_once_with(
            "/admin/teams/telephoneNumberManagement/operations/my-operation-id-abc"
        )
        assert result.id == "my-operation-id-abc"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_wait_true_polls_for_completion(
        self, mock_graph_client: MagicMock
    ) -> None:
        """assign_number with wait=True polls until operation completes."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-123')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)

        # Poll returns running, then succeeded
        responses = [
            make_operation_dict(status="running"),
            make_operation_dict(status="succeeded"),
        ]
        mock_graph_client.get = AsyncMock(side_effect=responses)

        service = NumberService(mock_graph_client)

        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            result = await service.assign_number(
                user_id="user-123",
                phone_number="+12065551234",
                number_type=NumberType.DIRECT_ROUTING,
                wait=True,
            )

        assert result.status == OperationStatus.SUCCEEDED
        assert mock_graph_client.get.call_count == 2

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_wait_false_returns_immediately(
        self, mock_graph_client: MagicMock
    ) -> None:
        """assign_number with wait=False returns AsyncOperation immediately."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-immediate')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)

        service = NumberService(mock_graph_client)

        result = await service.assign_number(
            user_id="user-123",
            phone_number="+12065551234",
            number_type=NumberType.DIRECT_ROUTING,
            wait=False,
        )

        assert isinstance(result, AsyncOperation)
        assert result.id == "op-immediate"
        assert result.status == OperationStatus.NOT_STARTED
        assert result.numbers == []
        # graph_client.get should NOT have been called (no polling)
        mock_graph_client.get.assert_not_called()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_wait_false_includes_created_datetime(
        self, mock_graph_client: MagicMock
    ) -> None:
        """assign_number with wait=False sets created_date_time."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock
        from datetime import datetime, timezone

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-123')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)

        service = NumberService(mock_graph_client)

        before = datetime.now(timezone.utc)
        result = await service.assign_number(
            user_id="user-123",
            phone_number="+12065551234",
            number_type=NumberType.DIRECT_ROUTING,
            wait=False,
        )
        after = datetime.now(timezone.utc)

        assert before <= result.created_date_time <= after

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_timeout_passed_to_poll_operation(
        self, mock_graph_client: MagicMock
    ) -> None:
        """assign_number passes timeout parameter to poll_operation."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-123')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)

        # Always return running - we'll verify timeout is passed
        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="running")
        )

        service = NumberService(mock_graph_client)

        start_time = 1000.0
        call_count = 0

        def mock_time() -> float:
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return start_time
            return start_time + 31  # Beyond 30s custom timeout

        with (
            patch(
                "teams_phone.services.number_service.time.time", side_effect=mock_time
            ),
            patch("teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()),
        ):
            with pytest.raises(TimeoutError) as exc_info:
                await service.assign_number(
                    user_id="user-123",
                    phone_number="+12065551234",
                    number_type=NumberType.DIRECT_ROUTING,
                    timeout=30,
                )

        assert "30s" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_invalid_location_header_raises_validation_error(
        self, mock_graph_client: MagicMock
    ) -> None:
        """assign_number raises ValidationError for invalid Location header."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock

        mock_response = SyncMock()
        mock_response.headers = {"location": "invalid-header-format"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)

        service = NumberService(mock_graph_client)

        with pytest.raises(ValidationError) as exc_info:
            await service.assign_number(
                user_id="user-123",
                phone_number="+12065551234",
                number_type=NumberType.DIRECT_ROUTING,
            )

        assert "Invalid Location header format" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_missing_location_header_raises_validation_error(
        self, mock_graph_client: MagicMock
    ) -> None:
        """assign_number raises ValidationError when Location header is missing."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock

        mock_response = SyncMock()
        mock_response.headers = {}  # No Location header
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)

        service = NumberService(mock_graph_client)

        with pytest.raises(ValidationError) as exc_info:
            await service.assign_number(
                user_id="user-123",
                phone_number="+12065551234",
                number_type=NumberType.DIRECT_ROUTING,
            )

        assert "Invalid Location header format" in str(exc_info.value)


class TestNumberServiceUnassignNumber:
    """Tests for NumberService.unassign_number method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_successful_unassign_with_wait(
        self, mock_graph_client: MagicMock
    ) -> None:
        """unassign_number successfully unassigns number with wait=True."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        mock_response = SyncMock()
        mock_response.headers = {
            "location": "https://graph.microsoft.com/beta/admin/teams/"
            "telephoneNumberManagement/operations('unassign-op-123')"
        }
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(
                operation_id="unassign-op-123", status="succeeded"
            )
        )

        service = NumberService(mock_graph_client)

        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            result = await service.unassign_number(
                phone_number="+12065551234",
                number_type=NumberType.DIRECT_ROUTING,
            )

        assert result.status == OperationStatus.SUCCEEDED
        assert result.id == "unassign-op-123"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_request_body_contains_only_required_fields(
        self, mock_graph_client: MagicMock
    ) -> None:
        """unassign_number request body contains only telephoneNumber and numberType."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-123')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="succeeded")
        )

        service = NumberService(mock_graph_client)

        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            await service.unassign_number(
                phone_number="+12065551234",
                number_type=NumberType.CALLING_PLAN,
            )

        # Verify endpoint
        call_args = mock_graph_client.post_with_response.call_args
        endpoint = call_args[0][0]
        assert (
            endpoint
            == "/admin/teams/telephoneNumberManagement/numberAssignments/unassignNumber"
        )

        # Verify body has ONLY telephoneNumber and numberType (no userId, no locationId)
        body = call_args[0][1]
        assert body == {
            "telephoneNumber": "12065551234",
            "numberType": "callingPlan",
        }
        assert "assignmentTargetId" not in body
        assert "locationId" not in body

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_phone_number_normalized_via_format_for_assign(
        self, mock_graph_client: MagicMock
    ) -> None:
        """unassign_number normalizes phone number using format_for_assign."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-123')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="succeeded")
        )

        service = NumberService(mock_graph_client)

        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            await service.unassign_number(
                phone_number="+1 (206) 555-1234",  # Formatted input
                number_type=NumberType.DIRECT_ROUTING,
            )

        # Verify POST body has digits-only phone number
        call_args = mock_graph_client.post_with_response.call_args
        body = call_args[0][1]
        assert body["telephoneNumber"] == "12065551234"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_wait_true_polls_for_completion(
        self, mock_graph_client: MagicMock
    ) -> None:
        """unassign_number with wait=True polls until operation completes."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-123')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)

        # Poll returns running, then succeeded
        responses = [
            make_operation_dict(status="running"),
            make_operation_dict(status="succeeded"),
        ]
        mock_graph_client.get = AsyncMock(side_effect=responses)

        service = NumberService(mock_graph_client)

        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            result = await service.unassign_number(
                phone_number="+12065551234",
                number_type=NumberType.DIRECT_ROUTING,
                wait=True,
            )

        assert result.status == OperationStatus.SUCCEEDED
        assert mock_graph_client.get.call_count == 2

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_wait_false_returns_immediately(
        self, mock_graph_client: MagicMock
    ) -> None:
        """unassign_number with wait=False returns AsyncOperation immediately."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-immediate')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)

        service = NumberService(mock_graph_client)

        result = await service.unassign_number(
            phone_number="+12065551234",
            number_type=NumberType.DIRECT_ROUTING,
            wait=False,
        )

        assert isinstance(result, AsyncOperation)
        assert result.id == "op-immediate"
        assert result.status == OperationStatus.NOT_STARTED
        assert result.numbers == []
        # graph_client.get should NOT have been called (no polling)
        mock_graph_client.get.assert_not_called()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_wait_false_includes_created_datetime(
        self, mock_graph_client: MagicMock
    ) -> None:
        """unassign_number with wait=False sets created_date_time."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock
        from datetime import datetime, timezone

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-123')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)

        service = NumberService(mock_graph_client)

        before = datetime.now(timezone.utc)
        result = await service.unassign_number(
            phone_number="+12065551234",
            number_type=NumberType.DIRECT_ROUTING,
            wait=False,
        )
        after = datetime.now(timezone.utc)

        assert before <= result.created_date_time <= after

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_timeout_passed_to_poll_operation(
        self, mock_graph_client: MagicMock
    ) -> None:
        """unassign_number passes timeout parameter to poll_operation."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-123')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)

        # Always return running - we'll verify timeout is passed
        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="running")
        )

        service = NumberService(mock_graph_client)

        start_time = 1000.0
        call_count = 0

        def mock_time() -> float:
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return start_time
            return start_time + 31  # Beyond 30s custom timeout

        with (
            patch(
                "teams_phone.services.number_service.time.time", side_effect=mock_time
            ),
            patch("teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()),
        ):
            with pytest.raises(TimeoutError) as exc_info:
                await service.unassign_number(
                    phone_number="+12065551234",
                    number_type=NumberType.DIRECT_ROUTING,
                    timeout=30,
                )

        assert "30s" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_invalid_location_header_raises_validation_error(
        self, mock_graph_client: MagicMock
    ) -> None:
        """unassign_number raises ValidationError for invalid Location header."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock

        mock_response = SyncMock()
        mock_response.headers = {"location": "invalid-header-format"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)

        service = NumberService(mock_graph_client)

        with pytest.raises(ValidationError) as exc_info:
            await service.unassign_number(
                phone_number="+12065551234",
                number_type=NumberType.DIRECT_ROUTING,
            )

        assert "Invalid Location header format" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_missing_location_header_raises_validation_error(
        self, mock_graph_client: MagicMock
    ) -> None:
        """unassign_number raises ValidationError when Location header is missing."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock

        mock_response = SyncMock()
        mock_response.headers = {}  # No Location header
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)

        service = NumberService(mock_graph_client)

        with pytest.raises(ValidationError) as exc_info:
            await service.unassign_number(
                phone_number="+12065551234",
                number_type=NumberType.DIRECT_ROUTING,
            )

        assert "Invalid Location header format" in str(exc_info.value)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_operation_failure_returns_failed_status(
        self, mock_graph_client: MagicMock
    ) -> None:
        """unassign_number returns failed status when operation fails."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        mock_response = SyncMock()
        mock_response.headers = {"location": "operations('op-123')"}
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        mock_graph_client.get = AsyncMock(
            return_value=make_operation_dict(status="failed")
        )

        service = NumberService(mock_graph_client)

        with patch(
            "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
        ):
            result = await service.unassign_number(
                phone_number="+12065551234",
                number_type=NumberType.DIRECT_ROUTING,
            )

        assert result.status == OperationStatus.FAILED

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_all_number_types_supported(
        self, mock_graph_client: MagicMock
    ) -> None:
        """unassign_number works with all number types."""
        from unittest.mock import AsyncMock, MagicMock as SyncMock, patch

        service = NumberService(mock_graph_client)

        for number_type in [
            NumberType.DIRECT_ROUTING,
            NumberType.CALLING_PLAN,
            NumberType.OPERATOR_CONNECT,
        ]:
            mock_response = SyncMock()
            mock_response.headers = {"location": "operations('op-123')"}
            mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
            mock_graph_client.get = AsyncMock(
                return_value=make_operation_dict(status="succeeded")
            )

            with patch(
                "teams_phone.services.number_service.asyncio.sleep", new=AsyncMock()
            ):
                result = await service.unassign_number(
                    phone_number="+12065551234",
                    number_type=number_type,
                )

            assert result.status == OperationStatus.SUCCEEDED

            # Verify correct numberType in body
            call_args = mock_graph_client.post_with_response.call_args
            body = call_args[0][1]
            assert body["numberType"] == number_type.value


class TestNumberServiceUpdateNumber:
    """Tests for NumberService.update_number method."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_update_with_location_id(self, mock_graph_client: MagicMock) -> None:
        """update_number sends location_id in request body."""
        from unittest.mock import AsyncMock

        mock_graph_client.post = AsyncMock(return_value={})

        service = NumberService(mock_graph_client)

        await service.update_number(
            phone_number="+12065551234",
            location_id="loc-abc-123",
        )

        # Verify endpoint and body
        call_args = mock_graph_client.post.call_args
        endpoint = call_args[0][0]
        body = call_args[0][1]
        assert (
            endpoint
            == "/admin/teams/telephoneNumberManagement/numberAssignments/updateNumber"
        )
        assert body["telephoneNumber"] == "+12065551234"
        assert body["locationId"] == "loc-abc-123"
        assert "networkSiteId" not in body

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_update_with_clear_location(
        self, mock_graph_client: MagicMock
    ) -> None:
        """update_number sends empty string to clear location."""
        from unittest.mock import AsyncMock

        mock_graph_client.post = AsyncMock(return_value={})

        service = NumberService(mock_graph_client)

        await service.update_number(
            phone_number="+12065551234",
            clear_location=True,
        )

        # Verify body has empty string for locationId
        call_args = mock_graph_client.post.call_args
        body = call_args[0][1]
        assert body["telephoneNumber"] == "+12065551234"
        assert body["locationId"] == ""
        assert "networkSiteId" not in body

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_update_with_network_site_id(
        self, mock_graph_client: MagicMock
    ) -> None:
        """update_number sends network_site_id in request body."""
        from unittest.mock import AsyncMock

        mock_graph_client.post = AsyncMock(return_value={})

        service = NumberService(mock_graph_client)

        await service.update_number(
            phone_number="+12065551234",
            network_site_id="site-xyz-789",
        )

        # Verify body
        call_args = mock_graph_client.post.call_args
        body = call_args[0][1]
        assert body["telephoneNumber"] == "+12065551234"
        assert body["networkSiteId"] == "site-xyz-789"
        assert "locationId" not in body

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_update_with_clear_network_site(
        self, mock_graph_client: MagicMock
    ) -> None:
        """update_number sends empty string to clear network site."""
        from unittest.mock import AsyncMock

        mock_graph_client.post = AsyncMock(return_value={})

        service = NumberService(mock_graph_client)

        await service.update_number(
            phone_number="+12065551234",
            clear_network_site=True,
        )

        # Verify body has empty string for networkSiteId
        call_args = mock_graph_client.post.call_args
        body = call_args[0][1]
        assert body["telephoneNumber"] == "+12065551234"
        assert body["networkSiteId"] == ""
        assert "locationId" not in body

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_update_with_both_location_and_network_site(
        self, mock_graph_client: MagicMock
    ) -> None:
        """update_number can update both location and network site at once."""
        from unittest.mock import AsyncMock

        mock_graph_client.post = AsyncMock(return_value={})

        service = NumberService(mock_graph_client)

        await service.update_number(
            phone_number="+12065551234",
            location_id="loc-abc",
            network_site_id="site-xyz",
        )

        # Verify body has both fields
        call_args = mock_graph_client.post.call_args
        body = call_args[0][1]
        assert body["telephoneNumber"] == "+12065551234"
        assert body["locationId"] == "loc-abc"
        assert body["networkSiteId"] == "site-xyz"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_phone_number_uses_format_for_update_with_plus_prefix(
        self, mock_graph_client: MagicMock
    ) -> None:
        """update_number formats phone number with + prefix (E.164)."""
        from unittest.mock import AsyncMock

        mock_graph_client.post = AsyncMock(return_value={})

        service = NumberService(mock_graph_client)

        await service.update_number(
            phone_number="1 (206) 555-1234",  # Unformatted input
            location_id="loc-123",
        )

        # Verify phone number has + prefix
        call_args = mock_graph_client.post.call_args
        body = call_args[0][1]
        assert body["telephoneNumber"] == "+12065551234"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_location_and_clear_location_mutually_exclusive(
        self, mock_graph_client: MagicMock
    ) -> None:
        """update_number raises ValidationError if both location_id and clear_location specified."""
        service = NumberService(mock_graph_client)

        with pytest.raises(ValidationError) as exc_info:
            await service.update_number(
                phone_number="+12065551234",
                location_id="loc-123",
                clear_location=True,
            )

        assert "Cannot specify both --location and --clear-location" in str(
            exc_info.value
        )
        assert exc_info.value.remediation is not None
        # Verify post was NOT called
        mock_graph_client.post.assert_not_called()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_network_site_and_clear_network_site_mutually_exclusive(
        self, mock_graph_client: MagicMock
    ) -> None:
        """update_number raises ValidationError if both network_site_id and clear_network_site specified."""
        service = NumberService(mock_graph_client)

        with pytest.raises(ValidationError) as exc_info:
            await service.update_number(
                phone_number="+12065551234",
                network_site_id="site-123",
                clear_network_site=True,
            )

        assert "Cannot specify both --network-site and --clear-network-site" in str(
            exc_info.value
        )
        assert exc_info.value.remediation is not None
        # Verify post was NOT called
        mock_graph_client.post.assert_not_called()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_no_update_parameters_raises_validation_error(
        self, mock_graph_client: MagicMock
    ) -> None:
        """update_number raises ValidationError if no update parameters provided."""
        service = NumberService(mock_graph_client)

        with pytest.raises(ValidationError) as exc_info:
            await service.update_number(
                phone_number="+12065551234",
                # No location or network site parameters
            )

        assert "No update parameters specified" in str(exc_info.value)
        assert exc_info.value.remediation is not None
        # Verify post was NOT called
        mock_graph_client.post.assert_not_called()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_returns_none_on_success(self, mock_graph_client: MagicMock) -> None:
        """update_number returns None (void) on successful update."""
        from unittest.mock import AsyncMock

        mock_graph_client.post = AsyncMock(return_value={})

        service = NumberService(mock_graph_client)

        result = await service.update_number(
            phone_number="+12065551234",
            location_id="loc-123",
        )

        assert result is None

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_clear_location_and_set_network_site(
        self, mock_graph_client: MagicMock
    ) -> None:
        """update_number can clear location while setting network site."""
        from unittest.mock import AsyncMock

        mock_graph_client.post = AsyncMock(return_value={})

        service = NumberService(mock_graph_client)

        await service.update_number(
            phone_number="+12065551234",
            clear_location=True,
            network_site_id="site-xyz",
        )

        # Verify body has empty string for location and value for network site
        call_args = mock_graph_client.post.call_args
        body = call_args[0][1]
        assert body["locationId"] == ""
        assert body["networkSiteId"] == "site-xyz"
