"""Phone number management commands for Teams Phone CLI.

This module provides CLI commands for listing, showing, and searching
phone numbers in the tenant inventory.
"""

from __future__ import annotations

import asyncio
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional

import typer

from teams_phone.cli.context import get_context
from teams_phone.exceptions import ConfigurationError, TeamsPhoneError, ValidationError
from teams_phone.infrastructure import CacheManager, ConfigManager, GraphClient
from teams_phone.models import (
    ActivationState,
    AssignmentStatus,
    AsyncOperation,
    NumberAssignment,
    NumberType,
    OperationStatus,
)
from teams_phone.services import AuthService
from teams_phone.services.bulk_operations import (
    BulkAssignResult,
    BulkOperations,
    BulkValidationResult,
    parse_assign_csv,
    write_results_csv,
)
from teams_phone.services.location_service import LocationService
from teams_phone.services.number_service import NumberService
from teams_phone.services.user_service import UserService

if TYPE_CHECKING:
    from teams_phone.infrastructure import OutputFormatter
    from teams_phone.models import EmergencyLocation, UserConfiguration

numbers_app = typer.Typer(
    name="numbers",
    help="Manage phone numbers.",
    no_args_is_help=True,
)


def _run_async(
    ctx: typer.Context,
    coro_factory: Any,
) -> tuple[Any, "OutputFormatter"]:
    """Run an async operation with NumberService.

    Creates the required infrastructure (GraphClient, NumberService) and runs
    the async operation using asyncio.run().

    Args:
        ctx: Typer context with CLIContext.
        coro_factory: A callable that takes NumberService and returns a coroutine.

    Returns:
        Tuple of (result, OutputFormatter) where result is the coroutine's return value.
    """
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()
    config_manager = cli_ctx.get_config_manager()
    cache_manager = CacheManager()
    auth_service = AuthService(config_manager, cache_manager)

    async def _execute() -> Any:
        async with GraphClient(auth_service) as graph_client:
            service = NumberService(graph_client)
            return await coro_factory(service)

    result = asyncio.run(_execute())
    return result, formatter


class AssignServices:
    """Container for services needed by assign command."""

    def __init__(
        self,
        user_service: UserService,
        number_service: NumberService,
        location_service: LocationService,
        config_manager: ConfigManager,
    ) -> None:
        self.user_service = user_service
        self.number_service = number_service
        self.location_service = location_service
        self.config_manager = config_manager


def _run_assign_async(
    ctx: typer.Context,
    coro_factory: Any,
) -> tuple[Any, "OutputFormatter"]:
    """Run an async operation with multiple services for assign/unassign.

    Creates UserService, NumberService, LocationService and runs
    the async operation using asyncio.run().

    Args:
        ctx: Typer context with CLIContext.
        coro_factory: A callable that takes AssignServices and returns a coroutine.

    Returns:
        Tuple of (result, OutputFormatter) where result is the coroutine's return value.
    """
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()
    config_manager = cli_ctx.get_config_manager()
    cache_manager = CacheManager()
    auth_service = AuthService(config_manager, cache_manager)

    async def _execute() -> Any:
        async with GraphClient(auth_service) as graph_client:
            services = AssignServices(
                user_service=UserService(graph_client),
                number_service=NumberService(graph_client),
                location_service=LocationService(cache_manager),
                config_manager=config_manager,
            )
            return await coro_factory(services)

    result = asyncio.run(_execute())
    return result, formatter


def _run_bulk_assign_async(
    ctx: typer.Context,
    coro_factory: Callable[[BulkOperations, ConfigManager], Coroutine[Any, Any, Any]],
) -> tuple[Any, "OutputFormatter"]:
    """Run an async operation with BulkOperations for bulk-assign.

    Creates UserService, NumberService, LocationService, and BulkOperations,
    then runs the async operation using asyncio.run().

    Args:
        ctx: Typer context with CLIContext.
        coro_factory: A callable that takes (BulkOperations, ConfigManager)
            and returns a coroutine.

    Returns:
        Tuple of (result, OutputFormatter) where result is the coroutine's return value.
    """
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()
    config_manager = cli_ctx.get_config_manager()
    cache_manager = CacheManager()
    auth_service = AuthService(config_manager, cache_manager)

    async def _execute() -> Any:
        async with GraphClient(auth_service) as graph_client:
            user_service = UserService(graph_client)
            number_service = NumberService(graph_client)
            location_service = LocationService(cache_manager)
            bulk_ops = BulkOperations(user_service, number_service, location_service)
            return await coro_factory(bulk_ops, config_manager)

    result = asyncio.run(_execute())
    return result, formatter


def _run_with_progress(
    formatter: "OutputFormatter",
    coro_factory: Callable[
        [Callable[[AsyncOperation], None] | None], Coroutine[Any, Any, AsyncOperation]
    ],
    initial_message: str = "Waiting for operation...",
) -> AsyncOperation:
    """Run an async operation with progress spinner display.

    Displays a Rich Status spinner during async polling operations. The spinner
    text updates with the current operation status on each poll callback.
    In JSON mode, runs without spinner display.

    Args:
        formatter: OutputFormatter for console access and json_mode check.
        coro_factory: A callable that takes an on_progress callback and returns
            an awaitable that yields AsyncOperation.
        initial_message: Initial status message shown in spinner.

    Returns:
        The final AsyncOperation result.
    """
    if formatter.json_mode:
        # No spinner in JSON mode - run silently
        return asyncio.run(coro_factory(None))

    # Use Rich Status for spinner display during polling
    status_holder: dict[str, Any] = {"status": None}

    def on_progress(operation: AsyncOperation) -> None:
        """Update spinner text with operation status."""
        if status_holder["status"] is not None:
            status_text = _format_operation_status(operation.status)
            status_holder["status"].update(f"Waiting for operation... ({status_text})")

    async def _execute_with_spinner() -> AsyncOperation:
        spinner_name = formatter.get_spinner_name()
        with formatter.console.status(initial_message, spinner=spinner_name) as status:
            status_holder["status"] = status
            return await coro_factory(on_progress)

    return asyncio.run(_execute_with_spinner())


