"""Unit tests for CLI policies commands."""

from __future__ import annotations

import json
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch

if TYPE_CHECKING:
    from pathlib import Path

import pytest
from tests.conftest import CliRunner

from teams_phone.cli.main import app
from teams_phone.exceptions import NotFoundError, ValidationError
from teams_phone.models import EffectivePolicyAssignment, Policy, UserConfiguration

runner = CliRunner()


# Test fixtures
def _make_policy(
    policy_type: str = "TeamsCallingPolicy",
    policy_name: str = "AllowCalling",
    policy_id: str = "policy-11111111-1111-1111-1111-111111111111",
    description: str | None = "Allow calling for all users",
    is_global: bool = False,
) -> Policy:
    """Create a Policy for testing."""
    return Policy.model_validate(
        {
            "policyType": policy_type,
            "policyName": policy_name,
            "policyId": policy_id,
            "description": description,
            "isGlobal": is_global,
        }
    )


def _make_assignment(
    policy_type: str = "TeamsCallingPolicy",
    display_name: str = "AllowCalling",
    assignment_type: str = "direct",
    policy_id: str = "policy-11111111-1111-1111-1111-111111111111",
    group_id: str | None = None,
) -> EffectivePolicyAssignment:
    """Create an EffectivePolicyAssignment for testing."""
    data: dict[str, Any] = {
        "policyType": policy_type,
        "policyAssignment": {
            "displayName": display_name,
            "assignmentType": assignment_type,
            "policyId": policy_id,
        },
    }
    if group_id is not None:
        data["policyAssignment"]["groupId"] = group_id
    return EffectivePolicyAssignment.model_validate(data)


def _make_user(
    user_id: str = "user-11111111-1111-1111-1111-111111111111",
    display_name: str = "John Doe",
    upn: str = "john.doe@contoso.com",
) -> UserConfiguration:
    """Create a UserConfiguration for testing."""
    return UserConfiguration.model_validate(
        {
            "id": user_id,
            "userPrincipalName": upn,
            "tenantId": "tenant-11111111-1111-1111-1111-111111111111",
            "displayName": display_name,
            "accountType": "user",
            "isEnterpriseVoiceEnabled": True,
            "telephoneNumbers": [],
            "featureTypes": [],
            "effectivePolicyAssignments": [],
        }
    )


class TestPoliciesListCommand:
    """Tests for policies list command."""

    @pytest.mark.unit
    def test_list_shows_in_help(self) -> None:
        """list command appears in policies help."""
        result = runner.invoke(app, ["policies", "--help"])
        assert result.exit_code == 0
        assert "list" in result.stdout

    @pytest.mark.unit
    def test_list_empty(self) -> None:
        """list shows empty message when no policies found."""
        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.list_policies.return_value = []
            mock_service.get_staleness_warning.return_value = None
            mock_get_service.return_value = mock_service

            result = runner.invoke(app, ["policies", "list"])
            assert result.exit_code == 0
            assert "No policies found" in result.stdout

    @pytest.mark.unit
    def test_list_shows_policies(self) -> None:
        """list shows policies in table format."""
        policies = [
            _make_policy(policy_name="AllowCalling", description="Allow calls"),
            _make_policy(
                policy_type="TenantDialPlan",
                policy_name="US-DialPlan",
                description="US dial plan",
            ),
        ]

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.list_policies.return_value = policies
            mock_service.get_staleness_warning.return_value = None
            mock_get_service.return_value = mock_service

            result = runner.invoke(app, ["--no-color", "policies", "list"])
            assert result.exit_code == 0
            assert "AllowCalling" in result.stdout
            assert "US-DialPlan" in result.stdout

    @pytest.mark.unit
    def test_list_json_output(self) -> None:
        """list outputs valid JSON with --json flag."""
        policies = [_make_policy()]

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.list_policies.return_value = policies
            mock_service.get_staleness_warning.return_value = None
            mock_get_service.return_value = mock_service

            result = runner.invoke(app, ["--json", "policies", "list"])
            assert result.exit_code == 0
            data = json.loads(result.stdout)
            assert len(data) == 1
            assert data[0]["policy_name"] == "AllowCalling"
            assert data[0]["policy_type"] == "TeamsCallingPolicy"
            assert data[0]["is_global"] is False

    @pytest.mark.unit
    def test_list_json_empty(self) -> None:
        """list outputs empty JSON array when no policies."""
        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.list_policies.return_value = []
            mock_service.get_staleness_warning.return_value = None
            mock_get_service.return_value = mock_service

            result = runner.invoke(app, ["--json", "policies", "list"])
            assert result.exit_code == 0
            data = json.loads(result.stdout)
            assert data == []

    @pytest.mark.unit
    def test_list_with_type_filter_cli_alias(self) -> None:
        """list filters by CLI policy type alias."""
        policies = [_make_policy(policy_type="TeamsCallingPolicy")]

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.list_policies.return_value = policies
            mock_service.get_staleness_warning.return_value = None
            mock_get_service.return_value = mock_service

            result = runner.invoke(app, ["--no-color", "policies", "list", "calling"])
            assert result.exit_code == 0
            # Verify the service was called with the resolved type
            mock_service.list_policies.assert_called_once_with(
                policy_type="TeamsCallingPolicy"
            )

    @pytest.mark.unit
    def test_list_with_type_filter_api_name(self) -> None:
        """list filters by API policy type name."""
        policies = [_make_policy(policy_type="TeamsCallingPolicy")]

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.list_policies.return_value = policies
            mock_service.get_staleness_warning.return_value = None
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app, ["--no-color", "policies", "list", "TeamsCallingPolicy"]
            )
            assert result.exit_code == 0
            mock_service.list_policies.assert_called_once_with(
                policy_type="TeamsCallingPolicy"
            )

    @pytest.mark.unit
    def test_list_shows_staleness_warning(self) -> None:
        """list shows staleness warning when cache is stale."""
        policies = [_make_policy()]

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.list_policies.return_value = policies
            mock_service.get_staleness_warning.return_value = (
                "Policy cache is 45 days old (threshold: 30 days). "
                "Consider refreshing by running the PowerShell export script."
            )
            mock_get_service.return_value = mock_service

            result = runner.invoke(app, ["--no-color", "policies", "list"])
            assert result.exit_code == 0
            assert "days old" in result.stdout
            assert "threshold" in result.stdout

    @pytest.mark.unit
    def test_list_global_policy_display(self) -> None:
        """list correctly displays global policies."""
        policies = [_make_policy(policy_name="Global", is_global=True)]

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.list_policies.return_value = policies
            mock_service.get_staleness_warning.return_value = None
            mock_get_service.return_value = mock_service

            result = runner.invoke(app, ["--json", "policies", "list"])
            assert result.exit_code == 0
            data = json.loads(result.stdout)
            assert data[0]["is_global"] is True


