<#
.SYNOPSIS
    ShopDB-Flask installer - Stage 1: read-only preflight.

.DESCRIPTION
    Discovers everything the installer needs to know about this box and reports
    it. Makes NO changes: no installs, no config edits, no service restarts.
    Safe to run on a production server.

    Written for stock Windows PowerShell 5.1 (Windows Server ships it). No
    pwsh-only syntax, no external modules, no network access.

.PARAMETER SitePort
    The port the ShopDB site will listen on. Default 8090 (the runbook's example;
    the classic ASP site keeps 8080).

.PARAMETER AppRoot
    Intended install directory. Default C:\shopdb-flask.

.PARAMETER Json
    Emit machine-readable JSON instead of the human report. Later installer
    stages consume this.

.EXAMPLE
    powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1
    powershell -ExecutionPolicy Bypass -File .\shopdb-preflight.ps1 -Json > preflight.json

.NOTES
    Exit 0 = no blocking problems. Exit 1 = at least one FAIL.
#>

[CmdletBinding()]
param(
    [int]    $SitePort = 8090,
    [string] $AppRoot  = 'C:\shopdb-flask',
    # Needed so the port check can tell OUR site apart from a stranger's.
    [string] $SiteName = 'shopdb-flask',
    [switch] $Json,
    # Machine-readable output for a GUI caller: one record per line,
    #   STATUS|AREA|CHECK|DETAIL|FIX
    # The console rendering below aligns columns with padding spaces, which only
    # works in a fixed-width font at console width. A GUI must do its own layout,
    # so give it DATA and let it decide - do not make it parse a formatted table.
    # (-Json exists too, but Inno's Pascal Script has no JSON parser.)
    [switch] $Delimited
)

Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'

# --- result collection -------------------------------------------------------
# Every check appends one record. Status is PASS / WARN / FAIL / INFO.
$script:Results = New-Object System.Collections.ArrayList
$script:IisPresent = $false

function Add-Result {
    param(
        [string] $Area,
        [string] $Check,
        [ValidateSet('PASS','WARN','FAIL','INFO','SKIP')] [string] $Status,
        [string] $Detail,
        [string] $Fix = ''
    )
    $null = $script:Results.Add([PSCustomObject]@{
        Area   = $Area
        Check  = $Check
        Status = $Status
        Detail = $Detail
        Fix    = $Fix
    })
}

# Wrap a check so one failure cannot abort the whole run. On an unfamiliar box
# an unexpected exception is itself a finding, not a crash.
function Invoke-Check {
    param([string] $Area, [string] $Check, [scriptblock] $Body)
    try { & $Body }
    catch {
        Add-Result $Area $Check 'WARN' "check could not run: $($_.Exception.Message)" `
            'Report this output; the installer needs to handle this box shape.'
    }
}

# =============================================================================
# 1. Operator context
# =============================================================================

Invoke-Check 'System' 'Elevation' {
    $id  = [Security.Principal.WindowsIdentity]::GetCurrent()
    $adm = (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole(
               [Security.Principal.WindowsBuiltInRole]::Administrator)
    if ($adm) { Add-Result 'System' 'Elevation' 'PASS' 'running as Administrator' }
    else {
        Add-Result 'System' 'Elevation' 'FAIL' 'not elevated' `
            'Re-run PowerShell as Administrator. IIS and service changes require it.'
    }
}

Invoke-Check 'System' 'Windows version' {
    $os   = Get-CimInstance Win32_OperatingSystem
    $name = $os.Caption
    $ver  = $os.Version
    # ProductType: 1 = workstation, 2 = domain controller, 3 = server
    $isServer = ($os.ProductType -ne 1)
    $detail = "$name (build $ver), $(if ($isServer) {'Server'} else {'Client'})"

    $supported = $false
    if ($isServer -and [version]$ver -ge [version]'10.0.17763') { $supported = $true }  # 2019+
    if (-not $isServer -and [version]$ver -ge [version]'10.0.19045') { $supported = $true } # Win10 22H2+

    if ($supported) { Add-Result 'System' 'Windows version' 'PASS' $detail }
    else {
        Add-Result 'System' 'Windows version' 'FAIL' $detail `
            'Supported: Windows Server 2019/2022+, or Windows 10 22H2 / 11 Pro+.'
    }

    # Client SKUs must be Pro/Enterprise/Education for IIS.
    if (-not $isServer -and $name -match 'Home') {
        Add-Result 'System' 'Windows edition' 'FAIL' 'Windows Home edition' `
            'IIS is not available on Home editions. Pro or higher is required.'
    }
}

