pt.EntraGraphUtils.psm1

# .ExternalHelp pt.EntraGraphUtils-help.xml

#region Private Functions
#region ConvertFrom-QueryString
<#
.SYNOPSIS
    Convert Query String to object.
.EXAMPLE
    PS C:\>ConvertFrom-QueryString '?name=path/file.json&index=10'
    Convert query string to object.
.EXAMPLE
    PS C:\>'name=path/file.json&index=10' | ConvertFrom-QueryString -AsHashtable
    Convert query string to hashtable.
.INPUTS
    System.String
.LINK
    https://github.com/jasoth/Utility.PS
#>

function ConvertFrom-QueryString {
    [CmdletBinding()]
    [OutputType([psobject])]
    [OutputType([hashtable])]
    param (
        # Value to convert
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string[]] $InputStrings,
        # URL decode parameter names
        [Parameter(Mandatory = $false)]
        [switch] $DecodeParameterNames,
        # Converts to hash table object
        [Parameter(Mandatory = $false)]
        [switch] $AsHashtable
    )

    process {
        foreach ($InputString in $InputStrings) {
            if ($AsHashtable) { [hashtable] $OutputObject = @{ } }
            else { [psobject] $OutputObject = New-Object psobject }

            if ($InputString[0] -eq '?') { $InputString = $InputString.Substring(1) }
            [string[]] $QueryParameters = $InputString.Split('&')
            foreach ($QueryParameter in $QueryParameters) {
                [string[]] $QueryParameterPair = $QueryParameter.Split('=')
                if ($DecodeParameterNames) { $QueryParameterPair[0] = [System.Net.WebUtility]::UrlDecode($QueryParameterPair[0]) }
                if ($OutputObject -is [hashtable]) {
                    $OutputObject.Add($QueryParameterPair[0], [System.Net.WebUtility]::UrlDecode($QueryParameterPair[1]))
                }
                else {
                    $OutputObject | Add-Member $QueryParameterPair[0] -MemberType NoteProperty -Value ([System.Net.WebUtility]::UrlDecode($QueryParameterPair[1]))
                }
            }
            Write-Output $OutputObject
        }
    }

}
#endregion ConvertFrom-QueryString

#region ConvertTo-QueryString
<#
.SYNOPSIS
    Convert Hashtable to Query String.
.EXAMPLE
    PS C:\>ConvertTo-QueryString @{ name = 'path/file.json'; index = 10 }
    Convert hashtable to query string.
.EXAMPLE
    PS C:\>[ordered]@{ title = 'convert&prosper'; id = [guid]'352182e6-9ab0-4115-807b-c36c88029fa4' } | ConvertTo-QueryString
    Convert ordered dictionary to query string.
.INPUTS
    System.Collections.Hashtable
.LINK
    https://github.com/jasoth/Utility.PS
#>

function ConvertTo-QueryString {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        # Value to convert
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [object] $InputObjects,
        # URL encode parameter names
        [Parameter(Mandatory = $false)]
        [switch] $EncodeParameterNames
    )

    process {
        foreach ($InputObject in $InputObjects) {
            $QueryString = New-Object System.Text.StringBuilder
            if ($InputObject -is [hashtable] -or $InputObject -is [System.Collections.Specialized.OrderedDictionary] -or $InputObject.GetType().FullName.StartsWith('System.Collections.Generic.Dictionary')) {
                foreach ($Item in $InputObject.GetEnumerator()) {
                    if ($QueryString.Length -gt 0) { [void]$QueryString.Append('&') }
                    [string] $ParameterName = $Item.Key
                    if ($EncodeParameterNames) { $ParameterName = [System.Net.WebUtility]::UrlEncode($ParameterName) }
                    [void]$QueryString.AppendFormat('{0}={1}', $ParameterName, [System.Net.WebUtility]::UrlEncode($Item.Value))
                }
            }
            elseif ($InputObject -is [object] -and $InputObject -isnot [ValueType]) {
                foreach ($Item in ($InputObject | Get-Member -MemberType Property, NoteProperty)) {
                    if ($QueryString.Length -gt 0) { [void]$QueryString.Append('&') }
                    [string] $ParameterName = $Item.Name
                    if ($EncodeParameterNames) { $ParameterName = [System.Net.WebUtility]::UrlEncode($ParameterName) }
                    [void]$QueryString.AppendFormat('{0}={1}', $ParameterName, [System.Net.WebUtility]::UrlEncode($InputObject.($Item.Name)))
                }
            }
            else {
                ## Non-Terminating Error
                $Exception = New-Object ArgumentException -ArgumentList ('Cannot convert input of type {0} to query string.' -f $InputObject.GetType())
                Write-Error -Exception $Exception -Category ([System.Management.Automation.ErrorCategory]::ParserError) -CategoryActivity $MyInvocation.MyCommand -ErrorId 'ConvertQueryStringFailureTypeNotSupported' -TargetObject $InputObject
                continue
            }

            Write-Output $QueryString.ToString()
        }
    }
}
#endregion ConvertTo-QueryString

#endregion Private Functions

#region Public Functions
#region Invoke-ptGraphBatchRequest
<#
.SYNOPSIS
    Executes multiple Microsoft Graph API requests in batches with automatic retry handling, rate limiting, and pagination support.

.DESCRIPTION
    This function processes multiple Microsoft Graph API requests efficiently by grouping them into batches of up to 20 requests.
    It handles rate limiting (HTTP 429 responses) with automatic retry logic, supports multiple pagination modes, and provides
    flexible output options including raw responses and grouped results. The function is designed to optimize API usage while
    respecting Microsoft Graph rate limits and providing robust error handling.

    The function supports three parameter sets:
    - Standard: Normal processing with individual item output and configurable pagination
    - GroupById: Groups results by request ID with automatic pagination (ideal for collecting complete datasets)
    - RawOutput: Returns complete batch response objects with configurable pagination (ideal for custom post-processing)

.PARAMETER BatchItems
    An array of PSCustomObject items representing individual Graph API requests. Each item must contain:
    - 'id' property: Unique identifier for tracking the request
    - 'url' property: The Graph API endpoint (relative to base URI and API version)
    - Optional: 'method', 'headers', 'body' properties for advanced requests

.PARAMETER GraphBaseUri
    The base URI for Microsoft Graph API. Supports different Graph environments:
    - https://graph.microsoft.com (default - Commercial cloud)
    - https://graph.microsoft.us (US Government cloud)
    - https://dod-graph.microsoft.us (US Government DoD cloud)
    - https://graph.microsoft.de (German cloud)
    - https://microsoftgraph.chinacloudapi.cn (China cloud)

