package tech.easyflow.admin.controller.skill;

import cn.dev33.satoken.annotation.SaCheckPermission;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
import tech.easyflow.admin.controller.skill.vo.SkillCopyRequest;
import tech.easyflow.admin.controller.skill.vo.SkillDraftRequest;
import tech.easyflow.admin.controller.skill.vo.SkillImportBatchResultView;
import tech.easyflow.admin.controller.skill.vo.SkillView;
import tech.easyflow.admin.controller.skill.vo.SkillPublishStatusView;
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.domain.Result;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.util.SearchKeywordUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.file.SkillFileContent;
import tech.easyflow.skill.file.SkillFileNode;
import tech.easyflow.skill.file.SkillFileRenameRequest;
import tech.easyflow.skill.file.SkillFileSaveRequest;
import tech.easyflow.skill.file.SkillFileService;
import tech.easyflow.skill.imports.SkillExportRequest;
import tech.easyflow.skill.imports.SkillExportArtifact;
import tech.easyflow.skill.imports.SkillExportService;
import tech.easyflow.skill.imports.SkillImportConfirmRequest;
import tech.easyflow.skill.imports.SkillImportPreview;
import tech.easyflow.skill.imports.SkillImportService;
import tech.easyflow.skill.publish.SkillPublishAppService;
import tech.easyflow.skill.security.SkillVisibilityQueryHelper;
import tech.easyflow.skill.service.SkillApprovalStateService;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.validation.SkillValidationResult;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;

import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;

/**
 * Skill 管理端 API，统一负责轻量查询、白名单写入、文件工作台和标准包导入导出。
 */
@RestController
@RequestMapping("/api/v1/skill")
public class SkillController {

    private static final Set<String> PAGE_SORT_COLUMNS = Set.of(
            "id", "name", "display_name", "created", "modified", "publish_status");

    private final SkillService skillService;
    private final SkillApprovalStateService skillApprovalStateService;
    private final SkillPublishAppService skillPublishAppService;
    private final SkillImportService skillImportService;
    private final SkillExportService skillExportService;
    private final SkillFileService skillFileService;
    private final ResourceAccessService resourceAccessService;
    private final CategoryPermissionService categoryPermissionService;
    private final SkillVisibilityQueryHelper visibilityQueryHelper;
    private final AiResourceCreatorNameSupport creatorNameSupport;

    /**
     * 创建 Skill 管理控制器。
     *
     * @param skillService Skill 服务
     * @param skillApprovalStateService 审批状态服务
     * @param skillPublishAppService 发布服务
     * @param skillImportService 导入服务
     * @param skillExportService 导出服务
     * @param skillFileService 文件服务
     * @param resourceAccessService 资源权限服务
     * @param categoryPermissionService 分类权限服务
     * @param visibilityQueryHelper 可见性查询助手
     * @param creatorNameSupport 创建人名称助手
     */
    public SkillController(SkillService skillService,
                           SkillApprovalStateService skillApprovalStateService,
                           SkillPublishAppService skillPublishAppService,
                           SkillImportService skillImportService,
                           SkillExportService skillExportService,
                           SkillFileService skillFileService,
                           ResourceAccessService resourceAccessService,
                           CategoryPermissionService categoryPermissionService,
                           SkillVisibilityQueryHelper visibilityQueryHelper,
                           AiResourceCreatorNameSupport creatorNameSupport) {
        this.skillService = skillService;
        this.skillApprovalStateService = skillApprovalStateService;
        this.skillPublishAppService = skillPublishAppService;
        this.skillImportService = skillImportService;
        this.skillExportService = skillExportService;
        this.skillFileService = skillFileService;
        this.resourceAccessService = resourceAccessService;
        this.categoryPermissionService = categoryPermissionService;
        this.visibilityQueryHelper = visibilityQueryHelper;
        this.creatorNameSupport = creatorNameSupport;
    }

