<?php
// +----------------------------------------------------------------------
// | onezan-admin 快速开发的管理平台
// +----------------------------------------------------------------------
// | 官方网址：https://www.onezan.cn
// +----------------------------------------------------------------------
// | onezan团队 版权所有 开源版本可自由商用
// +----------------------------------------------------------------------
// | Author: 543885307@qq.com
// +----------------------------------------------------------------------
namespace app\service\domain\upgrade;

use Core\service\BaseCoreService;
use think\facade\Db;
use ZipArchive;

/**
 * 升级安装器
 * @package app\service\domain\upgrade
 */
class UpgradeInstaller extends BaseCoreService
{
    private UpgradeBackupService $backupService;

    /**
     * Constructor
     * @param \think\App $app
     * @param UpgradeBackupService $backupService
     */
    public function __construct(\think\App $app, UpgradeBackupService $backupService)
    {
        parent::__construct($app);
        $this->backupService = $backupService;
    }

    /**
     * 安装升级包
     * @param string $zipPath ?
     * @param string $type framework|application
     * @param string $version 版本号
     * @param string $appCode type=application
     * @param bool|null $hasFrontendOverride 是否覆盖前端文件
     * @param callable|null $onProgress  function(int $percent, string $message): void
     * @return array{backup_path:string, sql_files:array, files_copied:int}
     */
    public function install(string $zipPath, string $type, string $version, string $appCode = '', ?callable $onProgress = null, ?bool $hasFrontendOverride = null): array
    {
        $report = ['backup_path' => '', 'sql_files' => [], 'files_copied' => 0];

        $extractDir = $this->app->getRuntimePath() . 'upgrade' . DIRECTORY_SEPARATOR . 'extract' . DIRECTORY_SEPARATOR . $version;
        if (is_dir($extractDir)) {
            $this->rmDir($extractDir);
        }
        mkdir($extractDir, 0755, true);

        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) {
            throw new \RuntimeException('');
        }
        $zip->extractTo($extractDir);
        $zip->close();

        // manifest.json 校验
        $manifest = $this->readManifest($extractDir);
        if (empty($manifest['version'])) {
            throw new \RuntimeException('Upgrade package missing valid manifest.json');
        }
        $hasFrontend = $hasFrontendOverride ?? ($manifest['has_frontend'] ?? true);
        $this->validateDirectories($manifest, $hasFrontend);


        $onProgress && $onProgress(20, '...');
        $filesDir = $extractDir . DIRECTORY_SEPARATOR . 'files';
        $fileList = [];
        if (is_dir($filesDir)) {
            $this->collectFiles($filesDir, '', $fileList);
        }
        $report['backup_path'] = $this->backupService->backup($fileList, $version);

        $onProgress && $onProgress(30, '...');
        $siteRoot = dirname($this->app->getRootPath()) . DIRECTORY_SEPARATOR;
        if (is_dir($filesDir)) {
            $copied = 0;
            $this->copyFiles($filesDir, $siteRoot, $copied, $hasFrontend);
            $report['files_copied'] = $copied;
        }

        $onProgress && $onProgress(60, 'Importing SQL...');
        $sqlDir = $extractDir . DIRECTORY_SEPARATOR . 'sql';
        if (is_dir($sqlDir)) {
            $sqlFiles = glob($sqlDir . DIRECTORY_SEPARATOR . '*.sql');
            sort($sqlFiles);
            foreach ($sqlFiles as $sqlFile) {
                $fileName = basename($sqlFile);
                $report['sql_files'][] = $fileName;
                $onProgress && $onProgress(70, 'SQL: ' . $fileName);
                $this->executeSql($sqlFile);
            }
        }

        // Update version (only after SQL succeeds)
        $onProgress && $onProgress(95, '...');
        $this->updateVersion($type, $version, $appCode);

        // opcacheHP
        if (function_exists('opcache_reset')) {
            opcache_reset();
        }

        // 清理临时目录
        $this->rmDir($extractDir);

