package tech.easyflow.ai.documentimport.task;

import com.alibaba.fastjson2.JSON;
import com.mybatisflex.core.paginate.Page;
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.web.multipart.MultipartFile;
import tech.easyflow.ai.documentimport.DocumentImportBatchCreateContext;
import tech.easyflow.ai.documentimport.DocumentImportBatchDtos;
import tech.easyflow.ai.documentimport.DocumentImportBatchRetryResult;
import tech.easyflow.ai.documentimport.ImportCallerContext;
import tech.easyflow.ai.documentimport.PublicDocumentImportDtos;
import tech.easyflow.ai.entity.DocumentImportBatch;
import tech.easyflow.ai.entity.DocumentImportBatchItem;
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
import tech.easyflow.ai.service.DocumentImportBatchItemService;
import tech.easyflow.ai.service.DocumentImportBatchService;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.exceptions.BusinessException;

import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

/**
 * 知识库 Public API 批量导入门面。
 *
 * <p>门面仅补充 Public API 容量、调用者归属、幂等和响应映射，
 * 解析、分块、快照、向量化及恢复继续复用现有批次链路。</p>
 *
 * @author Codex
 * @since 2026-08-02
 */
@Service
public class KnowledgeImportBatchFacade {

    private static final Logger LOG =
        LoggerFactory.getLogger(KnowledgeImportBatchFacade.class);
    private static final int MAX_FILE_COUNT = 200;
    private static final long MAX_FILE_BYTES = 100L * 1024L * 1024L;
    private static final long MAX_TOTAL_BYTES = 200L * 1024L * 1024L;
    private static final Duration SUBMISSION_DEDUPLICATION_WINDOW =
        Duration.ofMinutes(10);
    private static final Duration INCOMPLETE_SUBMISSION_TIMEOUT =
        Duration.ofMinutes(30);
    private static final Set<String> SUPPORTED_EXTENSIONS =
        DocumentImportFormatPolicy.supportedExtensions();

    private final DocumentImportBatchAppService batchAppService;
    private final DocumentImportBatchTracker batchTracker;
    private final DocumentImportBatchService batchService;
    private final DocumentImportBatchItemService itemService;
    private final DocumentImportBatchMapper batchMapper;

    /**
     * 创建 Public API 批量导入门面。
     *
     * @param batchAppService 批次应用服务
     * @param batchTracker 批次跟踪器
     * @param batchService 批次服务
     * @param itemService 批次项服务
     * @param batchMapper 批次 Mapper
     */
    public KnowledgeImportBatchFacade(DocumentImportBatchAppService batchAppService,
                                      DocumentImportBatchTracker batchTracker,
                                      DocumentImportBatchService batchService,
                                      DocumentImportBatchItemService itemService,
                                      DocumentImportBatchMapper batchMapper) {
        this.batchAppService = batchAppService;
        this.batchTracker = batchTracker;
        this.batchService = batchService;
        this.itemService = itemService;
        this.batchMapper = batchMapper;
    }