    /**
     * 分页查询当前用户可读的 Skill 描述信息。
     *
     * @param pageNumber 页码
     * @param pageSize 每页数量
     * @param categoryId 分类 ID
     * @param categoryScope 分类范围，UNCATEGORIZED 表示未分类
     * @param name 名称关键词
     * @param displayName 展示名称关键词
     * @param keyword 名称、用途或创建人模糊关键词
     * @param publishStatus 发布状态
     * @param visibilityScope 使用范围
     * @param sortKey 排序字段
     * @param sortType 排序方向
     * @return 轻量分页结果
     */
    @GetMapping("/page")
    @SaCheckPermission("/api/v1/skill/query")
    public Result<Page<SkillView>> page(Long pageNumber, Long pageSize, BigInteger categoryId, String categoryScope,
                                        String name, String displayName, String keyword, String publishStatus,
                                        String visibilityScope, String sortKey, String sortType) {
        long normalizedPage = pageNumber == null || pageNumber < 1 ? 1 : pageNumber;
        long normalizedSize = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
        QueryWrapper query = descriptorQuery();
        visibilityQueryHelper.applyReadableAccess(query);
        if ("UNCATEGORIZED".equalsIgnoreCase(categoryScope)) {
            query.isNull("category_id");
        } else {
            query.eq("category_id", categoryId, categoryId != null);
        }
        query.eq("publish_status", publishStatus, hasText(publishStatus))
                .eq("visibility_scope", visibilityScope, hasText(visibilityScope));
        String effectiveKeyword = hasText(keyword) ? keyword : hasText(displayName) ? displayName : name;
        if (hasText(effectiveKeyword)) {
            String pattern = SearchKeywordUtil.literalContainsPattern(effectiveKeyword);
            query.and("(name LIKE ? ESCAPE '\\\\' OR display_name LIKE ? ESCAPE '\\\\' "
                            + "OR description LIKE ? ESCAPE '\\\\' OR EXISTS (SELECT 1 FROM tb_sys_account a "
                            + "WHERE a.id = tb_skill.created_by AND a.tenant_id = tb_skill.tenant_id "
                            + "AND (a.nickname LIKE ? ESCAPE '\\\\' OR a.login_name LIKE ? ESCAPE '\\\\'))) ",
                    pattern, pattern, pattern, pattern, pattern);
        }
        query.orderBy(resolveSortColumn(sortKey) + ("asc".equalsIgnoreCase(sortType) ? " asc" : " desc"));
        Page<Skill> source = skillService.page(new Page<>(normalizedPage, normalizedSize), query);
        fillListState(source.getRecords());
        LoginAccount account = SaTokenUtil.getLoginAccount();
        boolean superAdmin = account != null && categoryPermissionService.isSuperAdmin(account);
        List<SkillView> records = source.getRecords().stream()
                .map(skill -> toPageView(skill, account, superAdmin)).toList();
        return Result.ok(new Page<>(records, source.getPageNumber(), source.getPageSize(), source.getTotalRow()));
    }

    /**
     * 获取 Skill 完整管理详情。
     *
     * @param id Skill ID
     * @return Skill 详情
     */
    @GetMapping("/detail")
    @SaCheckPermission("/api/v1/skill/getDetail")
    public Result<SkillView> detail(BigInteger id) {
        Skill skill = skillService.getManagementDetail(id);
        fillListState(List.of(skill));
        return Result.ok(toView(skill));
    }

    /**
     * 创建 Skill 草稿。
     *
     * @param request 草稿白名单请求
     * @return 创建后的 Skill
     */
    @PostMapping("/save")
    @SaCheckPermission("/api/v1/skill/save")
    public Result<SkillView> save(@JsonBody(required = true, skipConvertError = false) SkillDraftRequest request) {
        if (request == null || request.id() != null) {
            throw new BusinessException("创建 Skill 时不能指定 ID");
        }
        return Result.ok(toView(skillService.saveDraft(request.toEntity())));
    }

    /**
     * 更新 Skill 草稿。
     *
     * @param request 草稿白名单请求
     * @return 更新后的 Skill
     */
    @PostMapping("/update")
    @SaCheckPermission("/api/v1/skill/update")
    public Result<SkillView> update(@JsonBody(required = true, skipConvertError = false) SkillDraftRequest request) {
        if (request == null || request.id() == null) {
            throw new BusinessException("Skill ID 不能为空");
        }
        return Result.ok(toView(skillService.updateDraft(request.toUpdateEntity())));
    }

