"""User service for Teams Phone user configuration operations.

This module provides the UserService class for listing, searching, and
retrieving Teams user voice configurations from Microsoft Graph API.
"""

from __future__ import annotations

import re
from typing import TYPE_CHECKING, Any

from teams_phone.constants import GRAPH_API_V1_BASE
from teams_phone.exceptions import NotFoundError, ValidationError
from teams_phone.models import AccountType, UserConfiguration

if TYPE_CHECKING:
    from teams_phone.infrastructure.graph_client import GraphClient


def _strip_odata_metadata(data: dict[str, Any]) -> dict[str, Any]:
    """Strip OData metadata fields from a response dict.

    Graph API responses include @odata.context and other @ prefixed fields
    that are not part of the actual data contract. This helper removes them
    before Pydantic validation with extra="forbid".
    """
    return {k: v for k, v in data.items() if not k.startswith("@")}


class UserService:
    """Service for user voice configuration operations.

    Provides methods for listing, searching, and retrieving Teams user
    configurations from the Microsoft Graph API.

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

    # Maximum IDs per filter query (URL length limit)
    _MAX_IDS_PER_BATCH = 15

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

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

    async def _fetch_display_names(
        self,
        user_ids: list[str],
    ) -> dict[str, str]:
        """Fetch display names for a list of user IDs from the directory.

        The /admin/teams/userConfigurations endpoint doesn't return displayName,
        so we fetch it separately from the v1.0 /users endpoint.

        Args:
            user_ids: List of Entra object IDs to fetch display names for.

        Returns:
            Dict mapping user ID to display name. Users not found are omitted.
        """
        if not user_ids:
            return {}

        display_names: dict[str, str] = {}

        # Batch IDs to avoid URL length limits
        for i in range(0, len(user_ids), self._MAX_IDS_PER_BATCH):
            batch_ids = user_ids[i : i + self._MAX_IDS_PER_BATCH]

            # Build filter: id in ('id1', 'id2', ...)
            id_list = ", ".join(f"'{uid}'" for uid in batch_ids)
            url = f"{GRAPH_API_V1_BASE}/users"
            params: dict[str, Any] = {
                "$filter": f"id in ({id_list})",
                "$select": "id,displayName",
            }

            async for item in self.graph_client.get_paginated_url(
                url,
                params=params,
            ):
                user_id = item.get("id")
                display_name = item.get("displayName")
                if user_id and display_name:
                    display_names[user_id] = display_name

        return display_names

    async def _enrich_display_names(
        self,
        users: list[UserConfiguration],
    ) -> list[UserConfiguration]:
        """Enrich a list of users with display names from the directory.

        Args:
            users: List of UserConfiguration objects to enrich.

        Returns:
            New list with display names populated where available.
        """
        user_ids = [u.id for u in users if u.display_name is None]
        if not user_ids:
            return users

        display_names = await self._fetch_display_names(user_ids)
        return [
            u.model_copy(update={"display_name": display_names.get(u.id)})
            if u.display_name is None and u.id in display_names
            else u
            for u in users
        ]

    async def list_users(
        self,
        *,
        licensed: bool | None = None,
        assigned: bool | None = None,
        account_type: AccountType | None = None,
        page_size: int = 100,
        max_items: int | None = None,
    ) -> list[UserConfiguration]:
        """List users with optional filtering and pagination.

        Retrieves user voice configurations from Microsoft Graph API
        with support for filtering by licensing, phone number assignment,
        and account type.

        Args:
            licensed: If True, only return users with enterprise voice enabled.
                If False, only return users without enterprise voice.
                If None, return all users regardless of voice license.
            assigned: If True, only return users with phone numbers assigned.
                If False, only return users without phone numbers.
                If None, return all users regardless of assignment status.
            account_type: Filter by account type (user, resourceAccount, etc.).
                If None, return all account types.
            page_size: Number of items per page (default 100, max 999).
            max_items: Maximum total items to return. If None, return all items.

        Returns:
            List of UserConfiguration 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.
        """
        users: list[UserConfiguration] = []
        params: dict[str, Any] = {"$top": str(min(page_size, 999))}

        # Build OData $filter string based on parameters
        # Note: assigned filter is done client-side (Graph API doesn't support $count on telephoneNumbers)
        filters: list[str] = []
        if licensed is not None:
            filters.append(f"isEnterpriseVoiceEnabled eq {str(licensed).lower()}")
        if account_type is not None:
            filters.append(f"accountType eq '{account_type.value}'")

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

        async for item in self.graph_client.get_paginated(
            "/admin/teams/userConfigurations",
            params=params,
            max_items=max_items
            if assigned is None
            else None,  # Fetch all if filtering client-side
        ):
            user = UserConfiguration.model_validate(item)
            # Client-side filter for assigned status
            if assigned is not None:
                has_numbers = len(user.telephone_numbers) > 0
                if assigned and not has_numbers:
                    continue
                if not assigned and has_numbers:
                    continue
            users.append(user)
            # Check max_items after client-side filtering
            if max_items is not None and len(users) >= max_items:
                break

        # Enrich with display names from directory
        # The Teams admin API doesn't return displayName
        return await self._enrich_display_names(users)

    async def get_user(self, user_id: str) -> UserConfiguration:
        """Get a single user's voice configuration by ID.

        Retrieves detailed voice configuration for a specific user
        identified by their Entra object ID.

        Args:
            user_id: The user's Entra object ID (GUID).

        Returns:
            UserConfiguration for the specified user.

        Raises:
            NotFoundError: If the user is not found.
            ContractError: If API response doesn't match expected schema.
            AuthenticationError: If authentication fails.
        """
        response = await self.graph_client.get(
            f"/admin/teams/userConfigurations/{user_id}"
        )
        user = UserConfiguration.model_validate(_strip_odata_metadata(response))

        # Enrich with display name from directory if not present
        enriched = await self._enrich_display_names([user])
        return enriched[0]

    async def _search_directory_users(
        self,
        query: str,
        *,
        max_results: int = 25,
    ) -> list[dict[str, str | None]]:
        """Search users by display name via v1.0 /users endpoint.

        The /admin/teams/userConfigurations endpoint doesn't support
        displayName filtering, so we search the directory first.
        We also return displayName since the Teams admin API doesn't
        populate it in the userConfigurations response.

        Args:
            query: Display name search query.
            max_results: Maximum number of results to return.

        Returns:
            List of dicts with 'id' and 'displayName' for matching users.
        """
        # Use v1.0 /users endpoint which supports displayName filtering
        # Build full URL since GraphClient defaults to beta
        url = f"{GRAPH_API_V1_BASE}/users"
        params: dict[str, Any] = {
            "$filter": f"startsWith(displayName, '{query}')",
            "$select": "id,displayName",
            "$top": str(min(max_results, 999)),
        }

        users: list[dict[str, str | None]] = []
        async for item in self.graph_client.get_paginated_url(
            url,
            params=params,
            max_items=max_results,
        ):
            if "id" in item:
                users.append(
                    {
                        "id": str(item["id"]),
                        "displayName": item.get("displayName"),
                    }
                )

        return users

    async def search_users(
        self,
        query: str,
        *,
        max_results: int = 25,
    ) -> list[UserConfiguration]:
        """Search users by display name, UPN, or phone number.

        Searches user configurations using OData filters. Automatically
        detects the query type and applies appropriate filtering:
        - Phone numbers (starting with +): Searches telephoneNumbers collection
        - Email addresses (containing @): Searches by UPN
        - Other queries: Searches by display name using v1.0 /users endpoint

        Args:
            query: Search query - phone number, UPN, or display name.
            max_results: Maximum number of results to return (default 25).

        Returns:
            List of UserConfiguration objects matching the search query.

        Raises:
            ContractError: If API response doesn't match expected schema.
            AuthenticationError: If authentication fails.
            RateLimitError: If rate limit exceeded after retries.
        """
        # Detect query type and route to appropriate search method
        if query.startswith("+") or re.match(r"^\d+$", query):
            # Phone number search - normalize and search in telephoneNumbers
            # Handle both E.164 format and raw digits
            normalized = query if query.startswith("+") else f"+{query}"
            params: dict[str, Any] = {
                "$top": str(min(max_results, 999)),
                "$filter": (
                    f"telephoneNumbers/any(t: t/telephoneNumber eq '{normalized}')"
                ),
            }
            users: list[UserConfiguration] = []
            async for item in self.graph_client.get_paginated(
                "/admin/teams/userConfigurations",
                params=params,
                max_items=max_results,
            ):
                users.append(UserConfiguration.model_validate(item))
            # Enrich with display names
            return await self._enrich_display_names(users)

        elif "@" in query:
            # UPN search - exact match on userConfigurations
            params = {
                "$top": str(min(max_results, 999)),
                "$filter": f"userPrincipalName eq '{query}'",
            }
            users = []
            async for item in self.graph_client.get_paginated(
                "/admin/teams/userConfigurations",
                params=params,
                max_items=max_results,
            ):
                users.append(UserConfiguration.model_validate(item))
            # Enrich with display names
            return await self._enrich_display_names(users)

        else:
            # Display name search - use v1.0 /users endpoint first,
            # then fetch Teams configurations for matched users
            directory_users = await self._search_directory_users(
                query, max_results=max_results
            )

            if not directory_users:
                return []

            # Fetch Teams configurations for each matched user
            # Note: The Teams admin API doesn't return displayName, so we
            # inject it from the directory search result
            users = []
            for dir_user in directory_users:
                user_id = dir_user["id"]
                if user_id is None:
                    continue  # Skip if somehow id is None (shouldn't happen)
                try:
                    user_config = await self.get_user(user_id)
                    # Inject displayName from directory if Teams API didn't provide it
                    if user_config.display_name is None and dir_user.get("displayName"):
                        # Create a new instance with the displayName set
                        # Using model_copy to avoid mutating the original
                        user_config = user_config.model_copy(
                            update={"display_name": dir_user["displayName"]}
                        )
                    users.append(user_config)
                except NotFoundError:
                    # User exists in directory but has no Teams configuration
                    # (e.g., unlicensed user) - skip them
                    continue

            return users

    async def resolve_user(self, identifier: str) -> UserConfiguration:
        """Resolve a user by UPN, display name, or object ID.

        Attempts to find a single user matching the identifier. Uses
        intelligent detection to determine the identifier type:
        - GUID format: Direct lookup by object ID
        - Email format (contains @): Lookup by UPN
        - Other: Search by display name

        Args:
            identifier: User identifier - UPN, display name, or object ID.

        Returns:
            UserConfiguration for the resolved user.

        Raises:
            NotFoundError: If no user matches the identifier.
            ValidationError: If multiple users match (ambiguous identifier).
            AuthenticationError: If authentication fails.
        """
        # Check if identifier is a GUID (object ID)
        guid_pattern = re.compile(
            r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
            r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
        )

        if guid_pattern.match(identifier):
            # Direct lookup by ID
            return await self.get_user(identifier)

        if "@" in identifier:
            # UPN lookup - search for exact match
            users = await self.search_users(identifier, max_results=1)
            if not users:
                raise NotFoundError(
                    f"User '{identifier}' not found",
                    remediation=(
                        "Verify the user principal name (email) is correct.\n"
                        "Use 'teams-phone users search' to find available users."
                    ),
                )
            return users[0]

        # Display name search - requires disambiguation
        users = await self.search_users(identifier, max_results=10)

        if not users:
            raise NotFoundError(
                f"No user found matching '{identifier}'",
                remediation=(
                    "No user found with a display name starting with "
                    f"'{identifier}'.\n"
                    "Use 'teams-phone users list' to see available users, or\n"
                    "try searching by UPN (email address) for exact matching."
                ),
            )

        if len(users) == 1:
            return users[0]

        # Multiple matches - require disambiguation
        user_list = "\n".join(
            f"  - {u.display_name or u.user_principal_name} ({u.user_principal_name})"
            for u in users[:5]
        )
        additional = f"\n  ... and {len(users) - 5} more" if len(users) > 5 else ""

        raise ValidationError(
            f"Multiple users match '{identifier}'",
            remediation=(
                f"The following users match your query:\n{user_list}{additional}\n\n"
                "Please specify the user by their full UPN (email address) "
                "or object ID\nto avoid ambiguity."
            ),
        )
