<#
.SYNOPSIS
    Builds, packages and publishes the Windows desktop client.

.DESCRIPTION
    Run by a person, on a Windows machine that is not a CI runner. That is not an accident of tooling —
    docs/adr/0011-android-distribution.md rule 1 puts the capability to ship somebody a build on a machine
    which is not a runner, and docs/adr/0013-desktop-distribution-and-updates.md explains why the token that
    writes a Gitea release is that capability: Velopack clients trust their feed and do not verify a package
    signature when they apply it, so whoever can write a release can ship an update every install runs.

    Two phases, and the split is the design rather than a convenience.

      1. Without -Upload: builds, packs, and stops. Nothing has left this machine.
         Install the Setup.exe it names, and walk Phase 16 of docs/manual-checks.md.
      2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
         so the bytes that reach users are the bytes that were installed and checked.

    The token is prompted for rather than read from a file or an environment variable, and only in the phase
    that needs it — the build does not, and the fewer minutes a credential that can publish an update spends
    in a shell's memory the better.

.PARAMETER Upload
    Publish the packages already in Releases/ instead of building.

.PARAMETER SkipTests
    Skip the test run. For a re-pack of a tag CI has already gone green on.

.EXAMPLE
    pwsh -File scripts/release-windows.ps1
    pwsh -File scripts/release-windows.ps1 -Upload
#>
#Requires -Version 7.0

# PowerShell 7, and stated so the failure is a clear message rather than a confusing one: this script reads
# $IsWindows, which does not exist in Windows PowerShell 5.1 and under Set-StrictMode would throw about an
# unset variable — sending the reader after a typo rather than after the shell they are using.

[CmdletBinding()]
param(
    [switch] $Upload,
    [switch] $SkipTests
)

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

# Velopack's identity for this application, and it is effectively irreversible.
#
# It is what an installed client matches an update against and the directory it installs into, so changing
# it later orphans every existing install — still running, never updated, invisible to the new one.
#
# DodoSSH.Desktop and not DodoSSH, for a specific reason worth keeping next to the value: Velopack installs
# to %LOCALAPPDATA%\<packId> and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
# where ClientPaths keeps the encrypted cache, the outbox of changes not yet pushed, and the device key.
# Sharing the directory would mean the uninstaller silently taking a user's un-synced work with it.
$PackId = 'DodoSSH.Desktop'

# What a person sees, in the Start menu and in Add/Remove Programs. The distinct pack id costs nothing here.
$PackTitle = 'DodoSSH'
$PackAuthors = 'DodoTech'

# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
#
# The owner is part of it. When the repository moved organisations Gitea left a 301 at the old path, which
# a GET follows and an upload does not — so a stale URL here fails at the one step that matters rather than
# redirecting quietly.
$RepoUrl = 'https://git.dodotech.cloud/DodoTech-Public/DodoSSH'

# A contract with VelopackUpdateChannel.ReleaseChannel. It is Velopack's Windows default, so leaving it
# unsaid on both sides would work too — but unsaid here and stated there is how a feed goes quiet with no
# error at all: the client checks, finds nothing, and reports itself up to date forever.
#
# The nightly channel is the same three values with different contents — DodoSSH.Desktop.Nightly, DodoSSH
# Nightly, win-nightly — and they live in the `desktop nightly` job in .github/workflows/ci.yml rather than
# here, because that build is CI's and this script is a person's. Nothing shares them, deliberately: this
# file must keep working if that job is deleted. See ADR 0013 decision 9 for what the separation buys.
$Channel = 'win'

$RepoRoot = Split-Path -Parent $PSScriptRoot
$Project = Join-Path $RepoRoot 'src/DodoSSH.Client.App/DodoSSH.Client.App.csproj'
$PublishDir = Join-Path $RepoRoot 'publish/win-x64'
$ReleasesDir = Join-Path $RepoRoot 'Releases'

function Write-Step([string] $Message) {
    Write-Host ''
    Write-Host "==> $Message" -ForegroundColor Cyan
}

function Stop-With([string] $Message) {
    Write-Host ''
    Write-Host $Message -ForegroundColor Red
    exit 1
}

if (-not $IsWindows) {
    # vpk stamps and embeds the Setup.exe and Update.exe stubs with Windows tooling. This is the smaller of
    # the two reasons a runner cannot do this job; see the comment at the foot of .github/workflows/ci.yml
    # for the larger one.
    Stop-With 'This builds a Windows package and has to run on Windows.'
}

