<?php

require_once __DIR__ . '/cms_upgrade_workspace_support.php';

function cmsUpgradeVersion($version)
{
    $version = trim((string) $version);
    if (!preg_match('/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/', $version)) {
        throw new RuntimeException('目标版本号无效');
    }
    return $version;
}

function cmsUpgradeStatePath($root, $version)
{
    $workspace = cmsBuildUpgradeWorkspace($root, cmsUpgradeVersion($version));
    return $workspace['base_dir'] . '/upgrade_state_' . preg_replace('/[^A-Za-z0-9_.-]/', '_', $version) . '.json';
}

function cmsUpgradeReadState($root, $version)
{
    $path = cmsUpgradeStatePath($root, $version);
    $state = is_file($path) ? json_decode((string) file_get_contents($path), true) : null;
    if (!is_array($state)) {
        throw new RuntimeException('升级任务不存在或已失效，请重新开始');
    }
    return $state;
}

function cmsUpgradeWriteState($root, $version, array $state)
{
    $path = cmsUpgradeStatePath($root, $version);
    $dir = dirname($path);
    if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
        throw new RuntimeException('无法创建升级临时目录，请检查 data/upgrade_tmp 写入权限');
    }
    $state['updated_at'] = time();
    if (file_put_contents($path, json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOCK_EX) === false) {
        throw new RuntimeException('升级状态保存失败');
    }
    return $state;
}

function cmsUpgradeDeleteTree($path)
{
    if (!is_dir($path)) return;
    $items = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );
    foreach ($items as $item) {
        $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
    }
    @rmdir($path);
}

function cmsUpgradeSafeRelative($path)
{
    $path = ltrim(str_replace('\\', '/', trim((string) $path)), '/');
    if ($path === '' || strpos($path, '../') !== false || preg_match('/^[A-Za-z]:\//', $path)) {
        throw new RuntimeException('升级包包含非法路径');
    }
    return $path;
}

function cmsUpgradeVerifyPackageSignature($packageFile, $signatureBase64)
{
    $publicKeyFile = __DIR__ . '/upgrade_public_key.pem';
    if (!is_file($publicKeyFile)) return false;
    $signature = base64_decode((string) $signatureBase64, true);
    if ($signature === false || $signature === '') return false;
    $publicKey = openssl_pkey_get_public((string) file_get_contents($publicKeyFile));
    if (!$publicKey) return false;
    $result = openssl_verify(hash_file('sha256', $packageFile), $signature, $publicKey, OPENSSL_ALGO_SHA256) === 1;
    if (is_resource($publicKey)) openssl_free_key($publicKey);
    return $result;
}