class TestPoliciesShowCommand:
    """Tests for policies show command."""

    @pytest.mark.unit
    def test_show_shows_in_help(self) -> None:
        """show command appears in policies help."""
        result = runner.invoke(app, ["policies", "--help"])
        assert result.exit_code == 0
        assert "show" in result.stdout

    @pytest.mark.unit
    def test_show_requires_user_argument(self) -> None:
        """show requires user argument."""
        result = runner.invoke(app, ["policies", "show"])
        assert result.exit_code == 2
        output = result.stdout + (result.stderr or "")
        assert "USER" in output or "Missing argument" in output or result.exit_code == 2

    @pytest.mark.unit
    def test_show_displays_policies(self) -> None:
        """show displays user's policy assignments."""
        user = _make_user()
        assignments = [
            _make_assignment(
                policy_type="TeamsCallingPolicy", display_name="AllowCalling"
            ),
            _make_assignment(
                policy_type="TenantDialPlan",
                display_name="US-DialPlan",
                assignment_type="group",
            ),
        ]

        with patch("teams_phone.cli.policies._run_async") as mock_run_async:
            mock_run_async.return_value = (
                (assignments, user.display_name, user.id),
                MagicMock(),
            )

            result = runner.invoke(
                app, ["--no-color", "policies", "show", "john.doe@contoso.com"]
            )
            assert result.exit_code == 0
            assert "John Doe" in result.stdout
            assert "AllowCalling" in result.stdout

    @pytest.mark.unit
    def test_show_json_output(self) -> None:
        """show outputs valid JSON with --json flag."""
        user = _make_user()
        assignments = [
            _make_assignment(
                policy_type="TeamsCallingPolicy", display_name="AllowCalling"
            ),
        ]

        with patch("teams_phone.cli.policies._run_async") as mock_run_async:
            mock_run_async.return_value = (
                (assignments, user.display_name, user.id),
                MagicMock(),
            )

            result = runner.invoke(
                app, ["--json", "policies", "show", "john.doe@contoso.com"]
            )
            assert result.exit_code == 0
            data = json.loads(result.stdout)
            assert data["display_name"] == "John Doe"
            assert len(data["policies"]) == 1
            assert data["policies"][0]["policy_type"] == "TeamsCallingPolicy"
            assert data["policies"][0]["policy_name"] == "AllowCalling"

    @pytest.mark.unit
    def test_show_empty_assignments(self) -> None:
        """show displays message when no assignments found."""
        user = _make_user()

        with patch("teams_phone.cli.policies._run_async") as mock_run_async:
            mock_run_async.return_value = (
                ([], user.display_name, user.id),
                MagicMock(),
            )

            result = runner.invoke(
                app, ["--no-color", "policies", "show", "john.doe@contoso.com"]
            )
            assert result.exit_code == 0
            assert "No policy assignments found" in result.stdout

    @pytest.mark.unit
    def test_show_user_not_found(self) -> None:
        """show exits with error when user not found."""
        with patch("teams_phone.cli.policies._run_async") as mock_run_async:
            mock_run_async.side_effect = NotFoundError(
                "User 'nonexistent@contoso.com' not found",
                remediation="Verify the user identifier is correct.",
            )

            result = runner.invoke(app, ["policies", "show", "nonexistent@contoso.com"])
            assert result.exit_code == 4  # NotFoundError exit code

    @pytest.mark.unit
    def test_show_assignment_types_formatted(self) -> None:
        """show formats assignment types correctly."""
        user = _make_user()
        assignments = [
            _make_assignment(assignment_type="direct"),
            _make_assignment(
                policy_type="TenantDialPlan",
                display_name="DialPlan",
                assignment_type="group",
            ),
            _make_assignment(
                policy_type="TeamsEmergencyCallingPolicy",
                display_name="Default",
                assignment_type="global",
            ),
        ]

        with patch("teams_phone.cli.policies._run_async") as mock_run_async:
            mock_run_async.return_value = (
                (assignments, user.display_name, user.id),
                MagicMock(),
            )

            result = runner.invoke(
                app, ["--no-color", "policies", "show", "john.doe@contoso.com"]
            )
            assert result.exit_code == 0
            assert "Direct" in result.stdout
            assert "Group" in result.stdout
            assert "Global" in result.stdout


