<#
.SYNOPSIS
  Lean 101 deployment script — ships an app to a Digital Ocean droplet over SSH.

.DESCRIPTION
  Tars the local source tree, uploads it to the droplet, and runs
  docker compose up --build. No git required on the server.

  The same script handles first-time setup and subsequent updates.

  Designed to be swappable across Lean 101 apps. Override -AppName and
  -AppSlug (and any defaults derived from them) to deploy a different app.

.PARAMETER RemoteHost
  Hostname or IP of the Digital Ocean droplet. Required.

.PARAMETER RemoteUser
  SSH user. Defaults to 'root'.

.PARAMETER AppName
  Human-readable app name shown in the banner and log output.
  Defaults to 'Clients'.

.PARAMETER AppSlug
  Lowercase slug used to derive default remote path, container names, env files,
  and archive names. Defaults to 'clients'. Override to retarget the script at
  a different Lean 101 app (e.g. -AppSlug 'ops' for the Ops portal).

.PARAMETER RemotePath
  Absolute path on the droplet. Defaults to '/srv/lean101-<AppSlug>'.

.PARAMETER BackendContainer
  Backend container name to inspect for health. Defaults to 'lean101-<AppSlug>-backend'.

.PARAMETER PortEnvKey
  Env var name in the env file that holds the published port.
  Defaults to '<APPSLUG_UPPER>_APP_PORT' (e.g. CLIENTS_APP_PORT).

.PARAMETER EnvFile
  Local path to the production env file. Defaults to '.env.production'.

.PARAMETER SshKey
  Optional path to an SSH private key.

.PARAMETER Password
  Optional SSH password for password-based auth (no key). Requires sshpass on
  PATH. The password is handed to ssh/scp via the SSHPASS environment variable
  rather than an interactive prompt, because the script redirects ssh's I/O.

.PARAMETER ComposeFile
  Compose file name on the remote host. Defaults to 'docker-compose.production.yml'.

.PARAMETER Seed
  Run 'python -m app.seed' inside the backend container after the stack is up.

.PARAMETER Logs
  Tail logs for ~60 lines after deploy to verify the stack came up.

.PARAMETER SkipBuild
  Pass --no-build to docker compose (use when only env changed).

.PARAMETER Force
  Skip the remote port-availability preflight. Use when you know the port is
  held by this same stack and want to redeploy over it regardless.

.PARAMETER NoBanner
  Suppress the ASCII banner (useful in CI).

.EXAMPLE
  ./deploy/Deploy.ps1 -RemoteHost 209.38.24.231

.EXAMPLE
  ./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 -Seed -Logs

.EXAMPLE
  # Password auth instead of an SSH key (requires sshpass on PATH):
  ./deploy/Deploy.ps1 -RemoteHost 209.38.24.231 -Password 'your-password' -Seed -Logs

.EXAMPLE
  # Retarget the script at a different Lean 101 app:
  ./deploy/Deploy.ps1 -RemoteHost 1.2.3.4 -AppName 'Ops' -AppSlug 'ops'
#>

[CmdletBinding()]
param(
  [Parameter(Mandatory = $true)] [string] $RemoteHost,
  [string] $RemoteUser        = "root",
  [string] $AppName           = "Clients",
  [string] $AppSlug           = "clients",
  [string] $RemotePath,
  [string] $BackendContainer,
  [string] $PortEnvKey,
  [string] $EnvFile           = ".env.production",
  [string] $SshKey,
  [string] $Password,
  [string] $ComposeFile       = "docker-compose.production.yml",
  [switch] $Seed,
  [switch] $Logs,
  [switch] $SkipBuild,
  [switch] $Force,
  [switch] $NoBanner
)

$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest

# ── App identity (swappable) ──────────────────────────────────────────────────
$AppSlug = $AppSlug.ToLowerInvariant()
$AppStack = "lean101-$AppSlug"
if (-not $RemotePath)        { $RemotePath        = "/srv/$AppStack" }
if (-not $BackendContainer)  { $BackendContainer  = "$AppStack-backend" }
if (-not $PortEnvKey)        { $PortEnvKey        = "$($AppSlug.ToUpperInvariant())_APP_PORT" }

