<#
================================================================================
  VelaPanel - Manual Update Script   (run ON the production server, as Admin)
================================================================================

  PRODUCTION LAYOUT
    C:\velapanel.com\
        data\      <- DB, encryption.key, secrets, backups   (NEVER TOUCHED)
        publish\   <- the LIVE app the Windows service runs from
        Release\   <- you upload the NEW build here before running this script

  WORKFLOW
    1. Upload the new build's files into  C:\velapanel.com\Release\
    2. Dry run first (changes NOTHING):
         powershell -ExecutionPolicy Bypass -File C:\velapanel.com\deploy-update.ps1 -DryRun
    3. If the dry run is all green, run for real:
         powershell -ExecutionPolicy Bypass -File C:\velapanel.com\deploy-update.ps1

  WHAT IT DOES  (safe, reversible, never deletes data)
    - Backs up data\panel.db
    - Stops the VelaPanel service
    - Renames current  publish  ->  publish.bak-<timestamp>   (kept for rollback)
    - Moves  Release  ->  publish
    - Starts the service. On startup the app runs EF Core MigrateAsync(), which
      applies any new database migrations to the existing panel.db IN PLACE,
      preserving all data (idempotent - only unapplied migrations run).
    - Health-checks the panel at http://localhost:5100/
    - AUTO-ROLLBACK if unhealthy: moves the bad build aside, restores the
      previous publish.bak, restores panel.db, restarts the old build.

  GUARANTEE
    The  data\  folder is never in scope of any move/delete. The DB backup is
    taken BEFORE the new build can touch the schema, so a bad migration or a
    crashing build is fully reversible (old code + old schema restored).

  HEAVY MIGRATIONS
    A large migration can take a while on first startup. If you ship one, raise
    -HealthTimeoutSec so the script does not mistake "still migrating" for
    "unhealthy" and roll back. Normal add-column migrations are sub-second.
================================================================================
#>

[CmdletBinding()]
param(
    [string] $Root             = "C:\velapanel.com",
    [string] $ServiceName      = "VelaPanel",
    [string] $HealthUrl        = "http://localhost:5100/",
    [int]    $HealthTimeoutSec = 120,
    [int]    $KeepBackups      = 3,
    [string] $AppExeName       = "VelaPanel.exe",
    [switch] $DryRun
)

$ErrorActionPreference = "Stop"
$ts          = Get-Date -Format "yyyyMMdd-HHmmss"
$LivePath    = Join-Path $Root "publish"
$ReleasePath = Join-Path $Root "Release"
$DataDir     = Join-Path $Root "data"
$PanelDb     = Join-Path $DataDir "panel.db"
$BackupDir   = Join-Path $DataDir "backups"
$BakPath     = Join-Path $Root "publish.bak-$ts"
$logDir      = Join-Path $DataDir "logs"

New-Item -ItemType Directory -Force -Path $logDir, $BackupDir | Out-Null
$transcript = Join-Path $logDir "deploy-$ts.log"
Start-Transcript -Path $transcript -Append | Out-Null

function Info($m) { Write-Host "[*] $m" -ForegroundColor Cyan }
function Ok($m)   { Write-Host "[+] $m" -ForegroundColor Green }
function Warn($m) { Write-Host "[!] $m" -ForegroundColor Yellow }
function Fail($m) { Write-Host "[x] $m" -ForegroundColor Red }

# ── SQLite post-stop integrity gate ─────────────────────────────────────────
# On 2026-08-01 a deploy left hangfire.db corrupted ("database disk image is
# malformed"). The service then burned a CPU core for 10+ hours retrying a lock
# cleanup every ~6s while the orphaned WAL grew from 2 MB to 4 MB.
#
# Notably the deploy log showed the GRACEFUL path ("exited on its own - no
# force-kill needed"), so the pre-existing force-kill guard below was not enough:
# the process exited without checkpointing its WAL, leaving a dirty pair behind.
#
# A stale -wal/-shm alongside a database is the real hazard: SQLite replays the
# WAL on next open, so deleting only the .db and leaving a malformed WAL simply
# re-corrupts the fresh file.
#
# The two databases get deliberately OPPOSITE treatment:
#   hangfire.db - disposable job storage. Every recurring job is re-registered
#                 from Program.cs on boot, so a corrupt one is auto-quarantined
#                 and recreated. Losing queued jobs beats a wedged service.
#   panel.db    - domains, users, mailboxes, settings. NEVER auto-deleted. If it
#                 is malformed we abort the deploy so an operator can restore the
#                 pre-update backup taken in step 2.