def _format_status(status: AssignmentStatus) -> str:
    """Format assignment status for display."""
    display_names = {
        AssignmentStatus.UNASSIGNED: "Unassigned",
        AssignmentStatus.USER_ASSIGNED: "Assigned",
        AssignmentStatus.CONFERENCE_ASSIGNED: "Conference",
        AssignmentStatus.VOICE_APPLICATION_ASSIGNED: "Voice App",
    }
    return display_names.get(status, status.value)


def _format_number_type(number_type: NumberType) -> str:
    """Format number type for display."""
    display_names = {
        NumberType.DIRECT_ROUTING: "Direct Routing",
        NumberType.CALLING_PLAN: "Calling Plan",
        NumberType.OPERATOR_CONNECT: "Operator Connect",
    }
    return display_names.get(number_type, number_type.value)


def _format_activation_state(state: ActivationState) -> str:
    """Format activation state for display."""
    display_names = {
        ActivationState.ACTIVATED: "Activated",
        ActivationState.ASSIGNMENT_PENDING: "Assignment Pending",
        ActivationState.ASSIGNMENT_FAILED: "Assignment Failed",
        ActivationState.UPDATE_PENDING: "Update Pending",
        ActivationState.UPDATE_FAILED: "Update Failed",
    }
    return display_names.get(state, state.value)


def _format_capabilities(capabilities: list[str]) -> str:
    """Format capabilities list for display."""
    if not capabilities:
        return "—"
    # Shorten common capability names
    short_names = {
        "userAssignment": "User",
        "serviceAssignment": "Service",
        "conferenceAssignment": "Conference",
    }
    formatted = [short_names.get(c, c) for c in capabilities]
    return ", ".join(formatted)


def _number_to_list_dict(number: NumberAssignment) -> dict[str, Any]:
    """Convert NumberAssignment to dict for list/search table display."""
    return {
        "phone_number": number.telephone_number,
        "status": _format_status(number.assignment_status),
        "type": _format_number_type(number.number_type),
        "city": number.city or "—",
        "capabilities": _format_capabilities(number.capabilities),
    }


def _number_to_json_dict(number: NumberAssignment) -> dict[str, Any]:
    """Convert NumberAssignment to dict for JSON output."""
    return {
        "id": number.id,
        "telephone_number": number.telephone_number,
        "number_type": number.number_type.value,
        "assignment_status": number.assignment_status.value,
        "activation_state": number.activation_state.value,
        "capabilities": number.capabilities,
        "city": number.city,
        "iso_country_code": number.iso_country_code,
        "location_id": number.location_id,
        "civic_address_id": number.civic_address_id,
        "network_site_id": number.network_site_id,
        "assignment_target_id": number.assignment_target_id,
        "assignment_category": (
            number.assignment_category.value if number.assignment_category else None
        ),
        "operator_id": number.operator_id,
        "port_in_status": number.port_in_status,
        "number_source": number.number_source,
        "supported_customer_actions": number.supported_customer_actions,
    }


@numbers_app.command("list")
def list_numbers(
    ctx: typer.Context,
    status: Optional[AssignmentStatus] = typer.Option(
        None,
        "--status",
        "-s",
        help="Filter by assignment status (unassigned, userAssigned, conferenceAssigned, voiceApplicationAssigned).",
    ),
    number_type: Optional[NumberType] = typer.Option(
        None,
        "--type",
        "-t",
        help="Filter by number type (directRouting, callingPlan, operatorConnect).",
    ),
    city: Optional[str] = typer.Option(
        None,
        "--city",
        "-c",
        help="Filter by city (case-sensitive, client-side filter).",
    ),
    limit: Optional[int] = typer.Option(
        None,
        "--limit",
        "-l",
        help="Limit results to n numbers.",
    ),
    all_numbers: bool = typer.Option(
        False,
        "--all",
        help="Fetch all numbers (may be slow for large inventories).",
    ),
) -> None:
    """List phone numbers in inventory."""
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()

    # Validate mutually exclusive options
    if limit is not None and all_numbers:
        formatter.error(
            "Cannot specify both --limit and --all",
            remediation="Use either --limit <n> to limit results or --all to fetch all numbers.",
        )
        raise typer.Exit(5)  # ValidationError exit code

    # Determine max_items: None for --all, provided limit, or default 100
    if all_numbers:
        max_items = None
    elif limit is not None:
        max_items = limit
    else:
        max_items = 100  # Default limit

    try:
        numbers, _ = _run_async(
            ctx,
            lambda service: service.list_numbers(
                status=status,
                number_type=number_type,
                city=city,
                max_items=max_items,
            ),
        )

        if cli_ctx.json_output:
            formatter.json([_number_to_json_dict(n) for n in numbers])
        else:
            if not numbers:
                formatter.info("No numbers found matching the specified criteria")
            else:
                formatter.table(
                    data=[_number_to_list_dict(n) for n in numbers],
                    columns=[
                        "phone_number",
                        "status",
                        "type",
                        "city",
                        "capabilities",
                    ],
                    title="Phone Numbers",
                )
    except TeamsPhoneError as e:
        formatter.error(e.message, remediation=e.remediation)
        raise typer.Exit(e.exit_code)


