Private/RoleManagement/Get-PIMPoliciesBatch.ps1

function Get-PIMPoliciesBatch {
    <#
    .SYNOPSIS
        Retrieves policies for multiple roles in batch operations.
     
    .DESCRIPTION
        Fetches role management policies for multiple roles at once to improve performance.
        Uses batch API operations and intelligent filtering to minimize Graph API calls.
     
    .PARAMETER RoleIds
        Array of Entra ID role definition IDs to fetch policies for.
     
    .PARAMETER GroupIds
        Array of group IDs to fetch policies for.
     
    .PARAMETER Type
        The type of roles being processed (Entra or Group).
     
    .PARAMETER PolicyCache
        Hashtable to store the fetched policies in.
     
    .PARAMETER EnableParallelProcessing
        Switch to enable parallel processing of policy fetching.
        Requires PowerShell 7+ and significantly improves performance with multiple policies.
     
    .PARAMETER ThrottleLimit
        Maximum number of parallel operations for policy fetching.
        Default is 6. Only used when EnableParallelProcessing is specified.
     
    .EXAMPLE
        Get-PIMPoliciesBatch -RoleIds $roleIds -Type 'Entra' -PolicyCache $cache
        Fetches policies for the specified Entra roles and stores them in the cache.
     
    .EXAMPLE
        Get-PIMPoliciesBatch -RoleIds $roleIds -Type 'Entra' -PolicyCache $cache -ThrottleLimit 8
        Fetches policies using parallel processing with 8 concurrent operations.
     
    .NOTES
        This function uses batch operations to significantly reduce the number of API calls
        required to fetch role management policies.
    #>

    [CmdletBinding()]
    param(
        [string[]]$RoleIds = @(),
        [string[]]$GroupIds = @(),
        [ValidateSet('Entra', 'Group')]
        [string]$Type,
        [Parameter(Mandatory)]
        [hashtable]$PolicyCache,
        
        [switch]$DisableParallelProcessing,
        
        [int]$ThrottleLimit = 10
    )
    
    Write-Verbose "Starting batch policy fetch for $Type roles"
    
    # Ensure we have arrays to work with for input parameters
    if (-not $RoleIds) {
        $RoleIds = @()
    } elseif ($RoleIds -isnot [array]) {
        $RoleIds = @($RoleIds)
    }
    
    if (-not $GroupIds) {
        $GroupIds = @()
    } elseif ($GroupIds -isnot [array]) {
        $GroupIds = @($GroupIds)
    }
    
    try {
        if ($Type -eq 'Entra' -and $RoleIds.Count -gt 0) {
            Write-Verbose "Fetching policies for $($RoleIds.Count) Entra roles"
            
            # Filter out roles that already have cached policies
            $uncachedRoleIds = [System.Collections.ArrayList]::new()
            foreach ($roleId in $RoleIds) {
                $cacheKey = "Entra_$roleId"
                if (-not $script:PolicyCache.ContainsKey($cacheKey)) {
                    [void]$uncachedRoleIds.Add($roleId)
                } else {
                    Write-Verbose "Using cached policy for Entra role: $roleId"
                    # Copy from script cache to local cache for this batch operation
                    $PolicyCache[$cacheKey] = $script:PolicyCache[$cacheKey]
                }
            }
            
            # Only fetch policies for roles not in cache
            if ($uncachedRoleIds.Count -gt 0) {
                Write-Verbose "Fetching $($uncachedRoleIds.Count) uncached Entra role policies from Graph API"

                # Prepare base filter and chunking to avoid Graph InvalidFilter when too many OR predicates
                $filterBase = "scopeId eq '/' and scopeType eq 'DirectoryRole'"
                $roleIdsToQuery = $uncachedRoleIds | Sort-Object -Unique
                $chunkSize = 15
                $policyAssignments = [System.Collections.ArrayList]::new()
                $skipBatchMapping = $false

                try {
                    if ($roleIdsToQuery.Count -le $chunkSize) {
                        $orFilter = ($roleIdsToQuery | ForEach-Object { "roleDefinitionId eq '$_'" }) -join ' or '
                        $filter = "$filterBase and ($orFilter)"
                        $assignmentParams = @{ Filter = $filter; All = $true }
                        $result = Get-MgPolicyRoleManagementPolicyAssignment @assignmentParams -ErrorAction Stop
                        if ($result) { $result | ForEach-Object { [void]$policyAssignments.Add($_) } }
                    }
                    else {
                        Write-Verbose "Role count exceeds $chunkSize. Querying in chunks..."
                        for ($i = 0; $i -lt $roleIdsToQuery.Count; $i += $chunkSize) {
                            $chunk = $roleIdsToQuery[$i..([Math]::Min($i + $chunkSize - 1, $roleIdsToQuery.Count - 1))]
                            $chunkFilter = "$filterBase and (" + (($chunk | ForEach-Object { "roleDefinitionId eq '$_'" }) -join ' or ') + ")"
                            $chunkParams = @{ Filter = $chunkFilter; All = $true }
                            $chunkResult = Get-MgPolicyRoleManagementPolicyAssignment @chunkParams -ErrorAction Stop
                            if ($chunkResult) { $chunkResult | ForEach-Object { [void]$policyAssignments.Add($_) } }
                        }
                    }
                }
                catch {
                    if ($_.Exception.Message -match 'Invalid(Filter|Resource)') {
                        Write-Verbose "Filter rejected by Graph (InvalidFilter). Falling back to broad fetch + local filter."
                        $broadParams = @{ Filter = $filterBase; All = $true }
                        $allAssignments = Get-MgPolicyRoleManagementPolicyAssignment @broadParams -ErrorAction Stop
                        if (-not $allAssignments) { $allAssignments = @() } elseif ($allAssignments -isnot [array]) { $allAssignments = @($allAssignments) }
                        $policyAssignments = $allAssignments | Where-Object { $roleIdsToQuery -contains $_.RoleDefinitionId }
                    } else {
                        Write-Warning "Failed to fetch Entra policy assignments: $_"
                        # Fall back to individual fetches if batch fails with other errors
                        foreach ($roleId in $roleIdsToQuery) {
                            try {
                                $singleParams = @{ Filter = "$filterBase and roleDefinitionId eq '$roleId'"; All = $true }
                                $assignment = Get-MgPolicyRoleManagementPolicyAssignment @singleParams -ErrorAction Stop | Select-Object -First 1
                                if ($assignment) {
                                    $policyParams = @{ UnifiedRoleManagementPolicyId = $assignment.PolicyId; ExpandProperty = 'rules' }
                                    $policy = Get-MgPolicyRoleManagementPolicy @policyParams
                                    $policyInfo = ConvertTo-PolicyInfo -Policy $policy
                                    $cacheKey = "Entra_$roleId"
                                    $PolicyCache[$cacheKey] = $policyInfo
                                    # Also cache in script-level cache for future use
                                    $script:PolicyCache[$cacheKey] = $policyInfo
                                }
                            }
                            catch {
                                Write-Verbose "Failed to fetch policy for role $roleId : $_"
                                continue
                            }
                        }
                        $skipBatchMapping = $true
                    }
                }

                if (-not $skipBatchMapping) {
                    # Ensure we have arrays to work with; flatten ArrayList if used
                    if (-not $policyAssignments -or $policyAssignments.Count -eq 0) {
                        $policyAssignments = @()
                    } else {
                        $policyAssignments = @($policyAssignments | ForEach-Object { $_ })
                    }
                    Write-Verbose "Found $($policyAssignments.Count) policy assignments"

                    # Get unique policy IDs
                    $uniquePolicyIds = $policyAssignments | Select-Object -ExpandProperty PolicyId -Unique
                    if (-not $uniquePolicyIds) {
                        $uniquePolicyIds = @()
                    } elseif ($uniquePolicyIds -isnot [array]) {
                        $uniquePolicyIds = @($uniquePolicyIds)
                    }
                    Write-Verbose "Processing $($uniquePolicyIds.Count) unique policies"

                    # Check if parallel processing is requested and supported
                    $useParallel = -not $DisableParallelProcessing -and $PSVersionTable.PSVersion.Major -ge 7 -and $uniquePolicyIds.Count -gt 2
                    
                    if ($useParallel) {
                        Write-Verbose "Using parallel processing for $($uniquePolicyIds.Count) Entra policies (ThrottleLimit: $ThrottleLimit)"
                        
                        # Create thread-safe collection for results
                        $policyResults = [System.Collections.Concurrent.ConcurrentDictionary[string,object]]::new()
                        $processedCount = [System.Collections.Concurrent.ConcurrentDictionary[string,bool]]::new()
                        $startTime = Get-Date
                        
                        # Process policies in parallel
                        $uniquePolicyIds | ForEach-Object -Parallel {
                            $policyId = $_
                            $policyResultsLocal = $using:policyResults
                            $processedCountLocal = $using:processedCount
                            
                            # Import the ConvertTo-PolicyInfo function definition into parallel scope
                            function ConvertTo-PolicyInfo {
                                param([Parameter(Mandatory)]$Policy)
                                
                                $policyInfo = [PSCustomObject]@{
                                    MaxDuration = 8
                                    RequiresMfa = $false
                                    RequiresJustification = $false
                                    RequiresTicket = $false
                                    RequiresApproval = $false
                                    RequiresAuthenticationContext = $false
                                    AuthenticationContextId = $null
                                    AuthenticationContextDisplayName = $null
                                    AuthenticationContextDescription = $null
                                    AuthenticationContextDetails = $null
                                }
                                
                                if (-not $Policy.Rules) {
                                    return $policyInfo
                                }
                                
                                foreach ($rule in $Policy.Rules) {
                                    $ruleType = $rule.AdditionalProperties['@odata.type'] ?? $rule.'@odata.type'
                                    
                                    switch ($ruleType) {
                                        '#microsoft.graph.unifiedRoleManagementPolicyExpirationRule' {
                                            if ($rule.AdditionalProperties.maximumDuration -or $rule.maximumDuration) {
                                                $duration = $rule.AdditionalProperties.maximumDuration ?? $rule.maximumDuration
                                                try {
                                                    $timespan = [System.Xml.XmlConvert]::ToTimeSpan($duration)
                                                    $policyInfo.MaxDuration = [int]$timespan.TotalHours
                                                }
                                                catch {
                                                    # Keep default
                                                }
                                            }
                                        }
                                        '#microsoft.graph.unifiedRoleManagementPolicyEnablementRule' {
                                            $enabledRules = @($rule.AdditionalProperties.enabledRules ?? $rule.enabledRules ?? @())
                                            $policyInfo.RequiresJustification = 'Justification' -in $enabledRules
                                            $policyInfo.RequiresTicket = 'Ticketing' -in $enabledRules
                                            $policyInfo.RequiresMfa = 'MultiFactorAuthentication' -in $enabledRules
                                            $policyInfo.RequiresAuthenticationContext = 'AuthenticationContext' -in $enabledRules
                                        }
                                        '#microsoft.graph.unifiedRoleManagementPolicyApprovalRule' {
                                            $setting = $rule.AdditionalProperties.setting ?? $rule.setting
                                            if ($setting -and $setting.isApprovalRequired) {
                                                $policyInfo.RequiresApproval = $true
                                            }
                                        }
                                        '#microsoft.graph.unifiedRoleManagementPolicyAuthenticationContextRule' {
                                            if (($rule.AdditionalProperties.isEnabled ?? $rule.isEnabled) -and 
                                                ($rule.AdditionalProperties.claimValue ?? $rule.claimValue)) {
                                                $policyInfo.RequiresAuthenticationContext = $true
                                                $policyInfo.AuthenticationContextId = $rule.AdditionalProperties.claimValue ?? $rule.claimValue
                                            }
                                        }
                                    }
                                }
                                
                                return $policyInfo
                            }
                            
                            try {
                                Write-Verbose "[Parallel] Fetching policy: $policyId"
                                $policy = Get-MgPolicyRoleManagementPolicy -UnifiedRoleManagementPolicyId $policyId -ExpandProperty "rules" -ErrorAction Stop
                                
                                # Convert policy to policy info using the function now available in this runspace
                                $policyInfo = ConvertTo-PolicyInfo -Policy $policy
                                
                                $policyResultsLocal.TryAdd($policyId, $policyInfo) | Out-Null
                                $processedCountLocal.TryAdd($policyId, $true) | Out-Null
                                
                                $currentProgress = $processedCountLocal.Count
                                $totalPolicies = $using:uniquePolicyIds.Count
                                Write-Verbose "[Parallel] ✅ Policy $policyId completed ($currentProgress/$totalPolicies)"
                            }
                            catch {
                                Write-Verbose "[Parallel] ❌ Failed to fetch policy $policyId : $_"
                            }
                        } -ThrottleLimit $ThrottleLimit
                        
                        $endTime = Get-Date
                        $duration = ($endTime - $startTime).TotalSeconds
                        Write-Verbose "Parallel Entra policy processing completed in $([Math]::Round($duration, 1))s. Successfully fetched $($policyResults.Count)/$($uniquePolicyIds.Count) policies"
                        
                        # Apply results to cache
                        foreach ($assignment in $policyAssignments) {
                            if ($policyResults.ContainsKey($assignment.PolicyId)) {
                                $policyInfo = $policyResults[$assignment.PolicyId]
                                $cacheKey = "Entra_$($assignment.RoleDefinitionId)"
                                $PolicyCache[$cacheKey] = $policyInfo
                                $script:PolicyCache[$cacheKey] = $policyInfo
                                Write-Verbose "Cached policy for Entra role: $($assignment.RoleDefinitionId)"
                            }
                        }
                    }
                    else {
                        # Use sequential processing (original logic)
                        if ($DisableParallelProcessing) {
                            Write-Verbose "Parallel processing disabled by user. Using sequential processing."
                        } else {
                            Write-Verbose "Parallel processing not supported (PowerShell $($PSVersionTable.PSVersion.Major) or few policies). Using sequential processing."
                        }
                        
                        # Batch fetch all policies with expanded rules
                        foreach ($policyId in $uniquePolicyIds) {
                            try {
                                $policy = Get-MgPolicyRoleManagementPolicy -UnifiedRoleManagementPolicyId $policyId -ExpandProperty "rules"

                                # Process policy rules
                                $policyInfo = ConvertTo-PolicyInfo -Policy $policy

                                # Map policy to all roles that use it
                                $applicableRoles = $policyAssignments | Where-Object { $_.PolicyId -eq $policyId }
                                foreach ($assignment in $applicableRoles) {
                                    $cacheKey = "Entra_$($assignment.RoleDefinitionId)"
                                    $PolicyCache[$cacheKey] = $policyInfo
                                    # Also cache in script-level cache for future use
                                    $script:PolicyCache[$cacheKey] = $policyInfo
                                    Write-Verbose "Cached policy for Entra role: $($assignment.RoleDefinitionId)"
                                }
                            }
                            catch {
                                Write-Warning "Failed to fetch policy $policyId : $_"
                                continue
                            }
                        }
                    }
                }
            } else {
                Write-Verbose "All Entra role policies found in cache"
            }
        }
        
        if ($Type -eq 'Group' -and $GroupIds.Count -gt 0) {
            Write-Verbose "Fetching policies for $($GroupIds.Count) groups"
            
            # Filter out groups that already have cached policies
            $uncachedGroupIds = [System.Collections.ArrayList]::new()
            foreach ($groupId in $GroupIds) {
                $cacheKey = "Group_$groupId"
                if (-not $script:PolicyCache.ContainsKey($cacheKey)) {
                    [void]$uncachedGroupIds.Add($groupId)
                } else {
                    Write-Verbose "Using cached policy for Group: $groupId"
                    # Copy from script cache to local cache for this batch operation
                    $PolicyCache[$cacheKey] = $script:PolicyCache[$cacheKey]
                }
            }
            
            # Only fetch policies for groups not in cache
            if ($uncachedGroupIds.Count -gt 0) {
                Write-Verbose "Fetching $($uncachedGroupIds.Count) uncached group policies from Graph API"

                $filterBaseGroup = "scopeType eq 'Group'"
                $groupIdsToQuery = $uncachedGroupIds | Sort-Object -Unique
                $chunkSize = 15
                $allGroupAssignments = [System.Collections.ArrayList]::new()
                $fallbackToPerGroup = $false

                try {
                    if ($groupIdsToQuery.Count -le $chunkSize) {
                        $orFilter = ($groupIdsToQuery | ForEach-Object { "scopeId eq '$_'" }) -join ' or '
                        $filter = "$filterBaseGroup and ($orFilter)"
                        $uri = "https://graph.microsoft.com/v1.0/policies/roleManagementPolicyAssignments?`$filter=$filter"
                        $response = Invoke-MgGraphRequest -Uri $uri -Method GET -ErrorAction Stop
                        $items = @($response.value)
                        while ($response.'@odata.nextLink') {
                            $response = Invoke-MgGraphRequest -Uri $response.'@odata.nextLink' -Method GET -ErrorAction Stop
                            if ($response.value) { $items += @($response.value) }
                        }
                        foreach ($it in $items) {
                            $norm = [PSCustomObject]@{
                                Id = $it.id
                                PolicyId = $it.policyId
                                RoleDefinitionId = $it.roleDefinitionId
                                ScopeId = $it.scopeId
                                ScopeType = $it.scopeType
                            }
                            [void]$allGroupAssignments.Add($norm)
                        }
                    } else {
                        Write-Verbose "Group count exceeds $chunkSize. Querying in chunks..."
                        for ($i = 0; $i -lt $groupIdsToQuery.Count; $i += $chunkSize) {
                            $chunk = $groupIdsToQuery[$i..([Math]::Min($i + $chunkSize - 1, $groupIdsToQuery.Count - 1))]
                            $chunkFilter = "$filterBaseGroup and (" + (($chunk | ForEach-Object { "scopeId eq '$_'" }) -join ' or ') + ")"
                            $uri = "https://graph.microsoft.com/v1.0/policies/roleManagementPolicyAssignments?`$filter=$chunkFilter"
                            $response = Invoke-MgGraphRequest -Uri $uri -Method GET -ErrorAction Stop
                            $items = @($response.value)
                            while ($response.'@odata.nextLink') {
                                $response = Invoke-MgGraphRequest -Uri $response.'@odata.nextLink' -Method GET -ErrorAction Stop
                                if ($response.value) { $items += @($response.value) }
                            }
                            foreach ($it in $items) {
                                $norm = [PSCustomObject]@{
                                    Id = $it.id
                                    PolicyId = $it.policyId
                                    RoleDefinitionId = $it.roleDefinitionId
                                    ScopeId = $it.scopeId
                                    ScopeType = $it.scopeType
                                }
                                [void]$allGroupAssignments.Add($norm)
                            }
                        }
                    }
                }
                catch {
                    if ($_.Exception.Message -match 'Invalid(Filter|Resource)') {
                        Write-Verbose "Group filter rejected (InvalidFilter). Falling back to per-group fetch."
                        $fallbackToPerGroup = $true
                    } else {
                        Write-Warning "Failed to batch fetch group policy assignments: $_"
                        $fallbackToPerGroup = $true
                    }
                }
                if ($fallbackToPerGroup) {
                    foreach ($groupId in $groupIdsToQuery) {
                        try {
                            $singleParams = @{ Filter = "scopeId eq '$groupId' and scopeType eq 'Group'"; All = $true }
                            $assignments = Get-MgPolicyRoleManagementPolicyAssignment @singleParams -ErrorAction Stop
                            if (-not $assignments) { $assignments = @() } elseif ($assignments -isnot [array]) { $assignments = @($assignments) }

                            $assignment = $assignments | Where-Object { $_.RoleDefinitionId -eq 'member' } | Select-Object -First 1
                # If batch returned no assignments and no exception, fall back to per-group queries
                if (-not $fallbackToPerGroup -and ($allGroupAssignments.Count -eq 0) -and ($groupIdsToQuery.Count -gt 0)) {
                    Write-Verbose "No group policy assignments returned by batch query; falling back to per-group fetch."
                    $fallbackToPerGroup = $true
                }
                            if (-not $assignment) { $assignment = $assignments | Where-Object { $_.RoleDefinitionId -eq 'owner' } | Select-Object -First 1 }

                            if ($assignment) {
                                $policyParams = @{ UnifiedRoleManagementPolicyId = $assignment.PolicyId; ExpandProperty = 'rules' }
                                $policy = Get-MgPolicyRoleManagementPolicy @policyParams
                                $policyInfo = ConvertTo-PolicyInfo -Policy $policy

                                $cacheKey = "Group_$groupId"
                                $PolicyCache[$cacheKey] = $policyInfo
                                $script:PolicyCache[$cacheKey] = $policyInfo
                                Write-Verbose "Cached policy for group: $groupId"
                            } else {
                                Write-Verbose "No policy assignment found for group: $groupId"
                            }
                        }
                        catch {
                            Write-Warning "Failed to fetch policy for group $groupId : $_"
                            continue
                        }
                    }
                } else {
                    # Process batched group assignments
                    if (-not $allGroupAssignments) { $allGroupAssignments = @() }
                    Write-Verbose "Found $($allGroupAssignments.Count) group policy assignments"

                    $uniquePolicyIds = $allGroupAssignments | Select-Object -ExpandProperty PolicyId -Unique
                    if (-not $uniquePolicyIds) { $uniquePolicyIds = @() } elseif ($uniquePolicyIds -isnot [array]) { $uniquePolicyIds = @($uniquePolicyIds) }
                    Write-Verbose "Processing $($uniquePolicyIds.Count) unique group policies"

                    # Check if parallel processing is requested and supported
                    $useParallelGroups = -not $DisableParallelProcessing -and $PSVersionTable.PSVersion.Major -ge 7 -and $uniquePolicyIds.Count -gt 2
                    
                    if ($useParallelGroups) {
                        Write-Verbose "Using parallel processing for $($uniquePolicyIds.Count) Group policies (ThrottleLimit: $ThrottleLimit)"
                        
                        # Create thread-safe collection for results
                        $policyMapParallel = [System.Collections.Concurrent.ConcurrentDictionary[string,object]]::new()
                        $processedCountGroups = [System.Collections.Concurrent.ConcurrentDictionary[string,bool]]::new()
                        $startTimeGroups = Get-Date
                        
                        # Process group policies in parallel
                        $uniquePolicyIds | ForEach-Object -Parallel {
                            $policyId = $_
                            $policyMapParallelLocal = $using:policyMapParallel
                            $processedCountGroupsLocal = $using:processedCountGroups
                            
                            # Import the ConvertTo-PolicyInfo function definition into parallel scope
                            function ConvertTo-PolicyInfo {
                                param([Parameter(Mandatory)]$Policy)
                                
                                $policyInfo = [PSCustomObject]@{
                                    MaxDuration = 8
                                    RequiresMfa = $false
                                    RequiresJustification = $false
                                    RequiresTicket = $false
                                    RequiresApproval = $false
                                    RequiresAuthenticationContext = $false
                                    AuthenticationContextId = $null
                                    AuthenticationContextDisplayName = $null
                                    AuthenticationContextDescription = $null
                                    AuthenticationContextDetails = $null
                                }
                                
                                if (-not $Policy.Rules) {
                                    return $policyInfo
                                }
                                
                                foreach ($rule in $Policy.Rules) {
                                    $ruleType = $rule.AdditionalProperties['@odata.type'] ?? $rule.'@odata.type'
                                    
                                    switch ($ruleType) {
                                        '#microsoft.graph.unifiedRoleManagementPolicyExpirationRule' {
                                            if ($rule.AdditionalProperties.maximumDuration -or $rule.maximumDuration) {
                                                $duration = $rule.AdditionalProperties.maximumDuration ?? $rule.maximumDuration
                                                try {
                                                    $timespan = [System.Xml.XmlConvert]::ToTimeSpan($duration)
                                                    $policyInfo.MaxDuration = [int]$timespan.TotalHours
                                                }
                                                catch {
                                                    # Keep default
                                                }
                                            }
                                        }
                                        '#microsoft.graph.unifiedRoleManagementPolicyEnablementRule' {
                                            $enabledRules = @($rule.AdditionalProperties.enabledRules ?? $rule.enabledRules ?? @())
                                            $policyInfo.RequiresJustification = 'Justification' -in $enabledRules
                                            $policyInfo.RequiresTicket = 'Ticketing' -in $enabledRules
                                            $policyInfo.RequiresMfa = 'MultiFactorAuthentication' -in $enabledRules
                                            $policyInfo.RequiresAuthenticationContext = 'AuthenticationContext' -in $enabledRules
                                        }
                                        '#microsoft.graph.unifiedRoleManagementPolicyApprovalRule' {
                                            $setting = $rule.AdditionalProperties.setting ?? $rule.setting
                                            if ($setting -and $setting.isApprovalRequired) {
                                                $policyInfo.RequiresApproval = $true
                                            }
                                        }
                                        '#microsoft.graph.unifiedRoleManagementPolicyAuthenticationContextRule' {
                                            if (($rule.AdditionalProperties.isEnabled ?? $rule.isEnabled) -and 
                                                ($rule.AdditionalProperties.claimValue ?? $rule.claimValue)) {
                                                $policyInfo.RequiresAuthenticationContext = $true
                                                $policyInfo.AuthenticationContextId = $rule.AdditionalProperties.claimValue ?? $rule.claimValue
                                            }
                                        }
                                    }
                                }
                                
                                return $policyInfo
                            }
                            
                            try {
                                Write-Verbose "[Parallel] Fetching group policy: $policyId"
                                $policyParams = @{ UnifiedRoleManagementPolicyId = $policyId; ExpandProperty = 'rules' }
                                $policy = Get-MgPolicyRoleManagementPolicy @policyParams -ErrorAction Stop
                                $policyInfo = ConvertTo-PolicyInfo -Policy $policy
                                
                                $policyMapParallelLocal.TryAdd($policyId, $policyInfo) | Out-Null
                                $processedCountGroupsLocal.TryAdd($policyId, $true) | Out-Null
                                
                                $currentProgress = $processedCountGroupsLocal.Count
                                $totalPolicies = $using:uniquePolicyIds.Count
                                Write-Verbose "[Parallel] ✅ Group policy $policyId completed ($currentProgress/$totalPolicies)"
                            }
                            catch {
                                Write-Verbose "[Parallel] ❌ Failed to fetch group policy $policyId : $_"
                            }
                        } -ThrottleLimit $ThrottleLimit
                        
                        $endTimeGroups = Get-Date
                        $durationGroups = ($endTimeGroups - $startTimeGroups).TotalSeconds
                        Write-Verbose "Parallel Group policy processing completed in $([Math]::Round($durationGroups, 1))s. Successfully fetched $($policyMapParallel.Count)/$($uniquePolicyIds.Count) policies"
                        
                        # Convert concurrent dictionary to regular hashtable
                        $policyMap = @{}
                        foreach ($key in $policyMapParallel.Keys) {
                            $policyMap[$key] = $policyMapParallel[$key]
                        }
                    }
                    else {
                        # Use sequential processing (original logic)
                        if ($DisableParallelProcessing) {
                            Write-Verbose "Parallel processing disabled by user. Using sequential processing."
                        } else {
                            Write-Verbose "Parallel processing not supported (PowerShell $($PSVersionTable.PSVersion.Major) or few policies). Using sequential processing."
                        }
                        
                        # Fetch all referenced policies once
                        $policyMap = @{}
                        foreach ($policyId in $uniquePolicyIds) {
                            try {
                                $policyParams = @{ UnifiedRoleManagementPolicyId = $policyId; ExpandProperty = 'rules' }
                                $policy = Get-MgPolicyRoleManagementPolicy @policyParams
                                $policyMap[$policyId] = ConvertTo-PolicyInfo -Policy $policy
                            }
                            catch {
                                Write-Warning "Failed to fetch group policy $policyId : $_"
                                continue
                            }
                        }
                    }

                    # Map to each group (prefer member over owner)
                    foreach ($groupId in $groupIdsToQuery) {
                        $gAssign = $allGroupAssignments | Where-Object { $_.ScopeId -eq $groupId }
                        if ($gAssign) {
                            $assignment = $gAssign | Where-Object { $_.RoleDefinitionId -eq 'member' } | Select-Object -First 1
                            if (-not $assignment) { $assignment = $gAssign | Where-Object { $_.RoleDefinitionId -eq 'owner' } | Select-Object -First 1 }
                            if ($assignment -and $policyMap.ContainsKey($assignment.PolicyId)) {
                                $cacheKey = "Group_$groupId"
                                $PolicyCache[$cacheKey] = $policyMap[$assignment.PolicyId]
                                $script:PolicyCache[$cacheKey] = $policyMap[$assignment.PolicyId]
                                Write-Verbose "Cached policy for group: $groupId"
                            } else {
                                Write-Verbose "No member/owner policy assignment found for group: $groupId"
                            }
                        } else {
                            Write-Verbose "No policy assignments returned for group: $groupId"
                        }
                    }
                }
            } else {
                Write-Verbose "All group policies found in cache"
            }
        }
        
        Write-Verbose "Completed batch policy fetch for $Type"
    }
    catch {
        Write-Warning "Failed to batch fetch policies: $_"
        throw
    }
}