# True if a WAL/SHM sidecar is present, i.e. the DB was not closed cleanly.
function Test-SqliteDirty($dbPath) {
    return (Test-Path "$dbPath-wal") -or (Test-Path "$dbPath-shm")
}

# Best-effort corruption probe without needing the sqlite3 CLI on the box.
# A valid SQLite file starts with the 16-byte magic string "SQLite format 3\0";
# a truncated or overwritten header is the common corruption signature.
function Test-SqliteHeaderValid($dbPath) {
    try {
        $fs = [System.IO.File]::Open($dbPath, 'Open', 'Read', 'ReadWrite')
        try {
            if ($fs.Length -lt 16) { return $false }
            $buf = New-Object byte[] 16
            [void]$fs.Read($buf, 0, 16)
            return ([System.Text.Encoding]::ASCII.GetString($buf, 0, 15) -eq 'SQLite format 3')
        } finally { $fs.Dispose() }
    } catch {
        # Could not open for read => something still holds an exclusive handle.
        return $null
    }
}

# Returns $true when safe to proceed, $false when the deploy must abort.
function Assert-SqliteHealthy {
    $hangfireDb = Join-Path $DataDir "hangfire.db"

    # -- panel.db: verify only, never delete --
    if (Test-Path $PanelDb) {
        $state = Test-SqliteHeaderValid $PanelDb
        if ($state -eq $null) {
            Fail "panel.db is still locked by another process - aborting before the swap."
            Fail "Something is holding C:\velapanel.com\data\panel.db. Stop it, then re-run."
            return $false
        }
        if (-not $state) {
            Fail "panel.db header is invalid - the database appears corrupted."
            Fail "ABORTING. Restore the pre-update backup from $BackupDir and investigate."
            Fail "panel.db is never auto-deleted: it holds domains, users, mail and settings."
            return $false
        }
        if (Test-SqliteDirty $PanelDb) {
            # Sidecars alongside a valid header are normal if the process just exited;
            # SQLite will recover them on next open. Worth surfacing, not fatal.
            Warn "panel.db has a leftover -wal/-shm (unclean shutdown). SQLite will recover on open."
        }
        Ok "panel.db integrity check passed."
    }

    # -- hangfire.db: quarantine and recreate if suspect --
    if (Test-Path $hangfireDb) {
        $hfState = Test-SqliteHeaderValid $hangfireDb
        $walPath = "$hangfireDb-wal"
        $walSize = if (Test-Path $walPath) { (Get-Item $walPath).Length } else { 0 }
        $dbSize  = (Get-Item $hangfireDb).Length

        # A WAL several times larger than its database means checkpoints have been
        # failing - the exact signature of the 2026-08-01 incident (294 KB db / 4 MB wal).
        $walRunaway = ($walSize -gt 1MB) -and ($walSize -gt ($dbSize * 2))

        if ($hfState -eq $null) {
            Warn "hangfire.db still locked - skipping its integrity check."
        }
        elseif ((-not $hfState) -or $walRunaway) {
            $why = if (-not $hfState) { "invalid header" } else { "runaway WAL ($([int]($walSize/1KB)) KB vs $([int]($dbSize/1KB)) KB db)" }
            Warn "hangfire.db looks corrupted ($why) - quarantining and recreating."
            $qDir = Join-Path $BackupDir "corrupt-hangfire-$ts"
            New-Item -ItemType Directory -Force -Path $qDir | Out-Null
            foreach ($suffix in @("", "-wal", "-shm")) {
                $p = "$hangfireDb$suffix"
                if (Test-Path $p) {
                    # Move, don't delete: keeps the evidence if this needs diagnosing,
                    # and guarantees no stale sidecar is left to re-corrupt the new file.
                    try { Move-Item $p (Join-Path $qDir ("hangfire.db$suffix")) -Force }
                    catch { Remove-Item $p -Force -ErrorAction SilentlyContinue }
                }
            }
            Ok "Quarantined to $qDir - Hangfire will recreate its schema on startup."
        }
        else {
            Ok "hangfire.db integrity check passed."
        }
    }

    return $true
}