    /**
     * 提交一个 Multipart 批量导入任务。
     *
     * @param caller 调用者上下文
     * @param metadata 批量元数据
     * @param files Multipart 文件
     * @return 异步任务响应
     */
    public PublicDocumentImportDtos.SubmitResponse submit(
        ImportCallerContext caller,
        PublicDocumentImportDtos.BatchMetadata metadata,
        List<MultipartFile> files) {
        if (caller == null || caller.getCallerType() == null
            || caller.getCallerId() == null) {
            throw new BusinessException("导入调用者信息不完整");
        }
        PreparedSubmission prepared = prepareSubmission(metadata, files);
        String submissionFingerprint = buildSubmissionFingerprint(
            caller,
            prepared.requestDigest
        );
        Date deduplicationCutoff = new Date(
            System.currentTimeMillis()
                - SUBMISSION_DEDUPLICATION_WINDOW.toMillis()
        );
        Date incompleteCutoff = new Date(
            System.currentTimeMillis()
                - INCOMPLETE_SUBMISSION_TIMEOUT.toMillis()
        );
        DocumentImportBatch existing =
            findBySubmissionFingerprint(caller, submissionFingerprint);
        if (existing != null) {
            if (shouldReuseSubmission(
                existing,
                deduplicationCutoff,
                incompleteCutoff
            )) {
                return resolveExistingSubmission(
                    existing,
                    prepared.requestDigest
                );
            }
            if (!releaseExpiredSubmission(
                existing,
                caller,
                submissionFingerprint,
                deduplicationCutoff,
                incompleteCutoff
            )) {
                return resolveCurrentSubmissionOrConflict(
                    caller,
                    submissionFingerprint,
                    prepared.requestDigest
                );
            }
            DocumentImportBatch concurrent =
                findBySubmissionFingerprint(caller, submissionFingerprint);
            if (concurrent != null) {
                if (shouldReuseSubmission(
                    concurrent,
                    deduplicationCutoff,
                    incompleteCutoff
                )) {
                    return resolveExistingSubmission(
                        concurrent,
                        prepared.requestDigest
                    );
                }
                if (!releaseExpiredSubmission(
                    concurrent,
                    caller,
                    submissionFingerprint,
                    deduplicationCutoff,
                    incompleteCutoff
                )) {
                    return resolveCurrentSubmissionOrConflict(
                        caller,
                        submissionFingerprint,
                        prepared.requestDigest
                    );
                }
            }
        }

        DocumentImportBatchDtos.CreateRequest createRequest =
            new DocumentImportBatchDtos.CreateRequest();
        createRequest.setKnowledgeId(metadata.getKnowledgeId());
        createRequest.setFiles(prepared.files);
        String strategyJson = JSON.toJSONString(metadata.getChunkStrategy());
        DocumentImportBatchCreateContext createContext =
            new DocumentImportBatchCreateContext(
                caller,
                submissionFingerprint,
                prepared.requestDigest,
                normalizeDuplicatePolicy(metadata.getDuplicatePolicy()),
                strategyJson
            );

        DocumentImportBatchDtos.CreateResponse created;
        try {
            created = batchAppService.createBatch(createRequest, createContext);
        } catch (DuplicateKeyException duplicate) {
            DocumentImportBatch concurrent =
                findBySubmissionFingerprint(caller, submissionFingerprint);
            if (concurrent == null) {
                throw duplicate;
            }
            return resolveExistingSubmission(
                concurrent,
                prepared.requestDigest
            );
        }

        try {
            for (int index = 0; index < files.size(); index++) {
                DocumentImportBatchDtos.ItemResponse item = created.getItems().get(index);
                batchAppService.uploadItem(
                    metadata.getKnowledgeId(),
                    created.getBatchId(),
                    item.getItemId(),
                    files.get(index),
                    caller
                );
                persistContentHash(item.getItemId(), prepared.contentHashes.get(index));
            }
            DocumentImportBatchDtos.StartRequest startRequest =
                new DocumentImportBatchDtos.StartRequest();
            startRequest.setKnowledgeId(metadata.getKnowledgeId());
            startRequest.setBatchId(created.getBatchId());
            startRequest.setImportMode("AUTO");
            startRequest.setDuplicatePolicy(metadata.getDuplicatePolicy());
            batchAppService.startBatch(startRequest, caller);
            return toSubmitResponse(
                batchAppService.requireOwnedBatch(
                    metadata.getKnowledgeId(),
                    created.getBatchId(),
                    caller
                )
            );
        } catch (RuntimeException error) {
            compensateFailedSubmission(
                metadata.getKnowledgeId(),
                created.getBatchId(),
                caller,
                submissionFingerprint
            );
            throw error;
        }
    }

    /**
     * 查询调用者拥有的批次状态。
     *
     * @param caller 调用者上下文
     * @param taskId 批次任务 ID
     * @param itemStatus 可选文件状态
     * @param pageNumber 页码
     * @param pageSize 每页数量
     * @return Public API 状态响应
     */
    public PublicDocumentImportDtos.StatusResponse getStatus(
        ImportCallerContext caller,
        BigInteger taskId,
        String itemStatus,
        long pageNumber,
        long pageSize) {
        if (pageNumber < 1 || pageSize < 1 || pageSize > 100) {
            throw new BusinessException("分页参数无效，pageSize 最大为100");
        }
        DocumentImportBatch batch =
            batchAppService.requireBatchForCaller(taskId, caller);
        QueryWrapper itemQuery = QueryWrapper.create()
            .eq(DocumentImportBatchItem::getBatchId, batch.getId())
            .orderBy(DocumentImportBatchItem::getCreated, true)
            .orderBy(DocumentImportBatchItem::getId, true);
        if (StringUtil.hasText(itemStatus)) {
            try {
                itemQuery.eq(
                    DocumentImportBatchItem::getStatus,
                    DocumentImportBatchItemStatus.valueOf(
                        itemStatus.trim().toUpperCase(Locale.ROOT)
                    ).name()
                );
            } catch (IllegalArgumentException error) {
                throw new BusinessException("文件状态筛选值无效");
            }
        }
        Page<DocumentImportBatchItem> page =
            itemService.page(new Page<>(pageNumber, pageSize), itemQuery);
        return toStatusResponse(batch, page);
    }