Push-Location $RepoRoot
try {
    # ---- What is being released -----------------------------------------------------------------------

    # Restored before the version is read, and both halves of that sentence are load-bearing.
    #
    # -t:MinVer, because -getProperty on its own evaluates the project and runs no targets, while MinVer
    # sets Version from inside one — so this read answered the SDK's default 1.0.0 regardless of the tag,
    # and the check below would then have refused to build anything not tagged v1.0.0. CI's tag check had
    # the same line and the same fault.
    #
    # And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
    # clone, where obj/ has no MinVer targets to import yet. It costs seconds on a machine that has built
    # before, which is every machine except the one this would otherwise fail on.
    Write-Step 'Restoring the desktop head, so the version can be read'
    & dotnet restore $Project --locked-mode
    if ($LASTEXITCODE -ne 0) { Stop-With 'Restore failed.' }

    $version = (& dotnet msbuild $Project -getProperty:Version -t:MinVer -nologo) -replace '\s', ''
    if ([string]::IsNullOrWhiteSpace($version)) {
        Stop-With 'Could not read the version from MSBuild.'
    }

    $tag = "v$version"

    Write-Step "DodoSSH $version  ($PackId, channel $Channel)"

    if ($Upload) {
        # ---- Phase 2: publish what phase 1 built ------------------------------------------------------

        $setup = Get-ChildItem $ReleasesDir -Filter '*Setup*.exe' -ErrorAction SilentlyContinue |
                 Select-Object -First 1

        if (-not $setup) {
            Stop-With "Nothing to upload: $ReleasesDir has no Setup executable. Run this without -Upload first."
        }

        Write-Host "About to publish the contents of $ReleasesDir to $RepoUrl as $tag."
        Write-Host 'Only do this once you have installed it and walked Phase 16 of docs/manual-checks.md.'

        # Read-Host -AsSecureString so the token is never echoed and never lands in the shell's history.
        $secure = Read-Host -Prompt 'Gitea token (write:repository)' -AsSecureString
        $token = [System.Net.NetworkCredential]::new('', $secure).Password

        if ([string]::IsNullOrWhiteSpace($token)) {
            Stop-With 'No token given.'
        }

        # --merge because Gitea already has a release entry for the pushed tag, and without it the upload
        # fails on a release that exists. --pre mirrors the rule the docker image job already applies to the
        # same tag, so a release candidate is a prerelease in both channels or in neither.
        $uploadArgs = @(
            'upload', 'gitea',
            '--repoUrl', $RepoUrl,
            '--token', $token,
            '--outputDir', $ReleasesDir,
            '--channel', $Channel,
            '--releaseName', $tag,
            '--tag', $tag,
            '--merge',
            '--publish'
        )

        if ($version -match '-') {
            $uploadArgs += '--pre'
        }

        Write-Step 'Uploading'
        & dotnet vpk @uploadArgs
        if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }

        Write-Step "Published $tag."
        return
    }

    # ---- Phase 1: build and pack ----------------------------------------------------------------------

    if ((git status --porcelain) -ne $null) {
        Stop-With 'The working tree is not clean. A release is cut from a commit, not from a desk.'
    }

    $headTag = git describe --exact-match --tags HEAD 2>$null
    if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($headTag)) {
        Stop-With "HEAD is not tagged. Tag it $tag first, or change the version and tag that."
    }

    if ($headTag -ne $tag) {
        # Cannot happen while MinVer is deriving the version from this very tag, and checked anyway: the
        # day somebody pins a version by hand this is the guard that notices.
        Stop-With "HEAD is tagged $headTag but the computed version is $version."
    }

    Write-Step 'Restoring tools'
    & dotnet tool restore
    if ($LASTEXITCODE -ne 0) { Stop-With 'dotnet tool restore failed.' }

    Write-Step 'Restoring packages (locked, exactly as CI does)'
    & dotnet restore (Join-Path $RepoRoot 'DodoSSH.slnx') --locked-mode
    if ($LASTEXITCODE -ne 0) { Stop-With 'Restore failed. A lock file that only works on Linux fails here.' }

    Write-Step 'Building'
    & dotnet build (Join-Path $RepoRoot 'DodoSSH.slnx') --no-restore --configuration Release
    if ($LASTEXITCODE -ne 0) { Stop-With 'Build failed.' }

    if (-not $SkipTests) {
        # The end-to-end suite starts containers and takes minutes. It is run here anyway rather than taken
        # on trust from CI, because a tag is the one build nobody is watching — the same argument ci.yml
        # already makes for running the whole workflow on a tag.
        Write-Step 'Testing'
        & dotnet test (Join-Path $RepoRoot 'DodoSSH.slnx') --no-build --configuration Release
        if ($LASTEXITCODE -ne 0) { Stop-With 'Tests failed.' }
    }

    Write-Step 'Publishing win-x64'
    if (Test-Path $PublishDir) { Remove-Item $PublishDir -Recurse -Force }

    # Self-contained: .NET 10 is recent enough that almost no machine has the runtime, and the usual
    # objection — that runtime patches then need an application update — is answered by the updater this
    # very script exists to feed. Not single-file: the native libraries ship per RID, deltas would stop
    # working, and a self-extracting bundle puts the executable under a temp path deep enough to break
    # WebView2 (docs/platform-flags.md).
    # RestoreLockedMode=false, and the lock files put back straight afterwards. Both halves need saying.
    #
    # A RID-specific publish resolves a graph the committed lock files do not describe, because they are
    # deliberately kept RID-free: declaring win-x64 on the project writes a net10.0/win-x64 target into
    # every project it references transitively, including DodoSSH.Contracts and DodoSSH.Crypto — and the
    # API's Dockerfile then restores those with no RID under locked mode and fails NU1004. Packaging the
    # desktop client would have broken the server's image build. See the comment in the head's csproj.
    #
    # So this one command restores unlocked. It is a supervised build, from a tag, run by a person; the
    # gate that matters is the locked solution restore two steps above, which is untouched and is the same
    # command CI runs.
    & dotnet publish $Project `
        --configuration Release `
        --runtime win-x64 `
        --self-contained true `
        --output $PublishDir `
        -p:RestoreLockedMode=false
    if ($LASTEXITCODE -ne 0) { Stop-With 'Publish failed.' }

    # An unlocked restore rewrites the lock files it walked, adding the win-x64 target. Left there, the
    # next commit would carry exactly the change that breaks the image build — so they go back. Safe to do
    # bluntly because this script refuses to run on a dirty tree, so anything modified here is its own.
    & git checkout -- '*packages.lock.json'
    if ($LASTEXITCODE -ne 0) { Stop-With 'Could not restore the lock files after publishing.' }

    # Checked rather than assumed. A publish directory without Velopack.dll would pack into an installer for
    # an application that never checks for updates — which looks completely normal until the next release
    # goes out and nobody receives it.
    foreach ($required in @('DodoSSH.exe', 'Velopack.dll')) {
        if (-not (Test-Path (Join-Path $PublishDir $required))) {
            Stop-With "$required is missing from $PublishDir."
        }
    }

    $sizeMb = [math]::Round(((Get-ChildItem $PublishDir -Recurse -File | Measure-Object Length -Sum).Sum / 1MB), 1)
    Write-Host "  $sizeMb MB in $((Get-ChildItem $PublishDir -Recurse -File).Count) files"

    New-Item -ItemType Directory -Force -Path $ReleasesDir | Out-Null

    # The previous release, so a delta can be built against it. Tolerated when it finds nothing: the first
    # release has no predecessor, and a hard failure here would make cutting it impossible.
    Write-Step 'Fetching the previous release, for deltas'
    & dotnet vpk download gitea --repoUrl $RepoUrl --outputDir $ReleasesDir --channel $Channel
    if ($LASTEXITCODE -ne 0) {
        Write-Host '  Nothing came down. This package will be full-only, which is right for a first release.' -ForegroundColor Yellow
    }

    Write-Step 'Packing'

    # No --signParams. Every installer therefore raises SmartScreen's "Windows protected your PC" on first
    # run, once per user — Mark-of-the-Web is applied by the browser that downloads Setup.exe, so in-app
    # updates, which this application fetches itself and applies from a local file, never trip it.
    #
    # This is the one line that changes when a certificate is bought. See ADR 0013 for what it costs and
    # what the trigger for buying one is.
    & dotnet vpk pack `
        --packId $PackId `
        --packVersion $version `
        --packDir $PublishDir `
        --packTitle $PackTitle `
        --packAuthors $PackAuthors `
        --mainExe 'DodoSSH.exe' `
        --icon (Join-Path $RepoRoot 'src/DodoSSH.Client.App/Assets/dodossh.ico') `
        --channel $Channel `
        --outputDir $ReleasesDir
    if ($LASTEXITCODE -ne 0) { Stop-With 'vpk pack failed.' }

    Write-Step 'Built, and deliberately not uploaded'

    Get-ChildItem $ReleasesDir -File |
        Sort-Object Length -Descending |
        Select-Object Name, @{ n = 'MB'; e = { [math]::Round($_.Length / 1MB, 1) } } |
        Format-Table -AutoSize

    Write-Host 'Next:'
    Write-Host "  1. Install the Setup executable above and walk Phase 16 of docs/manual-checks.md."
    Write-Host '  2. Then: pwsh -File scripts/release-windows.ps1 -Upload'
}
finally {
    Pop-Location
}
