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
    )
    # 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, MFA, USERS, CA 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 }
    }

    # 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.
    #>

    [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'
            $null = (& $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
            } 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-HashToYN {
    <#
    .SYNOPSIS
    Converts boolean values in a hashtable/ordered-dictionary to Yes/No strings.
    Compatible shim for AsBuiltReport.Core's ConvertTo-HashToYN.
 
    .NOTES
    Param typed as [System.Collections.IDictionary] so it accepts BOTH
    [hashtable] (System.Collections.Hashtable) and [ordered]@{}
    (System.Collections.Specialized.OrderedDictionary) under PowerShell 5.1.
    Using [hashtable] alone rejects [ordered]@{} in PS5.1, causing booleans
    to pass through unconverted and trigger PScribo "Unexpected System.Boolean"
    warnings.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [System.Collections.IDictionary]$Hash
    )

    process {
        $Result = [ordered]@{}
        foreach ($Key in $Hash.Keys) {
            $Value = $Hash[$Key]
            if ($Value -is [bool]) {
                $Result[$Key] = if ($Value) { 'Yes' } else { 'No' }
            } elseif ($null -eq $Value -or $Value -eq '') {
                $Result[$Key] = '--'
            } else {
                $Result[$Key] = $Value
            }
        }
        return $Result
    }
}

function Export-EntraIDToExcel {
    <#
    .SYNOPSIS
    Exports one or more arrays of PSCustomObjects to a multi-sheet Excel workbook.
    .DESCRIPTION
    Uses the ImportExcel module to generate a formatted .xlsx report.
    Each key in the Sheets hashtable becomes a worksheet tab.
    .PARAMETER Sheets
    Ordered hashtable of SheetName => array of PSCustomObjects.
    .PARAMETER Path
    Full file path for the output .xlsx file (e.g. C:\Reports\EntraID.xlsx).
    .PARAMETER TenantId
    Tenant name used in the title row of each sheet.
    .EXAMPLE
    $Sheets = [ordered]@{
        'MFA Status' = $MfaData
        'Auth Methods' = $AuthMethodData
    }
    Export-EntraIDToExcel -Sheets $Sheets -Path 'C:\Reports\EntraID_MFA.xlsx' -TenantId 'contoso.com'
    #>

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

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

        [string]$TenantId = 'Unknown Tenant'
    )

    # Verify ImportExcel is available
    if (-not (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue)) {
        Write-Warning "ImportExcel module is not installed. Run 'Install-Module -Name ImportExcel -Force' to enable Excel exports."
        Write-Warning "Falling back to CSV export..."

        foreach ($SheetName in $Sheets.Keys) {
            $CsvPath = $Path -replace '\.xlsx$', "_$($SheetName -replace '\s','_').csv"
            $Sheets[$SheetName] | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
            Write-Host " - CSV exported: $CsvPath" -ForegroundColor Cyan
        }
        return
    }

    Import-Module ImportExcel -ErrorAction Stop

    # Remove existing file to avoid sheet conflicts
    if (Test-Path $Path) { Remove-Item $Path -Force }

    $SheetIndex = 0
    foreach ($SheetName in $Sheets.Keys) {
        $Data = $Sheets[$SheetName]
        if (-not $Data -or @($Data).Count -eq 0) {
            Write-TranscriptLog "Sheet '$SheetName' has no data -- skipping." 'WARNING' 'EXPORT'
            continue
        }

        $ExcelParams = @{
            Path          = $Path
            WorksheetName = $SheetName
            AutoSize      = $true
            AutoFilter    = $true
            FreezeTopRow  = $true
            BoldTopRow    = $true
            TableStyle    = 'Medium9'
            Append        = ($SheetIndex -gt 0)
            PassThru      = $false
        }

        $Data | Export-Excel @ExcelParams
        $SheetIndex++
        Write-Host " - Sheet '$SheetName' written ($(@($Data).Count) rows)." -ForegroundColor Cyan
    }

    Write-Host " - Excel report saved to: $Path" -ForegroundColor Green
    Write-TranscriptLog "Excel export complete: $Path" 'SUCCESS' 'EXPORT'
}