# ── Palette ───────────────────────────────────────────────────────────────────
# ANSI escapes give us truecolor + dim/bold that Write-Host -ForegroundColor cannot.
$Esc = [char]27
$C = @{
  Reset   = "$Esc[0m"
  Dim     = "$Esc[2m"
  Bold    = "$Esc[1m"
  Italic  = "$Esc[3m"
  Magenta = "$Esc[38;5;177m"  # soft violet
  Pink    = "$Esc[38;5;213m"
  Cyan    = "$Esc[38;5;87m"
  Teal    = "$Esc[38;5;79m"
  Green   = "$Esc[38;5;120m"
  Yellow  = "$Esc[38;5;221m"
  Red     = "$Esc[38;5;203m"
  Grey    = "$Esc[38;5;244m"
  Blue    = "$Esc[38;5;117m"
}

$Glyph = @{
  Step    = '▸'
  OK      = '✓'
  Warn    = '!'
  Info    = '·'
  Fail    = '✗'
  Arrow   = '→'
  Spark   = '✦'
}

function Write-Banner {
  if ($NoBanner) { return }
  $line   = '─' * 62
  $title  = "Lean 101 Deployment Script"
  $sub    = "App: $AppName    Slug: $AppSlug    Target: $RemoteUser@$RemoteHost"
  Write-Host ""
  Write-Host ("$($C.Magenta)╭$line╮$($C.Reset)")
  Write-Host ("$($C.Magenta)│$($C.Reset)$($C.Bold)$($C.Pink)        ██╗     ███████╗ █████╗ ███╗   ██╗   ███╗ ██████╗ ███╗$($C.Reset)$($C.Magenta)  │$($C.Reset)")
  Write-Host ("$($C.Magenta)│$($C.Reset)$($C.Bold)$($C.Pink)        ██║     ██╔════╝██╔══██╗████╗  ██║  ████║██╔═████╗ ██║$($C.Reset)$($C.Magenta)  │$($C.Reset)")
  Write-Host ("$($C.Magenta)│$($C.Reset)$($C.Bold)$($C.Pink)        ██║     █████╗  ███████║██╔██╗ ██║ ██╔██║██║██╔██║ ██║$($C.Reset)$($C.Magenta)  │$($C.Reset)")
  Write-Host ("$($C.Magenta)│$($C.Reset)$($C.Bold)$($C.Pink)        ██║     ██╔══╝  ██╔══██║██║╚██╗██║ ╚═╝██║████╔╝██║ ██║$($C.Reset)$($C.Magenta)  │$($C.Reset)")
  Write-Host ("$($C.Magenta)│$($C.Reset)$($C.Bold)$($C.Pink)        ███████╗███████╗██║  ██║██║ ╚████║ ███████╗╚██████╔╝ ██║$($C.Reset)$($C.Magenta)│$($C.Reset)")
  Write-Host ("$($C.Magenta)│$($C.Reset)$($C.Bold)$($C.Pink)        ╚══════╝╚══════╝╚═╝  ╚═╝╚═╝  ╚═══╝ ╚══════╝ ╚═════╝  ╚═╝$($C.Reset)$($C.Magenta)│$($C.Reset)")
  Write-Host ("$($C.Magenta)│$($C.Reset)                                                              $($C.Magenta)│$($C.Reset)")
  Write-Host ("$($C.Magenta)│$($C.Reset)  $($C.Bold)$($C.Cyan)$($Glyph.Spark) $title$($C.Reset)" + (' ' * (60 - $title.Length - 3)) + "$($C.Magenta)│$($C.Reset)")
  Write-Host ("$($C.Magenta)│$($C.Reset)  $($C.Dim)$($C.Grey)$sub$($C.Reset)" + (' ' * [Math]::Max(0, 60 - $sub.Length - 2)) + "$($C.Magenta)│$($C.Reset)")
  Write-Host ("$($C.Magenta)╰$line╯$($C.Reset)")
  Write-Host ""
}