class TestPoliciesAssignCommand:
    """Tests for policies assign command."""

    @pytest.mark.unit
    def test_assign_shows_in_help(self) -> None:
        """assign command appears in policies help."""
        result = runner.invoke(app, ["policies", "--help"])
        assert result.exit_code == 0
        assert "assign" in result.stdout

    @pytest.mark.unit
    def test_assign_requires_all_arguments(self) -> None:
        """assign requires user, policy-type, and policy-name arguments."""
        result = runner.invoke(app, ["policies", "assign"])
        assert result.exit_code == 2

        result = runner.invoke(app, ["policies", "assign", "john@contoso.com"])
        assert result.exit_code == 2

        result = runner.invoke(
            app, ["policies", "assign", "john@contoso.com", "calling"]
        )
        assert result.exit_code == 2

    @pytest.mark.unit
    def test_assign_dry_run(self) -> None:
        """assign with --dry-run shows preview without making changes."""
        user = _make_user()
        policy = _make_policy()

        with (
            patch("teams_phone.cli.policies._run_async") as mock_run_async,
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
        ):
            mock_run_async.return_value = (
                (user.id, user.display_name, user.user_principal_name),
                MagicMock(),
            )
            mock_service = MagicMock()
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--no-color",
                    "policies",
                    "assign",
                    "john@contoso.com",
                    "calling",
                    "AllowCalling",
                    "--dry-run",
                ],
            )
            assert result.exit_code == 0
            assert "Dry Run Preview" in result.stdout
            assert "John Doe" in result.stdout
            assert "AllowCalling" in result.stdout
            assert "No changes made" in result.stdout

    @pytest.mark.unit
    def test_assign_dry_run_json(self) -> None:
        """assign with --dry-run --json outputs valid JSON."""
        user = _make_user()
        policy = _make_policy()

        with (
            patch("teams_phone.cli.policies._run_async") as mock_run_async,
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
        ):
            mock_run_async.return_value = (
                (user.id, user.display_name, user.user_principal_name),
                MagicMock(),
            )
            mock_service = MagicMock()
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--json",
                    "policies",
                    "assign",
                    "john@contoso.com",
                    "calling",
                    "AllowCalling",
                    "--dry-run",
                ],
            )
            assert result.exit_code == 0
            data = json.loads(result.stdout)
            assert data["dry_run"] is True
            assert data["display_name"] == "John Doe"
            assert data["policy_type"] == "TeamsCallingPolicy"
            assert data["policy_name"] == "AllowCalling"

    @pytest.mark.unit
    def test_assign_success_with_force(self) -> None:
        """assign with --force skips confirmation and succeeds."""
        user = _make_user()
        policy = _make_policy()

        call_count = [0]

        def mock_run_async_side_effect(ctx: Any, coro_factory: Any) -> Any:
            call_count[0] += 1
            formatter = MagicMock()
            if call_count[0] == 1:
                # First call: resolve user
                return (
                    (user.id, user.display_name, user.user_principal_name),
                    formatter,
                )
            else:
                # Second call: assign policy
                return (None, formatter)

        with (
            patch(
                "teams_phone.cli.policies._run_async",
                side_effect=mock_run_async_side_effect,
            ),
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
        ):
            mock_service = MagicMock()
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--no-color",
                    "policies",
                    "assign",
                    "john@contoso.com",
                    "calling",
                    "AllowCalling",
                    "--force",
                ],
            )
            assert result.exit_code == 0
            assert "Assigned" in result.stdout
            assert "John Doe" in result.stdout

    @pytest.mark.unit
    def test_assign_success_json(self) -> None:
        """assign success outputs valid JSON with --json flag."""
        user = _make_user()
        policy = _make_policy()

        call_count = [0]

        def mock_run_async_side_effect(ctx: Any, coro_factory: Any) -> Any:
            call_count[0] += 1
            formatter = MagicMock()
            if call_count[0] == 1:
                return (
                    (user.id, user.display_name, user.user_principal_name),
                    formatter,
                )
            else:
                return (None, formatter)

        with (
            patch(
                "teams_phone.cli.policies._run_async",
                side_effect=mock_run_async_side_effect,
            ),
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
        ):
            mock_service = MagicMock()
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--json",
                    "policies",
                    "assign",
                    "john@contoso.com",
                    "calling",
                    "AllowCalling",
                ],
            )
            assert result.exit_code == 0
            data = json.loads(result.stdout)
            assert data["success"] is True
            assert data["display_name"] == "John Doe"
            assert data["policy_type"] == "TeamsCallingPolicy"

    @pytest.mark.unit
    def test_assign_user_not_found(self) -> None:
        """assign exits with error when user not found."""
        with patch("teams_phone.cli.policies._run_async") as mock_run_async:
            mock_run_async.side_effect = NotFoundError(
                "User 'nonexistent@contoso.com' not found",
                remediation="Verify the user identifier is correct.",
            )

            result = runner.invoke(
                app,
                [
                    "policies",
                    "assign",
                    "nonexistent@contoso.com",
                    "calling",
                    "AllowCalling",
                    "--force",
                ],
            )
            assert result.exit_code == 4  # NotFoundError exit code

    @pytest.mark.unit
    def test_assign_policy_not_found(self) -> None:
        """assign exits with error when policy not found."""
        user = _make_user()

        with (
            patch("teams_phone.cli.policies._run_async") as mock_run_async,
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
        ):
            mock_run_async.return_value = (
                (user.id, user.display_name, user.user_principal_name),
                MagicMock(),
            )
            mock_service = MagicMock()
            mock_service.validate_policy.side_effect = NotFoundError(
                "Policy 'Nonexistent' of type 'TeamsCallingPolicy' not found",
                remediation="Use 'teams-phone policies list' to see available policies.",
            )
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "policies",
                    "assign",
                    "john@contoso.com",
                    "calling",
                    "Nonexistent",
                    "--force",
                ],
            )
            assert result.exit_code == 4  # NotFoundError exit code

    @pytest.mark.unit
    def test_assign_voicemail_policy_error(self) -> None:
        """assign raises validation error for voicemail policy."""
        user = _make_user()
        policy = _make_policy(policy_type="TeamsVoicemailPolicy")

        call_count = [0]

        def mock_run_async_side_effect(ctx: Any, coro_factory: Any) -> Any:
            call_count[0] += 1
            formatter = MagicMock()
            if call_count[0] == 1:
                return (
                    (user.id, user.display_name, user.user_principal_name),
                    formatter,
                )
            else:
                # This is the assign call - it should raise ValidationError
                raise ValidationError(
                    "Policy type 'TeamsVoicemailPolicy' is not supported for batch assignment",
                    remediation="Use PowerShell cmdlet Grant-CsTeamsVoicemailPolicy instead.",
                )

        with (
            patch(
                "teams_phone.cli.policies._run_async",
                side_effect=mock_run_async_side_effect,
            ),
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
        ):
            mock_service = MagicMock()
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "policies",
                    "assign",
                    "john@contoso.com",
                    "voicemail",
                    "VoicemailPolicy",
                    "--force",
                ],
            )
            assert result.exit_code == 5  # ValidationError exit code

    @pytest.mark.unit
    def test_assign_policy_type_resolution(self) -> None:
        """assign correctly resolves CLI policy type aliases."""
        user = _make_user()
        policy = _make_policy(policy_type="CallingLineIdentity")

        call_count = [0]

        def mock_run_async_side_effect(ctx: Any, coro_factory: Any) -> Any:
            call_count[0] += 1
            formatter = MagicMock()
            if call_count[0] == 1:
                return (
                    (user.id, user.display_name, user.user_principal_name),
                    formatter,
                )
            else:
                return (None, formatter)

        with (
            patch(
                "teams_phone.cli.policies._run_async",
                side_effect=mock_run_async_side_effect,
            ),
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
        ):
            mock_service = MagicMock()
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--json",
                    "policies",
                    "assign",
                    "john@contoso.com",
                    "caller-id",
                    "CallerIdPolicy",
                ],
            )
            assert result.exit_code == 0
            data = json.loads(result.stdout)
            assert data["policy_type"] == "CallingLineIdentity"


