<?php
/**
 * PHP 原生自动更新示例
 *
 * 更新包约定：
 * - 普通文件：按 ZIP 内路径覆盖到项目根目录。
 * - 数据库升级 SQL：放到 upgrade/sql/*.sql，按文件名升序执行。
 * - SQL 文件应尽量写成可重复执行，例如先判断字段/索引是否存在。
 *
 * 默认只检测并下载更新包，不自动覆盖。确认备份策略后再开启 AUTO_APPLY。
 */

const LICENSE_API_BASE = 'https://auth.php11.cc';
const CURRENT_VERSION = '1.0.0';
const VERSION_FILE = __DIR__ . '/runtime/current_version.txt';
const AUTH_TOKEN_FILE = __DIR__ . '/license/authtoken.txt';
const AUTH_DOMAIN = '';
const AUTH_IP = '';
const AUTO_APPLY = false;

// 如需自动执行 upgrade/sql/*.sql，请配置数据库和备份命令。
const AUTO_MIGRATE_SQL = true;
const DB_DSN = 'mysql:host=127.0.0.1;port=3306;dbname=your_db;charset=utf8mb4';
const DB_USERNAME = 'your_user';
const DB_PASSWORD = 'your_password';
const DB_BACKUP_COMMAND = 'mysqldump -h127.0.0.1 -P3306 -uyour_user -pyour_password your_db > {file}';
const MIGRATION_TABLE = 'license_update_migrations';

function current_version(): string
{
    if (is_file(VERSION_FILE)) {
        $version = trim((string)file_get_contents(VERSION_FILE));
        if (preg_match('/^[A-Za-z0-9._-]{1,50}$/', $version)) {
            return $version;
        }
    }
    return CURRENT_VERSION;
}

function write_current_version(string $version): void
{
    if (!preg_match('/^[A-Za-z0-9._-]{1,50}$/', $version)) {
        return;
    }
    $dir = dirname(VERSION_FILE);
    if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
        throw new RuntimeException('版本文件目录不可写：' . $dir);
    }
    file_put_contents(VERSION_FILE, $version, LOCK_EX);
}

function license_domain(): string
{
    if (AUTH_DOMAIN !== '') {
        return strtolower(trim(AUTH_DOMAIN));
    }
    $host = $_SERVER['HTTP_HOST'] ?? php_uname('n');
    return strtolower(preg_replace('/:\d+$/', '', $host));
}

function read_auth_token(): string
{
    if (!is_file(AUTH_TOKEN_FILE)) {
        throw new RuntimeException('授权 token 文件不存在');
    }
    $token = trim((string)file_get_contents(AUTH_TOKEN_FILE));
    if (!preg_match('/^[A-Za-z0-9]{16,64}$/', $token)) {
        throw new RuntimeException('授权 token 格式不正确');
    }
    return $token;
}

function post_request(string $url, array $data): array
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query($data),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT => 60,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);
    $body = curl_exec($ch);
    if ($body === false) {
        throw new RuntimeException('请求失败：' . curl_error($ch));
    }
    $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new RuntimeException('服务器返回 HTTP ' . $status);
    }
    $json = json_decode($body, true);
    if (!is_array($json)) {
        throw new RuntimeException('服务器响应不是 JSON');
    }
    return $json;
}

function download_update(string $url, array $data, string $target): string
{
    $fp = fopen($target, 'wb');
    if ($fp === false) {
        throw new RuntimeException('无法写入更新包');
    }
    $ch = curl_init($url);
    $headers = [];
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query($data),
        CURLOPT_FILE => $fp,
        CURLOPT_HEADERFUNCTION => function ($ch, string $header) use (&$headers): int {
            $pos = strpos($header, ':');
            if ($pos !== false) {
                $headers[strtolower(trim(substr($header, 0, $pos)))] = trim(substr($header, $pos + 1));
            }
            return strlen($header);
        },
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT => 300,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);
    $ok = curl_exec($ch);
    $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $error = curl_error($ch);
    curl_close($ch);
    fclose($fp);
    if ($ok === false || $status >= 400) {
        @unlink($target);
        throw new RuntimeException('下载失败：' . ($error ?: 'HTTP ' . $status));
    }
    return strtolower($headers['x-package-sha256'] ?? '');
}

function is_safe_zip_path(string $name): bool
{
    return $name !== ''
        && !str_starts_with($name, '/')
        && !preg_match('/^[A-Za-z]:\//', $name)
        && !str_contains($name, '../');
}

