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
    Writes a standardised error paragraph inside a PScribo section.
    Detects 403 Forbidden and surfaces either a licensing or permissions note
    depending on whether Graph scopes were fully confirmed at connection time.
    #>

    [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 available or not licensed on this tenant
            Paragraph " [!] '$Section' returned 403 Forbidden. This feature may not be licensed or provisioned on this tenant (e.g. Proactive Remediations requires Intune Plan 2 / Intune Suite). Skipping section."
            Write-TranscriptLog "'$Section' not available on this tenant (403 with confirmed scopes -- likely licensing)" 'WARNING' $Section
        } else {
            # Missing scopes + 403 = genuine permissions issue
            Paragraph " [!] Insufficient permissions for '$Section': the authenticated account requires 'Intune Service Administrator' or 'Global Administrator' role."
            Write-TranscriptLog "Insufficient permissions for '$Section': $Message" 'WARNING' $Section
        }
    } else {
        Paragraph " [!] Error collecting data for '$Section': $Message"
        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
    Writes a standardised 403-error paragraph inside a PScribo section.
    When Graph scopes are fully confirmed, interprets 403 as a licensing/feature
    availability issue rather than an RBAC permissions problem.
    #>

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

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

        [string]$Message = ''
    )
    if ($script:GraphScopesFullyConfirmed -eq $true) {
        # All scopes present + Global Admin + 403 = feature not licensed/provisioned on tenant
        Paragraph " [!] '$Section' is not available on this tenant (403 with all scopes confirmed). This feature may require a specific Intune licence (e.g. Intune Plan 2 / Intune Suite for Proactive Remediations, or Intune Plan 1 for scripts). Skipping section."
        Write-TranscriptLog "'$Section' not available on this tenant -- likely a licensing or feature provisioning issue" 'WARNING' $Section
    } else {
        $detail = if ($Message) { " ($Message)" } else { '' }
        Paragraph " [!] Insufficient permissions for '$Section'$detail. The authenticated account requires '$RequiredRole' role in Entra ID. Grant the role and re-run the report."
        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)
}