    /**
     * 查询调用者拥有任务的知识库 ID。
     *
     * @param caller 调用者上下文
     * @param taskId 批次任务 ID
     * @return 知识库 ID
     */
    public BigInteger getOwnedKnowledgeId(ImportCallerContext caller,
                                          BigInteger taskId) {
        return batchAppService.requireBatchForCaller(taskId, caller).getKnowledgeId();
    }

    /**
     * 重试当前调用者拥有的异常批次。
     *
     * @param caller 调用者上下文
     * @param request 重试请求
     * @return 重试响应
     */
    public PublicDocumentImportDtos.RetryResponse retry(
        ImportCallerContext caller,
        PublicDocumentImportDtos.RetryRequest request) {
        if (request == null || request.getTaskId() == null) {
            throw new BusinessException("重试请求信息不完整");
        }
        if (request.getFileKeys() != null && request.getFileKeys().isEmpty()) {
            throw new BusinessException("fileKeys 不能为空数组；省略该参数可重试全部异常文件");
        }
        Set<String> fileKeys = new LinkedHashSet<>();
        if (request.getFileKeys() != null) {
            if (request.getFileKeys().size() > MAX_FILE_COUNT) {
                throw new BusinessException("fileKeys 数量不能超过200");
            }
            for (String fileKey : request.getFileKeys()) {
                if (!StringUtil.hasText(fileKey)
                    || fileKey.trim().length() > 64
                    || !fileKeys.add(fileKey.trim())) {
                    throw new BusinessException("fileKeys 包含空值、重复值或超长值");
                }
            }
        }
        DocumentImportBatchRetryResult result =
            batchAppService.retryOwnedBatch(
            request.getTaskId(),
            caller,
            fileKeys
        );
        PublicDocumentImportDtos.RetryResponse response =
            new PublicDocumentImportDtos.RetryResponse();
        response.setTaskId(result.getTaskId());
        response.setStatus(result.getStatus());
        response.setRetriedCount(result.getRetriedCount());
        return response;
    }

    /**
     * 校验提交内容并计算实际文件摘要。
     *
     * @param metadata 批量元数据
     * @param files 上传文件
     * @return 准备完成的提交信息
     */
    private PreparedSubmission prepareSubmission(
        PublicDocumentImportDtos.BatchMetadata metadata,
        List<MultipartFile> files) {
        if (metadata == null || metadata.getKnowledgeId() == null) {
            throw new BusinessException("knowledgeId 不能为空");
        }
        if (files == null || files.isEmpty()
            || metadata.getFiles() == null || metadata.getFiles().isEmpty()) {
            throw new BusinessException("请选择需要导入的文件");
        }
        if (files.size() != metadata.getFiles().size()) {
            throw new BusinessException("metadata.files 与文件 Part 数量不一致");
        }
        if (files.size() > MAX_FILE_COUNT) {
            throw new BusinessException(413, 41301, "单次最多上传200个文件");
        }
        normalizeDuplicatePolicy(metadata.getDuplicatePolicy());

        long totalBytes = 0L;
        Set<String> normalizedPaths = new HashSet<>();
        Set<String> fileKeys = new HashSet<>();
        List<String> contentHashes = new ArrayList<>(files.size());
        List<DocumentImportBatchDtos.ManifestItem> measuredFiles =
            new ArrayList<>(files.size());
        StringBuilder digestSource = new StringBuilder()
            .append(metadata.getKnowledgeId()).append('\n')
            .append(normalizeDuplicatePolicy(metadata.getDuplicatePolicy())).append('\n')
            .append(JSON.toJSONString(metadata.getChunkStrategy())).append('\n');
        for (int index = 0; index < files.size(); index++) {
            MultipartFile file = files.get(index);
            DocumentImportBatchDtos.ManifestItem declared = metadata.getFiles().get(index);
            validateManifestPair(file, declared, fileKeys, normalizedPaths);
            FileFingerprint fingerprint = fingerprint(file);
            if (fingerprint.actualBytes > MAX_FILE_BYTES) {
                throw new BusinessException(413, 41302, "单个文件不能超过100MiB");
            }
            totalBytes = Math.addExact(totalBytes, fingerprint.actualBytes);
            if (totalBytes > MAX_TOTAL_BYTES) {
                throw new BusinessException(413, 41303, "单次文件总大小不能超过200MiB");
            }
            validateContentSignature(declared.getFileName(), fingerprint.prefix);
            contentHashes.add(fingerprint.sha256);
            measuredFiles.add(withMeasuredSize(declared, fingerprint.actualBytes));
            digestSource.append(declared.getClientFileKey()).append('\u0000')
                .append(normalizeRelativePath(
                    declared.getRelativePath(),
                    declared.getFileName()
                )).append('\u0000')
                .append(fingerprint.actualBytes).append('\u0000')
                .append(fingerprint.sha256).append('\n');
        }
        return new PreparedSubmission(
            sha256(digestSource.toString().getBytes(StandardCharsets.UTF_8)),
            contentHashes,
            measuredFiles
        );
    }

