<?php

declare(strict_types=1);

class UpdateManager
{
    private const CACHE_TTL = 43200;  
    private const LOCK_FILE = '/storage/tmp/.update.lock';
    private const MAX_DOWNLOAD_SIZE = 524288000;  
    private const DIFF_MAX_LINES = 2000;
    private const MIN_FREE_DISK_MB = 100;

    private const CORE_MANAGED_PATHS = [
        'src',
        'sql',
        'lang',
        'templates/admin',
        'templates/install',
        'config/app.php',
        'public/index.php',
        'bin',
    ];

     
     
    private const SIGNATURE_PUBLIC_KEY = 'wRwRZPH67XN8ksSQqMITSgXdzQLLQh8Rf5Mlh0v6X50=';

     

    private static function acquireLock(): mixed
    {
        $lockPath = APP_ROOT . self::LOCK_FILE;
        $dir = dirname($lockPath);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }
        $fp = fopen($lockPath, 'c');
        if (!$fp || !flock($fp, LOCK_EX | LOCK_NB)) {
            if ($fp) {
                fclose($fp);
            }
            return false;
        }
        return $fp;
    }

    private static function releaseLock(mixed $fp): void
    {
        if (is_resource($fp)) {
            flock($fp, LOCK_UN);
            fclose($fp);
        }
    }

     

    private static function preChecks(string $targetDir = ''): ?string
    {
         
        $freeBytes = @disk_free_space(APP_ROOT . '/storage');
        if ($freeBytes !== false && $freeBytes < self::MIN_FREE_DISK_MB * 1024 * 1024) {
            return 'Insufficient disk space. At least ' . self::MIN_FREE_DISK_MB . ' MB free is required.';
        }

         
        $tmpDir = APP_ROOT . '/storage/tmp';
        if (!is_dir($tmpDir)) {
            @mkdir($tmpDir, 0755, true);
        }
        if (!is_writable($tmpDir)) {
            return 'The storage/tmp/ directory is not writable.';
        }

         
        if ($targetDir !== '' && is_dir($targetDir) && !is_writable($targetDir)) {
            return 'The target directory is not writable: ' . basename($targetDir);
        }

        return null;
    }

     

    private static function verifySignature(string $filePath, string $signatureHex): bool
    {
        if (!function_exists('sodium_crypto_sign_verify_detached')) {
            return false;
        }

        $publicKey = base64_decode(self::SIGNATURE_PUBLIC_KEY);
        if ($publicKey === false || strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
            return false;
        }

        $signature = @hex2bin($signatureHex);
        if ($signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) {
            return false;
        }

        $fileContent = file_get_contents($filePath);
        if ($fileContent === false) {
            return false;
        }

        return sodium_crypto_sign_verify_detached($signature, $fileContent, $publicKey);
    }

     

    private static function verifyDownload(string $localPath, array $download, string $callerChecksum, bool $requireSignature = false): array
    {
        $verified = ['checksum' => false, 'signature' => false];

         
         
         
         
        $checksum = ($download['checksum'] ?? '') !== '' ? $download['checksum'] : $callerChecksum;
        if ($checksum === '') {
            @unlink($localPath);
            return ['error' => 'No integrity checksum available for this package. Refusing to install.'];
        }
        if (!hash_equals(strtolower($checksum), strtolower((string) hash_file('sha256', $localPath)))) {
            @unlink($localPath);
            return ['error' => 'Checksum verification failed. The downloaded file may be corrupted or tampered with.'];
        }
        $verified['checksum'] = true;

         
         
         
        $signatureHex = $download['signature'] ?? '';
        if ($signatureHex === '') {
            if ($requireSignature) {
                @unlink($localPath);
                return ['error' => 'This package is not cryptographically signed. Refusing to install an unsigned core update.'];
            }
        } elseif (self::verifySignature($localPath, $signatureHex)) {
            $verified['signature'] = true;
        } else {
            @unlink($localPath);
            return ['error' => 'Cryptographic signature verification failed. The package may have been tampered with.'];
        }

         
        $size = filesize($localPath);
        if ($size === false || $size === 0) {
            @unlink($localPath);
            return ['error' => 'Downloaded file is empty or unreadable.'];
        }
        if ($size > self::MAX_DOWNLOAD_SIZE) {
            @unlink($localPath);
            return ['error' => 'Downloaded file exceeds maximum allowed size (' . round(self::MAX_DOWNLOAD_SIZE / 1048576) . ' MB).'];
        }

        return $verified;
    }

     

    private static function logUpdate(string $type, string $slug, string $fromVersion, string $toVersion, bool $checksumOk, bool $signatureOk, bool $success, ?string $error, ?string $backupPath, int $durationMs): void
    {
        try {
            Database::insert('update_log', [
                'user_id' => Auth::id(),
                'type' => $type,
                'slug' => $slug,
                'from_version' => $fromVersion,
                'to_version' => $toVersion,
                'checksum_verified' => $checksumOk ? 1 : 0,
                'signature_verified' => $signatureOk ? 1 : 0,
                'success' => $success ? 1 : 0,
                'error_message' => $error,
                'backup_path' => $backupPath,
                'duration_ms' => $durationMs,
            ]);
        } catch (\Throwable $e) {
            error_log('UpdateManager: failed to write audit log: ' . $e->getMessage());
        }
    }

     

    

    public static function ensureSchema(): void
    {
        try {
            if (!App::isInstalled()) {
                return;
            }
            if (Setting::get('db_schema_version', '') === APP_VERSION) {
                return;  
            }
            $pdo = Database::get();
            $locked = $pdo->query("SELECT GET_LOCK('tp_schema_migrate', 0)")->fetchColumn();
            if (!$locked) {
                return;  
            }
            try {
                if (Setting::get('db_schema_version', '') === APP_VERSION) {
                    return;  
                }
                if (!class_exists('Migrator')) {
                    require_once APP_ROOT . '/src/Install/Migrator.php';
                }
                $results = Migrator::run($pdo);
                $errors = array_filter($results, fn($r) => ($r['status'] ?? '') === 'error');
                if (empty($errors)) {
                    Setting::set('db_schema_version', APP_VERSION);
                } else {
                    error_log('UpdateManager::ensureSchema migration errors: ' . json_encode($errors));
                }
            } finally {
                $pdo->query("SELECT RELEASE_LOCK('tp_schema_migrate')");
            }
        } catch (\Throwable $e) {
            error_log('UpdateManager::ensureSchema failed: ' . $e->getMessage());
        }
    }

    public static function backupDatabase(): ?string
    {
        $backupDir = APP_ROOT . '/storage/backups/db';
        if (!is_dir($backupDir)) {
            mkdir($backupDir, 0755, true);
        }

        $backupFile = $backupDir . '/pre-update-' . date('Y-m-d_His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8) . '.sql';

         
         
         
        $config = self::getDbCredentials();
        if ($config !== null && $config['name'] !== '') {
            $mysqldump = self::findBinary('mysqldump');
            if ($mysqldump !== null) {
                $defFile = $backupDir . '/.my-' . bin2hex(random_bytes(6)) . '.cnf';
                $ini = "[client]\nhost = \"" . addslashes($config['host']) . "\"\n"
                     . "user = \"" . addslashes($config['user']) . "\"\n"
                     . "password = \"" . addslashes($config['pass']) . "\"\n";
                if (@file_put_contents($defFile, $ini) !== false) {
                    @chmod($defFile, 0600);
                    $cmd = sprintf(
                        '%s --defaults-extra-file=%s --single-transaction --quick --no-tablespaces --skip-lock-tables --routines --events %s > %s 2>/dev/null',
                        escapeshellarg($mysqldump),
                        escapeshellarg($defFile),
                        escapeshellarg($config['name']),
                        escapeshellarg($backupFile)
                    );
                    @exec($cmd, $output, $exitCode);
                    @unlink($defFile);
                     
                     
                    if ($exitCode === 0 && is_file($backupFile) && filesize($backupFile) > 0
                        && self::fileEndsWith($backupFile, 'Dump completed')) {
                        return $backupFile;
                    }
                    @unlink($backupFile);
                }
            }
        }

         
         
         
        try {
            $pdo = Database::get();
            $fp = fopen($backupFile, 'w');
            if (!$fp) {
                return null;
            }

            fwrite($fp, "-- TubePress database backup (pre-update, PDO fallback)\n");
            fwrite($fp, "-- Date: " . date('Y-m-d H:i:s') . "\n");
            fwrite($fp, "SET NAMES utf8mb4;\n");
            fwrite($fp, "SET FOREIGN_KEY_CHECKS=0;\n\n");

            $tables = [];
            foreach ($pdo->query("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'") as $row) {
                $tables[] = array_values($row)[0];
            }

            foreach ($tables as $table) {
                $q = '`' . str_replace('`', '``', $table) . '`';
                fwrite($fp, "DROP TABLE IF EXISTS {$q};\n");
                $create = $pdo->query("SHOW CREATE TABLE {$q}")->fetch();
                if (isset($create['Create Table'])) {
                    fwrite($fp, $create['Create Table'] . ";\n\n");
                }

                $offset = 0;
                $chunk = 2000;
                while (true) {
                    $rows = $pdo->query("SELECT * FROM {$q} LIMIT {$chunk} OFFSET {$offset}")->fetchAll();
                    if (empty($rows)) {
                        break;
                    }
                    $cols = implode(', ', array_map(fn($c) => '`' . str_replace('`', '``', $c) . '`', array_keys($rows[0])));
                    foreach ($rows as $r) {
                        $vals = implode(', ', array_map(function ($v) use ($pdo) {
                            return $v === null ? 'NULL' : $pdo->quote((string) $v);
                        }, array_values($r)));
                        fwrite($fp, "INSERT INTO {$q} ({$cols}) VALUES ({$vals});\n");
                    }
                    if (count($rows) < $chunk) {
                        break;
                    }
                    $offset += $chunk;
                }
                fwrite($fp, "\n");
            }

            fwrite($fp, "SET FOREIGN_KEY_CHECKS=1;\n");
            fwrite($fp, "-- Dump completed\n");
            fclose($fp);

            return filesize($backupFile) > 0 ? $backupFile : null;
        } catch (\Throwable $e) {
            error_log('UpdateManager: DB backup failed: ' . $e->getMessage());
            return null;
        }
    }

    private static function getDbCredentials(): ?array
    {
        $configFile = APP_ROOT . '/config/database.php';
        if (!is_file($configFile)) {
            return null;
        }
        $content = file_get_contents($configFile);
        if ($content === false) {
            return null;
        }

         
         
         
         
        $norm = preg_replace('/[\'"]\s*\.\s*[\'"]/', '', $content);
        if ($norm === null) {
            $norm = $content;
        }

        if (!preg_match('/host=([^;\'"]+)/', $norm, $hm)) {
            return null;
        }
        $host = trim($hm[1]);
        $name = preg_match('/dbname=([^;\'"]+)/', $norm, $dm) ? trim($dm[1]) : Database::getName();

         
        if (!preg_match('/charset=[^;\'"]*[\'"]\s*,\s*([\'"])(.*?)\1\s*,\s*([\'"])(.*?)\3\s*,/s', $norm, $cm)) {
            return null;
        }
        $user = $cm[2];
        $pass = $cm[4];

        if ($host === '' || $user === '') {
            return null;
        }

        return ['host' => $host, 'user' => $user, 'pass' => $pass, 'name' => $name];
    }

     

    private static function atomicExtract(ZipArchive $zip, string $targetDir, string $slug, string $parentDir): ?string
    {
         
        $stageDir = APP_ROOT . '/storage/tmp/stage-' . $slug . '-' . bin2hex(random_bytes(4));
        mkdir($stageDir, 0755, true);

         
        $firstEntry = $zip->getNameIndex(0);
        $hasRootFolder = str_contains($firstEntry, '/') && explode('/', $firstEntry)[0] === $slug;

        if ($hasRootFolder) {
            $zip->extractTo($stageDir);
            $extractedDir = $stageDir . '/' . $slug;
        } else {
            $innerDir = $stageDir . '/' . $slug;
            mkdir($innerDir, 0755, true);
            $zip->extractTo($innerDir);
            $extractedDir = $innerDir;
        }

        if (!is_dir($extractedDir)) {
            self::deleteDir($stageDir);
            return 'Extraction failed: expected directory not found after extraction.';
        }

         
        $oldDir = $targetDir . '.old-' . bin2hex(random_bytes(4));

        if (is_dir($targetDir)) {
            if (!@rename($targetDir, $oldDir)) {
                 
                self::copyDir($extractedDir, $targetDir);
                self::deleteDir($stageDir);
                return null;
            }
        }

        if (!@rename($extractedDir, $targetDir)) {
             
            if (is_dir($oldDir)) {
                @rename($oldDir, $targetDir);
            }
            self::deleteDir($stageDir);
            return 'Failed to move extracted files to target directory.';
        }

         
        if (is_dir($oldDir)) {
            self::deleteDir($oldDir);
        }
        self::deleteDir($stageDir);

        return null;
    }

     

    public static function getAvailableUpdates(bool $forceRefresh = false): array
    {
        if (!$forceRefresh) {
            $cached = Setting::get('update_check_data', '');
            $lastCheck = Setting::get('update_check_time', '');
            if ($cached !== '' && $lastCheck !== '' && (time() - (int) $lastCheck) < self::CACHE_TTL) {
                $decoded = json_decode($cached, true);
                if ($decoded !== null) {
                    return $decoded;
                }
            }
        }

        return self::refresh();
    }

    public static function refresh(): array
    {
        $result = CatalogueClient::checkUpdates();
        if ($result === null) {
            return ['cms' => null, 'plugins' => [], 'themes' => []];
        }

        $data = self::filterRealUpdates([
            'cms' => $result['cms'] ?? null,
            'plugins' => $result['plugins'] ?? [],
            'themes' => $result['themes'] ?? [],
        ]);

        Setting::set('update_check_data', json_encode($data));
        Setting::set('update_check_time', (string) time());

        return $data;
    }

    

    private static function filterRealUpdates(array $data): array
    {
        $cms = $data['cms'] ?? null;
        if (is_array($cms)) {
            $installed = defined('APP_VERSION') ? (string) APP_VERSION : '';
            if ($installed === '') {
                $installed = (string) ($cms['current'] ?? '');
            }
            $latest = (string) ($cms['latest'] ?? $cms['version'] ?? '');
            if (self::isRealUpgrade($latest, $installed)) {
                if ($installed !== '') {
                    $cms['current'] = $installed;
                }
                $data['cms'] = $cms;
            } else {
                $data['cms'] = null;
            }
        } else {
            $data['cms'] = null;
        }

        foreach (['plugins' => 'plugin', 'themes' => 'theme'] as $key => $kind) {
            $kept = [];
            foreach (($data[$key] ?? []) as $item) {
                if (!is_array($item)) {
                    continue;
                }
                $slug = (string) ($item['slug'] ?? '');
                $latest = (string) ($item['latest'] ?? $item['version'] ?? '');
                if ($slug === '' || !self::isValidSlug($slug) || !self::isExtensionInstalled($kind, $slug)) {
                    continue;
                }
                $installed = self::installedExtensionVersion($kind, $slug);
                if ($installed === '') {
                    $installed = (string) ($item['current'] ?? '');
                }
                if (!self::isRealUpgrade($latest, $installed)) {
                    continue;
                }
                if ($installed !== '') {
                    $item['current'] = $installed;
                }
                $kept[] = $item;
            }
            $data[$key] = $kept;
        }

        return $data;
    }

     
    private static function isRealUpgrade(string $latest, string $installed): bool
    {
        $latest = ltrim(trim($latest), 'vV');
        $installed = ltrim(trim($installed), 'vV');
        if ($latest === '') {
            return false;
        }
        if ($installed === '') {
            return true;  
        }
        return version_compare($latest, $installed, '>');
    }

     
    public static function isExtensionInstalled(string $kind, string $slug): bool
    {
        if (!self::isValidSlug($slug)) {
            return false;
        }
        $dir = ($kind === 'plugin' ? APP_ROOT . '/plugins/' : APP_ROOT . '/themes/') . $slug;
        return is_dir($dir);
    }

    private static function installedExtensionVersion(string $kind, string $slug): string
    {
        $manifest = $kind === 'plugin'
            ? APP_ROOT . '/plugins/' . $slug . '/plugin.json'
            : APP_ROOT . '/themes/' . $slug . '/theme.json';
        if (!is_file($manifest)) {
            return '';
        }
        $info = json_decode((string) file_get_contents($manifest), true);
        return (is_array($info) && isset($info['version'])) ? (string) $info['version'] : '';
    }

    

    public static function autoApply(): array
    {
        $log = [];
        $avail = self::getAvailableUpdates(true);

        $cms = $avail['cms'] ?? null;
        $cmsVer = is_array($cms) ? (string) ($cms['latest'] ?? $cms['version'] ?? '') : '';
        if ($cmsVer !== '') {
            $r = self::updateCore($cmsVer, (string) ($cms['checksum'] ?? ''));
            $log[] = ['type' => 'cms', 'version' => $cmsVer, 'success' => !empty($r['success']), 'error' => $r['error'] ?? null];
        }
         
        foreach (['plugins' => 'plugin'] as $key => $type) {
            foreach (($avail[$key] ?? []) as $u) {
                $ver = (string) ($u['latest'] ?? $u['version'] ?? '');
                if (empty($u['slug']) || $ver === '') continue;
                if (empty($u['is_free'])) { $log[] = ['type' => $type, 'slug' => $u['slug'], 'skipped' => 'paid']; continue; }
                $r = $type === 'plugin'
                    ? self::updatePlugin((string) $u['slug'], $ver, (string) ($u['checksum'] ?? ''), true)
                    : self::updateTheme((string) $u['slug'], $ver, (string) ($u['checksum'] ?? ''), true);
                $log[] = ['type' => $type, 'slug' => $u['slug'], 'version' => $ver, 'success' => !empty($r['success']), 'error' => $r['error'] ?? null];
            }
        }
        if ($log) {
            error_log('UpdateManager::autoApply applied: ' . json_encode($log));
            Setting::set('auto_update_last', (string) time());
        }
        return $log;
    }

    public static function countUpdates(): int
    {
        $cached = Setting::get('update_check_data', '');
        if ($cached === '') {
            return 0;
        }

        $data = json_decode($cached, true);
        if ($data === null) {
            return 0;
        }

        $count = 0;
        if (!empty($data['cms'])) {
            $count++;
        }
        $count += count($data['plugins'] ?? []);
        $count += count($data['themes'] ?? []);

        return $count;
    }

     

    

    private static function downloadErr(array $download): string
    {
        if (($download['error_code'] ?? '') === 'update_access_expired') {
            $m = (string) ($download['error'] ?? '');
            return $m !== '' ? $m : 'Your included update period has ended. Renew update access at tubepress.io to install newer versions.';
        }
        return 'Download failed: ' . ($download['error'] ?? 'Unknown');
    }

    public static function updatePlugin(string $slug, string $version, string $expectedChecksum, bool $isFree = false, bool $allowInstall = false): array
    {
        if (!$allowInstall && !self::isExtensionInstalled('plugin', $slug)) {
            return ['success' => false, 'error' => 'That plugin is not installed on this site, so there is nothing to update.'];
        }
        if (!self::isValidSlug($slug)) {
            return ['success' => false, 'error' => 'Invalid slug'];
        }

        $startTime = hrtime(true);
        $targetDir = APP_ROOT . '/plugins/' . $slug;
        $backupPath = null;

         
        $preError = self::preChecks($targetDir);
        if ($preError !== null) {
            return ['success' => false, 'error' => $preError];
        }

        $lock = self::acquireLock();
        if ($lock === false) {
            return ['success' => false, 'error' => 'Another update is already in progress. Please wait and try again.'];
        }

        try {
            @set_time_limit(300);

             
            $fromVersion = '';
            $pluginJson = $targetDir . '/plugin.json';
            if (is_file($pluginJson)) {
                $meta = json_decode(file_get_contents($pluginJson), true);
                $fromVersion = $meta['version'] ?? '';
            }

             
            $download = CatalogueClient::downloadPackage('plugin', $slug, $version, $isFree);
            if (!$download['success']) {
                $error = self::downloadErr($download);
                self::logUpdate('plugin', $slug, $fromVersion, $version, false, false, false, $error, null, self::elapsed($startTime));
                return ['success' => false, 'error' => $error];
            }

            $localPath = $download['local_path'];

             
            $verification = self::verifyDownload($localPath, $download, $expectedChecksum);
            if (isset($verification['error'])) {
                self::logUpdate('plugin', $slug, $fromVersion, $version, false, false, false, $verification['error'], null, self::elapsed($startTime));
                return ['success' => false, 'error' => $verification['error']];
            }

             
            if (is_dir($targetDir)) {
                $backupDir = APP_ROOT . '/storage/backups/plugins';
                if (!is_dir($backupDir)) {
                    mkdir($backupDir, 0755, true);
                }
                $backupPath = $backupDir . '/' . $slug . '-' . date('Y-m-d_His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8);
                self::copyDir($targetDir, $backupPath);
                self::writeBackupMeta($backupPath, $fromVersion, $version);
            }

             
            if (!class_exists('ZipArchive')) { throw new \RuntimeException('The PHP "zip" extension is required for this operation. Install ext-zip and reload PHP.'); } $zip = new ZipArchive();
            if ($zip->open($localPath) !== true) {
                @unlink($localPath);
                $error = 'Failed to open zip file';
                self::logUpdate('plugin', $slug, $fromVersion, $version, $verification['checksum'], $verification['signature'], false, $error, $backupPath, self::elapsed($startTime));
                return ['success' => false, 'error' => $error];
            }

            $zipCheck = self::verifyZipSafety($zip, dirname($targetDir), $slug);
            if ($zipCheck !== true) {
                $zip->close();
                @unlink($localPath);
                self::logUpdate('plugin', $slug, $fromVersion, $version, $verification['checksum'], $verification['signature'], false, $zipCheck, $backupPath, self::elapsed($startTime));
                return ['success' => false, 'error' => $zipCheck];
            }

             
            $extractError = self::atomicExtract($zip, $targetDir, $slug, dirname($targetDir));
            $zip->close();
            @unlink($localPath);

            if ($extractError !== null) {
                self::logUpdate('plugin', $slug, $fromVersion, $version, $verification['checksum'], $verification['signature'], false, $extractError, $backupPath, self::elapsed($startTime));
                return ['success' => false, 'error' => $extractError];
            }

             
            if (!is_file($targetDir . '/plugin.php') && !is_file($targetDir . '/plugin.json')) {
                $error = 'Extraction failed: plugin files not found. Backup available at: ' . basename($backupPath ?? '');
                self::logUpdate('plugin', $slug, $fromVersion, $version, $verification['checksum'], $verification['signature'], false, $error, $backupPath, self::elapsed($startTime));
                return ['success' => false, 'error' => $error];
            }

            self::clearCache();
            self::logUpdate('plugin', $slug, $fromVersion, $version, $verification['checksum'], $verification['signature'], true, null, $backupPath, self::elapsed($startTime));

            return ['success' => true, 'error' => null];
        } finally {
            self::releaseLock($lock);
        }
    }

     

    public static function updateTheme(string $slug, string $version, string $expectedChecksum, bool $isFree = false, bool $allowInstall = false): array
    {
        if (!$allowInstall && !self::isExtensionInstalled('theme', $slug)) {
            return ['success' => false, 'error' => 'That theme is not installed on this site, so there is nothing to update.'];
        }
        if (!self::isValidSlug($slug)) {
            return ['success' => false, 'error' => 'Invalid slug'];
        }

        $startTime = hrtime(true);
        $targetDir = APP_ROOT . '/themes/' . $slug;
        $backupPath = null;

        $preError = self::preChecks($targetDir);
        if ($preError !== null) {
            return ['success' => false, 'error' => $preError];
        }

        $lock = self::acquireLock();
        if ($lock === false) {
            return ['success' => false, 'error' => 'Another update is already in progress. Please wait and try again.'];
        }

        try {
            @set_time_limit(300);

            $fromVersion = '';
            $themeJson = $targetDir . '/theme.json';
            if (is_file($themeJson)) {
                $meta = json_decode(file_get_contents($themeJson), true);
                $fromVersion = $meta['version'] ?? '';
            }

            $download = CatalogueClient::downloadPackage('theme', $slug, $version, $isFree);
            if (!$download['success']) {
                $error = self::downloadErr($download);
                self::logUpdate('theme', $slug, $fromVersion, $version, false, false, false, $error, null, self::elapsed($startTime));
                return ['success' => false, 'error' => $error];
            }

            $localPath = $download['local_path'];

            $verification = self::verifyDownload($localPath, $download, $expectedChecksum);
            if (isset($verification['error'])) {
                self::logUpdate('theme', $slug, $fromVersion, $version, false, false, false, $verification['error'], null, self::elapsed($startTime));
                return ['success' => false, 'error' => $verification['error']];
            }

            if (is_dir($targetDir)) {
                $backupDir = APP_ROOT . '/storage/backups/themes';
                if (!is_dir($backupDir)) {
                    mkdir($backupDir, 0755, true);
                }
                $backupPath = $backupDir . '/' . $slug . '-' . date('Y-m-d_His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8);
                self::copyDir($targetDir, $backupPath);
                self::writeBackupMeta($backupPath, $fromVersion, $version);
            }

            if (!class_exists('ZipArchive')) { throw new \RuntimeException('The PHP "zip" extension is required for this operation. Install ext-zip and reload PHP.'); } $zip = new ZipArchive();
            if ($zip->open($localPath) !== true) {
                @unlink($localPath);
                $error = 'Failed to open zip file';
                self::logUpdate('theme', $slug, $fromVersion, $version, $verification['checksum'], $verification['signature'], false, $error, $backupPath, self::elapsed($startTime));
                return ['success' => false, 'error' => $error];
            }

            $zipCheck = self::verifyZipSafety($zip, dirname($targetDir), $slug);
            if ($zipCheck !== true) {
                $zip->close();
                @unlink($localPath);
                self::logUpdate('theme', $slug, $fromVersion, $version, $verification['checksum'], $verification['signature'], false, $zipCheck, $backupPath, self::elapsed($startTime));
                return ['success' => false, 'error' => $zipCheck];
            }

            $extractError = self::atomicExtract($zip, $targetDir, $slug, dirname($targetDir));
            $zip->close();
            @unlink($localPath);

            if ($extractError !== null) {
                self::logUpdate('theme', $slug, $fromVersion, $version, $verification['checksum'], $verification['signature'], false, $extractError, $backupPath, self::elapsed($startTime));
                return ['success' => false, 'error' => $extractError];
            }

            if (!is_file($targetDir . '/theme.json')) {
                $error = 'Extraction failed: theme.json not found. Backup available at: ' . basename($backupPath ?? '');
                self::logUpdate('theme', $slug, $fromVersion, $version, $verification['checksum'], $verification['signature'], false, $error, $backupPath, self::elapsed($startTime));
                return ['success' => false, 'error' => $error];
            }

            self::clearCache();
            self::logUpdate('theme', $slug, $fromVersion, $version, $verification['checksum'], $verification['signature'], true, null, $backupPath, self::elapsed($startTime));

            return ['success' => true, 'error' => null];
        } finally {
            self::releaseLock($lock);
        }
    }

     

    public static function updateCore(string $version, string $expectedChecksum): array
    {
        $startTime = hrtime(true);

        $preError = self::preChecks(APP_ROOT . '/src');
        if ($preError !== null) {
            return ['success' => false, 'error' => $preError];
        }

        $lock = self::acquireLock();
        if ($lock === false) {
            return ['success' => false, 'error' => 'Another update is already in progress. Please wait and try again.'];
        }

        try {
            @set_time_limit(600);

            $fromVersion = defined('APP_VERSION') ? APP_VERSION : '';

            $download = CatalogueClient::downloadPackage('cms', 'core', $version, true);
            if (!$download['success']) {
                $error = self::downloadErr($download);
                self::logUpdate('cms', 'core', $fromVersion, $version, false, false, false, $error, null, self::elapsed($startTime));
                return ['success' => false, 'error' => $error];
            }

            $localPath = $download['local_path'];

            $verification = self::verifyDownload($localPath, $download, $expectedChecksum, true);
            if (isset($verification['error'])) {
                self::logUpdate('cms', 'core', $fromVersion, $version, false, false, false, $verification['error'], null, self::elapsed($startTime));
                return ['success' => false, 'error' => $verification['error']];
            }

             
            $backupPath = APP_ROOT . '/storage/backups/core-' . date('Y-m-d_His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8);
            if (!is_dir($backupPath)) {
                mkdir($backupPath, 0755, true);
            }
            self::writeBackupMeta($backupPath, $fromVersion, $version);

            foreach (self::CORE_MANAGED_PATHS as $item) {
                $srcPath = APP_ROOT . '/' . $item;
                $dstPath = $backupPath . '/' . $item;
                if (is_file($srcPath)) {
                    $dstDir = dirname($dstPath);
                    if (!is_dir($dstDir)) {
                        mkdir($dstDir, 0755, true);
                    }
                    copy($srcPath, $dstPath);
                } elseif (is_dir($srcPath)) {
                    self::copyDir($srcPath, $dstPath);
                }
            }

             
             
             
            $dbBackupFile = self::backupDatabase();
            if ($dbBackupFile === null) {
                @unlink($localPath);
                $err = 'Could not create a database safety backup; core update aborted to protect your data. Check that storage/ is writable and the database is reachable.';
                self::logUpdate('cms', 'core', $fromVersion, $version, $verification['checksum'], $verification['signature'], false, $err, $backupPath, self::elapsed($startTime));
                return ['success' => false, 'error' => $err];
            }

             
            if (!class_exists('ZipArchive')) { throw new \RuntimeException('The PHP "zip" extension is required for this operation. Install ext-zip and reload PHP.'); } $zip = new ZipArchive();
            if ($zip->open($localPath) !== true) {
                @unlink($localPath);
                $error = 'Failed to open zip file';
                self::logUpdate('cms', 'core', $fromVersion, $version, $verification['checksum'], $verification['signature'], false, $error, $backupPath, self::elapsed($startTime));
                return ['success' => false, 'error' => $error];
            }

            $extractDir = APP_ROOT . '/storage/tmp/core-update-' . $version;
            if (is_dir($extractDir)) {
                self::deleteDir($extractDir);
            }
            mkdir($extractDir, 0755, true);
             
             
            $zipCheck = self::verifyZipSafety($zip, dirname($extractDir), basename($extractDir));
            if ($zipCheck !== true) {
                $zip->close();
                @unlink($localPath);
                self::deleteDir($extractDir);
                self::logUpdate('cms', 'core', $fromVersion, $version, $verification['checksum'], $verification['signature'], false, $zipCheck, $backupPath, self::elapsed($startTime));
                return ['success' => false, 'error' => $zipCheck];
            }
            $zip->extractTo($extractDir);
            $zip->close();
            @unlink($localPath);

             
            $entries = scandir($extractDir);
            $entries = array_diff($entries, ['.', '..']);
            $sourceRoot = $extractDir;
            if (count($entries) === 1 && is_dir($extractDir . '/' . reset($entries))) {
                $sourceRoot = $extractDir . '/' . reset($entries);
            }

             
             
             
             
             
             
            $replaced = [];
            try {
                foreach (self::CORE_MANAGED_PATHS as $item) {
                    $src = $sourceRoot . '/' . $item;
                    $dst = APP_ROOT . '/' . $item;
                    if (!file_exists($src)) {
                        continue;  
                    }
                    $aside = $dst . '.old-' . bin2hex(random_bytes(4));
                     
                     
                    $parent = dirname($dst);
                    if (!is_dir($parent) && !@mkdir($parent, 0755, true) && !is_dir($parent)) {
                        throw new \RuntimeException('cannot create parent directory for ' . $item);
                    }
                    if (is_dir($src)) {
                        if (is_dir($dst) && !@rename($dst, $aside)) {
                            throw new \RuntimeException('cannot move aside ' . $item);
                        }
                        if (!@rename($src, $dst)) {
                            if (is_dir($aside)) {
                                @rename($aside, $dst);
                            }
                            throw new \RuntimeException('cannot install new ' . $item);
                        }
                        $replaced[] = ['dir', $dst, $aside];
                    } else {
                        if (is_file($dst)) {
                            @copy($dst, $aside);
                        }
                        if (!@copy($src, $dst)) {
                            if (is_file($aside)) {
                                @copy($aside, $dst);
                                @unlink($aside);
                            }
                            throw new \RuntimeException('cannot install new ' . $item);
                        }
                        $replaced[] = ['file', $dst, $aside];
                    }
                }
            } catch (\Throwable $e) {
                foreach (array_reverse($replaced) as [$kind, $dst, $aside]) {
                    if ($kind === 'dir') {
                        if (is_dir($dst)) {
                            self::deleteDir($dst);
                        }
                        if (is_dir($aside)) {
                            @rename($aside, $dst);
                        }
                    } elseif (is_file($aside)) {
                        @copy($aside, $dst);
                        @unlink($aside);
                    }
                }
                self::restoreCoreBackup($backupPath);
                self::deleteDir($extractDir);
                $err = 'Core file replacement failed and was rolled back: ' . $e->getMessage();
                self::logUpdate('cms', 'core', $fromVersion, $version, $verification['checksum'], $verification['signature'], false, $err, $backupPath, self::elapsed($startTime));
                return ['success' => false, 'error' => $err];
            }

             
            foreach ($replaced as [$kind, $dst, $aside]) {
                if ($kind === 'dir') {
                    if (is_dir($aside)) {
                        self::deleteDir($aside);
                    }
                } elseif (is_file($aside)) {
                    @unlink($aside);
                }
            }

            self::deleteDir($extractDir);

             
            $migrationWarning = null;
            try {
                if (!class_exists('Migrator')) {
                    require_once APP_ROOT . '/src/Install/Migrator.php';
                }
                if (class_exists('Migrator')) {
                    $pdo = Database::get();
                    $migrationResults = Migrator::run($pdo);
                    $errors = array_filter($migrationResults, fn($r) => $r['status'] === 'error');
                    if (!empty($errors)) {
                        $migrationWarning = 'Some database migrations had errors. Check error logs.';
                        error_log('Core update migration errors: ' . json_encode($errors));
                    }
                }
            } catch (\Throwable $e) {
                $migrationWarning = 'Database migration failed: ' . $e->getMessage();
                error_log('Core update migration exception: ' . $e->getMessage());
            }

            self::clearCache();

            $result = ['success' => true, 'error' => null];
            if ($migrationWarning !== null) {
                $result['warning'] = $migrationWarning;
                if ($dbBackupFile !== null) {
                    $result['warning'] .= ' Database backup: ' . basename($dbBackupFile);
                }
            }

            self::logUpdate('cms', 'core', $fromVersion, $version, $verification['checksum'], $verification['signature'], true, $migrationWarning, $backupPath, self::elapsed($startTime));

            return $result;
        } finally {
            self::releaseLock($lock);
        }
    }

     

     
    private static function writeBackupMeta(string $backupPath, string $fromVersion, string $toVersion): void
    {
        @file_put_contents($backupPath . '.meta.json', json_encode(['from' => $fromVersion, 'to' => $toVersion]));
    }

     
    private static function backupVersion(string $type, string $path): string
    {
        try {
            if ($type === 'cms') {
                $f = $path . '/config/app.php';
                if (is_file($f) && preg_match('~APP_VERSION.{0,6}(\d+\.\d+\.\d+)~', (string) file_get_contents($f), $m)) {
                    return $m[1];
                }
            } else {
                $f = $path . '/' . ($type === 'theme' ? 'theme.json' : 'plugin.json');
                if (is_file($f)) {
                    $j = json_decode((string) file_get_contents($f), true);
                    if (is_array($j) && isset($j['version'])) return (string) $j['version'];
                }
            }
        } catch (\Throwable $e) {}
        return '';
    }

    public static function listBackups(string $type = ''): array
    {
        $backups = [];
        $baseDirs = [];

        if ($type === '' || $type === 'plugins') {
            $baseDirs['plugin'] = APP_ROOT . '/storage/backups/plugins';
        }
        if ($type === '' || $type === 'themes') {
            $baseDirs['theme'] = APP_ROOT . '/storage/backups/themes';
        }
        if ($type === '' || $type === 'cms') {
            $baseDir = APP_ROOT . '/storage/backups';
            if (is_dir($baseDir)) {
                foreach (scandir($baseDir) as $entry) {
                    if (str_starts_with($entry, 'core-') && is_dir($baseDir . '/' . $entry)) {
                        $cMeta = @json_decode((string) @file_get_contents($baseDir . '/' . $entry . '.meta.json'), true);
                        $backups[] = [
                            'type' => 'cms',
                            'slug' => 'core',
                            'name' => $entry,
                            'path' => $baseDir . '/' . $entry,
                            'date' => filemtime($baseDir . '/' . $entry),
                            'version' => self::backupVersion('cms', $baseDir . '/' . $entry) ?: (is_array($cMeta) ? (string) ($cMeta['from'] ?? '') : ''),
                            'to_version' => is_array($cMeta) ? (string) ($cMeta['to'] ?? '') : '',
                        ];
                    }
                }
            }
        }

        foreach ($baseDirs as $bType => $dir) {
            if (!is_dir($dir)) {
                continue;
            }
            foreach (scandir($dir) as $entry) {
                if ($entry === '.' || $entry === '..') {
                    continue;
                }
                $fullPath = $dir . '/' . $entry;
                if (is_dir($fullPath)) {
                    $slug = preg_replace('/-\d{4}-\d{2}-\d{2}_\d{6}-[a-f0-9]+$/', '', $entry);
                    $eMeta = @json_decode((string) @file_get_contents($fullPath . '.meta.json'), true);
                    $backups[] = [
                        'type' => $bType,
                        'slug' => $slug,
                        'name' => $entry,
                        'path' => $fullPath,
                        'date' => filemtime($fullPath),
                        'version' => self::backupVersion($bType, $fullPath) ?: (is_array($eMeta) ? (string) ($eMeta['from'] ?? '') : ''),
                        'to_version' => is_array($eMeta) ? (string) ($eMeta['to'] ?? '') : '',
                    ];
                }
            }
        }

        usort($backups, fn($a, $b) => $b['date'] - $a['date']);

        return $backups;
    }

    public static function rollback(string $type, string $slug, string $backupName): array
    {
        if (!self::isValidSlug($slug)) {
            return ['success' => false, 'error' => 'Invalid slug'];
        }

        $lock = self::acquireLock();
        if ($lock === false) {
            return ['success' => false, 'error' => 'Another update is already in progress.'];
        }

        try {
             
            if ($type === 'cms') {
                $backupDir = APP_ROOT . '/storage/backups/' . $backupName;
                $realBackup = realpath($backupDir);
                $realBase = realpath(APP_ROOT . '/storage/backups');
                if ($realBackup === false || $realBase === false || !str_starts_with($realBackup, $realBase) || !is_dir($realBackup)) {
                    return ['success' => false, 'error' => 'Backup not found'];
                }
                self::restoreCoreBackup($realBackup);
                self::clearCache();
                return ['success' => true, 'error' => null];
            }

            $backupDir = match ($type) {
                'plugin' => APP_ROOT . '/storage/backups/plugins/' . $backupName,
                'theme' => APP_ROOT . '/storage/backups/themes/' . $backupName,
                default => null,
            };

            if ($backupDir === null || !is_dir($backupDir)) {
                return ['success' => false, 'error' => 'Backup not found'];
            }

             
            $realBackup = realpath($backupDir);
            $realBase = realpath(APP_ROOT . '/storage/backups');
            if ($realBackup === false || $realBase === false || !str_starts_with($realBackup, $realBase)) {
                return ['success' => false, 'error' => 'Invalid backup path'];
            }

            $targetDir = match ($type) {
                'plugin' => APP_ROOT . '/plugins/' . $slug,
                'theme' => APP_ROOT . '/themes/' . $slug,
                default => null,
            };

            if ($targetDir === null) {
                return ['success' => false, 'error' => 'Invalid type'];
            }

             
            $oldDir = $targetDir . '.rollback-' . bin2hex(random_bytes(4));
            if (is_dir($targetDir)) {
                if (!@rename($targetDir, $oldDir)) {
                    return ['success' => false, 'error' => 'Failed to move current version aside'];
                }
            }

            self::copyDir($backupDir, $targetDir);

            if (is_dir($oldDir)) {
                self::deleteDir($oldDir);
            }

            self::clearCache();

            return ['success' => true, 'error' => null];
        } finally {
            self::releaseLock($lock);
        }
    }

     

    public static function getUpdateLog(int $limit = 20): array
    {
        try {
            return Database::fetchAll(
                'SELECT ul.*, u.username FROM update_log ul LEFT JOIN users u ON ul.user_id = u.id ORDER BY ul.created_at DESC LIMIT ?',
                [$limit]
            );
        } catch (\Throwable $e) {
            return [];
        }
    }

     

    public static function clearCache(): void
    {
        Setting::set('update_check_data', '');
        Setting::set('update_check_time', '');
    }

     

    public static function getThemeDiff(string $slug, string $version, string $expectedChecksum, bool $isFree = false): array
    {
        if (!self::isValidSlug($slug)) {
            return ['success' => false, 'error' => 'Invalid slug'];
        }

        $download = CatalogueClient::downloadPackage('theme', $slug, $version, $isFree);
        if (!$download['success']) {
            return ['success' => false, 'error' => self::downloadErr($download)];
        }

        $localPath = $download['local_path'];

        $verification = self::verifyDownload($localPath, $download, $expectedChecksum);
        if (isset($verification['error'])) {
            return ['success' => false, 'error' => $verification['error']];
        }

        $tempDir = APP_ROOT . '/storage/tmp/theme-diff-' . $slug . '-' . $version;
        try {
            if (!class_exists('ZipArchive')) { throw new \RuntimeException('The PHP "zip" extension is required for this operation. Install ext-zip and reload PHP.'); } $zip = new ZipArchive();
            if ($zip->open($localPath) !== true) {
                @unlink($localPath);
                return ['success' => false, 'error' => 'Failed to open zip file'];
            }

            if (is_dir($tempDir)) {
                self::deleteDir($tempDir);
            }
            mkdir($tempDir, 0755, true);
            $zip->extractTo($tempDir);
            $zip->close();
            @unlink($localPath);

            $entries = scandir($tempDir);
            $entries = array_diff($entries, ['.', '..']);
            $newDir = $tempDir;
            if (count($entries) === 1 && is_dir($tempDir . '/' . reset($entries))) {
                $newDir = $tempDir . '/' . reset($entries);
            }

            $currentDir = APP_ROOT . '/themes/' . $slug;
            if (!is_dir($currentDir)) {
                return ['success' => false, 'error' => 'Theme not installed locally'];
            }

            return self::buildDiff($currentDir, $newDir);
        } finally {
            if (is_dir($tempDir)) {
                self::deleteDir($tempDir);
            }
        }
    }

    public static function getThemeDiffDemo(string $slug): array
    {
        $currentDir = APP_ROOT . '/themes/' . $slug;
        if (!is_dir($currentDir)) {
            return ['success' => false, 'error' => 'Theme not installed'];
        }

        $tempDir = APP_ROOT . '/storage/tmp/theme-diff-demo-' . $slug;
        try {
            if (is_dir($tempDir)) {
                self::deleteDir($tempDir);
            }
            self::copyDir($currentDir, $tempDir);

             
            $themeJson = $tempDir . '/theme.json';
            if (is_file($themeJson)) {
                $data = json_decode(file_get_contents($themeJson), true);
                $data['version'] = '1.1.0';
                $data['settings']['lazy_loading'] = true;
                $data['settings']['videos_per_row'] = 5;
                file_put_contents($themeJson, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
            }

            $cssFile = $tempDir . '/assets/css/style.css';
            if (is_file($cssFile)) {
                $css = file_get_contents($cssFile);
                $css = str_replace('--card-gap: 4px;', "--card-gap: 6px;\n    --card-gap-mobile: 2px;\n    --transition-speed: 0.2s;", $css);
                $css = str_replace('--shadow-lg: 0 8px 24px rgba(0,0,0,0.12);', "--shadow-lg: 0 8px 24px rgba(0,0,0,0.12);\n    --shadow-xl: 0 12px 32px rgba(0,0,0,0.16);", $css);
                file_put_contents($cssFile, $css);
            }

            $layoutFile = $tempDir . '/templates/layout.php';
            if (is_file($layoutFile)) {
                $layout = file_get_contents($layoutFile);
                $layout = str_replace(
                    '<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>',
                    "<link rel=\"preconnect\" href=\"https://cdn.jsdelivr.net\" crossorigin>\n    <link rel=\"dns-prefetch\" href=\"https://cdn.jsdelivr.net\">\n    <link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/flag-icons@7.3.2/css/flag-icons.min.css\">",
                    $layout
                );
                file_put_contents($layoutFile, $layout);
            }

            $videoFile = $tempDir . '/templates/video.php';
            if (is_file($videoFile)) {
                $video = file_get_contents($videoFile);
                $video = str_replace('<div class="video-page">', "<?php // Lazy-load improvements for v1.1.0 ?>\n<div class=\"video-page\" itemscope itemtype=\"https://schema.org/VideoObject\">", $video);
                $video = str_replace('$channels       = $video[\'channels\'] ?? [];', "\$channels       = \$video['channels'] ?? [];\n\$videoQuality  = \$video['quality'] ?? 'HD';", $video);
                file_put_contents($videoFile, $video);
            }

            $newJsDir = $tempDir . '/assets/js';
            if (!is_dir($newJsDir)) {
                mkdir($newJsDir, 0755, true);
            }
            file_put_contents($newJsDir . '/lazy-load.js', "/**\n * Lazy-load module for TubePress Simply theme v1.1.0\n * Intersection Observer based image loading\n */\n(function() {\n    'use strict';\n\n    const observer = new IntersectionObserver((entries) => {\n        entries.forEach(entry => {\n            if (entry.isIntersecting) {\n                const img = entry.target;\n                if (img.dataset.src) {\n                    img.src = img.dataset.src;\n                    img.removeAttribute('data-src');\n                }\n                observer.unobserve(img);\n            }\n        });\n    }, {\n        rootMargin: '200px 0px',\n        threshold: 0.01\n    });\n\n    function init() {\n        document.querySelectorAll('img[data-src]').forEach(img => {\n            observer.observe(img);\n        });\n    }\n\n    if (document.readyState === 'loading') {\n        document.addEventListener('DOMContentLoaded', init);\n    } else {\n        init();\n    }\n})();\n");

            $oldFile = $tempDir . '/templates/watch-history.php';
            if (is_file($oldFile)) {
                unlink($oldFile);
            }

            return self::buildDiff($currentDir, $tempDir);
        } finally {
            if (is_dir($tempDir)) {
                self::deleteDir($tempDir);
            }
        }
    }

    private static function buildDiff(string $currentDir, string $newDir): array
    {
        $currentFiles = self::collectFiles($currentDir);
        $newFiles = self::collectFiles($newDir);
        $allPaths = array_unique(array_merge($currentFiles, $newFiles));
        sort($allPaths);

        $files = [];
        $summary = ['modified' => 0, 'added' => 0, 'removed' => 0, 'unchanged' => 0];

        foreach ($allPaths as $relPath) {
            $inCurrent = in_array($relPath, $currentFiles, true);
            $inNew = in_array($relPath, $newFiles, true);

            if ($inNew && !$inCurrent) {
                $content = file_get_contents($newDir . '/' . $relPath);
                $lines = explode("\n", $content);
                $diffLines = array_map(fn($l) => '+' . $l, $lines);
                $diff = implode("\n", $diffLines);
                if (count($diffLines) > self::DIFF_MAX_LINES) {
                    $diff = implode("\n", array_slice($diffLines, 0, self::DIFF_MAX_LINES)) . "\n... (" . (count($diffLines) - self::DIFF_MAX_LINES) . " more lines)";
                }
                $files[] = ['path' => $relPath, 'status' => 'added', 'diff' => $diff, 'additions' => count($lines), 'deletions' => 0];
                $summary['added']++;
            } elseif ($inCurrent && !$inNew) {
                $content = file_get_contents($currentDir . '/' . $relPath);
                $lines = explode("\n", $content);
                $diffLines = array_map(fn($l) => '-' . $l, $lines);
                $diff = implode("\n", $diffLines);
                if (count($diffLines) > self::DIFF_MAX_LINES) {
                    $diff = implode("\n", array_slice($diffLines, 0, self::DIFF_MAX_LINES)) . "\n... (" . (count($diffLines) - self::DIFF_MAX_LINES) . " more lines)";
                }
                $files[] = ['path' => $relPath, 'status' => 'removed', 'diff' => $diff, 'additions' => 0, 'deletions' => count($lines)];
                $summary['removed']++;
            } else {
                $currentPath = $currentDir . '/' . $relPath;
                $newPath = $newDir . '/' . $relPath;

                if (self::isBinaryFile($currentPath) || self::isBinaryFile($newPath)) {
                    $same = hash_file('sha256', $currentPath) === hash_file('sha256', $newPath);
                    $status = $same ? 'unchanged' : 'binary_changed';
                    $files[] = ['path' => $relPath, 'status' => $status, 'diff' => $same ? '' : 'Binary file changed', 'additions' => 0, 'deletions' => 0];
                    $summary[$same ? 'unchanged' : 'modified']++;
                } else {
                    $descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
                    $proc = proc_open(['diff', '-u', $currentPath, $newPath], $descriptors, $pipes);
                    if (!is_resource($proc)) {
                        $files[] = ['path' => $relPath, 'status' => 'modified', 'diff' => 'Failed to run diff', 'additions' => 0, 'deletions' => 0];
                        $summary['modified']++;
                        continue;
                    }
                    fclose($pipes[0]);
                    $output = stream_get_contents($pipes[1]);
                    fclose($pipes[1]);
                    fclose($pipes[2]);
                    $exitCode = proc_close($proc);

                    if ($exitCode === 0) {
                        $files[] = ['path' => $relPath, 'status' => 'unchanged', 'diff' => '', 'additions' => 0, 'deletions' => 0];
                        $summary['unchanged']++;
                    } elseif ($exitCode === 1) {
                        $diffLines = explode("\n", $output);
                        $additions = 0;
                        $deletions = 0;
                        foreach ($diffLines as $dl) {
                            if (str_starts_with($dl, '+') && !str_starts_with($dl, '+++')) {
                                $additions++;
                            } elseif (str_starts_with($dl, '-') && !str_starts_with($dl, '---')) {
                                $deletions++;
                            }
                        }
                        if (count($diffLines) > self::DIFF_MAX_LINES) {
                            $output = implode("\n", array_slice($diffLines, 0, self::DIFF_MAX_LINES)) . "\n... (" . (count($diffLines) - self::DIFF_MAX_LINES) . " more lines)";
                        }
                        $files[] = ['path' => $relPath, 'status' => 'modified', 'diff' => $output, 'additions' => $additions, 'deletions' => $deletions];
                        $summary['modified']++;
                    } else {
                        $files[] = ['path' => $relPath, 'status' => 'modified', 'diff' => 'Diff error', 'additions' => 0, 'deletions' => 0];
                        $summary['modified']++;
                    }
                }
            }
        }

        $order = ['modified' => 0, 'added' => 1, 'removed' => 2, 'binary_changed' => 3, 'unchanged' => 4];
        usort($files, function ($a, $b) use ($order) {
            $oa = $order[$a['status']] ?? 5;
            $ob = $order[$b['status']] ?? 5;
            return $oa !== $ob ? $oa - $ob : strcmp($a['path'], $b['path']);
        });

        return ['success' => true, 'summary' => $summary, 'files' => $files];
    }

     

    public static function copyDir(string $src, string $dst): void
    {
        if (!is_dir($src)) {
            return;
        }

        if (!is_dir($dst)) {
            mkdir($dst, 0755, true);
        }

        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($src, RecursiveDirectoryIterator::SKIP_DOTS),
            RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ($iterator as $item) {
            $target = $dst . '/' . $iterator->getSubPathname();
            if ($item->isDir()) {
                if (!is_dir($target)) {
                    mkdir($target, 0755, true);
                }
            } else {
                $targetDir = dirname($target);
                if (!is_dir($targetDir)) {
                    mkdir($targetDir, 0755, true);
                }
                copy($item->getPathname(), $target);
            }
        }
    }

    private static function collectFiles(string $dir): array
    {
        $files = [];
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
            RecursiveIteratorIterator::LEAVES_ONLY
        );
        foreach ($iterator as $file) {
            if ($file->isFile()) {
                $files[] = ltrim(str_replace($dir, '', $file->getPathname()), '/');
            }
        }
        sort($files);
        return $files;
    }

    private static function isBinaryFile(string $path): bool
    {
        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
        $textExtensions = ['php', 'phtml', 'inc', 'css', 'js', 'json', 'html', 'htm', 'txt', 'md', 'xml', 'svg', 'yml', 'yaml', 'htaccess', 'twig', 'ini', 'sh', 'bat', 'sql', 'map', 'env'];
        if (in_array($ext, $textExtensions, true)) {
            return false;
        }
        $handle = fopen($path, 'rb');
        if (!$handle) {
            return true;
        }
        $chunk = fread($handle, 8192);
        fclose($handle);
        return $chunk !== false && str_contains($chunk, "\x00");
    }

    private static function isValidSlug(string $slug): bool
    {
        return $slug !== '' && (bool) preg_match('/^[a-zA-Z0-9_-]+$/', $slug) && strlen($slug) <= 100;
    }

    private static function verifyZipSafety(ZipArchive $zip, string $parentDir, string $slug): string|bool
    {
        $allowedBase = realpath($parentDir);
        if ($allowedBase === false) {
            return 'Target parent directory does not exist';
        }

        for ($i = 0; $i < $zip->numFiles; $i++) {
            $entryName = $zip->getNameIndex($i);

             
            if (str_contains($entryName, '..') || str_contains($entryName, '\\')) {
                return 'Zip contains path traversal in entry: ' . $entryName;
            }

             
            if (str_starts_with($entryName, '/')) {
                return 'Zip contains absolute path in entry: ' . $entryName;
            }

             
            $stat = $zip->statIndex($i);
            if ($stat !== false && (((int) ($stat['external_attr'] ?? 0) >> 16) & 0xA000) === 0xA000) {
                return 'Zip contains symlink entry: ' . $entryName;
            }

             
            $resolved = $allowedBase . '/' . $slug . '/' . ltrim(str_replace($slug . '/', '', $entryName), '/');
            $normalised = realpath(dirname($resolved));
            if ($normalised !== false && !str_starts_with($normalised, $allowedBase)) {
                return 'Zip entry escapes target directory: ' . $entryName;
            }
        }

        return true;
    }

    public static function deleteDir(string $dir): void
    {
        if (!is_dir($dir)) {
            return;
        }

        $real = realpath($dir);
        if ($real === false || !str_starts_with($real, realpath(APP_ROOT))) {
            return;
        }

        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST
        );

        foreach ($iterator as $item) {
            if ($item->isDir()) {
                @rmdir($item->getPathname());
            } else {
                @unlink($item->getPathname());
            }
        }

        @rmdir($dir);

        if (is_dir($dir)) {
            $escaped = escapeshellarg($real);
            @exec("rm -rf {$escaped}");
        }
    }

    private static function findBinary(string $name): ?string
    {
        foreach (['/usr/bin/', '/usr/local/bin/', '/bin/', '/opt/bin/'] as $p) {
            if (is_executable($p . $name)) {
                return $p . $name;
            }
        }
        $which = @trim((string) @shell_exec('command -v ' . escapeshellarg($name) . ' 2>/dev/null'));
        return ($which !== '' && is_executable($which)) ? $which : null;
    }

    private static function fileEndsWith(string $path, string $needle): bool
    {
        $size = @filesize($path);
        if ($size === false) {
            return false;
        }
        $len = strlen($needle) + 64;
        $fp = @fopen($path, 'rb');
        if (!$fp) {
            return false;
        }
        if ($size > $len) {
            fseek($fp, -$len, SEEK_END);
        }
        $tail = (string) fread($fp, $len);
        fclose($fp);
        return str_contains($tail, $needle);
    }

    private static function restoreCoreBackup(string $backupPath): void
    {
        if ($backupPath === '' || !is_dir($backupPath)) {
            return;
        }
        foreach (self::CORE_MANAGED_PATHS as $item) {
            $src = $backupPath . '/' . $item;
            $dst = APP_ROOT . '/' . $item;
            if (is_dir($src)) {
                if (is_dir($dst)) {
                    self::deleteDir($dst);
                }
                self::copyDir($src, $dst);
            } elseif (is_file($src)) {
                @copy($src, $dst);
            }
        }
    }

    private static function elapsed(int $startNs): int
    {
        return (int) ((hrtime(true) - $startNs) / 1_000_000);
    }
}