.PARAMETER BatchSize
    The maximum number of requests to include in each batch. Must be between 1 and 20.
    Microsoft Graph supports up to 20 requests per batch operation. Defaults to 20.

.PARAMETER GroupById
    Switch parameter that enables result grouping by request ID. When used:
    - Results are collected in a hashtable keyed by request ID
    - Automatically enables 'auto' pagination to ensure complete datasets
    - Returns a hashtable with request IDs as keys and arrays of results as values
    - Cannot be used with RawOutput parameter

.PARAMETER ApiVersion
    The Microsoft Graph API version to use. Valid values are 'v1.0' (default) or 'beta'.
    Choose 'v1.0' for production stability or 'beta' for preview features.

.PARAMETER RawOutput
    Switch parameter that returns complete batch response objects instead of processed results.
    When enabled:
    - Returns the raw Graph batch API responses
    - Pagination still works (if enabled) but responses aren't processed
    - Ideal for custom post-processing or debugging
    - Cannot be used with GroupById parameter
    - Great for using $count or other metadata from the response

.PARAMETER pagination
    Controls how the function handles paginated responses with @odata.nextLink. Valid values:
    - 'auto': Automatically follow all pagination links to retrieve complete datasets
    - 'none': Return only the first page of results without following pagination
    - Not specified: Return first page with warnings about available additional pages
    Note: Not available with GroupById parameter set (always uses 'auto')

.PARAMETER EnrichOutput
    Switch parameter that adds batch metadata to each output item. When enabled:
    - Adds a '@batchMetadata' property to each result object containing:
      * 'requestId': The batch request ID that returned this item
      * '@odata.context': The OData context URL from the response
    - Available in Standard and GroupById modes only (not available with RawOutput)
    - Helps track which batch request produced each result
    - Useful for debugging and audit trails

.OUTPUTS
    The output depends on the parameter set used:
    
    Standard mode: Individual result objects from successful requests
    GroupById mode: Hashtable with request IDs as keys and result arrays as values
    RawOutput mode: Complete batch response objects from Graph API

.EXAMPLE
    $requests = @(
        New-ptGraphRequestItem -id "1" -url "/users"
        New-ptGraphRequestItem -id "2" -url "/groups"
    )
    Invoke-ptGraphBatchRequest -BatchItems $requests

    Processes two Graph API requests in standard mode. Returns individual user and group objects.
    Shows warnings if additional pages are available.

.EXAMPLE
    $requests = @(
        New-ptGraphRequestItem -id "users" -url "/users"
        New-ptGraphRequestItem -id "groups" -url "/groups"
    )
    $results = Invoke-ptGraphBatchRequest -BatchItems $requests -GroupById
    $results["users"] # All user objects
    $results["groups"] # All group objects

    Groups results by request ID with automatic pagination. Returns a hashtable where
    each key contains all results for that request type.

.EXAMPLE
    Invoke-ptGraphBatchRequest -BatchItems $requests -RawOutput -pagination 'auto'

    Returns raw batch response objects with automatic pagination. Useful for custom
    processing or when you need access to response metadata like status codes.

.EXAMPLE
    $largeRequests = @(
        New-ptGraphRequestItem -id "all-users" -url "/users"
        New-ptGraphRequestItem -id "all-groups" -url "/groups"
    )
    Invoke-ptGraphBatchRequest -BatchItems $largeRequests -pagination 'auto'

    Automatically follows pagination to retrieve all users and groups. Each individual
    object is output as it's processed.

.EXAMPLE
    Invoke-ptGraphBatchRequest -BatchItems $requests -GraphBaseUri 'https://graph.microsoft.us' -ApiVersion 'beta'

    Processes requests using the US Government cloud endpoint with the beta API version.

.EXAMPLE
    Invoke-ptGraphBatchRequest -BatchItems $requests -pagination 'auto' -Verbose

    Processes batch items with automatic pagination and verbose output showing
    detailed progress information including batch counts and API call results.

.EXAMPLE
    $requests = @(
        New-ptGraphRequestItem -id "users" -url "/users"
        New-ptGraphRequestItem -id "groups" -url "/groups"
    )
    $results = Invoke-ptGraphBatchRequest -BatchItems $requests -EnrichOutput -pagination 'auto'
    $results | Select-Object displayName, '@batchMetadata'

    Enriches each output item with batch metadata. Each result will have a '@batchMetadata' property
    containing the batch request ID and OData context, helpful for tracking data sources.

.NOTES
    - Requires the Microsoft.Graph PowerShell SDK for Invoke-MgGraphRequest
    - Automatically handles rate limiting with exponential backoff retry logic
    - Thread-safe queue implementation for reliable batch processing
    - Supports all Microsoft Graph sovereign cloud environments
    - Maximum 20 requests per batch (Microsoft Graph limitation)
    - Rate limit retry delays are extracted from Graph API error responses when available

.LINK
    https://docs.microsoft.com/en-us/graph/json-batching

.LINK
    https://docs.microsoft.com/en-us/graph/throttling

.LINK
    https://docs.microsoft.com/en-us/graph/paging
#>