    /**
     * 校验单个声明与 Multipart 文件的一致性。
     *
     * @param file 实际文件
     * @param declared 声明项
     * @param fileKeys 已使用文件键
     * @param normalizedPaths 已使用规范路径
     */
    private void validateManifestPair(MultipartFile file,
                                      DocumentImportBatchDtos.ManifestItem declared,
                                      Set<String> fileKeys,
                                      Set<String> normalizedPaths) {
        if (file == null || file.isEmpty() || declared == null
            || !StringUtil.hasText(declared.getClientFileKey())
            || !StringUtil.hasText(declared.getFileName())) {
            throw new BusinessException("文件 Part 或 metadata.files 不完整");
        }
        if (!fileKeys.add(declared.getClientFileKey())) {
            throw new BusinessException("clientFileKey 在批次内必须唯一");
        }
        if (!declared.getFileName().equals(file.getOriginalFilename())) {
            throw new BusinessException("文件 Part 顺序或文件名与 metadata 不一致");
        }
        String relativePath =
            normalizeRelativePath(declared.getRelativePath(), declared.getFileName());
        if (!normalizedPaths.add(relativePath)) {
            throw new BusinessException("规范化后的文件相对路径不能重复");
        }
        assertSupportedExtension(declared.getFileName());
    }

    /**
     * 使用服务端实际读取到的文件大小构造内部清单项。
     *
     * @param declared Public API 声明项
     * @param actualBytes 服务端读取到的实际字节数
     * @return 内部批量导入清单项
     */
    private DocumentImportBatchDtos.ManifestItem withMeasuredSize(
        DocumentImportBatchDtos.ManifestItem declared,
        long actualBytes) {
        DocumentImportBatchDtos.ManifestItem measured =
            new DocumentImportBatchDtos.ManifestItem();
        measured.setClientFileKey(declared.getClientFileKey());
        measured.setFileName(declared.getFileName());
        measured.setRelativePath(declared.getRelativePath());
        measured.setFileSize(actualBytes);
        return measured;
    }

    /**
     * 流式计算实际大小、SHA-256 和小型签名前缀。
     *
     * @param file 上传文件
     * @return 文件指纹
     */
    private FileFingerprint fingerprint(MultipartFile file) {
        MessageDigest digest = newSha256Digest();
        byte[] buffer = new byte[8192];
        byte[] prefix = new byte[1024];
        int prefixLength = 0;
        long actualBytes = 0L;
        try (InputStream input = file.getInputStream()) {
            int read;
            while ((read = input.read(buffer)) >= 0) {
                if (read == 0) {
                    continue;
                }
                if (prefixLength < prefix.length) {
                    int copyLength = Math.min(read, prefix.length - prefixLength);
                    System.arraycopy(buffer, 0, prefix, prefixLength, copyLength);
                    prefixLength += copyLength;
                }
                digest.update(buffer, 0, read);
                actualBytes = Math.addExact(actualBytes, read);
                if (actualBytes > MAX_FILE_BYTES) {
                    throw new BusinessException(413, 41302, "单个文件不能超过100MiB");
                }
            }
        } catch (IOException error) {
            throw new BusinessException(500, 50021, "读取上传文件失败", error);
        }
        byte[] actualPrefix = new byte[prefixLength];
        System.arraycopy(prefix, 0, actualPrefix, 0, prefixLength);
        return new FileFingerprint(actualBytes, toHex(digest.digest()), actualPrefix);
    }

