<?php
declare(strict_types=1);

class Updater
{
    const MANIFEST_URL    = 'https://plenvo.gp-server.com/update/manifest';
    const CHECK_INTERVAL  = 3600; // seconds between remote checks

    // Hard caps on what we accept from the update channel. Manifests are
    // tiny JSON; release ZIPs are normally a few MB. The caps defend
    // against a forged or corrupted manifest pointing at a multi-GB blob
    // that would either OOM the worker or fill the disk.
    private const MAX_MANIFEST_BYTES = 65536;             // 64 KB
    private const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024; // 100 MB

    // ── Version check ────────────────────────────────────────────────────────

    public static function getRemoteInfo(bool $force = false): ?array
    {
        $db = Database::getInstance();
        if (($db->fetch("SELECT value FROM settings WHERE key='updates_enabled'")['value'] ?? '1') === '0') {
            return null;
        }

        $lastCheck = (int)($db->fetch("SELECT value FROM settings WHERE key='update_last_check'")['value'] ?? 0);
        $cached    =       $db->fetch("SELECT value FROM settings WHERE key='update_cached_json'")['value'] ?? '';

        if (!$force && $cached && (time() - $lastCheck) < self::CHECK_INTERVAL) {
            $data = json_decode($cached, true);
            // Same validation as the fresh-fetch path so a previously-poisoned
            // cache row (from before the manifest validation existed) cannot
            // still surface as the "available version" in the UI.
            if (is_array($data)
                && !empty($data['version'])
                && is_string($data['version'])
                && preg_match('/^[0-9]+\.[0-9]+\.[0-9]+([+\-][A-Za-z0-9.\-]+)?$/', $data['version'])) {
                return $data;
            }
            // Fall through to a fresh fetch when the cache is unusable.
        }

        $raw = self::httpFetchCapped(self::MANIFEST_URL, self::MAX_MANIFEST_BYTES, 5, 'update-check');

        // Always update the timestamp so we don't hammer the server on failures
        $db->execute("INSERT OR REPLACE INTO settings (key,value) VALUES ('update_last_check',?)", [(string)time()]);

        if ($raw === null) return null;
        $data = json_decode($raw, true);
        if (!is_array($data) || empty($data['version'])) return null;
        // Reject obvious garbage in the version string before it lands in
        // the cached settings row and gets rendered in the admin UI. A
        // misconfigured release pipeline once leaked HTML into version.json
        // (`{"version":"<!DOCTYPE html>",...}`); the parse succeeded and the
        // settings page displayed the doctype as the "available version".
        // SemVer with optional pre-release / build suffix only.
        if (!is_string($data['version']) || !preg_match('/^[0-9]+\.[0-9]+\.[0-9]+([+\-][A-Za-z0-9.\-]+)?$/', $data['version'])) {
            error_log('[Updater] manifest rejected: malformed version field');
            return null;
        }

        $db->execute("INSERT OR REPLACE INTO settings (key,value) VALUES ('update_cached_json',?)", [$raw]);

        return $data;
    }

    public static function isAvailable(): bool
    {
        $info = self::getRemoteInfo();
        if (!$info) return false;
        return version_compare($info['version'], APP_VERSION, '>');
    }

    // Notifies all admins once per new version. Safe to call on every page load.
    public static function notifyAdminsIfNewVersion(?array $info): void
    {
        if (!$info || !version_compare($info['version'], APP_VERSION, '>')) return;

        $db             = Database::getInstance();
        $notifiedVersion = $db->fetch("SELECT value FROM settings WHERE key='update_notified_version'")['value'] ?? '';

        if ($notifiedVersion === $info['version']) return;

        $db->execute("INSERT OR REPLACE INTO settings (key,value) VALUES ('update_notified_version',?)", [$info['version']]);

        // Each admin gets the notification in their own preferred language.
        // Lang::current() is restored at the end so unrelated downstream code
        // is unaffected.
        $admins   = $db->fetchAll("SELECT id, language FROM users WHERE role='admin' AND is_active=1");
        $prevLang = Lang::current();
        foreach ($admins as $admin) {
            Lang::load($admin['language'] ?? Lang::FALLBACK);
            Helper::createNotification(
                $admin['id'],
                Lang::get('update_available_notif_title', ['version' => $info['version']]),
                Lang::get('update_available_notif_msg'),
                'info',
                'update'
            );
        }
        Lang::load($prevLang);
    }