@numbers_app.command("show")
def show_number(
    ctx: typer.Context,
    number: str = typer.Argument(
        ...,
        help="Phone number (E.164 format or partial).",
    ),
) -> None:
    """Show detailed information for a specific phone number."""
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()

    try:
        number_info, _ = _run_async(
            ctx,
            lambda service: service.get_number(number),
        )

        if cli_ctx.json_output:
            formatter.json(_number_to_json_dict(number_info))
        else:
            # Number Details section
            formatter.info(f"Phone Number: {number_info.telephone_number}")
            formatter.info(f"ID: {number_info.id}")
            if number_info.operator_id:
                formatter.info(f"Operator ID: {number_info.operator_id}")

            # Assignment section
            formatter.info("")
            formatter.info("Assignment:")
            formatter.info(f"  Status: {_format_status(number_info.assignment_status)}")
            if number_info.assignment_target_id:
                formatter.info(f"  Assigned To: {number_info.assignment_target_id}")
            if number_info.assignment_category:
                formatter.info(f"  Category: {number_info.assignment_category.value}")

            # Activation section
            formatter.info("")
            formatter.info("Activation:")
            formatter.info(
                f"  State: {_format_activation_state(number_info.activation_state)}"
            )

            # Source section
            formatter.info("")
            formatter.info("Source:")
            formatter.info(
                f"  Number Type: {_format_number_type(number_info.number_type)}"
            )
            if number_info.number_source:
                formatter.info(f"  Source: {number_info.number_source}")
            if number_info.port_in_status:
                formatter.info(f"  Port-In Status: {number_info.port_in_status}")

            # Capabilities section
            formatter.info("")
            formatter.info("Capabilities:")
            if number_info.capabilities:
                for cap in number_info.capabilities:
                    formatter.info(f"  - {cap}")
            else:
                formatter.info("  None")

            # Location section
            formatter.info("")
            formatter.info("Location:")
            formatter.info(f"  City: {number_info.city or '—'}")
            formatter.info(f"  Country: {number_info.iso_country_code or '—'}")
            if number_info.location_id:
                formatter.info(f"  Emergency Location ID: {number_info.location_id}")
            if number_info.civic_address_id:
                formatter.info(f"  Civic Address ID: {number_info.civic_address_id}")

            # Network section (if applicable)
            if number_info.network_site_id:
                formatter.info("")
                formatter.info("Network:")
                formatter.info(f"  Network Site ID: {number_info.network_site_id}")

    except TeamsPhoneError as e:
        formatter.error(e.message, remediation=e.remediation)
        raise typer.Exit(e.exit_code)


@numbers_app.command("search")
def search_numbers(
    ctx: typer.Context,
    pattern: str = typer.Argument(
        ...,
        help="Search pattern (supports wildcards: 206*, *1234, *555*).",
    ),
    limit: int = typer.Option(
        25,
        "--limit",
        "-l",
        help="Maximum results (default: 25).",
    ),
) -> None:
    """Search phone numbers by pattern.

    Supports wildcards: '206*' (prefix), '*1234' (suffix), '*555*' (contains).
    Note: Searches all numbers client-side, may be slow for large inventories.
    """
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()

    try:
        numbers, _ = _run_async(
            ctx,
            lambda service: service.search_numbers(pattern, max_results=limit),
        )

        if cli_ctx.json_output:
            formatter.json([_number_to_json_dict(n) for n in numbers])
        else:
            if not numbers:
                formatter.info(f"No numbers found matching '{pattern}'")
            else:
                formatter.table(
                    data=[_number_to_list_dict(n) for n in numbers],
                    columns=[
                        "phone_number",
                        "status",
                        "type",
                        "city",
                        "capabilities",
                    ],
                    title=f"Search Results for '{pattern}'",
                )
    except TeamsPhoneError as e:
        formatter.error(e.message, remediation=e.remediation)
        raise typer.Exit(e.exit_code)


def _format_operation_status(status: OperationStatus) -> str:
    """Format operation status for display."""
    display_names = {
        OperationStatus.NOT_STARTED: "Not Started",
        OperationStatus.RUNNING: "Running",
        OperationStatus.SUCCEEDED: "Succeeded",
        OperationStatus.FAILED: "Failed",
        OperationStatus.SKIPPED: "Skipped",
    }
    return display_names.get(status, status.value)


def _operation_to_json_dict(
    operation: AsyncOperation,
    user: "UserConfiguration",
    phone_number: str,
    number_type: NumberType,
    location: "EmergencyLocation | None",
) -> dict[str, Any]:
    """Convert AsyncOperation to dict for JSON output."""
    return {
        "operation_id": operation.id,
        "status": operation.status.value,
        "created_date_time": operation.created_date_time.isoformat(),
        "user_id": user.id,
        "user_principal_name": user.user_principal_name,
        "display_name": user.display_name,
        "phone_number": phone_number,
        "number_type": number_type.value,
        "location_id": location.location_id if location else None,
        "location_description": location.description if location else None,
    }