class TestPoliciesCommandIntegration:
    """Integration tests for policies command group."""

    @pytest.mark.unit
    def test_policies_commands_appear_in_help(self) -> None:
        """All policies commands appear in policies --help."""
        result = runner.invoke(app, ["policies", "--help"])
        assert result.exit_code == 0
        assert "list" in result.stdout
        assert "show" in result.stdout
        assert "assign" in result.stdout

    @pytest.mark.unit
    def test_policies_no_args_shows_help(self) -> None:
        """policies with no args shows help (exit code 2)."""
        result = runner.invoke(app, ["policies"])
        assert result.exit_code == 2
        assert "Usage:" in result.stdout

    @pytest.mark.unit
    def test_policies_appears_in_main_help(self) -> None:
        """policies command group appears in main help."""
        result = runner.invoke(app, ["--help"])
        assert result.exit_code == 0
        assert "policies" in result.stdout


class TestPolicyTypeMapping:
    """Tests for policy type name mapping."""

    @pytest.mark.unit
    def test_all_cli_aliases_resolve(self) -> None:
        """All CLI aliases resolve to valid API names."""
        from teams_phone.cli.policies import POLICY_TYPE_MAP, _resolve_policy_type

        for cli_alias, api_name in POLICY_TYPE_MAP.items():
            resolved = _resolve_policy_type(cli_alias)
            assert resolved == api_name

    @pytest.mark.unit
    def test_api_names_pass_through(self) -> None:
        """API names pass through unchanged."""
        from teams_phone.cli.policies import _resolve_policy_type

        api_names = [
            "TeamsCallingPolicy",
            "TeamsVoicemailPolicy",
            "CallingLineIdentity",
            "TenantDialPlan",
            "OnlineVoiceRoutingPolicy",
            "TeamsEmergencyCallingPolicy",
            "TeamsEmergencyCallRoutingPolicy",
        ]

        for api_name in api_names:
            resolved = _resolve_policy_type(api_name)
            assert resolved == api_name

    @pytest.mark.unit
    def test_case_insensitive_resolution(self) -> None:
        """Policy type resolution is case-insensitive."""
        from teams_phone.cli.policies import _resolve_policy_type

        assert _resolve_policy_type("CALLING") == "TeamsCallingPolicy"
        assert _resolve_policy_type("Calling") == "TeamsCallingPolicy"
        assert _resolve_policy_type("teamscallingpolicy") == "TeamsCallingPolicy"

    @pytest.mark.unit
    def test_none_returns_none(self) -> None:
        """None input returns None."""
        from teams_phone.cli.policies import _resolve_policy_type

        assert _resolve_policy_type(None) is None