Invoke-Check 'System' 'Architecture' {
    if ([Environment]::Is64BitOperatingSystem) {
        Add-Result 'System' 'Architecture' 'PASS' '64-bit'
    } else {
        Add-Result 'System' 'Architecture' 'FAIL' '32-bit' `
            'The bundled Python and wheels are 64-bit (win_amd64) only.'
    }
}

Invoke-Check 'System' 'PowerShell version' {
    $v = $PSVersionTable.PSVersion
    Add-Result 'System' 'PowerShell version' 'INFO' "$v"
    if ($v.Major -lt 5) {
        Add-Result 'System' 'PowerShell version' 'FAIL' "$v" `
            'PowerShell 5.1 or later is required.'
    }
}

# =============================================================================
# 2. Disk and ports
# =============================================================================

Invoke-Check 'Disk' 'Free space' {
    $drive = (Split-Path -Qualifier $AppRoot)
    $d = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$drive'"
    if ($null -eq $d) {
        Add-Result 'Disk' 'Free space' 'FAIL' "drive $drive not found" `
            "Choose an -AppRoot on an existing volume."
        return
    }
    $freeGB = [math]::Round($d.FreeSpace / 1GB, 1)
    $detail = "$freeGB GB free on $drive"
    if ($freeGB -ge 5) { Add-Result 'Disk' 'Free space' 'PASS' $detail }
    else { Add-Result 'Disk' 'Free space' 'FAIL' $detail 'At least 5 GB is required.' }
}

Invoke-Check 'Disk' 'AppRoot' {
    if (Test-Path $AppRoot) {
        $existing = @(Get-ChildItem $AppRoot -Force -ErrorAction SilentlyContinue)
        if ($existing.Count -gt 0) {
            $hasEnv = Test-Path (Join-Path $AppRoot '.env')
            if ($hasEnv) {
                # An existing install is the NORMAL state for an upgrade. Reporting
                # it as a warning makes a routine update look like a problem.
                $ver = ''
                $vf = Join-Path $AppRoot '.installed-version'
                if (Test-Path $vf) { $ver = ' version ' + (Get-Content $vf -TotalCount 1).Trim() }
                Add-Result 'Disk' 'AppRoot' 'INFO' `
                    "existing ShopDB-Flask install found$ver - it will be upgraded in place, and your settings and database are kept"
            } else {
                Add-Result 'Disk' 'AppRoot' 'WARN' "$AppRoot exists and is not empty" `
                    'Confirm this directory is safe to install into.'
            }
        } else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot exists and is empty" }
    } else { Add-Result 'Disk' 'AppRoot' 'PASS' "$AppRoot does not exist yet" }
}

function Test-PortFree {
    param([int] $Port)
    # Get-NetTCPConnection is the reliable listener check on Server 2012R2+.
    try {
        $listening = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue)
        return ($listening.Count -eq 0)
    } catch {
        # Fall back to a bind attempt if the cmdlet is unavailable.
        try {
            $l = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Any, $Port)
            $l.Start(); $l.Stop(); return $true
        } catch { return $false }
    }
}

Invoke-Check 'Network' 'Site port' {
    if (Test-PortFree $SitePort) {
        Add-Result 'Network' 'Site port' 'PASS' "TCP $SitePort is free"
    } else {
        $owner = ''
        try {
            $c = Get-NetTCPConnection -State Listen -LocalPort $SitePort -ErrorAction SilentlyContinue | Select-Object -First 1
            if ($c) { $owner = " (pid $($c.OwningProcess): $((Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue).ProcessName))" }
        } catch { }
        # Is the listener OUR OWN site? On a reinstall or upgrade the port is held
        # by the very application being upgraded, and blocking on that makes the
        # installer refuse to update anything it previously installed.
        $ours = $false
        try {
            Import-Module WebAdministration -ErrorAction SilentlyContinue
            $site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
            if ($site) {
                foreach ($b in $site.bindings.Collection) {
                    # ${} is required: "$SitePort:" parses as a DRIVE-qualified variable.
                    if ($b.bindingInformation -match ":${SitePort}:") { $ours = $true }
                }
            }
        } catch { }

        if ($ours) {
            Add-Result 'Network' 'Site port' 'INFO' `
                "TCP $SitePort is used by the existing $SiteName site - this will be upgraded in place"
        } else {
            # WARN, not FAIL. This check runs before the operator has reached the
            # Address page, so it is testing the DEFAULT port, not necessarily
            # the one they intend to use. Blocking here would refuse an install
            # over a conflict the very next page lets them resolve.
            Add-Result 'Network' 'Site port' 'WARN' "TCP $SitePort is in use$owner" `
                "Pick a different port on the Address page later in this wizard, or stop whatever is holding it."
        }
    }
}

# =============================================================================
# 3. IIS
# =============================================================================

Invoke-Check 'IIS' 'Installed' {
    $svc = Get-Service -Name W3SVC -ErrorAction SilentlyContinue
    $script:IisPresent = ($null -ne $svc)
    if ($null -eq $svc) {
        Add-Result 'IIS' 'Installed' 'FAIL' 'W3SVC service not found' `
            'Install IIS. Server: Install-WindowsFeature Web-Server -IncludeManagementTools. Client: enable Internet Information Services in Windows Features.'
        return
    }
    Add-Result 'IIS' 'Installed' 'PASS' "W3SVC present, status $($svc.Status)"
    if ($svc.Status -ne 'Running') {
        Add-Result 'IIS' 'Running' 'WARN' "W3SVC is $($svc.Status)" 'Start-Service W3SVC'
    }
}

