modules/Azure/Infrastructure/Public/Invoke-AzureApi.ps1

function Invoke-AzureApi {
    <#
    .SYNOPSIS
        Invokes Azure REST API (ARM, Graph, or KeyVault) with standardized error handling.

    .DESCRIPTION
        Single point of entry for all Azure API calls. Handles authentication
        internally - callers should not deal with tokens or auth logic.

        Supports three calling patterns:
        - ByUri: Pass a full URL via -Uri
        - ByPath: Pass a relative path via -Path with a mandatory -Api selector
        - Batch: Pass Graph sub-requests via -Requests to call Microsoft Graph $batch

    .PARAMETER Uri
        Full API URL (ByUri set). Mutually exclusive with -Path and -Requests.

    .PARAMETER Path
        Relative path appended to the base URL resolved from azure_provider_apis.

    .PARAMETER Requests
        Graph batch sub-requests. Each entry must have Id, Method, and Path.

    .PARAMETER Api
        Target API selector (ARM, Graph, GraphBeta, KeyVault). Mandatory for
        all parameter sets — the old URI host auto-detection path has been
        removed.

    .PARAMETER ResourceName
        Friendly resource name used in error messages.

    .PARAMETER Method
        HTTP method for ByUri/ByPath calls. Defaults to GET.

    .PARAMETER Body
        Hashtable/PSCustomObject request body for POST/PUT/PATCH.

    .PARAMETER Raw
        Return the raw response envelope with StatusCode/Body/Headers instead of
        unwrapping paginated `value`/`data` collections.
    #>

    [CmdletBinding(DefaultParameterSetName = 'ByUri')]
    [OutputType([PSObject])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'ByUri')]
        [ValidateNotNullOrEmpty()]
        [string]$Uri,

        [Parameter(Mandatory, ParameterSetName = 'ByPath')]
        [ValidateNotNullOrEmpty()]
        [string]$Path,

        [Parameter(Mandatory, ParameterSetName = 'Batch')]
        [AllowEmptyCollection()]
        [hashtable[]]$Requests,

        [Parameter(Mandatory, ParameterSetName = 'ByUri')]
        [Parameter(Mandatory, ParameterSetName = 'ByPath')]
        [Parameter(Mandatory, ParameterSetName = 'Batch')]
        [ValidateSet('ARM', 'Graph', 'GraphBeta', 'KeyVault')]
        [string]$Api,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$ResourceName,

        [Parameter(ParameterSetName = 'ByUri')]
        [Parameter(ParameterSetName = 'ByPath')]
        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
        [string]$Method = 'GET',

        [Parameter(ParameterSetName = 'ByUri')]
        [Parameter(ParameterSetName = 'ByPath')]
        [object]$Body,

        [Parameter(ParameterSetName = 'ByUri')]
        [Parameter(ParameterSetName = 'ByPath')]
        [switch]$Raw
    )

    $ErrorActionPreference = 'Stop'
    $ProgressPreference = 'SilentlyContinue'

    function ConvertToHeaderMap {
        param(
            [Parameter()]
            [object]$HeaderSource
        )

        $ErrorActionPreference = 'Stop'

        $headerMap = @{}
        if (-not $HeaderSource) {
            return $headerMap
        }

        if ($HeaderSource -is [System.Net.Http.Headers.HttpHeaders]) {
            foreach ($entry in $HeaderSource) {
                $headerMap[([string]$entry.Key).ToLowerInvariant()] = (@($entry.Value) | ForEach-Object { [string]$_ }) -join ','
            }
            return $headerMap
        }

        if ($HeaderSource -is [System.Collections.IDictionary]) {
            foreach ($key in @($HeaderSource.Keys)) {
                if ($null -eq $key) {
                    continue
                }

                $value = $HeaderSource[$key]
                if ($null -eq $value) {
                    continue
                }

                $normalizedKey = ([string]$key).ToLowerInvariant()
                $headerMap[$normalizedKey] = if ($value -is [System.Array]) {
                    @($value) -join ','
                }
                else {
                    [string]$value
                }
            }
            return $headerMap
        }

        foreach ($property in $HeaderSource.PSObject.Properties) {
            if ($null -eq $property.Value) {
                continue
            }

            $normalizedKey = ([string]$property.Name).ToLowerInvariant()
            $headerMap[$normalizedKey] = if ($property.Value -is [System.Array]) {
                @($property.Value) -join ','
            }
            else {
                [string]$property.Value
            }
        }

        $headerMap
    }

    function GetHeaderValue {
        param(
            [Parameter()]
            [hashtable]$Headers,
            [Parameter(Mandatory)]
            [string]$Name
        )

        $ErrorActionPreference = 'Stop'

        if (-not $Headers) {
            return $null
        }

        $lookupName = $Name.ToLowerInvariant()
        if ($Headers.ContainsKey($lookupName)) {
            return $Headers[$lookupName]
        }

        $null
    }

    function ConvertErrorBodyToObject {
        param(
            [Parameter()]
            [string]$ErrorText
        )

        $ErrorActionPreference = 'Stop'

        if (-not $ErrorText) {
            return $null
        }

        # Error responses may be free-form text in rare cases (e.g., HTML error pages
        # from intermediary proxies). Try JSON first and wrap raw text in a
        # pscustomobject so the downstream shape is consistent with the success path.
        try {
            return ($ErrorText | ConvertFrom-Json -ErrorAction Stop)
        }
        catch [System.ArgumentException], [System.Text.Json.JsonException] {
            return [pscustomobject]@{ rawText = $ErrorText }
        }
    }

    function InvokeSafeRestMethod {
        param(
            [Parameter(Mandatory)]
            [string]$RequestUri,
            [Parameter(Mandatory)]
            [hashtable]$RequestHeaders,
            [Parameter(Mandatory)]
            [string]$RequestMethod,
            [Parameter()]
            [string]$JsonBody
        )

        $ErrorActionPreference = 'Stop'

        try {
            $responseHeaders = $null
            $restParams = @{
                Uri                     = $RequestUri
                Method                  = $RequestMethod
                Headers                 = $RequestHeaders
                ErrorAction             = 'Stop'
                ResponseHeadersVariable = 'responseHeaders'
            }

            if ($JsonBody) {
                $restParams.Body = $JsonBody
                $restParams.ContentType = 'application/json'
            }

            $restResponse = Invoke-RestMethod @restParams

            [pscustomobject]@{
                StatusCode = 200
                Body = $restResponse
                Headers = ConvertToHeaderMap -HeaderSource $responseHeaders
            }
        }
        catch [Microsoft.PowerShell.Commands.HttpResponseException] {
            $errorRecord = $_
            $errorText = $null
            if ($errorRecord.ErrorDetails -and $errorRecord.ErrorDetails.Message) {
                $errorText = $errorRecord.ErrorDetails.Message
            }

            [pscustomobject]@{
                StatusCode = [int]$errorRecord.Exception.Response.StatusCode
                Body = ConvertErrorBodyToObject -ErrorText $errorText
                Headers = ConvertToHeaderMap -HeaderSource $errorRecord.Exception.Response.Headers
            }
        }
        catch {
            [pscustomobject]@{
                StatusCode = 0
                Body = [pscustomobject]@{ rawText = $_.Exception.Message }
                Headers = @{}
            }
        }
    }

    function GetRetryDelaySeconds {
        param(
            [Parameter(Mandatory)]
            [object]$Response,
            [Parameter(Mandatory)]
            [int]$RetryAttempt
        )

        $ErrorActionPreference = 'Stop'

        # Cast to double so [Math]::Min picks the double overload. Otherwise
        # [Math]::Min(60, 1.2) picks the int overload and returns 1.
        [double]$maxDelay = 60

        $retryAfterHeader = GetHeaderValue -Headers $Response.Headers -Name 'Retry-After'
        if ($retryAfterHeader) {
            $parsedRetryAfter = $null
            if ([System.Net.Http.Headers.RetryConditionHeaderValue]::TryParse($retryAfterHeader, [ref]$parsedRetryAfter)) {
                if ($parsedRetryAfter.Delta -and $parsedRetryAfter.Delta.TotalSeconds -gt 0) {
                    return [Math]::Min($maxDelay, [math]::Ceiling($parsedRetryAfter.Delta.TotalSeconds))
                }

                if ($parsedRetryAfter.Date) {
                    $headerDelay = [math]::Ceiling(($parsedRetryAfter.Date - [DateTimeOffset]::UtcNow).TotalSeconds)
                    if ($headerDelay -gt 0) {
                        return [Math]::Min($maxDelay, $headerDelay)
                    }
                }
            }
        }

        # Body-level retryAfter (already parsed by InvokeSafeRestMethod / ConvertErrorBodyToObject)
        if ($Response.Body -and $Response.Body.PSObject.Properties.Name -contains 'retryAfter') {
            $bodyDelay = [double]$Response.Body.retryAfter
            if ($bodyDelay -gt 0) {
                return [Math]::Min($maxDelay, $bodyDelay)
            }
        }

        $baseDelay = [math]::Pow(2, $RetryAttempt - 1)
        $jitter = Get-Random -Minimum 0.8 -Maximum 1.2
        [Math]::Min($maxDelay, [math]::Round($baseDelay * $jitter, 2))
    }

    function InvokeAzureRequestWithRetry {
        param(
            [Parameter(Mandatory)]
            [string]$RequestUri,
            [Parameter(Mandatory)]
            [hashtable]$RequestHeaders,
            [Parameter(Mandatory)]
            [string]$RequestMethod,
            [Parameter()]
            [string]$JsonBody,
            [Parameter(Mandatory)]
            [string]$FriendlyName
        )

        $ErrorActionPreference = 'Stop'

        $maxRetries = 5
        $retryAttempts = 0

        while ($true) {
            $response = InvokeSafeRestMethod -RequestUri $RequestUri -RequestHeaders $RequestHeaders -RequestMethod $RequestMethod -JsonBody $JsonBody
            if ($response.StatusCode -ne 429) {
                return $response
            }

            if ($retryAttempts -ge $maxRetries) {
                throw "Failed to load $FriendlyName - retries exhausted after $maxRetries retries."
            }

            $retryAttempts++
            $retryDelay = GetRetryDelaySeconds -Response $response -RetryAttempt $retryAttempts
            Write-Verbose "[$FriendlyName] Rate limited (429). Retry $retryAttempts of $maxRetries after ${retryDelay}s..."
            InvokeCIEMAzureSleep -Seconds $retryDelay
        }
    }

    function GetParsedErrorMessage {
        param(
            [Parameter()]
            [object]$Body
        )

        $ErrorActionPreference = 'Stop'

        if (-not $Body) {
            return $null
        }

        if ($Body -is [string]) {
            return $Body
        }

        if ($Body.PSObject.Properties.Name -contains 'rawText' -and $Body.rawText) {
            return [string]$Body.rawText
        }

        if ($Body.PSObject.Properties.Name -contains 'error' -and $Body.error) {
            if ($Body.error.PSObject.Properties.Name -contains 'message') {
                return [string]$Body.error.message
            }
        }

        $Body | ConvertTo-Json -Depth 20 -Compress
    }

    function ConvertToSkipTokenBody {
        # Takes the previous POST body (hashtable or pscustomobject) and returns
        # a fresh hashtable with $skipToken appended, suitable for re-serialization.
        # This is used by Azure Resource Graph pagination where the next page is
        # requested by re-posting the original body plus a $skipToken field.
        param(
            [Parameter()]
            [object]$BodyObject,
            [Parameter(Mandatory)]
            [string]$SkipToken
        )

        $ErrorActionPreference = 'Stop'

        $nextBody = if ($BodyObject) {
            # Round-trip via JSON so we get a detached hashtable we can mutate
            # without disturbing the caller's original object.
            $BodyObject | ConvertTo-Json -Depth 20 -Compress | ConvertFrom-Json -AsHashtable
        }
        else {
            @{}
        }

        $nextBody['$skipToken'] = $SkipToken
        $nextBody
    }

    function InvokeAzureBatchRequests {
        param(
            [Parameter(Mandatory)]
            [AllowEmptyCollection()]
            [hashtable[]]$BatchRequests,
            [Parameter(Mandatory)]
            [string]$BatchApi,
            [Parameter(Mandatory)]
            [hashtable]$BatchHeaders,
            [Parameter(Mandatory)]
            [string]$BatchResourceName
        )

        $ErrorActionPreference = 'Stop'

        if (@($BatchRequests).Count -eq 0) {
            throw 'Invoke-AzureApi batch mode requires at least one batch request.'
        }

        if ($BatchApi -notin @('Graph', 'GraphBeta')) {
            throw "Invoke-AzureApi batch mode supports Graph APIs only. Received '$BatchApi'."
        }

        $batchEndpoint = (Get-CIEMAzureProviderApi -Name $BatchApi).BaseUrl.TrimEnd('/')
        $batchUri = "$batchEndpoint/`$batch"
        $results = @{}

        $wallClockCapSeconds = if ($script:CIEMGraphBatchWallClockSeconds -and $script:CIEMGraphBatchWallClockSeconds -gt 0) {
            $script:CIEMGraphBatchWallClockSeconds
        }
        else {
            300
        }

        for ($offset = 0; $offset -lt $BatchRequests.Count; $offset += $script:CIEMGraphBatchSize) {
            $remaining = $BatchRequests.Count - $offset
            $chunkSize = [Math]::Min($script:CIEMGraphBatchSize, $remaining)
            $pendingRequests = @(
                for ($i = $offset; $i -lt $offset + $chunkSize; $i++) {
                    $BatchRequests[$i]
                }
            )
            $retryAttempts = @{}
            $chunkStart = [DateTimeOffset]::UtcNow

            while ($pendingRequests.Count -gt 0) {
                if (([DateTimeOffset]::UtcNow - $chunkStart).TotalSeconds -ge $wallClockCapSeconds) {
                    throw "Failed to load $BatchResourceName batch - wall-clock retry budget ($wallClockCapSeconds s) exceeded."
                }

                $payloadRequests = foreach ($request in $pendingRequests) {
                    if (-not $request.Id) {
                        throw 'Invoke-AzureApi batch requests must include Id.'
                    }
                    if (-not $request.Method) {
                        throw "Invoke-AzureApi batch request '$($request.Id)' is missing Method."
                    }
                    if (-not $request.Path) {
                        throw "Invoke-AzureApi batch request '$($request.Id)' is missing Path."
                    }

                    @{
                        id = [string]$request.Id
                        method = [string]$request.Method
                        url = $request.Path.TrimStart('/')
                    }
                }

                $payload = @{
                    requests = $payloadRequests
                }

                $payloadJson = $payload | ConvertTo-Json -Depth 20 -Compress
                $batchResponse = InvokeAzureRequestWithRetry -RequestUri $batchUri -RequestHeaders $BatchHeaders -RequestMethod 'POST' -JsonBody $payloadJson -FriendlyName "$BatchResourceName batch"

                if ($batchResponse.StatusCode -ne 200) {
                    $errorDetail = GetParsedErrorMessage -Body $batchResponse.Body
                    throw "Failed to load $BatchResourceName batch - Status: $($batchResponse.StatusCode) - $errorDetail"
                }

                $parsedBatchResponse = $batchResponse.Body
                if (-not $parsedBatchResponse -or -not ($parsedBatchResponse.PSObject.Properties.Name -contains 'responses')) {
                    throw "Failed to load $BatchResourceName batch - malformed batch response."
                }

                $subResponsesById = @{}
                foreach ($subResponse in @($parsedBatchResponse.responses)) {
                    $subResponsesById[[string]$subResponse.id] = $subResponse
                }

                $nextPending = [System.Collections.Generic.List[hashtable]]::new()
                $retryDelays = [System.Collections.Generic.List[double]]::new()

                foreach ($request in $pendingRequests) {
                    $requestId = [string]$request.Id
                    if (-not $subResponsesById.ContainsKey($requestId)) {
                        throw "Failed to load $BatchResourceName batch - missing response for request '$requestId'."
                    }

                    $subResponse = $subResponsesById[$requestId]
                    $statusCode = [int]$subResponse.status

                    if ($statusCode -eq 429) {
                        $currentRetryCount = if ($retryAttempts.ContainsKey($requestId)) { $retryAttempts[$requestId] } else { 0 }
                        if ($currentRetryCount -ge 5) {
                            throw "Failed to load $BatchResourceName - batch sub-request '$requestId' retries exhausted."
                        }

                        $retryAttempts[$requestId] = $currentRetryCount + 1
                        $retryResponse = [pscustomobject]@{
                            StatusCode = 429
                            Body = $subResponse.body
                            Headers = ConvertToHeaderMap -HeaderSource $subResponse.headers
                        }
                        $retryDelays.Add((GetRetryDelaySeconds -Response $retryResponse -RetryAttempt $retryAttempts[$requestId]))
                        $nextPending.Add($request)
                        continue
                    }

                    $subBody = $subResponse.body
                    if ($statusCode -lt 200 -or $statusCode -ge 300) {
                        $results[$requestId] = [pscustomobject]@{
                            Id = $requestId
                            Success = $false
                            StatusCode = $statusCode
                            Items = @()
                            Content = $subBody
                            Error = GetParsedErrorMessage -Body $subBody
                        }
                        continue
                    }

                    $items = @()
                    if ($subBody) {
                        if ($subBody.PSObject.Properties.Name -contains 'value') {
                            $items = @($subBody.value)
                        }
                        elseif ($subBody -is [System.Array]) {
                            $items = @($subBody)
                        }
                        else {
                            $items = @($subBody)
                        }

                        if ($subBody.PSObject.Properties.Name -contains '@odata.nextLink' -and $subBody.'@odata.nextLink') {
                            $items += @(Invoke-AzureApi -Uri $subBody.'@odata.nextLink' -Api $BatchApi -ResourceName "$BatchResourceName/$requestId" -ErrorAction Stop)
                        }
                    }

                    $results[$requestId] = [pscustomobject]@{
                        Id = $requestId
                        Success = $true
                        StatusCode = $statusCode
                        Items = @($items)
                        Content = $subBody
                        Error = $null
                    }
                }

                if ($nextPending.Count -gt 0) {
                    $sleepSeconds = ($retryDelays | Measure-Object -Maximum).Maximum
                    if ($sleepSeconds -gt 0) {
                        InvokeCIEMAzureSleep -Seconds $sleepSeconds
                    }
                    $pendingRequests = @($nextPending.ToArray())
                    continue
                }

                break
            }
        }

        $results
    }

    if ($PSCmdlet.ParameterSetName -eq 'ByPath') {
        $apiRecord = Get-CIEMAzureProviderApi -Name $Api
        if (-not $apiRecord) {
            throw "No API endpoint record found for '$Api' in azure_provider_apis table."
        }

        $baseUrl = $apiRecord.BaseUrl.TrimEnd('/')
        $Uri = "$baseUrl/$($Path.TrimStart('/'))"
    }

    if (-not $script:AzureAuthContext -or -not $script:AzureAuthContext.IsConnected) {
        throw 'Not connected to Azure. Run Connect-CIEM first.'
    }

    $jsonBody = $null
    if ($Body) {
        $jsonBody = $Body | ConvertTo-Json -Depth 20 -Compress
    }

    $token = switch ($Api) {
        'Graph' { $script:AzureAuthContext.GraphToken }
        'GraphBeta' { $script:AzureAuthContext.GraphToken }
        'ARM' { $script:AzureAuthContext.ARMToken }
        'KeyVault' { $script:AzureAuthContext.KeyVaultToken }
    }

    if (-not $token) {
        throw "$Api API call requested but no $Api token available. Run Connect-CIEM first."
    }

    $headers = @{ Authorization = "Bearer $token" }

    if ($PSCmdlet.ParameterSetName -eq 'Batch') {
        return InvokeAzureBatchRequests -BatchRequests $Requests -BatchApi $Api -BatchHeaders $headers -BatchResourceName $ResourceName
    }

    Write-Verbose "Loading $ResourceName..."

    $currentUri = $Uri
    $currentMethod = $Method
    $currentJsonBody = $jsonBody
    $currentBodyObject = $Body
    $pageCount = 0
    $lastNextLink = $null
    $hasWrittenPaginationProgress = $false

    try {
        while ($currentUri) {
            $currentResponse = InvokeAzureRequestWithRetry -RequestUri $currentUri -RequestHeaders $headers -RequestMethod $currentMethod -JsonBody $currentJsonBody -FriendlyName $ResourceName

            if ($Raw) {
                return $currentResponse
            }

            $statusCode = $currentResponse.StatusCode

            if ($statusCode -eq 200) {
                $content = $currentResponse.Body
                if (-not $content) {
                    $currentUri = $null
                    continue
                }

                $pageCount++
                $isValueCollection = $content.PSObject.Properties.Name -contains 'value'
                $isDataCollection = $content.PSObject.Properties.Name -contains 'data'

                if (-not ($isValueCollection -or $isDataCollection)) {
                    # Single-object response — emit and exit the loop
                    $content
                    $currentUri = $null
                    continue
                }

                $items = if ($isValueCollection) { @($content.value) } else { @($content.data) }
                $nextLink = if ($content.PSObject.Properties.Name -contains '@odata.nextLink') {
                    $content.'@odata.nextLink'
                }
                elseif ($content.PSObject.Properties.Name -contains 'nextLink') {
                    $content.nextLink
                }
                else {
                    $null
                }

                if ($items.Count -eq 0 -and $nextLink) {
                    $currentUri = $null
                    continue
                }

                if ($nextLink) {
                    if ($lastNextLink -and $nextLink -eq $lastNextLink) {
                        throw "Failed to load $ResourceName - pagination cycle detected at '$nextLink'."
                    }

                    $lastNextLink = $nextLink
                    $hasWrittenPaginationProgress = $true
                    Write-Progress -Activity "Loading $ResourceName" -Status "Page $pageCount" -CurrentOperation $nextLink
                }
                elseif ($hasWrittenPaginationProgress) {
                    Write-Progress -Activity "Loading $ResourceName" -Status "Page $pageCount"
                }

                $items

                if ($nextLink) {
                    $currentUri = $nextLink
                    $currentMethod = 'GET'
                    $currentJsonBody = $null
                    continue
                }

                if ($content.PSObject.Properties.Name -contains '$skipToken' -and $content.'$skipToken' -and $currentMethod -eq 'POST') {
                    $currentBodyObject = ConvertToSkipTokenBody -BodyObject $currentBodyObject -SkipToken $content.'$skipToken'
                    $currentJsonBody = $currentBodyObject | ConvertTo-Json -Depth 20 -Compress
                    $hasWrittenPaginationProgress = $true
                    Write-Progress -Activity "Loading $ResourceName" -Status "Page $pageCount"
                    continue
                }

                $currentUri = $null
                continue
            }

            # Non-200 status — all fail-fast by throw.
            if ($statusCode -eq 401) {
                throw "Unauthorized loading $ResourceName - invalid or expired token"
            }
            if ($statusCode -eq 403) {
                throw "Access denied loading $ResourceName - missing permissions"
            }
            if ($statusCode -eq 404) {
                throw "Resource not found: $ResourceName"
            }
            if ($statusCode -eq 0) {
                $detail = GetParsedErrorMessage -Body $currentResponse.Body
                if (-not $detail) { $detail = 'Unknown error' }
                throw "Failed to load $ResourceName - $detail"
            }

            $detail = GetParsedErrorMessage -Body $currentResponse.Body
            $msg = "Failed to load $ResourceName - Status: $statusCode"
            if ($detail) {
                $msg += " - $detail"
            }
            throw $msg
        }
    }
    finally {
        if ($hasWrittenPaginationProgress) {
            Write-Progress -Activity "Loading $ResourceName" -Completed
        }
    }
}