        $onProgress && $onProgress(100, '');
        return $report;
    }

    /**
     * 递归收集目录下的所有文件
     * @param string $dir
     * @param string $prefix
     * @param array $list
     * @return void
     */
    private function collectFiles(string $dir, string $prefix, array &$list): void
    {
        $handle = opendir($dir);
        if (!$handle) {
            return;
        }
        while (($entry = readdir($handle)) !== false) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }
            $path = $dir . DIRECTORY_SEPARATOR . $entry;
            $relative = $prefix . DIRECTORY_SEPARATOR . $entry;
            if (is_file($path)) {
                $list[] = $relative;
            } elseif (is_dir($path)) {
                $this->collectFiles($path, $relative, $list);
            }
        }
        closedir($handle);
    }

    /**
     * 递归复制目录下的所有文件
     * @param string $src
     * @param string $dst
     * @param int $copied
     * @param bool $hasFrontend
     * @return void
     */
    private function copyFiles(string $src, string $dst, int &$copied, bool $hasFrontend = true): void
    {
        $handle = opendir($src);
        if (!$handle) {
            return;
        }
        while (($entry = readdir($handle)) !== false) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }
            //  public
            if (!$hasFrontend && $entry === 'public') {
                continue;
            }
            $srcPath = $src . DIRECTORY_SEPARATOR . $entry;
            $dstPath = $dst . DIRECTORY_SEPARATOR . $entry;

            if (is_dir($srcPath)) {
                if (!is_dir($dstPath)) {
                    mkdir($dstPath, 0755, true);
                }
                $this->copyFiles($srcPath, $dstPath, $copied, $hasFrontend);
            } else {
                $dstParent = dirname($dstPath);
                if (!is_dir($dstParent)) {
                    mkdir($dstParent, 0755, true);
                }
                copy($srcPath, $dstPath);
                $copied++;
            }
        }
        closedir($handle);
    }

    /**
     * 执行SQL文件
     * @param string $sqlFile
     * @return void
     */
    private function executeSql(string $sqlFile): void
    {
        $content = file_get_contents($sqlFile);
        if ($content === false || trim($content) === '') {
            return;
        }

        // 替换表前缀
        $prefix = Db::getConfig('prefix') ?? 'onezan_';
        $content = str_replace('onezan_', $prefix, $content);

        // 解析SQL语句
        $statements = [];
        $current = '';
        foreach (explode("\n", $content) as $line) {
            $trimmed = trim($line);
            if ($trimmed === '' || str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) {
                continue;
            }
            $current .= $line;
            if (str_ends_with(trim($line), ';')) {
                $statements[] = trim($current);
                $current = '';
            }
        }
        if (trim($current) !== '') {
            $statements[] = trim($current);
        }

        foreach ($statements as $sql) {
            if (trim($sql) === '') {
                continue;
            }
            $trimmed = trim($sql);
            $upper = strtoupper($trimmed);

            // Block destructive statements
            if (preg_match('/^\s*(DROP\s+(TABLE|DATABASE|INDEX|VIEW|PROCEDURE|FUNCTION|SCHEMA)|TRUNCATE\s+(TABLE\s+)?)/i', $trimmed)) {
                throw new \RuntimeException('Destructive SQL blocked: ' . substr($trimmed, 0, 80));
            }

            // Allowlist: only safe DDL and DML
            if (!preg_match('/^(CREATE|ALTER|INSERT|UPDATE|DELETE|SET\s+FOREIGN|RENAME\s+TABLE)/i', $trimmed)) {
                continue;
            }
            Db::execute($sql);
        }
    }

    /**
     * 更新版本号
     * @param string $type  类型 framework 或 application
      * @param string $version
     * @param string $appCode
     * @return void
     */
    private function updateVersion(string $type, string $version, string $appCode): void
    {
        if ($type === 'framework') {
            $manifestFile = $this->app->getRootPath() . 'core/manifest/manifest.json';
        } else {
            $manifestFile = $this->app->getRootPath() . 'applications/' . $appCode . '/manifest.json';
        }

        if (file_exists($manifestFile)) {
            $manifest = json_decode(file_get_contents($manifestFile), true);
            if (is_array($manifest)) {
                $manifest['version'] = $version;
                file_put_contents($manifestFile, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
            }
        }
    }

    /**
     * 读取manifest.json文件
     * @param string $extractDir
     * @return array
     */
    private function readManifest(string $extractDir): array
    {
        $manifestPath = $extractDir . DIRECTORY_SEPARATOR . 'manifest.json';
        if (file_exists($manifestPath)) {
            $content = file_get_contents($manifestPath);
            // Strip UTF-8 BOM if present
            if (str_starts_with($content, "\xEF\xBB\xBF")) {
                $content = substr($content, 3);
            }
            $data = json_decode($content, true);
            if (is_array($data)) {
                return $data;
            }
        }
        // ?manifest.json  version
        $versionDirs = glob($extractDir . DIRECTORY_SEPARATOR . 'files' . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR);
        $hasPublic = false;
        foreach ($versionDirs as $d) {
            if (basename($d) === 'public') {
                $hasPublic = true;
                break;
            }
        }
        return ['has_frontend' => $hasPublic, 'require' => ['dirs' => ['api']]];
    }

    /**
     * 验证目录是否存在文件
     * @param array $manifest
     * @throws \RuntimeException
     */
    private function validateDirectories(array $manifest, bool $hasFrontend = true): void
    {
        $apiRoot = $this->app->getRootPath();
        $siteRoot = dirname($apiRoot) . DIRECTORY_SEPARATOR;
        $requiredDirs = $manifest['require']['dirs'] ?? [];
        $requiredFiles = $manifest['require']['files'] ?? [];

        if (empty($requiredDirs)) {
            $requiredDirs = ['api'];
        }
        if (empty($requiredFiles)) {
            $requiredFiles = ['api' . DIRECTORY_SEPARATOR . 'core' . DIRECTORY_SEPARATOR . 'manifest' . DIRECTORY_SEPARATOR . 'manifest.json'];
        }

        $errors = [];
        foreach ($requiredDirs as $dir) {
            $full = $siteRoot . $dir;
            if (!is_dir($full)) {
                $errors[] = "Directory not found: {$dir}";
            }
        }
        foreach ($requiredFiles as $file) {
            $full = $siteRoot . $file;
            if (!file_exists($full)) {
                $errors[] = "File not found: {$file}";
            }
        }
        if ($hasFrontend && !is_dir($apiRoot . 'public')) {
            $errors[] = "Public directory not found";
        }

        if (!empty($errors)) {
            throw new \RuntimeException('Validation failed: ' . implode('; ', $errors));
        }
    }

    /**
     * 递归删除目录
     * @param string $dir
     * @return void
     */
    private function rmDir(string $dir): void
    {
        if (!is_dir($dir)) {
            return;
        }
        $handle = opendir($dir);
        if (!$handle) {
            return;
        }
        while (($entry = readdir($handle)) !== false) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }
            $path = $dir . DIRECTORY_SEPARATOR . $entry;
            if (is_dir($path)) {
                $this->rmDir($path);
            } else {
                @unlink($path);
            }
        }
        closedir($handle);
        @rmdir($dir);
    }
}