# ── Output helpers ────────────────────────────────────────────────────────────
$script:StepIndex = 0
function Write-Step([string]$msg) {
  $script:StepIndex++
  $num = "{0:D2}" -f $script:StepIndex
  Write-Host ("$($C.Dim)$($C.Grey)[$num]$($C.Reset) $($C.Bold)$($C.Cyan)$($Glyph.Step)$($C.Reset) $($C.Bold)$msg$($C.Reset)")
}
function Write-Ok([string]$msg)   { Write-Host ("     $($C.Green)$($Glyph.OK)$($C.Reset) $($C.Green)$msg$($C.Reset)") }
function Write-Warn([string]$msg) { Write-Host ("     $($C.Yellow)$($Glyph.Warn)$($C.Reset) $($C.Yellow)$msg$($C.Reset)") }
function Write-Info([string]$msg) { Write-Host ("     $($C.Dim)$($C.Grey)$($Glyph.Info) $msg$($C.Reset)") }
function Write-Fail([string]$msg) { Write-Host ("     $($C.Red)$($Glyph.Fail)$($C.Reset) $($C.Red)$msg$($C.Reset)") }

# ── Spinner ───────────────────────────────────────────────────────────────────
$Spinner = @('⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏')

function Invoke-Spinner {
  <#
    Runs an external process while animating a braille spinner with elapsed time.
    Captures stdout/stderr and surfaces them on failure (or via -Quiet:$false).
  #>
  param(
    [Parameter(Mandatory = $true)] [string]   $Label,
    [Parameter(Mandatory = $true)] [string]   $FilePath,
    [string[]] $ArgList = @(),
    [string]   $StdinFile,
    [switch]   $ShowOutput
  )
  $outFile = [System.IO.Path]::GetTempFileName()
  $errFile = [System.IO.Path]::GetTempFileName()
  $startParams = @{
    FilePath               = $FilePath
    NoNewWindow            = $true
    PassThru               = $true
    RedirectStandardOutput = $outFile
    RedirectStandardError  = $errFile
  }
  if ($ArgList.Count -gt 0) { $startParams.ArgumentList = $ArgList }
  if ($StdinFile)           { $startParams.RedirectStandardInput = $StdinFile }

  $proc = Start-Process @startParams
  $start = Get-Date
  $i = 0
  try {
    while (-not $proc.HasExited) {
      $elapsed = ((Get-Date) - $start).TotalSeconds
      $frame = $Spinner[$i % $Spinner.Count]
      Write-Host ("`r     $($C.Cyan)$frame$($C.Reset) $($C.Dim)$($C.Grey)$Label  $($C.Reset)$($C.Teal)$('{0,5:0.0}s' -f $elapsed)$($C.Reset)   ") -NoNewline
      Start-Sleep -Milliseconds 90
      $i++
    }
    $proc.WaitForExit()
    $elapsed = ((Get-Date) - $start).TotalSeconds
    $stdout = if (Test-Path $outFile) { Get-Content $outFile -Raw } else { '' }
    $stderr = if (Test-Path $errFile) { Get-Content $errFile -Raw } else { '' }

    if ($proc.ExitCode -eq 0) {
      Write-Host ("`r     $($C.Green)$($Glyph.OK)$($C.Reset) $Label  $($C.Dim)$($C.Grey)$('{0,5:0.0}s' -f $elapsed)$($C.Reset)" + (' ' * 12))
      if ($ShowOutput -and $stdout) {
        foreach ($ln in ($stdout -split "`r?`n")) { if ($ln) { Write-Info $ln } }
      }
      return $stdout
    }
    else {
      Write-Host ("`r     $($C.Red)$($Glyph.Fail)$($C.Reset) $Label  $($C.Dim)$($C.Grey)$('{0,5:0.0}s' -f $elapsed) (exit $($proc.ExitCode))$($C.Reset)" + (' ' * 8))
      if ($stdout) { foreach ($ln in ($stdout -split "`r?`n")) { if ($ln) { Write-Host "       $($C.Dim)$($C.Grey)$ln$($C.Reset)" } } }
      if ($stderr) { foreach ($ln in ($stderr -split "`r?`n")) { if ($ln) { Write-Host "       $($C.Red)$ln$($C.Reset)" } } }
      throw "$Label failed (exit $($proc.ExitCode))"
    }
  }
  finally {
    Remove-Item $outFile -Force -ErrorAction SilentlyContinue
    Remove-Item $errFile -Force -ErrorAction SilentlyContinue
  }
}

# ── Helpers ───────────────────────────────────────────────────────────────────
function Get-RepoRoot {
  $dir = Split-Path -Parent $PSScriptRoot
  if (-not $dir) { $dir = (Get-Location).Path }
  return $dir
}