    // ── Changelog parser ─────────────────────────────────────────────────────

    /**
     * Returns changelog entries for all versions newer than $currentVersion,
     * parsed from the local CHANGELOG.md.
     *
     * If no versioned entries are found (e.g. the upcoming release is still
     * listed as [Unreleased] in the changelog), the [Unreleased] block is
     * returned instead, labelled with $targetVersion so the admin still sees
     * what is included in the pending update.
     *
     * Each returned entry: ['version' => '1.2.3', 'date' => '2026-01-01', 'html' => '...']
     */
    public static function getChangelogSince(string $currentVersion, ?array $remoteInfo = null): array
    {
        $file = BASE_PATH . '/CHANGELOG.md';
        if (!file_exists($file)) return [];
        $content = file_get_contents($file);

        // Locate all version headers (/u = UTF-8 mode for en-dash)
        preg_match_all('/^## \[([^\]]+)\](?:\s*[–\-]\s*(\d{4}-\d{2}-\d{2}))?/mu', $content, $matches, PREG_OFFSET_CAPTURE);

        $entries      = [];
        $unreleased   = null;
        $count        = count($matches[0]);

        for ($i = 0; $i < $count; $i++) {
            $version   = trim($matches[1][$i][0]);
            $date      = trim($matches[2][$i][0] ?? '');
            $headerEnd = $matches[0][$i][1] + strlen($matches[0][$i][0]);
            $nextStart = isset($matches[0][$i + 1]) ? $matches[0][$i + 1][1] : strlen($content);
            $body      = trim(substr($content, $headerEnd, $nextStart - $headerEnd));

            if ($version === 'Unreleased') {
                if ($body !== '' && $body !== '---') {
                    $unreleased = $body; // save for fallback
                }
                continue;
            }

            if (!version_compare($version, $currentVersion, '>')) continue;

            $entries[] = [
                'version' => $version,
                'date'    => $date,
                'html'    => self::changelogBodyToHtml($body),
            ];
        }

        // Fallback: no versioned entries found → show [Unreleased] content
        // labelled with the target version from the update manifest
        if (empty($entries) && $unreleased !== null) {
            $targetVersion = $remoteInfo['version'] ?? '';
            $entries[] = [
                'version' => $targetVersion ?: '?',
                'date'    => '',
                'html'    => self::changelogBodyToHtml($unreleased),
            ];
        }

        return $entries;
    }

    private static function changelogBodyToHtml(string $md): string
    {
        $lines  = explode("\n", $md);
        $html   = '';
        $inList = false;

        foreach ($lines as $line) {
            $line = rtrim($line);
            if ($line === '' || $line === '---') {
                if ($inList) { $html .= '</ul>'; $inList = false; }
                continue;
            }
            if (preg_match('/^### (.+)$/', $line, $m)) {
                if ($inList) { $html .= '</ul>'; $inList = false; }
                $html .= '<p class="cl-section">' . self::changelogInline($m[1]) . '</p>';
            } elseif (preg_match('/^- (.+)$/', $line, $m)) {
                if (!$inList) { $html .= '<ul class="cl-list">'; $inList = true; }
                $html .= '<li>' . self::changelogInline($m[1]) . '</li>';
            }
        }
        if ($inList) $html .= '</ul>';

        return $html;
    }