    /**
     * 校验基础文件签名，阻止明显的扩展名伪装。
     *
     * @param fileName 文件名
     * @param prefix 文件前缀
     */
    private void validateContentSignature(String fileName, byte[] prefix) {
        String extension = extension(fileName);
        boolean valid;
        if ("pdf".equals(extension)) {
            valid = new String(prefix, StandardCharsets.ISO_8859_1).contains("%PDF-");
        } else if ("docx".equals(extension)
            || "pptx".equals(extension)
            || "xlsx".equals(extension)) {
            valid = prefix.length >= 4
                && prefix[0] == 'P'
                && prefix[1] == 'K'
                && (prefix[2] == 3 || prefix[2] == 5 || prefix[2] == 7);
        } else {
            valid = true;
            for (byte value : prefix) {
                if (value == 0) {
                    valid = false;
                    break;
                }
            }
        }
        if (!valid) {
            throw new BusinessException(415, 41501, "文件内容与扩展名不匹配");
        }
    }

    /**
     * 处理已存在的服务端提交指纹记录。
     *
     * @param existing 已存在批次
     * @param requestDigest 当前请求摘要
     * @return 原任务响应
     */
    private PublicDocumentImportDtos.SubmitResponse resolveExistingSubmission(
        DocumentImportBatch existing,
        String requestDigest) {
        if (!requestDigest.equals(existing.getRequestDigest())) {
            throw new BusinessException(
                409,
                40901,
                "导入请求指纹冲突，请重新提交"
            );
        }
        return toSubmitResponse(existing);
    }

    /**
     * 生成调用者隔离的服务端提交指纹。
     *
     * @param caller 调用者上下文
     * @param requestDigest 请求内容摘要
     * @return 服务端提交指纹
     */
    private String buildSubmissionFingerprint(ImportCallerContext caller,
                                              String requestDigest) {
        String source = caller.getCallerType().name() + '\n'
            + caller.getCallerId() + '\n'
            + requestDigest;
        return sha256(source.getBytes(StandardCharsets.UTF_8));
    }

    /**
     * 判断已有提交是否仍应复用。
     *
     * <p>执行中的任务始终复用；上传中或待启动任务在超时前复用；
     * 已结束任务仅在终态后的短去重窗口内复用。</p>
     *
     * @param batch 已存在批次
     * @param deduplicationCutoff 终态去重窗口起点
     * @param incompleteCutoff 未完成提交超时起点
     * @return 是否复用原任务
     */
    private boolean shouldReuseSubmission(DocumentImportBatch batch,
                                          Date deduplicationCutoff,
                                          Date incompleteCutoff) {
        if (DocumentImportBatchStatus.CANCELLED.name().equals(batch.getStatus())) {
            return false;
        }
        if (DocumentImportBatchStatus.UPLOADING.name().equals(batch.getStatus())
            || DocumentImportBatchStatus.READY.name().equals(batch.getStatus())) {
            Date lastProgressAt = batch.getModified() == null
                ? batch.getCreated()
                : batch.getModified();
            return lastProgressAt == null
                || !lastProgressAt.before(incompleteCutoff);
        }
        if (DocumentImportBatchStatus.RUNNING.name().equals(batch.getStatus())) {
            return true;
        }
        Date finishedAt = batch.getFinishedAt() != null
            ? batch.getFinishedAt()
            : batch.getModified();
        if (finishedAt == null) {
            finishedAt = batch.getCreated();
        }
        return finishedAt == null
            || !finishedAt.before(deduplicationCutoff);
    }

