<#
.SYNOPSIS
Deploys the complete Memby Docker Compose stack to MATT-NAS.

.DESCRIPTION
Builds and verifies a signed Memby APK, packages it with the local server build
context, Docker Compose file and .env.example, then streams everything to the NAS
over one SSH connection. Once the new gateway is healthy it publishes the APK
through the gateway's release endpoint, making it available to older TVs.

The remote deployment:
  - checks Docker and Docker Compose;
  - installs .env from the local .env.example, overwriting the deployed
    copy, and keeps the old one alongside as .env.previous;
  - preserves the named database volume;
  - tells every signed-in television a deployment is starting, through the
    gateway that is still running, before the build begins;
  - pulls Redis and PostgreSQL, then builds the Memby server;
  - starts each service and waits for its health check;
  - restores the previous application files if activation fails.

Configuration lives in the local .env.example and is included directly in every
deployment. It does not need to be committed or pushed before deployment.

SSH performs the password prompt directly. The password is never read or stored
by this script.

This deploys the current local working tree, including uncommitted server changes.

.EXAMPLE
.\deploy-server.ps1

.EXAMPLE
.\deploy-server.ps1 -Destination /share/Docker/Memby-test -HealthTimeoutSeconds 180

.EXAMPLE
.\deploy-server.ps1 -AppVersion 0.1.70 -ReleaseNotes 'Reliable in-app updates'

.EXAMPLE
.\deploy-server.ps1 -ReleaseNotes 'Required security update' --m
#>

#Requires -Version 7.2

[CmdletBinding()]
param(
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string] $SourceDirectory = $PSScriptRoot,

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string] $RemoteHost = '10.0.0.213',

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string] $RemoteUser = 'ssh',

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string] $Destination = '/share/Docker/Memby',

    [Parameter()]
    [ValidateRange(30, 600)]
    [int] $HealthTimeoutSeconds = 120,

    [Parameter()]
    [ValidatePattern('^\d+\.\d+\.\d+$')]
    [string] $AppVersion,

    [Parameter()]
    [string] $ReleaseNotes = '',

    [Parameter()]
    [switch] $SkipAppTests,

    [Parameter()]
    [switch] $SkipAppRelease

    ,
    [Parameter()]
    [Alias('m')]
    [switch] $MandatoryUpdate,

    # PowerShell advanced scripts do not bind GNU-style double-dash switches. Accept
    # --m explicitly as the sole positional argument so it can still sit at the end.
    [Parameter(Position = 0)]
    [ValidateSet('--m')]
    [string] $MandatoryFlag
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$mandatoryRelease = $MandatoryUpdate -or $MandatoryFlag -eq '--m'

if ($mandatoryRelease -and $SkipAppRelease) {
    throw '--m cannot be combined with -SkipAppRelease because no update would be published.'
}

$script:CurrentStep = 0
$script:TotalSteps = if ($SkipAppRelease) { 5 } else { 6 }

function Write-Banner {
    Write-Host ''
    Write-Host '╭─ Memby deployment' -ForegroundColor Magenta
    Write-Host "│  Source  $SourceDirectory (local working tree)" -ForegroundColor DarkGray
    Write-Host "│  Target  ${RemoteUser}@${RemoteHost}:$Destination" -ForegroundColor DarkGray
    if ($mandatoryRelease) {
        Write-Host '│  Update  mandatory (viewers cannot skip it)' -ForegroundColor Yellow
    }
    Write-Host '╰─' -ForegroundColor Magenta
    Write-Host ''
}

function Write-Step {
    param(
        [Parameter(Mandatory)]
        [string] $Message
    )

    $script:CurrentStep++
    Write-Host ("● [{0}/{1}] {2}" -f $script:CurrentStep, $script:TotalSteps, $Message) -ForegroundColor Cyan
}

function Write-Detail {
    param(
        [Parameter(Mandatory)]
        [string] $Message
    )

    Write-Host "  ↳ $Message" -ForegroundColor DarkGray
}

function Write-Success {
    param(
        [Parameter(Mandatory)]
        [string] $Message
    )

    Write-Host "✓ $Message" -ForegroundColor Green
}

function Get-RequiredCommand {
    param(
        [Parameter(Mandatory)]
        [string] $Name
    )

    $command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue |
        Select-Object -First 1

    if (-not $command) {
        throw "Required command '$Name' was not found in PATH."
    }

    return $command.Source
}

