<?php
namespace App\Core;

use ZipArchive;

class Updater
{
    private string $root;

    private const MAX_ZIP_BYTES = 200000000;
    private const MAX_ZIP_FILES = 10000;
    private const MAX_EXTRACT_BYTES = 800000000;
    private const REMOTE_TIMEOUT = 12;
    private const REMOTE_CONNECT_TIMEOUT = 5;
    private const REMOTE_RETRIES = 2;

    private array $exclude = [
        'storage',
        'config/database.php',
        'config/config.php',
        'auth_station',
        'auth',
    ];

    public function __construct()
    {
        $this->root = dirname(__DIR__, 2);
        $this->sanitizeStoredLogs();
    }

    public function check(): array
    {
        if (!function_exists('\\fetchUpdateState')) {
            return ['ok' => false, 'error' => 'Update state helper is unavailable'];
        }

        try {
            // Manual update checks should bypass the short-lived success cache
            // so newly published versions show up immediately.
            $state = \fetchUpdateState(true);
        } catch (\Throwable $e) {
            $this->updateLog([
                'stage' => 'fetch_update_state_exception',
                'url' => function_exists('\\station_update_server') ? $this->safeUrlForLog((string)\station_update_server()) : '',
                'error' => $e->getMessage(),
            ]);
            return ['ok' => false, 'error' => 'Update check failed: ' . $e->getMessage()];
        }

        $this->updateLog([
            'stage' => 'fetch_update_state',
            'url' => $this->safeUrlForLog((string)($state['update_api_url'] ?? '')),
            'authorized' => !empty($state['authorized']) ? 1 : 0,
            'has_update' => !empty($state['has_update']) ? 1 : 0,
            'cache_hit' => !empty($state['cache_hit']) ? 1 : 0,
            'error' => (string)($state['error'] ?? ''),
        ]);

        $error = trim((string)($state['error'] ?? ''));
        if ($error !== '') {
            return ['ok' => false, 'error' => $error];
        }

        $latest = is_array($state['latest'] ?? null) ? (array)$state['latest'] : [];
        $latestVersion = trim((string)($state['latest_version'] ?? ($latest['version'] ?? $latest['latest_version'] ?? $latest['version_name'] ?? '')));
        $currentVersion = trim((string)(\app_config()['app_version'] ?? ''));
        if ($latestVersion === '' || empty($state['has_update']) || !\station_is_version_newer($latestVersion, $currentVersion)) {
            return ['ok' => true, 'data' => null];
        }

        $downloadUrl = trim((string)($state['download_url'] ?? ($latest['download_url'] ?? $latest['package_url'] ?? $latest['url'] ?? '')));
        $latest['version'] = $latestVersion;
        $latest['download_url'] = $downloadUrl;
        $latest['_server_used'] = trim((string)($state['update_api_url'] ?? (function_exists('\\station_update_server') ? \station_update_server() : '')));
        $latest['_manifest_v2'] = !empty($latest['_manifest_v2'])
            || stripos($downloadUrl, 'action=v2.download') !== false
            || trim((string)($latest['package_sig'] ?? '')) !== '';

        if ($this->shouldRequireSignedMetadata($latest) && trim((string)($latest['package_sig'] ?? '')) === '') {
            return ['ok' => false, 'error' => 'Signed update metadata is required but missing'];
        }

        return ['ok' => true, 'data' => $latest];
    }
    public function apply(array $latest): array
    {
        if (empty($latest['version'])) {
            return ['ok' => false, 'error' => 'Missing target version'];
        }
        if (!class_exists(ZipArchive::class)) {
            return ['ok' => false, 'error' => 'ZipArchive extension is required'];
        }

        $domain = $this->clientDomain();
        $serverUsed = trim((string)($latest['_server_used'] ?? ''));
        if ($serverUsed === '' && function_exists('\\station_update_server')) {
            $serverUsed = trim((string)\station_update_server());
        }

        if (function_exists('\\station_update_request_headers')) {
            $headers = \station_update_request_headers($domain, trim((string)(\app_config()['app_version'] ?? '')), 'online');
        } else {
            $token = function_exists('\\station_bridge_token') ? trim((string)\station_bridge_token()) : '';
            $headers = $this->buildAuthHeaders($token, $domain, trim((string)(\app_config()['app_version'] ?? '')), 'online');
        }

        $version = (string)$latest['version'];
        $safeVersion = preg_replace('/[^A-Za-z0-9._-]/', '_', $version);

        $updateBase = $this->root . '/storage/update';
        $baseDir = $updateBase . '/' . date('Ymd_His') . '_' . $safeVersion;
        $zipPath = $baseDir . '/package.zip';
        $extractDir = $baseDir . '/extract';

        if (!$this->ensureDir($baseDir)) {
            return ['ok' => false, 'error' => 'Cannot create update directory: ' . $baseDir];
        }
        if (!$this->ensureDir($extractDir)) {
            return ['ok' => false, 'error' => 'Cannot create extract directory: ' . $extractDir];
        }

        $downloadUrlRaw = trim((string)($latest['download_url'] ?? ''));
        $isManifestV2 = !empty($latest['_manifest_v2'])
            || stripos($downloadUrlRaw, 'action=v2.download') !== false
            || trim((string)($latest['package_sig'] ?? '')) !== '';

        $downloadBase = $this->resolveRemoteUrl($downloadUrlRaw, '');
        if ($downloadBase === '' && $serverUsed !== '') {
            $downloadBase = $this->buildApiUrl($serverUsed, $isManifestV2 ? 'v2.download' : 'download', ['version' => $version]);
        }

        $downloadUrl = $this->appendQuery($downloadBase, 'domain', $domain);
        if ($downloadUrl === '') {
            return ['ok' => false, 'error' => 'Download URL is empty (check station base_url config)'];
        }

        $this->updateLog([
            'stage' => 'download_package',
            'version' => $version,
            'url' => $this->safeUrlForLog($downloadUrl),
            'header_mode' => function_exists('\\station_update_header_mode') ? \station_update_header_mode($headers) : '',
        ]);

        $download = $this->httpDownloadToFile($downloadUrl, $zipPath, $headers, 180, self::MAX_ZIP_BYTES);
        if (empty($download['ok'])) {
            return ['ok' => false, 'error' => (string)($download['error'] ?? 'Download package failed')];
        }

        $packageCheck = $this->validateDownloadedPackage($zipPath, $latest);
        if (empty($packageCheck['ok'])) {
            $this->updateLog([
                'stage' => 'package_validate_failed',
                'url' => $this->safeUrlForLog($downloadUrl),
                'error' => (string)($packageCheck['error'] ?? 'Package validation failed'),
                'expected_size' => (int)($packageCheck['expected_size'] ?? 0),
                'actual_size' => (int)($packageCheck['actual_size'] ?? 0),
                'expected_sha256' => (string)($packageCheck['expected_sha256'] ?? ''),
                'actual_sha256' => (string)($packageCheck['actual_sha256'] ?? ''),
                'head_preview' => (string)($packageCheck['head_preview'] ?? ''),
            ]);
            return ['ok' => false, 'error' => (string)($packageCheck['error'] ?? 'Package validation failed')];
        }
        $zip = $packageCheck['zip'];
        if (!$zip instanceof ZipArchive) {
            return ['ok' => false, 'error' => 'Open zip failed'];
        }

        $this->clearDir($extractDir);
        $extract = $this->extractZipSafe($zip, $extractDir);
        $zip->close();
        if (empty($extract['ok'])) {
            return ['ok' => false, 'error' => (string)($extract['error'] ?? 'Extract failed')];
        }

        $sourceRoot = $this->resolveSourceRoot($extractDir);
        if ($sourceRoot === null) {
            return ['ok' => false, 'error' => 'Invalid package structure (app/config/public missing)'];
        }

        $rollbackDir = $baseDir . '/rollback';
        $snapshot = $this->prepareRollbackSnapshot($sourceRoot, $this->root, $rollbackDir);
        if (empty($snapshot['ok'])) {
            return ['ok' => false, 'error' => (string)($snapshot['error'] ?? 'Prepare rollback snapshot failed')];
        }

        try {
            $copied = $this->copyProject($sourceRoot, $this->root);
            if (empty($copied['ok'])) {
                throw new \RuntimeException((string)($copied['error'] ?? 'Copy files failed'));
            }
            if ((int)($copied['files'] ?? 0) < 1) {
                throw new \RuntimeException('No files copied from update package');
            }

            $localMigrateFile = $this->resolveLocalMigrationFile($version);
            $migrationRequired = !empty($latest['has_migrate']) || trim((string)($latest['migrate_url'] ?? '')) !== '';
            if ($this->isMigrationApplied($version)) {
                $this->appendMigrationLog([
                    'version' => $version,
                    'status' => 'skipped',
                    'started_at' => date('Y-m-d H:i:s'),
                    'ended_at' => date('Y-m-d H:i:s'),
                    'duration_ms' => 0,
                    'statement_count' => 0,
                    'error' => 'migration already applied',
                ]);
            } elseif ($localMigrateFile !== null) {
                $startAt = date('Y-m-d H:i:s');
                $tick = microtime(true);
                $meta = [];

                $sql = @file_get_contents($localMigrateFile);
                if ($sql === false) {
                    $this->appendMigrationLog([
                        'version' => $version,
                        'status' => 'failed',
                        'started_at' => $startAt,
                        'ended_at' => date('Y-m-d H:i:s'),
                        'duration_ms' => (int)round((microtime(true) - $tick) * 1000),
                        'error' => 'Cannot read local migration file: ' . basename($localMigrateFile),
                    ]);
                    throw new \RuntimeException('Cannot read local migration file for version ' . $version);
                }

                try {
                    $this->runSql((string)$sql, $meta);
                    $this->markMigrationApplied($version);
                    $this->appendMigrationLog([
                        'version' => $version,
                        'status' => 'success',
                        'started_at' => $startAt,
                        'ended_at' => date('Y-m-d H:i:s'),
                        'duration_ms' => (int)round((microtime(true) - $tick) * 1000),
                        'statement_count' => (int)($meta['statement_count'] ?? 0),
                        'source' => basename($localMigrateFile),
                    ]);
                } catch (\Throwable $e) {
                    $this->appendMigrationLog([
                        'version' => $version,
                        'status' => 'failed',
                        'started_at' => $startAt,
                        'ended_at' => date('Y-m-d H:i:s'),
                        'duration_ms' => (int)round((microtime(true) - $tick) * 1000),
                        'statement_index' => (int)($meta['statement_index'] ?? 0),
                        'statement_preview' => (string)($meta['statement_preview'] ?? ''),
                        'source' => basename($localMigrateFile),
                        'error' => $e->getMessage(),
                    ]);
                    throw new \RuntimeException('Database migrate execution failed: ' . $e->getMessage(), 0, $e);
                }
            } elseif ($migrationRequired) {
                $this->appendMigrationLog([
                    'version' => $version,
                    'status' => 'skipped',
                    'started_at' => date('Y-m-d H:i:s'),
                    'ended_at' => date('Y-m-d H:i:s'),
                    'duration_ms' => 0,
                    'statement_count' => 0,
                    'error' => 'Local migration file is missing, skipped',
                ]);
            }

            $this->updateVersion($version);
        } catch (\Throwable $e) {
            $restored = $this->restoreRollbackSnapshot($rollbackDir, $this->root);
            $this->updateLog([
                'stage' => 'rollback',
                'version' => $version,
                'restored' => !empty($restored['ok']),
                'error' => $e->getMessage(),
                'created_removed' => $restored['removed_created'] ?? 0,
                'files_restored' => $restored['restored_files'] ?? 0,
            ]);
            return ['ok' => false, 'error' => $e->getMessage()];
        }

        @unlink($zipPath);
        $this->clearDir($extractDir);
        $rollbackKeep = (int)((\app_security_config()['update']['rollback_keep'] ?? 3));
        $this->cleanupOldUpdates($updateBase, max(1, $rollbackKeep));
        $cleanup = $this->cleanupOneTimeTools();
        $this->updateLog([
            'stage' => 'post_update_cleanup',
            'removed' => (int)($cleanup['removed_count'] ?? 0),
            'failed' => (int)($cleanup['failed_count'] ?? 0),
            'files_removed' => $cleanup['removed_files'] ?? [],
            'files_failed' => $cleanup['failed_files'] ?? [],
        ]);

        return ['ok' => true];
    }
    private function extractZipSafe(ZipArchive $zip, string $extractDir): array
    {
        $extractDir = rtrim(str_replace('\\', '/', $extractDir), '/');
        if (!$this->ensureDir($extractDir)) {
            return ['ok' => false, 'error' => 'Extract directory is not writable: ' . $extractDir];
        }

        if ($zip->numFiles > self::MAX_ZIP_FILES) {
            return ['ok' => false, 'error' => 'Too many files in package, extraction rejected'];
        }

        $totalBytes = 0;
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = (string)$zip->getNameIndex($i);
            if ($name === '') {
                continue;
            }
            $name = str_replace('\\', '/', $name);
            if (strpos($name, "\0") !== false) {
                return ['ok' => false, 'error' => 'Invalid filename in package'];
            }

            if ($name[0] === '/' || preg_match('#^[A-Za-z]:/#', $name)) {
                continue;
            }

            $parts = array_values(array_filter(explode('/', $name), static fn($p) => $p !== '' && $p !== '.'));
            $safeParts = [];
            $bad = false;
            foreach ($parts as $p) {
                if ($p === '..') {
                    $bad = true;
                    break;
                }
                $safeParts[] = $p;
            }
            if ($bad || !$safeParts) {
                continue;
            }

            $rel = implode('/', $safeParts);
            $dst = $extractDir . '/' . $rel;

            if (substr($name, -1) === '/') {
                if (!is_dir($dst) && !@mkdir($dst, 0755, true) && !is_dir($dst)) {
                    return ['ok' => false, 'error' => 'Create directory failed: ' . $rel];
                }
                continue;
            }

            $st = $zip->statIndex($i);
            $fileSize = is_array($st) && isset($st['size']) ? (int)$st['size'] : 0;
            if ($fileSize > 0) {
                $totalBytes += $fileSize;
                if ($totalBytes > self::MAX_EXTRACT_BYTES) {
                    return ['ok' => false, 'error' => 'Extracted size exceeds safety limit'];
                }
            }

            $parent = dirname($dst);
            if (!is_dir($parent) && !@mkdir($parent, 0755, true) && !is_dir($parent)) {
                return ['ok' => false, 'error' => 'Create directory failed: ' . $parent];
            }

            $in = $zip->getStream($name);
            if ($in === false) {
                return ['ok' => false, 'error' => 'Read entry failed: ' . $rel];
            }

            $out = @fopen($dst, 'wb');
            if ($out === false) {
                fclose($in);
                return ['ok' => false, 'error' => 'Write file failed: ' . $rel];
            }

            while (!feof($in)) {
                $buf = fread($in, 1024 * 1024);
                if ($buf === false) {
                    break;
                }
                fwrite($out, $buf);
            }
            fclose($out);
            fclose($in);
        }