    /**
     * 复制已有 Skill 为当前用户拥有的新草稿。
     *
     * @param request 复制请求
     * @return 新建的 Skill 草稿
    */
    @PostMapping("/copy")
    @SaCheckPermission("/api/v1/skill/save")
    public Result<SkillView> copy(@JsonBody(required = true, skipConvertError = false) SkillCopyRequest request) {
        if (request == null) {
            throw new BusinessException("复制参数不能为空");
        }
        return Result.ok(toView(skillService.copyDraft(request.sourceId(), request.name(),
                request.displayName(), request.categoryId())));
    }

    /**
     * 在展示发布确认前执行发布级全量校验。
     *
     * @param id Skill ID
     * @return 标准包结构化校验结果
     */
    @PostMapping("/validatePublish")
    @SaCheckPermission("/api/v1/skill/submitPublishApproval")
    public Result<SkillValidationResult> validatePublish(
            @JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
        return Result.ok(skillService.validateSkill(id, true));
    }

    /**
     * 获取 Skill 文件树。
     *
     * @param skillId Skill ID
     * @return 文件树
     */
    @GetMapping("/file/tree")
    @SaCheckPermission("/api/v1/skill/getDetail")
    public Result<List<SkillFileNode>> fileTree(BigInteger skillId) {
        return Result.ok(skillFileService.tree(skillId));
    }

    /**
     * 获取 Skill 文本文件内容或二进制摘要。
     *
     * @param skillId Skill ID
     * @param path 包内路径
     * @return 文件内容
     */
    @GetMapping("/file/content")
    @SaCheckPermission("/api/v1/skill/getDetail")
    public Result<SkillFileContent> fileContent(BigInteger skillId, String path) {
        return Result.ok(skillFileService.getContent(skillId, path));
    }

    /**
     * 保存已有文本文件。
     *
     * @param request 保存请求
     * @return 最新文件内容
     */
    @PostMapping("/file/save")
    @SaCheckPermission("/api/v1/skill/file")
    public Result<SkillFileContent> saveFile(
            @JsonBody(required = true, skipConvertError = false) SkillFileSaveRequest request) {
        return Result.ok(skillFileService.saveContent(request));
    }

    /**
     * 创建文本文件。
     *
     * @param request 创建请求
     * @return 文件内容
     */
    @PostMapping("/file/create")
    @SaCheckPermission("/api/v1/skill/file")
    public Result<SkillFileContent> createFile(
            @JsonBody(required = true, skipConvertError = false) SkillFileSaveRequest request) {
        return Result.ok(skillFileService.createTextFile(request));
    }

    /**
     * 重命名文件。
     *
     * @param request 重命名请求
     * @return 最新文件内容
     */
    @PostMapping("/file/rename")
    @SaCheckPermission("/api/v1/skill/file")
    public Result<SkillFileContent> renameFile(
            @JsonBody(required = true, skipConvertError = false) SkillFileRenameRequest request) {
        return Result.ok(skillFileService.renameFile(request));
    }

    /**
     * 删除包内文件。
     *
     * @param skillId Skill ID
     * @param path 文件路径
     * @return 空结果
     */
    @PostMapping("/file/delete")
    @SaCheckPermission("/api/v1/skill/file")
    public Result<Void> deleteFile(
            @JsonBody(value = "skillId", required = true, skipConvertError = false) BigInteger skillId,
            @JsonBody(value = "path", required = true, skipConvertError = false) String path,
            @JsonBody(value = "expectedContentHash", required = true, skipConvertError = false)
            String expectedContentHash) {
        skillFileService.deleteFile(skillId, path, expectedContentHash);
        return Result.ok();
    }

