<?php
/**
 * ThinkPHP8 自动更新示例
 *
 * 建议放到 app/common/service/Updater.php，再由命令行或后台按钮调用。
 *
 * 更新包约定：
 * - 普通文件：按 ZIP 内路径覆盖到项目根目录。
 * - 数据库升级 SQL：放到 upgrade/sql/*.sql，按文件名升序执行。
 * - SQL 文件应尽量写成可重复执行，例如先判断字段/索引是否存在。
 */

namespace app\common\service;

use think\facade\Db;

class Updater
{
    private const API_BASE = 'https://auth.php11.cc';
    private const CURRENT_VERSION = '1.0.0';
    private const AUTO_APPLY = false;
    private const AUTO_MIGRATE_SQL = true;
    private const VERSION_FILE = 'current_version.txt';
    private const MIGRATION_TABLE = 'license_update_migrations';
    private const DB_BACKUP_COMMAND = 'mysqldump -h127.0.0.1 -P3306 -uyour_user -pyour_password your_db > {file}';
    private const PROTECTED_PATHS = [
        '.env',
        'runtime/',
        'public/storage/',
        'uploads/',
        'license/',
        'authtoken.php',
        'config/license.php',
    ];

    public function checkAndDownload(string $authToken, string $domain, string $authIp = ''): array
    {
        if (!preg_match('/^[A-Za-z0-9]{16,64}$/', $authToken)) {
            throw new \InvalidArgumentException('授权 token 格式不正确');
        }

        $payload = [
            'domain' => strtolower(trim($domain)),
            'authtoken' => $authToken,
            'authip' => trim($authIp),
            'current_version' => $this->currentVersion(),
        ];

        $check = $this->postJson(self::API_BASE . '/api/package/checkUpdate', $payload);
        if (($check['code'] ?? 0) !== 200 || empty($check['data']['has_update'])) {
            return ['has_update' => false, 'message' => '当前已经是最新版本'];
        }

        $version = (string)$check['data']['version'];
        $payload['version'] = $version;
        $file = runtime_path() . 'updates' . DIRECTORY_SEPARATOR . 'update_' . preg_replace('/[^A-Za-z0-9._-]/', '_', $version) . '.zip';
        if (!is_dir(dirname($file)) && !mkdir(dirname($file), 0755, true) && !is_dir(dirname($file))) {
            throw new \RuntimeException('更新目录不可写');
        }

        $packageSha256 = $this->download(self::API_BASE . '/api/package/downloadUpdate', $payload, $file);
        $expectedSha256 = $packageSha256 !== '' ? $packageSha256 : strtolower((string)$check['data']['sha256']);
        if (!hash_equals($expectedSha256, hash_file('sha256', $file))) {
            @unlink($file);
            throw new \RuntimeException('更新包 SHA256 校验失败');
        }

        $applyResult = null;
        if (self::AUTO_APPLY) {
            $applyResult = $this->apply($file, root_path(), $version);
        }

        return [
            'has_update' => true,
            'version' => $version,
            'file' => $file,
            'sha256' => hash_file('sha256', $file),
            'auto_applied' => self::AUTO_APPLY,
            'apply_result' => $applyResult,
        ];
    }

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

    private function writeCurrentVersion(string $version): void
    {
        if (!preg_match('/^[A-Za-z0-9._-]{1,50}$/', $version)) {
            return;
        }
        file_put_contents(runtime_path() . self::VERSION_FILE, $version, LOCK_EX);
    }

    private function postJson(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;
    }

    private function download(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_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'] ?? '');
    }

    private function apply(string $zipFile, string $root, string $version): array
    {
        $backup = runtime_path() . 'backup_' . date('Ymd_His');
        if (!is_dir($backup) && !mkdir($backup, 0755, true) && !is_dir($backup)) {
            throw new \RuntimeException('备份目录不可写');
        }

        $this->backupTargets($zipFile, $root, $backup);
        $migrationResult = $this->applySqlMigrations($zipFile, $backup);
        $this->extractFiles($zipFile, $root);
        $this->writeCurrentVersion($version);

        return [
            'backup' => $backup,
            'migrations' => $migrationResult,
        ];
    }

    private function extractFiles(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 (!$this->isSafePath($name) || $this->isProtected($name) || $this->isSqlMigration($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();
    }

    private function backupTargets(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 (!$this->isSafePath($name) || $this->isProtected($name) || $this->isSqlMigration($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();
    }

    private function applySqlMigrations(string $zipFile, string $backup): array
    {
        $files = $this->listSqlMigrations($zipFile);
        if (!$files || !self::AUTO_MIGRATE_SQL) {
            return ['backup' => '', 'applied' => []];
        }

        $databaseBackup = $this->backupDatabase($backup);
        $this->ensureMigrationTable();
        $table = preg_replace('/[^A-Za-z0-9_]/', '', self::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);
            $oldChecksum = Db::table($table)->where('migration', $name)->value('checksum');
            if ($oldChecksum === $checksum) {
                continue;
            }
            if ($oldChecksum !== null && $oldChecksum !== $checksum) {
                throw new \RuntimeException('升级 SQL 已执行但内容发生变化：' . $name);
            }

            foreach ($this->splitSqlStatements($sql) as $statement) {
                Db::execute($statement);
            }
            Db::table($table)->insert([
                'migration' => $name,
                'checksum' => $checksum,
                'applied_at' => time(),
            ]);
            $applied[] = $name;
        }
        $zip->close();

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

    private function splitSqlStatements(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;
    }

    private function listSqlMigrations(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 ($this->isSqlMigration($name)) {
                $files[] = $name;
            }
        }
        $zip->close();
        sort($files, SORT_NATURAL | SORT_FLAG_CASE);
        return $files;
    }

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

    private function ensureMigrationTable(): void
    {
        $table = preg_replace('/[^A-Za-z0-9_]/', '', self::MIGRATION_TABLE);
        Db::execute("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");
    }

    private function isSqlMigration(string $name): bool
    {
        return str_starts_with($name, 'upgrade/sql/')
            && str_ends_with(strtolower($name), '.sql')
            && $this->isSafePath($name);
    }

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

    private function isProtected(string $name): bool
    {
        foreach (self::PROTECTED_PATHS as $path) {
            if ($name === rtrim($path, '/') || str_starts_with($name, $path)) {
                return true;
            }
        }
        return false;
    }
}