class TestPoliciesBulkAssignCommand:
    """Tests for policies bulk-assign command."""

    @pytest.mark.unit
    def test_bulk_assign_shows_in_help(self) -> None:
        """bulk-assign command appears in policies help."""
        result = runner.invoke(app, ["policies", "--help"])
        assert result.exit_code == 0
        assert "bulk-assign" in result.stdout

    @pytest.mark.unit
    def test_bulk_assign_requires_csv_file_argument(self) -> None:
        """bulk-assign requires csv_file argument."""
        result = runner.invoke(app, ["policies", "bulk-assign"])
        assert result.exit_code == 2
        output = result.stdout + (result.stderr or "")
        assert (
            "CSV_FILE" in output
            or "Missing argument" in output
            or result.exit_code == 2
        )

    @pytest.mark.unit
    def test_bulk_assign_dry_run_valid_csv(self, tmp_path: Any) -> None:
        """bulk-assign --dry-run shows validation summary for valid CSV."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,calling,AllowCalling
jane@contoso.com,dial-plan,US-DialPlan"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        policy1 = _make_policy(
            policy_type="TeamsCallingPolicy", policy_name="AllowCalling"
        )
        policy2 = _make_policy(policy_type="TenantDialPlan", policy_name="US-DialPlan")

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_service.validate_policy.side_effect = [policy1, policy2]
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--no-color",
                    "policies",
                    "bulk-assign",
                    str(csv_file),
                    "--dry-run",
                ],
            )
            assert result.exit_code == 0
            assert "Validation Summary" in result.stdout
            assert "Total rows:" in result.stdout
            assert "Valid:" in result.stdout
            assert "Errors:" in result.stdout
            assert "Dry run complete" in result.stdout

    @pytest.mark.unit
    def test_bulk_assign_dry_run_json(self, tmp_path: Any) -> None:
        """bulk-assign --dry-run --json outputs valid JSON."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,calling,AllowCalling"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        policy = _make_policy(
            policy_type="TeamsCallingPolicy", policy_name="AllowCalling"
        )

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--json",
                    "policies",
                    "bulk-assign",
                    str(csv_file),
                    "--dry-run",
                ],
            )
            assert result.exit_code == 0
            data = json.loads(result.stdout)
            assert data["dry_run"] is True
            assert data["valid_count"] == 1
            assert data["error_count"] == 0
            assert len(data["rows"]) == 1
            assert data["rows"][0]["valid"] is True

    @pytest.mark.unit
    def test_bulk_assign_validation_errors(self, tmp_path: Any) -> None:
        """bulk-assign shows validation errors for invalid policies."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,calling,NonexistentPolicy"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_service.validate_policy.side_effect = NotFoundError(
                "Policy 'NonexistentPolicy' of type 'TeamsCallingPolicy' not found"
            )
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--no-color",
                    "policies",
                    "bulk-assign",
                    str(csv_file),
                    "--dry-run",
                ],
            )
            assert result.exit_code == 1  # Error exit code
            assert "Validation Errors" in result.stdout
            assert "NonexistentPolicy" in result.stdout

    @pytest.mark.unit
    def test_bulk_assign_voicemail_policy_rejected(self, tmp_path: Any) -> None:
        """bulk-assign rejects TeamsVoicemailPolicy with clear error."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,voicemail,VoicemailPolicy"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--no-color",
                    "policies",
                    "bulk-assign",
                    str(csv_file),
                    "--dry-run",
                ],
            )
            assert result.exit_code == 1
            assert "TeamsVoicemailPolicy" in result.stdout
            assert "not supported" in result.stdout
            assert "PowerShell" in result.stdout

    @pytest.mark.unit
    def test_bulk_assign_invalid_policy_type(self, tmp_path: Any) -> None:
        """bulk-assign rejects invalid policy type."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,invalid-type,SomePolicy"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--no-color",
                    "policies",
                    "bulk-assign",
                    str(csv_file),
                    "--dry-run",
                ],
            )
            # Exit code 1 due to validation errors in dry-run
            assert result.exit_code == 1
            assert "Invalid policy type" in result.stdout
            assert "invalid-type" in result.stdout

    @pytest.mark.unit
    def test_bulk_assign_resolves_policy_type_aliases(self, tmp_path: Any) -> None:
        """bulk-assign correctly resolves CLI policy type aliases."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,caller-id,CallerIdPolicy"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        policy = _make_policy(
            policy_type="CallingLineIdentity", policy_name="CallerIdPolicy"
        )

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--json",
                    "policies",
                    "bulk-assign",
                    str(csv_file),
                    "--dry-run",
                ],
            )
            assert result.exit_code == 0
            data = json.loads(result.stdout)
            # Verify the resolved type is CallingLineIdentity
            assert data["rows"][0]["policy_type"] == "CallingLineIdentity"

    @pytest.mark.unit
    def test_bulk_assign_empty_csv(self, tmp_path: Any) -> None:
        """bulk-assign handles CSV with only header row."""
        csv_content = """user,policy_type,policy_name"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        result = runner.invoke(
            app,
            [
                "--no-color",
                "policies",
                "bulk-assign",
                str(csv_file),
                "--dry-run",
            ],
        )
        assert result.exit_code == 0
        assert "No data rows found" in result.stdout

    @pytest.mark.unit
    def test_bulk_assign_missing_columns(self, tmp_path: Any) -> None:
        """bulk-assign rejects CSV missing required columns."""
        csv_content = """user,policy_type