function Invoke-Checked {
    param(
        [Parameter(Mandatory)]
        [string] $FilePath,

        [Parameter()]
        [string[]] $Arguments = @()
    )

    & $FilePath @Arguments
    if ($LASTEXITCODE -ne 0) {
        throw "'$FilePath' failed with exit code $LASTEXITCODE."
    }
}

function Get-AndroidSdk {
    param([Parameter(Mandatory)][string] $RepositoryDirectory)

    if ($env:ANDROID_HOME -and (Test-Path -LiteralPath $env:ANDROID_HOME)) {
        return $env:ANDROID_HOME
    }
    $propertiesPath = Join-Path $RepositoryDirectory 'local.properties'
    if (Test-Path -LiteralPath $propertiesPath) {
        $sdkLine = Get-Content -LiteralPath $propertiesPath |
            Where-Object { $_ -match '^sdk\.dir=' } |
            Select-Object -First 1
        if ($sdkLine) {
            $sdkPath = $sdkLine.Substring($sdkLine.IndexOf('=') + 1).
                Replace('\:', ':').
                Replace('\\', '\')
            if (Test-Path -LiteralPath $sdkPath) {
                return $sdkPath
            }
        }
    }
    throw 'Android SDK not found. Set ANDROID_HOME or sdk.dir in local.properties.'
}

function Import-UserSigningEnvironment {
    foreach ($name in @(
        'MEMBY_KEYSTORE',
        'MEMBY_KEYSTORE_PASSWORD',
        'MEMBY_KEY_ALIAS',
        'MEMBY_KEY_PASSWORD'
    )) {
        $value = [Environment]::GetEnvironmentVariable($name, 'Process')
        if ([string]::IsNullOrWhiteSpace($value)) {
            $value = [Environment]::GetEnvironmentVariable($name, 'User')
        }
        if ([string]::IsNullOrWhiteSpace($value)) {
            throw "Release signing variable $name is not configured."
        }
        [Environment]::SetEnvironmentVariable($name, $value, 'Process')
    }
    if (-not (Test-Path -LiteralPath $env:MEMBY_KEYSTORE -PathType Leaf)) {
        throw "Release keystore not found: $env:MEMBY_KEYSTORE"
    }
}

function Get-ConfiguredAppVersion {
    param([Parameter(Mandatory)][string] $RepositoryDirectory)

    if ($AppVersion) {
        return $AppVersion
    }
    $gradle = Get-Content -LiteralPath (Join-Path $RepositoryDirectory 'app/build.gradle.kts') -Raw
    if ($gradle -notmatch 'val defaultVersionName = "(\d+\.\d+\.\d+)"') {
        throw 'Could not read defaultVersionName from app/build.gradle.kts.'
    }
    return $Matches[1]
}

function Get-DotEnvValue {
    param(
        [Parameter(Mandatory)][string] $Path,
        [Parameter(Mandatory)][string] $Name
    )

    $line = Get-Content -LiteralPath $Path |
        Where-Object { $_ -match ("^" + [regex]::Escape($Name) + "=") } |
        Select-Object -First 1
    if (-not $line) { return '' }
    return $line.Substring($line.IndexOf('=') + 1).Trim()
}

function Assert-SafeRemoteSettings {
    if ($RemoteHost -notmatch '^[A-Za-z0-9._:-]+$') {
        throw "RemoteHost contains unsupported characters: '$RemoteHost'."
    }

    if ($RemoteUser -notmatch '^[A-Za-z0-9._-]+$') {
        throw "RemoteUser contains unsupported characters: '$RemoteUser'."
    }

    if ($Destination -notmatch '^/[A-Za-z0-9._/-]+$') {
        throw "Destination must be an absolute Linux path using only letters, numbers, '.', '_', '-', and '/'."
    }

    $segments = $Destination.Split('/', [System.StringSplitOptions]::RemoveEmptyEntries)
    if ($segments -contains '..') {
        throw "Destination cannot contain '..' path segments."
    }

    $broadPaths = @(
        '/', '/bin', '/boot', '/dev', '/etc', '/home', '/lib', '/lib64',
        '/opt', '/proc', '/root', '/run', '/sbin', '/srv', '/sys', '/tmp',
        '/usr', '/var'
    )
    if ($Destination -in $broadPaths) {
        throw "Destination '$Destination' is too broad to replace safely."
    }
}

function New-DeploymentArchive {
    param(
        [Parameter(Mandatory)]
        [string] $RepositoryDirectory,

        [Parameter(Mandatory)]
        [string] $ArchivePath,

        [Parameter()]
        [string] $ReleaseDirectory
    )

    # Everything under server/ is streamed over one SSH connection, so a build
    # artefact left in the tree is paid for on the wire. The exclusions mirror
    # server/.dockerignore: a GOCACHE pointed inside server/ (see the note there)
    # is gitignored and therefore silent, and turned a 1.2MB source tree into a
    # 774MB deployment. Both the directory entry and its contents are named
    # because bsdtar — which is what tar.exe is on Windows — matches --exclude
    # against each entry path rather than pruning the walk.
    $arguments = @(
        '-cf', $ArchivePath,
        '--exclude', 'server/.tmp-go-cache', '--exclude', 'server/.tmp-go-cache/*',
        '--exclude', 'server/bin', '--exclude', 'server/bin/*',
        '-C', $RepositoryDirectory,
        'server', 'docker-compose.yml', '.env.example'
    )
    if ($ReleaseDirectory) {
        $arguments += @(
            '-C', (Split-Path -Parent $ReleaseDirectory),
            (Split-Path -Leaf $ReleaseDirectory)
        )
    }
    & $script:TarCommand @arguments
    if ($LASTEXITCODE -ne 0) {
        throw "Unable to create the deployment archive (tar exit code $LASTEXITCODE)."
    }

    if (-not (Test-Path -LiteralPath $ArchivePath -PathType Leaf)) {
        throw 'The deployment archive was not created.'
    }
}

function Send-ArchiveOverSsh {
    param(
        [Parameter(Mandatory)]
        [string] $ArchivePath,

        [Parameter(Mandatory)]
        [string] $RemoteCommand
    )

    $startInfo = [System.Diagnostics.ProcessStartInfo]::new()
    $startInfo.FileName = $script:SshCommand
    $startInfo.UseShellExecute = $false
    $startInfo.RedirectStandardInput = $true

    # Force interactive password authentication as requested. OpenSSH reads the
    # password from the console while stdin carries the tar archive.
    foreach ($argument in @(
        '-o', 'PubkeyAuthentication=no',
        '-o', 'PreferredAuthentications=keyboard-interactive,password',
        '-o', 'NumberOfPasswordPrompts=3',
        "$RemoteUser@$RemoteHost",
        $RemoteCommand
    )) {
        [void] $startInfo.ArgumentList.Add($argument)
    }

    $process = [System.Diagnostics.Process]::new()
    $process.StartInfo = $startInfo
    $archiveStream = $null
    $processStarted = $false
    $exitCode = $null

    try {
        if (-not $process.Start()) {
            throw 'SSH could not be started.'
        }
        $processStarted = $true

        $archiveStream = [System.IO.File]::OpenRead($ArchivePath)
        try {
            $archiveStream.CopyTo($process.StandardInput.BaseStream)
        }
        catch [System.IO.IOException] {
            # The remote side exited before reading the whole archive — a failed early
            # step, or a shell that rejected the command. Its own message is already on
            # screen, so let the exit code below do the explaining rather than burying it
            # under a broken-pipe stack trace.
        }
        try { $process.StandardInput.Close() } catch [System.IO.IOException] { }
        $process.WaitForExit()
        $exitCode = $process.ExitCode
    }
    catch {
        if ($processStarted -and -not $process.HasExited) {
            $process.StandardInput.Close()
            $process.WaitForExit()
        }
        throw
    }
    finally {
        if ($archiveStream) {
            $archiveStream.Dispose()
        }
        $process.Dispose()
    }

    if ($exitCode -ne 0) {
        throw "Remote deployment failed with SSH exit code $exitCode. Review the last remote step above."
    }
}

Write-Banner
$deploymentTimer = [System.Diagnostics.Stopwatch]::StartNew()

Write-Step 'Checking local prerequisites and settings'
Assert-SafeRemoteSettings
$script:TarCommand = Get-RequiredCommand -Name 'tar'
$script:SshCommand = Get-RequiredCommand -Name 'ssh'
Write-Detail "SSH  $script:SshCommand"
Write-Success 'Local prerequisites are ready'
Write-Host ''

$workDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("memby-deploy-" + [guid]::NewGuid().ToString('N'))
$archivePath = Join-Path $workDirectory 'memby-deployment.tar'

try {
    [void] (New-Item -ItemType Directory -Path $workDirectory)

    Write-Step 'Selecting the local deployment source'
    $checkoutDirectory = (Resolve-Path -LiteralPath $SourceDirectory -ErrorAction Stop).Path
    Write-Success "Using $checkoutDirectory"
    Write-Host ''

    Write-Step 'Validating the Compose deployment payload'
    $requiredPaths = @(
        (Join-Path $checkoutDirectory 'server'),
        (Join-Path $checkoutDirectory 'server/Dockerfile'),
        (Join-Path $checkoutDirectory 'server/go.mod'),
        (Join-Path $checkoutDirectory 'docker-compose.yml'),
        (Join-Path $checkoutDirectory '.env.example')
    )
    foreach ($requiredPath in $requiredPaths) {
        if (-not (Test-Path -LiteralPath $requiredPath)) {
            throw "Required deployment file is missing: $requiredPath"
        }
    }
    Write-Detail 'server/ build context'
    Write-Detail 'docker-compose.yml'
    Write-Detail '.env.example'
    $releaseDirectory = ''
    $releaseVersion = ''
    $releaseSHA256 = ''
    if (-not $SkipAppRelease) {
        foreach ($appPath in @(
            (Join-Path $checkoutDirectory 'app/build.gradle.kts'),
            (Join-Path $checkoutDirectory 'gradlew.bat')
        )) {
            if (-not (Test-Path -LiteralPath $appPath -PathType Leaf)) {
                throw "Required Android build file is missing: $appPath"
            }
        }
        if ([string]::IsNullOrWhiteSpace(
            (Get-DotEnvValue -Path (Join-Path $checkoutDirectory '.env.example') `
                -Name 'MEMBY_RELEASE_PUBLISH_TOKEN')
        )) {
            throw 'MEMBY_RELEASE_PUBLISH_TOKEN is empty in .env.example; a signed APK cannot be published.'
        }
    }
    Write-Success 'Deployment payload is complete'
    Write-Host ''

    if (-not $SkipAppRelease) {
        Write-Step 'Building and verifying the signed Android update'
        Import-UserSigningEnvironment
        $releaseVersion = Get-ConfiguredAppVersion -RepositoryDirectory $checkoutDirectory
        $sdk = Get-AndroidSdk -RepositoryDirectory $checkoutDirectory
        $buildTools = Get-ChildItem -LiteralPath (Join-Path $sdk 'build-tools') -Directory |
            Sort-Object { [version]$_.Name } -Descending |
            Select-Object -First 1
        if (-not $buildTools) {
            throw 'Android SDK Build Tools are not installed.'
        }
        $apkSigner = Join-Path $buildTools.FullName 'apksigner.bat'
        if (-not (Test-Path -LiteralPath $apkSigner -PathType Leaf)) {
            throw "APK signer not found: $apkSigner"
        }

        $jdkHome = 'C:\Program Files\Android\Android Studio\jbr'
        if (Test-Path -LiteralPath $jdkHome) {
            $env:JAVA_HOME = $jdkHome
        }
        $gradleArguments = @('--console=plain')
        if (-not $SkipAppTests) {
            $gradleArguments += 'testDebugUnitTest'
        }
        $gradleArguments += @(
            'assembleRelease',
            "-Pmemby.versionName=$releaseVersion"
        )
        Invoke-Checked -FilePath (Join-Path $checkoutDirectory 'gradlew.bat') `
            -Arguments $gradleArguments

        $apk = Join-Path $checkoutDirectory 'app/build/outputs/apk/release/app-release.apk'
        if (-not (Test-Path -LiteralPath $apk -PathType Leaf)) {
            $unsigned = Join-Path $checkoutDirectory `
                'app/build/outputs/apk/release/app-release-unsigned.apk'
            if (Test-Path -LiteralPath $unsigned -PathType Leaf) {
                throw 'Gradle produced an unsigned APK; deployment was stopped.'
            }
            throw "Signed release APK not found: $apk"
        }
        Invoke-Checked -FilePath $apkSigner -Arguments @(
            'verify', '--verbose', '--print-certs', $apk
        )

        $metadataPath = Join-Path $checkoutDirectory `
            'app/build/outputs/apk/release/output-metadata.json'
        $metadata = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json
        $builtVersion = [string]$metadata.elements[0].versionName
        if ($builtVersion -ne $releaseVersion) {
            throw "Built APK version is $builtVersion, expected $releaseVersion."
        }

        $releaseDirectory = Join-Path $workDirectory 'release'
        [void] (New-Item -ItemType Directory -Path $releaseDirectory)
        $apkName = "memby-$releaseVersion.apk"
        Copy-Item -LiteralPath $apk -Destination (Join-Path $releaseDirectory $apkName)
        $releaseSHA256 = (
            Get-FileHash -LiteralPath (Join-Path $releaseDirectory $apkName) -Algorithm SHA256
        ).Hash.ToLowerInvariant()
        $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
        [System.IO.File]::WriteAllText(
            (Join-Path $releaseDirectory 'version.txt'), $releaseVersion, $utf8NoBom
        )
        [System.IO.File]::WriteAllText(
            (Join-Path $releaseDirectory 'sha256.txt'), $releaseSHA256, $utf8NoBom
        )
        [System.IO.File]::WriteAllText(
            (Join-Path $releaseDirectory 'notes.txt'), $ReleaseNotes, $utf8NoBom
        )
        Write-Detail "Version $releaseVersion"
        Write-Detail "SHA-256 $releaseSHA256"
        Write-Success 'Signed Android update is ready'
        Write-Host ''
    }

    Write-Step 'Packaging the release'
    New-DeploymentArchive -RepositoryDirectory $checkoutDirectory -ArchivePath $archivePath `
        -ReleaseDirectory $releaseDirectory
    $archiveSize = (Get-Item -LiteralPath $archivePath).Length
    Write-Detail ("Archive size {0:N1} MiB" -f ($archiveSize / 1MB))
    Write-Success 'Release archive is ready'
    Write-Host ''

    # This template is single-quoted so PowerShell does not expand the remote
    # shell's variables. Replacement values are validated before insertion.
    $remoteCommand = @'
set -eu

destination='__DESTINATION__'
health_timeout=__HEALTH_TIMEOUT__
publish_release=__PUBLISH_RELEASE__
mandatory_update=__MANDATORY_UPDATE__
parent=$(dirname "$destination")
staging="${destination}.new.$$"
backup="${destination}.previous.$$"
activated=0
previous_stopped=0

step() {
    printf '\033[36m  ● %s\033[0m\n' "$1"
}

detail() {
    printf '\033[90m    ↳ %s\033[0m\n' "$1"
}

success() {
    printf '\033[32m  ✓ %s\033[0m\n' "$1"
}

failure() {
    printf '\033[31m  ✗ %s\033[0m\n' "$1" >&2
}

rollback() {
    status=$?
    trap - EXIT

    if [ "$status" -eq 0 ]; then
        return
    fi

    failure "Deployment failed; cleaning up"
    rm -rf -- "$staging"

    if [ "$activated" -eq 1 ]; then
        if [ -f "$destination/docker-compose.yml" ]; then
            detail "Stopping the incomplete application release"
            (
                cd "$destination"
                docker compose down --remove-orphans >/dev/null 2>&1
            ) || true
        fi

        rm -rf -- "$destination"
        if [ -e "$backup" ] || [ -L "$backup" ]; then
            detail "Restoring the previous application release"
            mv -- "$backup" "$destination"
            (
                cd "$destination"
                docker compose up -d --build --remove-orphans >/dev/null 2>&1
            ) || true
            failure "Previous application files were restored"
        fi
    elif [ "$previous_stopped" -eq 1 ] && [ -f "$destination/docker-compose.yml" ]; then
        detail "Restarting the previous application release"
        (
            cd "$destination"
            docker compose up -d --build --remove-orphans >/dev/null 2>&1
        ) || true
    fi

    exit "$status"
}

wait_for_service() {
    service="$1"
    elapsed=0
    detail "Waiting for $service"

    while [ "$elapsed" -lt "$health_timeout" ]; do
        container_id=$(docker compose ps --all -q "$service" 2>/dev/null || true)

        if [ -n "$container_id" ]; then
            state=$(docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || true)
            health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true)

            if [ "$state" = 'running' ] && { [ "$health" = 'healthy' ] || [ "$health" = 'none' ]; }; then
                success "$service is $state ($health)"
                return 0
            fi

            if [ "$state" = 'exited' ] || [ "$state" = 'dead' ]; then
                failure "$service entered state: $state"
                docker compose logs --no-color --tail 60 "$service" || true
                return 1
            fi
        fi

        sleep 2
        elapsed=$((elapsed + 2))
    done

    failure "$service did not become healthy within ${health_timeout}s"
    docker compose logs --no-color --tail 60 "$service" || true
    return 1
}

trap rollback EXIT
trap 'exit 130' INT TERM

step '[remote 1/10] Checking Docker'
if ! command -v docker >/dev/null 2>&1; then
    failure 'Docker is not installed on the NAS'
    exit 1
fi
docker info >/dev/null 2>&1 || {
    failure 'Docker is installed but the daemon is unavailable'
    exit 1
}
docker compose version >/dev/null 2>&1 || {
    failure 'The Docker Compose v2 plugin is not installed'
    exit 1
}
if [ "$publish_release" -eq 1 ] && ! command -v curl >/dev/null 2>&1; then
    failure 'curl is required on the NAS to publish the signed Android update'
    exit 1
fi
success "$(docker --version)"
success "$(docker compose version)"

step '[remote 2/10] Extracting the release'
# Checked before anything is created: the staging directory, the swap and the backup all
# need write access to the parent, and "can't create directory" from BusyBox halfway
# through a deployment is a poor way to learn the account cannot write there.
mkdir -p -- "$parent" 2>/dev/null || true
if [ ! -d "$parent" ]; then
    failure "$parent does not exist and could not be created by $(id -un)"
    detail 'Pick a path this account can write to with -Destination'
    exit 1
fi
if [ ! -w "$parent" ]; then
    failure "$(id -un) cannot write to $parent"
    detail 'Deploy somewhere this account owns, for example:'
    detail '  .\deploy-server.ps1 -Destination /share/Docker/Memby'
    detail "or grant this account write access to $parent"
    exit 1
fi
rm -rf -- "$staging"
mkdir -- "$staging"
tar -xf - -C "$staging"
test -f "$staging/docker-compose.yml"
test -f "$staging/server/Dockerfile"
if [ "$publish_release" -eq 1 ]; then
    test -f "$staging/release/version.txt"
    test -f "$staging/release/sha256.txt"
fi
success 'Release extracted'

step '[remote 3/10] Installing configuration from .env.example'
# The local .env.example is the single source of truth for configuration and carries
# real values rather than placeholders. Every deployment overwrites the deployed .env
# with it, so neither a Git push nor an SSH edit is needed.
previous_password=''
previous_admin_token=''
if [ -f "$destination/.env" ]; then
    previous_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$destination/.env" | head -n 1 | tr -d '\r')
    # The token of the gateway that is still running, which is the one that can still
    # tell the televisions anything. It is usually the same as the incoming one, but
    # reading it from the release being replaced is what makes that not a requirement.
    previous_admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$destination/.env" | head -n 1 | tr -d '\r')
    # Kept beside the new one purely so a bad edit is recoverable by hand.
    cp -- "$destination/.env" "$staging/.env.previous"
    detail 'Previous .env saved as .env.previous'
fi

cp -- "$staging/.env.example" "$staging/.env"
success 'Installed .env from .env.example'

new_password=$(sed -n 's/^POSTGRES_PASSWORD=//p' "$staging/.env" | head -n 1 | tr -d '\r')
admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$staging/.env" | head -n 1 | tr -d '\r')
emby_url=$(sed -n 's/^MEMBY_EMBY_URL=//p' "$staging/.env" | head -n 1 | tr -d '\r')
configured_port=$(sed -n 's/^MEMBY_PORT=//p' "$staging/.env" | head -n 1 | tr -d '\r')
release_token=$(sed -n 's/^MEMBY_RELEASE_PUBLISH_TOKEN=//p' "$staging/.env" | head -n 1 | tr -d '\r')
if [ -z "$new_password" ]; then
    failure 'POSTGRES_PASSWORD is empty in .env.example; Compose will refuse to start'
    exit 1
fi
if [ -z "$emby_url" ]; then
    failure 'MEMBY_EMBY_URL is empty in .env.example'
    exit 1
fi
if [ -z "$admin_token" ]; then
    failure 'MEMBY_ADMIN_TOKEN is empty in .env.example; refusing to deploy with /admin disabled'
    exit 1
fi
if [ "$configured_port" != '32768' ]; then
    failure "MEMBY_PORT must be 32768 for the mserver.sublogue.com reverse proxy (found: ${configured_port:-unset})"
    exit 1
fi
if [ "$publish_release" -eq 1 ] && [ -z "$release_token" ]; then
    failure 'MEMBY_RELEASE_PUBLISH_TOKEN is empty; the signed APK cannot be published'
    exit 1
fi
success 'Required gateway configuration is present'
if [ -n "$previous_password" ] && [ "$previous_password" != "$new_password" ]; then
    failure 'POSTGRES_PASSWORD differs from the deployed value'
    detail 'The PostgreSQL volume is always preserved; restore the deployed password in .env.example'
    detail 'Change the database credential separately with an explicit migration if required'
    exit 1
fi

(
    cd "$staging"
    docker compose config --quiet
)
success 'Compose configuration is valid'

step '[remote 4/10] Telling the televisions'
# Announced here rather than at the swap, and the position is the point of it: the
# gateway being replaced is still answering, and the build below takes minutes, which is
# the window in which every open television polls /v1/status and collects the notice.
# Announcing at the swap would be too late twice over — nothing is left to publish with
# once the stack is down, and Redis holds the alert list in memory with no volume, so the
# swap discards anything published but not yet collected.
#
# Entirely best-effort: a deployment must never fail over a banner, and a first
# deployment (or one onto a host with no curl) has nothing to announce with.
if [ -z "$previous_admin_token" ]; then
    detail 'No previous release to announce from; skipping'
elif ! command -v curl >/dev/null 2>&1; then
    detail 'curl is unavailable on the NAS; skipping the announcement'
else
    announce_status=$(curl --silent --show-error --max-time 5 \
        --output /dev/null \
        --write-out '%{http_code}' \
        -X POST \
        -H "Authorization: Bearer $previous_admin_token" \
        http://127.0.0.1:32768/admin/api/deployment-alert 2>/dev/null || true)
    case "$announce_status" in
        2??) success 'Signed-in televisions have been told a deployment is starting' ;;
        *) detail "Could not announce the deployment (HTTP ${announce_status:-no response})" ;;
    esac
fi

step '[remote 5/10] Pulling PostgreSQL and Redis'
(
    cd "$staging"
    docker compose pull postgres redis
)
success 'Dependency images are ready'

step '[remote 6/10] Building memby-server'
(
    cd "$staging"
    docker compose build --pull server
)
success 'Server image built'

step '[remote 7/10] Activating the release'
rm -rf -- "$backup"
if [ -e "$destination" ] || [ -L "$destination" ]; then
    if [ -f "$destination/docker-compose.yml" ]; then
        # Compose projects created by older releases may use a different project
        # name. Stop them from their original directory before moving it so their
        # published ports (especially 32768) are released for the new stack.
        detail 'Stopping the previous Compose application'
        (
            cd "$destination"
            docker compose down --remove-orphans
        )
        previous_stopped=1
        success 'Previous Compose application stopped'
    fi

    mv -- "$destination" "$backup"
fi
mv -- "$staging" "$destination"
activated=1
success 'Release activated'

step '[remote 8/10] Starting the Compose stack'
cd "$destination"
docker compose up -d --remove-orphans
success 'Compose start command completed'

step '[remote 9/10] Waiting for healthy services'
wait_for_service postgres
wait_for_service redis
wait_for_service server

published_address=$(docker compose port server 32768 | head -n 1)
actual_port=${published_address##*:}
if [ "$actual_port" != '32768' ]; then
    failure "Memby published the wrong NAS port: ${published_address:-none}"
    exit 1
fi

server_container_id=$(docker compose ps -q server)
if ! docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$server_container_id" |
    grep -q '^MEMBY_ADMIN_TOKEN=.'; then
    failure 'MEMBY_ADMIN_TOKEN was not passed into the running server container'
    exit 1
fi
success 'Runtime configuration includes the admin token'

step '[remote 10/10] Publishing the signed Android update'
if [ "$publish_release" -eq 1 ]; then
    release_version=$(tr -d '\r\n' < "$destination/release/version.txt")
    release_sha256=$(tr -d '\r\n' < "$destination/release/sha256.txt")
    if ! printf '%s' "$release_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
        failure 'The packaged Android version is invalid'
        exit 1
    fi
    if ! printf '%s' "$release_sha256" | grep -Eq '^[0-9a-f]{64}$'; then
        failure 'The packaged Android checksum is invalid'
        exit 1
    fi
    release_apk="$destination/release/memby-${release_version}.apk"
    if [ ! -f "$release_apk" ]; then
        failure "Signed APK is missing: $release_apk"
        exit 1
    fi

    release_response="$destination/release/publish-response.json"
    release_status=$(curl --show-error --silent \
        --output "$release_response" \
        --write-out '%{http_code}' \
        -X POST \
        -H "Authorization: Bearer $release_token" \
        -F "version=$release_version" \
        -F "sha256=$release_sha256" \
        -F "mandatory=$mandatory_update" \
        -F "notes=<$destination/release/notes.txt" \
        -F "apk=@$release_apk;type=application/vnd.android.package-archive" \
        http://127.0.0.1:32768/admin/api/release)
    case "$release_status" in
        2??) ;;
        *)
            failure "Gateway rejected the Android update (HTTP ${release_status:-unknown})"
            if [ -s "$release_response" ]; then
                detail "$(tr -d '\r\n' < "$release_response")"
            fi
            exit 1
            ;;
    esac
    rm -rf -- "$destination/release"
    success "Memby $release_version is available to older TVs"
else
    detail 'Skipped by -SkipAppRelease'
fi

printf '\n'
docker compose ps
printf '\n'

rm -rf -- "$backup"
activated=0
trap - EXIT INT TERM
success 'Remote deployment is healthy'
success 'Memby gateway: https://mserver.sublogue.com'
'@

    $remoteCommand = $remoteCommand.Replace('__DESTINATION__', $Destination)
    $remoteCommand = $remoteCommand.Replace('__HEALTH_TIMEOUT__', $HealthTimeoutSeconds.ToString())
    $remoteCommand = $remoteCommand.Replace(
        '__PUBLISH_RELEASE__',
        $(if ($SkipAppRelease) { '0' } else { '1' })
    )
    $remoteCommand = $remoteCommand.Replace(
        '__MANDATORY_UPDATE__',
        $(if ($mandatoryRelease) { '1' } else { '0' })
    )
    # This file is edited on Windows, so the here-string above arrives with whatever line
    # endings it was saved with. A remote shell reads a trailing carriage return as part
    # of the token — 'set -eu\r' fails with "illegal option" before anything runs — so the
    # script is normalised to LF here rather than depending on how it was saved.
    $remoteCommand = $remoteCommand.Replace("`r`n", "`n").Replace("`r", "`n")

    Write-Step "Deploying to $RemoteHost"
    Write-Detail 'One SSH password prompt will appear'
    Write-Detail 'Remote build output follows'
    Write-Host ''
    Send-ArchiveOverSsh -ArchivePath $archivePath -RemoteCommand $remoteCommand

    $deploymentTimer.Stop()
    Write-Host ''
    Write-Success ("Deployment complete in {0:mm\:ss}" -f $deploymentTimer.Elapsed)
    Write-Host '  Memby gateway: https://mserver.sublogue.com' -ForegroundColor White
    Write-Host "  NAS endpoint:  http://${RemoteHost}:32768" -ForegroundColor DarkGray
    Write-Host "  Install path:  ${RemoteHost}:$Destination" -ForegroundColor DarkGray
    if (-not $SkipAppRelease) {
        Write-Host "  TV update:     Memby $releaseVersion (signed and published)" -ForegroundColor White
    }
}
catch {
    $deploymentTimer.Stop()
    Write-Host ''
    Write-Host "✗ Deployment stopped after $($deploymentTimer.Elapsed.ToString('mm\:ss'))" -ForegroundColor Red
    Write-Host "  $($_.Exception.Message)" -ForegroundColor Red
    throw
}
finally {
    if (Test-Path -LiteralPath $workDirectory) {
        Remove-Item -LiteralPath $workDirectory -Recurse -Force
    }
}