    /**
     * 上传任意包内二进制资源。
     *
     * @param skillId Skill ID
     * @param path 文件路径
     * @param expectedContentHash 目标文件预期内容哈希，替换时必填
     * @param file 上传文件
     * @return 文件摘要
     */
    @PostMapping(value = "/file/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @SaCheckPermission("/api/v1/skill/file")
    public Result<SkillFileContent> uploadFile(BigInteger skillId,
                                               String path,
                                               String expectedContentHash,
                                               MultipartFile file) {
        return Result.ok(skillFileService.uploadResource(skillId, path, file, expectedContentHash));
    }

    /**
     * 下载包内文件。
     *
     * @param skillId Skill ID
     * @param path 文件路径
     * @param response HTTP 响应
     * @throws IOException 响应写入失败
     */
    @GetMapping("/file/download")
    @SaCheckPermission("/api/v1/skill/getDetail")
    public void downloadFile(BigInteger skillId, String path, HttpServletResponse response) throws IOException {
        transferFile(skillId, path, response, false);
    }

    /**
     * 安全预览包内文件；主动内容强制下载。
     *
     * @param skillId Skill ID
     * @param path 文件路径
     * @param response HTTP 响应
     * @throws IOException 响应写入失败
     */
    @GetMapping("/file/preview")
    @SaCheckPermission("/api/v1/skill/getDetail")
    public void previewFile(BigInteger skillId, String path, HttpServletResponse response) throws IOException {
        transferFile(skillId, path, response, true);
    }

    /**
     * 批量预览标准 ZIP 导入内容。
     *
     * @param files 导入文件
     * @param file 兼容单文件字段
     * @return 每个文件的 token 化预览
     */
    @PostMapping(value = "/import/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @SaCheckPermission("/api/v1/skill/import")
    public Result<List<SkillImportPreview>> importPreview(
            @RequestPart(value = "files", required = false) List<MultipartFile> files,
            @RequestPart(value = "file", required = false) MultipartFile file) {
        List<MultipartFile> uploads = new java.util.ArrayList<>(files == null ? List.of() : files);
        if (file != null) {
            uploads.add(file);
        }
        if (uploads.isEmpty()) {
            throw new BusinessException("请选择要导入的标准 Skill ZIP");
        }
        if (uploads.size() > 20) {
            throw new BusinessException("单次最多预检 20 个 Skill ZIP");
        }
        return Result.ok(uploads.stream().map(skillImportService::preview).toList());
    }

    /**
     * 使用一次性 token 确认导入。
     *
     * @param request 导入确认请求
     * @return 导入后的 Skill
     */
    @PostMapping("/import/confirm")
    @SaCheckPermission("/api/v1/skill/import")
    public Result<List<SkillView>> importConfirm(
            @JsonBody(required = true, skipConvertError = false) SkillImportConfirmRequest request) {
        return Result.ok(skillImportService.confirm(request).stream().map(this::toView).toList());
    }

    /**
     * 独立确认多个已预检的标准 Skill ZIP；单包业务失败不回滚其他包。
     *
     * @param requests 导入确认请求，按预检 token 一一对应
     * @return 各包独立导入结果
     */
    @PostMapping("/import/confirmBatch")
    @SaCheckPermission("/api/v1/skill/import")
    public Result<List<SkillImportBatchResultView>> importConfirmBatch(
            @JsonBody(required = true, skipConvertError = false) List<SkillImportConfirmRequest> requests) {
        if (requests == null || requests.isEmpty()) {
            throw new BusinessException("请选择要确认导入的 Skill");
        }
        if (requests.size() > 20) {
            throw new BusinessException("单次最多确认导入 20 个 Skill");
        }
        List<SkillImportBatchResultView> results = new java.util.ArrayList<>(requests.size());
        for (SkillImportConfirmRequest request : requests) {
            String token = request == null ? null : request.getImportToken();
            try {
                List<SkillView> skills = skillImportService.confirm(request).stream().map(this::toView).toList();
                results.add(SkillImportBatchResultView.succeeded(token, skills));
            } catch (BusinessException exception) {
                results.add(SkillImportBatchResultView.failed(token, exception.getMessage()));
            }
        }
        return Result.ok(results);
    }

    /**
     * 取消导入并清理临时包。
     *
     * @param importToken 导入 token
     * @return 空结果
     */
    @PostMapping("/import/cancel")
    @SaCheckPermission("/api/v1/skill/import")
    public Result<Void> importCancel(
            @JsonBody(value = "importToken", required = true, skipConvertError = false) String importToken) {
        skillImportService.cancel(importToken);
        return Result.ok();
    }

    /**
     * 导出标准 Skill ZIP。
     *
     * @param request 导出请求
     * @param response HTTP 响应
     */
    @PostMapping("/export")
    @SaCheckPermission("/api/v1/skill/export")
    public void export(@JsonBody(required = true, skipConvertError = false) SkillExportRequest request,
                       HttpServletResponse response) {
        if (request == null || request.getIds().isEmpty()) {
            throw new BusinessException("请选择要导出的 Skill");
        }
        if (request.getIds().size() > 100) {
            throw new BusinessException("单次最多导出 100 个 Skill");
        }
        try (SkillExportArtifact artifact = skillExportService.prepare(request.getIds())) {
            response.setContentType(artifact.getMediaType());
            response.setHeader("Content-Disposition", attachment(artifact.getFileName()));
            artifact.transferTo(output(response));
        }
    }

    /**
     * 导出单个标准 Skill 包。
     *
     * @param id Skill ID
     * @param response HTTP 响应
     */
    @GetMapping("/export")
    @SaCheckPermission("/api/v1/skill/export")
    public void exportOne(BigInteger id, HttpServletResponse response) {
        if (id == null) {
            throw new BusinessException("Skill ID 不能为空");
        }
        writeExport(List.of(id), response);
    }

    /**
     * 提交发布审批。
     *
     * @param id Skill ID
     * @param applicationReason 发布说明
     * @return 审批实例 ID
     */
    @PostMapping("/submitPublishApproval")
    @SaCheckPermission("/api/v1/skill/submitPublishApproval")
    public Result<BigInteger> submitPublishApproval(
            @JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id,
            @JsonBody(value = "applicationReason", required = true, skipConvertError = false)
            String applicationReason) {
        return approvalResult(skillPublishAppService.submitPublishApproval(id, applicationReason),
                "已提交发布审批", "已直接发布");
    }

    /**
     * 提交下线审批。
     *
     * @param id Skill ID
     * @return 审批实例 ID
     */
    @PostMapping("/submitOfflineApproval")
    @SaCheckPermission("/api/v1/skill/submitOfflineApproval")
    public Result<BigInteger> submitOfflineApproval(
            @JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
        return approvalResult(skillPublishAppService.submitOfflineApproval(id), "已提交下线审批", "已直接下线");
    }

    /**
     * 提交删除审批。
     *
     * @param id Skill ID
     * @return 审批实例 ID
     */
    @PostMapping("/submitDeleteApproval")
    @SaCheckPermission("/api/v1/skill/submitDeleteApproval")
    public Result<BigInteger> submitDeleteApproval(
            @JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
        return approvalResult(skillPublishAppService.submitDeleteApproval(id), "已提交删除审批", "已直接删除");
    }

    /**
     * 查询 Skill 发布和审批派生状态。
     *
     * @param id Skill ID
     * @return 发布状态
     */
    @GetMapping("/publish/status")
    @SaCheckPermission("/api/v1/skill/getDetail")
    public Result<SkillPublishStatusView> publishStatus(BigInteger id) {
        QueryWrapper query = descriptorQuery().eq(Skill::getId, id);
        visibilityQueryHelper.applyReadableAccess(query);
        Skill skill = skillService.getOne(query);
        if (skill == null) {
            throw new BusinessException(404, 404, "Skill 不存在");
        }
        fillListState(List.of(skill));
        return Result.ok(new SkillPublishStatusView(skill.getId(), skill.getPublishStatus(),
                skill.getApprovalPending(), skill.getCurrentApprovalActionType(), skill.getDisplayPublishStatus(),
                skill.getCurrentApprovalInstanceId()));
    }

    private QueryWrapper descriptorQuery() {
        return QueryWrapper.create().select("id", "tenant_id", "dept_id", "category_id", "name", "display_name", "description",
                "visibility_scope", "package_hash", "snapshot_hash",
                "publish_status", "current_approval_instance_id", "created", "created_by", "modified", "modified_by");
    }

    private void writeExport(List<BigInteger> ids, HttpServletResponse response) {
        try (SkillExportArtifact artifact = skillExportService.prepare(ids)) {
            response.setContentType(artifact.getMediaType());
            response.setHeader("Content-Disposition", attachment(artifact.getFileName()));
            artifact.transferTo(output(response));
        }
    }

    private void fillListState(List<Skill> skills) {
        skillApprovalStateService.fillSkillApprovalState(skills);
        creatorNameSupport.fillSkillCreatorNames(skills);
    }

    private SkillView toView(Skill skill) {
        boolean readable = resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ);
        boolean manageable = resourceAccessService.canAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE);
        return SkillView.from(skill, readable, manageable);
    }