john@contoso.com,calling"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        result = runner.invoke(
            app,
            [
                "--no-color",
                "policies",
                "bulk-assign",
                str(csv_file),
                "--dry-run",
            ],
        )
        assert result.exit_code == 5  # ValidationError exit code
        assert "policy_name" in result.stdout

    @pytest.mark.unit
    def test_bulk_assign_staleness_warning(self, tmp_path: Any) -> None:
        """bulk-assign shows staleness warning when cache is stale."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,calling,AllowCalling"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        policy = _make_policy(
            policy_type="TeamsCallingPolicy", policy_name="AllowCalling"
        )

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = (
                "Policy cache is 45 days old (threshold: 30 days). "
                "Consider refreshing by running the PowerShell export script."
            )
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--no-color",
                    "policies",
                    "bulk-assign",
                    str(csv_file),
                    "--dry-run",
                ],
            )
            assert result.exit_code == 0
            assert "days old" in result.stdout

    @pytest.mark.unit
    def test_bulk_assign_continue_on_error_flag(self, tmp_path: "Path") -> None:
        """bulk-assign with --continue-on-error processes valid rows."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,calling,AllowCalling
jane@contoso.com,calling,NonexistentPolicy"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        policy = _make_policy(
            policy_type="TeamsCallingPolicy", policy_name="AllowCalling"
        )
        user = _make_user(upn="john@contoso.com")

        with (
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
            patch("teams_phone.cli.policies.CacheManager"),
            patch("teams_phone.cli.policies.AuthService"),
            patch("teams_phone.cli.policies.GraphClient") as mock_gc_class,
            patch("teams_phone.cli.policies.PolicyService") as mock_ps_class,
            patch("teams_phone.cli.policies.UserService") as mock_us_class,
            patch("teams_phone.cli.policies.write_policy_results_csv"),
        ):
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_service.validate_policy.side_effect = [
                policy,
                NotFoundError("Policy 'NonexistentPolicy' not found"),
            ]
            mock_get_service.return_value = mock_service

            mock_gc = AsyncMock()
            mock_gc.__aenter__.return_value = mock_gc
            mock_gc.__aexit__.return_value = None
            mock_gc_class.return_value = mock_gc

            mock_ps = MagicMock()
            mock_ps.assign_policy = AsyncMock()
            mock_ps_class.return_value = mock_ps

            mock_us = MagicMock()
            mock_us.resolve_user = AsyncMock(return_value=user)
            mock_us_class.return_value = mock_us

            result = runner.invoke(
                app,
                [
                    "--no-color",
                    "policies",
                    "bulk-assign",
                    str(csv_file),
                    "--continue-on-error",
                    "--yes",
                ],
            )
            # Should succeed because valid row was assigned successfully
            assert result.exit_code == 0
            assert "Execution Summary:" in result.stdout
            assert "Successful:" in result.stdout
            # Verify assignment was called once (for the valid row)
            mock_ps.assign_policy.assert_called_once()

    @pytest.mark.unit
    def test_bulk_assign_validation_fails_without_continue_on_error(
        self, tmp_path: Any
    ) -> None:
        """bulk-assign fails validation without --continue-on-error."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,calling,AllowCalling
jane@contoso.com,calling,NonexistentPolicy"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        policy = _make_policy(
            policy_type="TeamsCallingPolicy", policy_name="AllowCalling"
        )

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_service.validate_policy.side_effect = [
                policy,
                NotFoundError("Policy 'NonexistentPolicy' not found"),
            ]
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--no-color",
                    "policies",
                    "bulk-assign",
                    str(csv_file),
                    "--yes",
                ],
            )
            # Should fail because there are validation errors
            assert result.exit_code == 5  # ValidationError exit code
            assert "Validation failed" in result.stdout

    @pytest.mark.unit
    def test_bulk_assign_missing_required_fields_in_rows(self, tmp_path: Any) -> None:
        """bulk-assign shows errors for missing required fields in rows."""
        csv_content = """user,policy_type,policy_name