function is_update_sql_path(string $name): bool
{
    return str_starts_with($name, 'upgrade/sql/')
        && str_ends_with(strtolower($name), '.sql')
        && is_safe_zip_path($name);
}

function is_protected_path(string $name): bool
{
    $protect = ['.env', 'runtime/', 'public/storage/', 'uploads/', 'license/', 'authtoken.php'];
    foreach ($protect as $prefix) {
        if ($name === rtrim($prefix, '/') || str_starts_with($name, $prefix)) {
            return true;
        }
    }
    return false;
}

function safe_extract_zip(string $zipFile, string $root): void
{
    $zip = new ZipArchive();
    if ($zip->open($zipFile) !== true) {
        throw new RuntimeException('无法打开更新包');
    }
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $name = str_replace('\\', '/', (string)$zip->getNameIndex($i));
        if (!is_safe_zip_path($name) || is_protected_path($name) || is_update_sql_path($name) || str_ends_with($name, '/')) {
            continue;
        }
        $target = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $name);
        $dir = dirname($target);
        if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
            throw new RuntimeException('目录不可写：' . $dir);
        }
        $content = $zip->getFromIndex($i);
        if ($content !== false) {
            file_put_contents($target, $content, LOCK_EX);
        }
    }
    $zip->close();
}

function backup_targets_from_zip(string $zipFile, string $root, string $backup): void
{
    $zip = new ZipArchive();
    if ($zip->open($zipFile) !== true) {
        throw new RuntimeException('无法打开更新包');
    }
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $name = str_replace('\\', '/', (string)$zip->getNameIndex($i));
        if (!is_safe_zip_path($name) || is_protected_path($name) || is_update_sql_path($name) || str_ends_with($name, '/')) {
            continue;
        }
        $source = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $name);
        if (!is_file($source)) {
            continue;
        }
        $target = rtrim($backup, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $name);
        $dir = dirname($target);
        if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
            throw new RuntimeException('备份目录不可写：' . $dir);
        }
        copy($source, $target);
    }
    $zip->close();
}

function list_sql_migrations(string $zipFile): array
{
    $zip = new ZipArchive();
    if ($zip->open($zipFile) !== true) {
        throw new RuntimeException('无法打开更新包');
    }
    $files = [];
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $name = str_replace('\\', '/', (string)$zip->getNameIndex($i));
        if (is_update_sql_path($name)) {
            $files[] = $name;
        }
    }
    $zip->close();
    sort($files, SORT_NATURAL | SORT_FLAG_CASE);
    return $files;
}