function cmsUpgradeDownload($root, $apiBase, $targetVersion, $currentVersion, $licenseKey = '', $domain = '')
{
    $targetVersion = cmsUpgradeVersion($targetVersion);
    $workspace = cmsBuildUpgradeWorkspace($root, $targetVersion);
    if (!is_dir($workspace['base_dir']) && !mkdir($workspace['base_dir'], 0755, true) && !is_dir($workspace['base_dir'])) {
        throw new RuntimeException('无法创建升级临时目录，请检查 data/upgrade_tmp 写入权限');
    }
    cmsUpgradeDeleteTree($workspace['extract_dir']);
    @unlink($workspace['zip_file']);

    $handle = fopen($workspace['zip_file'], 'wb');
    if (!$handle) throw new RuntimeException('升级包临时文件无法写入');
    $url = rtrim($apiBase, '/') . '/api.php?action=package_download&version=' . rawurlencode($targetVersion)
        . '&cache_bust=' . rawurlencode((string) time());
    $ch = curl_init($url);
    $responseHeaders = [];
    curl_setopt_array($ch, [
        CURLOPT_CONNECTTIMEOUT => 20,
        CURLOPT_TIMEOUT => 300,
        CURLOPT_FILE => $handle,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
        CURLOPT_FAILONERROR => false,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => json_encode(['license_key' => (string) $licenseKey, 'domain' => (string) $domain]),
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        CURLOPT_HEADERFUNCTION => function ($curl, $line) use (&$responseHeaders) {
            $length = strlen($line);
            $parts = explode(':', $line, 2);
            if (count($parts) === 2) $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);
            return $length;
        },
    ]);
    $ok = curl_exec($ch);
    $httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE));
    $error = curl_error($ch);
    curl_close($ch);
    fclose($handle);

    $bytes = is_file($workspace['zip_file']) ? filesize($workspace['zip_file']) : 0;
    if (!$ok || $httpCode !== 200 || $bytes < 100) {
        @unlink($workspace['zip_file']);
        throw new RuntimeException($error !== '' ? ('升级包下载失败：' . $error) : ('升级包下载失败，HTTP ' . $httpCode));
    }
    if ($bytes > 200 * 1024 * 1024) {
        @unlink($workspace['zip_file']);
        throw new RuntimeException('升级包异常：文件超过 200MB');
    }
    $actualDigest = hash_file('sha256', $workspace['zip_file']);
    $headerDigest = strtolower(trim((string) ($responseHeaders['x-package-sha256'] ?? '')));
    if ($headerDigest !== '' && !hash_equals($headerDigest, $actualDigest)) {
        @unlink($workspace['zip_file']);
        throw new RuntimeException('升级包传输校验失败');
    }
    $signature = trim((string) ($responseHeaders['x-package-signature'] ?? ''));
    $signatureRequired = version_compare($targetVersion, '1.1.6', '>=');
    if (($signature !== '' && !cmsUpgradeVerifyPackageSignature($workspace['zip_file'], $signature))
        || ($signatureRequired && $signature === '')) {
        @unlink($workspace['zip_file']);
        throw new RuntimeException('升级包数字签名校验失败');
    }
    $zip = new ZipArchive();
    if ($zip->open($workspace['zip_file']) !== true) {
        @unlink($workspace['zip_file']);
        throw new RuntimeException('升级包不是有效的 ZIP 文件');
    }
    $zip->close();

    $state = [
        'phase' => 'downloaded',
        'target_version' => $targetVersion,
        'current_version' => (string) $currentVersion,
        'zip_file' => $workspace['zip_file'],
        'extract_dir' => $workspace['extract_dir'],
        'package_bytes' => $bytes,
        'files' => [],
        'index' => 0,
        'total' => 0,
        'started_at' => time(),
    ];
    cmsUpgradeWriteState($root, $targetVersion, $state);
    return ['phase' => 'downloaded', 'progress' => 20, 'message' => '升级包下载完成', 'bytes' => $bytes];
}