Invoke-Check 'IIS' 'WebAdministration module' {
    $m = Get-Module -ListAvailable -Name WebAdministration
    if ($m) { Add-Result 'IIS' 'WebAdministration module' 'PASS' 'available' }
    else {
        Add-Result 'IIS' 'WebAdministration module' 'FAIL' 'not available' `
            'Install the IIS management tools (Web-Mgmt-Console / IIS Management Scripts and Tools).'
    }
}

Invoke-Check 'IIS' 'HttpPlatformHandler' {
    # The handler registers itself as a global module. Check the module list.
    $appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
    if (-not (Test-Path $appcmd)) {
        Add-Result 'IIS' 'HttpPlatformHandler' 'SKIP' 'appcmd.exe not present (IIS not installed)' `
            'Re-run this preflight after installing IIS.'
        return
    }
    $modules = & $appcmd list module 2>$null
    if ($modules -match 'httpPlatformHandler') {
        Add-Result 'IIS' 'HttpPlatformHandler' 'PASS' 'installed'
    } else {
        # NOT a blocker: the MSI is in the bundle and stage 4 installs it. This
        # was a FAIL, which - once the preflight page started blocking on any
        # failure - stopped the wizard dead over something the installer was
        # about to do by itself, with no way forward but to go and install it by
        # hand. Nothing the installer SUPPLIES may be a blocker.
        Add-Result 'IIS' 'HttpPlatformHandler' 'INFO' 'not installed yet' `
            'The installer installs it from the bundle. No action needed.'
    }
}

Invoke-Check 'IIS' 'Locked config sections' {
    # The authoritative source is applicationHost.config. `appcmd list config
    # /section:X` prints the section CONTENTS, not its lock state, so grepping
    # that output silently reports every section as unlocked.
    $cfg = Join-Path $env:windir 'system32\inetsrv\config\applicationHost.config'
    if (-not (Test-Path $cfg)) {
        Add-Result 'IIS' 'Locked config sections' 'SKIP' 'applicationHost.config not found'
        return
    }
    foreach ($name in @('handlers','httpPlatform')) {
        $line = Select-String -Path $cfg -Pattern ('<section name="' + $name + '"') |
                Select-Object -First 1
        if ($null -eq $line) {
            # httpPlatform is registered by the HttpPlatformHandler MSI. Before
            # that, `appcmd unlock config /section:system.webServer/httpPlatform`
            # fails with "Unknown config section".
            Add-Result 'IIS' "Section $name" 'SKIP' 'section not registered yet' `
                'Install HttpPlatformHandler FIRST; only then can this section be unlocked.'
        } elseif ($line.Line -match 'overrideModeDefault="Deny"') {
            Add-Result 'IIS' "Section $name" 'WARN' 'locked (overrideModeDefault="Deny")' `
                "Installer must run: appcmd unlock config /section:system.webServer/$name  (else IIS 500.19)"
        } else {
            Add-Result 'IIS' "Section $name" 'PASS' 'not locked'
        }
    }
}

Invoke-Check 'IIS' 'URL Rewrite module' {
    $appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'
    if (-not (Test-Path $appcmd)) {
        Add-Result 'IIS' 'URL Rewrite module' 'SKIP' 'IIS not installed; cannot check'
        return
    }
    $modules = & $appcmd list module 2>$null
    if ($modules -match 'RewriteModule') {
        Add-Result 'IIS' 'URL Rewrite module' 'PASS' 'installed (the X-Forwarded-For rule can be enabled)'
    } else {
        # Not a FAIL: it is only needed for -ClientIpSource direct, and the
        # installer carries the MSI and installs it itself. Worth reporting
        # because without the rule IIS sends no X-Forwarded-For at all, so every
        # client reads as 127.0.0.1 and the GE-Enforce IP allowlist, the
        # visitor-location lookup and per-host login rate limiting go quiet.
        Add-Result 'IIS' 'URL Rewrite module' 'INFO' 'not installed' `
            'The installer installs it from the bundle when -ClientIpSource is direct. Behind a reverse proxy that already sets X-Forwarded-For, use -ClientIpSource proxy and leave it out.'
    }
}

Invoke-Check 'IIS' 'Existing sites' {
    if (-not $script:IisPresent) {
        Add-Result 'IIS' 'Existing sites' 'SKIP' 'IIS not installed'
        return
    }
    try {
        Import-Module WebAdministration -ErrorAction Stop
        $sites = @(Get-Website)
        if ($sites.Count -eq 0) { Add-Result 'IIS' 'Existing sites' 'INFO' 'none' ; return }
        $desc = ($sites | ForEach-Object {
            $b = ($_.bindings.Collection | ForEach-Object { $_.bindingInformation }) -join ','
            "$($_.Name) [$($_.State)] $b"
        }) -join '; '
        Add-Result 'IIS' 'Existing sites' 'INFO' $desc
        # Adoption sites typically run the classic ASP shopdb here already.
        if ($desc -match '8080') {
            Add-Result 'IIS' 'Classic ASP site' 'INFO' 'a site is bound on 8080 (likely the classic ASP shopdb)' `
                'Install ShopDB as a separate site on its own port; do not disturb this one.'
        }
    } catch {
        Add-Result 'IIS' 'Existing sites' 'WARN' "could not enumerate: $($_.Exception.Message)" ''
    }
}

