<#
.SYNOPSIS
    Day-to-day control of a ShopDB-Flask installation, on the server itself.

.DESCRIPTION
    Installed alongside the application so an operator never has to open IIS
    Manager, hunt for a log, or remember an appcmd incantation.

    Run it with no arguments for a menu, or pass a command directly:

      .\shopdb-admin.ps1 status
      .\shopdb-admin.ps1 restart
      .\shopdb-admin.ps1 backup D:\backups

    Everything here is safe to run at any time EXCEPT backup/restore, which are
    called out explicitly.

.NOTES
    Written for stock Windows PowerShell 5.1. No modules to install.
#>

[CmdletBinding()]
param(
    [ValidateSet('menu','status','start','stop','restart','logs','open','backup',
                 'check','repair','catalog','sessions','plugins','add-plugin','verify','uninstall')]
    [string] $Command = 'menu',
    [string] $Path = '',
    # Machine-readable output for 'check'. The people running this are expected to
    # ask an AI assistant for help, and pasting a screenshot of a console into a
    # chat window loses most of what matters. One structured blob they can paste
    # gives the assistant real state to reason about instead of guesses.
    [switch] $Json,
    [string] $AppRoot  = 'C:\shopdb-flask',
    [string] $SiteName = 'shopdb-flask',
    [string] $AppPool  = 'shopdbflask',
    [int]    $SitePort = 8090
)

$ErrorActionPreference = 'Continue'
$AppCmd = Join-Path $env:windir 'System32\inetsrv\appcmd.exe'

# --- bitness ---------------------------------------------------------------
# IIS's management COM objects are 64-BIT ONLY. Under the 32-bit PowerShell,
# Import-Module WebAdministration SUCCEEDS but Get-Website then fails with
#   Retrieving the COM class factory ... REGDB_E_CLASSNOTREG
# which this script caught and reported as "cannot read IIS" - indistinguishable
# from IIS being absent.
#
# A 32-bit launcher is easy to end up with: Inno Setup is a 32-bit process, so
# anything it starts gets SysWOW64 powershell through WOW64 redirection. Rather
# than fix every launcher, relaunch under the native PowerShell. 'Sysnative' is
# the alias that lets a 32-bit process reach the real System32, and it exists
# ONLY for 32-bit processes - hence the guard.

# Every relaunch below must carry the ORIGINAL arguments through. They used to be
# dropped, so a console started from the Start Menu with -AppRoot D:\shopdb
# relaunched itself with the C:\shopdb-flask default and reported a healthy
# install as missing - and the operator had done nothing wrong.
function Get-ForwardedArgs {
    $forward = @('-NoProfile', '-ExecutionPolicy', 'Bypass',
                 '-File', ('"' + $PSCommandPath + '"'), $Command)
    if ($Path)     { $forward += @('-Path',     ('"' + $Path + '"')) }
    if ($AppRoot)  { $forward += @('-AppRoot',  ('"' + $AppRoot + '"')) }
    if ($SiteName) { $forward += @('-SiteName', ('"' + $SiteName + '"')) }
    if ($AppPool)  { $forward += @('-AppPool',  ('"' + $AppPool + '"')) }
    if ($SitePort) { $forward += @('-SitePort', $SitePort) }
    if ($Json)     { $forward += '-Json' }
    return $forward
}

if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) {
    $native = Join-Path $env:windir 'Sysnative\WindowsPowerShell\v1.0\powershell.exe'
    if (Test-Path $native) {
        Start-Process -FilePath $native -ArgumentList (Get-ForwardedArgs) -Wait -NoNewWindow
        return
    }
}

# --- elevation -------------------------------------------------------------
# Reading IIS state needs Administrator: the WebAdministration module and the
# IIS: drive both fail without it. Unelevated, this tool used to report
# "IIS not available / application pool: not installed", which reads as "your
# install is broken" when it actually means "I cannot see it". Relaunch elevated
# instead of reporting a false state.
$identity  = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
# 'open' just launches a browser. Prompting for administrator to do that trains
# people to click through UAC, and the Start Menu shortcut uses this command.
if ($Command -ne 'open' -and
    -not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
    Write-Host ''
    Write-Host '  Administrator rights are needed to read IIS state.' -ForegroundColor Yellow
    Write-Host '  Re-launching elevated - approve the prompt.' -ForegroundColor Yellow
    $argList = @('-NoExit') + (Get-ForwardedArgs)
    try {
        Start-Process -FilePath 'powershell.exe' -ArgumentList $argList -Verb RunAs | Out-Null
    } catch {
        Write-Host ''
        Write-Host '  Elevation was declined.' -ForegroundColor Red
        Write-Host '  Right-click the shortcut and choose "Run as administrator".' -ForegroundColor Red
        Read-Host '  Press Enter to close' | Out-Null
    }
    return
}


# --- brand header ----------------------------------------------------------
# Deliberately typographic, NOT ASCII art. The GE monogram is fine cursive
# linework; rendered as block characters at console resolution it reads as noise,
# which looks worse than no mark at all. A clean rule and correct wordmark says
# "considered"; mushy art says the opposite.
#
# Box-drawing characters used here are all in code page 437 (the Windows console
# default), and this file is saved UTF-8 WITH BOM so PowerShell 5.1 reads them
# correctly rather than assuming the ANSI code page.
function Show-Banner {
    $rule = ([string][char]0x2500) * 58
    Write-Host ''
    Write-Host '  GE AEROSPACE' -ForegroundColor Blue
    Write-Host ('  ' + $rule) -ForegroundColor DarkGray
    Write-Host '  ShopDB-Flask' -ForegroundColor White
    Write-Host '  Asset management for the shop floor' -ForegroundColor DarkGray
    Write-Host ''
}

function Say  { param($m, $c = 'Gray') Write-Host $m -ForegroundColor $c }
function Head {
    param($m)
    Write-Host ''
    Write-Host ('  ' + $m) -ForegroundColor Cyan
    Write-Host ('  ' + (([string][char]0x2500) * $m.Length)) -ForegroundColor DarkGray
}

