"""Unit tests for PolicyService."""

from datetime import timedelta
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock

import pytest

from teams_phone.exceptions import NotFoundError, ValidationError
from teams_phone.infrastructure import CacheManager
from teams_phone.models import EffectivePolicyAssignment, Policy
from teams_phone.services.policy_service import (
    ASSIGNABLE_POLICY_TYPES,
    PolicyService,
)

# Sample CSV content for tests
POLICIES_CSV = """policyType,policyName,policyId,description,isGlobal
TeamsCallingPolicy,AllowCalling,policy-id-1,Allows all calling features,False
TeamsCallingPolicy,Global,global-policy-id,,True
TenantDialPlan,US-DialPlan,dialplan-id-1,US dialing rules,False
OnlineVoiceRoutingPolicy,International,routing-id-1,International routing,False"""


class TestPolicyServiceInit:
    """Tests for PolicyService initialization."""

    @pytest.mark.unit
    def test_init_stores_cache_manager_and_graph_client(self, tmp_path: Path) -> None:
        """Constructor stores both cache_manager and graph_client instances."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        graph_client = MagicMock()
        service = PolicyService(graph_client=graph_client, cache_manager=cache_manager)

        assert service.cache_manager is cache_manager
        assert service.graph_client is graph_client


class TestPolicyServiceListPolicies:
    """Tests for list_policies method."""

    @pytest.mark.unit
    def test_list_policies_returns_empty_list_when_no_csv(self, tmp_path: Path) -> None:
        """list_policies returns empty list when CSV doesn't exist."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.list_policies()

        assert result == []

    @pytest.mark.unit
    def test_list_policies_returns_policy_objects(self, tmp_path: Path) -> None:
        """list_policies returns list of Policy objects."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.list_policies()

        assert len(result) == 4
        assert all(isinstance(p, Policy) for p in result)

    @pytest.mark.unit
    def test_list_policies_parses_fields_correctly(self, tmp_path: Path) -> None:
        """list_policies correctly parses all fields from CSV."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.list_policies()

        first = result[0]
        assert first.policy_type == "TeamsCallingPolicy"
        assert first.policy_name == "AllowCalling"
        assert first.policy_id == "policy-id-1"
        assert first.description == "Allows all calling features"
        assert first.is_global is False

    @pytest.mark.unit
    def test_list_policies_converts_boolean_correctly(self, tmp_path: Path) -> None:
        """list_policies converts isGlobal string to boolean."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.list_policies()

        assert result[0].is_global is False
        assert result[1].is_global is True

    @pytest.mark.unit
    def test_list_policies_handles_empty_description(self, tmp_path: Path) -> None:
        """list_policies handles empty description field."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.list_policies()

        # Global policy has empty description
        global_policy = next(p for p in result if p.policy_name == "Global")
        assert global_policy.description == ""

    @pytest.mark.unit
    def test_list_policies_filters_by_type(self, tmp_path: Path) -> None:
        """list_policies filters by policy_type when provided."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.list_policies(policy_type="TeamsCallingPolicy")

        assert len(result) == 2
        assert all(p.policy_type == "TeamsCallingPolicy" for p in result)

    @pytest.mark.unit
    def test_list_policies_filter_is_case_insensitive(self, tmp_path: Path) -> None:
        """list_policies filter is case-insensitive."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.list_policies(policy_type="teamscallingpolicy")

        assert len(result) == 2
        assert all(p.policy_type == "TeamsCallingPolicy" for p in result)

    @pytest.mark.unit
    def test_list_policies_no_filter_returns_all(self, tmp_path: Path) -> None:
        """list_policies returns all policies when policy_type is None."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.list_policies(policy_type=None)

        assert len(result) == 4

    @pytest.mark.unit
    def test_list_policies_returns_empty_for_unknown_type(self, tmp_path: Path) -> None:
        """list_policies returns empty list for unknown policy type."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.list_policies(policy_type="UnknownPolicyType")

        assert result == []


