from typing import Any, Optional
from cw_rpa import HttpClient
from m365_user_manager.exceptions.exceptions import GraphAPIError, InsufficientPermissionsError
from m365_user_manager.managers.password_manager import PasswordManager
from m365_user_manager.managers.environment_manager import EnvironmentManager
from difflib import get_close_matches
import logging
import requests
import json

class M365UserManager:
    """
    Core class for Microsoft 365 user management operations.
    
    Handles user creation, license assignment, group management, and property
    updates through Microsoft Graph API.
    
    Usage:
        user_manager = M365UserManager(
            access_token="token",
            logger=logging.getLogger(),
            PRODUCT_LICENSES=license_list,
            running_in_asio=False
        )
        
        result = user_manager.create_user(
            display_name="John Doe",
            email_address="john@example.com",
            license_skus=["E3", "E5"],
            groups=["IT", "Admin"]
        )
    
    Methods:
        create_user(): Creates new M365 user with specified properties
        assign_licenses(): Assigns licenses to existing user
        get_user_licenses(): Retrieves user's current licenses
        
    Raises:
        GraphAPIError: When API operations fail
        InsufficientPermissionsError: When lacking required permissions
    """

    def __init__(self, access_token: str, logger: Optional[logging.Logger] = None, 
                PRODUCT_LICENSES: Optional[list[dict[str, Any]]] = None, 
                running_in_asio: bool = None, 
                http_client: Optional[HttpClient] = None, discord_webhook_url: Optional[str] = None):
        """Initialize M365UserManager with access token and logger."""
        self.discord_webhook_url = discord_webhook_url
        self.access_token = access_token
        self.running_in_asio = running_in_asio
        self.logger = EnvironmentManager(discord_webook_url=self.discord_webhook_url, running_in_asio=self.running_in_asio).setup_logging() if not logger else logger
        self.graph_api_endpoint = "https://graph.microsoft.com/v1.0"
        self.headers = {
            "Authorization": f"Bearer {access_token}",
            "Content-Type": "application/json"
        }
        self.password_manager = PasswordManager(discord_webhook_url=self.discord_webhook_url)
        self.http_client = http_client or HttpClient()
        self.integration_name = "azure_o365"
        self.PRODUCT_LICENSES = PRODUCT_LICENSES or []
        self.request_timeout = 60  # Making timeout configurable
        self.max_retries = 5      # Adding retry capability

    def _make_request(self, method: str, endpoint: str, 
                     json_data: Optional[dict] = None,
                     params: Optional[dict] = None,
                     timeout: Optional[int] = None,
                     retry_count: int = 0) -> requests.Response:
        """
        Make a request to Microsoft Graph API with retry logic.
        
        Args:
            method (str): HTTP method (GET, POST, etc.)
            endpoint (str): API endpoint
            json_data (Optional[dict]): Request payload
            params (Optional[dict]): Query parameters
            timeout (Optional[int]): Request timeout in seconds
            retry_count (int): Current retry attempt number
            
        Returns:
            requests.Response: API response
            
        Raises:
            GraphAPIError: When API request fails after retries
            InsufficientPermissionsError: When permissions are inadequate
        """
        url = f"{self.graph_api_endpoint}/{endpoint}"
        timeout = timeout or self.request_timeout
        
        try:
            if self.running_in_asio:
                integration_client = self.http_client.third_party_integration(
                    self.integration_name
                )
                if method.upper() == "GET":
                    response = integration_client.get(
                        url=url, 
                        headers=self.headers, 
                        params=params,
                        timeout=timeout
                    )
                elif method.upper() == "POST":
                    response = integration_client.post(
                        url=url, 
                        headers=self.headers, 
                        json=json_data,
                        timeout=timeout
                    )
                else:
                    raise GraphAPIError(
                        f"Unsupported HTTP method: {method}",
                        status_code=400
                    )
            else:
                response = requests.request(
                    method=method,
                    url=url,
                    headers=self.headers,
                    json=json_data,
                    params=params,
                    timeout=timeout
                )

            # Log request details (masking sensitive data)
            self.logger.info(f"Made {method} request to {url}")
            
            if response.status_code == 403:
                error_msg = response.json().get('error', {}).get('message', 'Access denied')
                raise InsufficientPermissionsError(
                    f"Insufficient permissions: {error_msg}",
                    required_permissions=['Directory.ReadWrite.All']
                )

            response.raise_for_status()
            return response

        except requests.exceptions.Timeout:
            if retry_count < self.max_retries:
                self.logger.warning(
                    f"Request timeout, attempt {retry_count + 1} of {self.max_retries}"
                )
                return self._make_request(
                    method, endpoint, json_data, params, 
                    timeout, retry_count + 1
                )
            raise GraphAPIError(
                f"Request to {url} timed out after {self.max_retries} attempts",
                status_code=408
            )
            
        except requests.exceptions.RequestException as e:
            error_text = getattr(e.response, 'text', str(e)) if hasattr(e, 'response') else str(e)
            status_code = getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
            
            self.logger.error(
                f"API request failed: {method} {url} - {error_text}"
            )
            
            if retry_count < self.max_retries and status_code >= 500:
                self.logger.warning(
                    f"Request failed, attempt {retry_count + 1} of {self.max_retries}"
                )
                return self._make_request(
                    method, endpoint, json_data, params, 
                    timeout, retry_count + 1
                )
                
            raise GraphAPIError(
                f"API request failed: {error_text}",
                status_code=status_code
            )

    def _get_tenant_skus(self) -> list[dict[str, Any]]:
        """Get all SKUs available in the tenant."""
        try:
            self.logger.info("Fetching tenant SKUs...")
            response = self._make_request("GET", "subscribedSkus")
            tenant_skus = response.json().get('value', [])
            self.logger.info(f"Found {len(tenant_skus)} SKUs in tenant")
            
            processed_skus = []
            for sku in tenant_skus:
                sku_id = sku['skuId'].lower()
                sku_part_number = sku['skuPartNumber'].upper()
                
                # Find friendly name from PRODUCT_LICENSES
                friendly_name = next(
                    (info["ProductName"] for info in self.PRODUCT_LICENSES
                    if info["StringID"].upper() == sku_part_number),
                    sku_part_number
                )
                
                remaining = sku['prepaidUnits']['enabled'] - sku['consumedUnits']
                
                processed_skus.append({
                    'skuId': sku_id,
                    'skuPartNumber': sku_part_number,
                    'friendlyName': friendly_name,
                    'remaining': remaining,
                    'enabled': sku['prepaidUnits']['enabled'],
                    'consumed': sku['consumedUnits']
                })
                self.logger.info(
                    f"Processed SKU: {friendly_name} "
                    f"(Remaining: {remaining}, SKU ID: {sku_id})"
                )
                
            return processed_skus
            
        except Exception as e:
            self.logger.error(f"Failed to get tenant SKUs: {str(e)}")
            raise GraphAPIError(f"Failed to get tenant SKUs: {str(e)}")

    def _process_unique_licenses(self, license_list: list[str]) -> set[str]:
        """
        Deduplicate licenses by normalizing names and SKU IDs.
        """
        unique_licenses = set()
        normalized_map = {}
        
        # Create mapping of normalized names to original values
        for license_info in self.PRODUCT_LICENSES:
            normalized_name = license_info["ProductName"].lower().replace(" ", "")
            normalized_sku = license_info["StringID"].lower()
            normalized_map[normalized_name] = license_info["ProductName"]
            normalized_map[normalized_sku] = license_info["ProductName"]
        
        # Process each license
        for license_name in license_list:
            normalized = license_name.lower().replace(" ", "")
            if normalized in normalized_map:
                unique_licenses.add(normalized_map[normalized])
            else:
                unique_licenses.add(license_name)
        
        return unique_licenses

    def _process_license_request(self, requested_license: str, 
                            tenant_skus: list[dict[str, Any]]) -> dict[str, Any]:
        """
        Process a single license request against available tenant SKUs.
        
        Args:
            requested_license: License name or SKU ID to process
            tenant_skus: list of available SKUs in tenant
            
        Returns:
            dict containing match results and resolution details
        """
        try:
            if not isinstance(requested_license, str):
                return {
                    'matched': False,
                    'type': 'invalid',
                    'resolved_to': str(requested_license)
                }
                
            requested_license = requested_license.strip()
            self.logger.debug(f"Processing license request: {requested_license}")
            
            # First try exact match against PRODUCT_LICENSES
            license_info = next(
                (info for info in self.PRODUCT_LICENSES
                if info["StringID"].upper() == requested_license.upper() or 
                info["ProductName"].upper() == requested_license.upper()),
                None
            )
            
            if license_info:
                friendly_name = license_info["ProductName"]
                string_id = license_info["StringID"]
                
                # Check if license exists in tenant
                matching_sku = next(
                    (sku for sku in tenant_skus 
                    if sku['skuPartNumber'].upper() == string_id.upper()),
                    None
                )
                
                if matching_sku:
                    self.logger.debug(f"Found matching SKU: {friendly_name}")
                    return {
                        'matched': True,
                        'type': 'exact_match',
                        'resolved_to': friendly_name,
                        'sku_id': matching_sku['skuId'],
                        'remaining': matching_sku.get('remaining', 0)
                    }
                    
                # License exists in product list but not in tenant
                self.logger.debug(f"License {friendly_name} not available in tenant")
                return {
                    'matched': False,
                    'type': 'not_in_tenant',
                    'resolved_to': friendly_name,
                    'remaining': 0
                }
            
            # Try fuzzy matching for unknown licenses
            product_names = [info["ProductName"] for info in self.PRODUCT_LICENSES]
            if closest_matches := get_close_matches(requested_license, product_names, n=1, cutoff=0.75):
                self.logger.debug(f"Found fuzzy match: {closest_matches[0]}")
                return self._process_license_request(closest_matches[0], tenant_skus)
            
            # No match found at all
            self.logger.debug(f"No match found for license: {requested_license}")
            return {
                'matched': False,
                'type': 'unknown_license',
                'resolved_to': requested_license,
                'remaining': 0
            }
            
        except Exception as e:
            self.logger.error(f"Failed to process license request: {str(e)}")
            return {
                'matched': False,
                'type': 'error',
                'resolved_to': requested_license,
                'remaining': 0
            }

    def _format_license_results(self, processed_licenses: list[dict[str, Any]], 
                            tenant_skus: list[dict[str, Any]]) -> dict[str, Any]:
        """Format the results of license processing into a standardized output."""
        available = []
        unavailable = set()
        available_str_list = set()
        unavailable_str_list = set()
        
        for license_result in processed_licenses:
            if not license_result:  # Handle None results
                continue
                
            resolved_name = license_result.get('resolved_to', 'Unknown License')
            
            if license_result['matched']:
                sku_id = license_result['sku_id']
                matching_sku = next(
                    (sku for sku in tenant_skus if sku['skuId'] == sku_id),
                    None
                )
                
                if matching_sku and matching_sku['remaining'] > 0:
                    available.append(matching_sku)
                    available_str_list.add(
                        f"{matching_sku['friendlyName']} ({matching_sku['remaining']} available)"
                    )
                else:
                    unavailable.add(resolved_name)
                    unavailable_str_list.add(
                        f"{resolved_name} (no licenses available)"
                    )
            else:
                unavailable.add(resolved_name)
                # Get specific reason for unavailability
                reason = {
                    'not_in_tenant': 'license not available in tenant',
                    'unknown_license': 'unknown license',
                    'invalid': 'invalid license format',
                    'error': 'processing error'
                }.get(license_result['type'], 'license unavailable')
                
                unavailable_str_list.add(
                    f"{resolved_name} ({reason})"
                )
        
        return {
            'available': available,
            'unavailable': list(unavailable),
            'available_str': "\n".join(sorted(available_str_list)) if available_str_list else "No licenses available",
            'unavailable_str': "\n".join(sorted(unavailable_str_list)) if unavailable_str_list else "No unavailable licenses"
        }

    def _get_user_details(self, user_identifier: str) -> Optional[dict[str, Any]]:
        """Get user details by email or display name."""
        try:
            self.logger.info(f"Fetching all users from tenant...")
            response = self._make_request(
                "GET",
                "users",
                params={
                    "$select": "id,displayName,userPrincipalName",
                    "$top": 999  # Limit results for faster response
                }
            )
            users = response.json().get('value', [])
            self.logger.info(f"Found {len(users)} users in tenant")
            
            # Try matching email first
            if '@' in user_identifier:
                self.logger.info(f"Attempting to match user by email: {user_identifier}")
                matching_user = next(
                    (u for u in users if u['userPrincipalName'].lower() == user_identifier.lower()),
                    None
                )
                if matching_user:
                    self.logger.info(
                        f"Found exact email match: {matching_user['displayName']} "
                        f"(UPN: {matching_user['userPrincipalName']})"
                    )
                    return matching_user
                
                # Log when no exact match is found
                self.logger.info(f"No exact email match found for: {user_identifier}")
            
            # Try fuzzy matching against display name
            name_list = [u['displayName'] for u in users]
            best_name_match = get_close_matches(
                user_identifier, 
                name_list,
                n=1, 
                cutoff=0.8
            )
            
            if best_name_match:
                matching_user = next(
                    (u for u in users if u['displayName'] == best_name_match[0]),
                    None
                )
                if matching_user:
                    self.logger.info(
                        f"Found fuzzy name match: {matching_user['displayName']} "
                        f"(UPN: {matching_user['userPrincipalName']})"
                    )
                    return matching_user
                    
            self.logger.warning(f"No user match found for: {user_identifier}")
            return None
                
        except Exception as e:
            self.logger.error(f"Failed to get user details: {str(e)}")
            if hasattr(e, 'response'):
                self.logger.error(f"Response: {e.response.text}")
            return None

    def _create_base_user(self, display_name: str, email_address: str, 
                         password: Optional[str] = None) -> dict[str, Any]:
        """Create base user with required properties."""
        try:
            first_name = display_name.split(" ")[0]
            last_name = " ".join(display_name.split(" ")[1:]) if " " in display_name else ""

            user_payload = {
                "accountEnabled": True,
                "displayName": display_name,
                "mailNickname": email_address.split("@")[0],
                "userPrincipalName": email_address,
                "passwordProfile": {
                    "forceChangePasswordNextSignIn": True,
                    "password": password or self.password_manager.generate_secure_password()
                },
                "usageLocation": "US",
                "givenName": first_name,
                "surname": last_name
            }

            response = self._make_request("POST", "users", json_data=user_payload)
            created_user = response.json()
            self.logger.info(f"Created base user {email_address} with ID {created_user['id']}")
            
            return {
                "id": created_user["id"],
                "email": email_address,
                "password": user_payload["passwordProfile"]["password"],
                "displayName": display_name
            }
            
        except Exception as e:
            self.logger.error(f"Failed to create base user: {str(e)}")
            raise GraphAPIError(f"Failed to create base user: {str(e)}")

    def _copy_user_properties(self, source_user_id: str, target_user_id: str, skip_roles: bool = False) -> dict[str, Any]:
        """
        Copy properties and groups from source to target user.
        """
        results = {
            "properties": {"copied": [], "failed": []},
            "groups": {"copied": [], "failed": []},
            "roles": {"copied": [], "failed": []}
        }
        
        try:
            # 1. Copy user propertiess
            source_details = self._make_request(
                "GET", 
                f"users/{source_user_id}",
                params={
                    "$select": "id,displayName,jobTitle,department,officeLocation,city,state,businessPhones,mobilePhone"
                }
            ).json()
            properties_to_copy = ['jobTitle', 'department', 'officeLocation', 'city', 'state']
            self.logger.info(f"Copying properties: {properties_to_copy}")
            # Add this log line:
            self.logger.info(f"Source user details: {json.dumps({k: v for k, v in source_details.items() if k in properties_to_copy}, indent=2)}")
            self.logger.info(f"Full source user details: {json.dumps(source_details)}")

            update_payload = {
                prop: source_details[prop]
                for prop in properties_to_copy
                if source_details.get(prop)
            }
            
            if update_payload:
                self._make_request(
                    "PATCH",
                    f"users/{target_user_id}",
                    json_data=update_payload
                )
                results["properties"]["copied"] = list(update_payload.keys())
                self.logger.info(f"Copied properties: {update_payload.keys()}")

            # 2. Get all memberships (groups and roles together)
            membership_response = self._make_request(
                "GET",
                f"users/{source_user_id}/memberOf"
            )
            
            memberships = membership_response.json().get('value', [])
            
            # Process groups
            groups = [m for m in memberships if m.get('@odata.type') == '#microsoft.graph.group'
                    and not any(gt == 'DynamicMembership' for gt in m.get('groupTypes', []))]
            
            for group in groups:
                try:
                    self._make_request(
                        "POST",
                        f"groups/{group['id']}/members/$ref",
                        json_data={
                            "@odata.id": f"{self.graph_api_endpoint}/directoryObjects/{target_user_id}"
                        }
                    )
                    results["groups"]["copied"].append(group.get('displayName', group['id']))
                    self.logger.info(f"Added to group: {group.get('displayName', group['id'])}")
                except Exception as e:
                    results["groups"]["failed"].append({
                        "name": group.get('displayName', group['id']),
                        "error": str(e)
                    })
                    self.logger.warning(f"Failed to add to group {group.get('displayName', group['id'])}: {e}")

            # Skip role processing if skip_roles is True
            if not skip_roles:
                roles = [m for m in memberships if m.get('@odata.type') == '#microsoft.graph.directoryRole']
                for role in roles:
                    try:
                        self._make_request(
                            "POST",
                            f"directoryRoles/{role['id']}/members/$ref",
                            json_data={
                                "@odata.id": f"{self.graph_api_endpoint}/directoryObjects/{target_user_id}"
                            }
                        )
                        results["roles"]["copied"].append(role.get('displayName', role['id']))
                        self.logger.info(f"Assigned role: {role.get('displayName', role['id'])}")
                    except Exception as e:
                        results["roles"]["failed"].append({
                            "name": role.get('displayName', role['id']),
                            "error": str(e)
                        })
                        self.logger.warning(f"Failed to assign role {role.get('displayName', role['id'])}: {e}")

            return results
            
        except Exception as e:
            self.logger.error(f"User property copying failed: {e}")
            raise GraphAPIError(f"Failed to copy user properties: {e}")

    def _copy_user_roles(self, source_user_id: str, target_user_id: str) -> dict[str, list]:
        """
        Copy only roles from source to target user.
        Attempted at the end of user creation to prevent permission issues.
        """
        results = {
            "copied": [],
            "failed": []
        }
        
        try:
            # Get source user's memberships
            membership_response = self._make_request(
                "GET",
                f"users/{source_user_id}/memberOf"
            )
            
            memberships = membership_response.json().get('value', [])
            
            # Filter for only directory roles
            roles = [m for m in memberships if m.get('@odata.type') == '#microsoft.graph.directoryRole']
            
            if not roles:
                self.logger.info("No roles to copy from source user")
                return results
                
            self.logger.info(f"Found {len(roles)} roles to copy")
            
            # Try to copy each role
            for role in roles:
                try:
                    self._make_request(
                        "POST",
                        f"directoryRoles/{role['id']}/members/$ref",
                        json_data={
                            "@odata.id": f"{self.graph_api_endpoint}/directoryObjects/{target_user_id}"
                        }
                    )
                    role_name = role.get('displayName', role['id'])
                    results["copied"].append(role_name)
                    self.logger.info(f"Successfully copied role: {role_name}")
                    
                except Exception as e:
                    error_msg = str(e)
                    results["failed"].append({
                        "name": role.get('displayName', role['id']),
                        "error": error_msg
                    })
                    self.logger.warning(f"Failed to copy role {role.get('displayName', role['id'])}: {error_msg}")
                    
            return results
            
        except Exception as e:
            error_msg = f"Role copying failed: {str(e)}"
            self.logger.error(error_msg)
            if hasattr(e, 'response'):
                self.logger.error(f"Error response: {e.response.text}")
            raise GraphAPIError(error_msg)
        
    def _format_phone_number(self, phone_number: str) -> str:
        """
        Format phone number according to Microsoft standards with extension support.
        
        Supports formats:
        - International: +1 (415) 867-5309 x123
        - Domestic: (415) 867-5309 ext. 123
        - E.164: +14158675309;ext=123
        
        Args:
            phone_number (str): Phone number to format
                
        Returns:
            str: Formatted phone number in Microsoft preferred format
        """
        try:
            if not phone_number:
                return ""

            # Split extension if present
            extension = ""
            for ext_marker in ['x', 'ext', 'ext.', 'extension']:
                if ext_marker in phone_number.lower():
                    parts = phone_number.lower().split(ext_marker)
                    phone_number = parts[0]
                    if len(parts) > 1:
                        extension = parts[1].strip(' .')
                    break

            # Remove all non-digit characters except '+'
            digits = ''.join(char for char in phone_number if char.isdigit() or char == '+')
            
            # Ensure number starts with country code
            if not digits.startswith('+'):
                if len(digits) == 10:
                    digits = '+1' + digits  # US/Canada numbers
                elif len(digits) > 10:
                    digits = '+' + digits  # International numbers
                else:
                    self.logger.warning(f"Invalid phone number length: {digits}")
                    return phone_number  # Return original if invalid

            # Format the main number beautifully
            if digits.startswith('+1'):  # US/Canada format
                formatted = f"{digits[:2]} ({digits[2:5]}) {digits[5:8]}-{digits[8:12]}"
            else:  # International format
                formatted = digits

            # Add extension if present
            if extension:
                if formatted.startswith('+1'):
                    formatted += f" x{extension}"  # US/Canada format
                else:
                    formatted += f";ext={extension}"  # International format

            self.logger.info(f"Formatted phone number: {formatted}")
            return formatted

        except Exception as e:
            self.logger.error(f"Failed to format phone number '{phone_number}': {str(e)}")
            return phone_number  # Return original if formatting fails
        
    def _get_group_by_name(self, group_name: str) -> Optional[dict[str, Any]]:
        """
        Get group details by display name with fuzzy matching.
        """
        try:
            self.logger.info(f"Searching for group: {group_name}")
            # Remove filter to get all groups first
            response = self._make_request(
                "GET",
                "groups",
                params={
                    "$select": "id,displayName",
                    "$top": 999  # Limit results but get more
                }
            )
            groups = response.json().get('value', [])
            self.logger.info(f"Found {len(groups)} total groups")
            
            # Log group names for debugging
            self.logger.debug(f"Available groups: {[g['displayName'] for g in groups]}")
            
            # Try exact match first (case-insensitive)
            exact_match = next(
                (g for g in groups if g['displayName'].lower() == group_name.lower()),
                None
            )
            if exact_match:
                self.logger.info(f"Found exact group match: {exact_match['displayName']}")
                return exact_match
                
            # Try fuzzy matching if no exact match
            group_names = [g['displayName'] for g in groups]
            matches = get_close_matches(group_name, group_names, n=1, cutoff=0.8)
            
            if matches:
                matching_group = next(g for g in groups if g['displayName'] == matches[0])
                self.logger.info(
                    f"Found fuzzy group match: '{matching_group['displayName']}' "
                    f"for input: '{group_name}'"
                )
                return matching_group
                
            self.logger.warning(
                f"No matching group found for '{group_name}'. "
                f"Available groups: {', '.join(group_names)}"
            )
            return None
            
        except Exception as e:
            self.logger.error(f"Failed to find group '{group_name}': {str(e)}")
            if hasattr(e, 'response'):
                self.logger.error(f"Response: {e.response.text}")
            return None

    def _add_user_to_groups(self, user_id: str, group_names: list[str]) -> dict[str, list[str]]:
        """
        Add user to specified groups with improved error handling and membership checking.
        
        Args:
            user_id (str): User's Azure AD object ID
            group_names (list[str]): list of group display names
            
        Returns:
            dict[str, list[str]]: Results of group assignments
        """
        results = {
            "successful": [],
            "failed": []
        }
        
        try:
            # First get user's current group memberships
            current_memberships_response = self._make_request(
                "GET",
                f"users/{user_id}/memberOf",
                params={
                    "$select": "id,displayName"
                }
            )
            current_groups = current_memberships_response.json().get('value', [])
            current_group_names = {
                g.get('displayName', '').lower(): g.get('id') 
                for g in current_groups 
                if g.get('@odata.type', '') == '#microsoft.graph.group'
            }
            
            self.logger.info(f"User's current groups: {list(current_group_names.keys())}")
            
            for group_name in group_names:
                try:
                    if not group_name.strip():
                        continue
                        
                    group_details = self._get_group_by_name(group_name)
                    if not group_details:
                        results["failed"].append(f"{group_name} (group not found)")
                        continue
                        
                    # Check if user is already a member of this group
                    if group_details['displayName'].lower() in current_group_names:
                        self.logger.info(
                            f"User is already a member of group: {group_details['displayName']}"
                        )
                        results["successful"].append(f"{group_details['displayName']} (already member)")
                        continue
                        
                    # Attempt to add user to group
                    self._make_request(
                        "POST",
                        f"groups/{group_details['id']}/members/$ref",
                        json_data={
                            "@odata.id": f"{self.graph_api_endpoint}/directoryObjects/{user_id}"
                        },
                        timeout=45  # Increased timeout for group operations
                    )
                    
                    results["successful"].append(group_details['displayName'])
                    self.logger.info(f"Added user to group: {group_details['displayName']}")
                    
                except GraphAPIError as e:
                    error_msg = str(e)
                    # Check if error is due to existing membership
                    if "already exist" in error_msg.lower():
                        results["successful"].append(f"{group_name} (already member)")
                        self.logger.info(f"User is already a member of group: {group_name}")
                    else:
                        self.logger.error(f"Failed to add to group {group_name}: {error_msg}")
                        results["failed"].append(f"{group_name} (error: {error_msg})")
                    
                except Exception as e:
                    error_msg = f"Unexpected error adding to group {group_name}: {str(e)}"
                    self.logger.error(error_msg)
                    results["failed"].append(f"{group_name} (unexpected error)")
                    
        except Exception as e:
            self.logger.error(f"Failed to process group assignments: {str(e)}")
            if hasattr(e, 'response'):
                self.logger.error(f"Error response: {e.response.text}")
            results["failed"].append(f"Error retrieving group memberships: {str(e)}")
            
        return results

    def _assign_licenses_internal(self, user_id: str, 
                                license_details: list[dict[str, Any]]) -> bool:
        """Internal method to assign licenses to a user."""
        try:
            if not license_details:
                return True
                
            license_payload = {
                "addLicenses": [
                    {
                        "skuId": sku['skuId'],
                        "disabledPlans": []
                    } for sku in license_details
                ],
                "removeLicenses": []
            }
            
            self._make_request(
                "POST",
                f"users/{user_id}/assignLicense",
                json_data=license_payload
            )
            
            self.logger.info(
                f"Assigned licenses: {[sku['friendlyName'] for sku in license_details]}"
            )
            return True
            
        except Exception as e:
            self.logger.error(f"License assignment failed: {str(e)}")
            if hasattr(e, 'response'):
                self.logger.error(f"Response: {e.response.text}")
            return False

    def create_user(self, display_name: str, email_address: str, **kwargs) -> Optional[dict[str, Any]]:
        """Create a new Microsoft 365 user with specified properties and licenses."""
        try:
            # Create base user first
            user_result = self._create_base_user(
                display_name=display_name,
                email_address=email_address,
                password=kwargs.get('password')
            )

            if user_to_copy := kwargs.get('user_to_copy'):
                if source_user := self._get_user_details(user_to_copy):
                    self.logger.info(f"Found user to copy from: {source_user['displayName']}")
                    
                    # Copy properties and groups first, exclude roles
                    copy_results = {
                        'properties': {'copied': [], 'failed': []},
                        'groups': {'copied': [], 'failed': []},
                        'roles': {'copied': [], 'failed': []}  # Will be populated later
                    }
                    
                    # Copy properties, groups first (modify _copy_user_properties to exclude roles)
                    initial_copy_results = self._copy_user_properties(
                        source_user['id'],
                        user_result['id'],
                        skip_roles=True  # Add this parameter
                    )
                    
                    copy_results['properties'] = initial_copy_results['properties']
                    copy_results['groups'] = initial_copy_results['groups']
                    
                    if kwargs.get('copy_licenses'):
                        self.logger.info("Copying licenses from template user...")
                        template_licenses = self.get_user_licenses(source_user['id'])
                        if template_licenses:
                            license_skus = [lic['friendlyName'] for lic in template_licenses]
                            self.logger.info(f"Found licenses to copy: {license_skus}")
                            license_results = self.assign_licenses(user_result['id'], set(license_skus))
                            user_result['licenses'] = license_results
                        else:
                            self.logger.info("No licenses found on template user to copy")
                    
                    # Add copy results to user_result
                    user_result['copy_results'] = {
                        'properties': {
                            'copied': copy_results['properties']['copied'],
                            'failed': copy_results['properties']['failed']
                        },
                        'groups': {
                            'copied': copy_results['groups']['copied'],
                            'failed': [f"{g['name']} ({g['error']})" for g in copy_results['groups']['failed']]
                        },
                        'roles': {
                            'copied': [],
                            'failed': []
                        }
                    }

            # Add/override specified properties
            update_properties = {}
            for field in ['department', 'city', 'state']:
                if value := kwargs.get(field):
                    update_properties[field] = value

            if email_address := kwargs.get('email_address'):
                update_properties['userPrincipalName'] = email_address
                update_properties['mailNickname'] = email_address.split("@")[0]
                update_properties['mail'] = email_address
                self.logger.info(f"setting email address: {email_address}, nickname: {update_properties['mailNickname']}, mail: {update_properties['mail']}")

            # Handle phone numbers specifically
            if business_phone := kwargs.get('business_phone'):
                business_phone = self._format_phone_number(business_phone)
                self.logger.info(f"setting business phone: {business_phone}")
                update_properties['businessPhones'] = [business_phone]
            if mobile_phone := kwargs.get('mobile_phone'):
                mobile_phone = self._format_phone_number(mobile_phone)
                self.logger.info(f"setting mobile phone: {mobile_phone}")
                update_properties['mobilePhone'] = mobile_phone
            if job_title := kwargs.get('job_title'):
                update_properties['jobTitle'] = job_title
            if office_location := kwargs.get('office_location'):
                update_properties['officeLocation'] = office_location
            
            if update_properties:
                try:
                    self.logger.info(f"Sending update request with properties: {update_properties}")
                    response = self._make_request(
                        "PATCH",
                        f"users/{user_result['id']}",
                        json_data=update_properties
                    )
                    self.logger.info(f"Update response status: {response.status_code}")
                    self.logger.info(f"Update response body: {response.text}")
                    self.logger.info(f"Updated custom properties: {list(update_properties.keys())}")
                except Exception as e:
                    self.logger.error(f"Failed to update custom properties: {str(e)}")
                    if hasattr(e, 'response'):
                        self.logger.error(f"Error response: {e.response.text}")

            # Create password link
            try:
                password_link = self.password_manager.create_secure_link(user_result['password'])
                user_result['password_link'] = password_link
            except Exception as e:
                self.logger.error(f"Failed to create password link: {e}")
                user_result['password_link'] = None

            # Process license SKUs if provided directly
            if license_skus := kwargs.get('license_skus'):
                if isinstance(license_skus, str):
                    license_skus = [sku.strip() for sku in license_skus.split(',') if sku.strip()]
                license_results = self.assign_licenses(user_result['id'], set(license_skus))
                user_result['licenses'] = license_results

            # Process additional groups if provided
            if groups := kwargs.get('groups'):
                if isinstance(groups, str):
                    groups = [g.strip() for g in groups.split(',') if g.strip()]
                group_results = self._add_user_to_groups(user_result['id'], groups)
                user_result['groups'] = group_results

            # Now try to copy roles if they exist
            if user_to_copy and source_user:
                try:
                    role_copy_results = self._copy_user_roles(
                        source_user['id'],
                        user_result['id']
                    )
                    user_result['copy_results']['roles'] = {
                        'copied': role_copy_results['copied'],
                        'failed': [f"{r['name']} ({r['error']})" for r in role_copy_results['failed']]
                    }
                except Exception as e:
                    self.logger.error(f"Failed to copy roles: {str(e)}")
                    user_result['copy_results']['roles'] = {
                        'copied': [],
                        'failed': ['Failed to copy roles: insufficient permissions']
                    }

            return user_result

        except Exception as e:
            self.logger.error(f"Failed to create user: {str(e)}")
            return None
        
    def get_user_licenses(self, user_id: str) -> list[dict[str, str]]:
        """
        Get user's licenses with both SKU IDs and friendly names.
        
        Args:
            user_id: User's ID in Azure AD
            
        Returns:
            list of dictionaries containing skuId and friendly name for each license
        """
        try:
            response = self._make_request(
                "GET",
                f"users/{user_id}/licenseDetails"
            )
            
            licenses = []
            for sku in response.json().get('value', []):
                # Find friendly name from PRODUCT_LICENSES using skuPartNumber
                friendly_name = next(
                    (info["ProductName"] for info in self.PRODUCT_LICENSES 
                    if info["StringID"].upper() == sku.get('skuPartNumber', '').upper()),
                    sku.get('skuPartNumber', '')  # Fallback to skuPartNumber if no friendly name found
                )
                
                licenses.append({
                    'skuId': sku['skuId'],
                    'friendlyName': friendly_name,
                    'skuPartNumber': sku.get('skuPartNumber', '')
                })
                
                self.logger.info(f"Found license: {friendly_name} ({sku.get('skuPartNumber', '')})")
                
            return licenses
                
        except Exception as e:
            self.logger.error(f"Failed to get user licenses: {str(e)}")
            if hasattr(e, 'response'):
                self.logger.error(f"Response: {e.response.text}")
            return []

    def assign_licenses(self, user_id: str, licenses_to_assign: set) -> dict[str, Any]:
        """
        Assign licenses to a user with validation and availability checking.
        
        Args:
            user_id: The ID of the user to assign licenses to
            licenses_to_assign: set of license SKUs or friendly names to assign
                
        Returns:
            dictionary containing results of license assignment
        """
        if not licenses_to_assign:
            return {
                "assigned": [],
                "unavailable": [],
                "available_str": "",
                "unavailable_str": ""
            }

        self.logger.info(f"Processing licenses to assign: {list(licenses_to_assign)}")
        
        try:
            # Get tenant SKUs
            tenant_skus = self._get_tenant_skus()
            
            # Process each license request
            processed_licenses = [
                self._process_license_request(license_sku, tenant_skus)
                for license_sku in licenses_to_assign
                if license_sku
            ]
            
            # Format results
            results = self._format_license_results(processed_licenses, tenant_skus)
            
            # Attempt to assign available licenses
            if results['available']:
                if self._assign_licenses_internal(user_id, results['available']):
                    self.logger.info(f"Successfully assigned licenses: {[sku['friendlyName'] for sku in results['available']]}")
                else:
                    self.logger.error("License assignment failed")
                    results['unavailable'].extend([sku['friendlyName'] for sku in results['available']])
                    results['available'] = []
                    results['available_str'] = "No licenses available"
                    results['unavailable_str'] = "License assignment failed"
            
            return results

        except Exception as e:
            self.logger.error(f"License assignment failed: {str(e)}")
            return {
                "assigned": [],
                "unavailable": list(licenses_to_assign),
                "available_str": "",
                "unavailable_str": f"Error assigning licenses: {str(e)}"
            }