    /**
     * 原子取消超时的未完成提交并释放已过期指纹。
     *
     * @param batch 待释放批次
     * @param caller 调用者上下文
     * @param submissionFingerprint 服务端提交指纹
     * @param deduplicationCutoff 终态去重窗口起点
     * @param incompleteCutoff 未完成提交超时起点
     * @return 是否已安全释放指纹
     */
    private boolean releaseExpiredSubmission(
        DocumentImportBatch batch,
        ImportCallerContext caller,
        String submissionFingerprint,
        Date deduplicationCutoff,
        Date incompleteCutoff) {
        if (DocumentImportBatchStatus.UPLOADING.name().equals(batch.getStatus())
            || DocumentImportBatchStatus.READY.name().equals(batch.getStatus())) {
            boolean cancelled = batchAppService.cancelStaleBatch(
                batch.getKnowledgeId(),
                batch.getId(),
                caller,
                incompleteCutoff
            );
            if (!cancelled) {
                return false;
            }
        }
        return batchMapper.releaseSubmissionFingerprint(
            batch.getId(),
            submissionFingerprint,
            deduplicationCutoff,
            new Date()
        ) > 0;
    }

    /**
     * 在并发状态变化后返回当前任务，或给出明确冲突。
     *
     * @param caller 调用者上下文
     * @param submissionFingerprint 服务端提交指纹
     * @param requestDigest 请求摘要
     * @return 当前有效任务
     */
    private PublicDocumentImportDtos.SubmitResponse
        resolveCurrentSubmissionOrConflict(
            ImportCallerContext caller,
            String submissionFingerprint,
            String requestDigest) {
        DocumentImportBatch current =
            findBySubmissionFingerprint(caller, submissionFingerprint);
        if (current != null
            && !DocumentImportBatchStatus.CANCELLED.name().equals(
                current.getStatus())) {
            return resolveExistingSubmission(current, requestDigest);
        }
        throw new BusinessException(
            409,
            40902,
            "相同导入请求状态正在变化，请稍后重试"
        );
    }

    /**
     * 按调用者和服务端提交指纹查询批次。
     *
     * @param caller 调用者上下文
     * @param submissionFingerprint 服务端提交指纹
     * @return 已存在批次
     */
    private DocumentImportBatch findBySubmissionFingerprint(
        ImportCallerContext caller,
        String submissionFingerprint) {
        return batchService.getOne(
            QueryWrapper.create()
                .eq(DocumentImportBatch::getCallerType, caller.getCallerType().name())
                .eq(DocumentImportBatch::getCallerId, caller.getCallerId())
                .eq(
                    DocumentImportBatch::getIdempotencyKeyHash,
                    submissionFingerprint
                )
                .limit(1)
        );
    }

    /**
     * 提交失败时取消未启动批次、清理对象并释放提交指纹。
     *
     * @param knowledgeId 知识库 ID
     * @param batchId 批次 ID
     * @param caller 调用者上下文
     * @param submissionFingerprint 服务端提交指纹
     */
    private void compensateFailedSubmission(BigInteger knowledgeId,
                                            BigInteger batchId,
                                            ImportCallerContext caller,
                                            String submissionFingerprint) {
        try {
            batchAppService.cancelBatch(knowledgeId, batchId, caller);
        } catch (RuntimeException compensationError) {
            LOG.error("Public API 批量提交失败后取消批次异常: batchId={}",
                batchId, compensationError);
        }
        batchMapper.releaseSubmissionFingerprint(
            batchId,
            submissionFingerprint,
            new Date(),
            new Date()
        );
    }

    /**
     * 持久化已校验的内容哈希。
     *
     * @param itemId 批次项 ID
     * @param contentHash 内容 SHA-256
     */
    private void persistContentHash(BigInteger itemId, String contentHash) {
        DocumentImportBatchItem update = new DocumentImportBatchItem();
        update.setId(itemId);
        update.setContentSha256(contentHash);
        update.setModified(new Date());
        // 使用默认忽略 null 的更新语义，避免部分实体将其余非空列覆盖为 null。
        if (!itemService.updateById(update)) {
            throw new BusinessException(500, 50022, "保存文件内容摘要失败");
        }
    }

    /**
     * 转换提交响应。
     *
     * @param batch 批次
     * @return 提交响应
     */
    private PublicDocumentImportDtos.SubmitResponse toSubmitResponse(
        DocumentImportBatch batch) {
        PublicDocumentImportDtos.SubmitResponse response =
            new PublicDocumentImportDtos.SubmitResponse();
        response.setTaskId(batch.getId());
        response.setStatus(mapBatchStatus(batch));
        response.setTotalCount(valueOrZero(batch.getTotalCount()));
        response.setTotalBytes(batch.getTotalBytes() == null ? 0L : batch.getTotalBytes());
        response.setCreatedAt(batch.getCreated());
        return response;
    }

