Common/Show-CheckProgress.ps1

<#
.SYNOPSIS
    Real-time streaming progress display for M365 security checks.
.DESCRIPTION
    Provides real-time feedback as security checks complete. Uses a streaming
    approach — each check is printed as it finishes, flowing naturally with
    the rest of the console output (section headers, collector results, etc.).
 
    Uses Write-Progress for an always-visible progress bar that shows the
    current collector and overall completion percentage.
 
    Designed to be dot-sourced by Invoke-M365Assessment.ps1. Exposes two
    global functions (Update-CheckProgress, Update-ProgressStatus) that
    collectors call from Add-Setting to drive real-time updates.
.NOTES
    Author: Daren9m
#>


# ── Map registry collector names to section names and display labels ──
$script:CollectorSectionMap = @{
    'Entra'          = 'Identity'
    'CAEvaluator'    = 'Identity'
    'ExchangeOnline' = 'Email'
    'DNS'            = 'Email'
    'Defender'       = 'Security'
    'Compliance'     = 'Security'
    'StrykerReadiness' = 'Security'
    'Intune'         = 'Intune'
    'SharePoint'     = 'Collaboration'
    'Teams'          = 'Collaboration'
    'PowerBI'        = 'PowerBI'
}

$script:CollectorLabelMap = @{
    'Entra'          = 'Entra Security Config'
    'CAEvaluator'    = 'CA Policy Evaluation'
    'ExchangeOnline' = 'EXO Security Config'
    'DNS'            = 'DNS Security Config'
    'Defender'       = 'Defender Security Config'
    'Compliance'     = 'Compliance Security Config'
    'StrykerReadiness' = 'Stryker Incident Readiness'
    'Intune'         = 'Intune Security Config'
    'SharePoint'     = 'SharePoint Security Config'
    'Teams'          = 'Teams Security Config'
    'PowerBI'        = 'Power BI Security Config'
}

# Ordered list for consistent display
$script:CollectorOrder = @('Entra', 'CAEvaluator', 'ExchangeOnline', 'DNS', 'Defender', 'Compliance', 'StrykerReadiness', 'Intune', 'SharePoint', 'Teams', 'PowerBI')

function Initialize-CheckProgress {
    <#
    .SYNOPSIS
        Sets up global progress state and prints a summary of queued checks.
    .PARAMETER ControlRegistry
        Hashtable returned by Import-ControlRegistry.
    .PARAMETER ActiveSections
        Array of section names the user selected (e.g., 'Identity', 'Email').
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ControlRegistry,

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

    # Build ordered list of automated checks for active sections
    $checksByCollector = [ordered]@{}

    foreach ($collectorName in $script:CollectorOrder) {
        $section = $script:CollectorSectionMap[$collectorName]
        if ($section -notin $ActiveSections) { continue }

        $checks = $ControlRegistry.GetEnumerator() |
            Where-Object {
                $_.Key -ne '__cisReverseLookup' -and
                $_.Value.hasAutomatedCheck -eq $true -and
                $_.Value.collector -eq $collectorName
            } |
            ForEach-Object { $_.Value } |
            Sort-Object -Property checkId

        if (@($checks).Count -gt 0) {
            $checksByCollector[$collectorName] = @($checks)
        }
    }

    $totalChecks = ($checksByCollector.Values | ForEach-Object { $_.Count } | Measure-Object -Sum).Sum
    if (-not $totalChecks) { $totalChecks = 0 }

    # Build state
    $state = @{
        Completed         = 0
        Total             = $totalChecks
        CheckIds          = @{}      # checkId -> collector name (for validation)
        CountedIds        = @{}      # checkId -> $true (track first occurrence for counter)
        CurrentCollector  = ''
        CollectorCounts   = @{}      # collector -> total count
        CollectorDone     = @{}      # collector -> completed count
        PrintedHeaders    = @{}      # collector -> $true (header printed)
        LabelMap          = $script:CollectorLabelMap  # accessible from any scope via global state
    }

    # Populate check IDs and collector counts
    foreach ($collectorName in $checksByCollector.Keys) {
        $state.CollectorCounts[$collectorName] = $checksByCollector[$collectorName].Count
        $state.CollectorDone[$collectorName] = 0
        foreach ($c in $checksByCollector[$collectorName]) {
            $state.CheckIds[$c.checkId] = $collectorName
        }
    }

    $global:CheckProgressState = $state

    if ($totalChecks -eq 0) {
        Write-Host ''
        Write-Host ' No automated security checks queued for the selected sections.' -ForegroundColor DarkGray
        Write-Host ''
        return
    }

    # Print status legend so users know what the symbols mean
    Write-Host ''
    Write-Host ' Status Legend:' -ForegroundColor White
    Write-Host ' ' -NoNewline
    Write-Host "$([char]0x2713) Pass " -ForegroundColor Green -NoNewline
    Write-Host "$([char]0x2717) Fail " -ForegroundColor Red -NoNewline
    Write-Host '! Warning ' -ForegroundColor Yellow -NoNewline
    Write-Host '? Review ' -ForegroundColor Cyan -NoNewline
    Write-Host 'i Info' -ForegroundColor DarkGray

    # Print a compact summary of what's queued
    Write-Host ''
    Write-Host " Security Checks: $totalChecks queued across $($checksByCollector.Count) collectors" -ForegroundColor Cyan
    foreach ($collectorName in $checksByCollector.Keys) {
        $label = $script:CollectorLabelMap[$collectorName]
        $count = $checksByCollector[$collectorName].Count
        Write-Host " $([char]0x25B8) $label — $count checks" -ForegroundColor DarkGray
    }
    Write-Host ''

    # Start the Write-Progress bar
    Write-Progress -Activity 'M365 Security Assessment' -Status "0 / $totalChecks checks complete" -PercentComplete 0 -Id 1
}


