Private/Get-SPCGraphSiteUsageInternal.ps1

function Get-SPCGraphSiteUsageInternal {
    <#
    .SYNOPSIS
        Retrieves SharePoint site usage metrics via Microsoft Graph REST API.
    .DESCRIPTION
        Queries /reports/getSharePointSiteUsageDetail with automatic HTTP 429 exponential backoff and jitter.
    .OUTPUTS
        [System.Collections.Generic.List[PSCustomObject]]
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [ValidateSet('D7', 'D30', 'D90', 'D180')]
        [string]$Period = 'D180'
    )

    $endpoint = "https://graph.microsoft.com/v1.0/reports/getSharePointSiteUsageDetail(period='$Period')"
    $token = $script:SPCContext.GraphAccessToken
    $headers = @{
        'Authorization'    = "Bearer $token"
        'ConsistencyLevel' = 'eventual'
    }

    $maxRetries = 5
    $retryCount = 0
    $backoffSec = 2

    while ($retryCount -lt $maxRetries) {
        try {
            Write-Verbose "Querying Graph Site Usage Report: $endpoint"
            $csvResponse = Invoke-RestMethod -Uri $endpoint -Headers $headers -Method Get -ErrorAction Stop
            
            # Graph reports endpoint returns CSV content directly
            $rows = $csvResponse | ConvertFrom-Csv
            $result = [System.Collections.Generic.List[PSCustomObject]]::new()

            if ($null -ne $rows) {
                # Check if Site URLs are concealed due to M365 privacy settings
                $isConcealed = $false
                $firstRowWithId = $rows | Where-Object { -not [string]::IsNullOrWhiteSpace($_.'Site Id') } | Select-Object -First 1
                if ($firstRowWithId -and [string]::IsNullOrWhiteSpace($firstRowWithId.'Site URL')) {
                    $isConcealed = $true
                }

                $pnpSiteLookup = @{}
                if ($isConcealed -and $script:SPCContext.PnPContext) {
                    try {
                        Write-Verbose "Get-SPCGraphSiteUsageInternal: Detected M365 report privacy concealment. Resolving site URLs via PnP tenant query."
                        $tenantSites = Get-PnPTenantSite -Connection $script:SPCContext.PnPContext -ErrorAction SilentlyContinue
                        if ($tenantSites) {
                            foreach ($ts in $tenantSites) {
                                if ($ts.SiteId) {
                                    $pnpSiteLookup[$ts.SiteId.ToString().ToLower()] = $ts
                                }
                            }
                        }
                    } catch {
                        Write-Verbose "Get-SPCGraphSiteUsageInternal: PnP tenant site lookup for privacy concealment failed: $($_.Exception.Message)"
                    }
                }

                foreach ($r in $rows) {
                    $siteIdKey = if (-not [string]::IsNullOrWhiteSpace($r.'Site Id')) { $r.'Site Id'.ToString().ToLower() } else { $null }
                    $pnpMatch = if ($siteIdKey -and $pnpSiteLookup.ContainsKey($siteIdKey)) { $pnpSiteLookup[$siteIdKey] } else { $null }

                    $resolvedUrl = if (-not [string]::IsNullOrWhiteSpace($r.'Site URL')) {
                        $r.'Site URL'
                    } elseif ($pnpMatch) {
                        $pnpMatch.Url
                    } else {
                        $null
                    }

                    $resolvedTitle = if (-not [string]::IsNullOrWhiteSpace($r.'Site Title')) {
                        $r.'Site Title'
                    } elseif ($pnpMatch -and -not [string]::IsNullOrWhiteSpace($pnpMatch.Title)) {
                        $pnpMatch.Title
                    } elseif (-not [string]::IsNullOrWhiteSpace($r.'Owner Display Name')) {
                        $r.'Owner Display Name'
                    } else {
                        $null
                    }

                    $storageBytes = [int64]0
                    if ($null -ne $r.'Storage Used (Byte)') {
                        [int64]::TryParse($r.'Storage Used (Byte)', [ref]$storageBytes) | Out-Null
                    }
                    $storageMB = [Math]::Round(($storageBytes / 1MB), 2)

                    $fileCount = [int64]0
                    if ($null -ne $r.'File Count') {
                        [int64]::TryParse($r.'File Count', [ref]$fileCount) | Out-Null
                    }

                    $lastActivity = $null
                    if (-not [string]::IsNullOrWhiteSpace($r.'Last Activity Date')) {
                        $parsedDate = [datetime]::MinValue
                        if ([datetime]::TryParse($r.'Last Activity Date', [ref]$parsedDate)) {
                            $lastActivity = $parsedDate
                        }
                    }

                    $ownerUPN = if (-not [string]::IsNullOrWhiteSpace($r.'Site Owner Principal Name')) {
                        $r.'Site Owner Principal Name'
                    } elseif (-not [string]::IsNullOrWhiteSpace($r.'Owner Display Name')) {
                        $r.'Owner Display Name'
                    } elseif ($pnpMatch -and -not [string]::IsNullOrWhiteSpace($pnpMatch.Owner)) {
                        $pnpMatch.Owner
                    } else {
                        'Unknown'
                    }

                    $result.Add([PSCustomObject]@{
                        SiteUrl          = $resolvedUrl
                        SiteTitle        = $resolvedTitle
                        GroupId          = if (-not [string]::IsNullOrWhiteSpace($r.'Group ID')) { $r.'Group ID' } elseif ($pnpMatch) { $pnpMatch.GroupId } else { $null }
                        Template         = if (-not [string]::IsNullOrWhiteSpace($r.'Root Web Template')) { $r.'Root Web Template' } elseif ($pnpMatch) { $pnpMatch.Template } else { $null }
                        StorageUsedMB    = $storageMB
                        FileCount        = $fileCount
                        LastActivityDate = $lastActivity
                        LockState        = if (-not [string]::IsNullOrWhiteSpace($r.'Lock State')) { $r.'Lock State' } elseif ($pnpMatch) { $pnpMatch.LockState } else { $null }
                        OwnerUPN         = $ownerUPN
                        IsTeamSite       = (-not [string]::IsNullOrWhiteSpace($r.'Group ID') -or ($pnpMatch -and -not [string]::IsNullOrWhiteSpace($pnpMatch.GroupId) -and $pnpMatch.GroupId -ne [Guid]::Empty.ToString()))
                    })
                }
            }
            return $result
        }
        catch {
            $statusCode = $null
            if ($_.Exception -and $_.Exception.Response) {
                if ($_.Exception.Response.StatusCode) {
                    if ($_.Exception.Response.StatusCode.value__) {
                        $statusCode = $_.Exception.Response.StatusCode.value__
                    } else {
                        $statusCode = [int]$_.Exception.Response.StatusCode
                    }
                }
            }

            if ($statusCode -in @(429, 503)) {
                $retryCount++
                $retryAfter = $null
                if ($_.Exception.Response.Headers -and $_.Exception.Response.Headers['Retry-After']) {
                    $retryAfter = $_.Exception.Response.Headers['Retry-After']
                }
                $sleepDuration = if ($retryAfter) { [int]$retryAfter } else { $backoffSec }
                
                # Add jitter
                $jitter = (Get-Random -Minimum 100 -Maximum 1000) / 1000.0
                $totalSleep = $sleepDuration + $jitter

                Write-Warning "ERR-STO-104: Microsoft Graph API Throttled (HTTP $statusCode). Retrying in $totalSleep seconds (Attempt $retryCount of $maxRetries)..."
                Start-Sleep -Seconds $totalSleep
                $backoffSec *= 2
            } else {
                Write-Verbose "Get-SPCGraphSiteUsageInternal: Graph report request failed ($($_.Exception.Message)). Falling back to PnP Tenant Sites query."
                try {
                    $tenantSites = if (Get-Command -Name 'Get-PnPTenantSite' -Module PnP.PowerShell -ErrorAction SilentlyContinue) {
                        Get-PnPTenantSite -Connection $script:SPCContext.PnPContext -ErrorAction Stop
                    } else { @() }

                    $fallbackResult = [System.Collections.Generic.List[PSCustomObject]]::new()
                    foreach ($ts in $tenantSites) {
                        if ($ts.Template -like 'REDIRECTSITE#*' -or $ts.Template -like 'POINTPUBLISHINGHUB#*') {
                            continue
                        }
                        $mb = [Math]::Round(($ts.StorageUsageCurrent), 2)
                        $fallbackResult.Add([PSCustomObject]@{
                            SiteUrl          = $ts.Url
                            SiteTitle        = $ts.Title
                            GroupId          = $ts.GroupId
                            Template         = $ts.Template
                            StorageUsedMB    = $mb
                            FileCount        = 0
                            LastActivityDate = $ts.LastContentModifiedDate
                            LockState        = $ts.LockState
                            OwnerUPN         = $ts.Owner
                            IsTeamSite       = (-not [string]::IsNullOrWhiteSpace($ts.GroupId) -and $ts.GroupId -ne [Guid]::Empty.ToString())
                        })
                    }
                    return $fallbackResult
                }
                catch {
                    throw "ERR-STO-102: Failed to fetch Graph Site Usage Report and PnP fallback failed: $($_.Exception.Message)"
                }
            }
        }
    }
    throw "ERR-STO-104: Exceeded maximum retry attempts for Microsoft Graph Site Usage Report."
}