function Get-EnvValue {
    param([string] $Key)
    $envFile = Join-Path $AppRoot '.env'
    if (-not (Test-Path $envFile)) { return '' }
    $line = Get-Content $envFile | Where-Object { $_ -like "$Key=*" } | Select-Object -First 1
    if ($line) { return $line.Substring($Key.Length + 1) }
    return ''
}

function Get-Deployment {
    <#
      Which way was this installed?

      Method A: its own IIS site on $SitePort.
      Method B: an IIS Application under an existing site, at /<alias>, reached
                on that site's port. MOUNT_PATH in .env is what distinguishes
                them - it is the same value wsgi.py uses to mount the app.

      Without this the console looked for a SITE that method B never creates and
      reported "web site: not installed" on a perfectly healthy server, then
      probed the wrong port and said it was not responding.
    #>
    $mount = (Get-EnvValue 'MOUNT_PATH').Trim()
    if (-not $mount) {
        return @{ Subpath = $false; Alias = ''; BaseUrl = "http://localhost:$SitePort"; Port = $SitePort }
    }
    $alias = $mount.Trim('/')
    $port  = 80
    $parent = 'Default Web Site'
    try {
        Import-Module WebAdministration -ErrorAction Stop
        foreach ($site in (Get-Website)) {
            $app = Get-WebApplication -Site $site.Name -Name $alias -ErrorAction SilentlyContinue
            if ($app) {
                $parent = $site.Name
                $b = $site.bindings.Collection | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
                if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $port = [int]$Matches[1] }
                break
            }
        }
    } catch { }
    $base = "http://localhost:$port/$alias"
    if ($port -eq 80) { $base = "http://localhost/$alias" }
    return @{ Subpath = $true; Alias = $alias; Parent = $parent; BaseUrl = $base; Port = $port }
}

function Get-DbParts {
    # Pull host/port/name/user out of DATABASE_URL without printing the password.
    $url = Get-EnvValue 'DATABASE_URL'
    if ($url -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') {
        # The password is PERCENT-ENCODED in DATABASE_URL (the installer applies
        # [uri]::EscapeDataString). SQLAlchemy unescapes it; so must we. Without
        # this, every command here fails to authenticate whenever the password
        # contains a space, @, %, ! or /, and the console reports the database as
        # unreachable on a server where the application is running perfectly.
        return @{ User = [uri]::UnescapeDataString($Matches[1])
                  Pass = [uri]::UnescapeDataString($Matches[2])
                  Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] }
    }
    return $null
}

function Find-MysqlClient {
    $candidates = @(
        'C:\MySQL84\bin\mysql.exe',
        'C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe',
        'C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe',
        'C:\mysql56\bin\mysql.exe'
    ) + @(Get-ChildItem 'C:\Program Files\MySQL' -Filter mysql.exe -Recurse -EA SilentlyContinue |
          Select-Object -ExpandProperty FullName)
    foreach ($c in $candidates) { if ($c -and (Test-Path $c)) { return $c } }
    return ''
}

# --------------------------------------------------------------------------
function Show-Status {
    Head 'Status'

    $deploy = Get-Deployment
    $siteState = 'not installed'
    $poolState = 'not installed'
    try {
        Import-Module WebAdministration -ErrorAction Stop
        if ($deploy.Subpath) {
            # Method B: there is no site of our own - we are an Application.
            $app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue
            if ($app) {
                $parentSite = Get-Website -Name $deploy.Parent -ErrorAction SilentlyContinue
                $siteState = ("/{0} under '{1}' ({2})" -f $deploy.Alias, $deploy.Parent,
                              $(if ($parentSite) { $parentSite.State } else { 'unknown' }))
            }
        } else {
            $site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
            if ($site) { $siteState = $site.State }
        }
        if (Test-Path "IIS:\AppPools\$AppPool") { $poolState = (Get-Item "IIS:\AppPools\$AppPool").State }
    } catch {
        # Should be unreachable now that the script self-elevates, but if IIS is
        # genuinely absent say THAT, rather than implying ShopDB is missing.
        # Name the real cause instead of implying ShopDB is missing.
        if (-not (Get-Service W3SVC -ErrorAction SilentlyContinue)) {
            $siteState = 'IIS is not installed on this server'
        } else {
            $siteState = 'could not read IIS - ' + $_.Exception.Message
        }
        $poolState = $siteState
    }

    $colour = if (($siteState -eq 'Started') -or ($siteState -like '*Started*')) { 'Green' } else { 'Yellow' }
    Say ("  published as    : {0}" -f $siteState) $colour
    Say ("  application pool: {0}" -f $poolState) $colour
    Say ("  installed at    : {0}" -f $(if (Test-Path $AppRoot) { $AppRoot } else { 'not found' }))

    # Does it actually answer? A "Started" pool proves nothing. Under method B
    # this must include the mount path, or it tests a URL that never existed.
    $url = $deploy.BaseUrl + '/'
    try {
        $r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 20
        Say ("  responding      : yes (HTTP {0})" -f $r.StatusCode) 'Green'
    } catch {
        Say  '  responding      : NO' 'Red'
        Say ("                    {0}" -f $_.Exception.Message) 'DarkGray'
    }

    $db = Get-DbParts
    if ($db) {
        Say ("  database        : {0} on {1}:{2} as {3}" -f $db.Name, $db.Host, $db.Port, $db.User)
        $mysql = Find-MysqlClient
        if ($mysql) {
            $q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';"
            $n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null
            if ($LASTEXITCODE -eq 0) { Say ("  tables          : {0}" -f $n) 'Green' }
            else { Say '  tables          : could not connect' 'Red' }
        }
    } else { Say '  database        : no .env found' 'Yellow' }

    # Is anyone set up yet?
    try {
        $na = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/api/setup/needs-admin') -UseBasicParsing -TimeoutSec 15
        if ($na.Content -match '"needsadmin"\s*:\s*true') {
            Say '  first run       : NOT SET UP - open the site to create the first administrator' 'Yellow'
        } else { Say '  first run       : complete' 'Green' }
    } catch { }

    Say ''
    if ($deploy.Subpath) {
        $shown = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias
        if ($deploy.Port -ne 80) { $shown = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias }
    } else {
        $shown = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort
    }
    Say ("  open with       : {0}" -f $shown) 'White'
}