function global:Update-CheckProgress {
    <#
    .SYNOPSIS
        Marks a single security check as complete in the progress display.
    .DESCRIPTION
        Called from Add-Setting inside each security config collector.
        Streams a colored line to the console and updates Write-Progress.
    #>

    param(
        [string]$CheckId,
        [string]$Setting,
        [string]$Status
    )

    $state = $global:CheckProgressState
    if (-not $state -or $state.Total -eq 0) { return }

    # Extract base CheckId (strip .N sub-number) for registry lookup and counting
    $baseCheckId = if ($CheckId -match '^(.+)\.\d+$') { $Matches[1] } else { $CheckId }

    if (-not $state.CheckIds.ContainsKey($baseCheckId)) { return }

    $collectorName = $state.CheckIds[$baseCheckId]

    # Only count unique base CheckIds toward progress (sub-numbered settings
    # share the same base, e.g., DEFENDER-ANTISPAM-001.1, .2, .3).
    $isFirstOccurrence = -not $state.CountedIds.ContainsKey($baseCheckId)
    if ($isFirstOccurrence) {
        $state.CountedIds[$baseCheckId] = $true
        $state.Completed++
        $state.CollectorDone[$collectorName]++
    }

    # Print collector sub-header on first check from this collector
    if (-not $state.PrintedHeaders[$collectorName]) {
        $state.PrintedHeaders[$collectorName] = $true
        $labelMap = if ($script:CollectorLabelMap) { $script:CollectorLabelMap } else { $global:CheckProgressState.LabelMap }
        $label = if ($labelMap) { $labelMap[$collectorName] } else { $collectorName }
        $count = $state.CollectorCounts[$collectorName]
        Write-Host " $([char]0x250C) $label ($count checks)" -ForegroundColor White
    }

    # ── Symbol + color by status ──
    $symbol = switch ($Status) {
        'Pass'    { [char]0x2713 }
        'Fail'    { [char]0x2717 }
        'Warning' { '!' }
        'Review'  { '?' }
        'Info'    { 'i' }
        default   { [char]0x2713 }
    }
    $color = switch ($Status) {
        'Pass'    { 'Green' }
        'Fail'    { 'Red' }
        'Warning' { 'Yellow' }
        'Review'  { 'Cyan' }
        'Info'    { 'DarkGray' }
        default   { 'White' }
    }

    # Truncate setting name for clean display
    $name = $Setting
    if ($name.Length -gt 44) { $name = $name.Substring(0, 41) + '...' }

    # Stream the check result line
    Write-Host " $([char]0x2502) " -ForegroundColor DarkGray -NoNewline
    Write-Host "$symbol " -ForegroundColor $color -NoNewline
    Write-Host "$($CheckId.PadRight(28)) $name" -ForegroundColor $color

    # Print collector footer when all unique checks in this collector are done
    $done = $state.CollectorDone[$collectorName]
    $total = $state.CollectorCounts[$collectorName]
    if ($done -eq $total) {
        Write-Host " $([char]0x2514) $done/$total complete" -ForegroundColor DarkGray
    }

    # Update Write-Progress bar (cap at 100%)
    $pct = [math]::Min(100, [math]::Round(($state.Completed / $state.Total) * 100))
    $statusText = "$($state.Completed) / $($state.Total) checks complete"
    Write-Progress -Activity 'M365 Security Assessment' -Status $statusText -PercentComplete $pct -Id 1
}


function global:Update-ProgressStatus {
    <#
    .SYNOPSIS
        Updates the Write-Progress bar with a verbose status message.
    #>

    param([string]$Message)

    $state = $global:CheckProgressState
    if (-not $state -or $state.Total -eq 0) { return }

    $pct = if ($state.Total -gt 0) { [math]::Round(($state.Completed / $state.Total) * 100) } else { 0 }
    Write-Progress -Activity 'M365 Security Assessment' -Status $Message -PercentComplete $pct -Id 1 -CurrentOperation "$($state.Completed) / $($state.Total) checks"
}


function Complete-CheckProgress {
    <#
    .SYNOPSIS
        Cleans up the progress display and global functions.
    #>

    [CmdletBinding()]
    param()

    $state = $global:CheckProgressState
    if ($state -and $state.Total -gt 0) {
        Write-Progress -Activity 'M365 Security Assessment' -Completed -Id 1
        Write-Host ''
        Write-Host " $([char]0x2713) All $($state.Total) security checks complete" -ForegroundColor Green
        Write-Host ''
    }

    # Clean up globals
    Remove-Item -Path 'Function:\Update-CheckProgress' -ErrorAction SilentlyContinue
    Remove-Item -Path 'Function:\Update-ProgressStatus' -ErrorAction SilentlyContinue
    Remove-Variable -Name CheckProgressState -Scope Global -ErrorAction SilentlyContinue
}