@numbers_app.command("assign")
def assign_number(
    ctx: typer.Context,
    user: str = typer.Argument(
        ...,
        help="User identifier (UPN, display name, or object ID).",
    ),
    number: str = typer.Argument(
        ...,
        help="Phone number to assign (E.164 format or partial).",
    ),
    location: Optional[str] = typer.Option(
        None,
        "--location",
        "-l",
        help="Emergency location ID or description. Required for Calling Plan numbers.",
    ),
    number_type: NumberType = typer.Option(
        NumberType.CALLING_PLAN,
        "--type",
        "-t",
        help="Number type (directRouting, callingPlan, operatorConnect).",
    ),
    dry_run: bool = typer.Option(
        False,
        "--dry-run",
        help="Preview assignment without making changes.",
    ),
    no_wait: bool = typer.Option(
        False,
        "--no-wait",
        help="Return immediately without waiting for operation to complete.",
    ),
    force: bool = typer.Option(
        False,
        "--force",
        "-f",
        help="Skip confirmation prompt.",
    ),
) -> None:
    """Assign a phone number to a user.

    Resolves the user by UPN, display name, or object ID, validates the
    emergency location for Calling Plan numbers, and assigns the phone number.

    Examples:

        teams-phone numbers assign john@contoso.com +14255551234

        teams-phone numbers assign "John Smith" +14255551234 --location "Seattle HQ"

        teams-phone numbers assign john@contoso.com +14255551234 --dry-run
    """
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()

    async def _resolve(
        services: AssignServices,
    ) -> tuple[
        "UserConfiguration",
        "EmergencyLocation | None",
    ]:
        # Step 1: Resolve user
        resolved_user = await services.user_service.resolve_user(user)

        # Step 2: Resolve location
        resolved_location: EmergencyLocation | None = None

        if location:
            # Explicit location provided
            resolved_location = services.location_service.validate_location(location)
        elif number_type == NumberType.CALLING_PLAN:
            # Check tenant default location
            default_tenant_name = services.config_manager.get_default_tenant()
            if default_tenant_name:
                tenant_profile = services.config_manager.get_tenant(default_tenant_name)
                if tenant_profile.default_location:
                    resolved_location = services.location_service.validate_location(
                        tenant_profile.default_location
                    )
                    return resolved_user, resolved_location

            raise ValidationError(
                f"Emergency location required for Calling Plan number {number}",
                remediation=(
                    "Use --location to specify an emergency location, or\n"
                    "set default_location in tenant config with:\n"
                    "  teams-phone tenants update <name> --default-location <id>"
                ),
            )

        return resolved_user, resolved_location

    try:
        (resolved_user, resolved_location), _ = _run_assign_async(ctx, _resolve)

        # Handle dry-run output
        if dry_run:
            if cli_ctx.json_output:
                formatter.json(
                    {
                        "dry_run": True,
                        "user_id": resolved_user.id,
                        "user_principal_name": resolved_user.user_principal_name,
                        "display_name": resolved_user.display_name,
                        "phone_number": number,
                        "number_type": number_type.value,
                        "location_id": resolved_location.location_id
                        if resolved_location
                        else None,
                        "location_description": (
                            resolved_location.description if resolved_location else None
                        ),
                    }
                )
            else:
                formatter.info("Dry Run Preview:")
                formatter.info(
                    f"  User: {resolved_user.display_name or resolved_user.user_principal_name}"
                )
                formatter.info(f"  User ID: {resolved_user.id}")
                formatter.info(f"  Phone Number: {number}")
                formatter.info(f"  Number Type: {_format_number_type(number_type)}")
                if resolved_location:
                    formatter.info(f"  Location: {resolved_location.description}")
                    formatter.info(f"  Location ID: {resolved_location.location_id}")
                formatter.info("")
                formatter.info("No changes made (dry run)")
            raise typer.Exit(0)

        # Confirm before executing (unless --force or --json)
        if not force and not cli_ctx.json_output:
            formatter.info("Assignment Details:")
            formatter.info(
                f"  User: {resolved_user.display_name or resolved_user.user_principal_name}"
            )
            formatter.info(f"  Phone Number: {number}")
            formatter.info(f"  Number Type: {_format_number_type(number_type)}")
            if resolved_location:
                formatter.info(f"  Location: {resolved_location.description}")
            formatter.info("")
            confirm = typer.confirm("Proceed with assignment?")
            if not confirm:
                formatter.info("Assignment cancelled")
                raise typer.Exit(0)

        # Execute assignment
        if no_wait:
            # No waiting - return immediately after initiating
            async def _execute_assign_no_wait(
                services: AssignServices,
            ) -> AsyncOperation:
                return await services.number_service.assign_number(
                    user_id=resolved_user.id,
                    phone_number=number,
                    number_type=number_type,
                    location_id=resolved_location.location_id
                    if resolved_location
                    else None,
                    wait=False,
                )

            result, _ = _run_assign_async(ctx, _execute_assign_no_wait)
        else:
            # Wait with progress spinner (unless JSON mode)
            config_manager = cli_ctx.get_config_manager()
            cache_manager = CacheManager()
            auth_service = AuthService(config_manager, cache_manager)

            def _make_assign_coro(
                on_progress: Callable[[AsyncOperation], None] | None,
            ) -> Coroutine[Any, Any, AsyncOperation]:
                async def _execute() -> AsyncOperation:
                    async with GraphClient(auth_service) as graph_client:
                        service = NumberService(graph_client)
                        return await service.assign_number(
                            user_id=resolved_user.id,
                            phone_number=number,
                            number_type=number_type,
                            location_id=resolved_location.location_id
                            if resolved_location
                            else None,
                            wait=True,
                            on_progress=on_progress,
                        )

                return _execute()

            result = _run_with_progress(
                formatter,
                _make_assign_coro,
                initial_message="Assigning phone number...",
            )

        # Handle result output
        if cli_ctx.json_output:
            formatter.json(
                _operation_to_json_dict(
                    result, resolved_user, number, number_type, resolved_location
                )
            )
        else:
            if result.status == OperationStatus.SUCCEEDED:
                formatter.success(
                    f"Assigned {number} to {resolved_user.display_name or resolved_user.user_principal_name}"
                )
            elif result.status == OperationStatus.FAILED:
                formatter.error(
                    f"Assignment failed for {number}",
                    remediation=(
                        "Check that the number is available and the user is eligible.\n"
                        f"Operation ID: {result.id}"
                    ),
                )
                raise typer.Exit(1)
            elif no_wait:
                formatter.info(f"Assignment initiated for {number}")
                formatter.info(f"Operation ID: {result.id}")
                formatter.info(f"Status: {_format_operation_status(result.status)}")
                formatter.info("")
                formatter.info("Use the operation ID to check status later.")
            else:
                # Other status (running, not_started)
                formatter.info(
                    f"Assignment status: {_format_operation_status(result.status)}"
                )
                formatter.info(f"Operation ID: {result.id}")

    except TeamsPhoneError as e:
        formatter.error(e.message, remediation=e.remediation)
        raise typer.Exit(e.exit_code)


def _unassign_operation_to_json_dict(
    operation: AsyncOperation,
    phone_number: str,
    number_type: NumberType,
    previous_assignee: str | None,
) -> dict[str, Any]:
    """Convert AsyncOperation to dict for unassign JSON output."""
    return {
        "operation_id": operation.id,
        "status": operation.status.value,
        "phone_number": phone_number,
        "number_type": number_type.value,
        "previous_assignee": previous_assignee,
    }