function Get-EnvValue([string] $path, [string] $key) {
  foreach ($line in Get-Content $path) {
    $trimmed = $line.Trim()
    if (-not $trimmed -or $trimmed.StartsWith("#")) { continue }
    if ($trimmed -notmatch "=") { continue }
    $parts = $trimmed -split "=", 2
    if ($parts[0].Trim() -eq $key) {
      return $parts[1].Trim()
    }
  }
  return $null
}

$RepoRoot   = Get-RepoRoot
$SshTarget  = "$RemoteUser@$RemoteHost"
$SshOpts    = @("-o", "StrictHostKeyChecking=accept-new", "-o", "BatchMode=no")
if ($SshKey) { $SshOpts += @("-i", $SshKey) }

# Password auth (no SSH key): the spinner runs ssh/scp with redirected I/O, so
# there is no terminal for an interactive password prompt. Feed the password
# non-interactively via sshpass instead, reading it from the SSHPASS env var.
$SshExe    = 'ssh'
$ScpExe    = 'scp'
$SshPrefix = @()
$ScpPrefix = @()
if ($Password) {
  if (-not (Get-Command sshpass -ErrorAction SilentlyContinue)) {
    throw "sshpass is required for -Password but was not found on PATH. Install it (e.g. 'scoop install sshpass') or use an SSH key."
  }
  $env:SSHPASS = $Password
  $SshExe = 'sshpass'; $SshPrefix = @('-e', 'ssh')
  $ScpExe = 'sshpass'; $ScpPrefix = @('-e', 'scp')
  $SshOpts += @("-o", "PubkeyAuthentication=no", "-o", "PreferredAuthentications=password")
}

function Write-RemoteScript([string] $path, [string] $content) {
  # Remote bash chokes on Windows CRLF line endings (each line gets a trailing
  # \r) and on a UTF-8 BOM. Write LF-only, no BOM.
  $lf = $content -replace "`r`n", "`n" -replace "`r", "`n"
  [System.IO.File]::WriteAllText($path, $lf, (New-Object System.Text.UTF8Encoding($false)))
}

function Invoke-Ssh([string] $cmd, [string] $Label, [switch] $ShowOutput) {
  if (-not $Label) { $Label = "ssh $($cmd.Substring(0, [Math]::Min(48, $cmd.Length)))..." }
  # Send the command over stdin to `bash -s` rather than as a command-line
  # argument. Complex commands (quotes, pipes, $(), newlines) get corrupted
  # when passed as an argv token through sshpass's cygwin argument parsing;
  # stdin keeps the only argv tokens simple ("bash -s").
  $tmp = [System.IO.Path]::GetTempFileName()
  try {
    Write-RemoteScript $tmp $cmd
    Invoke-Spinner -Label $Label -FilePath $SshExe -ArgList (@($SshPrefix) + @($SshOpts) + @($SshTarget, "bash -s")) -StdinFile $tmp -ShowOutput:$ShowOutput
  } finally {
    Remove-Item $tmp -Force -ErrorAction SilentlyContinue
  }
}

function Invoke-Scp([string] $local, [string] $remote, [string] $Label) {
  if (-not $Label) { $Label = "scp $(Split-Path -Leaf $local) $($Glyph.Arrow) $remote" }
  Invoke-Spinner -Label $Label -FilePath $ScpExe -ArgList (@($ScpPrefix) + @($SshOpts) + @($local, "${SshTarget}:${remote}"))
}

function Try-Ssh([string] $cmd) {
  $tmp = [System.IO.Path]::GetTempFileName()
  try {
    Write-RemoteScript $tmp $cmd
    Get-Content $tmp -Raw | & $SshExe @SshPrefix @SshOpts $SshTarget "bash -s"
    return $LASTEXITCODE
  } finally {
    Remove-Item $tmp -Force -ErrorAction SilentlyContinue
  }
}

function Invoke-SshScript([string] $script, [string] $Label) {
  if (-not $Label) { $Label = "ssh (remote script)" }
  $tmp = [System.IO.Path]::GetTempFileName()
  try {
    Write-RemoteScript $tmp $script
    Invoke-Spinner -Label $Label -FilePath $SshExe -ArgList (@($SshPrefix) + @($SshOpts) + @($SshTarget, "bash -s")) -StdinFile $tmp -ShowOutput
  } finally {
    Remove-Item $tmp -Force -ErrorAction SilentlyContinue
  }
}

