<?php

/**
 * Web Updater
 *
 * A standalone, self-contained update tool for PHP applications distributed
 * as .update packages. It lives at public/updater.php inside the installed
 * application, is laid down by the installer, and is refreshed by every
 * update it applies.
 *
 * It deliberately does NOT load the application's framework or vendor/
 * directory to do its work: uploading, validating, backing up, and
 * extracting all run on plain PHP, so a broken or half-updated application
 * can never take the updater down with it. The framework is booted exactly
 * once — via the package's own post-update hook — only after every file is
 * in place.
 *
 * Access is token-gated. The admin panel hands off a one-time token when it
 * launches the updater; without one, the updater falls back to a
 * filesystem-proof token (written to storage/, readable only by the site
 * operator), which also makes this the recovery tool for a broken install.
 */

// ============================================================================
// DO NOT TOUCH ANY CODE BELOW
// ============================================================================

// The values below are placeholders. bin/build replaces this entire block
// with real values from the consuming app's package/package-config.php
// (app folder/slug, app name, min_php_version) plus the toolkit's own
// UPDATER_VERSION every time it generates that app's updater.php —
// editing them here has no effect on any deployed app.
define('APP_FOLDER', 'pet-adoption');
define('APP_NAME', 'Pet Adoption');
define('MIN_PHP_VERSION', '8.5.0');
define('UPDATER_VERSION', '2.7.0');

// updater.php lives in {app}/public/, so the application root is one up.
define('UPDATER_APP_ROOT', dirname(__DIR__));

// ============================================================================
// env() polyfill
// ============================================================================
// The application's config/updates.php (and config/app.php) call env(),
// which only exists once Laravel's helpers are loaded — and loading those
// would defeat the whole point of a framework-free updater. This polyfill
// lets the updater `require` those config files directly. It reads from
// $_ENV/$_SERVER first (so when the post-update hook later boots Laravel and
// Dotenv populates them, values stay correct), then from the .env values the
// updater parsed itself, then getenv(). Laravel's own env() helper is
// guarded by function_exists(), so defining it here is safe: Laravel simply
// keeps using this one, with matching semantics for quoted values and the
// true/false/null/empty literals.

$GLOBALS['__updater_env'] = [];

if (! function_exists('env')) {
    function env(string $key, mixed $default = null): mixed
    {
        $value = $_SERVER[$key] ?? $_ENV[$key] ?? $GLOBALS['__updater_env'][$key] ?? null;

        if ($value === null) {
            $fromGetenv = getenv($key);
            $value = $fromGetenv === false ? null : $fromGetenv;
        }

        if ($value === null) {
            return $default;
        }

        if (! is_string($value)) {
            return $value;
        }

        switch (strtolower($value)) {
            case 'true':
            case '(true)':
                return true;
            case 'false':
            case '(false)':
                return false;
            case 'null':
            case '(null)':
                return null;
            case 'empty':
            case '(empty)':
                return '';
        }

        if (strlen($value) > 1 && str_starts_with($value, '"') && str_ends_with($value, '"')) {
            return substr($value, 1, -1);
        }

        return $value;
    }
}

// ============================================================================
// Updater Class
// ============================================================================


/**
 * Whether this request arrived over TLS — the one shared answer for the
 * session cookie's Secure flag, the auto-detected application URL, and the
 * mod_rewrite self-probe, so they can never disagree (e.g. a Secure cookie
 * paired with a suggested http:// app URL behind a TLS-terminating proxy).
 *
 * X-Forwarded-Proto is honored so Cloudflare-flexible-SSL-style setups
 * (browser↔proxy HTTPS, proxy↔origin plain HTTP) detect correctly — common
 * on the shared hosting these tools target. A client spoofing the header on
 * a plain-HTTP site only breaks its own session cookie (browsers never send
 * this header themselves), so trusting it unconditionally is safe here.
 */
function requestIsHttps(): bool
{
    return (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
        || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
}

/**
 * Apply hardened cookie parameters before session_start(), shared by
 * install.php and updater.php so both get the same protection: HttpOnly
 * (unreachable from JS, closing an XSS-to-session-theft path), SameSite=Lax
 * (a cross-site page cannot drive the authorized updater session into a
 * state-changing POST like start-update/restore), and Secure whenever the
 * request itself arrived over HTTPS (never forced — these tools must still
 * work on a host that hasn't set up TLS yet).
 */
function applyHardenedSessionCookieParams(): void
{
    session_set_cookie_params([
        'lifetime' => 0,
        'path' => '/',
        'httponly' => true,
        'samesite' => 'Lax',
        'secure' => requestIsHttps(),
    ]);
}

/**
 * Mint (once per session) and return a CSRF token, and expose the check
 * used to validate one. Shared between install.php and updater.php's
 * state-changing POST actions — both tools drive their own session-gated
 * multi-step flow with no other CSRF protection, since neither loads any
 * framework that would otherwise provide it.
 */
function csrfToken(string $sessionKey): string
{
    if (empty($_SESSION[$sessionKey])) {
        $_SESSION[$sessionKey] = bin2hex(random_bytes(32));
    }

    return $_SESSION[$sessionKey];
}

function csrfTokenValid(string $sessionKey): bool
{
    $expected = (string) ($_SESSION[$sessionKey] ?? '');
    $provided = (string) ($_POST['_csrf'] ?? '');

    return $expected !== '' && $provided !== '' && hash_equals($expected, $provided);
}

/**
 * Render a clean, standalone error page when an unhandled exception
 * escapes the installer or updater. Provides the full error detail in a
 * copy-pasteable block and directs the user to raise a support ticket.
 *
 * Shared between install.php and updater.php — bin/build inlines this file
 * into both generated tools, so there is exactly one authored copy.
 *
 * $isAuthorized gates the exception message, file/line, and stack trace:
 * updater.php sits behind a token gate on a live production site, and an
 * unauthenticated visitor who merely triggers an exception (a malformed
 * request, a broken config file) must not learn internal paths or stack
 * frames. install.php has no equivalent concept — running it at all is the
 * install wizard's whole pre-auth flow, identical in exposure before and
 * after this change — so it always passes true.
 */
function renderFatalErrorPage(Throwable $e, string $toolLabel, string $toolVersion, string $productName, bool $isAuthorized = true): void
{
    // If headers haven't been sent yet, set a 500 status.
    if (! headers_sent()) {
        http_response_code(500);
        header('Content-Type: text/html; charset=UTF-8');
    }

    $version = htmlspecialchars($toolVersion);
    $toolLabelEsc = htmlspecialchars($toolLabel);
    $productName = htmlspecialchars($productName);
    $phpVersion = htmlspecialchars(PHP_VERSION);
    $timestamp = date('Y-m-d H:i:s T');
    $url = htmlspecialchars($_SERVER['REQUEST_URI'] ?? '');
    $errorClass = htmlspecialchars(get_class($e));
    $line = (int) $e->getLine();

    $message = $isAuthorized
        ? htmlspecialchars($e->getMessage())
        : 'Details are hidden because this session is not authorized. Sign in (or use the recovery token) and reload to see the full report.';
    $file = $isAuthorized ? htmlspecialchars($e->getFile()) : '(hidden)';

    // Build the plain-text error block the user can copy
    $traceText = '(hidden — not authorized)';
    if ($isAuthorized) {
        $traceLines = [];
        foreach ($e->getTrace() as $i => $frame) {
            $f = $frame['file'] ?? '[internal]';
            $l = $frame['line'] ?? 0;
            $fn = ($frame['class'] ?? '').($frame['type'] ?? '').($frame['function'] ?? '');
            $traceLines[] = "#{$i} {$f}:{$l} {$fn}()";
            if ($i >= 9) { // limit trace depth for readability
                $traceLines[] = '... (truncated)';
                break;
            }
        }
        $traceText = implode("\n", $traceLines);
    }

    $copyBlock = htmlspecialchars(
        "=== {$toolLabel} Error ===\n"
        ."Timestamp:  {$timestamp}\n"
        ."URL:        {$url}\n"
        ."Tool:       {$toolLabel} v{$toolVersion}\n"
        ."PHP:        {$phpVersion}\n"
        ."Error:      {$errorClass}\n"
        .'Message:    '.($isAuthorized ? $e->getMessage() : '(hidden — not authorized)')."\n"
        ."File:       {$file}\n"
        .'Line:       '.($isAuthorized ? $line : '(hidden)')."\n"
        ."\nStack Trace:\n{$traceText}\n"
        ."=====================================\n"
    );

    echo <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Error — {$toolLabelEsc}</title>
    <style>
        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
            background: #f8fafc;
            color: #0f172a;
            min-height: 100vh;
            display: flex;
            align-items: flex-start;
            justify-content: center;
            padding: 2rem 1rem;
            line-height: 1.6;
        }
        .wrapper {
            width: 100%;
            max-width: 860px;
        }
        .brand {
            display: flex;
            align-items: center;
            gap: 0.75rem;
            margin-bottom: 1.5rem;
            font-size: 1rem;
            font-weight: 600;
            color: #475569;
        }
        .brand-icon {
            font-size: 1.5rem;
        }
        .card {
            background: #ffffff;
            border: 1.5px solid #fecaca;
            border-radius: 1rem;
            overflow: hidden;
            box-shadow: 0 0 0 4px rgba(239,68,68,0.08), 0 20px 40px rgba(0,0,0,0.08);
        }
        .card-header {
            background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
            padding: 1.5rem 2rem;
            display: flex;
            align-items: center;
            gap: 1rem;
            border-bottom: 1px solid #fecaca;
        }
        .card-header .icon {
            font-size: 2rem;
            flex-shrink: 0;
        }
        .card-header h1 {
            font-size: 1.375rem;
            font-weight: 800;
            color: #991b1b;
            letter-spacing: -0.02em;
        }
        .card-header p {
            font-size: 0.875rem;
            color: #b91c1c;
            margin-top: 0.25rem;
        }
        .card-body {
            padding: 1.75rem 2rem;
        }
        .section-title {
            font-size: 0.75rem;
            font-weight: 700;
            text-transform: uppercase;
            letter-spacing: 0.08em;
            color: #64748b;
            margin-bottom: 0.625rem;
        }
        .meta-grid {
            display: grid;
            grid-template-columns: max-content 1fr;
            gap: 0.375rem 1rem;
            font-size: 0.875rem;
            margin-bottom: 1.5rem;
        }
        .meta-grid dt {
            font-weight: 600;
            color: #475569;
            white-space: nowrap;
        }
        .meta-grid dd {
            color: #0f172a;
            word-break: break-word;
        }
        .error-message {
            background: #fef2f2;
            border: 1px solid #fecaca;
            border-radius: 0.5rem;
            padding: 1rem 1.25rem;
            font-size: 0.9375rem;
            color: #b91c1c;
            font-weight: 600;
            margin-bottom: 1.75rem;
            line-height: 1.5;
        }
        .copy-section {
            margin-bottom: 1.75rem;
        }
        .copy-header {
            display: flex;
            align-items: center;
            justify-content: space-between;
            margin-bottom: 0.625rem;
        }
        .copy-btn {
            background: #f1f5f9;
            border: 1px solid #cbd5e1;
            border-radius: 0.375rem;
            color: #475569;
            font-size: 0.8125rem;
            font-weight: 600;
            padding: 0.375rem 0.875rem;
            cursor: pointer;
            transition: background 0.15s, color 0.15s;
        }
        .copy-btn:hover {
            background: #e2e8f0;
            color: #0f172a;
        }
        .copy-btn.copied {
            background: #dcfce7;
            border-color: #86efac;
            color: #166534;
        }
        .error-block {
            background: #f8fafc;
            border: 1px solid #e2e8f0;
            border-radius: 0.625rem;
            padding: 1rem 1.25rem;
            font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
            font-size: 0.8125rem;
            color: #475569;
            white-space: pre-wrap;
            word-break: break-word;
            max-height: 320px;
            overflow-y: auto;
            line-height: 1.7;
        }
        .support-box {
            background: #eff6ff;
            border: 1.5px solid #bfdbfe;
            border-radius: 0.75rem;
            padding: 1.25rem 1.5rem;
            display: flex;
            align-items: flex-start;
            gap: 1rem;
        }
        .support-box .support-icon {
            font-size: 1.75rem;
            flex-shrink: 0;
            line-height: 1;
            margin-top: 0.125rem;
        }
        .support-box h3 {
            font-size: 1rem;
            font-weight: 700;
            color: #1d4ed8;
            margin-bottom: 0.375rem;
        }
        .support-box p {
            font-size: 0.875rem;
            color: #475569;
            margin-bottom: 0.625rem;
        }
        .support-link {
            display: inline-block;
            background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
            color: #ffffff;
            font-weight: 700;
            font-size: 0.875rem;
            padding: 0.625rem 1.25rem;
            border-radius: 0.5rem;
            text-decoration: none;
            transition: opacity 0.15s;
        }
        .support-link:hover {
            opacity: 0.85;
        }
        .footer {
            text-align: center;
            margin-top: 1.5rem;
            font-size: 0.75rem;
            color: #94a3b8;
        }
        @media (max-width: 600px) {
            .card-body { padding: 1.25rem; }
            .card-header { padding: 1.25rem; }
            .meta-grid { grid-template-columns: 1fr; gap: 0.25rem; }
            .meta-grid dt { margin-top: 0.5rem; }
        }
    </style>
</head>
<body>
    <div class="wrapper">
        <div class="brand">
            <span class="brand-icon">⚙️</span>
            {$toolLabelEsc} v{$version}
        </div>

        <div class="card">
            <div class="card-header">
                <div class="icon">🚨</div>
                <div>
                    <h1>An Unexpected Error Occurred</h1>
                    <p>The {$toolLabelEsc} encountered an unhandled exception and could not continue.</p>
                </div>
            </div>

            <div class="card-body">
                <p class="section-title">Error Details</p>
                <dl class="meta-grid">
                    <dt>Type</dt><dd>{$errorClass}</dd>
                    <dt>File</dt><dd>{$file}</dd>
                    <dt>PHP</dt><dd>{$phpVersion}</dd>
                    <dt>Time</dt><dd>{$timestamp}</dd>
                </dl>

                <div class="error-message">{$message}</div>

                <div class="copy-section">
                    <div class="copy-header">
                        <p class="section-title" style="margin:0;">Full Error Report — Copy This When Raising a Ticket</p>
                        <button class="copy-btn" id="copy-btn" onclick="copyError()">Copy to Clipboard</button>
                    </div>
                    <pre class="error-block" id="error-block">{$copyBlock}</pre>
                </div>

                <div class="support-box">
                    <div class="support-icon">💬</div>
                    <div>
                        <h3>Need Help?</h3>
                        <p>
                            Copy the error report above and raise a support ticket. Our team will help
                            you resolve the issue as quickly as possible.
                        </p>
                        <a href="https://support.powerphpscripts.com" target="_blank" rel="noopener noreferrer" class="support-link">
                            Raise a Support Ticket &rarr;
                        </a>
                    </div>
                </div>
            </div>
        </div>

        <div class="footer">
            <div style="font-size:.82rem; font-weight:700; color:#475569;">{$productName}</div>
            <div>{$toolLabelEsc} v{$version} &mdash; <a href="https://support.powerphpscripts.com" target="_blank" rel="noopener noreferrer" style="color:#334155;">support.powerphpscripts.com</a></div>
        </div>
    </div>

    <script>
        function copyError() {
            var text = document.getElementById('error-block').textContent;
            var btn  = document.getElementById('copy-btn');
            if (navigator.clipboard) {
                navigator.clipboard.writeText(text).then(function() {
                    btn.textContent = 'Copied!';
                    btn.classList.add('copied');
                    setTimeout(function() {
                        btn.textContent = 'Copy to Clipboard';
                        btn.classList.remove('copied');
                    }, 2500);
                }).catch(function() { fallbackCopy(text, btn); });
            } else {
                fallbackCopy(text, btn);
            }
        }
        function fallbackCopy(text, btn) {
            var ta = document.createElement('textarea');
            ta.value = text;
            ta.style.position = 'fixed';
            ta.style.opacity  = '0';
            document.body.appendChild(ta);
            ta.select();
            try {
                document.execCommand('copy');
                btn.textContent = 'Copied!';
                btn.classList.add('copied');
                setTimeout(function() {
                    btn.textContent = 'Copy to Clipboard';
                    btn.classList.remove('copied');
                }, 2500);
            } catch(e) {}
            document.body.removeChild(ta);
        }
    </script>
</body>
</html>
HTML;
}


/**
 * Small dependency-free helpers shared between the installer and updater.
 */
trait ProvidesUtilities
{
    /**
     * Test writability by actually creating and deleting a temp file.
     * More trustworthy than is_writable(), which can report false
     * positives under ACLs, disk quotas, or open_basedir restrictions.
     */
    private function verifyWritableByTest(string $dir): bool
    {
        if (! is_dir($dir)) {
            return false;
        }

        $testFile = rtrim($dir, '/').'/.install-write-test-'.uniqid();
        if (@file_put_contents($testFile, 'test') === false) {
            return false;
        }
        @unlink($testFile);

        return true;
    }

    /**
     * Deliberately a separate implementation from
     * PackageBuildCommand::formatBytes() (that one is Composer-autoloaded
     * CLI code with its own pinned output format) — this one must stay
     * dependency-free so bin/build can inline it into the standalone
     * installer/updater, and formats for the wizard UI rather than a CLI
     * summary table.
     */
    private function formatBytes(int $bytes): string
    {
        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
        $i = 0;
        $value = (float) $bytes;
        while ($value >= 1024 && $i < count($units) - 1) {
            $value /= 1024;
            $i++;
        }

        return round($value, $value >= 100 ? 0 : 1).' '.$units[$i];
    }

    /**
     * Whether a submitted port string is a valid TCP port number. Used for
     * both db_port and mail_port on the installer's wizard forms — an
     * interior newline or non-numeric garbage in either would otherwise be
     * written straight into .env unescaped and unquoted, corrupting the file
     * or being silently misinterpreted.
     */
    private function isValidPort(string $port): bool
    {
        return ctype_digit($port) && (int) $port >= 1 && (int) $port <= 65535;
    }
}


/**
 * Page chrome shared between the installer and updater: the HTML shell
 * (with theme handling, the page-transition loader, and shared JS helpers),
 * status icons, and the error list. The composing class must provide:
 *
 *   - toolLabel(): string      e.g. 'Application Installer'
 *   - toolVersion(): string    the generated tool's version
 *   - renderHeaderStrip(string $title, int $currentStep): string
 */
trait RendersChrome
{
    /**
     * Inline SVG status icons (check / x / warning triangle) used in place
     * of emoji, which render inconsistently across OS/browser emoji fonts.
     * All use currentColor so they inherit whatever text-color class wraps
     * them.
     *
     * Size is applied via explicit width/height attributes rather than a
     * class, since these SVGs have no intrinsic size (only a viewBox) —
     * without one a browser falls back to its default SVG size (often
     * ~300x150), rendering the icon comically oversized.
     */
    private function statusIcon(string $type, int $size = 20): string
    {
        return match ($type) {
            'check' => "<svg width=\"{$size}\" height=\"{$size}\" class=\"inline-block\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"3\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M4.5 12.75l6 6 9-13.5\" /></svg>",
            'x' => "<svg width=\"{$size}\" height=\"{$size}\" class=\"inline-block\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"3\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" /></svg>",
            'warning' => "<svg width=\"{$size}\" height=\"{$size}\" class=\"inline-block\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"2\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z\" /></svg>",
            default => '',
        };
    }

    private function renderErrors(): string
    {
        if (empty($this->errors)) {
            return '';
        }

        $html = '<div class="h-errors"><ul>';
        foreach ($this->errors as $error) {
            $html .= '<li>'.htmlspecialchars($error).'</li>';
        }
        $html .= '</ul></div>';

        return $html;
    }

