<?php
declare(strict_types=1);

/**
 * Backup / Restore for self-hosted Plenvo instances.
 *
 * Backup payload (ZIP):
 *   manifest.json             plenvo_version, created_at, format
 *   database.sqlite           SQLite-online-snapshot via VACUUM INTO
 *   app.key                   AES-256-GCM key for encrypted columns
 *   plugins_disabled.json     plugin enable/disable state
 *   avatars/...               user avatars
 *   uploads/...               task attachments
 *
 * Optional outer wrapper (AES-256-GCM, PBKDF2-SHA256, 200_000 iterations):
 *   "PLBK01\n" + 16-byte salt + 12-byte IV + 16-byte tag + ciphertext.
 *
 * Restore is a two-step flow controlled from `AdminController`:
 *   1) `stage()`  decrypts (if needed), validates manifest + path entries,
 *                 reads the source's `app_url`, `app_name` and a few counts
 *                 for the review screen, writes the decrypted ZIP plus a
 *                 sidecar metadata file under `data/.restore-staging/`,
 *                 returns a one-time token.
 *   2) `applyStaged()`  takes that token plus the (possibly edited) URL,
 *                 atomically swaps the live data/ aside into
 *                 `data/.restore-backup-<ts>/`, extracts the staged ZIP,
 *                 rewrites `settings.app_url` if the admin changed it,
 *                 runs `Migrator::run()`, and clears the staging entry.
 *
 * `cancelStaged()` removes the staging entry without applying anything;
 * any abandoned staging files are also reaped on the next stage call when
 * older than the TTL (30 minutes).
 */
class Backup
{
    // Encryption-wrapper format. V2 raises the PBKDF2 iteration count to
    // 600_000 (OWASP 2023 recommendation for SHA-256). V1 (200_000) stays
    // decryptable so backups taken with the previous build still restore.
    // New backups are always written as V2.
    private const MAGIC_V1       = "PLBK01\n";
    private const MAGIC_V2       = "PLBK02\n";
    private const MAGIC_LEN      = 7;
    private const PBKDF2_ITER_V1 = 200000;
    private const PBKDF2_ITER_V2 = 600000;
    private const FORMAT         = 1;
    private const STAGE_TTL      = 1800;          // 30 minutes
    // Hard ceiling on the uncompressed size of a single ZIP entry. Defends
    // against zip-bomb DoS during peek and extract — a malicious admin
    // upload could otherwise fit a 50-GB-uncompressed `database.sqlite`
    // under the upload cap and OOM the worker on `getFromName`.
    private const MAX_ENTRY_BYTES = 2 * 1024 * 1024 * 1024;   // 2 GB
    // Hard ceiling on the size of a backup file that the restore endpoints
    // accept. AES-GCM encrypt/decrypt buffers the entire file in PHP RAM
    // (one input + one output buffer) before writing — at default
    // `memory_limit = 128M` even ~50 MB uploads OOM the worker. 512 MB
    // covers any realistic Plenvo install while staying within reach of a
    // single `ensureMemoryFor()` bump.
    public  const MAX_RESTORE_UPLOAD_BYTES = 512 * 1024 * 1024; // 512 MB

    // ── Backup creation ──────────────────────────────────────────────────────