,calling,AllowCalling
john@contoso.com,,AllowCalling
john@contoso.com,calling,"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        with patch("teams_phone.cli.policies._get_sync_service") as mock_get_service:
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_get_service.return_value = mock_service

            result = runner.invoke(
                app,
                [
                    "--no-color",
                    "policies",
                    "bulk-assign",
                    str(csv_file),
                    "--dry-run",
                ],
            )
            assert result.exit_code == 1
            assert "Errors:" in result.stdout
            assert "Missing required field: user" in result.stdout
            assert "Missing required field: policy_type" in result.stdout
            assert "Missing required field: policy_name" in result.stdout

    @pytest.mark.unit
    def test_bulk_assign_execution_success(self, tmp_path: "Path") -> None:
        """bulk-assign executes assignments and shows success."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,calling,AllowCalling"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        policy = _make_policy(
            policy_type="TeamsCallingPolicy", policy_name="AllowCalling"
        )
        user = _make_user()

        with (
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
            patch("teams_phone.cli.policies.CacheManager"),
            patch("teams_phone.cli.policies.AuthService"),
            patch("teams_phone.cli.policies.GraphClient") as mock_gc_class,
            patch("teams_phone.cli.policies.PolicyService") as mock_ps_class,
            patch("teams_phone.cli.policies.UserService") as mock_us_class,
            patch("teams_phone.cli.policies.write_policy_results_csv"),
        ):
            # Mock validation service
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            # Mock GraphClient context manager
            mock_gc = AsyncMock()
            mock_gc.__aenter__.return_value = mock_gc
            mock_gc.__aexit__.return_value = None
            mock_gc_class.return_value = mock_gc

            # Mock PolicyService
            mock_ps = MagicMock()
            mock_ps.assign_policy = AsyncMock()
            mock_ps_class.return_value = mock_ps

            # Mock UserService
            mock_us = MagicMock()
            mock_us.resolve_user = AsyncMock(return_value=user)
            mock_us_class.return_value = mock_us

            result = runner.invoke(
                app,
                ["--no-color", "policies", "bulk-assign", str(csv_file), "--yes"],
            )
            assert result.exit_code == 0
            assert "Execution Summary:" in result.stdout
            assert "Successful:" in result.stdout
            # Verify services were called
            mock_us.resolve_user.assert_called_once_with("john@contoso.com")
            mock_ps.assign_policy.assert_called_once()

    @pytest.mark.unit
    def test_bulk_assign_execution_json_output(self, tmp_path: "Path") -> None:
        """bulk-assign --json outputs structured JSON with results."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,calling,AllowCalling"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        policy = _make_policy(
            policy_type="TeamsCallingPolicy", policy_name="AllowCalling"
        )
        user = _make_user()

        with (
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
            patch("teams_phone.cli.policies.CacheManager"),
            patch("teams_phone.cli.policies.AuthService"),
            patch("teams_phone.cli.policies.GraphClient") as mock_gc_class,
            patch("teams_phone.cli.policies.PolicyService") as mock_ps_class,
            patch("teams_phone.cli.policies.UserService") as mock_us_class,
            patch("teams_phone.cli.policies.write_policy_results_csv"),
        ):
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            mock_gc = AsyncMock()
            mock_gc.__aenter__.return_value = mock_gc
            mock_gc.__aexit__.return_value = None
            mock_gc_class.return_value = mock_gc

            mock_ps = MagicMock()
            mock_ps.assign_policy = AsyncMock()
            mock_ps_class.return_value = mock_ps

            mock_us = MagicMock()
            mock_us.resolve_user = AsyncMock(return_value=user)
            mock_us_class.return_value = mock_us

            result = runner.invoke(
                app,
                ["--json", "policies", "bulk-assign", str(csv_file), "--yes"],
            )
            assert result.exit_code == 0
            data = json.loads(result.stdout)
            assert data["status"] == "completed"
            assert data["summary"]["successful"] == 1
            assert data["summary"]["failed"] == 0
            assert len(data["results"]) == 1
            assert data["results"][0]["status"] == "success"

    @pytest.mark.unit
    def test_bulk_assign_results_csv_written(self, tmp_path: "Path") -> None:
        """bulk-assign writes results CSV file."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,calling,AllowCalling"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")
        output_file = tmp_path / "results.csv"

        policy = _make_policy(
            policy_type="TeamsCallingPolicy", policy_name="AllowCalling"
        )
        user = _make_user()

        with (
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
            patch("teams_phone.cli.policies.CacheManager"),
            patch("teams_phone.cli.policies.AuthService"),
            patch("teams_phone.cli.policies.GraphClient") as mock_gc_class,
            patch("teams_phone.cli.policies.PolicyService") as mock_ps_class,
            patch("teams_phone.cli.policies.UserService") as mock_us_class,
        ):
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            mock_gc = AsyncMock()
            mock_gc.__aenter__.return_value = mock_gc
            mock_gc.__aexit__.return_value = None
            mock_gc_class.return_value = mock_gc

            mock_ps = MagicMock()
            mock_ps.assign_policy = AsyncMock()
            mock_ps_class.return_value = mock_ps

            mock_us = MagicMock()
            mock_us.resolve_user = AsyncMock(return_value=user)
            mock_us_class.return_value = mock_us

            result = runner.invoke(
                app,
                [
                    "--no-color",
                    "policies",
                    "bulk-assign",
                    str(csv_file),
                    "--yes",
                    "--output",
                    str(output_file),
                ],
            )
            assert result.exit_code == 0
            assert output_file.exists()
            # Check contents
            contents = output_file.read_text(encoding="utf-8")
            assert "row,user,policy_type,policy_name,status,error" in contents
            assert "john@contoso.com" in contents
            assert "success" in contents

    @pytest.mark.unit
    def test_bulk_assign_continue_on_error_execution(self, tmp_path: "Path") -> None:
        """bulk-assign --continue-on-error processes all rows despite failures."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,calling,AllowCalling
