package tech.easyflow.ai.service.impl;

import com.alibaba.fastjson2.JSON;
import com.easyagents.core.document.Document;
import com.easyagents.core.model.rerank.RerankException;
import com.easyagents.core.model.rerank.RerankModel;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.SearchWrapper;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.rag.retrieval.*;
import com.easyagents.search.engine.service.DocumentSearcher;
import com.easyagents.search.engine.service.KeywordSearchRequest;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import tech.easyflow.ai.config.SearcherFactory;
import tech.easyflow.ai.documentimport.DocumentImportKeys;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.FaqItem;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.DocumentProcessStatus;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.mapper.DocumentChunkMapper;
import tech.easyflow.ai.mapper.DocumentCollectionMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
import tech.easyflow.ai.mapper.FaqItemMapper;
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
import tech.easyflow.ai.utils.CustomBeanUtils;
import tech.easyflow.ai.utils.RegexUtils;
import tech.easyflow.common.util.StringUtil;
import tech.easyflow.common.web.exceptions.BusinessException;

import javax.annotation.Resource;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.*;
import java.util.stream.Collectors;

import static tech.easyflow.ai.entity.DocumentCollection.*;

/**
 * 服务层实现。
 *
 * @author michael
 * @since 2024-08-23
 */
@Service
public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectionMapper, DocumentCollection> implements DocumentCollectionService {

    private static final Logger LOG = LoggerFactory.getLogger(DocumentCollectionServiceImpl.class);
    private static final int MAX_FAQ_IMAGES_IN_PROMPT = 3;
    private static final int INTERNAL_RECALL_MULTIPLIER = 5;
    private static final int MAX_INTERNAL_RECALL_LIMIT = 100;
    private static final int LOG_TEXT_MAX_LENGTH = 300;

    @Resource
    private ModelService llmService;

    @Autowired
    private SearcherFactory searcherFactory;

    @Autowired
    private DocumentChunkMapper documentChunkMapper;

    @Autowired
    private DocumentMapper documentMapper;

    @Autowired
    private FaqItemMapper faqItemMapper;

    @Override
    public List<Document> search(BigInteger id, String keyword) {
        KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
        request.setKnowledgeId(id);
        request.setQuery(keyword);
        request.setRetrievalMode(RetrievalMode.HYBRID);
        return search(request);
    }

    @Override
    public List<Document> search(KnowledgeRetrievalRequest request) {
        if (request == null || request.getKnowledgeId() == null) {
            throw new BusinessException("知识库ID不能为空");
        }
        String keyword = request.getQuery();
        if (StringUtil.noText(keyword)) {
            return Collections.emptyList();
        }

        RetrievalMode retrievalMode = request.getRetrievalMode() == null
            ? RetrievalMode.HYBRID
            : request.getRetrievalMode();
        DocumentCollection documentCollection = getById(request.getKnowledgeId());
        if (documentCollection == null) {
            throw new BusinessException("知识库不存在");
        }

        int docRecallMaxNum = resolveDocRecallMaxNum(request, documentCollection);
        int internalRecallLimit = resolveInternalRecallLimit(docRecallMaxNum);
        float minSimilarity = resolveMinSimilarity(request, documentCollection);
        LOG.info(
            "Knowledge retrieval started, callerType={}, callerId={}, knowledgeId={}, query={}, retrievalMode={}, limit={}, internalLimit={}, minSimilarity={}, faqCollection={}",
            request.getCallerType(),
            request.getCallerId(),
            request.getKnowledgeId(),
            keyword,
            retrievalMode,
            docRecallMaxNum,
            internalRecallLimit,
            minSimilarity,
            documentCollection.isFaqCollection()
        );

        RagQuery ragQuery = new RagQuery();
        ragQuery.setQuery(keyword);
        ragQuery.setRetrievalMode(retrievalMode);
        ragQuery.setTopK(internalRecallLimit);
        ragQuery.setMinScore((double) minSimilarity);

        RagRetrievalExecutor retrievalExecutor = new RagRetrievalExecutor(
            buildVectorRetriever(documentCollection, internalRecallLimit, retrievalMode == RetrievalMode.VECTOR ? minSimilarity : null),
            buildKeywordRetriever(documentCollection, internalRecallLimit),
            new RrfFusionStrategy()
        );

        RagRetrievalResult retrievalResult = retrievalExecutor.retrieve(ragQuery);
        LOG.info(
            "Knowledge retrieval raw hits, callerType={}, callerId={}, knowledgeId={}, query={}, hitCount={}, hits={}",
            request.getCallerType(),
            request.getCallerId(),
            request.getKnowledgeId(),
            keyword,
            retrievalResult == null || retrievalResult.getHits() == null ? 0 : retrievalResult.getHits().size(),
            summarizeRagHits(retrievalResult == null ? null : retrievalResult.getHits())
        );
        List<Document> searchDocuments = prepareSearchDocuments(
            documentCollection,
            toDocuments(retrievalResult.getHits())
        );
        LOG.info(
            "Knowledge retrieval prepared documents, callerType={}, callerId={}, knowledgeId={}, query={}, documentCount={}, documents={}",
            request.getCallerType(),
            request.getCallerId(),
            request.getKnowledgeId(),
            keyword,
            searchDocuments.size(),
            summarizeDocuments(searchDocuments)
        );
        if (searchDocuments.isEmpty()) {
            return Collections.emptyList();
        }

        RerankModel rerankModel = resolveRerankModel(documentCollection);
        boolean reranked = false;
        if (rerankModel != null) {
            try {
                RagRetrievalResult rerankResult = retrievalExecutor.rerank(keyword, toRagHits(searchDocuments), rerankModel, docRecallMaxNum);
                searchDocuments = prepareSearchDocuments(
                    documentCollection,
                    toDocuments(rerankResult.getHits())
                );
                reranked = true;
                LOG.info(
                    "Knowledge retrieval reranked documents, callerType={}, callerId={}, knowledgeId={}, query={}, documentCount={}, documents={}",
                    request.getCallerType(),
                    request.getCallerId(),
                    request.getKnowledgeId(),
                    keyword,
                    searchDocuments.size(),
                    summarizeDocuments(searchDocuments)
                );
            } catch (RerankException e) {
                LOG.warn("Rerank failed for collectionId={}, modelId={}, fallback to retrieved results. message={}",
                    documentCollection.getId(), documentCollection.getRerankModelId(), e.getMessage());
            }
        }

        RagScoreNormalizer.normalize(searchDocuments, retrievalMode, reranked);
        List<Document> formattedDocuments = formatDocuments(
            searchDocuments,
            shouldApplyMinSimilarityFilter(retrievalMode, reranked),
            minSimilarity,
            docRecallMaxNum
        );
        LOG.info(
            "Knowledge retrieval completed, callerType={}, callerId={}, knowledgeId={}, query={}, reranked={}, finalCount={}, documents={}",
            request.getCallerType(),
            request.getCallerId(),
            request.getKnowledgeId(),
            keyword,
            reranked,
            formattedDocuments.size(),
            summarizeDocuments(formattedDocuments)
        );
        return formattedDocuments;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public DocumentCollection getPublishedDetail(String idOrAlias) {
        return toPublishedView(getDetail(idOrAlias));
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public DocumentCollection getPublishedById(BigInteger id) {
        return toPublishedView(getById(id));
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public DocumentCollection toPublishedView(DocumentCollection collection) {
        if (collection == null) {
            return null;
        }
        Map<String, Object> snapshot = collection.getPublishedSnapshotJson();
        if (snapshot == null || snapshot.isEmpty()) {
            return collection;
        }
        DocumentCollection published = JSON.parseObject(JSON.toJSONString(snapshot), DocumentCollection.class);
        if (published == null) {
            return collection;
        }
        published.setId(collection.getId());
        published.setTenantId(collection.getTenantId());
        published.setCategoryId(collection.getCategoryId());
        published.setDeptId(collection.getDeptId());
        published.setCreated(collection.getCreated());
        published.setCreatedBy(collection.getCreatedBy());
        published.setModified(collection.getModified());
        published.setModifiedBy(collection.getModifiedBy());
        published.setPublishStatus(collection.getPublishStatus());
        published.setCurrentApprovalInstanceId(collection.getCurrentApprovalInstanceId());
        published.setPublishedSnapshotJson(collection.getPublishedSnapshotJson());
        published.setPublishedAt(collection.getPublishedAt());
        published.setPublishedBy(collection.getPublishedBy());
        if (published.getPublishStatus() == null) {
            published.setPublishStatus(PublishStatus.DRAFT.getCode());
        }
        return published;
    }

    private VectorRetriever buildVectorRetriever(DocumentCollection documentCollection,
                                                 int docRecallMaxNum,
                                                 Float minSimilarity) {
        return new VectorRetriever() {
            @Override
            public List<RagHit> retrieve(RagQuery query) {
                return adaptDocuments(searchVectorDocuments(documentCollection, query.getQuery(), docRecallMaxNum, minSimilarity), HitSource.VECTOR);
            }
        };
    }

    private KeywordRetriever buildKeywordRetriever(DocumentCollection documentCollection, int docRecallMaxNum) {
        return new KeywordRetriever() {
            @Override
            public List<RagHit> retrieve(RagQuery query) {
                return adaptDocuments(searchKeywordDocuments(documentCollection, query.getQuery(), docRecallMaxNum), HitSource.KEYWORD);
            }
        };
    }

    private List<Document> searchVectorDocuments(DocumentCollection documentCollection,
                                                 String keyword,
                                                 int docRecallMaxNum,
                                                 Float minSimilarity) {
        DocumentStore documentStore = documentCollection.toDocumentStore();
        if (documentStore == null) {
            throw new BusinessException("知识库没有配置向量库");
        }

        try {
            Model model = llmService.getModelInstance(documentCollection.getVectorEmbedModelId());
            if (model == null) {
                throw new BusinessException("知识库没有配置向量模型");
            }

            documentStore.setEmbeddingModel(model.toEmbeddingModel());
            SearchWrapper wrapper = new SearchWrapper();
            wrapper.setMaxResults(docRecallMaxNum);
            if (minSimilarity != null) {
                wrapper.setMinScore((double) minSimilarity);
            }
            wrapper.setText(keyword);

            StoreOptions options = StoreOptions.ofCollectionName(documentCollection.getVectorStoreCollection());
            options.setIndexName(documentCollection.getVectorStoreCollection());
            List<Document> documents = documentStore.search(wrapper, options);
            List<Document> result = documents == null ? Collections.<Document>emptyList() : documents;
            LOG.info(
                "Knowledge vector search completed, knowledgeId={}, collectionName={}, query={}, limit={}, minSimilarity={}, hitCount={}, hits={}",
                documentCollection.getId(),
                documentCollection.getVectorStoreCollection(),
                keyword,
                docRecallMaxNum,
                minSimilarity,
                result.size(),
                summarizeDocuments(result)
            );
            return result;
        } finally {
            DocumentStoreLifecycleSupport.closeQuietly(documentStore);
        }
    }

    private List<Document> searchKeywordDocuments(DocumentCollection documentCollection, String keyword, int docRecallMaxNum) {
        DocumentSearcher searcher = searcherFactory.getSearcher();
        if (searcher == null) {
            LOG.warn("Knowledge keyword search skipped because searcher is unavailable, knowledgeId={}, query={}",
                documentCollection == null ? null : documentCollection.getId(), keyword);
            return Collections.emptyList();
        }
        KeywordSearchRequest request = KeywordSearchRequest.of(keyword, docRecallMaxNum);
        request.setKnowledgeId(documentCollection == null || documentCollection.getId() == null
            ? null
            : documentCollection.getId().toString());
        List<Document> documents = searcher.searchDocuments(request);
        List<Document> result = documents == null ? Collections.<Document>emptyList() : documents;
        LOG.info(
            "Knowledge keyword search completed, knowledgeId={}, query={}, limit={}, hitCount={}, hits={}",
            request.getKnowledgeId(),
            keyword,
            docRecallMaxNum,
            result.size(),
            summarizeDocuments(result)
        );
        return result;
    }

    private List<RagHit> adaptDocuments(List<Document> documents, HitSource hitSource) {
        List<RagHit> hits = new ArrayList<>();
        if (documents == null) {
            return hits;
        }
        for (Document document : documents) {
            RagHit hit = RagHit.fromDocument(document, hitSource);
            if (hit != null) {
                hits.add(hit);
            }
        }
        return hits;
    }

    private List<Document> toDocuments(List<RagHit> hits) {
        List<Document> documents = new ArrayList<>();
        if (hits == null) {
            return documents;
        }
        for (RagHit hit : hits) {
            if (hit == null) {
                continue;
            }
            documents.add(hit.toDocument());
        }
        return documents;
    }

    private List<RagHit> toRagHits(List<Document> documents) {
        List<RagHit> hits = new ArrayList<>();
        if (documents == null) {
            return hits;
        }
        for (Document document : documents) {
            RagHit hit = RagHit.fromDocument(document);
            if (hit != null) {
                hits.add(hit);
            }
        }
        return hits;
    }

    private RerankModel resolveRerankModel(DocumentCollection documentCollection) {
        boolean rerankEnable = Boolean.TRUE.equals(documentCollection.getOptionsByKey(KEY_RERANK_ENABLE));
        if (!rerankEnable || documentCollection.getRerankModelId() == null) {
            return null;
        }
        Model modelRerank = llmService.getModelInstance(documentCollection.getRerankModelId());
        if (modelRerank == null) {
            return null;
        }
        return modelRerank.toRerankModel();
    }

    private boolean shouldApplyMinSimilarityFilter(RetrievalMode retrievalMode, boolean reranked) {
        return !reranked && retrievalMode == RetrievalMode.VECTOR;
    }

    /**
     * 解析本次查询使用的召回上限，优先采用请求参数，其次回退到知识库默认配置。
     *
     * @param request 查询请求
     * @param documentCollection 知识库实体
     * @return 规范化后的召回上限
     */
    private int resolveDocRecallMaxNum(KnowledgeRetrievalRequest request, DocumentCollection documentCollection) {
        Integer requestLimit = request == null ? null : request.getLimit();
        if (requestLimit != null) {
            return Math.max(requestLimit, 1);
        }
        return readIntegerOption(documentCollection, KEY_DOC_RECALL_MAX_NUM, 5);
    }

    /**
     * 解析本次查询使用的最小相似度阈值，优先采用请求参数，其次回退到知识库默认配置。
     *
     * @param request 查询请求
     * @param documentCollection 知识库实体
     * @return 规范化后的最小相似度
     */
    private float resolveMinSimilarity(KnowledgeRetrievalRequest request, DocumentCollection documentCollection) {
        Double requestMinSimilarity = request == null ? null : request.getMinSimilarity();
        if (requestMinSimilarity != null) {
            double normalizedValue = Math.max(0D, Math.min(requestMinSimilarity, 1D));
            return (float) normalizedValue;
        }
        return readFloatOption(documentCollection, KEY_SIMILARITY_THRESHOLD, 0.6F);
    }

    /**
     * 计算内部候选召回上限。
     *
     * @param docRecallMaxNum 业务召回上限
     * @return 内部候选集上限
     */
    private int resolveInternalRecallLimit(int docRecallMaxNum) {
        int normalizedLimit = Math.max(docRecallMaxNum, 1);
        return Math.min(normalizedLimit * INTERNAL_RECALL_MULTIPLIER, MAX_INTERNAL_RECALL_LIMIT);
    }

    /**
     * 在重排前过滤掉未完成文档对应的 chunk 命中，避免进行中的文档占用正式召回名额。
     *
     * @param documentCollection 知识库
     * @param searchDocuments 当前召回结果
     * @return 仅保留完成态文档命中的结果
     */
    private List<Document> prepareSearchDocuments(DocumentCollection documentCollection, List<Document> searchDocuments) {
        if (searchDocuments == null || searchDocuments.isEmpty()) {
            return Collections.emptyList();
        }
        if (documentCollection == null) {
            return searchDocuments;
        }
        if (documentCollection.isFaqCollection()) {
            fillSearchContent(documentCollection, searchDocuments);
            LOG.info(
                "Knowledge FAQ documents prepared, knowledgeId={}, remainingCount={}, documents={}",
                documentCollection.getId(),
                searchDocuments.size(),
                summarizeDocuments(searchDocuments)
            );
            return searchDocuments;
        }
        DocumentHitSnapshot hitSnapshot = loadDocumentHitSnapshot(documentCollection, searchDocuments);
        if (hitSnapshot.isEmpty()) {
            LOG.info(
                "Knowledge document hits filtered out, knowledgeId={}, reason=no_completed_source_document, originalCount={}",
                documentCollection.getId(),
                searchDocuments.size()
            );
            return Collections.emptyList();
        }

        List<Document> preparedDocuments = searchDocuments.stream()
            .filter(Objects::nonNull)
            .filter(item -> {
                Object chunkId = item.getId();
                String content = hitSnapshot.findChunkContent(item.getId());
                if (!StringUtil.hasText(content)) {
                    return false;
                }
                item.setContent(content);
                item.addMetadata("chunkId", chunkId);
                Object sourceDocumentId = hitSnapshot.findSourceDocumentId(item.getId());
                if (sourceDocumentId != null) {
                    item.addMetadata("documentId", sourceDocumentId);
                }
                String renderMarkdown = hitSnapshot.findChunkRenderMarkdown(item.getId());
                if (StringUtil.hasText(renderMarkdown)) {
                    item.addMetadata("renderMarkdown", renderMarkdown);
                }
                String sourceFileName = hitSnapshot.findSourceFileName(item.getId());
                if (StringUtil.hasText(sourceFileName)) {
                    item.addMetadata("sourceFileName", sourceFileName);
                }
                return true;
            })
            .collect(Collectors.toList());
        LOG.info(
            "Knowledge document hits prepared, knowledgeId={}, originalCount={}, remainingCount={}, documents={}",
            documentCollection.getId(),
            searchDocuments.size(),
            preparedDocuments.size(),
            summarizeDocuments(preparedDocuments)
        );
        return preparedDocuments;
    }

    @Override
    public DocumentCollection getDetail(String idOrAlias) {
        DocumentCollection knowledge = null;

        if (idOrAlias.matches(RegexUtils.ALL_NUMBER)) {
            knowledge = getById(idOrAlias);
            if (knowledge == null) {
                knowledge = getByAlias(idOrAlias);
            }
        }

        if (knowledge == null) {
            knowledge = getByAlias(idOrAlias);
        }

        return knowledge;
    }

    @Override
    public DocumentCollection getByAlias(String idOrAlias) {
        QueryWrapper queryWrapper = QueryWrapper.create();
        queryWrapper.eq(DocumentCollection::getAlias, idOrAlias);
        return getOne(queryWrapper);
    }

    @Override
    public boolean updateById(DocumentCollection entity) {
        DocumentCollection documentCollection = getById(entity.getId());
        if (documentCollection == null) {
            throw new BusinessException("bot 不存在");
        }

        CustomBeanUtils.copyPropertiesIgnoreNull(entity, documentCollection);

        if ("".equals(documentCollection.getAlias())) {
            documentCollection.setAlias(null);
        }

        return super.updateById(documentCollection, false);
    }

    public List<Document> formatDocuments(List<Document> documents,
                                          boolean applyMinSimilarity,
                                          float minSimilarity,
                                          int maxResults) {
        return documents.stream()
            .filter(Objects::nonNull)
            .filter(document -> !applyMinSimilarity
                || (document.getScore() != null && document.getScore() >= minSimilarity))
            .map(this::roundDocumentScore)
            .sorted(Comparator.comparing(Document::getScore, Comparator.nullsLast(Comparator.reverseOrder())))
            .limit(maxResults)
            .collect(Collectors.toList());
    }

    private Document roundDocumentScore(Document document) {
        if (document == null || document.getScore() == null) {
            return document;
        }
        document.setScore(roundDouble(document.getScore()));
        return document;
    }

    private void fillSearchContent(DocumentCollection documentCollection, List<Document> searchDocuments) {
        if (searchDocuments == null || searchDocuments.isEmpty()) {
            return;
        }
        List<Serializable> ids = searchDocuments.stream()
            .map(item -> (Serializable) item.getId())
            .collect(Collectors.toList());
        if (documentCollection.isFaqCollection()) {
            QueryWrapper queryWrapper = QueryWrapper.create();
            queryWrapper.in(FaqItem::getId, ids);
            queryWrapper.eq(FaqItem::getCollectionId, documentCollection.getId());
            Map<String, FaqItem> faqItemMap = faqItemMapper.selectListByQuery(queryWrapper).stream()
                .collect(Collectors.toMap(item -> item.getId().toString(), item -> item, (a, b) -> a));
            searchDocuments.removeIf(item -> !faqItemMap.containsKey(String.valueOf(item.getId())));
            searchDocuments.forEach(item -> {
                FaqItem faqItem = faqItemMap.get(String.valueOf(item.getId()));
                if (faqItem == null) {
                    return;
                }
                List<Map<String, String>> faqImages = readFaqImages(faqItem);
                item.setContent(buildFaqPromptContent(faqItem, faqImages));

                Map<String, Object> metadataMap = item.getMetadataMap() == null
                    ? new HashMap<String, Object>()
                    : new HashMap<String, Object>(item.getMetadataMap());
                List<String> imageUrls = faqImages.stream()
                    .map(image -> image.get("url"))
                    .filter(Objects::nonNull)
                    .collect(Collectors.toList());
                metadataMap.put("chunkId", item.getId());
                metadataMap.put("documentId", item.getId());
                metadataMap.put("imageUrls", imageUrls);
                item.setMetadataMap(metadataMap);
            });
            return;
        }

        DocumentHitSnapshot hitSnapshot = loadDocumentHitSnapshot(documentCollection, searchDocuments);
        searchDocuments.forEach(item -> {
            item.setContent(hitSnapshot.findChunkContent(item.getId()));
        });
        searchDocuments.removeIf(item -> !StringUtil.hasText(item.getContent()));
    }

    /**
     * 批量加载命中 chunk 及其完成态父文档，供过滤和内容填充复用。
     *
     * @param documentCollection 知识库
     * @param searchDocuments 检索命中
     * @return 命中快照
     */
    private DocumentHitSnapshot loadDocumentHitSnapshot(DocumentCollection documentCollection, List<Document> searchDocuments) {
        if (documentCollection == null || searchDocuments == null || searchDocuments.isEmpty()) {
            return DocumentHitSnapshot.empty();
        }
        List<Serializable> chunkIds = searchDocuments.stream()
            .map(Document::getId)
            .filter(Objects::nonNull)
            .map(item -> (Serializable) item)
            .collect(Collectors.toList());
        if (chunkIds.isEmpty()) {
            return DocumentHitSnapshot.empty();
        }

        QueryWrapper chunkWrapper = QueryWrapper.create();
        chunkWrapper.in(DocumentChunk::getId, chunkIds);
        chunkWrapper.eq(DocumentChunk::getDocumentCollectionId, documentCollection.getId());
        Map<String, DocumentChunk> chunkMap = documentChunkMapper.selectListByQuery(chunkWrapper).stream()
            .collect(Collectors.toMap(item -> item.getId().toString(), item -> item, (a, b) -> a));
        if (chunkMap.isEmpty()) {
            return DocumentHitSnapshot.empty();
        }

        List<Serializable> documentIds = chunkMap.values().stream()
            .map(DocumentChunk::getDocumentId)
            .filter(Objects::nonNull)
            .distinct()
            .map(item -> (Serializable) item)
            .collect(Collectors.toList());
        if (documentIds.isEmpty()) {
            return DocumentHitSnapshot.empty();
        }

        QueryWrapper documentWrapper = QueryWrapper.create();
        documentWrapper.in(tech.easyflow.ai.entity.Document::getId, documentIds);
        documentWrapper.eq(tech.easyflow.ai.entity.Document::getCollectionId, documentCollection.getId());
        documentWrapper.eq(tech.easyflow.ai.entity.Document::getProcessStatus, DocumentProcessStatus.COMPLETED.name());
        Map<String, tech.easyflow.ai.entity.Document> documentMap = documentMapper.selectListByQuery(documentWrapper).stream()
            .collect(Collectors.toMap(item -> item.getId().toString(), item -> item, (a, b) -> a));
        return new DocumentHitSnapshot(chunkMap, documentMap);
    }

    /**
     * 文档检索命中的批量快照，避免过滤和填充阶段重复查询。
     */
    private static class DocumentHitSnapshot {

        private final Map<String, DocumentChunk> chunkMap;
        private final Map<String, tech.easyflow.ai.entity.Document> documentMap;

        private DocumentHitSnapshot(Map<String, DocumentChunk> chunkMap,
                                    Map<String, tech.easyflow.ai.entity.Document> documentMap) {
            this.chunkMap = chunkMap == null ? Collections.emptyMap() : chunkMap;
            this.documentMap = documentMap == null ? Collections.emptyMap() : documentMap;
        }

        private static DocumentHitSnapshot empty() {
            return new DocumentHitSnapshot(Collections.emptyMap(), Collections.emptyMap());
        }

        private boolean isEmpty() {
            return chunkMap.isEmpty() || documentMap.isEmpty();
        }

        private String findChunkContent(Object chunkId) {
            DocumentChunk documentChunk = chunkMap.get(String.valueOf(chunkId));
            if (documentChunk == null || documentChunk.getDocumentId() == null) {
                return null;
            }
            if (!documentMap.containsKey(String.valueOf(documentChunk.getDocumentId()))) {
                return null;
            }
            return StringUtil.noText(documentChunk.getContent()) ? null : documentChunk.getContent();
        }

        private Object findSourceDocumentId(Object chunkId) {
            DocumentChunk documentChunk = chunkMap.get(String.valueOf(chunkId));
            if (documentChunk == null || documentChunk.getDocumentId() == null) {
                return null;
            }
            if (!documentMap.containsKey(String.valueOf(documentChunk.getDocumentId()))) {
                return null;
            }
            return documentChunk.getDocumentId();
        }

        private String findChunkRenderMarkdown(Object chunkId) {
            DocumentChunk documentChunk = chunkMap.get(String.valueOf(chunkId));
            if (documentChunk == null || documentChunk.getDocumentId() == null || documentChunk.getOptions() == null) {
                return null;
            }
            if (!documentMap.containsKey(String.valueOf(documentChunk.getDocumentId()))) {
                return null;
            }
            Object renderMarkdown = documentChunk.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN);
            return renderMarkdown == null ? null : String.valueOf(renderMarkdown);
        }

        private String findSourceFileName(Object chunkId) {
            DocumentChunk documentChunk = chunkMap.get(String.valueOf(chunkId));
            if (documentChunk == null || documentChunk.getDocumentId() == null) {
                return null;
            }
            tech.easyflow.ai.entity.Document sourceDocument = documentMap.get(String.valueOf(documentChunk.getDocumentId()));
            if (sourceDocument == null || StringUtil.noText(sourceDocument.getTitle())) {
                return null;
            }
            return sourceDocument.getTitle();
        }
    }

    private String buildFaqPromptContent(FaqItem faqItem, List<Map<String, String>> images) {
        StringBuilder contentBuilder = new StringBuilder();
        contentBuilder.append("问题：").append(faqItem.getQuestion());
        if (StringUtil.hasText(faqItem.getAnswerText())) {
            contentBuilder.append("\n答案：").append(faqItem.getAnswerText());
        }
        if (images == null || images.isEmpty()) {
            return contentBuilder.toString();
        }
        contentBuilder.append("\n相关图片：");
        int limit = Math.min(images.size(), MAX_FAQ_IMAGES_IN_PROMPT);
        for (int i = 0; i < limit; i++) {
            Map<String, String> image = images.get(i);
            String url = image.get("url");
            if (!StringUtil.hasText(url)) {
                continue;
            }
            String alt = image.get("alt");
            if (!StringUtil.hasText(alt)) {
                alt = image.get("title");
            }
            if (!StringUtil.hasText(alt)) {
                alt = "faq-image-" + (i + 1);
            }
            contentBuilder.append("\n").append("![").append(alt).append("](").append(url).append(")");
        }
        return contentBuilder.toString();
    }

    @SuppressWarnings("unchecked")
    private List<Map<String, String>> readFaqImages(FaqItem faqItem) {
        if (faqItem == null || faqItem.getOptions() == null) {
            return Collections.emptyList();
        }
        Object imagesValue = faqItem.getOptions().get("images");
        if (!(imagesValue instanceof List<?>)) {
            return Collections.emptyList();
        }
        List<Map<String, String>> images = new ArrayList<>();
        for (Object imageObj : (List<Object>) imagesValue) {
            if (!(imageObj instanceof Map<?, ?>)) {
                continue;
            }
            Map<?, ?> imageMap = (Map<?, ?>) imageObj;
            String url = trimToNull(imageMap.get("url"));
            if (url == null) {
                continue;
            }
            Map<String, String> normalized = new HashMap<>();
            normalized.put("url", url);
            String alt = trimToNull(imageMap.get("alt"));
            if (alt != null) {
                normalized.put("alt", alt);
            }
            String title = trimToNull(imageMap.get("title"));
            if (title != null) {
                normalized.put("title", title);
            }
            images.add(normalized);
        }
        return images;
    }

    private String trimToNull(Object value) {
        if (value == null) {
            return null;
        }
        String text = String.valueOf(value).trim();
        return text.isEmpty() ? null : text;
    }

    private int readIntegerOption(DocumentCollection documentCollection, String key, int defaultValue) {
        Object value = documentCollection.getOptionsByKey(key);
        if (value instanceof Number) {
            return ((Number) value).intValue();
        }
        if (value instanceof String && StringUtil.hasText((String) value)) {
            try {
                return Integer.parseInt((String) value);
            } catch (NumberFormatException ignore) {
                return defaultValue;
            }
        }
        return defaultValue;
    }

    private float readFloatOption(DocumentCollection documentCollection, String key, float defaultValue) {
        Object value = documentCollection.getOptionsByKey(key);
        if (value instanceof Number) {
            return ((Number) value).floatValue();
        }
        if (value instanceof String && StringUtil.hasText((String) value)) {
            try {
                return Float.parseFloat((String) value);
            } catch (NumberFormatException ignore) {
                return defaultValue;
            }
        }
        return defaultValue;
    }

    private Double roundDouble(Double value) {
        if (value == null) {
            return null;
        }
        return new BigDecimal(String.valueOf(value))
            .setScale(4, RoundingMode.HALF_UP)
            .doubleValue();
    }

    /**
     * 构建 RAG 原始命中摘要，便于排查向量、关键词与融合阶段的召回情况。
     *
     * @param hits RAG 命中列表
     * @return 命中摘要
     */
    private List<Map<String, Object>> summarizeRagHits(List<RagHit> hits) {
        if (hits == null || hits.isEmpty()) {
            return Collections.emptyList();
        }
        return hits.stream()
            .filter(Objects::nonNull)
            .map(hit -> {
                Map<String, Object> summary = new LinkedHashMap<>();
                summary.put("id", hit.getDocumentId());
                summary.put("title", hit.getTitle());
                summary.put("source", hit.getHitSource());
                summary.put("score", hit.getScore());
                summary.put("vectorScore", hit.getVectorScore());
                summary.put("keywordScore", hit.getKeywordScore());
                summary.put("rank", hit.getRank());
                summary.put("content", truncate(hit.getContent()));
                summary.put("metadata", hit.getMetadata());
                return summary;
            })
            .collect(Collectors.toList());
    }

    /**
     * 构建文档命中摘要，避免完整知识库内容撑爆日志。
     *
     * @param documents 文档命中列表
     * @return 文档摘要
     */
    private List<Map<String, Object>> summarizeDocuments(List<Document> documents) {
        if (documents == null || documents.isEmpty()) {
            return Collections.emptyList();
        }
        return documents.stream()
            .filter(Objects::nonNull)
            .map(document -> {
                Map<String, Object> summary = new LinkedHashMap<>();
                summary.put("id", document.getId());
                summary.put("title", document.getTitle());
                summary.put("score", document.getScore());
                summary.put("content", truncate(document.getContent()));
                summary.put("metadata", document.getMetadataMap());
                return summary;
            })
            .collect(Collectors.toList());
    }

    /**
     * 截断日志文本，保留足够排查上下文。
     *
     * @param text 原始文本
     * @return 截断文本
     */
    private String truncate(String text) {
        if (text == null || text.length() <= LOG_TEXT_MAX_LENGTH) {
            return text;
        }
        return text.substring(0, LOG_TEXT_MAX_LENGTH) + "...";
    }
}