function Write-AbrSectionError {
    <#
    .SYNOPSIS
    Logs a section-level error and writes a placeholder Paragraph to the document.
    The Paragraph ensures the parent Section is never left empty, which would cause
    PScribo's Word exporter to crash with 'Index was outside the bounds of the array'.
 
    Enhanced: also captures full exception detail, inner exception, stack trace,
    and PowerShell call stack to a dedicated diagnostic log for faster debugging.
    #>

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

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

        # Pass $_ (the ErrorRecord) from a catch block for full diagnostic capture
        [System.Management.Automation.ErrorRecord]$ErrorRecord = $null
    )

    # Log to PScribo warning stream
    Write-PScriboMessage -IsWarning -Message "${Section}: ${Message}"

    # Write a visible placeholder to the document so the section is never empty.
    Paragraph "Unable to retrieve data for this section. Error: $Message"

    # Log to transcript
    Write-TranscriptLog "${Section}: ${Message}" 'ERROR' $Section

    # ---- Deep diagnostic capture ----
    $Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'
    $DiagLines = [System.Collections.Generic.List[string]]::new()
    $null = $DiagLines.Add("[$Timestamp] [SECTION-ERROR] [$Section]")
    $null = $DiagLines.Add(" Message : $Message")

    if ($ErrorRecord) {
        $null = $DiagLines.Add(" Exception : $($ErrorRecord.Exception.GetType().FullName)")
        $null = $DiagLines.Add(" ExMsg : $($ErrorRecord.Exception.Message)")
        if ($ErrorRecord.Exception.InnerException) {
            $null = $DiagLines.Add(" InnerExc : $($ErrorRecord.Exception.InnerException.Message)")
        }
        $null = $DiagLines.Add(" Category : $($ErrorRecord.CategoryInfo.Category)")
        $null = $DiagLines.Add(" TargetObj : $($ErrorRecord.TargetObject)")
        $null = $DiagLines.Add(" ScriptPos : $($ErrorRecord.InvocationInfo.PositionMessage -replace '\r?\n',' | ')")
        $null = $DiagLines.Add(" StackTrace:")
        foreach ($line in ($ErrorRecord.ScriptStackTrace -split "`n")) {
            $null = $DiagLines.Add(" $($line.Trim())")
        }
    }

    # PS call stack (always useful even without ErrorRecord)
    $null = $DiagLines.Add(" PSCallStack:")
    foreach ($frame in (Get-PSCallStack | Select-Object -Skip 1 -First 8)) {
        $null = $DiagLines.Add(" $($frame.Location) | $($frame.Command)")
    }
    $null = $DiagLines.Add("")

    # Write to structured error log
    try {
        $Entry = "[$Timestamp] [ERROR] [$Section] $Message"
        if ($script:ErrorLogPath) {
            Add-Content -Path $script:ErrorLogPath -Value ($DiagLines -join "`n") -ErrorAction SilentlyContinue
        }
        if ($script:ErrorLog) {
            $null = $script:ErrorLog.Add($Entry)
        }
        Write-Host " [ERR] $Entry" -ForegroundColor Red
        foreach ($dline in $DiagLines) {
            Write-Host " $dline" -ForegroundColor DarkRed
        }
    } catch { }
}