# =============================================================================
# 4. MySQL  (detect BEFORE offering bundled vs existing)
# =============================================================================

Invoke-Check 'MySQL' 'Service' {
    $svcs = @(Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '^MySQL' -or $_.DisplayName -match 'MySQL' })
    if ($svcs.Count -eq 0) {
        Add-Result 'MySQL' 'Service' 'INFO' 'no MySQL service found' `
            'Bundled MySQL 8.4 LTS is the appropriate choice on this box.'
        return
    }
    foreach ($s in $svcs) {
        Add-Result 'MySQL' 'Service' 'WARN' "$($s.Name) ($($s.DisplayName)) is $($s.Status)" `
            'MySQL already present. Default to the EXISTING-server option; installing bundled MySQL will collide on port 3306.'
    }
}

Invoke-Check 'MySQL' 'Port 3306' {
    if (Test-PortFree 3306) {
        Add-Result 'MySQL' 'Port 3306' 'INFO' 'nothing listening on 3306'
    } else {
        Add-Result 'MySQL' 'Port 3306' 'WARN' 'something is listening on 3306' `
            'Bundled MySQL cannot use the default port. Use the existing server, or pick another port.'
    }
}

Invoke-Check 'MySQL' 'Backup client' {
    # mysqldump is what takes the mandatory pre-upgrade backup. Without it every
    # upgrade skips the backup - and skips it AFTER the application pool has been
    # stopped and the tree replaced, so the site is down and there is nothing to
    # restore from. A site whose database is on another server typically has no
    # MySQL client installed here at all, which is exactly the case that needs it.
    $names = @('mysqldump.exe')
    $found = ''
    foreach ($root in @((Join-Path $PSScriptRoot 'mysqlclient'),
                        (Join-Path $AppRoot 'mysqlclient'),
                        'C:\MySQL84', 'C:\Program Files\MySQL', 'C:\mysql56\bin',
                        'C:\Program Files (x86)\MySQL')) {
        if (-not (Test-Path $root)) { continue }
        $hit = Get-ChildItem $root -Filter $names[0] -Recurse -ErrorAction SilentlyContinue |
               Select-Object -First 1
        if ($hit) { $found = $hit.FullName; break }
    }
    if ($found) {
        Add-Result 'MySQL' 'Backup client' 'PASS' "mysqldump found ($found)"
    } else {
        Add-Result 'MySQL' 'Backup client' 'WARN' 'mysqldump not found on this server' `
            'Needed for the automatic pre-upgrade backup and for "shopdb-admin.ps1 backup". A first install works without it; upgrades will not be protected. It is not on this server yet - the installer stages its own copy, so this normally resolves itself during installation.'
    }
}

