<?php

namespace App\Services;

use App\Enums\AppOperation;
use App\Enums\AppStatus;
use App\Enums\Status;
use App\Http\Responses\ApiResponse;
use App\Models\AdminMenu;
use App\Models\AdminPermission;
use App\Models\AdminRoleMenu;
use App\Models\AdminRolePermission;
use App\Models\AppDependency;
use App\Models\AppModel;
use App\Models\AppOperationLog;
use App\Models\ConfigGroup;
use App\Models\ConfigItem;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use ZipArchive;

class AppInstallerService
{
    protected HookManager $hookManager;

    protected AppMarketService $marketService;

    public function __construct(HookManager $hookManager, AppMarketService $marketService)
    {
        $this->hookManager = $hookManager;
        $this->marketService = $marketService;
    }

    protected function resolveAppPath(AppModel $app): string
    {
        $path = $app->path;
        if (! str_starts_with($path, '/') && ! str_starts_with($path, '\\') && ! preg_match('/^[A-Za-z]:/', $path)) {
            return base_path($path);
        }

        return $path;
    }

    public function export(string $appId)
    {
        $app = AppModel::where('app_id', $appId)->first();
        $isInstalled = (bool) $app;

        // 已安装应用：从数据库记录获取路径和版本
        if ($isInstalled) {
            $appPath = $this->resolveAppPath($app);
            $version = $app->version;
        } else {
            // 未安装应用：扫描 app/Apps/ 目录，通过 manifest.id 匹配
            $appPath = null;
            $version = 'unknown';
            $appsBasePath = app_path('Apps');

            if (is_dir($appsBasePath)) {
                $dirs = scandir($appsBasePath);
                foreach ($dirs as $dir) {
                    if ($dir === '.' || $dir === '..') {
                        continue;
                    }
                    $candidatePath = $appsBasePath . DIRECTORY_SEPARATOR . $dir;
                    $manifestPath = $candidatePath . DIRECTORY_SEPARATOR . 'manifest.json';
                    if (! file_exists($manifestPath)) {
                        continue;
                    }
                    $manifest = json_decode(file_get_contents($manifestPath), true);
                    if (! empty($manifest['id']) && $manifest['id'] === $appId) {
                        $appPath = $candidatePath;
                        $version = $manifest['version'] ?? 'unknown';
                        break;
                    }
                }
            }

            if (! $appPath || ! File::isDirectory($appPath)) {
                return ApiResponse::error(50006, '应用不存在');
            }
        }

        if (! File::isDirectory($appPath)) {
            return ApiResponse::error(50001, '应用目录不存在');
        }

        $packagePath = config('apps.package_path');
        if (! File::isDirectory($packagePath)) {
            File::makeDirectory($packagePath, 0755, true);
        }

        $zipFileName = $appId.'-'.$version.'.zip';
        $zipFilePath = $packagePath.'/'.$zipFileName;

        $zip = new ZipArchive;
        if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            return ApiResponse::error(50001, '无法创建导出包');
        }

        $files = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($appPath, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );

        $appPathReal = realpath($appPath);

        // 已添加的目录条目缓存，避免重复 addEmptyDir
        $addedDirs = [];

        // 读取导出排除配置（.exportignore），一行一个路径前缀模式，# 开头为注释
        $excludePatterns = [];
        $exportIgnorePath = $appPathReal . '/.exportignore';
        if (file_exists($exportIgnorePath)) {
            $lines = file($exportIgnorePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
            foreach ($lines as $line) {
                $line = trim($line);
                if ($line === '' || str_starts_with($line, '#')) {
                    continue;
                }
                $excludePatterns[] = $line;
            }
        }

        foreach ($files as $file) {
            if (! $file->isFile()) {
                continue;
            }
            $filePath = $file->getRealPath();
            $relativePath = substr($filePath, strlen($appPathReal) + 1);
            // 标准化路径分隔符：Windows 下 getRealPath 返回反斜杠，ZIP 规范要求正斜杠
            $relativePath = str_replace('\\', '/', $relativePath);

            // 匹配排除配置，跳过不应打包的文件
            foreach ($excludePatterns as $pattern) {
                // 目录模式（以 / 结尾）：匹配路径中任意位置的同名目录
                if (str_ends_with($pattern, '/')) {
                    $dirName = rtrim($pattern, '/');
                    if (str_starts_with($relativePath, $dirName . '/') || str_contains($relativePath, '/' . $dirName . '/')) {
                        continue 2;
                    }
                } else {
                    // 文件模式：匹配精确文件名
                    if ($relativePath === $pattern || str_ends_with($relativePath, '/' . $pattern)) {
                        continue 2;
                    }
                }
            }

            // 添加目录条目，确保 Linux 上 extractTo 能正确创建中间目录
            $dirPath = dirname($relativePath);
            while ($dirPath !== '.' && $dirPath !== '') {
                if (!isset($addedDirs[$dirPath])) {
                    $zip->addEmptyDir($dirPath);
                    $addedDirs[$dirPath] = true;
                }
                $dirPath = dirname($dirPath);
            }

            $zip->addFile($filePath, $relativePath);
        }

        $zip->close();

        // 仅已安装应用记录操作日志
        if ($isInstalled) {
            AppOperationLog::create([
                'app_id' => $appId,
                'operation' => AppOperation::EXPORT->value,
                'version_from' => $version,
                'operator_id' => Auth::guard('admin')->id(),
                'result' => 1,
            ]);
        }

        return $zipFilePath;
    }

    public function uploadAndInstall(UploadedFile $file, ?string $targetAppId = null): array
    {
        $packagePath = config('apps.package_path');
        if (! File::isDirectory($packagePath)) {
            File::makeDirectory($packagePath, 0755, true);
        }

        $zipPath = $packagePath.'/'.$file->getClientOriginalName();
        $file->move($packagePath, $file->getClientOriginalName());

        try {
            $validation = $this->validatePackage($zipPath);
            if ($validation['code'] !== 0) {
                return $validation;
            }

            $manifest = $validation['data']['manifest'];

            if ($targetAppId && $manifest['id'] !== $targetAppId) {
                return ApiResponse::error(50002, "应用包不匹配：当前操作的是「{$targetAppId}」应用，但上传的包是「{$manifest['id']}」");
            }

            $existing = AppModel::where('app_id', $manifest['id'])->first();

            if ($existing) {
                $result = $this->executeUpgrade($existing, $zipPath, $manifest);
            } else {
                $result = $this->installFromZip($zipPath);
            }
        } finally {
            if (File::exists($zipPath)) {
                File::delete($zipPath);
            }
        }

        return $result;
    }

    public function installFromZip(string $zipPath): array
    {
        $validation = $this->validatePackage($zipPath);
        if ($validation['code'] !== 0) {
            return $validation;
        }

        $manifest = $validation['data']['manifest'];

        $existing = AppModel::where('app_id', $manifest['id'])->first();
        if ($existing) {
            return ApiResponse::error(50005, '应用已安装，请使用升级功能');
        }

        $requireCheck = $this->checkRequirements($manifest);
        if ($requireCheck !== null) {
            return $requireCheck;
        }

        $depCheck = $this->checkDependencies($manifest);
        if ($depCheck !== null) {
            return $depCheck;
        }

        $routeConflictCheck = $this->checkHomeRoutesConflict($manifest);
        if ($routeConflictCheck !== null) {
            return $routeConflictCheck;
        }

        return $this->executeInstall($zipPath, $manifest);
    }

    public function installFromMarket(string $appId): array
    {
        $marketService = app(AppMarketService::class);
        $zipPath = $marketService->downloadPackage($appId, 'latest');

        try {
            $result = $this->installFromZip($zipPath);
        } finally {
            if (File::exists($zipPath)) {
                File::delete($zipPath);
            }
        }

        return $result;
    }

