"""Phone number service for Teams Phone number inventory operations.

This module provides the NumberService class for listing, searching, and
retrieving phone number assignments from Microsoft Graph API.
"""

from __future__ import annotations

import asyncio
import random
import re
import time
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Callable

from teams_phone.exceptions import NotFoundError, ValidationError
from teams_phone.models import (
    AssignmentStatus,
    AsyncOperation,
    NumberAssignment,
    NumberType,
    OperationStatus,
)
from teams_phone.utils import format_for_assign, format_for_update, match_pattern

if TYPE_CHECKING:
    from teams_phone.infrastructure.graph_client import GraphClient


class NumberService:
    """Service for phone number inventory operations.

    Provides methods for listing, searching, and retrieving phone number
    assignments from the Microsoft Graph API.

    Attributes:
        graph_client: GraphClient instance for API communication.
    """

    def __init__(self, graph_client: GraphClient) -> None:
        """Initialize the NumberService.

        Args:
            graph_client: GraphClient instance for API communication.
        """
        self.graph_client = graph_client

    async def list_numbers(
        self,
        *,
        status: AssignmentStatus | None = None,
        number_type: NumberType | None = None,
        city: str | None = None,
        page_size: int = 100,
        max_items: int | None = None,
    ) -> list[NumberAssignment]:
        """List phone numbers with optional filtering and pagination.

        Retrieves phone number assignments from Microsoft Graph API
        with support for filtering by assignment status, number type,
        and city.

        Note:
            The city filter is applied client-side because the Graph API
            does not support OData filtering by city. This means all numbers
            matching other filters are fetched before city filtering is applied,
            which may impact performance for large inventories.

        Args:
            status: Filter by assignment status (unassigned, userAssigned, etc.).
                If None, return all numbers regardless of assignment status.
            number_type: Filter by number type (directRouting, callingPlan, etc.).
                If None, return all number types.
            city: Filter by city name (case-sensitive, client-side filter).
                If None, return all cities.
            page_size: Number of items per page (default 100, max 999).
            max_items: Maximum total items to return. If None, return all items.
                Note: When using city filter, max_items applies before city
                filtering, so fewer results may be returned.

        Returns:
            List of NumberAssignment objects matching the filter criteria.

        Raises:
            ContractError: If API response is missing expected fields.
            AuthenticationError: If authentication fails.
            RateLimitError: If rate limit exceeded after retries.
        """
        numbers: list[NumberAssignment] = []
        params: dict[str, Any] = {"$top": str(min(page_size, 999))}

        # Build OData $filter string based on parameters
        # Note: city cannot be filtered via OData per API limitations
        filters: list[str] = []
        if status is not None:
            filters.append(f"assignmentStatus eq '{status.value}'")
        if number_type is not None:
            filters.append(f"numberType eq '{number_type.value}'")

        if filters:
            params["$filter"] = " and ".join(filters)

        async for item in self.graph_client.get_paginated(
            "/admin/teams/telephoneNumberManagement/numberAssignments",
            params=params,
            max_items=max_items,
        ):
            number = NumberAssignment.model_validate(item)
            # Client-side city filter (OData doesn't support city filter)
            if city is not None and number.city != city:
                continue
            numbers.append(number)

        return numbers

    async def get_number(self, phone_number: str) -> NumberAssignment:
        """Get a single phone number assignment by phone number.

        Accepts phone numbers in any format and normalizes to E.164 format
        before querying the Graph API.

        Args:
            phone_number: Phone number in any format (E.164, digits-only, formatted).
                Examples: "+12065551234", "12065551234", "+1-206-555-1234".

        Returns:
            NumberAssignment for the specified phone number.

        Raises:
            NotFoundError: If the phone number is not found in inventory.
            ValidationError: If the phone number format is invalid.
            ContractError: If API response doesn't match expected schema.
        """
        normalized = format_for_update(phone_number)
        params = {"$filter": f"telephoneNumber eq '{normalized}'"}

        async for item in self.graph_client.get_paginated(
            "/admin/teams/telephoneNumberManagement/numberAssignments",
            params=params,
            max_items=1,
        ):
            return NumberAssignment.model_validate(item)

        raise NotFoundError(
            f"Phone number '{normalized}' not found",
            remediation=(
                "Verify the phone number is in your inventory. "
                "Use `teams-phone numbers list` to see available numbers."
            ),
        )

    async def search_numbers(
        self,
        pattern: str,
        *,
        max_results: int = 100,
    ) -> list[NumberAssignment]:
        """Search phone numbers by wildcard pattern.

        Fetches all numbers and applies client-side pattern matching.
        Supports wildcards: '206*' (prefix), '*1234' (suffix), '*555*' (contains).

        Note:
            The Graph API does not support wildcard filtering, so all numbers
            are fetched first and then filtered client-side. For large inventories
            this may impact performance.

        Args:
            pattern: Search pattern with optional wildcards (*).
                - '206*' matches numbers containing 206 anywhere
                - '*1234' matches numbers ending with 1234
                - '*555*' matches numbers containing 555 anywhere
                - '1234' matches numbers containing that substring
            max_results: Maximum number of results to return (default 100).

        Returns:
            List of NumberAssignment objects matching the pattern.

        Raises:
            ContractError: If API response doesn't match expected schema.
            AuthenticationError: If authentication fails.
            RateLimitError: If rate limit exceeded after retries.
        """
        results: list[NumberAssignment] = []

        # Fetch all numbers - no server-side pattern filtering available
        all_numbers = await self.list_numbers()

        # Apply client-side pattern matching
        for number in all_numbers:
            if match_pattern(number.telephone_number, pattern):
                results.append(number)
                if len(results) >= max_results:
                    break

        return results

    async def poll_operation(
        self,
        operation_id: str,
        *,
        timeout: int = 60,
        on_progress: Callable[[AsyncOperation], None] | None = None,
    ) -> AsyncOperation:
        """Poll an async operation until completion or timeout.

        Polls the Graph API operation status endpoint at 2-3 second intervals
        with jitter until the operation reaches a terminal state (succeeded,
        failed, or skipped) or the timeout is exceeded.

        Args:
            operation_id: The operation ID from the Location header of a 202 response.
            timeout: Maximum time in seconds to wait for completion (default 60s).
            on_progress: Optional callback invoked on each poll iteration with
                the current AsyncOperation state.

        Returns:
            AsyncOperation model representing the final state of the operation.

        Raises:
            TimeoutError: If the operation does not complete within the timeout.
            ContractError: If the API response doesn't match expected schema.
            AuthenticationError: If authentication fails.
            RateLimitError: If rate limit exceeded after retries.
        """
        terminal_states = {
            OperationStatus.SUCCEEDED,
            OperationStatus.FAILED,
            OperationStatus.SKIPPED,
        }
        endpoint = f"/admin/teams/telephoneNumberManagement/operations/{operation_id}"

        start = time.time()
        while time.time() - start < timeout:
            result = await self.graph_client.get(endpoint)
            operation = AsyncOperation.model_validate(result)

            if on_progress is not None:
                on_progress(operation)

            if operation.status in terminal_states:
                return operation

            # 2-3 second interval with jitter
            await asyncio.sleep(2 + random.uniform(0, 1))

        raise TimeoutError(
            f"Operation {operation_id} did not complete within {timeout}s"
        )

    def _extract_operation_id(self, location_header: str) -> str:
        """Extract operation ID from Location header.

        Parses the operation ID from the Location header returned by
        202 Accepted responses for assign/unassign operations.

        Args:
            location_header: The Location header value containing the
                operations URL. Supports both formats:
                - .../operations('{operation_id}') - OData function style
                - .../operations/{operation_id} - REST path style

        Returns:
            The extracted operation ID string.

        Raises:
            ValidationError: If the header format doesn't match the
                expected pattern.
        """
        # Try OData function style first: operations('{id}')
        pattern = r"operations\('([^']+)'\)"
        match = re.search(pattern, location_header)

        # Fall back to REST path style: operations/{id}
        if not match:
            pattern = r"operations/([^/\s]+)$"
            match = re.search(pattern, location_header)

        if not match:
            raise ValidationError(
                f"Invalid Location header format: '{location_header}'",
                remediation=(
                    "Expected format: .../operations('{operation_id}') or "
                    ".../operations/{operation_id}. "
                    "This may indicate an API response format change."
                ),
            )

        return match.group(1)

    async def assign_number(
        self,
        user_id: str,
        phone_number: str,
        number_type: NumberType,
        *,
        location_id: str | None = None,
        wait: bool = True,
        timeout: int = 60,
        on_progress: Callable[[AsyncOperation], None] | None = None,
    ) -> AsyncOperation:
        """Assign a phone number to a user.

        Args:
            user_id: User's Entra object ID (GUID).
            phone_number: Phone number in any format.
            number_type: Type of phone number (DirectRouting, CallingPlan, OperatorConnect).
            location_id: Emergency location ID. Required for Calling Plan numbers.
            wait: If True, poll for operation completion. If False, return immediately.
            timeout: Maximum seconds to wait for completion (default 60).
            on_progress: Optional callback invoked on each poll iteration with
                the current AsyncOperation state.

        Returns:
            AsyncOperation representing the assignment result.

        Raises:
            ValidationError: If Calling Plan number is missing required location_id.
            TimeoutError: If wait=True and operation doesn't complete within timeout.
        """
        # E911 validation: Calling Plan numbers require emergency location
        if number_type == NumberType.CALLING_PLAN and not location_id:
            raise ValidationError(
                f"Emergency location required for Calling Plan number {phone_number}",
                remediation="Use --location flag or set default_location in tenant config",
            )

        # Build request body
        body: dict[str, Any] = {
            "telephoneNumber": format_for_assign(phone_number),
            "numberType": number_type.value,
            "assignmentTargetId": user_id,
        }
        if location_id:
            body["locationId"] = location_id

        # Make POST request to assignNumber endpoint
        response = await self.graph_client.post_with_response(
            "/admin/teams/telephoneNumberManagement/numberAssignments/assignNumber",
            body,
        )

        # Extract operation ID from Location header
        location_header = response.headers.get("location", "")
        operation_id = self._extract_operation_id(location_header)

        if wait:
            # Poll for completion
            return await self.poll_operation(
                operation_id, timeout=timeout, on_progress=on_progress
            )

        # Return immediately with notStarted status
        return AsyncOperation.model_validate(
            {
                "id": operation_id,
                "createdDateTime": datetime.now(timezone.utc).isoformat(),
                "status": OperationStatus.NOT_STARTED.value,
                "numbers": [],
            }
        )

    async def unassign_number(
        self,
        phone_number: str,
        number_type: NumberType,
        *,
        wait: bool = True,
        timeout: int = 60,
        on_progress: Callable[[AsyncOperation], None] | None = None,
    ) -> AsyncOperation:
        """Unassign a phone number from its current user.

        Args:
            phone_number: Phone number in any format.
            number_type: Type of phone number (DirectRouting, CallingPlan, OperatorConnect).
            wait: If True, poll for operation completion. If False, return immediately.
            timeout: Maximum seconds to wait for completion (default 60).
            on_progress: Optional callback invoked on each poll iteration with
                the current AsyncOperation state.

        Returns:
            AsyncOperation representing the unassignment result.

        Raises:
            TimeoutError: If wait=True and operation doesn't complete within timeout.
        """
        # Build request body (simpler than assign - no user or location required)
        body: dict[str, Any] = {
            "telephoneNumber": format_for_assign(phone_number),
            "numberType": number_type.value,
        }

        # Make POST request to unassignNumber endpoint
        response = await self.graph_client.post_with_response(
            "/admin/teams/telephoneNumberManagement/numberAssignments/unassignNumber",
            body,
        )

        # Extract operation ID from Location header
        location_header = response.headers.get("location", "")
        operation_id = self._extract_operation_id(location_header)

        if wait:
            # Poll for completion
            return await self.poll_operation(
                operation_id, timeout=timeout, on_progress=on_progress
            )

        # Return immediately with notStarted status
        return AsyncOperation.model_validate(
            {
                "id": operation_id,
                "createdDateTime": datetime.now(timezone.utc).isoformat(),
                "status": OperationStatus.NOT_STARTED.value,
                "numbers": [],
            }
        )

    async def update_number(
        self,
        phone_number: str,
        *,
        location_id: str | None = None,
        clear_location: bool = False,
        network_site_id: str | None = None,
        clear_network_site: bool = False,
    ) -> None:
        """Update phone number location or network site settings.

        This is a synchronous operation (returns 200 OK immediately, not 202 Accepted).
        Use this to change emergency location or network site on an already-assigned number.

        Args:
            phone_number: Phone number in any format.
            location_id: Emergency location ID to set. Mutually exclusive with clear_location.
            clear_location: Set to True to clear the emergency location.
            network_site_id: Network site ID to set. Mutually exclusive with clear_network_site.
            clear_network_site: Set to True to clear the network site.

        Raises:
            ValidationError: If both location_id and clear_location are specified,
                or both network_site_id and clear_network_site are specified,
                or no update parameters are provided.
        """
        # Validate mutually exclusive options
        if location_id and clear_location:
            raise ValidationError(
                "Cannot specify both --location and --clear-location",
                remediation="Use either --location <id> to set a location or --clear-location to remove it.",
            )
        if network_site_id and clear_network_site:
            raise ValidationError(
                "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.",
            )

        # Build request body with only the fields being updated
        body: dict[str, Any] = {
            "telephoneNumber": format_for_update(phone_number),
        }

        # Handle location updates
        if location_id:
            body["locationId"] = location_id
        elif clear_location:
            body["locationId"] = ""  # Empty string clears the field

        # Handle network site updates
        if network_site_id:
            body["networkSiteId"] = network_site_id
        elif clear_network_site:
            body["networkSiteId"] = ""  # Empty string clears the field

        # Validate at least one update is requested
        if len(body) == 1:  # Only telephoneNumber is present
            raise ValidationError(
                "No update parameters specified",
                remediation="Use --location, --clear-location, --network-site, or --clear-network-site to specify what to update.",
            )

        # Make POST request to updateNumber endpoint
        # This is a synchronous operation - returns 200 OK (not 202 Accepted)
        await self.graph_client.post(
            "/admin/teams/telephoneNumberManagement/numberAssignments/updateNumber",
            body,
        )