# ── Render banner ─────────────────────────────────────────────────────────────
Write-Banner

# ── Resolve paths ─────────────────────────────────────────────────────────────
Push-Location $RepoRoot
try {
  $EnvPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
  if (-not (Test-Path $EnvPath)) {
    throw "Env file not found at '$EnvPath'. Copy .env.production.example and fill in secrets."
  }
  $WorkbookCandidates = @(
    (Join-Path $RepoRoot "input_data\\1.xlsx"),
    (Join-Path $RepoRoot "Input Cost Spreadsheet(1).xlsx")
  )
  $WorkbookPath = $WorkbookCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
  if (-not $WorkbookPath) {
    throw "Workbook not found. Checked: $($WorkbookCandidates -join ', '). The production seed expects at least one workbook file to exist."
  }

  $AppPort = Get-EnvValue $EnvPath $PortEnvKey
  if (-not $AppPort) { $AppPort = "8081" }
  $Origin = Get-EnvValue $EnvPath "ORIGIN"
  if (-not $Origin) { $Origin = "https://clients.lean-101.com.au" }

  Write-Step "Preflight"
  Write-Info "App           : $AppName ($AppSlug)"
  Write-Info "Remote host   : $SshTarget"
  Write-Info "Remote path   : $RemotePath"
  Write-Info "Container     : $BackendContainer"
  Write-Info "Env file      : $EnvPath"
  Write-Info "Compose file  : $ComposeFile"
  Write-Info "Origin        : $Origin"
  Write-Info "Port ($PortEnvKey) : $AppPort"

  # ── Connectivity check ──────────────────────────────────────────────────────
  Write-Step "Verifying SSH connectivity"
  Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)" -Label "ssh handshake" -ShowOutput

  # ── Remote port availability ───────────────────────────────────────────────
  # A re-deploy of this same stack is expected to replace its containers in
  # place, so any container belonging to this stack (name == STACK or STACK-*)
  # is treated as "ours". Only a genuinely different service triggers an abort.
  if ($Force) {
    Write-Step "Skipping remote port check for $AppStack (-Force)"
  }
  else {
    Write-Step "Checking that remote port $AppPort is free for $AppStack"
    $portCheckCmd = @'
set -e
PORT='__APP_PORT__'
STACK='__APP_STACK__'
OWNER=$(docker ps --format '{{.Names}} {{.Ports}}' | grep -m1 ":${PORT}->" | cut -d' ' -f1 || true)
if [ -n "$OWNER" ]; then
  case "$OWNER" in
    "$STACK"|"$STACK"-*) : ;;
    *)
      echo "Port $PORT is already owned by container: $OWNER" >&2
      exit 2 ;;
  esac