    public static function create(?string $passphrase = null): array
    {
        if (!class_exists('ZipArchive')) {
            return ['ok' => false, 'error' => 'backup_err_no_zip_ext'];
        }

        // Persistent backup dir under data/. Each backup keeps living there
        // after the download stream finishes, so admins can list, restore
        // or re-download earlier backups from /admin/settings.
        $persistDir = self::backupDir();
        $tmp        = tempnam(sys_get_temp_dir(), 'plenvo_bk_');
        if ($tmp === false) return ['ok' => false, 'error' => 'backup_err_tmp'];
        @unlink($tmp);
        $tmp .= '.zip';

        $snapshotPath = sys_get_temp_dir() . '/plenvo_db_' . bin2hex(random_bytes(8)) . '.sqlite';
        try {
            $pdo     = Database::getInstance()->getPdo();
            $escaped = str_replace("'", "''", $snapshotPath);
            $pdo->exec("VACUUM INTO '" . $escaped . "'");
        } catch (\Throwable) {
            @unlink($snapshotPath);
            return ['ok' => false, 'error' => 'backup_err_snapshot'];
        }

        $zip = new ZipArchive();
        if ($zip->open($tmp, ZipArchive::CREATE) !== true) {
            @unlink($snapshotPath);
            return ['ok' => false, 'error' => 'backup_err_zip_open'];
        }
        $zip->addFromString('manifest.json', json_encode([
            'plenvo_version' => APP_VERSION,
            'created_at'     => time(),
            'created_iso'    => date('c'),
            'format'         => self::FORMAT,
        ], JSON_PRETTY_PRINT));
        $zip->addFile($snapshotPath, 'database.sqlite');
        if (file_exists(DATA_PATH . '/app.key')) {
            $zip->addFile(DATA_PATH . '/app.key', 'app.key');
        }
        if (file_exists(DATA_PATH . '/plugins_disabled.json')) {
            $zip->addFile(DATA_PATH . '/plugins_disabled.json', 'plugins_disabled.json');
        }
        self::addDir($zip, DATA_PATH . '/avatars', 'avatars');
        self::addDir($zip, DATA_PATH . '/uploads', 'uploads');
        $zip->close();
        @unlink($snapshotPath);

        $encrypted = false;
        if ($passphrase !== null && $passphrase !== '') {
            $encPath = $tmp . '.enc';
            if (!self::encryptFile($tmp, $encPath, $passphrase)) {
                @unlink($tmp);
                @unlink($encPath);
                return ['ok' => false, 'error' => 'backup_err_encrypt'];
            }
            @unlink($tmp);
            $tmp       = $encPath;
            $encrypted = true;
        }

        $base     = 'plenvo-backup-' . date('Ymd-His') . '-v' . APP_VERSION;
        $extPart  = $encrypted ? '.zip.enc' : '.zip';
        $filename = $base . $extPart;
        // Microsecond-resolution clock would still collide if two admins click
        // at the same instant; suffix-on-collision keeps every backup unique.
        $n = 2;
        while (file_exists($persistDir . '/' . $filename)) {
            $filename = $base . '-' . $n . $extPart;
            $n++;
        }
        $persistPath = $persistDir . '/' . $filename;
        if (!@rename($tmp, $persistPath)) {
            // rename across filesystems can fail; fall back to copy+unlink
            if (!@copy($tmp, $persistPath)) {
                @unlink($tmp);
                return ['ok' => false, 'error' => 'backup_err_persist'];
            }
            @unlink($tmp);
        }
        @chmod($persistPath, 0600);

        return [
            'ok'        => true,
            'path'      => $persistPath,
            'size'      => filesize($persistPath) ?: 0,
            'encrypted' => $encrypted,
            'filename'  => $filename,
        ];
    }

    // ── Persistent backup management ─────────────────────────────────────────