function Invoke-ptGraphBatchRequest {
    [CmdletBinding(DefaultParameterSetName = 'Standard', HelpUri = 'https://github.com/PowerShellToday/pt.EntraGraphUtils/blob/main/docs/Invoke-ptGraphBatchRequest.md')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Standard')]
        [Parameter(Mandatory = $true, ParameterSetName = 'GroupById')]
        [Parameter(Mandatory = $true, ParameterSetName = 'RawOutput')]
        [PSCustomObject[]] $BatchItems,

        [Parameter(Mandatory = $false, ParameterSetName = 'Standard')]
        [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')]
        [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')]
        [ValidateSet(
            'https://graph.microsoft.us/', 
            'https://microsoftgraph.chinacloudapi.cn/',
            'https://graph.microsoft.com/',
            'https://dod-graph.microsoft.us/',
            'https://graph.microsoft.de/'
        )]
        [uri] $GraphBaseUri = 'https://graph.microsoft.com',

        # Specify Batch size.
        [Parameter(Mandatory = $false, ParameterSetName = 'Standard')]
        [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')]
        [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')]
        [ValidateRange(1, 20)]
        [int] $BatchSize = 20,
        
        # Group results by batch id - only works with auto pagination
        [Parameter(Mandatory = $true, ParameterSetName = 'GroupById')]
        [switch] $GroupById,

        [Parameter(Mandatory = $false, ParameterSetName = 'Standard')]
        [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')]
        [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')]
        [ValidateSet('v1.0', 'beta')]
        [string] $ApiVersion = 'v1.0',

        # Raw output - returns complete batch responses
        [Parameter(Mandatory = $true, ParameterSetName = 'RawOutput')]
        [switch]$RawOutput,

        # Pagination only available for Standard and RawOutput (GroupById always uses auto)
        [Parameter(Mandatory = $false, ParameterSetName = 'Standard')]
        [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')]
        [ValidateSet('none', 'auto')]
        [string]$pagination,

        # Enrich output with batch metadata (only for Standard and GroupById modes)
        [Parameter(Mandatory = $false, ParameterSetName = 'Standard')]
        [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')]
        [switch]$EnrichOutput
    )

    begin {
        # Validate that all batch items have required properties
        $invalidItems = $BatchItems | Where-Object { -not $_.id -or -not $_.url -or -not $_.method }
        if ($invalidItems) {
            throw "All batch items must have 'id', 'method' and 'url' properties. Found $($invalidItems.Count) invalid item(s)."
        }
        
        # Determine pagination strategy based on parameter set
        # GroupById parameter set always uses auto pagination to ensure complete datasets
        if ($PSCmdlet.ParameterSetName -eq 'GroupById') {
            $pagination = 'auto'
        }
        # Note: When pagination is not specified, it remains empty string which triggers
        # the 'default' case in the switch statement to show warnings
        
        # Initialize thread-safe queue for managing batch items during processing
        $queue = [System.Collections.Queue]::Synchronized((New-Object System.Collections.Queue))
        
        # Initialize results collection if GroupById mode is enabled
        if ($GroupById) {
            $Results = @{}
        }

        # Populate the queue with all batch items and initialize result collections for GroupById
        $BatchItems | ForEach-Object {
            if ($GroupById) {
                $id = $_.id
                # Create a generic list for each unique ID to efficiently collect results
                $Results[$id] = New-Object 'System.Collections.Generic.List[psobject]'
            }
            # Add each item to the processing queue
            $queue.Enqueue($_)
        }
    }

    process {
        Write-Information ('Processing {0} requests in batches of {1}.' -f $BatchItems.Count, $BatchSize)
        Write-Verbose "Using parameter set: $($PSCmdlet.ParameterSetName)"
        Write-Verbose "Pagination mode: $(if ($pagination) { $pagination } else { 'default (warn)' })"
        
        # Initialize batch collection for current processing batch
        $batch = New-Object 'System.Collections.Generic.List[psobject]'
        
        # Construct the Graph API batch endpoint URI
        $uriQueryEndpoint = New-Object System.UriBuilder -ArgumentList ([IO.Path]::Combine($GraphBaseUri.AbsoluteUri, $ApiVersion, '$batch'))
        Write-Verbose "Batch endpoint: $($uriQueryEndpoint.Uri.AbsoluteUri)"

        # Track statistics for verbose output
        $batchCount = 0
        $totalProcessed = 0
        $pagesFollowed = 0

        # Main processing loop - continues until all items are processed
        do {
            $batchCount++
            Write-Information ('Items in queue: {0}' -f $queue.Count)
            Write-Verbose "Processing batch #$batchCount"
            
            # Fill the current batch up to the specified batch size
            while ($batch.Count -lt $BatchSize -and $queue.Count -gt 0) {
                $batch.Add($queue.Dequeue())
            }

            # Serialize batch requests to JSON format required by Graph batch API
            $jsonRequests = New-Object psobject -Property @{ requests = $batch } | ConvertTo-Json -Depth 5
            Write-Debug -Message "Batch Request JSON: $jsonRequests"

            # Execute the batch request if there are items to process
            if ($batch.Count) {
                try {
                    $batchResponse = Invoke-MgGraphRequest -Method POST -Uri $uriQueryEndpoint.Uri.AbsoluteUri -Body $jsonRequests -OutputType PSObject
                    Write-Verbose "Batch #$batchCount completed successfully with $($batchResponse.responses.Count) response(s)"
                }
                catch {
                    Write-Error "Failed to execute batch request #$batchCount : $_" -ErrorAction Stop
                    throw
                }
            }
            else {
                # Create empty response if batch is empty (shouldn't normally happen)
                $batchResponse = [PSCustomObject]@{ responses = @() }
            }
            
            # Initialize retry timer for rate limiting (429 responses)
            $RetryTimer = 0
            
            # Output raw batch response if RawOutput mode is enabled
            # This provides access to complete response metadata including status codes and headers
            if ($RawOutput) {
                Write-Output $batchResponse
            }

            # Process each individual response in the batch
            foreach ($response in $batchResponse.responses) {
                # Handle rate limiting (HTTP 429) - extract retry delay and continue
                if ($response.status -eq 429) {
                    $retryAfter = 15 # Default retry delay in seconds
                    $message = $response.body.error.message
                    # Try to extract specific retry delay from error message
                    if ($message -match 'Try again in (\d+) seconds') {
                        $retryAfter = [int]$matches[1]
                    }
                    # Track the longest retry delay if multiple 429s occur
                    if ($retryAfter -gt $RetryTimer) {
                        $RetryTimer = $retryAfter
                    }
                    continue
                } 
                # Handle all other non-success status codes (not 2xx)
                elseif ($response.status -notin 200..299) {
                    $errorMessage = "Graph API request failed with status $($response.status) for request ID '$($response.id)'"
                    # Include Graph API error message if available
                    if ($response.body.error.message) {
                        $errorMessage += ": $($response.body.error.message)"
                    }
                    Write-Debug -Message ($response | ConvertTo-Json -Depth 5)
                    Write-Error -Message $errorMessage -Category InvalidOperation -TargetObject $response
                    
                    # Remove failed item from batch to prevent infinite loop
                    $failedInstance = $batch.Find({ param($x) $x.id -eq $response.id })
                    if ($failedInstance) {
                        $batch.Remove($failedInstance) | Out-Null
                    }
                    continue
                }
                
                # Extract the request ID for tracking and result correlation
                $instanceId = $response.id
                $totalProcessed++

                # Process response body if not in raw output mode
                if (-not $RawOutput) {
                    # Ensure response has a body before processing
                    if (-not $response.body) {
                        Write-Warning "Response for request ID '$instanceId' has no body. Skipping."
                        
                        # Remove item with no body from batch to prevent infinite loop
                        $noBodyInstance = $batch.Find({ param($x) $x.id -eq $instanceId })
                        if ($noBodyInstance) {
                            $batch.Remove($noBodyInstance) | Out-Null
                        }
                        continue
                    }
                
                    # GroupById mode: Collect all results in hashtable keyed by request ID
                    if ($GroupById) {
                        # Check if value property is an array (typical for list responses)
                        if ($response.body.value -is [array]) {
                            # Add each item individually to maintain flat structure
                            $response.body.value | ForEach-Object {
                                # Enrich item with batch metadata if requested
                                if ($EnrichOutput) {
                                    $_ | Add-Member -NotePropertyName '@batchMetadata' -NotePropertyValue @{
                                        requestId        = $instanceId
                                        '@odata.context' = $response.body.'@odata.context'
                                    } -Force
                                }
                                $Results[$instanceId].Add($_)
                            }
                        }
                        else {
                            # Handle single object responses or responses without 'value' property
                            if ($EnrichOutput) {
                                $response.body | Add-Member -NotePropertyName '@batchMetadata' -NotePropertyValue @{
                                    requestId        = $instanceId
                                    '@odata.context' = $response.body.'@odata.context'
                                } -Force
                            }
                            $Results[$instanceId].Add($response.body)
                        }
                    } 
                    # Standard mode: Output individual items as they're processed
                    else {
                        # Check if response has a 'value' property (collection response)
                        if ($null -ne $response.body.PSObject.Properties['value']) {
                            # Check if value property is an array
                            if ($response.body.value -is [array]) {
                                # Output each item individually for pipeline processing
                                # Empty arrays won't output anything (correct behavior)
                                $response.body.value | ForEach-Object {
                                    # Enrich item with batch metadata if requested
                                    if ($EnrichOutput) {
                                        $_ | Add-Member -NotePropertyName '@batchMetadata' -NotePropertyValue @{
                                            requestId        = $instanceId
                                            '@odata.context' = $response.body.'@odata.context'
                                        } -Force
                                    }
                                    Write-Output $_
                                }
                            }
                            else {
                                # Handle single object in value property
                                if ($EnrichOutput) {
                                    $response.body.value | Add-Member -NotePropertyName '@batchMetadata' -NotePropertyValue @{
                                        requestId        = $instanceId
                                        '@odata.context' = $response.body.'@odata.context'
                                    } -Force
                                }
                                Write-Output $response.body.value
                            }
                        }
                        else {
                            # No value property - output the entire body (e.g., single entity GET)
                            if ($EnrichOutput) {
                                $response.body | Add-Member -NotePropertyName '@batchMetadata' -NotePropertyValue @{
                                    requestId        = $instanceId
                                    '@odata.context' = $response.body.'@odata.context'
                                } -Force
                            }
                            Write-Output $response.body
                        }
                    }
                }

                # Find the original batch item by response ID for pagination handling
                $instance = $batch.Find({ param($x) $x.id -eq $instanceId })
                
                # Validate that the batch item was found (should always exist)
                if (-not $instance) {
                    Write-Warning "Could not find batch item with ID '$instanceId' in current batch. Skipping pagination handling."
                    continue
                }

                # Handle pagination based on user preference and @odata.nextLink presence
                if ($response.body.'@odata.nextLink') {
                    switch ($pagination) {
                        'auto' {
                            # Automatically follow pagination - update URL and keep in batch
                            # Strip base URI and version to get relative URL for next page
                            $nextLink = $response.body.'@odata.nextLink'
                            # Extract the path after the API version (e.g., /v1.0/users?... -> /users?...)
                            if ($nextLink -match "$ApiVersion(.+)$") {
                                $instance.url = $matches[1]
                            }
                            else {
                                # Fallback: just strip base URI and version
                                $instance.url = $nextLink -replace ('{0}{1}' -f $GraphBaseUri.AbsoluteUri, $ApiVersion)
                            }
                            $pagesFollowed++
                        }
                        'none' {
                            # No pagination - remove from batch to stop processing this request
                            $batch.Remove($instance) | Out-Null
                        }
                        default {
                            # Pagination not specified - inform user about available pages and stop
                            Write-Warning "Request ID '$($response.id)' has additional pages available. Use -pagination 'auto' to retrieve all pages automatically, or 'none' to stop after first page."
                            $batch.Remove($instance) | Out-Null
                        }
                    }
                }
                else {
                    # No more pages available - remove completed request from batch
                    $batch.Remove($instance) | Out-Null
                }
            }

            # Apply rate limit delay if any 429 responses were encountered
            if ($RetryTimer) {
                Write-Information -MessageData "Rate limit exceeded, waiting $RetryTimer seconds"
                Start-Sleep -Seconds $RetryTimer
            }

        } while ($batch.Count -gt 0 -or $queue.Count -gt 0 )
        
        # Output final statistics
        Write-Verbose "Completed processing: $batchCount batch(es), $totalProcessed response(s), $pagesFollowed page(s) followed"
        
        # Output grouped results if GroupById mode was used
        if ($GroupById) {
            Write-Verbose "Returning grouped results for $($Results.Keys.Count) unique ID(s)"
            $Results.Clone()
        }
    }  
}
#endregion Invoke-ptGraphBatchRequest

#region Invoke-ptGraphRequest
<#
.SYNOPSIS
    Executes Microsoft Graph API requests individually with automatic retry handling, rate limiting, and pagination support.

.DESCRIPTION
    This function processes Microsoft Graph API requests one at a time. It handles rate limiting (HTTP 429 responses)
    with automatic retry logic, supports multiple pagination modes, and provides flexible output options including
    raw responses and grouped results. The function is designed to optimize API usage while respecting Microsoft Graph
    rate limits and providing robust error handling.

    The function supports three parameter sets:
    - Standard: Normal processing with individual item output and configurable pagination
    - GroupById: Groups results by request ID with automatic pagination (ideal for collecting complete datasets)
    - RawOutput: Returns complete response objects with configurable pagination (ideal for custom post-processing)

.PARAMETER RequestItems
    An array of PSCustomObject items representing individual Graph API requests. Each item must contain:
    - 'id' property: Unique identifier for tracking the request
    - 'url' property: The Graph API endpoint (relative to base URI and API version)
    - 'method' property: HTTP method (GET, POST, PUT, PATCH, DELETE)
    - Optional: 'headers', 'body' properties for advanced requests

.PARAMETER GraphBaseUri
    The base URI for Microsoft Graph API. Supports different Graph environments:
    - https://graph.microsoft.com (default - Commercial cloud)
    - https://graph.microsoft.us (US Government cloud)
    - https://dod-graph.microsoft.us (US Government DoD cloud)
    - https://graph.microsoft.de (German cloud)
    - https://microsoftgraph.chinacloudapi.cn (China cloud)

.PARAMETER GroupById
    Switch parameter that enables result grouping by request ID. When used:
    - Results are collected in a hashtable keyed by request ID
    - Automatically enables 'auto' pagination to ensure complete datasets
    - Returns a hashtable with request IDs as keys and arrays of results as values
    - Cannot be used with RawOutput parameter

.PARAMETER ApiVersion
    The Microsoft Graph API version to use. Valid values are 'v1.0' (default) or 'beta'.
    Choose 'v1.0' for production stability or 'beta' for preview features.

.PARAMETER RawOutput
    Switch parameter that returns complete response objects instead of processed results.
    When enabled:
    - Returns the raw Graph API responses
    - Pagination still works (if enabled) but responses aren't processed
    - Ideal for custom post-processing or debugging
    - Cannot be used with GroupById parameter

.PARAMETER pagination
    Controls how the function handles paginated responses with @odata.nextLink. Valid values:
    - 'auto': Automatically follow all pagination links to retrieve complete datasets
    - 'none': Return only the first page of results without following pagination
    - Not specified: Return first page with warnings about available additional pages
    Note: Not available with GroupById parameter set (always uses 'auto')

.PARAMETER EnrichOutput
    Switch parameter that adds request metadata to each output item. When enabled:
    - Adds a '@requestMetadata' property to each result object containing:
      * 'requestId': The request ID that returned this item
      * '@odata.context': The OData context URL from the response
    - Available in Standard and GroupById modes only (not available with RawOutput)
    - Helps track which request produced each result

.OUTPUTS
    The output depends on the parameter set used:
    
    Standard mode: Individual result objects from successful requests
    GroupById mode: Hashtable with request IDs as keys and result arrays as values
    RawOutput mode: Complete response objects from Graph API

.EXAMPLE
    $requests = @(
        New-ptGraphRequestItem -id "1" -url "/users"
        New-ptGraphRequestItem -id "2" -url "/groups"
    )
    Invoke-ptGraphRequest -RequestItems $requests

    Processes two Graph API requests individually. Returns individual user and group objects.

.EXAMPLE
    $requests = @(
        New-ptGraphRequestItem -id "users" -url "/users"
        New-ptGraphRequestItem -id "groups" -url "/groups"
    )
    $results = Invoke-ptGraphRequest -RequestItems $requests -GroupById
    $results["users"] # All user objects
    $results["groups"] # All group objects

    Groups results by request ID with automatic pagination.

.EXAMPLE
    Invoke-ptGraphRequest -RequestItems $requests -RawOutput -pagination 'auto'

    Returns raw response objects with automatic pagination.

.EXAMPLE
    Invoke-ptGraphRequest -RequestItems $requests -pagination 'auto' -EnrichOutput

    Processes requests with automatic pagination and enriches each output item with metadata.

.NOTES
    - Requires the Microsoft.Graph PowerShell SDK for Invoke-MgGraphRequest
    - Automatically handles rate limiting with exponential backoff retry logic
    - Supports all Microsoft Graph sovereign cloud environments
    - Rate limit retry delays are extracted from Graph API error responses when available

.LINK
    https://docs.microsoft.com/en-us/graph/throttling

.LINK
    https://docs.microsoft.com/en-us/graph/paging
#>

function Invoke-ptGraphRequest {
    [CmdletBinding(DefaultParameterSetName = 'Standard', HelpUri = 'https://github.com/PowerShellToday/pt.EntraGraphUtils/blob/main/docs/Invoke-ptGraphRequest.md')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Standard')]
        [Parameter(Mandatory = $true, ParameterSetName = 'GroupById')]
        [Parameter(Mandatory = $true, ParameterSetName = 'RawOutput')]
        [PSCustomObject[]] $RequestItems,

        [Parameter(Mandatory = $false, ParameterSetName = 'Standard')]
        [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')]
        [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')]
        [ValidateSet(
            'https://graph.microsoft.us/', 
            'https://microsoftgraph.chinacloudapi.cn/',
            'https://graph.microsoft.com/',
            'https://dod-graph.microsoft.us/',
            'https://graph.microsoft.de/'
        )]
        [uri] $GraphBaseUri = 'https://graph.microsoft.com',

        [Parameter(Mandatory = $true, ParameterSetName = 'GroupById')]
        [switch] $GroupById,

        [Parameter(Mandatory = $false, ParameterSetName = 'Standard')]
        [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')]
        [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')]
        [ValidateSet('v1.0', 'beta')]
        [string] $ApiVersion = 'v1.0',

        [Parameter(Mandatory = $true, ParameterSetName = 'RawOutput')]
        [switch]$RawOutput,

        [Parameter(Mandatory = $false, ParameterSetName = 'Standard')]
        [Parameter(Mandatory = $false, ParameterSetName = 'RawOutput')]
        [ValidateSet('none', 'auto')]
        [string]$pagination,

        [Parameter(Mandatory = $false, ParameterSetName = 'Standard')]
        [Parameter(Mandatory = $false, ParameterSetName = 'GroupById')]
        [switch]$EnrichOutput
    )

    begin {
        # Validate that all request items have required properties
        $invalidItems = $RequestItems | Where-Object { -not $_.id -or -not $_.url -or -not $_.method }
        if ($invalidItems) {
            throw "All request items must have 'id', 'method' and 'url' properties. Found $($invalidItems.Count) invalid item(s)."
        }
        
        # Determine pagination strategy based on parameter set
        if ($PSCmdlet.ParameterSetName -eq 'GroupById') {
            $pagination = 'auto'
        }
        
        # Initialize thread-safe queue for managing request items
        $queue = [System.Collections.Queue]::Synchronized((New-Object System.Collections.Queue))
        
        # Initialize results collection if GroupById mode is enabled
        if ($GroupById) {
            $Results = @{}
        }

        # Populate the queue with all request items and initialize result collections for GroupById
        $RequestItems | ForEach-Object {
            if ($GroupById) {
                $id = $_.id
                $Results[$id] = New-Object 'System.Collections.Generic.List[psobject]'
            }
            $queue.Enqueue($_)
        }
    }

    process {
        Write-Information ('Processing {0} request(s).' -f $RequestItems.Count)
        Write-Verbose "Using parameter set: $($PSCmdlet.ParameterSetName)"
        Write-Verbose "Pagination mode: $(if ($pagination) { $pagination } else { 'default (warn)' })"

        # Track statistics
        $totalProcessed = 0
        $pagesFollowed = 0

        # Main processing loop
        while ($queue.Count -gt 0) {
            $request = $queue.Dequeue()
            $instanceId = $request.id
            
            Write-Verbose "Processing request ID: $instanceId"
            
            # Construct full URI
            $uri = [IO.Path]::Combine($GraphBaseUri.AbsoluteUri, $ApiVersion, $request.url.TrimStart('/'))
            Write-Verbose "Request URI: $uri"
            
            # Prepare request parameters
            $requestParams = @{
                Method = $request.method
                Uri    = $uri
            }
            
            # Add headers if provided
            if ($request.headers) {
                $requestParams['Headers'] = $request.headers
            }
            
            # Add body if provided
            if ($request.body) {
                if ($request.body -is [hashtable]) {
                    $requestParams['Body'] = ($request.body | ConvertTo-Json -Depth 10)
                }
                else {
                    $requestParams['Body'] = $request.body
                }
            }
            
            # Execute request with retry logic
            $maxRetries = 3
            $retryCount = 0
            $success = $false
            
            while (-not $success -and $retryCount -lt $maxRetries) {
                try {
                    $response = Invoke-MgGraphRequest @requestParams -OutputType PSObject
                    $success = $true
                    $totalProcessed++
                    
                    Write-Verbose "Request ID '$instanceId' completed successfully"
                    
                }
                catch {
                    $statusCode = $_.Exception.Response.StatusCode.value__
                    
                    # Handle rate limiting (429)
                    if ($statusCode -eq 429) {
                        $retryAfter = 15
                        if ($_.Exception.Response.Headers['Retry-After']) {
                            $retryAfter = [int]$_.Exception.Response.Headers['Retry-After']
                        }
                        
                        Write-Warning "Rate limit exceeded for request ID '$instanceId'. Waiting $retryAfter seconds (attempt $($retryCount + 1)/$maxRetries)"
                        Start-Sleep -Seconds $retryAfter
                        $retryCount++
                        continue
                    }
                    
                    # Handle other errors
                    $errorMessage = "Graph API request failed for request ID '$instanceId': $($_.Exception.Message)"
                    Write-Error -Message $errorMessage -Category InvalidOperation
                    break
                }
            }
            
            # Skip further processing if request failed
            if (-not $success) {
                continue
            }
            
            # Output raw response if RawOutput mode is enabled
            if ($RawOutput) {
                Write-Output $response
            }
            
            # Process response if not in raw output mode
            if (-not $RawOutput) {
                # Ensure response exists
                if (-not $response) {
                    Write-Warning "Response for request ID '$instanceId' is null. Skipping."
                    continue
                }
                
                # GroupById mode: Collect all results in hashtable
                if ($GroupById) {
                    if ($response.value -is [array]) {
                        $response.value | ForEach-Object {
                            if ($EnrichOutput) {
                                $_ | Add-Member -NotePropertyName '@requestMetadata' -NotePropertyValue @{
                                    requestId        = $instanceId
                                    '@odata.context' = $response.'@odata.context'
                                } -Force
                            }
                            $Results[$instanceId].Add($_)
                        }
                    }
                    else {
                        if ($EnrichOutput) {
                            $response | Add-Member -NotePropertyName '@requestMetadata' -NotePropertyValue @{
                                requestId        = $instanceId
                                '@odata.context' = $response.'@odata.context'
                            } -Force
                        }
                        $Results[$instanceId].Add($response)
                    }
                }
                # Standard mode: Output individual items
                else {
                    if ($null -ne $response.PSObject.Properties['value']) {
                        if ($response.value -is [array]) {
                            $response.value | ForEach-Object {
                                if ($EnrichOutput) {
                                    $_ | Add-Member -NotePropertyName '@requestMetadata' -NotePropertyValue @{
                                        requestId        = $instanceId
                                        '@odata.context' = $response.'@odata.context'
                                    } -Force
                                }
                                Write-Output $_
                            }
                        }
                        else {
                            if ($EnrichOutput) {
                                $response.value | Add-Member -NotePropertyName '@requestMetadata' -NotePropertyValue @{
                                    requestId        = $instanceId
                                    '@odata.context' = $response.'@odata.context'
                                } -Force
                            }
                            Write-Output $response.value
                        }
                    }
                    else {
                        if ($EnrichOutput) {
                            $response | Add-Member -NotePropertyName '@requestMetadata' -NotePropertyValue @{
                                requestId        = $instanceId
                                '@odata.context' = $response.'@odata.context'
                            } -Force
                        }
                        Write-Output $response
                    }
                }
            }
            
            # Handle pagination
            if ($response.'@odata.nextLink') {
                switch ($pagination) {
                    'auto' {
                        # Update URL and re-queue
                        $request.url = $response.'@odata.nextLink' -replace ('{0}{1}/' -f $GraphBaseUri.AbsoluteUri, $ApiVersion)
                        $queue.Enqueue($request)
                        $pagesFollowed++
                    }
                    'none' {
                        # Don't follow pagination
                    }
                    default {
                        Write-Warning "Request ID '$instanceId' has additional pages available. Use -pagination 'auto' to retrieve all pages automatically, or 'none' to stop after first page."
                    }
                }
            }
        }
        
        # Output statistics
        Write-Verbose "Completed processing: $totalProcessed response(s), $pagesFollowed page(s) followed"
        
        # Output grouped results if GroupById mode was used
        if ($GroupById) {
            Write-Verbose "Returning grouped results for $($Results.Keys.Count) unique ID(s)"
            $Results.Clone()
        }
    }
}
#endregion Invoke-ptGraphRequest

#region New-ptGraphRequestItem
<#
.SYNOPSIS
    Creates a new Microsoft Graph request item for use with batch requests or individual API calls.

.DESCRIPTION
    This helper function creates properly formatted Graph request items that can be used with
    the Invoke-GraphBatchRequest2 function or for individual Graph API requests. It supports
    different parameter sets for requests with and without body content, and handles both
    string and hashtable body types.
    
    The function intelligently handles query parameters by:
    - Preserving existing query parameters from the input URL
    - Merging them with new OData parameters specified via function parameters
    - Allowing QueryParameters hashtable to override or add additional parameters
    - Properly URL-encoding the final query string

.PARAMETER id
    Unique identifier for the request within a batch operation. Used to correlate
    batch responses with individual requests. Only used when this request item is
    part of a batch request. If not specified, a new GUID will be automatically generated.

.PARAMETER url
    The Graph API endpoint URL (relative to the base URI, without the version prefix).
    Must start with a forward slash '/'. Can include existing query parameters which will
    be preserved and merged with any OData parameters specified via function parameters.
    Examples: "/users", "/groups/12345/members", "/me/messages", "/users?$top=5"

.PARAMETER method
    HTTP method for the request. Valid values: GET, POST, PUT, PATCH, DELETE.
    Defaults to 'GET' if not specified.

.PARAMETER headers
    Optional hashtable of HTTP headers to include with the request.
    When using body parameters, Content-Type will be automatically set to 'application/json'
    unless explicitly overridden via the ContentType parameter or headers hashtable.

.PARAMETER body
    Request body content. Can be either:
    - A hashtable object (will be converted to JSON automatically)
    - A pre-formatted JSON string (when you need precise control over formatting)
    The function automatically detects the type and handles it appropriately.

.PARAMETER bodyHashtable
    (Deprecated - use -body instead) Alias for -body parameter when passing a hashtable.

.PARAMETER bodyString
    (Deprecated - use -body instead) Alias for -body parameter when passing a string.

.PARAMETER dependsOn
    Optional identifier of another request within the same batch that this request depends on.
    Used to control execution order within batch operations. Only applicable when using
    batch requests - ignored for individual API calls.

.PARAMETER pageSize
    Number of items to return per page (OData $top parameter).

.PARAMETER Count
    Include count of total items in response (OData $count parameter).

.PARAMETER ExpandProperty
    Expand related properties (OData $expand parameter).

.PARAMETER Filter
    Filter results based on criteria (OData $filter parameter).

.PARAMETER Format
    Response format (OData $format parameter).

.PARAMETER Sort
    Sort order for results (OData $orderby parameter).

.PARAMETER Search
    Search query (OData $search parameter).

.PARAMETER Property
    Select specific properties (OData $select parameter).

.PARAMETER Skip
    Number of items to skip (OData $skip parameter).

.PARAMETER skipToken
    Token for pagination (OData $skiptoken parameter).

.PARAMETER QueryParameters
    Optional hashtable of additional query parameters to include in the request URL.
    These parameters will be merged with any existing query parameters from the URL
    and any OData parameters specified via dedicated function parameters.
    QueryParameters take precedence over existing URL parameters with the same name.

.PARAMETER ConsistencyLevel
    Sets the consistency level for the request. Currently only 'eventual' is supported.
    Automatically adds the ConsistencyLevel header to the request when specified.

.PARAMETER ContentType
    Sets the Content-Type header for the request. If not specified, defaults to 'application/json'
    for requests with body content. Common values include: 'application/json' (default for most
    Graph operations), 'application/x-www-form-urlencoded' (for OAuth), 'multipart/form-data'
    (for file uploads), etc.

.OUTPUTS
    PSCustomObject
    Returns a properly formatted Graph request item object with all query parameters
    (existing, OData, and custom) properly merged and URL-encoded in the final URL.

.EXAMPLE
    # Simple GET request (ID and method auto-generated/defaulted)
    $item1 = New-ptGraphRequestItem -url "/users"

.EXAMPLE
    # POST request with hashtable body and custom ID
    $item2 = New-ptGraphRequestItem -id "create-user" -url "/users" -method "POST" -headers @{"Content-Type"="application/json"} -body @{
        displayName = "John Doe"
        userPrincipalName = "john@contoso.com"
    }

.EXAMPLE
    # POST request with string body (auto-generated ID)
    $jsonBody = '{"displayName":"Jane Doe","userPrincipalName":"jane@contoso.com"}'
    $item3 = New-ptGraphRequestItem -url "/users" -method "POST" -headers @{"Content-Type"="application/json"} -body $jsonBody

.EXAMPLE
    # Backward compatibility: Using legacy -bodyHashtable parameter (aliased to -body)
    $item3b = New-ptGraphRequestItem -url "/users" -method "POST" -bodyHashtable @{
        displayName = "Legacy User"
        userPrincipalName = "legacy@contoso.com"
    }

.EXAMPLE
    # GET request with OData query parameters
    $item4 = New-ptGraphRequestItem -url "/users" -pageSize 10 -Filter "startswith(displayName,'John')" -Property "id,displayName,mail" -Sort "displayName"
    # Results in URL: /users?$top=10&$filter=startswith(displayName,'John')&$select=id,displayName,mail&$orderby=displayName

.EXAMPLE
    # URL with existing query parameters that get preserved and merged
    $item5 = New-ptGraphRequestItem -url "/users?$expand=manager" -pageSize 5 -Filter "department eq 'Sales'"
    # Results in URL: /users?$expand=manager&$top=5&$filter=department eq 'Sales'

.EXAMPLE
    # Using QueryParameters to add custom parameters
    $customParams = @{ 'api-version' = '2.0'; 'custom' = 'value' }
    $item6 = New-ptGraphRequestItem -url "/users" -QueryParameters $customParams -pageSize 10
    # Results in URL: /users?api-version=2.0&custom=value&$top=10

.NOTES
    - The function automatically handles the proper structure required by Graph API
    - Body content is only validated for type, not for Graph API schema compliance
    - Can be used for both batch operations and individual Graph API requests
    - The 'id' and 'dependsOn' parameters are only used for batch operations and are ignored for individual requests
    - Query parameters are intelligently merged: URL parameters + OData parameters + QueryParameters
    - Parameter precedence: QueryParameters > OData function parameters > existing URL parameters
    - All query parameters are properly URL-encoded in the final output
    - Uses internal module functions ConvertFrom-QueryString and ConvertTo-QueryString for query parameter processing
#>

function New-ptGraphRequestItem {
    [CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact='None', HelpUri = 'https://github.com/PowerShellToday/pt.EntraGraphUtils/blob/main/docs/New-ptGraphRequestItem.md')]
    param (
        [Parameter(Mandatory = $false)]
        [string]$id = [System.Guid]::NewGuid().ToString(),

        [Parameter(Mandatory = $true)]
        [ValidateScript({
                if ($_ -notmatch '^/') {
                    throw "URL must start with '/'. Use relative URLs only. Example: '/users' instead of 'users' or 'https://graph.microsoft.com/v1.0/users'. Current value: '$_'"
                }
                return $true
            })]
        [string]$url,

        [Parameter(Mandatory = $false)]
        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
        [string]$method = 'GET',

        [Parameter(Mandatory = $false)]
        [hashtable]$headers,

        [Parameter(Mandatory = $false)]
        [ValidateNotNull()]
        [Alias('bodyHashtable', 'bodyString')]
        [object]$body,

        [Parameter(Mandatory = $false)]
        [string]$dependsOn,

        # OData Query Parameters
        [Parameter(Mandatory = $false)]
        [Alias('$top')]
        [int]$pageSize,

        [Parameter(Mandatory = $false)]
        [Alias('$count')]
        [switch]$Count,

        [Parameter(Mandatory = $false)]
        [Alias('$expand')]
        [string]$ExpandProperty,

        [Parameter(Mandatory = $false)]
        [Alias('$filter')]
        [string]$Filter,

        [Parameter(Mandatory = $false)]
        [Alias('$format')]
        [string]$Format,

        [Parameter(Mandatory = $false)]
        [Alias('$orderby')]
        [string]$Sort,

        [Parameter(Mandatory = $false)]
        [Alias('$search')]
        [string]$Search,

        [Parameter(Mandatory = $false)]
        [Alias('$select')]
        [string[]]$Property,

        [Parameter(Mandatory = $false)]
        [Alias('$skip')]
        [int]$Skip,

        [Parameter(Mandatory = $false)]
        [Alias('$skiptoken')]
        [string]$skipToken,

        # Parameters such as "$top".
        [Parameter(Mandatory = $false)]
        [hashtable] $QueryParameters,

        [Parameter(Mandatory = $false)]
        [ValidateSet('eventual')]
        [string] $ConsistencyLevel,

        [Parameter(Mandatory = $false)]
        [ValidateSet('application/json', 'application/x-www-form-urlencoded', 'multipart/form-data', 'application/octet-stream', 'text/plain')]
        [string] $ContentType

    )
    # Initialize URI builder
    $uriQueryEndpoint = [System.UriBuilder]::new("https://server.com$url")

    if ($uriQueryEndpoint.Query) {
        [hashtable] $odataParams = ConvertFrom-QueryString $uriQueryEndpoint.Query -AsHashtable
        if ($QueryParameters) {
            foreach ($ParameterName in $QueryParameters.Keys) {
                $odataParams[$ParameterName] = $QueryParameters[$ParameterName]
            }
        }
    } elseif ($QueryParameters) {
        [hashtable] $odataParams = $QueryParameters
    } else { 
        [hashtable] $odataParams = @{ } 
    }

    
    # Define OData parameter mappings
    $odataParameterMap = @{
        'pageSize'       = '$top'
        'Count'          = '$count'
        'ExpandProperty' = '$expand'
        'Filter'         = '$filter'
        'Format'         = '$format'
        'Sort'           = '$orderby'
        'Search'         = '$search'
        'Property'       = '$select'
        'Skip'           = '$skip'
        'skipToken'      = '$skiptoken'
    }
    
    # Process each bound parameter that's an OData parameter
    $PSBoundParameters.Keys | Where-Object { $odataParameterMap.ContainsKey($_) } | 
        ForEach-Object {
            $paramName = $_
            $paramValue = $PSBoundParameters[$paramName]
            $odataParamName = $odataParameterMap[$paramName]
        
            # Warn if this OData parameter already exists in the URL or QueryParameters
            if ($odataParams.ContainsKey($odataParamName)) {
                $existingValue = $odataParams[$odataParamName]
                if ($existingValue -ne $paramValue -and ($paramName -ne 'Count' -or $existingValue -ne 'true')) {
                    Write-Warning "OData parameter '$odataParamName' specified multiple times. Function parameter '-$paramName' value ('$paramValue') will override existing value ('$existingValue') from URL or QueryParameters."
                }
            }
        
            if ($paramName -eq 'Count' -and $paramValue) {
                # Count is a switch parameter
                $odataParams[$odataParamName] = 'true'
            } elseif ($paramName -eq 'Property' -and $paramValue.Count -gt 1) {
                # Property accepts string array - join with commas for OData $select
                $odataParams[$odataParamName] = $paramValue -join ','
            } else {
                $odataParams[$odataParamName] = $paramValue
            }
        }
    
    # Append query parameters to URL if any OData parameters were specified
    if ($odataParams.Count -gt 0) {
        $queryString = ConvertTo-QueryString $odataParams
        $url = $uriQueryEndpoint.Path + '?' + [uri]::UnescapeDataString($queryString)
    } else {
        # No query parameters, just use the path
        $url = $uriQueryEndpoint.Path
    }

    # Create the base batch item structure
    $batchItem = @{
        id     = $id
        url    = $url
        method = $method.ToUpper()
    }

    # consistencyLevel header
    if ($ConsistencyLevel) {
        if (-not $headers) {
            $headers = @{}
        }
        $headers['ConsistencyLevel'] = $ConsistencyLevel
    }

    # contentType header - set explicitly or default for body requests
    $finalContentType = $ContentType
    if (-not $finalContentType -and $PSBoundParameters.ContainsKey('body')) {
        # Default to application/json for body requests if not specified
        $finalContentType = 'application/json'
    }
    
    if ($finalContentType) {
        if (-not $headers) {
            $headers = @{}
        }
        # Warn if both ContentType parameter and headers["Content-Type"] are specified
        if ($ContentType -and $headers -and $headers.ContainsKey('Content-Type') -and $headers['Content-Type'] -ne $ContentType) {
            Write-Warning "Both -ContentType parameter ('$ContentType') and headers['Content-Type'] ('$($headers['Content-Type'])') are specified. The -ContentType parameter will override the header value."
        }
        $headers['Content-Type'] = $finalContentType
    }

    # Add headers if provided
    if ($headers) {
        $batchItem.headers = $headers
    }

    # Add body if provided
    if ($PSBoundParameters.ContainsKey('body')) {
        # Detect body type at runtime
        if ($body -is [hashtable]) {
            $batchItem.body = $body
        } elseif ($body -is [string]) {
            # Validate JSON if Content-Type suggests it should be JSON
            if ($finalContentType -eq 'application/json' -or (-not $ContentType -and -not ($headers -and $headers.ContainsKey('Content-Type')))) {
                try {
                    # Test if the string is valid JSON
                    $null = $body | ConvertFrom-Json -ErrorAction Stop
                } catch {
                    Write-Warning "Body string does not appear to be valid JSON, but Content-Type is set to 'application/json' or not defined. Consider:"
                    Write-Warning ' 1. Fix the JSON syntax in your body string'
                    Write-Warning " 2. Explicitly set -ContentType to the correct type (e.g., 'text/plain', 'application/x-www-form-urlencoded')"
                    Write-Warning ' 3. Use a hashtable for -body instead for automatic JSON conversion'
                    Write-Warning "JSON validation error: $($_.Exception.Message)"
                }
            }
            $batchItem.body = $body
        } else {
            throw "Body must be either a hashtable or string. Received type: $($body.GetType().Name)"
        }
    } else {
        # Warn if using methods that typically require a body
        if ($method.ToUpper() -in @('POST', 'PUT', 'PATCH')) {
            Write-Warning "Using $($method.ToUpper()) method without a body. Consider:"
            Write-Warning ' 1. Add -body with a hashtable for structured data that will be converted to JSON'
            Write-Warning ' 2. Add -body with a string for pre-formatted content (JSON, form data, etc.)'
            Write-Warning ' 3. If no body is intentional, you can ignore this warning'
        }
    }

    # Add dependency if specified
    if ($dependsOn) {
        $batchItem.dependsOn = $dependsOn
    }

    return [PSCustomObject]$batchItem
}
#endregion New-ptGraphRequestItem

#endregion Public Functions

Export-ModuleMember -Function @('Invoke-ptGraphBatchRequest', 'Invoke-ptGraphRequest', 'New-ptGraphRequestItem')