    /**
     * Inline-markdown for changelog entries: `code`, **bold**, *italic*.
     * Escapes first, then introduces tags on the escaped text.
     */
    private static function changelogInline(string $s): string
    {
        $s = htmlspecialchars($s, ENT_QUOTES);
        $s = preg_replace('/`([^`]+)`/', '<code>$1</code>', $s);
        $s = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $s);
        $s = preg_replace('/(?<!\*)\*(?!\*)([^*\n]+?)\*(?!\*)/', '<em>$1</em>', $s);
        return $s;
    }

    // ── Perform update ───────────────────────────────────────────────────────

    public static function perform(array $info): array
    {
        // U3: VACUUM INTO + ZIP build + extract + Migrator can each take a
        // while on a heavy install; default 30 s would kill mid-write.
        @set_time_limit(0);
        @ignore_user_abort(true);

        $zipUrl = $info['url']    ?? '';
        $sha256 = $info['sha256'] ?? '';
        $log    = [];

        // U6: every failure path emits a SEV_CRITICAL audit event so admins
        // see attempted-but-failed updates in `/admin/security-log`. Wrapped
        // in try/catch because the SecurityLog table may be unavailable
        // mid-update (rare but possible during DB swap).
        $fail = function(string $msg) use (&$log, $info): array {
            $log[] = '✗ ' . $msg;
            if (class_exists('SecurityLog')) {
                try {
                    SecurityLog::log('update_perform_failed', SecurityLog::SEV_CRITICAL, [
                        'reason'         => $msg,
                        'target_version' => (string)($info['version'] ?? ''),
                    ]);
                } catch (\Throwable) { /* logging is best-effort */ }
            }
            return ['ok' => false, 'error' => $msg, 'log' => $log];
        };

        if (!$zipUrl) return $fail('No download URL in manifest.');
        // U7: HTTPS-only — refuses any http:// in the manifest URL even if
        // the host whitelist matches. Defends against a forged manifest
        // that downgrades the channel to MITM-ablen plain HTTP.
        if (!preg_match('#^https://plenvo\.gp-server\.com/#', $zipUrl)) return $fail('Update URL must use https:// and originate from plenvo.gp-server.com.');
        if (!class_exists('ZipArchive')) return $fail('PHP extension "zip" is required for auto-updates.');

        // U4: register tempfile cleanup at PHP shutdown so the file goes
        // away even if perform() exits via an unexpected path (exception,
        // fatal error, OOM). The explicit unlink calls below are still
        // there for the fast-path; they become no-ops on the second call.
        $tmp = tempnam(sys_get_temp_dir(), 'plenvo_upd_');
        if ($tmp === false) return $fail('Konnte Temporärdatei nicht anlegen.');
        register_shutdown_function(function() use ($tmp) { @unlink($tmp); });

        // 1. Download ZIP — streamed to disk, never materialised in RAM.
        // cURL preferred for the explicit `CURLOPT_PROTOCOLS = HTTPS`,
        // `CURLOPT_FOLLOWLOCATION = false` and `CURLOPT_MAXFILESIZE` we get
        // for free; stream-wrapper fallback mirrors the same controls so
        // installations without ext-curl behave identically. Either way:
        // redirects rejected (U1), HTTPS-only, TLS verified, response cap
        // 100 MB (U8), status-line must be 200.
        $log[] = '⬇ Lade Update-Paket herunter…';
        $download = self::httpDownloadToFile($zipUrl, $tmp, self::MAX_DOWNLOAD_BYTES, 120, 'updater');
        if ($download['ok'] !== true) {
            return $fail($download['error']);
        }
        $copied = $download['bytes'];
        $log[]  = '✓ ' . round($copied / 1024) . ' KB heruntergeladen';

        // 2. Verify SHA-256 (mandatory: refuse to install unverified packages)
        $log[] = '🔒 Prüfe SHA-256 Checksumme…';
        if ($sha256 === '' || !preg_match('/^[a-f0-9]{64}$/i', $sha256)) {
            unlink($tmp);
            return $fail('Update abgebrochen: Manifest enthält keine gültige SHA-256-Checksumme. Aus Sicherheitsgründen werden nur signierte Pakete installiert.');
        }
        if (!hash_equals(strtolower($sha256), strtolower(hash_file('sha256', $tmp)))) {
            unlink($tmp);
            return $fail('Checksumme stimmt nicht überein – Datei möglicherweise beschädigt oder manipuliert.');
        }
        $log[] = '✓ Checksumme korrekt';

        // 2b. Pre-update backup. Same code path as the manual /admin/settings
        // backup button — the file persists under data/backups/ and is
        // visible/restorable from the same UI. If the operator configured a
        // default passphrase it is used automatically; otherwise the backup
        // is unencrypted (operator-controlled trade-off). A backup failure
        // aborts the update before any destructive write to keep the
        // safety-net contract: every install reaches the extraction stage
        // only when a fresh restorable snapshot exists on disk.
        $log[] = '🛡 Erstelle Backup vor Update…';
        if (class_exists('Backup')) {
            try {
                $passphrase = Backup::defaultPassphrase();
            } catch (\Throwable $e) {
                @unlink($tmp);
                return $fail('Backup vor Update abgebrochen: hinterlegte Standard-Passphrase konnte nicht entschlüsselt werden (' . $e->getMessage() . '). Update wird nicht ausgeführt, damit kein unverschlüsseltes Pre-Update-Backup entsteht. Standard-Passphrase prüfen oder zurücksetzen, dann erneut versuchen.');
            }
            $backupResult = Backup::create($passphrase);
            if (!$backupResult['ok']) {
                @unlink($tmp);
                return $fail('Backup vor Update fehlgeschlagen (' . ($backupResult['error'] ?? 'unbekannt') . '). Update abgebrochen, damit kein Stand ohne Rollback-Pfad geschrieben wird.');
            }
            $sizeMb = round(($backupResult['size'] ?? 0) / 1048576, 1);
            $log[]  = '✓ Backup erstellt: ' . $backupResult['filename']
                   . ' (' . $sizeMb . ' MB'
                   . ($backupResult['encrypted'] ? ', verschlüsselt' : '')
                   . ')';
            try {
                if (class_exists('SecurityLog')) {
                    SecurityLog::log('pre_update_backup', SecurityLog::SEV_WARN, [
                        'filename'       => $backupResult['filename'],
                        'size'           => (int)($backupResult['size'] ?? 0),
                        'encrypted'      => $backupResult['encrypted'] ? 1 : 0,
                        'target_version' => (string)($info['version'] ?? ''),
                    ]);
                }
            } catch (\Throwable) { /* logging is best-effort */ }
        } else {
            $log[] = '⚠ Backup-Klasse nicht verfügbar — Update wird ohne Pre-Update-Backup fortgesetzt.';
        }

        // 3. Extract ZIP
        $log[] = '📦 Entpacke Dateien…';
        $zip = new ZipArchive();
        if ($zip->open($tmp) !== true) {
            unlink($tmp);
            return $fail('ZIP-Archiv konnte nicht geöffnet werden.');
        }

        $base    = rtrim(realpath(BASE_PATH), '/') . '/';
        $written = 0;
        $skipped = 0;
        $failed  = 0;

        // Plugin blacklist: directories under plugins/ that an update must never
        // overwrite because they are operator-specific (custom branding,
        // internal automation, vendor-customised bundles). Everything else
        // under plugins/ is part of the release and gets shipped/updated.
        // Add another id here only if it must stay tied to a specific operator
        // and not follow the app version.
        $pluginBlacklist  = ['provider-edition'];
        $blacklistPattern = '#^plugins/(' . implode('|', array_map('preg_quote', $pluginBlacklist)) . ')/#';

        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = $zip->getNameIndex($i);
            // Never overwrite operator state: runtime data and release downloads.
            if (preg_match('#^(data/|downloads/[^/]+\.zip$)#', $name)) { $skipped++; continue; }
            // Plugins: skip blacklisted directories.
            if (preg_match($blacklistPattern, $name)) { $skipped++; continue; }
            // Path traversal hardening: reject names containing parent-directory
            // segments or NUL bytes outright. Legitimate ZIP entries never need them.
            if (str_contains($name, '..') || str_contains($name, "\0")) { $skipped++; continue; }
            $dest = $base . $name;
            if (str_ends_with($name, '/')) {
                @mkdir($dest, 0775, true);
            } else {
                @mkdir(dirname($dest), 0775, true);
                // Final safety net: after mkdir, verify the resolved parent really
                // sits inside $base (catches any residual symlink shenanigans).
                $parentReal = realpath(dirname($dest));
                if ($parentReal === false || !str_starts_with($parentReal . '/', $base)) {
                    $skipped++; continue;
                }
                if (file_put_contents($dest, $zip->getFromIndex($i)) === false) {
                    $failed++;
                } else {
                    $written++;
                }
            }
        }
        $zip->close();
        unlink($tmp);
        $log[] = '✓ ' . $written . ' Dateien geschrieben, ' . $skipped . ' Pfade übersprungen (data/, downloads/, geblockte Plugins)';
        if ($failed > 0) {
            $log[] = '⚠ ' . $failed . ' Dateien konnten nicht geschrieben werden – Verzeichnisberechtigungen prüfen';
        }

        // 4. Reset update cache
        Database::getInstance()->execute("INSERT OR REPLACE INTO settings (key,value) VALUES ('update_last_check','0')");
        $log[] = '✓ Update-Cache zurückgesetzt';

        // 4b. Run pending versioned migrations from the freshly extracted code
        $log[] = '🛢 Wende Datenbank-Migrationen an…';
        if (class_exists('Migrator')) {
            $migLog = Migrator::run();
            $applied = 0;
            $failedMig = 0;
            foreach ($migLog as $m) {
                if ($m['ok']) { $applied++; } else { $failedMig++; $log[] = '✗ Migration ' . $m['id'] . ': ' . ($m['error'] ?? ''); }
            }
            $log[] = '✓ ' . $applied . ' Migration(en) angewendet' . ($failedMig > 0 ? ', ' . $failedMig . ' fehlgeschlagen' : '');
        } else {
            $log[] = '⚠ Migrator-Klasse nicht gefunden, überspringe';
        }

        // 5. Flush PHP opcode cache so the new index.php/APP_VERSION is picked up immediately
        if (function_exists('opcache_reset')) {
            opcache_reset();
            $log[] = '✓ PHP Opcache geleert';
        }

        // 6. Verify the installed version matches the target version
        $log[] = '🔍 Prüfe installierte Version…';
        $targetVersion    = $info['version'] ?? '';
        $installedVersion = null;
        $indexFile        = $base . 'index.php';
        if (file_exists($indexFile)) {
            $src = file_get_contents($indexFile);
            if ($src !== false && preg_match("/define\('APP_VERSION',\s*'([^']+)'\)/", $src, $m)) {
                $installedVersion = $m[1];
            }
        }

        if ($installedVersion !== $targetVersion) {
            $found = $installedVersion ?? 'unbekannt';
            $log[] = '✗ Versionscheck fehlgeschlagen: erwartet ' . $targetVersion . ', gefunden ' . $found;
            return $fail('Update fehlgeschlagen: Zielversion ' . $targetVersion . ' wurde nicht installiert (gefunden: ' . $found . '). Verzeichnisberechtigungen prüfen.');
        }
        $log[] = '✓ Versionscheck bestätigt: ' . $installedVersion;

        $log[] = '🎉 Update auf ' . $targetVersion . ' erfolgreich abgeschlossen!';

        if (class_exists('SecurityLog')) {
            SecurityLog::log('update_perform', SecurityLog::SEV_WARN, [
                'target_version' => $targetVersion,
                'written'        => $written,
            ]);
        }

        return ['ok' => true, 'written' => $written, 'log' => $log];
    }

    // ── HTTP helpers (TLS-pinned, redirect-blocking, size-capped) ────────────

    /**
     * Fetch a small HTTPS response (manifest, version probe) into memory.
     * Returns the body string on HTTP 200, or null on any other outcome.
     * Hard size cap; redirects rejected; TLS peer-verified.
     */
    private static function httpFetchCapped(string $url, int $maxBytes, int $timeout, string $uaTag): ?string
    {
        $ua = 'Plenvo/' . APP_VERSION . ' (' . $uaTag . ')';

        if (function_exists('curl_init')) {
            $ch   = curl_init($url);
            $body = '';
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => false,
                CURLOPT_FOLLOWLOCATION => false,
                CURLOPT_PROTOCOLS      => CURLPROTO_HTTPS,
                CURLOPT_TIMEOUT        => $timeout,
                CURLOPT_CONNECTTIMEOUT => 5,
                CURLOPT_SSL_VERIFYPEER => true,
                CURLOPT_SSL_VERIFYHOST => 2,
                CURLOPT_USERAGENT      => $ua,
                CURLOPT_WRITEFUNCTION  => function ($ch, string $chunk) use (&$body, $maxBytes) {
                    $body .= $chunk;
                    if (strlen($body) > $maxBytes) return -1; // signal abort
                    return strlen($chunk);
                },
            ]);
            curl_exec($ch);
            $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);
            if ($code !== 200) return null;
            if (strlen($body) > $maxBytes) return null;
            return $body;
        }

        // Stream-wrapper fallback: same security posture, less granular
        // size enforcement (file_get_contents reads up to maxlen).
        $ctx = stream_context_create([
            'http' => [
                'timeout'         => $timeout,
                'user_agent'      => $ua,
                'follow_location' => 0,
                'max_redirects'   => 0,
                'ignore_errors'   => true,
            ],
            'ssl' => [
                'verify_peer'      => true,
                'verify_peer_name' => true,
            ],
        ]);
        $raw = @file_get_contents($url, false, $ctx, 0, $maxBytes);
        if ($raw === false) return null;
        // Inspect the status line populated by the HTTP wrapper. PHP 8.5
        // deprecates the magic `$http_response_header` variable; use the new
        // accessor function when available so the deprecation is not echoed
        // into the response body on hosts running PHP 8.5+ with display_errors.
        $hdr  = function_exists('http_get_last_response_headers')
            ? (http_get_last_response_headers() ?: [])
            : ($http_response_header ?? []);
        $line = $hdr[0] ?? '';
        if (!preg_match('#^HTTP/\d+(?:\.\d+)?\s+200\b#', $line)) return null;
        return $raw;
    }

    /**
     * Stream a larger HTTPS response (release ZIP) directly to disk.
     * Returns ['ok' => true, 'bytes' => int] on success, or
     * ['ok' => false, 'error' => string] otherwise. Same TLS / redirect /
     * scheme-pinning guarantees as `httpFetchCapped`.
     */
    private static function httpDownloadToFile(string $url, string $destPath, int $maxBytes, int $timeout, string $uaTag): array
    {
        $ua = 'Plenvo/' . APP_VERSION . ' (' . $uaTag . ')';

        if (function_exists('curl_init')) {
            $fh = @fopen($destPath, 'wb');
            if (!$fh) return ['ok' => false, 'error' => 'Konnte Temporärdatei nicht beschreiben.'];
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_FILE           => $fh,
                CURLOPT_FOLLOWLOCATION => false,
                CURLOPT_PROTOCOLS      => CURLPROTO_HTTPS,
                CURLOPT_TIMEOUT        => $timeout,
                CURLOPT_CONNECTTIMEOUT => 5,
                CURLOPT_SSL_VERIFYPEER => true,
                CURLOPT_SSL_VERIFYHOST => 2,
                CURLOPT_USERAGENT      => $ua,
                // CURLOPT_MAXFILESIZE is checked against Content-Length when
                // the server sends one; the progress callback below catches
                // the streaming-no-Content-Length case.
                CURLOPT_MAXFILESIZE    => $maxBytes,
                CURLOPT_NOPROGRESS     => false,
                CURLOPT_PROGRESSFUNCTION => function ($ch, $dlTotal, $dlNow) use ($maxBytes) {
                    return $dlNow > $maxBytes ? 1 : 0;
                },
            ]);
            $ok = curl_exec($ch);
            $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $err  = curl_error($ch);
            curl_close($ch);
            fclose($fh);
            if ($ok === false) {
                return ['ok' => false, 'error' => 'Download fehlgeschlagen: ' . ($err !== '' ? $err : 'unbekannter Fehler')];
            }
            if ($code !== 200) {
                $kind = ($code >= 300 && $code < 400) ? 'Redirect' : 'Fehler';
                return ['ok' => false, 'error' => 'Update-Server antwortete mit HTTP ' . $code . ' (' . $kind . ') — wird aus Sicherheitsgründen abgelehnt.'];
            }
            $bytes = (int)(@filesize($destPath) ?: 0);
            if ($bytes === 0) return ['ok' => false, 'error' => 'Download lieferte keine Daten.'];
            if ($bytes > $maxBytes) {
                @unlink($destPath);
                return ['ok' => false, 'error' => 'Download überschritt das Größenlimit (' . round($maxBytes / 1048576) . ' MB).'];
            }
            return ['ok' => true, 'bytes' => $bytes];
        }

        // Stream fallback: fopen + stream_copy_to_stream with the same
        // hardening. Size enforcement is post-hoc (after the copy
        // finishes), so a malicious server could exhaust disk before we
        // notice — acceptable trade-off given that ext-curl is bundled
        // with virtually every PHP install.
        $ctx = stream_context_create([
            'http' => [
                'timeout'         => $timeout,
                'user_agent'      => $ua,
                'follow_location' => 0,
                'max_redirects'   => 0,
                'ignore_errors'   => true,
            ],
            'ssl' => [
                'verify_peer'      => true,
                'verify_peer_name' => true,
            ],
        ]);
        $src = @fopen($url, 'rb', false, $ctx);
        if (!$src) return ['ok' => false, 'error' => 'Download fehlgeschlagen. Netzwerkverbindung prüfen.'];
        $headers    = stream_get_meta_data($src)['wrapper_data'] ?? [];
        $statusLine = is_array($headers) && isset($headers[0]) ? (string)$headers[0] : '';
        if (preg_match('/^HTTP\/[\d.]+\s+(\d{3})/', $statusLine, $m)) {
            $code = (int)$m[1];
            if ($code !== 200) {
                fclose($src);
                $kind = ($code >= 300 && $code < 400) ? 'Redirect' : 'Fehler';
                return ['ok' => false, 'error' => 'Update-Server antwortete mit HTTP ' . $code . ' (' . $kind . ') — wird aus Sicherheitsgründen abgelehnt.'];
            }
        }
        $dst = @fopen($destPath, 'wb');
        if (!$dst) { fclose($src); return ['ok' => false, 'error' => 'Konnte Temporärdatei nicht beschreiben.']; }
        $copied = @stream_copy_to_stream($src, $dst, $maxBytes + 1);
        fclose($src);
        fclose($dst);
        if ($copied === false || $copied === 0) return ['ok' => false, 'error' => 'Download lieferte keine Daten.'];
        if ($copied > $maxBytes) {
            @unlink($destPath);
            return ['ok' => false, 'error' => 'Download überschritt das Größenlimit (' . round($maxBytes / 1048576) . ' MB).'];
        }
        return ['ok' => true, 'bytes' => (int)$copied];
    }
}