# Lift the management scripts that ship inside the new build (publish\deploy-update.ps1,
# publish\rollback-update.ps1, publish\tools\*) up to the panel root, so C:\velapanel.com\
# always holds the latest tooling and never needs to be copied by hand. The root copies are
# what actually run (they live outside publish and survive the publish swap). Best-effort:
# a failure here must never fail an otherwise-successful deploy.
function Sync-MgmtScripts($fromPublish) {
    try {
        foreach ($s in @("deploy-update.ps1", "rollback-update.ps1")) {
            $src = Join-Path $fromPublish $s
            if (Test-Path $src) { Copy-Item $src (Join-Path $Root $s) -Force; Info "Synced $s to root." }
        }
        $srcTools = Join-Path $fromPublish "tools"
        if (Test-Path $srcTools) {
            $dstTools = Join-Path $Root "tools"
            if (-not (Test-Path $dstTools)) { New-Item -ItemType Directory -Force -Path $dstTools | Out-Null }
            Copy-Item (Join-Path $srcTools "*") $dstTools -Recurse -Force
            Info "Synced tools\ to root."
        }
    } catch { Warn "Management-script sync skipped: $($_.Exception.Message)" }
}

function Test-Healthy {
    # The panel root redirects (/ -> /login). We FOLLOW redirects and treat any
    # response under HTTP 500 as "the app is up and serving". A 4xx (e.g. 401/404)
    # still proves the server is alive; only a 5xx or no-response means unhealthy.
    # NOTE: do NOT use -MaximumRedirection 0 here - on Windows PowerShell 5.1 that
    # throws InvalidOperationException with a null Response on a redirect, which
    # reads as a false "unhealthy" and would trigger an unnecessary rollback.
    $ProgressPreference = 'SilentlyContinue'
    $deadline = (Get-Date).AddSeconds($HealthTimeoutSec)
    while ((Get-Date) -lt $deadline) {
        $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
        if ($svc -and $svc.Status -eq 'Running') {
            try {
                $r = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 8 -ErrorAction Stop
                if ([int]$r.StatusCode -lt 500) { Info "Healthy (HTTP $($r.StatusCode))"; return $true }
            } catch {
                # Server responded with a non-2xx/3xx status (4xx) -> still alive.
                $code = $null
                try { $code = [int]$_.Exception.Response.StatusCode } catch {}
                if ($code -and $code -gt 0 -and $code -lt 500) { Info "Healthy (HTTP $code)"; return $true }
            }
        }
        Start-Sleep -Seconds 3
    }
    return $false
}

$dbBackup = $null
$swapDone = $false