@numbers_app.command("unassign")
def unassign_number(
    ctx: typer.Context,
    number: str = typer.Argument(
        ...,
        help="Phone number to unassign (E.164 format or partial).",
    ),
    dry_run: bool = typer.Option(
        False,
        "--dry-run",
        help="Preview unassignment without making changes.",
    ),
    no_wait: bool = typer.Option(
        False,
        "--no-wait",
        help="Return immediately without waiting for operation to complete.",
    ),
    force: bool = typer.Option(
        False,
        "--force",
        "-f",
        help="Skip confirmation prompt.",
    ),
) -> None:
    """Unassign a phone number from its current user.

    Looks up the phone number to get its current assignment, displays a
    confirmation prompt (unless --force), and unassigns the number.

    Examples:

        teams-phone numbers unassign +14255551234

        teams-phone numbers unassign +14255551234 --force

        teams-phone numbers unassign +14255551234 --dry-run
    """
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()

    try:
        # Step 1: Look up the number to get current assignment
        number_info, _ = _run_async(
            ctx,
            lambda service: service.get_number(number),
        )

        # Step 2: Validate that the number is currently assigned
        if number_info.assignment_status != AssignmentStatus.USER_ASSIGNED:
            raise ValidationError(
                f"Phone number {number_info.telephone_number} is not assigned to a user",
                remediation=(
                    f"Current status: {_format_status(number_info.assignment_status)}.\n"
                    "Only numbers with status 'Assigned' (userAssigned) can be unassigned."
                ),
            )

        # Handle dry-run output
        if dry_run:
            if cli_ctx.json_output:
                formatter.json(
                    {
                        "dry_run": True,
                        "phone_number": number_info.telephone_number,
                        "number_type": number_info.number_type.value,
                        "current_assignee": number_info.assignment_target_id,
                    }
                )
            else:
                formatter.info("Dry Run Preview:")
                formatter.info(f"  Phone Number: {number_info.telephone_number}")
                formatter.info(
                    f"  Number Type: {_format_number_type(number_info.number_type)}"
                )
                formatter.info(
                    f"  Current Assignee: {number_info.assignment_target_id}"
                )
                formatter.info("")
                formatter.info("No changes made (dry run)")
            raise typer.Exit(0)

        # Confirm before executing (unless --force or --json)
        if not force and not cli_ctx.json_output:
            formatter.info("Unassignment Details:")
            formatter.info(f"  Phone Number: {number_info.telephone_number}")
            formatter.info(
                f"  Number Type: {_format_number_type(number_info.number_type)}"
            )
            formatter.info(f"  Current Assignee: {number_info.assignment_target_id}")
            formatter.info("")
            confirm = typer.confirm("Proceed with unassignment?")
            if not confirm:
                formatter.info("Unassignment cancelled")
                raise typer.Exit(0)

        # Execute unassignment
        if no_wait:
            # No waiting - return immediately after initiating
            async def _execute_unassign_no_wait(
                service: NumberService,
            ) -> AsyncOperation:
                return await service.unassign_number(
                    phone_number=number_info.telephone_number,
                    number_type=number_info.number_type,
                    wait=False,
                )

            result, _ = _run_async(ctx, _execute_unassign_no_wait)
        else:
            # Wait with progress spinner (unless JSON mode)
            config_manager = cli_ctx.get_config_manager()
            cache_manager = CacheManager()
            auth_service = AuthService(config_manager, cache_manager)

            def _make_unassign_coro(
                on_progress: Callable[[AsyncOperation], None] | None,
            ) -> Coroutine[Any, Any, AsyncOperation]:
                async def _execute() -> AsyncOperation:
                    async with GraphClient(auth_service) as graph_client:
                        service = NumberService(graph_client)
                        return await service.unassign_number(
                            phone_number=number_info.telephone_number,
                            number_type=number_info.number_type,
                            wait=True,
                            on_progress=on_progress,
                        )

                return _execute()

            result = _run_with_progress(
                formatter,
                _make_unassign_coro,
                initial_message="Unassigning phone number...",
            )

        # Handle result output
        if cli_ctx.json_output:
            formatter.json(
                _unassign_operation_to_json_dict(
                    result,
                    number_info.telephone_number,
                    number_info.number_type,
                    number_info.assignment_target_id,
                )
            )
        else:
            if result.status == OperationStatus.SUCCEEDED:
                formatter.success(f"Unassigned {number_info.telephone_number}")
            elif result.status == OperationStatus.FAILED:
                formatter.error(
                    f"Unassignment failed for {number_info.telephone_number}",
                    remediation=f"Operation ID: {result.id}",
                )
                raise typer.Exit(1)
            elif no_wait:
                formatter.info(
                    f"Unassignment initiated for {number_info.telephone_number}"
                )
                formatter.info(f"Operation ID: {result.id}")
                formatter.info(f"Status: {_format_operation_status(result.status)}")
                formatter.info("")
                formatter.info("Use the operation ID to check status later.")
            else:
                # Other status (running, not_started)
                formatter.info(
                    f"Unassignment status: {_format_operation_status(result.status)}"
                )
                formatter.info(f"Operation ID: {result.id}")

    except TeamsPhoneError as e:
        formatter.error(e.message, remediation=e.remediation)
        raise typer.Exit(e.exit_code)


def _update_to_json_dict(
    phone_number: str,
    number_info: NumberAssignment,
    location: "EmergencyLocation | None",
    clear_location: bool,
    network_site_id: str | None,
    clear_network_site: bool,
) -> dict[str, Any]:
    """Convert update parameters to dict for JSON output."""
    result: dict[str, Any] = {
        "phone_number": number_info.telephone_number,
        "number_type": number_info.number_type.value,
    }
    if location:
        result["location_id"] = location.location_id
        result["location_description"] = location.description
    elif clear_location:
        result["location_id"] = None
        result["clear_location"] = True
    if network_site_id:
        result["network_site_id"] = network_site_id
    elif clear_network_site:
        result["network_site_id"] = None
        result["clear_network_site"] = True
    return result


