Private/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
    )

    # Helper function to build group request body from definition
    function ConvertTo-GroupBody {
        param(
            [Parameter(Mandatory)]
            [object]$GroupDef,
            [Parameter(Mandatory)]
            [string]$GroupType
        )

        $description = New-HydrationDescription -ExistingText $GroupDef.description

        # Generate safe mailNickname (alphanumeric only, max 64 chars)
        $mailNickname = ($GroupDef.displayName -replace '[^a-zA-Z0-9]', '')
        if ($mailNickname.Length -gt 64) {
            $mailNickname = $mailNickname.Substring(0, 64)
        }
        if ([string]::IsNullOrWhiteSpace($mailNickname)) {
            $mailNickname = "group" + [guid]::NewGuid().ToString("N").Substring(0, 8)
        }

        $body = @{
            displayName     = $GroupDef.displayName
            description     = $description
            mailEnabled     = $false
            mailNickname    = $mailNickname
            securityEnabled = $true
        }

        if ($GroupType -eq 'Dynamic') {
            $body['groupTypes'] = @('DynamicMembership')
            $body['membershipRule'] = $GroupDef.membershipRule
            $body['membershipRuleProcessingState'] = 'On'
        }

        return $body
    }

    # Helper function to get Graph base URI for the current environment
    function Get-GraphBaseUri {
        param([string]$Environment)
        switch ($Environment) {
            "USGov" { return "https://graph.microsoft.us" }
            "USGovDoD" { return "https://graph.microsoft.us" }
            "China" { return "https://graph.chinacloudapi.cn" }
            "Germany" { return "https://graph.microsoft.de" }
            default { return "https://graph.microsoft.com" }
        }
    }

    $results = @()
    $maxBatchSize = if ($script:MaxBatchSize) { $script:MaxBatchSize } else { 10 }

    # 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' }
        $importPrefix = if ([string]::IsNullOrEmpty($script:ImportPrefix)) { '[IHD] ' } else { $script:ImportPrefix }

        try {
            $allGroups = Get-GraphPagedResults -Uri "beta/groups?`$filter=$typeFilter&`$select=id,displayName,description&`$count=true" -Headers $headers
            foreach ($group in $allGroups) {
                if (Test-HydrationKitObject -Description $group.description -ObjectName $group.displayName) {
                    # If KnownNames provided, only delete groups that match a template name
                    if ($KnownNames -and $KnownNames.Count -gt 0) {
                        $unprefixedName = if ($group.displayName -and $group.displayName.StartsWith($importPrefix)) {
                            $group.displayName.Substring($importPrefix.Length)
                        } else {
                            $group.displayName
                        }
                        if (-not $KnownNames.Contains($unprefixedName)) {
                            Write-Verbose " Skipping '$($group.displayName)' - not in current template set"
                            continue
                        }
                    }
                    $groupsToDelete += $group
                } else {
                    Write-Verbose " Skipping '$($group.displayName)' - not created by Intune Hydration Kit"
                }
            }
        } 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
        }

        # Batch delete groups
        for ($batchStart = 0; $batchStart -lt $groupsToDelete.Count; $batchStart += $maxBatchSize) {
            $batchEnd = [Math]::Min($batchStart + $maxBatchSize, $groupsToDelete.Count) - 1
            $currentBatch = $groupsToDelete[$batchStart..$batchEnd]

            $batchRequests = @()
            for ($i = 0; $i -lt $currentBatch.Count; $i++) {
                $group = $currentBatch[$i]
                $batchRequests += @{
                    id     = ($i + 1).ToString()
                    method = "DELETE"
                    url    = "/groups/$($group.id)"
                }
            }

            # Submit batch delete request
            $batchBody = @{ requests = $batchRequests }
            try {
                $batchResponse = Invoke-MgGraphRequest -Method POST -Uri "beta/`$batch" -Body $batchBody -ErrorAction Stop

                # Process responses
                foreach ($resp in $batchResponse.responses) {
                    $requestIndex = $null
                    $group = $null
                    if ([int]::TryParse([string]$resp.id, [ref]$requestIndex)) {
                        $requestIndex = $requestIndex - 1
                        if ($requestIndex -ge 0 -and $requestIndex -lt $currentBatch.Count) {
                            $group = $currentBatch[$requestIndex]
                        }
                    }

                    # Skip if we can't find the matching group
                    if (-not $group -or -not $group.displayName) {
                        Write-Verbose "Skipping response with id=$($resp.id) - no matching group"
                        continue
                    }

                    if ($resp.status -eq 204 -or $resp.status -eq 200) {
                        # Deleted successfully
                        $results += New-HydrationResult -Type $resultTypeName -Name $group.displayName -Id $group.id -Action 'Deleted' -Status 'Success'
                        Write-Verbose " Deleted: $($group.displayName)"
                    } elseif ($resp.status -eq 404) {
                        # Already deleted (race condition)
                        $results += New-HydrationResult -Type $resultTypeName -Name $group.displayName -Action 'Skipped' -Status 'Already deleted'
                        Write-Verbose " Skipped: $($group.displayName) (already deleted)"
                    } else {
                        # Deletion failed
                        $errorMessage = if ($resp.body.error.message) { $resp.body.error.message } else { "HTTP $($resp.status)" }
                        $results += New-HydrationResult -Type $resultTypeName -Name $group.displayName -Id $group.id -Action 'Failed' -Status "Delete failed: $errorMessage"
                        Write-Warning " Failed to delete: $($group.displayName) - $errorMessage"
                    }
                }
            } catch {
                # Batch request failed - log individual failures
                Write-Warning "Batch delete failed: $_"
                foreach ($group in $currentBatch) {
                    $results += New-HydrationResult -Type $resultTypeName -Name $group.displayName -Id $group.id -Action 'Failed' -Status "Batch delete failed: $_"
                }
            }
        }

        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]

        $batchRequests = @()
        for ($i = 0; $i -lt $currentBatch.Count; $i++) {
            $groupDef = $currentBatch[$i]
            # Escape single quotes for OData filter — both prefixed and original (legacy) names
            $safePrefixedName = $groupDef.displayName -replace "'", "''"
            $safeOriginalName = $groupDef._OriginalDisplayName -replace "'", "''"

            if ($safePrefixedName -ne $safeOriginalName) {
                $filterUri = "/groups?`$filter=displayName eq '$safePrefixedName' or displayName eq '$safeOriginalName'&`$select=id,displayName,description"
            } else {
                $filterUri = "/groups?`$filter=displayName eq '$safePrefixedName'&`$select=id,displayName,description"
            }

            $batchRequests += @{
                id     = ($i + 1).ToString()
                method = "GET"
                url    = $filterUri
            }
        }

        # Submit batch request
        $batchBody = @{ requests = $batchRequests }
        try {
            $batchResponse = Invoke-MgGraphRequest -Method POST -Uri "beta/`$batch" -Body $batchBody -ErrorAction Stop

            # Process responses
            foreach ($resp in $batchResponse.responses) {
                $requestIndex = $null
                $groupDef = $null
                if ([int]::TryParse([string]$resp.id, [ref]$requestIndex)) {
                    $requestIndex = $requestIndex - 1
                    if ($requestIndex -ge 0 -and $requestIndex -lt $currentBatch.Count) {
                        $groupDef = $currentBatch[$requestIndex]
                    }
                }

                # Skip if we can't find the matching group definition
                if (-not $groupDef -or -not $groupDef.displayName) {
                    Write-Verbose "Skipping response with id=$($resp.id) - no matching group definition"
                    continue
                }

                if ($resp.status -eq 200 -and $resp.body.value.Count -gt 0) {
                    # Group exists — prefer the prefixed name match when multiple results
                    $matchingGroup = $resp.body.value | Where-Object { $_.displayName -eq $groupDef.displayName } | Select-Object -First 1
                    if (-not $matchingGroup) {
                        $matchingGroup = $resp.body.value[0]
                    }
                    $existingGroups[$groupDef.displayName] = $matchingGroup
                } 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)"
                }
            }
        } 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]

        $batchRequests = @()
        for ($i = 0; $i -lt $currentBatch.Count; $i++) {
            $groupDef = $currentBatch[$i]
            $batchRequests += @{
                id      = ($i + 1).ToString()
                method  = "POST"
                url     = "/groups"
                headers = @{ "Content-Type" = "application/json" }
                body    = ConvertTo-GroupBody -GroupDef $groupDef -GroupType $GroupType
            }
        }

        # Submit batch creation request
        $batchBody = @{ requests = $batchRequests }
        try {
            $batchResponse = Invoke-MgGraphRequest -Method POST -Uri "beta/`$batch" -Body $batchBody -ErrorAction Stop

            # Process responses
            foreach ($resp in $batchResponse.responses) {
                $requestIndex = $null
                $groupDef = $null
                if ([int]::TryParse([string]$resp.id, [ref]$requestIndex)) {
                    $requestIndex = $requestIndex - 1
                    if ($requestIndex -ge 0 -and $requestIndex -lt $currentBatch.Count) {
                        $groupDef = $currentBatch[$requestIndex]
                    }
                }

                # Skip if we can't find the matching group definition
                if (-not $groupDef -or -not $groupDef.displayName) {
                    Write-Verbose "Skipping response with id=$($resp.id) - no matching group definition"
                    continue
                }

                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)"
                } 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"
                }
            }
        } catch {
            # Batch request failed - log individual failures
            Write-Warning "Batch creation failed: $_"
            foreach ($groupDef in $currentBatch) {
                $results += New-HydrationResult -Type $resultTypeName -Name $groupDef.displayName -Action 'Failed' -Status "Batch creation failed: $_"
            }
        }
    }

    #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-GraphBaseUri -Environment $mgContext.Environment

        # Create each SP owner group sequentially (need to add owner after creation)
        foreach ($groupDef in $spOwnerGroups) {
            try {
                $groupBody = ConvertTo-GroupBody -GroupDef $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
}