Private/ScanBridgeSetupStatus.ps1

# ScanBridge setup status dashboard: one read-only status object (Get-NSPScanBridgeSetupStatus),
# drawn above the setup menu by Show-NSPScanBridgeSetupStatus. Names and states only: no tenant or
# app IDs, thumbprints, addresses, paths or file names, so a screenshot is safe to share. A read
# that fails is Unknown, never No, and the verdict is never Ready while any line is Unknown.

$script:NSPScanBridgeTaskName='ScanBridge'
$script:NSPScanBridgeTaskPath='\NSP\'

function New-NSPSetupStatusLine {
    # Role: Success, Warning, Error or Muted (unknown / not applicable). Blocker: why unattended runs cannot work yet.
    param([Parameter(Mandatory)][string]$Label,[Parameter(Mandatory)][AllowEmptyString()][string]$Text,[string]$Role='Normal',[string]$Blocker,[switch]$Detail)
    [pscustomobject]@{Label=$Label;Text=$Text;Role=$Role;Blocker=$(if($Blocker){$Blocker}else{$null});Detail=[bool]$Detail}
}

function New-NSPSetupUnknownLine {
    # A failed read: shown Gray and blocking, with a generic reason rather than the exception text (which can carry paths).
    param([Parameter(Mandatory)][string]$Label,[Parameter(Mandatory)][string]$Reason,[switch]$Detail)
    New-NSPSetupStatusLine -Label $Label -Text "Unknown ($Reason)" -Role Muted -Blocker "$($Label.ToLowerInvariant()) unknown" -Detail:$Detail
}

function Get-NSPSetupStatusReason {
    param([Parameter(Mandatory)][object]$ErrorRecord)
    $exception=if($ErrorRecord -is [Management.Automation.ErrorRecord]){$ErrorRecord.Exception}else{$ErrorRecord}
    for($e=$exception; $e; $e=$e.InnerException) {
        if($e -is [UnauthorizedAccessException] -or $e.Message -match 'Access is denied|0x80070005') { return 'access denied' }
    }
    'read failed'
}

function Use-NSPSetupStatusModule {
    # Loads an installed or checked-out sibling for a status read. Never installs from the Gallery on a redraw.
    param([Parameter(Mandatory)][string]$Name,[Parameter(Mandatory)][version]$MinimumVersion)
    $previous=$env:NSP_NO_AUTOINSTALL
    $env:NSP_NO_AUTOINSTALL='1'
    try { Import-NSPToolkitModule -Name $Name -MinimumVersion $MinimumVersion; $true }
    catch { $false }
    finally { $env:NSP_NO_AUTOINSTALL=$previous }
}

function Test-NSPAclAllowsRead {
    # True when the ACL has an explicit Allow ACE granting Read to this SID. Group membership is not evaluated.
    param([Parameter(Mandatory)][Security.AccessControl.FileSystemSecurity]$Acl,[Parameter(Mandatory)][string]$Sid)
    $read=[Security.AccessControl.FileSystemRights]::Read
    foreach($rule in $Acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) {
        if($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -eq $Sid -and ($rule.FileSystemRights -band $read) -eq $read) { return $true }
    }
    $false
}