    /**
     * 转换状态响应。
     *
     * @param batch 批次
     * @param itemPage 文件分页
     * @return 状态响应
     */
    private PublicDocumentImportDtos.StatusResponse toStatusResponse(
        DocumentImportBatch batch,
        Page<DocumentImportBatchItem> itemPage) {
        DocumentImportBatchDtos.StatusResponse internal =
            batchTracker.toStatusResponse(batch);
        PublicDocumentImportDtos.StatusResponse response =
            new PublicDocumentImportDtos.StatusResponse();
        response.setTaskId(batch.getId());
        response.setKnowledgeId(batch.getKnowledgeId());
        response.setStatus(mapBatchStatus(batch));
        response.setProgressPercent(internal.getProgressPercent());
        PublicDocumentImportDtos.Counts counts = new PublicDocumentImportDtos.Counts();
        counts.setTotal(internal.getTotalCount());
        counts.setCompleted(internal.getCompletedCount());
        counts.setProcessing(internal.getProcessingCount());
        counts.setPending(internal.getPendingCount());
        counts.setFailed(internal.getFailedCount());
        counts.setSkipped(internal.getSkippedCount());
        counts.setRetryableFailed(internal.getFailedCount());
        response.setCounts(counts);
        response.setCanRetry(internal.getFailedCount() > 0
            && (DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name().equals(batch.getStatus())
            || DocumentImportBatchStatus.INTERRUPTED.name().equals(batch.getStatus())));
        PublicDocumentImportDtos.ItemPage items =
            new PublicDocumentImportDtos.ItemPage();
        items.setPageNumber(itemPage.getPageNumber());
        items.setPageSize(itemPage.getPageSize());
        items.setTotal(itemPage.getTotalRow());
        items.setRecords(itemPage.getRecords().stream().map(this::toItemRecord).toList());
        response.setItems(items);
        return response;
    }

    /**
     * 转换文件状态记录。
     *
     * @param item 批次项
     * @return Public 文件记录
     */
    private PublicDocumentImportDtos.ItemRecord toItemRecord(
        DocumentImportBatchItem item) {
        PublicDocumentImportDtos.ItemRecord record =
            new PublicDocumentImportDtos.ItemRecord();
        record.setFileKey(item.getClientFileKey());
        record.setRelativePath(item.getRelativePath());
        record.setDocumentId(item.getDocumentId());
        record.setStage(item.getStage());
        record.setStatus(item.getStatus());
        record.setAttemptCount(valueOrZero(item.getAttemptCount()));
        // 人工重试按实际失败状态选择，兼容字段不再受历史 retryable 标记影响。
        record.setRetryable(
            DocumentImportBatchItemStatus.FAILED.name().equals(item.getStatus())
        );
        if (StringUtil.hasText(item.getFailureCode())
            || StringUtil.hasText(item.getErrorSummary())) {
            PublicDocumentImportDtos.ItemError error =
                new PublicDocumentImportDtos.ItemError();
            error.setCode(StringUtil.hasText(item.getFailureCode())
                ? item.getFailureCode()
                : String.valueOf(item.getStage()).toUpperCase(Locale.ROOT) + "_FAILED");
            error.setMessage(StringUtil.hasText(item.getErrorSummary())
                ? item.getErrorSummary()
                : "文件处理失败");
            record.setError(error);
        }
        return record;
    }

    /**
     * 映射稳定 Public 批次状态。
     *
     * @param batch 批次
     * @return Public 状态
     */
    private String mapBatchStatus(DocumentImportBatch batch) {
        String status = batch.getStatus();
        if (DocumentImportBatchStatus.UPLOADING.name().equals(status)
            || DocumentImportBatchStatus.READY.name().equals(status)) {
            return "QUEUED";
        }
        if (DocumentImportBatchStatus.COMPLETED.name().equals(status)) {
            return "SUCCEEDED";
        }
        if (DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name().equals(status)
            && valueOrZero(batch.getFailedCount()) >= valueOrZero(batch.getTotalCount())
            && valueOrZero(batch.getCompletedCount()) == 0
            && valueOrZero(batch.getSkippedCount()) == 0) {
            return "FAILED";
        }
        return status;
    }