Invoke-Check 'MySQL' 'Version and config' {
    # Find mysqld.exe via the service binary path; read the version and locate my.ini.
    $svc = Get-CimInstance Win32_Service -ErrorAction SilentlyContinue |
           Where-Object { $_.PathName -match 'mysqld' } | Select-Object -First 1
    if ($null -eq $svc) { return }

    $path = $svc.PathName
    $exe = ''
    if ($path -match '"([^"]+mysqld[^"]*)"') { $exe = $matches[1] }
    elseif ($path -match '(\S+mysqld\S*)') { $exe = $matches[1] }

    $ver = ''
    if ($exe -and (Test-Path $exe)) {
        try { $ver = (& $exe --version 2>$null | Out-String).Trim() } catch { }
    }
    if ($ver) { Add-Result 'MySQL' 'Version' 'INFO' $ver }

    # my.ini path is passed as --defaults-file in the service command line.
    $ini = ''
    if ($path -match '--defaults-file="?([^"]+\.ini)"?') { $ini = $matches[1] }
    if ($ini -and (Test-Path $ini)) {
        Add-Result 'MySQL' 'Config file' 'INFO' $ini

        # MySQL 5.6 needs three flags or `flask db upgrade` dies with error 1071.
        $is56 = ($ver -match '\b5\.6\.')
        if ($is56) {
            $content = Get-Content $ini -Raw
            $need = @('innodb_file_per_table','innodb_file_format','innodb_large_prefix')
            $missing = @()
            foreach ($k in $need) { if ($content -notmatch $k) { $missing += $k } }
            if ($missing.Count -eq 0) {
                # Present in the FILE is not the same as ACTIVE. MySQL must be
                # restarted for them to take effect, and the app's own
                # `flask db-utils preflight` queries the live server - trust that.
                Add-Result 'MySQL' '5.6 index flags' 'WARN' 'all three present in my.ini' `
                    'Present in the file only. They take effect after a MySQL RESTART, which interrupts the classic ASP app. Confirm with SHOW VARIABLES or flask db-utils preflight.'
            } else {
                # WARN, not FAIL. This inspects the LOCAL MySQL, which may not be the
            # database the operator is about to install against - a bundled 8.4,
            # or a remote server. Blocking the wizard here refused an install
            # over a server that had nothing to do with it. Stage 3 runs
            # 'flask db-utils preflight' against the database actually chosen,
            # which is the check that can genuinely block.
            Add-Result 'MySQL' '5.6 index flags' 'WARN' ("missing: " + ($missing -join ', ')) `
                    "Add to [mysqld] in $ini and restart MySQL, or 'flask db upgrade' fails with error 1071. NOTE: restarting interrupts the classic ASP app."
            }
        }
    }
}

# =============================================================================
# 5. Python  (detect, but the installer uses its OWN bundled interpreter)
# =============================================================================

Invoke-Check 'Python' 'On PATH' {
    $cmd = Get-Command python -ErrorAction SilentlyContinue
    if ($null -eq $cmd) {
        Add-Result 'Python' 'On PATH' 'INFO' 'no python on PATH' `
            'Expected. The installer supplies its own interpreter.'
        return
    }
    $v = ''
    try { $v = (& $cmd.Source --version 2>&1 | Out-String).Trim() } catch { }
    $detail = "$v at $($cmd.Source)"

    # A per-user install under %LOCALAPPDATA% is unreadable by the IIS app-pool
    # identity. That produces a 500 with an empty HttpPlatform log.
    if ($cmd.Source -like "$env:LOCALAPPDATA*") {
        Add-Result 'Python' 'On PATH' 'WARN' "$detail (PER-USER install)" `
            'The IIS app-pool identity cannot read %LOCALAPPDATA%. The installer must install Python for ALL USERS and use absolute paths.'
    } elseif ($cmd.Source -like '*WindowsApps*') {
        Add-Result 'Python' 'On PATH' 'WARN' "$detail (Microsoft Store)" `
            'Store Python misbehaves under service identities. The installer will not use it.'
    } else {
        Add-Result 'Python' 'On PATH' 'INFO' $detail `
            'Not used by the installer, but a manual `flask` command later would resolve to this interpreter.'
    }
}

