<#
.SYNOPSIS
    ShopDB-Flask offline installer for Windows (IIS + HttpPlatformHandler + waitress).

.DESCRIPTION
    Automates docs/INSTALL-WINDOWS-IIS.md. Fully offline: every prerequisite comes
    from the bundle directory. Never contacts the network.

    Stages (each can be run alone with -Stage):
      1 preflight  - read-only discovery            (shopdb-preflight.ps1)
      2 runtime    - Python, venv, wheels, .env
      3 data       - schema, seeds, plugins
      4 iis        - app pool, ACLs, site, firewall
      5 verify     - smoke test + handoff
      all          - 2,3,4,5 in order (default)

    Written for stock Windows PowerShell 5.1. No pwsh-only syntax, no modules,
    no network.

.PARAMETER BundleRoot
    Directory holding the offline bundle. Expected layout:
      python\python-3.14.x-amd64.exe
      wheels\*.whl
      app\            (release tree: wsgi.py, shopdb\, plugins\, frontend\dist\, ...)
      httpplatformhandler\httpPlatformHandler_amd64.msi
      urlrewrite\     (optional, -ClientIpSource direct)
      mysql\          (optional, bundled-database option)
      bundle-lock.json + bundle-lock.ps1

    The payload is checked against bundle-lock.json before anything runs. A
    bundle whose wheels or MSIs do not match the lock exactly - missing, extra or
    altered - is refused, because every one of them executes as SYSTEM here.

.PARAMETER DbHost
    Existing-MySQL option: the server hostname. The PASSWORD IS NEVER A PARAMETER -
    it is prompted for as a SecureString, because Windows command lines are
    readable by any user (Win32_Process) and are captured in PowerShell
    transcripts and history. If a valid .env already exists it is reused and
    nothing is prompted.

.EXAMPLE
    .\shopdb-install.ps1 -BundleRoot D:\shopdb-bundle -SiteHost shopdb.plant.local
    .\shopdb-install.ps1 -Stage 4 -BundleRoot D:\shopdb-bundle

.NOTES
    Idempotent. Re-running preserves an existing .env (regenerating JWT_SECRET_KEY
    would invalidate every issued session).
#>

[CmdletBinding()]
param(
    # Stage 0 installs the bundled MySQL 8.4 LTS (greenfield sites only). It is NOT in
    # 'all': a site with an existing MySQL must never have a second server dropped
    # on top of it, and that is the common case (docs/DEPLOY-WINDOWS-IIS.md: the
    # target box already runs the classic ASP shopdb against MySQL 5.6). Run it
    # explicitly, and only when preflight reports no MySQL on 3306.
    [ValidateSet('0','1','2','3','4','5','all','uninstall')] [string] $Stage = 'all',
    # Bundled-MySQL (stage 0) settings. The root and app passwords are GENERATED,
    # never supplied: see New-Secret. Root is shown once and not persisted.
    # SPACE-FREE, deliberately - same reason as the Python target below. msiexec
    # takes public properties as PROPERTY=value on the command line, and a value
    # containing spaces has to be quoted as INSTALLDIR="C:\..." - quoting the
    # whole PROPERTY=value token instead makes msiexec reject the command line
    # and pop its usage dialog, which is what 'the bundled MySQL database could
    # not be installed' looked like from the outside.
    [string] $MysqlRoot    = 'C:\MySQL84',
    [string] $MysqlDataDir = 'C:\ProgramData\MySQL\data',
    [string] $MysqlIni     = 'C:\ProgramData\MySQL\my.ini',
    [string] $MysqlService = 'MySQL84',
    [Parameter(Mandatory=$true)] [string] $BundleRoot,
    [string] $AppRoot   = 'C:\shopdb-flask',
    [int]    $SitePort  = 8090,
    [string] $SiteName  = 'shopdb-flask',
    [string] $AppPool   = 'shopdbflask',
    [string] $SiteHost  = '',          # for CORS_ORIGINS; defaults to this machine
    # Deliberately NOT a password parameter. Command lines are readable by any
    # user via Win32_Process and are captured in PowerShell transcripts.
    # Supply host/user/db here; the password is prompted for.
    [string] $DbHost   = '',
    [int]    $DbPort   = 3306,
    [string] $DbName   = 'shopdb_flask',
    [string] $DbUser   = 'shopdb',
    # Unattended installs only. Path to a file whose FIRST LINE is the DB password.
    # The installer reads it, overwrites it and deletes it. ACL the file to
    # SYSTEM + Administrators before writing it. Omit this for an interactive run
    # and the password is prompted for instead. It is never a parameter value:
    # command lines are world-readable via Win32_Process and are captured in
    # PowerShell transcripts and ConsoleHost_history.txt.
    [string] $DbPasswordFile = '',
    # The site's plugin set is DECLARED in a profile (ADR-013), not listed here.
    # The bundle is built lean for that same profile by scripts/build-site.sh, so
    # a plugin the site did not choose is absent from the backend tree entirely.
    # That absence is what makes core's `try: from plugins.X.models import ...
    # except ImportError` guards correct: the import genuinely fails, no
    # relationship backref is created, and no query joins the missing table.
    #
    # Do NOT go back to installing a fixed list against a full tree. That was the
    # cause of the 500s on /api/assets and /api/dashboard/summary
    # ("Table 'shopdb_flask.measuringtools' doesn't exist"): the plugin's CODE
    # shipped regardless, so the import succeeded, SQLAlchemy created the
    # Asset.measuringtool backref and LEFT OUTER JOINed a table that only exists
    # if the plugin was installed.
    [string] $SiteProfile = '',
    # Comma-separated plugin names chosen at INSTALL time (the wizard's plugin
    # page). When supplied this REWRITES site-profile.json before apply-profile
    # runs, so the operator's choice wins over whatever the bundle was built with.
    # Omit it and the profile shipped in the bundle is used unchanged.
    [string] $SitePlugins = '',
    # Subpath deployment (docs/INSTALL-WINDOWS-IIS.md method B). Empty = the app
    # gets its own IIS site on -SitePort (method A, the default). Set to an alias
    # such as 'shopdb' and it becomes an IIS Application under -ParentSite, i.e.
    # http://<server-fqdn>/shopdb/ - no new DNS record, no port in the URL.
    # The alias MUST match the one the bundle's subpath SPA was built with; the
    # installer checks that and refuses if they disagree.
    [string] $MountAlias = '',
    [string] $ParentSite = 'Default Web Site',
    # How this server learns a request's real client IP.
    #
    #   direct  IIS is exposed to clients. Install URL Rewrite from the bundle
    #           and set X-Forwarded-For from REMOTE_ADDR. Overwriting the header
    #           is what stops a client spoofing it.
    #   proxy   A reverse proxy (ARR, load balancer) sits in front and already
    #           sets X-Forwarded-For. Leave the rule off - REMOTE_ADDR would be
    #           the proxy, so applying it would DESTROY the real client IP.
    #
    # Without one or the other, IIS sends no X-Forwarded-For at all and every
    # client reads as 127.0.0.1: the GE-Enforce IP allowlist, the dashboard
    # visitor-location lookup and per-host login rate limiting all break quietly.
    [ValidateSet('direct','proxy')] [string] $ClientIpSource = 'direct',
    # Load the shared vendor/model catalog during stage 3. Offered by the
    # wizard rather than assumed: a site that machines nothing does not want
    # 21 machine types cluttering its dropdowns.
    [switch] $SeedCatalog,
    # Required before this installer will alter an installation it did not
    # create. Everything here is built for a greenfield server: it makes its own
    # Python, its own venv and its own IIS objects, and its upgrade path assumes
    # the thing it is upgrading came out of a previous run.
    #
    # Pointed at a server that was deployed by hand, the IIS reconciliation would
    # DELETE the existing application or site as part of switching deployment
    # method - silently, because removing the artifact of the method you are not
    # using is correct behaviour when the installer owns both. It is not correct
    # when someone else built it. So it asks first.
    [switch] $AdoptExisting,
    [switch] $WhatIfOnly,
    # Unattended runs cannot answer a prompt. Choose the failure behaviour up front.
    [ValidateSet('ask','always','never')] [string] $OnFailure = 'ask'
)

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