    private SkillView toPageView(Skill skill, LoginAccount account, boolean superAdmin) {
        boolean sameTenant = account != null && account.getTenantId() != null
                && Objects.equals(account.getTenantId(), skill.getTenantId());
        boolean manageable = sameTenant && (superAdmin || Objects.equals(account.getId(), skill.getCreatedBy()));
        return SkillView.from(skill, sameTenant, manageable);
    }

    private void transferFile(BigInteger skillId, String path, HttpServletResponse response, boolean preview) throws IOException {
        SkillFileContent content = skillFileService.getContent(skillId, path);
        String mediaType = content.getMediaType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : content.getMediaType();
        boolean inline = preview && isSafeInline(mediaType);
        response.setContentType(inline ? mediaType : MediaType.APPLICATION_OCTET_STREAM_VALUE);
        response.setHeader("X-Content-Type-Options", "nosniff");
        response.setHeader("Content-Security-Policy", "sandbox; default-src 'none'");
        response.setHeader("Content-Disposition", (inline ? "inline" : "attachment") + filenameParameter(fileName(path)));
        if (Boolean.TRUE.equals(content.getIsText())) {
            response.getOutputStream().write((content.getContent() == null ? "" : content.getContent())
                    .getBytes(StandardCharsets.UTF_8));
            return;
        }
        try (InputStream inputStream = skillFileService.openResource(skillId, path)) {
            StreamUtils.copy(inputStream, response.getOutputStream());
        }
    }

