package tech.easyflow.ai.service.impl;

import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreResult;
import com.easyagents.rag.core.RagDefaults;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import tech.easyflow.ai.config.SearcherFactory;
import tech.easyflow.ai.entity.Document;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Model;
import tech.easyflow.ai.enums.DocumentProcessStatus;
import tech.easyflow.ai.mapper.DocumentChunkMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.web.exceptions.BusinessException;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;

/**
 * {@link DocumentServiceImpl} 文档维护回归测试。
 *
 * @author Codex
 * @since 2026-08-02
 */
public class DocumentServiceImplTest {

    /**
     * 验证管理端列表只查询展示字段，并直接使用文档表中的分块统计。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void getDocumentListShouldAvoidChunkJoinAndLargeFields() throws Exception {
        DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
        DocumentServiceImpl service = new DocumentServiceImpl();
        setField(service, "documentMapper", documentMapper);

        service.getDocumentList("1001", 10, 1, null);

        ArgumentCaptor<QueryWrapper> queryCaptor =
            ArgumentCaptor.forClass(QueryWrapper.class);
        Mockito.verify(documentMapper).paginateAs(
            Mockito.eq(1),
            Mockito.eq(10),
            queryCaptor.capture(),
            Mockito.eq(Document.class)
        );
        String sql = normalizeSql(queryCaptor.getValue().toSQL());
        Assert.assertFalse(sql.contains("tb_document_chunk"));
        Assert.assertFalse(containsSqlIdentifier(sql, "content"));
        Assert.assertFalse(containsSqlIdentifier(sql, "options"));
        Assert.assertFalse(sql.contains("group by"));
        Assert.assertTrue(containsSqlIdentifier(sql, "total_chunks"));
        Assert.assertTrue(containsSqlIdentifier(sql, "chunk_count"));
    }

    /**
     * 验证公开文档分页继续返回历史详情字段，同时不再关联分块表聚合。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void getDocumentListByIdShouldKeepDetailFieldsWithoutChunkJoin()
        throws Exception {
        DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
        DocumentServiceImpl service = new DocumentServiceImpl();
        setField(service, "documentMapper", documentMapper);

        service.getDocumentListById(
            "1001",
            10,
            1,
            BigInteger.valueOf(2002)
        );

        ArgumentCaptor<QueryWrapper> queryCaptor =
            ArgumentCaptor.forClass(QueryWrapper.class);
        Mockito.verify(documentMapper).paginateAs(
            Mockito.eq(1),
            Mockito.eq(10),
            queryCaptor.capture(),
            Mockito.eq(Document.class)
        );
        String sql = normalizeSql(queryCaptor.getValue().toSQL());
        Assert.assertFalse(sql.contains("tb_document_chunk"));
        Assert.assertFalse(sql.contains("group by"));
        Assert.assertTrue(sql.contains("*"));
        Assert.assertTrue(containsSqlIdentifier(sql, "chunk_count"));
    }

    /**
     * 验证删除链路在外部索引和分块清理后删除文档主记录。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void removeDocShouldDeleteDocumentRecord() throws Exception {
        BigInteger documentId = BigInteger.valueOf(101);
        BigInteger knowledgeId = BigInteger.valueOf(102);
        BigInteger modelId = BigInteger.valueOf(103);
        Document document = new Document();
        document.setId(documentId);
        document.setCollectionId(knowledgeId);
        document.setDocumentPath("storage://document.txt");
        document.setProcessStatus(DocumentProcessStatus.COMPLETED.name());

        DocumentStore documentStore = Mockito.mock(DocumentStore.class);
        Mockito.when(documentStore.delete(
            Mockito.anyList(), Mockito.any())).thenReturn(StoreResult.success());
        DocumentCollection knowledge = Mockito.mock(DocumentCollection.class);
        Mockito.when(knowledge.getVectorEmbedModelId()).thenReturn(modelId);
        Mockito.when(knowledge.getVectorStoreCollection()).thenReturn("kb-test");
        Mockito.when(knowledge.toDocumentStore()).thenReturn(documentStore);
        Model model = new Model();
        model.setModelName("embedding-test");

        DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
        Mockito.when(documentMapper.selectOneByQuery(
            Mockito.any(QueryWrapper.class))).thenReturn(document);
        Mockito.when(documentMapper.deleteById(documentId)).thenReturn(1);
        DocumentChunkMapper chunkMapper =
            Mockito.mock(DocumentChunkMapper.class);
        Mockito.when(chunkMapper.selectListByQueryAs(
            Mockito.any(QueryWrapper.class), Mockito.eq(BigInteger.class)))
            .thenReturn(List.of(BigInteger.valueOf(201)));
        Mockito.when(chunkMapper.deleteByQuery(
            Mockito.any(QueryWrapper.class))).thenReturn(1);
        DocumentCollectionService knowledgeService =
            Mockito.mock(DocumentCollectionService.class);
        Mockito.when(knowledgeService.getById(knowledgeId))
            .thenReturn(knowledge);
        ModelService modelService = Mockito.mock(ModelService.class);
        Mockito.when(modelService.getById(modelId)).thenReturn(model);
        FileStorageService storageService =
            Mockito.mock(FileStorageService.class);
        SearcherFactory searcherFactory = Mockito.mock(SearcherFactory.class);

        DocumentServiceImpl service = new DocumentServiceImpl();
        setField(service, "documentMapper", documentMapper);
        setField(service, "documentChunkMapper", chunkMapper);
        setField(service, "knowledgeService", knowledgeService);
        setField(service, "modelService", modelService);
        setField(service, "storageService", storageService);
        setField(service, "searcherFactory", searcherFactory);

        Assert.assertTrue(service.removeDoc(documentId.toString()));

        Mockito.verify(documentMapper).deleteById(documentId);
        Mockito.verify(storageService).delete(document.getDocumentPath());
    }

    /**
     * 验证最后一个分块已单独删除时，文档删除跳过空向量请求并完成清理。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void removeDocShouldHandleDocumentWithoutChunks() throws Exception {
        BigInteger documentId = BigInteger.valueOf(202);
        BigInteger knowledgeId = BigInteger.valueOf(203);
        BigInteger modelId = BigInteger.valueOf(204);
        Document document = new Document();
        document.setId(documentId);
        document.setCollectionId(knowledgeId);
        document.setDocumentPath("storage://empty-document.txt");
        document.setProcessStatus(DocumentProcessStatus.COMPLETED.name());

        DocumentCollection knowledge = Mockito.mock(DocumentCollection.class);
        Mockito.when(knowledge.getVectorEmbedModelId()).thenReturn(modelId);
        Mockito.when(knowledge.getVectorStoreCollection()).thenReturn("kb-test");
        Mockito.when(knowledge.toDocumentStore()).thenReturn(null);
        DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
        Mockito.when(documentMapper.selectOneByQuery(
            Mockito.any(QueryWrapper.class))).thenReturn(document);
        Mockito.when(documentMapper.deleteById(documentId)).thenReturn(1);
        DocumentChunkMapper chunkMapper = Mockito.mock(DocumentChunkMapper.class);
        Mockito.when(chunkMapper.selectListByQueryAs(
            Mockito.any(QueryWrapper.class), Mockito.eq(BigInteger.class)))
            .thenReturn(List.of());
        Mockito.when(chunkMapper.deleteByQuery(
            Mockito.any(QueryWrapper.class))).thenReturn(0);
        DocumentCollectionService knowledgeService =
            Mockito.mock(DocumentCollectionService.class);
        Mockito.when(knowledgeService.getById(knowledgeId)).thenReturn(knowledge);
        ModelService modelService = Mockito.mock(ModelService.class);
        FileStorageService storageService = Mockito.mock(FileStorageService.class);
        SearcherFactory searcherFactory = Mockito.mock(SearcherFactory.class);

        DocumentServiceImpl service = new DocumentServiceImpl();
        setField(service, "documentMapper", documentMapper);
        setField(service, "documentChunkMapper", chunkMapper);
        setField(service, "knowledgeService", knowledgeService);
        setField(service, "modelService", modelService);
        setField(service, "storageService", storageService);
        setField(service, "searcherFactory", searcherFactory);

        Assert.assertTrue(service.removeDoc(documentId.toString()));

        Mockito.verify(knowledge, Mockito.never()).toDocumentStore();
        Mockito.verifyNoInteractions(modelService);
        Mockito.verify(documentMapper).deleteById(documentId);
        Mockito.verify(storageService).delete(document.getDocumentPath());
    }

    /**
     * 验证分块中的文档禁止删除，且不会触发任何外部或数据库清理。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void removeDocShouldRejectSplittingDocumentWithoutSideEffects()
        throws Exception {
        BigInteger documentId = BigInteger.valueOf(301);
        Document document = new Document();
        document.setId(documentId);
        document.setProcessStatus(DocumentProcessStatus.SPLITTING.name());
        DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
        Mockito.when(documentMapper.selectOneByQuery(
            Mockito.any(QueryWrapper.class))).thenReturn(document);
        DocumentChunkMapper chunkMapper = Mockito.mock(DocumentChunkMapper.class);
        DocumentCollectionService knowledgeService =
            Mockito.mock(DocumentCollectionService.class);

        DocumentServiceImpl service = new DocumentServiceImpl();
        setField(service, "documentMapper", documentMapper);
        setField(service, "documentChunkMapper", chunkMapper);
        setField(service, "knowledgeService", knowledgeService);

        try {
            service.removeDoc(documentId.toString());
            Assert.fail("分块中的文档应拒绝删除");
        } catch (BusinessException expected) {
            Assert.assertEquals("文档处理中，暂不允许删除", expected.getMessage());
        }

        Mockito.verifyNoInteractions(chunkMapper, knowledgeService);
        Mockito.verify(documentMapper, Mockito.never()).deleteById(Mockito.any());
    }

    /**
     * 验证向量删除失败时停止后续清理，避免接口返回虚假成功。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void removeDocShouldStopWhenVectorDeleteFails() throws Exception {
        BigInteger documentId = BigInteger.valueOf(401);
        BigInteger knowledgeId = BigInteger.valueOf(402);
        BigInteger modelId = BigInteger.valueOf(403);
        Document document = new Document();
        document.setId(documentId);
        document.setCollectionId(knowledgeId);
        document.setProcessStatus(DocumentProcessStatus.COMPLETED.name());
        DocumentStore documentStore = Mockito.mock(DocumentStore.class);
        Mockito.when(documentStore.delete(
            Mockito.anyList(), Mockito.any())).thenReturn(StoreResult.fail("测试失败"));
        DocumentCollection knowledge = Mockito.mock(DocumentCollection.class);
        Mockito.when(knowledge.getVectorEmbedModelId()).thenReturn(modelId);
        Mockito.when(knowledge.getVectorStoreCollection()).thenReturn("kb-test");
        Mockito.when(knowledge.toDocumentStore()).thenReturn(documentStore);
        Model model = new Model();
        model.setModelName("embedding-test");
        DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
        Mockito.when(documentMapper.selectOneByQuery(
            Mockito.any(QueryWrapper.class))).thenReturn(document);
        DocumentChunkMapper chunkMapper = Mockito.mock(DocumentChunkMapper.class);
        Mockito.when(chunkMapper.selectListByQueryAs(
            Mockito.any(QueryWrapper.class), Mockito.eq(BigInteger.class)))
            .thenReturn(List.of(BigInteger.valueOf(404)));
        DocumentCollectionService knowledgeService =
            Mockito.mock(DocumentCollectionService.class);
        Mockito.when(knowledgeService.getById(knowledgeId)).thenReturn(knowledge);
        ModelService modelService = Mockito.mock(ModelService.class);
        Mockito.when(modelService.getById(modelId)).thenReturn(model);
        FileStorageService storageService = Mockito.mock(FileStorageService.class);
        SearcherFactory searcherFactory = Mockito.mock(SearcherFactory.class);

        DocumentServiceImpl service = new DocumentServiceImpl();
        setField(service, "documentMapper", documentMapper);
        setField(service, "documentChunkMapper", chunkMapper);
        setField(service, "knowledgeService", knowledgeService);
        setField(service, "modelService", modelService);
        setField(service, "storageService", storageService);
        setField(service, "searcherFactory", searcherFactory);

        try {
            service.removeDoc(documentId.toString());
            Assert.fail("向量删除失败时应停止文档删除");
        } catch (BusinessException expected) {
            Assert.assertEquals("文档向量删除失败", expected.getMessage());
        }

        Mockito.verify(chunkMapper, Mockito.never()).deleteByQuery(Mockito.any());
        Mockito.verify(documentMapper, Mockito.never()).deleteById(Mockito.any());
        Mockito.verifyNoInteractions(storageService);
    }

    /**
     * 验证旧版向量化入口同样拒绝超过 BGE-M3 上下文预算的分块。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void embeddingGuardShouldRejectOversizedChunk()
        throws Exception {
        DocumentServiceImpl service = new DocumentServiceImpl();
        DocumentChunk chunk = new DocumentChunk();
        chunk.setId(BigInteger.valueOf(8801));
        chunk.setContent("长".repeat(
            RagDefaults.BGE_M3_HARD_CHUNK_TOKEN_LIMIT + 1));
        Method method = DocumentServiceImpl.class.getDeclaredMethod(
            "assertEmbeddingChunkWithinHardLimit", DocumentChunk.class);
        method.setAccessible(true);

        try {
            method.invoke(service, chunk);
            Assert.fail("超过 BGE-M3 上下文预算的分块应被拒绝");
        } catch (InvocationTargetException expected) {
            Assert.assertTrue(expected.getCause() instanceof BusinessException);
            Assert.assertTrue(expected.getCause().getMessage().contains(
                "tokenEstimate"));
        }
    }

    /**
     * 通过反射注入测试依赖。
     *
     * @param target 目标对象
     * @param fieldName 字段名
     * @param value 字段值
     * @throws Exception 字段不存在或无法访问时抛出
     */
    private static void setField(Object target,
                                 String fieldName,
                                 Object value) throws Exception {
        Field field = DocumentServiceImpl.class.getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(target, value);
    }

    /**
     * 统一 SQL 文本格式，便于断言查询结构。
     *
     * @param sql 原始 SQL
     * @return 去除标识符引号并转为小写的 SQL
     */
    private static String normalizeSql(String sql) {
        return sql.replace("`", "").toLowerCase(Locale.ROOT);
    }

    /**
     * 判断 SQL 是否包含完整列标识符，避免与同前缀列名混淆。
     *
     * @param sql 已标准化的 SQL
     * @param identifier 列标识符
     * @return 包含完整标识符时返回 true
     */
    private static boolean containsSqlIdentifier(String sql, String identifier) {
        return Pattern.compile("\\b" + Pattern.quote(identifier) + "\\b")
            .matcher(sql)
            .find();
    }
}