fi
'@.Replace('__APP_PORT__', $AppPort).Replace('__APP_STACK__', $AppStack)
    try {
      Invoke-Ssh $portCheckCmd -Label "port $AppPort availability"
    }
    catch {
      throw "Remote port $AppPort is already in use by a different service. Change $PortEnvKey in '$EnvPath', retire the conflicting service, or re-run with -Force to deploy anyway."
    }
  }

  # ── Package source files ────────────────────────────────────────────────────
  Write-Step "Packaging source tree (excluding node_modules, caches, secrets)"

  $TarFile = Join-Path $env:TEMP "$AppStack-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss').tar.gz"

  $excludes = @(
    "--exclude=./node_modules",
    "--exclude=./frontend/node_modules",
    "--exclude=./frontend/.svelte-kit",
    "--exclude=./frontend/build",
    "--exclude=./.git",
    "--exclude=./.pytest_cache",
    "--exclude=./__pycache__",
    "--exclude=./backend/__pycache__",
    "--exclude=./backend/app/__pycache__",
    "--exclude=./backend/.pytest_cache",
    "--exclude=./backend/.tmp",
    "--exclude=./backend/.venv",
    "--exclude=./backend/data_entry_app_backend.egg-info",
    "--exclude=./**/__pycache__",
    "--exclude=./*.pyc",
    "--exclude=./.env",
    "--exclude=./.env.production",
    "--exclude=./.env.alpha",
    "--exclude=./data_entry_app.db",
    "--exclude=./*.db"
  )

  Invoke-Spinner -Label "tar -czf $(Split-Path -Leaf $TarFile)" -FilePath 'tar' -ArgList (@('-czf', $TarFile) + $excludes + @('-C', $RepoRoot, '.'))

  $TarSize = [math]::Round((Get-Item $TarFile).Length / 1MB, 1)
  Write-Info "Archive: $TarFile ($($C.Bold)$TarSize MB$($C.Reset))"

  # ── Upload env file ─────────────────────────────────────────────────────────
  Write-Step "Uploading env file to droplet"
  Invoke-Scp $EnvPath "$RemotePath/.env.production" -Label "scp .env.production $($Glyph.Arrow) $RemotePath/"
  Invoke-Ssh "chmod 600 '$RemotePath/.env.production'" -Label "chmod 600 .env.production"

  # ── Upload and extract source ────────────────────────────────────────────────
  Write-Step "Uploading source archive ($TarSize MB)"
  Invoke-Scp $TarFile "/tmp/$AppStack-deploy.tar.gz" -Label "scp archive $($Glyph.Arrow) /tmp/"
  Remove-Item $TarFile -Force

  Write-Step "Extracting archive on server"
  Invoke-Ssh "mkdir -p '$RemotePath' && tar -xzf /tmp/$AppStack-deploy.tar.gz -C '$RemotePath' && rm /tmp/$AppStack-deploy.tar.gz" -Label "untar into $RemotePath"

  # ── Docker compose up ───────────────────────────────────────────────────────
  $ComposeArgs = "--env-file .env.production -f $ComposeFile"
  $BuildFlag   = if ($SkipBuild) { "--no-build" } else { "--build" }

  $buildMsg = if ($SkipBuild) { "without rebuild" } else { "with --build" }
  Write-Step "Bringing the $AppName stack up $buildMsg"
  $composeUpCmd = "cd '$RemotePath' && docker compose $ComposeArgs up -d $BuildFlag --remove-orphans"
  try {
    Invoke-Ssh $composeUpCmd -Label "docker compose up $BuildFlag"
  }
  catch {
    Write-Warn "docker compose up failed; collecting remote status and backend logs"
    Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps"
    Try-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=120 backend"
    throw
  }

  # ── Reload nginx config ───────────────────────────────────────────────────
  # The nginx config is a bind-mounted file. `docker compose up` only recreates
  # a service when its definition changes, not when a mounted file's contents
  # change, so a running nginx keeps serving the config it loaded at start. Force
  # a reload so edits to clients.lean-101.conf (routing, security headers/CSP)
  # actually take effect on every deploy. Non-fatal: stacks without an nginx
  # service simply skip this.
  Write-Step "Reloading nginx to apply config changes"
  $nginxReload = "cd '$RemotePath' && docker compose $ComposeArgs exec -T nginx nginx -t && docker compose $ComposeArgs exec -T nginx nginx -s reload"
  if ((Try-Ssh $nginxReload) -eq 0) {
    Write-Ok "nginx reloaded"
  } else {
    Write-Warn "Skipped nginx reload (no nginx service, or config test failed)"
  }

  # ── Health check ────────────────────────────────────────────────────────────
  Write-Step "Waiting for backend health check ($BackendContainer)"
  $healthScript = @"
set -e
cd '$RemotePath'
for i in `$(seq 1 30); do
  status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $BackendContainer 2>/dev/null || echo missing)
  case "`$status" in
    healthy|running) echo "backend is `$status"; exit 0 ;;
    *) printf '.'; sleep 4 ;;
  esac
done
echo; echo 'backend did not become healthy in time' >&2; exit 1
"@
  Invoke-SshScript $healthScript -Label "backend health (up to ~2 min)"

  # ── Seed access ─────────────────────────────────────────────────────────────
  Write-Step "Seeding default internal users and permissions"
  Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs exec -T backend python -m app.seed_access" -Label "python -m app.seed_access"

  if ($Seed) {
    Write-Step "Seeding reference data"
    Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs exec -T backend python -m app.seed" -Label "python -m app.seed"
  }

  # ── Final status ────────────────────────────────────────────────────────────
  Write-Step "Stack status"
  Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs ps" -Label "docker compose ps" -ShowOutput

  # ── Frontend asset verification ─────────────────────────────────────────────
  Write-Step "Verifying published Inter font asset"
  $fontCheckBody = @'
