package tech.easyflow.skill.imports;

import com.easyagents.skill.codec.SkillPackageReadOptions;
import com.easyagents.skill.codec.SkillPackageReadResult;
import com.easyagents.skill.codec.SkillPackageWriteOptions;
import com.easyagents.skill.codec.ZipSkillPackageCodec;
import com.easyagents.skill.exception.SkillPackageException;
import com.easyagents.skill.model.SkillDocument;
import com.easyagents.skill.model.SkillPackage;
import com.easyagents.skill.model.SkillPackageLayout;
import com.easyagents.skill.model.SkillPackageLimits;
import com.easyagents.skill.model.SkillResourceKind;
import com.easyagents.skill.store.SkillContentStage;
import com.easyagents.skill.store.SkillContentStore;
import com.easyagents.skill.util.SkillFrontmatter;
import com.easyagents.skill.util.SkillHashes;
import com.easyagents.skill.util.SkillResources;
import com.mybatisflex.core.query.QueryWrapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillImportStage;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.skill.support.SkillModelConverter;
import tech.easyflow.skill.validation.SkillValidationIssue;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.enums.VisibilityScope;
import tech.easyflow.system.service.ResourceAccessService;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * 基于一次性令牌的标准 Skill ZIP 导入服务。
 */
@Service
public class SkillImportServiceImpl implements SkillImportService {

    private static final Logger LOG = LoggerFactory.getLogger(SkillImportServiceImpl.class);
    private static final int MAX_RENAMES = MAX_BATCH_SKILL_COUNT;
    private static final int NAME_CONFLICT_ERROR_CODE = 4092;
    private static final String NAME_UNAVAILABLE_REASON = "NAME_UNAVAILABLE";

    private final SkillService skillService;
    private final DBSkillContentStore contentStore;
    private final FileStorageService fileStorageService;
    private final SkillImportStageStore stageStore;
    private final ResourceAccessService resourceAccessService;

    /**
     * 创建标准 Skill ZIP 导入服务。
     *
     * @param skillService Skill 服务
     * @param contentStore 二进制内容仓库
     * @param fileStorageService 文件存储
     * @param stageStore 导入暂存仓库
     * @param resourceAccessService 资源权限服务
     */
    public SkillImportServiceImpl(SkillService skillService,
                                  DBSkillContentStore contentStore,
                                  @Qualifier("default") FileStorageService fileStorageService,
                                  SkillImportStageStore stageStore,
                                  ResourceAccessService resourceAccessService) {
        this.skillService = skillService;
        this.contentStore = contentStore;
        this.fileStorageService = fileStorageService;
        this.stageStore = stageStore;
        this.resourceAccessService = resourceAccessService;
    }