    /** List backups currently on disk, newest first. */
    public static function listBackups(): array
    {
        $dir = self::backupDir();
        $entries = [];
        foreach ((array)@glob($dir . '/plenvo-backup-*') as $path) {
            if (!is_file($path)) continue;
            $name = basename($path);
            if (!self::isValidBackupFilename($name)) continue;
            // Trust the file content (magic bytes), not the filename suffix:
            // a manually-renamed file would otherwise be displayed with a
            // wrong "encrypted" flag. `isEncrypted()` reads only the first
            // 7 bytes per file, negligible I/O for the listing.
            $entries[] = [
                'filename'  => $name,
                'size'      => (int)(@filesize($path) ?: 0),
                'mtime'     => (int)(@filemtime($path) ?: 0),
                'encrypted' => self::isEncrypted($path),
            ];
        }
        usort($entries, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
        return $entries;
    }

    /** Resolve a list-supplied filename to its on-disk path, or null. */
    public static function resolveBackupPath(string $filename): ?string
    {
        if (!self::isValidBackupFilename($filename)) return null;
        $dir  = self::backupDir();
        $path = $dir . '/' . $filename;
        if (!is_file($path)) return null;

        // Symlink-confinement: even though the regex on $filename rules out
        // path-traversal via the name itself, a symlink planted under
        // data/backups/ (post-compromise or sloppy filesystem perms) would
        // make `is_file()` follow it to anywhere readable by the web user.
        // The download endpoint would then stream the symlink target as if
        // it were a backup. Pin the resolved file to the backup dir.
        $real = @realpath($path);
        $base = @realpath($dir);
        if ($real === false || $base === false
            || !str_starts_with($real, $base . DIRECTORY_SEPARATOR)) {
            return null;
        }
        return $path;
    }

    /** Delete a backup file from data/backups/. Returns true on success. */
    public static function deletePersisted(string $filename): bool
    {
        $path = self::resolveBackupPath($filename);
        if ($path === null) return false;
        return (bool)@unlink($path);
    }

    /**
     * Delete persisted backups older than $retentionDays. Returns the count
     * removed. A non-positive retention disables the sweep entirely.
     */
    public static function pruneOlderThan(int $retentionDays): int
    {
        if ($retentionDays <= 0) return 0;
        $cutoff  = time() - ($retentionDays * 86400);
        $removed = 0;
        foreach (self::listBackups() as $b) {
            if ($b['mtime'] < $cutoff) {
                if (self::deletePersisted($b['filename'])) $removed++;
            }
        }
        return $removed;
    }

    private static function backupDir(): string
    {
        $dir = DATA_PATH . '/backups';
        if (!is_dir($dir)) {
            @mkdir($dir, 0700, true);
        }
        // Defense-in-depth: every persisted backup is the entire instance
        // state, so we lock the directory off independent of the parent
        // data/.htaccess in case the cascade ever stops working.
        self::writeHtaccessDeny($dir);
        return $dir;
    }

    private static function isValidBackupFilename(string $name): bool
    {
        // Format: plenvo-backup-YYYYMMDD-HHMMSS[-N]-vX.Y.Z[-suffix].zip[.enc]
        return (bool)preg_match(
            '/^plenvo-backup-[0-9]{8}-[0-9]{6}(-[0-9]+)?-v[A-Za-z0-9._\-]+\.zip(\.enc)?$/',
            $name
        );
    }

    // ── Staging (step 1: validate) ───────────────────────────────────────────

    /**
     * Decrypt (if needed), validate, peek metadata, persist a staged copy.
     * Returns ['ok' => true, 'token' => <hex>, 'meta' => [...]] on success.
     */
    public static function stage(string $uploadedPath, ?string $passphrase): array
    {
        $stagingDir = self::stagingDir();
        $wasEncrypted = self::isEncrypted($uploadedPath);

        if ($wasEncrypted && ($passphrase === null || $passphrase === '')) {
            return ['ok' => false, 'error' => 'backup_err_pass_required'];
        }

        // Materialise the (decrypted) ZIP under staging.
        $token       = bin2hex(random_bytes(32));
        $stagedZip   = $stagingDir . '/' . $token . '.zip';
        $stagedMeta  = $stagingDir . '/' . $token . '.meta.json';

        if ($wasEncrypted) {
            if (!self::decryptFile($uploadedPath, $stagedZip, (string)$passphrase)) {
                @unlink($stagedZip);
                return ['ok' => false, 'error' => 'backup_err_decrypt'];
            }
        } else {
            if (!@copy($uploadedPath, $stagedZip)) {
                return ['ok' => false, 'error' => 'backup_err_tmp'];
            }
        }
        @chmod($stagedZip, 0600);

        // Open and validate.
        $zip = new ZipArchive();
        if ($zip->open($stagedZip) !== true) {
            @unlink($stagedZip);
            return ['ok' => false, 'error' => 'backup_err_invalid_zip'];
        }

        $manifest = self::readManifest($zip);
        if ($manifest === null) {
            $zip->close();
            @unlink($stagedZip);
            return ['ok' => false, 'error' => 'backup_err_invalid_manifest'];
        }
        if (version_compare((string)$manifest['plenvo_version'], APP_VERSION, '>')) {
            $zip->close();
            @unlink($stagedZip);
            return ['ok' => false, 'error' => 'backup_err_newer_version'];
        }

        $pathErr = self::validateZipPaths($zip);
        if ($pathErr !== null) {
            $zip->close();
            @unlink($stagedZip);
            return ['ok' => false, 'error' => $pathErr];
        }
        if ($zip->locateName('database.sqlite') === false) {
            $zip->close();
            @unlink($stagedZip);
            return ['ok' => false, 'error' => 'backup_err_missing_db'];
        }

        // Peek into the inner SQLite for the review screen.
        $peek = self::peekDatabase($zip);
        $zip->close();

        $meta = [
            'created_at'         => time(),
            'expires_at'         => time() + self::STAGE_TTL,
            'source_version'     => (string)$manifest['plenvo_version'],
            'source_created_at'  => (int)($manifest['created_at'] ?? 0),
            'was_encrypted'      => $wasEncrypted,
            'source_app_url'     => $peek['app_url']     ?? '',
            'source_app_name'    => $peek['app_name']    ?? '',
            'counts'             => $peek['counts']      ?? [],
        ];
        if (@file_put_contents($stagedMeta, json_encode($meta)) === false) {
            @unlink($stagedZip);
            return ['ok' => false, 'error' => 'backup_err_tmp'];
        }
        @chmod($stagedMeta, 0600);

        return ['ok' => true, 'token' => $token, 'meta' => $meta];
    }

    /** Returns the metadata for a staged token, or null if expired/unknown. */
    public static function getStagedMeta(string $token): ?array
    {
        if (!self::isValidToken($token)) return null;
        $stagingDir = self::stagingDir();
        $metaPath   = $stagingDir . '/' . $token . '.meta.json';
        $zipPath    = $stagingDir . '/' . $token . '.zip';
        if (!is_file($metaPath) || !is_file($zipPath)) return null;
        $meta = json_decode((string)@file_get_contents($metaPath), true);
        if (!is_array($meta)) return null;
        if (((int)($meta['expires_at'] ?? 0)) < time()) {
            @unlink($metaPath);
            @unlink($zipPath);
            return null;
        }
        return $meta;
    }

    // ── Apply (step 2) ───────────────────────────────────────────────────────

    /**
     * Carry out the actual restore using the previously-staged ZIP. If
     * `$newAppUrl` is non-null, `settings.app_url` is rewritten to it after
     * the new database is in place. Pass an empty string to clear the
     * setting (auto-detect from HTTP host); pass null to keep whatever the
     * source backup contained.
     */
    public static function applyStaged(string $token, ?string $newAppUrl): array
    {
        $log = [];
        if (!self::isValidToken($token)) {
            return ['ok' => false, 'error' => 'backup_err_invalid_token', 'log' => $log];
        }

        // Serialise concurrent applies. Two parallel admin sessions hitting
        // step=apply would otherwise both try to swap data/ aside, with the
        // second swap archiving the first's just-restored payload instead of
        // the original — corrupting both the rollback dir and the live state.
        // The lockfile lives outside the swap-list so it is not moved aside.
        $lockPath = DATA_PATH . '/.restore.lock';
        $lockFh   = @fopen($lockPath, 'c');
        if (!$lockFh || !@flock($lockFh, LOCK_EX | LOCK_NB)) {
            if ($lockFh) @fclose($lockFh);
            return ['ok' => false, 'error' => 'backup_err_in_progress', 'log' => $log];
        }

        try {
            return self::doApplyStaged($token, $newAppUrl, $log);
        } finally {
            @flock($lockFh, LOCK_UN);
            @fclose($lockFh);
        }
    }

    private static function doApplyStaged(string $token, ?string $newAppUrl, array $log): array
    {
        $stagingDir = self::stagingDir();
        $zipPath    = $stagingDir . '/' . $token . '.zip';
        $metaPath   = $stagingDir . '/' . $token . '.meta.json';
        if (!is_file($zipPath) || !is_file($metaPath)) {
            return ['ok' => false, 'error' => 'backup_err_staging_expired', 'log' => $log];
        }
        $meta = json_decode((string)@file_get_contents($metaPath), true);
        if (!is_array($meta) || ((int)($meta['expires_at'] ?? 0)) < time()) {
            @unlink($zipPath); @unlink($metaPath);
            return ['ok' => false, 'error' => 'backup_err_staging_expired', 'log' => $log];
        }

        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) {
            @unlink($zipPath); @unlink($metaPath);
            return ['ok' => false, 'error' => 'backup_err_invalid_zip', 'log' => $log];
        }

        // Re-validate every entry as defence-in-depth — the staging file
        // could conceivably have been tampered with between stage() and now.
        $pathErr = self::validateZipPaths($zip);
        if ($pathErr !== null) {
            $zip->close();
            @unlink($zipPath); @unlink($metaPath);
            return ['ok' => false, 'error' => $pathErr, 'log' => $log];
        }

        // Drop the live DB singleton so the upcoming rename releases the
        // database.sqlite handle from PHP's perspective.
        Database::reset();

        $backupTs  = date('Ymd-His');
        $backupDir = DATA_PATH . '/.restore-backup-' . $backupTs;
        if (!@mkdir($backupDir, 0750, true)) {
            $zip->close();
            return ['ok' => false, 'error' => 'backup_err_backup_dir', 'log' => $log];
        }
        // Lock the rollback archive against direct HTTP access. The contents
        // (full prior database, encryption key, attachments) must stay private
        // even if the parent `data/.htaccess` is missing.
        self::writeHtaccessDeny($backupDir);

        $movables = ['database.sqlite', 'database.sqlite-wal', 'database.sqlite-shm', 'app.key', 'plugins_disabled.json', 'avatars', 'uploads'];
        $moved    = [];
        foreach ($movables as $item) {
            $src = DATA_PATH . '/' . $item;
            if (file_exists($src)) {
                $dst = $backupDir . '/' . $item;
                if (@rename($src, $dst)) $moved[] = $item;
            }
        }
        $log[] = 'moved_aside: ' . count($moved);

        $extractFail = null;
        try {
            for ($i = 0; $i < $zip->numFiles; $i++) {
                $name = $zip->getNameIndex($i);
                if ($name === 'manifest.json') continue;
                $dest = DATA_PATH . '/' . $name;
                if (str_ends_with($name, '/')) {
                    @mkdir($dest, 0750, true);
                } else {
                    // Per-entry size cap: `getFromIndex` decompresses the
                    // entry into RAM before we write it out, so a zip-bomb
                    // entry would still OOM us mid-extract even though the
                    // outer file is bounded.
                    $stat = $zip->statIndex($i);
                    if (is_array($stat) && (int)($stat['size'] ?? 0) > self::MAX_ENTRY_BYTES) {
                        throw new \RuntimeException('entry_too_large: ' . $name);
                    }
                    @mkdir(dirname($dest), 0750, true);
                    if (file_put_contents($dest, $zip->getFromIndex($i)) === false) {
                        throw new \RuntimeException('write_failed: ' . $name);
                    }
                }
            }
        } catch (\Throwable $e) {
            $extractFail = $e->getMessage();
        }
        $zip->close();

        if ($extractFail !== null) {
            // Rollback: drop newly-written items, restore the previous payload.
            foreach (['database.sqlite', 'app.key', 'plugins_disabled.json'] as $f) {
                @unlink(DATA_PATH . '/' . $f);
            }
            self::deleteDir(DATA_PATH . '/avatars');
            self::deleteDir(DATA_PATH . '/uploads');
            foreach ($moved as $item) {
                @rename($backupDir . '/' . $item, DATA_PATH . '/' . $item);
            }
            @rmdir($backupDir);
            @unlink($zipPath); @unlink($metaPath);
            return ['ok' => false, 'error' => 'backup_err_extract', 'log' => $log];
        }

        @chmod(DATA_PATH . '/database.sqlite', 0640);
        if (file_exists(DATA_PATH . '/app.key')) {
            @chmod(DATA_PATH . '/app.key', 0600);
        }

        // Rewrite app_url BEFORE migrations so anything migration-time-derived
        // sees the operator's chosen URL.
        if ($newAppUrl !== null) {
            try {
                $pdo  = Database::getInstance()->getPdo();
                $stmt = $pdo->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('app_url', ?)");
                $stmt->execute([$newAppUrl]);
                $log[] = 'app_url_rewritten: ' . ($newAppUrl === '' ? '<auto>' : $newAppUrl);
            } catch (\Throwable $e) {
                $log[] = 'app_url_rewrite_warning: ' . $e->getMessage();
            }
        }

        try {
            if (class_exists('Migrator')) {
                $mlog    = Migrator::run();
                $applied = 0;
                foreach ($mlog as $m) if (!empty($m['ok'])) $applied++;
                $log[] = 'migrations_applied: ' . $applied;
            }
        } catch (\Throwable $e) {
            $log[] = 'migration_warning: ' . $e->getMessage();
        }

        // Cleanup staging.
        @unlink($zipPath);
        @unlink($metaPath);

        $log[] = 'rollback_dir: ' . basename($backupDir);
        return ['ok' => true, 'log' => $log];
    }