# NOT $env:TEMP. When this runs under the Inno wrapper, TEMP points at Setup's
# own extraction directory, which is deleted when Setup exits - so the log the
# operator is told to send to support disappears with it, and a failed install
# leaves nothing to diagnose. ProgramData persists and is admin-writable.
$script:LogDir = Join-Path $env:ProgramData 'ShopDB-Flask\logs'
try { if (-not (Test-Path $script:LogDir)) { New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null } }
catch { $script:LogDir = $env:TEMP }
$script:LogPath = Join-Path $script:LogDir ("shopdb-install-{0}.log" -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
$script:Created = New-Object System.Collections.ArrayList   # for rollback
$script:RebootPending = $false
$script:MysqlAlreadyOurs = $false
# Was this server already stamped by a previous run of THIS installer, as observed
# BEFORE stage 2 writes its own stamp? Stage 4 needs the answer to know whether the
# IIS objects it is about to reconcile are its own. $null means nothing has looked
# yet, which is the case when stage 4 is run on its own.
$script:PreexistingInstallStamp = $null

function Write-Log {
    param([string] $Message, [string] $Level = 'INFO')
    $line = "{0} [{1}] {2}" -f (Get-Date -Format 'HH:mm:ss'), $Level, $Message
    Add-Content -Path $script:LogPath -Value $line
    $colour = 'Gray'
    if ($Level -eq 'OK')   { $colour = 'Green' }
    if ($Level -eq 'WARN') { $colour = 'Yellow' }
    if ($Level -eq 'FAIL') { $colour = 'Red' }
    if ($Level -eq 'STEP') { $colour = 'Cyan' }
    Write-Host $line -ForegroundColor $colour
}

function Fail {
    param([string] $Message, [string] $Fix = '')
    Write-Log $Message 'FAIL'
    if ($Fix) { Write-Log "  fix: $Fix" 'FAIL' }
    Write-Log "log: $script:LogPath"
    throw $Message
}

# -----------------------------------------------------------------------------
# DURABLE INSTALL RECORD
#
# "Is this a re-run of MY install?" used to be answered from whatever the machine
# happened to look like: a MySQL service exists, the database has tables, the
# site exists, the venv exists. None of those record WHO created them, so a retry
# after a failed first attempt was repeatedly mistaken for an upgrade of somebody
# else's working system. That single confusion produced several separate dead
# ends, the worst being a failed first install demanding a mandatory backup of
# its own half-written database and then refusing to prune tables it had created
# itself minutes earlier.
#
# This file answers the question instead. It is written when provisioning
# STARTS, not when it finishes, so a run that dies halfway still leaves a record
# the next run can read - which is the whole point, because the run that dies
# halfway is the one whose retry needs to know.
# -----------------------------------------------------------------------------
$script:StateFile    = Join-Path $env:ProgramData 'ShopDB-Flask\install-state.json'
$script:InstallState = $null

function Get-StateValue {
    # Set-StrictMode makes reading a missing property a terminating error, so
    # every read of a file that may be older, hand-edited or truncated goes
    # through here rather than dotting into it directly.
    param($Object, [string] $Name)
    if ($null -eq $Object) { return $null }
    $prop = $Object.PSObject.Properties[$Name]
    if ($null -eq $prop) { return $null }
    return $prop.Value
}

function Save-InstallState {
    param($State)
    # Never fatal. The record is an improvement on guessing; failing to write it
    # only returns the installer to the behaviour it had before, so a permissions
    # problem here must not stop an install that is otherwise fine.
    try {
        $dir = Split-Path $script:StateFile -Parent
        if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
        ($State | ConvertTo-Json -Depth 6) | Set-Content -Path $script:StateFile -Encoding UTF8
        Protect-File $script:StateFile
    } catch {
        Write-Log "could not write the install record: $($_.Exception.Message)" 'WARN'
    }
}

function Get-InstallState {
    if (-not (Test-Path $script:StateFile)) { return $null }
    try {
        $raw = Get-Content $script:StateFile -Raw -ErrorAction Stop
        if (-not $raw) { return $null }
        $obj = $raw | ConvertFrom-Json -ErrorAction Stop
    } catch {
        Write-Log 'the install record is unreadable; falling back to inspecting the server' 'WARN'
        return $null
    }
    # A record describing a DIFFERENT directory says nothing about this one.
    $recorded = Get-StateValue $obj 'approot'
    if ($recorded -and -not (Test-SamePath $recorded $AppRoot)) { return $null }
    return $obj
}

function Initialize-InstallState {
    $state = Get-InstallState
    if ($null -eq $state) {
        # No record at all. Either this is a genuinely new server, or an install
        # that predates this file. Distinguish by the version stamp, and when in
        # doubt choose the SAFE side: an established install, which keeps the
        # mandatory pre-upgrade backup. Guessing "first run" for a server holding
        # real data would let prune --force loose on it.
        $stamped = Test-Path (Join-Path $AppRoot '.installed-version')
        $state = [PSCustomObject]@{
            schema            = 1
            approot           = $AppRoot
            firstrunstarted   = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
            firstruncompleted = $(if ($stamped) { 'predates-this-record' } else { $null })
            created           = [PSCustomObject]@{}
        }
        if ($stamped) {
            Write-Log 'no install record found, but this server carries a version stamp' 'WARN'
            Write-Log '  treating it as an established install, so the pre-upgrade backup stays mandatory' 'WARN'
        }
        Save-InstallState $state
    }
    $script:InstallState = $state
}

function Test-FirstProvisioning {
    # TRUE only while OUR first provisioning of this directory is unfinished -
    # including every retry of it. Whatever is in the database at that point was
    # put there by an earlier attempt of this same install, not by a running site.
    if ($null -eq $script:InstallState) { return $false }
    return ($null -eq (Get-StateValue $script:InstallState 'firstruncompleted'))
}

function Complete-InstallState {
    if ($null -eq $script:InstallState) { return }
    if ($null -ne (Get-StateValue $script:InstallState 'firstruncompleted')) { return }
    $script:InstallState.firstruncompleted =
        (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
    Save-InstallState $script:InstallState
}

function Track {
    param([string] $Kind, [string] $Id)
    $null = $script:Created.Add(@{Kind=$Kind; Id=$Id})
    # Persist as well. $script:Created lives only in memory, so a run that died
    # left nothing behind saying what it had already made, and the next run was
    # back to inferring it from the machine.
    try {
        if ($null -ne $script:InstallState) {
            $created = Get-StateValue $script:InstallState 'created'
            if ($null -eq $created) {
                $created = [PSCustomObject]@{}
                $script:InstallState | Add-Member -NotePropertyName 'created' -NotePropertyValue $created -Force
            }
            $list = @(Get-StateValue $created $Kind | Where-Object { $_ })
            if ($list -notcontains $Id) {
                $created | Add-Member -NotePropertyName $Kind -NotePropertyValue (@($list) + $Id) -Force
                Save-InstallState $script:InstallState
            }
        }
    } catch { }
}

function Get-CreatedItems {
    # What THIS installer recorded creating, by kind. Empty when there is no
    # record, which callers must treat as "unknown", never as "nothing".
    #
    # The leading comma is load-bearing. Returning a zero-length array from a
    # PowerShell function UNROLLS it to $null, so the caller gets $null and
    # $null.Count is a terminating error under Set-StrictMode - the exact fault
    # that made bundle verification fail on every install earlier. Comma-wrapping
    # emits the array itself as a single object, so an empty result stays an
    # empty array.
    param([string] $Kind)
    if ($null -eq $script:InstallState) { return ,@() }
    $created = Get-StateValue $script:InstallState 'created'
    return ,@(Get-StateValue $created $Kind | Where-Object { $_ })
}

function Invoke-Native {
    # Runs an external command and fails loudly.
    #
    # Three PowerShell 5.1 traps are handled here, each found the hard way on a
    # real Server 2025 box:
    #  1. `& $exe @array` can collapse the array into ONE argument. Start-Process
    #     -ArgumentList binds arrays correctly.
    #  2. Native tools write progress to stderr on SUCCESS, and
    #     $ErrorActionPreference='Stop' turns that into a terminating error.
    #  3. Argument VALUES must never be logged: a DATABASE_URL or password would
    #     land in the log we invite operators to send to support.
    #  4. Some installers succeed with a NON-ZERO code. 3010 and 1641 both mean
    #     "done, reboot required" - the VC++ redistributable returns 3010 on a
    #     server with a pending file rename, which is an ordinary state on a
    #     freshly patched box. Treating it as failure aborts a correct install.
    param([string] $Exe, [string[]] $Arguments, [string] $What, [string] $StdinFile = '',
          [int] $TimeoutSec = 0, [int[]] $OkExit = @(0))
    Write-Log ("  exec {0} ({1} args)" -f (Split-Path $Exe -Leaf), $Arguments.Count)
    # Start-Process -ArgumentList joins the array with spaces WITHOUT quoting, so
    # any argument containing whitespace gets split into several. That is how
    # "IIS AppPool\name:(OI)(CI)RX" became the parameter "IIS" (icacls exit 87),
    # and how TargetDir=C:\Program Files\... installed Python into C:\Program\.
    $quoted = $Arguments | ForEach-Object {
        if ($_ -notmatch '\s' -or $_ -match '^".*"$') { $_ }
        # PROPERTY=value (msiexec, msbuild): the VALUE is quoted, never the whole
        # token. "INSTALLDIR=C:\Program Files\..." is rejected outright, whereas
        # INSTALLDIR="C:\Program Files\..." is what the tool expects.
        elseif ($_ -match '^([A-Za-z_][A-Za-z0-9_]*)=(.*)$') { '{0}="{1}"' -f $Matches[1], $Matches[2] }
        else { '"' + $_ + '"' }
    }
    $so = [System.IO.Path]::GetTempFileName()
    $se = [System.IO.Path]::GetTempFileName()
    try {
        # NO -Wait when a timeout is wanted. -Wait blocks inside Start-Process
        # until the child exits, so by the time the timeout check below ran the
        # process had ALWAYS exited and the whole block was dead code - every
        # -TimeoutSec on every MSI call was doing nothing. Start without -Wait
        # and do the waiting here, where it can be bounded.
        $common = @{ FilePath = $Exe; ArgumentList = $quoted; PassThru = $true
                     NoNewWindow = $true
                     RedirectStandardOutput = $so; RedirectStandardError = $se }
        if ($StdinFile) {
            # Feeding SQL as a file on stdin, not as -e "source <path>": the mysql
            # client reads that path as a DATABASE NAME and fails with
            # ERROR 1049 Unknown database '<path>'.
            $common['RedirectStandardInput'] = $StdinFile
        }
        # -Wait ALWAYS. Dropping it to make -TimeoutSec enforceable left
        # $p.ExitCode unreadable: a Python install that had actually worked
        # reported "Python install failed (exit )" with an empty code, because
        # PowerShell only reliably populates ExitCode on a waited process.
        #
        # So the trade is deliberate. Exit codes are load-bearing here - 1639 vs
        # 1603 vs 3010 is the whole diagnosis - and a bounded wait is not worth
        # losing them for. -TimeoutSec is therefore ADVISORY: it is recorded in
        # the log so a hang is identifiable, but the wait is unbounded.
        $common['Wait'] = $true
        if ($TimeoutSec -gt 0) { Write-Log ("  (expected to finish within {0}s)" -f $TimeoutSec) }
        $p = Start-Process @common

        # NOTE: msiexec can deadlock when another Windows Installer transaction
        # holds the global mutex - two msiexec processes once sat for 40 minutes
        # with no output and no error. That is why the expected duration is
        # logged above: an operator seeing no progress well past it should check
        # for stray msiexec processes (Get-Process msiexec) and end them.
        $out = @()
        if (Test-Path $so) { $out += Get-Content $so -ErrorAction SilentlyContinue }
        if (Test-Path $se) { $out += Get-Content $se -ErrorAction SilentlyContinue }
        $code = $null
        try { $code = $p.ExitCode } catch { }
        if ($null -eq $code) {
            # Never silently treat "cannot tell" as success.
            $out | Select-Object -Last 8 | ForEach-Object { Write-Log "    $_" 'FAIL' }
            Fail "$What finished but its exit code could not be read" `
                 'Check the log above and the tool''s own log before re-running.'
        }
        if ($OkExit -notcontains $code) {
            $out | Select-Object -Last 8 | ForEach-Object { Write-Log "    $_" 'FAIL' }
            Fail "$What failed (exit $code)"
        }
        if ($code -ne 0) {
            # Accepted, but say so: 3010/1641 mean the server needs a reboot to
            # finish, and an operator who never sees that wonders later why
            # something only half works.
            Write-Log ("$What returned {0} - completed, but this server needs a reboot to finish" -f $code) 'WARN'
            $script:RebootPending = $true
        }
        return $out
    }
    finally { Remove-Item $so, $se -Force -ErrorAction SilentlyContinue }
}

function Protect-File {
    # Owner-only ACL: Administrators + SYSTEM, inheritance broken.
    #
    # -Directory additionally makes the two grants INHERITABLE. Without (OI)(CI)
    # the grants apply to the directory alone, so files written into it later -
    # backups taken by the console, not by this script - are not covered.
    param([string] $Path, [switch] $Directory)
    $admins = 'BUILTIN\Administrators:(F)'
    $system = 'NT AUTHORITY\SYSTEM:(F)'
    if ($Directory) {
        $admins = 'BUILTIN\Administrators:(OI)(CI)(F)'
        $system = 'NT AUTHORITY\SYSTEM:(OI)(CI)(F)'
    }
    Invoke-Native 'icacls.exe' @($Path,'/inheritance:r','/grant',$admins,'/grant',$system) 'ACL'
}

function Protect-InstallRoot {
    # A directory created directly under C:\ INHERITS the root's DACL, and the
    # default C:\ DACL carries an inherit-only Modify grant for Authenticated
    # Users. So C:\shopdb-flask, C:\Python314 and C:\MySQL84 are writable by
    # every logged-on user until inheritance is broken.
    #
    # That is a local privilege escalation here specifically, because this
    # installer puts two things a non-admin could then replace inside those
    # roots: shopdb-admin.ps1, which self-elevates with -Verb RunAs, and
    # instance\config.py, which the app loads unconditionally through
    # from_pyfile. Overwrite either, wait for an administrator or the app pool
    # to run it, and the code runs as them.
    #
    # RE-APPLIED EVERY TIME, not only on creation - same reasoning as the
    # ProgramData backups directory. An upgrade over an installation that
    # predates this must repair the ACL, or the hole survives the fix.
    #
    # The app pool's own RX/Modify grants are applied in stage 4, AFTER this,
    # so breaking inheritance here does not lock the site out of its own tree.
    param([string] $Path)
    if (-not (Test-Path $Path)) { return }
    Write-Log "hardening $Path (breaking inheritance from its drive root)"
    Protect-File $Path -Directory
}

function Test-UserWritableRoot {
    # Whether a non-admin can write to a path. Used ONLY on roots this
    # installer did not create: hardening a Python another application already
    # depends on would break that application, so a pre-existing root is
    # reported rather than seized. Silence would be worse - the app would be
    # running on an interpreter any user can replace.
    param([string] $Path)
    if (-not (Test-Path $Path)) { return $false }
    $acl = & icacls.exe $Path 2>$null
    return [bool]($acl -match '(Authenticated Users|BUILTIN\\Users|Everyone):\([^)]*\)?\(?(M|F|W)\)')
}

function New-Secret {
    param([int] $Bytes = 48)
    $b = New-Object byte[] $Bytes
    [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b)
    # URL-safe, no padding: avoids characters that need escaping in .env or URLs.
    return [Convert]::ToBase64String($b).Replace('+','-').Replace('/','_').TrimEnd('=')
}

function Resolve-IisPath {
    # IIS stores physicalPath as it was typed, so it can carry %SystemDrive%-style
    # variables and a trailing backslash. A raw string compare therefore misses
    # paths that ARE ours - on a hand-built deployment that means stage 4 fails to
    # recognise its own application, and the uninstaller fails to recognise what
    # it may remove.
    param([string] $Path)
    if (-not $Path) { return '' }
    return [System.Environment]::ExpandEnvironmentVariables($Path).TrimEnd('\').ToLowerInvariant()
}

function Test-SamePath {
    param([string] $A, [string] $B)
    return (Resolve-IisPath $A) -eq (Resolve-IisPath $B)
}

# Paths derived once.
# Default so a stage that runs on its own still knows where the interpreter is;
# the Python stage overwrites it with what it actually installed.
$script:PyRoot = 'C:\Python314'
$Py        = Join-Path $AppRoot 'venv\Scripts\python.exe'
$Flask     = Join-Path $AppRoot 'venv\Scripts\flask.exe'
$Pip       = Join-Path $AppRoot 'venv\Scripts\pip.exe'
$EnvFile   = Join-Path $AppRoot '.env'
$WheelDir  = Join-Path $BundleRoot 'wheels'
$AppSource = Join-Path $BundleRoot 'app'

# Dot-sourced HERE, at script scope, not inside Assert-BundleIntegrity. Dot-
# sourcing inside a function loads into that FUNCTION's scope, so every helper
# it defines - Get-JsonProperty, Read-BundleLock, Test-BundleLock - disappears
# the moment the function returns. Assert-BundleIntegrity itself worked; the
# next caller, Get-WheelhousePythonTag, died with "The term 'Get-JsonProperty'
# is not recognized" on any run where a venv already existed, which is every
# retry after a part-completed install.
#
# Absent on a stage that runs without the bundle (uninstall), so this is a
# guarded load and Assert-BundleIntegrity still fails loudly if it is missing.
$BundleLockLib = Join-Path $BundleRoot 'bundle-lock.ps1'
if (Test-Path $BundleLockLib) { . $BundleLockLib }


# =============================================================================
# Version awareness and database safety (upgrades)
# =============================================================================
# An upgrade is just this installer run against an existing install. That is
# only safe if three things are true, and none of them were before:
#   - it knows whether it is going forwards or backwards;
#   - the database is backed up BEFORE migrations touch it;
#   - a failed migration puts the backup back rather than leaving a half-state.

function Assert-BundleIntegrity {
    <#
      Refuse to run against a payload that is not exactly what was built and
      reviewed.

      This runs BEFORE anything is installed, because everything downstream is an
      executable that runs as SYSTEM on this server: the Python installer, the
      HttpPlatformHandler and URL Rewrite MSIs, and ~40 wheels. requirements.txt
      hashes cover the wheels once pip gets to them; nothing covered the rest,
      and nothing noticed a stale extra wheel sitting in the wheelhouse.

      There is deliberately no override. A bundle that fails here was altered
      after it was built, and the fix is to get a correct bundle rather than to
      wave this one through on a server nobody can see.
    #>
    $checker = Join-Path $BundleRoot 'bundle-lock.ps1'
    $lockFile = Join-Path $BundleRoot 'bundle-lock.json'
    if (-not (Test-Path $checker)) {
        Fail 'bundle-lock.ps1 is missing from the bundle' `
             'This bundle was not assembled by build-installer.ps1/.sh. Rebuild it.'
    }
    if (-not (Test-Path $lockFile)) {
        Fail 'bundle-lock.json is missing from the bundle' `
             'This bundle was not assembled by build-installer.ps1/.sh. Rebuild it.'
    }
    $lock = Read-BundleLock $lockFile
    # NO @() here. Test-BundleLock returns ,$problems, which already hands the
    # array back intact - wrapping it again nests it, and .Count becomes 1 no
    # matter how many problems there are. That turns a byte-perfect bundle into
    # "1 problem(s)" on every install. Applying the same guard at both ends was
    # worse than applying it at neither.
    $problems = Test-BundleLock -BundleRoot $BundleRoot -Lock $lock
    if ($problems.Count -gt 0) {
        foreach ($p in $problems) { Write-Log "  $p" 'FAIL' }
        Fail ("the bundle does not match bundle-lock.json ({0} problem(s))" -f $problems.Count) @'
The payload was changed after this installer was built. Do not install from it.
Obtain a bundle whose payload matches its lock, or rebuild one and re-compile.
'@
    }
    Write-Log ("bundle payload verified against bundle-lock.json ({0}, {1})" -f `
               (Get-JsonProperty $lock 'pythontag' 'unknown'), (Get-JsonProperty $lock 'platform' 'unknown')) 'OK'
}

function Get-WheelhousePythonTag {
    # Which Python the wheelhouse was built for. The lock is authoritative; a
    # bundle predating it is read from the wheel filenames instead.
    $lockFile = Join-Path $BundleRoot 'bundle-lock.json'
    if (Test-Path $lockFile) {
        $tag = Get-JsonProperty (Get-Content $lockFile -Raw | ConvertFrom-Json) 'pythontag'
        if ($tag -and ($tag -match '^cp(\d)(\d+)$')) { return ('{0}.{1}' -f $Matches[1], $Matches[2]) }
    }
    $wheel = Get-ChildItem $WheelDir -Filter '*.whl' -ErrorAction SilentlyContinue |
             Where-Object { $_.Name -match '-cp(\d)(\d+)-' } | Select-Object -First 1
    if ($wheel -and ($wheel.Name -match '-cp(\d)(\d+)-')) { return ('{0}.{1}' -f $Matches[1], $Matches[2]) }
    return ''
}

function Assert-VenvMatchesWheelhouse {
    <#
      An existing venv is REUSED rather than rebuilt, which is right for a repair
      or an upgrade of an install this made. It is wrong when the venv belongs to
      a different Python: the wheelhouse is tagged for one minor version, so pip
      finds no candidate for cffi, cryptography, greenlet or mysql-connector and
      dies partway through - after Python has already been installed and the app
      tree already replaced.

      Fail before any of that, and say which two versions disagree.
    #>
    if (-not (Test-Path $Py)) { return }          # greenfield: nothing to disagree with
    $wanted = Get-WheelhousePythonTag
    if (-not $wanted) { return }                  # unknowable; the pip failure will have to do

    $found = ''
    try {
        $found = (& $Py -c "import sys; print('%d.%d' % sys.version_info[:2])" 2>$null | Select-Object -First 1)
        if ($found) { $found = $found.Trim() }
    } catch { return }
    if (-not $found -or ($found -eq $wanted)) { return }

    Fail ("the existing venv is Python {0}, but this bundle's wheelhouse is for {1}" -f $found, $wanted) @'
The wheelhouse is locked to one Python minor version. Installing it into a venv
built by a different one fails at the first compiled package, halfway through.

On a server this installer built: delete APP_ROOT\venv and re-run. The venv is
rebuilt from the bundle and holds nothing of yours.

On a server someone else built: this is not an upgrade, it is a runtime change.
Take a database backup first, and expect to reconcile web.config by hand.
'@
}

function Get-BundleVersion {
    # The version being installed, read from the payload itself so it can never
    # disagree with the code that is about to be copied.
    $init = Join-Path $AppSource 'shopdb\__init__.py'
    if (-not (Test-Path $init)) { return '' }
    $m = Select-String -Path $init -Pattern "^__version__\s*=\s*'([^']+)'" | Select-Object -First 1
    if ($m) { return $m.Matches[0].Groups[1].Value }
    return ''
}

function Get-InstalledVersion {
    $f = Join-Path $AppRoot '.installed-version'
    if (Test-Path $f) { return (Get-Content $f -TotalCount 1).Trim() }
    # An install predating version stamping still has an app tree.
    if (Test-Path (Join-Path $AppRoot 'shopdb\__init__.py')) { return 'unknown' }
    return ''
}

function Compare-Version {
    # -1 / 0 / 1, comparing dotted numeric versions. 'unknown' sorts as older so
    # a stamped bundle over an unstamped install is treated as an upgrade.
    param([string] $A, [string] $B)
    if ($A -eq $B) { return 0 }
    if ($A -eq 'unknown' -or $A -eq '') { return -1 }
    if ($B -eq 'unknown' -or $B -eq '') { return 1 }
    $x = @($A -split '[.\-]' | ForEach-Object { [int]($_ -replace '\D','0') })
    $y = @($B -split '[.\-]' | ForEach-Object { [int]($_ -replace '\D','0') })
    for ($i = 0; $i -lt [Math]::Max($x.Count, $y.Count); $i++) {
        $xi = if ($i -lt $x.Count) { $x[$i] } else { 0 }
        $yi = if ($i -lt $y.Count) { $y[$i] } else { 0 }
        if ($xi -gt $yi) { return 1 }
        if ($xi -lt $yi) { return -1 }
    }
    return 0
}

function Find-MysqlTool {
    param([string] $Name)   # mysql.exe or mysqldump.exe
    # The BUNDLE first, then the install directory, then the local server.
    #
    # A site whose MySQL lives on another host has no client installed here at
    # all, so mysqldump was never found - which meant every upgrade skipped the
    # pre-upgrade backup, and it did so AFTER stage 2 had stopped the pool and
    # replaced the application tree. Shipping the client makes the backup work
    # on a remote-database site, which is the case that needs it most.
    $bundled = @(
        (Join-Path $BundleRoot ('mysqlclient\' + $Name)),
        (Join-Path $AppRoot ('mysqlclient\' + $Name))
    )
    foreach ($b in $bundled) { if (Test-Path $b) { return $b } }

    # C:\MySQL84 first: that is where the bundled server installs (space-free,
    # see $MysqlRoot). The Program Files paths cover a server MySQL put there
    # by hand or by an older build.
    $roots = @('C:\MySQL84', 'C:\Program Files\MySQL', 'C:\mysql56\bin', 'C:\Program Files (x86)\MySQL')
    foreach ($r in $roots) {
        if (Test-Path $r) {
            $hit = Get-ChildItem $r -Filter $Name -Recurse -ErrorAction SilentlyContinue |
                   Select-Object -First 1
            if ($hit) { return $hit.FullName }
        }
    }
    return ''
}

function Get-DbFromEnv {
    $line = Get-Content $EnvFile -ErrorAction SilentlyContinue |
            Where-Object { $_ -like 'DATABASE_URL=*' } | Select-Object -First 1
    if ($line -match '://([^:]+):([^@]*)@([^:/]+):(\d+)/([^?]+)') {
        return @{ User = $Matches[1]; Pass = [uri]::UnescapeDataString($Matches[2])
                  Host = $Matches[3]; Port = $Matches[4]; Name = $Matches[5] }
    }
    return $null
}


function Test-DatabaseEmpty {
    <#
      Is the TARGET DATABASE empty right now?

      This - not the presence of C:\shopdb-flask - is what decides whether it is
      safe to force-drop tables. A rebuilt server pointed at a site's existing,
      populated database has no app directory, so filesystem-based detection
      called it a "fresh install" and ran prune-schema --yes --force, destroying
      the rows of every unselected plugin with no backup taken.

      Returns $true ONLY when we positively confirm zero tables. Anything we
      cannot determine returns $false, because the failure mode of guessing
      "empty" is irreversible data loss and the failure mode of guessing
      "populated" is merely a redundant backup.
    #>
    $probe = Join-Path $env:TEMP ("shopdb-dbprobe-{0}.py" -f (Get-Date -Format 'HHmmssfff'))
    @'
import os, sys
try:
    from sqlalchemy import create_engine, inspect
    url = None
    for line in open(".env", encoding="utf-8", errors="replace"):
        if line.startswith("DATABASE_URL="):
            url = line.split("=", 1)[1].strip()
    if not url:
        print("UNKNOWN"); sys.exit(0)
    print("EMPTY" if len(inspect(create_engine(url)).get_table_names()) == 0 else "POPULATED")
except Exception:
    print("UNKNOWN")
'@ | Set-Content -Path $probe -Encoding ASCII
    try {
        Push-Location $AppRoot
        $prev = $ErrorActionPreference
        $ErrorActionPreference = 'Continue'
        try { $out = & $Py $probe 2>&1 } finally { $ErrorActionPreference = $prev; Pop-Location }
        $verdict = ($out | Where-Object { $_ -match '^(EMPTY|POPULATED|UNKNOWN)$' } | Select-Object -Last 1)
        Write-Log "database probe: $verdict"
        return ($verdict -eq 'EMPTY')
    }
    finally { Remove-Item $probe -Force -ErrorAction SilentlyContinue }
}

function Backup-Database {
    # Returns the backup path, or '' if it could not be taken.
    param([string] $Reason = 'pre-upgrade')
    $db = Get-DbFromEnv
    if (-not $db) { Write-Log 'cannot read DATABASE_URL; skipping backup' 'WARN'; return '' }
    $dump = Find-MysqlTool 'mysqldump.exe'
    if (-not $dump) { Write-Log 'mysqldump not found; skipping backup' 'WARN'; return '' }

    # A dump contains every row, including the users table and its password
    # hashes. ProgramData is readable by every user on the box by default, so the
    # directory is locked to Administrators and SYSTEM the moment it is created.
    $dir = Join-Path $env:ProgramData 'ShopDB-Flask\backups'
    if (-not (Test-Path $dir)) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    }
    # RE-APPLIED EVERY TIME, not only on creation. The console creates this
    # directory too (shopdb-admin.ps1 backup), and one created there inherits
    # ProgramData's Users:RX - so dumps containing the users table and its
    # password hashes became readable by every authenticated user on the server.
    # The old create-only guard meant the installer could never repair that.
    Protect-File $dir -Directory
    $file = Join-Path $dir ("{0}-{1}-{2}.sql" -f $db.Name, $Reason, (Get-Date -Format 'yyyyMMdd-HHmmss'))

    Write-Log "backing up $($db.Name) before migrating"
    # --single-transaction keeps it consistent without locking the whole server.
    # Password goes in the argument array, never interpolated into a log line.
    $args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)",
              '--single-transaction', '--routines', '--triggers', $db.Name)
    $so = [System.IO.Path]::GetTempFileName()
    $se = [System.IO.Path]::GetTempFileName()
    try {
        # Start-Process -ArgumentList does NOT quote, so a password containing a
        # space splits into several arguments and the dump fails. Quote anything
        # with whitespace, exactly as Invoke-Native does.
        $quoted = $args | ForEach-Object {
            if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ }
        }
        Write-Log ("  exec mysqldump.exe ({0} args)" -f $args.Count)
        $p = Start-Process -FilePath $dump -ArgumentList $quoted -Wait -PassThru -NoNewWindow `
                 -RedirectStandardOutput $so -RedirectStandardError $se
        if ($p.ExitCode -ne 0) {
            Write-Log "mysqldump exited $($p.ExitCode); no usable backup" 'WARN'
            return ''
        }

        # VALIDATE before anything relies on this file. mysqldump can exit 0 and
        # still leave a truncated dump - a full disk, or a table it could not
        # read part-way through. A backup that is trusted but unusable is worse
        # than no backup at all, because migrations then proceed on its strength.
        if (-not (Test-Path $so)) { Write-Log 'mysqldump produced no file' 'WARN'; return '' }
        $len = (Get-Item $so).Length
        if ($len -lt 512) {
            Write-Log "backup is only $len bytes; refusing to treat it as valid" 'WARN'; return ''
        }
        $tail = @(Get-Content $so -Tail 5 -ErrorAction SilentlyContinue)
        if (-not ($tail -match 'Dump completed')) {
            Write-Log 'backup lacks the mysqldump completion marker - it is truncated' 'WARN'
            return ''
        }

        Move-Item $so $file -Force
        $mb = [math]::Round((Get-Item $file).Length / 1MB, 1)
        Write-Log "backup written and verified complete: $file ($mb MB)" 'OK'
        return $file
    }
    finally { Remove-Item $so, $se -Force -ErrorAction SilentlyContinue }
}

function Restore-Database {
    param([string] $BackupFile)
    if (-not $BackupFile -or -not (Test-Path $BackupFile)) {
        Write-Log 'no backup available to restore from' 'FAIL'; return $false
    }
    $db = Get-DbFromEnv
    $mysql = Find-MysqlTool 'mysql.exe'
    if (-not $db -or -not $mysql) { Write-Log 'cannot restore: client or .env missing' 'FAIL'; return $false }
    Write-Log "restoring $($db.Name) from $BackupFile" 'WARN'
    $args = @("-u$($db.User)", "-p$($db.Pass)", "-h$($db.Host)", "-P$($db.Port)", $db.Name)
    try {
        # mysqldump emits DROP TABLE IF EXISTS before each CREATE, so replaying
        # genuinely restores every table the dump contains.
        Invoke-Native $mysql $args 'database restore' -StdinFile $BackupFile | Out-Null
    } catch { Write-Log "restore failed: $($_.Exception.Message)" 'FAIL'; return $false }

    # What the replay CANNOT undo: tables the failed migration created that the
    # dump knows nothing about. MySQL DDL is not transactional, so those CREATEs
    # are already committed and no dump-replay removes them.
    #
    # We report them rather than dropping them. Dropping would mean deciding, on
    # a machine holding a site's only copy of its data, that a table is safe to
    # destroy - and the installer deliberately does not claim that authority.
    # (Restoring into a side database instead is not available either: the app
    # user has rights on this database only, not CREATE DATABASE.)
    $dumped = @()
    foreach ($line in (Get-Content $BackupFile -ErrorAction SilentlyContinue)) {
        if ($line -match '^CREATE TABLE `([^`]+)`') { $dumped += $Matches[1] }
    }

    $probe = Join-Path $env:TEMP ("shopdb-tables-{0}.py" -f (Get-Date -Format 'HHmmssfff'))
    @'
import sys
try:
    from sqlalchemy import create_engine, inspect
    url = None
    for line in open(".env", encoding="utf-8", errors="replace"):
        if line.startswith("DATABASE_URL="):
            url = line.split("=", 1)[1].strip()
    for t in sorted(inspect(create_engine(url)).get_table_names()):
        print("TABLE " + t)
except Exception:
    pass
'@ | Set-Content -Path $probe -Encoding ASCII
    $current = @()
    try {
        Push-Location $AppRoot
        $prev = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
        try { $current = @(& $Py $probe 2>&1 | Where-Object { $_ -match '^TABLE ' } |
                           ForEach-Object { $_.Substring(6).Trim() }) }
        finally { $ErrorActionPreference = $prev; Pop-Location }
    } finally { Remove-Item $probe -Force -ErrorAction SilentlyContinue }

    $residue = @($current | Where-Object { $dumped -notcontains $_ })

    if ($residue.Count -eq 0) {
        Write-Log 'data restored from backup; no leftover tables from the failed migration' 'OK'
    } else {
        # Say exactly what is true. The previous wording claimed the database was
        # "restored to its previous state", which is a false assurance when the
        # schema still carries changes the migration committed before it failed.
        Write-Log 'data restored from backup, but the schema is NOT identical to before' 'WARN'
        Write-Log ("  {0} table(s) created by the failed migration remain:" -f $residue.Count) 'WARN'
        $residue | ForEach-Object { Write-Log "    $_" 'WARN' }
        Write-Log '  they hold no data and are harmless to the running application,' 'WARN'
        Write-Log '  but a DBA should drop them before the next upgrade attempt.' 'WARN'
    }
    Write-Log "  backup used: $BackupFile"
    return $true
}

# =============================================================================
# STAGE 0 - bundled MySQL 8.4 LTS (greenfield sites only)
# =============================================================================
# The MSI installs BINARIES ONLY: no service, no data directory, no config. The
# full sequence is msiexec -> write my.ini -> --initialize-insecure -> --install
# -> Start-Service -> bootstrap SQL. Every step below has bitten us once.
function Invoke-Stage0 {
    Write-Log 'STAGE 0: bundled MySQL 8.4 LTS' 'STEP'

    $msi = Get-ChildItem (Join-Path $BundleRoot 'mysql') -Filter '*.msi' -ErrorAction SilentlyContinue |
           Select-Object -First 1
    if (-not $msi) {
        Fail "no MySQL MSI in $BundleRoot\mysql" @'
Stage 0 installs the BUNDLED database. Add mysql\mysql-8.4.x-winx64.msi to the
bundle, or skip stage 0 and use the site's existing server (the common case).
'@
    }

    # Refuse to stack a second server on an existing one. Two servers fighting
    # over 3306, or a silent takeover of the classic ASP app's database, is far
    # worse than stopping here.
    # OUR service is not "someone else's MySQL". This refused to run when the
    # only service present was the MySQL84 that a previous stage 0 registered -
    # so every retry of a part-completed bundled install dead-ended, while the
    # wizard told the operator that re-running was safe.
    $existing = @(Get-Service -Name 'MySQL*' -ErrorAction SilentlyContinue)
    $foreign  = @($existing | Where-Object { $_.Name -ne $MysqlService })
    if ($foreign.Count -gt 0) {
        Fail ("MySQL service already present: {0}" -f (($foreign | ForEach-Object Name) -join ', ')) @'
This box already runs MySQL. Do NOT install the bundled server on top of it:
it would collide on port 3306 and could disrupt the existing application.
Skip stage 0 and point the installer at the existing server with -DbHost.
'@
    }
    $ours = @($existing | Where-Object { $_.Name -eq $MysqlService })
    if ($ours.Count -gt 0) {
        # Ours, from an earlier run. Bring it up if it is down and let the rest of
        # the stage run: the bootstrap SQL is idempotent (CREATE ... IF NOT EXISTS
        # plus ALTER USER), so it re-asserts the password and the grants and
        # leaves .dbpass agreeing with the server.
        Write-Log "$MysqlService is already registered by a previous run" 'OK'
        if ($ours[0].Status -ne 'Running') {
            Write-Log "starting $MysqlService"
            Start-Service -Name $MysqlService -ErrorAction SilentlyContinue
            Start-Sleep -Seconds 3
        }
        $script:MysqlAlreadyOurs = $true
    }
    $inUse = Get-NetTCPConnection -LocalPort $DbPort -State Listen -ErrorAction SilentlyContinue
    if ($inUse) { Fail "port $DbPort is already in use" 'Free the port, or use the existing server.' }

    if ($WhatIfOnly) { Write-Log 'WhatIf: skipping MySQL install' 'WARN'; return }

    # --- binaries -----------------------------------------------------------
    $mysqld = Join-Path $MysqlRoot 'bin\mysqld.exe'
    if (Test-Path $mysqld) {
        # Re-running stage 0 after a partial failure must not re-run the MSI.
        # Reinstalling an already-registered product is a no-op that STILL needs
        # the Windows Installer service, and if msiserver is stopped the client
        # blocks forever at 0% CPU with no transaction ever opening and no error.
        # The binaries are what this step exists to produce, so if they are here,
        # skip straight to configuration. Also makes the stage idempotent (T-005).
        Write-Log "MySQL binaries already present at $MysqlRoot; skipping MSI" 'OK'
    }
    else {
        # The Windows Installer service is Manual-start and is supposed to start
        # on demand. When it does not, msiexec waits on it indefinitely - so start
        # it explicitly rather than trusting on-demand activation.
        $msiSvc = Get-Service msiserver -ErrorAction SilentlyContinue
        if ($msiSvc -and $msiSvc.Status -ne 'Running') {
            Write-Log 'starting Windows Installer service (msiserver)'
            try { Start-Service msiserver -ErrorAction Stop } catch {
                Write-Log "could not start msiserver: $($_.Exception.Message)" 'WARN'
            }
        }
        # The Visual C++ runtime, BEFORE MySQL. MySQL's MSI carries a launch
        # condition requiring it, and a bare Windows Server does not ship it -
        # so on a fresh box the MSI aborts in about a second with the generic
        # exit 1603 and no indication of what was missing. That is what a real
        # Server 2019 install did.
        $vcMarker = Join-Path $env:windir 'system32\vcruntime140.dll'
        if (Test-Path $vcMarker) {
            Write-Log 'Visual C++ runtime already present' 'OK'
        } else {
            $vc = Get-ChildItem (Join-Path $BundleRoot 'vcredist') -Filter '*.exe' -ErrorAction SilentlyContinue |
                  Select-Object -First 1
            if (-not $vc) {
                Fail 'the Visual C++ runtime is missing and no redistributable is in the bundle' @'
MySQL will not install without it - its MSI fails with a bare exit 1603.
Add vcredist\VC_redist.x64.exe to the bundle and rebuild, or install the
Microsoft Visual C++ 2015-2022 Redistributable (x64) on this server by hand.
'@
            }
            Write-Log "installing $($vc.Name) (MySQL requires it)"
            if (-not $WhatIfOnly) {
                Invoke-Native $vc.FullName @('/install','/quiet','/norestart') `
                    'Visual C++ runtime' -TimeoutSec 600 -OkExit 0,3010,1641
                Write-Log 'Visual C++ runtime installed' 'OK'
            }
        }

        # Quiet MSI. INSTALLDIR must have no trailing backslash or the MSI mis-parses it.
        Write-Log "installing $($msi.Name) (125MB, takes a minute)"
        # /l*v: without the MSI's own log, exit 1603 says only "something went
        # wrong" - it names neither the action nor the reason, and it is the most
        # common MySQL failure code. Costs nothing and turns the next failure
        # into something readable.
        $msiLog = Join-Path $script:LogDir ('mysql-msi-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
        Write-Log "MSI log: $msiLog"
        Invoke-Native 'msiexec.exe' @('/i', $msi.FullName, '/quiet', '/norestart',
                                      '/l*v', $msiLog,
                                      "INSTALLDIR=$MysqlRoot") 'MySQL MSI' -TimeoutSec 900 -OkExit 0,3010
    }
    # An installer exit code of 0 does NOT mean it did what you asked - the Python
    # bootstrapper returned 0 while installing to the wrong directory. Verify.
    if (-not (Test-Path $mysqld)) { Fail "MSI reported success but $mysqld is missing" }
    Track 'mysql-files' $MysqlRoot
    # The service is registered with `mysqld --install` and no obj=, so it runs
    # as LocalSystem - the SYSTEM grant covers it. Nothing else needs to write
    # here; a user who can replace mysqld.exe owns the service account.
    Protect-InstallRoot $MysqlRoot
    Write-Log 'MySQL binaries installed' 'OK'

    # --- config -------------------------------------------------------------
    # Space-free paths on purpose: every quoting bug in this script came from a
    # path containing a space being split into two arguments.
    New-Item -ItemType Directory -Force -Path (Split-Path $MysqlIni -Parent) | Out-Null
    $iniText = @"
[mysqld]
basedir=$MysqlRoot
datadir=$MysqlDataDir
port=$DbPort
bind-address=127.0.0.1
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
default-storage-engine=INNODB
innodb_file_per_table=1
max_connections=200
log-error=$MysqlDataDir\mysql-error.log
"@
    Set-Content -Path $MysqlIni -Value $iniText -Encoding ASCII
    Write-Log "wrote $MysqlIni"
    Track 'mysql-files' $MysqlIni

    # Everything from here to the bootstrap creates the server. Skip it wholesale
    # when the service is already ours: the datadir check would refuse a
    # populated directory, --initialize-insecure would refuse it too, and
    # --install would fail on an existing service name.
    if ($script:MysqlAlreadyOurs) {
        Write-Log 'server already exists from a previous run; skipping create' 'OK'
    } else {

    # --- data directory -----------------------------------------------------
    # --initialize-insecure REFUSES a non-empty datadir, so a previous failed
    # attempt must be cleared before retrying.
    if (Test-Path $MysqlDataDir) {
        $items = Get-ChildItem $MysqlDataDir -Force -ErrorAction SilentlyContinue
        if ($items) {
            Fail "$MysqlDataDir already exists and is not empty" @'
mysqld --initialize-insecure refuses a non-empty data directory. If this is a
failed previous run, remove the directory and re-run stage 0. If it holds real
data, STOP: you are about to destroy a database.
'@
        }
    }
    Write-Log 'initializing data directory'
    Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize'
    Track 'mysql-datadir' $MysqlDataDir
    # The data directory holds the users table and its password hashes, and it
    # sits under ProgramData, which grants Users read by inheritance - the same
    # exposure the backup directory already had to be locked against. mysqld
    # runs as LocalSystem, so Administrators + SYSTEM is sufficient.
    Protect-File $MysqlDataDir -Directory

    # --- service ------------------------------------------------------------
    # Argument ORDER MATTERS: --install <name> BEFORE --defaults-file. Verified
    # empirically - reversed, mysqld exits 1 and the service is not registered.
    Write-Log "registering service $MysqlService"
    Invoke-Native $mysqld @('--install', $MysqlService, "--defaults-file=$MysqlIni") 'MySQL service register'
    Track 'service' $MysqlService
    Start-Service -Name $MysqlService
    $svc = Get-Service -Name $MysqlService
    if ($svc.Status -ne 'Running') { Fail "service $MysqlService did not start" "Check $MysqlDataDir\mysql-error.log" }
    Set-Service -Name $MysqlService -StartupType Automatic
    Write-Log "service $MysqlService running" 'OK'

    }   # end of the create-the-server block

    # --- bootstrap ----------------------------------------------------------
    # --initialize-insecure leaves root with an EMPTY password, so this must run
    # immediately. Generated, never supplied; root is shown once and not stored.
    #
    # On a server this stage already built, root HAS a password, so the
    # --skip-password connection below cannot authenticate. The database, the
    # user and the handoff all exist from that run, so there is nothing to do -
    # skip rather than fail. Without the handoff there is no safe automatic
    # recovery, so say what to do instead of guessing.
    if ($script:MysqlAlreadyOurs) {
        if (Test-Path (Join-Path $AppRoot '.dbpass')) {
            Write-Log 'database, user and password handoff already exist; skipping bootstrap' 'OK'
            Write-Log 'stage 0 complete' 'OK'
            return
        }
        Fail 'MySQL is installed by a previous run but its password handoff is gone' @'
The bundled server exists and root already has a generated password, so this
stage cannot re-create the application user, and .dbpass is not there to reuse.

Either point the installer at this server with -DbHost 127.0.0.1 and the
password from C:\shopdb-flask\.env, or remove the MySQL84 service and its data
directory to install cleanly.
'@
    }
    $rootPass = New-Secret -Bytes 24
    $appPass  = New-Secret -Bytes 24
    # Written OUTSIDE %TEMP% and ACL'd BEFORE the secrets go in - a file created
    # with default ACLs and populated afterwards is readable in the gap.
    $sqlPath = Join-Path $AppRoot 'mysql-bootstrap.sql'
    New-Item -ItemType Directory -Force -Path $AppRoot | Out-Null
    # Before the file goes in: this may be the first thing that creates AppRoot,
    # and the file below holds the root and app database passwords.
    Protect-InstallRoot $AppRoot
    New-Item -ItemType File -Force -Path $sqlPath | Out-Null
    Protect-File $sqlPath
    $sql = @"
CREATE DATABASE IF NOT EXISTS $DbName CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '$DbUser'@'localhost' IDENTIFIED BY '$appPass';
CREATE USER IF NOT EXISTS '$DbUser'@'127.0.0.1' IDENTIFIED BY '$appPass';
-- ::1 as well. On current Windows 'localhost' resolves to the IPv6 loopback
-- FIRST, so an operator who types localhost instead of 127.0.0.1 arrives as
-- '$DbUser'@'::1' - an account that did not exist, and MySQL answers "Access
-- denied" naming a host the operator never typed.
CREATE USER IF NOT EXISTS '$DbUser'@'::1' IDENTIFIED BY '$appPass';
-- ALTER after CREATE, because CREATE USER IF NOT EXISTS is a NO-OP on an
-- existing user and does NOT change its password. This stage generates a fresh
-- password every run and overwrites .dbpass with it, so on any re-run after a
-- partial failure the file held a password the server had never been told -
-- "Access denied" while the operator was looking straight at the right file.
-- ALTER makes the stored password and the handoff agree, every time.
ALTER USER '$DbUser'@'localhost' IDENTIFIED BY '$appPass';
ALTER USER '$DbUser'@'127.0.0.1' IDENTIFIED BY '$appPass';
ALTER USER '$DbUser'@'::1' IDENTIFIED BY '$appPass';
GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'localhost';
GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'127.0.0.1';
GRANT ALL PRIVILEGES ON $DbName.* TO '$DbUser'@'::1';
ALTER USER 'root'@'localhost' IDENTIFIED BY '$rootPass';
FLUSH PRIVILEGES;
"@
    Set-Content -Path $sqlPath -Value $sql -Encoding ASCII
    try {
        $mysqlExe = Join-Path $MysqlRoot 'bin\mysql.exe'
        # -e "source <file>" fails with ERROR 1049 Unknown database '<path>': the
        # client reads the path as a database name. Redirect stdin instead. That
        # also stops PowerShell eating the backticks MySQL uses to quote identifiers.
        Invoke-Native $mysqlExe @('-uroot', '--skip-password', '--protocol=TCP',
                                  "--port=$DbPort") 'MySQL bootstrap' -StdinFile $sqlPath
    }
    finally {
        # Unconditional: this file holds both passwords.
        Set-Content -Path $sqlPath -Value ('0' * 512) -Encoding ASCII -ErrorAction SilentlyContinue
        Remove-Item $sqlPath -Force -ErrorAction SilentlyContinue
    }

    # Hand the app password to stage 2 through an ACL'd FILE, never stdout -
    # stdout lands in the log that FR-161 invites operators to send to support.
    $handoff = Join-Path $AppRoot '.dbpass'
    New-Item -ItemType File -Force -Path $handoff | Out-Null
    Protect-File $handoff
    Set-Content -Path $handoff -Value $appPass -Encoding UTF8
    Write-Log "database $DbName and user $DbUser created" 'OK'
    Write-Log "app password written to $handoff (stage 2 consumes and deletes it)"

    # NOT Write-Host. Under the wizard this process runs hidden, so nobody ever
    # saw this - and every line of stdout is forwarded into the setup log that
    # operators are told to send to support. The generated root password was
    # therefore invisible to the person who needed it and permanently recorded
    # for everyone who did not.
    #
    # Write it to a file only Administrators and SYSTEM can read, and let the
    # wizard tell the operator where it is.
    $rootFile = Join-Path $env:ProgramData 'ShopDB-Flask\mysql-root-password.txt'
    $rootDir = Split-Path $rootFile -Parent
    if (-not (Test-Path $rootDir)) { New-Item -ItemType Directory -Path $rootDir -Force | Out-Null }
    New-Item -ItemType File -Force -Path $rootFile | Out-Null
    Protect-File $rootFile
    Set-Content -Path $rootFile -Value @(
        'MySQL root password for this server, generated during installation.',
        'It is not recorded anywhere else and cannot be recovered.',
        'Move it into your password manager, then delete this file.',
        '',
        $rootPass
    ) -Encoding UTF8
    Write-Log "MySQL root password written to $rootFile (Administrators and SYSTEM only)" 'OK'
    Write-Log 'stage 0 complete' 'OK'
}

# =============================================================================
# STAGE 2 - runtime: Python, venv, wheels, .env
# =============================================================================
function Invoke-Stage2 {
    Write-Log 'STAGE 2: runtime and configuration' 'STEP'

    if (-not (Test-Path $BundleRoot)) { Fail "bundle not found: $BundleRoot" }
    if (-not (Test-Path $WheelDir))   { Fail "wheelhouse not found: $WheelDir" 'Rebuild the bundle with deploy\windows\installer\build-installer.ps1 (or .sh); it refuses to produce a bundle without one.' }
    if (-not (Test-Path $AppSource))  { Fail "application payload not found: $AppSource" }
    Assert-BundleIntegrity
    Assert-VenvMatchesWheelhouse
    # Captured HERE, before the stamp below is written. Stage 4's guard against
    # touching an installation this did not create tested the stamp directly, and
    # stage 2 always runs first in a '-Stage all' install - so the stamp it had
    # just written made every server look like one of ours and the guard never
    # fired on the case it exists for.
    $script:PreexistingInstallStamp = Test-Path (Join-Path $AppRoot '.installed-version')

    # --- Python, all users --------------------------------------------------
    # A per-user install lands in %LOCALAPPDATA%, which the IIS app-pool identity
    # cannot read. That produces a 500 with an empty HttpPlatform log.
    $pyInstaller = Get-ChildItem (Join-Path $BundleRoot 'python') -Filter '*.exe' -ErrorAction SilentlyContinue |
                   Select-Object -First 1
    # SPACE-FREE deliberately. `TargetDir=C:\Program Files\Python314` splits at
    # the space when passed to the bootstrapper and installs into C:\Program\.
    # Same class of failure as MySQL's --defaults-file. Never put a space in a
    # path this installer controls.
    $pyTarget = 'C:\Python314'
    # Stage 4 needs it to grant the app pool read access; a venv's python.exe is
    # a copy, but python314.dll and the stdlib still come from here.
    $script:PyRoot = $pyTarget
    if (Test-Path (Join-Path $pyTarget 'python.exe')) {
        Write-Log "Python already present at $pyTarget" 'OK'
        # NOT hardened: something else installed it and may depend on the ACL it
        # has. Say so instead - the app would otherwise run on an interpreter
        # any logged-on user can replace.
        if (Test-UserWritableRoot $pyTarget) {
            Write-Log "$pyTarget is writable by non-administrators; this installer did not create it, so it is left alone. Harden it or reinstall Python under an ACL only administrators can write." 'WARN'
        }
    } elseif ($null -eq $pyInstaller) {
        Fail "no Python installer in $BundleRoot\python"
    } else {
        Write-Log "installing Python (all users) from $($pyInstaller.Name)"
        if (-not $WhatIfOnly) {
            # /norestart matters: without it the bootstrapper is free to reboot
            # the server on its own, mid-install, with the wizard still running.
            Invoke-Native $pyInstaller.FullName @(
                '/quiet','/norestart','InstallAllUsers=1','PrependPath=0','Include_launcher=1',
                'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget"
            ) 'Python install' -TimeoutSec 900 -OkExit 0,3010,1641
            Track 'python' $pyTarget
            # Ours, so ours to lock down. InstallAllUsers=1 puts it at C:\Python314
            # with the drive root's inherited Authenticated Users Modify.
            Protect-InstallRoot $pyTarget
        }
    }
    $sysPy = Join-Path $pyTarget 'python.exe'
    if (-not $WhatIfOnly -and -not (Test-Path $sysPy)) { Fail "Python not at $sysPy after install" }

    # --- application payload ------------------------------------------------
    if (-not (Test-Path $AppRoot)) {
        New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null
        Track 'dir' $AppRoot
    }
    Protect-InstallRoot $AppRoot
    # --- upgrade detection ---------------------------------------------------
    $script:BundleVersion    = Get-BundleVersion
    $script:InstalledVersion = Get-InstalledVersion
    $script:IsUpgrade        = ($script:InstalledVersion -ne '')
    $script:DbWasEmpty       = $false   # established for real in stage 3

    if ($script:IsUpgrade) {
        Write-Log ("upgrading: installed {0} -> bundle {1}" -f $script:InstalledVersion, $script:BundleVersion) 'STEP'
        $cmp = Compare-Version $script:BundleVersion $script:InstalledVersion
        if ($cmp -lt 0) {
            # Migrations only go forwards. Running older code against a newer
            # schema fails in ways that are hard to unpick, so refuse outright.
            Fail ("this bundle ({0}) is OLDER than what is installed ({1})" -f $script:BundleVersion, $script:InstalledVersion) @'
Downgrading is not supported: the database schema has already been migrated
forwards, and older code cannot read it. Install a build at least as new as
what is on this server, or restore a backup taken before the upgrade.
'@
        }
        if ($cmp -eq 0) {
            Write-Log 'same version already installed; re-running is harmless (repair)' 'WARN'
        }
    } else {
        Write-Log ("installing version {0}" -f $script:BundleVersion)
    }

    # UPGRADE PATH: on an existing install the app pool holds python.exe and the
    # site files open, so copying over them fails with "file in use" partway
    # through - leaving a half-replaced application. Stop the pool first and note
    # that we did, so stage 4 knows to start it again.
    $script:PoolWasStopped = $false
    if (-not $WhatIfOnly) {
        try {
            Import-Module WebAdministration -ErrorAction Stop
            if ((Test-Path "IIS:\AppPools\$AppPool") -and
                ((Get-Item "IIS:\AppPools\$AppPool").State -eq 'Started')) {
                Write-Log "stopping app pool $AppPool so its files can be replaced" 'WARN'
                Stop-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue
                # The worker does not exit instantly; copying too soon still fails.
                for ($i = 0; $i -lt 15; $i++) {
                    Start-Sleep -Seconds 1
                    if (-not (Get-Process w3wp -ErrorAction SilentlyContinue)) { break }
                }
                $script:PoolWasStopped = $true
            }
        } catch { }
    }

    # Plugin directories present on the server but NOT in the new bundle are
    # removed BEFORE the copy. The copy merges rather than replaces, so a plugin
    # dropped from a site's profile kept its code on disk forever - which defeats
    # the point of a lean build (ADR-013) and, worse, leaves the core's
    # `try: from plugins.X.models import ...` guards succeeding for a plugin this
    # site no longer has, so SQLAlchemy still builds the relationship and queries
    # still join a table that may have been pruned.
    if (-not $WhatIfOnly) {
        $newPlugins = @()
        $srcPlugins = Join-Path $AppSource 'plugins'
        if (Test-Path $srcPlugins) {
            $newPlugins = @(Get-ChildItem $srcPlugins -Directory | Select-Object -ExpandProperty Name)
        }
        $livePlugins = Join-Path $AppRoot 'plugins'
        if ($newPlugins.Count -gt 0 -and (Test-Path $livePlugins)) {
            foreach ($dir in (Get-ChildItem $livePlugins -Directory)) {
                if ($newPlugins -notcontains $dir.Name) {
                    Write-Log "removing '$($dir.Name)': not in this build" 'WARN'
                    # Uninstall FIRST, while the code is still importable - the
                    # registry entry outlives the directory otherwise, and the app
                    # then fails to load a plugin it still believes is installed.
                    $deregistered = $false
                    try {
                        if (Test-Path $Flask) {
                            Push-Location $AppRoot
                            try {
                                $env:FLASK_APP = 'shopdb'
                                # --yes is REQUIRED: the command carries a click
                                # confirmation_option, which aborts with exit 1
                                # when there is no console to answer the prompt.
                                # Without it this deregistration could never once
                                # have succeeded.
                                #
                                # EAP bracket for the reason documented on
                                # Invoke-Native: '2>&1' under EAP 'Stop' turns any
                                # stderr line into a terminating error, which the
                                # catch below then downgraded to a WARN while the
                                # code was deleted regardless.
                                $prevEap = $ErrorActionPreference
                                $ErrorActionPreference = 'Continue'
                                try {
                                    & $Flask plugin uninstall $dir.Name --yes 2>&1 |
                                        ForEach-Object { Write-Log "    $_" }
                                    $deregistered = ($LASTEXITCODE -eq 0)
                                } finally { $ErrorActionPreference = $prevEap }
                            } finally { Pop-Location }
                        }
                    } catch { Write-Log "  could not deregister $($dir.Name): $($_.Exception.Message)" 'WARN' }
                    # Delete the code ONLY once the registry entry is really gone.
                    # Deleting it anyway left the application loading a plugin
                    # whose directory no longer existed - the exact failure the
                    # uninstall-first ordering above exists to avoid.
                    if ($deregistered) {
                        Remove-Item $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
                    } else {
                        Write-Log "  left $($dir.Name) on disk: deregistration did not succeed" 'WARN'
                    }
                }
            }
        }
    }

    Write-Log "copying application payload to $AppRoot"
    if (-not $WhatIfOnly) {
        Copy-Item (Join-Path $AppSource '*') -Destination $AppRoot -Recurse -Force
    }
    # Pick the SPA build that matches the deployment method. Vite compiles the
    # base path in, so the wrong one loads a page that then fetches its assets
    # from the wrong prefix and renders nothing.
    if (-not $WhatIfOnly) {
        $rootDist = Join-Path $AppRoot 'frontend\dist'
        $subDist  = Join-Path $AppRoot 'frontend\dist-subpath'
        if ($MountAlias) {
            if (-not (Test-Path $subDist)) {
                Fail 'this bundle has no subpath frontend build' @'
The bundle was built without the /<alias> SPA, so a subpath install would serve
a page that cannot load its own assets. Rebuild with scripts/build-site.sh
(which produces both), or install without -MountAlias.
'@
            }
            # The alias is fixed at BUILD time; refuse rather than mis-serve.
            $built = ''
            $aliasFile = Join-Path $subDist '.alias'
            if (Test-Path $aliasFile) { $built = (Get-Content $aliasFile -TotalCount 1).Trim() }
            if ($built -and ($built -ne $MountAlias.Trim('/'))) {
                Fail ("this bundle's subpath build is for '/{0}', not '/{1}'" -f $built, $MountAlias.Trim('/')) `
                     'Rebuild the bundle with SUBPATH_ALIAS set to the alias you want.'
            }
            Remove-Item $rootDist -Recurse -Force -ErrorAction SilentlyContinue
            Move-Item $subDist $rootDist -Force
            Write-Log ("using the /{0} frontend build" -f $MountAlias.Trim('/')) 'OK'
        } else {
            # Not needed for a root install; leave nothing confusing behind.
            Remove-Item $subDist -Recurse -Force -ErrorAction SilentlyContinue
        }
    }

    if (-not $WhatIfOnly -and $script:BundleVersion) {
        # Stamped AFTER the copy: if the copy dies, the stamp still reflects what
        # is actually on disk rather than what we hoped to put there.
        Set-Content -Path (Join-Path $AppRoot '.installed-version') `
                    -Value $script:BundleVersion -Encoding ASCII
    }
    # Keep the lock with the install so `shopdb-admin.ps1 verify` can say WHICH
    # bundle this server was built from, months later and offline.
    if (-not $WhatIfOnly) {
        $bundledLock = Join-Path $BundleRoot 'bundle-lock.json'
        if (Test-Path $bundledLock) { Copy-Item $bundledLock $AppRoot -Force }
        # The MySQL client stays on the server. The bundle is extracted to a temp
        # directory and deleted when Setup exits, so a copy that lived only there
        # would leave `shopdb-admin backup` with nothing to run on a site whose
        # database is on another host.
        $bundledClient = Join-Path $BundleRoot 'mysqlclient'
        if (Test-Path $bundledClient) {
            $target = Join-Path $AppRoot 'mysqlclient'
            if (-not (Test-Path $target)) { New-Item -ItemType Directory -Path $target -Force | Out-Null }
            Copy-Item (Join-Path $bundledClient '*') $target -Recurse -Force
            Write-Log 'MySQL client staged for backups' 'OK'
        }
    }
    foreach ($sub in @('logs','instance')) {
        $p = Join-Path $AppRoot $sub
        if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null }
    }

    # --- venv ---------------------------------------------------------------
    if (Test-Path $Py) {
        Write-Log 'venv already exists' 'OK'
    } else {
        Write-Log 'creating venv'
        if (-not $WhatIfOnly) {
            Invoke-Native $sysPy @('-m','venv',(Join-Path $AppRoot 'venv')) 'venv creation'
            Track 'dir' (Join-Path $AppRoot 'venv')
        }
    }

    # --- offline dependency install ----------------------------------------
    # PIP_NO_INDEX makes a network attempt impossible rather than merely
    # unnecessary. On an air-gapped box pip otherwise hangs on DNS timeouts.
    #
    # --require-hashes puts pip in hash-checking mode: every wheel must match a
    # sha256 listed in requirements.txt or the install ABORTS. Without it pip
    # took whatever file in the wheelhouse satisfied the version pin, so a
    # swapped or hand-dropped wheel installed silently. The flag is passed
    # explicitly rather than relying on pip inferring it from the presence of
    # hashes, so shipping an unhashed requirements.txt fails loudly here instead
    # of quietly dropping the check.
    #
    # --only-binary=:all: refuses to fall back to building from an sdist. On a
    # server with no compiler and no network that fallback cannot succeed; it
    # just turns a clear "no wheel for this platform" into a confusing build
    # error deep in someone's setup.py.
    Write-Log 'installing dependencies from the wheelhouse (offline, hash-checked)'
    if (-not $WhatIfOnly) {
        $env:PIP_NO_INDEX    = '1'
        $env:PIP_FIND_LINKS  = $WheelDir
        $env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
        try {
            Invoke-Native $Py @('-m','pip','install','--no-index',"--find-links=$WheelDir",
                                '--require-hashes','--only-binary=:all:',
                                '-r',(Join-Path $AppRoot 'requirements.txt')) 'dependency install'
        } finally {
            Remove-Item Env:\PIP_NO_INDEX, Env:\PIP_FIND_LINKS -ErrorAction SilentlyContinue
        }
        # waitress and tzdata are load-bearing on Windows; verify rather than assume.
        foreach ($mod in @('waitress','tzdata')) {
            & $Py -c "import $mod" 2>$null
            if ($LASTEXITCODE -ne 0) {
                Fail "$mod did not install" "The wheelhouse is incomplete. $mod is required (waitress is the WSGI server; tzdata provides the IANA timezone database Windows lacks)."
            }
        }
        Write-Log 'waitress and tzdata present' 'OK'
    }

    # --- .env ---------------------------------------------------------------
    # The stage-0 handoff is checked BEFORE the .env branch, not inside its else.
    # It used to sit in the else, so it was consulted only when .env was absent -
    # and on a part-finished install .env DOES exist, holding whatever password
    # stage 2 last wrote. If stage 0 has since regenerated the credential, .env
    # is stale, .dbpass is correct, and the installer preferred the stale one:
    # "Access denied", with the right password sitting unread on disk.
    #
    # .dbpass only exists between stage 0 and stage 5 - stage 5 shreds it once
    # the install is proven - so its presence means "this install is unfinished
    # and THIS is the current password". Applied only when .env points at the
    # local server, because the handoff describes the bundled database and must
    # never redirect a site whose database lives elsewhere.
    $handoff = Join-Path $AppRoot '.dbpass'
    if (-not $DbPasswordFile -and (Test-Path $handoff)) {
        $envIsLocal = $true
        if (Test-Path $EnvFile) {
            $envIsLocal = $false
            $urlLine = Get-Content $EnvFile -ErrorAction SilentlyContinue |
                       Where-Object { $_ -like 'DATABASE_URL=*' } | Select-Object -First 1
            if ($urlLine -and ($urlLine -match '@(127\.0\.0\.1|localhost|\[::1\]):')) { $envIsLocal = $true }
        }
        if ($envIsLocal) {
            $DbPasswordFile = $handoff
            if (-not $DbHost) { $DbHost = '127.0.0.1' }
            Write-Log 'using the stage-0 password handoff (an unfinished install; .env may be stale)'
        }
    }

    if (Test-Path $EnvFile) {
        # Preserving .env is right for a plain re-run: regenerating JWT_SECRET_KEY
        # would invalidate every issued session.
        #
        # But if the caller EXPLICITLY supplied connection details - a wizard where
        # someone just typed a password, or -DbHost/-DbPasswordFile on the command
        # line - then silently keeping the old DATABASE_URL ignores what they asked
        # for and fails later with a bare "Access denied", pointing at the database
        # instead of at the stale file that actually caused it.
        # So: keep the secrets, refresh the connection string.
        # Rewrite the connection string ONLY when a password was actually
        # supplied. Previously any -DbHost (which the wizard always passes, from
        # a page whose defaults were 127.0.0.1/shopdb_flask) rewrote DATABASE_URL
        # - so a site whose database lives on another server had its real
        # connection string silently replaced with defaults during an upgrade,
        # and .env was the only record of it.
        if ($DbPasswordFile) {
            Write-Log '.env exists and new database details were supplied; updating DATABASE_URL' 'WARN'
            $dbPass = ''
            if ($DbPasswordFile) {
                if (-not (Test-Path $DbPasswordFile)) { Fail "password file not found: $DbPasswordFile" }
                $dbPass = (Get-Content $DbPasswordFile -TotalCount 1 -Encoding UTF8)
                if ($dbPass) { $dbPass = $dbPass.Trim() }
                if ($DbPasswordFile -ne (Join-Path $AppRoot '.dbpass')) {
                    Set-Content -Path $DbPasswordFile -Value ('0' * 256) -Encoding UTF8 -Force -ErrorAction SilentlyContinue
                    Remove-Item $DbPasswordFile -Force -ErrorAction SilentlyContinue
                }
            }
            if (-not $dbPass) { Fail 'no database password supplied to update .env' }
            if (-not $DbHost) { $DbHost = '127.0.0.1' }
            $escUser = [uri]::EscapeDataString($DbUser)
            $escPass = [uri]::EscapeDataString($dbPass)
            $newUrl  = "DATABASE_URL=mysql+pymysql://${escUser}:${escPass}@${DbHost}:${DbPort}/${DbName}?charset=utf8mb4"
            # Keep a copy before touching it. .env holds the DB password in
            # plaintext by design, so the backup gets the same owner-only ACL and
            # is the operator's way back if the new details turn out wrong.
            $envBak = "$EnvFile.bak-{0}" -f (Get-Date -Format 'yyyyMMdd-HHmmss')
            Copy-Item $EnvFile $envBak -Force -ErrorAction SilentlyContinue
            if (Test-Path $envBak) {
                Protect-File $envBak
                Write-Log "previous .env kept at $envBak"
            }
            $kept = Get-Content $EnvFile | Where-Object { $_ -notmatch '^\s*DATABASE_URL=' }
            Set-Content -Path $EnvFile -Value ($kept + $newUrl) -Encoding ASCII
            Protect-File $EnvFile
            Write-Log 'DATABASE_URL updated; SECRET_KEY and JWT_SECRET_KEY kept' 'OK'
        } else {
            Write-Log '.env exists and no new password was supplied; keeping it unchanged' 'OK'
            Write-Log '  (its database settings and secret keys are left exactly as they are)'
        }

        # MOUNT_PATH must ALWAYS track the chosen deployment method, whether or
        # not the connection string changed. Switching from a subpath install
        # back to its own site otherwise leaves MOUNT_PATH=/shopdb behind, and
        # wsgi.py keeps mounting the app at /shopdb - so the new site answers
        # 404 on every path and the smoke test fails on a correct install.
        # CORS_ORIGINS is tied to the deployment method too: switching between
        # methods changes the port people arrive on, and a stale value fails every
        # browser request while curl (which does not send Origin) still works.
        $wantOrigin = ''
        if ($MountAlias) {
            $parentPort = 80
            try {
                Import-Module WebAdministration -ErrorAction SilentlyContinue
                $b = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection |
                     Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
                if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] }
            } catch { }
            if (-not $SiteHost) { $SiteHost = $env:COMPUTERNAME }
            if ($parentPort -eq 80) { $wantOrigin = "http://{0}" -f $SiteHost }
            else { $wantOrigin = "http://{0}:{1}" -f $SiteHost, $parentPort }
        } elseif ($SiteHost) {
            $wantOrigin = "http://{0}:{1}" -f $SiteHost, $SitePort
        }

        $envLines = @(Get-Content $EnvFile | Where-Object { $_ -notmatch '^\s*MOUNT_PATH=' })
        if ($wantOrigin) {
            $envLines = @($envLines | Where-Object { $_ -notmatch '^\s*CORS_ORIGINS=' })
            $envLines += ("CORS_ORIGINS={0}" -f $wantOrigin)
            Write-Log "CORS_ORIGINS set to $wantOrigin" 'OK'
        }
        if ($MountAlias) {
            $envLines += ("MOUNT_PATH=/{0}" -f $MountAlias.Trim('/'))
            Write-Log ("MOUNT_PATH set to /{0}" -f $MountAlias.Trim('/')) 'OK'
        } else {
            Write-Log 'MOUNT_PATH removed - the app serves from the site root' 'OK'
        }
        Set-Content -Path $EnvFile -Value ($envLines -join "`r`n") -Encoding ASCII
        Protect-File $EnvFile
    } else {
        if (-not $SiteHost) { $SiteHost = $env:COMPUTERNAME }

        # The stage-0 handoff was already applied above, for both branches.

        if (-not $DbHost) {
            Fail 'no database host supplied and no existing .env' @'
Pass -DbHost <server>. The password is prompted for, never passed on the
command line. For a bundled-database install run stage 0 first: it creates the
database and leaves an ACL'd handoff file, after which no password is needed.
'@
        }
        # Two ways in, both keeping the password off the command line (which any
        # user can read via Win32_Process, and which lands in transcripts and
        # ConsoleHost_history.txt).
        if ($DbPasswordFile) {
            # Unattended path. The caller writes an ACL'd file; we consume and
            # shred it so it does not outlive the install.
            if (-not (Test-Path $DbPasswordFile)) { Fail "password file not found: $DbPasswordFile" }
            $dbPass = (Get-Content $DbPasswordFile -TotalCount 1 -Encoding UTF8)
            if ($dbPass) { $dbPass = $dbPass.Trim() }
            Write-Log "read DB password from $DbPasswordFile"
            if ($DbPasswordFile -eq $handoff) {
                # Do NOT shred the stage-0 handoff here. It holds the ONLY copy of a
                # GENERATED password: .env is the sole other copy, and rollback
                # deletes .env. Shredding both on a failed install leaves an
                # unrecoverable box - the database exists, the app user exists, and
                # nobody alive knows the password. Stage 5 shreds it once the
                # install is proven working.
                Write-Log 'keeping the stage-0 handoff until stage 5 confirms the install'
            } else {
                try {
                    # Operator-supplied file: they know the password, so consume it
                    # immediately. Overwrite before delete - a plain Remove-Item
                    # leaves the bytes on disk.
                    Set-Content -Path $DbPasswordFile -Value ('0' * 256) -Encoding UTF8 -Force
                    Remove-Item $DbPasswordFile -Force
                    Write-Log 'password file overwritten and removed'
                } catch { Write-Log "could not remove $DbPasswordFile - delete it manually" 'WARN' }
            }
        }
        else {
            # Interactive path. Read-Host -AsSecureString reads the CONSOLE, not
            # stdin: piping into it does not work, it just blocks forever with no
            # error and no timeout. So refuse up front rather than hang a remote or
            # scheduled run.
            if (-not [Environment]::UserInteractive) {
                Fail 'no interactive console for the password prompt' @'
This session has no console, so the password prompt would hang indefinitely
(Read-Host -AsSecureString reads the console directly and ignores piped stdin).
Re-run with -DbPasswordFile <path> pointing at a file whose first line is the
password. The installer reads it, overwrites it and deletes it.
'@
            }
            $sec = Read-Host -Prompt ("Password for MySQL user '{0}' on {1}" -f $DbUser, $DbHost) -AsSecureString
            $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
            try   { $dbPass = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
            finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
        }
        if (-not $dbPass) { Fail 'no password entered' }
        # Percent-encode: passwords routinely contain characters that break a URL.
        $escUser = [uri]::EscapeDataString($DbUser)
        $escPass = [uri]::EscapeDataString($dbPass)
        $DatabaseUrl = "mysql+pymysql://${escUser}:${escPass}@${DbHost}:${DbPort}/${DbName}?charset=utf8mb4"
        # CORS_ORIGINS must be the ORIGIN PEOPLE ACTUALLY USE, and an explicit one -
        # a wildcard makes the app refuse to boot. Under a subpath install the app
        # is reached on the PARENT site's port (usually 80), so pinning the
        # stand-alone port here would make every browser request fail CORS on a
        # correctly installed server. Origin is scheme+host+port only - never the path.
        if ($MountAlias) {
            $parentPort = 80
            try {
                Import-Module WebAdministration -ErrorAction SilentlyContinue
                $b = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection |
                     Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
                if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] }
            } catch { }
            if ($parentPort -eq 80) { $origin = "http://{0}" -f $SiteHost }
            else { $origin = "http://{0}:{1}" -f $SiteHost, $parentPort }
        } else {
            $origin = "http://{0}:{1}" -f $SiteHost, $SitePort
        }
        $content = @(
            'FLASK_ENV=production'
            ("SECRET_KEY={0}"     -f (New-Secret))
            ("JWT_SECRET_KEY={0}" -f (New-Secret))
            ("DATABASE_URL={0}"   -f $DatabaseUrl)
            ("CORS_ORIGINS={0}"   -f $origin)
            'LOG_LEVEL=INFO'
        )
        if ($MountAlias) {
            # wsgi.py reads MOUNT_PATH and moves the prefix from PATH_INFO to
            # SCRIPT_NAME, so Flask both routes and GENERATES urls under it.
            # Added to the ARRAY, before the join - appending to the joined string
            # glues it onto the previous line ("LOG_LEVEL=INFOMOUNT_PATH=/shopdb").
            $content += ("MOUNT_PATH=/{0}" -f $MountAlias.Trim('/'))
        }
        $content = $content -join "`r`n"
        Write-Log "writing .env (CORS_ORIGINS=$origin)"
        if (-not $WhatIfOnly) {
            Set-Content -Path $EnvFile -Value $content -Encoding ASCII
            Track 'file' $EnvFile
            # .env holds the database password in plaintext BY DESIGN - the app
            # reads it at boot. Filesystem permissions are the only control.
            # Administrators and SYSTEM only; the app-pool identity gets Read in stage 4.
            Protect-File $EnvFile
            # Clear the plaintext copy from this session's memory.
            $dbPass = $null; $content = $null
            [GC]::Collect()
        }
    }
    Write-Log 'stage 2 complete' 'OK'
}

# =============================================================================
# STAGE 3 - data: schema, seeds, plugins
# =============================================================================
function Invoke-Stage3 {
    Write-Log 'STAGE 3: schema, seed data, plugins' 'STEP'
    if (-not (Test-Path $Flask)) { Fail 'venv not found; run stage 2 first' }

    # Resolve the site profile. An explicit -SiteProfile wins; otherwise use the
    # one the bundle was built from, which scripts/build-site.sh stages alongside
    # the app tree so the profile and the lean tree can never drift apart.
    $ProfilePath = $SiteProfile
    if (-not $ProfilePath) { $ProfilePath = Join-Path $AppRoot 'site-profile.json' }
    if (-not (Test-Path $ProfilePath)) {
        Fail "site profile not found: $ProfilePath" @'
The installer no longer carries a hardcoded plugin list - the site's set is
declared in a profile and the bundle is built lean for it.
Either pass -SiteProfile <path>, or rebuild the bundle with
scripts/build-site.sh <profile.json>, which stages the profile into the app tree.
See deploy/site-profile.example.json for the format.
'@
    }
    # Report the chosen set up front: it decides both what gets installed and what
    # prune-schema drops, so it belongs in the log FR-161 sends to support.
    try {
        $declared = (Get-Content $ProfilePath -Raw | ConvertFrom-Json).plugins -join ', '
        Write-Log "site profile: $ProfilePath"
        Write-Log "declared plugins: $declared"
    } catch { Fail "site profile is not valid JSON: $ProfilePath" }

    $env:FLASK_APP = 'shopdb'
    Push-Location $AppRoot
    try {
        # The app ships its own preflight. Use it rather than reimplementing:
        # it checks Python, required env, DB connectivity and the MySQL 5.6 flags.
        Write-Log 'running flask db-utils preflight'
        # TRAP (documented in Invoke-Native, and hit again here): the app logs
        # plugin initialisation to STDERR even on success. With 2>&1 and
        # $ErrorActionPreference='Stop' PowerShell turns that into a terminating
        # NativeCommandError, so a healthy preflight aborts the install with a
        # plugin INFO line as the "error". Judge by the OUTPUT, not by stderr.
        $prevEAP = $ErrorActionPreference
        $ErrorActionPreference = 'Continue'
        try { $pf = & $Flask db-utils preflight 2>&1 }
        finally { $ErrorActionPreference = $prevEAP }
        $pf | ForEach-Object { Write-Log "  $_" }
        # Match the preflight's own status column ("  FAIL <check>"), not any line
        # that merely contains the word - plugin log lines can too.
        $pfFailed = @($pf | Where-Object { $_ -match '^\s*FAIL\s' }).Count -gt 0
        if ($LASTEXITCODE -ne 0 -or $pfFailed) {
            Fail 'application preflight reported failures' 'Fix the FAIL items above, then re-run stage 3.'
        }

        if ($WhatIfOnly) { Write-Log 'WhatIf: skipping migrations and seeds' 'WARN'; return }

        # Decide fresh-vs-existing from the DATABASE, and do it HERE rather than
        # relying on a variable set in stage 2 - stage 3 is documented as
        # separately runnable ("re-run stage 3"), and under Set-StrictMode
        # reading an unset $script:IsUpgrade is a terminating error.
        $script:DbWasEmpty = Test-DatabaseEmpty
        # Tables present does NOT mean upgrade. It meant that for a long time,
        # and it is why the retry after a failed first install dead-ended: stage 3
        # had already created the schema before dying, so the retry found tables,
        # called itself an upgrade of a live site, and then demanded a mandatory
        # backup and refused to prune - of a database it had written itself.
        # The install record distinguishes the two, which nothing on the machine
        # can.
        $firstRun = Test-FirstProvisioning
        if (-not $script:DbWasEmpty) {
            if ($firstRun) {
                Write-Log 'the database already has tables, but the install record shows first provisioning is still unfinished' 'WARN'
                Write-Log '  they were created by an earlier attempt of THIS install, so this is not an upgrade' 'WARN'
            } else {
                $script:IsUpgrade = $true
                Write-Log 'existing data found in the target database - treating this as an upgrade' 'WARN'
            }
        }

        # Back up BEFORE Alembic touches anything. Skipped only when the database
        # is provably empty; on anything else it is the only thing standing
        # between a failed migration and a half-migrated database.
        $script:PreUpgradeBackup = ''
        if (-not $script:DbWasEmpty) {
            $script:PreUpgradeBackup = Backup-Database 'pre-upgrade'
            if (-not $script:PreUpgradeBackup) {
                if ($firstRun) {
                    # Nothing of anyone's to lose: every row was written by an
                    # earlier attempt of this same install. Refusing here turned a
                    # missing mysqldump into a hard dead end on exactly the retry
                    # the wizard tells the operator is safe to run.
                    Write-Log 'no pre-migration backup was taken' 'WARN'
                    Write-Log '  continuing anyway: this is still first provisioning, so the only data present is what an earlier attempt wrote' 'WARN'
                } else {
                    Fail 'could not back up the database before upgrading' @'
Refusing to migrate without a backup. Fix mysqldump access (or take a backup
manually with shopdb-admin.ps1 backup) and run this again.
'@
                }
            }
        }

        # Alembic owns the schema. Nothing else may create tables.
        Write-Log 'flask db upgrade'
        try {
            Invoke-Native $Flask @('db','upgrade') 'schema upgrade'
        } catch {
            if ($script:PreUpgradeBackup) {
                Write-Log 'migration failed - putting the database back' 'FAIL'
                if (Restore-Database $script:PreUpgradeBackup) {
                    Fail 'the upgrade failed; your data was restored from the pre-upgrade backup' @'
Your DATA is intact. The schema may still carry tables the failed migration
created before it stopped - the log lists any of them, and they are empty.
The application files on disk are the NEW version, so re-install the PREVIOUS
build to get a matching pair, then send the install log to support.
'@
                }
            }
            throw
        }

        foreach ($seed in @('reference-data','permissions','settings')) {
            Write-Log "flask seed $seed"
            Invoke-Native $Flask @('seed',$seed) "seed $seed"
        }

        # Additive and idempotent, so a re-run adds only what is genuinely new
        # and never overwrites a description somebody corrected here.
        if ($SeedCatalog) {
            Write-Log 'flask seed catalog   (shared vendor and model catalog)'
            $catalogOut = Invoke-Native $Flask @('seed','catalog') 'seed catalog' -OkExit 0,1
            $catalogOut | Select-Object -Last 12 | ForEach-Object { Write-Log "    $_" }
        }

        # Plugin registry starts empty on a fresh box. apply-profile resolves the
        # declared set's hard-dependency closure and installs + enables it in
        # dependency order, idempotently (ADR-013). It fails loudly if the profile
        # names a plugin that is not on disk - which on a lean bundle correctly
        # catches a profile/bundle mismatch.
        # An install-time selection overrides the bundled profile. Written here,
        # after the payload copy, so it survives stage 2 replacing the app tree.
        if ($SitePlugins) {
            $chosen = @($SitePlugins -split ',' | ForEach-Object { $_.Trim() } |
                        Where-Object { $_ })
            $onDisk = @(Get-ChildItem (Join-Path $AppRoot 'plugins') -Directory -ErrorAction SilentlyContinue |
                        Where-Object { Test-Path (Join-Path $_.FullName 'manifest.json') } |
                        Select-Object -ExpandProperty Name)
            $missing = @($chosen | Where-Object { $onDisk -notcontains $_ })
            if ($missing.Count -gt 0) {
                Fail ("these plugins are not in this bundle: {0}" -f ($missing -join ', ')) `
                     'Rebuild the installer from a profile that includes them.'
            }
            $profileJson = @{ site = $SiteHost; plugins = $chosen; locked = @() } |
                           ConvertTo-Json -Depth 3
            Set-Content -Path $ProfilePath -Value $profileJson -Encoding ASCII
            Write-Log ("site profile rewritten from the installer selection: {0}" -f ($chosen -join ', ')) 'OK'
        }

        Write-Log "flask plugin apply-profile $ProfilePath"
        Invoke-Native $Flask @('plugin','apply-profile',$ProfilePath) 'apply site profile'

        # Per-plugin Alembic chains (ADR-008) run after the plugins are registered.
        Write-Log 'flask plugin upgrade-all'
        Invoke-Native $Flask @('plugin','upgrade-all') 'plugin migrations'

        # ADR-014 Phase 2. The shared core Alembic baseline creates EVERY plugin's
        # tables regardless of this site's choice, so a lean site still carries the
        # omitted plugins' (empty) tables. Drop them, leaving core + chosen.
        #
        # Both flags are required and they mean different things: --yes executes
        # (the command is a dry-run preview otherwise), --force permits dropping a
        # table that holds rows. --force is needed even on a fresh install because
        # core migrations seed a few plugin reference tables (e.g. 7d05 inserts
        # default access protocols). Safe here and ONLY here: this runs during
        # initial provisioning, before any site data exists.
        # NOTE: ADR-014's prose says lean provisioning "uses --force"; that alone
        # would silently do nothing but print a preview. It needs both.
        # --force drops tables that CONTAIN ROWS. That is required on a fresh
        # install, because core migrations seed a few plugin reference tables
        # (7d05 inserts default access protocols) and prune would otherwise
        # refuse. On an UPGRADE the same flag would silently destroy a site's
        # data for any plugin that is not installed - so never force there.
        # Without --force, prune-schema refuses non-empty tables and says so,
        # which is the behaviour we want when data exists.
        # --force is safe during first provisioning for the same reason the backup
        # is not required: every row present was written by an earlier attempt of
        # this install. Core migration 7d05 seeds access protocols owned by the
        # computers plugin, so any profile omitting computers hit the refusal on
        # every single retry and could never get past stage 3.
        if ((-not $script:DbWasEmpty) -and (-not $firstRun)) {
            Write-Log 'flask plugin prune-schema --yes   (no --force: refuses to drop tables holding data)'
            # -OkExit 0,1: refusing is the DESIGNED outcome here, and the CLI
            # signals it with SystemExit(1). Without this, Invoke-Native failed
            # the stage the moment prune declined to drop a table holding rows -
            # and the reporting below, which exists precisely to explain that,
            # could never run. Core migration 7d05 seeds access protocols owned
            # by the computers plugin, so any profile omitting computers hit it
            # on every retry.
            $pruneOut = Invoke-Native $Flask @('plugin','prune-schema','--yes') `
                            'prune not-installed plugin tables' -OkExit 0,1
            $skipped = @($pruneOut | Where-Object { $_ -match 'REFUSING|rows$|not empty' })
            if ($skipped.Count -gt 0) {
                Write-Log 'some plugin tables were kept because they hold data:' 'WARN'
                $skipped | ForEach-Object { Write-Log "    $_" 'WARN' }
                Write-Log '  drop them by hand only if you are certain that data is not needed' 'WARN'
            } elseif ($pruneOut -match 'not initialized') {
                # The other reason this CLI exits 1, and it is a genuine failure.
                Fail 'the plugin manager did not initialise' 'Check the application log; the app could not start.'
            }
        } else {
            # Reached when the database was provably empty, OR when the install
            # record says first provisioning is still unfinished. Both mean the
            # only rows present are ones this install put there - core migrations
            # seed a few plugin reference tables, so prune would otherwise refuse.
            if ($script:DbWasEmpty) {
                Write-Log 'flask plugin prune-schema --yes --force   (database was empty at start)'
            } else {
                Write-Log 'flask plugin prune-schema --yes --force   (still first provisioning: these tables are ours)'
            }
            Invoke-Native $Flask @('plugin','prune-schema','--yes','--force') 'prune not-installed plugin tables'
        }
    }
    finally { Pop-Location }
    Write-Log 'stage 3 complete' 'OK'
}

# =============================================================================
# STAGE 4 - IIS
# =============================================================================
function Invoke-Stage4 {
    Write-Log 'STAGE 4: IIS site' 'STEP'
    Import-Module WebAdministration -ErrorAction Stop
    $appcmd = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'

    # HttpPlatformHandler is what actually launches waitress; IIS cannot serve
    # this application without it. The MSI ships in the bundle but nothing ever
    # installed it, so a clean server ran all the way to the stage 5 smoke test -
    # Python, venv, schema, plugins and IIS all mutated - and only then failed.
    # It must also come BEFORE the section unlock below: system.webServer/
    # httpPlatform does not exist until the module is registered.
    $hphDll = Join-Path $env:windir 'system32\inetsrv\httpplatformhandler.dll'
    if (Test-Path $hphDll) {
        Write-Log 'HttpPlatformHandler already installed' 'OK'
    } else {
        $hphMsi = Get-ChildItem (Join-Path $BundleRoot 'httpplatformhandler') -Filter '*.msi' -ErrorAction SilentlyContinue |
                  Select-Object -First 1
        if (-not $hphMsi) {
            Fail 'HttpPlatformHandler is not installed and no MSI is in the bundle' @'
IIS cannot run this application without the HttpPlatformHandler module.
Add httpplatformhandler\httpPlatformHandler_amd64.msi to the bundle, or install
the module by hand, then re-run stage 4.
'@
        }
        Write-Log "installing $($hphMsi.Name)"
        if (-not $WhatIfOnly) {
            Invoke-Native 'msiexec.exe' @('/i', $hphMsi.FullName, '/quiet', '/norestart') `
                'HttpPlatformHandler MSI' -TimeoutSec 600 -OkExit 0,3010
            if (-not (Test-Path $hphDll)) {
                Fail 'the HttpPlatformHandler MSI reported success but the module is missing' `
                     'An installer exit code of 0 does not prove it did what was asked.'
            }
            Write-Log 'HttpPlatformHandler installed' 'OK'
        }
    }

    # --- client IP -----------------------------------------------------------
    # URL Rewrite is what lets the XFF rule below exist. Install it BEFORE
    # writing a web.config that references <rewrite>, or IIS answers every
    # request with 500.19 until someone works out which module is missing.
    if ($ClientIpSource -eq 'direct') {
        $rewriteDll = Join-Path $env:windir 'system32\inetsrv\rewrite.dll'
        if (Test-Path $rewriteDll) {
            Write-Log 'URL Rewrite already installed' 'OK'
        } else {
            $rwMsi = Get-ChildItem (Join-Path $BundleRoot 'urlrewrite') -Filter '*.msi' -ErrorAction SilentlyContinue |
                     Select-Object -First 1
            if (-not $rwMsi) {
                Fail 'URL Rewrite is not installed and no MSI is in the bundle' @'
-ClientIpSource direct needs the IIS URL Rewrite module to set X-Forwarded-For.
Add urlrewrite\rewrite_amd64.msi to the bundle and rebuild, or re-run with
-ClientIpSource proxy if something in front of IIS already sets the header.
Installing it later by hand also works: this server has no network, so the MSI
has to come from the bundle either way.
'@
            }
            Write-Log "installing $($rwMsi.Name)"
            if (-not $WhatIfOnly) {
                Invoke-Native 'msiexec.exe' @('/i', $rwMsi.FullName, '/quiet', '/norestart') `
                    'URL Rewrite MSI' -TimeoutSec 600 -OkExit 0,3010
                if (-not (Test-Path $rewriteDll)) {
                    Fail 'the URL Rewrite MSI reported success but the module is missing' `
                         'An installer exit code of 0 does not prove it did what was asked.'
                }
                Write-Log 'URL Rewrite installed' 'OK'
            }
        }
    } else {
        Write-Log 'client IP comes from an upstream proxy; not installing URL Rewrite' 'OK'
    }

    # web.config ships in the repo; its paths need correcting and the client-IP
    # rule needs enabling or leaving off.
    #
    # An EXISTING web.config is never overwritten. It is the one file on the
    # server that legitimately carries hand-edits - a nested application for
    # /installers, bindings, a proxy-specific rule - and overwriting it reverts
    # them silently. On a server where the XFF rule was enabled by hand, that
    # alone would turn the GE-Enforce IP allowlist off without a word in any log.
    $srcCfg = Join-Path $AppRoot 'deploy\windows\web.config'
    $dstCfg = Join-Path $AppRoot 'web.config'
    if (Test-Path $dstCfg) {
        Write-Log 'web.config already exists; leaving it alone' 'OK'
        $existing = Get-Content $dstCfg -Raw

        # ONE EXCEPTION to leaving it alone. An <allowedServerVariables> block is
        # fatal by itself: that section is Deny by default, so IIS rejects the
        # whole file with 500.52 (0x80070021) before httpPlatformHandler runs.
        # The app never starts and its stdout log stays empty, which reads as a
        # broken application and is not one.
        #
        # Earlier builds of THIS installer wrote exactly that block, so honouring
        # "leave it alone" without qualification would leave every server already
        # installed permanently unable to start, with re-running powerless to fix
        # it. Strip just that element and keep every other edit - and only when it
        # contains nothing but the variable this installer puts there, because
        # anything else is somebody's deliberate change.
        if ($existing -match '(?s)<allowedServerVariables>(.*?)</allowedServerVariables>') {
            $inner = $Matches[1]
            $names = [regex]::Matches($inner, 'name\s*=\s*"([^"]+)"') |
                     ForEach-Object { $_.Groups[1].Value }
            # @() around the pipeline: a zero-match filter returns $null, and
            # $null.Count is a terminating error under Set-StrictMode 2.0.
            $foreign = @($names | Where-Object { $_ -ne 'HTTP_X_FORWARDED_FOR' }).Count
            if ($foreign -gt 0) {
                Write-Log 'web.config declares <allowedServerVariables> holding variables this installer did not add' 'WARN'
                Write-Log '  IIS returns 500.52 unless that section is unlocked server-wide; left unchanged' 'WARN'
            } elseif (-not $WhatIfOnly) {
                $backup = "$dstCfg.before-xff-fix"
                Copy-Item $dstCfg $backup -Force
                $fixed = $existing -replace '(?s)[ \t]*<allowedServerVariables>.*?</allowedServerVariables>\r?\n?', ''
                Set-Content -Path $dstCfg -Value $fixed -Encoding UTF8
                Write-Log 'removed <allowedServerVariables> from web.config; it made IIS return 500.52' 'OK'
                Write-Log "  the previous file is kept at $backup"
                $existing = $fixed
            }
        }

        $hasRule  = $existing -match '<rewrite>' -and $existing -notmatch 'SHOPDB-CLIENTIP-BEGIN'
        if ($ClientIpSource -eq 'direct' -and -not $hasRule) {
            Write-Log 'this web.config does NOT set X-Forwarded-For; client IPs will read as 127.0.0.1' 'WARN'
            Write-Log '  enable the SHOPDB-CLIENTIP block by hand, or delete web.config and re-run stage 4' 'WARN'
        }
        if ($ClientIpSource -eq 'proxy' -and $hasRule) {
            Write-Log 'this web.config OVERWRITES X-Forwarded-For, which discards the real client IP behind a proxy' 'WARN'
            Write-Log '  remove the <rewrite> block by hand if a proxy in front already sets the header' 'WARN'
        }
    } elseif (Test-Path $srcCfg) {
        Write-Log "installing web.config (client IP: $ClientIpSource)"
        if (-not $WhatIfOnly) {
            $cfg = Get-Content $srcCfg -Raw
            $cfg = $cfg.Replace('C:\shopdb-flask', $AppRoot)
            if ($ClientIpSource -eq 'direct') {
                # Uncomment by deleting the two marker lines. A string operation
                # on explicit markers, not a regex over the surrounding prose:
                # editing that prose must never silently disable the rule.
                if ($cfg -notmatch 'SHOPDB-CLIENTIP-BEGIN') {
                    Fail 'the shipped web.config has no SHOPDB-CLIENTIP block' `
                         'It was edited. Restore deploy\windows\web.config from the repository.'
                }
                $cfg = $cfg.Replace('<!-- SHOPDB-CLIENTIP-BEGIN', '').Replace('SHOPDB-CLIENTIP-END -->', '')
                Write-Log 'X-Forwarded-For rule enabled' 'OK'
            }
            Set-Content -Path $dstCfg -Value $cfg -Encoding UTF8
            Track 'file' $dstCfg
        }
    } else {
        Fail "web.config not found at $srcCfg or $dstCfg"
    }

    # App pool: No Managed Code. This is a Python app; the CLR must not load.
    if (Test-Path "IIS:\AppPools\$AppPool") {
        Write-Log "app pool $AppPool already exists" 'OK'
    } else {
        Write-Log "creating app pool $AppPool"
        if (-not $WhatIfOnly) {
            New-WebAppPool -Name $AppPool | Out-Null
            Set-ItemProperty "IIS:\AppPools\$AppPool" -Name managedRuntimeVersion -Value ''
            Track 'apppool' $AppPool
        }
    }

    # ACLs. RX on the tree; Modify on logs\ and instance\ or plugin toggles and
    # uploads fail with "internal error". Read on .env for the pool identity.
    if (-not $WhatIfOnly) {
        $ident = "IIS AppPool\$AppPool"
        Write-Log "granting $ident access"
        Invoke-Native 'icacls.exe' @($AppRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on AppRoot'
        # The Python root no longer inherits from the drive root, so the pool has
        # no access to it by default. The venv's python.exe is a copy; the DLL
        # and the standard library are still read from the base install, so
        # without this grant the site 500s on every request.
        if ($script:PyRoot -and (Test-Path $script:PyRoot)) {
            Invoke-Native 'icacls.exe' @($script:PyRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on Python root'
        }
        foreach ($sub in @('logs','instance')) {
            Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub"
        }
        Invoke-Native 'icacls.exe' @($EnvFile,'/grant',"${ident}:(R)") 'ACL on .env'
    }

    # Switching method must not leave BOTH deployments in place: two entry points
    # to one directory, one of them serving an SPA built for the wrong base path.
    # Remove whichever artifact belongs to the method we are NOT using.
    #
    # GUARDED. These removals are correct when this installer owns both artifacts
    # - it is how switching method avoids leaving two entry points to one
    # directory, one of them serving an SPA built for the wrong base path. They
    # are NOT correct against an installation someone else built: a wrong
    # -MountAlias would delete a live mount with no prompt and no error, and the
    # first sign would be the site 404ing.
    #
    # 'Ours' means there was a version stamp BEFORE this run started - only this
    # installer writes one. Stage 2 records that observation; testing the file
    # here instead would read the stamp stage 2 has already written.
    $weBuiltThis = if ($null -ne $script:PreexistingInstallStamp) {
        $script:PreexistingInstallStamp
    } else {
        # Stage 4 run on its own, so stage 2 has not written anything this run.
        Test-Path (Join-Path $AppRoot '.installed-version')
    }
    if (-not $WhatIfOnly) {
        $doomed = @()
        if ($MountAlias) {
            if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) {
                $doomed += @{ Kind = 'site'; Name = $SiteName; Site = '' }
            }
        } else {
            foreach ($site in (Get-Website)) {
                foreach ($app in (Get-WebApplication -Site $site.Name -ErrorAction SilentlyContinue)) {
                    if (Test-SamePath $app.PhysicalPath $AppRoot) {
                        $doomed += @{ Kind = 'app'; Name = $app.Path.Trim('/'); Site = $site.Name }
                    }
                }
            }
        }

        if ($doomed.Count -gt 0 -and -not $weBuiltThis -and -not $AdoptExisting) {
            foreach ($d in $doomed) {
                if ($d.Kind -eq 'site') { Write-Log ("  would remove IIS site '{0}'" -f $d.Name) 'FAIL' }
                else { Write-Log ("  would remove application '/{0}' under '{1}'" -f $d.Name, $d.Site) 'FAIL' }
            }
            Fail 'this server has an IIS deployment that this installer did not create' @'
Publishing the way you asked means removing what is listed above, and there is
no version stamp to show this installer put it there. Deleting a mount somebody
else configured is not something to do without being asked.

If the removal is what you want, re-run with -AdoptExisting.
If not, re-run matching how the application is published today: pass
-MountAlias <alias> to keep it as an application under an existing site, or omit
-MountAlias to keep it as a site of its own.
'@
        }

        foreach ($d in $doomed) {
            if ($d.Kind -eq 'site') {
                Write-Log "removing the previous stand-alone site '$($d.Name)' (now published as a subpath)" 'WARN'
                Remove-Website -Name $d.Name -ErrorAction SilentlyContinue
                Get-NetFirewallRule -DisplayName ("{0} {1}" -f $d.Name, $SitePort) -ErrorAction SilentlyContinue |
                    Remove-NetFirewallRule -ErrorAction SilentlyContinue
            } else {
                Write-Log "removing the previous subpath application '/$($d.Name)' (now its own site)" 'WARN'
                Remove-WebApplication -Site $d.Site -Name $d.Name -ErrorAction SilentlyContinue
            }
        }
    }

    if ($MountAlias) {
        # METHOD B: an IIS Application under an existing site, reached at
        # http://<server-fqdn>/<alias>/ - no new DNS name and no port in the URL.
        # The app's own handler mappings apply only inside the Application, so the
        # parent site's handlers (classic ASP, static files) are untouched.
        $alias = $MountAlias.Trim('/')
        if (-not (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue)) {
            Fail "parent site '$ParentSite' does not exist" `
                 'Pass -ParentSite with the name of an existing IIS site, or install without -MountAlias.'
        }
        $existing = Get-WebApplication -Site $ParentSite -Name $alias -ErrorAction SilentlyContinue
        if ($existing) {
            Write-Log "application /$alias already exists under '$ParentSite'" 'OK'
        } else {
            Write-Log "creating application /$alias under '$ParentSite'"
            if (-not $WhatIfOnly) {
                New-WebApplication -Site $ParentSite -Name $alias -PhysicalPath $AppRoot `
                    -ApplicationPool $AppPool | Out-Null
                Track 'webapp' ("{0}/{1}" -f $ParentSite, $alias)
            }
        }
        # No firewall rule: the parent site's port is already reachable, which is
        # the whole point of this method.
        Write-Log "no firewall change needed - served on '$ParentSite' existing bindings" 'OK'
    }
    else {
        # METHOD A: its own site on $SitePort.
        if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) {
            Write-Log "site $SiteName already exists" 'OK'
            # RECONCILE, do not just accept it. "Already exists" used to take the
            # site however it was, so re-running with a different port left the
            # binding alone while everything downstream - CORS_ORIGINS, the
            # firewall rule, the shortcut, the stage 5 smoke test - used the NEW
            # port. A working server was then reported as a failed install.
            #
            # Refuse rather than silently re-bind: moving where people reach the
            # site is not something to do because a field defaulted.
            if (-not $WhatIfOnly) {
                $existingPorts = @()
                $existingPath  = ''
                try {
                    $siteNow = Get-Website -Name $SiteName
                    $existingPath = [string] $siteNow.physicalPath
                    foreach ($b in $siteNow.bindings.Collection) {
                        if ($b.protocol -eq 'http' -and ($b.bindingInformation -match '^[^:]*:(\d+):')) {
                            $existingPorts += [int] $Matches[1]
                        }
                    }
                } catch { }
                if (($existingPorts.Count -gt 0) -and ($existingPorts -notcontains [int] $SitePort)) {
                    Fail ("site '{0}' is bound to port {1}, but this run was told to use {2}" -f `
                            $SiteName, ($existingPorts -join ', '), $SitePort) `
                         'Re-run and enter the port the site already uses, or change the binding in IIS Manager first.'
                }
                if ($existingPath -and (-not (Test-SamePath $existingPath $AppRoot))) {
                    Fail ("site '{0}' serves {1}, not {2}" -f $SiteName, $existingPath, $AppRoot) `
                         'Something else owns that site name. Install into the directory it serves, or use a different site name.'
                }
            }
        } else {
            Write-Log "creating site $SiteName on port $SitePort"
            if (-not $WhatIfOnly) {
                New-Website -Name $SiteName -Port $SitePort -PhysicalPath $AppRoot -ApplicationPool $AppPool | Out-Null
                Track 'site' $SiteName
            }
        }

        $ruleName = "$SiteName $SitePort"
        if (Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue) {
            Write-Log 'firewall rule already exists' 'OK'
        } else {
            Write-Log "adding firewall rule for TCP $SitePort"
            if (-not $WhatIfOnly) {
                New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP `
                    -LocalPort $SitePort -Action Allow | Out-Null
                Track 'firewall' $ruleName
            }
        }

        if (-not $WhatIfOnly) { Start-Website -Name $SiteName -ErrorAction SilentlyContinue }
    }

    # Handler sections are locked server-wide by default. Without unlocking,
    # IIS returns 500.19 the moment it reads the app's web.config.
    #
    # `appcmd unlock config /section:X` unlocks it for EVERY site on the machine,
    # which on a shared server hands every other application the ability to
    # define its own handlers. Delegate to our own location instead, and fall
    # back to the server-wide unlock only if that is refused - a 500.19 the
    # operator cannot diagnose is worse than a wider delegation, but it should be
    # the second choice, not the first.
    #
    # MUST run AFTER the site or application exists. appcmd resolves the location
    # against applicationHost.config, so unlocking "Default Web Site/shopdb"
    # before New-WebApplication created it failed with 80070003 ("the system
    # cannot find the path specified") on every first install - and the fallback
    # then handed handler delegation to the whole machine, which is exactly the
    # outcome the comment above calls the second choice. A first install could
    # never take the scoped path.
    $ourLocation = if ($MountAlias) { "$ParentSite/$($MountAlias.Trim('/'))" } else { $SiteName }
    foreach ($section in @('system.webServer/handlers','system.webServer/httpPlatform')) {
        if ($WhatIfOnly) { Write-Log "would unlock $section for $ourLocation"; continue }
        Write-Log "unlocking $section for $ourLocation"
        # EAP must drop to Continue around these. appcmd writes to stderr on
        # ordinary refusals, and '2>&1' with $ErrorActionPreference = 'Stop'
        # turns that into a TERMINATING NativeCommandError - so the exit-code
        # test and the server-wide fallback below, which is the entire point of
        # the block, were unreachable, and stage 4 aborted after Python, the
        # venv, the schema and the ACLs had all been changed.
        $prevEap = $ErrorActionPreference
        $ErrorActionPreference = 'Continue'
        try {
            $scoped = & $appcmd unlock config "$ourLocation" /section:$section 2>&1
            $scoped | ForEach-Object { Write-Log "    $_" }
            if ($LASTEXITCODE -ne 0) {
                Write-Log "  scoped unlock refused; unlocking $section server-wide" 'WARN'
                & $appcmd unlock config /section:$section 2>&1 | ForEach-Object { Write-Log "    $_" }
                if ($LASTEXITCODE -ne 0) {
                    Write-Log "  server-wide unlock also refused - IIS may return 500.19" 'WARN'
                }
            }
        } finally { $ErrorActionPreference = $prevEap }
    }

    # URL Rewrite: a rule may only set a server variable that is ALLOWED, and
    # system.webServer/rewrite/allowedServerVariables ships with
    # overrideModeDefault="Deny". An <allowedServerVariables> block in the app's
    # own web.config is therefore refused outright - IIS answered 500.52 with
    # 0x80070021, "this configuration section cannot be used at this path",
    # before it ever reached httpPlatformHandler. python was never launched, the
    # stdout log stayed empty, and the stage 5 smoke test reported it as a dead
    # application when the config had been rejected wholesale.
    #
    # Allow the ONE variable at server level rather than unlocking the section.
    # Unlocking would let every site on this machine declare arbitrary server
    # variables; this grants exactly HTTP_X_FORWARDED_FOR.
    if (($ClientIpSource -eq 'direct') -and (-not $WhatIfOnly)) {
        $varName = 'HTTP_X_FORWARDED_FOR'
        $section = 'system.webServer/rewrite/allowedServerVariables'
        $prevEap = $ErrorActionPreference
        $ErrorActionPreference = 'Continue'
        try {
            # Adding a duplicate entry is an error, so look before writing.
            $current = (& $appcmd list config /section:$section 2>&1 | Out-String)
            if ($current -match [regex]::Escape($varName)) {
                Write-Log "$varName is already allowed for URL Rewrite" 'OK'
            } else {
                Write-Log "allowing $varName for URL Rewrite (server level)"
                & $appcmd set config /section:$section "/+[name='$varName']" /commit:apphost 2>&1 |
                    ForEach-Object { Write-Log "    $_" }
                if ($LASTEXITCODE -ne 0) {
                    Write-Log "  could not allow $varName - IIS will return 500.52 and the app will not start" 'WARN'
                }
            }
        } finally { $ErrorActionPreference = $prevEap }
    }

    # Stage 2 stops the pool on an upgrade so its files can be replaced. Nothing
    # else starts it again, so the smoke test would fail against a stopped site
    # and report it as a broken install.
    try {
        Import-Module WebAdministration -ErrorAction SilentlyContinue
        if (Test-Path "IIS:\AppPools\$AppPool") {
            if ((Get-Item "IIS:\AppPools\$AppPool").State -ne 'Started') {
                Write-Log "starting app pool $AppPool"
                Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue
                Start-Sleep -Seconds 2
            }
        }
        $site = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
        if ($site -and $site.State -ne 'Started') {
            Write-Log "starting site $SiteName"
            Start-Website -Name $SiteName -ErrorAction SilentlyContinue
        }
    } catch { Write-Log "could not start the site: $($_.Exception.Message)" 'WARN' }

    Write-Log 'stage 4 complete' 'OK'
}

# =============================================================================
# STAGE 5 - verify and hand off
# =============================================================================
function Invoke-Stage5 {
    Write-Log 'STAGE 5: smoke test' 'STEP'
    if ($WhatIfOnly) { Write-Log 'WhatIf: skipping smoke test' 'WARN'; return }

    # Stages can be run one at a time, so do not assume stage 4 just ran.
    try {
        Import-Module WebAdministration -ErrorAction SilentlyContinue
        if ((Test-Path "IIS:\AppPools\$AppPool") -and
            ((Get-Item "IIS:\AppPools\$AppPool").State -ne 'Started')) {
            Write-Log "app pool was stopped; starting it before the smoke test" 'WARN'
            Start-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue
            Start-Sleep -Seconds 3
        }
    } catch { }

    # FR-150: test the host operators will actually use, not just the loopback.
    # localhost alone passes even when the app is unreachable by name and when
    # CORS_ORIGINS names a host that does not resolve - so the SPA would fail its
    # first XHR on a site the installer just called healthy.
    $hostName = if ($SiteHost) { $SiteHost } else { $env:COMPUTERNAME }
    # Under method B the app answers on the PARENT site's port, at the alias -
    # requesting http://localhost:8090/ would test a site that does not exist.
    if ($MountAlias) {
        $alias = $MountAlias.Trim('/')
        # Prefer an http binding, but fall back to https. A parent site published
        # ONLY over https - which is normal, and which the operator did nothing
        # wrong to have - left this defaulting to port 80, requesting a URL that
        # answers nothing, and failing a working install with a red dialog.
        $parentScheme = 'http'
        $parentPort = 0
        try {
            $bindings = (Get-Website -Name $ParentSite -ErrorAction SilentlyContinue).bindings.Collection
            $b = $bindings | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
            if (-not $b) {
                $b = $bindings | Where-Object { $_.protocol -eq 'https' } | Select-Object -First 1
                if ($b) { $parentScheme = 'https' }
            }
            if ($b -and ($b.bindingInformation -match '^[^:]*:(\d+):')) { $parentPort = [int]$Matches[1] }
        } catch { }
        if ($parentPort -eq 0) { $parentPort = if ($parentScheme -eq 'https') { 443 } else { 80 } }

        $targets = @("{0}://localhost:{1}/{2}/" -f $parentScheme, $parentPort, $alias)
        if ($hostName -and $hostName -ne 'localhost') {
            $targets += "{0}://{1}:{2}/{3}/" -f $parentScheme, $hostName, $parentPort, $alias
        }
        # An https parent almost certainly has a certificate for its real name,
        # not for 'localhost', and a certificate complaint is not an application
        # fault. Accept any certificate for the duration of the smoke test only.
        if ($parentScheme -eq 'https') {
            Write-Log 'parent site is https-only; certificate validation is skipped for this check' 'WARN'
            try {
                Add-Type -TypeDefinition @'
using System.Net;
using System.Security.Cryptography.X509Certificates;
public class ShopdbSmokeTestCertPolicy : ICertificatePolicy {
    public bool CheckValidationResult(ServicePoint sp, X509Certificate cert, WebRequest req, int problem) {
        return true;
    }
}
'@ -ErrorAction SilentlyContinue
                [System.Net.ServicePointManager]::CertificatePolicy = New-Object ShopdbSmokeTestCertPolicy
            } catch { }
        }
    } else {
        $targets  = @("http://localhost:$SitePort/")
        if ($hostName -and $hostName -ne 'localhost') {
            $targets += "http://{0}:{1}/" -f $hostName, $SitePort
        }
    }

    # First request boots the app and connects to MySQL: the runbook records ~15s.
    Write-Log "requesting $($targets[0]) (first request takes ~15s while the app boots)"
    $ok = $false
    $lastStatus = 'no response'
    $lastDetail = ''
    for ($i = 1; $i -le 12; $i++) {
        try {
            $r = Invoke-WebRequest -Uri $targets[0] -UseBasicParsing -TimeoutSec 20
            $lastStatus = "HTTP $($r.StatusCode)"
            if ($r.StatusCode -eq 200) { $ok = $true; break }
            # A non-200 that does NOT throw (a redirect, say) is still not success.
            # Without this sleep the loop burned all 12 attempts instantly and
            # reported a timeout that never waited for anything.
            Start-Sleep -Seconds 5
        } catch {
            # The status code and the response body ARE the diagnosis, and this
            # block used to discard both - leaving a generic "check the logs" for
            # a fault IIS had already named. 500.19 = section still locked,
            # 502.3/503 = httpPlatform could not start the app, 404 = the
            # application or its handler mapping is missing.
            $lastStatus = 'no response'
            $lastDetail = $_.Exception.Message
            try {
                $resp = $_.Exception.Response
                if ($resp) {
                    $lastStatus = "HTTP {0} ({1})" -f [int]$resp.StatusCode, $resp.StatusCode
                    $reader = New-Object System.IO.StreamReader($resp.GetResponseStream())
                    $body = $reader.ReadToEnd()
                    $reader.Close()
                    if ($body) {
                        # IIS error pages state the precise cause in their body;
                        # strip the markup so it fits in the log readably.
                        $clean = (($body -replace '<[^>]+>', ' ') -replace '\s+', ' ').Trim()
                        if ($clean) {
                            if ($clean.Length -gt 500) { $clean = $clean.Substring(0, 500) + ' ...' }
                            $lastDetail = $clean
                        }
                    }
                }
            } catch { }
            Start-Sleep -Seconds 5
        }
    }
    if (-not $ok) {
        Write-Log "  last response: $lastStatus" 'FAIL'
        if ($lastDetail) { Write-Log "  $lastDetail" 'FAIL' }
        # The HttpPlatform stdout log is where a Python traceback lands. Reading it
        # here saves a round trip: whoever is sending this log back already has the
        # cause in it, instead of being told which second file to go and open.
        try {
            $logDir = Join-Path $AppRoot 'logs'
            $newest = Get-ChildItem $logDir -Filter '*.log' -ErrorAction SilentlyContinue |
                      Sort-Object LastWriteTime -Descending | Select-Object -First 1
            if (-not $newest) {
                Write-Log "  no HttpPlatform log in $logDir - the pool never launched python" 'FAIL'
            } elseif ($newest.Length -eq 0) {
                Write-Log "  $($newest.Name) is empty - python was launched but wrote nothing" 'FAIL'
            } else {
                Write-Log "  tail of $($newest.Name):" 'FAIL'
                Get-Content $newest.FullName -Tail 20 -ErrorAction SilentlyContinue |
                    ForEach-Object { Write-Log "    $_" 'FAIL' }
            }
        } catch { }
        Fail "site did not return 200 at $($targets[0])" `
             "Check $AppRoot\logs. Empty HttpPlatform log usually means the app-pool identity cannot read $AppRoot or run the venv, or .env is missing/invalid."
    }
    Write-Log 'site responded 200 on localhost' 'OK'

    # The app is up by now, so the configured-host check needs no boot retry. A
    # failure here is a name-resolution or firewall problem, not a broken install,
    # so warn rather than Fail: rolling back a working install would be worse.
    foreach ($t in $targets[1..($targets.Count - 1)]) {
        try {
            $r = Invoke-WebRequest -Uri $t -UseBasicParsing -TimeoutSec 20
            if ($r.StatusCode -eq 200) { Write-Log "site responded 200 on $t" 'OK' }
            else { Write-Log "unexpected status $($r.StatusCode) at $t" 'WARN' }
        } catch {
            Write-Log "site did NOT respond at $t - CORS_ORIGINS names this host, so the SPA will fail its first request from other machines" 'WARN'
            Write-Log "  check DNS/hosts for '$hostName' and the firewall rule on TCP $SitePort" 'WARN'
        }
    }

    # Is the client-IP rule actually in effect? It is the control that makes
    # remote_addr trustworthy: without it IIS forwards whatever X-Forwarded-For
    # the CALLER sent, waitress trusts it because it arrives from loopback, and
    # the GE-Enforce IP allowlist becomes bypassable from anywhere on the network.
    # It does not fail closed. Verified here rather than assumed, because the
    # failure is silent and looks exactly like a working server.
    if ($ClientIpSource -eq 'direct') {
        $liveCfg = Join-Path $AppRoot 'web.config'
        $cfgText = if (Test-Path $liveCfg) { Get-Content $liveCfg -Raw } else { '' }
        $ruleLive = ($cfgText -match '<rewrite>') -and ($cfgText -notmatch 'SHOPDB-CLIENTIP-BEGIN')
        $moduleThere = Test-Path (Join-Path $env:windir 'system32\inetsrv\rewrite.dll')
        if ($ruleLive -and $moduleThere) {
            Write-Log 'client-IP rule is live; remote_addr is trustworthy' 'OK'
        } else {
            Write-Log 'CLIENT-IP RULE IS NOT IN EFFECT' 'WARN'
            if (-not $moduleThere) { Write-Log '  URL Rewrite module is not installed' 'WARN' }
            if (-not $ruleLive)    { Write-Log '  web.config does not have an active <rewrite> block' 'WARN' }
            Write-Log '  every client will be recorded as 127.0.0.1, and the GE-Enforce IP' 'WARN'
            Write-Log '  allowlist is BYPASSABLE - a caller can send its own X-Forwarded-For' 'WARN'
            Write-Log '  re-run with -ClientIpSource direct, or use token authentication' 'WARN'
        }
    }

    # The self-hosted API documentation. It is the thing an operator - or the
    # assistant an operator is asking - reaches for on a server with no internet,
    # and it is served from a file that has to have been staged into the build.
    # A missing file makes /api/docs render an empty page, which nobody notices
    # until it is needed. Warn, never fail: the application itself is fine.
    $base = $targets[0].TrimEnd('/')
    try {
        $d = Invoke-WebRequest -Uri "$base/api/docs/openapi.json" -UseBasicParsing -TimeoutSec 20
        if ($d.StatusCode -eq 200) { Write-Log 'API documentation is being served at /api/docs' 'OK' }
        else { Write-Log "/api/docs/openapi.json returned $($d.StatusCode)" 'WARN' }
    } catch {
        Write-Log '/api/docs is NOT working - docs/openapi.json was not staged into this build' 'WARN'
        Write-Log '  the site runs fine; offline API reference and LLM assistance will not' 'WARN'
    }

    # The install is proven working, so the stage-0 handoff is no longer the only
    # copy of the generated password (.env has it and the app is running on it).
    # Safe to shred now, and only now - see the note in stage 2.
    $handoff = Join-Path $AppRoot '.dbpass'
    if (Test-Path $handoff) {
        try {
            Set-Content -Path $handoff -Value ('0' * 256) -Encoding UTF8 -Force
            Remove-Item $handoff -Force
            Write-Log 'stage-0 password handoff shredded (install verified)' 'OK'
        } catch { Write-Log "could not remove $handoff - delete it manually" 'WARN' }
    }

    Write-Host ''
    Write-Host '  ShopDB-Flask is installed.' -ForegroundColor Green
    Write-Host ''
    # FR-151: send the operator to /login, not to /. The site root renders the
    # anonymous dashboard; the first-run gate lives on the login route, so an
    # operator landing on / sees an empty dashboard and no way to discover setup.
    if ($MountAlias) {
        Write-Host ("  Open:  http://{0}/{1}/login" -f $hostName, $MountAlias.Trim('/'))
    } else {
        Write-Host ("  Open:  http://{0}:{1}/login" -f $hostName, $SitePort)
    }
    Write-Host ''
    Write-Host '  With no user in the database that page offers to create the first'
    Write-Host '  administrator, then runs the setup wizard (site details, features,'
    Write-Host '  floor map, common vendors).'
    Write-Host ''
    Write-Host '  Headless alternative:'
    Write-Host ("    cd {0}" -f $AppRoot)
    Write-Host '    venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example'
    Write-Host '    (the password is generated and printed once)'
    Write-Host ''
    Write-Host ("  Install log: {0}" -f $script:LogPath)
    Write-Host ''
}

# =============================================================================
# Rollback
# =============================================================================
function Invoke-Rollback {
    Write-Log 'rolling back what this run created' 'WARN'
    # Reverse order. Never touches an external database, and never removes a
    # data directory without explicit confirmation.
    for ($i = $script:Created.Count - 1; $i -ge 0; $i--) {
        $item = $script:Created[$i]
        try {
            switch ($item.Kind) {
                'site'     { Remove-Website -Name $item.Id -ErrorAction SilentlyContinue; Write-Log "  removed site $($item.Id)" }
                'webapp'   {
                    $parts = $item.Id -split '/', 2
                    Remove-WebApplication -Site $parts[0] -Name $parts[1] -ErrorAction SilentlyContinue
                    Write-Log "  removed application /$($parts[1]) under '$($parts[0])'"
                }
                'apppool'  { Remove-WebAppPool -Name $item.Id -ErrorAction SilentlyContinue; Write-Log "  removed app pool $($item.Id)" }
                'firewall' { Remove-NetFirewallRule -DisplayName $item.Id -ErrorAction SilentlyContinue; Write-Log "  removed firewall rule" }
                'file'     {
                    # Overwrite before deleting, same as uninstall does. A rolled
                    # back install left .env - which holds the database password
                    # in plaintext by design - recoverable on disk.
                    try {
                        if ((Split-Path $item.Id -Leaf) -eq '.env') {
                            Set-Content -Path $item.Id -Value ('0' * 512) -Encoding UTF8 -Force
                        }
                    } catch { }
                    Remove-Item $item.Id -Force -ErrorAction SilentlyContinue
                    Write-Log "  removed $($item.Id)"
                }
                'dir'      { Write-Log "  left directory $($item.Id) in place (remove manually if wanted)" 'WARN' }
                'python'   { Write-Log "  left Python install in place at $($item.Id)" 'WARN' }
                'service'  {
                    # Stop and deregister a service THIS run created. Safe because
                    # stage 0 refuses to run when any MySQL service already exists.
                    Stop-Service -Name $item.Id -Force -ErrorAction SilentlyContinue
                    & sc.exe delete $item.Id | Out-Null
                    Write-Log "  removed service $($item.Id)"
                }
                'mysql-files' { Write-Log "  left MySQL binaries/config at $($item.Id) (remove manually if wanted)" 'WARN' }
                # FR-162a: never delete a database directory automatically. Even on
                # a failed run the operator may have put data in it, and a wrong
                # guess here is unrecoverable.
                'mysql-datadir' {
                    Write-Log "  LEFT MySQL data directory $($item.Id) IN PLACE" 'WARN'
                    Write-Log '    delete it manually only if you are certain it holds no data' 'WARN'
                }
            }
        } catch { Write-Log "  rollback of $($item.Kind) $($item.Id) failed: $($_.Exception.Message)" 'WARN' }
    }
}

# =============================================================================
# Uninstall
# =============================================================================
# Deliberately conservative. It removes what the installer creates and NEVER
# touches data without a typed confirmation: no dropped databases, no deleted
# data directory, no uninstalled MySQL that might serve another application.
function Invoke-Uninstall {
    Write-Log 'UNINSTALL' 'STEP'
    Write-Host ''
    Write-Host '  This removes the ShopDB-Flask IIS site, app pool, firewall rule and' -ForegroundColor Yellow
    Write-Host ("  the application directory {0}." -f $AppRoot) -ForegroundColor Yellow
    Write-Host '  It does NOT drop any database and does NOT uninstall MySQL.' -ForegroundColor Yellow
    Write-Host ''
    if ($OnFailure -ne 'never') {
        $answer = Read-Host 'Type UNINSTALL to proceed'
        if ($answer -ne 'UNINSTALL') { Write-Log 'aborted by operator'; return }
    }

    Import-Module WebAdministration -ErrorAction SilentlyContinue

    # A SUBPATH install has no site of its own - it is an Application under an
    # existing one. Removing only the site left the application in place, pointed
    # at a directory this is about to delete, so the parent site (at West
    # Jefferson, the live classic ASP) served 503 on that path forever and
    # Add/Remove Programs reported success.
    #
    # The alias is whatever MOUNT_PATH says, which is the same value wsgi.py
    # mounts on, so it cannot disagree with how the app was actually published.
    $mount = ''
    $envFile = Join-Path $AppRoot '.env'
    if (Test-Path $envFile) {
        $line = Get-Content $envFile -ErrorAction SilentlyContinue |
                Where-Object { $_ -like 'MOUNT_PATH=*' } | Select-Object -First 1
        if ($line) { $mount = $line.Substring('MOUNT_PATH='.Length).Trim().Trim('/') }
    }
    if ($mount) {
        $removed = $false
        foreach ($site in (Get-Website -ErrorAction SilentlyContinue)) {
            $app = Get-WebApplication -Site $site.Name -Name $mount -ErrorAction SilentlyContinue
            if (-not $app) { continue }
            # OWNERSHIP CHECK. Matching on the alias alone removed ANY application
            # on the server that happened to share the name - a second instance,
            # or an unrelated /shopdb under a different site - and -OnFailure
            # never suppresses the typed confirmation, so it happened unattended.
            if (-not (Test-SamePath $app.PhysicalPath $AppRoot)) {
                Write-Log ("left /{0} under '{1}' alone: it serves {2}, not {3}" -f `
                    $mount, $site.Name, $app.PhysicalPath, $AppRoot) 'WARN'
                continue
            }
            Remove-WebApplication -Site $site.Name -Name $mount -ErrorAction SilentlyContinue
            Write-Log "removed application /$mount from site '$($site.Name)'" 'OK'
            $removed = $true
        }
        if (-not $removed) { Write-Log "application /$mount not present" }
    }

    if (Get-Website -Name $SiteName -ErrorAction SilentlyContinue) {
        Remove-Website -Name $SiteName -ErrorAction SilentlyContinue
        Write-Log "removed site $SiteName" 'OK'
    } else { Write-Log "site $SiteName not present" }

    if (Test-Path "IIS:\AppPools\$AppPool") {
        # Stop first: a running worker holds the app directory open and the
        # subsequent Remove-Item then fails with "file in use".
        Stop-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue
        Start-Sleep -Seconds 3
        Remove-WebAppPool -Name $AppPool -ErrorAction SilentlyContinue
        Write-Log "removed app pool $AppPool" 'OK'
    } else { Write-Log "app pool $AppPool not present" }

    # Stage 4 creates the rule as "$SiteName $SitePort". This looked for the
    # literal "ShopDB-Flask 8090", which is not the same string as the default
    # "shopdb-flask 8090" and matches nothing at all on a non-default port, so
    # the rule outlived the uninstall. Build the name the same way stage 4 does,
    # and sweep any rule left by a differently-ported install of the same site.
    $rule = "$SiteName $SitePort"
    if (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue) {
        Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue
        Write-Log "removed firewall rule '$rule'" 'OK'
    } else { Write-Log "firewall rule '$rule' not present" }
    Get-NetFirewallRule -ErrorAction SilentlyContinue |
        Where-Object { $_.DisplayName -like "$SiteName *" -and $_.DisplayName -ne $rule } |
        ForEach-Object {
            Remove-NetFirewallRule -DisplayName $_.DisplayName -ErrorAction SilentlyContinue
            Write-Log "removed leftover firewall rule '$($_.DisplayName)'" 'OK'
        }

    if (Test-Path $AppRoot) {
        # .env holds the database password in plaintext by design (the app reads it
        # at boot), so shred it rather than just unlinking.
        $envPath = Join-Path $AppRoot '.env'
        if (Test-Path $envPath) {
            Set-Content -Path $envPath -Value ('0' * 1024) -ErrorAction SilentlyContinue
            Write-Log 'overwrote .env before deletion (it holds the DB password)'
        }
        try {
            Remove-Item $AppRoot -Recurse -Force -ErrorAction Stop
            Write-Log "removed $AppRoot" 'OK'
        } catch {
            Write-Log "could not fully remove $AppRoot : $($_.Exception.Message)" 'WARN'
            Write-Log '  a running worker process usually holds it; try again after iisreset' 'WARN'
        }
    } else { Write-Log "$AppRoot not present" }

    Write-Host ''
    Write-Host '  Uninstalled.' -ForegroundColor Green
    Write-Host ''
    Write-Host ("  The database '{0}' was NOT dropped." -f $DbName)
    Write-Host '  MySQL was NOT uninstalled or modified.'
    Write-Host '  Remove either manually if this box is being decommissioned.'
    Write-Host ''
}

# =============================================================================
# Main
# =============================================================================
Write-Log "ShopDB-Flask installer, stage=$Stage, approot=$AppRoot, bundle=$BundleRoot" 'STEP'
if ($WhatIfOnly) { Write-Log 'WhatIfOnly: no changes will be made' 'WARN' }

try {
    # Before ANY stage, so that a stage which dies still leaves a record saying
    # provisioning had started. Skipped for uninstall, which must not create a
    # record of an install it is removing.
    if ($Stage -ne 'uninstall') { Initialize-InstallState }

    switch ($Stage) {
        '0'   { Invoke-Stage0 }
        '1'   { & (Join-Path $PSScriptRoot 'shopdb-preflight.ps1') -SitePort $SitePort -AppRoot $AppRoot }
        '2'   { Invoke-Stage2 }
        '3'   { Invoke-Stage3 }
        '4'   { Invoke-Stage4 }
        '5'   { Invoke-Stage5 }
        # Stage 0 is NOT in 'all' on purpose: most sites already run MySQL, and
        # installing a second server on top of an existing one is destructive.
        'all' { Invoke-Stage2; Invoke-Stage3; Invoke-Stage4; Invoke-Stage5 }
        'uninstall' { Invoke-Uninstall }
    }
    # Provisioning is finished only when a run that INCLUDED the later stages
    # completed. A successful stage 2 on its own leaves first-run open, because
    # the schema and the site are still ahead of it, and the retry that follows
    # must still be treated as part of the first install.
    if ($Stage -eq 'all' -or $Stage -eq '5') { Complete-InstallState }
    Write-Log "done. log: $script:LogPath" 'OK'
    exit 0
}
catch {
    Write-Log $_.Exception.Message 'FAIL'
    if ($script:Created.Count -gt 0) {
        $doRollback = $false
        if ($OnFailure -eq 'always') { $doRollback = $true }
        elseif ($OnFailure -eq 'ask') {
            Write-Host ''
            $answer = Read-Host 'Roll back what this run created? (yes/no)'
            $doRollback = ($answer -eq 'yes')
        }
        if ($doRollback) { Invoke-Rollback }
        else { Write-Log 'rollback declined; partial install left in place' 'WARN' }
    }
    # Stage 2 stops the pool on an upgrade so files can be replaced. If the run
    # then failed, a site that was SERVING before this started is now off, and
    # saying only "part-configured" left the operator to discover that from users.
    # Not restarted automatically: after a failure in or past stage 3 the deployed
    # code and the schema may no longer agree, and starting it would put a broken
    # application back in front of people. Say it plainly instead.
    $poolStopped = $false
    try { $poolStopped = [bool] $script:PoolWasStopped } catch { }
    if ($poolStopped) {
        Write-Log "the application pool '$AppPool' was stopped for this upgrade and is STILL STOPPED" 'FAIL'
        Write-Log '  the site was serving before this run and is now offline' 'FAIL'
        Write-Log "  once the cause above is fixed, re-run this installer, or start it by hand with:" 'FAIL'
        Write-Log "    Start-WebAppPool -Name '$AppPool'" 'FAIL'
    }
    Write-Log "log: $script:LogPath" 'FAIL'
    exit 1
}
