package tech.easyflow.skill.file;

import com.easyagents.skill.exception.SkillException;
import com.easyagents.skill.util.SkillHashes;
import com.easyagents.skill.util.SkillPaths;
import com.easyagents.skill.util.SkillResources;
import com.mybatisflex.core.query.QueryWrapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillResource;
import tech.easyflow.skill.service.SkillResourceService;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.service.ResourceAccessService;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.URLConnection;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/**
 * 基于通用资源表的 Skill 文件工作台服务。
 */
@Service
public class SkillFileServiceImpl implements SkillFileService {

    private static final Logger LOG = LoggerFactory.getLogger(SkillFileServiceImpl.class);
    private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream";
    private static final long MAX_TEXT_RESOURCE_BYTES = 2L * 1024 * 1024;
    private static final long MAX_BINARY_RESOURCE_BYTES = 50L * 1024 * 1024;
    private static final List<String> STANDARD_DIRECTORIES = List.of("references", "scripts", "assets");

    private final SkillService skillService;
    private final SkillResourceService skillResourceService;
    private final DBSkillContentStore contentStore;
    private final ResourceAccessService resourceAccessService;

    /**
     * 创建 Skill 文件服务。
     *
     * @param skillService Skill 服务
     * @param skillResourceService 通用资源服务
     * @param contentStore 二进制内容仓库
     * @param resourceAccessService 资源访问服务
     */
    public SkillFileServiceImpl(SkillService skillService,
                                SkillResourceService skillResourceService,
                                DBSkillContentStore contentStore,
                                ResourceAccessService resourceAccessService) {
        this.skillService = skillService;
        this.skillResourceService = skillResourceService;
        this.contentStore = contentStore;
        this.resourceAccessService = resourceAccessService;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public List<SkillFileNode> tree(BigInteger skillId) {
        Skill skill = requireReadableSkill(skillId);
        SkillFileNode skillFile = fileNode(SkillPaths.SKILL_FILE, SkillFileType.SKILL.name(), null, null,
                true, (long) bytes(skill.getSkillContent()).length, SkillHashes.sha256Hex(bytes(skill.getSkillContent())));
        List<SkillFileNode> flatResources = listResourceDescriptors(skillId).stream().map(this::fileNode).toList();
        List<SkillFileNode> roots = new ArrayList<>();
        roots.add(skillFile);
        List<SkillFileNode> resourceRoots = new ArrayList<>(toNestedTree(flatResources));
        for (String directory : STANDARD_DIRECTORIES) {
            SkillFileNode standardRoot = resourceRoots.stream()
                    .filter(node -> directory.equals(node.getPath()) && "DIRECTORY".equals(node.getType()))
                    .findFirst()
                    .orElseGet(() -> directoryNode(directory, directory));
            roots.add(standardRoot);
            resourceRoots.remove(standardRoot);
        }
        roots.addAll(resourceRoots);
        return roots;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public SkillFileContent getContent(BigInteger skillId, String path) {
        Skill skill = requireReadableSkill(skillId);
        String normalizedPath = normalizePath(path);
        if (SkillPaths.SKILL_FILE.equals(normalizedPath)) {
            byte[] bytes = bytes(skill.getSkillContent());
            SkillFileContent result = new SkillFileContent();
            result.setPath(normalizedPath);
            result.setType(SkillFileType.SKILL.name());
            result.setContent(skill.getSkillContent());
            result.setMediaType("text/markdown");
            result.setIsText(true);
            result.setSize((long) bytes.length);
            result.setContentHash(SkillHashes.sha256Hex(bytes));
            return result;
        }
        return toContent(requireResource(skillId, normalizedPath));
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public SkillFileContent saveContent(SkillFileSaveRequest request) {
        validateSaveRequest(request);
        Skill skill = requireManageSkill(request.getSkillId());
        String normalizedPath = normalizePath(request.getPath());
        String content = request.getContent() == null ? "" : request.getContent();
        if (SkillPaths.SKILL_FILE.equals(normalizedPath)) {
            Skill update = detachedContentUpdate(skill, content);
            skillService.updateDraftIfContentMatches(update, request.getExpectedContentHash());
            return getContent(skill.getId(), normalizedPath);
        }
        SkillResource existing = requireResource(skill.getId(), normalizedPath);
        assertExpectedHash(request.getExpectedContentHash(), existing.getContentHash());
        saveTextResource(skill, existing, normalizedPath, content, request.getExpectedContentHash());
        return getContent(skill.getId(), normalizedPath);
    }

    /**
     * 构造与当前 MyBatis 会话实体隔离的 SKILL.md 更新对象。
     *
     * <p>文件保存入口已经读取并锁定 Skill。若直接修改该实体，同一事务中的后续查询可能从
     * MyBatis 一级缓存取得同一对象，使乐观并发校验误把新内容当作数据库旧版本。</p>
     *
     * @param source 当前持久化 Skill
     * @param content 新的 SKILL.md 内容
     * @return 保留管理配置且与持久化实体隔离的更新对象
     */
    private Skill detachedContentUpdate(Skill source, String content) {
        Skill update = new Skill();
        update.setId(source.getId());
        update.setCategoryId(source.getCategoryId());
        update.setDisplayName(source.getDisplayName());
        update.setSkillContent(content);
        update.setVisibilityScope(source.getVisibilityScope());
        return update;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public SkillFileContent createTextFile(SkillFileSaveRequest request) {
        validateSaveRequest(request);
        Skill skill = requireManageSkill(request.getSkillId());
        String normalizedPath = normalizePath(request.getPath());
        if (SkillPaths.SKILL_FILE.equals(normalizedPath)) {
            throw new BusinessException("SKILL.md 已存在，不能重复创建");
        }
        if (findResource(skill.getId(), normalizedPath) != null) {
            throw conflict("Skill 资源路径已存在：" + normalizedPath);
        }
        assertNoCanonicalCollision(skill.getId(), normalizedPath, null);
        saveTextResource(skill, null, normalizedPath, request.getContent() == null ? "" : request.getContent(), null);
        return getContent(skill.getId(), normalizedPath);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public SkillFileContent renameFile(SkillFileRenameRequest request) {
        if (request == null || request.getSkillId() == null) {
            throw new BusinessException("Skill ID 不能为空");
        }
        requireManageSkill(request.getSkillId());
        String sourcePath = normalizePath(request.getPath());
        String targetPath = normalizePath(request.getNewPath());
        if (SkillPaths.SKILL_FILE.equals(sourcePath) || SkillPaths.SKILL_FILE.equals(targetPath)) {
            throw new BusinessException("SKILL.md 不允许重命名");
        }
        if (findResource(request.getSkillId(), targetPath) != null) {
            throw conflict("目标资源路径已存在：" + targetPath);
        }
        SkillResource resource = requireResource(request.getSkillId(), sourcePath);
        assertExpectedHash(request.getExpectedContentHash(), resource.getContentHash());
        assertNoCanonicalCollision(request.getSkillId(), targetPath, resource.getId());
        String releasedContentRef = null;
        boolean targetText = shouldStoreAsText(targetPath);
        boolean sourceText = Boolean.TRUE.equals(resource.getIsText());
        if (targetText && !sourceText) {
            releasedContentRef = resource.getContentRef();
            String textContent = readStrictUtf8Content(resource, targetPath);
            byte[] contentBytes = bytes(textContent);
            resource.setIsText(true);
            resource.setTextContent(textContent);
            resource.setContentRef(null);
            resource.setContentHash(SkillHashes.sha256Hex(contentBytes));
            resource.setSize((long) contentBytes.length);
        } else if (!targetText && sourceText) {
            byte[] contentBytes = bytes(resource.getTextContent());
            String contentRef = contentStore.put(contentBytes);
            resource.setIsText(false);
            resource.setTextContent(null);
            resource.setContentRef(contentRef);
            resource.setContentHash(contentRef.substring("sha256:".length()));
            resource.setSize((long) contentBytes.length);
        }
        boolean text = Boolean.TRUE.equals(resource.getIsText());
        resource.setPath(targetPath);
        resource.setNormalizedPath(targetPath);
        resource.setMediaType(resolveMediaType(targetPath, text));
        try {
            if (!skillResourceService.update(resource, tenantResourceQuery(resource.getSkillId(), resource.getId())
                    .eq(SkillResource::getContentHash, request.getExpectedContentHash()))) {
                throw conflict("文件已被其他操作更新，请重新加载后再重命名");
            }
        } catch (DuplicateKeyException exception) {
            throw conflict("目标资源路径已存在或发生大小写冲突：" + targetPath);
        }
        if (releasedContentRef != null) {
            contentStore.release(releasedContentRef);
        }
        skillService.refreshPackageState(request.getSkillId());
        return getContent(request.getSkillId(), targetPath);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteFile(BigInteger skillId, String path) {
        deleteFile(skillId, path, null);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteFile(BigInteger skillId, String path, String expectedContentHash) {
        requireManageSkill(skillId);
        String normalizedPath = normalizePath(path);
        if (SkillPaths.SKILL_FILE.equals(normalizedPath)) {
            throw new BusinessException("SKILL.md 不允许删除");
        }
        SkillResource resource = requireResource(skillId, normalizedPath);
        assertExpectedHash(expectedContentHash, resource.getContentHash());
        if (!skillResourceService.remove(tenantResourceQuery(skillId, resource.getId())
                .eq(SkillResource::getContentHash, expectedContentHash))) {
            throw conflict("文件已被其他操作更新，请重新加载后再删除");
        }
        if (resource.getContentRef() != null) {
            contentStore.release(resource.getContentRef());
        }
        skillService.refreshPackageState(skillId);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public SkillFileContent uploadAsset(BigInteger skillId, String path, MultipartFile file) {
        return uploadResource(skillId, path, file);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public SkillFileContent uploadResource(BigInteger skillId, String path, MultipartFile file) {
        return uploadResource(skillId, path, file, null);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public SkillFileContent uploadResource(BigInteger skillId,
                                           String path,
                                           MultipartFile file,
                                           String expectedContentHash) {
        Skill skill = requireManageSkill(skillId);
        validateUpload(file);
        String normalizedPath = normalizeUploadPath(path, file.getOriginalFilename());
        if (shouldStoreAsText(normalizedPath)) {
            SkillResource resource = findResource(skillId, normalizedPath);
            if (resource == null) {
                assertNoCanonicalCollision(skillId, normalizedPath, null);
            } else {
                assertExpectedHash(expectedContentHash, resource.getContentHash());
            }
            saveTextResource(skill, resource, normalizedPath,
                    readStrictUtf8(file, normalizedPath), expectedContentHash);
            return getContent(skillId, normalizedPath);
        }
        String mediaType = detectMediaType(normalizedPath);
        String newContentRef = null;
        try {
            newContentRef = contentStore.put(file, mediaType);
            SkillResource resource = findResource(skillId, normalizedPath);
            if (resource == null) {
                assertNoCanonicalCollision(skillId, normalizedPath, null);
            } else {
                assertExpectedHash(expectedContentHash, resource.getContentHash());
            }
            String oldContentRef = resource == null ? null : resource.getContentRef();
            if (resource == null) {
                resource = new SkillResource();
                resource.setTenantId(skill.getTenantId());
                resource.setSkillId(skillId);
                resource.setPath(normalizedPath);
                resource.setNormalizedPath(normalizedPath);
            }
            resource.setMediaType(mediaType);
            resource.setIsText(false);
            resource.setTextContent(null);
            resource.setContentRef(newContentRef);
            resource.setContentHash(newContentRef.substring("sha256:".length()));
            resource.setSize(file.getSize());
            boolean saved;
            try {
                saved = resource.getId() == null
                        ? skillResourceService.save(resource)
                        : skillResourceService.update(resource, tenantResourceQuery(skillId, resource.getId())
                        .eq(SkillResource::getContentHash, expectedContentHash));
            } catch (DuplicateKeyException exception) {
                throw conflict("Skill 资源路径已存在或发生大小写冲突：" + normalizedPath);
            }
            if (!saved) {
                if (resource.getId() != null) {
                    throw conflict("文件已被其他操作更新，请重新加载后再替换");
                }
                throw new BusinessException(500, 500, "保存 Skill 二进制资源失败，请稍后重试");
            }
            if (oldContentRef != null) {
                contentStore.release(oldContentRef);
            }
            skillService.refreshPackageState(skillId);
            return getContent(skillId, normalizedPath);
        } catch (RuntimeException exception) {
            throw exception;
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public InputStream openAsset(BigInteger skillId, String path) {
        return openResource(skillId, path);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public InputStream openResource(BigInteger skillId, String path) {
        requireReadableSkill(skillId);
        SkillResource resource = requireResource(skillId, normalizePath(path));
        if (resource.getContentRef() == null) {
            throw new BusinessException("该 Skill 资源不是二进制文件");
        }
        return contentStore.open(resource.getContentRef());
    }

    private void saveTextResource(Skill skill,
                                  SkillResource resource,
                                  String path,
                                  String content,
                                  String expectedContentHash) {
        if (!shouldStoreAsText(path)) {
            throw new BusinessException("该资源路径按二进制文件管理，请使用上传功能：" + path);
        }
        String oldContentRef = resource == null ? null : resource.getContentRef();
        if (resource == null) {
            resource = new SkillResource();
            resource.setTenantId(skill.getTenantId());
            resource.setSkillId(skill.getId());
            resource.setPath(path);
            resource.setNormalizedPath(path);
        }
        byte[] contentBytes = bytes(content);
        resource.setMediaType(resolveMediaType(path, true));
        resource.setIsText(true);
        resource.setTextContent(content);
        resource.setContentRef(null);
        resource.setContentHash(SkillHashes.sha256Hex(contentBytes));
        resource.setSize((long) contentBytes.length);
        boolean saved;
        try {
            if (resource.getId() == null) {
                saved = skillResourceService.save(resource);
            } else {
                QueryWrapper updateQuery = tenantResourceQuery(skill.getId(), resource.getId())
                        .eq(SkillResource::getContentHash, expectedContentHash);
                saved = skillResourceService.update(resource, updateQuery);
            }
        } catch (DuplicateKeyException exception) {
            throw conflict("Skill 资源路径已存在或发生大小写冲突：" + path);
        }
        if (!saved) {
            if (resource.getId() != null) {
                throw conflict("文件已被其他操作更新，请重新加载后合并内容");
            }
            throw new BusinessException(500, 500, "保存 Skill 文本资源失败，请稍后重试");
        }
        if (oldContentRef != null) {
            contentStore.release(oldContentRef);
        }
        skillService.refreshPackageState(skill.getId());
    }

    private Skill requireReadableSkill(BigInteger skillId) {
        Skill skill = requireSkill(skillId);
        resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.READ,
                "无权限查看该 Skill");
        return skill;
    }

    private Skill requireManageSkill(BigInteger skillId) {
        Skill skill = requireSkill(skillId, true);
        resourceAccessService.assertAccess(CategoryResourceType.SKILL, skill, ResourceAction.MANAGE,
                "无权限管理该 Skill");
        return skill;
    }

    private Skill requireSkill(BigInteger skillId) {
        return requireSkill(skillId, false);
    }

    private Skill requireSkill(BigInteger skillId, boolean forUpdate) {
        if (skillId == null) {
            throw new BusinessException("Skill ID 不能为空");
        }
        tech.easyflow.common.entity.LoginAccount account = tech.easyflow.common.satoken.util.SaTokenUtil.getLoginAccount();
        if (account == null || account.getId() == null || account.getTenantId() == null) {
            throw new BusinessException(401, 401, "未登录或登录态无效");
        }
        QueryWrapper query = QueryWrapper.create()
                .eq(Skill::getId, skillId)
                .eq(Skill::getTenantId, account.getTenantId());
        if (forUpdate) {
            query.forUpdate();
        }
        Skill skill = skillService.getOne(query);
        if (skill == null) {
            throw new BusinessException(404, 404, "Skill 不存在");
        }
        return skill;
    }

    private List<SkillResource> listResources(BigInteger skillId) {
        return skillResourceService.list(QueryWrapper.create()
                .eq(SkillResource::getTenantId, currentTenantId())
                .eq(SkillResource::getSkillId, skillId)
                .orderBy("normalized_path asc"));
    }

    private List<SkillResource> listResourceDescriptors(BigInteger skillId) {
        return skillResourceService.listDescriptors(skillId, currentTenantId());
    }

    private SkillResource findResource(BigInteger skillId, String path) {
        List<SkillResource> records = skillResourceService.list(QueryWrapper.create()
                .eq(SkillResource::getTenantId, currentTenantId())
                .eq(SkillResource::getSkillId, skillId)
                .eq(SkillResource::getNormalizedPath, path)
                .limit(1));
        return records.isEmpty() ? null : records.get(0);
    }

    private QueryWrapper tenantResourceQuery(BigInteger skillId, BigInteger resourceId) {
        return QueryWrapper.create()
                .eq(SkillResource::getTenantId, currentTenantId())
                .eq(SkillResource::getSkillId, skillId)
                .eq(SkillResource::getId, resourceId);
    }

    private BigInteger currentTenantId() {
        tech.easyflow.common.entity.LoginAccount account = tech.easyflow.common.satoken.util.SaTokenUtil.getLoginAccount();
        if (account == null || account.getId() == null || account.getTenantId() == null) {
            throw new BusinessException(401, 401, "未登录或登录态无效");
        }
        return account.getTenantId();
    }

    private SkillResource requireResource(BigInteger skillId, String path) {
        SkillResource resource = findResource(skillId, path);
        if (resource == null) {
            throw new BusinessException(404, 404, "Skill 资源不存在：" + path);
        }
        return resource;
    }

    private SkillFileContent toContent(SkillResource resource) {
        SkillFileContent content = new SkillFileContent();
        content.setPath(resource.getNormalizedPath());
        content.setType(SkillResources.classify(resource.getNormalizedPath()).name());
        content.setContent(resource.getTextContent());
        content.setLanguage(resolveLanguage(resource.getNormalizedPath()));
        content.setMediaType(resource.getMediaType());
        content.setIsText(resource.getIsText());
        content.setSize(resource.getSize());
        content.setContentHash(resource.getContentHash());
        return content;
    }

    private SkillFileNode fileNode(SkillResource resource) {
        String path = resource.getNormalizedPath();
        return fileNode(path, SkillResources.classify(path).name(), resolveLanguage(path),
                resource.getMediaType(), resource.getIsText(), resource.getSize(), resource.getContentHash());
    }

    private SkillFileNode fileNode(String path, String type, String language,
                                   String mediaType, Boolean isText, Long size, String contentHash) {
        SkillFileNode node = new SkillFileNode();
        node.setKey(path);
        node.setPath(path);
        node.setName(SkillPaths.fileName(path));
        node.setType(type);
        node.setLanguage(language);
        node.setMediaType(mediaType);
        node.setIsText(isText);
        node.setSize(size);
        node.setContentHash(contentHash);
        return node;
    }

    private List<SkillFileNode> toNestedTree(List<SkillFileNode> flatFiles) {
        Map<String, SkillFileNode> nodes = new LinkedHashMap<>();
        List<SkillFileNode> roots = new ArrayList<>();
        for (SkillFileNode file : flatFiles) {
            String[] segments = file.getPath().split("/");
            String current = "";
            SkillFileNode parent = null;
            for (int index = 0; index < segments.length; index++) {
                current = current.isEmpty() ? segments[index] : current + "/" + segments[index];
                boolean leaf = index == segments.length - 1;
                String segmentName = segments[index];
                SkillFileNode node = leaf ? file : nodes.computeIfAbsent(current, key -> directoryNode(key, segmentName));
                nodes.putIfAbsent(current, node);
                if (parent == null) {
                    if (!roots.contains(node)) {
                        roots.add(node);
                    }
                } else if (!parent.getChildren().contains(node)) {
                    parent.getChildren().add(node);
                }
                parent = node;
            }
        }
        sortTree(roots);
        return roots;
    }

    private SkillFileNode directoryNode(String path, String name) {
        SkillFileNode node = new SkillFileNode();
        node.setKey(path);
        node.setPath(path);
        node.setName(name + "/");
        node.setType("DIRECTORY");
        return node;
    }

    private void sortTree(List<SkillFileNode> nodes) {
        nodes.sort(Comparator.comparing((SkillFileNode node) -> !"DIRECTORY".equals(node.getType()))
                .thenComparing(SkillFileNode::getName));
        nodes.forEach(node -> sortTree(node.getChildren()));
    }

    private void validateSaveRequest(SkillFileSaveRequest request) {
        if (request == null || request.getSkillId() == null) {
            throw new BusinessException("Skill ID 不能为空");
        }
        if (request.getPath() == null || request.getPath().isBlank()) {
            throw new BusinessException("Skill 资源路径不能为空");
        }
        if (bytes(request.getContent()).length > MAX_TEXT_RESOURCE_BYTES) {
            throw new BusinessException(413, 4131, "Skill 文本资源超过 2 MiB 限制");
        }
    }

    private void assertExpectedHash(String expectedHash, String actualHash) {
        if (expectedHash == null || expectedHash.isBlank()) {
            throw conflict("缺少文件版本，请重新加载后再保存");
        }
        if (!expectedHash.equals(actualHash)) {
            throw conflict("文件已被其他操作更新，请重新加载后合并内容");
        }
    }

    private void validateUpload(MultipartFile file) {
        if (file == null || file.isEmpty()) {
            throw new BusinessException("Skill 资源文件不能为空");
        }
        if (file.getSize() > MAX_BINARY_RESOURCE_BYTES) {
            throw new BusinessException(413, 4131, "Skill 二进制资源超过 50 MiB 限制");
        }
    }

    /**
     * 根据路径与媒体类型判断资源的规范存储表示；目录分类不参与文本编码决策。
     *
     * @param path 规范化资源路径
     * @return 应以内联严格 UTF-8 文本保存时返回 true
     */
    private boolean shouldStoreAsText(String path) {
        return SkillResources.isText(path, detectMediaType(path));
    }

    /**
     * 将上传的文本资源按安全上限严格解码为 UTF-8 文本。
     *
     * @param file 上传文件
     * @param path 目标路径
     * @return 资源文本
     */
    private String readStrictUtf8(MultipartFile file, String path) {
        if (file.getSize() > MAX_TEXT_RESOURCE_BYTES) {
            throw new BusinessException(413, 4131, "Skill 文本资源超过 2 MiB 限制：" + path);
        }
        try (InputStream inputStream = file.getInputStream()) {
            return readStrictUtf8(inputStream, path);
        } catch (IOException exception) {
            throw new BusinessException(500, 500, "读取 Skill 文本资源上传内容失败", exception);
        }
    }

    /**
     * 将已有二进制资源转换为文本，供重命名后收敛存储表示。
     *
     * @param resource 源资源
     * @param targetPath 目标资源路径
     * @return 严格 UTF-8 文本
     */
    private String readStrictUtf8Content(SkillResource resource, String targetPath) {
        if (resource.getContentRef() == null || resource.getSize() == null
                || resource.getSize() > MAX_TEXT_RESOURCE_BYTES) {
            throw new BusinessException("二进制资源不能转换为文本：" + targetPath);
        }
        try (InputStream inputStream = contentStore.open(resource.getContentRef())) {
            return readStrictUtf8(inputStream, targetPath);
        } catch (IOException exception) {
            throw new BusinessException(500, 500, "读取待转换的 Skill 资源失败", exception);
        }
    }

    /**
     * 在读取过程中执行大小限制，并拒绝非法 UTF-8 字节序列。
     *
     * @param inputStream 内容流
     * @param path 资源路径
     * @return 解码后的文本
     */
    private String readStrictUtf8(InputStream inputStream, String path) {
        try {
            ByteArrayOutputStream output = new ByteArrayOutputStream();
            byte[] buffer = new byte[8192];
            long total = 0;
            int length;
            while ((length = inputStream.read(buffer)) >= 0) {
                if (length == 0) {
                    continue;
                }
                total += length;
                if (total > MAX_TEXT_RESOURCE_BYTES) {
                    throw new BusinessException(413, 4131, "Skill 文本资源超过 2 MiB 限制：" + path);
                }
                output.write(buffer, 0, length);
            }
            return StandardCharsets.UTF_8.newDecoder()
                    .onMalformedInput(CodingErrorAction.REPORT)
                    .onUnmappableCharacter(CodingErrorAction.REPORT)
                    .decode(ByteBuffer.wrap(output.toByteArray()))
                    .toString();
        } catch (CharacterCodingException exception) {
            throw new BusinessException("Skill 文本资源必须使用严格 UTF-8 编码：" + path);
        } catch (IOException exception) {
            throw new BusinessException(500, 500, "读取 Skill 文本资源失败", exception);
        }
    }

    private String normalizeUploadPath(String path, String originalFilename) {
        String effectivePath = path;
        if (effectivePath == null || effectivePath.isBlank()) {
            effectivePath = SkillPaths.ASSETS_DIR + "/" + (originalFilename == null ? "resource.bin" : originalFilename);
        }
        String normalized = normalizePath(effectivePath);
        if (SkillPaths.SKILL_FILE.equals(normalized)) {
            throw new BusinessException("SKILL.md 只能通过文本编辑器保存");
        }
        return normalized;
    }

    private String normalizePath(String path) {
        String normalized;
        try {
            normalized = SkillPaths.normalize(path);
        } catch (SkillException exception) {
            throw new BusinessException("Skill 资源路径不合法：" + exception.getMessage());
        }
        if (normalized.length() > 512) {
            throw new BusinessException("Skill 资源路径超过 512 个字符");
        }
        if (normalized.split("/").length > 16) {
            throw new BusinessException("Skill 资源路径层级不能超过 16 层");
        }
        return normalized;
    }

    private String resolveLanguage(String path) {
        String lowerPath = path.toLowerCase(Locale.ROOT);
        if (isMarkdownPath(path)) {
            return "MARKDOWN";
        }
        if (lowerPath.endsWith(".py")) {
            return "PYTHON";
        }
        if (lowerPath.endsWith(".js") || lowerPath.endsWith(".mjs") || lowerPath.endsWith(".cjs")
                || lowerPath.endsWith(".jsx")) {
            return "JAVASCRIPT";
        }
        if (lowerPath.endsWith(".ts") || lowerPath.endsWith(".tsx")) {
            return "TYPESCRIPT";
        }
        if (lowerPath.endsWith(".sh") || lowerPath.endsWith(".bash") || lowerPath.endsWith(".zsh")) {
            return "SHELL";
        }
        if (lowerPath.endsWith(".json")) {
            return "JSON";
        }
        if (lowerPath.endsWith(".yaml") || lowerPath.endsWith(".yml")) {
            return "YAML";
        }
        if (lowerPath.endsWith(".xml")) {
            return "XML";
        }
        if (lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
                || lowerPath.endsWith(".vue") || lowerPath.endsWith(".svelte")) {
            return "HTML";
        }
        if (lowerPath.endsWith(".css") || lowerPath.endsWith(".scss") || lowerPath.endsWith(".less")) {
            return "CSS";
        }
        if (lowerPath.endsWith(".java") || lowerPath.endsWith(".kt") || lowerPath.endsWith(".kts")) {
            return "JAVA";
        }
        return lowerPath.endsWith(".sql") ? "SQL" : null;
    }

    /**
     * 根据重命名后的路径、语义类型和文本表示重新计算媒体类型。
     *
     * @param path 资源路径
     * @param text 是否为文本
     * @return 媒体类型
     */
    private String resolveMediaType(String path, boolean text) {
        if (!text) {
            return detectMediaType(path);
        }
        String lowerPath = path.toLowerCase(Locale.ROOT);
        if (isMarkdownPath(path)) {
            return "text/markdown";
        }
        if (lowerPath.endsWith(".json")) {
            return "application/json";
        }
        if (lowerPath.endsWith(".yaml") || lowerPath.endsWith(".yml")) {
            return "application/yaml";
        }
        if (lowerPath.endsWith(".xml")) {
            return "application/xml";
        }
        if (lowerPath.endsWith(".csv")) {
            return "text/csv";
        }
        if (lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")) {
            return "text/html";
        }
        if (lowerPath.endsWith(".js") || lowerPath.endsWith(".mjs") || lowerPath.endsWith(".cjs")) {
            return "application/javascript";
        }
        String detected = detectMediaType(path);
        return DEFAULT_MEDIA_TYPE.equals(detected) ? "text/plain" : detected;
    }

    /**
     * 判断路径是否为 Markdown 文档。
     *
     * @param path 资源路径
     * @return Markdown 扩展名时返回 true
     */
    private boolean isMarkdownPath(String path) {
        String lowerPath = path.toLowerCase(Locale.ROOT);
        return lowerPath.endsWith(".md") || lowerPath.endsWith(".markdown");
    }

    private String detectMediaType(String path) {
        String lowerPath = path.toLowerCase(Locale.ROOT);
        if (lowerPath.endsWith(".png")) {
            return "image/png";
        }
        if (lowerPath.endsWith(".jpg") || lowerPath.endsWith(".jpeg")) {
            return "image/jpeg";
        }
        if (lowerPath.endsWith(".gif")) {
            return "image/gif";
        }
        if (lowerPath.endsWith(".webp")) {
            return "image/webp";
        }
        if (lowerPath.endsWith(".avif")) {
            return "image/avif";
        }
        if (lowerPath.endsWith(".pdf")) {
            return "application/pdf";
        }
        if (lowerPath.endsWith(".svg") || lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
                || lowerPath.endsWith(".js") || lowerPath.endsWith(".mjs")) {
            return DEFAULT_MEDIA_TYPE;
        }
        String detected = URLConnection.guessContentTypeFromName(path);
        if (detected == null) {
            return DEFAULT_MEDIA_TYPE;
        }
        String normalized = detected.toLowerCase(Locale.ROOT);
        if (normalized.equals("text/html") || normalized.equals("application/xhtml+xml")
                || normalized.equals("image/svg+xml") || normalized.equals("application/javascript")) {
            return DEFAULT_MEDIA_TYPE;
        }
        return normalized;
    }

    private void assertNoCanonicalCollision(BigInteger skillId, String path, BigInteger excludeId) {
        String expectedKey = collisionKey(path);
        for (SkillResource resource : listResourceDescriptors(skillId)) {
            if ((excludeId == null || !excludeId.equals(resource.getId()))
                    && expectedKey.equals(collisionKey(resource.getNormalizedPath()))) {
                throw conflict("Skill 资源路径与已有文件冲突：" + path);
            }
        }
    }

    private String collisionKey(String path) {
        return Normalizer.normalize(path, Normalizer.Form.NFKC).toLowerCase(Locale.ROOT);
    }

    private BusinessException conflict(String message) {
        return new BusinessException(409, 4091, message);
    }

    private byte[] bytes(String content) {
        return (content == null ? "" : content).getBytes(StandardCharsets.UTF_8);
    }
}