function Start-App {
    Head 'Starting'
    $deploy = Get-Deployment
    & $AppCmd start apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say "  $_" }
    if (-not $deploy.Subpath) {
        & $AppCmd start site /site.name:$SiteName 2>&1 | ForEach-Object { Say "  $_" }
    } else {
        # Method B shares the parent site - starting or stopping THAT would take
        # every other application on this server with it.
        Say ("  (published under '{0}' - only the application pool is ours to control)" -f $deploy.Parent) 'DarkGray'
    }
    Say '  started' 'Green'
}

function Stop-App {
    Head 'Stopping'
    $deploy = Get-Deployment
    Say '  ShopDB-Flask will be unavailable until you start it again.' 'Yellow'
    if (-not $deploy.Subpath) {
        & $AppCmd stop site /site.name:$SiteName 2>&1 | ForEach-Object { Say "  $_" }
    } else {
        Say ("  (leaving site '{0}' running - stopping it would take down everything else on it)" -f $deploy.Parent) 'DarkGray'
    }
    & $AppCmd stop apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say "  $_" }
    Say '  stopped' 'Yellow'
}

function Restart-App {
    Head 'Restarting'
    # A recycle CANNOT start something that is stopped - it is a no-op on a
    # stopped pool. Restarting after a failed upgrade, which is exactly when the
    # pool is stopped, therefore did nothing and then reported "did not respond"
    # in red, as though the application were broken. Start it first when needed.
    Import-Module WebAdministration -ErrorAction SilentlyContinue
    $started = $true
    try {
        if (Test-Path "IIS:\AppPools\$AppPool") {
            $started = ((Get-Item "IIS:\AppPools\$AppPool").State -eq 'Started')
        }
    } catch { }
    if (-not $started) {
        Say '  the application pool is stopped; starting it' 'Yellow'
        Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue
        Start-Sleep -Seconds 3
    } else {
        # Recycle rather than stop/start: it drains existing requests instead of
        # cutting them off, and it is what a config change actually needs.
        & $AppCmd recycle apppool /apppool.name:$AppPool 2>&1 | ForEach-Object { Say "  $_" }
        if ($LASTEXITCODE -ne 0) { Say "  recycle reported exit $LASTEXITCODE" 'Yellow' }
        Start-Sleep -Seconds 2
    }

    # A stopped SITE answers nothing however healthy the pool is.
    try {
        $siteNow = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
        if ($siteNow -and $siteNow.State -ne 'Started') {
            Say '  the site is stopped; starting it' 'Yellow'
            Start-Website -Name $SiteName -ErrorAction SilentlyContinue
            Start-Sleep -Seconds 2
        }
    } catch { }
    try {
        $r = Invoke-WebRequest -Uri ((Get-Deployment).BaseUrl + '/') -UseBasicParsing -TimeoutSec 30
        Say ("  back up (HTTP {0})" -f $r.StatusCode) 'Green'
    } catch { Say '  did not respond after restart - run: shopdb-admin.ps1 logs' 'Red' }
}

function Show-Logs {
    Head 'Recent logs'
    $appLog = Join-Path $AppRoot 'logs'
    if (Test-Path $appLog) {
        $newest = Get-ChildItem "$appLog\*.log" -EA SilentlyContinue |
                  Sort-Object LastWriteTime -Descending | Select-Object -First 1
        if ($newest) {
            Say ("  application: {0}" -f $newest.FullName) 'White'
            Get-Content $newest.FullName -Tail 20 | ForEach-Object { Say "    $_" }
        } else { Say '  no application log yet' }
    }
    $inst = 'C:\ProgramData\ShopDB-Flask\logs'
    if (Test-Path $inst) {
        $newest = Get-ChildItem "$inst\*.log" -EA SilentlyContinue |
                  Sort-Object LastWriteTime -Descending | Select-Object -First 1
        if ($newest) {
            Say ''
            Say ("  install: {0}" -f $newest.FullName) 'White'
            Get-Content $newest.FullName -Tail 12 | ForEach-Object { Say "    $_" }
        }
    }
}

function Open-Site {
    # The installer records the address it actually published at. Prefer it: this
    # command runs unelevated, and .env is ACL'd, so working the address out from
    # MOUNT_PATH and IIS may not be possible from here.
    $recorded = Join-Path $AppRoot '.installed-url'
    if (Test-Path $recorded) {
        $u = (Get-Content $recorded -TotalCount 1).Trim()
        if ($u) { Start-Process $u; Say "  opened $u" 'Green'; return }
    }
    $deploy = Get-Deployment
    if ($deploy.Subpath) {
        if ($deploy.Port -eq 80) { $u = "http://{0}/{1}/login" -f $env:COMPUTERNAME, $deploy.Alias }
        else { $u = "http://{0}:{1}/{2}/login" -f $env:COMPUTERNAME, $deploy.Port, $deploy.Alias }
    } else {
        $u = "http://{0}:{1}/login" -f $env:COMPUTERNAME, $SitePort
    }
    Start-Process $u
    Say "  opened $u" 'Green'
}

