Src/Private/Helpers.ps1


function Write-AbrDebugTableObject {
    <#
    .SYNOPSIS
    Debug helper: logs any System.Boolean values found in a hashtable or pscustomobject
    before it reaches PScribo. Only active when TranscriptLogPath is set.
    Call immediately before $Obj.Add([pscustomobject]$inObj) to catch boolean sources.
    #>

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

        [Parameter(Mandatory, Position = 1)]
        [string]$Context
    )

    if (-not $script:TranscriptLogPath) { return }

    $boolKeys = @()
    if ($InputObject -is [System.Collections.Specialized.OrderedDictionary]) {
        foreach ($key in $InputObject.Keys) {
            if ($InputObject[$key] -is [System.Boolean]) {
                $boolKeys += "$key = $($InputObject[$key])"
            }
        }
    } elseif ($InputObject -is [pscustomobject]) {
        foreach ($prop in $InputObject.PSObject.Properties) {
            if ($prop.Value -is [System.Boolean]) {
                $boolKeys += "$($prop.Name) = $($prop.Value)"
            }
        }
    }

    if ($boolKeys.Count -gt 0) {
        $msg = "BOOLEAN DETECTED in [$Context]: $($boolKeys -join ' | ')"
        Write-TranscriptLog $msg 'WARNING' 'BOOL_DEBUG' | Out-Null
        Write-Host " [BOOL_DEBUG] $msg" -ForegroundColor Magenta
    }
}

function Invoke-Ternary {
    <#
    .SYNOPSIS
    PS5.1-safe ternary operator. Returns TrueValue if Condition is truthy, else FalseValue.
    Solves "The term 'if' is not recognized" errors that occur when if-expressions are used
    as hashtable values inside nested PScribo Section{} scriptblocks on Windows PowerShell 5.1.
    Usage: Invoke-Ternary ($x -gt 0) 'Yes' 'No'
    #>

    param (
        [Parameter(Mandatory, Position = 0)] [AllowNull()] $Condition,
        [Parameter(Mandatory, Position = 1)] [AllowNull()] $TrueValue,
        [Parameter(Mandatory, Position = 2)] [AllowNull()] $FalseValue
    )
    if ($Condition) { $TrueValue } else { $FalseValue }
}

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

    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory)]
        [string]$UserPrincipalName
    )
    # RFC-compliant UPN: localpart@domain.tld
    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, DLP, RETENTION 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 | Out-Null }
        'INFO'    { Write-Host " $Entry" }
        'SUCCESS' { Write-Host " $Entry" -ForegroundColor Green }
        'WARNING' { Write-PScriboMessage -IsWarning -Message $Entry | Out-Null }
        'ERROR'   { Write-Host " $Entry" -ForegroundColor Red }
    }

    # Append to transcript log file if path is set in script scope
    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.
    .DESCRIPTION
    Retries the given ScriptBlock up to MaxAttempts times. Waits DelaySeconds
    between attempts, doubling the delay on each retry (exponential back-off).
    Throws on final failure.
    #>

    [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 {
            Write-TranscriptLog "Attempt ${Attempt}/${MaxAttempts}: $OperationName" 'DEBUG' 'RETRY'
            & $ScriptBlock
            Write-TranscriptLog "Attempt ${Attempt}/${MaxAttempts} succeeded: $OperationName" 'DEBUG' 'RETRY'
            return
        } catch {
            if ($Attempt -lt $MaxAttempts) {
                Write-TranscriptLog "Attempt ${Attempt}/${MaxAttempts} failed for '$OperationName'. Retrying in ${CurrentDelay}s... Error: $($_.Exception.Message)" 'WARNING' 'RETRY'
                Start-Sleep -Seconds $CurrentDelay
                $CurrentDelay = $CurrentDelay * 2   # exponential back-off
            } else {
                Write-TranscriptLog "All ${MaxAttempts} attempts failed for '$OperationName'. Error: $($_.Exception.Message)" 'ERROR' 'RETRY'
                throw
            }
        }
    }
}

