Src/Private/Helpers.ps1

function Test-UserPrincipalName {
    <#
    .SYNOPSIS
    Validates that a string is a properly formatted User Principal Name.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory)]
        [string]$UserPrincipalName
    )
    return ($UserPrincipalName -match '^[^@\s]+@[^@\s]+\.[^@\s]+$')
}

function Write-TranscriptLog {
    <#
    .SYNOPSIS
    Writes a structured log entry to the host and optionally to a transcript file.
    .DESCRIPTION
    Provides consistent log output across the module.
    Level values: DEBUG | INFO | SUCCESS | WARNING | ERROR
    Category is a short tag e.g. AUTH, COMPLIANCE, DEVICES etc.
    #>

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

        [Parameter(Position = 1)]
        [ValidateSet('DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR')]
        [string]$Level = 'INFO',

        [Parameter(Position = 2)]
        [string]$Category = 'GENERAL'
    )

    $Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    $Entry     = "[$Timestamp] [$Level] [$Category] $Message"

    $null = switch ($Level) {
        'DEBUG'   { Write-PScriboMessage -Message $Entry  }
        'INFO'    { Write-Host " $Entry" }
        'SUCCESS' { Write-Host " $Entry" -ForegroundColor Green }
        'WARNING' { Write-PScriboMessage -IsWarning -Message $Entry  }
        'ERROR'   { Write-Host " $Entry" -ForegroundColor Red }
    }

    if ($script:TranscriptLogPath) {
        try {
            Add-Content -Path $script:TranscriptLogPath -Value $Entry -ErrorAction SilentlyContinue | Out-Null
        } catch { }
    }
}

function Invoke-WithRetry {
    <#
    .SYNOPSIS
    Executes a ScriptBlock with configurable retry logic and exponential back-off.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [scriptblock]$ScriptBlock,

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

        [int]$MaxAttempts = 3,

        [int]$DelaySeconds = 5
    )

    $Attempt = 0
    $CurrentDelay = $DelaySeconds

    while ($Attempt -lt $MaxAttempts) {
        $Attempt++
        try {
            & $ScriptBlock
            return
        } catch {
            if ($Attempt -eq $MaxAttempts) {
                Write-Warning "[$OperationName] Failed after $MaxAttempts attempts: $($_.Exception.Message)"
                throw
            }
            Write-Host " [RETRY] $OperationName attempt $Attempt/$MaxAttempts failed. Retrying in ${CurrentDelay}s..." -ForegroundColor Yellow
            Start-Sleep -Seconds $CurrentDelay
            $CurrentDelay = [math]::Min($CurrentDelay * 2, 60)
        }
    }
}

function ConvertTo-HashToYN {
    <#
    .SYNOPSIS
    Converts boolean values in a hashtable to Yes/No strings for report display.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [hashtable]$InputHash
    )
    $Result = [ordered]@{}
    foreach ($Key in $InputHash.Keys) {
        $Val = $InputHash[$Key]
        if ($Val -is [bool]) {
            $Result[$Key] = if ($Val) { 'Yes' } else { 'No' }
        } elseif ($null -eq $Val) {
            $Result[$Key] = '--'
        } else {
            $Result[$Key] = $Val
        }
    }
    return $Result
}

function Show-AbrDebugExecutionTime {
    <#
    .SYNOPSIS
    Records and optionally displays section execution timing.
    #>

    [CmdletBinding()]
    param (
        [switch]$Start,
        [switch]$End,
        [string]$TitleMessage
    )
    if ($Start) {
        $script:SectionTimers[$TitleMessage] = [System.Diagnostics.Stopwatch]::StartNew()
        Write-AbrDebugLog "Section [$TitleMessage] started" 'DEBUG' 'TIMING'
    }
    if ($End -and $script:SectionTimers.ContainsKey($TitleMessage)) {
        $script:SectionTimers[$TitleMessage].Stop()
        $elapsed = $script:SectionTimers[$TitleMessage].Elapsed.TotalSeconds
        Write-AbrDebugLog "Section [$TitleMessage] completed in $([math]::Round($elapsed,2))s" 'INFO' 'TIMING'
    }
}

function Write-AbrSectionError {
    <#
    .SYNOPSIS
    Handles section errors — logs to console/transcript only; never writes to the document.
    403 Forbidden responses (licensing or permissions issues) are silently skipped in the
    report output. Genuine unexpected errors are also logged but not rendered in the document
    to avoid polluting output with stack-trace noise.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$Section,
        [Parameter(Mandatory)]
        [string]$Message
    )
    if ($Message -like '*Forbidden*' -or $Message -like '*403*') {
        if ($script:GraphScopesFullyConfirmed -eq $true) {
            # Scopes OK + 403 = feature not licensed or not provisioned on this tenant — skip silently
            Write-Host " [SKIP] '$Section' — 403 with confirmed scopes (feature likely not licensed on this tenant). Section omitted." -ForegroundColor DarkYellow
            Write-TranscriptLog "'$Section' not available on this tenant (403 with confirmed scopes -- likely licensing)" 'WARNING' $Section
        } else {
            # Missing scopes + 403 = permissions issue — skip silently
            Write-Host " [SKIP] '$Section' — 403 Forbidden (insufficient permissions). Requires 'Intune Service Administrator' or 'Global Administrator'. Section omitted." -ForegroundColor DarkYellow
            Write-TranscriptLog "Insufficient permissions for '$Section': $Message" 'WARNING' $Section
        }
    } else {
        # Unexpected error — log only, do not render in document
        Write-Host " [ERROR] '$Section': $Message" -ForegroundColor Red
        Write-TranscriptLog "Error in section '$Section': $Message" 'ERROR' $Section
    }
}