jane@contoso.com,calling,AllowCalling"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        policy = _make_policy(
            policy_type="TeamsCallingPolicy", policy_name="AllowCalling"
        )
        user1 = _make_user(user_id="user-1", upn="john@contoso.com")
        user2 = _make_user(user_id="user-2", upn="jane@contoso.com")

        with (
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
            patch("teams_phone.cli.policies.CacheManager"),
            patch("teams_phone.cli.policies.AuthService"),
            patch("teams_phone.cli.policies.GraphClient") as mock_gc_class,
            patch("teams_phone.cli.policies.PolicyService") as mock_ps_class,
            patch("teams_phone.cli.policies.UserService") as mock_us_class,
            patch("teams_phone.cli.policies.write_policy_results_csv"),
        ):
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            mock_gc = AsyncMock()
            mock_gc.__aenter__.return_value = mock_gc
            mock_gc.__aexit__.return_value = None
            mock_gc_class.return_value = mock_gc

            mock_ps = MagicMock()
            # First call fails, second succeeds
            mock_ps.assign_policy = AsyncMock(
                side_effect=[NotFoundError("User not found"), None]
            )
            mock_ps_class.return_value = mock_ps

            mock_us = MagicMock()
            mock_us.resolve_user = AsyncMock(side_effect=[user1, user2])
            mock_us_class.return_value = mock_us

            result = runner.invoke(
                app,
                [
                    "--no-color",
                    "policies",
                    "bulk-assign",
                    str(csv_file),
                    "--yes",
                    "--continue-on-error",
                ],
            )
            # Should exit 1 due to failures
            assert result.exit_code == 1
            assert "Successful:" in result.stdout
            assert "Failed:" in result.stdout
            # Verify both users were processed
            assert mock_ps.assign_policy.call_count == 2

    @pytest.mark.unit
    def test_bulk_assign_stop_on_first_error(self, tmp_path: "Path") -> None:
        """bulk-assign stops on first error without --continue-on-error."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,calling,AllowCalling
jane@contoso.com,calling,AllowCalling"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        policy = _make_policy(
            policy_type="TeamsCallingPolicy", policy_name="AllowCalling"
        )
        user1 = _make_user(user_id="user-1", upn="john@contoso.com")

        with (
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
            patch("teams_phone.cli.policies.CacheManager"),
            patch("teams_phone.cli.policies.AuthService"),
            patch("teams_phone.cli.policies.GraphClient") as mock_gc_class,
            patch("teams_phone.cli.policies.PolicyService") as mock_ps_class,
            patch("teams_phone.cli.policies.UserService") as mock_us_class,
            patch("teams_phone.cli.policies.write_policy_results_csv"),
        ):
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            mock_gc = AsyncMock()
            mock_gc.__aenter__.return_value = mock_gc
            mock_gc.__aexit__.return_value = None
            mock_gc_class.return_value = mock_gc

            mock_ps = MagicMock()
            # First call fails
            mock_ps.assign_policy = AsyncMock(
                side_effect=NotFoundError("User not found")
            )
            mock_ps_class.return_value = mock_ps

            mock_us = MagicMock()
            mock_us.resolve_user = AsyncMock(return_value=user1)
            mock_us_class.return_value = mock_us

            result = runner.invoke(
                app,
                ["--no-color", "policies", "bulk-assign", str(csv_file), "--yes"],
            )
            assert result.exit_code == 1
            # Only one assignment attempt (stopped on first error)
            assert mock_ps.assign_policy.call_count == 1
            # Second row should be skipped
            assert "Skipped:" in result.stdout

    @pytest.mark.unit
    def test_bulk_assign_summary_counts_displayed(self, tmp_path: "Path") -> None:
        """bulk-assign displays correct summary counts."""
        csv_content = """user,policy_type,policy_name
john@contoso.com,calling,AllowCalling
jane@contoso.com,calling,AllowCalling
bob@contoso.com,calling,AllowCalling"""
        csv_file = tmp_path / "policies.csv"
        csv_file.write_text(csv_content, encoding="utf-8")

        policy = _make_policy(
            policy_type="TeamsCallingPolicy", policy_name="AllowCalling"
        )
        user1 = _make_user(user_id="user-1", upn="john@contoso.com")
        user2 = _make_user(user_id="user-2", upn="jane@contoso.com")
        user3 = _make_user(user_id="user-3", upn="bob@contoso.com")

        with (
            patch("teams_phone.cli.policies._get_sync_service") as mock_get_service,
            patch("teams_phone.cli.policies.CacheManager"),
            patch("teams_phone.cli.policies.AuthService"),
            patch("teams_phone.cli.policies.GraphClient") as mock_gc_class,
            patch("teams_phone.cli.policies.PolicyService") as mock_ps_class,
            patch("teams_phone.cli.policies.UserService") as mock_us_class,
            patch("teams_phone.cli.policies.write_policy_results_csv"),
        ):
            mock_service = MagicMock()
            mock_service.get_staleness_warning.return_value = None
            mock_service.validate_policy.return_value = policy
            mock_get_service.return_value = mock_service

            mock_gc = AsyncMock()
            mock_gc.__aenter__.return_value = mock_gc
            mock_gc.__aexit__.return_value = None
            mock_gc_class.return_value = mock_gc

            mock_ps = MagicMock()
            # First succeeds, second fails, third succeeds
            mock_ps.assign_policy = AsyncMock(
                side_effect=[None, NotFoundError("Policy not found"), None]
            )
            mock_ps_class.return_value = mock_ps

            mock_us = MagicMock()
            mock_us.resolve_user = AsyncMock(side_effect=[user1, user2, user3])
            mock_us_class.return_value = mock_us

            result = runner.invoke(
                app,
                [
                    "--no-color",
                    "policies",
                    "bulk-assign",
                    str(csv_file),
                    "--yes",
                    "--continue-on-error",
                ],
            )
            assert result.exit_code == 1  # Failed count > 0
            assert "Execution Summary:" in result.stdout
            # Check summary values appear
            assert "Successful:" in result.stdout
            assert "Failed:" in result.stdout
            assert "Skipped:" in result.stdout