    public function installFromLocal(string $appId): array
    {
        $appPath = config('apps.path') . '/' . app_id_to_class_name($appId);

        if (! File::isDirectory($appPath)) {
            return ApiResponse::error(50006, '应用目录不存在：' . app_id_to_class_name($appId));
        }

        $manifestPath = $appPath . '/manifest.json';
        if (! File::exists($manifestPath)) {
            return ApiResponse::error(50002, '应用缺少 manifest.json 文件');
        }

        $manifest = json_decode(file_get_contents($manifestPath), true);
        if (! $manifest || ! is_array($manifest)) {
            return ApiResponse::error(50002, 'manifest.json 格式错误');
        }

        $manifestValidation = $this->validateManifest($manifest);
        if ($manifestValidation !== null) {
            return $manifestValidation;
        }

        $existing = AppModel::where('app_id', $manifest['id'])->first();
        if ($existing) {
            return ApiResponse::error(50005, '应用已安装，请使用升级功能');
        }

        $requireCheck = $this->checkRequirements($manifest);
        if ($requireCheck !== null) {
            return $requireCheck;
        }

        $depCheck = $this->checkDependencies($manifest);
        if ($depCheck !== null) {
            return $depCheck;
        }

        $routeConflictCheck = $this->checkHomeRoutesConflict($manifest);
        if ($routeConflictCheck !== null) {
            return $routeConflictCheck;
        }

        return $this->executeLocalInstall($appPath, $manifest);
    }

    public function uninstall(string $appId): array
    {
        $app = AppModel::where('app_id', $appId)->first();
        if (! $app) {
            return ApiResponse::error(50006, '应用未安装');
        }

        if ($app->status === AppStatus::ENABLED) {
            return ApiResponse::error(50016, '请先禁用应用再进行卸载操作');
        }

        if ($app->is_system) {
            return ApiResponse::error(50014, '系统应用不可卸载');
        }

        $dependents = AppDependency::where('depend_app_id', $appId)
            ->where('app_id', '!=', $appId)
            ->pluck('app_id')
            ->toArray();

        $installedDependents = AppModel::whereIn('app_id', $dependents)
            ->whereIn('status', [AppStatus::INSTALLED, AppStatus::ENABLED, AppStatus::DISABLED])
            ->pluck('name', 'app_id')
            ->toArray();

        if (! empty($installedDependents)) {
            $names = implode('、', $installedDependents);

            return ApiResponse::error(50015, "以下应用依赖此应用，无法卸载：{$names}");
        }

        return $this->executeUninstall($app);
    }

    public function upgrade(string $appId, string $targetVersion = ''): array
    {
        $app = AppModel::where('app_id', $appId)->first();
        if (! $app) {
            return ApiResponse::error(50006, '应用未安装');
        }

        if ($app->status === AppStatus::ENABLED) {
            return ApiResponse::error(50016, '请先禁用应用再进行升级操作');
        }

        $marketService = app(AppMarketService::class);
        $updateInfo = $marketService->checkAppUpdate($appId, $app->version);

        if (! $updateInfo) {
            return ApiResponse::error(50010, '没有可用的更新');
        }

        // 确定目标版本：未指定则取最新；指定则校验合法性
        if (empty($targetVersion)) {
            $targetVersion = $updateInfo['version'];
        } else {
            $availableVersions = $updateInfo['available_versions'] ?? [
                ['version' => $updateInfo['version']],
            ];
            $validVersions = array_column($availableVersions, 'version');
            if (!in_array($targetVersion, $validVersions, true)) {
                return ApiResponse::error(50010, '目标版本不可用：' . $targetVersion);
            }
            if (version_compare($targetVersion, $app->version, '<=')) {
                return ApiResponse::error(50010, '目标版本必须高于当前版本');
            }
        }

        $zipPath = $marketService->downloadPackage($appId, $targetVersion);

        try {
            $validation = $this->validatePackage($zipPath);
            if ($validation['code'] !== 0) {
                return $validation;
            }

            $newManifest = $validation['data']['manifest'];

            return $this->executeUpgrade($app, $zipPath, $newManifest);
        } finally {
            if (File::exists($zipPath)) {
                File::delete($zipPath);
            }
        }
    }

    public function validatePackage(string $zipPath): array
    {
        if (! File::exists($zipPath)) {
            return ApiResponse::error(50001, '应用包文件不存在');
        }

        $zip = new ZipArchive;
        if ($zip->open($zipPath) !== true) {
            return ApiResponse::error(50001, '应用包格式无效，无法解压');
        }

        $manifestContent = $zip->getFromName('manifest.json');
        if ($manifestContent === false) {
            $zip->close();

            return ApiResponse::error(50002, '应用包缺少 manifest.json');
        }

        $manifest = json_decode($manifestContent, true);
        if (! $manifest || ! is_array($manifest)) {
            $zip->close();

            return ApiResponse::error(50002, 'manifest.json 格式错误');
        }

        $manifestValidation = $this->validateManifest($manifest);
        if ($manifestValidation !== null) {
            $zip->close();

            return $manifestValidation;
        }

        $hasProvider = false;
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $name = $zip->getNameIndex($i);
            if (str_ends_with($name, 'ServiceProvider.php')) {
                $hasProvider = true;
                break;
            }
        }

        if (! $hasProvider) {
            $zip->close();

            return ApiResponse::error(50001, '应用包缺少 ServiceProvider.php');
        }

        $zip->close();

