package tech.easyflow.agent.runtime.document;

import com.mybatisflex.core.query.QueryWrapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import tech.easyflow.agent.config.AgentDocumentProperties;
import tech.easyflow.agent.entity.AgentDocumentAttachment;
import tech.easyflow.agent.entity.AgentDocumentSnapshot;
import tech.easyflow.agent.mapper.AgentDocumentAttachmentMapper;
import tech.easyflow.agent.mapper.AgentDocumentSnapshotMapper;
import tech.easyflow.agent.runtime.media.AgentMediaObjectStorage;
import tech.easyflow.common.entity.LoginAccount;

import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Date;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

/**
 * Agent 文档上传、归属校验、消息绑定与删除服务。
 */
@Service
public class AgentDocumentService {

    /** 正式聊天模式。 */
    public static final String MODE_FORMAL = "FORMAL";
    /** 草稿试用模式。 */
    public static final String MODE_DRAFT = "DRAFT";

    private static final Logger LOG = LoggerFactory.getLogger(AgentDocumentService.class);
    private static final Set<String> SUPPORTED_EXTENSIONS = Set.of(
            "pdf", "doc", "docx", "ppt", "pptx", "xls", "xlsx", "txt", "md");
    private static final Set<String> OLE_EXTENSIONS = Set.of("doc", "ppt", "xls");
    private static final Set<String> OOXML_EXTENSIONS = Set.of("docx", "pptx", "xlsx");
    private static final Set<String> EXCEL_EXTENSIONS = Set.of("xls", "xlsx");
    private static final Set<String> TEXT_EXTENSIONS = Set.of("txt", "md");
    private static final byte[] OLE_SIGNATURE = {
            (byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0, (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
    };
    private static final Map<String, String> CANONICAL_MIME_TYPES = canonicalMimeTypes();

    private final AgentDocumentAttachmentMapper attachmentMapper;
    private final AgentDocumentSnapshotMapper snapshotMapper;
    private final AgentMediaObjectStorage objectStorage;
    private final AgentDocumentProperties properties;
    private final AgentDocumentReadTaskProducer taskProducer;

    /**
     * 创建文档附件服务。
     *
     * @param attachmentMapper 附件 Mapper
     * @param snapshotMapper 快照 Mapper
     * @param objectStorage 私有对象存储
     * @param properties 文档安全配置
     * @param taskProducer 读取任务生产者
     */
    public AgentDocumentService(AgentDocumentAttachmentMapper attachmentMapper,
                                AgentDocumentSnapshotMapper snapshotMapper,
                                AgentMediaObjectStorage objectStorage,
                                AgentDocumentProperties properties,
                                AgentDocumentReadTaskProducer taskProducer) {
        this.attachmentMapper = attachmentMapper;
        this.snapshotMapper = snapshotMapper;
        this.objectStorage = objectStorage;
        this.properties = properties;
        this.taskProducer = taskProducer;
    }

    /**
     * 流式上传文档并创建状态账本。
     *
     * @param file 文档文件
     * @param mode 聊天模式
     * @param agentId Agent ID
     * @param sessionId 会话 ID
     * @param requestedUploadId 客户端生成的幂等上传 ID
     * @param account 当前账号
     * @return 上传视图
     */
    public AgentDocumentUploadView upload(MultipartFile file,
                                          String mode,
                                          String agentId,
                                          String sessionId,
                                          String requestedUploadId,
                                          LoginAccount account) {
        ensureEnabled();
        Identity identity = identity(account);
        String safeMode = requireMode(mode);
        BigInteger safeAgentId = positiveId(agentId, "Agent ID 不能为空");
        String safeSessionId = requireText(sessionId, "会话 ID 不能为空");
        DocumentFile document = validateFile(file);
        String uploadId = StringUtils.hasText(requestedUploadId)
                ? requireUploadId(requestedUploadId) : opaqueId();
        AgentDocumentAttachment existing = findByUpload(uploadId);
        if (existing != null) {
            assertSameUpload(existing, identity, safeMode, safeAgentId, safeSessionId, document);
            return toView(existing);
        }
        String attachmentId = opaqueId();
        String objectKey = "documents/objects/%s/%s/%s/original.%s".formatted(
                identity.tenantId(), identity.userId(), attachmentId, document.extension());
        Date now = new Date();
        AgentDocumentAttachment attachment = new AgentDocumentAttachment();
        attachment.setAttachmentId(attachmentId);
        attachment.setUploadId(uploadId);
        attachment.setTenantId(identity.tenantId());
        attachment.setUserId(identity.userId());
        attachment.setAgentId(safeAgentId);
        attachment.setMode(safeMode);
        attachment.setSessionId(safeSessionId);
        attachment.setOriginalName(document.originalName());
        attachment.setExtension(document.extension());
        attachment.setMimeType(document.mimeType());
        attachment.setFileSize(document.size());
        attachment.setObjectKey(objectKey);
        attachment.setStatus(AgentDocumentStatus.UPLOADING.name());
        attachment.setExpiresAt(Date.from(Instant.now().plus(properties.getTempRetention())));
        attachment.setVersion(0L);
        attachment.setCreated(now);
        attachment.setCreatedBy(identity.userId());
        attachment.setModified(now);
        attachment.setModifiedBy(identity.userId());
        try {
            attachmentMapper.insert(attachment);
        } catch (RuntimeException error) {
            AgentDocumentAttachment raced = findByUpload(uploadId);
            if (raced == null) {
                throw error;
            }
            assertSameUpload(raced, identity, safeMode, safeAgentId, safeSessionId, document);
            return toView(raced);
        }

        boolean stored = false;
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            try (InputStream raw = file.getInputStream();
                 DigestInputStream input = new DigestInputStream(raw, digest)) {
                objectStorage.put(objectKey, input, document.size(), document.mimeType());
            }
            stored = true;
            String sha256 = HexFormat.of().formatHex(digest.digest());
            AgentDocumentAttachment update = new AgentDocumentAttachment();
            update.setFileSha256(sha256);
            update.setStatus(AgentDocumentStatus.UPLOADED.name());
            update.setVersion(1L);
            update.setModified(new Date());
            int updated = attachmentMapper.updateByQuery(update, QueryWrapper.create()
                    .eq("id", attachment.getId())
                    .eq("status", AgentDocumentStatus.UPLOADING.name())
                    .eq("version", 0L));
            if (updated != 1) {
                throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "文档上传状态提交失败");
            }
            attachment.setFileSha256(sha256);
            attachment.setStatus(update.getStatus());
            attachment.setVersion(1L);
        } catch (ResponseStatusException error) {
            if (!stored) {
                markUploadFailed(attachment, "DOCUMENT_STORAGE_FAILED", error.getReason());
                deleteObjectQuietly(objectKey);
            }
            throw error;
        } catch (NoSuchAlgorithmException | IOException error) {
            markUploadFailed(attachment, "DOCUMENT_STORAGE_FAILED", "文档上传失败");
            deleteObjectQuietly(objectKey);
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "文档上传失败", error);
        } catch (RuntimeException error) {
            if (!stored) {
                markUploadFailed(attachment, "DOCUMENT_STORAGE_FAILED", "文档上传失败");
                deleteObjectQuietly(objectKey);
            }
            throw error;
        }
        dispatchReadQuietly(attachmentId);
        return toView(attachment);
    }

    /**
     * 获取当前用户一个上传文档的最新状态。
     *
     * @param uploadId 上传 ID
     * @param account 当前账号
     * @return 上传视图
     */
    public AgentDocumentUploadView status(String uploadId, LoginAccount account) {
        AgentDocumentAttachment attachment = requireOwnedUpload(uploadId, identity(account));
        return toView(attachment);
    }

    /**
     * 重试一次明确失败的本地读取。
     *
     * @param uploadId 上传 ID
     * @param account 当前账号
     * @return 重试后的状态
     */
    public AgentDocumentUploadView retry(String uploadId, LoginAccount account) {
        AgentDocumentAttachment attachment = requireOwnedUpload(uploadId, identity(account));
        if (!AgentDocumentStatus.READ_FAILED.name().equals(attachment.getStatus())) {
            throw badRequest("当前文档状态无需重试");
        }
        AgentDocumentAttachment update = new AgentDocumentAttachment();
        update.setStatus(AgentDocumentStatus.UPLOADED.name());
        update.setErrorCode(null);
        update.setErrorMessage(null);
        update.setVersion(attachment.getVersion() + 1L);
        update.setModified(new Date());
        int updated = attachmentMapper.updateByQuery(update, QueryWrapper.create()
                .eq("id", attachment.getId())
                .eq("status", AgentDocumentStatus.READ_FAILED.name())
                .eq("version", attachment.getVersion()));
        if (updated != 1) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "文档状态已变化，请刷新后重试");
        }
        attachment.setStatus(update.getStatus());
        attachment.setErrorCode(null);
        attachment.setErrorMessage(null);
        attachment.setVersion(update.getVersion());
        dispatchReadQuietly(attachment.getAttachmentId());
        return toView(attachment);
    }

    /**
     * 校验本轮文档归属、状态、数量和总大小。
     *
     * @param uploadIds 上传 ID
     * @param mode 聊天模式
     * @param agentId Agent ID
     * @param sessionId 会话 ID
     * @param account 当前账号
     * @return 可发送附件
     */
    public List<AgentDocumentAttachment> requireUploads(List<String> uploadIds,
                                                        String mode,
                                                        String agentId,
                                                        String sessionId,
                                                        LoginAccount account) {
        List<String> ids = normalizedIds(uploadIds);
        if (ids.size() > properties.getMaxDocumentsPerTurn()) {
            throw badRequest("每次最多发送 " + properties.getMaxDocumentsPerTurn() + " 份文档");
        }
        if (ids.isEmpty()) {
            return List.of();
        }
        Identity owner = identity(account);
        String safeMode = requireMode(mode);
        BigInteger safeAgentId = positiveId(agentId, "Agent ID 不能为空");
        String safeSessionId = requireText(sessionId, "会话 ID 不能为空");
        long totalBytes = 0;
        List<AgentDocumentAttachment> result = new ArrayList<>(ids.size());
        for (String uploadId : ids) {
            AgentDocumentAttachment attachment = requireOwnedUpload(uploadId, owner);
            assertScope(attachment, safeMode, safeAgentId, safeSessionId);
            if (attachment.getExpiresAt() != null && attachment.getExpiresAt().before(new Date())
                    && !AgentDocumentStatus.BOUND.name().equals(attachment.getStatus())) {
                markDeletePending(attachment);
                throw new ResponseStatusException(HttpStatus.GONE, "文档已过期，请重新上传");
            }
            if (!AgentDocumentStatus.READY.name().equals(attachment.getStatus())) {
                if (AgentDocumentStatus.READ_FAILED.name().equals(attachment.getStatus())) {
                    throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY,
                            StringUtils.hasText(attachment.getErrorMessage())
                                    ? attachment.getErrorMessage() : "文档读取失败");
                }
                throw new ResponseStatusException(HttpStatus.CONFLICT, "文档仍在读取，请稍后发送");
            }
            totalBytes += attachment.getFileSize() == null ? 0 : attachment.getFileSize();
            if (totalBytes > properties.getMaxTotalBytesPerTurn().toBytes()) {
                throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE,
                        "本轮文档总大小不能超过 "
                                + properties.getMaxTotalBytesPerTurn().toMegabytes() + " MiB");
            }
            result.add(attachment);
        }
        return result;
    }

    /**
     * 校验并取得草稿中仍可展示的文档，允许读取中和可重试失败状态。
     *
     * @param uploadIds 上传 ID
     * @param mode 聊天模式
     * @param agentId Agent ID
     * @param sessionId 会话 ID
     * @param account 当前账号
     * @return 草稿文档
     */
    public List<AgentDocumentAttachment> draftUploads(List<String> uploadIds,
                                                      String mode,
                                                      String agentId,
                                                      String sessionId,
                                                      LoginAccount account) {
        List<String> ids = normalizedIds(uploadIds);
        if (ids.size() > properties.getMaxDocumentsPerTurn()) {
            throw badRequest("每次最多添加 " + properties.getMaxDocumentsPerTurn() + " 份文档");
        }
        Identity owner = identity(account);
        String safeMode = requireMode(mode);
        BigInteger safeAgentId = positiveId(agentId, "Agent ID 不能为空");
        String safeSessionId = requireText(sessionId, "会话 ID 不能为空");
        List<AgentDocumentAttachment> result = new ArrayList<>(ids.size());
        for (String uploadId : ids) {
            AgentDocumentAttachment attachment = findByUpload(uploadId);
            if (attachment == null || AgentDocumentStatus.DELETED.name().equals(attachment.getStatus())
                    || AgentDocumentStatus.DELETE_PENDING.name().equals(attachment.getStatus())
                    || attachment.getExpiresAt() != null && attachment.getExpiresAt().before(new Date())) {
                continue;
            }
            assertOwner(attachment, owner);
            assertScope(attachment, safeMode, safeAgentId, safeSessionId);
            result.add(attachment);
        }
        return result;
    }

    /**
     * 为草稿试用续期并转换为运行时文档。
     *
     * @param attachments 已校验附件
     * @return 运行时文档
     */
    public List<AgentBoundDocument> bindDraft(List<AgentDocumentAttachment> attachments) {
        if (attachments == null || attachments.isEmpty()) {
            return List.of();
        }
        Date expiresAt = Date.from(Instant.now().plus(properties.getTempRetention()));
        List<AgentBoundDocument> result = new ArrayList<>(attachments.size());
        for (AgentDocumentAttachment attachment : attachments) {
            AgentDocumentAttachment update = new AgentDocumentAttachment();
            update.setExpiresAt(expiresAt);
            update.setModified(new Date());
            attachmentMapper.updateByQuery(update, QueryWrapper.create()
                    .eq("id", attachment.getId())
                    .ne("status", AgentDocumentStatus.DELETED.name())
                    .ne("status", AgentDocumentStatus.DELETE_PENDING.name()));
            attachment.setExpiresAt(expiresAt);
            result.add(toBound(attachment));
        }
        return result;
    }

    /**
     * 将已校验附件转换为只读运行时描述，不改变附件状态。
     *
     * @param attachments 已校验附件
     * @return 运行时文档
     */
    public List<AgentBoundDocument> describe(List<AgentDocumentAttachment> attachments) {
        if (attachments == null || attachments.isEmpty()) {
            return List.of();
        }
        return attachments.stream().map(this::toBound).toList();
    }

    /**
     * 将正式聊天文档条件更新到 BINDING。
     *
     * @param attachments 已校验附件
     * @param messageId 稳定消息 ID
     * @return 运行时文档
     */
    public List<AgentBoundDocument> beginFormalBinding(List<AgentDocumentAttachment> attachments, String messageId) {
        if (attachments == null || attachments.isEmpty()) {
            return List.of();
        }
        String safeMessageId = requireText(messageId, "聊天消息标识无效");
        List<AgentBoundDocument> result = new ArrayList<>(attachments.size());
        for (AgentDocumentAttachment attachment : attachments) {
            if (safeMessageId.equals(attachment.getMessageId())
                    && (AgentDocumentStatus.BINDING.name().equals(attachment.getStatus())
                    || AgentDocumentStatus.BOUND.name().equals(attachment.getStatus()))) {
                result.add(toBound(attachment));
                continue;
            }
            AgentDocumentAttachment update = new AgentDocumentAttachment();
            update.setMessageId(safeMessageId);
            update.setStatus(AgentDocumentStatus.BINDING.name());
            update.setExpiresAt(null);
            update.setVersion(attachment.getVersion() + 1L);
            update.setModified(new Date());
            int updated = attachmentMapper.updateByQuery(update, QueryWrapper.create()
                    .eq("id", attachment.getId())
                    .eq("status", AgentDocumentStatus.READY.name())
                    .eq("version", attachment.getVersion()));
            if (updated != 1) {
                throw new ResponseStatusException(HttpStatus.CONFLICT, "文档已被其他消息使用");
            }
            attachment.setMessageId(safeMessageId);
            attachment.setStatus(update.getStatus());
            attachment.setExpiresAt(null);
            attachment.setVersion(update.getVersion());
            result.add(toBound(attachment));
        }
        return result;
    }

    /**
     * 在用户消息写入后完成附件绑定。
     *
     * @param attachments 进入绑定态的附件
     * @param messageId 消息 ID
     */
    public void completeFormalBinding(List<AgentDocumentAttachment> attachments, String messageId) {
        for (AgentDocumentAttachment attachment : attachments == null
                ? List.<AgentDocumentAttachment>of() : attachments) {
            AgentDocumentAttachment update = new AgentDocumentAttachment();
            update.setStatus(AgentDocumentStatus.BOUND.name());
            update.setVersion(attachment.getVersion() + 1L);
            update.setModified(new Date());
            int updated = attachmentMapper.updateByQuery(update, QueryWrapper.create()
                    .eq("id", attachment.getId())
                    .eq("status", AgentDocumentStatus.BINDING.name())
                    .eq("message_id", messageId)
                    .eq("version", attachment.getVersion()));
            if (updated != 1) {
                throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "文档消息绑定提交失败");
            }
            attachment.setStatus(update.getStatus());
            attachment.setVersion(update.getVersion());
        }
    }

    /**
     * 删除当前用户尚未绑定的上传文档。
     *
     * @param uploadId 上传 ID
     * @param account 当前账号
     */
    public void deleteUpload(String uploadId, LoginAccount account) {
        AgentDocumentAttachment attachment = findByUpload(uploadId);
        if (attachment == null || AgentDocumentStatus.DELETED.name().equals(attachment.getStatus())) {
            return;
        }
        assertOwner(attachment, identity(account));
        if (AgentDocumentStatus.BOUND.name().equals(attachment.getStatus())
                || AgentDocumentStatus.BINDING.name().equals(attachment.getStatus())) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "已发送文档不能从输入框删除");
        }
        markDeletePending(attachment);
        deletePending(attachment);
    }

    /**
     * 删除指定草稿作用域内的上传文档。
     *
     * @param uploadIds 上传 ID
     * @param mode 聊天模式
     * @param agentId Agent ID
     * @param sessionId 会话 ID
     * @param account 当前账号
     */
    public void deleteUploadsForScope(List<String> uploadIds,
                                      String mode,
                                      String agentId,
                                      String sessionId,
                                      LoginAccount account) {
        Identity owner = identity(account);
        String safeMode = requireMode(mode);
        BigInteger safeAgentId = positiveId(agentId, "Agent ID 不能为空");
        String safeSessionId = requireText(sessionId, "会话 ID 不能为空");
        for (String uploadId : normalizedIds(uploadIds)) {
            AgentDocumentAttachment attachment = findByUpload(uploadId);
            if (attachment == null || AgentDocumentStatus.DELETED.name().equals(attachment.getStatus())) {
                continue;
            }
            assertOwner(attachment, owner);
            assertScope(attachment, safeMode, safeAgentId, safeSessionId);
            // 正式消息已接管附件生命周期，草稿清理只能移除尚未发送的上传。
            if (AgentDocumentStatus.BOUND.name().equals(attachment.getStatus())
                    || AgentDocumentStatus.BINDING.name().equals(attachment.getStatus())) {
                continue;
            }
            markDeletePending(attachment);
            deletePending(attachment);
        }
    }

    /**
     * 删除一个正式聊天会话的文档对象。
     *
     * @param sessionId 会话 ID
     * @param account 当前账号
     */
    public void deleteFormalSession(String sessionId, LoginAccount account) {
        Identity owner = identity(account);
        List<AgentDocumentAttachment> attachments = attachmentMapper.selectListByQuery(QueryWrapper.create()
                .eq("tenant_id", owner.tenantId())
                .eq("user_id", owner.userId())
                .eq("mode", MODE_FORMAL)
                .eq("session_id", requireText(sessionId, "会话 ID 不能为空"))
                .ne("status", AgentDocumentStatus.DELETED.name()));
        for (AgentDocumentAttachment attachment : attachments) {
            markDeletePending(attachment);
            deletePending(attachment);
        }
    }

    /**
     * 通过稳定引用读取当前用户有权访问的原始文档。
     *
     * @param reference 稳定附件引用
     * @param account 当前账号
     * @return 文档资源
     */
    public AgentDocumentResource load(String reference, LoginAccount account) {
        AgentDocumentAttachment attachment = requireOwnedReference(reference, identity(account));
        if (AgentDocumentStatus.DELETE_PENDING.name().equals(attachment.getStatus())
                || AgentDocumentStatus.DELETED.name().equals(attachment.getStatus())) {
            throw new ResponseStatusException(HttpStatus.GONE, "历史附件已失效");
        }
        return new AgentDocumentResource(attachment.getOriginalName(), attachment.getMimeType(),
                attachment.getFileSize(), objectStorage.openStream(attachment.getObjectKey()));
    }

    /**
     * 根据稳定附件引用获取元数据并校验当前用户。
     *
     * @param reference 稳定附件引用
     * @param account 当前账号
     * @return 附件元数据
     */
    public AgentDocumentAttachment requireReference(String reference, LoginAccount account) {
        return requireOwnedReference(reference, identity(account));
    }

    /**
     * 把待删除附件的原文件和全部快照幂等删除。
     *
     * @param attachment 待删除附件
     */
    public void deletePending(AgentDocumentAttachment attachment) {
        if (attachment == null || AgentDocumentStatus.DELETED.name().equals(attachment.getStatus())) {
            return;
        }
        objectStorage.delete(attachment.getObjectKey());
        List<AgentDocumentSnapshot> snapshots = snapshotMapper.selectListByQuery(QueryWrapper.create()
                .eq("attachment_id", attachment.getAttachmentId()));
        for (AgentDocumentSnapshot snapshot : snapshots) {
            objectStorage.delete(snapshot.getSnapshotObjectKey());
        }
        AgentDocumentAttachment update = new AgentDocumentAttachment();
        update.setStatus(AgentDocumentStatus.DELETED.name());
        update.setModified(new Date());
        attachmentMapper.updateByQuery(update, QueryWrapper.create()
                .eq("id", attachment.getId())
                .eq("status", AgentDocumentStatus.DELETE_PENDING.name()));
        attachment.setStatus(AgentDocumentStatus.DELETED.name());
    }

    /**
     * 把附件条件更新为待删除。
     *
     * @param attachment 附件
     */
    public void markDeletePending(AgentDocumentAttachment attachment) {
        if (attachment == null || AgentDocumentStatus.DELETE_PENDING.name().equals(attachment.getStatus())
                || AgentDocumentStatus.DELETED.name().equals(attachment.getStatus())) {
            return;
        }
        AgentDocumentAttachment update = new AgentDocumentAttachment();
        update.setStatus(AgentDocumentStatus.DELETE_PENDING.name());
        update.setModified(new Date());
        int updated = attachmentMapper.updateByQuery(update, QueryWrapper.create()
                .eq("id", attachment.getId())
                .ne("status", AgentDocumentStatus.DELETED.name()));
        if (updated > 0) {
            attachment.setStatus(update.getStatus());
        }
    }

    /**
     * 将数据库附件转换为安全展示视图。
     *
     * @param attachment 附件
     * @return 展示视图
     */
    public AgentDocumentUploadView toView(AgentDocumentAttachment attachment) {
        AgentDocumentUploadView view = new AgentDocumentUploadView();
        view.setUploadId(attachment.getUploadId());
        view.setAttachmentRef(reference(attachment.getAttachmentId()));
        view.setName(attachment.getOriginalName());
        view.setMimeType(attachment.getMimeType());
        view.setSize(attachment.getFileSize() == null ? 0 : attachment.getFileSize());
        view.setStatus(displayStatus(attachment.getStatus()));
        view.setErrorCode(attachment.getErrorCode());
        view.setErrorMessage(attachment.getErrorMessage());
        view.setExpiresAt(attachment.getExpiresAt() == null ? null : attachment.getExpiresAt().toInstant());
        view.setReadSnapshotId(attachment.getCurrentSnapshotId());
        view.setDownloadUrl("/api/v1/agent/media/document/content?reference="
                + reference(attachment.getAttachmentId()));
        return view;
    }

    private AgentBoundDocument toBound(AgentDocumentAttachment attachment) {
        return new AgentBoundDocument(attachment.getUploadId(), reference(attachment.getAttachmentId()),
                attachment.getCurrentSnapshotId(), attachment.getOriginalName(),
                attachment.getMimeType(), attachment.getFileSize());
    }

    private AgentDocumentAttachment requireOwnedUpload(String uploadId, Identity owner) {
        AgentDocumentAttachment attachment = findByUpload(requireText(uploadId, "文档上传 ID 不能为空"));
        if (attachment == null) {
            throw new ResponseStatusException(HttpStatus.GONE, "文档已过期，请重新上传");
        }
        assertOwner(attachment, owner);
        return attachment;
    }

    private AgentDocumentAttachment requireOwnedReference(String reference, Identity owner) {
        if (reference == null || !reference.matches("document:[a-f0-9]{32}")) {
            throw badRequest("文档引用无效");
        }
        AgentDocumentAttachment attachment = attachmentMapper.selectOneByQuery(QueryWrapper.create()
                .eq("attachment_id", reference.substring("document:".length()))
                .limit(1));
        if (attachment == null) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "文档不存在");
        }
        assertOwner(attachment, owner);
        return attachment;
    }

    private AgentDocumentAttachment findByUpload(String uploadId) {
        return attachmentMapper.selectOneByQuery(QueryWrapper.create()
                .eq("upload_id", uploadId)
                .limit(1));
    }

    private String requireUploadId(String value) {
        String uploadId = requireText(value, "文档上传 ID 不能为空");
        if (!uploadId.matches("[a-zA-Z0-9_-]{16,64}")) {
            throw badRequest("文档上传 ID 无效");
        }
        return uploadId;
    }

    private void assertSameUpload(AgentDocumentAttachment attachment,
                                  Identity owner,
                                  String mode,
                                  BigInteger agentId,
                                  String sessionId,
                                  DocumentFile document) {
        assertOwner(attachment, owner);
        assertScope(attachment, mode, agentId, sessionId);
        if (!document.originalName().equals(attachment.getOriginalName())
                || !document.extension().equals(attachment.getExtension())
                || document.size() != (attachment.getFileSize() == null ? 0L : attachment.getFileSize())) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "文档上传 ID 已用于其他文件");
        }
    }

    private void assertOwner(AgentDocumentAttachment attachment, Identity owner) {
        if (!owner.tenantId().equals(attachment.getTenantId())
                || !owner.userId().equals(attachment.getUserId())) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该文档");
        }
    }

    private void assertScope(AgentDocumentAttachment attachment,
                             String mode,
                             BigInteger agentId,
                             String sessionId) {
        if (!mode.equals(attachment.getMode())
                || !agentId.equals(attachment.getAgentId())
                || !sessionId.equals(attachment.getSessionId())) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN, "文档不属于当前聊天会话");
        }
    }

    private DocumentFile validateFile(MultipartFile file) {
        if (file == null || file.isEmpty() || file.getSize() <= 0) {
            throw badRequest("请选择非空文档");
        }
        String originalName = safeOriginalName(file.getOriginalFilename());
        String extension = extension(originalName);
        if (!SUPPORTED_EXTENSIONS.contains(extension)) {
            throw new ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "不支持该文档类型");
        }
        long limit = limitFor(extension);
        if (file.getSize() > limit) {
            throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE,
                    "该类型文档不能超过 " + limit / (1024 * 1024) + " MiB");
        }
        validateSignature(file, extension);
        String mimeType = normalizeMime(file.getContentType(), extension);
        return new DocumentFile(originalName, extension, mimeType, file.getSize());
    }

    private void validateSignature(MultipartFile file, String extension) {
        try (InputStream input = file.getInputStream()) {
            byte[] prefix = input.readNBytes(8);
            boolean valid;
            if ("pdf".equals(extension)) {
                valid = startsWith(prefix, "%PDF-".getBytes(StandardCharsets.US_ASCII));
            } else if (OLE_EXTENSIONS.contains(extension)) {
                valid = startsWith(prefix, OLE_SIGNATURE);
            } else if (OOXML_EXTENSIONS.contains(extension)) {
                valid = prefix.length >= 4 && prefix[0] == 'P' && prefix[1] == 'K'
                        && ((prefix[2] == 3 && prefix[3] == 4)
                        || (prefix[2] == 5 && prefix[3] == 6)
                        || (prefix[2] == 7 && prefix[3] == 8));
            } else {
                valid = textSignature(prefix);
            }
            if (!valid) {
                throw new ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE,
                        "文件内容与扩展名不一致");
            }
        } catch (ResponseStatusException error) {
            throw error;
        } catch (IOException error) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "文档读取失败", error);
        }
    }

    private boolean textSignature(byte[] prefix) {
        if (prefix.length >= 2 && ((prefix[0] & 0xff) == 0xff && (prefix[1] & 0xff) == 0xfe
                || (prefix[0] & 0xff) == 0xfe && (prefix[1] & 0xff) == 0xff)) {
            return true;
        }
        for (byte value : prefix) {
            if (value == 0) {
                return false;
            }
        }
        return true;
    }

    private boolean startsWith(byte[] actual, byte[] expected) {
        if (actual.length < expected.length) {
            return false;
        }
        for (int index = 0; index < expected.length; index++) {
            if (actual[index] != expected[index]) {
                return false;
            }
        }
        return true;
    }

    private long limitFor(String extension) {
        if (EXCEL_EXTENSIONS.contains(extension)) {
            return properties.getLimits().getExcelMaxBytes().toBytes();
        }
        if (TEXT_EXTENSIONS.contains(extension)) {
            return properties.getLimits().getTextMaxBytes().toBytes();
        }
        return properties.getLimits().getOfficeMaxBytes().toBytes();
    }

    /**
     * 将客户端声明的 MIME 规范为服务端可信类型。
     *
     * @param declared 客户端声明 MIME
     * @param extension 已校验扩展名
     * @return 服务端规范 MIME
     */
    private String normalizeMime(String declared, String extension) {
        String normalized = declared == null ? "" : declared.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
        String canonical = CANONICAL_MIME_TYPES.get(extension);
        if (!StringUtils.hasText(canonical)) {
            throw new ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "不支持该文档类型");
        }
        // 浏览器 MIME 由系统注册表或桌面 MIME 数据库决定，只用于诊断，最终类型由扩展名和文件内容校验确定。
        if (StringUtils.hasText(normalized)
                && !"application/octet-stream".equals(normalized)
                && !canonical.equals(normalized)) {
            LOG.info("Normalize Agent document MIME alias: extension={}, declaredMime={}, canonicalMime={}",
                    extension, normalized, canonical);
        }
        return canonical;
    }

    private String safeOriginalName(String value) {
        String name = StringUtils.hasText(value) ? value.replace('\\', '/').trim() : "document";
        int slash = name.lastIndexOf('/');
        if (slash >= 0) {
            name = name.substring(slash + 1);
        }
        name = name.replaceAll("[\\r\\n\\t\\u0000]", " ").trim();
        if (!StringUtils.hasText(name) || name.equals(".") || name.equals("..")) {
            throw badRequest("文档文件名无效");
        }
        return name.length() > 255 ? name.substring(name.length() - 255) : name;
    }

    private String extension(String fileName) {
        int dot = fileName.lastIndexOf('.');
        return dot < 0 ? "" : fileName.substring(dot + 1).toLowerCase(Locale.ROOT);
    }

    private void markUploadFailed(AgentDocumentAttachment attachment, String code, String message) {
        AgentDocumentAttachment update = new AgentDocumentAttachment();
        update.setStatus(AgentDocumentStatus.READ_FAILED.name());
        update.setErrorCode(code);
        update.setErrorMessage(StringUtils.hasText(message) ? message : "文档上传失败");
        update.setModified(new Date());
        attachmentMapper.updateByQuery(update, QueryWrapper.create()
                .eq("id", attachment.getId())
                .eq("status", AgentDocumentStatus.UPLOADING.name()));
    }

    private void dispatchReadQuietly(String attachmentId) {
        try {
            taskProducer.send(attachmentId);
        } catch (RuntimeException error) {
            // UPLOADED 状态是事实来源，补偿扫描会重新投递。
            LOG.error("Agent 文档读取任务投递失败，等待补偿: attachmentId={}", attachmentId, error);
        }
    }

    private void deleteObjectQuietly(String objectKey) {
        try {
            objectStorage.delete(objectKey);
        } catch (RuntimeException error) {
            LOG.error("Agent 文档上传失败后的对象清理失败: objectKeyHash={}",
                    DigestUtils.md5DigestAsHex(objectKey.getBytes(StandardCharsets.UTF_8)), error);
        }
    }

    private List<String> normalizedIds(List<String> values) {
        return values == null ? List.of() : values.stream()
                .filter(StringUtils::hasText)
                .map(String::trim)
                .distinct()
                .toList();
    }

    private String displayStatus(String status) {
        if (AgentDocumentStatus.UPLOADING.name().equals(status)) {
            return "UPLOADING";
        }
        if (AgentDocumentStatus.UPLOADED.name().equals(status)
                || AgentDocumentStatus.READING.name().equals(status)) {
            return "READING";
        }
        if (AgentDocumentStatus.READ_FAILED.name().equals(status)) {
            return "FAILED";
        }
        if (AgentDocumentStatus.DELETE_PENDING.name().equals(status)
                || AgentDocumentStatus.DELETED.name().equals(status)) {
            return "EXPIRED";
        }
        return "READY";
    }

    private void ensureEnabled() {
        if (!properties.isEnabled()) {
            throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文档附件功能未启用");
        }
    }

    private Identity identity(LoginAccount account) {
        if (account == null || account.getId() == null || account.getTenantId() == null) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "当前登录状态失效");
        }
        return new Identity(account.getTenantId(), account.getId());
    }

    private BigInteger positiveId(String value, String message) {
        try {
            BigInteger result = new BigInteger(requireText(value, message));
            if (result.signum() <= 0) {
                throw new NumberFormatException();
            }
            return result;
        } catch (NumberFormatException error) {
            throw badRequest(message);
        }
    }

    private String requireMode(String mode) {
        String normalized = mode == null ? "" : mode.trim().toUpperCase(Locale.ROOT);
        if (!MODE_FORMAL.equals(normalized) && !MODE_DRAFT.equals(normalized)) {
            throw badRequest("聊天模式无效");
        }
        return normalized;
    }

    private String requireText(String value, String message) {
        if (!StringUtils.hasText(value) || value.length() > 200) {
            throw badRequest(message);
        }
        return value.trim();
    }

    private String opaqueId() {
        return UUID.randomUUID().toString().replace("-", "");
    }

    private String reference(String attachmentId) {
        return "document:" + attachmentId;
    }

    private ResponseStatusException badRequest(String message) {
        return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
    }

    /**
     * 创建受支持扩展名到服务端规范 MIME 的映射。
     *
     * @return 不可变规范 MIME 映射
     */
    private static Map<String, String> canonicalMimeTypes() {
        Map<String, String> values = new LinkedHashMap<>();
        values.put("pdf", "application/pdf");
        values.put("doc", "application/msword");
        values.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
        values.put("ppt", "application/vnd.ms-powerpoint");
        values.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
        values.put("xls", "application/vnd.ms-excel");
        values.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        values.put("txt", "text/plain");
        values.put("md", "text/markdown");
        return Map.copyOf(values);
    }

    private record Identity(BigInteger tenantId, BigInteger userId) {
    }

    private record DocumentFile(String originalName, String extension, String mimeType, long size) {
    }
}