Invoke-Check 'Python' 'Registered installs' {
    $found = @()
    foreach ($hive in @('HKLM:\SOFTWARE\Python\PythonCore','HKCU:\SOFTWARE\Python\PythonCore')) {
        if (Test-Path $hive) {
            foreach ($k in Get-ChildItem $hive -ErrorAction SilentlyContinue) {
                $ip = Join-Path $k.PSPath 'InstallPath'
                if (Test-Path $ip) {
                    $loc = (Get-ItemProperty $ip -ErrorAction SilentlyContinue).'(default)'
                    $scope = if ($hive -like 'HKLM*') { 'all-users' } else { 'per-user' }
                    $found += "$($k.PSChildName) ($scope) $loc"
                }
            }
        }
    }
    if ($found.Count -eq 0) { Add-Result 'Python' 'Registered installs' 'INFO' 'none' }
    else { Add-Result 'Python' 'Registered installs' 'INFO' ($found -join '; ') }
}

# =============================================================================
# Report
# =============================================================================

$fails = @($script:Results | Where-Object { $_.Status -eq 'FAIL' })
$warns = @($script:Results | Where-Object { $_.Status -eq 'WARN' })
$skips = @($script:Results | Where-Object { $_.Status -eq 'SKIP' })

if ($Delimited) {
    # Data only. No padding, no colour, no alignment - the caller lays it out.
    # Pipes are stripped from field values so the record can be split naively.
    foreach ($r in $script:Results) {
        $fix = ''
        if ($r.Fix) { $fix = $r.Fix }
        $fields = @($r.Status, $r.Area, $r.Check, $r.Detail, $fix) | ForEach-Object {
            ([string]$_) -replace '\|', '/' -replace '\s*\r?\n\s*', ' '
        }
        Write-Output ($fields -join '|')
    }
} elseif ($Json) {
    [PSCustomObject]@{
        Timestamp = (Get-Date).ToString('s')
        Computer  = $env:COMPUTERNAME
        SitePort  = $SitePort
        AppRoot   = $AppRoot
        Failures  = $fails.Count
        Warnings  = $warns.Count
        Skipped   = $skips.Count
        Results   = $script:Results
    } | ConvertTo-Json -Depth 5
} else {
    Write-Host ''
    Write-Host 'ShopDB-Flask preflight' -ForegroundColor Cyan
    Write-Host ("  host {0}   site port {1}   approot {2}" -f $env:COMPUTERNAME, $SitePort, $AppRoot)
    Write-Host ''
    $area = ''
    foreach ($r in $script:Results) {
        if ($r.Area -ne $area) { $area = $r.Area; Write-Host "[$area]" -ForegroundColor White }
        $colour = 'Gray'
        if ($r.Status -eq 'PASS') { $colour = 'Green' }
        if ($r.Status -eq 'WARN') { $colour = 'Yellow' }
        if ($r.Status -eq 'FAIL') { $colour = 'Red' }
        if ($r.Status -eq 'SKIP') { $colour = 'DarkGray' }
        Write-Host ("  {0,-5} {1,-28} {2}" -f $r.Status, $r.Check, $r.Detail) -ForegroundColor $colour
        if ($r.Fix -and $r.Status -ne 'PASS' -and $r.Status -ne 'INFO' -and $r.Status -ne 'SKIP') {
            Write-Host ("        -> {0}" -f $r.Fix) -ForegroundColor DarkGray
        }
    }
    Write-Host ''
    if ($fails.Count -eq 0) {
        Write-Host "No blocking problems. $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Green
    } else {
        Write-Host "$($fails.Count) blocking problem(s), $($warns.Count) warning(s), $($skips.Count) skipped." -ForegroundColor Red
    }
    if ($skips.Count -gt 0) {
        Write-Host "  Skipped checks were NOT verified. Re-run once their prerequisite is installed." -ForegroundColor DarkGray
    }
    Write-Host ''
}

if ($fails.Count -gt 0) { exit 1 } else { exit 0 }