    /** Drop a staged backup without applying it. */
    public static function cancelStaged(string $token): bool
    {
        if (!self::isValidToken($token)) return false;
        $stagingDir = self::stagingDir();
        @unlink($stagingDir . '/' . $token . '.zip');
        @unlink($stagingDir . '/' . $token . '.meta.json');
        return true;
    }

    // ── Format detection ─────────────────────────────────────────────────────

    public static function isEncrypted(string $path): bool
    {
        $fh = @fopen($path, 'rb');
        if (!$fh) return false;
        $head = (string)fread($fh, self::MAGIC_LEN);
        fclose($fh);
        return $head === self::MAGIC_V2 || $head === self::MAGIC_V1;
    }

    /**
     * Return the admin-stored default passphrase (decrypted), or null when
     * none is configured. Used by both the manual backup endpoints and by
     * `Updater::perform()` for the pre-update backup so the same passphrase
     * applies regardless of who triggers the backup.
     *
     * Throws when a passphrase IS stored but decryption fails (e.g. the
     * `app.key` was rotated or replaced after the passphrase was saved).
     * Callers must distinguish: silently falling through to null would
     * produce an unencrypted backup despite the admin having configured
     * encryption — exactly what the operator would expect to be impossible.
     */
    public static function defaultPassphrase(): ?string
    {
        $stored = Helper::getSetting('backup_default_passphrase_enc', '');
        if ($stored === '') return null;
        $plain = Crypto::decrypt($stored);
        if ($plain === '') {
            // Crypto::decrypt already wrote a `crypto_decrypt_failed` event
            // (severity critical) before returning ''. Surfacing the failure
            // here lets the caller abort the operation instead of silently
            // creating an unencrypted backup.
            throw new \RuntimeException('backup_err_default_passphrase_unreadable');
        }
        return $plain;
    }