        return ['ok' => true];
    }
    private function serverCandidates(string $raw): array
    {
        $raw = trim($raw);
        if ($raw === '') {
            return [];
        }
        if (function_exists('\\station_normalize_update_api_url')) {
            $raw = (string)\station_normalize_update_api_url($raw);
        }
        return $raw === '' ? [] : [$raw];
    }

    private function fetchLatestFromPublicInfo(array $candidates, string $usedServer, string $domain, array $headers, string $currentVersion = ''): array
    {
        $fallbackServers = $candidates;
        if ($usedServer !== '') {
            array_unshift($fallbackServers, $usedServer);
        }
        $fallbackServers = array_values(array_unique(array_filter($fallbackServers, static function ($v): bool {
            return is_string($v) && trim($v) !== '';
        })));

        $bestLatest = null;
        $anyOk = false;
        $bestVersion = '';
        $bestScore = -1;

        foreach ($fallbackServers as $fallbackServer) {
            $fallbackQuery = [
                'domain' => $domain,
                'current_version' => trim($currentVersion),
                'target' => 'online',
                '_t' => time(),
                '_cb' => function_exists('station_cache_bust_token') ? station_cache_bust_token() : str_replace('.', '', sprintf('%.6F', microtime(true))),
            ];
            $fallbackUrl = $this->buildApiUrl($fallbackServer, 'public_info', $fallbackQuery);
            $fallbackResp = $this->httpGet($fallbackUrl, $headers, self::REMOTE_TIMEOUT, self::REMOTE_RETRIES, self::REMOTE_CONNECT_TIMEOUT);
            if ((int)($fallbackResp['status'] ?? 0) !== 200) {
                continue;
            }
            $fallbackJson = json_decode((string)($fallbackResp['body'] ?? ''), true);
            if (!is_array($fallbackJson) || empty($fallbackJson['status'])) {
                continue;
            }
            $anyOk = true;

            if (function_exists('\\station_bridge_verify_signed_payload')) {
                $verified = \station_bridge_verify_signed_payload($fallbackJson, 'public_info', $domain);
                if (!$verified) {
                    $requireSignature = function_exists('\\station_bridge_require_signature')
                        ? \station_bridge_require_signature()
                        : false;
                    if ($requireSignature) {
                        continue;
                    }
                }
            }

            if (function_exists('\\station_update_server_pin_success')) {
                \station_update_server_pin_success($fallbackServer);
            }

            $latest = $fallbackJson['latest'] ?? null;
            if (!is_array($latest)) {
                continue;
            }

            $latestVersion = trim((string)($latest['version'] ?? ''));
            $latestScore = $this->latestPayloadScore($latest);
            if ($this->shouldPreferLatest($latestVersion, $latestScore, $bestVersion, $bestScore)) {
                $latest['_server_used'] = $fallbackServer;
                $bestLatest = $latest;
                $bestVersion = $latestVersion;
                $bestScore = $latestScore;
            }
        }

        return ['ok' => $anyOk, 'latest' => $bestLatest];
    }

    private function latestPayloadScore(?array $latest): int
    {
        if (!is_array($latest)) {
            return -1;
        }

        $score = 0;
        if (trim((string)($latest['version'] ?? '')) !== '') {
            $score += 20;
        }
        if (trim((string)($latest['download_url'] ?? '')) !== '') {
            $score += 4;
        }
        if (!empty($latest['has_migrate']) || trim((string)($latest['migrate_url'] ?? '')) !== '') {
            $score += 2;
        }
        if (trim((string)($latest['created_at'] ?? '')) !== '') {
            $score += 1;
        }

        foreach (['update_note', 'optimize_note', 'fix_note', 'update_items', 'optimize_items', 'fix_items'] as $key) {
            if (function_exists('\\station_release_note_text') && \station_release_note_text($latest[$key] ?? '') !== '') {
                $score += 2;
            }
        }

        return $score;
    }

    private function shouldPreferLatest(string $candidateVersion, int $candidateScore, string $bestVersion, int $bestScore): bool
    {
        $candidateVersion = trim($candidateVersion);
        $bestVersion = trim($bestVersion);

        if ($candidateVersion === '') {
            return false;
        }
        if ($bestVersion === '') {
            return true;
        }
        if (function_exists('\\station_is_version_newer') && \station_is_version_newer($candidateVersion, $bestVersion)) {
            return true;
        }
        if (strcasecmp($candidateVersion, $bestVersion) === 0 && $candidateScore > $bestScore) {
            return true;
        }

        return false;
    }

    private function isFallbackLatestPreferred(string $fallbackVersion, string $checkVersion): bool
    {
        $fallbackVersion = trim($fallbackVersion);
        $checkVersion = trim($checkVersion);
        if ($fallbackVersion === '') {
            return false;
        }
        if ($checkVersion === '') {
            return true;
        }
        if ($fallbackVersion === $checkVersion) {
            return false;
        }
        if (function_exists('\\station_is_version_newer')) {
            return \station_is_version_newer($fallbackVersion, $checkVersion);
        }

        if (preg_match('/^[0-9]+(?:\.[0-9]+)*$/', $fallbackVersion) && preg_match('/^[0-9]+(?:\.[0-9]+)*$/', $checkVersion)) {
            return version_compare($fallbackVersion, $checkVersion, '>');
        }

        return strcmp($fallbackVersion, $checkVersion) > 0;
    }

    private function expandServerCandidate(string $server): array
    {
        $server = $this->normalizeServer($server);
        if ($server === '' || !preg_match('#^https://#i', $server)) {
            return [];
        }

        return [$server];
    }

    private function normalizeServer(string $server): string
    {
        $server = trim($server);
        if ($server === '') return '';
        return rtrim($server, '/');
    }

    private function clientDomain(): string
    {
        if (function_exists('\\station_current_domain')) {
            $domain = trim((string)\station_current_domain());
            if ($domain !== '') {
                return strtolower($domain);
            }
        }

        $host = (string)($_SERVER['HTTP_HOST'] ?? '');
        $host = preg_replace('/:\d+$/', '', strtolower($host));
        if ($host !== '' && $host !== 'localhost' && $host !== '127.0.0.1') {
            return $host;
        }

        $cfg = app_config();
        $baseUrl = trim((string)($cfg['base_url'] ?? ''));
        if ($baseUrl !== '' && preg_match('#^https?://#i', $baseUrl)) {
            $baseHost = parse_url($baseUrl, PHP_URL_HOST);
            if (is_string($baseHost) && $baseHost !== '') {
                return strtolower($baseHost);
            }
        }
        return 'unknown';
    }

    private function buildApiUrl(string $server, string $action, array $query = []): string
    {
        $base = trim($server);
        if ($base === '') {
            return '';
        }

        if (!preg_match('#^https?://#i', $base)) {
            $base = 'https://' . ltrim($base, '/');
        }

        $base = rtrim($base, '/');
        if (preg_match('#/public/api\\.php$#i', $base)) {
            // keep as-is
        } elseif (preg_match('#/api\\.php$#i', $base)) {
            $base = preg_replace('#/api\\.php$#i', '/public/api.php', $base) ?: $base;
        } elseif (!preg_match('#/[^/]+\\.php$#i', $base)) {
            $base .= '/public/api.php';
        }

        $query['action'] = $action;
        return $base . (strpos($base, '?') === false ? '?' : '&') . http_build_query($query);
    }

    private function appendQuery(string $url, string $key, string $value): string
    {
        if ($url === '' || $value === '') return $url;
        $join = strpos($url, '?') === false ? '?' : '&';
        return $url . $join . rawurlencode($key) . '=' . rawurlencode($value);
    }

    private function httpGet(string $url, array $headers = [], int $timeout = self::REMOTE_TIMEOUT, int $retries = self::REMOTE_RETRIES, int $connectTimeout = self::REMOTE_CONNECT_TIMEOUT): array
    {
        $attempts = max(1, $retries + 1);
        $last = ['status' => 0, 'body' => '', 'error' => ''];

        if (function_exists('curl_init')) {
            $try = function () use ($url, $headers, $timeout, $connectTimeout): array {
                $ch = curl_init();
                curl_setopt_array($ch, [
                    CURLOPT_URL => $url,
                    CURLOPT_RETURNTRANSFER => true,
                    CURLOPT_FOLLOWLOCATION => true,
                    CURLOPT_TIMEOUT => $timeout,
                    CURLOPT_CONNECTTIMEOUT => $connectTimeout,
                    CURLOPT_NOSIGNAL => true,
                    CURLOPT_HTTPHEADER => $headers,
                    CURLOPT_SSL_VERIFYPEER => true,
                    CURLOPT_SSL_VERIFYHOST => 2,
                ]);
                $body = curl_exec($ch);
                $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
                $err = curl_error($ch) ?: '';
                curl_close($ch);
                return ['status' => $status, 'body' => $body === false ? '' : (string)$body, 'error' => $body === false ? $err : ''];
            };

            for ($attempt = 0; $attempt < $attempts; $attempt++) {
                $last = $try();
                if ((int)($last['status'] ?? 0) === 200) {
                    return $last;
                }
                if ($attempt >= ($attempts - 1)) {
                    return $last;
                }

                $status = (int)($last['status'] ?? 0);
                $shouldRetry = $status === 0 || $status >= 500 || trim((string)($last['error'] ?? '')) !== '';
                if (!$shouldRetry) {
                    return $last;
                }
                usleep(200000 * ($attempt + 1));
            }

            return $last;
        }

        for ($attempt = 0; $attempt < $attempts; $attempt++) {
            $context = stream_context_create([
                'http' => [
                    'method' => 'GET',
                    'header' => implode("\r\n", $headers),
                    'timeout' => $timeout,
                ],
                'ssl' => [
                    'verify_peer' => true,
                    'verify_peer_name' => true,
                    'allow_self_signed' => false,
                ],
            ]);
            $body = @file_get_contents($url, false, $context);
            $status = 0;
            if (!empty($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
                $status = (int)$m[1];
            }

            $last = [
                'status' => $status,
                'body' => $body === false ? '' : (string)$body,
                'error' => $body === false ? 'request failed (network/permission/timeout)' : '',
            ];
            if ($status === 200) {
                return $last;
            }
            if ($attempt >= ($attempts - 1)) {
                return $last;
            }
            if ($status !== 0 && $status < 500 && $last['error'] === '') {
                return $last;
            }
            usleep(200000 * ($attempt + 1));
        }

        return $last;
    }

    private function httpDownloadToFile(string $url, string $filePath, array $headers, int $timeout, int $maxBytes = 0): array
    {
        $dir = dirname($filePath);
        if (!$this->ensureDir($dir)) {
            return ['ok' => false, 'error' => 'Download directory is not writable: ' . $dir];
        }

        if (function_exists('curl_init')) {
            $attempts = max(1, self::REMOTE_RETRIES + 1);
            $last = ['ok' => false, 'status' => 0, 'error' => ''];

            for ($attempt = 0; $attempt < $attempts; $attempt++) {
                $fp = @fopen($filePath, 'wb');
                if ($fp === false) {
                    return ['ok' => false, 'error' => 'Cannot write file: ' . $filePath];
                }

                $contentLength = null;
                $download = function () use ($url, $headers, $timeout, $fp, $maxBytes, &$contentLength): array {
                    $ch = curl_init();
                    curl_setopt_array($ch, [
                        CURLOPT_URL => $url,
                        CURLOPT_FILE => $fp,
                        CURLOPT_FOLLOWLOCATION => true,
                        CURLOPT_TIMEOUT => $timeout,
                        CURLOPT_CONNECTTIMEOUT => self::REMOTE_CONNECT_TIMEOUT,
                        CURLOPT_NOSIGNAL => true,
                        CURLOPT_HTTPHEADER => $headers,
                        CURLOPT_SSL_VERIFYPEER => true,
                        CURLOPT_SSL_VERIFYHOST => 2,
                        CURLOPT_HEADERFUNCTION => static function ($ch, string $headerLine) use ($maxBytes, &$contentLength): int {
                            $line = trim($headerLine);
                            if ($line !== '' && stripos($line, 'content-length:') === 0) {
                                $v = trim(substr($line, strlen('content-length:')));
                                if (ctype_digit($v)) {
                                    $contentLength = (int)$v;
                                }
                            }
                            if ($maxBytes > 0 && is_int($contentLength) && $contentLength > $maxBytes) {
                                return 0;
                            }
                            return strlen($headerLine);
                        },
                    ]);
                    $ok = curl_exec($ch);
                    $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
                    $err = curl_error($ch) ?: '';
                    curl_close($ch);
                    return ['ok' => (bool)$ok, 'status' => $status, 'error' => $err];
                };

                $last = $download();
                fclose($fp);

                if (!empty($last['ok']) && (int)($last['status'] ?? 0) === 200) {
                    if ($maxBytes > 0 && is_file($filePath) && (int)filesize($filePath) > $maxBytes) {
                        @unlink($filePath);
                        return ['ok' => false, 'error' => 'Package too large'];
                    }

                    return ['ok' => true];
                }

                if ($maxBytes > 0 && is_int($contentLength) && $contentLength > $maxBytes) {
                    @unlink($filePath);
                    return ['ok' => false, 'error' => 'Package too large'];
                }

                $reason = '';
                if (is_file($filePath)) {
                    $raw = (string)@file_get_contents($filePath);
                    $reason = $this->extractRemoteReason($raw);
                }
                @unlink($filePath);

                $status = (int)($last['status'] ?? 0);
                $shouldRetry = $status === 0 || $status >= 500 || trim((string)($last['error'] ?? '')) !== '';
                if ($attempt >= ($attempts - 1) || !$shouldRetry) {
                    if (empty($last['ok'])) {
                        return ['ok' => false, 'error' => 'Download failed: ' . (string)($last['error'] ?? '')];
                    }

                    return ['ok' => false, 'error' => 'Download failed, HTTP ' . $status . ($reason !== '' ? ', reason: ' . $reason : '')];
                }
                usleep(250000 * ($attempt + 1));
            }

            return ['ok' => false, 'error' => 'Download failed'];
        }

        $resp = $this->httpGet($url, $headers, $timeout, self::REMOTE_RETRIES, self::REMOTE_CONNECT_TIMEOUT);
        if ((int)$resp['status'] !== 200) {
            return ['ok' => false, 'error' => 'Download failed, HTTP ' . (int)$resp['status']];
        }
        if ($maxBytes > 0 && strlen((string)$resp['body']) > $maxBytes) {
            return ['ok' => false, 'error' => 'Package too large'];
        }
        if (@file_put_contents($filePath, (string)$resp['body']) === false) {
            return ['ok' => false, 'error' => 'Cannot write file: ' . $filePath];
        }

        return ['ok' => true];
    }

    private function resolveLocalMigrationFile(string $version): ?string
    {
        $safeVersion = preg_replace('/[^A-Za-z0-9._-]/', '_', trim($version));
        if ($safeVersion === '') {
            return null;
        }

        $candidates = [
            $this->root . '/tools/migrations/' . $safeVersion . '.sql',
            $this->root . '/database/migrations/' . $safeVersion . '.sql',
            $this->root . '/storage/update/migrations/' . $safeVersion . '.sql',
            $this->root . '/tools/migrate_' . $safeVersion . '.sql',
        ];

        foreach ($candidates as $file) {
            if (is_file($file)) {
                return $file;
            }
        }

        return null;
    }

    private function buildAuthHeaders(string $token, string $domain, string $currentVersion = '', string $target = 'online'): array
    {
        if (function_exists('\\station_bridge_headers')) {
            $headers = \station_bridge_headers($domain, $currentVersion, $target);
        } else {
            $headers = [
                'X-Client-Domain: ' . $domain,
            ];
        }
        if ($token !== '') {
            $headers[] = 'X-API-KEY: ' . $token;
            $headers[] = 'X-API-TOKEN: ' . $token;
            $headers[] = 'X-Update-Token: ' . $token;
            $headers[] = 'X-Auth-Station-Token: ' . $token;
            $headers[] = 'Authorization: Bearer ' . $token;
        }
        if (function_exists('\\station_ticket_cache_raw') && function_exists('\\app_canonical_json') && function_exists('\\app_b64url_encode')) {
            $ticket = \station_ticket_cache_raw('site_ticket');
            if (is_array($ticket) && $ticket) {
                try {
                    $headers[] = 'X-License-Ticket: ' . \app_b64url_encode(\app_canonical_json($ticket));
                } catch (\Throwable $e) {
                    $this->updateLog([
                        'stage' => 'build_headers_ticket',
                        'error' => $e->getMessage(),
                    ]);
                }
            }
        }
        return array_values(array_unique($headers));
    }

    private function resolveRemoteUrl(string $primary, string $fallback): string
    {
        $primary = trim($primary);
        $fallback = trim($fallback);
        if ($primary === '') return $fallback;

        if (preg_match('#^https://#i', $primary)) {
            return $primary;
        }

        if ($fallback !== '' && preg_match('#^https://#i', $fallback)) {
            $base = preg_replace('/[?#].*$/', '', $fallback);
            $u = parse_url($base);
            if (is_array($u) && !empty($u['scheme']) && !empty($u['host'])) {
                $origin = $u['scheme'] . '://' . $u['host'] . (!empty($u['port']) ? (':' . $u['port']) : '');
                $path = (string)($u['path'] ?? '/');
                $dir = str_replace('\\', '/', dirname($path));
                if ($dir === '.' || $dir === '\\' || $dir === '/') {
                    $dir = '';
                }
                $prefix = rtrim($origin . ($dir !== '' ? $dir : ''), '/');
                return $prefix . '/' . ltrim($primary, '/');
            }
        }

        return $primary;
    }

    private function safeUrlForLog(string $url): string
    {
        $url = trim((string)$url);
        if ($url === '') return '';

        $parts = parse_url($url);
        if (!is_array($parts)) {
            return $this->redactText($url);
        }

        $query = [];
        if (!empty($parts['query'])) {
            parse_str((string)$parts['query'], $query);
            if (!is_array($query)) {
                $query = [];
            }
        }
        foreach (['token', 'api_token', 'api_key', 'update_token', 'auth_station_token', 'station_token', 'key', 'domain'] as $k) {
            if (array_key_exists($k, $query)) {
                $query[$k] = '***';
            }
        }
        $queryStr = http_build_query($query);

        if (!empty($parts['host'])) {
            $scheme = (string)($parts['scheme'] ?? 'https');
            $path = (string)($parts['path'] ?? '/');
            if ($path === '') {
                $path = '/';
            }
            $masked = $scheme . '://***' . $path . ($queryStr !== '' ? ('?' . $queryStr) : '');
            return $this->redactText($masked);
        }

        $path = (string)($parts['path'] ?? $url);
        return $this->redactText($path . ($queryStr !== '' ? ('?' . $queryStr) : ''));
    }

    private function redactText(string $text): string
    {
        $text = (string)preg_replace('#https?://[^\s"\'<>()]+#i', 'https://***', $text);
        $text = (string)preg_replace('/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/i', '***', $text);
        return $text;
    }

    private function sanitizeLogValue($value)
    {
        if (is_array($value)) {
            $out = [];
            foreach ($value as $k => $v) {
                $out[$k] = $this->sanitizeLogValue($v);
            }
            return $out;
        }
        if (is_string($value)) {
            return $this->redactText($value);
        }
        return $value;
    }

    private function sanitizeLogRow(array $row): array
    {
        $out = [];
        foreach ($row as $k => $v) {
            $out[$k] = $this->sanitizeLogValue($v);
        }
        return $out;
    }

    private function sanitizeStoredLogs(): void
    {
        $dir = $this->root . '/storage/update';
        if (!is_dir($dir)) {
            return;
        }

        $logFile = $dir . '/update_client.log';
        if (is_file($logFile)) {
            $raw = @file_get_contents($logFile);
            if (is_string($raw) && $raw !== '') {
                $clean = $this->redactText($raw);
                if ($clean !== $raw) {
                    @file_put_contents($logFile, $clean);
                }
            }
        }

        $migFile = $dir . '/migration_logs.json';
        if (is_file($migFile)) {
            $raw = @file_get_contents($migFile);
            $arr = json_decode((string)$raw, true);
            if (is_array($arr)) {
                $san = [];
                foreach ($arr as $row) {
                    $san[] = is_array($row) ? $this->sanitizeLogRow($row) : $row;
                }
                @file_put_contents($migFile, json_encode($san, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
            }
        }
    }

    private function updateLog(array $row): void
    {
        try {
            $dir = $this->root . '/storage/update';
            if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
                return;
            }
            $row = array_merge([
                'ts' => date('Y-m-d H:i:s'),
            ], $row);
            $row = $this->sanitizeLogRow($row);
            $line = json_encode($row, JSON_UNESCAPED_UNICODE) . "\n";
            @file_put_contents($dir . '/update_client.log', $line, FILE_APPEND);
        } catch (\Throwable $e) {
        }
    }

    private function ensureDir(string $dir): bool
    {
        if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
            return false;
        }
        $probe = rtrim($dir, '/\\') . '/.probe_' . bin2hex(random_bytes(4));
        $ok = @file_put_contents($probe, '1');
        if ($ok === false) {
            return false;
        }
        @unlink($probe);
        return true;
    }

    private function clearDir(string $dir): void
    {
        if (!is_dir($dir)) return;
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST
        );
        foreach ($it as $item) {
            if ($item->isDir()) {
                @rmdir($item->getPathname());
            } else {
                @unlink($item->getPathname());
            }
        }
    }

    private function deleteDir(string $dir): void
    {
        if (!is_dir($dir)) return;
        $this->clearDir($dir);
        @rmdir($dir);
    }

    private function resolveSourceRoot(string $extractDir): ?string
    {
        if ($this->isProjectRoot($extractDir)) return $extractDir;
        $entries = glob($extractDir . '/*');
        if (is_array($entries)) {
            foreach ($entries as $entry) {
                if (is_dir($entry) && $this->isProjectRoot($entry)) {
                    return $entry;
                }
            }
        }
        return null;
    }

    private function validateDownloadedPackage(string $zipPath, array $latest): array
    {
        if (!is_file($zipPath)) {
            return ['ok' => false, 'error' => 'Downloaded package file missing'];
        }

        $headPreview = '';
        $fh = @fopen($zipPath, 'rb');
        if (is_resource($fh)) {
            $head = fread($fh, 160);
            fclose($fh);
            if (is_string($head) && $head !== '') {
                $head = preg_replace('/[^\P{C}\t\r\n]/u', '', $head) ?? '';
                $headPreview = substr(trim((string)$head), 0, 160);
            }
        }

        $expectedSize = (int)($latest['package_size'] ?? ($latest['size'] ?? 0));
        $actualSize = (int)(filesize($zipPath) ?: 0);
        if ($expectedSize > 0 && $actualSize !== $expectedSize) {
            return [
                'ok' => false,
                'error' => 'Package size mismatch',
                'expected_size' => $expectedSize,
                'actual_size' => $actualSize,
                'head_preview' => $headPreview,
            ];
        }

        $expectedSha = strtolower(trim((string)($latest['package_sha256'] ?? ($latest['sha256'] ?? ''))));
        $actualSha = strtolower((string)(hash_file('sha256', $zipPath) ?: ''));
        if ($actualSha === '') {
            return [
                'ok' => false,
                'error' => 'Package hash calculation failed',
                'expected_sha256' => $expectedSha,
                'actual_sha256' => '',
                'head_preview' => $headPreview,
            ];
        }

        $packageSig = trim((string)($latest['package_sig'] ?? ''));
        if ($this->shouldRequireSignedMetadata($latest) && $packageSig === '') {
            return [
                'ok' => false,
                'error' => 'Package signature is required',
                'head_preview' => $headPreview,
            ];
        }
        if ($packageSig !== '' && function_exists('\\station_bridge_verify_envelope') === false && !function_exists('openssl_verify')) {
            return [
                'ok' => false,
                'error' => 'Package signature cannot be verified (OpenSSL unavailable)',
                'head_preview' => $headPreview,
            ];
        }
        if ($packageSig !== '') {
            $publicKey = function_exists('\\station_bridge_security_public_key')
                ? (string)\station_bridge_security_public_key()
                : '';
            if ($publicKey === '') {
                return [
                    'ok' => false,
                    'error' => 'Package signature public key missing',
                    'head_preview' => $headPreview,
                ];
            }
            if ($actualSha === '') {
                $actualSha = strtolower((string)(hash_file('sha256', $zipPath) ?: ''));
            }
            $sig = function_exists('\\app_b64url_decode')
                ? \app_b64url_decode($packageSig)
                : base64_decode($packageSig, true);
            $packageSigAlg = trim((string)($latest['package_sig_alg'] ?? ''));
            $verified = is_string($sig) && $sig !== '';
            if ($verified) {
                if (function_exists('\\station_bridge_verify_signature_binary')) {
                    $verified = \station_bridge_verify_signature_binary('package-sha256:' . $actualSha, $sig, $packageSigAlg);
                } else {
                    $verified = $this->verifyPackageSignature('package-sha256:' . $actualSha, $sig, $publicKey, $packageSigAlg);
                }
            }
            if (!$verified) {
                return [
                    'ok' => false,
                    'error' => 'Package signature mismatch',
                    'expected_sha256' => $expectedSha,
                    'actual_sha256' => $actualSha,
                    'head_preview' => $headPreview,
                ];
            }
            if ($expectedSha !== '' && !hash_equals($expectedSha, $actualSha)) {
                $this->updateLog([
                    'stage' => 'package_hash_mismatch_but_signature_ok',
                    'expected_sha256' => $expectedSha,
                    'actual_sha256' => $actualSha,
                ]);
            }
        } elseif ($expectedSha !== '' && !hash_equals($expectedSha, $actualSha)) {
            return [
                'ok' => false,
                'error' => 'Package hash mismatch',
                'expected_sha256' => $expectedSha,
                'actual_sha256' => $actualSha,
                'head_preview' => $headPreview,
            ];
        }

        $zip = new ZipArchive();
        $openCode = $zip->open($zipPath);
        if ($openCode !== true) {
            return [
                'ok' => false,
                'error' => 'Open zip failed, code: ' . $openCode,
                'head_preview' => $headPreview,
            ];
        }

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

    private function updateSignatureRequired(): bool
    {
        $cfg = \app_config();
        if (!empty($cfg['update_require_signature'])) {
            return true;
        }

        $security = \app_security_config();
        return !empty($security['update']['require_signature']);
    }

    /**
     * Keep strict signature verification for v2/modern update metadata,
     * while allowing legacy check/public_info payloads to continue working
     * with hash/size validation only. This avoids breaking historical clients
     * and older auth deployments during staged rollouts.
     */
    private function shouldRequireSignedMetadata(array $latest): bool
    {
        if (!$this->updateSignatureRequired()) {
            return false;
        }

        if (!empty($latest['_manifest_v2'])) {
            return true;
        }

        $downloadUrl = trim((string)($latest['download_url'] ?? ''));
        if ($downloadUrl !== '' && stripos($downloadUrl, 'action=v2.download') !== false) {
            return true;
        }

        $sig = trim((string)($latest['package_sig'] ?? ''));
        $sigAlg = trim((string)($latest['package_sig_alg'] ?? ''));
        if ($sig !== '' || $sigAlg !== '') {
            return true;
        }

        // Compatibility note: legacy auth deployments may only return the old
        // check/public_info payload without package_sig fields. Keep accepting
        // those payloads so historical clients can still fetch updates.
        $sha = trim((string)($latest['sha256'] ?? ''));
        $size = (int)($latest['size'] ?? 0);
        if ($sha !== '' || $size > 0 || $downloadUrl !== '') {
            $this->updateLog([
                'stage' => 'legacy_unsigned_update_metadata',
                'version' => (string)($latest['version'] ?? ''),
                'warning' => 'signature_missing_compat_mode',
            ]);
            return false;
        }

        return false;
    }

    /**
     * Verify package signatures with a compatibility-first strategy.
     *
     * Old production environments may not expose OPENSSL_PKCS1_PSS_PADDING,
     * so we only use RSA-PSS when the runtime explicitly supports it.
     * Otherwise we gracefully fall back to RS256 verification instead of
     * crashing during update apply.
     */
    private function verifyPackageSignature(string $message, string $signature, string $publicKey, string $alg = ''): bool
    {
        if (!function_exists('openssl_verify')) {
            return false;
        }

        $alg = strtoupper(trim($alg));
        if ($alg === '') {
            $alg = 'RS256';
        }

        if ($alg === 'RS256-PSS') {
            $result = $this->verifyPackageSignaturePss($message, $signature, $publicKey);
            if ($result !== null) {
                return $result;
            }
        }

        return openssl_verify($message, $signature, $publicKey, OPENSSL_ALGO_SHA256) === 1;
    }

    /**
     * Attempt RSA-PSS verification when the current runtime supports it.
     *
     * Returns:
     * - true / false when PSS verification can be performed
     * - null when the runtime does not support the required OpenSSL options
     */
    private function verifyPackageSignaturePss(string $message, string $signature, string $publicKey): ?bool
    {
        if (!defined('OPENSSL_PKCS1_PSS_PADDING')) {
            return null;
        }

        $options = [
            'digest_alg' => 'sha256',
            'rsa_padding_mode' => constant('OPENSSL_PKCS1_PSS_PADDING'),
            'salt_length' => 32,
        ];

        $verified = openssl_verify($message, $signature, $publicKey, $options);
        return $verified === 1;
    }

    private function isProjectRoot(string $dir): bool
    {
        return is_dir($dir . '/app') && is_dir($dir . '/config') && is_dir($dir . '/public');
    }

    private function copyProject(string $sourceRoot, string $targetRoot): array
    {
        $sourceRoot = rtrim($sourceRoot, '/\\');
        $targetRoot = rtrim($targetRoot, '/\\');
        $files = 0;

        if (!$this->ensureDir($targetRoot)) {
            return ['ok' => false, 'files' => 0, 'error' => 'Target root is not writable: ' . $targetRoot];
        }

        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($sourceRoot, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ($it as $item) {
            $srcPath = $item->getPathname();
            $rel = str_replace('\\', '/', substr($srcPath, strlen($sourceRoot) + 1));
            if ($this->isExcluded($rel)) {
                continue;
            }

            $dstPath = $targetRoot . '/' . $rel;
            if ($item->isDir()) {
                if (!is_dir($dstPath) && !@mkdir($dstPath, 0755, true) && !is_dir($dstPath)) {
                    return ['ok' => false, 'files' => $files, 'error' => 'Create directory failed: ' . $dstPath];
                }
                continue;
            }

            $parent = dirname($dstPath);
            if (!is_dir($parent) && !@mkdir($parent, 0755, true) && !is_dir($parent)) {
                return ['ok' => false, 'files' => $files, 'error' => 'Create directory failed: ' . $parent];
            }

            if (!@copy($srcPath, $dstPath)) {
                $last = error_get_last();
                $detail = is_array($last) && !empty($last['message']) ? $last['message'] : 'unknown error';
                return ['ok' => false, 'files' => $files, 'error' => 'Copy failed: ' . $rel . ' | ' . $detail];
            }
            $files++;
        }

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

    private function prepareRollbackSnapshot(string $sourceRoot, string $targetRoot, string $rollbackDir): array
    {
        $sourceRoot = rtrim($sourceRoot, '/\\');
        $targetRoot = rtrim($targetRoot, '/\\');

        if (!$this->ensureDir($rollbackDir) || !$this->ensureDir($rollbackDir . '/files')) {
            return ['ok' => false, 'error' => 'Rollback directory is not writable'];
        }

        $created = [];
        $restorable = 0;
        $it = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($sourceRoot, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ($it as $item) {
            if ($item->isDir()) {
                continue;
            }

            $srcPath = $item->getPathname();
            $rel = str_replace('\\', '/', substr($srcPath, strlen($sourceRoot) + 1));
            if ($this->isExcluded($rel)) {
                continue;
            }

            $targetPath = $targetRoot . '/' . $rel;
            if (!is_file($targetPath)) {
                $created[] = $rel;
                continue;
            }

            $backupPath = $rollbackDir . '/files/' . $rel;
            $parent = dirname($backupPath);
            if (!is_dir($parent) && !@mkdir($parent, 0755, true) && !is_dir($parent)) {
                return ['ok' => false, 'error' => 'Cannot create rollback directory: ' . $parent];
            }
            if (!@copy($targetPath, $backupPath)) {
                return ['ok' => false, 'error' => 'Cannot backup file: ' . $rel];
            }
            $restorable++;
        }

        $configRel = 'config/config.php';
        $configTarget = $targetRoot . '/' . $configRel;
        if (is_file($configTarget)) {
            $backupPath = $rollbackDir . '/files/' . $configRel;
            $parent = dirname($backupPath);
            if (!is_dir($parent) && !@mkdir($parent, 0755, true) && !is_dir($parent)) {
                return ['ok' => false, 'error' => 'Cannot create rollback directory: ' . $parent];
            }
            if (!@copy($configTarget, $backupPath)) {
                return ['ok' => false, 'error' => 'Cannot backup version config'];
            }
            $restorable++;
        }

        $manifest = [
            'created' => array_values(array_unique($created)),
            'restorable_files' => $restorable,
            'saved_at' => date('c'),
        ];
        @file_put_contents(
            $rollbackDir . '/manifest.json',
            json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),
            LOCK_EX
        );

        return ['ok' => true, 'created' => $created, 'restorable_files' => $restorable];
    }

    private function restoreRollbackSnapshot(string $rollbackDir, string $targetRoot): array
    {
        $manifestFile = rtrim($rollbackDir, '/\\') . '/manifest.json';
        $manifest = [];
        if (is_file($manifestFile)) {
            $raw = @file_get_contents($manifestFile);
            $manifest = is_string($raw) ? (array)(json_decode($raw, true) ?: []) : [];
        }

        $restoredFiles = 0;
        $removedCreated = 0;
        $filesRoot = rtrim($rollbackDir, '/\\') . '/files';
        if (is_dir($filesRoot)) {
            $it = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($filesRoot, \FilesystemIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::SELF_FIRST
            );
            foreach ($it as $item) {
                if ($item->isDir()) {
                    continue;
                }

                $backupPath = $item->getPathname();
                $rel = str_replace('\\', '/', substr($backupPath, strlen($filesRoot) + 1));
                $targetPath = rtrim($targetRoot, '/\\') . '/' . $rel;
                $parent = dirname($targetPath);
                if (!is_dir($parent) && !@mkdir($parent, 0755, true) && !is_dir($parent)) {
                    continue;
                }
                if (@copy($backupPath, $targetPath)) {
                    $restoredFiles++;
                }
            }
        }

        $created = is_array($manifest['created'] ?? null) ? (array)$manifest['created'] : [];
        foreach ($created as $rel) {
            $rel = ltrim(str_replace('\\', '/', (string)$rel), '/');
            if ($rel === '') {
                continue;
            }
            $targetPath = rtrim($targetRoot, '/\\') . '/' . $rel;
            if (is_file($targetPath) && @unlink($targetPath)) {
                $removedCreated++;
            }
        }

        return [
            'ok' => true,
            'restored_files' => $restoredFiles,
            'removed_created' => $removedCreated,
        ];
    }

    private function isExcluded(string $rel): bool
    {
        $rel = ltrim(str_replace('\\', '/', $rel), '/');
        foreach ($this->exclude as $ex) {
            $ex = ltrim(str_replace('\\', '/', (string)$ex), '/');
            if ($rel === $ex || $this->startsWith($rel, $ex . '/')) {
                return true;
            }
        }
        return false;
    }

    private function runSql(string $sql, array &$meta = []): void
    {
        if (strncmp($sql, "\xEF\xBB\xBF", 3) === 0) {
            $sql = substr($sql, 3);
        }

        $lines = preg_split('/\r?\n/', $sql);
        $buffer = '';
        foreach ($lines as $line) {
            $trim = trim($line);
            if ($trim === '' || $this->startsWith($trim, '--')) {
                continue;
            }
            $buffer .= $line . "\n";
        }

        $statements = array_filter(array_map('trim', explode(';', $buffer)));
        $pdo = DB::pdo();
        $idx = 0;
        foreach ($statements as $stmt) {
            $idx++;
            try {
                $pdo->exec($stmt);
            } catch (\Throwable $e) {
                $meta['statement_index'] = $idx;
                $meta['statement_preview'] = substr((string)preg_replace('/\s+/', ' ', $stmt), 0, 220);
                throw $e;
            }
        }
        $meta['statement_count'] = $idx;
    }

    private function updateVersion(string $version): void
    {
        $cfg = app_config();
        $cfg['app_version'] = $version;
        $content = "<?php\nreturn " . var_export($cfg, true) . ";\n";
        @file_put_contents($this->root . '/config/config.php', $content);
    }

    private function cleanupOldUpdates(string $updateBase, int $keepCount): void
    {
        if (!is_dir($updateBase)) return;
        $dirs = array_values(array_filter((array)glob($updateBase . '/*'), 'is_dir'));
        if (count($dirs) <= $keepCount) return;

        usort($dirs, static function ($a, $b) {
            return filemtime($b) <=> filemtime($a);
        });

        $kept = 0;
        foreach ($dirs as $dir) {
            $name = basename($dir);
            if ($name !== '' && substr($name, 0, 1) === '_') {
                continue;
            }
            $kept++;
            if ($kept <= $keepCount) continue;
            $this->deleteDir($dir);
        }
    }

    private function cleanupOneTimeTools(): array
    {
        $targets = [
            'tools/init_admin.php',
            'tools/reset_admin.php',
            'tools/seed_admin.php',
            'tools/migrate_add_activated_at.sql',
            'tools/migrate_allow_self_unbind.sql',
            'tools/migrate_system_settings.sql',
        ];

        $removed = [];
        $failed = [];

        foreach ($targets as $rel) {
            $path = $this->root . '/' . str_replace('\\', '/', $rel);
            if (!is_file($path)) {
                continue;
            }
            if (@unlink($path)) {
                $removed[] = $rel;
            } else {
                $failed[] = $rel;
            }
        }

        $toolsDir = $this->root . '/tools';
        if (is_dir($toolsDir)) {
            $entries = @scandir($toolsDir);
            if (is_array($entries)) {
                $remain = array_values(array_filter($entries, static function ($v) {
                    return $v !== '.' && $v !== '..';
                }));
                if (count($remain) === 0) {
                    @rmdir($toolsDir);
                }
            }
        }

        return [
            'removed_count' => count($removed),
            'failed_count' => count($failed),
            'removed_files' => $removed,
            'failed_files' => $failed,
        ];
    }

    private function extractRemoteReason(string $body): string
    {
        $body = trim($body);
        if ($body === '') return '';
        $json = json_decode($body, true);
        if (is_array($json) && !empty($json['reason'])) {
            return $this->translateRemoteReason((string)$json['reason']);
        }
        if (strlen($body) > 120) {
            return $this->translateRemoteReason(substr($body, 0, 120) . '...');
        }
        return $this->translateRemoteReason($body);
    }

    private function translateRemoteReason(string $reason): string
    {
        $raw = trim($reason);
        if ($raw === '') {
            return '';
        }

        $u = static function (string $escaped, string $fallback): string {
            $decoded = json_decode('"' . $escaped . '"');
            return is_string($decoded) ? $decoded : $fallback;
        };

        $norm = strtolower($raw);
        if ($norm === 'unauthorized') return $u('未授权访问', 'Unauthorized');
        if ($norm === 'license disabled') return $u('密钥已禁用', 'License disabled');
        if ($norm === 'license banned') return $u('密钥已封禁', 'License banned');
        if ($norm === 'domain banned') return $u('域名已被封禁', 'Domain banned');
        if ($norm === 'domain not authorized') return $u('当前域名未授权', 'Domain not authorized');
        if ($this->startsWith($norm, 'license already bound to domain:')) {
            $domain = trim(substr($raw, strlen('License already bound to domain:')));
            return $u('该密钥已绑定域名：', 'License already bound to domain: ') . $domain;
        }
        if ($norm === 'missing client domain') return $u('缺少客户端域名', 'Missing client domain');
        if ($norm === 'license not found') return $u('密钥不存在', 'License not found');
        if ($norm === 'invalid action') return $u('无效操作', 'Invalid action');
        if ($norm === 'station not installed') return $u('授权站未安装', 'Station not installed');

        return $raw;
    }
    private function ensureMigrationTable(): void
    {
        $pdo = DB::pdo();
        $pdo->exec('CREATE TABLE IF NOT EXISTS schema_migrations (version VARCHAR(64) NOT NULL PRIMARY KEY, applied_at DATETIME NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
    }

    private function isMigrationApplied(string $version): bool
    {
        try {
            $this->ensureMigrationTable();
            $pdo = DB::pdo();
            $stmt = $pdo->prepare('SELECT version FROM schema_migrations WHERE version = ? LIMIT 1');
            $stmt->execute([$version]);
            return (bool)$stmt->fetch();
        } catch (\Throwable $e) {
            return false;
        }
    }

    private function markMigrationApplied(string $version): void
    {
        try {
            $this->ensureMigrationTable();
            $pdo = DB::pdo();
            $stmt = $pdo->prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, NOW()) ON DUPLICATE KEY UPDATE applied_at = applied_at');
            $stmt->execute([$version]);
        } catch (\Throwable $e) {
        }
    }

    public static function recentMigrationLogs(int $limit = 20): array
    {
        $root = dirname(__DIR__, 2);
        $file = $root . '/storage/update/migration_logs.json';
        if (!is_file($file)) return [];
        $raw = @file_get_contents($file);
        if ($raw === false || $raw === '') return [];
        $arr = json_decode($raw, true);
        if (!is_array($arr)) return [];

        usort($arr, static function ($a, $b) {
            $ta = strtotime((string)($a['started_at'] ?? '')) ?: 0;
            $tb = strtotime((string)($b['started_at'] ?? '')) ?: 0;
            return $tb <=> $ta;
        });
        $arr = array_slice($arr, 0, max(1, $limit));

        $sanitize = static function ($value) use (&$sanitize) {
            if (is_array($value)) {
                $out = [];
                foreach ($value as $k => $v) {
                    $out[$k] = $sanitize($v);
                }
                return $out;
            }
            if (is_string($value)) {
                $value = (string)preg_replace('#https?://[^\s"\'<>()]+#i', 'https://***', $value);
                $value = (string)preg_replace('/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/i', '***', $value);
                return $value;
            }
            return $value;
        };

        $out = [];
        foreach ($arr as $row) {
            $out[] = is_array($row) ? $sanitize($row) : $row;
        }
        return $out;
    }

    private function appendMigrationLog(array $entry): void
    {
        $file = $this->root . '/storage/update/migration_logs.json';
        $dir = dirname($file);
        if (!$this->ensureDir($dir)) return;

        $logs = [];
        if (is_file($file)) {
            $raw = @file_get_contents($file);
            $arr = json_decode((string)$raw, true);
            if (is_array($arr)) $logs = $arr;
        }

        $logs[] = $this->sanitizeLogRow($entry);
        if (count($logs) > 200) {
            $logs = array_slice($logs, -200);
        }
        @file_put_contents($file, json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
    }

    private function startsWith(string $s, string $prefix): bool
    {
        return $prefix === '' ? true : strncmp($s, $prefix, strlen($prefix)) === 0;
    }
}