function open_update_database(): PDO
{
    if (DB_DSN === '' || DB_USERNAME === '') {
        throw new RuntimeException('发现升级 SQL，但未配置数据库连接信息');
    }
    return new PDO(DB_DSN, DB_USERNAME, DB_PASSWORD, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
}

function ensure_migration_table(PDO $pdo): void
{
    $table = preg_replace('/[^A-Za-z0-9_]/', '', MIGRATION_TABLE);
    $pdo->exec("CREATE TABLE IF NOT EXISTS `{$table}` (
        `id` int unsigned NOT NULL AUTO_INCREMENT,
        `migration` varchar(255) NOT NULL,
        `checksum` char(64) NOT NULL,
        `applied_at` int unsigned NOT NULL DEFAULT 0,
        PRIMARY KEY (`id`),
        UNIQUE KEY `uk_migration` (`migration`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}

function backup_database(string $backupDir): string
{
    if (trim(DB_BACKUP_COMMAND) === '') {
        throw new RuntimeException('发现升级 SQL，但未配置 DB_BACKUP_COMMAND，已停止以避免无备份升级数据库');
    }
    if (!is_dir($backupDir) && !mkdir($backupDir, 0755, true) && !is_dir($backupDir)) {
        throw new RuntimeException('数据库备份目录不可写：' . $backupDir);
    }
    $backupFile = rtrim($backupDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'database_' . date('Ymd_His') . '.sql';
    $command = str_replace('{file}', escapeshellarg($backupFile), DB_BACKUP_COMMAND);
    exec($command, $output, $code);
    if ($code !== 0 || !is_file($backupFile) || filesize($backupFile) === 0) {
        throw new RuntimeException('数据库备份失败，请检查 DB_BACKUP_COMMAND');
    }
    return $backupFile;
}

function apply_sql_migrations(string $zipFile, string $backupDir): array
{
    $files = list_sql_migrations($zipFile);
    if (!$files || !AUTO_MIGRATE_SQL) {
        return [];
    }

    $backupFile = backup_database($backupDir);
    $pdo = open_update_database();
    ensure_migration_table($pdo);
    $table = preg_replace('/[^A-Za-z0-9_]/', '', MIGRATION_TABLE);

    $zip = new ZipArchive();
    $zip->open($zipFile);
    $applied = [];
    foreach ($files as $name) {
        $sql = $zip->getFromName($name);
        if ($sql === false || trim($sql) === '') {
            continue;
        }
        $checksum = hash('sha256', $sql);
        $stmt = $pdo->prepare("SELECT checksum FROM `{$table}` WHERE migration = ?");
        $stmt->execute([$name]);
        $oldChecksum = $stmt->fetchColumn();
        if ($oldChecksum === $checksum) {
            continue;
        }
        if ($oldChecksum !== false && $oldChecksum !== $checksum) {
            throw new RuntimeException('升级 SQL 已执行但内容发生变化：' . $name);
        }

        foreach (split_sql_statements($sql) as $statement) {
            $pdo->exec($statement);
        }
        $insert = $pdo->prepare("INSERT INTO `{$table}` (`migration`, `checksum`, `applied_at`) VALUES (?, ?, ?)");
        $insert->execute([$name, $checksum, time()]);
        $applied[] = $name;
    }
    $zip->close();

    return ['backup' => $backupFile, 'applied' => $applied];
}

function split_sql_statements(string $sql): array
{
    $statements = [];
    $buffer = '';
    $quote = null;
    $length = strlen($sql);
    for ($i = 0; $i < $length; $i++) {
        $char = $sql[$i];
        $next = $sql[$i + 1] ?? '';
        if ($quote === null && $char === '-' && $next === '-') {
            while ($i < $length && $sql[$i] !== "\n") {
                $i++;
            }
            continue;
        }
        if ($quote === null && $char === '/' && $next === '*') {
            $i += 2;
            while ($i < $length - 1 && !($sql[$i] === '*' && $sql[$i + 1] === '/')) {
                $i++;
            }
            $i++;
            continue;
        }
        if (($char === "'" || $char === '"') && ($i === 0 || $sql[$i - 1] !== '\\')) {
            $quote = $quote === $char ? null : ($quote === null ? $char : $quote);
        }
        if ($char === ';' && $quote === null) {
            $statement = trim($buffer);
            if ($statement !== '') {
                $statements[] = $statement;
            }
            $buffer = '';
            continue;
        }
        $buffer .= $char;
    }
    $tail = trim($buffer);
    if ($tail !== '') {
        $statements[] = $tail;
    }
    return $statements;
}

$token = read_auth_token();
$common = [
    'domain' => license_domain(),
    'authtoken' => $token,
    'authip' => AUTH_IP,
    'current_version' => current_version(),
];

$check = post_request(LICENSE_API_BASE . '/api/package/checkUpdate', $common);
if (($check['code'] ?? 0) !== 200 || empty($check['data']['has_update'])) {
    exit("当前已经是最新版本\n");
}

$data = $check['data'];
$file = __DIR__ . '/runtime/update_' . preg_replace('/[^A-Za-z0-9._-]/', '_', $data['version']) . '.zip';
if (!is_dir(dirname($file))) {
    mkdir(dirname($file), 0755, true);
}

$downloadData = $common;
$downloadData['version'] = $data['version'];
$packageSha256 = download_update(LICENSE_API_BASE . '/api/package/downloadUpdate', $downloadData, $file);
$expectedSha256 = $packageSha256 !== '' ? $packageSha256 : strtolower($data['sha256']);
if (!hash_equals($expectedSha256, hash_file('sha256', $file))) {
    @unlink($file);
    throw new RuntimeException('更新包 SHA256 校验失败');
}

echo "更新包已下载：{$file}\n";
if (AUTO_APPLY) {
    $backup = __DIR__ . '/runtime/backup_' . date('Ymd_His');
    mkdir($backup, 0755, true);
    backup_targets_from_zip($file, __DIR__, $backup);
    $migrationResult = apply_sql_migrations($file, $backup);
    safe_extract_zip($file, __DIR__);
    write_current_version((string)$data['version']);
    echo "更新已覆盖，备份目录：{$backup}\n";
    if ($migrationResult) {
        echo "数据库备份：{$migrationResult['backup']}\n";
        echo "已执行 SQL：" . implode(', ', $migrationResult['applied']) . "\n";
    }
}
