; ShopDB-Flask air-gapped Windows installer
; Wraps the tested PowerShell stages in an operator-facing wizard.
; Version 1.0
;
; DESIGN NOTE - why this is a thin wrapper and not a reimplementation:
; every install action lives in shopdb-install.ps1, which is verified end to end
; on Windows Server 2025 against both a bundled MySQL 8.4 LTS and an existing MySQL
; 5.6. Reimplementing any of it in Pascal Script would create a second code path
; that nobody tests. This file only collects operator input, runs the stages in
; order, and reports which one failed.

; Inno Setup 6.6.0 is the floor: WizardStyle below uses the built-in 'windows11'
; custom style, which 6.5 and earlier reject. Stated here so a build box on an
; older compiler fails with this sentence rather than with a bare
; "Value of [Setup] section directive WizardStyle is invalid".
#if VER < EncodeVer(6,6,0)
  #error This script needs Inno Setup 6.6.0 or newer (WizardStyle=... windows11). Download it from https://jrsoftware.org/isdl.php
#endif

#define AppName        "ShopDB-Flask"
; The PRODUCT version, generated into version.iss by the builder from
; shopdb/__init__.py. It was hardcoded, and had drifted to 0.9.0 while the
; product, the frontend and the newest tag all said 0.7.0 - so the delivered exe,
; its Add/Remove Programs entry and the version stamp written onto the server all
; disagreed with the code inside it, and 0.9.0 collided with a retired contract
; version. Never hardcode it here again.
;
; Do NOT bump a version to work around a locked output file: the BUILD STAMP
; below makes every compile a unique filename, which is what that problem needed.
#include "version.iss"
#define AppPublisher   "GE Aerospace"
#define BundleDir      "bundle"
; Single source for the install directory - used by DefaultDirName and by the
; wizard's pre-fill fallback, so the two cannot disagree.
#define DefaultDir     "C:\shopdb-flask"
; Generated by build-installer.sh from what is actually in the bundle, so the
; plugin page can never offer something the payload does not contain.
#include "plugins.iss"