class TestPolicyServiceValidatePolicy:
    """Tests for validate_policy method."""

    @pytest.mark.unit
    def test_validate_policy_finds_exact_match(self, tmp_path: Path) -> None:
        """validate_policy returns policy when type and name match."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.validate_policy("TeamsCallingPolicy", "AllowCalling")

        assert result.policy_type == "TeamsCallingPolicy"
        assert result.policy_name == "AllowCalling"
        assert result.policy_id == "policy-id-1"

    @pytest.mark.unit
    def test_validate_policy_is_case_insensitive(self, tmp_path: Path) -> None:
        """validate_policy matches type and name case-insensitively."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.validate_policy("teamscallingpolicy", "allowcalling")

        assert result.policy_type == "TeamsCallingPolicy"
        assert result.policy_name == "AllowCalling"

    @pytest.mark.unit
    def test_validate_policy_raises_not_found_error(self, tmp_path: Path) -> None:
        """validate_policy raises NotFoundError for unknown policy."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        with pytest.raises(NotFoundError) as exc_info:
            service.validate_policy("TeamsCallingPolicy", "UnknownPolicy")

        assert "Policy 'UnknownPolicy' of type 'TeamsCallingPolicy' not found" in str(
            exc_info.value
        )
        assert exc_info.value.remediation is not None
        assert "teams-phone policies list" in exc_info.value.remediation

    @pytest.mark.unit
    def test_validate_policy_raises_when_type_mismatch(self, tmp_path: Path) -> None:
        """validate_policy raises NotFoundError when type doesn't match."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        # AllowCalling exists but is a TeamsCallingPolicy, not TenantDialPlan
        with pytest.raises(NotFoundError):
            service.validate_policy("TenantDialPlan", "AllowCalling")

    @pytest.mark.unit
    def test_validate_policy_raises_when_no_csv(self, tmp_path: Path) -> None:
        """validate_policy raises NotFoundError when CSV doesn't exist."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        with pytest.raises(NotFoundError):
            service.validate_policy("TeamsCallingPolicy", "AllowCalling")


class TestPolicyServiceCacheAge:
    """Tests for get_cache_age method."""

    @pytest.mark.unit
    def test_get_cache_age_returns_none_when_no_cache(self, tmp_path: Path) -> None:
        """get_cache_age returns None when no cache exists."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.get_cache_age()

        assert result is None

    @pytest.mark.unit
    def test_get_cache_age_returns_timedelta(self, tmp_path: Path) -> None:
        """get_cache_age returns timedelta when cache exists."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.get_cache_age()

        assert result is not None
        assert isinstance(result, timedelta)
        assert result.days == 0  # Just created, so 0 days old


class TestPolicyServiceIsCacheStale:
    """Tests for is_cache_stale method."""

    @pytest.mark.unit
    def test_is_cache_stale_returns_true_when_no_cache(self, tmp_path: Path) -> None:
        """is_cache_stale returns True when no cache exists."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.is_cache_stale()

        assert result is True

    @pytest.mark.unit
    def test_is_cache_stale_returns_false_for_fresh_cache(self, tmp_path: Path) -> None:
        """is_cache_stale returns False when cache is fresh."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.is_cache_stale()

        assert result is False


class TestPolicyServiceStalenessWarning:
    """Tests for get_staleness_warning method."""

    @pytest.mark.unit
    def test_get_staleness_warning_returns_none_for_fresh_cache(
        self, tmp_path: Path
    ) -> None:
        """get_staleness_warning returns None when cache is fresh."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.get_staleness_warning()

        assert result is None

    @pytest.mark.unit
    def test_get_staleness_warning_returns_message_when_no_cache(
        self, tmp_path: Path
    ) -> None:
        """get_staleness_warning returns message when no cache exists."""
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        service = PolicyService(graph_client=MagicMock(), cache_manager=cache_manager)

        result = service.get_staleness_warning()

        assert result is not None
        assert "cache not found" in result.lower()
        assert "PowerShell" in result