    /**
     * Hidden input carrying the per-session CSRF token, for every plain
     * <form method="POST"> — the fetch()-driven AJAX calls instead use the
     * shared fetchWithCsrf() JS helper (see renderLayout()).
     */
    private function csrfField(): string
    {
        return '<input type="hidden" name="_csrf" value="'.htmlspecialchars(csrfToken('csrf_token')).'">';
    }

    private function renderLayout(string $title, string $content, int $currentStep): void
    {
        $headerStrip = $this->renderHeaderStrip($title, $currentStep);
        $productName = htmlspecialchars(APP_NAME);
        $toolLabel = htmlspecialchars($this->toolLabel());
        $version = htmlspecialchars($this->toolVersion());

        // Exposed globally so per-step inline scripts (DB/mail connection
        // tests, task list, etc.) can build status HTML without duplicating
        // the SVG markup — same source as statusIcon() above. 'small' variants
        // are pre-sized here rather than shrunk client-side, so callers never
        // depend on the exact class string statusIcon() happens to emit.
        $iconsJson = json_encode([
            'check' => $this->statusIcon('check'),
            'x' => $this->statusIcon('x'),
            'warning' => $this->statusIcon('warning'),
            'checkSmall' => $this->statusIcon('check', 14),
            'xSmall' => $this->statusIcon('x', 14),
        ]);

        // Exposed globally so every fetch()-driven AJAX call (task runners,
        // connection tests, upload chunks) can carry proof it originated
        // from a page this session actually rendered, not a cross-site
        // request riding the session cookie. Plain <form> POSTs get their
        // own hidden input at the call site instead of relying on this.
        $csrfToken = json_encode(csrfToken('csrf_token'));

        echo <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{$toolLabel} - {$title}</title>
    <style>
        :root {
    --h-bg: #faf6f1;
    --h-surface: #ffffff;
    --h-surface-2: #fffdfb;
    --h-border: #e8ddd0;
    --h-border-soft: #f0e6d9;
    --h-ink: #2b2420;
    --h-mute: #8a7b6c;
    --h-accent: #c4602f;
    --h-accent-hover: #ad5228;
    --h-accent-soft: #f3e2d3;
    --h-accent-soft-border: #e6c9ab;
    --h-good: #3f7a5c;
    --h-good-soft: #e1efe6;
    --h-good-soft-border: #bfdccb;
    --h-bad: #b8493b;
    --h-bad-soft: #f8e6e2;
    --h-bad-soft-border: #eabdb2;
    --h-warn: #a97516;
    --h-warn-soft: #faf0dc;
    --h-warn-soft-border: #edd6a0;
    --h-shadow-accent: 196, 96, 47;
    --h-shadow-ink: 43, 36, 32;
}
:root[data-theme="dark"] {
    --h-bg: #1c1815;
    --h-surface: #262019;
    --h-surface-2: #211c17;
    --h-border: #3c332a;
    --h-border-soft: #332b23;
    --h-ink: #f3ece3;
    --h-mute: #a1917e;
    --h-accent: #e08a5f;
    --h-accent-hover: #e79d78;
    --h-accent-soft: rgba(196, 96, 47, 0.18);
    --h-accent-soft-border: rgba(196, 96, 47, 0.4);
    --h-good: #6fbb92;
    --h-good-soft: rgba(63, 122, 92, 0.18);
    --h-good-soft-border: rgba(63, 122, 92, 0.4);
    --h-bad: #e08072;
    --h-bad-soft: rgba(184, 73, 59, 0.18);
    --h-bad-soft-border: rgba(184, 73, 59, 0.4);
    --h-warn: #e0b658;
    --h-warn-soft: rgba(169, 117, 22, 0.18);
    --h-warn-soft-border: rgba(169, 117, 22, 0.4);
}
@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
        --h-bg: #1c1815;
        --h-surface: #262019;
        --h-surface-2: #211c17;
        --h-border: #3c332a;
        --h-border-soft: #332b23;
        --h-ink: #f3ece3;
        --h-mute: #a1917e;
        --h-accent: #e08a5f;
        --h-accent-hover: #e79d78;
        --h-accent-soft: rgba(196, 96, 47, 0.18);
        --h-accent-soft-border: rgba(196, 96, 47, 0.4);
        --h-good: #6fbb92;
        --h-good-soft: rgba(63, 122, 92, 0.18);
        --h-good-soft-border: rgba(63, 122, 92, 0.4);
        --h-bad: #e08072;
        --h-bad-soft: rgba(184, 73, 59, 0.18);
        --h-bad-soft-border: rgba(184, 73, 59, 0.4);
        --h-warn: #e0b658;
        --h-warn-soft: rgba(169, 117, 22, 0.18);
        --h-warn-soft-border: rgba(169, 117, 22, 0.4);
    }
}
* { box-sizing: border-box; }
body.h-body {
    margin: 0;
    background: var(--h-bg);
    color: var(--h-ink);
    font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    transition: background-color .2s ease, color .2s ease;
}
.h-shell { max-width: 960px; margin: 0 auto; padding: 40px 24px 64px; }
.h-topbar { display: flex; justify-content: flex-end; margin-bottom: 8px; }
.h-theme-btn {
    width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
    display: flex; align-items: center; justify-content: center;
    background: var(--h-surface); border: 1px solid var(--h-border);
    color: var(--h-mute); cursor: pointer; font-size: 14px;
}
.h-theme-btn:hover { border-color: var(--h-accent-soft-border); }
.h-strip {
    display: flex; align-items: center; gap: 16px;
    padding: 14px 20px; margin-bottom: 16px;
    background: var(--h-surface); border: 1px solid var(--h-border); border-radius: 16px;
}
.h-strip-icon {
    width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
    background: var(--h-accent-soft); border: 1px solid var(--h-accent-soft-border);
    display: flex; align-items: center; justify-content: center; font-size: 18px;
}
.h-strip-text { flex: 1; min-width: 0; }
.h-strip-text h1 {
    font-size: 1.05rem; font-weight: 700; letter-spacing: -0.01em; margin: 0;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.h-strip-text p {
    color: var(--h-mute); font-size: .78rem; line-height: 1.4; margin: 2px 0 0;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.h-strip-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; }
.h-strip-top { display: flex; align-items: center; gap: 12px; }
.h-strip-dots { display: flex; align-items: center; gap: 4px; }
.h-sdot2 { width: 6px; height: 6px; border-radius: 50%; background: var(--h-border); flex-shrink: 0; }
.h-sdot2.done { background: var(--h-accent); }
.h-sdot2.now { background: var(--h-accent); width: 8px; height: 8px; box-shadow: 0 0 0 3px var(--h-accent-soft); }
.h-strip-label { font-size: .72rem; font-weight: 700; color: var(--h-mute); white-space: nowrap; }
@media (max-width: 640px) {
    .h-strip { flex-wrap: wrap; }
    .h-strip-text h1, .h-strip-text p { white-space: normal; }
    .h-strip-side { flex-direction: row-reverse; align-items: center; width: 100%; justify-content: space-between; }
    .h-strip-top { flex-direction: row-reverse; }
}
.h-card {
    background: var(--h-surface); border: 1px solid var(--h-border); border-radius: 22px;
    padding: 28px; box-shadow: 0 1px 2px rgba(var(--h-shadow-ink), .04);
}
.h-card-header {
    display: flex; align-items: center; gap: 12px; margin: -28px -28px 24px; padding: 20px 28px;
    background: var(--h-surface-2); border-bottom: 1px solid var(--h-border-soft);
    border-radius: 22px 22px 0 0;
}
.h-card-header .icon {
    width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
    background: var(--h-accent-soft); display: flex; align-items: center; justify-content: center; font-size: 18px;
}
.h-card-header h3 { font-size: 1rem; font-weight: 700; margin: 0; }
.h-card-header p { font-size: .8rem; color: var(--h-mute); margin: 2px 0 0; }
.h-lede { color: var(--h-mute); font-size: .9rem; line-height: 1.6; margin: 0 0 20px; }
.h-section-title {
    display: flex; align-items: center; gap: 9px; font-size: 1.02rem; font-weight: 700;
    margin: 0 0 14px;
}
.h-section-title .bump { width: 5px; height: 20px; border-radius: 3px; background: var(--h-accent); flex-shrink: 0; }
.h-field { margin-bottom: 16px; }
.h-field label { display: block; font-size: .8rem; font-weight: 700; color: var(--h-ink); margin-bottom: 6px; }
.h-field label .req { color: var(--h-accent); }
.h-field .wrap { position: relative; }
.h-field input, .h-field select {
    width: 100%; padding: 11px 14px; border-radius: 12px; border: 1.5px solid var(--h-border);
    background: var(--h-surface-2); color: var(--h-ink); font: inherit; font-size: .88rem;
    transition: border-color .15s ease;
}
.h-field input::placeholder { color: var(--h-mute); opacity: .7; }
.h-field input:focus, .h-field select:focus { outline: none; border-color: var(--h-accent); }
.h-field .hint { font-size: .74rem; color: var(--h-mute); margin: 6px 0 0; }
.h-field select { cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%238a7b6c' stroke-width='1.6'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 18px; padding-right: 38px; }
.h-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 16px; }
@media (max-width: 520px) { .h-grid2 { grid-template-columns: 1fr; } }
.h-radio {
    display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; border-radius: 16px;
    border: 1.5px solid var(--h-border); cursor: pointer; background: var(--h-surface-2); margin-bottom: 10px;
    transition: border-color .15s ease, background-color .15s ease;
}
.h-radio:hover { border-color: var(--h-accent-soft-border); }
.h-radio input { margin-top: 2px; accent-color: var(--h-accent); width: 17px; height: 17px; flex-shrink: 0; }
.h-radio .t { font-weight: 700; font-size: .9rem; }
.h-radio .d { font-size: .8rem; color: var(--h-mute); margin-top: 3px; line-height: 1.5; }
.h-checkline {
    display: flex; align-items: flex-start; gap: 12px; padding: 16px 18px; border-radius: 16px;
    background: var(--h-accent-soft); border: 1px solid var(--h-accent-soft-border); margin-bottom: 20px;
}
.h-checkline input { margin-top: 3px; accent-color: var(--h-accent); width: 18px; height: 18px; flex-shrink: 0; cursor: pointer; }
.h-checkline .t { font-weight: 700; font-size: .9rem; }
.h-checkline .d { font-size: .8rem; color: var(--h-mute); margin-top: 3px; }
.h-eula {
    background: var(--h-surface-2); border: 1px solid var(--h-border-soft); border-radius: 18px;
    overflow: hidden; margin-bottom: 18px;
}
.h-eula-head { padding: 16px 20px; border-bottom: 1px solid var(--h-border-soft); display: flex; align-items: center; gap: 12px; }
.h-eula-head .icon { font-size: 20px; }
.h-eula-head h3 { font-size: .95rem; font-weight: 700; margin: 0; }
.h-eula-head p { font-size: .78rem; color: var(--h-mute); margin: 2px 0 0; }
.h-eula-body { padding: 20px; max-height: 280px; overflow-y: auto; font-size: .82rem; line-height: 1.65; color: var(--h-ink); white-space: pre-wrap; }
.h-eula-body::-webkit-scrollbar { width: 8px; }
.h-eula-body::-webkit-scrollbar-thumb { background: var(--h-border); border-radius: 8px; }
.h-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 24px; padding-top: 22px; border-top: 1px solid var(--h-border-soft); }
.h-actions.end { justify-content: flex-end; }
.h-actions-group { display: flex; gap: 10px; }
.h-btn {
    font: inherit; font-size: .85rem; font-weight: 700; padding: 12px 22px; border-radius: 999px;
    cursor: pointer; border: none; display: inline-flex; align-items: center; gap: 8px;
    text-decoration: none; transition: transform .12s ease, box-shadow .12s ease, opacity .12s ease;
}
.h-btn:disabled { opacity: .5; cursor: not-allowed; }
.h-btn-ghost { background: var(--h-surface); border: 1.5px solid var(--h-border); color: var(--h-mute); }
.h-btn-ghost:hover:not(:disabled) { border-color: var(--h-accent-soft-border); color: var(--h-ink); }
.h-btn-primary { background: var(--h-accent); color: #fff; box-shadow: 0 8px 20px -8px rgba(var(--h-shadow-accent), .55); }
.h-btn-primary:hover:not(:disabled) { background: var(--h-accent-hover); transform: translateY(-1px); }
.h-btn-tint { background: var(--h-accent-soft); border: 1.5px solid var(--h-accent-soft-border); color: var(--h-accent); }
.h-btn-tint:hover:not(:disabled) { background: var(--h-accent-soft-border); }
.h-btn-good { background: var(--h-good); color: #fff; box-shadow: 0 8px 20px -8px rgba(63, 122, 92, .5); }
.h-btn-good:hover:not(:disabled) { transform: translateY(-1px); }
.h-alert { display: flex; align-items: flex-start; gap: 12px; padding: 16px 18px; border-radius: 16px; margin-bottom: 20px; }
.h-alert .icon { width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
.h-alert .t { font-weight: 700; font-size: .88rem; margin: 0; }
.h-alert .d { font-size: .8rem; margin: 2px 0 0; line-height: 1.5; }
.h-alert .d code { background: rgba(var(--h-shadow-ink), .08); padding: 2px 6px; border-radius: 6px; font-size: .85em; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.h-alert-bad { background: var(--h-bad-soft); border: 1px solid var(--h-bad-soft-border); }
.h-alert-bad .icon { background: var(--h-bad-soft-border); color: var(--h-bad); }
.h-alert-bad .t { color: var(--h-bad); }
.h-alert-bad .d { color: var(--h-ink); }
.h-alert-good { background: var(--h-good-soft); border: 1px solid var(--h-good-soft-border); }
.h-alert-good .icon { background: var(--h-good-soft-border); color: var(--h-good); }
.h-alert-good .t { color: var(--h-good); }
.h-alert-good .d { color: var(--h-ink); }
.h-alert-warn { background: var(--h-warn-soft); border: 1px solid var(--h-warn-soft-border); }
.h-alert-warn .icon { background: var(--h-warn-soft-border); color: var(--h-warn); }
.h-alert-warn .t { color: var(--h-warn); }
.h-alert-warn .d { color: var(--h-ink); }
.h-errors { background: var(--h-bad-soft); border: 1px solid var(--h-bad-soft-border); border-radius: 16px; padding: 14px 18px; margin-bottom: 20px; }
.h-errors ul { margin: 0; padding: 0; list-style: none; }
.h-errors li { display: flex; align-items: flex-start; gap: 8px; color: var(--h-bad); font-size: .84rem; font-weight: 600; padding: 3px 0; }
.h-errors li::before { content: '•'; }
.h-hidden { display: none !important; }
.h-reqrow {
    display: flex; align-items: center; gap: 14px; padding: 14px 16px; border-radius: 16px;
    border: 1.5px solid var(--h-border); background: var(--h-surface); margin-bottom: 10px;
}
.h-reqrow .icon { width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
.h-reqrow .body { flex: 1; min-width: 0; }
.h-reqrow .t { font-weight: 700; font-size: .86rem; margin: 0; }
.h-reqrow .d { font-size: .76rem; color: var(--h-mute); margin: 2px 0 0; }
.h-reqrow .badge { font-size: .68rem; font-weight: 800; padding: 4px 10px; border-radius: 999px; flex-shrink: 0; text-transform: uppercase; letter-spacing: .03em; }
.h-reqrow.good { border-color: var(--h-good-soft-border); }
.h-reqrow.good .icon { background: var(--h-good-soft); color: var(--h-good); }
.h-reqrow.good .badge { background: var(--h-good-soft); color: var(--h-good); }
.h-reqrow.bad { border-color: var(--h-bad-soft-border); background: var(--h-bad-soft); }
.h-reqrow.bad .icon { background: var(--h-bad-soft-border); color: var(--h-bad); }
.h-reqrow.bad .badge { background: var(--h-bad-soft-border); color: var(--h-bad); }
.h-reqrow.warn { border-color: var(--h-warn-soft-border); background: var(--h-warn-soft); }
.h-reqrow.warn .icon { background: var(--h-warn-soft-border); color: var(--h-warn); }
.h-reqrow.warn .badge { background: var(--h-warn-soft-border); color: var(--h-warn); }
.h-progress-card { border-radius: 22px; overflow: hidden; border: 1px solid var(--h-border); margin-bottom: 20px; background: var(--h-surface); }
.h-progress-card-head { background: var(--h-accent); padding: 18px 24px; display: flex; align-items: center; justify-content: space-between; color: #fff; }
.h-progress-card-head .left { display: flex; align-items: center; gap: 12px; }
.h-progress-card-head .icn { width: 34px; height: 34px; border-radius: 10px; background: rgba(255,255,255,.2); display: flex; align-items: center; justify-content: center; font-size: 17px; }
.h-progress-card-head h3 { margin: 0; font-size: .95rem; font-weight: 700; }
.h-progress-card-head .sub { font-size: .76rem; opacity: .85; margin: 1px 0 0; }
.h-progress-card-head .pct { font-size: 1.4rem; font-weight: 800; font-variant-numeric: tabular-nums; }
.h-progress-card-head .pct-label { font-size: .74rem; opacity: .85; text-align: right; }
.h-track { height: 6px; background: var(--h-border-soft); }
.h-track-fill { height: 100%; background: var(--h-accent); transition: width .4s ease; }
.h-tasklist { padding: 18px; display: flex; flex-direction: column; gap: 8px; }
.h-task {
    display: flex; align-items: center; gap: 14px; padding: 12px 14px; border-radius: 14px;
    border: 1px solid var(--h-border-soft); background: var(--h-surface); transition: background-color .15s ease, border-color .15s ease;
}
.h-task .icon { width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 15px; background: var(--h-border-soft); color: var(--h-mute); }
.h-task .label { flex: 1; font-size: .85rem; font-weight: 600; color: var(--h-ink); }
.h-task .msg { font-size: .72rem; color: var(--h-bad); text-align: right; font-weight: 600; max-width: 45%; }
.h-task.active { background: var(--h-accent-soft); border-color: var(--h-accent-soft-border); }
.h-task.active .icon { background: var(--h-accent-soft-border); color: var(--h-accent); animation: h-pulse 1.4s ease-in-out infinite; }
.h-task.active .label { color: var(--h-accent); }
.h-task.done .icon { background: var(--h-good-soft); color: var(--h-good); }
.h-task.done .label { color: var(--h-good); }
.h-task.error .icon { background: var(--h-bad-soft); color: var(--h-bad); }
.h-task.error .label { color: var(--h-bad); }
.h-spin { animation: h-spin 1s linear infinite; }
@keyframes h-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .h-spin { animation-duration: 3s; } }
.h-bounce { animation: h-bounce 1.6s ease-in-out infinite; }
@keyframes h-bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
@media (prefers-reduced-motion: reduce) { .h-bounce { animation: none; } }
.h-pulse { animation: h-pulse 1.4s ease-in-out infinite; }
@keyframes h-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.15); } }
@media (prefers-reduced-motion: reduce) { .h-pulse { animation: none; } }
.h-code-block {
    background: var(--h-ink); color: #f3ece3; border-radius: 14px; padding: 16px 18px;
    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: .8rem;
    word-break: break-all; margin-bottom: 14px;
}
.h-code-block.accent { color: #f0b892; }
.h-details { background: var(--h-surface); border: 1px solid var(--h-border); border-radius: 16px; overflow: hidden; margin-bottom: 10px; }
.h-details summary { padding: 16px 20px; font-weight: 700; font-size: .88rem; cursor: pointer; display: flex; align-items: center; justify-content: between; list-style: none; }
.h-details summary::-webkit-details-marker { display: none; }
.h-details summary::after { content: '+'; margin-left: auto; color: var(--h-mute); font-size: 1.2rem; }
.h-details[open] summary::after { content: '−'; }
.h-details ol { margin: 0; padding: 0 20px 18px 38px; font-size: .82rem; color: var(--h-ink); line-height: 1.7; }
.h-details code { background: var(--h-border-soft); padding: 2px 7px; border-radius: 6px; font-size: .78em; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.h-success-icon {
    width: 76px; height: 76px; border-radius: 50%; margin: 0 auto 20px; display: flex; align-items: center; justify-content: center;
    background: var(--h-good); color: #fff; box-shadow: 0 12px 30px -10px rgba(63, 122, 92, .5);
}
.h-success-head { text-align: center; margin-bottom: 24px; }
.h-success-head h2 { font-size: 1.5rem; font-weight: 800; color: var(--h-good); margin: 0 0 6px; }
.h-success-head p { color: var(--h-mute); font-size: .9rem; margin: 0; }
.h-credentials { background: var(--h-accent-soft); border: 1px solid var(--h-accent-soft-border); border-radius: 18px; padding: 22px; margin-bottom: 20px; }
.h-credentials .row { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
.h-credentials .row .icon { font-size: 17px; width: 22px; text-align: center; flex-shrink: 0; }
.h-credentials .row .t { font-size: .78rem; font-weight: 700; color: var(--h-accent); margin: 0; }
.h-credentials .row a, .h-credentials .row .v { color: var(--h-ink); font-weight: 600; font-size: .88rem; text-decoration: none; }
.h-credentials .row a:hover { text-decoration: underline; }
.h-strength-bars { display: flex; gap: 4px; margin-top: 8px; }
.h-strength-bars div { height: 5px; flex: 1; border-radius: 999px; background: var(--h-border); transition: background-color .15s ease; }
.h-footer { text-align: center; margin-top: 20px; font-size: .72rem; color: var(--h-mute); }
.h-match-msg { font-size: .74rem; margin-top: 6px; display: flex; align-items: center; gap: 5px; font-weight: 600; }
.h-match-msg.good { color: var(--h-good); }
.h-match-msg.bad { color: var(--h-bad); }
.h-page-loader {
    position: fixed; inset: 0; z-index: 999;
    display: flex; align-items: center; justify-content: center;
    background: rgba(var(--h-shadow-ink), .12);
    backdrop-filter: blur(1px);
    opacity: 0; visibility: hidden;
    transition: opacity .18s ease;
}
.h-page-loader.visible { opacity: 1; visibility: visible; transition-delay: 0s, 0s; }
.h-page-loader-spinner {
    width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0;
    border: 3.5px solid var(--h-accent-soft); border-top-color: var(--h-accent);
    animation: h-spin .7s linear infinite;
    box-shadow: 0 8px 24px -8px rgba(var(--h-shadow-ink), .3);
}
@media (prefers-reduced-motion: reduce) { .h-page-loader-spinner { animation-duration: 2s; } }
    </style>
    <script>
        window.INSTALLER_ICONS = {$iconsJson};
        window.CSRF_TOKEN = {$csrfToken};

        // POSTs the CSRF token in the request body (never the query string,
        // which would leak it into access/proxy logs and browser history) —
        // used by every AJAX call below and in per-step inline scripts, so a
        // POST to install.php/updater.php can't be driven from a cross-site
        // page riding the session cookie alone. Reuses the caller's FormData
        // body when given one (upload endpoints already send one), otherwise
        // creates one so a body-less POST still carries the token.
        function fetchWithCsrf(url, options) {
            options = options || {};
            // Always POST — the token travels in a FormData body, and a GET
            // with a body is invalid anyway, so callers never need to say it.
            options.method = options.method || 'POST';
            var body = options.body instanceof FormData ? options.body : new FormData();
            body.append('_csrf', window.CSRF_TOKEN);
            options.body = body;

            return fetch(url, options);
        }

        (function() {
            var stored = localStorage.getItem('installer-theme');
            var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
            var isDark = stored ? stored === 'dark' : prefersDark;
            document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
        })();

        // Shared "copy to clipboard" behavior for copy buttons (app key,
        // cron command, access token, …) — one definition so all stay in
        // lockstep instead of carrying their own copies.
        function initCopyButton(buttonId, sourceId) {
            var btn = document.getElementById(buttonId);
            var codeEl = document.getElementById(sourceId);
            var originalLabel = btn.textContent;

            function showCopied() {
                btn.textContent = '✓ Copied!';
                setTimeout(function() { btn.textContent = originalLabel; }, 2000);
            }

            function selectFallback() {
                var range = document.createRange();
                range.selectNodeContents(codeEl);
                var selection = window.getSelection();
                selection.removeAllRanges();
                selection.addRange(range);
                btn.textContent = 'Press Ctrl+C / Cmd+C to copy';
                setTimeout(function() { btn.textContent = originalLabel; }, 3000);
            }

            btn.addEventListener('click', function() {
                if (!navigator.clipboard) {
                    selectFallback();
                    return;
                }
                navigator.clipboard.writeText(codeEl.textContent).then(showCopied).catch(selectFallback);
            });
        }

        // Shared "Test Connection" AJAX handler for form-driven probes —
        // identical request/response contract (POST the form to the step's
        // ?ajax=... endpoint, expect {success, message}), differing only in
        // which form/button/result element and endpoint to use, and the
        // fallback error copy.
        function initTestConnectionButton(options) {
            var btn = document.getElementById(options.buttonId);
            var resultDiv = document.getElementById(options.resultId);
            var form = document.getElementById(options.formId);
            var originalLabel = btn.innerHTML;

            btn.addEventListener('click', function() {
                btn.disabled = true;
                btn.innerHTML = '<span class="h-spin">⏳</span> Testing...';
                resultDiv.classList.add('h-hidden');

                var formData = new FormData(form);

                fetchWithCsrf(options.endpoint, { body: formData })
                    .then(function(r) { return r.json(); })
                    .then(function(data) {
                        resultDiv.classList.remove('h-hidden');
                        resultDiv.className = data.success ? 'h-alert h-alert-good' : 'h-alert h-alert-bad';
                        resultDiv.innerHTML = '<div class="icon">' + (data.success ? window.INSTALLER_ICONS.check : window.INSTALLER_ICONS.x) + '</div>' +
                            '<div><p class="t">' + (data.success ? 'Connection Successful' : 'Connection Failed') + '</p><p class="d">' + data.message + '</p></div>';
                        btn.disabled = false;
                        btn.innerHTML = originalLabel;
                    })
                    .catch(function() {
                        resultDiv.classList.remove('h-hidden');
                        resultDiv.className = 'h-alert h-alert-bad';
                        resultDiv.innerHTML = '<div class="icon">' + window.INSTALLER_ICONS.x + '</div><div><p class="t">Error</p><p class="d">' + options.errorMessage + '</p></div>';
                        btn.disabled = false;
                        btn.innerHTML = originalLabel;
                    });
            });
        }
    </script>
</head>
<body class="h-body">
    <div class="h-shell">
        {$headerStrip}

        <div class="h-card">
            {$content}
        </div>

        <div class="h-footer">
            <div style="font-size:.82rem; font-weight:700; color:var(--h-ink);">{$productName}</div>
            <div>{$toolLabel} v{$version}</div>
        </div>
    </div>

    <!-- Page Transition Overlay -->
    <div class="h-page-loader" id="page-loader">
        <div class="h-page-loader-spinner"></div>
    </div>

    <script>
        (function() {
            var btn = document.getElementById('theme-toggle');
            var icon = document.getElementById('theme-toggle-icon');

            function sync() {
                var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
                icon.textContent = isDark ? '☀️' : '🌙';
            }

            btn.addEventListener('click', function() {
                var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
                document.documentElement.setAttribute('data-theme', isDark ? 'light' : 'dark');
                localStorage.setItem('installer-theme', isDark ? 'light' : 'dark');
                sync();
            });

            sync();
        })();

        // Shows a full-page spinner while the browser waits on the next
        // page load, so multi-second server-side work (e.g. the mod_rewrite
        // self-request and filesystem checks on the requirements step)
        // doesn't look like a frozen/broken page. Only fires for plain
        // navigations — AJAX calls (data-ajax) and buttons that toggle
        // in-page UI (type=button) are excluded since they don't leave the
        // page.
        (function() {
            var loader = document.getElementById('page-loader');

            function showLoader() {
                loader.classList.add('visible');
            }

            document.addEventListener('submit', function(e) {
                var form = e.target;
                if (form.tagName === 'FORM' && !form.hasAttribute('data-ajax')) {
                    showLoader();
                }
            });

            document.addEventListener('click', function(e) {
                var link = e.target.closest('a[href]');
                if (!link) {
                    return;
                }
                if (link.target === '_blank' || link.hasAttribute('download') || e.metaKey || e.ctrlKey || e.shiftKey) {
                    return;
                }
                showLoader();
            });

            // Restores the page if the browser serves it from bfcache on
            // back/forward nav, where the loader would otherwise still be
            // showing from the click that navigated away. event.persisted
            // is true only for bfcache restores, not normal loads — without
            // that check this would hide the loader on every fresh page
            // load, immediately after it appears.
            window.addEventListener('pageshow', function(e) {
                if (e.persisted) {
                    loader.classList.remove('visible');
                }
            });
        })();
    </script>
</body>
</html>
HTML;
    }

    private function renderStripDot(bool $done, bool $now): string
    {
        $state = $done ? 'done' : ($now ? 'now' : '');

        return "<div class=\"h-sdot2 {$state}\"></div>";
    }

    /**
     * Guard pages (e.g. "already installed", the updater's token gate) have
     * no title/progress to show, but still need the theme toggle available.
     */
    private function renderThemeToggleOnly(): string
    {
        return <<<'HTML'
        <div class="h-topbar">
            <button type="button" id="theme-toggle" aria-label="Toggle dark mode" class="h-theme-btn">
                <span id="theme-toggle-icon">🌙</span>
            </button>
        </div>
        HTML;
    }
}


/**
 * Resolves everything the updater needs to know about the application it
 * lives inside — paths, the parsed .env, the resolved config/updates.php,
 * and the currently installed version — all without booting the framework.
 */
trait BootsUpdaterEnvironment
{
    /**
     * Wall-clock budget per batched backup/restore/extract request, in
     * seconds. Lives here (rather than in ManagesBackups or RunsUpdateTasks,
     * both of which read it) since every trait assembled into the Updater
     * class already depends on this one for paths/environment — a neutral
     * home that doesn't make one batching trait implicitly depend on
     * another's internals.
     */
    private const BATCH_BUDGET_SECONDS = 8;

    private function appRoot(): string
    {
        return UPDATER_APP_ROOT;
    }

    private function updaterStorageDir(): string
    {
        // Same value the Laravel package reads (config/updates.php
        // updater.storage_dir), so the handoff token and results are always
        // written and read from the one location both sides agree on.
        $dir = (string) ($this->updatesConfig()['updater']['storage_dir'] ?? 'storage/app/updater');

        return $this->appRoot().'/'.ltrim($dir, '/');
    }

    private function backupsDir(): string
    {
        return $this->updaterStorageDir().'/backups';
    }

    private function resultsDir(): string
    {
        return $this->updaterStorageDir().'/results';
    }

    /**
     * Marker recording that an update run is underway (or was interrupted
     * partway through). Written by taskPrepare() with the target version,
     * removed by taskFinalize() and a completed restore. Its presence on
     * disk — unlike anything session-bound — survives a lost browser
     * session, which is exactly the scenario it exists for: extraction may
     * have already bumped config/app.php's version literal before the run
     * died, and this marker is what lets ValidatesUpdatePackage accept the
     * same-version package again to finish the job.
     */
    private function updateInProgressFile(): string
    {
        return $this->updaterStorageDir().'/update-in-progress.json';
    }

    private function writeUpdateInProgressMarker(?string $versionTo): void
    {
        @file_put_contents($this->updateInProgressFile(), json_encode([
            'version_to' => $versionTo,
            'started_at' => date('c'),
        ]));
    }

    private function clearUpdateInProgressMarker(): void
    {
        @unlink($this->updateInProgressFile());
    }

    /**
     * The one authored copy of the update's disk-space heuristic: the
     * uploaded package, its staged inner zip, the pre-update backup, and
     * the extracted files all coexist on disk during a run — roughly 4x the
     * package size. Returns [needed, free] when free space is definitely
     * insufficient, or null when there is enough (or free space cannot be
     * determined, which must not block an update). Shared by the upload
     * gate, the review screen, and the start-update guard so the three can
     * never disagree about whether a run fits.
     *
     * @return array{needed: int, free: int}|null
     */
    private function diskSpaceShortfall(int $packageBytes, string $dir): ?array
    {
        $needed = $packageBytes * 4;
        $free = function_exists('disk_free_space') ? @disk_free_space($dir) : false;

        if ($free === false || $free >= $needed) {
            return null;
        }

        return ['needed' => $needed, 'free' => (int) $free];
    }

    /**
     * The version_to of an interrupted update run, or null when no run is
     * in progress/interrupted.
     */
    private function interruptedUpdateVersion(): ?string
    {
        $file = $this->updateInProgressFile();

        if (! is_file($file)) {
            return null;
        }

        $marker = json_decode((string) file_get_contents($file), true);

        return is_array($marker) && is_string($marker['version_to'] ?? null)
            ? $marker['version_to']
            : null;
    }

    private function ensureUpdaterDirs(): void
    {
        foreach ([$this->updaterStorageDir(), $this->backupsDir(), $this->resultsDir()] as $dir) {
            if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
                throw new RuntimeException("Cannot create {$dir}. Check that storage/app is writable by the web server.");
            }
        }
    }

    /**
     * Parse the application's .env into the global store env() reads from.
     * Deliberately simple (KEY=VALUE, quoted values, # comment lines) —
     * matching the subset the installer itself writes. Values already in
     * the real environment are never overridden, mirroring Dotenv.
     */
    private function loadEnvIntoStore(): void
    {
        $envPath = $this->appRoot().'/.env';

        if (! is_file($envPath) || ! is_readable($envPath)) {
            return;
        }

        foreach (file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
            $line = trim($line);

            if ($line === '' || str_starts_with($line, '#') || ! str_contains($line, '=')) {
                continue;
            }

            [$key, $value] = explode('=', $line, 2);
            $key = trim($key);
            $value = trim($value);

            if ($key === '' || ! preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) {
                continue;
            }

            // Strip a matching pair of surrounding quotes and unescape the
            // sequences the installer's escapeEnvValue() produces.
            if (strlen($value) > 1) {
                $first = $value[0];
                if (($first === '"' || $first === "'") && str_ends_with($value, $first)) {
                    $value = substr($value, 1, -1);
                    if ($first === '"') {
                        $value = str_replace(['\\"', '\\\\'], ['"', '\\'], $value);
                    }
                }
            }

            $GLOBALS['__updater_env'][$key] = $value;
        }
    }

    /**
     * The application's resolved config/updates.php merged over safe
     * defaults, so a missing or partially-broken config file degrades to
     * the same defaults the Laravel package ships with.
     *
     * @return array<string, mixed>
     */
    private function updatesConfig(): array
    {
        if ($this->updatesConfig !== null) {
            return $this->updatesConfig;
        }

        $defaults = [
            'slug' => env('UPDATE_SLUG', APP_FOLDER),
            'updater' => ['storage_dir' => 'storage/app/updater'],
            'protected_paths' => [
                '.env',
                'storage/app/license.key',
                'storage/app/public/',
                'storage/logs/',
                'storage/framework/sessions/',
            ],
            'signing' => ['trusted_keys' => []],
            'backup' => [
                'enabled' => true,
                'keep' => 3,
                'include_vendor' => true,
                'exclude' => [
                    'node_modules',
                    '.git',
                    'storage/framework',
                    'storage/logs',
                    'package',
                    'tests',
                ],
                'database' => [
                    'enabled' => true,
                    'mysqldump_path' => 'mysqldump',
                ],
            ],
        ];

        $configPath = $this->appRoot().'/config/updates.php';
        $fromApp = [];

        if (is_file($configPath) && is_readable($configPath)) {
            try {
                $loaded = require $configPath;
                if (is_array($loaded)) {
                    $fromApp = $loaded;
                }
            } catch (Throwable) {
                // A config file the updater can't parse must not block a
                // recovery-mode update — fall back to defaults.
            }
        }

        return $this->updatesConfig = $this->mergeConfigOverDefaults($defaults, $fromApp);
    }

    /**
     * Deep-merge $overrides over $defaults, but replace list-type values
     * (sequential, numeric-keyed arrays like `protected_paths` or
     * `backup.exclude`) wholesale rather than index-by-index.
     * array_replace_recursive merges lists positionally, so an app config
     * with e.g. 3 protected_paths entries would still inherit index 3+ from
     * the 5-entry default list — an operator can never actually shrink or
     * fully replace a default list, only overwrite its first N entries.
     *
     * @param  array<string, mixed>  $defaults
     * @param  array<string, mixed>  $overrides
     * @return array<string, mixed>
     */
    private function mergeConfigOverDefaults(array $defaults, array $overrides): array
    {
        foreach ($overrides as $key => $value) {
            if (is_array($value) && isset($defaults[$key]) && is_array($defaults[$key]) && ! array_is_list($value)) {
                $defaults[$key] = $this->mergeConfigOverDefaults($defaults[$key], $value);
            } else {
                $defaults[$key] = $value;
            }
        }

        return $defaults;
    }

    private function slug(): string
    {
        $slug = (string) ($this->updatesConfig()['slug'] ?? APP_FOLDER);

        return $slug !== '' ? $slug : APP_FOLDER;
    }

    private function innerZipName(): string
    {
        return $this->slug().'.zip';
    }

    /**
     * @return list<string>
     */
    private function protectedPaths(): array
    {
        return array_values(array_map('strval', (array) ($this->updatesConfig()['protected_paths'] ?? [])));
    }

    /**
     * @return array<string, string>
     */
    private function trustedKeys(): array
    {
        return array_map('strval', (array) ($this->updatesConfig()['signing']['trusted_keys'] ?? []));
    }

    /**
     * @return array<string, mixed>
     */
    private function backupConfig(): array
    {
        return (array) ($this->updatesConfig()['backup'] ?? []);
    }

    /**
     * The currently installed version, parsed from config/app.php's version
     * literal. The updater must not boot the app to ask config('app.version')
     * — parsing the same literal UpdateService::verify() checks keeps the two
     * in agreement.
     */
    private function currentVersion(): ?string
    {
        $configPath = $this->appRoot().'/config/app.php';

        if (! is_file($configPath) || ! is_readable($configPath)) {
            return null;
        }

        $content = (string) file_get_contents($configPath);

        if (preg_match('/[\'"]version[\'"]\s*=>\s*[\'"]([0-9]+\.[0-9]+\.[0-9]+)[\'"]/', $content, $m) === 1) {
            return $m[1];
        }

        return null;
    }

    /**
     * Database connection settings from the parsed .env, for backup/restore.
     *
     * @return array{connection: string, host: string, port: string, database: string, username: string, password: string}
     */
    private function databaseSettings(): array
    {
        return [
            'connection' => (string) env('DB_CONNECTION', 'mysql'),
            'host' => (string) env('DB_HOST', '127.0.0.1'),
            'port' => (string) env('DB_PORT', '3306'),
            'database' => (string) env('DB_DATABASE', ''),
            'username' => (string) env('DB_USERNAME', ''),
            'password' => (string) env('DB_PASSWORD', ''),
        ];
    }

    /**
     * Laravel's maintenance-mode marker. Writing/removing the file directly
     * is what `php artisan down`/`up` do under the hood — no framework boot
     * needed, and it works even when the app can't boot at all.
     */
    private function maintenanceFile(): string
    {
        return $this->appRoot().'/storage/framework/down';
    }

    private function enableMaintenanceMode(): void
    {
        $dir = dirname($this->maintenanceFile());
        if (! is_dir($dir)) {
            @mkdir($dir, 0755, true);
        }

        $payload = json_encode([
            'except' => [],
            'retry' => 60,
            'refresh' => 15,
            'secret' => null,
            'status' => 503,
            'template' => null,
        ]);

        if (@file_put_contents($this->maintenanceFile(), $payload) === false) {
            throw new RuntimeException('Failed to enable maintenance mode. Check that storage/framework is writable.');
        }
    }

    private function disableMaintenanceMode(): void
    {
        if (file_exists($this->maintenanceFile())) {
            @unlink($this->maintenanceFile());
        }
    }
}


trait ChecksUpdateRequirements
{
    /**
     * Environment checks shown on the updater's status screen. Mirrors the
     * installer's requirements step, scoped to what an update actually
     * needs: the zip/sodium extensions, writable application directories,
     * and a parseable current version.
     */
    private function checkUpdateRequirements(): array
    {
        $results = [];

        $results[] = [
            'name' => 'PHP Version >= '.MIN_PHP_VERSION,
            'detail' => 'Current: '.PHP_VERSION,
            'passed' => version_compare(PHP_VERSION, MIN_PHP_VERSION, '>='),
            'critical' => true,
            'group' => 'php',
        ];

        foreach (['zip', 'json'] as $ext) {
            $results[] = [
                'name' => "PHP Extension: {$ext}",
                'detail' => extension_loaded($ext) ? 'Loaded' : 'Not loaded',
                'passed' => extension_loaded($ext),
                'critical' => true,
                'group' => 'php',
            ];
        }

        // Signature verification is fail-closed: when the app config trusts
        // signing keys, a missing sodium extension blocks updates entirely,
        // so surface it as critical in exactly that case.
        $signatureRequired = $this->trustedKeys() !== [];
        $results[] = [
            'name' => 'PHP Extension: sodium'.($signatureRequired ? '' : ' (optional)'),
            'detail' => extension_loaded('sodium')
                ? 'Loaded'
                : ($signatureRequired
                    ? 'Not loaded — this application requires signed updates, which cannot be verified without sodium.'
                    : 'Not loaded — update packages will not be signature-verified.'),
            'passed' => extension_loaded('sodium'),
            'critical' => $signatureRequired,
            'group' => 'php',
        ];

        // Writability, verified by real write tests. The app root receives
        // extracted files, storage/ holds the working area, public/ must
        // accept the refreshed updater.php.
        foreach ([
            'Application Root Writable' => $this->appRoot(),
            'storage/app Writable' => $this->appRoot().'/storage/app',
            'public/ Writable' => $this->appRoot().'/public',
        ] as $label => $dir) {
            $writable = $this->verifyWritableByTest($dir);
            $results[] = [
                'name' => $label,
                'detail' => $writable ? 'Writable (verified by write test)' : "Not writable: {$dir}",
                'passed' => $writable,
                'critical' => true,
            ];
        }

        $version = $this->currentVersion();
        $results[] = [
            'name' => 'Installed Version Detected',
            'detail' => $version !== null
                ? "v{$version} (from config/app.php)"
                : 'Could not read a version literal from config/app.php — updates cannot be version-checked.',
            'passed' => $version !== null,
            'critical' => true,
        ];

        // Database backup tooling — non-critical: a missing mysqldump means
        // the update proceeds with a files-only backup.
        $backup = $this->backupConfig();
        if (($backup['enabled'] ?? true) && ($backup['database']['enabled'] ?? true)) {
            $db = $this->databaseSettings();

            if (in_array($db['connection'], ['mysql', 'mariadb'], true)) {
                $available = function_exists('proc_open');
                $results[] = [
                    'name' => 'Database Backup (mysqldump)',
                    'detail' => $available
                        ? 'proc_open available — the database will be dumped before updating.'
                        : 'proc_open is disabled — the update will run with a files-only backup.',
                    'passed' => $available,
                    'critical' => false,
                ];
            }
        }

        $free = function_exists('disk_free_space') ? @disk_free_space($this->appRoot()) : false;
        if ($free !== false) {
            $results[] = [
                'name' => 'Free Disk Space',
                'detail' => $this->formatBytes((int) $free).' free. Updating needs roughly the uploaded package size × 4 (upload + staging + backup + extraction).',
                'passed' => true,
                'critical' => false,
            ];
        }

        return $results;
    }

    private function allCriticalRequirementsPass(): bool
    {
        foreach ($this->checkUpdateRequirements() as $result) {
            if ($result['critical'] && ! $result['passed']) {
                return false;
            }
        }

        return true;
    }
}


/**
 * Token-gated access, with two ways in:
 *
 *  1. Handoff (the normal path): the application's admin panel writes
 *     storage/app/updater/handoff.json — {"token_hash": sha256, "expires_at": ts}
 *     — and redirects the admin here with ?token=… . The token is one-time:
 *     the file is deleted the moment it authorizes a session.
 *
 *  2. Recovery (no handoff present): the updater writes a token to
 *     storage/app/updater/access-token.txt. Only someone with filesystem
 *     access — the operator — can read it, which is the proof. This is what
 *     makes the updater double as the disaster-recovery tool when the app
 *     (and its admin panel) is down.
 */
trait GuardsUpdaterAccess
{
    private function handoffFile(): string
    {
        return $this->updaterStorageDir().'/handoff.json';
    }

    private function recoveryTokenFile(): string
    {
        return $this->updaterStorageDir().'/access-token.txt';
    }

    /**
     * Returns true when the session is authorized; otherwise renders the
     * token gate (or consumes a presented token) and returns false, in which
     * case the caller must stop.
     */
    private function authorizeOrRenderGate(): bool
    {
        if (! empty($_SESSION['updater']['authorized'])) {
            return true;
        }

        $token = (string) ($_GET['token'] ?? $_POST['token'] ?? '');

        if ($token !== '' && $this->consumeToken($token)) {
            session_regenerate_id(true);
            $_SESSION['updater']['authorized'] = true;

            // Redirect so the token never lingers in the address bar or
            // browser history.
            header('Location: '.$this->selfUrl());
            exit;
        }

        if ($token !== '') {
            $this->errors[] = 'That token was not recognized. Tokens are single-use — delete storage/app/updater/access-token.txt and reload this page to mint a fresh one.';
        }

        $this->renderTokenGate();

        return false;
    }

    private function consumeToken(string $token): bool
    {
        // Handoff token from the admin panel.
        $handoffFile = $this->handoffFile();
        if (is_file($handoffFile)) {
            $handoff = json_decode((string) file_get_contents($handoffFile), true);

            if (is_array($handoff)) {
                $hash = (string) ($handoff['token_hash'] ?? '');
                $expiresAt = (int) ($handoff['expires_at'] ?? 0);

                if ($hash !== '' && hash_equals($hash, hash('sha256', $token))) {
                    @unlink($handoffFile);

                    return $expiresAt === 0 || time() <= $expiresAt;
                }
            }
        }

        // Recovery token minted by this script.
        $recoveryFile = $this->recoveryTokenFile();
        if (is_file($recoveryFile)) {
            $expected = trim((string) file_get_contents($recoveryFile));

            if ($expected !== '' && hash_equals($expected, $token)) {
                @unlink($recoveryFile);

                return true;
            }
        }

        return false;
    }

    /**
     * Make sure a recovery token exists for the operator to fetch. Returns
     * whether the token file is in place (it may not be writable on a badly
     * broken install — the gate page explains what to fix).
     */
    private function ensureRecoveryToken(): bool
    {
        $file = $this->recoveryTokenFile();

        if (is_file($file) && trim((string) file_get_contents($file)) !== '') {
            return true;
        }

        $token = bin2hex(random_bytes(32));

        if (@file_put_contents($file, $token) === false) {
            return false;
        }

        @chmod($file, 0600);

        return true;
    }

    private function selfUrl(): string
    {
        return strtok((string) ($_SERVER['REQUEST_URI'] ?? 'updater.php'), '?') ?: 'updater.php';
    }

    /**
     * Whether the current session has passed the token gate. Public so
     * bootstrap.php's fatal-error handler can decide whether the visitor is
     * entitled to see exception detail — deliberately defensive (an
     * exception thrown before session_start() ran, e.g. a malformed
     * config/updates.php, must not fatal a second time just answering this).
     */
    public function isAuthorized(): bool
    {
        return ! empty($_SESSION['updater']['authorized']);
    }

    /**
     * selfUrl(), HTML-escaped for use in a form action="" attribute — the
     * path is derived from the client-controlled REQUEST_URI, so unlike a
     * literal string it must never be interpolated into markup unescaped.
     */
    private function selfUrlEscaped(): string
    {
        return htmlspecialchars($this->selfUrl());
    }
}


trait HandlesUpdaterRequests
{
    public function run(): void
    {
        // Most shared hosts allow overriding this per-request even when the
        // php.ini default is low; @-suppressed since it's a no-op (not an
        // error) when disabled via disable_functions. Covers every request
        // this tool handles — chunk upload assembly/validation, backup,
        // extraction, and restore are all slow enough to risk the default.
        @set_time_limit(300);

        // A distinct session name so the updater's session cookie never
        // collides with the application's own session.
        session_name('app_updater');
        applyHardenedSessionCookieParams();
        session_start();

        if (! isset($_SESSION['updater'])) {
            $_SESSION['updater'] = [];
        }

        $this->ensureUpdaterDirs();
        $this->loadEnvIntoStore();

        if (! $this->authorizeOrRenderGate()) {
            return;
        }

        // AJAX sub-actions are routed by name, not by which screen they
        // happen to live on. CSRF is only checked once authorized — the
        // token-gate form above submits the operator's filesystem-proof
        // token itself, which an attacker riding the session cookie
        // wouldn't have anyway.
        if (isset($_GET['ajax']) && $_SERVER['REQUEST_METHOD'] === 'POST') {
            $this->requireValidCsrfToken();
            $this->handleAjax((string) $_GET['ajax']);

            return;
        }

        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            $this->requireValidCsrfToken();
            $this->handlePost((string) ($_POST['action'] ?? ''));

            return;
        }

        $this->renderCurrentScreen();
    }

    /**
     * Whether an update run has started and not yet finished. Shared by
     * renderCurrentScreen() (so a mid-update page reload always lands back
     * on the progress screen, never on upload) and by the upload/start-update
     * handlers (so a second tab, or a hand-crafted request from the same
     * authorized session, can't kick off a new upload or run while one is
     * already in flight — see handleUploadInit()'s docblock).
     */
    private function updateRunInProgress(): bool
    {
        $update = $_SESSION['updater']['update'] ?? [];

        return ! empty($update['started']) && empty($update['finished']);
    }

    private function renderCurrentScreen(): void
    {
        $state = $_SESSION['updater'];

        // An update run in progress always wins — a mid-update page reload
        // must land back on the progress screen, never on upload.
        if ($this->updateRunInProgress()) {
            $this->renderProgress();

            return;
        }

        if (($_GET['screen'] ?? '') === 'home') {
            unset($_SESSION['updater']['done']);
            $this->renderHome();

            return;
        }

        if (! empty($state['done'])) {
            $this->renderComplete();

            return;
        }

        if (! empty($state['package']['valid'])) {
            $this->renderReview();

            return;
        }

        $this->renderHome();
    }

    private function handleAjax(string $action): void
    {
        switch ($action) {
            case 'upload-init':
                $this->handleUploadInit();
                break;
            case 'upload-chunk':
                $this->handleUploadChunk();
                break;
            case 'update-task':
                $this->handleUpdateTask((string) ($_GET['task'] ?? ''));
                break;
            default:
                header('Content-Type: application/json');
                http_response_code(400);
                echo json_encode(['success' => false, 'message' => 'Unknown AJAX action.']);
                exit;
        }
    }

    private function handlePost(string $action): void
    {
        switch ($action) {
            case 'start-update':
            case 'cancel-package':
                // Both mutate or delete $_SESSION['updater']['package']/
                // ['update'] state that an in-flight run depends on —
                // start-update would clobber the run already underway,
                // and cancel-package would delete the staged zip
                // taskExtract() is actively reading (see
                // updateRunInProgress()'s docblock). The UI can't reach
                // either action once a run has started
                // (renderCurrentScreen() always shows the progress screen
                // instead), so this only guards against a second tab or a
                // hand-crafted direct request. Checked once here, at the
                // dispatch choke point, rather than in each handler, so a
                // future state-changing POST action added to this switch
                // can't forget it.
                if ($this->updateRunInProgress()) {
                    $this->renderProgress();

                    return;
                }

                $action === 'start-update' ? $this->handleStartUpdate() : $this->handleCancelPackage();
                break;
            default:
                $this->renderCurrentScreen();
        }
    }

    /**
     * Transition from the review screen into the update run. All the heavy
     * lifting happens in the progress screen's AJAX task loop; this just
     * pins the run's metadata into the session. The in-progress guard lives
     * in handlePost() (see its docblock), not here.
     */
    private function handleStartUpdate(): void
    {
        $package = $_SESSION['updater']['package'] ?? [];

        if (empty($package['valid'])) {
            $this->errors[] = 'No validated update package found. Upload one first.';
            $this->renderHome();

            return;
        }

        // The review screen already hides the "Apply Update" button when
        // space is short, but that's client-side only — re-check here so a
        // resubmitted or hand-crafted request can't start a run that will
        // run out of disk partway through backup or extraction. (The size
        // heuristic lives in diskSpaceShortfall(), shared with the upload
        // gate and the review screen.)
        $stagedZip = (string) ($package['staged_zip'] ?? '');
        $stagedSize = is_file($stagedZip) ? (int) filesize($stagedZip) : 0;

        if ($shortfall = $this->diskSpaceShortfall($stagedSize, $this->appRoot())) {
            $this->errors[] = 'Not enough free disk space to safely apply this update (needs roughly '.$this->formatBytes($shortfall['needed']).', '.$this->formatBytes($shortfall['free']).' available).';
            $this->renderReview();

            return;
        }

        $_SESSION['updater']['update'] = [
            'started' => true,
            'finished' => false,
            'started_at' => date('c'),
            'version_from' => $this->currentVersion(),
            'version_to' => $package['version'],
            'completed_tasks' => [],
        ];

        header('Location: '.$this->selfUrl());
        exit;
    }

    private function handleCancelPackage(): void
    {
        $package = $_SESSION['updater']['package'] ?? [];

        foreach (['path', 'staged_zip'] as $key) {
            if (! empty($package[$key]) && file_exists($package[$key])) {
                @unlink($package[$key]);
            }
        }

        unset($_SESSION['updater']['package'], $_SESSION['updater']['upload']);

        header('Location: '.$this->selfUrl());
        exit;
    }

    /**
     * Every authorized state-changing POST (form submission or AJAX call)
     * must carry the per-session token minted by csrfToken() and rendered
     * into forms/window.CSRF_TOKEN by renderLayout() — otherwise a
     * cross-site page could ride the authorized updater session cookie
     * into e.g. start-update or update-task=restore.
     */
    private function requireValidCsrfToken(): void
    {
        if (csrfTokenValid('csrf_token')) {
            return;
        }

        if (isset($_GET['ajax'])) {
            $this->jsonResponse(['success' => false, 'message' => 'Your session has expired or this request could not be verified. Please reload the page and try again.']);
        }

        $this->errors[] = 'Your session has expired or this request could not be verified. Please reload the page and try again.';
        $this->renderCurrentScreen();
        exit;
    }

    private function jsonResponse(array $payload): void
    {
        header('Content-Type: application/json');
        echo json_encode($payload);
        exit;
    }
}


/**
 * Chunked .update upload, in plain PHP. The browser slices the file and
 * POSTs sequential chunks, each comfortably below the ~2 MB
 * upload_max_filesize floor common on shared hosting — so a multi-hundred-MB
 * package uploads reliably without any php.ini changes.
 */
trait HandlesUploads
{
    /** Server-side ceiling per chunk; the client sends 1.5 MB chunks. */
    private const MAX_CHUNK_BYTES = 4194304;

    /** Refuse packages over this size outright (corrupt/abusive uploads). */
    private const MAX_PACKAGE_BYTES = 2147483648;

    private function uploadPartPath(string $uploadId): string
    {
        return $this->updaterStorageDir().'/upload-'.$uploadId.'.part';
    }

    private function pendingPackagePath(string $uploadId): string
    {
        return $this->updaterStorageDir().'/pending-'.$uploadId.'.update';
    }

    private function stagedZipPath(string $uploadId): string
    {
        return $this->updaterStorageDir().'/staged-'.$uploadId.'.zip';
    }

    private function handleUploadInit(): void
    {
        // The UI can't reach the upload screen while a run is in progress
        // (renderCurrentScreen() always routes to the progress screen
        // instead — see HandlesUpdaterRequests), but nothing stops a second
        // tab or a hand-crafted request from the same authorized session
        // reaching this endpoint directly. discardPackageArtifacts() below
        // would otherwise delete the staged zip taskExtract() is actively
        // reading, failing the run mid-extraction.
        if ($this->updateRunInProgress()) {
            $this->jsonResponse(['success' => false, 'message' => 'An update is currently in progress. Wait for it to finish before uploading another package.']);
        }

        $name = (string) ($_POST['name'] ?? '');
        $size = (int) ($_POST['size'] ?? 0);
        $totalChunks = (int) ($_POST['total_chunks'] ?? 0);

        if (! str_ends_with(strtolower($name), '.update')) {
            $this->jsonResponse(['success' => false, 'message' => 'Please choose a .update package file.']);
        }

        if ($size <= 0 || $size > self::MAX_PACKAGE_BYTES || $totalChunks <= 0) {
            $this->jsonResponse(['success' => false, 'message' => 'Invalid upload size.']);
        }

        // Running out of space mid-run is one of the ugliest ways an update
        // can fail on shared hosting — reject up front rather than
        // discovering it partway through backup or extraction. (The size
        // heuristic lives in diskSpaceShortfall(), shared with the review
        // screen and the start-update guard.)
        if ($shortfall = $this->diskSpaceShortfall($size, $this->updaterStorageDir())) {
            $this->jsonResponse([
                'success' => false,
                'message' => 'Not enough free disk space for this update. Needs roughly '.$this->formatBytes($shortfall['needed']).', but only '.$this->formatBytes($shortfall['free']).' is available.',
            ]);
        }

        // Discard any previous attempt's artifacts before starting fresh.
        $this->discardPackageArtifacts();

        $uploadId = bin2hex(random_bytes(16));

        $_SESSION['updater']['upload'] = [
            'id' => $uploadId,
            'size' => $size,
            'total_chunks' => $totalChunks,
            'next_index' => 0,
        ];
        unset($_SESSION['updater']['package']);

        $this->jsonResponse(['success' => true, 'upload_id' => $uploadId]);
    }

    private function handleUploadChunk(): void
    {
        $upload = $_SESSION['updater']['upload'] ?? null;
        $uploadId = (string) ($_POST['upload_id'] ?? '');
        $index = (int) ($_POST['index'] ?? -1);

        if (! is_array($upload) || $uploadId === '' || ! hash_equals($upload['id'], $uploadId)) {
            // A retried final chunk whose original response was lost: the
            // upload state was already cleared when the package finished
            // assembling and validating, but the validated package it
            // produced is still in the session — replay that completion
            // response instead of failing the client into a full re-upload.
            $package = $_SESSION['updater']['package'] ?? null;
            if ($uploadId !== '' && is_array($package)
                && hash_equals((string) ($package['upload_id'] ?? ''), $uploadId)) {
                $this->jsonResponse([
                    'success' => true,
                    'complete' => true,
                    'valid' => true,
                    'version' => $package['version'],
                ]);
            }

            $this->jsonResponse(['success' => false, 'message' => 'No upload in progress. Start again.']);
        }

        // The client resending the chunk immediately before next_index is
        // not "out of order" — it's a retry after a dropped response (the
        // chunk landed and was acked server-side, but the ack never reached
        // the browser). That exact chunk is already fully appended to
        // partPath, so just re-acknowledge it instead of failing the whole
        // multi-hundred-MB upload over one flaky response. Retrying the
        // *final* chunk this way falls through to the assembly/validation
        // logic below exactly as if it were the original request for that
        // index, rather than re-reporting an incomplete state.
        $isRetryOfPreviousChunk = $index === (int) $upload['next_index'] - 1;

        if ($isRetryOfPreviousChunk && $index + 1 < (int) $upload['total_chunks']) {
            $this->jsonResponse([
                'success' => true,
                'complete' => false,
                'received' => (int) $upload['next_index'],
                'total' => (int) $upload['total_chunks'],
            ]);
        }

        if (! $isRetryOfPreviousChunk && $index !== (int) $upload['next_index']) {
            $this->jsonResponse(['success' => false, 'message' => "Chunk out of order (expected {$upload['next_index']}, got {$index}). Start the upload again."]);
        }

        $partPath = $this->uploadPartPath($uploadId);

        // A retry of the final chunk: its bytes are already appended to
        // partPath from the original request (only the response was lost),
        // so skip straight to assembly/validation below instead of
        // re-appending and duplicating the tail of the file.
        if (! $isRetryOfPreviousChunk) {
            $chunk = $_FILES['chunk'] ?? null;

            if (! is_array($chunk) || ($chunk['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
                $this->jsonResponse(['success' => false, 'message' => 'Chunk upload failed (PHP upload error '.(int) ($chunk['error'] ?? -1).'). Check post_max_size/upload_max_filesize.']);
            }

            if (! is_uploaded_file($chunk['tmp_name'])) {
                $this->jsonResponse(['success' => false, 'message' => 'Chunk upload rejected (not a genuine upload).']);
            }

            if ($chunk['size'] > self::MAX_CHUNK_BYTES) {
                $this->jsonResponse(['success' => false, 'message' => 'Chunk too large.']);
            }

            $appendOffset = $index === 0 ? 0 : (int) @filesize($partPath);

            $target = fopen($partPath, $index === 0 ? 'wb' : 'ab');
            $source = fopen($chunk['tmp_name'], 'rb');

            if ($target === false || $source === false) {
                $this->jsonResponse(['success' => false, 'message' => 'Failed to write the upload to storage/app/updater. Check permissions and disk space.']);
            }

            $copied = stream_copy_to_stream($source, $target);
            fclose($source);
            fclose($target);

            // A short write (e.g. the disk filled up mid-chunk) must fail
            // this chunk immediately rather than silently ack a truncated
            // write — otherwise it only surfaces once every chunk of a
            // multi-hundred-MB upload has been sent, as a generic "upload
            // incomplete" with no indication which chunk actually failed.
            // Roll the part file back to its pre-append length first: the
            // client's lost-response retry re-sends this same index, and it
            // must append onto clean state, not after the truncated bytes.
            if ($copied !== $chunk['size']) {
                if (($handle = @fopen($partPath, 'r+b')) !== false) {
                    ftruncate($handle, $appendOffset);
                    fclose($handle);
                }

                $this->jsonResponse(['success' => false, 'message' => 'Failed to write the full chunk to storage/app/updater. Check available disk space.']);
            }

            $_SESSION['updater']['upload']['next_index'] = $index + 1;
        }

        if ($index + 1 < (int) $upload['total_chunks']) {
            $this->jsonResponse([
                'success' => true,
                'complete' => false,
                'received' => $index + 1,
                'total' => (int) $upload['total_chunks'],
            ]);
        }

        // Last chunk: assemble and validate.
        $received = filesize($partPath);
        if ($received !== (int) $upload['size']) {
            @unlink($partPath);
            unset($_SESSION['updater']['upload']);
            $this->jsonResponse(['success' => false, 'message' => "Upload incomplete: received {$received} of {$upload['size']} bytes. Try again."]);
        }

        $pendingPath = $this->pendingPackagePath($uploadId);
        if (! rename($partPath, $pendingPath)) {
            $this->jsonResponse(['success' => false, 'message' => 'Failed to finalize the uploaded package.']);
        }

        unset($_SESSION['updater']['upload']);

        $validation = $this->validateUpdatePackage($pendingPath, $uploadId);

        if (! $validation['valid']) {
            @unlink($pendingPath);
            @unlink($this->stagedZipPath($uploadId));
            $this->jsonResponse(['success' => true, 'complete' => true, 'valid' => false, 'message' => $validation['error']]);
        }

        $_SESSION['updater']['package'] = [
            'valid' => true,
            'upload_id' => $uploadId,
            'path' => $pendingPath,
            'staged_zip' => $this->stagedZipPath($uploadId),
            'version' => $validation['version'],
            'files_count' => $validation['files_count'],
            'minimum_php' => $validation['minimum_php'],
            'signed_by' => $validation['signed_by'],
        ];

        $this->jsonResponse([
            'success' => true,
            'complete' => true,
            'valid' => true,
            'version' => $validation['version'],
        ]);
    }

    /**
     * Remove any pending/staged package artifacts from earlier attempts so
     * abandoned uploads never pile up in storage.
     */
    private function discardPackageArtifacts(): void
    {
        // Three globs instead of GLOB_BRACE — brace expansion is a glibc
        // extension and silently unavailable on musl-based hosts.
        foreach (['upload-', 'pending-', 'staged-'] as $prefix) {
            foreach (glob($this->updaterStorageDir().'/'.$prefix.'*') ?: [] as $file) {
                if (is_file($file)) {
                    @unlink($file);
                }
            }
        }
    }
}


/**
 * Pre-update backup (files + database) and its restore path, dependency-free.
 * Ported from the Laravel package's UpdateBackupManager, restructured so the
 * slow parts (zipping and re-extracting ~20k files) run in batches across
 * HTTP requests — the same gateway-timeout discipline the installer uses.
 *
 * Backups live in storage/app/updater/backups/{id}/ as files.zip +
 * database.sql + metadata.json.
 */
trait ManagesBackups
{
    /** Files added to the backup zip per request. */
    private const BACKUP_BATCH_FILES = 400;

    // BATCH_BUDGET_SECONDS lives in BootsUpdaterEnvironment — every trait
    // assembled into the Updater class already depends on it for paths, so
    // it's a neutral shared home for a constant both this trait and
    // RunsUpdateTasks read.

    private function backupDir(string $backupId): string
    {
        if (! preg_match('/^[a-z0-9\-]+$/', $backupId)) {
            throw new RuntimeException('Invalid backup identifier.');
        }

        return $this->backupsDir().'/'.$backupId;
    }

    private function backupListFile(string $backupId): string
    {
        return $this->backupDir($backupId).'/file-list.txt';
    }

    private function newBackupId(): string
    {
        return date('Ymd-His').'-'.substr(bin2hex(random_bytes(4)), 0, 6);
    }

    /**
     * First backup call: enumerate everything to back up into a list file,
     * so subsequent batched calls can seek straight to their offset.
     * Returns the total file count.
     */
    private function buildBackupFileList(string $backupId): int
    {
        $dir = $this->backupDir($backupId);
        if (! is_dir($dir) && ! @mkdir($dir, 0755, true)) {
            throw new RuntimeException('Failed to create the backup directory. Check storage permissions.');
        }

        $excluded = $this->backupExcludedSegments();
        $base = $this->appRoot();
        $list = fopen($this->backupListFile($backupId), 'wb');

        if ($list === false) {
            throw new RuntimeException('Failed to create the backup file list.');
        }

        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::LEAVES_ONLY
        );

        $count = 0;
        foreach ($iterator as $item) {
            if (! $item->isFile() || $item->isLink()) {
                continue;
            }

            $relative = substr($item->getPathname(), strlen($base) + 1);

            if ($this->isBackupExcluded($relative, $excluded)) {
                continue;
            }

            fwrite($list, $relative."\n");
            $count++;
        }

        fclose($list);

        file_put_contents($dir.'/metadata.json', json_encode([
            'id' => $backupId,
            'version' => $this->currentVersion(),
            'created_at' => date('c'),
            'files_total' => $count,
        ]));

        return $count;
    }

    /**
     * Append the next batch of listed files to the backup zip. Returns
     * [added_so_far, total, done].
     *
     * @return array{offset: int, total: int, done: bool}
     */
    private function appendBackupBatch(string $backupId, int $offset, int $total): array
    {
        $zipPath = $this->backupDir($backupId).'/files.zip';
        $zip = new ZipArchive;

        if ($zip->open($zipPath, ZipArchive::CREATE) !== true) {
            throw new RuntimeException('Failed to open the backup archive for writing.');
        }

        $base = $this->appRoot();
        $started = microtime(true);
        $added = 0;

        $list = new SplFileObject($this->backupListFile($backupId));
        $list->seek($offset);

        while (! $list->eof() && $added < self::BACKUP_BATCH_FILES && (microtime(true) - $started) < self::BATCH_BUDGET_SECONDS) {
            $relative = rtrim((string) $list->current(), "\n");
            $list->next();

            if ($relative === '') {
                $offset++;

                continue;
            }

            $absolute = $base.'/'.$relative;
            if (file_exists($absolute)) {
                $zip->addFile($absolute, $relative);
            }

            $added++;
            $offset++;
        }

        if (! $zip->close()) {
            throw new RuntimeException('Failed to write a backup batch. Check disk space.');
        }

        // The list file's trailing newline reads as one empty line past the
        // real entries — clamp so progress never reports past the total.
        $offset = min($offset, $total);

        return ['offset' => $offset, 'total' => $total, 'done' => $offset >= $total];
    }

    /**
     * Dump the database next to the files backup. MySQL/MariaDB uses
     * mysqldump via proc_open (password passed through the environment, not
     * argv); SQLite is a plain file copy. Returns a human-readable status —
     * a missing tool degrades to a files-only backup rather than blocking
     * the update.
     */
    private function backupDatabase(string $backupId): string
    {
        $backup = $this->backupConfig();

        if (! ($backup['database']['enabled'] ?? true)) {
            return 'Skipped (disabled in config).';
        }

        $db = $this->databaseSettings();
        $sqlPath = $this->backupDir($backupId).'/database.sql';

        if ($db['connection'] === 'sqlite') {
            $sqlitePath = $db['database'] !== '' && $db['database'][0] === '/'
                ? $db['database']
                : $this->appRoot().'/database/database.sqlite';

            if (! is_file($sqlitePath)) {
                return 'Skipped (SQLite database file not found).';
            }

            if (! copy($sqlitePath, $this->backupDir($backupId).'/database.sqlite')) {
                throw new RuntimeException('Failed to copy the SQLite database into the backup.');
            }

            return 'SQLite database copied.';
        }

        if (! in_array($db['connection'], ['mysql', 'mariadb'], true)) {
            return "Skipped (unsupported connection '{$db['connection']}').";
        }

        if (! function_exists('proc_open')) {
            return 'Skipped (proc_open is disabled on this server) — files-only backup.';
        }

        $binary = (string) ($backup['database']['mysqldump_path'] ?? 'mysqldump');

        $command = [
            $binary,
            '--host='.$db['host'],
            '--port='.$db['port'],
            '--user='.$db['username'],
            '--single-transaction',
            '--skip-lock-tables',
            // Since MySQL 5.7.31/8.0.21, dumping tablespace metadata requires
            // the PROCESS privilege, which shared-hosting DB users are almost
            // never granted — without this flag mysqldump exits non-zero and
            // the backup silently degrades to files-only. Triggers are dumped
            // by default; --routines is deliberately NOT passed because it
            // needs SELECT on mysql.proc / SHOW_ROUTINE, which those same
            // users typically lack — it would reintroduce the exact
            // privilege-based failure --no-tablespaces exists to avoid.
            '--no-tablespaces',
            '--default-character-set=utf8mb4',
            '--result-file='.$sqlPath,
            $db['database'],
        ];

        $exitCode = $this->runProcess($command, ['MYSQL_PWD' => $db['password']], $stderr);

        if ($exitCode !== 0) {
            @unlink($sqlPath);

            return 'Skipped (mysqldump failed: '.trim(substr($stderr, 0, 300)).') — files-only backup.';
        }

        return 'Database dumped ('.$this->formatBytes((int) filesize($sqlPath)).').';
    }

    /**
     * Restore a batch of files from the backup zip over the live tree.
     *
     * @return array{offset: int, total: int, done: bool}
     */
    private function restoreBackupBatch(string $backupId, int $offset): array
    {
        $zipPath = $this->backupDir($backupId).'/files.zip';
        $zip = new ZipArchive;

        if ($zip->open($zipPath) !== true) {
            throw new RuntimeException('Failed to open the backup archive for restore.');
        }

        $base = $this->appRoot();
        $total = $zip->numFiles;
        $started = microtime(true);
        $processed = 0;

        for ($i = $offset; $i < $total; $i++) {
            if ($processed >= self::BACKUP_BATCH_FILES || (microtime(true) - $started) >= self::BATCH_BUDGET_SECONDS) {
                break;
            }

            $name = (string) $zip->getNameIndex($i);
            $offset++;
            $processed++;

            if ($name === '' || str_ends_with($name, '/') || str_contains($name, '..')) {
                continue;
            }

            $targetFile = $base.'/'.$name;
            $targetDir = dirname($targetFile);

            if (! is_dir($targetDir) && ! @mkdir($targetDir, 0755, true) && ! is_dir($targetDir)) {
                $zip->close();
                throw new RuntimeException("Failed to create {$targetDir} during restore.");
            }

            $stream = $zip->getStream($name);
            if ($stream === false) {
                $zip->close();
                throw new RuntimeException("Failed to read {$name} from the backup archive.");
            }

            $out = fopen($targetFile, 'wb');
            if ($out === false) {
                fclose($stream);
                $zip->close();
                throw new RuntimeException("Failed to restore {$name}. Check permissions.");
            }

            stream_copy_to_stream($stream, $out);
            fclose($stream);
            fclose($out);
        }

        $zip->close();

        return ['offset' => $offset, 'total' => $total, 'done' => $offset >= $total];
    }

    /**
     * Delete files that exist on disk but not in the backup — files a
     * partially-applied update introduced. Uses the backup's own file list
     * (not the zip index) for a fast membership set. Protected paths and
     * backup-excluded segments are never deleted.
     */
    private function deleteFilesAbsentFromBackup(string $backupId): int
    {
        $listFile = $this->backupListFile($backupId);
        if (! is_file($listFile)) {
            return 0;
        }

        $inBackup = [];
        foreach (file($listFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
            $inBackup[$line] = true;
        }

        $excluded = $this->backupExcludedSegments();
        $base = $this->appRoot();
        $deleted = 0;
        $touchedDirs = [];

        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::LEAVES_ONLY
        );

        foreach ($iterator as $item) {
            if (! $item->isFile() || $item->isLink()) {
                continue;
            }

            $relative = substr($item->getPathname(), strlen($base) + 1);

            if (isset($inBackup[$relative])
                || $this->isBackupExcluded($relative, $excluded)
                || $this->isProtectedRelativePath($relative)) {
                continue;
            }

            if (@unlink($item->getPathname())) {
                $deleted++;
                $touchedDirs[] = dirname($item->getPathname());
            }
        }

        $this->pruneEmptyDirectories($touchedDirs, $base);

        return $deleted;
    }

    /**
     * Remove directories left empty by deleteFilesAbsentFromBackup()'s
     * unlinks, walking each one up toward (but never including) $base so a
     * removed update also cleans up any now-empty directories it introduced
     * a level or two above the deleted file, not just the immediate parent.
     * Only ever removes a directory found genuinely empty — never touches
     * $base itself, and stops climbing the moment a directory still has
     * other contents or isn't a descendant of $base.
     *
     * @param  list<string>  $dirs
     */
    private function pruneEmptyDirectories(array $dirs, string $base): void
    {
        $base = rtrim($base, '/');

        foreach (array_unique($dirs) as $dir) {
            while ($dir !== $base && str_starts_with($dir, $base.'/')) {
                $entries = @scandir($dir);

                if ($entries === false || array_diff($entries, ['.', '..']) !== []) {
                    break;
                }

                if (! @rmdir($dir)) {
                    break;
                }

                $dir = dirname($dir);
            }
        }
    }

    /**
     * Import the database dump taken before the update. Returns a status
     * string; like the dump itself, a missing tool degrades to a warning.
     */
    private function restoreDatabase(string $backupId): string
    {
        $dir = $this->backupDir($backupId);
        $db = $this->databaseSettings();

        if (is_file($dir.'/database.sqlite')) {
            $sqlitePath = $db['database'] !== '' && $db['database'][0] === '/'
                ? $db['database']
                : $this->appRoot().'/database/database.sqlite';

            return copy($dir.'/database.sqlite', $sqlitePath)
                ? 'SQLite database restored.'
                : 'WARNING: failed to restore the SQLite database file.';
        }

        $sqlPath = $dir.'/database.sql';

        if (! is_file($sqlPath)) {
            return 'No database dump in this backup — database left as-is.';
        }

        if (! function_exists('proc_open')) {
            return 'WARNING: proc_open is disabled; restore the database manually from '.$sqlPath;
        }

        $backup = $this->backupConfig();
        $binary = (string) ($backup['database']['mysqldump_path'] ?? 'mysqldump');
        // The mysql client lives next to mysqldump.
        $mysql = preg_replace('/mysqldump(\.exe)?$/', 'mysql$1', $binary) ?: 'mysql';

        // Drop every existing table first. The dump only DROPs tables it
        // contains, so a table created by the failed update's migrations
        // (but absent from the pre-update dump) would otherwise survive the
        // restore — and a later retry of the same update then fails at
        // `migrate` with "table already exists", stranding the site.
        $dropWarning = $this->dropAllTables($mysql, $db);

        $command = [
            $mysql,
            '--host='.$db['host'],
            '--port='.$db['port'],
            '--user='.$db['username'],
            $db['database'],
        ];

        $exitCode = $this->runProcess($command, ['MYSQL_PWD' => $db['password']], $stderr, $sqlPath);

        return $exitCode === 0
            ? 'Database restored from dump.'.$dropWarning
            : 'WARNING: database restore failed ('.trim(substr($stderr, 0, 300)).'). Restore manually from '.$sqlPath;
    }

    /**
     * Drop every table currently in the database before importing a restore
     * dump, using a single `mysql` invocation fed a generated
     * `SET FOREIGN_KEY_CHECKS=0; DROP TABLE ...;` script (disabling FK
     * checks for the duration avoids drop-order errors on tables with
     * cross-references). Best-effort: a failure here is surfaced as a
     * warning appended to the restore's own success message rather than
     * aborting the restore, since the dump import that follows is what
     * actually matters for getting the site back up.
     *
     * @param  array{connection: string, host: string, port: string, database: string, username: string, password: string}  $db
     */
    private function dropAllTables(string $mysql, array $db): string
    {
        $listCommand = [
            $mysql,
            '--host='.$db['host'],
            '--port='.$db['port'],
            '--user='.$db['username'],
            '--batch',
            '--skip-column-names',
            '--execute=SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()',
            $db['database'],
        ];

        $listExitCode = $this->runProcess($listCommand, ['MYSQL_PWD' => $db['password']], $listStderr, null, $tables);

        if ($listExitCode !== 0) {
            return ' WARNING: could not enumerate existing tables before restore ('.trim(substr($listStderr, 0, 200)).') — restore may fail if the update left extra tables behind.';
        }

        $tableNames = array_filter(array_map('trim', explode("\n", $tables)));

        if ($tableNames === []) {
            return '';
        }

        $dropScript = "SET FOREIGN_KEY_CHECKS=0;\n";
        foreach ($tableNames as $table) {
            $dropScript .= 'DROP TABLE IF EXISTS `'.str_replace('`', '``', $table)."`;\n";
        }
        $dropScript .= "SET FOREIGN_KEY_CHECKS=1;\n";

        $scriptPath = $this->updaterStorageDir().'/restore-drop-'.bin2hex(random_bytes(4)).'.sql';
        file_put_contents($scriptPath, $dropScript);

        $dropCommand = [
            $mysql,
            '--host='.$db['host'],
            '--port='.$db['port'],
            '--user='.$db['username'],
            $db['database'],
        ];

        $exitCode = $this->runProcess($dropCommand, ['MYSQL_PWD' => $db['password']], $dropStderr, $scriptPath);
        @unlink($scriptPath);

        return $exitCode === 0
            ? ''
            : ' WARNING: failed to drop existing tables before restore ('.trim(substr($dropStderr, 0, 200)).') — the dump import may fail if the update left extra tables behind.';
    }

    /**
     * Keep the newest $keep backups, delete the rest. Returns how many were
     * pruned.
     */
    private function pruneBackups(int $keep): int
    {
        $dirs = glob($this->backupsDir().'/*', GLOB_ONLYDIR) ?: [];
        rsort($dirs); // ids sort chronologically (Ymd-His prefix)

        $pruned = 0;
        foreach (array_slice($dirs, max(0, $keep)) as $dir) {
            $this->deleteDirectoryRecursive($dir);
            $pruned++;
        }

        return $pruned;
    }

    private function deleteDirectoryRecursive(string $dir): void
    {
        $items = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::CHILD_FIRST
        );

        foreach ($items as $item) {
            $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
        }

        @rmdir($dir);
    }

    /**
     * @return list<string>
     */
    private function backupExcludedSegments(): array
    {
        $backup = $this->backupConfig();
        $exclude = array_values(array_map('strval', (array) ($backup['exclude'] ?? [])));

        // The updater's own working area (uploads, staging, backups) must
        // never be swallowed into a backup of itself. Derived from config
        // rather than hardcoded, so an app that overrides
        // updates.updater.storage_dir still gets this exclusion — hardcoding
        // 'storage/app/updater' here would silently stop protecting a site
        // that customizes the storage dir.
        $exclude[] = ltrim((string) ($this->updatesConfig()['updater']['storage_dir'] ?? 'storage/app/updater'), '/');

        if (($backup['include_vendor'] ?? true) === false) {
            $exclude[] = 'vendor';
        }

        return $exclude;
    }

    /**
     * @param  list<string>  $excluded
     */
    private function isBackupExcluded(string $relativePath, array $excluded): bool
    {
        foreach ($excluded as $segment) {
            $segment = rtrim($segment, '/');

            if ($segment === '') {
                continue;
            }

            // Segments may be a root-relative prefix ('storage/logs') or a
            // bare directory name matched anywhere ('node_modules'), same as
            // the Laravel-side backup manager.
            if ($relativePath === $segment || str_starts_with($relativePath, $segment.'/')) {
                return true;
            }

            if (! str_contains($segment, '/') && str_contains($relativePath, '/'.$segment.'/')) {
                return true;
            }
        }

        return false;
    }

    /**
     * Run an external process with stdin optionally fed from a file and
     * stderr captured. Returns the exit code; stdout is captured into the
     * optional $stdout out-param (discarded when the caller doesn't ask).
     *
     * @param  list<string>  $command
     * @param  array<string, string>  $extraEnv
     */
    private function runProcess(array $command, array $extraEnv, ?string &$stderr, ?string $stdinFile = null, ?string &$stdout = null): int
    {
        $descriptors = [
            0 => $stdinFile !== null ? ['file', $stdinFile, 'r'] : ['pipe', 'r'],
            1 => ['pipe', 'w'],
            2 => ['pipe', 'w'],
        ];

        $env = array_merge($_SERVER['PATH'] ?? false ? ['PATH' => $_SERVER['PATH']] : [], $extraEnv);
        // Preserve the inherited environment too — proc_open with an env
        // array replaces it entirely otherwise.
        foreach ($_ENV as $k => $v) {
            if (is_string($v) && ! isset($env[$k])) {
                $env[$k] = $v;
            }
        }

        $process = @proc_open($command, $descriptors, $pipes, $this->appRoot(), $env);

        if (! is_resource($process)) {
            $stderr = 'Failed to start process: '.$command[0];

            return 127;
        }

        if ($stdinFile === null && isset($pipes[0])) {
            fclose($pipes[0]);
        }

        [$stdout, $stderr] = $this->drainProcessPipes($pipes[1], $pipes[2]);

        return proc_close($process);
    }

    /**
     * Read stdout and stderr to EOF without blocking on either individually.
     * A plain stream_get_contents($pipes[1]) followed by stream_get_contents
     * ($pipes[2]) deadlocks if the child fills the stderr pipe's OS buffer
     * (~64KB) while stdout is still open and this process hasn't gotten to
     * reading stderr yet — the child blocks writing, this process blocks
     * reading stdout, and neither side ever proceeds. mysqldump/mysql write
     * their bulk output to stdout/--result-file and only diagnostics to
     * stderr, so this is a defense-in-depth guard against an unusually
     * chatty failure rather than a scenario hit in normal operation.
     *
     * @param  resource  $stdoutPipe
     * @param  resource  $stderrPipe
     * @return array{0: string, 1: string}
     */
    private function drainProcessPipes($stdoutPipe, $stderrPipe): array
    {
        stream_set_blocking($stdoutPipe, false);
        stream_set_blocking($stderrPipe, false);

        $stdout = '';
        $stderr = '';
        $open = [$stdoutPipe, $stderrPipe];

        while ($open !== []) {
            $read = $open;
            $write = null;
            $except = null;

            // Both pipes are non-blocking, so a stream_select() timeout
            // (nothing readable within 5s) is harmless to just loop past —
            // the next iteration's feof() check is what actually detects a
            // pipe closing, not this call's return value.
            if (@stream_select($read, $write, $except, 5) === false) {
                break;
            }

            foreach ($read as $pipe) {
                $chunk = fread($pipe, 65536);

                if ($chunk !== '' && $chunk !== false) {
                    if ($pipe === $stdoutPipe) {
                        $stdout .= $chunk;
                    } else {
                        $stderr .= $chunk;
                    }
                }
            }

            foreach ($open as $pipe) {
                if (feof($pipe)) {
                    $open = array_values(array_filter($open, fn ($p) => $p !== $pipe));
                }
            }
        }

        fclose($stdoutPipe);
        fclose($stderrPipe);

        return [$stdout, $stderr];
    }
}


/**
 * Updater-specific layout pieces: the screen sidebar copy and the header
 * strip. The shared page shell, icons, and theme handling live in
 * templates/shared/Concerns/RendersChrome.php.
 */
trait RendersUpdaterLayout
{
    private function toolLabel(): string
    {
        return 'Application Updater';
    }

    private function toolVersion(): string
    {
        return UPDATER_VERSION;
    }

    private function getSidebarInfo(int $step): array
    {
        return match ($step) {
            1 => [
                'icon' => '📦',
                'desc' => 'Check your server, review past updates, and upload a new .update package.',
            ],
            2 => [
                'icon' => '🔍',
                'desc' => 'The package has been validated. Review the details before applying the update.',
            ],
            3 => [
                'icon' => '🚀',
                'desc' => 'Sit tight — the application is being backed up and updated. Do not close this page.',
            ],
            4 => [
                'icon' => '🎉',
                'desc' => 'The update is complete and your application is live again.',
            ],
            default => ['icon' => '⚙️', 'desc' => ''],
        };
    }

    private function renderHeaderStrip(string $title, int $currentStep): string
    {
        if ($currentStep === 0) {
            return $this->renderThemeToggleOnly();
        }

        $sidebar = $this->getSidebarInfo($currentStep);
        $icon = $sidebar['icon'];
        $desc = htmlspecialchars($sidebar['desc']);

        $dots = '';
        foreach (array_keys($this->stepNames) as $num) {
            $dots .= $this->renderStripDot($num < $currentStep, $num === $currentStep);
        }

        $label = "{$currentStep} of {$this->totalSteps}";

        return <<<HTML
        <div class="h-strip">
            <div class="h-strip-icon">{$icon}</div>
            <div class="h-strip-text">
                <h1>{$title}</h1>
                <p>{$desc}</p>
            </div>
            <div class="h-strip-side">
                <div class="h-strip-top">
                    <div class="h-strip-dots">{$dots}</div>
                    <button type="button" id="theme-toggle" aria-label="Toggle dark mode" class="h-theme-btn">
                        <span id="theme-toggle-icon">🌙</span>
                    </button>
                </div>
                <div class="h-strip-label">Step {$label}</div>
            </div>
        </div>
        HTML;
    }

    private function renderRequirementRow(array $r): string
    {
        $icon = $this->statusIcon($r['passed'] ? 'check' : ($r['critical'] ? 'x' : 'warning'), 16);
        $rowClass = $r['passed'] ? 'good' : ($r['critical'] ? 'bad' : 'warn');
        $badge = $r['passed'] ? 'Passed' : ($r['critical'] ? 'Critical' : 'Warning');
        $name = htmlspecialchars($r['name']);
        $detail = htmlspecialchars($r['detail']);

        return <<<HTML
        <div class="h-reqrow {$rowClass}">
            <div class="icon">{$icon}</div>
            <div class="body">
                <p class="t">{$name}</p>
                <p class="d">{$detail}</p>
            </div>
            <span class="badge">{$badge}</span>
        </div>
        HTML;
    }

    /**
     * Renders a set of related checks (e.g. all PHP version/extension
     * checks) as a single h-reqrow-sized box, same footprint as any other
     * check, instead of one row per check.
     */
    private function renderRequirementGroup(string $title, array $items): string
    {
        $anyCritical = false;
        $allPassed = true;
        $failing = [];

        foreach ($items as $r) {
            if (! $r['passed']) {
                $allPassed = false;
                $failing[] = $r['name'];
                if ($r['critical']) {
                    $anyCritical = true;
                }
            }
        }

        $rowClass = $allPassed ? 'good' : ($anyCritical ? 'bad' : 'warn');
        $badge = $allPassed ? 'Passed' : ($anyCritical ? 'Critical' : 'Warning');
        $icon = $this->statusIcon($allPassed ? 'check' : ($anyCritical ? 'x' : 'warning'), 16);
        $count = count($items);
        $detail = $allPassed
            ? "All {$count} checks passed."
            : 'Needs attention: '.implode(', ', $failing);
        $title = htmlspecialchars($title);
        $detail = htmlspecialchars($detail);

        return <<<HTML
        <div class="h-reqrow {$rowClass}">
            <div class="icon">{$icon}</div>
            <div class="body">
                <p class="t">{$title}</p>
                <p class="d">{$detail}</p>
            </div>
            <span class="badge">{$badge}</span>
        </div>
        HTML;
    }
}


trait RendersUpdaterSteps
{
    // ------------------------------------------------------------------
    // Step 0: token gate
    // ------------------------------------------------------------------

    private function renderTokenGate(): void
    {
        $tokenReady = $this->ensureRecoveryToken();
        $errorsHtml = $this->renderErrors();
        $productName = htmlspecialchars(APP_NAME);

        $tokenHelp = $tokenReady
            ? <<<'HTML'
            <ol style="margin:12px 0 0 20px; font-size:.86rem; line-height:1.9;">
                <li>Open <code>storage/app/updater/access-token.txt</code> in your hosting file manager (or over FTP/SSH).</li>
                <li>Copy its contents and paste the token below.</li>
            </ol>
            <p style="font-size:.78rem; margin-top:10px;" class="d">Only someone with filesystem access to this server can read that file — which is the point. Tokens are single-use.</p>
            HTML
            : <<<'HTML'
            <p class="d" style="font-size:.86rem; margin-top:10px;">The updater could not write its access token to <code>storage/app/updater/</code>. Fix the permissions on <code>storage/app</code> (writable by the web server user), then reload this page.</p>
            HTML;

        $content = <<<HTML
        <p class="h-lede">This is the standalone updater for <strong>{$productName}</strong>. To continue, prove you are the site operator.</p>

        {$errorsHtml}

        <div class="h-alert h-alert-warn" style="display:block;">
            <p class="t" style="margin:0 0 2px;">Access token required</p>
            {$tokenHelp}
        </div>

        <form method="post" action="{$this->selfUrlEscaped()}" style="margin-top:18px;">
            <div class="h-field">
                <label for="token">Access Token</label>
                <input type="text" id="token" name="token" autocomplete="off" spellcheck="false" placeholder="Paste the token from access-token.txt" required>
            </div>
            <div class="h-actions">
                <button type="submit" class="h-btn h-btn-primary" style="margin-left:auto;">Unlock Updater →</button>
            </div>
        </form>
        HTML;

        $this->renderLayout('Updater Access', $content, 0);
    }

    // ------------------------------------------------------------------
    // Step 1: status, history, upload
    // ------------------------------------------------------------------

    private function renderHome(): void
    {
        $version = $this->currentVersion();
        $requirements = $this->checkUpdateRequirements();
        $criticalsPass = true;

        $phpChecks = [];
        $rows = '';
        foreach ($requirements as $r) {
            if (($r['group'] ?? null) === 'php') {
                $phpChecks[] = $r;
            } else {
                $rows .= $this->renderRequirementRow($r);
            }
            if ($r['critical'] && ! $r['passed']) {
                $criticalsPass = false;
            }
        }
        if ($phpChecks !== []) {
            $rows = $this->renderRequirementGroup('PHP', $phpChecks).$rows;
        }

        $versionLabel = $version !== null ? 'v'.htmlspecialchars($version) : 'unknown';
        $historyHtml = $this->renderRecentResults();
        $errorsHtml = $this->renderErrors();
        $selfUrlJson = json_encode($this->selfUrl());

        $uploadSection = $criticalsPass
            ? <<<'HTML'
            <div class="h-alert" id="upload-zone" style="display:block; text-align:center; padding:28px; border-style:dashed; cursor:pointer;">
                <p class="t" style="margin-bottom:4px;">📦 Upload an update package</p>
                <p class="d">Click to choose the <code>.update</code> file you received, or drag it here. It will be uploaded in small chunks, then validated before anything is touched.</p>
                <input type="file" id="update-file" accept=".update" class="h-hidden">
            </div>

            <div id="upload-progress" class="h-progress-card h-hidden">
                <div class="h-progress-card-head">
                    <div class="left">
                        <div class="icn">⬆️</div>
                        <div>
                            <h3>Uploading package…</h3>
                            <p class="sub" id="upload-detail">Starting…</p>
                        </div>
                    </div>
                    <div>
                        <div class="pct" id="upload-percent">0%</div>
                        <div class="pct-label">Uploaded</div>
                    </div>
                </div>
                <div class="h-track"><div id="upload-bar" class="h-track-fill" style="width:0%"></div></div>
            </div>

            <div id="upload-error" class="h-alert h-alert-bad h-hidden">
                <div><p class="t">Upload Failed</p><p class="d" id="upload-error-message"></p></div>
            </div>
            HTML
            : <<<'HTML'
            <div class="h-alert h-alert-bad" style="display:block;">
                <p class="t">Updates are blocked</p>
                <p class="d">Fix the critical requirements above, then reload this page.</p>
            </div>
            HTML;

        $content = <<<HTML
        {$errorsHtml}

        <div class="h-reqrow good" style="margin-bottom:20px;">
            <div class="icon">🏷️</div>
            <div class="body">
                <p class="t">Installed Version</p>
                <p class="d">You are currently running {$versionLabel}.</p>
            </div>
            <span class="badge">{$versionLabel}</span>
        </div>

        <p class="h-section-title">Server Check</p>
        {$rows}

        {$historyHtml}

        <p class="h-section-title" style="margin-top:22px;">Apply an Update</p>
        {$uploadSection}

        <script>
            (function() {
                var zone = document.getElementById('upload-zone');
                if (!zone) return;

                var SELF_URL = {$selfUrlJson};
                var input = document.getElementById('update-file');
                var CHUNK = 1572864; // 1.5 MB — safely under shared-hosting upload_max_filesize floors

                zone.addEventListener('click', function() { input.click(); });
                zone.addEventListener('dragover', function(e) { e.preventDefault(); });
                zone.addEventListener('drop', function(e) {
                    e.preventDefault();
                    if (e.dataTransfer.files.length) startUpload(e.dataTransfer.files[0]);
                });
                input.addEventListener('change', function() {
                    if (input.files.length) startUpload(input.files[0]);
                });

                function fail(message) {
                    document.getElementById('upload-progress').classList.add('h-hidden');
                    zone.classList.remove('h-hidden');
                    document.getElementById('upload-error').classList.remove('h-hidden');
                    document.getElementById('upload-error-message').textContent = message;
                }

                function parseJsonResponse(r) {
                    if (!r.ok) {
                        return r.text().then(function(text) {
                            throw new Error('Server returned HTTP ' + r.status + ': ' + text.substring(0, 300));
                        });
                    }
                    return r.json();
                }

                function startUpload(file) {
                    if (!/\.update$/i.test(file.name)) {
                        fail('Please choose a .update package file.');
                        return;
                    }

                    document.getElementById('upload-error').classList.add('h-hidden');
                    zone.classList.add('h-hidden');
                    document.getElementById('upload-progress').classList.remove('h-hidden');

                    var totalChunks = Math.max(1, Math.ceil(file.size / CHUNK));
                    var initData = new FormData();
                    initData.append('name', file.name);
                    initData.append('size', file.size);
                    initData.append('total_chunks', totalChunks);

                    fetchWithCsrf(SELF_URL + '?ajax=upload-init', { body: initData })
                        .then(parseJsonResponse)
                        .then(function(data) {
                            if (!data.success) throw new Error(data.message);
                            sendChunk(file, data.upload_id, 0, totalChunks, false);
                        })
                        .catch(function(err) { fail(err.message); });
                }

                function sendChunk(file, uploadId, index, totalChunks, isRetry) {
                    var start = index * CHUNK;
                    var blob = file.slice(start, Math.min(start + CHUNK, file.size));

                    var form = new FormData();
                    form.append('upload_id', uploadId);
                    form.append('index', index);
                    form.append('chunk', blob, 'chunk');

                    fetchWithCsrf(SELF_URL + '?ajax=upload-chunk', { body: form })
                        .then(parseJsonResponse)
                        .then(function(data) {
                            // A definitive server answer (validation failure,
                            // chunk rejected) — never retried, unlike the
                            // transient errors handled in .catch below.
                            if (!data.success) { fail(data.message); return; }

                            var pct = Math.round(((index + 1) / totalChunks) * 100);
                            document.getElementById('upload-percent').textContent = pct + '%';
                            document.getElementById('upload-bar').style.width = pct + '%';
                            document.getElementById('upload-detail').textContent = 'Chunk ' + (index + 1) + ' of ' + totalChunks;

                            if (!data.complete) {
                                sendChunk(file, uploadId, index + 1, totalChunks, false);
                            } else if (data.valid) {
                                document.getElementById('upload-detail').textContent = 'Validated — v' + data.version;
                                window.location = SELF_URL;
                            } else {
                                fail(data.message);
                            }
                        })
                        .catch(function(err) {
                            // Transient failure (network drop, gateway 502,
                            // dropped response): retry this chunk once before
                            // giving up, so a single blip can't kill a
                            // multi-hundred-MB upload. The server treats a
                            // re-send of an already-received chunk as
                            // idempotent, so a lost *response* (chunk actually
                            // landed) is also safe to retry.
                            if (!isRetry) {
                                document.getElementById('upload-detail').textContent = 'Chunk ' + (index + 1) + ' failed — retrying…';
                                setTimeout(function() { sendChunk(file, uploadId, index, totalChunks, true); }, 2000);
                                return;
                            }
                            fail(err.message);
                        });
                }
            })();
        </script>
        HTML;

        $this->renderLayout('System Update', $content, 1);
    }

    private function renderRecentResults(): string
    {
        $files = glob($this->resultsDir().'/*.json') ?: [];
        rsort($files);
        $files = array_slice($files, 0, 5);

        if ($files === []) {
            return '';
        }

        $rows = '';
        foreach ($files as $file) {
            $result = json_decode((string) file_get_contents($file), true);
            if (! is_array($result)) {
                continue;
            }

            $status = (string) ($result['status'] ?? 'unknown');
            $good = $status === 'applied';
            $rowClass = $good ? 'good' : 'warn';
            $icon = $this->statusIcon($good ? 'check' : 'warning', 16);
            $from = htmlspecialchars((string) ($result['version_from'] ?? '?'));
            $to = htmlspecialchars((string) ($result['version_to'] ?? '?'));
            $when = htmlspecialchars((string) ($result['finished_at'] ?? ''));
            $badge = htmlspecialchars(str_replace('_', ' ', $status));

            $rows .= <<<HTML
            <div class="h-reqrow {$rowClass}">
                <div class="icon">{$icon}</div>
                <div class="body">
                    <p class="t">v{$from} → v{$to}</p>
                    <p class="d">{$when}</p>
                </div>
                <span class="badge">{$badge}</span>
            </div>
            HTML;
        }

        if ($rows === '') {
            return '';
        }

        return '<p class="h-section-title" style="margin-top:22px;">Recent Updates</p>'.$rows;
    }

    // ------------------------------------------------------------------
    // Step 2: review the validated package
    // ------------------------------------------------------------------

    private function renderReview(): void
    {
        $package = $_SESSION['updater']['package'];
        $current = htmlspecialchars((string) $this->currentVersion());
        $target = htmlspecialchars((string) $package['version']);
        $filesCount = $package['files_count'] !== null ? number_format((int) $package['files_count']).' files' : 'unknown file count';
        $signedRow = $package['signed_by'] !== null
            ? $this->renderRequirementRow(['name' => 'Signature Verified', 'detail' => "Signed with trusted key '{$package['signed_by']}' (Ed25519).", 'passed' => true, 'critical' => true])
            : $this->renderRequirementRow(['name' => 'Unsigned Package', 'detail' => 'This server does not require signed updates.', 'passed' => false, 'critical' => false]);

        $backupNote = ($this->backupConfig()['enabled'] ?? true)
            ? 'A full backup of your files'.(($this->backupConfig()['database']['enabled'] ?? true) ? ' and database' : '').' is taken first, and can be restored automatically if anything fails.'
            : 'Backups are disabled in this application\'s configuration — a failed update cannot be automatically rolled back.';

        $checkRow = $this->renderRequirementRow(['name' => 'Package Integrity', 'detail' => 'Checksum verified; no unsafe paths found in the archive.', 'passed' => true, 'critical' => true]);

        // The exact staged inner-zip size is known now (unlike at
        // upload-init's rough size-based estimate), so this is a firmer
        // check. The size heuristic lives in diskSpaceShortfall(), shared
        // with the upload gate and the start-update guard.
        $stagedZip = (string) ($package['staged_zip'] ?? '');
        $stagedSize = is_file($stagedZip) ? (int) filesize($stagedZip) : 0;
        $shortfall = $this->diskSpaceShortfall($stagedSize, $this->appRoot());

        $applyAction = $shortfall === null
            ? <<<HTML
            <form method="post" action="{$this->selfUrlEscaped()}" style="margin-left:auto;">
                {$this->csrfField()}
                <input type="hidden" name="action" value="start-update">
                <button type="submit" class="h-btn h-btn-primary">Apply Update →</button>
            </form>
            HTML
            : '';

        $spaceWarning = $shortfall === null ? '' : <<<HTML
            <div class="h-alert h-alert-bad" style="display:block; margin-top:16px;">
                <p class="t">Not enough free disk space</p>
                <p class="d">This update needs roughly {$this->formatBytes($shortfall['needed'])} free (backup + staged files + extraction), but only {$this->formatBytes($shortfall['free'])} is available. Free up space on the server, then reload this page.</p>
            </div>
            HTML;

        $content = <<<HTML
        <p class="h-lede">The uploaded package is valid and ready to apply.</p>

        <div class="h-reqrow good" style="margin-bottom:16px;">
            <div class="icon">🚀</div>
            <div class="body">
                <p class="t">v{$current} → v{$target}</p>
                <p class="d">{$filesCount} will be applied.</p>
            </div>
            <span class="badge">Ready</span>
        </div>

        {$checkRow}
        {$signedRow}

        <div class="h-alert h-alert-warn" style="display:block; margin-top:16px;">
            <p class="t">What happens next</p>
            <p class="d">The site goes into maintenance mode while the update runs. {$backupNote}</p>
        </div>

        {$spaceWarning}

        <div class="h-actions" style="margin-top:18px;">
            <form method="post" action="{$this->selfUrlEscaped()}">
                {$this->csrfField()}
                <input type="hidden" name="action" value="cancel-package">
                <button type="submit" class="h-btn h-btn-ghost">← Cancel</button>
            </form>
            {$applyAction}
        </div>
        HTML;

        $this->renderLayout('Review Update', $content, 2);
    }

    // ------------------------------------------------------------------
    // Step 3: progress
    // ------------------------------------------------------------------

    private function renderProgress(): void
    {
        $tasks = [
            'prepare' => 'Enabling maintenance mode',
            'backup_files' => 'Backing up application files',
            'backup_db' => 'Backing up database',
            'extract' => 'Extracting update files',
            'post_update' => 'Running migrations and refreshing caches',
            'verify' => 'Verifying the new version',
            'finalize' => 'Finalizing and bringing the site back',
        ];

        $update = $_SESSION['updater']['update'];
        $completedTasks = $update['completed_tasks'] ?? [];
        $target = htmlspecialchars((string) ($update['version_to'] ?? ''));

        $taskList = '';
        foreach ($tasks as $key => $label) {
            $status = in_array($key, $completedTasks, true) ? 'done' : 'pending';
            $taskList .= "<div class=\"h-task task-item\" data-task=\"{$key}\" data-status=\"{$status}\">";
            $taskList .= '<span class="icon task-icon"></span>';
            $taskList .= "<span class=\"label task-label\">{$label}</span>";
            $taskList .= '<span class="msg task-message"></span>';
            $taskList .= '</div>';
        }

        $tasksJson = json_encode(array_keys($tasks));
        $xIcon = $this->statusIcon('x');
        $checkIconJson = json_encode($this->statusIcon('check'));
        $xIconJson = json_encode($this->statusIcon('x'));
        $selfUrlJson = json_encode($this->selfUrl());

        $content = <<<HTML
        <p class="h-lede">Updating to <strong>v{$target}</strong>. <strong>Please do not close or refresh this page.</strong></p>

        <div class="h-progress-card">
            <div class="h-progress-card-head">
                <div class="left">
                    <div class="icn">⚡</div>
                    <div>
                        <h3>Update Progress</h3>
                        <p class="sub">Applying v{$target}</p>
                    </div>
                </div>
                <div>
                    <div class="pct" id="progress-percent">0%</div>
                    <div class="pct-label">Complete</div>
                </div>
            </div>
            <div class="h-track"><div id="progress-bar" class="h-track-fill" style="width: 0%"></div></div>
            <div class="h-tasklist" id="task-list">{$taskList}</div>
        </div>

        <div id="update-error" class="h-hidden h-alert h-alert-bad">
            <div class="icon">{$xIcon}</div>
            <div>
                <p class="t">Update Failed</p>
                <p class="d" id="error-message"></p>
            </div>
        </div>

        <div class="h-actions h-hidden" id="update-actions">
            <div class="h-actions-group" style="margin-left:auto;">
                <button type="button" class="h-btn h-btn-ghost h-hidden" id="retry-btn">Retry Step</button>
                <button type="button" class="h-btn h-btn-primary h-hidden" id="restore-btn">Restore Backup</button>
            </div>
        </div>

        <script>
            var tasks = {$tasksJson};
            var SELF_URL = {$selfUrlJson};
            var currentTaskIndex = 0;

            document.querySelectorAll('.task-item[data-status="done"]').forEach(function(el) {
                setTaskState(el, 'done');
                currentTaskIndex++;
            });

            var SPINNER_SVG = '<svg class="h-spin" width="16" height="16" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';

            var TASK_STATE_HTML = {
                pending: '',
                active: SPINNER_SVG,
                done: {$checkIconJson},
                error: {$xIconJson},
            };

            function setTaskState(el, state, message) {
                el.dataset.status = state;
                el.className = 'h-task task-item' + (state !== 'pending' ? ' ' + state : '');
                el.querySelector('.task-icon').innerHTML = TASK_STATE_HTML[state];
                el.querySelector('.task-message').textContent = message || '';
            }

            function updateProgress() {
                var percent = Math.round((currentTaskIndex / tasks.length) * 100);
                document.getElementById('progress-percent').textContent = percent + '%';
                document.getElementById('progress-bar').style.width = percent + '%';
            }

            function parseJsonResponse(r) {
                if (!r.ok) {
                    return r.text().then(function(text) {
                        throw new Error('Server returned HTTP ' + r.status + ': ' + text.substring(0, 500));
                    });
                }
                return r.json();
            }

            function handleTaskError(el, message, canRestore) {
                setTaskState(el, 'error', '');
                document.getElementById('update-error').classList.remove('h-hidden');
                document.getElementById('error-message').textContent = message;
                document.getElementById('update-actions').classList.remove('h-hidden');
                document.getElementById('retry-btn').classList.remove('h-hidden');
                if (canRestore) {
                    document.getElementById('restore-btn').classList.remove('h-hidden');
                }
            }

            function runTask(taskName, el) {
                setTaskState(el, 'active');
                fetchWithCsrf(SELF_URL + '?ajax=update-task&task=' + taskName)
                    .then(parseJsonResponse)
                    .then(function(data) {
                        if (!data.success) {
                            handleTaskError(el, data.message, !!data.can_restore);
                            return;
                        }
                        if (data.task_done === false) {
                            setTaskState(el, 'active', data.message);
                            runTask(taskName, el);
                            return;
                        }
                        setTaskState(el, 'done', data.message || '');
                        currentTaskIndex++;
                        updateProgress();
                        runNextTask();
                    })
                    .catch(function(err) {
                        handleTaskError(el, err.message, false);
                    });
            }

            function runNextTask() {
                if (currentTaskIndex >= tasks.length) {
                    // finalize cleared the run state server-side; reloading
                    // lands on the Complete screen.
                    window.location = SELF_URL;
                    return;
                }

                var task = tasks[currentTaskIndex];
                runTask(task, document.querySelector('.task-item[data-task="' + task + '"]'));
            }

            document.getElementById('retry-btn').addEventListener('click', function() {
                document.getElementById('update-error').classList.add('h-hidden');
                document.getElementById('update-actions').classList.add('h-hidden');
                document.getElementById('retry-btn').classList.add('h-hidden');
                document.getElementById('restore-btn').classList.add('h-hidden');
                runNextTask();
            });

            document.getElementById('restore-btn').addEventListener('click', function() {
                document.getElementById('update-error').classList.add('h-hidden');
                document.getElementById('update-actions').classList.add('h-hidden');

                var el = document.querySelector('.task-item[data-status="error"]') || document.querySelector('.task-item');
                setTaskState(el, 'active', 'Restoring backup…');

                (function restoreLoop() {
                    fetchWithCsrf(SELF_URL + '?ajax=update-task&task=restore')
                        .then(parseJsonResponse)
                        .then(function(data) {
                            if (!data.success) {
                                handleTaskError(el, 'Restore failed: ' + data.message, false);
                                return;
                            }
                            setTaskState(el, 'active', data.message);
                            if (data.task_done === false) {
                                restoreLoop();
                            } else {
                                window.location = SELF_URL + '?screen=home';
                            }
                        })
                        .catch(function(err) {
                            handleTaskError(el, 'Restore failed: ' + err.message, false);
                        });
                })();
            });

            updateProgress();
            runNextTask();
        </script>
        HTML;

        $this->renderLayout('Updating', $content, 3);
    }

    // ------------------------------------------------------------------
    // Step 4: complete
    // ------------------------------------------------------------------

    private function renderComplete(): void
    {
        $done = $_SESSION['updater']['done'] ?? [];
        $to = htmlspecialchars((string) ($done['version'] ?? ''));
        $from = htmlspecialchars((string) ($done['version_from'] ?? ''));
        $productName = htmlspecialchars(APP_NAME);
        $runAnotherUrl = htmlspecialchars($this->selfUrl().'?screen=home');

        $content = <<<HTML
        <div class="h-success-head">
            <div class="h-success-icon">🎉</div>
            <h2 style="margin:8px 0 4px;">Update Complete!</h2>
            <p class="h-lede">{$productName} was updated from v{$from} to <strong>v{$to}</strong> and is live again.</p>
        </div>

        <div class="h-alert h-alert-good" style="display:block; margin-top:12px;">
            <p class="t">The updater refreshed itself too</p>
            <p class="d">The version of this updater that shipped inside the package is now in place for next time. Your pre-update backup is kept in <code>storage/app/updater/backups/</code> until newer backups rotate it out.</p>
        </div>

        <div class="h-actions" style="margin-top:20px;">
            <a href="{$runAnotherUrl}" class="h-btn h-btn-ghost">Run Another Update</a>
            <a href="/" class="h-btn h-btn-primary" style="margin-left:auto;">Go to Site →</a>
        </div>
        HTML;

        $this->renderLayout('Complete', $content, 4);
    }
}


/**
 * The update pipeline, one AJAX task per unit of work:
 *
 *   prepare → backup_files* → backup_db → extract* → post_update → verify → finalize
 *
 * Tasks marked * are batched: they return task_done=false and the browser
 * calls the same task again until it reports done — the installer's
 * gateway-timeout discipline. The framework is never booted during any of
 * this except post_update, which runs the hook shipped inside the signed
 * package after every file is already in place.
 *
 * On failure the browser offers a restore, which replays the pre-update
 * backup (files, deletions, database) the same batched way.
 */
trait RunsUpdateTasks
{
    /**
     * Default/ceiling files-per-batch for extraction, overridable per-app
     * via config/updates.php's `extraction_batch_size` (e.g. lowered on
     * memory-constrained shared hosts). Also shrunk adaptively at runtime —
     * see taskExtract() — when a batch runs close to or over
     * BATCH_BUDGET_SECONDS.
     */
    private const EXTRACT_BATCH_FILES_DEFAULT = 400;

    /** Locks stale after this many seconds are considered abandoned. */
    private const LOCK_STALE_SECONDS = 1800;

    private function extractBatchFilesCeiling(): int
    {
        $configured = (int) ($this->updatesConfig()['extraction_batch_size'] ?? self::EXTRACT_BATCH_FILES_DEFAULT);

        return $configured > 0 ? $configured : self::EXTRACT_BATCH_FILES_DEFAULT;
    }

    private function lockFile(): string
    {
        return $this->updaterStorageDir().'/update.lock';
    }

    private function handleUpdateTask(string $task): void
    {
        header('Content-Type: application/json');

        $update = &$_SESSION['updater']['update'];

        if (empty($update['started'])) {
            echo json_encode(['success' => false, 'message' => 'No update run in progress.']);
            exit;
        }

        $completed = $update['completed_tasks'] ?? [];
        if (in_array($task, $completed, true) && $task !== 'restore') {
            echo json_encode(['success' => true, 'task_done' => true, 'message' => 'Already completed.']);
            exit;
        }

        try {
            $result = match ($task) {
                'prepare' => $this->taskPrepare(),
                'backup_files' => $this->taskBackupFiles(),
                'backup_db' => $this->taskBackupDb(),
                'extract' => $this->taskExtract(),
                'post_update' => $this->taskPostUpdate(),
                'verify' => $this->taskVerify(),
                'finalize' => $this->taskFinalize(),
                'restore' => $this->taskRestore(),
                default => throw new RuntimeException('Unknown task.'),
            };

            if (($result['task_done'] ?? true) && $task !== 'restore') {
                $update['completed_tasks'][] = $task;
            }

            echo json_encode(array_merge(['success' => true, 'task_done' => true], $result));
        } catch (Throwable $e) {
            $update['failed'] = ['task' => $task, 'message' => $e->getMessage()];

            echo json_encode([
                'success' => false,
                'message' => $e->getMessage(),
                'can_restore' => $this->backupIsUsable(),
            ]);
        }

        exit;
    }

    private function backupIsUsable(): bool
    {
        $update = $_SESSION['updater']['update'] ?? [];

        return in_array('backup_files', $update['completed_tasks'] ?? [], true)
            && ! empty($update['backup_id']);
    }

    // ------------------------------------------------------------------
    // Forward pipeline
    // ------------------------------------------------------------------

    private function taskPrepare(): array
    {
        $lock = $this->lockFile();

        if (file_exists($lock)) {
            $age = time() - (int) filemtime($lock);
            $ownLock = hash_equals((string) ($_SESSION['updater']['update']['lock_token'] ?? ''), trim((string) file_get_contents($lock)));

            if (! $ownLock && $age < self::LOCK_STALE_SECONDS) {
                throw new RuntimeException('Another update appears to be in progress (lock held for '.$age.'s). Wait for it to finish, or delete storage/app/updater/update.lock if it crashed.');
            }
        }

        $lockToken = bin2hex(random_bytes(16));
        if (@file_put_contents($lock, $lockToken) === false) {
            throw new RuntimeException('Failed to write the update lock file.');
        }
        $_SESSION['updater']['update']['lock_token'] = $lockToken;

        // On-disk record that a run is underway — unlike the session, this
        // survives a lost browser session, letting a re-upload of the same
        // version through validation to finish an interrupted run (see
        // updateInProgressFile()). Cleared at finalize and after a restore.
        $this->writeUpdateInProgressMarker($_SESSION['updater']['update']['version_to'] ?? null);

        $this->enableMaintenanceMode();

        return ['message' => 'Maintenance mode enabled.'];
    }

    private function taskBackupFiles(): array
    {
        $update = &$_SESSION['updater']['update'];
        $backup = $this->backupConfig();

        if (! ($backup['enabled'] ?? true)) {
            return ['message' => 'Skipped (backups disabled in config).'];
        }

        if (empty($update['backup_id'])) {
            $backupId = $this->newBackupId();
            $total = $this->buildBackupFileList($backupId);

            $update['backup_id'] = $backupId;
            $update['backup_total'] = $total;
            $update['backup_offset'] = 0;

            return [
                'task_done' => false,
                'message' => "Cataloged {$total} files to back up…",
            ];
        }

        $state = $this->appendBackupBatch(
            $update['backup_id'],
            (int) $update['backup_offset'],
            (int) $update['backup_total']
        );

        $update['backup_offset'] = $state['offset'];

        return [
            'task_done' => $state['done'],
            'message' => "Backed up {$state['offset']}/{$state['total']} files",
        ];
    }

    private function taskBackupDb(): array
    {
        $update = $_SESSION['updater']['update'];
        $backup = $this->backupConfig();

        if (! ($backup['enabled'] ?? true) || empty($update['backup_id'])) {
            return ['message' => 'Skipped (backups disabled).'];
        }

        return ['message' => $this->backupDatabase($update['backup_id'])];
    }

    private function taskExtract(): array
    {
        $update = &$_SESSION['updater']['update'];
        $package = $_SESSION['updater']['package'] ?? [];

        $stagedZip = (string) ($package['staged_zip'] ?? '');
        if ($stagedZip === '' || ! file_exists($stagedZip)) {
            throw new RuntimeException('The staged update archive is missing. Upload the package again.');
        }

        $zip = new ZipArchive;
        if ($zip->open($stagedZip) !== true) {
            throw new RuntimeException('Failed to open the staged update archive.');
        }

        $total = $zip->numFiles;
        $offset = (int) ($update['extract_offset'] ?? 0);
        $base = $this->appRoot();
        $prefix = $this->slug().'/';
        $prefixLen = strlen($prefix);
        $started = microtime(true);
        $processed = 0;
        $batchCeiling = $this->extractBatchFilesCeiling();

        for ($i = $offset; $i < $total; $i++) {
            if ($processed >= $batchCeiling || (microtime(true) - $started) >= self::BATCH_BUDGET_SECONDS) {
                break;
            }

            $entryName = (string) $zip->getNameIndex($i);
            $offset++;
            $processed++;

            if ($entryName === '' || str_ends_with($entryName, '/') || ! str_starts_with($entryName, $prefix)) {
                continue;
            }

            $relative = substr($entryName, $prefixLen);

            if ($this->isSkippedDuringExtraction($relative)) {
                continue;
            }

            $targetFile = $base.'/'.$relative;
            $targetDir = dirname($targetFile);

            if (! is_dir($targetDir) && ! @mkdir($targetDir, 0755, true) && ! is_dir($targetDir)) {
                $zip->close();
                throw new RuntimeException("Failed to create directory {$targetDir}.");
            }

            $stream = $zip->getStream($entryName);
            if ($stream === false) {
                $zip->close();
                throw new RuntimeException("Failed to read {$entryName} from the update archive. The file may be corrupted.");
            }

            $out = fopen($targetFile, 'wb');
            if ($out === false) {
                fclose($stream);
                $zip->close();
                throw new RuntimeException("Failed to write {$relative}. Check disk space and permissions.");
            }

            stream_copy_to_stream($stream, $out);
            fclose($stream);
            fclose($out);
        }

        $zip->close();

        $update['extract_offset'] = $offset;
        $update['extract_total'] = $total;

        return [
            'task_done' => $offset >= $total,
            'message' => "Extracted {$offset}/{$total} files",
            'percent' => $total > 0 ? (int) round(($offset / $total) * 100) : 100,
        ];
    }

    /**
     * Paths never overwritten by extraction: the operator's protected paths
     * from config, SQLite data files, the updater's own working area, and
     * updater.php itself (this very script, executing right now — the fresh
     * copy is swapped in at finalize, when nothing else remains to run).
     */
    private function isSkippedDuringExtraction(string $relative): bool
    {
        if ($relative === 'public/updater.php') {
            return true;
        }

        if (str_starts_with($relative, 'storage/app/updater/')) {
            return true;
        }

        return $this->isProtectedRelativePath($relative);
    }

    private function isProtectedRelativePath(string $relative): bool
    {
        foreach ($this->protectedPaths() as $protected) {
            if (str_ends_with($protected, '/') && str_starts_with($relative, $protected)) {
                return true;
            }

            if ($relative === $protected) {
                return true;
            }
        }

        if (str_starts_with($relative, 'database/') && str_ends_with($relative, '.sqlite')) {
            return true;
        }

        return false;
    }

    /**
     * Run the post-update hook the package shipped inside its signed inner
     * zip (extracted to .updater/post_update.php). The hook boots the
     * freshly-updated application exactly once and runs migrations plus the
     * cache/optimize commands from the app's own config.
     */
    private function taskPostUpdate(): array
    {
        $hook = $this->appRoot().'/.updater/post_update.php';

        if (! is_file($hook)) {
            throw new RuntimeException('The update package did not provide a post-update hook (.updater/post_update.php). It may have been built with an older toolkit.');
        }

        ob_start();

        try {
            $result = require $hook;
        } finally {
            ob_end_clean();
        }

        if (! is_array($result) || ! array_key_exists('success', $result)) {
            throw new RuntimeException('The post-update hook returned an unexpected result.');
        }

        if (! $result['success']) {
            throw new RuntimeException('Post-update failed: '.(string) ($result['output'] ?? 'unknown error'));
        }

        return ['message' => 'Migrations and caches complete.'];
    }

    private function taskVerify(): array
    {
        $expected = (string) ($_SESSION['updater']['update']['version_to'] ?? '');
        $actual = $this->currentVersion();

        if ($expected === '' || $actual !== $expected) {
            throw new RuntimeException("Version verification failed: expected v{$expected}, found v".($actual ?? 'unknown').'. The update may not have been applied correctly.');
        }

        return ['message' => "v{$expected} verified"];
    }

    private function taskFinalize(): array
    {
        $update = &$_SESSION['updater']['update'];
        $package = $_SESSION['updater']['package'] ?? [];

        // Swap in the fresh updater shipped inside the package. This
        // overwrites the currently-executing file, which is safe: this
        // request's code is already loaded, and every later request parses
        // the new copy.
        $this->swapInNewUpdater((string) ($package['staged_zip'] ?? ''));

        $this->writeResult('applied', [
            'version_from' => $update['version_from'] ?? null,
            'version_to' => $update['version_to'] ?? null,
            'backup_id' => $update['backup_id'] ?? null,
            'started_at' => $update['started_at'] ?? null,
        ]);

        foreach ([(string) ($package['path'] ?? ''), (string) ($package['staged_zip'] ?? '')] as $file) {
            if ($file !== '' && file_exists($file)) {
                @unlink($file);
            }
        }

        $keep = (int) ($this->backupConfig()['keep'] ?? 3);
        $this->pruneBackups(max(1, $keep));

        $this->disableMaintenanceMode();
        $this->clearUpdateInProgressMarker();
        @unlink($this->lockFile());

        $update['finished'] = true;
        $_SESSION['updater']['done'] = [
            'version' => $update['version_to'] ?? null,
            'version_from' => $update['version_from'] ?? null,
            'finished_at' => date('c'),
        ];
        unset($_SESSION['updater']['update'], $_SESSION['updater']['package']);

        return ['message' => 'Application is live again.'];
    }

    private function swapInNewUpdater(string $stagedZip): void
    {
        if ($stagedZip === '' || ! file_exists($stagedZip)) {
            return;
        }

        $zip = new ZipArchive;
        if ($zip->open($stagedZip) !== true) {
            return;
        }

        $entry = $this->slug().'/public/updater.php';

        if ($zip->locateName($entry) !== false) {
            $content = $zip->getFromName($entry);

            if ($content !== false && $content !== '') {
                // Write to a temp file in the same directory and rename()
                // into place — a plain file_put_contents() over the running
                // script would let a request that lands mid-write parse a
                // truncated file. rename() is atomic within a filesystem, so
                // every request sees either the old or the new script, never
                // a partial one.
                $target = $this->appRoot().'/public/updater.php';
                $tempTarget = $target.'.new-'.bin2hex(random_bytes(4));

                if (@file_put_contents($tempTarget, $content) !== false) {
                    if (! @rename($tempTarget, $target)) {
                        @unlink($tempTarget);
                    }
                }
            }
        }

        $zip->close();
    }

    /**
     * Append the run's outcome to storage/app/updater/results/ — the
     * updater's history and the single source of truth for recent results,
     * read directly by RecentUpdateResults for the admin panel.
     */
    private function writeResult(string $status, array $extra): void
    {
        $payload = array_merge([
            'status' => $status,
            'finished_at' => date('c'),
            'updater_version' => UPDATER_VERSION,
        ], $extra);

        $path = $this->resultsDir().'/'.date('Ymd-His').'-'.$status.'.json';

        @file_put_contents($path, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
        // No secrets in here (status/versions/timestamps only), but matches
        // the rest of storage/app/updater's file permissions as defense in
        // depth on a shared host where other local users could otherwise
        // read operational history.
        @chmod($path, 0600);
    }

    // ------------------------------------------------------------------
    // Restore (after a failed run)
    // ------------------------------------------------------------------

    private function taskRestore(): array
    {
        $update = &$_SESSION['updater']['update'];

        if (! $this->backupIsUsable()) {
            throw new RuntimeException('No usable backup to restore from.');
        }

        $backupId = (string) $update['backup_id'];
        $phase = (string) ($update['restore_phase'] ?? 'files');

        if ($phase === 'files') {
            $state = $this->restoreBackupBatch($backupId, (int) ($update['restore_offset'] ?? 0));
            $update['restore_offset'] = $state['offset'];

            if (! $state['done']) {
                return ['task_done' => false, 'message' => "Restored {$state['offset']}/{$state['total']} files"];
            }

            $update['restore_phase'] = 'cleanup';

            return ['task_done' => false, 'message' => 'Files restored. Cleaning up…'];
        }

        if ($phase === 'cleanup') {
            $deleted = $this->deleteFilesAbsentFromBackup($backupId);
            $update['restore_phase'] = 'database';

            return ['task_done' => false, 'message' => "Removed {$deleted} files the update had added. Restoring database…"];
        }

        // database + bring the site back up
        $dbMessage = $this->restoreDatabase($backupId);

        $this->writeResult('rolled_back', [
            'version_from' => $update['version_from'] ?? null,
            'version_to' => $update['version_to'] ?? null,
            'backup_id' => $backupId,
            'failed_task' => $update['failed']['task'] ?? null,
            'error' => $update['failed']['message'] ?? null,
        ]);

        $this->disableMaintenanceMode();
        $this->clearUpdateInProgressMarker();
        @unlink($this->lockFile());

        unset($_SESSION['updater']['update'], $_SESSION['updater']['package']);

        return ['task_done' => true, 'message' => 'Restore complete. '.$dbMessage];
    }
}


/**
 * Validation of an uploaded .update package: manifest sanity, version
 * ordering, checksum, Ed25519 signature, and a path-traversal scan of the
 * inner zip. This is a dependency-free port of the Laravel package's
 * UpdateService::validateUpdateZip() — the canonical payload format below
 * MUST stay in lockstep with InstallerToolkit\Update\UpdateSignature.
 */
trait ValidatesUpdatePackage
{
    private const SIGNATURE_FORMAT_VERSION = 1;

    /**
     * @return array{valid: bool, error: ?string, version: ?string, files_count: ?int, minimum_php: ?string, signed_by: ?string}
     */
    private function validateUpdatePackage(string $zipPath, string $uploadId): array
    {
        $fail = fn (string $error) => [
            'valid' => false, 'error' => $error, 'version' => null,
            'files_count' => null, 'minimum_php' => null, 'signed_by' => null,
        ];

        $currentVersion = $this->currentVersion();

        if ($currentVersion === null) {
            return $fail('Could not determine the installed version from config/app.php.');
        }

        if (! file_exists($zipPath)) {
            return $fail('Update file not found.');
        }

        $zip = new ZipArchive;
        if ($zip->open($zipPath) !== true) {
            return $fail('Unable to open update file. The zip may be corrupted.');
        }

        $manifestContent = $zip->getFromName('manifest.json');
        if ($manifestContent === false) {
            $zip->close();

            return $fail('Invalid update package: missing manifest.json.');
        }

        $manifest = json_decode($manifestContent, true);
        if (! is_array($manifest)) {
            $zip->close();

            return $fail('Invalid update package: manifest.json is malformed.');
        }

        if (($manifest['type'] ?? '') !== 'update') {
            $zip->close();

            return $fail('Invalid update package: not an update package.');
        }

        $updateVersion = (string) ($manifest['version'] ?? '');
        if (! preg_match('/^\d+\.\d+\.\d+$/', $updateVersion)) {
            $zip->close();

            return $fail("Invalid version format in update package: '{$updateVersion}'.");
        }

        // Ordinarily the package must be strictly newer. The one exception:
        // config/app.php's version literal is overwritten by extraction
        // partway through applying an update, before post_update/verify/
        // finalize complete — so a run that fails after extraction but
        // before finalize leaves the site on the new version literal.
        // Without this exception, re-uploading that exact same package to
        // finish the job would be rejected as "not newer than current",
        // stranding the site between versions with no way to retry. The
        // on-disk marker written at taskPrepare() (and cleared at finalize/
        // restore) is what distinguishes an interrupted run from a normal
        // completed update — and it survives a lost browser session, which
        // is precisely the stranding scenario.
        $sameVersionInterrupted = version_compare($updateVersion, $currentVersion, '==')
            && $this->interruptedUpdateVersion() === $updateVersion;

        if (version_compare($updateVersion, $currentVersion, '<=') && ! $sameVersionInterrupted) {
            $zip->close();

            return $fail("Update version ({$updateVersion}) must be newer than the current version ({$currentVersion}).");
        }

        $minimumVersion = (string) ($manifest['minimum_version'] ?? '1.0.0');
        if (version_compare($currentVersion, $minimumVersion, '<')) {
            $zip->close();

            return $fail("This update requires version {$minimumVersion} or later. You are running {$currentVersion}.");
        }

        $minimumPhp = (string) ($manifest['minimum_php'] ?? '8.2.0');
        if (version_compare(PHP_VERSION, $minimumPhp, '<')) {
            $zip->close();

            return $fail("This update requires PHP {$minimumPhp} or later. You are running PHP ".PHP_VERSION.'.');
        }

        // Checked here with the other manifest-shape validations, before the
        // inner zip is streamed to disk — a package missing its checksum is
        // rejected without paying for a potentially hundreds-of-MB copy.
        $expectedChecksum = (string) ($manifest['checksum'] ?? '');
        if ($expectedChecksum === '') {
            $zip->close();

            return $fail('Invalid update package: manifest.json is missing a checksum.');
        }

        $innerZipName = $this->innerZipName();

        if ($zip->locateName($innerZipName) === false) {
            $zip->close();

            return $fail("Invalid update package: missing {$innerZipName}.");
        }

        // Stream the inner zip out to the staging file — real packages run
        // to hundreds of MB, and getFromName() would load the whole thing
        // into memory (fatal under a shared host's memory_limit). The staged
        // copy serves the checksum and traversal checks below, then becomes
        // the extraction source, so this copy happens exactly once.
        $stagedPath = $this->stagedZipPath($uploadId);

        try {
            $this->copyZipEntryToFile($zip, $innerZipName, $stagedPath);
        } catch (RuntimeException) {
            $zip->close();
            @unlink($stagedPath);

            return $fail('Failed to read the inner update archive. The file may be corrupted.');
        }

        $zip->close();

        if (hash_file('sha256', $stagedPath) !== $expectedChecksum) {
            @unlink($stagedPath);

            return $fail('Update package integrity check failed. The file may be corrupted.');
        }

        $signatureError = $this->verifyManifestSignature($manifest, $updateVersion, $minimumVersion, $expectedChecksum);
        if ($signatureError !== null) {
            @unlink($stagedPath);

            return $fail($signatureError);
        }

        // Verify no path traversal in the inner zip.
        $innerZip = new ZipArchive;
        if ($innerZip->open($stagedPath) === true) {
            for ($i = 0; $i < $innerZip->numFiles; $i++) {
                $name = (string) $innerZip->getNameIndex($i);
                if (str_contains($name, '..')) {
                    $innerZip->close();
                    @unlink($stagedPath);

                    return $fail('Update package contains invalid file paths.');
                }
            }
            $innerZip->close();
        }

        return [
            'valid' => true,
            'error' => null,
            'version' => $updateVersion,
            'files_count' => isset($manifest['files_count']) ? (int) $manifest['files_count'] : null,
            'minimum_php' => $minimumPhp,
            'signed_by' => isset($manifest['key_id']) ? (string) $manifest['key_id'] : null,
        ];
    }

    /**
     * Verify the manifest's Ed25519 signature. Returns null when the package
     * is acceptable (either verified, or no trusted keys are configured so
     * verification is not required), or an error message when it must be
     * rejected. Fail-closed, identical to the Laravel-side check.
     *
     * @param  array<string, mixed>  $manifest
     */
    private function verifyManifestSignature(array $manifest, string $version, string $minimumVersion, string $checksum): ?string
    {
        $trustedKeys = $this->trustedKeys();

        if ($trustedKeys === []) {
            return null;
        }

        if (! extension_loaded('sodium')) {
            return 'Signature verification is required but the sodium PHP extension is not available on this server.';
        }

        $signature = (string) ($manifest['signature'] ?? '');
        $keyId = (string) ($manifest['key_id'] ?? '');

        if ($signature === '' || $keyId === '') {
            return 'This update package is not signed, but signature verification is required on this server.';
        }

        if (! isset($trustedKeys[$keyId])) {
            return "This update package is signed with an unrecognized key ('{$keyId}').";
        }

        // Must byte-match InstallerToolkit\Update\UpdateSignature::canonicalPayload().
        $payload = implode("\n", [
            'update-signature-v'.self::SIGNATURE_FORMAT_VERSION,
            $this->slug(),
            $version,
            $minimumVersion,
            $checksum,
            $keyId,
        ]);

        $publicKey = base64_decode($trustedKeys[$keyId], true);
        $signatureRaw = base64_decode($signature, true);

        if ($publicKey === false || strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES
            || $signatureRaw === false || strlen($signatureRaw) !== SODIUM_CRYPTO_SIGN_BYTES
            || ! sodium_crypto_sign_verify_detached($signatureRaw, $payload, $publicKey)) {
            return 'Update package signature verification failed. The file may have been tampered with.';
        }

        return null;
    }

    /**
     * Stream a single zip entry to a file on disk without buffering the
     * whole entry in memory.
     */
    private function copyZipEntryToFile(ZipArchive $zip, string $entryName, string $targetPath): void
    {
        $source = $zip->getStream($entryName);
        if ($source === false) {
            throw new RuntimeException("Failed to read {$entryName} from the archive.");
        }

        $target = fopen($targetPath, 'wb');
        if ($target === false) {
            fclose($source);
            throw new RuntimeException("Failed to open {$targetPath} for writing.");
        }

        $copied = stream_copy_to_stream($source, $target);
        fclose($source);
        fclose($target);

        if ($copied === false) {
            @unlink($targetPath);
            throw new RuntimeException("Failed to write {$targetPath}. Check disk space and permissions.");
        }
    }
}


class Updater
{
    private array $errors = [];

    /** @var array<string, mixed>|null Cached, resolved config/updates.php values. */
    private ?array $updatesConfig = null;

    private int $totalSteps = 4;

    private array $stepNames = [
        1 => 'Status',
        2 => 'Review',
        3 => 'Update',
        4 => 'Complete',
    ];

        use ProvidesUtilities;
    use RendersChrome;
    use BootsUpdaterEnvironment;
    use ChecksUpdateRequirements;
    use GuardsUpdaterAccess;
    use HandlesUpdaterRequests;
    use HandlesUploads;
    use ManagesBackups;
    use RendersUpdaterLayout;
    use RendersUpdaterSteps;
    use RunsUpdateTasks;
    use ValidatesUpdatePackage;
}


// ============================================================================
// Run the updater
// ============================================================================
// renderFatalErrorPage() is the shared fatal handler inlined from
// templates/shared/functions.php by bin/build.

$updater = null;

try {
    $updater = new Updater;
    $updater->run();
} catch (Throwable $e) {
    // An unauthenticated visitor must not see exception detail (paths,
    // stack trace) for a live production site — only a session that has
    // passed the token gate is entitled to it. $updater may be null (the
    // exception happened during construction, before a session could even
    // be started), which is exactly the "not authorized" case.
    renderFatalErrorPage(
        $e,
        'Application Updater',
        defined('UPDATER_VERSION') ? UPDATER_VERSION : 'unknown',
        defined('APP_NAME') ? APP_NAME : '',
        $updater?->isAuthorized() ?? false
    );
}