@numbers_app.command("update")
def update_number(
    ctx: typer.Context,
    number: str = typer.Argument(
        ...,
        help="Phone number to update (E.164 format or partial).",
    ),
    location: Optional[str] = typer.Option(
        None,
        "--location",
        "-l",
        help="Emergency location ID or description to set.",
    ),
    clear_location: bool = typer.Option(
        False,
        "--clear-location",
        help="Clear the emergency location.",
    ),
    network_site: Optional[str] = typer.Option(
        None,
        "--network-site",
        "-n",
        help="Network site ID to set.",
    ),
    clear_network_site: bool = typer.Option(
        False,
        "--clear-network-site",
        help="Clear the network site.",
    ),
    dry_run: bool = typer.Option(
        False,
        "--dry-run",
        help="Preview update without making changes.",
    ),
    force: bool = typer.Option(
        False,
        "--force",
        "-f",
        help="Skip confirmation prompt.",
    ),
) -> None:
    """Update phone number location or network site settings.

    Change the emergency location or network site on an already-assigned phone
    number. This is a synchronous operation that takes effect immediately.

    Examples:

        teams-phone numbers update +14255551234 --location "Seattle HQ"

        teams-phone numbers update +14255551234 --clear-location

        teams-phone numbers update +14255551234 --network-site "Site-001"

        teams-phone numbers update +14255551234 --location "NYC Office" --dry-run
    """
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()

    # Validate mutually exclusive options
    if location and clear_location:
        formatter.error(
            "Cannot specify both --location and --clear-location",
            remediation="Use either --location <id> to set a location or --clear-location to remove it.",
        )
        raise typer.Exit(5)  # ValidationError exit code
    if network_site and clear_network_site:
        formatter.error(
            "Cannot specify both --network-site and --clear-network-site",
            remediation="Use either --network-site <id> to set a site or --clear-network-site to remove it.",
        )
        raise typer.Exit(5)

    # Validate at least one update is requested
    if not any([location, clear_location, network_site, clear_network_site]):
        formatter.error(
            "No update parameters specified",
            remediation="Use --location, --clear-location, --network-site, or --clear-network-site to specify what to update.",
        )
        raise typer.Exit(5)

    try:
        # Step 1: Look up the number to verify it exists and is assigned
        number_info, _ = _run_async(
            ctx,
            lambda service: service.get_number(number),
        )

        # Step 2: Validate that the number is assigned
        if number_info.assignment_status != AssignmentStatus.USER_ASSIGNED:
            raise ValidationError(
                f"Phone number {number_info.telephone_number} is not assigned to a user",
                remediation=(
                    f"Current status: {_format_status(number_info.assignment_status)}.\n"
                    "Only numbers assigned to users can have their settings updated."
                ),
            )

        # Step 3: Resolve location if provided
        resolved_location: EmergencyLocation | None = None
        if location:
            cache_manager = CacheManager()
            location_service = LocationService(cache_manager)
            resolved_location = location_service.validate_location(location)

        # Handle dry-run output
        if dry_run:
            if cli_ctx.json_output:
                json_result = _update_to_json_dict(
                    number,
                    number_info,
                    resolved_location,
                    clear_location,
                    network_site,
                    clear_network_site,
                )
                json_result["dry_run"] = True
                formatter.json(json_result)
            else:
                formatter.info("Dry Run Preview:")
                formatter.info(f"  Phone Number: {number_info.telephone_number}")
                formatter.info(
                    f"  Number Type: {_format_number_type(number_info.number_type)}"
                )
                if resolved_location:
                    formatter.info(f"  New Location: {resolved_location.description}")
                    formatter.info(f"  Location ID: {resolved_location.location_id}")
                elif clear_location:
                    formatter.info("  Location: Will be cleared")
                if network_site:
                    formatter.info(f"  New Network Site: {network_site}")
                elif clear_network_site:
                    formatter.info("  Network Site: Will be cleared")
                formatter.info("")
                formatter.info("No changes made (dry run)")
            raise typer.Exit(0)

        # Confirm before executing (unless --force or --json)
        if not force and not cli_ctx.json_output:
            formatter.info("Update Details:")
            formatter.info(f"  Phone Number: {number_info.telephone_number}")
            formatter.info(
                f"  Number Type: {_format_number_type(number_info.number_type)}"
            )
            if resolved_location:
                formatter.info(f"  New Location: {resolved_location.description}")
            elif clear_location:
                formatter.info("  Location: Will be cleared")
            if network_site:
                formatter.info(f"  New Network Site: {network_site}")
            elif clear_network_site:
                formatter.info("  Network Site: Will be cleared")
            formatter.info("")
            confirm = typer.confirm("Proceed with update?")
            if not confirm:
                formatter.info("Update cancelled")
                raise typer.Exit(0)

        # Execute update
        async def _execute_update(service: NumberService) -> None:
            await service.update_number(
                phone_number=number_info.telephone_number,
                location_id=resolved_location.location_id
                if resolved_location
                else None,
                clear_location=clear_location,
                network_site_id=network_site,
                clear_network_site=clear_network_site,
            )

        _, _ = _run_async(ctx, _execute_update)

        # Handle result output
        if cli_ctx.json_output:
            json_result = _update_to_json_dict(
                number,
                number_info,
                resolved_location,
                clear_location,
                network_site,
                clear_network_site,
            )
            json_result["status"] = "success"
            formatter.json(json_result)
        else:
            formatter.success(f"Updated {number_info.telephone_number}")

    except TeamsPhoneError as e:
        formatter.error(e.message, remediation=e.remediation)
        raise typer.Exit(e.exit_code)


