Modules/Private/80-ProgressDisplay.ps1

# Issue #76 — Spectre.Console TUI progress display
#
# Provides a live progress bar display during collector execution using
# PwshSpectreConsole (wrapper around Spectre.Console).
#
# Design constraints:
# • PwshSpectreConsole is an optional dependency — all functions degrade
# gracefully to no-op / Write-Progress when the module is absent.
# • TUI is suppressed automatically in non-interactive sessions (CI, scheduled
# tasks, Unattended mode) to avoid garbled terminal output.
# • The progress host runs in the caller's thread via Add-SpectreJob callbacks;
# no background runspaces are created.

function Test-RangerSpectreAvailable {
    <#
    .SYNOPSIS
        Returns $true when PwshSpectreConsole is importable and the session is interactive.
    #>

    param(
        [switch]$Force   # skip interactivity check (for testing)
    )

    # Non-interactive guard: suppress TUI in CI, scheduled tasks, or Unattended runs
    if (-not $Force) {
        $isCI = [bool]($env:CI -or $env:TF_BUILD -or $env:GITHUB_ACTIONS -or $env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI)
        if ($isCI) { return $false }
        if (-not [Environment]::UserInteractive) { return $false }
        # No host / dumb terminal
        if ($null -eq $Host -or $Host.Name -eq 'Default Host' -or $Host.Name -eq 'ServerRemoteHost') { return $false }
    }

    return (Get-Module -Name 'PwshSpectreConsole' -ListAvailable -ErrorAction SilentlyContinue) -as [bool]
}

function Import-RangerSpectreConsole {
    <#
    .SYNOPSIS
        Imports PwshSpectreConsole if available; returns $true on success.
    #>

    if (Get-Module -Name 'PwshSpectreConsole' -ErrorAction SilentlyContinue) {
        return $true
    }
    try {
        Import-Module -Name 'PwshSpectreConsole' -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
        return $true
    }
    catch {
        return $false
    }
}

function New-RangerProgressContext {
    <#
    .SYNOPSIS
        Returns a progress context object used by the collector loop.
    .DESCRIPTION
        When Spectre is available: returns a hashtable with a live progress table.
        When Spectre is absent: returns a hashtable in 'fallback' mode that
                                   delegates to Write-Progress.
    .OUTPUTS
        Ordered hashtable with keys: Mode, Total, Completed, Tasks
    #>

    param(
        [Parameter(Mandatory = $true)]
        [object[]]$Collectors,

        [switch]$Force
    )

    $ctx = [ordered]@{
        Mode      = 'none'
        Total     = $Collectors.Count
        Completed = 0
        Tasks     = [ordered]@{}
    }

    if (-not (Test-RangerSpectreAvailable -Force:$Force)) {
        $ctx.Mode = 'fallback'
        Write-Progress -Activity 'AzureLocalRanger' -Status "Starting $($Collectors.Count) collectors…" -PercentComplete 0
        return $ctx
    }

    if (-not (Import-RangerSpectreConsole)) {
        $ctx.Mode = 'fallback'
        return $ctx
    }

    # Build a Spectre progress table row for each collector
    try {
        $spectreRows = @($Collectors | ForEach-Object {
            [ordered]@{
                Id     = $_.Id
                Label  = $_.Id
                Status = 'pending'
            }
        })
        $ctx.Mode  = 'spectre'
        $ctx.Tasks = $spectreRows
    }
    catch {
        # Spectre initialisation failed — fall back silently
        $ctx.Mode = 'fallback'
    }

    return $ctx
}

function Update-RangerProgressCollectorStart {
    <#
    .SYNOPSIS
        Marks a collector as running in the progress display.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [object]$Context,

        [Parameter(Mandatory = $true)]
        [string]$CollectorId
    )

    if ($Context.Mode -eq 'none') { return }

    if ($Context.Mode -eq 'spectre') {
        try {
            $row = @($Context.Tasks | Where-Object { $_.Id -eq $CollectorId })[0]
            if ($row) { $row.Status = 'running' }
            $pct = [int](($Context.Completed / $Context.Total) * 100)
            Write-SpectreHost "[grey]Collecting:[/] [cyan]$CollectorId[/]…" -NoNewline
            $null = $pct
        }
        catch { }
        return
    }

    # fallback: Write-Progress
    $pct  = [int](($Context.Completed / $Context.Total) * 100)
    Write-Progress -Activity 'AzureLocalRanger' -Status "Running: $CollectorId" -PercentComplete $pct
}