function cmsUpgradeExtractFile(ZipArchive $zip, $entry, $targetRoot)
{
    $entry = cmsUpgradeSafeRelative($entry);
    $destination = rtrim($targetRoot, '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $entry);
    if (substr($entry, -1) === '/') {
        if (!is_dir($destination) && !mkdir($destination, 0755, true) && !is_dir($destination)) {
            throw new RuntimeException('无法创建升级解压目录');
        }
        return;
    }
    $parent = dirname($destination);
    if (!is_dir($parent) && !mkdir($parent, 0755, true) && !is_dir($parent)) {
        throw new RuntimeException('无法创建升级文件目录');
    }
    $source = $zip->getStream($entry);
    $target = fopen($destination, 'wb');
    if (!$source || !$target) {
        if (is_resource($source)) fclose($source);
        if (is_resource($target)) fclose($target);
        throw new RuntimeException('升级文件解压失败：' . $entry);
    }
    $stat = $zip->statName($entry);
    $copied = stream_copy_to_stream($source, $target);
    fclose($source);
    fclose($target);
    if ($copied === false || (is_array($stat) && isset($stat['size']) && intval($copied) !== intval($stat['size']))) {
        @unlink($destination);
        throw new RuntimeException('升级文件解压不完整：' . $entry);
    }
}

function cmsUpgradeZipEntryHash(ZipArchive $zip, $entry)
{
    $stream = $zip->getStream($entry);
    if (!$stream) {
        return '';
    }
    $hash = hash_init('sha256');
    hash_update_stream($hash, $stream);
    fclose($stream);
    return hash_final($hash);
}

function cmsUpgradeExtract($root, $targetVersion)
{
    $state = cmsUpgradeReadState($root, $targetVersion);
    if (($state['phase'] ?? '') !== 'downloaded') throw new RuntimeException('升级任务阶段不正确，请重新开始');
    cmsUpgradeDeleteTree($state['extract_dir']);
    $zip = new ZipArchive();
    if ($zip->open($state['zip_file']) !== true) throw new RuntimeException('升级包打开失败');
    try {
        for ($i = 0; $i < $zip->numFiles; $i++) {
            cmsUpgradeSafeRelative((string) $zip->getNameIndex($i));
        }

        $manifest = json_decode((string) $zip->getFromName('update.json'), true);
        $checksums = json_decode((string) $zip->getFromName('checksums.json'), true);
        if (!is_array($manifest) || !is_array($checksums)) throw new RuntimeException('升级包缺少有效的清单或校验文件');
        if (($manifest['version'] ?? '') !== $state['target_version']) throw new RuntimeException('升级包版本与目标版本不一致');
        if (!empty($manifest['minimum_version']) && version_compare($state['current_version'], (string) $manifest['minimum_version'], '<')) {
            throw new RuntimeException('当前版本过低，请先升级到 v' . $manifest['minimum_version']);
        }
        $files = array_values(array_unique(array_map('cmsUpgradeSafeRelative', (array) ($manifest['files'] ?? []))));
        if (!$files) throw new RuntimeException('升级包文件清单为空');
        foreach ($files as $file) {
            foreach (['data/', 'uploads/', 'template/', 'install/', 'license-center/'] as $forbiddenPrefix) {
                if (strpos($file, $forbiddenPrefix) === 0) throw new RuntimeException('升级包尝试覆盖受保护目录：' . $file);
            }
            $expectedHash = trim((string) ($checksums[$file] ?? ''));
            $actualHash = cmsUpgradeZipEntryHash($zip, $file);
            if ($expectedHash === '' || $actualHash === '' || !hash_equals($expectedHash, $actualHash)) {
                $detail = $expectedHash !== '' && $actualHash !== ''
                    ? '（预期 ' . substr($expectedHash, 0, 12) . '，实际 ' . substr($actualHash, 0, 12) . '）'
                    : '';
                throw new RuntimeException('升级文件校验失败：' . $file . $detail);
            }
        }
    } catch (Throwable $e) {
        $zip->close();
        throw $e;
    }
    $zip->close();

    $backupDir = rtrim($root, '/\\') . '/core/backup/' . date('YmdHis') . '_' . str_replace('.', '', $state['target_version']);
    if (!is_dir($backupDir) && !mkdir($backupDir, 0755, true) && !is_dir($backupDir)) {
        throw new RuntimeException('升级备份目录创建失败');
    }
    file_put_contents($backupDir . '/version_backup.txt', 'Backup version: ' . $state['current_version'] . ' -> ' . $state['target_version'] . ', time: ' . date('Y-m-d H:i:s'));
    $state['phase'] = 'copying';
    $state['files'] = $files;
    $state['checksums'] = array_intersect_key($checksums, array_flip($files));
    $state['index'] = 0;
    $state['total'] = count($files);
    $state['backup_dir'] = $backupDir;
    $state['backup_bytes'] = 0;
    cmsUpgradeWriteState($root, $targetVersion, $state);
    return ['phase' => 'copying', 'progress' => 35, 'message' => '升级包校验完成', 'total' => $state['total']];
}

function cmsUpgradeCopyBatch($root, $targetVersion, $batchSize = 120)
{
    $state = cmsUpgradeReadState($root, $targetVersion);
    if (($state['phase'] ?? '') !== 'copying') throw new RuntimeException('升级任务阶段不正确，请重新开始');
    $root = rtrim(str_replace('\\', '/', $root), '/') . '/';
    $start = intval($state['index']);
    $end = min(intval($state['total']), $start + max(10, min(250, intval($batchSize))));
    $zip = new ZipArchive();
    if ($zip->open($state['zip_file']) !== true) throw new RuntimeException('升级包打开失败');
    $checksums = (array) ($state['checksums'] ?? []);
    if (!$checksums) {
        $checksums = json_decode((string) $zip->getFromName('checksums.json'), true);
        if (!is_array($checksums)) {
            $zip->close();
            throw new RuntimeException('升级包校验清单读取失败');
        }
    }
    try {
        for ($i = $start; $i < $end; $i++) {
            $relative = cmsUpgradeSafeRelative($state['files'][$i]);
            $destination = $root . $relative;
            $backup = rtrim($state['backup_dir'], '/\\') . '/' . $relative;
            if (is_file($destination) && !is_file($backup)) {
                if (!is_dir(dirname($backup)) && !mkdir(dirname($backup), 0755, true) && !is_dir(dirname($backup))) {
                    throw new RuntimeException('备份目录创建失败：' . $relative);
                }
                if (!@copy($destination, $backup)) throw new RuntimeException('原文件备份失败：' . $relative);
                $state['backup_bytes'] = intval($state['backup_bytes'] ?? 0) + intval(filesize($backup));
            }
            if (!is_dir(dirname($destination)) && !mkdir(dirname($destination), 0755, true) && !is_dir(dirname($destination))) {
                throw new RuntimeException('目标目录创建失败：' . $relative);
            }

            $source = $zip->getStream($relative);
            $temporary = $destination . '.upgrade-' . bin2hex(random_bytes(4)) . '.tmp';
            $target = $source ? @fopen($temporary, 'wb') : false;
            if (!$source || !$target) {
                if (is_resource($source)) fclose($source);
                if (is_resource($target)) fclose($target);
                @unlink($temporary);
                throw new RuntimeException('升级文件读取失败：' . $relative);
            }
            $copied = stream_copy_to_stream($source, $target);
            fclose($source);
            fclose($target);
            $expectedHash = trim((string) ($checksums[$relative] ?? ''));
            $actualHash = is_file($temporary) ? hash_file('sha256', $temporary) : '';
            if ($copied === false || $expectedHash === '' || $actualHash === '' || !hash_equals($expectedHash, $actualHash)) {
                @unlink($temporary);
                throw new RuntimeException('升级文件写入前校验失败：' . $relative);
            }
            if (!@copy($temporary, $destination) || hash_file('sha256', $destination) !== $expectedHash) {
                @unlink($temporary);
                if (is_file($backup)) {
                    @copy($backup, $destination);
                } else {
                    @unlink($destination);
                }
                throw new RuntimeException('升级文件写入失败：' . $relative);
            }
            @unlink($temporary);
        }
    } catch (Throwable $e) {
        $zip->close();
        throw $e;
    }
    $zip->close();
    $state['index'] = $end;
    if ($end >= intval($state['total'])) $state['phase'] = 'copied';
    cmsUpgradeWriteState($root, $targetVersion, $state);
    $ratio = $state['total'] > 0 ? $end / $state['total'] : 1;
    return [
        'phase' => $state['phase'],
        'progress' => min(90, 35 + intval(round($ratio * 55))),
        'message' => '正在备份并更新程序文件',
        'current' => $end,
        'total' => intval($state['total']),
        'done' => $state['phase'] === 'copied',
    ];
}

function cmsUpgradeFinalize($root, $targetVersion)
{
    $state = cmsUpgradeReadState($root, $targetVersion);
    if (($state['phase'] ?? '') !== 'copied') throw new RuntimeException('程序文件尚未全部更新');
    $dbSummary = [];
    $dbUpgrade = rtrim($root, '/\\') . '/db_upgrade.php';
    if (is_file($dbUpgrade)) {
        if (!defined('CMS_DB_UPGRADE_BOOTSTRAP_ONLY')) define('CMS_DB_UPGRADE_BOOTSTRAP_ONLY', true);
        require_once $dbUpgrade;
        if (function_exists('cmsRunDatabaseUpgrade')) {
            $result = cmsRunDatabaseUpgrade();
            $dbSummary = (array) ($result['results'] ?? []);
        }
    }
    @unlink($state['zip_file']);
    cmsUpgradeDeleteTree($state['extract_dir']);
    $state['phase'] = 'complete';
    $state['completed_at'] = time();
    file_put_contents(rtrim($state['backup_dir'], '/\\') . '/upgrade_meta.json', json_encode([
        'from_version' => $state['current_version'],
        'to_version' => $state['target_version'],
        'backup_bytes' => intval($state['backup_bytes'] ?? 0),
        'updated_files' => intval($state['total'] ?? 0),
        'completed_at' => $state['completed_at'],
    ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
    cmsUpgradeWriteState($root, $targetVersion, $state);
    return [
        'phase' => 'complete',
        'progress' => 100,
        'message' => '升级完成，程序文件已更新并保留原文件备份',
        'backup_dir' => str_replace(rtrim(str_replace('\\', '/', $root), '/') . '/', '', str_replace('\\', '/', $state['backup_dir'])),
        'database_results' => $dbSummary,
    ];
}
