Private/Write-TTLog.ps1

function Write-TTLog {
    <#
    .SYNOPSIS
        Unified logging for all TenantToolbox cmdlets.
    .DESCRIPTION
        Writes a line with timestamp and level to the console and - if a log path was
        set via Connect-TenantToolbox - additionally to the file. ACTION entries are
        meant for traceable, state-changing steps (audit).
    #>

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

        [ValidateSet('INFO', 'WARN', 'ERROR', 'ACTION')]
        [string]$Level = 'INFO'
    )

    $ts   = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    $line = "[$ts] [$Level] $Message"

    if ($script:TTLogPath) {
        try { Add-Content -Path $script:TTLogPath -Value $line -Encoding utf8 }
        catch { Write-Warning "TenantToolbox: could not write log ($($script:TTLogPath)): $_" }
    }

    switch ($Level) {
        'ERROR'  { Write-Host $line -ForegroundColor Red }
        'WARN'   { Write-Host $line -ForegroundColor Yellow }
        'ACTION' { Write-Host $line -ForegroundColor Cyan }
        default  { Write-Verbose $line }
    }
}

function Assert-TTGraph {
    <#
    .SYNOPSIS
        Ensures a Graph connection exists - otherwise a clear error.
    #>

    [CmdletBinding()]
    param()

    if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) {
        throw "Microsoft Graph SDK is missing. Please run once: Install-Module Microsoft.Graph -Scope CurrentUser"
    }
    $ctx = $null
    try { $ctx = Get-MgContext } catch { }
    if (-not $ctx) {
        throw "No Graph connection. Please run first: Connect-TenantToolbox"
    }
}

function Invoke-TTGraph {
    <#
    .SYNOPSIS
        Single Graph request with automatic retry on throttling (429) and transient errors (503/504).
    .DESCRIPTION
        Wraps Invoke-MgGraphRequest with exponential backoff. Honors a Retry-After hint when present
        in the error, otherwise backs off 2, 4, 8, ... seconds (capped). Up to -MaxRetries attempts.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Uri,
        [string]$Method = 'GET',
        $Body,
        [int]$MaxRetries = 5
    )
    $attempt = 0
    while ($true) {
        try {
            $params = @{ Method = $Method; Uri = $Uri; OutputType = 'PSObject'; ErrorAction = 'Stop' }
            if ($null -ne $Body) { $params.Body = $Body }
            return Invoke-MgGraphRequest @params
        }
        catch {
            $msg = "$_"
            $throttled = $msg -match '429|Too Many Requests|throttl|503|504|Service Unavailable|Gateway Timeout'
            if (-not $throttled -or $attempt -ge $MaxRetries) { throw }
            $attempt++
            $wait = [math]::Min([math]::Pow(2, $attempt), 60)
            if ($msg -match 'Retry-After[:\s]+(\d+)') { $wait = [int]$Matches[1] }
            Write-TTLog -Level WARN -Message "Graph throttled/transient - retry $attempt/$MaxRetries in ${wait}s."
            Start-Sleep -Seconds $wait
        }
    }
}

function Get-TTGraphCollection {
    <#
    .SYNOPSIS
        Fetches a full Graph collection (with paging) via Invoke-MgGraphRequest.
    .DESCRIPTION
        Robust and independent of which Microsoft.Graph submodules are installed - only needs
        Microsoft.Graph.Authentication. Follows @odata.nextLink to the end, retries on throttling
        (429) and shows progress. Use -NoProgress to suppress the progress bar.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Uri, [string]$Activity = 'Reading from Microsoft Graph', [switch]$NoProgress)

    $items = New-Object System.Collections.Generic.List[object]
    $next = $Uri
    $page = 0
    while ($next) {
        $resp = Invoke-TTGraph -Uri $next
        if ($resp.value) { foreach ($v in $resp.value) { $items.Add($v) } }
        $next = $resp.'@odata.nextLink'
        $page++
        if (-not $NoProgress) { Write-Progress -Activity $Activity -Status "$($items.Count) items (page $page)" }
    }
    if (-not $NoProgress) { Write-Progress -Activity $Activity -Completed }
    , $items
}

function Assert-TTExchange {
    <#
    .SYNOPSIS
        Ensures an Exchange Online connection exists - otherwise a clear error.
    #>

    [CmdletBinding()]
    param()

    if (-not (Get-Command -Name Get-Mailbox -ErrorAction SilentlyContinue)) {
        throw "ExchangeOnlineManagement is missing or not connected. Please: Install-Module ExchangeOnlineManagement -Scope CurrentUser; then Connect-ExchangeOnline"
    }
    # Get-Mailbox without a session throws -> use as a connection test
    try { Get-EXOMailbox -ResultSize 1 -ErrorAction Stop | Out-Null }
    catch { throw "No active Exchange Online connection. Please: Connect-ExchangeOnline" }
}

function Assert-TTSpo {
    <#
    .SYNOPSIS
        Ensures a SharePoint Online (SPO Management Shell) connection exists.
    #>

    [CmdletBinding()]
    param()
    if (-not (Get-Command -Name Get-SPOSite -ErrorAction SilentlyContinue)) {
        throw "Microsoft.Online.SharePoint.PowerShell is missing or not connected. Please: Install-Module Microsoft.Online.SharePoint.PowerShell; then Connect-SPOService -Url https://<tenant>-admin.sharepoint.com"
    }
    try { Get-SPOTenant -ErrorAction Stop | Out-Null }
    catch { throw "No active SharePoint Online connection. Please: Connect-SPOService -Url https://<tenant>-admin.sharepoint.com" }
}