function Show-AbrDebugExecutionTime {
    <#
    .SYNOPSIS
    Tracks and displays execution time for report sections.
    Compatible shim for AsBuiltReport.Core's Show-AbrDebugExecutionTime.
    #>

    [CmdletBinding()]
    param (
        [switch]$Start,
        [switch]$End,
        [string]$TitleMessage
    )

    if ($Start) {
        $script:SectionTimers[$TitleMessage] = [System.Diagnostics.Stopwatch]::StartNew()
        Write-TranscriptLog "Starting section: $TitleMessage" 'DEBUG' 'TIMER'
    }

    if ($End) {
        if ($script:SectionTimers -and $script:SectionTimers.ContainsKey($TitleMessage)) {
            $script:SectionTimers[$TitleMessage].Stop()
            $Elapsed = $script:SectionTimers[$TitleMessage].Elapsed.TotalSeconds
            Write-TranscriptLog "Completed section: $TitleMessage (${Elapsed}s)" 'DEBUG' 'TIMER'
            $null = $script:SectionTimers.Remove($TitleMessage)
        }
    }
}


function ConvertTo-TextYN {
    [CmdletBinding()]
    [OutputType([String])]
    param (
        [Parameter(Position = 0, Mandatory)]
        [AllowNull()]
        $TEXT
    )
    # Explicitly handle System.Boolean objects (Exchange Online cmdlets return
    # [System.Boolean] rather than PowerShell native bool literals, which causes
    # PScribo to throw "Unexpected System.Boolean" warnings if not caught here).
    if ($TEXT -is [System.Boolean]) {
        if ($TEXT) { return 'Yes' } else { return 'No' }
    }
    switch ($TEXT) {
        $true  { return 'Yes' }
        $false { return 'No' }
        $null  { return '--' }
        ''     { return '--' }
        default { return [string]$TEXT }
    }
}

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

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param (
        [Parameter(Position = 0, Mandatory)]
        [AllowNull()]
        [AllowEmptyString()]
        [System.Collections.Specialized.OrderedDictionary] $TEXT
    )
    $result = [ordered] @{}
    foreach ($i in $TEXT.GetEnumerator()) {
        try {
            $result.add($i.Key, (ConvertTo-TextYN $i.Value))
        } catch {
            $result.add($i.Key, ($i.Value))
        }
    }
    if ($result) {
        $result
    } else { $TEXT }
}
function Write-AbrPurviewACSCCheck {
    <#
    .SYNOPSIS
    Renders an inline ACSC ISM / Essential Eight compliance check box
    directly beneath a report table (InfoLevel 3 only).
    .DESCRIPTION
        Outputs a compact two-column PScribo table showing each relevant ISM
        control ID, a plain-English description of what is being checked,
        and a Pass / Fail / Partial / Manual status. Colour-coded rows are
        applied when -EnableHealthCheck is active.

        Call this immediately after the Table command for each sub-section.
        Pass in an array of [ordered] hashtables, each with keys:
          ControlId - e.g. 'ISM-0271'
          E8 - e.g. 'E8 Backup ML1' or 'N/A'
          Description - Short description of the requirement
          Check - What was checked in Purview
          Status - 'Pass' | 'Fail' | 'Partial' | 'Manual'
    .PARAMETER Checks
        Array of [pscustomobject] check results.
    .PARAMETER TenantId
        Tenant identifier used in the table caption.
    .PARAMETER SectionName
        Label used in the table name, e.g. 'Sensitivity Labels'.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [object[]]$Checks,

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

        [Parameter(Mandatory)]
        [string]$SectionName
    )

    # Only render at InfoLevel 3+
    $MaxLevel = ($script:InfoLevel.PSObject.Properties.Value | Measure-Object -Maximum).Maximum
    if ($MaxLevel -lt 3) { return }

    $ACSCObj = [System.Collections.ArrayList]::new()
    foreach ($c in $Checks) {
        $ACSCObj.Add([pscustomobject][ordered]@{
            'Control'     = "$($c.ControlId)$(if ($c.E8 -and $c.E8 -ne 'N/A') { " / $($c.E8)" })"
            'Requirement' = $c.Description
            'Check'       = $c.Check
            'Status'      = $c.Status
        }) | Out-Null
    }

    if ($Healthcheck -and $script:HealthCheck.Purview.ACSC) {
        $ACSCObj | Where-Object { $_.Status -eq 'Fail' }    | Set-Style -Style Critical | Out-Null
        $ACSCObj | Where-Object { $_.Status -eq 'Partial' } | Set-Style -Style Warning  | Out-Null
        $ACSCObj | Where-Object { $_.Status -eq 'Manual' }  | Set-Style -Style Info     | Out-Null
    }

    BlankLine
    $ACSCTableParams = @{
        Name         = "ACSC ISM - $SectionName - $TenantId"
        List         = $false
        ColumnWidths = 20, 35, 30, 15
    }
    if ($script:Report.ShowTableCaptions) { $ACSCTableParams['Caption'] = "- $($ACSCTableParams.Name)" }
    $ACSCObj | Table @ACSCTableParams
}