    // ── Internal helpers ─────────────────────────────────────────────────────

    private static function isValidToken(string $token): bool
    {
        return (bool)preg_match('/^[a-f0-9]{64}$/', $token);
    }

    private static function stagingDir(): string
    {
        $dir = DATA_PATH . '/.restore-staging';
        if (!is_dir($dir)) {
            @mkdir($dir, 0700, true);
        }
        // Always (re-)assert the deny rule so the lock isn't lost when the
        // directory was created by an older version, manually recreated, or
        // had its `.htaccess` removed accidentally.
        self::writeHtaccessDeny($dir);
        // Sweep stale entries every time staging is touched.
        foreach ((array)@glob($dir . '/*.{zip,meta.json}', GLOB_BRACE) as $f) {
            if (@filemtime($f) < time() - self::STAGE_TTL) @unlink($f);
        }
        return $dir;
    }

    /**
     * Drop a strict deny-all `.htaccess` into the given directory if one is
     * not already present. The parent `data/.htaccess` already cascades to
     * every subdirectory under Apache 2.4, so this is defence-in-depth: it
     * keeps the lock in place even when the cascade is bypassed (parent
     * `.htaccess` removed, AllowOverride scoped, deployment swapped to a
     * different web server). Backups contain the entire instance state
     * (database, encryption key, attachments) and must never be servable.
     */
    private static function writeHtaccessDeny(string $dir): void
    {
        if (!is_dir($dir)) return;
        $path = $dir . '/.htaccess';
        if (file_exists($path)) return;
        @file_put_contents(
            $path,
            "# Backup payload — never serve over HTTP.\n"
            . "Require all denied\n"
        );
        @chmod($path, 0600);
    }