function Export-IntuneToExcel {
    <#
    .SYNOPSIS
    Exports collected Intune data sheets to an Excel workbook using ImportExcel.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [System.Collections.Specialized.OrderedDictionary]$Sheets,

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

        [string]$TenantId = ''
    )

    $ImportExcelAvailable = Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue
    if (-not $ImportExcelAvailable) {
        Write-Host ' - ImportExcel module not found. Skipping Excel export.' -ForegroundColor Yellow
        Write-TranscriptLog 'ImportExcel module not found. Excel export skipped.' 'WARNING' 'EXPORT'
        return
    }

    Import-Module ImportExcel -ErrorAction SilentlyContinue

    Write-Host " - Writing $($Sheets.Count) sheet(s) to: $Path" -ForegroundColor Cyan

    foreach ($SheetName in $Sheets.Keys) {
        $Data = $Sheets[$SheetName]
        if (-not $Data -or @($Data).Count -eq 0) { continue }

        try {
            $ExcelParams = @{
                Path          = $Path
                WorksheetName = $SheetName
                AutoSize      = $true
                AutoFilter    = $true
                BoldTopRow    = $true
                FreezeTopRow  = $true
                PassThru      = $false
            }

            if (Test-Path $Path) {
                $ExcelParams['Append'] = $true
            }

            $Data | Export-Excel @ExcelParams
            Write-Host " - Sheet '$SheetName' written ($(@($Data).Count) rows)" -ForegroundColor DarkGray
        } catch {
            Write-Warning " - Failed to write sheet '$SheetName': $($_.Exception.Message)"
        }
    }

    Write-Host " - Excel workbook saved: $Path" -ForegroundColor Green
    Write-TranscriptLog "Excel workbook saved: $Path" 'SUCCESS' 'EXPORT'
}

function Write-AbrPermissionError {
    <#
    .SYNOPSIS
    Handles 403-based section skips — logs to console/transcript only; never writes to the document.
    When Graph scopes are fully confirmed, interprets 403 as a licensing/feature availability
    issue rather than an RBAC permissions problem. Either way the section is omitted silently
    from the report output so the document is not cluttered with skip notices.
    #>

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

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

        [string]$Message = ''
    )
    if ($script:GraphScopesFullyConfirmed -eq $true) {
        # All scopes present + 403 = feature not licensed/provisioned on this tenant — skip silently
        Write-Host " [SKIP] '$Section' — 403 with all scopes confirmed (feature likely not licensed on this tenant). Section omitted." -ForegroundColor DarkYellow
        Write-TranscriptLog "'$Section' not available on this tenant -- likely a licensing or feature provisioning issue" 'WARNING' $Section
    } else {
        # Missing role + 403 = permissions issue — skip silently
        $detail = if ($Message) { " ($Message)" } else { '' }
        Write-Host " [SKIP] '$Section'$detail — requires '$RequiredRole' in Entra ID. Section omitted." -ForegroundColor DarkYellow
        Write-TranscriptLog "Insufficient permissions for '$Section' -- requires: $RequiredRole" 'WARNING' $Section
    }
}

function Test-AbrGraphForbidden {
    <#
    .SYNOPSIS
    Returns $true if an exception is a 403 Forbidden Graph API response.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )
    return ($ErrorRecord.Exception.Message -like '*Forbidden*' -or
            $ErrorRecord.Exception.Message -like '*403*')
}

function Get-IntuneExcelSheetEnabled {
    <#
    .SYNOPSIS
    Returns $true if a given Excel sheet is enabled in Options.ExcelExport.Sheets.
    Returns $false if ExcelExport.Enabled is globally false or the sheet key is set to false.
    Defaults to $true if the key is absent (backwards compatible with old config files).
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([string]$SheetKey)

    if (-not $script:ExcelEnabled) { return $false }
    $sheetsConfig = $script:Options.ExcelExport.Sheets
    if ($null -eq $sheetsConfig) { return $true }   # entire Sheets block absent = all enabled
    $val = $sheetsConfig.$SheetKey
    if ($null -eq $val) { return $true }             # specific key absent = enabled
    return ($val -eq $true)
}

function Get-IntuneBackupSectionEnabled {
    <#
    .SYNOPSIS
    Returns $true if a given backup section is enabled in Options.JsonBackup.IncludeSections.
    Returns $false if JsonBackup.Enabled is globally false or the section key is set to false.
    Defaults to $true if the key is absent (backwards compatible with old config files).
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([string]$SectionKey)

    if (-not $script:JsonBackupEnabled) { return $false }
    $sectionsConfig = $script:Options.JsonBackup.IncludeSections
    if ($null -eq $sectionsConfig) { return $true }
    $val = $sectionsConfig.$SectionKey
    if ($null -eq $val) { return $true }   # key absent = enabled
    return ($val -eq $true)
}