set -e
ORIGIN="__ORIGIN__"

fetch() {
  URL="$1"
  if command -v curl >/dev/null 2>&1; then
    curl -fsSL "$URL"
    return
  fi
  if command -v wget >/dev/null 2>&1; then
    wget -qO- "$URL"
    return
  fi
  echo "Neither curl nor wget is available on the remote host." >&2
  exit 1
}

fetch_status() {
  URL="$1"
  if command -v curl >/dev/null 2>&1; then
    curl -fsS -o /dev/null -w "%{http_code}" "$URL"
    return
  fi
  if command -v wget >/dev/null 2>&1; then
    wget -S --spider "$URL" 2>&1 | awk '/^  HTTP\// { code=$2 } END { if (code) print code; else print "000" }'
    return
  fi
  echo "000"
}

INDEX_HTML=$(fetch "$ORIGIN")
CSS_PATHS=$(printf '%s' "$INDEX_HTML" | grep -oE '/_app/immutable/assets/[^"]+\.css' | sort -u || true)
if [ -z "$CSS_PATHS" ]; then
  echo "Could not find a built CSS asset on $ORIGIN" >&2
  exit 1
fi

FONT_URL=""
for CSS_PATH in $CSS_PATHS; do
  CSS_URL="${ORIGIN%/}${CSS_PATH}"
  CSS_CONTENT=$(fetch "$CSS_URL")
  FONT_PATH=$(printf '%s' "$CSS_CONTENT" | grep -oE 'inter-latin-400-normal\.[^")]+\.woff2' | head -n 1 || true)
  if [ -n "$FONT_PATH" ]; then
    FONT_URL="${CSS_URL%/*}/${FONT_PATH}"
    break
  fi
done

if [ -z "$FONT_PATH" ]; then
  echo "Could not find the Inter Latin 400 woff2 asset in any built CSS asset from $ORIGIN" >&2
  exit 1
fi

FONT_STATUS=$(fetch_status "$FONT_URL")
if [ "$FONT_STATUS" != "200" ]; then
  echo "Inter font check failed: $FONT_URL returned HTTP $FONT_STATUS" >&2
  exit 1
fi

echo "Verified Inter font asset: $FONT_URL"
'@.Replace('__ORIGIN__', $Origin)
  Invoke-SshScript $fontCheckBody -Label "Inter font asset check"

  if ($Logs) {
    Write-Step "Recent logs (last 60 lines)"
    Invoke-Ssh "cd '$RemotePath' && docker compose $ComposeArgs logs --tail=60" -Label "docker compose logs --tail=60" -ShowOutput
  }

  Write-Step "Published access"
  Write-Info "Container port : $($C.Bold)$AppPort$($C.Reset)"
  Write-Info "Origin         : $($C.Bold)$($C.Blue)$Origin$($C.Reset)"
  if ($AppPort -ne "80" -and $AppPort -ne "443") {
    Write-Warn "This stack is published on port $AppPort. The public domain may still point at another service until you swap the reverse proxy or port mapping."
  }

  # ── Done ────────────────────────────────────────────────────────────────────
  $line   = '─' * 62
  Write-Host ""
  Write-Host ("$($C.Green)╭$line╮$($C.Reset)")
  $done = "$($Glyph.Spark) $AppName deployed $($Glyph.Arrow) $Origin"
  $pad  = [Math]::Max(0, 60 - ($done.Length - 2))   # subtract 2 for two-byte glyphs
  Write-Host ("$($C.Green)│$($C.Reset)  $($C.Bold)$($C.Green)$done$($C.Reset)" + (' ' * $pad) + "$($C.Green)│$($C.Reset)")
  Write-Host ("$($C.Green)╰$line╯$($C.Reset)")
  Write-Host ""
}
catch {
  Write-Host ""
  Write-Fail "Deployment aborted: $($_.Exception.Message)"
  Write-Host ""
  throw
}
finally {
  Pop-Location
}
