Private/Groups/Invoke-GroupBatchImport.ps1

function Invoke-GroupBatchImport {
    <#
    .SYNOPSIS
        Batch imports or deletes groups using Graph API batch requests for improved performance
    .DESCRIPTION
        Creates or deletes dynamic or static groups using batched Graph API requests to reduce API calls.
        For creation: Batches existence checks (up to 20 per batch) and creation requests (up to 20 per batch).
        For deletion: Lists groups with hydration kit marker and batches DELETE requests.
        Returns results in standardized New-HydrationResult format.
    .PARAMETER GroupDefinitions
        Array of group definition objects from templates. Each must have displayName and description.
        Dynamic groups require membershipRule. Static groups may have requiresServicePrincipalOwner.
        Not required when using -Delete switch.
    .PARAMETER GroupType
        Type of groups to process: 'Dynamic' or 'Static'
    .PARAMETER Delete
        Switch to delete existing groups created by the hydration kit instead of creating new ones.
        Only groups with "Imported by Intune Hydration Kit" in their description will be deleted.
    .PARAMETER KnownNames
        Optional HashSet of known template display names (unprefixed). When provided during delete,
        only groups whose unprefixed name is in this set will be deleted (template-scoped delete).
    .EXAMPLE
        Invoke-GroupBatchImport -GroupDefinitions $dynamicGroups -GroupType 'Dynamic'
    .EXAMPLE
        Invoke-GroupBatchImport -GroupDefinitions $staticGroups -GroupType 'Static' -WhatIf
    .EXAMPLE
        Invoke-GroupBatchImport -GroupType 'Dynamic' -Delete
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter()]
        [array]$GroupDefinitions = @(),

        [ValidateSet('Dynamic', 'Static')]
        [string]$GroupType,

        [Parameter()]
        [switch]$Delete,

        [Parameter()]
        [System.Collections.Generic.HashSet[string]]$KnownNames
    )

    $results = @()
    $maxBatchSize = if ($script:MaxBatchSize) { $script:MaxBatchSize } else { 10 }
    $maxRetries = 3
    $retryDelaySeconds = 2

    # Early return if no groups to process in create mode
    if (-not $Delete -and $GroupDefinitions.Count -eq 0) {
        return $results
    }

    # Verify Graph connection exists
    $mgContext = Get-MgContext
    if (-not $mgContext) {
        Write-Error "No Microsoft Graph connection found. Please connect using Connect-MgGraph."
        return $results
    }

    $resultTypeName = "${GroupType}Group"

    #region Delete Mode

    if ($Delete) {
        Write-Verbose "Delete mode: Finding $GroupType groups to delete..."

        # Build the filter based on group type
        $typeFilter = if ($GroupType -eq 'Dynamic') {
            "groupTypes/any(c:c eq 'DynamicMembership')"
        } else {
            "securityEnabled eq true and NOT groupTypes/any(c:c eq 'DynamicMembership')"
        }

        # Get all groups of this type, then filter locally.
        # Avoids ProcessItems scriptblock scope issue ($var += inside & {} creates a local copy).
        $groupsToDelete = @()
        $headers = @{ 'ConsistencyLevel' = 'eventual' }

        try {
            $allGroups = Get-GraphPagedResults -Uri "beta/groups?`$filter=$typeFilter&`$select=id,displayName,description&`$count=true" -Headers $headers
            foreach ($group in $allGroups) {
                $deleteDecision = Resolve-HydrationMarkedDeleteCandidate `
                    -Name $group.displayName `
                    -Description $group.description `
                    -KnownTemplateNames $KnownNames `
                    -FullObjectUri "beta/groups/$($group.id)"
                if (-not $deleteDecision.IsMatch) {
                    Write-Verbose " Skipping '$($group.displayName)' - $($deleteDecision.Message)"
                    continue
                }

                $groupsToDelete += $group
            }
        } catch {
            Write-Warning "Failed to list $GroupType groups: $_"
            $results += New-HydrationResult -Type $resultTypeName -Name 'List operation' -Action 'Failed' -Status "Failed to list groups: $_"
            return $results
        }

        if ($groupsToDelete.Count -eq 0) {
            Write-Verbose "No $GroupType groups found to delete"
            return $results
        }

        Write-Verbose "Found $($groupsToDelete.Count) $GroupType groups to delete"

        # Handle WhatIf mode for deletion
        if ($WhatIfPreference) {
            foreach ($group in $groupsToDelete) {
                $results += New-HydrationResult -Type $resultTypeName -Name $group.displayName -Id $group.id -Action 'WouldDelete' -Status 'DryRun'
                Write-Verbose " WouldDelete: $($group.displayName)"
            }
            return $results
        }

        $deleteItems = @()
        foreach ($group in $groupsToDelete) {
            $deleteItems += @{
                Name = $group.displayName
                Id   = $group.id
            }
        }

        $results += Invoke-GraphBatchOperation -Items $deleteItems -Operation 'DELETE' -BaseUrl '/groups' -ResultType $resultTypeName
        return $results
    }

    #endregion

    # Apply import prefix - create copies to avoid mutating caller's objects
    $importPrefix = if ([string]::IsNullOrEmpty($script:ImportPrefix)) { '[IHD] ' } else { $script:ImportPrefix }
    $prefixedDefinitions = @()
    foreach ($gd in $GroupDefinitions) {
        $copy = $gd | Select-Object *
        $originalName = $gd.displayName
        if ($copy.displayName -and -not [string]::IsNullOrEmpty($importPrefix) -and -not $copy.displayName.StartsWith($importPrefix)) {
            $copy.displayName = "$importPrefix$($copy.displayName)"
        }
        $copy | Add-Member -NotePropertyName '_OriginalDisplayName' -NotePropertyValue $originalName -Force
        $prefixedDefinitions += $copy
    }

    #region Phase 1: Batch Existence Checks

    Write-Verbose "Checking existence of $($prefixedDefinitions.Count) groups in batches..."

    $existingGroups = @{}  # displayName -> group object
    $groupsToCreate = @()

    # Build batch requests for existence checks
    for ($batchStart = 0; $batchStart -lt $prefixedDefinitions.Count; $batchStart += $maxBatchSize) {
        $batchEnd = [Math]::Min($batchStart + $maxBatchSize, $prefixedDefinitions.Count) - 1
        $currentBatch = $prefixedDefinitions[$batchStart..$batchEnd]

        $batchEntries = @()
        for ($i = 0; $i -lt $currentBatch.Count; $i++) {
            $groupDef = $currentBatch[$i]
            $request = @{
                id     = ($i + 1).ToString()
                method = "GET"
                url    = Get-HydrationGroupLookupUri -GroupDefinition $groupDef
            }
            $batchEntries += [pscustomobject]@{
                Id      = [string]$request.id
                Request = $request
                Item    = $groupDef
                Index   = $i
            }
        }

        # Submit batch request
        $batchBody = @{ requests = @($batchEntries | ForEach-Object { $_.Request }) }
        try {
            $batchResponse = Invoke-MgGraphRequest -Method POST -Uri "beta/`$batch" -Body $batchBody -ErrorAction Stop
            $responseState = Resolve-HydrationBatchResponse -BatchEntries $batchEntries -Responses @(Get-HydrationGroupBatchResponses -BatchResponse $batchResponse)
            $results += New-HydrationBatchCorrelationFailure -ResponseState $responseState -ResultTypeName $resultTypeName -StatusPrefix 'Existence check'

            # Process responses
            foreach ($resolvedResponse in $responseState.Matched) {
                $resp = $resolvedResponse.Response
                $groupDef = $resolvedResponse.Item

                if ($resp.status -eq 200 -and $resp.body.value.Count -gt 0) {
                    # Group exists - prefer the prefixed name match when multiple results
                    $existingGroups[$groupDef.displayName] = Select-HydrationGroupLookupMatch -LookupResult $resp.body -GroupDefinition $groupDef
                } elseif ($resp.status -eq 200) {
                    # Group does not exist - add to creation list
                    $groupsToCreate += $groupDef
                } else {
                    # Error checking existence - log and skip
                    Write-Warning "Failed to check existence of '$($groupDef.displayName)': HTTP $($resp.status)"
                    $results += New-HydrationResult -Type $resultTypeName -Name $groupDef.displayName -Action 'Failed' -Status "Existence check failed: HTTP $($resp.status)"
                }
            }

            foreach ($missingRequest in $responseState.Missing) {
                $missingResolution = Resolve-HydrationMissingGroupExistence -MissingRequest $missingRequest -ResultTypeName $resultTypeName
                if ($missingResolution.ExistingGroup) {
                    $existingGroups[$missingRequest.Item.displayName] = $missingResolution.ExistingGroup
                } elseif ($missingResolution.GroupToCreate) {
                    $groupsToCreate += $missingResolution.GroupToCreate
                }
                if ($missingResolution.Result) {
                    $results += $missingResolution.Result
                }
            }
        } catch {
            # Batch request failed - fall back to individual results
            Write-Warning "Batch existence check failed: $_"
            foreach ($groupDef in $currentBatch) {
                $results += New-HydrationResult -Type $resultTypeName -Name $groupDef.displayName -Action 'Failed' -Status "Batch check failed: $_"
            }
        }
    }

    # Add skipped results for existing groups
    foreach ($displayName in $existingGroups.Keys) {
        $existingGroup = $existingGroups[$displayName]
        $results += New-HydrationResult -Type $resultTypeName -Name $displayName -Id $existingGroup.id -Action 'Skipped' -Status 'Group already exists'
        Write-Verbose " Skipped: $displayName (already exists)"
    }

    #endregion

    #region Phase 2: Batch Creation

    if ($groupsToCreate.Count -eq 0) {
        Write-Verbose "No groups to create - all exist"
        return $results
    }

    Write-Verbose "Creating $($groupsToCreate.Count) groups in batches..."

    # Handle WhatIf mode
    if ($WhatIfPreference) {
        foreach ($groupDef in $groupsToCreate) {
            $results += New-HydrationResult -Type $resultTypeName -Name $groupDef.displayName -Action 'WouldCreate' -Status 'DryRun'
            Write-Verbose " WouldCreate: $($groupDef.displayName)"
        }
        return $results
    }

    # Separate groups that require service principal owner (static only)
    $spOwnerGroups = @()
    $regularGroups = @()

    foreach ($groupDef in $groupsToCreate) {
        if ($GroupType -eq 'Static' -and $groupDef.requiresServicePrincipalOwner) {
            $spOwnerGroups += $groupDef
        } else {
            $regularGroups += $groupDef
        }
    }

    # Create regular groups in batches
    for ($batchStart = 0; $batchStart -lt $regularGroups.Count; $batchStart += $maxBatchSize) {
        $batchEnd = [Math]::Min($batchStart + $maxBatchSize, $regularGroups.Count) - 1
        $currentBatch = $regularGroups[$batchStart..$batchEnd]

        $pendingGroups = @($currentBatch)
        $retryCount = 0

        while ($pendingGroups.Count -gt 0 -and $retryCount -le $maxRetries) {
            if ($retryCount -gt 0) {
                $maxRetryAfter = 0
                foreach ($groupDef in $pendingGroups) {
                    if ($groupDef.RetryAfter) {
                        $parsedRetryAfter = 0
                        if ([int]::TryParse([string]$groupDef.RetryAfter, [ref]$parsedRetryAfter) -and $parsedRetryAfter -gt $maxRetryAfter) {
                            $maxRetryAfter = $parsedRetryAfter
                        }
                        $groupDef.PSObject.Properties.Remove('RetryAfter')
                    }
                }
                $delay = if ($maxRetryAfter -gt 0) { $maxRetryAfter } else { $retryDelaySeconds * [Math]::Pow(2, $retryCount - 1) }
                Start-Sleep -Seconds $delay
            }

            $batchEntries = @()
            for ($i = 0; $i -lt $pendingGroups.Count; $i++) {
                $groupDef = $pendingGroups[$i]
                $request = @{
                    id      = ($i + 1).ToString()
                    method  = "POST"
                    url     = "/groups"
                    headers = @{ "Content-Type" = "application/json" }
                    body    = ConvertTo-HydrationGroupBody -GroupDefinition $groupDef -GroupType $GroupType
                }
                $batchEntries += [pscustomobject]@{
                    Id      = [string]$request.id
                    Request = $request
                    Item    = $groupDef
                    Index   = $i
                }
            }

            $itemsToRetry = @()
            $batchBody = @{ requests = @($batchEntries | ForEach-Object { $_.Request }) }
            try {
                $batchResponse = Invoke-MgGraphRequest -Method POST -Uri "beta/`$batch" -Body $batchBody -ErrorAction Stop
            } catch {
                $statusCode = Get-GraphStatusCode -ErrorRecord $_
                if ($statusCode -eq 429) {
                    if ($retryCount -lt $maxRetries) {
                        $itemsToRetry = $pendingGroups
                    } else {
                        foreach ($groupDef in $pendingGroups) {
                            $results += New-HydrationResult -Type $resultTypeName -Name $groupDef.displayName -Action 'Failed' -Status "Creation failed: batch request throttled after retries: $_"
                        }
                    }
                    $pendingGroups = $itemsToRetry
                    $retryCount++
                    continue
                }

                $reason = "Batch creation request failed before responses: $_"
                Write-Warning $reason
                foreach ($groupDef in $pendingGroups) {
                    $results += Resolve-HydrationIndeterminateGroupCreate -GroupDefinition $groupDef -ResultTypeName $resultTypeName -Reason $reason
                }
                break
            }

            $responseState = Resolve-HydrationBatchResponse -BatchEntries $batchEntries -Responses @(Get-HydrationGroupBatchResponses -BatchResponse $batchResponse)
            $results += New-HydrationBatchCorrelationFailure -ResponseState $responseState -ResultTypeName $resultTypeName -StatusPrefix 'Creation'

            # Process responses
            foreach ($resolvedResponse in $responseState.Matched) {
                $resp = $resolvedResponse.Response
                $groupDef = $resolvedResponse.Item

                if ($resp.status -eq 201) {
                    # Created successfully
                    $results += New-HydrationResult -Type $resultTypeName -Name $groupDef.displayName -Id $resp.body.id -Action 'Created' -Status 'New group created'
                    Write-Verbose " Created: $($groupDef.displayName)"
                } elseif ($resp.status -eq 409) {
                    # Conflict - group was created between existence check and creation (race condition)
                    $results += New-HydrationResult -Type $resultTypeName -Name $groupDef.displayName -Action 'Skipped' -Status 'Group already exists (race condition)'
                    Write-Verbose " Skipped: $($groupDef.displayName) (race condition)"
                } elseif ((Test-HydrationBatchStatusRetryable -Status $resp.status -Operation 'POST') -and $retryCount -lt $maxRetries) {
                    $retryAfterSeconds = Get-HydrationBatchRetryAfterSeconds -Headers $resp.headers -Body $resp.body
                    if ($retryAfterSeconds) {
                        $groupDef | Add-Member -NotePropertyName RetryAfter -NotePropertyValue $retryAfterSeconds -Force
                    }
                    $itemsToRetry += $groupDef
                } elseif ($resp.status -ge 500 -and $resp.status -lt 600) {
                    $errorMessage = if ($resp.body.error.message) { $resp.body.error.message } else { "HTTP $($resp.status)" }
                    $results += Resolve-HydrationIndeterminateGroupCreate -GroupDefinition $groupDef -ResultTypeName $resultTypeName -Reason "Batch creation returned $errorMessage"
                } else {
                    # Creation failed
                    $errorMessage = if ($resp.body.error.message) { $resp.body.error.message } else { "HTTP $($resp.status)" }
                    $results += New-HydrationResult -Type $resultTypeName -Name $groupDef.displayName -Action 'Failed' -Status "Creation failed: $errorMessage"
                    Write-Warning " Failed: $($groupDef.displayName) - $errorMessage"
                }
            }

            foreach ($missingRequest in $responseState.Missing) {
                $results += Resolve-HydrationIndeterminateGroupCreate -GroupDefinition $missingRequest.Item -ResultTypeName $resultTypeName -Reason 'No response received from Graph API'
            }

            $pendingGroups = $itemsToRetry
            $retryCount++
        }
    }

    #endregion

    #region Phase 3: Service Principal Owner Groups (Sequential)

    if ($spOwnerGroups.Count -gt 0) {
        Write-Verbose "Creating $($spOwnerGroups.Count) groups that require service principal owner..."

        # Get or create the Intune Provisioning Client service principal
        $intuneProvisioningClientAppId = "f1346770-5b25-470b-88bd-d5744ab7952c"
        $servicePrincipalId = $null

        try {
            $spResponse = Invoke-MgGraphRequest -Method GET -Uri "v1.0/servicePrincipals?`$filter=appId eq '$intuneProvisioningClientAppId'" -ErrorAction Stop
            $existingSP = $spResponse.value | Select-Object -First 1

            if ($existingSP) {
                $servicePrincipalId = $existingSP.id
                Write-Verbose "Found existing Intune Provisioning Client service principal: $servicePrincipalId"
            } else {
                # Create the service principal
                Write-Verbose "Creating Intune Provisioning Client service principal..."
                $newSP = Invoke-MgGraphRequest -Method POST -Uri "v1.0/servicePrincipals" -Body @{ appId = $intuneProvisioningClientAppId } -ErrorAction Stop
                $servicePrincipalId = $newSP.id
                Write-Verbose "Created service principal: $servicePrincipalId"
            }
        } catch {
            Write-Warning "Could not get/create Intune Provisioning Client service principal: $_"
            # Continue without SP - groups can still be created but won't have the owner
        }

        # Get Graph base URI for owner reference
        $graphBaseUri = (Get-HydrationGraphEnvironmentInfo -Environment $mgContext.Environment).Endpoint

        # Create each SP owner group sequentially (need to add owner after creation)
        foreach ($groupDef in $spOwnerGroups) {
            try {
                $groupBody = ConvertTo-HydrationGroupBody -GroupDefinition $groupDef -GroupType 'Static'
                $newGroup = Invoke-MgGraphRequest -Method POST -Uri "v1.0/groups" -Body $groupBody -ErrorAction Stop

                # Add service principal as owner if available
                if ($servicePrincipalId) {
                    $ownerRef = @{ "@odata.id" = "$graphBaseUri/v1.0/servicePrincipals/$servicePrincipalId" }
                    Invoke-MgGraphRequest -Method POST -Uri "v1.0/groups/$($newGroup.id)/owners/`$ref" -Body $ownerRef -ErrorAction Stop
                    $results += New-HydrationResult -Type $resultTypeName -Name $groupDef.displayName -Id $newGroup.id -Action 'Created' -Status 'Created with service principal owner'
                    Write-Verbose " Created: $($groupDef.displayName) (with SP owner)"
                } else {
                    $results += New-HydrationResult -Type $resultTypeName -Name $groupDef.displayName -Id $newGroup.id -Action 'Created' -Status 'Created (SP owner not available)'
                    Write-Verbose " Created: $($groupDef.displayName) (SP owner unavailable)"
                }
            } catch {
                $errorMessage = Get-GraphErrorMessage -ErrorRecord $_
                $results += New-HydrationResult -Type $resultTypeName -Name $groupDef.displayName -Action 'Failed' -Status $errorMessage
                Write-Warning " Failed: $($groupDef.displayName) - $errorMessage"
            }
        }
    }

    #endregion

    return $results
}