Private/ApiHelpers.ps1

function Invoke-NCRestMethod {
    <#
    .SYNOPSIS
        Wraps Invoke-RestMethod with exponential back-off retry for N-Central REST calls.
    .DESCRIPTION
        Builds the full URI from BaseUri + Endpoint, appends query parameters, and handles:
          - 429 (rate limit): exponential back-off up to MaxRetries attempts
          - 401 (unauthorised): throws immediately with an actionable message
          - 5xx (server error): retries with same back-off as 429
 
        Returns the raw response object. Callers are responsible for unwrapping .data etc.
    .PARAMETER BaseUri
        Base URL including protocol, e.g. 'https://n-central.example.com'
    .PARAMETER Endpoint
        API path, e.g. '/api/devices'
    .PARAMETER Headers
        Hashtable of HTTP headers (must include Authorization Bearer token).
    .PARAMETER QueryParams
        Optional hashtable of query-string parameters.
    .PARAMETER Method
        HTTP method. Defaults to GET.
    .PARAMETER MaxRetries
        Maximum retry attempts on 429/5xx. Defaults to 5.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$BaseUri,

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

        [Parameter(Mandatory)]
        [hashtable]$Headers,

        [hashtable]$QueryParams = @{},

        [string]$Method = 'GET',

        [int]$MaxRetries = 5
    )

    # Build query string
    $queryString = ''
    if ($QueryParams.Count -gt 0) {
        $parts = foreach ($key in $QueryParams.Keys) {
            "$([Uri]::EscapeDataString($key))=$([Uri]::EscapeDataString([string]$QueryParams[$key]))"
        }
        $queryString = '?' + ($parts -join '&')
    }

    $fullUri = $BaseUri.TrimEnd('/') + $Endpoint + $queryString
    Write-Verbose " --> $Method $fullUri"

    $attempt = 0
    $waitSecs = 1

    while ($true) {
        $attempt++
        try {
            $response = Invoke-RestMethod -Uri $fullUri -Method $Method -Headers $Headers -ErrorAction Stop
            return $response
        }
        catch {
            $statusCode = $null
            if ($_.Exception.Response) {
                $statusCode = [int]$_.Exception.Response.StatusCode
            }

            # 401 - bad/expired token, no point retrying
            if ($statusCode -eq 401) {
                throw "N-Central API returned 401 Unauthorized for $fullUri. " +
                "Your access token may have expired - re-run the script to obtain a fresh one."
            }

            # 404 - resource not found, return null so callers can handle gracefully
            if ($statusCode -eq 404) {
                Write-Verbose " 404 Not Found: $fullUri - returning null"
                return $null
            }

            # 429 or 5xx - retry with backoff
            if ($statusCode -eq 429 -or ($statusCode -ge 500 -and $statusCode -le 599)) {
                if ($attempt -ge $MaxRetries) {
                    throw "N-Central API $statusCode on $fullUri after $MaxRetries attempts: $_"
                }
                Write-Verbose " $statusCode received - waiting ${waitSecs}s before retry $attempt/$MaxRetries"
                Start-Sleep -Seconds $waitSecs
                $waitSecs = $waitSecs * 2
                continue
            }

            # Any other error - throw immediately
            throw "N-Central API request failed ($statusCode) for $fullUri : $_"
        }
    }
}

function Get-NCPagedResults {
    <#
    .SYNOPSIS
        Retrieves all pages of a paginated N-Central API endpoint.
    .DESCRIPTION
        Loops through pages starting at pageNumber=1, collecting items from .data,
        until the total collected equals .totalItems (or no items returned).
 
        Returns a flat array of all items across all pages.
 
        NOTE: The pagination envelope fields (.data / .totalItems) are strictly
        mapped according to the N-Central OpenAPI specification.
    .PARAMETER BaseUri
        Base URL including protocol.
    .PARAMETER Endpoint
        API path.
    .PARAMETER Headers
        Hashtable containing Authorization header.
    .PARAMETER QueryParams
        Additional query parameters (pageSize and pageNumber are added automatically).
    .PARAMETER PageSize
        Items per page. Defaults to 100.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$BaseUri,

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

        [Parameter(Mandatory)]
        [hashtable]$Headers,

        [hashtable]$QueryParams = @{},

        [ValidateRange(1, 1000)]
        [int]$PageSize = 100,

        [int]$MaxPages = 500
    )

    $allItems = [System.Collections.Generic.List[object]]::new()
    $pageNumber = 1
    $firstPage = $true

    do {
        $params = $QueryParams.Clone()
        $params['pageSize'] = $PageSize
        $params['pageNumber'] = $pageNumber

        $response = Invoke-NCRestMethod -BaseUri $BaseUri -Endpoint $Endpoint `
            -Headers $Headers -QueryParams $params

        if ($null -eq $response) {
            Write-Verbose " Paged call returned null at page $pageNumber - stopping."
            break
        }

        # Log raw shape on first page so field names can be confirmed
        if ($firstPage) {
            Write-Verbose " First-page raw response: $($response | ConvertTo-Json -Depth 4 -Compress -WarningAction SilentlyContinue)"
            $firstPage = $false
        }

        # In Pester Mocks, the result is sometimes wrapped in a 1-element object array.
        # In the real world Invoke-RestMethod returns PSCustomObject directly.
        if ($response -is [array] -and $response.Count -eq 1 -and $response[0].PSObject.Properties.Match('data').Count -gt 0) {
            $response = $response[0]
        }

        $hasData = [bool]($response.PSObject.Properties.Match('data').Count -gt 0)

        if ($hasData) {
            # Unwrap items using strictly defined schema .data
            $items = $response.PSObject.Properties['data'].Value
            $totalProp = $response.PSObject.Properties['totalItems']
            $totalItems = if ($null -ne $totalProp) { $totalProp.Value } else { $null }
        }
        elseif ($response -is [array]) {
            # Handle raw array gracefully
            $items = $response
            $totalItems = $null
        }
        else {
            # Handle plain objects gracefully
            $items = @($response)
            $totalItems = $null
        }

        if ($items -isnot [array] -and $items -isnot [System.Collections.IEnumerable]) {
            # Single object returned
            if ($null -ne $items) { $items = @($items) }
            else { $items = @() }
        }

        $itemArray = @($items)
        if ($itemArray.Count -eq 0) {
            Write-Verbose " No items on page $pageNumber - stopping pagination."
            break
        }

        foreach ($item in $itemArray) { $allItems.Add($item) }

        Write-Verbose " Page $pageNumber - collected $($allItems.Count) of $totalItems total"

        if ($null -ne $totalItems -and $allItems.Count -ge $totalItems) { break }
        if ($itemArray.Count -lt $PageSize) { break }  # Last page was partial
        if ($pageNumber -ge $MaxPages) {
            Write-Warning "Get-NCPagedResults: reached MaxPages ($MaxPages) for $Endpoint - stopping to prevent runaway loop."
            break
        }

        $pageNumber++

    } while ($true)

    Write-Verbose " Total items collected from $($Endpoint): $($allItems.Count)"
    return $allItems.ToArray()
}