        return ApiResponse::success(['manifest' => $manifest], '验证通过');
    }

    public function validateManifest(array $manifest): ?array
    {
        $required = ['id', 'name', 'version', 'providers'];
        foreach ($required as $field) {
            if (empty($manifest[$field])) {
                return ApiResponse::error(50002, "manifest.json 缺少必填字段：{$field}");
            }
        }

        if (! preg_match('/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/', $manifest['id'])) {
            return ApiResponse::error(50002, '应用ID格式无效，需为 小写字母开头 或 开发者标识.应用名 格式');
        }

        if (! preg_match('/^\d+\.\d+\.\d+/', $manifest['version'])) {
            return ApiResponse::error(50002, '版本号格式无效，需遵循语义化版本规范');
        }

        return null;
    }

    protected function resolveAppIcon(string $appId, ?string $manifestIcon, ?string $currentIcon = null): ?string
    {
        if (! empty($manifestIcon)) {
            return $manifestIcon;
        }
        if (! empty($currentIcon)) {
            return $currentIcon;
        }
        try {
            $result = $this->marketService->getAppDetail($appId);
            if ($result['code'] === 0 && ! empty($result['data']['icon'])) {
                return $result['data']['icon'];
            }
        } catch (\Throwable $e) {
        }

        return null;
    }

    protected function checkRequirements(array $manifest): ?array
    {
        $require = $manifest['require'] ?? [];

        if (isset($require['php'])) {
            $phpVersion = PHP_VERSION;
            if (! $this->satisfiesVersion($phpVersion, $require['php'])) {
                return ApiResponse::error(50003, "PHP版本不满足要求：需要{$require['php']}，当前{$phpVersion}");
            }
        }

        if (isset($require['cmspro'])) {
            $cmsproVersion = config('app.version', '5.0.1');
            if (! $this->satisfiesVersion($cmsproVersion, $require['cmspro'])) {
                return ApiResponse::error(50003, "CmsPro版本不满足要求：需要{$require['cmspro']}，当前{$cmsproVersion}");
            }
        }

        return null;
    }

    protected function checkDependencies(array $manifest): ?array
    {
        $deps = $manifest['dependencies']['app_ids'] ?? [];
        if (empty($deps)) {
            return null;
        }

        foreach ($deps as $depAppId) {
            $depApp = AppModel::where('app_id', $depAppId)->first();

            if (! $depApp) {
                return ApiResponse::error(50004, "依赖应用「{$depAppId}」未安装");
            }

            if ($depApp->status !== AppStatus::ENABLED) {
                return ApiResponse::error(50017, "依赖应用「{$depAppId}」未启用，请先启用后再安装");
            }
        }

        return null;
    }

    /**
     * 检测前台路由冲突。
     *
     * 遍历已安装且已启用的应用，读取其 manifest 中的 home_routes 字段，
     * 与待安装应用的 home_routes 比对，发现相同路径时返回冲突详情。
     */
    protected function checkHomeRoutesConflict(array $manifest): ?array
    {
        $newRoutes = $manifest['home_routes'] ?? [];
        if (empty($newRoutes)) {
            return null;
        }

        $conflicts = [];
        $appId = $manifest['id'];

        // 查询所有已安装应用（包含已启用和未启用的，因为已安装未启用也可能被启用）
        $installedApps = AppModel::whereIn('status', [
            AppStatus::INSTALLED,
            AppStatus::DISABLED,
            AppStatus::ENABLED,
        ])->get();

        foreach ($installedApps as $app) {
            $otherManifest = $app->manifest ?? [];
            $otherRoutes = $otherManifest['home_routes'] ?? [];
            if (empty($otherRoutes)) {
                continue;
            }

            foreach ($newRoutes as $path => $title) {
                if (isset($otherRoutes[$path])) {
                    $conflicts[] = [
                        'path' => $path,
                        'current_app' => $otherManifest['name'] ?? $app->app_id,
                        'current_title' => $otherRoutes[$path],
                        'incoming_app' => $manifest['name'] ?? $appId,
                        'incoming_title' => $title,
                    ];
                }
            }
        }

        if (empty($conflicts)) {
            return null;
        }

        // 构建冲突提示信息
        $lines = [];
        foreach ($conflicts as $i => $c) {
            $lines[] = ($i + 1) . ". 路由 {$c['path']}：已被「{$c['current_app']}」占用（{$c['current_title']}），与「{$c['incoming_app']}」冲突（{$c['incoming_title']}）";
        }
        $message = "检测到前台路由冲突：\n" . implode("\n", $lines) . "\n请先禁用或卸载冲突应用后再安装。";

        return ApiResponse::error(50019, $message, ['conflicts' => $conflicts]);
    }

    protected function executeLocalInstall(string $appPath, array $manifest): array
    {
        $appId = $manifest['id'];
        $operatorId = auth('admin')->id();

        try {
            $this->safeRebuildCache();

            $this->runMigrations($appPath);
        } catch (\Throwable $e) {
            AppOperationLog::create([
                'app_id' => $appId,
                'operation' => AppOperation::INSTALL->value,
                'version_to' => $manifest['version'] ?? null,
                'operator_id' => $operatorId,
                'result' => 0,
                'error_message' => $e->getMessage(),
            ]);

            return ApiResponse::error(50008, '安装失败：' . $e->getMessage());
        }

        try {
            DB::beginTransaction();

            if (File::exists($appPath . '/Install.php')) {
                $this->runInstallScript($appPath, $manifest, 'install');
            }

            $this->registerMenus($manifest);

            $this->registerConfigGroups($manifest);

            $this->registerPermissions($manifest);

            // 权限和菜单注册完成后，执行安装后脚本（角色权限/菜单分配等）
            if (File::exists($appPath . '/Install.php')) {
                $this->runInstallScript($appPath, $manifest, 'postInstall');
            }

            AppModel::create([
                'app_id' => $appId,
                'name' => $manifest['name'],
                'description' => $manifest['description'] ?? null,
                'version' => $manifest['version'],
                'author' => $manifest['author'] ?? null,
                'icon' => $this->resolveAppIcon($appId, $manifest['icon'] ?? null),
                'path' => $appPath,
                'status' => AppStatus::INSTALLED,
                'is_system' => false,
                'manifest' => $manifest,
                'install_time' => now(),
            ]);

            if (! empty($manifest['dependencies']['app_ids'])) {
                foreach ($manifest['dependencies']['app_ids'] as $depAppId) {
                    AppDependency::create([
                        'app_id' => $appId,
                        'depend_app_id' => $depAppId,
                        'depend_version' => $manifest['dependencies']['versions'][$depAppId] ?? null,
                    ]);
                }
            }

            AppOperationLog::create([
                'app_id' => $appId,
                'operation' => AppOperation::INSTALL->value,
                'version_to' => $manifest['version'],
                'operator_id' => $operatorId,
                'result' => 1,
                'detail' => [
                    'menus_count' => count($manifest['menus'] ?? []),
                    'permissions_count' => count($manifest['permissions'] ?? []),
                    'config_groups_count' => count($manifest['config_groups'] ?? []),
                ],
            ]);

            DB::commit();
        } catch (\Throwable $e) {
            DB::rollBack();

            $this->rollbackMigrations($appPath);

            AppOperationLog::create([
                'app_id' => $appId,
                'operation' => AppOperation::INSTALL->value,
                'version_to' => $manifest['version'] ?? null,
                'operator_id' => $operatorId,
                'result' => 0,
                'error_message' => $e->getMessage(),
            ]);

            return ApiResponse::error(50008, '安装失败：' . $e->getMessage());
        }

        $this->createAssetSymlink($appId, $appPath);

        $this->hookManager->doAction('app.installed', $appId);

        return ApiResponse::success(null, '应用安装成功');
    }

    protected function executeInstall(string $zipPath, array $manifest): array
    {
        $appId = $manifest['id'];
        $appPath = config('apps.path').'/'.app_id_to_class_name($appId);
        $operatorId = auth('admin')->id();

        try {
            $zip = new ZipArchive;
            $zip->open($zipPath);
            if (!$zip->extractTo($appPath)) {
                $zip->close();
                throw new \RuntimeException('ZIP 解压失败，请检查目录权限');
            }
            $zip->close();

            $this->fixExtractedPaths($appPath);

            $this->safeRebuildCache();

            $this->runMigrations($appPath);
        } catch (\Throwable $e) {
            if (File::isDirectory($appPath)) {
                File::deleteDirectory($appPath);
            }

            AppOperationLog::create([
                'app_id' => $appId,
                'operation' => AppOperation::INSTALL->value,
                'version_to' => $manifest['version'] ?? null,
                'operator_id' => $operatorId,
                'result' => 0,
                'error_message' => $e->getMessage(),
            ]);

            return ApiResponse::error(50008, '安装失败：'.$e->getMessage());
        }

        try {
            DB::beginTransaction();

            if (File::exists($appPath.'/Install.php')) {
                $this->runInstallScript($appPath, $manifest, 'install');
            }

            $this->registerMenus($manifest);

            $this->registerConfigGroups($manifest);

            $this->registerPermissions($manifest);

            // 权限和菜单注册完成后，执行安装后脚本（角色权限/菜单分配等）
            if (File::exists($appPath . '/Install.php')) {
                $this->runInstallScript($appPath, $manifest, 'postInstall');
            }

            AppModel::create([
                'app_id' => $appId,
                'name' => $manifest['name'],
                'description' => $manifest['description'] ?? null,
                'version' => $manifest['version'],
                'author' => $manifest['author'] ?? null,
                'icon' => $this->resolveAppIcon($appId, $manifest['icon'] ?? null),
                'path' => $appPath,
                'status' => AppStatus::INSTALLED,
                'is_system' => false,
                'manifest' => $manifest,
                'install_time' => now(),
            ]);

            if (! empty($manifest['dependencies']['app_ids'])) {
                foreach ($manifest['dependencies']['app_ids'] as $depAppId) {
                    AppDependency::create([
                        'app_id' => $appId,
                        'depend_app_id' => $depAppId,
                        'depend_version' => $manifest['dependencies']['versions'][$depAppId] ?? null,
                    ]);
                }
            }

            AppOperationLog::create([
                'app_id' => $appId,
                'operation' => AppOperation::INSTALL->value,
                'version_to' => $manifest['version'],
                'operator_id' => $operatorId,
                'result' => 1,
                'detail' => [
                    'menus_count' => count($manifest['menus'] ?? []),
                    'permissions_count' => count($manifest['permissions'] ?? []),
                    'config_groups_count' => count($manifest['config_groups'] ?? []),
                ],
            ]);

            DB::commit();
        } catch (\Throwable $e) {
            DB::rollBack();

            $this->rollbackMigrations($appPath);

            if (File::isDirectory($appPath)) {
                File::deleteDirectory($appPath);
            }

            AppOperationLog::create([
                'app_id' => $appId,
                'operation' => AppOperation::INSTALL->value,
                'version_to' => $manifest['version'] ?? null,
                'operator_id' => $operatorId,
                'result' => 0,
                'error_message' => $e->getMessage(),
            ]);

            return ApiResponse::error(50008, '安装失败：'.$e->getMessage());
        }

        $this->createAssetSymlink($appId, $appPath);

        $this->hookManager->doAction('app.installed', $appId);

        return ApiResponse::success(null, '应用安装成功');
    }

    protected function executeUninstall(AppModel $app): array
    {
        $appId = $app->app_id;
        $appPath = $this->resolveAppPath($app);
        $operatorId = auth('admin')->id();

        if ($app->status === AppStatus::ENABLED) {
            $this->hookManager->removeHooksByApp($appId);
        }

        if (File::exists($appPath.'/Install.php')) {
            $this->runInstallScript($appPath, $app->manifest ?? [], 'uninstall');
        }

        $this->rollbackMigrations($appPath);

        try {
            DB::beginTransaction();

            AdminMenu::where('app_id', $appId)->each(function ($menu) {
                AdminRoleMenu::where('menu_id', $menu->id)->delete();
                $menu->delete();
            });

            $configGroupIds = ConfigGroup::where('app_id', $appId)->pluck('id');
            if ($configGroupIds->isNotEmpty()) {
                ConfigItem::whereIn('group_id', $configGroupIds)->delete();
                ConfigGroup::whereIn('id', $configGroupIds)->delete();
            }

            AdminPermission::where('app_id', $appId)->each(function ($perm) {
                AdminRolePermission::where('permission_id', $perm->id)->delete();
                $perm->delete();
            });

            AppDependency::where('app_id', $appId)->delete();

            $app->delete();

            AppOperationLog::create([
                'app_id' => $appId,
                'operation' => AppOperation::UNINSTALL->value,
                'version_from' => $app->version,
                'operator_id' => $operatorId,
                'result' => 1,
            ]);

            DB::commit();
        } catch (\Throwable $e) {
            DB::rollBack();

            $this->runMigrations($appPath);

            AppOperationLog::create([
                'app_id' => $appId,
                'operation' => AppOperation::UNINSTALL->value,
                'version_from' => $app->version,
                'operator_id' => $operatorId,
                'result' => 0,
                'error_message' => $e->getMessage(),
            ]);

            return ApiResponse::error(50009, '卸载失败：'.$e->getMessage());
        }

        $this->removeAssetSymlink($appId);

        $this->hookManager->doAction('app.uninstalled', $appId);

        // 清除缓存：services.php 中可能仍引用已卸载应用的 ServiceProvider，
        // 路由缓存中可能仍包含已卸载应用的路由
        $this->safeRebuildCache();

        return ApiResponse::success(null, '应用卸载成功');
    }

    public function deleteAppFiles(string $appId): array
    {
        $app = AppModel::where('app_id', $appId)->first();
        if ($app) {
            return ApiResponse::error(50018, '已安装的应用无法删除，请先卸载');
        }

        $appsPath = app_path('Apps');
        $manifestPath = null;
        $appDir = null;

        if (is_dir($appsPath)) {
            $dirs = scandir($appsPath);
            foreach ($dirs as $dir) {
                if ($dir === '.' || $dir === '..') {
                    continue;
                }

                $mf = $appsPath . DIRECTORY_SEPARATOR . $dir . DIRECTORY_SEPARATOR . 'manifest.json';
                if (! file_exists($mf)) {
                    continue;
                }

                $manifest = json_decode(file_get_contents($mf), true);
                if (($manifest['id'] ?? '') === $appId) {
                    $manifestPath = $mf;
                    $appDir = $appsPath . DIRECTORY_SEPARATOR . $dir;
                    break;
                }
            }
        }

        if (! $appDir || ! is_dir($appDir)) {
            return ApiResponse::error(50006, '应用不存在');
        }

        $this->removeAssetSymlink($appId);

        File::deleteDirectory($appDir);

        // 清除缓存：应用目录已删除，缓存中仍有该应用的注册信息
        $this->safeRebuildCache();

        return ApiResponse::success(null, '应用删除成功');
    }

    /**
     * 升级前备份菜单和角色关联数据。
     *
     * 备份位置：storage/app/backups/menu_upgrade_<timestamp>_<appId>/
     * 备份内容：admin_menus.json、admin_role_menus.json（仅当前应用）
     *
     * 失败时仅记录 warning，不阻断升级流程。
     */
    protected function backupMenusBeforeUpgrade(string $appId): void
    {
        try {
            $backupDir = storage_path('app/backups/menu_upgrade_' . date('Ymd_His') . '_' . str_replace('.', '_', $appId));
            if (!File::isDirectory($backupDir)) {
                File::makeDirectory($backupDir, 0755, true);
            }

            $menus = AdminMenu::where('app_id', $appId)->get();
            file_put_contents(
                $backupDir . '/admin_menus.json',
                $menus->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
            );

            $menuIds = $menus->pluck('id')->all();
            $roleMenus = DB::table('admin_role_menus')->whereIn('menu_id', $menuIds)->get();
            file_put_contents(
                $backupDir . '/admin_role_menus.json',
                $roleMenus->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
            );

            \Log::info("菜单备份已保存到 {$backupDir}", ['app_id' => $appId, 'menu_count' => $menus->count()]);
        } catch (\Throwable $e) {
            \Log::warning("菜单备份失败：{$e->getMessage()}", ['app_id' => $appId]);
        }
    }

    protected function executeUpgrade(AppModel $app, string $zipPath, array $newManifest): array
    {
        $appId = $app->app_id;
        $oldVersion = $app->version;
        $newVersion = $newManifest['version'];
        $isSameVersion = ($oldVersion === $newVersion);
        $operatorId = auth('admin')->id();
        $appPath = $this->resolveAppPath($app);

        try {
            $zip = new ZipArchive;
            $zip->open($zipPath);
            if (!$zip->extractTo($appPath)) {
                $zip->close();
                throw new \RuntimeException('ZIP 解压失败，请检查目录权限');
            }
            $zip->close();

            $this->fixExtractedPaths($appPath);

            $this->safeRebuildCache();

            // 升级后刷新前端资源：先移除旧的 public/apps/{appId}，再重新发布
            $this->removeAssetSymlink($appId);
            $this->createAssetSymlink($appId, $appPath);

            $this->runMigrations($appPath);
        } catch (\Throwable $e) {
            AppOperationLog::create([
                'app_id' => $appId,
                'operation' => AppOperation::UPGRADE->value,
                'version_from' => $oldVersion,
                'version_to' => $newVersion,
                'operator_id' => $operatorId,
                'result' => 0,
                'error_message' => $e->getMessage(),
            ]);

            return ApiResponse::error(50010, '升级失败：'.$e->getMessage());
        }

        // 升级前备份菜单数据，作为兜底回滚方案
        $this->backupMenusBeforeUpgrade($appId);

        try {
            DB::beginTransaction();

            if (File::exists($appPath.'/Install.php')) {
                $this->runInstallScript($appPath, $newManifest, 'upgrade', $oldVersion, $newVersion);
            }

            $menuChanges = $this->updateMenus($app, $newManifest);
            $this->updatePermissions($app, $newManifest);
            $this->registerConfigGroups($newManifest, $app);

            $app->update([
                'name' => $newManifest['name'],
                'description' => $newManifest['description'] ?? null,
                'version' => $newVersion,
                'author' => $newManifest['author'] ?? null,
                'icon' => $this->resolveAppIcon($appId, $newManifest['icon'] ?? null, $app->icon),
                'manifest' => $newManifest,
            ]);

            // 统计角色关联保留数
            $roleAssociationsPreserved = AdminRoleMenu::whereIn('menu_id', function ($query) use ($appId) {
                $query->select('id')->from('admin_menus')->where('app_id', $appId);
            })->count();

            AppOperationLog::create([
                'app_id' => $appId,
                'operation' => AppOperation::UPGRADE->value,
                'version_from' => $oldVersion,
                'version_to' => $newVersion,
                'operator_id' => $operatorId,
                'result' => 1,
                'detail' => [
                    'menu_changes' => $menuChanges,
                    'role_associations_preserved' => $roleAssociationsPreserved,
                ],
            ]);

            DB::commit();
        } catch (\Throwable $e) {
            DB::rollBack();

            AppOperationLog::create([
                'app_id' => $appId,
                'operation' => AppOperation::UPGRADE->value,
                'version_from' => $oldVersion,
                'version_to' => $newVersion,
                'operator_id' => $operatorId,
                'result' => 0,
                'error_message' => $e->getMessage(),
            ]);

            return ApiResponse::error(50010, '升级失败：'.$e->getMessage());
        }

        $this->hookManager->doAction('app.upgraded', $appId, $oldVersion, $newVersion);

        if ($isSameVersion) {
            return ApiResponse::success(null, "应用已覆盖安装（v{$newVersion}）");
        }

        return ApiResponse::success(null, "应用已从 {$oldVersion} 升级到 {$newVersion}");
    }

    protected function runInstallScript(string $appPath, array $manifest, string $method, ?string $fromVersion = null, ?string $toVersion = null): void
    {
        $appId = $manifest['id'];
        $className = $manifest['install'] ?? 'Install';
        $fullClass = 'App\\Apps\\'.app_id_to_class_name($appId)."\\{$className}";

        if (class_exists($fullClass)) {
            $instance = new $fullClass;
            if ($method === 'upgrade' && method_exists($instance, 'upgrade')) {
                $instance->upgrade($fromVersion ?? '', $toVersion ?? '');
            } elseif (method_exists($instance, $method)) {
                $instance->$method();
            }
        }
    }

    protected function runMigrations(string $appPath): void
    {
        $migrationPath = $appPath.'/Migrations';
        if (! File::isDirectory($migrationPath)) {
            return;
        }

        $relativePath = str_replace(
            [base_path().'/', base_path().'\\'],
            '',
            $migrationPath
        );
        $relativePath = str_replace('\\', '/', $relativePath);

        $migrationFiles = File::glob($migrationPath.'/*.php');
        sort($migrationFiles);

        $pendingMigrations = [];
        foreach ($migrationFiles as $file) {
            $migrationName = pathinfo($file, PATHINFO_FILENAME);
            if (! DB::table('migrations')->where('migration', $migrationName)->exists()) {
                $pendingMigrations[] = $migrationName;
            }
        }

        if (empty($pendingMigrations)) {
            return;
        }

        $this->syncExistingMigrations($migrationFiles);

        Artisan::call('migrate', [
            '--path' => $relativePath,
            '--force' => true,
        ]);

        $output = Artisan::output();
        if (str_contains($output, 'ErrorException') || str_contains($output, 'SQLSTATE')) {
            throw new \RuntimeException('数据库迁移执行失败：'.$output);
        }
    }

    protected function syncExistingMigrations(array $migrationFiles): void
    {
        foreach ($migrationFiles as $file) {
            $migrationName = pathinfo($file, PATHINFO_FILENAME);

            if (DB::table('migrations')->where('migration', $migrationName)->exists()) {
                continue;
            }

            if ($this->isMigrationAlreadyApplied($file)) {
                DB::table('migrations')->insert([
                    'migration' => $migrationName,
                    'batch' => 0,
                ]);
            }
        }
    }

    protected function isMigrationAlreadyApplied(string $file): bool
    {
        $content = file_get_contents($file);

        if (preg_match_all("/Schema::create\(\s*'(\w+)'/", $content, $matches)) {
            foreach ($matches[1] as $table) {
                if (! Schema::hasTable($table)) {
                    return false;
                }
            }

            return true;
        }

        if (preg_match_all("/\\\$table->(?:string|integer|bigInteger|unsignedBigInteger|text|longText|mediumText|timestamp|timestamps|decimal|float|boolean|enum|json|jsonb|date|dateTime|binary|uuid|macAddress|ipAddress|rememberToken|softDeletes)\(\s*'(\w+)'/", $content, $colMatches)) {
            if (preg_match("/Schema::table\(\s*'(\w+)'/", $content, $tableMatch)) {
                $table = $tableMatch[1];
                if (Schema::hasTable($table)) {
                    $allColsExist = true;
                    foreach ($colMatches[1] as $col) {
                        if (! Schema::hasColumn($table, $col)) {
                            $allColsExist = false;
                            break;
                        }
                    }

                    return $allColsExist;
                }
            }
        }

        return false;
    }

    protected function rollbackMigrations(string $appPath): void
    {
        $migrationPath = $appPath.'/Migrations';
        if (! File::isDirectory($migrationPath)) {
            return;
        }

        $files = File::glob($migrationPath.'/*.php');
        if (empty($files)) {
            return;
        }

        foreach (array_reverse($files) as $file) {
            $migrationName = pathinfo($file, PATHINFO_FILENAME);
            $migrationName = preg_replace('/^\d{4}_\d{2}_\d{2}_\d{6}_/', '', $migrationName);

            $relativePath = str_replace(
                [base_path().'/', base_path().'\\'],
                '',
                $migrationPath
            );
            $relativePath = str_replace('\\', '/', $relativePath);

            Artisan::call('migrate:rollback', [
                '--path' => $relativePath,
                '--step' => 1,
                '--force' => true,
            ]);
        }
    }

    /**
     * 将 path 转换为 slug 化的 code。
     *
     * 规则：
     * 1. 去掉 query string（? 之后的部分）
     * 2. 去掉首尾斜杠
     * 3. 小写化
     * 4. 非 [a-z0-9] 字符替换为下划线
     * 5. 去除连续下划线
     * 6. 去除首尾下划线
     *
     * 特殊场景：
     * - null 或空字符串 → 'root'
     * - '/' → 'home'
     */
    protected function slugifyPath(?string $path): string
    {
        if ($path === null || $path === '') {
            return 'root';
        }
        if ($path === '/') {
            return 'home';
        }
        // 去掉 query string
        $path = preg_replace('/\?.*$/', '', $path);
        $slug = strtolower(trim($path, '/'));
        $slug = preg_replace('/[^a-z0-9]+/', '_', $slug);
        $slug = trim($slug, '_');
        if ($slug === '') {
            return 'root';
        }
        // 数据库字段 VARCHAR(100)，截断到 100 字符防止写入失败
        return substr($slug, 0, 100);
    }

    /**
     * 解析菜单项的 code：优先 manifest.code，否则按 path slug 化。
     *
     * 校验规则：
     * - 正则 ^[a-z][a-z0-9_]*$
     * - 长度 ≤ 80
     *
     * path 为空时（顶级分组菜单），按 app_id + terminalType slug 化生成。
     * 非法 code 记录 warning 日志并回退到 path slug 化。
     */
    protected function resolveMenuCode(array $menuData, string $appId, string $terminalType): string
    {
        $manifestCode = $menuData['code'] ?? '';
        $path = $menuData['path'] ?? '';

        if (!empty($manifestCode)) {
            // 校验 manifest.code 合法性
            if (preg_match('/^[a-z][a-z0-9_]{0,79}$/', $manifestCode)) {
                return $manifestCode;
            }
            \Log::warning(
                sprintf(
                    '应用 %s 菜单 code "%s" 不符合规范（仅小写字母数字下划线，长度≤80），回退到 path slug 化',
                    $appId,
                    $manifestCode
                ),
                ['path' => $path]
            );
        }

        $slug = $this->slugifyPath($path);

        // path 为空时（顶级分组菜单），按 app_id + terminalType slug 化生成
        if ($slug === 'root') {
            $fallback = $appId . '_' . $terminalType;
            $fallback = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '_', $fallback));
            $fallback = trim($fallback, '_');
            return $fallback ?: 'root';
        }

        return $slug;
    }

    /**
     * 按 code 优先、path 兜底的策略查找或创建菜单。
     *
     * 匹配优先级：
     * 1. (app_id, code, terminal_type) 命中 → 更新属性，保留 ID 和角色关联
     * 2. (app_id, path, terminal_type) 兜底命中 → 更新属性 + 回写 code，保留 ID 和角色关联
     * 3. 都未命中 → 新建菜单
     *
     * @return array{0: AdminMenu, 1: string} 返回 [菜单对象, 动作] 动作为 created/updated/unchanged
     */
    protected function upsertMenuByCode(
        array $data,
        ?int $parentId,
        string $appId,
        string $terminalType,
        Status $defaultStatus
    ): array {
        $code = $this->resolveMenuCode($data, $appId, $terminalType);
        $path = $data['path'] ?? '';
        $parentId = $parentId ?? 0;

        // 判断 manifest 是否显式声明了 code
        // 无 code 时由 slugifyPath 兜底生成，需加 parent_id 条件防止不同层级碰撞（兼容旧版应用）
        $hasManifestCode = !empty($data['code']);

        // 第一优先级：按 (app_id, code, terminal_type) 查找
        // 兜底 code 时加 parent_id，与旧版 (app_id, path, parent_id, terminal_type) 行为一致
        $query = AdminMenu::where('app_id', $appId)
            ->where('code', $code)
            ->where('terminal_type', $terminalType);
        if (!$hasManifestCode) {
            $query->where('parent_id', $parentId);
        }
        $menu = $query->first();

        // 第二优先级（兜底）：按 (app_id, path, terminal_type) 查找
        // 同样，无显式 code 时加 parent_id 防碰撞
        if (!$menu && $path !== '') {
            $query = AdminMenu::where('app_id', $appId)
                ->where('path', $path)
                ->where('terminal_type', $terminalType);
            if (!$hasManifestCode) {
                $query->where('parent_id', $parentId);
            }
            $menu = $query->first();
        }

        // manifest 声明的属性：升级时会更新这些字段
        $manifestAttributes = [
            'name' => $data['title'],
            'icon' => $data['icon'] ?? '',
            'path' => $path,
            'app_id' => $appId,
            'code' => $code,
        ];

        if (!$menu) {
            // 新建菜单：使用 manifest 中的 parent_id 和 sort
            $menu = AdminMenu::create(array_merge($manifestAttributes, [
                'parent_id' => $parentId,
                'sort' => $data['order'] ?? 0,
                'type' => isset($data['children']) ? 1 : 2,
                'visible' => 1,
                'status' => $defaultStatus,
                'terminal_type' => $terminalType,
            ]));
            return [$menu, 'created'];
        }

        // 更新现有菜单：保留 parent_id、sort、visible、status、type、terminal_type
        // 这些字段可能已被用户手动调整（如移动到其他分类、调整顺序、隐藏、禁用），
        // 升级时不应覆盖。只更新 manifest 声明的属性（name/icon/path/code）。
        $changed = false;
        foreach (['name', 'icon', 'path', 'code'] as $field) {
            if ($menu->{$field} !== $manifestAttributes[$field]) {
                $changed = true;
                break;
            }
        }

        if ($changed) {
            $menu->update($manifestAttributes);
            return [$menu, 'updated'];
        }

        return [$menu, 'unchanged'];
    }

    /**
     * 删除数据库中 (app_id, terminal_type) 下不在 code 集合中的菜单。
     * 同步清理 admin_role_menus 关联和子菜单。
     *
     * @param string $appId 应用 ID
     * @param string $terminalType 终端类型（admin/user/home）
     * @param array<int,string> $validCodes 新 manifest 中所有合法 code
     * @return array<int,array{code:string,path:string}> 已删除的菜单列表
     */
    protected function pruneMenusByCode(string $appId, string $terminalType, array $validCodes): array
    {
        $deleted = [];

        AdminMenu::where('app_id', $appId)
            ->where('terminal_type', $terminalType)
            ->whereNotIn('code', $validCodes)
            ->each(function ($menu) use (&$deleted) {
                // 处理子菜单：区分 manifest 管理的和用户手动添加的
                // code 为 null 的子菜单是用户手动添加的，不应删除，改为提升为顶级菜单
                AdminMenu::where('parent_id', $menu->id)
                    ->whereNull('code')
                    ->update(['parent_id' => 0]);

                // code 不为 null 的子菜单属于 manifest 管理，一并清理
                AdminMenu::where('parent_id', $menu->id)
                    ->whereNotNull('code')
                    ->each(function ($child) {
                        AdminRoleMenu::where('menu_id', $child->id)->delete();
                        $child->delete();
                    });

                // 清理角色关联
                AdminRoleMenu::where('menu_id', $menu->id)->delete();

                $deleted[] = [
                    'code' => $menu->code,
                    'path' => $menu->path,
                ];

                $menu->delete();
            });

        return $deleted;
    }

    /**
     * 递归同步子菜单。
     *
     * 按 code 优先、path 兜底匹配现有子菜单，新增/更新/删除。
     *
     * @return array{added: array, updated: array, validCodes: array<int,string>}
     */
    protected function syncChildren(
        array $children,
        int $parentId,
        string $appId,
        string $terminalType,
        Status $defaultStatus
    ): array {
        $added = [];
        $updated = [];
        $validCodes = [];

        foreach ($children as $childData) {
            [$child, $action] = $this->upsertMenuByCode(
                $childData, $parentId, $appId, $terminalType, $defaultStatus
            );
            $validCodes[] = $child->code;

            if ($action === 'created') {
                $added[] = ['code' => $child->code, 'path' => $child->path];
            } elseif ($action === 'updated') {
                $updated[] = ['code' => $child->code, 'path' => $child->path];
            }

            // 递归处理三级及更深菜单
            if (isset($childData['children'])) {
                $grandchildChanges = $this->syncChildren(
                    $childData['children'], $child->id, $appId, $terminalType, $defaultStatus
                );
                $added = array_merge($added, $grandchildChanges['added']);
                $updated = array_merge($updated, $grandchildChanges['updated']);
                $validCodes = array_merge($validCodes, $grandchildChanges['validCodes']);
            }
        }

        // 注意：不在 syncChildren 内调用 pruneMenusByCode，
        // 避免越界清理。pruneMenusByCode 由顶层 syncMenusByTerminal 统一调用。

        return [
            'added' => $added,
            'updated' => $updated,
            'validCodes' => $validCodes,
        ];
    }

    /**
     * 处理 parent 挂载模式。
     *
     * 支持两种 manifest 写法：
     *
     * 1. 带目录菜单声明（有 title）：
     *    先在系统父菜单下创建/更新一个目录菜单，再把 children 挂到目录菜单下。
     *    {
     *      "title": "版本管理", "icon": "...", "path": "",
     *      "parent": 1, "children": [{...}, {...}]
     *    }
     *
     * 2. 纯挂载声明（无 title）：
     *    children 直接挂到 parent 指定的系统父菜单下。
     *    {
     *      "parent": 15, "children": [{...}, {...}]
     *    }
     *
     * @return array{added: array, updated: array, deleted: array}
     */
    protected function syncParentedMenus(
        string $appId,
        array $menuItems,
        string $terminalType,
        Status $defaultStatus
    ): array {
        $added = [];
        $updated = [];
        $validCodes = [];

        foreach ($menuItems as $menuData) {
            $systemParentId = $this->resolveParentMenu($menuData, $appId);

            if (!$systemParentId) {
                \Log::warning(
                    sprintf('应用 %s 菜单 parent 解析失败，跳过', $appId),
                    ['parent' => $menuData['parent'] ?? null]
                );
                continue;
            }

            // 判断是否有目录菜单声明（有 title 表示需要创建目录菜单作为中间层）
            $hasDirectoryMenu = !empty($menuData['title']);

            if ($hasDirectoryMenu) {
                // 创建/更新目录菜单，其 parent_id 为系统父菜单
                [$directoryMenu, $dirAction] = $this->upsertMenuByCode(
                    $menuData,
                    $systemParentId,
                    $appId,
                    $terminalType,
                    $defaultStatus
                );

                if ($dirAction === 'created') {
                    $added[] = $directoryMenu->name;
                } elseif ($dirAction === 'updated') {
                    $updated[] = $directoryMenu->name;
                }
                $validCodes[] = $directoryMenu->code;

                // children 挂到目录菜单下
                $childrenParentId = $directoryMenu->id;
            } else {
                // 无目录菜单声明，children 直接挂到系统父菜单下
                $childrenParentId = $systemParentId;
            }

            $children = $menuData['children'] ?? [];
            $childChanges = $this->syncChildren(
                $children, $childrenParentId, $appId, $terminalType, $defaultStatus
            );

            $added = array_merge($added, $childChanges['added']);
            $updated = array_merge($updated, $childChanges['updated']);
            $validCodes = array_merge($validCodes, $childChanges['validCodes']);
        }

        // 清理 manifest 中不存在的菜单（仅本应用的菜单，不影响父菜单）
        $deleted = $this->pruneMenusByCode($appId, $terminalType, $validCodes);

        return [
            'added' => $added,
            'updated' => $updated,
            'deleted' => $deleted,
        ];
    }

    /**
     * 同步指定终端的菜单。
     *
     * 处理两种模式：
     * 1. parent 挂载模式：manifest[0] 含 parent 字段 → 调用 syncParentedMenus
     * 2. 独立顶级模式：调用 upsertMenuByCode 处理每个顶级菜单 + syncChildren 处理子菜单
     *
     * 最后调用 pruneMenusByCode 清理 manifest 中不存在的菜单。
     *
     * @return array{added: array, updated: array, deleted: array}
     */
    protected function syncMenusByTerminal(
        string $appId,
        array $menuItems,
        string $terminalType,
        Status $defaultStatus
    ): array {
        // parent 挂载模式
        if (!empty($menuItems) && isset($menuItems[0]['parent'])) {
            return $this->syncParentedMenus($appId, $menuItems, $terminalType, $defaultStatus);
        }

        $added = [];
        $updated = [];
        $validCodes = [];

        foreach ($menuItems as $menuData) {
            [$menu, $action] = $this->upsertMenuByCode(
                $menuData, null, $appId, $terminalType, $defaultStatus
            );
            $validCodes[] = $menu->code;

            if ($action === 'created') {
                $added[] = ['code' => $menu->code, 'path' => $menu->path];
            } elseif ($action === 'updated') {
                $updated[] = ['code' => $menu->code, 'path' => $menu->path];
            }

            // 递归处理 children
            if (isset($menuData['children'])) {
                $childChanges = $this->syncChildren(
                    $menuData['children'], $menu->id, $appId, $terminalType, $defaultStatus
                );
                $added = array_merge($added, $childChanges['added']);
                $updated = array_merge($updated, $childChanges['updated']);
                $validCodes = array_merge($validCodes, $childChanges['validCodes']);
            }
        }

        // 清理 manifest 中不存在的菜单
        $deleted = $this->pruneMenusByCode($appId, $terminalType, $validCodes);

        return [
            'added' => $added,
            'updated' => $updated,
            'deleted' => $deleted,
        ];
    }

    /**
     * 首次安装时注册菜单。
     *
     * 复用 upsertMenuByCode 逻辑（首次安装时数据库无旧菜单，全部走新建路径）。
     * 同步注册 admin / user / home 三种终端的菜单。
     */
    protected function registerMenus(array $manifest): void
    {
        $appId = $manifest['id'];
        $defaultStatus = Status::ENABLED;

        $this->syncMenusByTerminal(
            $appId, $manifest['menus'] ?? [], 'admin', $defaultStatus
        );
        $this->syncMenusByTerminal(
            $appId, $manifest['user_menus'] ?? [], 'user', $defaultStatus
        );
        $this->syncMenusByTerminal(
            $appId, $manifest['home_menus'] ?? [], 'home', $defaultStatus
        );
    }

    protected function resolveParentMenu(array $menuData, string $appId): ?int
    {
        if (! isset($menuData['parent'])) {
            return null;
        }

        $parent = $menuData['parent'];

        if (is_numeric($parent)) {
            $menu = AdminMenu::find((int) $parent);

            return $menu?->id;
        }

        if (str_starts_with($parent, '/')) {
            $menu = AdminMenu::where('path', $parent)->first();
            if ($menu) {
                return $menu->id;
            }
        }

        $menu = AdminMenu::where('name', $parent)->first();

        return $menu?->id;
    }

    protected function registerConfigGroups(array $manifest, ?AppModel $app = null): void
    {
        $appId = $manifest['id'];
        $groups = $manifest['config_groups'] ?? [];
        $appDisabled = $app && $app->status !== AppStatus::ENABLED;
        $defaultStatus = $appDisabled ? Status::DISABLED : Status::ENABLED;

        foreach ($groups as $groupData) {
            $groupCode = "app_".str_replace('.', '_', $appId)."_".($groupData['name'] ?? $groupData['title']);

            $group = ConfigGroup::updateOrCreate(
                ['code' => $groupCode],
                [
                    'name' => $groupData['title'],
                    'app_id' => $appId,
                    'sort' => 0,
                    'status' => $defaultStatus,
                ]
            );

            $items = $groupData['items'] ?? [];
            foreach ($items as $index => $itemData) {
                $itemCode = "app_".str_replace('.', '_', $appId)."_".$itemData['name'];

                $existing = ConfigItem::where('code', $itemCode)->first();

                if ($existing) {
                    $existing->update([
                        'group_id' => $group->id,
                        'name' => $itemData['title'],
                        'type' => $itemData['type'] ?? 'text',
                        'options' => $itemData['options'] ?? null,
                        'tips' => $itemData['tips'] ?? null,
                        'sort' => $index,
                        'status' => $defaultStatus,
                    ]);
                } else {
                    ConfigItem::create([
                        'group_id' => $group->id,
                        'name' => $itemData['title'],
                        'code' => $itemCode,
                        'value' => $itemData['value'] ?? '',
                        'type' => $itemData['type'] ?? 'text',
                        'options' => $itemData['options'] ?? null,
                        'tips' => $itemData['tips'] ?? null,
                        'sort' => $index,
                        'status' => $defaultStatus,
                    ]);
                }
            }
        }
    }

    protected function registerPermissions(array $manifest): void
    {
        $appId = $manifest['id'];
        $permissions = $manifest['permissions'] ?? [];

        foreach ($permissions as $perm) {
            if (is_array($perm)) {
                $code = $perm['code'];
                $name = $perm['name'] ?? $code;
            } else {
                $code = $perm;
                $name = $perm;
            }

            AdminPermission::firstOrCreate(
                ['code' => $code],
                [
                    'name' => $name,
                    'module' => $appId,
                    'app_id' => $appId,
                ]
            );
        }
    }

    /**
     * 升级时同步菜单：基于 code 优先匹配、path 兜底，保留菜单 ID 和角色关联。
     *
     * 处理 admin / user / home 三种终端的菜单同步，返回变更详情供日志记录。
     *
     * @return array{admin: array, user: array, home: array} 各终端的 added/updated/deleted 列表
     */
    protected function updateMenus(AppModel $app, array $newManifest): array
    {
        $appId = $app->app_id;
        $appDisabled = $app->status !== AppStatus::ENABLED;
        $defaultStatus = $appDisabled ? Status::DISABLED : Status::ENABLED;

        return [
            'admin' => $this->syncMenusByTerminal(
                $appId, $newManifest['menus'] ?? [], 'admin', $defaultStatus
            ),
            'user' => $this->syncMenusByTerminal(
                $appId, $newManifest['user_menus'] ?? [], 'user', $defaultStatus
            ),
            'home' => $this->syncMenusByTerminal(
                $appId, $newManifest['home_menus'] ?? [], 'home', $defaultStatus
            ),
        ];
    }

    protected function updatePermissions(AppModel $app, array $newManifest): void
    {
        $appId = $app->app_id;
        $newPermList = $newManifest['permissions'] ?? [];
        $appDisabled = $app->status !== AppStatus::ENABLED;

        $newPermCodes = [];
        foreach ($newPermList as $perm) {
            if (is_array($perm)) {
                $newPermCodes[$perm['code']] = $perm['name'] ?? $perm['code'];
            } else {
                $newPermCodes[$perm] = $perm;
            }
        }

        $existingPerms = AdminPermission::whereRaw('LOWER(app_id) = ?', [strtolower($appId)])
            ->get()
            ->keyBy('code');

        foreach ($newPermCodes as $code => $name) {
            if (! $existingPerms->has($code)) {
                AdminPermission::create([
                    'name' => $name,
                    'code' => $code,
                    'module' => $appId,
                    'app_id' => $appId,
                    'status' => $appDisabled ? Status::DISABLED : Status::ENABLED,
                ]);
            } else {
                $existingPerms[$code]->update([
                    'name' => $name,
                    'app_id' => $appId,
                ]);
            }
        }

        $existingPerms->each(function ($perm) use ($newPermCodes) {
            if (! array_key_exists($perm->code, $newPermCodes)) {
                AdminRolePermission::where('permission_id', $perm->id)->delete();
                $perm->delete();
            }
        });
    }

    protected function fixExtractedPaths(string $appPath): void
    {
        if (! File::isDirectory($appPath)) {
            return;
        }

        $allFiles = File::allFiles($appPath);
        $fixed = false;

        foreach ($allFiles as $file) {
            $fileName = $file->getFilename();

            if (! str_contains($fileName, '\\')) {
                continue;
            }

            $parts = explode('\\', $fileName);
            $correctFileName = array_pop($parts);
            $subDirs = implode('/', $parts);

            if (! empty($subDirs)) {
                $targetDir = $file->getPath().'/'.$subDirs;
                File::makeDirectory($targetDir, 0755, true, true);

                $targetPath = $targetDir.'/'.$correctFileName;
            } else {
                $targetPath = $file->getPath().'/'.$correctFileName;
            }

            File::move($file->getPathname(), $targetPath);
            $fixed = true;
        }

        if ($fixed) {
            $this->safeRebuildCache();
        }
    }

    protected function createAssetSymlink(string $appId, string $appPath): void
    {
        $assetsPath = $appPath.'/Assets';
        if (! File::isDirectory($assetsPath)) {
            return;
        }

        $publicPath = config('apps.public_path').'/'.$appId;
        if (! File::isDirectory(dirname($publicPath))) {
            File::makeDirectory(dirname($publicPath), 0755, true);
        }

        if (File::exists($publicPath)) {
            return;
        }

        // Windows 环境下 symlink() 可能不可用，降级为目录复制
        if (function_exists('symlink')) {
            \symlink($assetsPath, $publicPath);
        } else {
            File::copyDirectory($assetsPath, $publicPath);
        }
    }

    protected function removeAssetSymlink(string $appId): void
    {
        $publicPath = config('apps.public_path').'/'.$appId;

        if (\is_link($publicPath)) {
            // Windows 上目录型符号链接需要用 rmdir() 删除
            if (\is_dir($publicPath)) {
                @\rmdir($publicPath);
            } else {
                @\unlink($publicPath);
            }
        } elseif (File::isDirectory($publicPath)) {
            File::deleteDirectory($publicPath);
        }
    }

    /**
     * 安全重建 Laravel 缓存文件（packages.php / services.php）。
     *
     * Windows 环境下 Filesystem::replace() 使用原子 rename()，
     * 可能被杀毒软件或文件锁拦截（code:5 拒绝访问），
     * 导致 services.php 无法生成 → Service Provider 未注册 → view 等Facade不可用 → 连锁崩溃。
     *
     * 策略：
     * 1. 先用 file_put_contents 重建 packages.php（非原子写入，绕过 rename）
     * 2. 再用 package:discover 重建 services.php
     * 3. 如果 package:discover 的 rename 失败，从残留的 .tmp 文件恢复内容
     * 4. 如果 .tmp 文件也不存在，从 bootstrap/providers.php + packages.php 手动构建
     */
    protected function safeRebuildCache(): void
    {
        $cacheDir = base_path('bootstrap/cache');

        // 清理可能残留的 .tmp 文件（Windows 上 rename 失败的产物）
        foreach (glob($cacheDir.'/pac*.tmp') ?: [] as $tmp) {
            @\unlink($tmp);
        }
        foreach (glob($cacheDir.'/ser*.tmp') ?: [] as $tmp) {
            @\unlink($tmp);
        }

        // 清除路由缓存：应用路由在 booted 回调中动态注册，
        // 路由缓存（routes-v7.php）不包含这些动态路由，必须清除
        @\unlink($cacheDir.'/routes-v7.php');

        // 重建 packages.php（PackageManifest）
        // 不使用 PackageManifest::build()，因为它内部调用 write() → Filesystem::replace() → rename() → Windows 上失败
        // 直接复制 build() 的逻辑，用 file_put_contents 写入
        try {
            $files = new \Illuminate\Filesystem\Filesystem();
            $packages = [];

            $installedJson = base_path('vendor/composer/installed.json');
            if ($files->exists($installedJson)) {
                $installed = json_decode($files->get($installedJson), true);
                $packages = $installed['packages'] ?? $installed;
            }

            // 读取 composer.json 的 extra.laravel.dont-discover 配置
            $ignore = [];
            if ($files->exists(base_path('composer.json'))) {
                $composerConfig = json_decode($files->get(base_path('composer.json')), true);
                $ignore = $composerConfig['extra']['laravel']['dont-discover'] ?? [];
            }
            $ignoreAll = in_array('*', $ignore);

            $manifest = (new \Illuminate\Support\Collection($packages))
                ->mapWithKeys(function ($package) {
                    return [\Illuminate\Foundation\PackageManifest::format($package['name']) => $package['extra']['laravel'] ?? []];
                })
                ->each(function ($configuration) use (&$ignore) {
                    $ignore = array_merge($ignore, $configuration['dont-discover'] ?? []);
                })
                ->reject(function ($configuration, $package) use ($ignore, $ignoreAll) {
                    return $ignoreAll || in_array($package, $ignore);
                })
                ->filter()
                ->all();

            @\file_put_contents($cacheDir.'/packages.php', '<?php return '.var_export($manifest, true).';', LOCK_EX);
        } catch (\Throwable) {
            // packages.php 构建失败，Laravel 后续会尝试自行处理
        }

        // 重建 services.php（ProviderRepository）
        // 注意：不预先删除 services.php，让 package:discover 覆盖写入
        // 如果 rename 失败，旧文件仍在，不会导致系统崩溃
        try {
            Artisan::call('package:discover');

            // 验证 artisan 是否成功生成了 services.php
            if (\file_exists($cacheDir.'/services.php')) {
                return;
            }

            // package:discover 的 rename 失败，但 .tmp 文件可能包含正确内容
            $tmpFiles = glob($cacheDir.'/ser*.tmp') ?: [];
            if (!empty($tmpFiles)) {
                $tmpContent = @\file_get_contents($tmpFiles[0]);
                if ($tmpContent !== false && str_contains($tmpContent, 'providers')) {
                    @\file_put_contents($cacheDir.'/services.php', $tmpContent, LOCK_EX);
                    // 清理 .tmp 文件
                    foreach ($tmpFiles as $tmp) {
                        @\unlink($tmp);
                    }

                    return;
                }
            }

            // .tmp 文件也无法恢复，手动构建
            throw new \RuntimeException('package:discover failed, no tmp file available');
        } catch (\Throwable) {
            $this->buildServicesManifest($cacheDir);
        }
    }

    /**
     * 手动构建 services.php 缓存文件。
     *
     * 当 Artisan::call('package:discover') 在 Windows 上因 rename() 失败时，
     * 用此方法直接 file_put_contents 写入，绕过原子 rename。
     *
     * Laravel 11 的 providers 定义在 bootstrap/providers.php（非 config/app.php），
     * 包的额外 providers 定义在 bootstrap/cache/packages.php。
     */
    protected function buildServicesManifest(string $cacheDir): void
    {
        try {
            // Laravel 11: 从 bootstrap/providers.php 读取应用 providers
            $appProviders = \file_exists(base_path('bootstrap/providers.php'))
                ? (array) require base_path('bootstrap/providers.php')
                : [];

            // 从 packages.php 读取包 providers（如果存在）
            $packageProviders = [];
            $packages = \file_exists($cacheDir.'/packages.php')
                ? (array) require $cacheDir.'/packages.php'
                : [];
            foreach ($packages as $package) {
                if (isset($package['providers'])) {
                    $packageProviders = array_merge($packageProviders, (array) $package['providers']);
                }
            }

            $allProviders = array_values(array_unique(array_merge(
                $this->getDefaultLaravelProviders(),
                $appProviders,
                $packageProviders
            )));

            // 构建 manifest：所有 provider 作为 eager 加载（安全兜底）
            $manifest = [
                'providers' => $allProviders,
                'eager' => $allProviders,
                'deferred' => [],
                'when' => [],
            ];

            @\file_put_contents(
                $cacheDir.'/services.php',
                '<?php return '.var_export($manifest, true).';',
                LOCK_EX
            );
        } catch (\Throwable) {
            // 最终兜底：写入 Laravel 核心 providers + AppServiceProvider
            $providers = array_merge($this->getDefaultLaravelProviders(), [
                'App\\Providers\\AppServiceProvider',
            ]);
            $manifest = ['providers' => $providers, 'eager' => $providers, 'deferred' => [], 'when' => []];
            @\file_put_contents(
                $cacheDir.'/services.php',
                '<?php return '.var_export($manifest, true).';',
                LOCK_EX
            );
        }
    }

    /**
     * 获取 Laravel 核心 Service Provider 列表。
     */
    protected function getDefaultLaravelProviders(): array
    {
        return [
            'Illuminate\\Auth\\AuthServiceProvider',
            'Illuminate\\Broadcasting\\BroadcastServiceProvider',
            'Illuminate\\Bus\\BusServiceProvider',
            'Illuminate\\Cache\\CacheServiceProvider',
            'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
            'Illuminate\\Concurrency\\ConcurrencyServiceProvider',
            'Illuminate\\Cookie\\CookieServiceProvider',
            'Illuminate\\Database\\DatabaseServiceProvider',
            'Illuminate\\Encryption\\EncryptionServiceProvider',
            'Illuminate\\Filesystem\\FilesystemServiceProvider',
            'Illuminate\\Foundation\\Providers\\FoundationServiceProvider',
            'Illuminate\\Hashing\\HashServiceProvider',
            'Illuminate\\Mail\\MailServiceProvider',
            'Illuminate\\Notifications\\NotificationServiceProvider',
            'Illuminate\\Pagination\\PaginationServiceProvider',
            'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
            'Illuminate\\Pipeline\\PipelineServiceProvider',
            'Illuminate\\Queue\\QueueServiceProvider',
            'Illuminate\\Redis\\RedisServiceProvider',
            'Illuminate\\Session\\SessionServiceProvider',
            'Illuminate\\Translation\\TranslationServiceProvider',
            'Illuminate\\Validation\\ValidationServiceProvider',
            'Illuminate\\View\\ViewServiceProvider',
        ];
    }

    protected function satisfiesVersion(string $current, string $constraint): bool
    {
        $constraint = trim($constraint);
        if (str_starts_with($constraint, '>=')) {
            return version_compare($current, ltrim($constraint, '>= ')) >= 0;
        }
        if (str_starts_with($constraint, '>')) {
            return version_compare($current, ltrim($constraint, '> ')) > 0;
        }
        if (str_starts_with($constraint, '<=')) {
            return version_compare($current, ltrim($constraint, '<= ')) <= 0;
        }
        if (str_starts_with($constraint, '<')) {
            return version_compare($current, ltrim($constraint, '< ')) < 0;
        }

        return version_compare($current, $constraint) >= 0;
    }
}