@numbers_app.command("bulk-assign")
def bulk_assign(
    ctx: typer.Context,
    csv_file: Path = typer.Argument(
        ...,
        help="Path to CSV file with user, phone_number, and optional location columns.",
        exists=True,
        readable=True,
    ),
    dry_run: bool = typer.Option(
        False,
        "--dry-run",
        help="Validate and preview all assignments without executing.",
    ),
    continue_on_error: bool = typer.Option(
        False,
        "--continue-on-error",
        help="Continue processing after individual failures.",
    ),
    default_location: Optional[str] = typer.Option(
        None,
        "--default-location",
        help="Default emergency location for rows without a location column.",
    ),
    output: Optional[Path] = typer.Option(
        None,
        "--output",
        "-o",
        help="Path to write results CSV file.",
    ),
    yes: bool = typer.Option(
        False,
        "--yes",
        "-y",
        help="Skip confirmation prompt.",
    ),
) -> None:
    """Bulk assign phone numbers from a CSV file.

    Reads a CSV file containing user, phone_number, and optional location columns.
    Validates all rows against Graph API, displays a summary, and executes
    assignments (unless --dry-run is specified).

    CSV format:
        user,phone_number,location
        john@contoso.com,+14255551234,Seattle-HQ
        jane@contoso.com,+14255555678,

    Examples:
        # Preview bulk assignment (validation only)
        teams-phone numbers bulk-assign assignments.csv --dry-run

        # Execute bulk assignment with default location for missing values
        teams-phone numbers bulk-assign assignments.csv --default-location "Seattle-HQ"

        # Continue on errors and save results to file
        teams-phone numbers bulk-assign assignments.csv --continue-on-error -o results.csv
    """
    cli_ctx = get_context(ctx)
    formatter = cli_ctx.get_output_formatter()

    try:
        # Step 1: Parse CSV file
        rows = parse_assign_csv(csv_file)

        if not rows:
            if cli_ctx.json_output:
                formatter.json(
                    {
                        "status": "no_rows",
                        "message": "No data rows found in CSV file",
                        "file": str(csv_file),
                    }
                )
            else:
                formatter.info("No data rows found in CSV file")
            raise typer.Exit(0)

        # Apply default location to rows missing location
        # Priority: 1) CLI --default-location option, 2) tenant profile default_location
        resolved_default_location = default_location
        if not resolved_default_location:
            # Check tenant profile for default_location
            config_manager = cli_ctx.get_config_manager()
            default_tenant_name = config_manager.get_default_tenant()
            if default_tenant_name:
                tenant_profile = config_manager.get_tenant(default_tenant_name)
                if tenant_profile.default_location:
                    resolved_default_location = tenant_profile.default_location
                    if not cli_ctx.json_output:
                        formatter.info(
                            f"Using default location from tenant profile: "
                            f"{resolved_default_location}"
                        )

        # Validate default location if one is resolved and rows need it
        rows_missing_location = [r for r in rows if not r.location]
        if resolved_default_location and rows_missing_location:
            # Validate the default location exists
            cache_manager = CacheManager()
            location_service = LocationService(cache_manager)
            location_service.validate_location(resolved_default_location)

            # Apply default location to rows
            for row in rows_missing_location:
                row.location = resolved_default_location

        # Step 2: Validate all rows against Graph API
        async def _validate(
            bulk_ops: BulkOperations, config_manager: ConfigManager
        ) -> BulkValidationResult:
            return await bulk_ops.validate_assign_csv(rows)

        validation_result, _ = _run_bulk_assign_async(ctx, _validate)

        # Step 3: Display validation summary
        if cli_ctx.json_output:
            # JSON output for validation results
            validation_json = {
                "dry_run": dry_run,
                "file": str(csv_file),
                "total_rows": len(validation_result.rows),
                "valid_count": validation_result.valid_count,
                "error_count": validation_result.error_count,
                "warning_count": validation_result.warning_count,
                "rows": [
                    {
                        "row_number": r.row_number,
                        "user": r.user,
                        "phone_number": r.phone_number,
                        "location": r.location,
                        "errors": r.errors,
                        "warnings": r.warnings,
                        "valid": len(r.errors) == 0,
                    }
                    for r in validation_result.rows
                ],
            }
            if dry_run:
                formatter.json(validation_json)
                # Exit with code 1 if there are validation errors, 0 otherwise
                raise typer.Exit(1 if validation_result.error_count > 0 else 0)
        else:
            # Human-readable validation summary
            formatter.info("Validation Summary:")
            formatter.info(f"  File: {csv_file}")
            formatter.info(f"  Total rows: {len(validation_result.rows)}")
            formatter.info(f"  Valid: {validation_result.valid_count}")
            formatter.info(f"  Errors: {validation_result.error_count}")
            formatter.info(f"  Warnings: {validation_result.warning_count}")
            formatter.info("")

            # Show rows with errors
            if validation_result.error_count > 0:
                formatter.info("Validation Errors:")
                for row in validation_result.rows:
                    if row.errors:
                        error_str = "; ".join(row.errors)
                        formatter.info(
                            f"  Row {row.row_number}: {row.user} - {error_str}"
                        )
                formatter.info("")

            # Show rows with warnings (if any valid rows have warnings)
            warning_rows = [
                r for r in validation_result.rows if r.warnings and not r.errors
            ]
            if warning_rows:
                formatter.info("Warnings:")
                for row in warning_rows:
                    warning_str = "; ".join(row.warnings)
                    formatter.info(
                        f"  Row {row.row_number}: {row.user} - {warning_str}"
                    )
                formatter.info("")

        # Step 4: Handle dry-run exit
        if dry_run:
            formatter.info("Dry run complete. No changes made.")
            raise typer.Exit(1 if validation_result.error_count > 0 else 0)

        # Step 5: Handle validation errors without --continue-on-error
        if validation_result.error_count > 0 and not continue_on_error:
            raise ValidationError(
                f"Validation failed for {validation_result.error_count} row(s)",
                remediation=(
                    "Fix the errors in the CSV file and try again, or use\n"
                    "--continue-on-error to process only valid rows."
                ),
            )

        # Step 6: Check if there are any valid rows to process
        if validation_result.valid_count == 0:
            formatter.info("No valid rows to process")
            raise typer.Exit(1)

        # Step 7: Confirmation prompt (unless --yes or JSON mode)
        if not yes and not cli_ctx.json_output:
            formatter.info(
                f"Ready to assign {validation_result.valid_count} phone number(s)"
            )
            if validation_result.error_count > 0:
                formatter.info(
                    f"  ({validation_result.error_count} row(s) will be skipped due to errors)"
                )
            formatter.info("")
            confirm = typer.confirm("Proceed with bulk assignment?")
            if not confirm:
                formatter.info("Bulk assignment cancelled")
                raise typer.Exit(0)

        # Step 8: Execute bulk assignments with progress bar
        # Initialize counters and results list
        success_count = 0
        fail_count = 0
        results: list[BulkAssignResult] = []

        # Get valid rows only (rows without errors)
        valid_rows = [r for r in validation_result.rows if not r.errors]

        # Add skipped results for rows with validation errors
        for row in validation_result.rows:
            if row.errors:
                error_msg = "; ".join(row.errors)
                results.append(
                    BulkAssignResult(
                        row_number=row.row_number,
                        user=row.user,
                        phone_number=row.phone_number,
                        status="skipped",
                        error=error_msg,
                    )
                )

        # Execute with progress bar
        try:
            total = len(valid_rows)
            with formatter.progress(total, "Processing") as progress:
                # Get the task_id (first and only task added by progress context)
                task_id = list(progress.task_ids)[0]

                async def _execute_with_progress(
                    bulk_ops: BulkOperations, config_manager: ConfigManager
                ) -> list[BulkAssignResult]:
                    nonlocal success_count, fail_count

                    execution_results: list[BulkAssignResult] = []

                    for idx, row in enumerate(valid_rows, start=1):
                        # Update progress description with current counts
                        desc = f"Processing [{idx}/{total}] OK:{success_count} FAIL:{fail_count}"
                        progress.update(task_id, description=desc)

                        try:
                            # Resolve user to get user_id
                            resolved_user = await bulk_ops.user_service.resolve_user(
                                row.user
                            )

                            # Get number to determine number_type
                            number_info = await bulk_ops.number_service.get_number(
                                row.phone_number
                            )

                            # Resolve location if provided
                            location_id: str | None = None
                            if row.location:
                                resolved_location = (
                                    bulk_ops.location_service.validate_location(
                                        row.location
                                    )
                                )
                                location_id = resolved_location.location_id

                            # Execute assignment
                            operation = await bulk_ops.number_service.assign_number(
                                user_id=resolved_user.id,
                                phone_number=row.phone_number,
                                number_type=number_info.number_type,
                                location_id=location_id,
                                wait=True,
                            )

                            # Check operation status
                            if operation.status == OperationStatus.SUCCEEDED:
                                success_count += 1
                                execution_results.append(
                                    BulkAssignResult(
                                        row_number=row.row_number,
                                        user=row.user,
                                        phone_number=row.phone_number,
                                        status="success",
                                    )
                                )
                            else:
                                fail_count += 1
                                execution_results.append(
                                    BulkAssignResult(
                                        row_number=row.row_number,
                                        user=row.user,
                                        phone_number=row.phone_number,
                                        status="failed",
                                        error=f"Operation status: {operation.status.value}",
                                    )
                                )
                                # Stop on first error if --continue-on-error not set
                                if not continue_on_error:
                                    progress.update(task_id, advance=1)
                                    break

                        except TeamsPhoneError as e:
                            fail_count += 1
                            execution_results.append(
                                BulkAssignResult(
                                    row_number=row.row_number,
                                    user=row.user,
                                    phone_number=row.phone_number,
                                    status="failed",
                                    error=e.message,
                                )
                            )
                            # Stop on first error if --continue-on-error not set
                            if not continue_on_error:
                                progress.update(task_id, advance=1)
                                break

                        # Advance progress bar
                        progress.update(task_id, advance=1)

                    return execution_results

                execution_results, _ = _run_bulk_assign_async(
                    ctx, _execute_with_progress
                )
                results.extend(execution_results)

                # Mark remaining rows as skipped if stopped early (not continue-on-error)
                processed_row_numbers = {r.row_number for r in results}
                for row in valid_rows:
                    if row.row_number not in processed_row_numbers:
                        results.append(
                            BulkAssignResult(
                                row_number=row.row_number,
                                user=row.user,
                                phone_number=row.phone_number,
                                status="skipped",
                                error="Stopped due to previous error",
                            )
                        )

        except KeyboardInterrupt:
            # Handle Ctrl+C gracefully - add remaining rows as skipped
            formatter.info("\nInterrupted. Processing partial results...")
            processed_row_numbers = {r.row_number for r in results}
            for row in valid_rows:
                if row.row_number not in processed_row_numbers:
                    results.append(
                        BulkAssignResult(
                            row_number=row.row_number,
                            user=row.user,
                            phone_number=row.phone_number,
                            status="skipped",
                            error="Cancelled by user",
                        )
                    )

        # Step 9: Write results to output file
        # Determine output path: use --output if provided, else generate timestamp-based name
        if output:
            output_path = output
        else:
            timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
            output_path = Path(f"bulk-assign-results-{timestamp}.csv")

        # Write results file
        try:
            write_results_csv(results, output_path)
            results_written = True
        except ConfigurationError as e:
            # Warn but don't fail if assignments succeeded
            results_written = False
            if not cli_ctx.json_output:
                formatter.warning(f"Could not write results file: {e.message}")

        # Step 10: Display execution summary
        skipped_count = len(results) - success_count - fail_count

        if cli_ctx.json_output:
            # JSON output with full results
            json_output = {
                "status": "completed",
                "summary": {
                    "total": len(results),
                    "successful": success_count,
                    "failed": fail_count,
                    "skipped": skipped_count,
                },
                "results_file": str(output_path) if results_written else None,
                "results": [
                    {
                        "row": r.row_number,
                        "user": r.user,
                        "phone_number": r.phone_number,
                        "status": r.status,
                        "error": r.error,
                    }
                    for r in results
                ],
            }
            formatter.json(json_output)
        else:
            formatter.info("")
            formatter.info("Execution Summary:")
            formatter.info(f"  Successful: {success_count}")
            formatter.info(f"  Failed: {fail_count}")
            formatter.info(f"  Skipped: {skipped_count}")
            if results_written:
                formatter.info(f"  Results file: {output_path}")

        # Exit with appropriate code
        if fail_count > 0:
            raise typer.Exit(1)
        raise typer.Exit(0)

    except TeamsPhoneError as e:
        formatter.error(e.message, remediation=e.remediation)
        raise typer.Exit(e.exit_code)
