<#
================================================================================
  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 }

# 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."

    # ---- 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