function Invoke-PScriboDocumentAudit {
    <#
    .SYNOPSIS
    Walks the live PScribo document object tree and reports every structural problem
    that can cause 'Index was outside the bounds of the array' during Word export.
 
    Call this AFTER all Section{} blocks are built but BEFORE New-AsBuiltReport
    hands off to PScribo's exporter.
 
    Problems detected:
      - Section with zero child elements (empty Section)
      - Section whose only child is another Section (no direct Paragraph/Table)
      - Table with zero rows
      - Table with zero columns
      - Paragraph with null/empty text
      - Any PScribo object with a null Id
 
    Output goes to the error log file AND to the host in bright yellow so it is
    impossible to miss in the transcript.
    #>

    [CmdletBinding()]
    param(
        # The PScribo Document object (accessible as $Document inside PScribo scriptblocks,
        # or pass the variable that New-AsBuiltReport returns / the $script:Document ref)
        [Parameter(Mandatory)]
        [object]$Document,

        [string]$LogPath = $script:ErrorLogPath
    )

    $Timestamp  = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    $Problems   = [System.Collections.Generic.List[string]]::new()
    $Checked    = 0

    function Audit-Node {
        param([object]$Node, [string]$Path)

        if ($null -eq $Node) { return }
        $script:Checked++

        $TypeName = $Node.GetType().Name
        $NodePath = if ($Path) { "$Path > $TypeName" } else { $TypeName }

        # Add name/title if available
        $Label = ''
        foreach ($prop in @('Id','Name','Number','Text')) {
            try {
                $val = $Node.$prop
                if ($val -and "$val".Trim()) { $Label = "[$prop=$val]"; break }
            } catch {}
        }
        $FullPath = "$NodePath $Label"

        # Check: null Id
        try {
            if ($null -eq $Node.Id -or "$($Node.Id)".Trim() -eq '') {
                $null = $Problems.Add(" [NULL-ID] $FullPath -- Object has null/empty Id (PScribo will crash on array lookup)")
            }
        } catch {}

        # Check: Table specifics
        if ($TypeName -like '*Table*') {
            try {
                $rows = $Node.Rows
                if ($null -eq $rows -or @($rows).Count -eq 0) {
                    $null = $Problems.Add(" [EMPTY-TABLE] $FullPath -- Table has ZERO rows")
                }
            } catch {}
            try {
                $cols = $Node.Columns
                if ($null -eq $cols -or @($cols).Count -eq 0) {
                    $null = $Problems.Add(" [NO-COLUMNS] $FullPath -- Table has ZERO columns")
                }
            } catch {}
        }

        # Check: Paragraph specifics
        if ($TypeName -like '*Paragraph*') {
            try {
                $runs = $Node.Runs
                $hasText = $false
                if ($runs) {
                    foreach ($r in $runs) {
                        if ($r.Text -and "$($r.Text)".Trim()) { $hasText = $true; break }
                    }
                }
                if (-not $hasText) {
                    try {
                        $txt = $Node.Text
                        if (-not ($txt -and "$txt".Trim())) {
                            $null = $Problems.Add(" [EMPTY-PARA] $FullPath -- Paragraph has no text content")
                        }
                    } catch {}
                }
            } catch {}
        }

        # Check: Section specifics
        if ($TypeName -like '*Section*') {
            try {
                $children = $Node.Elements
                if ($null -eq $children -or @($children).Count -eq 0) {
                    $null = $Problems.Add(" [EMPTY-SECT] $FullPath -- Section has ZERO child elements (PScribo Word exporter will crash)")
                } else {
                    # Recurse into children
                    foreach ($child in $children) {
                        Audit-Node -Node $child -Path $FullPath
                    }
                }
            } catch {
                # Try alternative property names PScribo uses
                try {
                    $children = $Node.Sections
                    if ($null -eq $children -or @($children).Count -eq 0) {
                        $null = $Problems.Add(" [EMPTY-SECT] $FullPath -- Section has ZERO child elements (PScribo Word exporter will crash)")
                    } else {
                        foreach ($child in $children) {
                            Audit-Node -Node $child -Path $FullPath
                        }
                    }
                } catch {}
            }
            return  # already recursed above
        }

        # Recurse into any child collection
        foreach ($prop in @('Elements','Sections','Rows','Columns','Runs')) {
            try {
                $children = $Node.$prop
                if ($children) {
                    foreach ($child in $children) {
                        Audit-Node -Node $child -Path $FullPath
                    }
                }
            } catch {}
        }
    }

    Write-Host "" -ForegroundColor Yellow
    Write-Host " [AUDIT] === PScribo Document Structure Audit ===" -ForegroundColor Yellow
    Write-Host " [AUDIT] Walking document tree..." -ForegroundColor Yellow

    # PScribo stores sections differently depending on version -- try common entry points
    $rootElements = $null
    foreach ($prop in @('Elements','Sections','Document','Structure')) {
        try {
            $val = $Document.$prop
            if ($val) { $rootElements = $val; break }
        } catch {}
    }

    if ($rootElements) {
        foreach ($elem in $rootElements) {
            Audit-Node -Node $elem -Path 'ROOT'
        }
    } else {
        # Try walking the document itself as the root
        Audit-Node -Node $Document -Path 'ROOT'
    }

    $summary = " [AUDIT] Checked $($script:Checked) nodes. Found $($Problems.Count) problem(s)."
    Write-Host $summary -ForegroundColor $(if ($Problems.Count -gt 0) { 'Red' } else { 'Green' })

    if ($Problems.Count -gt 0) {
        Write-Host " [AUDIT] PROBLEMS FOUND -- these will crash PScribo Word export:" -ForegroundColor Red
        foreach ($p in $Problems) {
            Write-Host $p -ForegroundColor Red
        }
    } else {
        Write-Host " [AUDIT] Document structure looks clean." -ForegroundColor Green
    }

    # Write to error log
    if ($LogPath) {
        $lines = @(
            "",
            "[$Timestamp] [AUDIT] === PScribo Document Structure Audit ===",
            $summary
        ) + $Problems + @("")
        try { Add-Content -Path $LogPath -Value ($lines -join "`n") -ErrorAction SilentlyContinue } catch {}
    }

    return $Problems
}