function Backup-Db {
    param([string] $Dest)
    Head 'Database backup'
    $db = Get-DbParts
    if (-not $db) { Say '  no .env found - cannot determine the database' 'Red'; return }
    $usingDefault = -not $Dest
    if (-not $Dest) { $Dest = 'C:\ProgramData\ShopDB-Flask\backups' }
    if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest -Force | Out-Null }

    # A dump holds every row, including the users table and its password hashes.
    # A directory created under ProgramData INHERITS Users:RX, so those hashes
    # were readable by every authenticated user on the server whenever this
    # command created the directory rather than the installer.
    #
    # Re-applied on every backup, not only on creation, because this may be
    # repairing a directory made by an earlier version.
    #
    # Only for the default location. A path the operator named is theirs, and
    # silently rewriting its ACL is not this command's business - say so instead.
    if ($usingDefault) {
        & icacls.exe $Dest '/inheritance:r' `
            '/grant' 'BUILTIN\Administrators:(OI)(CI)(F)' `
            '/grant' 'NT AUTHORITY\SYSTEM:(OI)(CI)(F)' 2>&1 | Out-Null
    } else {
        Say '  note: this dump contains password hashes - check who can read that directory' 'Yellow'
    }

    $mysql = Find-MysqlClient
    if (-not $mysql) { Say '  mysql client not found' 'Red'; return }
    $dump = Join-Path (Split-Path $mysql -Parent) 'mysqldump.exe'
    if (-not (Test-Path $dump)) { Say '  mysqldump not found' 'Red'; return }

    $file = Join-Path $Dest ("shopdb_flask-{0}.sql" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
    Say ("  writing {0}" -f $file)
    # Redirect rather than --result-file: keeps it working on 5.6 and 8.0 alike.
    # Do NOT pipe mysqldump through the PowerShell pipeline into Out-File.
    # PowerShell 5.1 writes a UTF-8 BOM and re-encodes the stream through the
    # console code page, producing a dump MySQL refuses to load and mangling any
    # non-ASCII data - discovered only when the backup is finally needed.
    # Redirect the process's stdout straight to the file instead.
    $args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)",
              '--single-transaction', '--routines', '--triggers', $db.Name)
    $quoted = $args | ForEach-Object {
        if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ }
    }
    $err = [System.IO.Path]::GetTempFileName()
    try {
        $proc = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow `
                    -RedirectStandardOutput $file -RedirectStandardError $err
        if ($proc.ExitCode -ne 0) {
            Say ("  mysqldump failed (exit {0})" -f $proc.ExitCode) 'Red'
            Get-Content $err -Tail 3 -EA SilentlyContinue | ForEach-Object { Say ("    " + $_) 'DarkGray' }
            return
        }
    } finally { Remove-Item $err -Force -ErrorAction SilentlyContinue }

    # Verify it is complete rather than merely present.
    $tail = @(Get-Content $file -Tail 5 -ErrorAction SilentlyContinue)
    if ((Test-Path $file) -and ((Get-Item $file).Length -gt 1024) -and ($tail -match 'Dump completed')) {
        Say ("  done - {0:N1} MB, verified complete" -f ((Get-Item $file).Length / 1MB)) 'Green'

        # The dump is NOT everything. Uploaded branding, map blueprints and
        # generated files live in instance\ on disk, not in the database, so a
        # restore from the .sql alone comes back with no floor map. Saying "all of
        # your asset data" was true and misleading at the same time.
        $instance = Join-Path $AppRoot 'instance'
        if (Test-Path $instance) {
            $zip = [System.IO.Path]::ChangeExtension($file, $null) + 'instance.zip'
            try {
                Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
                if (Test-Path $zip) { Remove-Item $zip -Force }
                [System.IO.Compression.ZipFile]::CreateFromDirectory($instance, $zip)
                Say ("  uploaded files: {0:N1} MB -> {1}" -f ((Get-Item $zip).Length / 1MB), (Split-Path $zip -Leaf)) 'Green'
            } catch {
                Say ("  could not archive instance\: {0}" -f $_.Exception.Message) 'Yellow'
                Say '  copy it by hand - it holds branding and floor-map images' 'Yellow'
            }
        }

        Say ''
        Say '  Store BOTH files off this server. The .sql holds the records; the' 'Yellow'
        Say '  .zip holds uploaded branding and floor-map images, which the' 'Yellow'
        Say '  database does not. A restore needs both.' 'Yellow'
        Say ''
        Say '  Contains user password hashes - treat it as sensitive.' 'Yellow'
    } else {
        Say '  backup is empty or truncated - do NOT rely on it' 'Red'
        Remove-Item $file -Force -ErrorAction SilentlyContinue
    }
}

function Get-CheckState {
    <#
      Everything an outside reader needs to reason about this server, gathered
      without changing anything. Secrets are NEVER included: the database password
      lives in DATABASE_URL and this reports the host, port, name and user only.
    #>
    $deploy = Get-Deployment
    $db = Get-DbParts
    $state = [ordered]@{
        collected    = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
        computername = $env:COMPUTERNAME
        approot      = $AppRoot
        installed    = (Test-Path (Join-Path $AppRoot 'shopdb\__init__.py'))
        version      = ''
        publishedas  = if ($deploy.Subpath) { "subpath /$($deploy.Alias) under '$($deploy.Parent)'" } else { "own site '$SiteName' on port $SitePort" }
        baseurl      = $deploy.BaseUrl
        apppool      = 'unknown'
        poolstate    = 'unknown'
        sitestate    = 'unknown'
        responding   = $false
        httpstatus   = 0
        database     = $null
        pythonversion = ''
        plugins      = @()
        sbomcomponents = 0
        errors       = @()
    }
    foreach ($pair in @(@('version', '.installed-version'))) {
        $f = Join-Path $AppRoot $pair[1]
        if (Test-Path $f) { $state[$pair[0]] = (Get-Content $f -TotalCount 1).Trim() }
    }
    $state.apppool = $AppPool
    try {
        Import-Module WebAdministration -ErrorAction Stop
        if (Test-Path "IIS:\AppPools\$AppPool") { $state.poolstate = (Get-Item "IIS:\AppPools\$AppPool").State.ToString() }
        if ($deploy.Subpath) {
            $app = Get-WebApplication -Site $deploy.Parent -Name $deploy.Alias -ErrorAction SilentlyContinue
            $state.sitestate = if ($app) { 'application present' } else { 'application MISSING' }
        } else {
            $s = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
            $state.sitestate = if ($s) { $s.State.ToString() } else { 'site MISSING' }
        }
    } catch { $state.errors += "IIS: $($_.Exception.Message)" }

    try {
        $r = Invoke-WebRequest -Uri ($deploy.BaseUrl + '/') -UseBasicParsing -TimeoutSec 20
        $state.responding = $true
        $state.httpstatus = [int] $r.StatusCode
    } catch { $state.errors += "HTTP: $($_.Exception.Message)" }

    if ($db) {
        $state.database = [ordered]@{ host = $db.Host; port = $db.Port; name = $db.Name; user = $db.User; reachable = $false }
        $mysql = Find-MysqlClient
        if ($mysql) {
            $q = "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='$($db.Name)';"
            $n = $q | & $mysql "-u$($db.User)" "-p$($db.Pass)" "-h$($db.Host)" "-P$($db.Port)" -N 2>$null
            if ($LASTEXITCODE -eq 0) { $state.database.reachable = $true; $state.database.tables = [int] $n }
        } else { $state.errors += 'no mysql client found, database not probed' }
    } else { $state.errors += 'no .env found' }

    $py = Join-Path $AppRoot 'venv\Scripts\python.exe'
    if (Test-Path $py) { $state.pythonversion = (& $py -c "import sys; print('%d.%d.%d' % sys.version_info[:3])" 2>$null | Select-Object -First 1) }

    $dir = Join-Path $AppRoot 'plugins'
    if (Test-Path $dir) {
        $state.plugins = @(Get-ChildItem $dir -Directory -EA SilentlyContinue |
            Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } |
            Select-Object -ExpandProperty Name)
    }
    $sbom = Join-Path $AppRoot 'sbom.cdx.json'
    if (Test-Path $sbom) {
        try { $state.sbomcomponents = (Get-Content $sbom -Raw | ConvertFrom-Json).components.Count } catch { }
    }
    return $state
}

function Invoke-Check {
    if ($Json) {
        # ONLY the JSON goes to stdout, so it can be redirected to a file or piped
        # without a banner in the middle of the document.
        Get-CheckState | ConvertTo-Json -Depth 6
        return
    }

    Head 'Health check'
    $flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
    if (-not (Test-Path $flask)) { Say '  application not installed' 'Red'; return }
    Push-Location $AppRoot
    $env:FLASK_APP = 'shopdb'
    try { & $flask db-utils preflight 2>&1 | ForEach-Object { Say "  $_" } }
    finally { Pop-Location }

    # Surface an unfinished install HERE, where somebody is already looking,
    # rather than leaving them to infer it from 500s on unrelated pages.
    $state = Test-Provisioned
    if ($state.SchemaCurrent -eq $false -or $state.Seeded -eq $false) {
        Say ''
        Say '  THIS SERVER IS NOT FULLY PROVISIONED' 'Red'
        foreach ($detail in $state.Detail) { Say ("    - {0}" -f $detail) 'Red' }
        Say '  Most pages will answer 500 until this is fixed. Run:' 'Yellow'
        Say '    shopdb-admin.ps1 repair' 'White'
    }

    Say ''
    Say '  For help from an AI assistant, paste the output of:' 'DarkGray'
    Say '    shopdb-admin.ps1 check -Json' 'White'
}

function Test-Provisioned {
    """Is the schema current and the reference data seeded?"""
    # A server whose migrations or seeds never completed does not fail politely:
    # it answers 500 on most pages and 404 on settings that were never created,
    # which reads as a broken application rather than an unfinished install. One
    # site was stood up that way and spent a morning being debugged as a bug.
    $result = @{ SchemaCurrent = $true; Seeded = $true; Detail = @() }

    $out = Invoke-Flask @('db','current')
    $current = ($out | Out-String)
    $head = (Invoke-Flask @('db','heads') | Out-String)
    if ($script:LastFlaskExit -ne 0) {
        $result.SchemaCurrent = $false
        $result.Detail += 'could not read the schema version'
    } elseif ($current -notmatch '\(head\)' -and $head.Trim()) {
        # `db current` appends "(head)" when the database is at the newest
        # revision. Its absence means migrations are outstanding.
        $result.SchemaCurrent = $false
        $result.Detail += 'database schema is behind the application'
    }

    # Sentinel seeds. permissions, settings and the reference data are each
    # created by a `flask seed` command, and their absence is what produced the
    # 404s on settings keys at a site whose install never finished.
    $seedOut = (Invoke-Flask @('db-utils','seed-state') | Out-String)
    if ($seedOut -match 'No such command') {
        # An older build with no seed-state. Report UNKNOWN rather than healthy:
        # claiming a clean bill of health from a check that did not run is how a
        # broken server passes inspection.
        $result.Seeded = $null
        $result.Detail += 'seed state could not be checked (older build)'
    } elseif ($script:LastFlaskExit -ne 0 -or $seedOut -match 'MISSING') {
        $result.Seeded = $false
        $result.Detail += 'seed data is missing (permissions, settings or reference data)'
    }
    return $result
}

function Invoke-Repair {
    Head 'Repair provisioning'
    Say '  Brings the database up to the application: migrations, plugin'
    Say '  migrations, and the seed data. Every step is idempotent, so running'
    Say '  this on a healthy server changes nothing.'
    Say ''

    $steps = @(
        @{ Name = 'core schema';        Args = @('db','upgrade') },
        @{ Name = 'plugin schemas';     Args = @('plugin','upgrade-all') },
        @{ Name = 'permissions';        Args = @('seed','permissions') },
        @{ Name = 'settings';           Args = @('seed','settings') },
        @{ Name = 'reference data';     Args = @('seed','reference-data') }
    )
    $failed = @()
    foreach ($step in $steps) {
        Say ("  {0} ..." -f $step.Name) 'White'
        $out = Invoke-Flask $step.Args
        if ($script:LastFlaskExit -ne 0) {
            $failed += $step.Name
            Say ("    FAILED (exit {0})" -f $script:LastFlaskExit) 'Red'
            $out | Select-Object -Last 6 | ForEach-Object { Say "      $_" 'Red' }
        } else {
            Say '    done' 'Green'
        }
    }

    if ($failed.Count -gt 0) {
        Say ''
        Say ("  {0} step(s) failed: {1}" -f $failed.Count, ($failed -join ', ')) 'Red'
        Say '  Nothing further was skipped - each step ran independently.' 'Red'
        Say '  Send the output above, or run:  shopdb-admin.ps1 check -Json' 'Red'
        return
    }

    Say ''
    Say '  Provisioning complete. Restarting the application.' 'Green'
    Restart-App
}

function Invoke-Catalog {
    Head 'Load the shared equipment catalog'
    Say '  Vendors, model numbers, printer supply part numbers and the type'
    Say '  lists, as built up across the other sites. It only ADDS - nothing'
    Say '  already here is changed or removed - so it is safe to run at any'
    Say '  time, including on a site that has been running for years.'
    Say ''
    Say '  Nothing site-specific is included: no assets, locations or people.'
    Say ''

    # Dry run FIRST, always. Somebody running this on a populated site deserves
    # to see what it would touch before it touches anything.
    Say '  Checking what would be added...' 'White'
    $preview = Invoke-Flask @('seed','catalog','--dry-run')
    if ($script:LastFlaskExit -ne 0) {
        Say '  could not read the catalog' 'Red'
        $preview | Select-Object -Last 8 | ForEach-Object { Say "    $_" 'Red' }
        Say ''
        Say '  An older build may not have this command. Update first.' 'Yellow'
        return
    }
    $preview | ForEach-Object { Say "    $_" }

    if (($preview | Out-String) -match 'nothing to add') {
        Say ''
        Say '  Already loaded. Nothing to do.' 'Green'
        return
    }

    Say ''
    $answer = Read-Host '  Load it? (yes/no)'
    if ($answer -ne 'yes') { Say '  Left alone.' 'Yellow'; return }

    $out = Invoke-Flask @('seed','catalog')
    $out | ForEach-Object { Say "    $_" }
    if ($script:LastFlaskExit -ne 0) {
        Say ''
        Say '  Catalog load FAILED. Nothing else was changed.' 'Red'
        return
    }
    Say ''
    Say '  Catalog loaded.' 'Green'
}

function Show-Sessions {
    Head 'Worker processes'
    $w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue
    if (-not $w) { Say '  no IIS worker running (the site starts one on first request)' }
    else {
        foreach ($p in $w) {
            Say ("  pid {0}   {1:N0} MB   started {2}" -f $p.ProcessId,
                 ($p.WorkingSetSize/1MB), $p.CreationDate)
        }
    }
    $py = Get-CimInstance Win32_Process -Filter "Name='python.exe'" -EA SilentlyContinue |
          Where-Object { $_.CommandLine -like "*$AppRoot*" }
    if ($py) { foreach ($p in $py) { Say ("  python pid {0}   {1:N0} MB" -f $p.ProcessId, ($p.WorkingSetSize/1MB)) } }
}


# Set by every Invoke-Flask call. Callers MUST test this rather than
# $LASTEXITCODE: when flask.exe is missing no native command runs at all, so
# $LASTEXITCODE keeps whatever value it had from something earlier - which reads
# as success and made a command that did nothing report that it had worked.
$script:LastFlaskExit = 0

function Invoke-Flask {
    param([string[]] $Arguments)
    $flask = Join-Path $AppRoot 'venv\Scripts\flask.exe'
    if (-not (Test-Path $flask)) {
        Say '  application not installed' 'Red'
        $script:LastFlaskExit = 127
        return $null
    }
    Push-Location $AppRoot
    $env:FLASK_APP = 'shopdb'
    # The app logs plugin startup to STDERR even on success; with EAP=Stop that
    # becomes a terminating error and a healthy command looks like a failure.
    $prev = $ErrorActionPreference
    $ErrorActionPreference = 'Continue'
    try {
        & $flask @Arguments 2>&1
        $script:LastFlaskExit = $LASTEXITCODE
    }
    finally { $ErrorActionPreference = $prev; Pop-Location }
}

function Show-Plugins {
    Head 'Plugins'
    Invoke-Flask @('plugin','list') | ForEach-Object { Say "  $_" }

    # What is on disk but NOT installed can still be added here. What is absent
    # from disk cannot - see the note below.
    $dir = Join-Path $AppRoot 'plugins'
    if (Test-Path $dir) {
        $onDisk = (Get-ChildItem $dir -Directory -EA SilentlyContinue |
                   Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } |
                   Select-Object -ExpandProperty Name) -join ', '
        Say ''
        Say ("  shipped in this build : {0}" -f $onDisk) 'White'
    }
    Say ''
    Say '  Add one that is shipped :  shopdb-admin.ps1 add-plugin -Path <name>' 'White'
    Say ''
    Say '  A plugin NOT listed above is not on this server at all. This build was' 'DarkGray'
    Say '  made for your site''s chosen plugin set, so its code was never shipped.' 'DarkGray'
    Say '  Adding one means a new installer built from an updated site profile.' 'DarkGray'
}

function Add-Plugin {
    param([string] $Name)
    Head 'Add a plugin'
    if (-not $Name) { Say '  usage: shopdb-admin.ps1 add-plugin -Path <plugin-name>' 'Yellow'; return }

    $dir = Join-Path $AppRoot ('plugins\' + $Name)
    if (-not (Test-Path (Join-Path $dir 'manifest.json'))) {
        Say ("  '{0}' is not present on this server." -f $Name) 'Red'
        Say ''
        Say '  This build ships only the plugins your site chose. Adding a new one' 'Yellow'
        Say '  requires a new installer built from an updated site profile - the' 'Yellow'
        Say '  code is not here to install.' 'Yellow'
        Say ''
        Say '  Run "shopdb-admin.ps1 plugins" to see what IS available.' 'White'
        return
    }

    # apply-profile, not plugin install. Five plugins ship default_enabled=false,
    # so `install` alone left them installed-but-disabled: the command printed a
    # green success line and the feature did not appear anywhere in the UI.
    # apply-profile installs AND enables, and pulls in the dependency closure.
    Say ("  adding {0}..." -f $Name)
    $profilePath = Join-Path $AppRoot 'site-profile.json'
    $applied = $false
    if (Test-Path $profilePath) {
        try {
            $profile = Get-Content $profilePath -Raw | ConvertFrom-Json
            $wanted = @($profile.plugins)
            if ($wanted -notcontains $Name) { $wanted += $Name }
            $profile.plugins = $wanted
            $profile | ConvertTo-Json -Depth 6 | Set-Content -Path $profilePath -Encoding UTF8
            Invoke-Flask @('plugin','apply-profile',$profilePath) | ForEach-Object { Say "    $_" }
            $applied = ($script:LastFlaskExit -eq 0)
        } catch { Say ("  could not update site-profile.json: {0}" -f $_.Exception.Message) 'Yellow' }
    }
    if (-not $applied) {
        # No profile on disk, or apply-profile failed: fall back, but enable
        # explicitly so the outcome is the same either way.
        Invoke-Flask @('plugin','install',$Name) | ForEach-Object { Say "    $_" }
        $installed = ($script:LastFlaskExit -eq 0)
        Invoke-Flask @('plugin','enable',$Name)  | ForEach-Object { Say "    $_" }
        $applied = $installed -and ($script:LastFlaskExit -eq 0)
    }
    if (-not $applied) {
        # Do NOT print the green line on a failure. It used to be unconditional,
        # so a failed add reported success and the operator went looking for a
        # feature that was never enabled.
        Say ("  {0} was NOT added - see the output above" -f $Name) 'Red'
        return
    }

    Say '  applying its database migrations...'
    Invoke-Flask @('plugin','upgrade-all')       | ForEach-Object { Say "    $_" }
    if ($script:LastFlaskExit -ne 0) {
        Say '  migrations FAILED - the feature is installed but its tables are not' 'Red'
        Say '  do not use it until this is resolved; restore a backup if needed' 'Red'
        return
    }
    Say '  restarting so its routes register...'
    Restart-App
    Say ("  {0} added" -f $Name) 'Green'
}

function Invoke-Verify {
    <#
      Prove the installed dependencies are still the ones that shipped.

      Two independent records, both written at install time:
        requirements.txt   a sha256 for every wheel, checked by pip on install
        bundle-lock.json   the exact third-party payload of the bundle

      This re-checks what can still be checked on a live server. It reports; it
      changes nothing.
    #>
    Head 'Verify'

    $lock = Join-Path $AppRoot 'bundle-lock.json'
    if (Test-Path $lock) {
        try {
            $l = Get-Content $lock -Raw | ConvertFrom-Json
            Say ("  installed from : {0} bundle ({1}, {2})" -f $l.generated, $l.pythontag, $l.platform)
        } catch { Say '  bundle-lock.json is present but unreadable' 'Yellow' }
    } else {
        Say '  no bundle-lock.json recorded (installed before payload locking, or by hand)' 'Yellow'
    }

    # The SBOM travels with the application, because a server on a vaulted
    # network cannot be scanned from anywhere else. When a CVE lands, this is
    # what answers "is that component here, and at what version" without needing
    # the build box, the internet, or anyone's memory.
    $sbom = Join-Path $AppRoot 'sbom.cdx.json'
    if (Test-Path $sbom) {
        try {
            $b = Get-Content $sbom -Raw | ConvertFrom-Json
            $shipped = @($b.components | Where-Object { $_.scope -eq 'required' }).Count
            Say ("  components     : {0} ({1} shipped), CycloneDX {2}" -f `
                 $b.components.Count, $shipped, $b.specVersion)
            Say ("  bill of materials: {0}" -f $sbom) 'DarkGray'
            Say '  search it with :  shopdb-admin.ps1 verify -Path <name>' 'DarkGray'
        } catch { Say '  sbom.cdx.json is present but unreadable' 'Yellow' }

        # A named component turns this into the actual question being asked.
        # Guarded on $b: an unreadable SBOM leaves it unset, and querying it then
        # would report 'none', which reads as "you are not affected".
        if ($Path -and $b) {
            Say ''
            Say ("  matches for '{0}':" -f $Path) 'Cyan'
            $hits = @($b.components | Where-Object { $_.name -like ('*' + $Path + '*') })
            if (-not $hits) { Say '    none - this server does not carry it' 'Green' }
            foreach ($h in $hits) {
                $tag = if ($h.scope -eq 'required') { 'SHIPPED' } else { 'build only' }
                Say ("    {0,-40} {1,-14} {2}" -f $h.name, $h.version, $tag) `
                    $(if ($h.scope -eq 'required') { 'Yellow' } else { 'DarkGray' })
            }
        }
    } else {
        Say '  no SBOM recorded (installed before SBOMs shipped, or by hand)' 'Yellow'
    }

    # pip's own audit. It re-reads the metadata of what is actually installed and
    # reports anything missing or version-inconsistent, which is the part that
    # can still drift after install - a hand-run `pip install` on the server.
    $py = Join-Path $AppRoot 'venv\Scripts\python.exe'
    if (-not (Test-Path $py)) { Say '  no venv - application not installed' 'Red'; return }

    Say ''
    Say '  checking installed packages against requirements.txt...'
    $req = Join-Path $AppRoot 'requirements.txt'
    if (-not (Test-Path $req)) { Say '  requirements.txt is missing from the install' 'Red'; return }

    $wanted = @{}
    foreach ($line in (Get-Content $req)) {
        if ($line -match '^([A-Za-z0-9._-]+)==([^ \\]+)') { $wanted[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] }
    }
    $frozen = @{}
    foreach ($line in (& $py -m pip freeze 2>$null)) {
        if ($line -match '^([A-Za-z0-9._-]+)==(.+)$') { $frozen[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] }
    }

    $bad = 0
    foreach ($name in ($wanted.Keys | Sort-Object)) {
        if (-not $frozen.ContainsKey($name)) {
            Say ("    MISSING  {0} {1}" -f $name, $wanted[$name]) 'Red'; $bad++
        } elseif ($frozen[$name] -ne $wanted[$name]) {
            Say ("    DIFFERS  {0} {1} installed, {2} expected" -f $name, $frozen[$name], $wanted[$name]) 'Red'; $bad++
        }
    }
    if ($bad -eq 0) {
        Say ("    all {0} packages match" -f $wanted.Count) 'Green'
    } else {
        Say ''
        Say ("  {0} package(s) do not match what shipped." -f $bad) 'Red'
        Say '  Something was installed or upgraded on this server by hand. Re-run the' 'Yellow'
        Say '  installer to put the shipped set back.' 'Yellow'
    }

    & $py -m pip check 2>&1 | ForEach-Object { Say "    $_" 'DarkGray' }
}

function Show-Uninstall {
    Head 'Uninstall'
    Say '  Use Settings > Apps > ShopDB-Flask, or Add/Remove Programs.'
    Say ''
    Say '  That removes the web site, application pool, firewall rule and files.'
    Say '  It does NOT drop the database and does NOT uninstall MySQL.' 'Yellow'
    Say '  Take a backup first:  shopdb-admin.ps1 backup' 'Yellow'
}

# Cached for the session. Test-Provisioned shells out to flask twice, which is
# far too slow to repeat on every redraw of the menu - and the answer does not
# change while somebody reads the screen. Cleared after a repair so the warning
# disappears once it is actually fixed.
$script:Attention = $null

function Show-Attention {
    <#
      Say what is wrong, and which key fixes it.

      A server whose seeds never ran answers 500 on most pages, and until now the
      console reported all of that as ordinary status lines that read as normal
      unless you knew what to look for. The one thing an operator needs is the
      next action.
    #>
    if ($null -eq $script:Attention) {
        $state = Test-Provisioned
        $script:Attention = @{
            Provisioned = -not (($state.SchemaCurrent -eq $false) -or ($state.Seeded -eq $false))
            Detail      = $state.Detail
        }
    }
    if (-not $script:Attention.Provisioned) {
        Write-Host ''
        Say '  ! This server is not fully provisioned - most pages will fail.' 'Red'
        foreach ($detail in $script:Attention.Detail) { Say ("      {0}" -f $detail) 'Red' }
        Say '    Press F to fix it.' 'Yellow'
    }
}

function Show-Menu {
    $first = $true
    while ($true) {
        if ($first) { Show-Banner; $first = $false }
        Show-Status
        Show-Attention
        Write-Host ''
        Write-Host '  APPLICATION          DATA                   DIAGNOSE'          -ForegroundColor DarkGray
        Write-Host '   R  Restart           B  Back up            H  Health check'   -ForegroundColor White
        Write-Host '   S  Stop              C  Load catalog       L  Recent logs'    -ForegroundColor White
        Write-Host '   T  Start             P  Plugins            W  Processes'      -ForegroundColor White
        Write-Host '   O  Open in browser   F  Repair             V  Verify install' -ForegroundColor White
        Write-Host ''
        Write-Host '   Q  Quit'                                                      -ForegroundColor White
        Write-Host ''
        $c = Read-Host '  Choose'
        # The old numbers still work. Somebody who has used this for months
        # should not be stopped by a menu that was rearranged under them.
        switch ($c.ToUpper()) {
            'R' { Restart-App }    '1' { Restart-App }
            'S' { Stop-App }       '2' { Stop-App }
            'T' { Start-App }      '3' { Start-App }
            'L' { Show-Logs }      '4' { Show-Logs }
            'H' { Invoke-Check }   '5' { Invoke-Check }
            'B' { Backup-Db $Path } '6' { Backup-Db $Path }
            'W' { Show-Sessions }  '7' { Show-Sessions }
            'O' { Open-Site }      '8' { Open-Site }
            'V' { Invoke-Verify }
            'F' { Invoke-Repair; $script:Attention = $null }
            'C' { Invoke-Catalog }
            'P' { Show-Plugins
                  $add = Read-Host '  Name of a shipped plugin to add (Enter to skip)'
                  if ($add) { Add-Plugin $add } }
            '9' { Show-Plugins
                  $add = Read-Host '  Name of a shipped plugin to add (Enter to skip)'
                  if ($add) { Add-Plugin $add } }
            'Q' { return }         '0' { return }
            default { Say '  not a choice' 'Yellow' }
        }
        Write-Host ''
        Read-Host '  Press Enter to continue' | Out-Null
        Clear-Host
        Show-Banner
    }
}

switch ($Command) {
    'status'    { Show-Banner; Show-Status }
    'start'     { Start-App }
    'stop'      { Stop-App }
    'restart'   { Restart-App }
    'logs'      { Show-Logs }
    'open'      { Open-Site }
    'backup'    { Backup-Db $Path }
    'check'     { Invoke-Check }
    'repair'    { Invoke-Repair }
    'catalog'   { Invoke-Catalog }
    'sessions'  { Show-Sessions }
    'plugins'   { Show-Plugins }
    'add-plugin'{ Add-Plugin $Path }
    'verify'    { Invoke-Verify }
    'uninstall' { Show-Uninstall }
    default     { Show-Menu }
}
