Private/Graph/Invoke-GraphBatchOperation.ps1

function Invoke-GraphBatchOperation {
    <#
    .SYNOPSIS
        Executes Graph API batch requests with retry logic and standardized result handling
    .DESCRIPTION
        Internal helper that handles batched Graph API requests with exponential backoff retry
        for transient server errors. Returns results in standardized New-HydrationResult format.
    .PARAMETER Items
        Array of items to process. Each item must be a hashtable with at least 'Name' key.
        For DELETE operations: must include 'Id' key (or 'Url' for the full batch URL path).
        For POST operations: must include 'BodyJson' key with JSON string payload.
        Optional keys: 'Url' (overrides BaseUrl for per-item endpoints), 'Type' (overrides ResultType),
        'Path' (source file path), 'State' (e.g., CA policy state).
    .PARAMETER Operation
        The HTTP operation: 'POST' for creation or 'DELETE' for removal.
    .PARAMETER BaseUrl
        The Graph API URL path (without /beta prefix), e.g., '/deviceAppManagement/mobileApps'.
        Optional if each item provides its own 'Url' key.
    .PARAMETER ResultType
        The Type value to use in New-HydrationResult (e.g., 'MobileApp', 'AppProtection')
    .PARAMETER MaxBatchSize
        Maximum items per batch request. Defaults to 10.
    .PARAMETER MaxRetries
        Maximum retries after the initial attempt for failed batches. Defaults to 3.
    .PARAMETER RetryDelaySeconds
        Base delay between retries (doubles with each retry). Defaults to 2.
    .EXAMPLE
        $items = @(@{ Name = 'App1'; Id = 'guid-1' }, @{ Name = 'App2'; Id = 'guid-2' })
        Invoke-GraphBatchOperation -Items $items -Operation 'DELETE' -BaseUrl '/deviceAppManagement/mobileApps' -ResultType 'MobileApp'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [array]$Items,

        [Parameter(Mandatory)]
        [ValidateSet('POST', 'DELETE')]
        [string]$Operation,

        [Parameter()]
        [string]$BaseUrl,

        [Parameter(Mandatory)]
        [string]$ResultType,

        [Parameter()]
        [int]$MaxBatchSize = $(if ($script:MaxBatchSize) { $script:MaxBatchSize } else { 10 }),

        [Parameter()]
        [int]$MaxRetries = 3,

        [Parameter()]
        [int]$RetryDelaySeconds = 2
    )

    function Test-IsThrottleSensitiveWrite {
        param(
            [Parameter()]
            [string]$Url
        )

        return $Operation -eq 'POST' -and $Url -eq '/deviceManagement/intents'
    }

    function Get-ItemUrl {
        param(
            [Parameter(Mandatory)]
            [hashtable]$Item,

            [Parameter(Mandatory)]
            [string]$OperationName,

            [Parameter()]
            [string]$DefaultUrl
        )

        if ($Item.Url) {
            return $Item.Url
        }

        if ($OperationName -eq 'DELETE') {
            return "$DefaultUrl/$($Item.Id)"
        }

        return $DefaultUrl
    }

    function Test-IsIndeterminateCreateStatus {
        param(
            [Parameter(Mandatory)]
            [int]$Status
        )

        return $Status -ge 500 -and $Status -lt 600
    }

    function New-ItemResultParams {
        param(
            [Parameter(Mandatory)]
            [hashtable]$Item
        )

        $resultParams = @{
            Name = $Item.Name
            Type = if ($Item.Type) { $Item.Type } else { $ResultType }
        }

        if ($Item.Path) { $resultParams.Path = $Item.Path }
        if ($Item.State) { $resultParams.State = $Item.State }
        if ($Item.Platform) { $resultParams.Platform = $Item.Platform }

        return $resultParams
    }

    function Get-BatchResponseList {
        param(
            [Parameter()]
            [object]$BatchResponse
        )

        if ($BatchResponse -and $BatchResponse.responses) {
            return @($BatchResponse.responses)
        }

        return @()
    }

    $results = @()

    if ($Items.Count -eq 0) {
        return $results
    }

    $containsThrottleSensitiveWrites = $false
    foreach ($item in $Items) {
        $resolvedUrl = Get-ItemUrl -Item $item -OperationName $Operation -DefaultUrl $BaseUrl
        if (Test-IsThrottleSensitiveWrite -Url $resolvedUrl) {
            $containsThrottleSensitiveWrites = $true
            break
        }
    }

    $effectiveMaxRetries = if ($containsThrottleSensitiveWrites) { [Math]::Max($MaxRetries, 5) } else { $MaxRetries }
    $baseRetryDelaySeconds = if ($containsThrottleSensitiveWrites) { [Math]::Max($RetryDelaySeconds, 15) } else { $RetryDelaySeconds }

    $actionVerb = if ($Operation -eq 'DELETE') { 'Deleting' } else { 'Creating' }
    Write-Verbose "$actionVerb $($Items.Count) $ResultType items in batches..."
    if ($containsThrottleSensitiveWrites) {
        Write-Verbose "Using conservative retry policy for throttling-prone deviceManagement/intents writes"
    }

    for ($batchStart = 0; $batchStart -lt $Items.Count; $batchStart += $MaxBatchSize) {
        $batchEnd = [Math]::Min($batchStart + $MaxBatchSize, $Items.Count) - 1
        $currentBatch = $Items[$batchStart..$batchEnd]

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

        while ($pendingItems.Count -gt 0 -and $retryCount -le $effectiveMaxRetries) {
            if ($retryCount -gt 0) {
                # Honor Retry-After from previous throttled responses; fall back to exponential backoff
                $maxRetryAfter = 0
                foreach ($item in $pendingItems) {
                    if ($item.RetryAfter) {
                        $parsedRetryAfter = 0
                        if ([int]::TryParse([string]$item.RetryAfter, [ref]$parsedRetryAfter) -and $parsedRetryAfter -gt $maxRetryAfter) {
                            $maxRetryAfter = $parsedRetryAfter
                        }
                        $item.Remove('RetryAfter')
                    }
                }
                $delay = if ($maxRetryAfter -gt 0) { $maxRetryAfter } else { $baseRetryDelaySeconds * [Math]::Pow(2, $retryCount - 1) }
                $delaySource = if ($maxRetryAfter -gt 0) { 'Retry-After header' } else { 'exponential backoff' }
                Write-Verbose "Retrying $($pendingItems.Count) failed $($Operation.ToLower())(s) after ${delay}s delay ($delaySource, attempt $retryCount of $effectiveMaxRetries)..."
                Start-Sleep -Seconds $delay
            }

            $batchResponse = $null
            $itemsToRetry = @()
            $batchEntries = @()

            if ($Operation -eq 'DELETE') {
                for ($i = 0; $i -lt $pendingItems.Count; $i++) {
                    $item = $pendingItems[$i]
                    $request = @{
                        id     = ($i + 1).ToString()
                        method = "DELETE"
                        url    = Get-ItemUrl -Item $item -OperationName $Operation -DefaultUrl $BaseUrl
                    }
                    $batchEntries += [pscustomobject]@{
                        Id      = [string]$request.id
                        Request = $request
                        Item    = $item
                        Index   = $i
                    }
                }

                $batchBody = @{ requests = @($batchEntries | ForEach-Object { $_.Request }) }

                try {
                    $batchResponse = Invoke-MgGraphRequest -Method POST -Uri "beta/`$batch" -Body $batchBody -ErrorAction Stop
                    Write-Verbose "Batch DELETE response received with $($batchResponse.responses.Count) responses"
                } catch {
                    if ($retryCount -lt $effectiveMaxRetries) {
                        Write-Verbose "Batch delete failed, will retry: $_"
                        $itemsToRetry = $pendingItems
                    } else {
                        Write-Warning "Batch delete failed: $_"
                        foreach ($item in $pendingItems) {
                            $results += New-HydrationResult -Name $item.Name -Type $ResultType -Action 'Failed' -Status "Batch delete failed: $_"
                        }
                    }
                    $pendingItems = $itemsToRetry
                    $retryCount++
                    continue
                }
            } else {
                # POST operation
                # Filter out items with missing URLs before building the batch
                $validItems = @()
                $invalidItems = @()
                foreach ($item in $pendingItems) {
                    $url = Get-ItemUrl -Item $item -OperationName $Operation -DefaultUrl $BaseUrl
                    if ([string]::IsNullOrWhiteSpace($url)) {
                        $invalidItems += $item
                    } else {
                        $validItems += $item
                    }
                }

                if ($invalidItems.Count -gt 0) {
                    foreach ($item in $invalidItems) {
                        Write-HydrationLog -Message " [!] Failed: $($item.Name) - No API endpoint URL resolved" -Level Warning
                        $resultParams = @{ Name = $item.Name; Type = if ($item.Type) { $item.Type } else { $ResultType } }
                        if ($item.Path) { $resultParams.Path = $item.Path }
                        $results += New-HydrationResult @resultParams -Action 'Failed' -Status 'No API endpoint URL resolved'
                    }
                }

                if ($validItems.Count -eq 0) {
                    $pendingItems = @()
                    continue
                }

                $pendingItems = $validItems
                for ($i = 0; $i -lt $pendingItems.Count; $i++) {
                    $item = $pendingItems[$i]
                    $url = Get-ItemUrl -Item $item -OperationName $Operation -DefaultUrl $BaseUrl
                    $request = @{
                        id      = ($i + 1).ToString()
                        method  = "POST"
                        url     = $url
                        headers = @{ "Content-Type" = "application/json" }
                        body    = $item.BodyJson | ConvertFrom-Json
                    }
                    $batchEntries += [pscustomobject]@{
                        Id      = [string]$request.id
                        Request = $request
                        Item    = $item
                        Index   = $i
                    }
                }

                $batchBodyJson = @{ requests = @($batchEntries | ForEach-Object { $_.Request }) } | ConvertTo-Json -Depth 100 -Compress

                try {
                    $batchResponse = Invoke-MgGraphRequest -Method POST -Uri "beta/`$batch" -Body $batchBodyJson -ContentType 'application/json' -ErrorAction Stop
                } catch {
                    $statusCode = Get-GraphStatusCode -ErrorRecord $_
                    if ($statusCode -eq 429) {
                        if ($retryCount -lt $effectiveMaxRetries) {
                            Write-Verbose "Batch create was throttled, will retry: $_"
                            $itemsToRetry = $pendingItems
                        } else {
                            $statusMessage = "Create failed: batch request throttled after retries: $_"
                            Write-Warning $statusMessage
                            foreach ($item in $pendingItems) {
                                $resultParams = New-ItemResultParams -Item $item
                                Write-HydrationLog -Message " [!] Failed: $($item.Name) - $statusMessage" -Level Warning
                                $results += New-HydrationResult @resultParams -Action 'Failed' -Status $statusMessage
                            }
                        }
                        $pendingItems = $itemsToRetry
                        $retryCount++
                        continue
                    }

                    $statusMessage = "Create indeterminate: batch request failed before responses; not retried to avoid duplicate creation: $_"
                    Write-Warning $statusMessage
                    foreach ($item in $pendingItems) {
                        $resultParams = New-ItemResultParams -Item $item
                        Write-HydrationLog -Message " [!] Failed: $($item.Name) - $statusMessage" -Level Warning
                        $results += New-HydrationResult @resultParams -Action 'Failed' -Status $statusMessage
                    }
                    $pendingItems = $itemsToRetry
                    $retryCount++
                    continue
                }
            }

            # Process batch response
            $batchResponses = @(Get-BatchResponseList -BatchResponse $batchResponse)
            if ($batchResponses.Count -eq 0) {
                Write-Warning "Batch response has no responses array for $($pendingItems.Count) items"
            }

            $responseState = Resolve-HydrationBatchResponse -BatchEntries $batchEntries -Responses $batchResponses

            foreach ($unmatchedResponse in $responseState.Unmatched) {
                $resp = $unmatchedResponse.Response
                $fallbackName = "Unknown item (batch id $($resp.id))"
                Write-HydrationLog -Message " [!] Failed: $fallbackName - Unmatched batch response" -Level Warning
                $results += New-HydrationResult -Name $fallbackName -Type $ResultType -Action 'Failed' -Status "$Operation failed: Unmatched batch response id='$($resp.id)'"
            }

            foreach ($resolvedResponse in $responseState.Matched) {
                $resp = $resolvedResponse.Response
                $item = $resolvedResponse.Item

                Write-Verbose "Batch response for '$($item.Name)': status=$($resp.status)"

                $resultParams = New-ItemResultParams -Item $item

                if ($Operation -eq 'DELETE') {
                    if ($resp.status -in @(200, 202, 204)) {
                        Write-HydrationLog -Message " Deleted: $($item.Name)" -Level Info
                        $results += New-HydrationResult @resultParams -Action 'Deleted' -Status 'Success'
                    } elseif ($resp.status -eq 404) {
                        Write-HydrationLog -Message " Skipped: $($item.Name) (already deleted)" -Level Info
                        $results += New-HydrationResult @resultParams -Action 'Skipped' -Status 'Already deleted'
                    } elseif ((Test-HydrationBatchStatusRetryable -Status $resp.status -Operation $Operation) -and $retryCount -lt $effectiveMaxRetries) {
                        Write-Verbose "Retryable error ($($resp.status)) for '$($item.Name)' - will retry"
                        $retryAfterSeconds = Get-HydrationBatchRetryAfterSeconds -Headers $resp.headers -Body $resp.body
                        if ($retryAfterSeconds) { $item.RetryAfter = $retryAfterSeconds }
                        $itemsToRetry += $item
                    } else {
                        $errorMessage = if ($resp.body.error.message) { $resp.body.error.message } else { "HTTP $($resp.status)" }
                        Write-HydrationLog -Message " [!] Failed: $($item.Name) - $errorMessage" -Level Warning
                        $results += New-HydrationResult @resultParams -Action 'Failed' -Status "Delete failed: $errorMessage"
                    }
                } else {
                    # POST response handling
                    if ($resp.status -in @(200, 201)) {
                        Write-HydrationLog -Message " Created: $($item.Name)" -Level Info
                        $resultParams.Id = $resp.body.id
                        $results += New-HydrationResult @resultParams -Action 'Created' -Status 'Success'
                    } elseif ($resp.status -eq 409) {
                        Write-HydrationLog -Message " Skipped: $($item.Name) (race condition)" -Level Info
                        $results += New-HydrationResult @resultParams -Action 'Skipped' -Status 'Already exists (race condition)'
                    } elseif ((Test-HydrationBatchStatusRetryable -Status $resp.status -Operation $Operation) -and $retryCount -lt $effectiveMaxRetries) {
                        Write-Verbose "Retryable error ($($resp.status)) for '$($item.Name)' - will retry"
                        $retryAfterSeconds = Get-HydrationBatchRetryAfterSeconds -Headers $resp.headers -Body $resp.body
                        if ($retryAfterSeconds) { $item.RetryAfter = $retryAfterSeconds }
                        $itemsToRetry += $item
                    } else {
                        $errorMessage = if ($resp.body.error.message) { $resp.body.error.message } else { "HTTP $($resp.status)" }
                        if (Test-IsIndeterminateCreateStatus -Status $resp.status) {
                            $statusMessage = "Create indeterminate: $errorMessage; not retried to avoid duplicate creation"
                        } else {
                            $statusMessage = $errorMessage
                        }
                        Write-HydrationLog -Message " [!] Failed: $($item.Name) - $statusMessage" -Level Warning
                        $results += New-HydrationResult @resultParams -Action 'Failed' -Status $statusMessage
                    }
                }
            }

            # Retry or fail any pending items missing from the batch response
            foreach ($missingRequest in $responseState.Missing) {
                $missingItem = $missingRequest.Item
                if ($Operation -eq 'DELETE' -and $retryCount -lt $effectiveMaxRetries) {
                    Write-Verbose "Missing response for '$($missingItem.Name)' - will retry"
                    $itemsToRetry += $missingItem
                } else {
                    $resultParams = New-ItemResultParams -Item $missingItem
                    if ($Operation -eq 'POST') {
                        $statusMessage = 'Create indeterminate: No response received from Graph API; not retried to avoid duplicate creation'
                    } else {
                        $statusMessage = 'Delete failed: No response received from Graph API'
                    }
                    Write-HydrationLog -Message " [!] Failed: $($missingItem.Name) - $statusMessage" -Level Warning
                    $results += New-HydrationResult @resultParams -Action 'Failed' -Status $statusMessage
                }
            }

            $pendingItems = $itemsToRetry
            $retryCount++
        }
    }

    return $results
}