function Get-NSPScanBridgeTaskState {
    # The ScanBridge task through the Task Scheduler COM API (milliseconds; Get-ScheduledTask's CIM
    # query takes seconds, too slow for every redraw). $null when the task or its folder is absent.
    $service=New-Object -ComObject Schedule.Service
    $service.Connect()
    try { $task=$service.GetFolder($script:NSPScanBridgeTaskPath.TrimEnd('\')).GetTask($script:NSPScanBridgeTaskName) }
    catch { if($_.Exception.HResult -in @(0x80070002,0x80070003)) { return $null }; throw }
    $definition=$task.Definition
    $states=@{0='Unknown';1='Disabled';2='Queued';3='Ready';4='Running'}
    [pscustomobject]@{
        State=$(if($states.ContainsKey([int]$task.State)){$states[[int]$task.State]}else{'Unknown'})
        UserId=[string]$definition.Principal.UserId
        Actions=@(foreach($action in @($definition.Actions)) { if($action.Type -eq 0) { [pscustomobject]@{Execute=[string]$action.Path;Arguments=[string]$action.Arguments} } })
        LastRunTime=[datetime]$task.LastRunTime
        LastTaskResult=[int64]$task.LastTaskResult
    }
}

function Get-NSPScanBridgeSetupCertificate {
    <#
        Store, days left and task-account key read for one app certificate. Same lookup order as
        ScanBridge's private Get-NSPScanBridgeCertificateStatus (LocalMachine\My, then this
        console user's CurrentUser\My); duplicated until that check moves to NSP.M365.Graph.
        KeyReadable is $null when it cannot be decided here (no task account, user store, unreadable key).
    #>

    param([Parameter(Mandatory)][string]$Thumbprint,[string]$Sid,[DateTime]$NowUtc=[DateTime]::UtcNow)
    $result=[ordered]@{Store=$null;DaysRemaining=$null;KeyReadable=$null;KeyReason=$null}
    foreach($store in 'LocalMachine','CurrentUser') {
        $certificate=Get-Item -LiteralPath ("Cert:\$store\My\$Thumbprint") -ErrorAction SilentlyContinue
        if($certificate) { $result.Store=$store; break }
    }
    if(-not $certificate) { return [pscustomobject]$result }
    $result.DaysRemaining=[int][math]::Floor(($certificate.NotAfter.ToUniversalTime()-$NowUtc).TotalDays)
    if($result.Store -eq 'CurrentUser') { $result.KeyReason='in your user store only'; return [pscustomobject]$result }
    if(-not $certificate.HasPrivateKey) { $result.KeyReadable=$false; $result.KeyReason='no private key'; return [pscustomobject]$result }
    if(-not $Sid) { $result.KeyReason='no task account'; return [pscustomobject]$result }
    try {
        $rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($certificate)
        try {
            $name=if($rsa -is [Security.Cryptography.RSACng]){[string]$rsa.Key.UniqueName}elseif($rsa -is [Security.Cryptography.RSACryptoServiceProvider]){[string]$rsa.CspKeyContainerInfo.UniqueKeyContainerName}else{$null}
        } finally { if($rsa) { $rsa.Dispose() } }
        if(-not $name -or [IO.Path]::GetFileName($name) -ne $name) { $result.KeyReason='key not a software machine key'; return [pscustomobject]$result }
        $base=[Environment]::GetFolderPath('CommonApplicationData')
        $file=@('Microsoft\Crypto\Keys','Microsoft\Crypto\RSA\MachineKeys') | ForEach-Object { Join-Path (Join-Path $base $_) $name } | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
        if(-not $file) { $result.KeyReason='key file not found'; return [pscustomobject]$result }
        $result.KeyReadable=Test-NSPAclAllowsRead -Acl (Get-Acl -LiteralPath $file) -Sid $Sid
    } catch { $result.KeyReason=Get-NSPSetupStatusReason $_ }
    [pscustomobject]$result
}

function ConvertTo-NSPSetupCertificateLine {
    # NPS/PKI colors: under 30 days Red, under 90 Yellow. Missing or expired blocks; a key the task account cannot read blocks.
    param([Parameter(Mandatory)][string]$Label,[Parameter(Mandatory)][object]$Certificate,[string]$Prefix,[switch]$Detail)
    $parts=@(); if($Prefix) { $parts+=$Prefix }
    if(-not $Certificate.Store) { return New-NSPSetupStatusLine -Label $Label -Text (($parts+'certificate missing on this computer') -join ', ') -Role Error -Blocker "$($Label.ToLowerInvariant()) certificate missing" -Detail:$Detail }
    $days=$Certificate.DaysRemaining
    if($days -lt 0) { return New-NSPSetupStatusLine -Label $Label -Text (($parts+"certificate $($Certificate.Store), EXPIRED") -join ', ') -Role Error -Blocker "$($Label.ToLowerInvariant()) certificate expired" -Detail:$Detail }
    $role=if($days -lt 30){'Error'}elseif($days -lt 90){'Warning'}else{'Success'}
    $parts+="certificate $($Certificate.Store), $days days left"
    $blocker=$null
    if($Certificate.KeyReadable -eq $true) { $parts+='task account can read key' }
    elseif($Certificate.KeyReadable -eq $false) {
        $parts+="task account cannot read key$(if($Certificate.KeyReason){" ($($Certificate.KeyReason))"})"
        $role='Error'; $blocker="task account cannot read the $($Label.ToLowerInvariant()) key"
    } else {
        $parts+="key access unknown ($($Certificate.KeyReason))"
        if($role -eq 'Success') { $role='Muted' }
        $blocker="$($Label.ToLowerInvariant()) key access unknown"
    }
    New-NSPSetupStatusLine -Label $Label -Text ($parts -join ', ') -Role $role -Blocker $blocker -Detail:$Detail
}

function Get-NSPScanBridgeSysmonCoverage {
    # Source folders covered by a rule named RuleName in the loaded Sysmon configuration file: TargetFilename "begin with" values.
    param([Parameter(Mandatory)][xml]$Configuration,[Parameter(Mandatory)][string]$RuleName,[Parameter(Mandatory)][string[]]$Source)
    $prefixes=@(foreach($node in $Configuration.SelectNodes('//TargetFilename[@condition="begin with"]')) {
        $named=($node.GetAttribute('name') -eq $RuleName) -or ($node.ParentNode.LocalName -eq 'Rule' -and $node.ParentNode.GetAttribute('name') -eq $RuleName)
        if($named) { $node.InnerText.Trim().TrimEnd('\') }
    })
    @($Source | Where-Object { $s=$_.TrimEnd('\'); @($prefixes | Where-Object { $s -ieq $_ -or $s.StartsWith($_+'\',[StringComparison]::OrdinalIgnoreCase) }).Count }).Count
}

function Get-NSPScanBridgeSetupStatus {
    <#
        Read-only status for the setup screen. Settings-dependent lines say "settings not selected"
        without a readable ConfigurationPath. Each read is bounded to local state: no sign-in, no
        receipt validation or PDF hashing (the Health menu item keeps that).
    #>

    param([string]$ConfigurationPath)
    $lines=[Collections.Generic.List[object]]::new()
    $add={ param($line) foreach($l in @($line)) { $lines.Add($l) } }
    $notSelected={ param($label) New-NSPSetupStatusLine -Label $label -Text 'settings not selected' -Role Muted -Blocker 'settings not selected' }

    $config=$null; $configReason=$null
    if($ConfigurationPath -and (Test-Path -LiteralPath $ConfigurationPath -PathType Leaf)) {
        try { $config=Import-PowerShellDataFile -LiteralPath $ConfigurationPath -ErrorAction Stop } catch { $configReason='settings unreadable' }
    }

    # Helpers: ScanBridge, Graph and SDK in both all-users folders.
    try {
        $editions=@('WinPS','PS7'); $roots=@(Get-NSPAllUsersModuleRoot)
        $rows=@(for($i=0; $i -lt $roots.Count; $i++) {
            $best=@{}
            foreach($name in 'NSP.ScanBridge','NSP.M365.Graph','Microsoft.Graph.Authentication') { $best[$name]=@(Get-NSPAllUsersModuleVersion -Root $roots[$i] -Name $name | Sort-Object Version -Descending)[0] }
            $text=(@(foreach($pair in @(@('NSP.ScanBridge','ScanBridge'),@('NSP.M365.Graph','Graph'),@('Microsoft.Graph.Authentication','SDK'))) {
                if($best[$pair[0]]) { "$($pair[1]) $($best[$pair[0]].Version)" } else { "$($pair[1]) missing" }
            })) -join ', '
            $complete=$best['NSP.ScanBridge'] -and $best['Microsoft.Graph.Authentication'] -and $best['NSP.M365.Graph'] -and $best['NSP.M365.Graph'].Version -ge [version]'0.1.2'
            [pscustomobject]@{Edition=$(if($i -lt $editions.Count){$editions[$i]}else{"folder $($i+1)"});Text=$text;Complete=[bool]$complete}
        })
        $anyComplete=@($rows | Where-Object Complete).Count -gt 0
        $role=if(@($rows | Where-Object { -not $_.Complete }).Count -eq 0){'Success'}elseif($anyComplete){'Warning'}else{'Error'}
        $blocker=if($anyComplete){$null}else{'ScanBridge modules not installed for all users'}
        if(@($rows | Select-Object -ExpandProperty Text -Unique).Count -eq 1) { & $add (New-NSPSetupStatusLine -Label 'Helpers' -Text "$($rows[0].Text) ($(@($rows.Edition) -join ' and '))" -Role $role -Blocker $blocker) }
        else {
            & $add (New-NSPSetupStatusLine -Label 'Helpers' -Text $(if($role -eq 'Error'){'incomplete in every all-users folder'}else{'differ between editions'}) -Role $role -Blocker $blocker)
            foreach($row in $rows) { & $add (New-NSPSetupStatusLine -Label $row.Edition -Text $row.Text -Role $(if($row.Complete){'Success'}else{'Warning'}) -Detail) }
        }
    } catch { & $add (New-NSPSetupUnknownLine -Label 'Helpers' -Reason (Get-NSPSetupStatusReason $_)) }

    try {
        $pwsh=Find-NSPPowerShell7
        & $add $(if($pwsh){New-NSPSetupStatusLine -Label 'PowerShell 7' -Text "found ($(([string]$pwsh.Version -split ' ')[0]))" -Role Success}else{New-NSPSetupStatusLine -Label 'PowerShell 7' -Text 'missing' -Role Warning})
    } catch { & $add (New-NSPSetupUnknownLine -Label 'PowerShell 7' -Reason (Get-NSPSetupStatusReason $_)) }

    # Task first (quietly): its principal is the account the other lines check.
    $task=$null; $taskReason=$null
    try { $task=Get-NSPScanBridgeTaskState } catch { $taskReason=Get-NSPSetupStatusReason $_ }
    $saved=[string](Get-NSPScanBridgeAnswerTable).ServiceAccount
    $accountName=$null; $accountKind=$null
    $principal=if($task){$task.UserId}else{$null}
    $candidate=if($principal){$principal}elseif($saved){$saved}else{$null}
    if($candidate -in @('SYSTEM','NT AUTHORITY\SYSTEM','S-1-5-18')) { $accountKind='System'; $accountName='SYSTEM' }
    elseif($candidate) {
        $local=ConvertTo-NSPLocalAccountName -Account $candidate
        if($local) { $accountKind='Local'; $accountName=$local } else { $accountKind='Other'; $accountName=$candidate }
    }

    # Service account.
    $sid=$null
    if($accountKind -eq 'System') { $sid='S-1-5-18'; & $add (New-NSPSetupStatusLine -Label 'Service account' -Text 'SYSTEM' -Role Success) }
    elseif($accountKind -eq 'Local') {
        try {
            if(-not (Use-NSPSetupStatusModule -Name NSP.Win.LocalAccount -MinimumVersion 0.1.0)) { & $add (New-NSPSetupUnknownLine -Label 'Service account' -Reason 'NSP.Win.LocalAccount not available') }
            else {
                $account=Get-NSPLocalServiceAccount -Name $accountName
                if(-not $account) { & $add (New-NSPSetupStatusLine -Label 'Service account' -Text "$accountName - does not exist" -Role Error -Blocker 'service account missing') }
                else {
                    $sid=$account.SID
                    $right=try { Get-NSPLocalAccountRight -Name $accountName } catch { $null }
                    $rightText=if(-not $right){'batch-logon right unknown'}elseif($right.DirectDenyBatchLogon){'batch logon DENIED'}elseif($right.DirectBatchLogon){'batch logon granted'}else{'no batch-logon right'}
                    $role='Success'; $blocker=$null
                    if(-not $account.Enabled) { $role='Error'; $blocker='service account disabled' }
                    elseif(-not $right) { $role='Muted'; $blocker='service account batch-logon right unknown' }
                    elseif($right.DirectDenyBatchLogon -or -not $right.DirectBatchLogon) { $role='Error'; $blocker='service account lacks batch logon' }
                    & $add (New-NSPSetupStatusLine -Label 'Service account' -Text ("{0} - {1}, {2}" -f $accountName,$(if($account.Enabled){'enabled'}else{'DISABLED'}),$rightText) -Role $role -Blocker $blocker)
                }
            }
        } catch { & $add (New-NSPSetupUnknownLine -Label 'Service account' -Reason (Get-NSPSetupStatusReason $_)) }
    }
    elseif($accountKind -eq 'Other') { & $add (New-NSPSetupUnknownLine -Label 'Service account' -Reason 'not a local account; not checked here') }
    elseif($taskReason) { & $add (New-NSPSetupUnknownLine -Label 'Service account' -Reason "task $taskReason") }
    else { & $add (New-NSPSetupStatusLine -Label 'Service account' -Text 'not chosen' -Role Warning -Blocker 'service account not chosen') }

    # Routes.
    $routes=@()
    if(-not $config) { & $add $(if($configReason){New-NSPSetupUnknownLine -Label 'Routes' -Reason $configReason}else{& $notSelected 'Routes'}) }
    else {
        $routes=@($config.Routes | Where-Object { $_ })
        if(-not $routes.Count) { & $add (New-NSPSetupStatusLine -Label 'Routes' -Text 'none configured' -Role Error -Blocker 'no routes') }
        else {
            & $add (New-NSPSetupStatusLine -Label 'Routes' -Text "$($routes.Count) configured" -Role Normal)
            foreach($route in $routes) {
                $name=[string]$route.Name
                try {
                    $missing=@('Source','Spool','Archive' | Where-Object { (Test-NSPPlaceholderValue $route[$_]) -or -not (Test-Path -LiteralPath ([string]$route[$_]) -PathType Container) })
                    $destination=-not (Test-NSPPlaceholderValue $route.DriveId) -and -not (Test-NSPPlaceholderValue $route.FolderItemId)
                    $count={ param($kind) if($kind -in $missing){'?'}else{@(Get-ChildItem -LiteralPath ([string]$route[$kind]) -File -ErrorAction Stop).Count} }
                    $text=('{0}, {1}, queue {2}/{3}' -f $(if($missing.Count){"$($missing -join '/') missing"}else{'folders OK'}),$(if($destination){'destination set'}else{'NO destination'}),(& $count 'Source'),(& $count 'Spool'))
                    $blocker=if($missing.Count){"route $name folders missing"}elseif(-not $destination){"route $name has no destination"}else{$null}
                    & $add (New-NSPSetupStatusLine -Label $name -Text $text -Role $(if($blocker){'Error'}else{'Success'}) -Blocker $blocker -Detail)
                } catch { & $add (New-NSPSetupUnknownLine -Label $name -Reason (Get-NSPSetupStatusReason $_) -Detail) }
            }
        }
    }

    # Ingestion and alert apps.
    if(-not $config) { & $add $(if($configReason){New-NSPSetupUnknownLine -Label 'Ingestion app' -Reason $configReason}else{& $notSelected 'Ingestion app'}) }
    elseif(Test-NSPPlaceholderValue $config.Auth.CertificateThumbprint) { & $add (New-NSPSetupStatusLine -Label 'Ingestion app' -Text 'not configured' -Role Error -Blocker 'ingestion app not configured') }
    else {
        try { & $add (ConvertTo-NSPSetupCertificateLine -Label 'Ingestion app' -Certificate (Get-NSPScanBridgeSetupCertificate -Thumbprint ([string]$config.Auth.CertificateThumbprint) -Sid $sid)) }
        catch { & $add (New-NSPSetupUnknownLine -Label 'Ingestion app' -Reason (Get-NSPSetupStatusReason $_)) }
    }
    if(-not $config) { & $add $(if($configReason){New-NSPSetupUnknownLine -Label 'Alerts' -Reason $configReason}else{& $notSelected 'Alerts'}) }
    else {
        $alerts=$config.Alerts
        if(-not $alerts -or -not $alerts.Enabled) { & $add (New-NSPSetupStatusLine -Label 'Alerts' -Text 'disabled' -Role Warning) }
        else {
            $parts=@('enabled'); $role='Success'; $blocker=$null
            $tenantMatches=-not (Test-NSPPlaceholderValue $alerts.Auth.TenantId) -and [string]$alerts.Auth.TenantId -eq [string]$config.Auth.TenantId
            $parts+=$(if($tenantMatches){'tenant matches'}else{'tenant DIFFERS from ingestion'})
            if(-not $tenantMatches) { $role='Error'; $blocker='alert tenant differs from ingestion' }
            $addresses=-not (Test-NSPPlaceholderValue $alerts.From) -and -not (Test-NSPPlaceholderValue $alerts.To)
            if(-not $addresses) { $parts+='sender/recipient NOT set'; $role='Error'; if(-not $blocker){$blocker='alert sender/recipient not set'} }
            if(-not (Test-NSPPlaceholderValue $alerts.StateDirectory) -and -not (Test-Path -LiteralPath ([string]$alerts.StateDirectory) -PathType Container)) { $parts+='state folder missing'; if($role -eq 'Success'){$role='Warning'} }
            if(Test-NSPPlaceholderValue $alerts.Auth.CertificateThumbprint) {
                & $add (New-NSPSetupStatusLine -Label 'Alerts' -Text (($parts+'alert app not configured') -join ', ') -Role Error -Blocker $(if($blocker){$blocker}else{'alert app not configured'}))
            } else {
                $line=try { ConvertTo-NSPSetupCertificateLine -Label 'Alerts' -Prefix ($parts -join ', ') -Certificate (Get-NSPScanBridgeSetupCertificate -Thumbprint ([string]$alerts.Auth.CertificateThumbprint) -Sid $sid) } catch { New-NSPSetupUnknownLine -Label 'Alerts' -Reason (Get-NSPSetupStatusReason $_) }
                if($blocker) { $line.Role='Error'; $line.Blocker=$blocker }
                & $add $line
            }
            & $add (New-NSPSetupStatusLine -Label 'Mail' -Text 'send not verified here (use the setup test message)' -Role Muted -Detail)
        }
    }

    # Sysmon: optional (the periodic sweep still runs), so never blocking.
    try {
        if(-not (Use-NSPSetupStatusModule -Name NSP.Win.Sysmon -MinimumVersion 0.1.1)) { & $add (New-NSPSetupStatusLine -Label 'Sysmon' -Text 'Unknown (NSP.Win.Sysmon not available)' -Role Muted) }
        else {
            $sysmon=Get-NSPSysmonStatus
            if(-not $sysmon.Installed) { & $add (New-NSPSetupStatusLine -Label 'Sysmon' -Text 'not installed (periodic runs only)' -Role Warning) }
            else {
                $parts=@($(if($sysmon.Running){'running'}else{'NOT running'})); $role=if($sysmon.Running){'Success'}else{'Warning'}
                if(-not $config) { $parts+=$(if($configReason){"rule coverage unknown ($configReason)"}else{'settings not selected'}); if($role -eq 'Success'){$role='Muted'} }
                elseif(-not $sysmon.ConfigFile -or -not (Test-Path -LiteralPath ([string]$sysmon.ConfigFile) -PathType Leaf)) { $parts+='rule coverage unknown (configuration file not readable)'; if($role -eq 'Success'){$role='Muted'} }
                else {
                    $sources=@($routes | Where-Object { -not (Test-NSPPlaceholderValue $_.Source) } | ForEach-Object { [IO.Path]::GetFullPath([string]$_.Source) } | Select-Object -Unique)
                    $covered=Get-NSPScanBridgeSysmonCoverage -Configuration ([xml][IO.File]::ReadAllText([string]$sysmon.ConfigFile)) -RuleName $script:NSPScanBridgeSysmonRuleName -Source $sources
                    $parts+=$(if($covered){"ScanBridge rule covers $covered of $($sources.Count) Sources"}else{'no ScanBridge rule'})
                    if($covered -lt $sources.Count) { $role='Warning' }
                    if(-not (Test-NSPSysmonConfig -ConfigPath ([string]$sysmon.ConfigFile)).InSync) { $parts+='loaded configuration differs from its file'; $role='Warning' }
                }
                & $add (New-NSPSetupStatusLine -Label 'Sysmon' -Text ($parts -join ', ') -Role $role)
            }
        }
    } catch { & $add (New-NSPSetupStatusLine -Label 'Sysmon' -Text "Unknown ($(Get-NSPSetupStatusReason $_))" -Role Muted) }

    # Scheduled task.
    if($taskReason) { & $add (New-NSPSetupUnknownLine -Label 'Scheduled task' -Reason $taskReason) }
    elseif(-not $task) { & $add (New-NSPSetupStatusLine -Label 'Scheduled task' -Text 'not registered' -Role Error -Blocker 'task not registered') }
    else {
        $taskState=[string]$task.State
        $parts=@('registered',$(if($taskState -eq 'Disabled'){'DISABLED'}else{$taskState}))
        $role='Success'; $blocker=$null
        if($taskState -eq 'Disabled') { $role='Error'; $blocker='task disabled' }
        $hosts=@(@($task.Actions) | ForEach-Object { [Environment]::ExpandEnvironmentVariables([string]$_.Execute) } | Where-Object { $_ })
        if(@($hosts | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) }).Count) { $parts+='its PowerShell is MISSING'; $role='Error'; if(-not $blocker){$blocker='task PowerShell missing'} }
        $result=$task.LastTaskResult
        if($result -eq 267011 -or -not $task.LastRunTime -or $task.LastRunTime.Year -lt 2000) { $parts+='never run' }
        else { $parts+=('last result {0} ({1})' -f $result,$task.LastRunTime.ToString('yyyy-MM-dd HH:mm')); if($result -ne 0 -and $role -eq 'Success'){$role='Warning'} }
        if($saved -and $accountKind -ne 'System' -and $accountName -ine $saved) { $parts+='runs as a different account than the saved one'; if($role -eq 'Success'){$role='Warning'} }
        try {
            $installed=Resolve-NSPScanBridgeInstalledRunner
            $arguments=(@($task.Actions) | ForEach-Object { [string]$_.Arguments }) -join ' '
            if(-not $installed) { $parts+='ScanBridge not installed for all users'; $role='Error'; if(-not $blocker){$blocker='task runner not installed'} }
            elseif($arguments.IndexOf($installed.Runner,[StringComparison]::OrdinalIgnoreCase) -ge 0) { $parts+="runs installed $($installed.Version)" }
            else { $parts+='runner is not the installed copy'; if($role -eq 'Success'){$role='Warning'} }
        } catch { $parts+='runner unknown' }
        & $add (New-NSPSetupStatusLine -Label 'Scheduled task' -Text ($parts -join ', ') -Role $role -Blocker $blocker)
    }

    $first=@($lines | Where-Object Blocker | Select-Object -First 1)
    [pscustomobject]@{
        ConfigurationSelected=[bool]$config
        Lines=@($lines)
        Ready=(-not $first.Count)
        Reason=$(if($first.Count){$first[0].Blocker}else{$null})
    }
}

function Show-NSPScanBridgeSetupStatus {
    # Renders Get-NSPScanBridgeSetupStatus through NSP.Console: label column, colored status, verdict.
    param([Parameter(Mandatory)][object]$Status)
    foreach($line in $Status.Lines) {
        $label=if($line.Detail){" $($line.Label):"}else{"$($line.Label):"}
        Write-NSPConsoleSegment -Segment @(@($label,'Muted',27),@($line.Text,$line.Role))
    }
    Write-NSPConsoleLine ''
    $verdict=if($Status.Ready){@('Yes','Success')}else{@("No - $($Status.Reason)",'Error')}
    Write-NSPConsoleSegment -Segment @(@('Ready for unattended runs:','Strong',27),$verdict)
    Write-NSPConsoleLine (' MODE: {0}' -f $(if(Get-NSPDryRun){'DRY RUN - setup actions plan only'}else{'APPLY - setup actions change this computer or tenant after confirmation'})) -Role $(if(Get-NSPDryRun){'Warning'}else{'Muted'})
    Write-NSPConsoleLine ''
}