    /**
     * 规范化重复文件策略。
     *
     * @param value 原始策略
     * @return 规范策略
     */
    private String normalizeDuplicatePolicy(String value) {
        String normalized = StringUtil.hasText(value)
            ? value.trim().toUpperCase(Locale.ROOT)
            : "SKIP";
        if (!Set.of("SKIP", "OVERWRITE", "REIMPORT").contains(normalized)) {
            throw new BusinessException("duplicatePolicy 仅支持 SKIP、OVERWRITE、REIMPORT");
        }
        return normalized;
    }

    /**
     * 规范化文件夹相对路径。
     *
     * @param relativePath 相对路径
     * @param fileName 文件名
     * @return 规范路径
     */
    private String normalizeRelativePath(String relativePath, String fileName) {
        String normalized = StringUtil.hasText(relativePath)
            ? relativePath.replace('\\', '/')
            : fileName;
        if (normalized.length() > 1024 || normalized.startsWith("/")
            || normalized.endsWith("/") || normalized.contains("//")) {
            throw new BusinessException("文件相对路径无效");
        }
        String[] segments = normalized.split("/");
        if (segments.length > 64) {
            throw new BusinessException("文件相对路径层级过深");
        }
        for (String segment : segments) {
            if (!StringUtil.hasText(segment) || ".".equals(segment)
                || "..".equals(segment)) {
                throw new BusinessException("文件相对路径无效");
            }
        }
        return String.join("/", segments);
    }

    /**
     * 校验文件扩展名。
     *
     * @param fileName 文件名
     */
    private void assertSupportedExtension(String fileName) {
        if (!SUPPORTED_EXTENSIONS.contains(extension(fileName))) {
            throw new BusinessException(415, 41502, "暂不支持该文件格式");
        }
    }

    /**
     * 获取小写文件扩展名。
     *
     * @param fileName 文件名
     * @return 扩展名
     */
    private String extension(String fileName) {
        int dot = fileName == null ? -1 : fileName.lastIndexOf('.');
        return dot < 0
            ? ""
            : fileName.substring(dot + 1).toLowerCase(Locale.ROOT);
    }

    /**
     * 计算 SHA-256。
     *
     * @param bytes 输入字节
     * @return 十六进制摘要
     */
    private String sha256(byte[] bytes) {
        return toHex(newSha256Digest().digest(bytes));
    }

    /**
     * 创建 SHA-256 摘要器。
     *
     * @return 摘要器
     */
    private MessageDigest newSha256Digest() {
        try {
            return MessageDigest.getInstance("SHA-256");
        } catch (NoSuchAlgorithmException error) {
            throw new IllegalStateException("JVM 不支持 SHA-256", error);
        }
    }

    /**
     * 将字节转换为小写十六进制。
     *
     * @param bytes 输入字节
     * @return 十六进制字符串
     */
    private String toHex(byte[] bytes) {
        StringBuilder hex = new StringBuilder(bytes.length * 2);
        for (byte value : bytes) {
            hex.append(Character.forDigit((value >>> 4) & 0x0f, 16));
            hex.append(Character.forDigit(value & 0x0f, 16));
        }
        return hex.toString();
    }

    /**
     * 将可空整数转换为零。
     *
     * @param value 可空整数
     * @return 非空整数
     */
    private int valueOrZero(Integer value) {
        return value == null ? 0 : value;
    }

    /**
     * 已准备的提交信息。
     */
    private static final class PreparedSubmission {
        private final String requestDigest;
        private final List<String> contentHashes;
        private final List<DocumentImportBatchDtos.ManifestItem> files;

        /**
         * 创建准备结果。
         *
         * @param requestDigest 请求摘要
         * @param contentHashes 文件内容摘要
         * @param files 已写入服务端实测大小的内部文件清单
         */
        private PreparedSubmission(
            String requestDigest,
            List<String> contentHashes,
            List<DocumentImportBatchDtos.ManifestItem> files) {
            this.requestDigest = requestDigest;
            this.contentHashes = contentHashes;
            this.files = files;
        }
    }

    /**
     * 流式文件指纹。
     */
    private static final class FileFingerprint {
        private final long actualBytes;
        private final String sha256;
        private final byte[] prefix;

        /**
         * 创建文件指纹。
         *
         * @param actualBytes 实际字节数
         * @param sha256 内容摘要
         * @param prefix 签名前缀
         */
        private FileFingerprint(long actualBytes, String sha256, byte[] prefix) {
            this.actualBytes = actualBytes;
            this.sha256 = sha256;
            this.prefix = prefix;
        }
    }
}