    private boolean isSafeInline(String mediaType) {
        String normalized = mediaType.toLowerCase(Locale.ROOT).split(";", 2)[0];
        return normalized.equals("application/pdf") || normalized.equals("text/plain")
                || normalized.equals("text/markdown") || normalized.equals("image/png")
                || normalized.equals("image/jpeg") || normalized.equals("image/gif")
                || normalized.equals("image/webp") || normalized.equals("image/avif");
    }

    private String resolveSortColumn(String sortKey) {
        if (!hasText(sortKey)) {
            return "modified";
        }
        String snake = sortKey.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
        return PAGE_SORT_COLUMNS.contains(snake) ? snake : "modified";
    }

    private String attachment(String fileName) {
        return "attachment" + filenameParameter(fileName);
    }

    private String filenameParameter(String fileName) {
        String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20");
        return "; filename*=UTF-8''" + encoded;
    }

    private String fileName(String path) {
        if (!hasText(path)) {
            return "resource.bin";
        }
        int index = path.lastIndexOf('/');
        return index < 0 ? path : path.substring(index + 1);
    }

    private Result<BigInteger> approvalResult(ApprovalActionResult result, String approvalMessage, String directMessage) {
        return Result.ok(result.isApprovalRequired() ? approvalMessage : directMessage, result.getInstanceId());
    }

    private java.io.OutputStream output(HttpServletResponse response) {
        try {
            return response.getOutputStream();
        } catch (IOException exception) {
            throw new BusinessException(500, 500, "创建 Skill 导出响应失败", exception);
        }
    }

    private boolean hasText(String value) {
        return value != null && !value.isBlank();
    }

}