function Write-EntraDebugDiag {
    <#
    .SYNOPSIS
    Writes a structured diagnostic checkpoint to the debug/error log.
    Call at the start of each section to log Graph module load state,
    available cmdlets, and PowerShell version -- useful for diagnosing
    'command found in module but module could not be loaded' errors.
    #>

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

    $Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'
    $Lines = [System.Collections.Generic.List[string]]::new()
    $null = $Lines.Add("[$Timestamp] [DIAG] [$Section] === Section Diagnostic Checkpoint ===")

    # PowerShell version
    $null = $Lines.Add(" PSVersion : $($PSVersionTable.PSVersion) | Edition: $($PSVersionTable.PSEdition) | OS: $($PSVersionTable.OS)")

    # Loaded Graph modules
    $LoadedGraph = Get-Module -Name 'Microsoft.Graph*' | Select-Object Name, Version
    if ($LoadedGraph) {
        $null = $Lines.Add(" Loaded Graph modules ($(@($LoadedGraph).Count)):")
        foreach ($m in $LoadedGraph) { $null = $Lines.Add(" [LOADED] $($m.Name) v$($m.Version)") }
    } else {
        $null = $Lines.Add(" [WARN] No Microsoft.Graph* modules are currently loaded in session!")
    }

    # Available (not yet loaded) Graph modules
    $AvailGraph = Get-Module -ListAvailable -Name 'Microsoft.Graph*' |
        Group-Object Name | ForEach-Object { $_.Group | Sort-Object Version -Descending | Select-Object -First 1 } |
        Select-Object Name, Version
    $null = $Lines.Add(" Available Graph modules ($(@($AvailGraph).Count) unique names installed):")
    foreach ($m in ($AvailGraph | Sort-Object Name)) {
        $loaded = if (Get-Module -Name $m.Name) { '[LOADED]' } else { '[NOT LOADED]' }
        $null = $Lines.Add(" $loaded $($m.Name) v$($m.Version)")
    }

    # Graph context
    try {
        $ctx = Get-MgContext -ErrorAction SilentlyContinue
        if ($ctx) {
            $null = $Lines.Add(" Graph Context: TenantId=$($ctx.TenantId) | Account=$($ctx.Account) | Scopes=$($ctx.Scopes -join ', ')")
        } else {
            $null = $Lines.Add(" [WARN] No active Microsoft Graph context (not connected)")
        }
    } catch {
        $null = $Lines.Add(" [WARN] Could not retrieve Graph context: $($_.Exception.Message)")
    }

    $null = $Lines.Add("")

    # Write to error log file and host
    try {
        if ($script:ErrorLogPath) {
            Add-Content -Path $script:ErrorLogPath -Value ($Lines -join "`n") -ErrorAction SilentlyContinue
        }
        foreach ($line in $Lines) {
            Write-Host " $line" -ForegroundColor DarkCyan
        }
    } catch { }
}


# Note: New-AbrE8AssessmentTable and New-AbrCISAssessmentTable
# have been moved to Compliance-Helpers.ps1