function Update-RangerProgressCollectorDone {
    <#
    .SYNOPSIS
        Marks a collector as complete (success / skipped / failed) in the progress display.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [object]$Context,

        [Parameter(Mandatory = $true)]
        [string]$CollectorId,

        [ValidateSet('success', 'partial', 'failed', 'skipped', 'not-applicable')]
        [string]$Status = 'success'
    )

    $Context.Completed = [int]$Context.Completed + 1

    if ($Context.Mode -eq 'none') { return }

    $statusColour = switch ($Status) {
        'success'        { 'green' }
        'partial'        { 'yellow' }
        'skipped'        { 'grey' }
        'not-applicable' { 'grey' }
        'failed'         { 'red' }
        default          { 'white' }
    }

    if ($Context.Mode -eq 'spectre') {
        try {
            $row = @($Context.Tasks | Where-Object { $_.Id -eq $CollectorId })[0]
            if ($row) { $row.Status = $Status }
            $pct = [int](($Context.Completed / $Context.Total) * 100)
            Write-SpectreHost " [$statusColour]$Status[/]"
            _ = $pct
        }
        catch { }
        return
    }

    # fallback: Write-Progress
    $pct = [int](($Context.Completed / $Context.Total) * 100)
    Write-Progress -Activity 'AzureLocalRanger' -Status "Done: $CollectorId ($Status)" -PercentComplete $pct
}

function Complete-RangerProgressDisplay {
    <#
    .SYNOPSIS
        Finalises and tears down the progress display.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [object]$Context
    )

    if ($Context.Mode -eq 'none') { return }

    if ($Context.Mode -eq 'fallback') {
        Write-Progress -Activity 'AzureLocalRanger' -Completed
        return
    }

    # spectre: print summary line
    try {
        $failed  = @($Context.Tasks | Where-Object { $_.Status -eq 'failed' }).Count
        $skipped = @($Context.Tasks | Where-Object { $_.Status -in @('skipped', 'not-applicable') }).Count
        $ok      = @($Context.Tasks | Where-Object { $_.Status -in @('success', 'partial') }).Count
        $total   = $Context.Total

        $summaryParts = @("[green]$ok ok[/]")
        if ($skipped -gt 0) { $summaryParts += "[grey]$skipped skipped[/]" }
        if ($failed  -gt 0) { $summaryParts += "[red]$failed failed[/]" }
        Write-SpectreHost "Collectors: $($summaryParts -join ' ') [grey]($total total)[/]"
    }
    catch { }
}

function Write-RangerProgressSummary {
    <#
    .SYNOPSIS
        Writes the final run summary to the console using Spectre markup when available.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest,

        [object]$Context
    )

    $posture    = $Manifest.run.connectivity.posture
    $pkgId      = Split-Path -Leaf (Split-Path -Parent $Manifest.run.startTimeUtc)
    $findingCnt = @($Manifest.findings).Count
    $warnCnt    = @($Manifest.findings | Where-Object { $_.severity -eq 'warning' }).Count
    $critCnt    = @($Manifest.findings | Where-Object { $_.severity -eq 'critical' }).Count

    if ($null -ne $Context -and $Context.Mode -eq 'spectre') {
        try {
            Write-SpectreRule -Title '[bold]AzureLocalRanger Run Complete[/]' -Color 'blue'
            Write-SpectreHost " Posture : [cyan]$posture[/]"
            Write-SpectreHost " Findings : [yellow]$findingCnt[/] ([red]$critCnt critical[/], [yellow]$warnCnt warning[/])"
        }
        catch { }
    }
    else {
        Write-RangerLog -Level info -Message "Run complete — posture: $posture, findings: $findingCnt (critical: $critCnt, warning: $warnCnt)"
    }
}