try {
    Info "Deploy $ts started  (DryRun=$DryRun, log: $transcript)"

    # ---- 1. Validate ----
    if (-not (Test-Path $ReleasePath)) { throw "No Release folder at '$ReleasePath'. Upload the new build there first." }
    if (-not (Test-Path (Join-Path $ReleasePath $AppExeName))) { throw "'$ReleasePath' has no $AppExeName - not a valid publish output." }
    if (-not (Test-Path $LivePath)) { throw "Live folder '$LivePath' not found - wrong server?" }
    $svc = Get-Service -Name $ServiceName -ErrorAction Stop
    Ok "Validated: Release build present, live publish present, service '$ServiceName' is $($svc.Status)."

    # ---- Runtime-model guard (prevents the framework-dependent-vs-self-contained trap) ----
    # If the live build bundles the .NET runtime (self-contained) but the new build
    # does not, the new exe will fail to start unless .NET is installed system-wide -
    # and it dies BEFORE logging anything. Catch the mismatch now, before any downtime.
    $liveSelfContained = Test-Path (Join-Path $LivePath 'hostfxr.dll')
    $relSelfContained  = Test-Path (Join-Path $ReleasePath 'hostfxr.dll')
    if ($liveSelfContained -ne $relSelfContained) {
        throw ("Build model MISMATCH - aborting before any change. " +
               "Live publish self-contained=$liveSelfContained, Release self-contained=$relSelfContained. " +
               "Rebuild the Release to match, e.g.:  dotnet publish -c Release -r win-x64 --self-contained $($liveSelfContained.ToString().ToLower())")
    }
    Ok "Runtime model matches (self-contained=$liveSelfContained)."

    if ($DryRun) {
        Info "DRY RUN - probing the CURRENTLY running panel to confirm the health check URL works..."
        if (Test-Healthy) { Ok "Health probe OK against the live panel - the real run can trust it." }
        else { Warn "Could not confirm health at $HealthUrl on the running panel. Check the URL/port before the real run, or it may roll back unnecessarily." }
        Info "DRY RUN complete. No changes made. The real run would:"
        Info "   backup  $PanelDb"
        Info "   stop    $ServiceName"
        Info "   rename  $LivePath -> $BakPath"
        Info "   move    $ReleasePath -> $LivePath"
        Info "   start   $ServiceName (runs EF migrations), then health-check (rollback if unhealthy)"
        Stop-Transcript | Out-Null
        exit 0
    }

    # ---- 2. Back up the DB (data\ is otherwise never touched) ----
    if (Test-Path $PanelDb) {
        $dbBackup = Join-Path $BackupDir "panel-pre-update-$ts.db"
        Copy-Item $PanelDb $dbBackup -Force
        Ok "Backed up panel.db -> $dbBackup"
    } else { Warn "panel.db not found at $PanelDb - skipping DB backup." }

    # ---- 3. Stop ----
    Info "Stopping service '$ServiceName'..."
    Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
    try { (Get-Service $ServiceName).WaitForStatus('Stopped', '00:01:00') } catch {}

    # Give any lingering process a chance to exit GRACEFULLY before force-killing it.
    # A hard Stop-Process -Force can land mid-write to a SQLite WAL (hangfire.db) and
    # corrupt it ("database disk image is malformed"), which then crashes the NEXT
    # startup. So we poll for self-exit first and only force-kill as a last resort.
    $procName  = $AppExeName.Replace('.exe','')
    $lingering = Get-Process -Name $procName -ErrorAction SilentlyContinue
    if ($lingering) {
        Warn "Lingering process found (PID $($lingering.Id)) - waiting up to 30s for graceful exit..."
        $graceDeadline = (Get-Date).AddSeconds(30)
        while ((Get-Date) -lt $graceDeadline -and (Get-Process -Name $procName -ErrorAction SilentlyContinue)) {
            Start-Sleep -Seconds 2
        }
        $stillThere = Get-Process -Name $procName -ErrorAction SilentlyContinue
        if ($stillThere) {
            Warn "Process still alive after grace period - force-killing (may risk WAL corruption)..."
            $stillThere | Stop-Process -Force -ErrorAction SilentlyContinue
            Start-Sleep -Seconds 3
        } else {
            Ok "Lingering process exited on its own - no force-kill needed."
        }
    }
    # Brief pause so the OS fully releases file handles before rename
    Start-Sleep -Seconds 2
    Ok "Service stopped."

    # ---- 3b. SQLite integrity gate ----
    # Runs AFTER the process is gone (so handles are released and any WAL is final)
    # but BEFORE the swap, so a corrupt database is caught while the old build is
    # still archived and rollback is trivial. Carrying corruption through the swap
    # is what turned the 2026-08-01 deploy into a 10-hour CPU burn.
    if (-not (Assert-SqliteHealthy)) {
        Fail "Integrity gate failed - restarting the existing service and aborting."
        # Nothing has been swapped yet, so bringing the old build back up is enough.
        Start-Service -Name $ServiceName -ErrorAction SilentlyContinue
        throw "Deploy aborted: SQLite integrity check failed. See messages above."
    }

    # ---- 4. Swap ----
    Info "Archiving current build -> $BakPath"
    Rename-Item -Path $LivePath -NewName (Split-Path $BakPath -Leaf)
    try {
        Info "Promoting new build $ReleasePath -> $LivePath"
        Move-Item -Path $ReleasePath -Destination $LivePath
        $swapDone = $true
        Ok "New build is now live."

        # Preserve production's machine-specific config: the live appsettings.json
        # holds the real License:SigningSecret, panel name, SMTP/DB credentials, etc.
        # The uploaded build ships a generic appsettings.json - do NOT let it clobber
        # production. Carry the previous one forward so updates change CODE only.
        $oldCfg = Join-Path $BakPath "appsettings.json"
        $newCfg = Join-Path $LivePath "appsettings.json"
        if (Test-Path $oldCfg) {
            Copy-Item $oldCfg $newCfg -Force
            Ok "Preserved production appsettings.json (config unchanged; only code updated)."
        } else {
            Warn "No previous appsettings.json found to preserve - the build's default will be used."
        }
    } catch {
        Warn "Promote failed: $($_.Exception.Message) - restoring previous build."
        if (-not (Test-Path $LivePath)) { Rename-Item -Path $BakPath -NewName (Split-Path $LivePath -Leaf) }
        throw
    }

    # ---- 5. Start (EF migrations run automatically on startup) ----
    Info "Starting service '$ServiceName'..."
    # A start FAILURE (process dies instantly - e.g. missing runtime) throws here.
    # Catch it so it flows into the SAME rollback path as an unhealthy start, rather
    # than aborting half-swapped with the broken build left live.
    $startErr = $null
    try { Start-Service -Name $ServiceName -ErrorAction Stop }
    catch { $startErr = $_.Exception.Message; Warn "Start-Service failed: $startErr" }
    Ok "Waiting up to ${HealthTimeoutSec}s for healthy (includes any DB migration time)..."

    # ---- 6. Health check / rollback ----
    if (-not $startErr -and (Test-Healthy)) {
        Ok "HEALTHY - update successful. Live build is now release $ts."
        Sync-MgmtScripts $LivePath   # refresh root deploy/rollback/tools from the new build
        Get-ChildItem $Root -Directory -Filter "publish.bak-*" |
            Sort-Object Name -Descending | Select-Object -Skip $KeepBackups |
            ForEach-Object { Remove-Item $_.FullName -Recurse -Force -ErrorAction SilentlyContinue; Info "Pruned old backup $($_.Name)" }
        Ok "Deploy complete. Previous build kept at $BakPath for safety."
    }
    else {
        if ($startErr) { Fail "Service failed to start ($startErr) - ROLLING BACK." }
        else { Fail "Panel did NOT become healthy in ${HealthTimeoutSec}s - ROLLING BACK." }
        Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
        try { (Get-Service $ServiceName).WaitForStatus('Stopped', '00:01:00') } catch {}
        if ($swapDone) {
            $badPath = Join-Path $Root "publish.failed-$ts"
            if (Test-Path $LivePath) { Rename-Item -Path $LivePath -NewName (Split-Path $badPath -Leaf) }
            Rename-Item -Path $BakPath -NewName (Split-Path $LivePath -Leaf)
            Ok "Restored previous build to $LivePath (bad build kept at $badPath for inspection)."
        }
        if ($dbBackup -and (Test-Path $dbBackup)) { Copy-Item $dbBackup $PanelDb -Force; Ok "Restored panel.db from pre-update backup." }
        Start-Service -Name $ServiceName -ErrorAction SilentlyContinue
        if (Test-Healthy) { Ok "Rollback complete - previous version healthy again." }
        else { Fail "Rollback restart not healthy - CHECK THE SERVER NOW. Log: $transcript" }
        throw "Update failed and was rolled back."
    }
}
catch {
    Fail $_.Exception.Message
    Stop-Transcript | Out-Null
    exit 1
}

Stop-Transcript | Out-Null
exit 0