    /**
     * 供同包测试执行无持久化标准 ZIP 预览。
     *
     * @param inputStream 标准 ZIP 输入流
     * @return 导入预览
     */
    SkillImportPreview previewStandardForTest(InputStream inputStream) {
        SkillPackageReadResult decoded = new ZipSkillPackageCodec(new PreviewContentStore())
                .decode(inputStream, SkillPackageReadOptions.reportOnly());
        return hasExactlyOneSkill(decoded) ? buildPreview(decoded, null) : invalidSkillCountPreview();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public List<SkillImportPreview> previewBatch(List<MultipartFile> files) {
        if (files == null || files.isEmpty()) {
            throw new BusinessException("请选择至少一个 Skill ZIP 文件");
        }
        if (files.size() > MAX_BATCH_SKILL_COUNT) {
            throw batchLimitExceeded();
        }

        List<SkillImportPreview> prepared = new ArrayList<>();
        int totalSkills = 0;
        try {
            for (MultipartFile file : files) {
                PreparedUpload upload = previewUploadedPackage(
                        file, MAX_BATCH_SKILL_COUNT - totalSkills);
                prepared.addAll(upload.previews());
                totalSkills += upload.skillCount();
            }
            return List.copyOf(prepared);
        } catch (RuntimeException exception) {
            cancelPreparedPreviews(prepared);
            throw exception;
        }
    }

    /**
     * 预检一个上传包，并在需要时拆分为独立 Skill 预览。
     *
     * @param file 上传的标准 ZIP
     * @param remainingSkills 当前批次剩余可接收 Skill 数
     * @return 本包产生的独立预览及 Skill 数量
     */
    private PreparedUpload previewUploadedPackage(MultipartFile file, int remainingSkills) {
        validateUpload(file);
        String sourceName = file.getOriginalFilename();
        SkillPackageReadResult reportOnly;
        try (InputStream input = file.getInputStream()) {
            reportOnly = new ZipSkillPackageCodec(new PreviewContentStore())
                    .decode(input, SkillPackageReadOptions.reportOnly());
        } catch (SkillPackageException exception) {
            return new PreparedUpload(List.of(failedPreview(exception, sourceName)), 0);
        } catch (IOException exception) {
            LOG.error("读取标准 Skill ZIP 失败，file={}", sourceName, exception);
            throw new BusinessException(500, 500, "读取 Skill 导入包失败", exception);
        }

        int skillCount = reportOnly.getSkillPackage().getSkills().size();
        if (skillCount > remainingSkills) {
            throw batchLimitExceeded();
        }
        if (skillCount == 1) {
            SkillImportPreview preview = preview(file);
            preview.setSourceName(sourceName);
            return new PreparedUpload(List.of(preview), 1);
        }
        if (reportOnly.getValidationReport().hasErrors()) {
            return new PreparedUpload(List.of(failedPreview(reportOnly, sourceName)), skillCount);
        }
        return splitAndPreview(file, sourceName, skillCount);
    }

    /**
     * 将已通过整包校验的多 Skill ZIP 拆成独立标准包并逐一暂存。
     *
     * @param file 原始上传 ZIP
     * @param sourceName 原始文件名
     * @param skillCount 已解析的 Skill 数量
     * @return 每个 Skill 对应一个 token 的预览结果
     */
    private PreparedUpload splitAndPreview(MultipartFile file, String sourceName, int skillCount) {
        List<SkillImportPreview> prepared = new ArrayList<>(skillCount);
        try (ZipSkillPackageCodec codec = new ZipSkillPackageCodec();
             InputStream input = file.getInputStream()) {
            SkillPackageReadResult decoded = codec.decode(input, SkillPackageReadOptions.defaults());
            for (com.easyagents.skill.model.Skill skill : decoded.getSkillPackage().getSkills()) {
                Path archive = null;
                try {
                    archive = Files.createTempFile("easyflow-skill-import-", ".zip");
                    try (OutputStream output = Files.newOutputStream(archive,
                            StandardOpenOption.TRUNCATE_EXISTING)) {
                        codec.encode(new SkillPackage(SkillPackageLayout.SINGLE_DIRECTORY, List.of(skill)),
                                output, SkillPackageWriteOptions.defaults());
                    }
                    SkillImportPreview preview = preview(sourceName, archive.toFile());
                    if (!hasUsableSingleSkillPreview(preview)) {
                        cancelPreview(preview);
                        throw new BusinessException(500, 500, "拆分 Skill 导入包后预检失败，请重新打包后重试");
                    }
                    preview.setSourceName(sourceName);
                    prepared.add(preview);
                } finally {
                    deleteLocalArchive(archive);
                }
            }
            return new PreparedUpload(List.copyOf(prepared), skillCount);
        } catch (SkillPackageException exception) {
            cancelPreparedPreviews(prepared);
            return new PreparedUpload(List.of(failedPreview(exception, sourceName)), skillCount);
        } catch (IOException exception) {
            cancelPreparedPreviews(prepared);
            LOG.error("拆分多 Skill ZIP 失败，file={}", sourceName, exception);
            throw new BusinessException(500, 500, "拆分 Skill 导入包失败", exception);
        } catch (RuntimeException exception) {
            cancelPreparedPreviews(prepared);
            throw exception;
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public SkillImportPreview preview(MultipartFile file) {
        validateUpload(file);
        LoginAccount account = requireAccount();
        String storedPath = null;
        try {
            storedPath = fileStorageService.save(file, "skill-imports/" + account.getTenantId());
            return previewStoredPath(storedPath, file.getOriginalFilename());
        } catch (SkillPackageException exception) {
            cleanupUnregisteredPath(storedPath);
            return failedPreview(exception, file.getOriginalFilename());
        } catch (BusinessException exception) {
            cleanupUnregisteredPath(storedPath);
            throw exception;
        } catch (IOException exception) {
            cleanupUnregisteredPath(storedPath);
            LOG.error("读取标准 Skill ZIP 失败，path={}", storedPath, exception);
            throw new BusinessException(500, 500, "读取 Skill 导入包失败", exception);
        } catch (RuntimeException exception) {
            cleanupUnregisteredPath(storedPath);
            throw exception;
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public SkillImportPreview preview(String originalName, File file) {
        validateGeneratedFile(originalName, file);
        LoginAccount account = requireAccount();
        String storedPath = null;
        try {
            storedPath = fileStorageService.save(file, "skill-imports/" + account.getTenantId());
            return previewStoredPath(storedPath, originalName);
        } catch (SkillPackageException exception) {
            cleanupUnregisteredPath(storedPath);
            return failedPreview(exception, originalName);
        } catch (BusinessException exception) {
            cleanupUnregisteredPath(storedPath);
            throw exception;
        } catch (IOException exception) {
            cleanupUnregisteredPath(storedPath);
            LOG.error("读取后端生成的标准 Skill ZIP 失败，path={}", storedPath, exception);
            throw new BusinessException(500, 500, "读取 Skill 导入包失败", exception);
        } catch (RuntimeException exception) {
            cleanupUnregisteredPath(storedPath);
            throw exception;
        }
    }

    private SkillImportPreview previewStoredPath(String storedPath, String originalName) throws IOException {
        if (storedPath == null || storedPath.isBlank()) {
            throw new BusinessException(500, 500, "Skill 导入临时包存储失败，请稍后重试");
        }
        SkillPackageReadResult decoded;
        try (InputStream input = fileStorageService.readStream(storedPath)) {
            decoded = new ZipSkillPackageCodec(new PreviewContentStore())
                    .decode(input, SkillPackageReadOptions.reportOnly());
        }
        if (!hasExactlyOneSkill(decoded)) {
            cleanupUnregisteredPath(storedPath);
            return invalidSkillCountPreview(originalName);
        }
        SkillImportStage stage = stageStore.create(storedPath, originalName);
        return buildPreview(decoded, stage);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public List<Skill> confirm(SkillImportConfirmRequest request) {
        validateConfirmRequest(request);
        SkillImportStage stage = stageStore.consume(request.getImportToken());
        scheduleStageCleanup(stage);
        try (InputStream input = fileStorageService.readStream(stage.getFilePath())) {
            SkillPackageReadResult decoded = new ZipSkillPackageCodec(contentStore)
                    .decode(input, SkillPackageReadOptions.defaults());
            return saveSkills(decoded, request);
        } catch (IOException exception) {
            LOG.error("读取 Skill 导入临时包失败，token={}", stage.getImportToken(), exception);
            throw new BusinessException(500, 500, "读取 Skill 导入包失败", exception);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void cancel(String importToken) {
        stageStore.cancel(importToken);
    }

    private List<Skill> saveSkills(SkillPackageReadResult decoded, SkillImportConfirmRequest request) {
        if (!hasExactlyOneSkill(decoded)) {
            throw new BusinessException("每个标准 Skill ZIP 必须且只能包含一个 Skill");
        }
        if (decoded.getValidationReport().getIssues().stream()
                .anyMatch(issue -> "ERROR".equals(issue.getSeverity().name()))) {
            throw new BusinessException("Skill 包存在校验错误，不能导入");
        }
        SkillImportConflictStrategy strategy = SkillImportConflictStrategy.fromOrDefault(request.getConflictStrategy());
        Set<String> lookupNames = new LinkedHashSet<>();
        decoded.getSkillPackage().getSkills().forEach(skill -> lookupNames.add(skill.getName()));
        lookupNames.addAll(request.getRenames().values());
        Map<String, Skill> existingByName = findByNames(lookupNames);
        List<Skill> saved = new ArrayList<>();
        for (com.easyagents.skill.model.Skill imported : decoded.getSkillPackage().getSkills()) {
            com.easyagents.skill.model.Skill effective = applyRename(imported, request, strategy, existingByName);
            Skill entity = SkillModelConverter.fromAgentSkill(effective);
            entity.setCategoryId(request.getCategoryId());
            entity.setVisibilityScope(request.getVisibilityScope());
            Skill existing = existingByName.get(entity.getName());
            Skill result;
            if (existing == null) {
                result = saveNewDraft(entity);
            } else if (!resourceAccessService.canAccess(
                    CategoryResourceType.SKILL, existing, ResourceAction.MANAGE)) {
                throw nameUnavailable(entity.getName());
            } else if (strategy == SkillImportConflictStrategy.OVERWRITE) {
                if (PublishStatus.from(existing.getPublishStatus()) != PublishStatus.DRAFT) {
                    throw new BusinessException("仅允许覆盖草稿状态的 Skill：" + entity.getName());
                }
                entity.setId(existing.getId());
                result = overwriteExistingDraft(entity);
            } else {
                throw nameUnavailable(entity.getName());
            }
            saved.add(result);
            existingByName.put(result.getName(), result);
        }
        return saved;
    }

    private com.easyagents.skill.model.Skill applyRename(com.easyagents.skill.model.Skill imported,
                                                          SkillImportConfirmRequest request,
                                                          SkillImportConflictStrategy strategy,
                                                          Map<String, Skill> existingByName) {
        if (!existingByName.containsKey(imported.getName())
                || strategy != SkillImportConflictStrategy.RENAME) {
            return imported;
        }
        String renamed = request.getRenames().getOrDefault(imported.getPackageRoot(),
                request.getRenames().get(imported.getName()));
        if (renamed == null || !renamed.matches("[a-z0-9]+(?:-[a-z0-9]+)*")) {
            throw new BusinessException("请为名称不可用的 Skill 提供规范连字符名称：" + imported.getName());
        }
        if (existingByName.containsKey(renamed)) {
            throw nameUnavailable(renamed);
        }
        SkillDocument document = SkillFrontmatter.parseDocument(imported.getSkillContent());
        Map<String, Object> values = new LinkedHashMap<>(document.getFrontmatter().getValues());
        values.put("name", renamed);
        com.easyagents.skill.model.Skill renamedSkill = com.easyagents.skill.factory.SkillFactory.createWithResources(
                SkillFrontmatter.serialize(values, document.getMarkdownBody()), imported.getResources());
        renamedSkill.setPackageRoot(renamed);
        return renamedSkill;
    }

    private SkillImportPreview buildPreview(SkillPackageReadResult decoded, SkillImportStage stage) {
        SkillImportPreview preview = new SkillImportPreview();
        if (stage != null) {
            preview.setSourceName(stage.getOriginalName());
            preview.setImportToken(stage.getImportToken());
            preview.setExpiresAt(stage.getExpiresAt());
        }
        preview.setIssues(decoded.getValidationReport().getIssues().stream().map(source -> {
            SkillValidationIssue issue = SkillValidationIssue.of(source.getSeverity().name(), source.getCode(),
                    source.getMessage(), source.getPath());
            issue.setLine(source.getLine());
            issue.setColumn(source.getColumn());
            issue.setSuggestion(source.getSuggestion());
            return issue;
        }).toList());
        Map<String, Skill> existing = findByNames(decoded.getSkillPackage().getSkills().stream()
                .map(com.easyagents.skill.model.Skill::getName).toList());
        preview.setSkills(decoded.getSkillPackage().getSkills().stream()
                .map(skill -> toPreviewItem(skill, existing.get(skill.getName()))).toList());
        return preview;
    }

    private SkillImportPreviewItem toPreviewItem(com.easyagents.skill.model.Skill skill, Skill existing) {
        SkillImportPreviewItem item = new SkillImportPreviewItem();
        item.setPackageId(skill.getPackageRoot());
        item.setPackageRoot(skill.getPackageRoot());
        item.setName(skill.getName());
        item.setDescription(skill.getDescription());
        item.setReferenceCount(countResources(skill, SkillResourceKind.REFERENCE));
        item.setScriptCount(countResources(skill, SkillResourceKind.SCRIPT));
        item.setAssetCount(countResources(skill, SkillResourceKind.ASSET));
        item.setResourceCount(skill.getResources().size());
        item.setPackageHash(calculatePackageHash(skill));
        item.setConflict(existing != null);
        if (existing != null) {
            boolean manageable = resourceAccessService.canAccess(
                    CategoryResourceType.SKILL, existing, ResourceAction.MANAGE);
            boolean draft = manageable && PublishStatus.from(existing.getPublishStatus()) == PublishStatus.DRAFT;
            item.setOverwriteAllowed(draft);
            item.setConflictReason(!manageable ? NAME_UNAVAILABLE_REASON : draft ? null : "NOT_DRAFT");
        }
        List<SkillImportPreviewFile> files = new ArrayList<>();
        files.add(previewFile("SKILL.md", "SKILL", "text/markdown", true,
                (skill.getSkillContent() == null ? "" : skill.getSkillContent())
                        .getBytes(StandardCharsets.UTF_8).length));
        skill.getResources().stream()
                .sorted(java.util.Comparator.comparing(com.easyagents.skill.model.SkillResource::getPath))
                .map(resource -> previewFile(resource.getPath(), resource.getKind().name(),
                        resource.getMediaType(), resource.isText(), resource.getSize()))
                .forEach(files::add);
        item.setFiles(files);
        return item;
    }

    /**
     * 统计指定语义类型的通用资源数量。
     *
     * @param skill 标准 Skill 聚合
     * @param kind 资源类型
     * @return 匹配资源数量
     */
    private static int countResources(com.easyagents.skill.model.Skill skill, SkillResourceKind kind) {
        return (int) skill.getResources().stream()
                .filter(resource -> resource.getKind() == kind)
                .count();
    }

    private SkillImportPreviewFile previewFile(String path, String kind, String mediaType, boolean text, long size) {
        SkillImportPreviewFile file = new SkillImportPreviewFile();
        file.setPath(path);
        file.setKind(kind);
        file.setMediaType(mediaType);
        file.setText(text);
        file.setSize(size);
        return file;
    }

    private String calculatePackageHash(com.easyagents.skill.model.Skill skill) {
        StringBuilder canonical = new StringBuilder("SKILL.md\n")
                .append(SkillHashes.sha256Hex((skill.getSkillContent() == null ? "" : skill.getSkillContent())
                        .getBytes(StandardCharsets.UTF_8))).append('\n');
        skill.getResources().stream().sorted(java.util.Comparator.comparing(
                        com.easyagents.skill.model.SkillResource::getPath))
                .forEach(resource -> canonical.append(resource.getPath()).append('\n')
                        .append(resource.getContentHash()).append('\n'));
        return SkillHashes.sha256Hex(canonical.toString().getBytes(StandardCharsets.UTF_8));
    }

    private Skill saveNewDraft(Skill skill) {
        try {
            return skillService.saveDraft(skill);
        } catch (DuplicateKeyException exception) {
            throw nameUnavailable(skill.getName());
        } catch (BusinessException exception) {
            if (exception.getHttpStatus() == 409 && exception.getErrorCode() == NAME_CONFLICT_ERROR_CODE) {
                throw nameUnavailable(skill.getName());
            }
            throw exception;
        }
    }

    private Skill overwriteExistingDraft(Skill skill) {
        try {
            return skillService.overwriteImportedDraft(skill);
        } catch (BusinessException exception) {
            if (exception.getHttpStatus() == 403 || exception.getHttpStatus() == 404) {
                throw nameUnavailable(skill.getName());
            }
            throw exception;
        }
    }

    private BusinessException nameUnavailable(String name) {
        return new BusinessException(409, NAME_CONFLICT_ERROR_CODE, "Skill 名称不可用：" + name);
    }

    private Map<String, Skill> findByNames(java.util.Collection<String> names) {
        List<String> safeNames = names == null ? List.of() : names.stream()
                .filter(name -> name != null && !name.isBlank()).distinct().limit(MAX_RENAMES * 2L).toList();
        if (safeNames.isEmpty()) {
            return new LinkedHashMap<>();
        }
        Map<String, Skill> result = new LinkedHashMap<>();
        skillService.list(QueryWrapper.create()
                        .eq(Skill::getTenantId, requireAccount().getTenantId())
                        .in(Skill::getName, safeNames))
                .forEach(skill -> result.put(skill.getName(), skill));
        return result;
    }

    private void validateUpload(MultipartFile file) {
        if (file == null || file.isEmpty()) {
            throw new BusinessException("Skill 导入文件不能为空");
        }
        long limit = SkillPackageLimits.defaults().getMaxCompressedPackageBytes();
        if (file.getSize() > limit) {
            throw new BusinessException(413, 4131, "Skill 导入文件超过 " + limit + " 字节限制");
        }
        String name = file.getOriginalFilename();
        if (name == null || !name.toLowerCase(java.util.Locale.ROOT).endsWith(".zip")) {
            throw new BusinessException("Skill 导入仅支持标准 .zip 文件，.efskill 已停止支持");
        }
    }

    private void validateGeneratedFile(String originalName, File file) {
        if (file == null || !file.isFile() || file.length() == 0L) {
            throw new BusinessException("Skill 导入文件不能为空");
        }
        long limit = SkillPackageLimits.defaults().getMaxCompressedPackageBytes();
        if (file.length() > limit) {
            throw new BusinessException(413, 4131, "Skill 导入文件超过 " + limit + " 字节限制");
        }
        if (originalName == null || !originalName.toLowerCase(java.util.Locale.ROOT).endsWith(".zip")) {
            throw new BusinessException("Skill 导入仅支持标准 .zip 文件");
        }
    }

    private void validateConfirmRequest(SkillImportConfirmRequest request) {
        if (request == null || request.getImportToken() == null || request.getImportToken().isBlank()) {
            throw new BusinessException("Skill 导入确认参数不能为空");
        }
        if (request.getVisibilityScope() == null || request.getVisibilityScope().isBlank()) {
            throw new BusinessException("Skill 使用范围不能为空");
        }
        try {
            request.setVisibilityScope(VisibilityScope.from(request.getVisibilityScope()).name());
        } catch (IllegalArgumentException exception) {
            throw new BusinessException("Skill 使用范围无效");
        }
        if (request.getRenames().size() > MAX_RENAMES) {
            throw new BusinessException("Skill 重命名映射数量超过限制");
        }
    }

    private boolean hasExactlyOneSkill(SkillPackageReadResult decoded) {
        return decoded != null && decoded.getSkillPackage() != null
                && decoded.getSkillPackage().getSkills().size() == 1;
    }

    private SkillImportPreview invalidSkillCountPreview() {
        return invalidSkillCountPreview(null);
    }

    /**
     * 构建单 token 路径收到非单 Skill 包时的失败预览。
     *
     * @param sourceName 原始文件名
     * @return 失败预览
     */
    private SkillImportPreview invalidSkillCountPreview(String sourceName) {
        SkillImportPreview preview = new SkillImportPreview();
        preview.setSourceName(sourceName);
        preview.setIssues(List.of(SkillValidationIssue.of(
                "ERROR", "STANDARD_PACKAGE_SKILL_COUNT", "每个标准 Skill ZIP 必须且只能包含一个 Skill", null)));
        return preview;
    }

    /**
     * 将包解析异常转换为可展示的失败预览。
     *
     * @param exception 包解析异常
     * @param sourceName 原始文件名
     * @return 失败预览
     */
    private SkillImportPreview failedPreview(SkillPackageException exception, String sourceName) {
        SkillImportPreview preview = new SkillImportPreview();
        preview.setSourceName(sourceName);
        preview.setSkills(List.of());
        if (exception.getReport() == null) {
            preview.setIssues(List.of(SkillValidationIssue.of(
                    "ERROR", "STANDARD_PACKAGE_INVALID", exception.getMessage(), null)));
            return preview;
        }
        preview.setIssues(exception.getReport().getIssues().stream().map(source -> {
            SkillValidationIssue issue = SkillValidationIssue.of(source.getSeverity().name(), source.getCode(),
                    source.getMessage(), source.getPath());
            issue.setLine(source.getLine());
            issue.setColumn(source.getColumn());
            issue.setSuggestion(source.getSuggestion());
            return issue;
        }).toList());
        return preview;
    }

    /**
     * 将报告模式的整包校验错误转换为失败预览。
     *
     * @param decoded 报告模式解码结果
     * @param sourceName 原始文件名
     * @return 失败预览
     */
    private SkillImportPreview failedPreview(SkillPackageReadResult decoded, String sourceName) {
        SkillImportPreview preview = new SkillImportPreview();
        preview.setSourceName(sourceName);
        preview.setSkills(List.of());
        preview.setIssues(decoded.getValidationReport().getIssues().stream().map(source -> {
            SkillValidationIssue issue = SkillValidationIssue.of(source.getSeverity().name(), source.getCode(),
                    source.getMessage(), source.getPath());
            issue.setLine(source.getLine());
            issue.setColumn(source.getColumn());
            issue.setSuggestion(source.getSuggestion());
            return issue;
        }).toList());
        return preview;
    }

    /**
     * 判断拆分后的预览是否满足单 Skill token 不变量。
     *
     * @param preview 拆分包预览
     * @return token、Skill 与校验结果均可用时返回 true
     */
    private boolean hasUsableSingleSkillPreview(SkillImportPreview preview) {
        return preview != null
                && preview.getImportToken() != null
                && !preview.getImportToken().isBlank()
                && preview.getSkills().size() == 1
                && preview.getIssues().stream().noneMatch(issue -> "ERROR".equals(issue.getSeverity()));
    }

    /**
     * 创建统一的批量 Skill 数量超限异常。
     *
     * @return 业务异常
     */
    private BusinessException batchLimitExceeded() {
        return new BusinessException("单次最多导入 " + MAX_BATCH_SKILL_COUNT + " 个 Skill");
    }

    /**
     * 尽力回滚一组已创建的预览 token。
     *
     * @param previews 已创建的预览
     */
    private void cancelPreparedPreviews(List<SkillImportPreview> previews) {
        previews.forEach(this::cancelPreview);
    }

    /**
     * 尽力回滚一个已创建的预览 token。
     *
     * @param preview 已创建的预览
     */
    private void cancelPreview(SkillImportPreview preview) {
        if (preview == null || preview.getImportToken() == null || preview.getImportToken().isBlank()) {
            return;
        }
        try {
            cancel(preview.getImportToken());
        } catch (RuntimeException cleanupException) {
            LOG.error("回滚 Skill 导入预览失败，token={}", preview.getImportToken(), cleanupException);
        }
    }

    /**
     * 尽力删除拆包过程中创建的本地临时 ZIP。
     *
     * @param archive 本地临时 ZIP 路径
     */
    private void deleteLocalArchive(Path archive) {
        if (archive == null) {
            return;
        }
        try {
            Files.deleteIfExists(archive);
        } catch (IOException cleanupException) {
            LOG.warn("清理本地 Skill 拆分临时包失败，path={}", archive, cleanupException);
        }
    }

    private void cleanupUnregisteredPath(String path) {
        if (path == null || path.isBlank()) {
            return;
        }
        try {
            fileStorageService.delete(path);
        } catch (RuntimeException cleanupException) {
            LOG.error("清理未登记 Skill 导入临时包失败，path={}", path, cleanupException);
        }
    }

    private void scheduleStageCleanup(SkillImportStage stage) {
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            stageStore.complete(stage);
            return;
        }
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCompletion(int status) {
                try {
                    stageStore.complete(stage);
                } catch (RuntimeException exception) {
                    LOG.error("完成 Skill 导入后清理临时包失败，token={}", stage.getImportToken(), exception);
                }
            }
        });
    }

    private LoginAccount requireAccount() {
        LoginAccount account = SaTokenUtil.getLoginAccount();
        if (account == null || account.getId() == null || account.getTenantId() == null) {
            throw new BusinessException(401, 401, "未登录或登录态无效");
        }
        return account;
    }

    /**
     * 预览阶段仅计算二进制摘要，不持久化正文。
     */
    private static final class PreviewContentStore implements SkillContentStore {

        @Override
        public String put(byte[] bytes) {
            return "sha256:" + SkillHashes.sha256Hex(bytes);
        }

        @Override
        public SkillContentStage stage(InputStream inputStream, long maxBytes) {
            try {
                java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
                byte[] buffer = new byte[8192];
                long size = 0;
                int length;
                while ((length = inputStream.read(buffer)) >= 0) {
                    size += length;
                    if (size > maxBytes) {
                        throw new BusinessException(413, 4131, "Skill 二进制资源超过安全限制");
                    }
                    digest.update(buffer, 0, length);
                }
                String hash = java.util.HexFormat.of().formatHex(digest.digest());
                return new SkillContentStage("preview:" + hash, "sha256:" + hash, hash, size);
            } catch (BusinessException exception) {
                throw exception;
            } catch (Exception exception) {
                throw new BusinessException(500, 500, "读取 Skill 二进制资源失败", exception);
            }
        }

        @Override public String commit(SkillContentStage stage) { return stage.getContentRef(); }
        @Override public void rollback(SkillContentStage stage) { }
        @Override public void retain(String contentRef) { }
        @Override public void release(String contentRef) { }
        @Override public InputStream open(String contentRef) { throw new UnsupportedOperationException(); }
        @Override public boolean exists(String contentRef) { return true; }
    }

    /**
     * 单个上传包完成预检后的独立预览及实际 Skill 数量。
     *
     * @param previews 独立预览列表
     * @param skillCount 实际解析出的 Skill 数量
     */
    private record PreparedUpload(List<SkillImportPreview> previews, int skillCount) {
    }
}