    private static function readManifest(ZipArchive $zip): ?array
    {
        $raw = $zip->getFromName('manifest.json');
        if ($raw === false) return null;
        $manifest = json_decode($raw, true);
        if (!is_array($manifest)
            || empty($manifest['plenvo_version'])
            || empty($manifest['format'])
            || (int)$manifest['format'] !== self::FORMAT) {
            return null;
        }
        return $manifest;
    }

    /** Return null on success, an error key on rejection. */
    private static function validateZipPaths(ZipArchive $zip): ?string
    {
        $allowedTops = ['manifest.json', 'database.sqlite', 'app.key', 'plugins_disabled.json', 'avatars', 'uploads'];
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = $zip->getNameIndex($i);
            if ($name === false || $name === '') continue;
            if (str_contains($name, '..') || str_contains($name, "\0") || str_starts_with($name, '/')) {
                return 'backup_err_path_traversal';
            }
            $top = explode('/', $name)[0];
            if (!in_array($top, $allowedTops, true)) {
                return 'backup_err_unknown_path';
            }
            // Reject dotfile components anywhere in the path. Legitimate
            // backups never contain them (`addDir` skips dot-prefixed names
            // on the create side); on restore a crafted entry such as
            // `avatars/.htaccess` or `uploads/.user.ini` would otherwise
            // override the parent `data/.htaccess` for that subdir or
            // inject a per-directory PHP ini that flips `auto_prepend_file`.
            foreach (explode('/', $name) as $segment) {
                if ($segment === '') continue;
                if (str_starts_with($segment, '.')) {
                    return 'backup_err_dotfile';
                }
            }
        }
        return null;
    }

    /**
     * Extract `database.sqlite` to a throwaway path, read a few headline
     * settings, and return them. Failures are non-fatal — the review screen
     * still works, just with placeholder values.
     *
     * Read-only-ness is enforced two ways: the file is `chmod 0400` before
     * opening so the SQLite engine cannot acquire a write lock, and (when
     * the `sqlite3` extension is loaded under the running SAPI) the
     * `SQLITE3_OPEN_READONLY` flag is passed to the connection. PDO has no
     * equivalent flag, so it gets the chmod-only path as a fallback.
     */
    private static function peekDatabase(ZipArchive $zip): array
    {
        // Refuse to materialise an unreasonably large `database.sqlite`
        // entry into PHP memory — `getFromName` would balloon RAM and
        // crash the worker. A real backup of a heavy install is well
        // under the cap; anything larger is treated as malicious.
        $stat = $zip->statName('database.sqlite');
        if ($stat === false) return [];
        if ((int)($stat['size'] ?? 0) > self::MAX_ENTRY_BYTES) return [];

        $bytes = $zip->getFromName('database.sqlite');
        if ($bytes === false) return [];
        $tmp = sys_get_temp_dir() . '/plenvo_peek_' . bin2hex(random_bytes(8)) . '.sqlite';
        if (@file_put_contents($tmp, $bytes) === false) return [];
        @chmod($tmp, 0400);

        $out  = ['app_url' => '', 'app_name' => '', 'counts' => []];
        $tbls = ['users','projects','tasks','attachments','project_externals'];

        try {
            if (class_exists('SQLite3') && defined('SQLITE3_OPEN_READONLY')) {
                $peek = new \SQLite3($tmp, SQLITE3_OPEN_READONLY);
                $peek->busyTimeout(2000);
                $res = $peek->query("SELECT key, value FROM settings WHERE key IN ('app_url','app_name')");
                if ($res !== false) {
                    while ($r = $res->fetchArray(SQLITE3_ASSOC)) {
                        if (($r['key'] ?? '') === 'app_url')  $out['app_url']  = (string)($r['value'] ?? '');
                        if (($r['key'] ?? '') === 'app_name') $out['app_name'] = (string)($r['value'] ?? '');
                    }
                }
                foreach ($tbls as $tbl) {
                    $val = @$peek->querySingle("SELECT COUNT(*) FROM " . $tbl);
                    if (is_int($val) || is_string($val) && is_numeric($val)) {
                        $out['counts'][$tbl] = (int)$val;
                    }
                }
                $peek->close();
            } else {
                // PDO fallback. The `0400` chmod above plus PDO's lack of
                // write triggers on SELECT-only code keeps this safe enough
                // when the dedicated sqlite3 ext isn't around.
                $peek = new \PDO('sqlite:' . $tmp);
                $peek->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
                $row = $peek->query("SELECT key, value FROM settings WHERE key IN ('app_url','app_name')")->fetchAll(\PDO::FETCH_KEY_PAIR);
                $out['app_url']  = (string)($row['app_url']  ?? '');
                $out['app_name'] = (string)($row['app_name'] ?? '');
                foreach ($tbls as $tbl) {
                    try {
                        $out['counts'][$tbl] = (int)$peek->query("SELECT COUNT(*) FROM $tbl")->fetchColumn();
                    } catch (\Throwable) {
                        // Older backups may not contain every table — skip silently.
                    }
                }
            }
        } catch (\Throwable) {
            // Ignore; out keeps its defaults.
        }
        @unlink($tmp);
        return $out;
    }

    private static function addDir(ZipArchive $zip, string $localPath, string $zipPath): void
    {
        if (!is_dir($localPath)) return;
        $rii = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($localPath, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );
        foreach ($rii as $file) {
            $rel = substr($file->getPathname(), strlen($localPath) + 1);
            $rel = str_replace('\\', '/', $rel);
            if (str_starts_with($rel, '.')) continue;
            $arcname = $zipPath . '/' . $rel;
            if ($file->isDir()) {
                $zip->addEmptyDir($arcname);
            } else {
                $zip->addFile($file->getPathname(), $arcname);
            }
        }
    }

    private static function encryptFile(string $inPath, string $outPath, string $passphrase): bool
    {
        // Lift `memory_limit` for this request to 4× the file size + 64 MB
        // headroom. AES-GCM buffers input + ciphertext output in RAM; the
        // default 128 MB limit blows up on backups bigger than ~30 MB.
        // TODO: stream-encrypt with AAD/chunked finalisation to drop this
        //       memory footprint entirely. Tag handling at the end makes
        //       single-pass streaming non-trivial in PHP.
        self::ensureMemoryFor($inPath);
        $data = @file_get_contents($inPath);
        if ($data === false) return false;
        $salt = random_bytes(16);
        $iv   = random_bytes(12);
        $key  = hash_pbkdf2('sha256', $passphrase, $salt, self::PBKDF2_ITER_V2, 32, true);
        $tag  = '';
        $ciphertext = openssl_encrypt($data, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
        if ($ciphertext === false) return false;
        return @file_put_contents($outPath, self::MAGIC_V2 . $salt . $iv . $tag . $ciphertext) !== false;
    }

    private static function decryptFile(string $inPath, string $outPath, string $passphrase): bool
    {
        // Same RAM caveat as encryptFile — see comment there.
        self::ensureMemoryFor($inPath);
        $data = @file_get_contents($inPath);
        if ($data === false) return false;
        $head = substr($data, 0, self::MAGIC_LEN);
        if ($head === self::MAGIC_V2)      $iter = self::PBKDF2_ITER_V2;
        elseif ($head === self::MAGIC_V1)  $iter = self::PBKDF2_ITER_V1;
        else return false;
        $offset     = self::MAGIC_LEN;
        $salt       = substr($data, $offset, 16); $offset += 16;
        $iv         = substr($data, $offset, 12); $offset += 12;
        $tag        = substr($data, $offset, 16); $offset += 16;
        $ciphertext = substr($data, $offset);
        $key        = hash_pbkdf2('sha256', $passphrase, $salt, $iter, 32, true);
        $plain      = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
        if ($plain === false) return false;
        return @file_put_contents($outPath, $plain) !== false;
    }

    /**
     * Ensure `memory_limit` is large enough for an in-memory copy of the
     * given file plus an AES-GCM output buffer. Bumps to 4× filesize + 64 MB
     * if the current limit is finite and lower; never reduces an already
     * higher limit. Cheap no-op when memory_limit is `-1` (unbounded).
     */
    private static function ensureMemoryFor(string $path): void
    {
        $size = @filesize($path);
        if ($size === false) return;
        $needed  = ($size * 4) + (64 * 1024 * 1024);
        $current = self::parseMemoryLimit((string)ini_get('memory_limit'));
        if ($current === -1) return;
        if ($current >= $needed) return;
        @ini_set('memory_limit', (string)$needed);
    }

    private static function parseMemoryLimit(string $val): int
    {
        $val = trim($val);
        if ($val === '' || $val === '-1') return -1;
        $unit = strtolower(substr($val, -1));
        $num  = (int)$val;
        return match ($unit) {
            'g'     => $num * 1024 * 1024 * 1024,
            'm'     => $num * 1024 * 1024,
            'k'     => $num * 1024,
            default => $num,
        };
    }

    private static function deleteDir(string $path): void
    {
        if (!is_dir($path)) return;
        $items = @scandir($path) ?: [];
        foreach ($items as $item) {
            if ($item === '.' || $item === '..') continue;
            $full = $path . '/' . $item;
            is_dir($full) ? self::deleteDir($full) : @unlink($full);
        }
        @rmdir($path);
    }
}