[Setup]
AppId={{7C4E1A93-2F86-4D5B-9E31-8A0C6B5D4F27}
AppName={#AppName}
AppVersion={#AppVersion}
AppPublisher={#AppPublisher}
DefaultDirName={#DefaultDir}
DisableDirPage=no
CreateAppDir=yes
PrivilegesRequired=admin
OutputDir=.\Output
; Version identifies the RELEASE; the stamp identifies the BUILD. Separating them
; means recompiling never collides with an exe you happen to have open, and the
; version only moves when something meaningful changed.
#define BuildStamp GetDateTimeString('yyyymmdd-hhnn', '', '')
OutputBaseFilename=ShopDBFlask_Installer_{#AppVersion}_{#BuildStamp}
; The payload is ~200MB of wheels, Python and an MSI, all already compressed.
; lzma2/max on top of that costs minutes and saves almost nothing.
Compression=lzma2/normal
SolidCompression=yes

; --- Appearance -------------------------------------------------------------
; 'modern'    larger layout, Segoe UI, no 1990s bevels
; 'windows11' built-in custom style (6.6.0+) - rounded controls, current palette
; 'dynamic'   follows the machine's light/dark setting automatically
; 'hidebevels' removes the remaining sunken separator lines
; Custom styles switch themselves off under a high-contrast theme or /NOSTYLE,
; so accessibility is not broken by any of this.
WizardStyle=modern windows11 dynamic hidebevels
; 15% larger than default without scaling the font up - the preflight page is
; dense and benefits from the extra room.
WizardSizePercent=115
DisableWelcomePage=no
; Name the Start Menu folder and skip the "choose a folder" page - a server tool
; does not need the operator to invent a location for its shortcuts.
DefaultGroupName=ShopDB-Flask
DisableProgramGroupPage=yes
; Always write a setup log. Inno writes one only when asked, and every streamed
; line from the install stages goes into it, so this is the difference between a
; diagnosable failure and "exit 1" with nothing to look at.
SetupLogging=yes

; Artwork is generated by make-branding.py from the app's OWN brand assets, so
; the installer and the running site look like the same product. The @125/@250
; variants let Inno pick a crisp image on high-DPI displays rather than upscaling.
WizardImageFile=wizard-image.bmp,wizard-image@125.bmp,wizard-image@250.bmp
WizardSmallImageFile=wizard-small.bmp,wizard-small@125.bmp,wizard-small@250.bmp

; Installer executable icon, and the icon shown in Apps & Features. Without this
; Windows falls back to a generic setup icon and, for uninstall, to whatever exe
; is named - which was python.exe, i.e. a Python logo for a ShopDB entry.
SetupIconFile=shopdb.ico
UninstallDisplayIcon={app}\shopdb.ico
UninstallDisplayName=ShopDB-Flask

[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"

[Messages]
WelcomeLabel1=Set up ShopDB-Flask on this server
; Short, and says the two things an operator actually wants up front: it will not
; need the internet, and it will tell them before it changes anything.
; The installer supplies Python, the wheels, the IIS modules and optionally MySQL.
; It does NOT install the IIS Web Server role itself - that is a Windows feature,
; and the check on the next page confirms it is present rather than adding it.
WelcomeLabel2=Everything this application needs is included - Python, its packages, the IIS modules and optionally MySQL. No internet connection is used at any point.%n%nThis server must already have the IIS Web Server role installed. The next page checks that, and everything else this needs, before anything is changed.%n%nYou will then be asked a few short questions. Nothing on this server is changed until the final confirmation.
ClickNext=Click Next to check this server.
FinishedHeadingLabel=ShopDB-Flask is ready
SetupAppTitle=ShopDB-Flask Setup
SetupWindowTitle=ShopDB-Flask Setup

[Files]
; The whole verified bundle, staged next to this script by build-installer.sh.
; Extracted during the install step, so it is available from ssPostInstall onward
; but NOT during the wizard pages.
Source: "{#BundleDir}\*"; DestDir: "{tmp}\shopdb-bundle"; \
    Flags: ignoreversion recursesubdirs createallsubdirs deleteafterinstall
; The preflight has to run on a WIZARD PAGE, which happens long before the [Files]
; section is processed. 'dontcopy' plus ExtractTemporaryFile is the only way to get
; a file on disk that early. Listed twice on purpose - once for each phase.
Source: "{#BundleDir}\shopdb-preflight.ps1"; Flags: dontcopy
; The operator's day-to-day tool. Installed into the app directory and given
; Start Menu shortcuts, so nobody has to open IIS Manager to restart the site.
; [UninstallRun] executes this from {app}. It was only ever staged into {tmp}
; with deleteafterinstall, so uninstall ran powershell against a path that no
; longer existed, exited non-zero unnoticed (runhidden, no result check), and
; Windows reported success while the site, app pool, firewall rule and .env with
; its plaintext password were all left in place.
Source: "{#BundleDir}\shopdb-install.ps1";   DestDir: "{app}"; Flags: ignoreversion
Source: "{#BundleDir}\shopdb-preflight.ps1"; DestDir: "{app}"; Flags: ignoreversion
Source: "shopdb-admin.ps1"; DestDir: "{app}"; Flags: ignoreversion
; Kept on disk so the Start Menu shortcuts and the uninstall entry have an icon.
Source: "shopdb.ico"; DestDir: "{app}"; Flags: ignoreversion
; Status colours. These are BITMAPS, not control colours: a custom VCL style
; (WizardStyle=... windows11) repaints styled controls and ignores both
; Font.Color and TPanel.Color, so a bitmap is the only thing guaranteed to show
; the colour the operator is meant to see.
Source: "swatch-ok.bmp";   Flags: dontcopy
Source: "swatch-warn.bmp"; Flags: dontcopy
Source: "swatch-bad.bmp";  Flags: dontcopy

[Icons]
; A folder rather than loose icons: this is a server tool, not a desktop app.
;
; EVERY shortcut passes -AppRoot and -SitePort. The console defaults to
; C:\shopdb-flask and port 8090, so on any install that chose a different
; directory or port it looked in the wrong place and reported a perfectly healthy
; site as broken - from a Start Menu shortcut the installer wrote itself.
Name: "{group}\ShopDB-Flask Console"; Filename: "powershell.exe"; \
    Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \
    WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \
    Comment: "Status, restart, logs and backups"
; Resolves the address from .env at click time rather than baking one in. The
; literal http://localhost:8090/login this used to carry was wrong for every
; subpath install and every non-default port, with no operator mistake involved.
Name: "{group}\Open ShopDB-Flask"; Filename: "powershell.exe"; \
    Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{app}\shopdb-admin.ps1"" open -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \
    WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \
    Comment: "Open the application in a browser"
Name: "{group}\Restart ShopDB-Flask"; Filename: "powershell.exe"; \
    Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" restart -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \
    WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \
    Comment: "Recycle the application pool"
Name: "{group}\Back up the database"; Filename: "powershell.exe"; \
    Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" backup -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \
    WorkingDir: "{app}"; IconFilename: "{app}\shopdb.ico"; \
    Comment: "Write a .sql dump to ProgramData"

[Run]
; Offer the console at the end, unticked - finishing the wizard should not
; surprise anyone with a shell.
; Setup is a 32-bit process, so a bare "powershell.exe" here resolves through
; WOW64 to the 32-bit build, whose Get-Website fails with REGDB_E_CLASSNOTREG
; and makes the console report "cannot read IIS". Sysnative reaches the real
; System32 from a 32-bit process. The script also self-corrects, but launching
; it correctly avoids a visible relaunch.
Filename: "{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe"; \
    Parameters: "-NoExit -NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-admin.ps1"" -AppRoot ""{app}"" -SitePort {code:SitePortValue}"; \
    Description: "Open the ShopDB-Flask console"; \
    Flags: postinstall skipifsilent unchecked

[Code]
var
  DbChoicePage:   TInputOptionWizardPage;
  DbDetailsPage:  TInputQueryWizardPage;
  DbCredsPage:    TInputQueryWizardPage;
  SitePage:       TInputQueryWizardPage;
  PreflightPage:  TWizardPage;
  BannerPanel:    TPanel;
  BannerBar:      TBitmapImage;
  BannerText:     TNewStaticText;
  BannerSub:      TNewStaticText;
  DetailPanel:    TPanel;
  FooterText:     TNewStaticText;
  RecheckButton:  TNewButton;
  // Set by the preflight render. The wizard REFUSES to leave the results page
  // while this is True: the check used to be advisory, so a server missing IIS
  // sailed through every page and failed in the middle of installing, having
  // already put Python on the box.
  PreflightBlocked: Boolean;
  PreflightDone:  Boolean;
  // Shown while the preflight runs. Without it, clicking Next appears to hang:
  // the check takes a few seconds, runs hidden, and gives no sign of life.
  CheckingPage:   TOutputProgressWizardPage;
  PluginPage:     TInputOptionWizardPage;
  CatalogPage:    TInputOptionWizardPage;
  PluginNames:    TArrayOfString;
  PluginPageReady: Boolean;
  DbPageReady:     Boolean;
  DeployPage:      TInputOptionWizardPage;
  DeployPageReady: Boolean;
  ClientIpPage:    TInputOptionWizardPage;
  // Set from the streamed stage output so a failure can name its cause. Without
  // this the wizard could only report "exit 1", which points at nothing.
  FailCause:      String;
  FailDetail:     String;
  UseBundledDb:   Boolean;
  LogPath:        String;

const
  DB_BUNDLED  = 0;
  DB_EXISTING = 1;
  // Row geometry, in the page's own coordinate space. ScaleY/ScaleX keep these
  // correct at 125%/150% DPI - hardcoded pixels would overlap on a 4K display.
  ROW_GAP     = 4;


// Plugin directory names are developer-facing. Show operators what the feature
// actually is; anything unlisted falls back to its raw name so a new plugin
// still appears rather than vanishing.
function PluginLabel(const Name: String): String;
begin
  if      Name = 'computers'     then Result := 'Computers and workstations'
  else if Name = 'machines'      then Result := 'Machines (CNC, CMM, lathes)'
  else if Name = 'printers'      then Result := 'Printers and supplies'
  else if Name = 'network'       then Result := 'Network devices'
  else if Name = 'measuringtools' then Result := 'Measuring tools and gauges'
  else if Name = 'printedparts'  then Result := '3D printed parts'
  else if Name = 'knowledgebase' then Result := 'Knowledge base articles'
  else if Name = 'slides'        then Result := 'Shopfloor display slides'
  else if Name = 'warranty'      then Result := 'Warranty tracking'
  else if Name = 'notifications' then Result := 'Notifications and announcements'
  else if Name = 'usb'           then Result := 'USB device checkout'
  else if Name = 'employees'     then Result := 'Employee directory'
  else if Name = 'geenforce'     then Result := 'GE-Enforce manifests'
  else Result := Name;
end;

// True when a fresh install should tick this by default. The five omitted here
// are specialised; a site that wants them can tick them.
// Pre-tick to match each plugin's own manifest. These five ship
// "default_enabled": false, so a site gets them only by asking. The list used to
// omit measuringtools and printedparts, which meant the wizard installed and
// enabled them against their manifests on every site that took the defaults -
// creating tables nobody asked for.
//
// Keep this in step with the manifests. tests/test_installer_defaults.py fails
// if they drift.
function PluginDefault(const Name: String): Boolean;
begin
  Result := (Name <> 'usb') and (Name <> 'employees') and (Name <> 'geenforce')
            and (Name <> 'measuringtools') and (Name <> 'printedparts');
end;

// Comma-separated list of what the operator ticked.
function SelectedPlugins: String;
var
  I, Count: Integer;
begin
  Result := '';
  if PluginPage = nil then Exit;
  // Never index past what was actually ADDED to the page. PluginNames comes from
  // a build-time define and CheckListBox.Items from the Add() calls; if those
  // ever disagree, reading Values[] past the end is a runtime error rather than
  // a graceful miss.
  Count := GetArrayLength(PluginNames);
  if PluginPage.CheckListBox.Items.Count < Count then
    Count := PluginPage.CheckListBox.Items.Count;
  for I := 0 to Count - 1 do
    if PluginPage.Values[I] then
    begin
      if Result <> '' then Result := Result + ',';
      Result := Result + PluginNames[I];
    end;
end;

procedure SetSwatch(Img: TBitmapImage; const Swatch: String);
begin
  ExtractTemporaryFile(Swatch);
  Img.Bitmap.LoadFromFile(ExpandConstant('{tmp}\') + Swatch);
end;

// A solid colour block, drawn as a stretched bitmap so no VCL style can repaint
// it. Used for the row markers and the banner's accent bar.
function AddSwatch(Parent: TWinControl; L, T, W, H: Integer; const Swatch: String): TBitmapImage;
begin
  Result := TBitmapImage.Create(WizardForm);
  Result.Parent := Parent;
  Result.Left := L;
  Result.Top := T;
  Result.Width := W;
  Result.Height := H;
  Result.Stretch := True;
  ExtractTemporaryFile(Swatch);
  Result.Bitmap.LoadFromFile(ExpandConstant('{tmp}\') + Swatch);
end;

// Defined further down, next to the preflight rendering they drive. Declared here
// because InitializeWizard wires RecheckClick to a button before that point.
procedure RunPreflight; forward;
procedure RecheckClick(Sender: TObject); forward;

procedure InitializeWizard;
var
  I: Integer;
  Domain: String;
begin
  LogPath := ExpandConstant('{tmp}\shopdb-installer-wizard.log');

  // A custom page, not a memo. Status belongs in controls with colour, not in a
  // wall of monospace text that has to be read line by line.
  PreflightPage := CreateCustomPage(wpWelcome,
    'Server check', 'Confirming this server is ready');

  // Verdict banner. Colour is the whole point: an operator should know within a
  // second whether they can continue, without reading anything.
  BannerPanel := TPanel.Create(WizardForm);
  BannerPanel.Parent := PreflightPage.Surface;
  BannerPanel.Left := 0;
  BannerPanel.Top := 0;
  BannerPanel.Width := PreflightPage.SurfaceWidth;
  BannerPanel.Height := ScaleY(58);
  BannerPanel.BevelOuter := bvNone;
  // No Color here: the style would repaint it. The verdict colour is carried by
  // BannerBar, a stretched bitmap down the left edge, which the style cannot touch.

  BannerBar := AddSwatch(BannerPanel, 0, 0, ScaleX(6), ScaleY(58), 'swatch-ok.bmp');

  BannerText := TNewStaticText.Create(WizardForm);
  BannerText.Parent := BannerPanel;
  BannerText.Left := ScaleX(18);
  BannerText.Top := ScaleY(10);
  BannerText.Font.Size := 12;
  BannerText.Font.Style := [fsBold];
  BannerText.Caption := 'Checking...';

  BannerSub := TNewStaticText.Create(WizardForm);
  BannerSub.Parent := BannerPanel;
  BannerSub.Left := ScaleX(18);
  BannerSub.Top := ScaleY(33);
  BannerSub.Width := PreflightPage.SurfaceWidth - ScaleX(28);
  BannerSub.AutoSize := False;
  BannerSub.Caption := '';

  // Rows are added here at run time, once the results are known.
  DetailPanel := TPanel.Create(WizardForm);
  DetailPanel.Parent := PreflightPage.Surface;
  DetailPanel.Left := 0;
  DetailPanel.Top := BannerPanel.Height + ScaleY(12);
  DetailPanel.Width := PreflightPage.SurfaceWidth;
  DetailPanel.Height := PreflightPage.SurfaceHeight - BannerPanel.Height - ScaleY(34);
  DetailPanel.BevelOuter := bvNone;
  DetailPanel.Color := clWindow;

  FooterText := TNewStaticText.Create(WizardForm);
  FooterText.Parent := PreflightPage.Surface;
  FooterText.Left := 0;
  FooterText.Top := PreflightPage.SurfaceHeight - ScaleY(16);
  FooterText.Width := PreflightPage.SurfaceWidth - ScaleX(84);
  FooterText.AutoSize := False;
  FooterText.Caption := '';

  // A blocking page needs a way forward that is not "cancel the installer". The
  // operator fixes what the page told them to fix - installs the IIS role, frees
  // the port - and re-checks without starting over.
  RecheckButton := TNewButton.Create(WizardForm);
  RecheckButton.Parent := PreflightPage.Surface;
  RecheckButton.Width := ScaleX(78);
  RecheckButton.Height := ScaleY(23);
  RecheckButton.Left := PreflightPage.SurfaceWidth - RecheckButton.Width;
  RecheckButton.Top := PreflightPage.SurfaceHeight - ScaleY(23);
  RecheckButton.Caption := 'Check again';
  RecheckButton.OnClick := @RecheckClick;

  CheckingPage := CreateOutputProgressPage('Checking this server',
    'Reading the current configuration. Nothing is being changed.');

  // Which features this site uses. Multi-select, and pre-ticked from the
  // EXISTING install when there is one - so an upgrade shows what you already
  // have and ticking a new box adds it.
  PluginPage := CreateInputOptionPage(PreflightPage.ID,
    'Features', 'Which parts of ShopDB-Flask does this site use?',
    'Unticked features are simply not set up, and their database tables are not ' +
    'created. On an upgrade this shows what the site already has - ticking a new ' +
    'box adds it, but UNTICKING one does NOT remove a feature that is already ' +
    'installed. Removing is a deliberate step, not a side effect of an upgrade.',
    False, True);
  PluginNames := StringSplit('{#AvailablePlugins}', [','], stAll);
  for I := 0 to GetArrayLength(PluginNames) - 1 do
    if Trim(PluginNames[I]) <> '' then
      PluginPage.Add(PluginLabel(Trim(PluginNames[I])));

  // Offered, not forced. A site that machines nothing inherits 21 machine types
  // it will never use, and a catalog nobody wanted is just clutter in every
  // dropdown. Ticked by default because the alternative is retyping a vendor and
  // model list another site already built.
  CatalogPage := CreateInputOptionPage(PluginPage.ID,
    'Starter data', 'Load the shared equipment catalog?',
    'Vendors, model numbers and the type lists, as built up across the other ' +
    'sites. It only ADDS - nothing existing is changed or removed - and it can ' +
    'be loaded later with "flask seed catalog". No assets, locations or people ' +
    'are included; this is catalogue information only.',
    False, False);
  CatalogPage.Add('Load the shared vendor and model catalog');
  CatalogPage.Values[0] := True;

  DbChoicePage := CreateInputOptionPage(CatalogPage.ID,
    'Database', 'Where should ShopDB-Flask store its data?',
    'Most sites already run MySQL for the existing shopdb application. If so, ' +
    'choose the second option - installing a second server would collide on ' +
    'port 3306.',
    True, False);
  DbChoicePage.Add('Install the bundled MySQL 8.4 LTS (new servers only)');
  DbChoicePage.Add('Use a MySQL server this site already runs');
  DbChoicePage.SelectedValueIndex := DB_EXISTING;

  DbDetailsPage := CreateInputQueryPage(DbChoicePage.ID,
    'Existing database', 'Connection details',
    'The database and application user must already exist - the installer does ' +
    'not create them, so it never needs administrative rights on your database ' +
    'server. The exact SQL to give your DBA is in the INSTALL-WINDOWS guide.');
  DbDetailsPage.Add('Host:',     False);
  DbDetailsPage.Add('Port:',     False);
  DbDetailsPage.Add('Database:', False);
  DbDetailsPage.Values[0] := '127.0.0.1';
  DbDetailsPage.Values[1] := '3306';
  DbDetailsPage.Values[2] := 'shopdb_flask';

  // SPLIT ACROSS TWO PAGES on purpose. CreateInputQueryPage stacks its fields
  // below the description and does not scroll or shrink: with five fields the
  // last one is drawn past the bottom of the surface and simply does not appear.
  // Trimming the description bought one field back and lost the next, which is
  // guessing at a pixel budget that varies with DPI and font. Three fields and
  // two fields both fit under any reasonable description, at any scaling.
  DbCredsPage := CreateInputQueryPage(DbDetailsPage.ID,
    'Existing database', 'Sign-in',
    'The account the application uses to reach that database. It needs full ' +
    'rights on it, and nothing outside it.');
  DbCredsPage.Add('Username:', False);
  DbCredsPage.Add('Password:', True);
  DbCredsPage.Values[0] := 'shopdb';

  // How the application is published. Offered only when the bundle actually
  // carries a subpath SPA build - Vite compiles the base path in, so this can
  // never be a pure runtime switch.
  DeployPage := CreateInputOptionPage(DbCredsPage.ID,
    'Address', 'How should people reach ShopDB-Flask?',
    'Both options serve the same application. The second needs no new DNS name '
    + 'and no port number, because it rides this server''s existing address.',
    True, False);
  DeployPage.Add('Its own address, on a port  -  http://<server>:8090/');
  DeployPage.Add('Under this server''s existing address  -  http://<server>/{#SubpathAlias}/');
  DeployPage.SelectedValueIndex := 0;

  SitePage := CreateInputQueryPage(DeployPage.ID,
    'Web site', 'How the site is published',
    'The host name is used for CORS. It must be the name operators actually ' +
    'type in the browser, or the page will load but its data requests will fail.');
  SitePage.Add('Host name:', False);
  SitePage.Add('Port:',      False);
  // FQDN, not the NetBIOS name: this value becomes CORS_ORIGINS, and a browser
  // arriving at the fully qualified address would be refused by a bare hostname.
  Domain := '';
  RegQueryStringValue(HKEY_LOCAL_MACHINE,
    'SYSTEM\CurrentControlSet\Services\Tcpip\Parameters', 'Domain', Domain);
  if Domain <> '' then
    SitePage.Values[0] := GetComputerNameString + '.' + Domain
  else
    SitePage.Values[0] := GetComputerNameString;
  SitePage.Values[1] := '8090';

  // Where the real client IP comes from. Not cosmetic: IIS sends no
  // X-Forwarded-For of its own, so with neither option applied every request
  // reads as 127.0.0.1 and the GE-Enforce IP allowlist, the dashboard's
  // visitor-location lookup and per-host login rate limiting all fail silently.
  //
  // The two answers are mutually exclusive, and picking the wrong one is worse
  // than picking neither: the rule OVERWRITES the header with REMOTE_ADDR, which
  // is exactly right when IIS faces clients (it defeats spoofing) and exactly
  // wrong behind a proxy (REMOTE_ADDR is the proxy, so the real client IP is
  // discarded). Hence a question rather than a default.
  ClientIpPage := CreateInputOptionPage(SitePage.ID,
    'Client addresses', 'Does anything sit between your users and this server?',
    'ShopDB records the address of every request, and some features decide what '
    + 'to show based on it. If you are not sure, choose the first option - it is '
    + 'correct for a server users reach directly.',
    True, False);
  ClientIpPage.Add('No - users reach this server directly  (installs URL Rewrite)');
  // Phrased as "already adds the visitor's address" rather than "is a proxy":
  // the operator can check that with their network team, whereas "is there a
  // proxy" invites a guess, and guessing wrong here silently discards the real
  // client address on every request.
  ClientIpPage.Add('Yes - a load balancer or gateway that already adds the visitor''s address');
  ClientIpPage.SelectedValueIndex := 0;
end;

// 'direct' installs URL Rewrite from the bundle and enables the X-Forwarded-For
// rule; 'proxy' leaves both alone because the proxy already sets the header.
function ClientIpSourceArg: String;
begin
  if ClientIpPage.SelectedValueIndex = 1 then
    Result := 'proxy'
  else
    Result := 'direct';
end;

// Full path to the 64-bit PowerShell.
//
// Setup is a 32-bit process, so 'powershell.exe' resolves through WOW64 to the
// 32-bit SysWOW64 build, which CANNOT instantiate IIS's 64-bit COM objects:
//   "Retrieving the COM class factory for component with CLSID ... 80040144"
// from Get-Website, while the identical script run from a normal shell is fine.
// 'Sysnative' is the alias that lets a 32-bit process reach the real System32;
// it exists ONLY for 32-bit processes, hence the IsWin64 guard.
//
// Inno has ExecAndCaptureOutputWithNativeSysDir for this, but there is no
// ExecAndLogOutput equivalent, so resolving the path ourselves keeps both call
// sites on one mechanism instead of two that can drift apart.
function PowerShellPath: String;
begin
  if IsWin64 then
    Result := ExpandConstant('{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe')
  else
    Result := ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe');
end;

// Streamed line by line from ExecAndLogOutput, so the operator sees the install
// happening instead of a frozen wizard. Stage 2 alone installs 47 wheels and can
// run for minutes with nothing on screen.
procedure OnStageLog(const S: String; const Error, FirstLine: Boolean);
var
  Line: String;
begin
  // Everything goes to the setup log verbatim - that is the diagnostic record.
  Log('[shopdb] ' + S);
  if Error then Exit;
  Line := Trim(S);
  if Line = '' then Exit;

  // Recognise the failures an operator can actually act on, and translate them
  // into plain language. Matching on the SYMPTOM text (not an exit code) is what
  // lets the wizard say "wrong password" instead of "exit 1".
  if Pos('Access denied for user', Line) > 0 then
  begin
    FailCause  := 'The database rejected the username or password.';
    FailDetail := 'Go back to the Database page and re-enter them. Check for '
                + 'capital letters - the password is case-sensitive.';
  end
  else if Pos('Unknown database', Line) > 0 then
  begin
    FailCause  := 'That database does not exist on the server.';
    FailDetail := 'Your DBA must create it before installing, along with the '
                + 'application user that owns it.';
  end
  else if (Pos('Can''t connect to MySQL server', Line) > 0) or (Pos('timed out', Line) > 0) then
  begin
    FailCause  := 'The database server did not answer.';
    FailDetail := 'Check the host name and port on the Database page, and that '
                + 'MySQL is running and reachable from this server.';
  end
  else if Pos('index flags', Line) > 0 then
  begin
    FailCause  := 'This MySQL 5.6 server is missing three required settings.';
    FailDetail := 'innodb_file_per_table, innodb_file_format=Barracuda and '
                + 'innodb_large_prefix must be set, then MySQL restarted.';
  end
  else if Pos('does not represent a valid object', Line) > 0 then
  begin
    FailCause  := 'IIS refused to create the site or application pool.';
    FailDetail := 'This usually means a previous ShopDB site or pool is in a '
                + 'half-removed state. Run IISRESET and try again.';
  end;

  // The status caption is a DIFFERENT job: it tells an operator what is happening
  // right now. Raw log lines are useless for that - "exec python.exe (7 args)"
  // says nothing, and the argument COUNT is only there because argument VALUES
  // must never be logged (they can carry a password).
  // So: translate the few lines that mark real progress, and ignore the rest.
  if Pos('exec ', Line) > 0 then Exit;

  if Pos('STAGE 0', Line) > 0 then
    WizardForm.StatusLabel.Caption := 'Installing the bundled MySQL database...'
  else if Pos('STAGE 2', Line) > 0 then
    WizardForm.StatusLabel.Caption := 'Installing Python and the application...'
  else if Pos('wheelhouse', Line) > 0 then
    WizardForm.StatusLabel.Caption := 'Installing dependencies (offline)...'
  else if Pos('STAGE 3', Line) > 0 then
    WizardForm.StatusLabel.Caption := 'Preparing the database...'
  else if Pos('db upgrade', Line) > 0 then
    WizardForm.StatusLabel.Caption := 'Creating the database schema...'
  else if Pos('seed ', Line) > 0 then
    WizardForm.StatusLabel.Caption := 'Adding reference data...'
  else if Pos('apply-profile', Line) > 0 then
    WizardForm.StatusLabel.Caption := 'Installing the plugins this site uses...'
  else if Pos('upgrade-all', Line) > 0 then
    WizardForm.StatusLabel.Caption := 'Applying plugin migrations...'
  else if Pos('prune-schema', Line) > 0 then
    WizardForm.StatusLabel.Caption := 'Removing unused plugin tables...'
  else if Pos('STAGE 4', Line) > 0 then
    WizardForm.StatusLabel.Caption := 'Configuring IIS...'
  else if Pos('STAGE 5', Line) > 0 then
    WizardForm.StatusLabel.Caption := 'Checking the site responds...'
  else if Pos('[FAIL]', Line) > 0 then
    WizardForm.StatusLabel.Caption := 'Failed - see the message that follows';
end;

function RunPowerShell(const ScriptArgs: String; var ResultCode: Integer): Boolean;
var
  Cmd: String;
begin
  Cmd := '-NoProfile -ExecutionPolicy Bypass -File "' +
         ExpandConstant('{tmp}\shopdb-bundle\shopdb-install.ps1') + '" ' + ScriptArgs;
  // ExecAndLogOutput streams output through OnStageLog as it is produced, and
  // also writes it into the setup log - so a failed install leaves a full trace
  // without the operator having to find the PowerShell log in TEMP.
  // 64-bit PowerShell: stage 4 configures IIS, which the 32-bit build cannot do.
  Result := ExecAndLogOutput(PowerShellPath, Cmd, '', SW_HIDE,
                             ewWaitUntilTerminated, ResultCode, @OnStageLog);
end;

// One result row: coloured dot, bold title, wrapped detail. Returns the Y for
// the next row so the caller does not have to guess heights.
//
// Status marker. Four approaches failed before this one, all recorded so nobody
// re-treads them:
//   - PNG into TBitmapImage.Bitmap  -> "Bitmap image is not valid" (BMP only).
//   - Chr(9679) for U+25CF          -> renders 'I'-diaeresis; Chr takes a BYTE.
//   - Wingdings glyph + Font.Color  -> draws, but BLACK: a custom VCL style owns
//                                      text painting and ignores Font.Color.
//   - TPanel + Color                -> also repainted by the style; no colour.
// A stretched BMP is painted verbatim, so the colour always shows.
function AddRow(Y: Integer; const Swatch, Title, Detail: String): Integer;
var
  T, D: TNewStaticText;
begin
  AddSwatch(DetailPanel, ScaleX(3), Y + ScaleY(4), ScaleX(10), ScaleY(10), Swatch);

  T := TNewStaticText.Create(WizardForm);
  T.Parent := DetailPanel;
  T.Left := ScaleX(22);
  T.Top := Y;
  T.Width := DetailPanel.Width - ScaleX(24);
  T.Font.Style := [fsBold];
  T.Caption := Title;

  D := TNewStaticText.Create(WizardForm);
  D.Parent := DetailPanel;
  D.Left := ScaleX(22);
  D.Top := Y + ScaleY(15);
  D.Width := DetailPanel.Width - ScaleX(24);
  D.AutoSize := False;
  D.WordWrap := True;
  D.Height := ScaleY(28);
  D.Font.Color := clGrayText;
  D.Caption := Detail;

  Result := Y + ScaleY(46) + ScaleY(ROW_GAP);
end;

// Turn the delimited records into a verdict plus rows. Passing checks are NEVER
// listed: 10 green lines bury the one amber line that actually needs reading.
// If nothing is wrong the page collapses to a single reassuring statement.
procedure RenderPreflight(Lines: TArrayOfString; var HasBlockers: Boolean);
var
  I, Y, Shown, Fails, Warns, Passes, RowHeight, MaxRows, Hidden: Integer;
  Parts: TArrayOfString;
  Check, Detail, Fix: String;
begin
  Fails := 0; Warns := 0; Passes := 0;
  Y := ScaleY(4);
  Shown := 0;

  // How many rows actually FIT, measured, not guessed. The old cap of 6 bore no
  // relation to the panel, which holds roughly three: at four or five notes the
  // rows below were drawn past the bottom edge and silently vanished - taking
  // with them the warning that the bundled MySQL collides on port 3306, which is
  // exactly what the operator needs before the very next page.
  RowHeight := ScaleY(46) + ScaleY(ROW_GAP);
  if RowHeight < 1 then RowHeight := 1;
  MaxRows := DetailPanel.Height div RowHeight;
  if MaxRows < 1 then MaxRows := 1;

  // Blockers first, then warnings - a second pass rather than one, so severity
  // ordering does not depend on the order the checks happen to run in.
  for I := 0 to GetArrayLength(Lines) - 1 do
  begin
    Parts := StringSplit(Lines[I], ['|'], stAll);
    if GetArrayLength(Parts) < 4 then Continue;
    if Parts[0] = 'FAIL' then Fails := Fails + 1
    else if Parts[0] = 'WARN' then Warns := Warns + 1
    else if Parts[0] = 'PASS' then Passes := Passes + 1;
  end;

  for I := 0 to GetArrayLength(Lines) - 1 do
  begin
    // Blockers are capped too. Without this the FAIL loop drew every failure,
    // however many, straight off the bottom of the panel.
    if Shown >= MaxRows then Break;
    Parts := StringSplit(Lines[I], ['|'], stAll);
    if GetArrayLength(Parts) < 4 then Continue;
    if Parts[0] <> 'FAIL' then Continue;
    // Qualify the check with its area: "Service" and "Port 3306" on their own
    // say nothing about WHAT service or whose port.
    Check := Parts[1] + ' - ' + Parts[2]; Detail := Parts[3];
    if GetArrayLength(Parts) > 4 then Fix := Parts[4] else Fix := '';
    if Fix <> '' then Detail := Detail + '  -  ' + Fix;
    Y := AddRow(Y, 'swatch-bad.bmp', Check, Detail);
    Shown := Shown + 1;
  end;

  for I := 0 to GetArrayLength(Lines) - 1 do
  begin
    if Shown >= MaxRows then Break;
    Parts := StringSplit(Lines[I], ['|'], stAll);
    if GetArrayLength(Parts) < 4 then Continue;
    if Parts[0] <> 'WARN' then Continue;
    Check := Parts[1] + ' - ' + Parts[2]; Detail := Parts[3];
    if GetArrayLength(Parts) > 4 then Fix := Parts[4] else Fix := '';
    if Fix <> '' then Detail := Detail + '  -  ' + Fix;
    Y := AddRow(Y, 'swatch-warn.bmp', Check, Detail);
    Shown := Shown + 1;
  end;

  HasBlockers := Fails > 0;

  if Fails > 0 then
  begin
    SetSwatch(BannerBar, 'swatch-bad.bmp');
    BannerText.Caption := 'This server is not ready';
    BannerSub.Caption := IntToStr(Fails) + ' problem(s) must be fixed before installing.';
  end
  else if Warns > 0 then
  begin
    SetSwatch(BannerBar, 'swatch-warn.bmp');
    BannerText.Caption := 'Ready, with notes';
    BannerSub.Caption := 'Installation can continue. ' + IntToStr(Warns) +
                         ' item(s) below are worth reading first.';
  end
  else
  begin
    SetSwatch(BannerBar, 'swatch-ok.bmp');
    BannerText.Caption := 'This server is ready';
    BannerSub.Caption := 'Everything needed for ShopDB-Flask is in place.';
    // Nothing to list, so say so rather than leaving an empty white box.
    AddRow(ScaleY(4), 'swatch-ok.bmp', 'All checks passed',
           'IIS, HttpPlatformHandler, disk space, ports and the database were all '
           + 'verified. Nothing needs your attention.');
  end;

  // Count what was actually left out rather than inferring it from the old cap,
  // which under-reported: with 2 failures and 5 warnings it claimed everything
  // was shown while four rows had been dropped.
  Hidden := (Fails + Warns) - Shown;
  if Hidden > 0 then
    FooterText.Caption := IntToStr(Hidden) + ' further item(s) are not shown here. '
                          + 'All of them are in the install log.'
  else
    FooterText.Caption := IntToStr(Passes) + ' checks passed. Nothing has been changed on this server.';
end;

// Run the read-only check and paint its results. Called from Next on the welcome
// page, and again whenever the operator fixes something and presses Check again.
//
// Stage 1 changes nothing, so running it before the operator commits to anything
// is free, and it catches the blockers that are expensive later: IIS absent,
// locked config sections, the port in use, MySQL 5.6 missing its index flags.
procedure RunPreflight;
var
  I, ResultCode: Integer;
  Output: TExecOutput;
  PreflightScript, Detail: String;
  HasBlockers: Boolean;
begin
  // The bundle is NOT extracted yet at wizard time - [Files] is processed during
  // the install step. Pull just this one script out of the compressed payload.
  ExtractTemporaryFile('shopdb-preflight.ps1');
  PreflightScript := ExpandConstant('{tmp}\shopdb-preflight.ps1');

  // Visible feedback for the few seconds the check takes. SW_HIDE means the
  // operator sees nothing at all otherwise, and a frozen wizard reads as a
  // crash. ProgressBar.Style := npbstMarquee because the duration is unknown -
  // a bar that sits at 0% is worse than no bar.
  CheckingPage.SetText('Looking at IIS, disk, ports and the database...', '');
  CheckingPage.SetProgress(0, 0);
  CheckingPage.ProgressBar.Style := npbstMarquee;
  CheckingPage.Show;
  try
    // ExecAndCaptureOutput (6.4.0+) hands back stdout and stderr as string arrays,
    // so output never touches disk. That deletes BOTH bugs this page shipped with:
    //   - Exec() has no shell, so "> file" went to PowerShell as a literal
    //     argument and no file was ever written (page came up empty);
    //   - PowerShell 5.1 writes UTF-16LE, and LoadStringFromFile reads bytes as
    //     ANSI, so the page rendered as garbage with a null between characters.
    // Neither failure mode can recur now: there is no file and no encoding step.
    // 64-bit PowerShell - see PowerShellPath. The IIS checks fail without it.
    ExecAndCaptureOutput(PowerShellPath,
         '-NoProfile -ExecutionPolicy Bypass -File "' + PreflightScript + '" -Delimited',
         '', SW_HIDE, ewWaitUntilTerminated, ResultCode, Output);

    // Every run repaints from scratch. The old code rendered ONCE and latched,
    // so a re-check could not have shown a different answer even if the operator
    // had fixed everything.
    while DetailPanel.ControlCount > 0 do
      DetailPanel.Controls[0].Free;

    PreflightDone := True;
    if (not Output.Error) and (GetArrayLength(Output.StdOut) > 0) then
    begin
      RenderPreflight(Output.StdOut, HasBlockers);
      PreflightBlocked := HasBlockers;
    end
    else
    begin
      // stderr is captured SEPARATELY, so a failure can report what actually
      // went wrong instead of only an exit code.
      Detail := '';
      for I := 0 to GetArrayLength(Output.StdErr) - 1 do
        Detail := Detail + Output.StdErr[I] + ' ';
      if Trim(Detail) = '' then Detail := 'No error output was produced.';
      SetSwatch(BannerBar, 'swatch-warn.bmp');
      BannerText.Caption := 'Could not check this server';
      BannerSub.Caption := 'Nothing has been verified. Continuing is a risk.';
      AddRow(ScaleY(4), 'swatch-warn.bmp',
             'Check did not run (exit ' + IntToStr(ResultCode) + ')', Detail);
      FooterText.Caption := 'Nothing has been changed on this server.';
      // A check that could not RUN is not a check that PASSED. It does not block -
      // there is no evidence of a problem - but it must not read like a green light.
      PreflightBlocked := False;
    end;
  finally
    CheckingPage.Hide;
  end;
end;

procedure RecheckClick(Sender: TObject);
begin
  RunPreflight;
end;

// The port the operator chose, for the [Icons]/[Run] entries. A subpath install
// is reached on the parent site's port, and the console works that out from
// MOUNT_PATH in .env, so the value only has to be right for the own-site case.
function SitePortValue(Param: String): String;
begin
  Result := SitePage.Values[1];
  if Trim(Result) = '' then Result := '8090';
end;

// Where an EXISTING install would be, for the pre-fill reads.
//
// Not WizardDirValue() alone: these pages are inserted after wpWelcome and so
// run BEFORE the directory page, where that value can still be empty - and an
// empty base silently turned every pre-fill into "no existing install", which
// is why the Address page kept defaulting to 8090 on a /shopdb server.
// Falls back to the compile-time default, which is also the only directory the
// operator could have meant at that point in the wizard.
function InstalledDir: String;
begin
  Result := WizardDirValue;
  if Trim(Result) = '' then Result := ExpandConstant('{#DefaultDir}');
  Result := AddBackslash(Result);
  Log('[shopdb] pre-fill base directory: ' + Result);
end;

// Everything typed in the wizard ends up on a command line that
// CommandLineToArgvW parses, and reaches PowerShell parameters typed [int] or
// [string]. A blank, a space, or a trailing backslash therefore has to be caught
// HERE - by the time the script sees it, the arguments have already shifted.

function IsValidPort(const S: String): Boolean;
var
  I, N: Integer;
begin
  Result := False;
  if (S = '') or (Length(S) > 5) then Exit;
  for I := 1 to Length(S) do
    if (S[I] < '0') or (S[I] > '9') then Exit;   // also rejects spaces and signs
  N := StrToIntDef(S, -1);
  Result := (N >= 1) and (N <= 65535);
end;

function IsAsciiOnly(const S: String): Boolean;
var
  I: Integer;
begin
  Result := True;
  for I := 1 to Length(S) do
    if Ord(S[I]) > 126 then
    begin
      Result := False;
      Exit;
    end;
end;

// A path that ends in a backslash - a drive root such as D:\ - closes the
// argument with \" , which CommandLineToArgvW reads as an ESCAPED quote. The
// argument never terminates and every argument after it shifts one place.
// Doubling the backslash inside the quotes is the documented way out.
function QuotePathArg(const P: String): String;
begin
  if (P <> '') and (P[Length(P)] = '\') then
    Result := '"' + P + '\' + '"'
  else
    Result := '"' + P + '"';
end;

function NextButtonClick(CurPageID: Integer): Boolean;
begin
  Result := True;

  // A drive root is refused outright rather than quoted around. Uninstall
  // removes the application directory recursively, so accepting D:\ here would
  // mean uninstalling ShopDB-Flask wipes the whole drive.
  if CurPageID = wpSelectDir then
  begin
    if Length(WizardDirValue) <= 3 then
    begin
      MsgBox('Choose a folder rather than a whole drive.' + #13#10#13#10
             + 'Removing ShopDB-Flask deletes the folder it was installed into, '
             + 'so installing to a drive root would delete everything on that '
             + 'drive when it is uninstalled.', mbError, MB_OK);
      Result := False;
    end;
  end;

  if CurPageID = SitePage.ID then
  begin
    if not IsValidPort(SitePage.Values[1]) then
    begin
      MsgBox('Enter the port as a number between 1 and 65535.', mbError, MB_OK);
      Result := False;
    end;
  end;

  if CurPageID = wpWelcome then
    RunPreflight;

  // The results page BLOCKS while anything is failing. It used to be advisory:
  // the operator read "IIS is not installed", pressed Next, answered five more
  // pages, and the install then died partway through with Python already on the
  // box. Refusing here costs them nothing - the server is untouched at this
  // point - and the page already says what to do about each failure.
  if CurPageID = PreflightPage.ID then
  begin
    if PreflightBlocked then
    begin
      MsgBox('This server is not ready yet.' + #13#10#13#10
             + 'Each problem above says what to do about it. Fix them, then choose '
             + '"Check again".' + #13#10#13#10
             + 'Nothing has been changed on this server.', mbError, MB_OK);
      Result := False;
    end;
  end;

  if CurPageID = DbChoicePage.ID then
    UseBundledDb := (DbChoicePage.SelectedValueIndex = DB_BUNDLED);

  if CurPageID = DbDetailsPage.ID then
  begin
    if not UseBundledDb then
    begin
      if DbDetailsPage.Values[0] = '' then
      begin
        MsgBox('Enter the database host.', mbError, MB_OK);
        Result := False;
      end
      else if not IsValidPort(DbDetailsPage.Values[1]) then
      begin
        MsgBox('Enter the database port as a number between 1 and 65535.'
               + #13#10#13#10 + 'MySQL uses 3306 unless it was changed.',
               mbError, MB_OK);
        Result := False;
      end;
    end;
  end;

  if CurPageID = DbCredsPage.ID then
  begin
    if not UseBundledDb then
    begin
      if DbCredsPage.Values[0] = '' then
      begin
        MsgBox('Enter the username the application connects with.', mbError, MB_OK);
        Result := False;
      end
      // Blank is allowed when the password is already ON DISK in a form the
      // installer can read: .env from a previous install, or the ACL'd .dbpass
      // handoff stage 0 leaves when it created the database itself. Stage 2
      // consumes .dbpass on its own, so demanding the password here blocked the
      // operator on something the installer already had - and sent them looking
      // for a generated secret they were never meant to handle.
      else if (DbCredsPage.Values[1] = '') and
              (not FileExists(InstalledDir + '.env')) and
              (not FileExists(InstalledDir + '.dbpass')) then
      begin
        // Only required on a FRESH install. On an upgrade, blank means "keep the
        // password already in .env", so the operator never has to know it.
        MsgBox('Enter the password for the application database user.', mbError, MB_OK);
        Result := False;
      end;
    end;
  end;
end;

// Pre-tick the boxes the first time the page appears. On an upgrade the existing
// site-profile.json is already on disk, so the page opens showing exactly what
// this server has today - and ticking another box adds it.
procedure CurPageChanged(CurPageID: Integer);
var
  I, Count: Integer;
  Existing, Creds, Rest: String;
  Lines: TArrayOfString;
begin
  // Pre-fill the database page from the EXISTING .env on an upgrade. Its
  // defaults are 127.0.0.1 / shopdb_flask, so a site whose database lives on
  // another server would otherwise have its real connection string overwritten
  // by defaults the operator never looked at - and .env was the only record.
  // Pre-select the method this server already uses. Without this, an upgrade of
  // a /shopdb install would sit on the default and create a SECOND deployment -
  // a new site on 8090 beside the existing Application, same directory, wrong
  // SPA build, MOUNT_PATH still set. Read it from .env, which is the same value
  // wsgi.py mounts on.
  if (CurPageID = DeployPage.ID) and (not DeployPageReady) then
  begin
    DeployPageReady := True;
    if LoadStringsFromFile(InstalledDir + '.env', Lines) then
      for I := 0 to GetArrayLength(Lines) - 1 do
        if Pos('MOUNT_PATH=', Lines[I]) = 1 then
        begin
          Log('[shopdb] existing install is a subpath deployment: ' + Lines[I]);
          DeployPage.SelectedValueIndex := 1;
          DeployPage.SubCaptionLabel.Caption :=
            'This server is currently published under its existing address. '
            + 'Changing this will move where people reach ShopDB-Flask.';
        end;
  end;

  if (CurPageID = DbDetailsPage.ID) and (not DbPageReady) then
  begin
    DbPageReady := True;
    // A stage-0 handoff means the database was created by a previous run of this
    // installer and the password is already on disk. Nobody should be asked to
    // find or retype a secret the installer generated.
    if FileExists(InstalledDir + '.dbpass') then
      DbCredsPage.SubCaptionLabel.Caption :=
        'This server already has a database created by this installer. Leave the '
        + 'password blank and it will be used automatically.';
    if LoadStringsFromFile(InstalledDir + '.env', Lines) then
      for I := 0 to GetArrayLength(Lines) - 1 do
        if Pos('DATABASE_URL=', Lines[I]) = 1 then
        begin
          // mysql+pymysql://USER:PASS@HOST:PORT/NAME?charset=...
          Existing := Copy(Lines[I], Pos('//', Lines[I]) + 2, Length(Lines[I]));
          if Pos('@', Existing) > 0 then
          begin
            Creds := Copy(Existing, 1, Pos('@', Existing) - 1);
            Rest  := Copy(Existing, Pos('@', Existing) + 1, Length(Existing));
            if Pos(':', Creds) > 0 then
              DbCredsPage.Values[0] := Copy(Creds, 1, Pos(':', Creds) - 1);
            if Pos(':', Rest) > 0 then
            begin
              DbDetailsPage.Values[0] := Copy(Rest, 1, Pos(':', Rest) - 1);
              Rest := Copy(Rest, Pos(':', Rest) + 1, Length(Rest));
              if Pos('/', Rest) > 0 then
              begin
                DbDetailsPage.Values[1] := Copy(Rest, 1, Pos('/', Rest) - 1);
                Rest := Copy(Rest, Pos('/', Rest) + 1, Length(Rest));
                if Pos('?', Rest) > 0 then Rest := Copy(Rest, 1, Pos('?', Rest) - 1);
                DbDetailsPage.Values[2] := Rest;
              end;
            end;
          end;
          // Password intentionally left blank: blank means "keep the current
          // one", so an upgrade never needs the operator to know it.
          DbCredsPage.Values[1] := '';
          DbDetailsPage.SubCaptionLabel.Caption :=
            'These are the settings this server is using now.';
          // The hint belongs where the password field actually is - it moved to
          // the next page when this one was split.
          DbCredsPage.SubCaptionLabel.Caption :=
            'Leave the password blank to keep the one this server already uses.';
        end;
  end;

  if (CurPageID = PluginPage.ID) and (not PluginPageReady) then
  begin
    PluginPageReady := True;
    Existing := '';
    // Read instance\plugins.json - the PLUGIN REGISTRY, which is what is actually
    // installed. site-profile.json only records the last SELECTION, and the two
    // drift: unticking a plugin shrinks the profile but does NOT uninstall it
    // (apply-profile never removes), so the profile would show a plugin as absent
    // while its tables and data are still on the server.
    //
    // NOT ExpandConstant('{app}') here: this page runs BEFORE the directory page
    // and {app} is not initialised yet - expanding it raises "attempt was made to
    // expand the app constant before it was initialized". WizardDirValue() is the
    // chosen directory and is safe at any point.
    if LoadStringsFromFile(InstalledDir + 'instance\plugins.json', Lines) then
      for I := 0 to GetArrayLength(Lines) - 1 do Existing := Existing + Lines[I]
    else if LoadStringsFromFile(InstalledDir + 'site-profile.json', Lines) then
      // Fallback for an install predating the registry, or a partial install.
      for I := 0 to GetArrayLength(Lines) - 1 do Existing := Existing + Lines[I];

    Count := GetArrayLength(PluginNames);
    if PluginPage.CheckListBox.Items.Count < Count then
      Count := PluginPage.CheckListBox.Items.Count;
    for I := 0 to Count - 1 do
      if Existing <> '' then
        // Crude but sufficient: the profile lists plugins as quoted strings.
        PluginPage.Values[I] := (Pos('"' + PluginNames[I] + '"', Existing) > 0)
      else
        PluginPage.Values[I] := PluginDefault(PluginNames[I]);
  end;
end;

function UsingSubpath: Boolean;
begin
  Result := ('{#SubpathAlias}' <> '') and (DeployPage.SelectedValueIndex = 1);
end;

function ShouldSkipPage(PageID: Integer): Boolean;
begin
  // A bundle without a subpath build cannot offer the choice at all.
  if (PageID = DeployPage.ID) and ('{#SubpathAlias}' = '') then
  begin
    Result := True;
    Exit;
  end;
  // The bundled path generates its own credentials, so asking for them would be
  // meaningless - and any value typed here would be silently ignored.
  Result := ((PageID = DbDetailsPage.ID) or (PageID = DbCredsPage.ID)) and UseBundledDb;
end;

// Runs from ssPostInstall, NOT PrepareToInstall.
//
// PrepareToInstall fires BEFORE the [Files] section is processed, so
// {tmp}\shopdb-bundle does not exist yet and powershell.exe fails on a missing
// -File path (exit -196608 / 0xFFFD0000). Everything here depends on the
// extracted bundle, so it has to run after the install step.

// Set the "Run as administrator" bit on a .lnk (byte 21, flag 0x20).
//
// The console needs Administrator to read IIS state. The script self-elevates,
// but that spawns a SECOND window after a UAC prompt, which is easy to miss and
// looks broken. Marking the shortcut makes Windows elevate up front: one window,
// one prompt.
//
// Inno cannot set this flag, and its file helpers are string-based rather than
// binary safe, so use PowerShell - already a dependency here.
procedure MarkShortcutRunAs(const LnkPath: String);
var
  ResultCode: Integer;
  Cmd: String;
begin
  if not FileExists(LnkPath) then Exit;
  // THREE quotes each side, not four. Four is an escaped quote twice over, which
  // keeps the whole expression inside one Pascal literal: LnkPath was never
  // interpolated, PowerShell got the bare word "LnkPath", failed to parse, and
  // no shortcut ever received the elevation flag. Self-elevation hid it - the
  // only symptom was a second window after an extra prompt.
  Cmd := '-NoProfile -ExecutionPolicy Bypass -Command "'
       + '$p=''' + LnkPath + '''; '
       + '$b=[IO.File]::ReadAllBytes($p); '
       + '$b[21]=$b[21] -bor 0x20; '
       + '[IO.File]::WriteAllBytes($p,$b)"';
  Exec(PowerShellPath, Cmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
  if ResultCode <> 0 then
    Log('[shopdb] could not mark ' + LnkPath + ' run-as-administrator (exit '
        + IntToStr(ResultCode) + '); the console will self-elevate instead');
end;

function RunInstallStages: String;
var
  ResultCode: Integer;
  PwFile, Args, Common: String;
  PwLines: TArrayOfString;
begin
  Result := '';
  Common := '-BundleRoot ' + QuotePathArg(ExpandConstant('{tmp}\shopdb-bundle')) +
            ' -AppRoot ' + QuotePathArg(ExpandConstant('{app}')) +
            ' -SiteHost "' + SitePage.Values[0] + '"' +
            ' -SitePort ' + SitePage.Values[1] +
            ' -OnFailure never' +
            ' -ClientIpSource ' + ClientIpSourceArg +
            ' -SitePlugins "' + SelectedPlugins + '"';
  if CatalogPage.Values[0] then
    Common := Common + ' -SeedCatalog';

  // Subpath deployment: an IIS Application under the existing site instead of a
  // site of its own. Appended here because Pascal has no conditional expression.
  if UsingSubpath then
    Common := Common + ' -MountAlias "{#SubpathAlias}"';

  if UseBundledDb then
  begin
    if not RunPowerShell('-Stage 0 ' + Common, ResultCode) or (ResultCode <> 0) then
    begin
      if FailCause <> '' then
        Result := FailCause + #13#10#13#10 + FailDetail
      else
        Result := 'The bundled MySQL database could not be installed (exit '
                  + IntToStr(ResultCode) + ').';
      Result := Result + #13#10#13#10 + 'Full details are in:' + #13#10
                + ExpandConstant('{log}');
      Exit;
    end;
    // Stage 0 leaves an ACL'd handoff file that stage 2 picks up, so no password
    // is passed here or anywhere else.
    Args := Common;
  end
  else
  begin
    // The password goes via an ACL'd FILE, never on the command line: command
    // lines are readable by any user through Win32_Process and are captured in
    // PowerShell transcripts. The installer shreds the file after reading it.
    // No password typed on an upgrade means "leave .env alone" - so send no
    // password file, and the installer keeps the existing DATABASE_URL.
    if DbCredsPage.Values[1] <> '' then
    begin
      PwFile := ExpandConstant('{tmp}\dbpw.txt');
      // UTF-8, no BOM. SaveStringToFile writes an AnsiString, but the installer
      // reads this back with -Encoding UTF8 - so any non-ASCII character in the
      // password came out mangled and the database rejected a password that was
      // typed correctly, reported as "wrong username or password".
      SetArrayLength(PwLines, 1);
      PwLines[0] := DbCredsPage.Values[1];
      SaveStringsToUTF8FileWithoutBOM(PwFile, PwLines, False);
    end
    else
      PwFile := '';
    Args := Common +
            ' -DbHost "' + DbDetailsPage.Values[0] + '"' +
            ' -DbPort ' + DbDetailsPage.Values[1] +
            ' -DbName "' + DbDetailsPage.Values[2] + '"' +
            ' -DbUser "' + DbCredsPage.Values[0] + '"';
    if PwFile <> '' then Args := Args + ' -DbPasswordFile "' + PwFile + '"';
  end;

  if not RunPowerShell('-Stage all ' + Args, ResultCode) or (ResultCode <> 0) then
  begin
    if FailCause <> '' then
      Result := FailCause + #13#10#13#10 + FailDetail
    else
      Result := 'The installation could not be completed (exit '
                + IntToStr(ResultCode) + ').';
    // NOT "nothing was left running". The stages run with -OnFailure never, so
    // nothing is rolled back: whatever had been created by the failing point is
    // still there. Claiming otherwise sent operators away believing the server
    // was clean when it was half-configured, and the next thing they did was
    // install again on top of it.
    Result := Result + #13#10#13#10
              + 'This server has been part-configured. Whatever had been done '
              + 'before the failure is still in place.' + #13#10#13#10
              + 'What to do:' + #13#10
              + '  - Fix the cause above, then run this installer again. Re-running '
              + 'is safe and picks up where it left off.' + #13#10
              + '  - Or remove it entirely from Settings > Apps.' + #13#10#13#10
              + 'Full details, including everything that was created, are in:' + #13#10
              + ExpandConstant('{log}');
  end;
end;

procedure CurStepChanged(CurStep: TSetupStep);
var
  Failure, FinalUrl: String;
begin
  if CurStep = ssPostInstall then
  begin
    if UsingSubpath then
      FinalUrl := 'http://' + SitePage.Values[0] + '/{#SubpathAlias}/login'
    else
      FinalUrl := 'http://' + SitePage.Values[0] + ':' + SitePage.Values[1] + '/login';

    Failure := RunInstallStages;

    // After the stages: the shortcuts exist by now, and this is cosmetic enough
    // that it must never be able to fail the install.
    MarkShortcutRunAs(ExpandConstant('{group}\ShopDB-Flask Console.lnk'));
    MarkShortcutRunAs(ExpandConstant('{group}\Restart ShopDB-Flask.lnk'));
    MarkShortcutRunAs(ExpandConstant('{group}\Back up the database.lnk'));
    // NOTE: never start a line with #13#10. The Inno PREPROCESSOR treats any line
    // whose first non-blank character is '#' as a directive and fails with
    // "Unknown preprocessor directive" before Pascal parsing happens. Keep the
    // concatenation operator at the start of continuation lines instead.
    if Failure <> '' then
    begin
      MsgBox(Failure, mbCriticalError, MB_OK);
      // The final page is headed "ShopDB-Flask is ready" from [Messages]. After a
      // failed install that is the last thing the operator reads, and it
      // contradicts the error box they just dismissed. Setup cannot be made to
      // fail from here, so at least stop it claiming success.
      WizardForm.FinishedHeadingLabel.Caption := 'ShopDB-Flask is NOT installed';
      WizardForm.FinishedLabel.Caption :=
        'The installation did not complete. This server has been part-configured.'
        + #13#10#13#10
        + 'Fix the problem reported above and run this installer again - re-running '
        + 'is safe. Or remove it from Settings > Apps.'
        + #13#10#13#10
        + 'The log is at ' + ExpandConstant('{log}');
    end
    else
    begin
      // Recorded so the Start Menu shortcut and the console open the address this
      // install actually serves, rather than a compile-time guess.
      SaveStringToFile(ExpandConstant('{app}\.installed-url'), FinalUrl, False);
      MsgBox('ShopDB-Flask is installed.' + #13#10#13#10
             + 'Open  ' + FinalUrl
             + #13#10#13#10
             + 'With no user in the database that page offers to create the first '
             + 'administrator and then runs the setup wizard.'
             + #13#10#13#10
             + 'Day-to-day: use the ShopDB-Flask Console in the Start Menu, or read '
             + ExpandConstant('{app}\docs\OPERATE-WINDOWS.md'),
             mbInformation, MB_OK);
    end;
  end;
end;

[UninstallRun]
; Delegate to the same tested script rather than duplicating removal logic here.
; It removes the site, app pool, firewall rule and application directory, and
; deliberately does NOT drop the database or uninstall MySQL.
;
; Sysnative, for the same reason as the [Run] entry above: the uninstaller is a
; 32-bit process, so a bare "powershell.exe" is resolved through WOW64 to the
; 32-bit PowerShell, which cannot see the IIS provider at all. Removal then
; skipped the site, application pool and application - leaving them pointing at
; a directory it HAD deleted - while Windows reported a clean uninstall.
Filename: "{win}\Sysnative\WindowsPowerShell\v1.0\powershell.exe"; \
    Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\shopdb-install.ps1"" -Stage uninstall -BundleRoot ""{app}"" -AppRoot ""{app}"" -OnFailure never"; \
    RunOnceId: "ShopDBFlaskUninstall"; Flags: waituntilterminated runhidden