def make_user_config_dict(
    user_id: str = "user-123",
    upn: str = "user@contoso.com",
    tenant_id: str = "tenant-123",
    policy_assignments: list[dict[str, Any]] | 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.
        policy_assignments: List of policy assignment dicts.

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


def make_policy_assignment_dict(
    policy_type: str = "TeamsCallingPolicy",
    display_name: str = "AllOn",
    assignment_type: str = "direct",
    policy_id: str = "policy-id-123",
    group_id: str | None = None,
) -> dict[str, Any]:
    """Create a policy assignment dictionary for testing.

    Args:
        policy_type: Type of the policy.
        display_name: Display name of the policy.
        assignment_type: Assignment type (direct, group).
        policy_id: Policy ID.
        group_id: Group ID if assignment type is group.

    Returns:
        Dictionary matching Graph API policy assignment format.
    """
    assignment_detail: dict[str, Any] = {
        "displayName": display_name,
        "assignmentType": assignment_type,
        "policyId": policy_id,
    }
    if group_id is not None:
        assignment_detail["groupId"] = group_id

    return {
        "policyType": policy_type,
        "policyAssignment": assignment_detail,
    }


class TestPolicyServiceGetUserPolicies:
    """Tests for get_user_policies method."""

    @pytest.fixture
    def mock_graph_client(self) -> MagicMock:
        """Create a mock GraphClient for testing."""
        return MagicMock()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_user_policies_returns_effective_assignments(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """get_user_policies returns list of EffectivePolicyAssignment objects."""
        policy_assignments = [
            make_policy_assignment_dict("TeamsCallingPolicy", "AllOn", "direct"),
            make_policy_assignment_dict("TenantDialPlan", "US-DialPlan", "group"),
        ]
        user_data = make_user_config_dict(policy_assignments=policy_assignments)
        mock_graph_client.get = AsyncMock(return_value=user_data)
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )
        result = await service.get_user_policies("user-123")

        assert len(result) == 2
        assert all(isinstance(p, EffectivePolicyAssignment) for p in result)
        assert result[0].policy_type == "TeamsCallingPolicy"
        assert result[1].policy_type == "TenantDialPlan"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_user_policies_returns_empty_list_when_no_assignments(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """get_user_policies returns empty list when user has no policy assignments."""
        user_data = make_user_config_dict(policy_assignments=[])
        mock_graph_client.get = AsyncMock(return_value=user_data)
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )
        result = await service.get_user_policies("user-123")

        assert result == []

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_user_policies_propagates_not_found_error(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """get_user_policies propagates NotFoundError from GraphClient."""
        mock_graph_client.get = AsyncMock(side_effect=NotFoundError("User not found"))
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )
        with pytest.raises(NotFoundError):
            await service.get_user_policies("nonexistent-user")

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_user_policies_uses_correct_endpoint(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """get_user_policies calls the correct API endpoint."""
        user_data = make_user_config_dict()
        mock_graph_client.get = AsyncMock(return_value=user_data)
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )
        await service.get_user_policies("test-user-id")

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

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_user_policies_filters_by_policy_type(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """get_user_policies filters by policy_type when provided."""
        policy_assignments = [
            make_policy_assignment_dict("TeamsCallingPolicy", "AllOn"),
            make_policy_assignment_dict("TenantDialPlan", "US-DialPlan"),
            make_policy_assignment_dict("TeamsCallingPolicy", "RestrictedCalling"),
        ]
        user_data = make_user_config_dict(policy_assignments=policy_assignments)
        mock_graph_client.get = AsyncMock(return_value=user_data)
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )
        result = await service.get_user_policies(
            "user-123", policy_type="TeamsCallingPolicy"
        )

        assert len(result) == 2
        assert all(p.policy_type == "TeamsCallingPolicy" for p in result)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_user_policies_filter_is_case_insensitive(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """get_user_policies filter is case-insensitive."""
        policy_assignments = [
            make_policy_assignment_dict("TeamsCallingPolicy", "AllOn"),
            make_policy_assignment_dict("TenantDialPlan", "US-DialPlan"),
        ]
        user_data = make_user_config_dict(policy_assignments=policy_assignments)
        mock_graph_client.get = AsyncMock(return_value=user_data)
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )
        result = await service.get_user_policies(
            "user-123", policy_type="teamscallingpolicy"
        )

        assert len(result) == 1
        assert result[0].policy_type == "TeamsCallingPolicy"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_user_policies_no_filter_returns_all(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """get_user_policies returns all policies when policy_type is None."""
        policy_assignments = [
            make_policy_assignment_dict("TeamsCallingPolicy", "AllOn"),
            make_policy_assignment_dict("TenantDialPlan", "US-DialPlan"),
            make_policy_assignment_dict("OnlineVoiceRoutingPolicy", "International"),
        ]
        user_data = make_user_config_dict(policy_assignments=policy_assignments)
        mock_graph_client.get = AsyncMock(return_value=user_data)
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )
        result = await service.get_user_policies("user-123", policy_type=None)

        assert len(result) == 3

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_user_policies_returns_empty_for_unknown_type(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """get_user_policies returns empty list for unknown policy type filter."""
        policy_assignments = [
            make_policy_assignment_dict("TeamsCallingPolicy", "AllOn"),
        ]
        user_data = make_user_config_dict(policy_assignments=policy_assignments)
        mock_graph_client.get = AsyncMock(return_value=user_data)
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )
        result = await service.get_user_policies(
            "user-123", policy_type="UnknownPolicyType"
        )

        assert result == []


class TestPolicyServiceAssignPolicy:
    """Tests for assign_policy method."""

    @pytest.fixture
    def mock_graph_client(self) -> MagicMock:
        """Create a mock GraphClient for testing with policyId lookup support."""
        client = MagicMock()

        # Mock get_paginated to return users with effective policy assignments
        # This enables resolve_policy_id to find the real policyId
        async def mock_get_paginated(*args: Any, **kwargs: Any) -> Any:
            # Return a user with policy assignments matching CSV test data
            users = [
                {
                    "id": "existing-user-id",
                    "userPrincipalName": "test@example.com",
                    "tenantId": "tenant-123",
                    "displayName": "Test User",
                    "accountType": "user",
                    "isEnterpriseVoiceEnabled": True,
                    "featureTypes": ["Teams"],
                    "telephoneNumbers": [],
                    "effectivePolicyAssignments": [
                        {
                            "policyType": "TeamsCallingPolicy",
                            "policyAssignment": {
                                "displayName": "AllowCalling",
                                "assignmentType": "direct",
                                "policyId": "real-policy-id-1",
                            },
                        },
                        {
                            "policyType": "TenantDialPlan",
                            "policyAssignment": {
                                "displayName": "US-DialPlan",
                                "assignmentType": "direct",
                                "policyId": "real-dialplan-id-1",
                            },
                        },
                        {
                            "policyType": "OnlineVoiceRoutingPolicy",
                            "policyAssignment": {
                                "displayName": "International",
                                "assignmentType": "direct",
                                "policyId": "real-routing-id-1",
                            },
                        },
                    ],
                }
            ]
            for user in users:
                yield user

        client.get_paginated = mock_get_paginated
        return client

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_assign_policy_posts_to_correct_endpoint(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """assign_policy POSTs to the policy assignment endpoint."""
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        mock_response = MagicMock()
        mock_response.status_code = 204
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )
        await service.assign_policy("user-123", "TeamsCallingPolicy", "AllowCalling")

        mock_graph_client.post_with_response.assert_called_once()
        call_args = mock_graph_client.post_with_response.call_args
        assert call_args[0][0] == "/admin/teams/policy/userAssignments/assign"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_assign_policy_builds_correct_request_body(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """assign_policy builds request body per Graph API contract."""
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        mock_response = MagicMock()
        mock_response.status_code = 204
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )
        await service.assign_policy("user-123", "TeamsCallingPolicy", "AllowCalling")

        call_args = mock_graph_client.post_with_response.call_args
        body = call_args[0][1]

        assert "value" in body
        assert len(body["value"]) == 1
        assignment = body["value"][0]
        assert (
            assignment["@odata.type"]
            == "#microsoft.graph.teamsAdministration.teamsPolicyUserAssignment"
        )
        assert assignment["userId"] == "user-123"
        assert assignment["policyType"] == "TeamsCallingPolicy"
        # policyId is now resolved at runtime from user assignments, not from CSV
        assert assignment["policyId"] == "real-policy-id-1"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_assign_policy_resolves_policy_id_at_runtime(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """assign_policy resolves policyId from tenant user assignments at runtime."""
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        mock_response = MagicMock()
        mock_response.status_code = 204
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )
        await service.assign_policy("user-123", "TenantDialPlan", "US-DialPlan")

        call_args = mock_graph_client.post_with_response.call_args
        body = call_args[0][1]
        # policyId is resolved from user assignments, not the CSV cache
        assert body["value"][0]["policyId"] == "real-dialplan-id-1"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_assign_policy_raises_validation_error_for_voicemail_policy(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """assign_policy raises ValidationError for TeamsVoicemailPolicy."""
        csv_with_voicemail = (
            POLICIES_CSV + "\nTeamsVoicemailPolicy,CustomVoicemail,vm-policy-id,,False"
        )
        (tmp_path / "policies.csv").write_text(csv_with_voicemail, encoding="utf-8")
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )

        with pytest.raises(ValidationError) as exc_info:
            await service.assign_policy(
                "user-123", "TeamsVoicemailPolicy", "CustomVoicemail"
            )

        assert "not supported for batch assignment" in str(exc_info.value)
        assert exc_info.value.remediation is not None
        assert "Grant-CsTeamsVoicemailPolicy" in exc_info.value.remediation
        assert "CustomVoicemail" in exc_info.value.remediation
        mock_graph_client.post_with_response.assert_not_called()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_assign_policy_raises_not_found_error_for_unknown_policy(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """assign_policy raises NotFoundError when policy doesn't exist in cache."""
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )

        with pytest.raises(NotFoundError) as exc_info:
            await service.assign_policy(
                "user-123", "TeamsCallingPolicy", "NonexistentPolicy"
            )

        assert (
            "Policy 'NonexistentPolicy' of type 'TeamsCallingPolicy' not found"
            in str(exc_info.value)
        )
        mock_graph_client.post_with_response.assert_not_called()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_assign_policy_propagates_graph_errors(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """assign_policy propagates errors from GraphClient."""
        from teams_phone.exceptions import AuthorizationError

        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        mock_graph_client.post_with_response = AsyncMock(
            side_effect=AuthorizationError("Insufficient permissions")
        )
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )

        with pytest.raises(AuthorizationError):
            await service.assign_policy(
                "user-123", "TeamsCallingPolicy", "AllowCalling"
            )

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_assign_policy_validates_before_api_call(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """assign_policy validates policy type before making API call."""
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )

        # TeamsVoicemailPolicy validation happens before validate_policy lookup
        with pytest.raises(ValidationError):
            await service.assign_policy(
                "user-123", "TeamsVoicemailPolicy", "AnyPolicyName"
            )

        # Should fail early without attempting to validate the policy name
        mock_graph_client.post_with_response.assert_not_called()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_assign_policy_returns_none_on_success(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        """assign_policy returns None on successful 204 No Content response."""
        (tmp_path / "policies.csv").write_text(POLICIES_CSV, encoding="utf-8")
        mock_response = MagicMock()
        mock_response.status_code = 204
        mock_graph_client.post_with_response = AsyncMock(return_value=mock_response)
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(
            graph_client=mock_graph_client, cache_manager=cache_manager
        )
        # Method returns None on success - just verify it doesn't raise
        await service.assign_policy(
            "user-123", "OnlineVoiceRoutingPolicy", "International"
        )

        mock_graph_client.post_with_response.assert_called_once()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_assign_policy_raises_not_found_when_policy_id_cannot_be_resolved(
        self, tmp_path: Path
    ) -> None:
        """assign_policy raises NotFoundError when no user has the policy assigned."""
        # Add a policy to CSV that no user in the mock data has assigned
        csv_with_extra_policy = (
            POLICIES_CSV + "\nTeamsCallingPolicy,NobodyHasThis,nobody-id,,False"
        )
        (tmp_path / "policies.csv").write_text(csv_with_extra_policy, encoding="utf-8")

        # Create a mock client with empty user list (no policies assigned)
        mock_client = MagicMock()

        async def empty_get_paginated(*args: Any, **kwargs: Any) -> Any:
            return
            yield  # Make this an async generator that yields nothing

        mock_client.get_paginated = empty_get_paginated
        cache_manager = CacheManager(cache_dir=str(tmp_path))

        service = PolicyService(graph_client=mock_client, cache_manager=cache_manager)

        with pytest.raises(NotFoundError) as exc_info:
            await service.assign_policy(
                "user-123", "TeamsCallingPolicy", "NobodyHasThis"
            )

        assert "Could not resolve policyId" in str(exc_info.value)
        assert "NobodyHasThis" in str(exc_info.value)
        assert exc_info.value.remediation is not None
        assert "No user in the tenant" in exc_info.value.remediation


class TestAssignablePolicyTypes:
    """Tests for ASSIGNABLE_POLICY_TYPES constant."""

    @pytest.mark.unit
    def test_assignable_policy_types_excludes_voicemail(self) -> None:
        """ASSIGNABLE_POLICY_TYPES excludes TeamsVoicemailPolicy."""
        assert "TeamsVoicemailPolicy" not in ASSIGNABLE_POLICY_TYPES

    @pytest.mark.unit
    def test_assignable_policy_types_includes_calling_policy(self) -> None:
        """ASSIGNABLE_POLICY_TYPES includes TeamsCallingPolicy."""
        assert "TeamsCallingPolicy" in ASSIGNABLE_POLICY_TYPES

    @pytest.mark.unit
    def test_assignable_policy_types_includes_voice_related_types(self) -> None:
        """ASSIGNABLE_POLICY_TYPES includes all voice-related policy types."""
        expected_types = [
            "TeamsCallingPolicy",
            "CallingLineIdentity",
            "TenantDialPlan",
            "OnlineVoiceRoutingPolicy",
            "TeamsEmergencyCallingPolicy",
            "TeamsEmergencyCallRoutingPolicy",
        ]
        for policy_type in expected_types:
            assert policy_type in ASSIGNABLE_POLICY_TYPES
