package tech.easyflow.ai.documentimport.task;

import com.easyagents.document.core.entity.DocumentBlock;
import com.easyagents.document.core.entity.DocumentImage;
import com.easyagents.document.core.entity.DocumentTable;
import com.easyagents.core.model.embedding.EmbeddingModel;
import com.easyagents.core.store.DocumentStore;
import com.easyagents.core.store.StoreOptions;
import com.easyagents.core.store.StoreResult;
import com.easyagents.rag.core.BgeM3ChunkSafety;
import com.easyagents.rag.core.RagChunk;
import com.easyagents.rag.core.RagDefaults;
import com.easyagents.rag.core.RagStrategyCodes;
import com.easyagents.rag.ingestion.RagIngestionService;
import com.easyagents.rag.ingestion.model.AnalysisResult;
import com.easyagents.rag.ingestion.model.StrategyConfig;
import com.easyagents.search.engine.service.DocumentSearcher;
import com.mybatisflex.core.query.QueryWrapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.data.redis.RedisSystemException;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
import tech.easyflow.ai.document.model.DocumentParseArtifacts;
import tech.easyflow.ai.document.model.DocumentParsedResult;
import tech.easyflow.ai.document.model.DocumentSourceRef;
import tech.easyflow.ai.documentimport.DocumentImportDtos;
import tech.easyflow.ai.documentimport.DocumentImportKeys;
import tech.easyflow.ai.entity.DocumentChunk;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.DocumentImportBatch;
import tech.easyflow.ai.entity.DocumentImportBatchItem;
import tech.easyflow.ai.entity.DocumentImportTask;
import tech.easyflow.ai.enums.DocumentImportBatchItemStage;
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
import tech.easyflow.ai.enums.DocumentImportMode;
import tech.easyflow.ai.enums.DocumentImportTaskStatus;
import tech.easyflow.ai.enums.DocumentImportTaskPhase;
import tech.easyflow.ai.enums.DocumentProcessStatus;
import tech.easyflow.ai.mapper.DocumentImportTaskMapper;
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
import tech.easyflow.ai.mapper.DocumentMapper;
import tech.easyflow.ai.service.DocumentImportBatchItemService;
import tech.easyflow.ai.service.DocumentImportTaskService;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.DocumentService;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.web.exceptions.BusinessException;

import java.io.ByteArrayInputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.math.BigInteger;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;

/**
 * {@link KnowledgeDocumentImportTaskAppService} 回归测试。
 *
 * @author Codex
 * @since 2026-04-15
 */
public class KnowledgeDocumentImportTaskAppServiceTest {

    /**
     * 验证预览翻页直接读取分片快照，避免恢复完整会话。
     *
     * @throws Exception 反射调用失败
     */
    @Test
    public void loadPreviewPageShouldReadOnlyRequestedSnapshotRange()
        throws Exception {
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        DocumentImportChunkSnapshotService snapshotService =
            Mockito.mock(DocumentImportChunkSnapshotService.class);
        setField(
            service,
            "documentImportChunkSnapshotService",
            snapshotService);

        DocumentChunk chunk = new DocumentChunk();
        chunk.setId(BigInteger.valueOf(9001));
        Mockito.when(snapshotService.loadPage("snapshot.json", 20, 20))
            .thenReturn(List.of(chunk));
        DocumentImportDtos.PreviewSession session =
            new DocumentImportDtos.PreviewSession();
        session.setChunkSnapshotPath("snapshot.json");

        Method method = KnowledgeDocumentImportTaskAppService.class
            .getDeclaredMethod(
                "loadPreviewPage",
                DocumentImportDtos.PreviewSession.class,
                int.class,
                int.class);
        method.setAccessible(true);
        @SuppressWarnings("unchecked")
        List<DocumentChunk> result = (List<DocumentChunk>) method.invoke(
            service, session, 2, 20);

        Assert.assertEquals(List.of(chunk), result);
        Mockito.verify(snapshotService).loadPage("snapshot.json", 20, 20);
    }

    /**
     * 验证待处理任务重新投递只更新必要字段，避免自定义查询结果覆盖非空列。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void dispatchPendingTasksShouldTouchPendingWithCas()
        throws Exception {
        BigInteger taskId = BigInteger.valueOf(29);
        DocumentImportTask task = new DocumentImportTask();
        task.setId(taskId);
        task.setPhase(DocumentImportTaskPhase.PARSE.name());
        task.setStatus(DocumentImportTaskStatus.PENDING.name());

        DocumentImportTaskMapper taskMapper =
            Mockito.mock(DocumentImportTaskMapper.class);
        Mockito.when(taskMapper.selectPendingFairly(
            Mockito.any(Date.class), Mockito.anyInt()))
            .thenReturn(List.of(task));
        Mockito.when(taskMapper.touchPendingForDispatch(
            Mockito.eq(taskId), Mockito.any(Date.class), Mockito.any(Date.class),
            Mockito.any(BigInteger.class)))
            .thenReturn(1);
        DocumentImportParseTaskProducer producer =
            Mockito.mock(DocumentImportParseTaskProducer.class);

        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportTaskMapper", taskMapper);
        setField(service, "parseTaskProducer", producer);
        setField(service, "bulkProperties", new DocumentImportBulkProperties());

        service.dispatchPendingTasks();

        ArgumentCaptor<Date> selectCutoffCaptor =
            ArgumentCaptor.forClass(Date.class);
        Mockito.verify(taskMapper).selectPendingFairly(
            selectCutoffCaptor.capture(), Mockito.anyInt());
        ArgumentCaptor<Date> touchCutoffCaptor =
            ArgumentCaptor.forClass(Date.class);
        Mockito.verify(taskMapper).touchPendingForDispatch(
            Mockito.eq(taskId), touchCutoffCaptor.capture(), Mockito.any(Date.class),
            Mockito.any(BigInteger.class));
        Assert.assertEquals(selectCutoffCaptor.getValue(), touchCutoffCaptor.getValue());
        Mockito.verify(producer).send(taskId);
    }

    /**
     * 验证任务已离开待处理状态时不再发送重复消息。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void dispatchPendingTasksShouldSkipChangedTask()
        throws Exception {
        BigInteger taskId = BigInteger.valueOf(30);
        DocumentImportTask task = new DocumentImportTask();
        task.setId(taskId);
        task.setPhase(DocumentImportTaskPhase.PARSE.name());

        DocumentImportTaskMapper taskMapper =
            Mockito.mock(DocumentImportTaskMapper.class);
        Mockito.when(taskMapper.selectPendingFairly(
            Mockito.any(Date.class), Mockito.anyInt()))
            .thenReturn(List.of(task));
        Mockito.when(taskMapper.touchPendingForDispatch(
            Mockito.eq(taskId), Mockito.any(Date.class), Mockito.any(Date.class),
            Mockito.any(BigInteger.class)))
            .thenReturn(0);
        DocumentImportParseTaskProducer producer =
            Mockito.mock(DocumentImportParseTaskProducer.class);

        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportTaskMapper", taskMapper);
        setField(service, "parseTaskProducer", producer);
        setField(service, "bulkProperties", new DocumentImportBulkProperties());

        service.dispatchPendingTasks();

        Mockito.verify(producer, Mockito.never()).send(Mockito.any());
    }

    /**
     * 验证自动导入任务投递异常会触发批次熔断，且不会阻断本轮扫描。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void dispatchPendingTasksShouldInterruptBatchWhenProducerFails()
        throws Exception {
        BigInteger taskId = BigInteger.valueOf(31);
        DocumentImportTask task = new DocumentImportTask();
        task.setId(taskId);
        task.setBatchId(BigInteger.valueOf(32));
        task.setPhase(DocumentImportTaskPhase.PARSE.name());
        task.setStatus(DocumentImportTaskStatus.PENDING.name());
        DocumentImportTaskMapper taskMapper =
            Mockito.mock(DocumentImportTaskMapper.class);
        Mockito.when(taskMapper.selectPendingFairly(
            Mockito.any(Date.class), Mockito.anyInt()))
            .thenReturn(List.of(task));
        Mockito.when(taskMapper.touchPendingForDispatch(
            Mockito.eq(taskId), Mockito.any(Date.class), Mockito.any(Date.class),
            Mockito.any(BigInteger.class)))
            .thenReturn(1);
        DocumentImportParseTaskProducer producer =
            Mockito.mock(DocumentImportParseTaskProducer.class);
        IllegalStateException failure =
            new IllegalStateException("Redis command timed out");
        Mockito.doThrow(failure).when(producer).send(taskId);
        DocumentImportBatchCircuitBreaker circuitBreaker =
            Mockito.mock(DocumentImportBatchCircuitBreaker.class);
        Mockito.when(circuitBreaker.interruptTaskBatch(taskId, failure))
            .thenReturn(true);
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportTaskMapper", taskMapper);
        setField(service, "parseTaskProducer", producer);
        setField(service, "documentImportBatchCircuitBreaker", circuitBreaker);
        setField(service, "bulkProperties", new DocumentImportBulkProperties());

        service.dispatchPendingTasks();

        Mockito.verify(circuitBreaker).interruptTaskBatch(taskId, failure);
    }

    /**
     * 验证投递失败后若批次中断写入也失败，异常会传播给调度器触发退避。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void dispatchPendingTasksShouldPropagateInterruptionWriteFailure()
        throws Exception {
        BigInteger taskId = BigInteger.valueOf(33);
        DocumentImportTask task = new DocumentImportTask();
        task.setId(taskId);
        task.setBatchId(BigInteger.valueOf(34));
        task.setPhase(DocumentImportTaskPhase.PARSE.name());
        task.setStatus(DocumentImportTaskStatus.PENDING.name());
        DocumentImportTaskMapper taskMapper =
            Mockito.mock(DocumentImportTaskMapper.class);
        Mockito.when(taskMapper.selectPendingFairly(
            Mockito.any(Date.class), Mockito.anyInt()))
            .thenReturn(List.of(task));
        Mockito.when(taskMapper.touchPendingForDispatch(
            Mockito.eq(taskId), Mockito.any(Date.class), Mockito.any(Date.class),
            Mockito.any(BigInteger.class)))
            .thenReturn(1);
        DocumentImportParseTaskProducer producer =
            Mockito.mock(DocumentImportParseTaskProducer.class);
        RedisSystemException dispatchFailure = new RedisSystemException(
            "Redis command timed out",
            new IllegalStateException("timeout")
        );
        Mockito.doThrow(dispatchFailure).when(producer).send(taskId);
        DocumentImportBatchCircuitBreaker circuitBreaker =
            Mockito.mock(DocumentImportBatchCircuitBreaker.class);
        IllegalStateException interruptionFailure =
            new IllegalStateException("database unavailable");
        Mockito.when(circuitBreaker.interruptTaskBatch(taskId, dispatchFailure))
            .thenThrow(interruptionFailure);
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportTaskMapper", taskMapper);
        setField(service, "parseTaskProducer", producer);
        setField(service, "documentImportBatchCircuitBreaker", circuitBreaker);
        setField(service, "bulkProperties", new DocumentImportBulkProperties());

        try {
            service.dispatchPendingTasks();
            Assert.fail("批次中断写入失败必须传播给外层调度器");
        } catch (IllegalStateException error) {
            Assert.assertSame(interruptionFailure, error.getCause());
            Assert.assertEquals(1, error.getSuppressed().length);
            Assert.assertSame(dispatchFailure, error.getSuppressed()[0]);
            Assert.assertTrue(error.getMessage().contains("taskId=33"));
            Assert.assertTrue(error.getMessage().contains("batchId=34"));
        }
    }

    /**
     * 验证无批次任务投递失败时保留任务并传播异常，避免两秒热循环。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void dispatchPendingTasksShouldPropagateUnbatchedFailure()
        throws Exception {
        BigInteger taskId = BigInteger.valueOf(35);
        DocumentImportTask task = new DocumentImportTask();
        task.setId(taskId);
        task.setPhase(DocumentImportTaskPhase.PARSE.name());
        task.setStatus(DocumentImportTaskStatus.PENDING.name());
        DocumentImportTaskMapper taskMapper =
            Mockito.mock(DocumentImportTaskMapper.class);
        Mockito.when(taskMapper.selectPendingFairly(
            Mockito.any(Date.class), Mockito.anyInt()))
            .thenReturn(List.of(task));
        Mockito.when(taskMapper.touchPendingForDispatch(
            Mockito.eq(taskId), Mockito.any(Date.class), Mockito.any(Date.class),
            Mockito.any(BigInteger.class)))
            .thenReturn(1);
        DocumentImportParseTaskProducer producer =
            Mockito.mock(DocumentImportParseTaskProducer.class);
        IllegalStateException dispatchFailure =
            new IllegalStateException("Redis unavailable");
        Mockito.doThrow(dispatchFailure).when(producer).send(taskId);
        DocumentImportBatchCircuitBreaker circuitBreaker =
            Mockito.mock(DocumentImportBatchCircuitBreaker.class);
        Mockito.when(circuitBreaker.interruptTaskBatch(taskId, dispatchFailure))
            .thenReturn(false);
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportTaskMapper", taskMapper);
        setField(service, "parseTaskProducer", producer);
        setField(service, "documentImportBatchCircuitBreaker", circuitBreaker);
        setField(service, "bulkProperties", new DocumentImportBulkProperties());

        try {
            service.dispatchPendingTasks();
            Assert.fail("无批次投递失败必须传播给外层调度器");
        } catch (IllegalStateException error) {
            Assert.assertSame(dispatchFailure, error.getCause());
            Assert.assertTrue(error.getMessage().contains("taskId=35"));
            Assert.assertTrue(error.getMessage().contains("batchId=null"));
        }
    }

    /**
     * 验证批次恢复遇到基础设施异常后向令牌持有者传播并停止后续重试。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void retryBatchInfrastructureFailureShouldPropagateToOwner()
        throws Exception {
        BigInteger batchId = BigInteger.valueOf(91);
        DocumentImportBatchItem first = new DocumentImportBatchItem();
        first.setId(BigInteger.valueOf(92));
        first.setBatchId(batchId);
        DocumentImportBatchItem second = new DocumentImportBatchItem();
        second.setId(BigInteger.valueOf(93));
        second.setBatchId(batchId);
        DocumentImportBatchItemService itemService =
            Mockito.mock(DocumentImportBatchItemService.class);
        Mockito.when(itemService.list(Mockito.any(QueryWrapper.class)))
            .thenReturn(List.of(first, second));
        KnowledgeDocumentImportTaskAppService selfProxy =
            Mockito.mock(KnowledgeDocumentImportTaskAppService.class);
        RedisSystemException failure = new RedisSystemException(
            "Redis command timed out",
            new IllegalStateException("timeout")
        );
        Mockito.doThrow(failure)
            .when(selfProxy)
            .retryBatchItemInNewTransaction(first.getId());
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportBatchItemService", itemService);
        setField(service, "selfProxy", selfProxy);

        try {
            service.retryBatchFailures(batchId, Set.of());
            Assert.fail("基础设施异常必须传播给恢复令牌持有者");
        } catch (RedisSystemException error) {
            Assert.assertSame(failure, error);
        }

        Mockito.verify(selfProxy, Mockito.never())
            .retryBatchItemInNewTransaction(second.getId());
    }

    /**
     * 验证单文件业务失败不会提前关闭批次并跳过后续失败项。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void retryBatchBusinessFailureShouldContinueRemainingItems()
        throws Exception {
        BigInteger batchId = BigInteger.valueOf(96);
        DocumentImportBatchItem first = new DocumentImportBatchItem();
        first.setId(BigInteger.valueOf(97));
        first.setBatchId(batchId);
        first.setDocumentId(BigInteger.valueOf(98));
        DocumentImportBatchItem second = new DocumentImportBatchItem();
        second.setId(BigInteger.valueOf(99));
        second.setBatchId(batchId);
        second.setDocumentId(BigInteger.valueOf(100));
        DocumentImportBatchItemService itemService =
            Mockito.mock(DocumentImportBatchItemService.class);
        Mockito.when(itemService.list(Mockito.any(QueryWrapper.class)))
            .thenReturn(List.of(first, second));
        KnowledgeDocumentImportTaskAppService selfProxy =
            Mockito.mock(KnowledgeDocumentImportTaskAppService.class);
        Mockito.doThrow(new BusinessException("格式不支持"))
            .when(selfProxy)
            .retryBatchItemInNewTransaction(first.getId());
        DocumentImportBatchItemMapper itemMapper =
            Mockito.mock(DocumentImportBatchItemMapper.class);
        DocumentImportBatchTracker batchTracker =
            Mockito.mock(DocumentImportBatchTracker.class);
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportBatchItemService", itemService);
        setField(service, "documentImportBatchItemMapper", itemMapper);
        setField(service, "documentImportBatchTracker", batchTracker);
        setField(service, "selfProxy", selfProxy);

        Assert.assertTrue(service.retryBatchFailures(
            batchId, Set.of(), () -> true));

        org.mockito.InOrder order = Mockito.inOrder(
            selfProxy, itemMapper);
        order.verify(selfProxy).retryBatchItemInNewTransaction(first.getId());
        order.verify(itemMapper).updateFailedRetryError(
            Mockito.eq(first.getId()), Mockito.eq(batchId),
            Mockito.eq("格式不支持"), Mockito.any(Date.class));
        order.verify(selfProxy).retryBatchItemInNewTransaction(second.getId());
        Mockito.verifyNoInteractions(batchTracker);
    }

    /**
     * 验证恢复租约失效后旧实例停止重试剩余文件。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void retryBatchFailuresShouldStopWhenRecoveryLeaseIsLost()
        throws Exception {
        BigInteger batchId = BigInteger.valueOf(94);
        DocumentImportBatchItem failed = new DocumentImportBatchItem();
        failed.setId(BigInteger.valueOf(95));
        failed.setBatchId(batchId);
        DocumentImportBatchItemService itemService =
            Mockito.mock(DocumentImportBatchItemService.class);
        Mockito.when(itemService.list(Mockito.any(QueryWrapper.class)))
            .thenReturn(List.of(failed));
        KnowledgeDocumentImportTaskAppService selfProxy =
            Mockito.mock(KnowledgeDocumentImportTaskAppService.class);
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportBatchItemService", itemService);
        setField(service, "selfProxy", selfProxy);

        Assert.assertFalse(service.retryBatchFailures(
            batchId, Set.of(), () -> false));

        Mockito.verify(selfProxy, Mockito.never())
            .retryBatchItemInNewTransaction(Mockito.any());
    }

    /**
     * 验证重新投递资格更新包含待处理状态和过期时间边界，确保竞争更新仅一个成功。
     *
     * @throws Exception 映射方法不存在时抛出
     */
    @Test
    public void touchPendingForDispatchSqlShouldFenceConcurrentDispatch()
        throws Exception {
        Method method = DocumentImportTaskMapper.class.getMethod(
            "touchPendingForDispatch",
            BigInteger.class,
            Date.class,
            Date.class,
            BigInteger.class
        );
        Update update = method.getAnnotation(Update.class);
        String sql = String.join(" ", update.value());

        Assert.assertTrue(sql.contains("status='PENDING'"));
        Assert.assertTrue(sql.contains("modified <= #{redispatchBefore}"));
        Assert.assertTrue(sql.contains("modified=#{now}"));
        Assert.assertTrue(sql.contains("batch.status='RUNNING'"));
    }

    /**
     * 验证任务扫描、领取、续租和完成 SQL 均包含批次运行状态门禁。
     */
    @Test
    public void activeTaskSqlShouldFenceInterruptedBatch() {
        for (Method method : DocumentImportTaskMapper.class.getDeclaredMethods()) {
            String name = method.getName();
            if (!"selectPendingFairly".equals(name)
                && !"claimPending".equals(name)
                && !"renewLease".equals(name)
                && !"finishOwned".equals(name)) {
                continue;
            }
            Select select = method.getAnnotation(Select.class);
            Update update = method.getAnnotation(Update.class);
            String sql = select == null
                ? String.join(" ", update.value())
                : String.join(" ", select.value());

            Assert.assertTrue(name + " 缺少批次运行状态门禁",
                sql.contains("batch.status='RUNNING'"));
        }
    }

    /**
     * 验证失败项恢复同时锁定文件项与运行批次，阻止熔断后继续建任务。
     *
     * @throws Exception 映射方法不存在时抛出
     */
    @Test
    public void retryItemSqlShouldRequireRunningBatchLock()
        throws Exception {
        Method method = DocumentImportBatchItemMapper.class.getMethod(
            "selectFailedForRetry",
            BigInteger.class
        );
        Select select = method.getAnnotation(Select.class);
        String sql = String.join(" ", select.value());

        Assert.assertTrue(sql.contains("item.status='FAILED'"));
        Assert.assertTrue(sql.contains("batch.status='RUNNING'"));
        Assert.assertTrue(sql.contains("FOR UPDATE"));
    }

    /**
     * 验证批次中断后，迟到 worker 不能重新打开文件项或覆盖中断原因。
     *
     * @throws Exception 映射方法不存在时抛出
     */
    @Test
    public void itemTransitionSqlShouldFenceInterruptedBatch()
        throws Exception {
        Method method = DocumentImportBatchItemMapper.class.getMethod(
            "transitionStatus",
            BigInteger.class,
            String.class,
            String.class,
            String.class,
            String.class,
            String.class,
            boolean.class,
            int.class,
            Date.class
        );
        Update update = method.getAnnotation(Update.class);
        String sql = String.join(" ", update.value());

        Assert.assertTrue(sql.contains(
            "INNER JOIN tb_document_import_batch batch"));
        Assert.assertTrue(sql.contains("batch.status<>'INTERRUPTED'"));
    }

    /**
     * 验证批次中断同步文档状态时兼容历史无效 JSON 扩展字段。
     *
     * @throws Exception 映射方法不存在时抛出
     */
    @Test
    public void interruptDocumentSqlShouldHandleInvalidOptions()
        throws Exception {
        Method method = DocumentImportTaskMapper.class.getMethod(
            "interruptActiveDocuments",
            BigInteger.class,
            String.class,
            String.class,
            Date.class,
            BigInteger.class
        );
        Update update = method.getAnnotation(Update.class);
        String sql = String.join(" ", update.value());

        Assert.assertTrue(sql.contains("JSON_VALID(document.options)"));
        Assert.assertTrue(sql.contains("ELSE JSON_OBJECT()"));
    }

    /**
     * 验证分块策略快照使用忽略 null 的部分实体更新。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void persistAppliedStrategyShouldKeepRequiredBatchItemFields()
        throws Exception {
        BigInteger itemId = BigInteger.valueOf(31);
        DocumentImportBatchItemService itemService =
            Mockito.mock(DocumentImportBatchItemService.class);
        Mockito.when(itemService.updateById(Mockito.any()))
            .thenReturn(true);
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportBatchItemService", itemService);

        Method method =
            KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
                "persistAppliedStrategy",
                BigInteger.class,
                StrategyConfig.class
            );
        method.setAccessible(true);
        method.invoke(service, itemId, StrategyConfig.defaults());

        ArgumentCaptor<DocumentImportBatchItem> updateCaptor =
            ArgumentCaptor.forClass(DocumentImportBatchItem.class);
        Mockito.verify(itemService).updateById(updateCaptor.capture());
        Mockito.verify(itemService, Mockito.never()).updateById(
            Mockito.any(), Mockito.anyBoolean());
        Assert.assertEquals(itemId, updateCaptor.getValue().getId());
        Assert.assertNotNull(
            updateCaptor.getValue().getStrategySnapshotJson());
    }

    /**
     * 验证 SPLIT 待处理任务使用 execution token 和租约原子领取。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void tryMarkSplitTaskRunningShouldCreateExecutionFence()
        throws Exception {
        BigInteger taskId = BigInteger.valueOf(35);
        DocumentImportTask task = new DocumentImportTask();
        task.setId(taskId);
        task.setPhase(DocumentImportTaskPhase.SPLIT.name());
        task.setStatus(DocumentImportTaskStatus.PENDING.name());

        DocumentImportTaskService taskService =
            Mockito.mock(DocumentImportTaskService.class);
        Mockito.when(taskService.getById(taskId)).thenReturn(task);
        Mockito.when(taskService.count(
            Mockito.any(com.mybatisflex.core.query.QueryWrapper.class)))
            .thenReturn(0L);
        DocumentImportTaskMapper taskMapper =
            Mockito.mock(DocumentImportTaskMapper.class);
        Mockito.when(taskMapper.claimPending(
            Mockito.eq(taskId),
            Mockito.anyString(),
            Mockito.any(Date.class),
            Mockito.any(Date.class),
            Mockito.nullable(BigInteger.class)
        )).thenReturn(1);
        RedisLockExecutor lockExecutor =
            Mockito.mock(RedisLockExecutor.class);
        RedisLockExecutor.LockHandle lockHandle =
            Mockito.mock(RedisLockExecutor.LockHandle.class);
        Mockito.when(lockExecutor.tryAcquire(
            Mockito.anyString(), Mockito.any(), Mockito.any()
        )).thenReturn(lockHandle);

        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportTaskService", taskService);
        setField(service, "documentImportTaskMapper", taskMapper);
        setField(service, "redisLockExecutor", lockExecutor);
        setField(service, "bulkProperties", new DocumentImportBulkProperties());

        Assert.assertTrue(service.tryMarkTaskRunning(taskId));

        ArgumentCaptor<String> token = ArgumentCaptor.forClass(String.class);
        ArgumentCaptor<Date> lease = ArgumentCaptor.forClass(Date.class);
        Mockito.verify(taskMapper).claimPending(
            Mockito.eq(taskId),
            token.capture(),
            lease.capture(),
            Mockito.any(Date.class),
            Mockito.nullable(BigInteger.class)
        );
        Assert.assertFalse(token.getValue().isBlank());
        Assert.assertTrue(lease.getValue().after(new Date()));
        Mockito.verify(lockHandle).release();
    }

    /**
     * 验证新文档完成后会清理待覆盖的历史文档并清除持久化待办。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void cleanupCompletedReplacementShouldDeleteHistoricalDocument() throws Exception {
        BigInteger itemId = BigInteger.valueOf(901);
        BigInteger documentId = BigInteger.valueOf(902);
        BigInteger replacedDocumentId = BigInteger.valueOf(903);
        BigInteger knowledgeId = BigInteger.valueOf(904);

        DocumentImportBatchItem item = new DocumentImportBatchItem();
        item.setId(itemId);
        item.setDocumentId(documentId);
        item.setReplacedDocumentId(replacedDocumentId);
        tech.easyflow.ai.entity.Document replacement = new tech.easyflow.ai.entity.Document();
        replacement.setId(documentId);
        replacement.setCollectionId(knowledgeId);
        replacement.setProcessStatus(DocumentProcessStatus.COMPLETED.name());
        tech.easyflow.ai.entity.Document historical = new tech.easyflow.ai.entity.Document();
        historical.setId(replacedDocumentId);
        historical.setCollectionId(knowledgeId);
        historical.setProcessStatus(DocumentProcessStatus.COMPLETED.name());

        DocumentImportBatchTracker tracker = Mockito.mock(DocumentImportBatchTracker.class);
        Mockito.when(tracker.requireItem(itemId)).thenReturn(item);
        DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
        Mockito.when(documentMapper.selectOneById(documentId)).thenReturn(replacement);
        Mockito.when(documentMapper.selectOneById(replacedDocumentId)).thenReturn(historical);
        DocumentService documentService = Mockito.mock(DocumentService.class);
        Mockito.when(documentService.removeDoc(replacedDocumentId.toString())).thenReturn(true);
        RedisLockExecutor redisLockExecutor = Mockito.mock(RedisLockExecutor.class);
        RedisLockExecutor.LockHandle lockHandle = Mockito.mock(RedisLockExecutor.LockHandle.class);
        Mockito.when(redisLockExecutor.tryAcquire(
            Mockito.anyString(), Mockito.any(), Mockito.any()
        )).thenReturn(lockHandle);

        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportBatchTracker", tracker);
        setField(service, "documentMapper", documentMapper);
        setField(service, "documentService", documentService);
        setField(service, "redisLockExecutor", redisLockExecutor);

        service.cleanupCompletedReplacement(itemId);

        Mockito.verify(documentService).removeDoc(replacedDocumentId.toString());
        Mockito.verify(documentMapper).deleteById(replacedDocumentId);
        Mockito.verify(tracker).clearReplacement(itemId, replacedDocumentId);
        Mockito.verify(lockHandle).release();
    }

    /**
     * 验证失联且关联文档已删除的运行任务会被收口，避免持续占用并发名额。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void recoverInterruptedTaskShouldFailOrphanWithoutDocument() throws Exception {
        BigInteger taskId = BigInteger.valueOf(41);
        Date cutoff = new Date(System.currentTimeMillis() - 60_000L);
        DocumentImportTask task = new DocumentImportTask();
        task.setId(taskId);
        task.setDocumentId(BigInteger.valueOf(42));
        task.setPhase(DocumentImportTaskPhase.PARSE.name());
        task.setStatus(DocumentImportTaskStatus.RUNNING.name());
        task.setModified(new Date(cutoff.getTime() - 1_000L));

        DocumentImportTaskService taskService = Mockito.mock(DocumentImportTaskService.class);
        Mockito.when(taskService.getById(taskId)).thenReturn(task);
        DocumentImportTaskMapper taskMapper = Mockito.mock(DocumentImportTaskMapper.class);
        Mockito.when(taskMapper.failExpiredOwned(
            Mockito.eq(taskId),
            Mockito.isNull(),
            Mockito.anyString(),
            Mockito.anyString(),
            Mockito.any(Date.class),
            Mockito.eq(cutoff),
            Mockito.any()
        )).thenReturn(1);
        DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
        Mockito.when(documentMapper.selectOneById(task.getDocumentId())).thenReturn(null);

        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportTaskService", taskService);
        setField(service, "documentImportTaskMapper", taskMapper);
        setField(service, "documentMapper", documentMapper);

        service.recoverInterruptedTask(taskId, cutoff);

        Mockito.verify(taskMapper).failExpiredOwned(
            Mockito.eq(taskId),
            Mockito.isNull(),
            Mockito.eq("任务执行中断，请重试"),
            Mockito.eq("execution_interrupted"),
            Mockito.any(Date.class),
            Mockito.eq(cutoff),
            Mockito.any()
        );
    }

    /**
     * 验证运行中断收口后仍保留批次文件的人工重试资格。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void recoveredBatchStateShouldRemainRetryableBeforeAttemptLimit()
        throws Exception {
        BigInteger batchId = BigInteger.valueOf(61);
        BigInteger itemId = BigInteger.valueOf(62);
        DocumentImportTask task = new DocumentImportTask();
        task.setId(BigInteger.valueOf(60));
        task.setBatchId(batchId);
        task.setBatchItemId(itemId);
        DocumentImportBatchItem item = new DocumentImportBatchItem();
        item.setId(itemId);
        item.setAttemptCount(0);

        DocumentImportBatchTracker tracker =
            Mockito.mock(DocumentImportBatchTracker.class);
        DocumentImportBatchCircuitBreaker circuitBreaker =
            Mockito.mock(DocumentImportBatchCircuitBreaker.class);
        Mockito.when(tracker.requireItem(itemId)).thenReturn(item);
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportBatchTracker", tracker);
        setField(service, "documentImportBatchCircuitBreaker", circuitBreaker);
        setField(service, "bulkProperties", new DocumentImportBulkProperties());
        Method method = KnowledgeDocumentImportTaskAppService.class
            .getDeclaredMethod(
                "finishRecoveredBatchState",
                DocumentImportTask.class,
                DocumentImportBatchItemStage.class,
                String.class,
                String.class
            );
        method.setAccessible(true);

        TransactionSynchronizationManager.setActualTransactionActive(true);
        TransactionSynchronizationManager.initSynchronization();
        try {
            method.invoke(
                service,
                task,
                DocumentImportBatchItemStage.PARSE,
                "任务执行中断，请继续批次",
                "execution_interrupted"
            );
        } finally {
            TransactionSynchronizationManager.clearSynchronization();
            TransactionSynchronizationManager.setActualTransactionActive(false);
        }

        Mockito.verify(tracker).transitionItem(
            itemId,
            DocumentImportBatchItemStage.PARSE,
            DocumentImportBatchItemStatus.FAILED,
            "任务执行中断，请继续批次",
            true,
            0,
            "execution_interrupted"
        );
        Mockito.verify(circuitBreaker).interruptBatchInCurrentTransaction(
            Mockito.eq(batchId),
            Mockito.eq("execution_interrupted"),
            Mockito.eq("任务执行中断，请继续批次"),
            Mockito.any(IllegalStateException.class)
        );
    }

    /**
     * 验证待处理超时按创建时间判定，即使重新投递刷新了修改时间也会正常收口。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void expireTimedOutPendingTaskShouldUseCreatedTime() throws Exception {
        BigInteger taskId = BigInteger.valueOf(51);
        BigInteger documentId = BigInteger.valueOf(52);
        Date cutoff = new Date(System.currentTimeMillis() - 60_000L);
        DocumentImportTask task = new DocumentImportTask();
        task.setId(taskId);
        task.setDocumentId(documentId);
        task.setPhase(DocumentImportTaskPhase.PARSE.name());
        task.setStatus(DocumentImportTaskStatus.PENDING.name());
        task.setCreated(new Date(cutoff.getTime() - 1_000L));
        task.setModified(new Date());

        tech.easyflow.ai.entity.Document document = new tech.easyflow.ai.entity.Document();
        document.setId(documentId);
        document.setCollectionId(BigInteger.valueOf(53));
        AtomicReference<tech.easyflow.ai.entity.Document> updatedDocumentRef =
            new AtomicReference<tech.easyflow.ai.entity.Document>();

        DocumentImportTaskService taskService = Mockito.mock(DocumentImportTaskService.class);
        Mockito.when(taskService.getById(taskId)).thenReturn(task);
        DocumentImportTaskMapper taskMapper = Mockito.mock(DocumentImportTaskMapper.class);
        Mockito.when(taskMapper.updateByQuery(
            Mockito.any(DocumentImportTask.class),
            Mockito.any()
        )).thenReturn(1);

        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportTaskService", taskService);
        setField(service, "documentImportTaskMapper", taskMapper);
        setField(service, "documentMapper", mockDocumentMapper(document, updatedDocumentRef));
        setField(service, "documentImportTaskStatusStreamService", new NoopTaskStatusStreamService());

        service.expireTimedOutPendingTask(taskId, cutoff);

        tech.easyflow.ai.entity.Document updatedDocument = updatedDocumentRef.get();
        Assert.assertNotNull(updatedDocument);
        Assert.assertEquals(DocumentProcessStatus.PARSE_FAILED.name(), updatedDocument.getProcessStatus());
        Assert.assertEquals("任务排队超时，请重试", updatedDocument.getLastTaskError());
        Assert.assertEquals("pending_timeout",
            updatedDocument.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_TASK_ERROR_CODE));
    }

    /**
     * 验证未取得 MinerU 任务 ID 的超时提交会失败并释放运行状态。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void expireTimedOutParseSubmissionShouldExposeFriendlyTimeout() throws Exception {
        BigInteger taskId = BigInteger.valueOf(61);
        BigInteger documentId = BigInteger.valueOf(62);
        Date cutoff = new Date(System.currentTimeMillis() - 60_000L);
        DocumentImportTask task = new DocumentImportTask();
        task.setId(taskId);
        task.setDocumentId(documentId);
        task.setPhase(DocumentImportTaskPhase.PARSE.name());
        task.setStatus(DocumentImportTaskStatus.RUNNING.name());
        task.setStartedAt(new Date(cutoff.getTime() - 1_000L));

        tech.easyflow.ai.entity.Document document = new tech.easyflow.ai.entity.Document();
        document.setId(documentId);
        document.setCollectionId(BigInteger.valueOf(63));
        AtomicReference<tech.easyflow.ai.entity.Document> updatedDocumentRef =
            new AtomicReference<tech.easyflow.ai.entity.Document>();

        DocumentImportTaskService taskService = Mockito.mock(DocumentImportTaskService.class);
        Mockito.when(taskService.getById(taskId)).thenReturn(task);
        DocumentImportTaskMapper taskMapper = Mockito.mock(DocumentImportTaskMapper.class);
        Mockito.when(taskMapper.updateByQuery(
            Mockito.any(DocumentImportTask.class),
            Mockito.any()
        )).thenReturn(1);

        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentImportTaskService", taskService);
        setField(service, "documentImportTaskMapper", taskMapper);
        setField(service, "documentMapper", mockDocumentMapper(document, updatedDocumentRef));
        setField(service, "documentImportTaskStatusStreamService", new NoopTaskStatusStreamService());

        service.expireTimedOutParseSubmission(taskId, cutoff);

        ArgumentCaptor<DocumentImportTask> updateCaptor = ArgumentCaptor.forClass(DocumentImportTask.class);
        Mockito.verify(taskMapper).updateByQuery(updateCaptor.capture(), Mockito.any());
        Assert.assertEquals(DocumentImportTaskStatus.FAILED.name(), updateCaptor.getValue().getStatus());
        Assert.assertEquals("文档解析服务响应超时，请重试", updateCaptor.getValue().getErrorSummary());

        tech.easyflow.ai.entity.Document updatedDocument = updatedDocumentRef.get();
        Assert.assertNotNull(updatedDocument);
        Assert.assertEquals(DocumentProcessStatus.PARSE_FAILED.name(), updatedDocument.getProcessStatus());
        Assert.assertEquals("文档解析服务响应超时，请重试", updatedDocument.getLastTaskError());
        Assert.assertEquals("parse_service_timeout",
            updatedDocument.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_TASK_ERROR_CODE));
    }

    /**
     * 验证 MinerU 任意 5xx 会归一化为服务暂不可用错误。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void resolveParseFailureCodeShouldRecognizeMineruServerErrors() throws Exception {
        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "resolveParseFailureCode",
            Throwable.class
        );
        method.setAccessible(true);

        for (int statusCode : new int[] {500, 502, 503, 504, 599}) {
            Object code = method.invoke(service,
                new RuntimeException("MinerU request failed: path=/tasks, status=" + statusCode + ", body="));
            Assert.assertEquals("status=" + statusCode, "parse_service_unavailable", code);
        }
    }

    /**
     * 验证底层读取超时会归一化为解析服务超时错误。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void resolveParseFailureCodeShouldRecognizeSocketTimeout() throws Exception {
        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "resolveParseFailureCode",
            Throwable.class
        );
        method.setAccessible(true);

        Object code = method.invoke(service,
            new RuntimeException("wrapped", new SocketTimeoutException("timeout")));

        Assert.assertEquals("parse_service_timeout", code);
    }

    /**
     * 验证文档源读取失败优先于底层 UnknownHostException 分类，避免误报 MinerU 不可用。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void resolveParseFailureCodeShouldPreferDocumentSourceFailure() throws Exception {
        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "resolveParseFailureCode",
            Throwable.class
        );
        method.setAccessible(true);

        DocumentParseBridgeException sourceError = DocumentParseBridgeException.sourceLoadFailed(
            "下载文档 URL 失败",
            new UnknownHostException("远端文档地址不允许访问非公网目标")
        );
        Object code = method.invoke(service, new RuntimeException("wrapped", sourceError));

        Assert.assertEquals("document_source_unavailable", code);
    }

    /**
     * 验证解析桥接层的确定性输入错误会转换为不可重试错误码。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void resolveParseFailureCodeShouldClassifyPermanentBridgeFailures()
        throws Exception {
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        Method method = KnowledgeDocumentImportTaskAppService.class
            .getDeclaredMethod("resolveParseFailureCode", Throwable.class);
        method.setAccessible(true);

        Object unsupported = method.invoke(
            service,
            DocumentParseBridgeException.unsupportedSource("不支持的文件类型")
        );
        Object invalidRequest = method.invoke(
            service,
            DocumentParseBridgeException.requestBuildFailed("解析请求无效")
        );

        Assert.assertEquals("unsupported_document_source", unsupported);
        Assert.assertEquals("invalid_parse_request", invalidRequest);
    }

    /**
     * 验证 MinerU 不支持文件类型的响应经桥接异常包装后仍能安全归类。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void resolveParseFailureShouldRecognizeWrappedUnsupportedFileType()
        throws Exception {
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        Method codeMethod = KnowledgeDocumentImportTaskAppService.class
            .getDeclaredMethod("resolveParseFailureCode", Throwable.class);
        Method messageMethod = KnowledgeDocumentImportTaskAppService.class
            .getDeclaredMethod("resolveParseFailureMessage", Throwable.class, String.class);
        codeMethod.setAccessible(true);
        messageMethod.setAccessible(true);
        DocumentParseBridgeException error = DocumentParseBridgeException.taskFailed(
            "异步解析任务失败",
            new RuntimeException(
                "MinerU request failed: path=/tasks, status=400, "
                    + "body={\"detail\":\"Unsupported file type: doc\"}"
            )
        );

        String code = (String) codeMethod.invoke(service, error);
        String message = (String) messageMethod.invoke(service, error, code);

        Assert.assertEquals("unsupported_document_source", code);
        Assert.assertEquals("文档格式或来源不受解析服务支持，请检查文件后继续", message);
        Assert.assertFalse(message.contains("doc"));
    }

    /**
     * 验证未识别的系统异常仍会获得稳定错误码并允许后续人工重试。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void resolveParseFailureCodeShouldStabilizeUnknownFailures()
        throws Exception {
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        Method method = KnowledgeDocumentImportTaskAppService.class
            .getDeclaredMethod("resolveParseFailureCode", Throwable.class);
        method.setAccessible(true);

        Object code = method.invoke(service, new IllegalStateException("代码异常"));

        Assert.assertEquals("parse_failed", code);
    }

    /**
     * 验证人工批次继续不受历史尝试次数限制。
     *
     * @throws Exception 反射调用或依赖注入异常
     */
    @Test
    public void retryBatchItemShouldIgnoreHistoricalAttemptCount()
        throws Exception {
        KnowledgeDocumentImportTaskAppService service = Mockito.spy(
            new KnowledgeDocumentImportTaskAppService());
        DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
        setField(service, "documentMapper", documentMapper);
        DocumentImportBatchItem failed = new DocumentImportBatchItem();
        failed.setId(BigInteger.valueOf(701));
        failed.setKnowledgeId(BigInteger.valueOf(702));
        failed.setDocumentId(BigInteger.valueOf(703));
        failed.setStage(DocumentImportBatchItemStage.INDEX.name());
        failed.setStatus(DocumentImportBatchItemStatus.FAILED.name());
        failed.setAttemptCount(99);
        tech.easyflow.ai.entity.Document document =
            new tech.easyflow.ai.entity.Document();
        document.setId(failed.getDocumentId());
        document.setProcessStatus(DocumentProcessStatus.INDEX_FAILED.name());
        Mockito.when(documentMapper.selectOneById(failed.getDocumentId()))
            .thenReturn(document);
        Mockito.doReturn(null).when(service).retryFailedTask(
            Mockito.any(DocumentImportDtos.TaskRetryRequest.class));

        Method method = KnowledgeDocumentImportTaskAppService.class
            .getDeclaredMethod("retryBatchItem", DocumentImportBatchItem.class);
        method.setAccessible(true);
        method.invoke(service, failed);

        Mockito.verify(service).retryFailedTask(Mockito.argThat(request ->
            failed.getKnowledgeId().equals(request.getKnowledgeId())
                && failed.getDocumentId().equals(request.getDocumentId())
        ));
    }

    /**
     * 验证中断时形成的无文档失败项可以重新建档并创建解析任务。
     *
     * @throws Exception 反射调用或依赖注入异常
     */
    @Test
    public void retryBatchItemWithoutDocumentShouldRecreateImportTask()
        throws Exception {
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
        DocumentImportTaskService taskService =
            Mockito.mock(DocumentImportTaskService.class);
        DocumentCollectionService knowledgeService =
            Mockito.mock(DocumentCollectionService.class);
        DocumentImportBatchTracker tracker =
            Mockito.mock(DocumentImportBatchTracker.class);
        DocumentImportParseTaskProducer producer =
            Mockito.mock(DocumentImportParseTaskProducer.class);
        BigInteger itemId = BigInteger.valueOf(711);
        BigInteger batchId = BigInteger.valueOf(712);
        BigInteger knowledgeId = BigInteger.valueOf(713);
        BigInteger taskId = BigInteger.valueOf(714);
        DocumentImportBatchItem failed = new DocumentImportBatchItem();
        failed.setId(itemId);
        failed.setBatchId(batchId);
        failed.setKnowledgeId(knowledgeId);
        failed.setFileName("recover.docx");
        failed.setRelativePath("folder/recover.docx");
        failed.setFilePath("/stored/recover.docx");
        failed.setStage(DocumentImportBatchItemStage.UPLOAD.name());
        failed.setStatus(DocumentImportBatchItemStatus.FAILED.name());
        DocumentImportBatch batch = new DocumentImportBatch();
        batch.setId(batchId);
        batch.setKnowledgeId(knowledgeId);
        batch.setImportMode(DocumentImportMode.AUTO.name());
        batch.setStatus(DocumentImportBatchStatus.RUNNING.name());
        DocumentCollection knowledge = new DocumentCollection();
        knowledge.setId(knowledgeId);
        Mockito.when(tracker.requireBatch(batchId)).thenReturn(batch);
        Mockito.when(knowledgeService.getById(knowledgeId))
            .thenReturn(knowledge);
        Mockito.when(taskService.save(Mockito.any(DocumentImportTask.class)))
            .thenAnswer(invocation -> {
                DocumentImportTask task = invocation.getArgument(0);
                task.setId(taskId);
                return true;
            });
        setField(service, "documentMapper", documentMapper);
        setField(service, "documentImportTaskService", taskService);
        setField(service, "knowledgeService", knowledgeService);
        setField(service, "documentImportBatchTracker", tracker);
        setField(service, "parseTaskProducer", producer);

        Method method = KnowledgeDocumentImportTaskAppService.class
            .getDeclaredMethod("retryBatchItem", DocumentImportBatchItem.class);
        method.setAccessible(true);
        method.invoke(service, failed);

        ArgumentCaptor<tech.easyflow.ai.entity.Document> documentCaptor =
            ArgumentCaptor.forClass(tech.easyflow.ai.entity.Document.class);
        Mockito.verify(documentMapper).insert(documentCaptor.capture());
        tech.easyflow.ai.entity.Document document = documentCaptor.getValue();
        Assert.assertNotNull(document.getId());
        Assert.assertEquals(knowledgeId, document.getCollectionId());
        Assert.assertEquals(DocumentProcessStatus.PARSING.name(),
            document.getProcessStatus());
        ArgumentCaptor<DocumentImportTask> taskCaptor =
            ArgumentCaptor.forClass(DocumentImportTask.class);
        Mockito.verify(taskService).save(taskCaptor.capture());
        Assert.assertEquals(taskId, taskCaptor.getValue().getId());
        Assert.assertEquals(batchId, taskCaptor.getValue().getBatchId());
        Assert.assertEquals(itemId, taskCaptor.getValue().getBatchItemId());
        Assert.assertEquals(DocumentImportTaskPhase.PARSE.name(),
            taskCaptor.getValue().getPhase());
        Mockito.verify(tracker).bindDocument(itemId, document.getId());
        Mockito.verify(producer).send(taskId);
    }

    /**
     * 验证无文档失败项绑定 SQL 同时校验失败状态、空文档和运行批次。
     *
     * @throws Exception Mapper 方法不存在时抛出
     */
    @Test
    public void bindFailedDocumentSqlShouldFenceRecoveryState()
        throws Exception {
        Method method = DocumentImportBatchItemMapper.class.getMethod(
            "bindFailedDocument",
            BigInteger.class,
            BigInteger.class,
            Date.class
        );
        Update update = method.getAnnotation(Update.class);
        String sql = String.join(" ", update.value());

        Assert.assertTrue(sql.contains("item.status='FAILED'"));
        Assert.assertTrue(sql.contains("item.document_id IS NULL"));
        Assert.assertTrue(sql.contains("batch.status='RUNNING'"));
        Assert.assertTrue(sql.contains("item.status='PENDING'"));
    }

    /**
     * 验证批次上传文件通过受信任存储服务读取为字节，避免内部附件 URL 进入公网 URL 校验。
     *
     * @throws Exception 反射调用或存储桩异常
     */
    @Test
    public void buildBridgeSourceRefShouldReadBatchFileFromStorage() throws Exception {
        byte[] content = "batch-document".getBytes(StandardCharsets.UTF_8);
        String storedUrl = "http://127.0.0.1:39000/easyflow/attachment/test.docx";
        FileStorageService storageService = Mockito.mock(FileStorageService.class);
        Mockito.when(storageService.readStream(storedUrl))
            .thenReturn(new ByteArrayInputStream(content));

        DocumentImportBulkProperties properties = new DocumentImportBulkProperties();
        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        setField(service, "storageService", storageService);
        setField(service, "bulkProperties", properties);

        DocumentImportTask task = new DocumentImportTask();
        task.setBatchId(BigInteger.valueOf(61));
        tech.easyflow.ai.entity.Document document = new tech.easyflow.ai.entity.Document();
        document.setTitle("test.docx");
        document.setDocumentPath(storedUrl);

        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "buildBridgeSourceRef",
            DocumentImportTask.class,
            tech.easyflow.ai.entity.Document.class,
            String.class
        );
        method.setAccessible(true);
        DocumentSourceRef sourceRef = (DocumentSourceRef) method.invoke(service, task, document, "docx");

        Assert.assertArrayEquals(content, sourceRef.getContentBytes());
        Assert.assertNull(sourceRef.getFilePath());
        Assert.assertEquals(Long.valueOf(content.length), sourceRef.getSize());
        Mockito.verify(storageService).readStream(storedUrl);
    }

    /**
     * 验证非批次远程文档仍保留 URL，由文档源加载器继续执行 SSRF 防护。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void buildBridgeSourceRefShouldKeepExternalUrlForSsrfValidation() throws Exception {
        FileStorageService storageService = Mockito.mock(FileStorageService.class);
        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        setField(service, "storageService", storageService);

        DocumentImportTask task = new DocumentImportTask();
        tech.easyflow.ai.entity.Document document = new tech.easyflow.ai.entity.Document();
        document.setTitle("external.pdf");
        document.setDocumentPath("https://example.com/external.pdf");

        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "buildBridgeSourceRef",
            DocumentImportTask.class,
            tech.easyflow.ai.entity.Document.class,
            String.class
        );
        method.setAccessible(true);
        DocumentSourceRef sourceRef = (DocumentSourceRef) method.invoke(service, task, document, "pdf");

        Assert.assertEquals("https://example.com/external.pdf", sourceRef.getFilePath());
        Assert.assertNull(sourceRef.getContentBytes());
        Mockito.verifyNoInteractions(storageService);
    }

    /**
     * 验证向量化失败会按整文档失败语义重置进度，并刷新任务错误信息。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void markIndexFailedShouldResetProgressAndPersistLatestError() throws Exception {
        BigInteger documentId = BigInteger.valueOf(10);
        BigInteger knowledgeId = BigInteger.valueOf(20);

        tech.easyflow.ai.entity.Document persistedDocument = new tech.easyflow.ai.entity.Document();
        persistedDocument.setId(documentId);
        persistedDocument.setCollectionId(knowledgeId);
        persistedDocument.setProcessStatus(DocumentProcessStatus.INDEXING.name());
        persistedDocument.setTotalChunks(8);
        persistedDocument.setCompletedChunks(5);
        persistedDocument.setFailedChunks(1);
        persistedDocument.setProgressPercent(63);
        persistedDocument.setLastTaskError("旧错误");

        AtomicReference<tech.easyflow.ai.entity.Document> updatedDocumentRef = new AtomicReference<tech.easyflow.ai.entity.Document>();
        DocumentImportTaskMapper taskMapper = Mockito.mock(DocumentImportTaskMapper.class);
        Mockito.when(taskMapper.finishOwned(
            Mockito.any(), Mockito.anyString(), Mockito.anyString(),
            Mockito.any(), Mockito.anyString(), Mockito.any(),
            Mockito.any()
        )).thenReturn(1);

        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentMapper", mockDocumentMapper(persistedDocument, updatedDocumentRef));
        setField(service, "documentImportTaskMapper", taskMapper);
        setField(service, "documentImportTaskStatusStreamService", new NoopTaskStatusStreamService());

        DocumentImportTask task = new DocumentImportTask();
        task.setId(BigInteger.valueOf(30));
        task.setDocumentId(documentId);
        task.setKnowledgeId(knowledgeId);
        task.setStatus(DocumentImportTaskStatus.RUNNING.name());
        task.setErrorSummary("旧错误");
        task.setExecutionToken("attempt-token");

        tech.easyflow.ai.entity.Document inputDocument = new tech.easyflow.ai.entity.Document();
        inputDocument.setId(documentId);
        inputDocument.setCollectionId(knowledgeId);

        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "markIndexFailed",
            DocumentImportTask.class,
            tech.easyflow.ai.entity.Document.class,
            String.class
        );
        method.setAccessible(true);
        method.invoke(service, task, inputDocument, "新错误");

        tech.easyflow.ai.entity.Document updatedDocument = updatedDocumentRef.get();
        Assert.assertNotNull(updatedDocument);
        Assert.assertEquals(DocumentProcessStatus.INDEX_FAILED.name(), updatedDocument.getProcessStatus());
        Assert.assertEquals(Integer.valueOf(0), updatedDocument.getCompletedChunks());
        Assert.assertEquals(Integer.valueOf(8), updatedDocument.getFailedChunks());
        Assert.assertEquals(Integer.valueOf(0), updatedDocument.getProgressPercent());
        Assert.assertEquals("新错误", updatedDocument.getLastTaskError());
        Assert.assertEquals("index_failed",
            updatedDocument.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_TASK_ERROR_CODE));

        Mockito.verify(taskMapper).finishOwned(
            Mockito.eq(task.getId()),
            Mockito.eq("attempt-token"),
            Mockito.eq(DocumentImportTaskStatus.FAILED.name()),
            Mockito.eq("新错误"),
            Mockito.eq("index_failed"),
            Mockito.any(Date.class),
            Mockito.any()
        );
    }

    /**
     * 验证自动导入向量化失败后从分块阶段重试，并废弃旧分块快照。
     *
     * @throws Exception 反射注入异常
     */
    @Test
    public void retryIndexTaskShouldResplitAutoBatchAfterIndexFailure()
        throws Exception {
        BigInteger knowledgeId = BigInteger.valueOf(101);
        BigInteger documentId = BigInteger.valueOf(102);
        BigInteger batchId = BigInteger.valueOf(103);
        BigInteger batchItemId = BigInteger.valueOf(104);
        BigInteger splitTaskId = BigInteger.valueOf(105);
        String staleSnapshotPath = "document-import/chunks/stale.snapshot";

        DocumentCollection knowledge = new DocumentCollection();
        knowledge.setId(knowledgeId);
        tech.easyflow.ai.entity.Document document =
            new tech.easyflow.ai.entity.Document();
        document.setId(documentId);
        document.setCollectionId(knowledgeId);
        document.setProcessStatus(DocumentProcessStatus.INDEX_FAILED.name());
        Map<String, Object> options = new LinkedHashMap<String, Object>();
        options.put(DocumentImportKeys.KEY_DOCUMENT_IMPORT_BATCH_ID,
            batchId.toString());
        options.put(DocumentImportKeys.KEY_DOCUMENT_IMPORT_BATCH_ITEM_ID,
            batchItemId.toString());
        options.put(DocumentImportKeys.KEY_DOCUMENT_CHUNK_SNAPSHOT_PATH,
            staleSnapshotPath);
        document.setOptions(options);

        DocumentImportBatch batch = new DocumentImportBatch();
        batch.setId(batchId);
        batch.setRequestedStrategyJson("{\"strategyCode\":\"AUTO\"}");

        DocumentCollectionService knowledgeService =
            Mockito.mock(DocumentCollectionService.class);
        Mockito.when(knowledgeService.getById(knowledgeId)).thenReturn(knowledge);
        DocumentMapper documentMapper = Mockito.mock(DocumentMapper.class);
        Mockito.when(documentMapper.selectOneById(documentId)).thenReturn(document);
        Mockito.when(documentMapper.updateByQuery(
            Mockito.any(tech.easyflow.ai.entity.Document.class),
            Mockito.any(com.mybatisflex.core.query.QueryWrapper.class)))
            .thenReturn(1);
        DocumentImportBatchTracker tracker =
            Mockito.mock(DocumentImportBatchTracker.class);
        Mockito.when(tracker.isAutoBatch(batchId)).thenReturn(true);
        Mockito.when(tracker.requireBatch(batchId)).thenReturn(batch);
        DocumentImportTaskService taskService =
            Mockito.mock(DocumentImportTaskService.class);
        AtomicReference<DocumentImportTask> createdTask =
            new AtomicReference<DocumentImportTask>();
        Mockito.doAnswer(invocation -> {
            DocumentImportTask task = invocation.getArgument(0);
            task.setId(splitTaskId);
            createdTask.set(task);
            return true;
        }).when(taskService).save(Mockito.any(DocumentImportTask.class));
        DocumentImportSplitTaskProducer splitTaskProducer =
            Mockito.mock(DocumentImportSplitTaskProducer.class);
        DocumentImportSnapshotCleanupService cleanupService =
            Mockito.mock(DocumentImportSnapshotCleanupService.class);
        ThreadPoolTaskExecutor taskExecutor =
            Mockito.mock(ThreadPoolTaskExecutor.class);

        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        setField(service, "knowledgeService", knowledgeService);
        setField(service, "documentMapper", documentMapper);
        setField(service, "documentImportBatchTracker", tracker);
        setField(service, "documentImportTaskService", taskService);
        setField(service, "splitTaskProducer", splitTaskProducer);
        setField(service, "snapshotCleanupService", cleanupService);
        setField(service, "documentImportTaskExecutor", taskExecutor);
        setField(service, "documentImportTaskStatusStreamService",
            new NoopTaskStatusStreamService());

        DocumentImportDtos.TaskRetryRequest request =
            new DocumentImportDtos.TaskRetryRequest();
        request.setKnowledgeId(knowledgeId);
        request.setDocumentId(documentId);
        DocumentImportDtos.TaskStartIndexResponse response =
            service.retryIndexTask(request).getData();

        Assert.assertNotNull(response);
        Assert.assertEquals(splitTaskId, response.getTaskId());
        Assert.assertEquals(DocumentProcessStatus.SPLITTING.name(),
            response.getProcessStatus());
        Assert.assertFalse(document.getOptions().containsKey(
            DocumentImportKeys.KEY_DOCUMENT_CHUNK_SNAPSHOT_PATH));
        Assert.assertNotNull(createdTask.get());
        Assert.assertEquals(DocumentImportTaskPhase.SPLIT.name(),
            createdTask.get().getPhase());
        Assert.assertEquals(batch.getRequestedStrategyJson(),
            createdTask.get().getPayloadJson().get("strategyConfigJson"));
        Mockito.verify(splitTaskProducer).send(splitTaskId);
        Mockito.verify(cleanupService).scheduleChunkSnapshot(
            staleSnapshotPath);
        Mockito.verify(documentMapper).update(
            Mockito.argThat(updated -> updated.getOptions() != null
                && !updated.getOptions().containsKey(
                    DocumentImportKeys.KEY_DOCUMENT_CHUNK_SNAPSHOT_PATH)),
            Mockito.eq(false));
        Mockito.verify(tracker).updateItem(
            batchItemId,
            DocumentImportBatchItemStage.SPLIT,
            DocumentImportBatchItemStatus.PENDING,
            null);
    }

    /**
     * 验证旧分块快照只会在事务提交后登记可靠清理。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void deleteChunkSnapshotShouldWaitForTransactionCommit()
        throws Exception {
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        DocumentImportSnapshotCleanupService cleanupService =
            Mockito.mock(DocumentImportSnapshotCleanupService.class);
        setField(service, "snapshotCleanupService", cleanupService);
        Method method = KnowledgeDocumentImportTaskAppService.class
            .getDeclaredMethod("deleteChunkSnapshotAfterCommit", String.class);
        method.setAccessible(true);

        TransactionSynchronizationManager.setActualTransactionActive(true);
        TransactionSynchronizationManager.initSynchronization();
        try {
            method.invoke(service, "document-import/chunks/commit.snapshot");
            Mockito.verifyNoInteractions(cleanupService);

            List<TransactionSynchronization> synchronizations =
                TransactionSynchronizationManager.getSynchronizations();
            for (TransactionSynchronization synchronization : synchronizations) {
                synchronization.afterCommit();
                synchronization.afterCompletion(
                    TransactionSynchronization.STATUS_COMMITTED);
            }
            Mockito.verify(cleanupService).scheduleChunkSnapshot(
                "document-import/chunks/commit.snapshot");
        } finally {
            TransactionSynchronizationManager.clearSynchronization();
            TransactionSynchronizationManager.setActualTransactionActive(false);
        }
    }

    /**
     * 验证事务回滚时保留旧分块快照。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void deleteChunkSnapshotShouldKeepSnapshotAfterRollback()
        throws Exception {
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        DocumentImportSnapshotCleanupService cleanupService =
            Mockito.mock(DocumentImportSnapshotCleanupService.class);
        setField(service, "snapshotCleanupService", cleanupService);
        Method method = KnowledgeDocumentImportTaskAppService.class
            .getDeclaredMethod("deleteChunkSnapshotAfterCommit", String.class);
        method.setAccessible(true);

        TransactionSynchronizationManager.setActualTransactionActive(true);
        TransactionSynchronizationManager.initSynchronization();
        try {
            method.invoke(service, "document-import/chunks/rollback.snapshot");
            for (TransactionSynchronization synchronization
                : TransactionSynchronizationManager.getSynchronizations()) {
                synchronization.afterCompletion(
                    TransactionSynchronization.STATUS_ROLLED_BACK);
            }
            Mockito.verifyNoInteractions(cleanupService);
        } finally {
            TransactionSynchronizationManager.clearSynchronization();
            TransactionSynchronizationManager.setActualTransactionActive(false);
        }
    }

    /**
     * 验证 Office 超长正文也会应用统一的 BGE-M3 硬限制。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void officeChunksShouldUseSharedBgeM3HardLimit()
        throws Exception {
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        DocumentChunk source = new DocumentChunk();
        source.setId(BigInteger.valueOf(501));
        source.setDocumentId(BigInteger.valueOf(502));
        source.setDocumentCollectionId(BigInteger.valueOf(503));
        source.setContent("长".repeat(
            RagDefaults.BGE_M3_HARD_CHUNK_TOKEN_LIMIT + 100));
        Map<String, Object> options = new LinkedHashMap<String, Object>();
        options.put(
            DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN,
            source.getContent());
        source.setOptions(options);

        Method method = KnowledgeDocumentImportTaskAppService.class
            .getDeclaredMethod("enforceDocumentChunkHardLimit", List.class);
        method.setAccessible(true);
        @SuppressWarnings("unchecked")
        List<DocumentChunk> result =
            (List<DocumentChunk>) method.invoke(service, List.of(source));

        Assert.assertTrue(result.size() > 1);
        Assert.assertTrue(result.stream().allMatch(
            chunk -> BgeM3ChunkSafety.isWithinHardLimit(chunk.getContent())));
        Assert.assertTrue(result.stream().allMatch(
            chunk -> BgeM3ChunkSafety.isWithinHardLimit(String.valueOf(
                chunk.getOptions().get(
                    DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN)))));
    }

    /**
     * 验证服务端拒绝无法推进游标的分块重叠参数。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void strategyOverlapShouldBeSmallerThanChunkSize()
        throws Exception {
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        StrategyConfig config = StrategyConfig.defaults();
        config.setChunkSize(128);
        config.setOverlapSize(128);
        Method method = KnowledgeDocumentImportTaskAppService.class
            .getDeclaredMethod("validateStrategyOverlap", StrategyConfig.class);
        method.setAccessible(true);

        try {
            method.invoke(service, config);
            Assert.fail("重叠大小等于分块大小时应拒绝请求");
        } catch (InvocationTargetException expected) {
            Assert.assertTrue(expected.getCause() instanceof BusinessException);
        }
    }

    /**
     * 验证 AUTO 空结果使用默认长度与重叠参数回退自然段长度拆分。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void autoEmptyChunksShouldFallbackWithDefaultParagraphSettings()
        throws Exception {
        assertAutoParagraphFallback(RagDefaults.CHUNK_SIZE, RagDefaults.OVERLAP_SIZE);
    }

    /**
     * 验证手动预览选择 AUTO 时，兜底保留页面传入的长度与重叠参数。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void autoEmptyChunksShouldFallbackWithRequestedParagraphSettings()
        throws Exception {
        assertAutoParagraphFallback(768, 192);
    }

    /**
     * 验证显式选择非 AUTO 策略时不触发兜底。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void explicitStrategyEmptyChunksShouldNotFallback() throws Exception {
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        RagIngestionService ragIngestionService = Mockito.mock(RagIngestionService.class);
        setField(service, "ragIngestionService", ragIngestionService);
        AnalysisResult analysis = Mockito.mock(AnalysisResult.class);
        StrategyConfig requestedStrategy = StrategyConfig.defaults();
        requestedStrategy.setStrategyCode(RagStrategyCodes.OUTLINE_SECTION);
        StrategyConfig effectiveStrategy = requestedStrategy.copy();
        Mockito.when(ragIngestionService.split(analysis, effectiveStrategy))
            .thenReturn(List.of());

        List<RagChunk> chunks = invokeAutoParagraphFallback(
            service, analysis, requestedStrategy, effectiveStrategy);

        Assert.assertTrue(chunks.isEmpty());
        Assert.assertEquals(RagStrategyCodes.OUTLINE_SECTION, effectiveStrategy.getStrategyCode());
        Mockito.verify(ragIngestionService).split(analysis, effectiveStrategy);
    }

    /**
     * 验证看门狗已经收口任务后，迟到执行者不能覆盖文档终态。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void markIndexFailedShouldIgnoreLostTaskOwnership() throws Exception {
        BigInteger documentId = BigInteger.valueOf(81);
        tech.easyflow.ai.entity.Document persistedDocument = new tech.easyflow.ai.entity.Document();
        persistedDocument.setId(documentId);
        persistedDocument.setProcessStatus(DocumentProcessStatus.PARSE_FAILED.name());
        AtomicReference<tech.easyflow.ai.entity.Document> updatedDocumentRef =
            new AtomicReference<tech.easyflow.ai.entity.Document>();
        DocumentImportTaskMapper taskMapper = Mockito.mock(DocumentImportTaskMapper.class);
        Mockito.when(taskMapper.updateByQuery(
            Mockito.any(DocumentImportTask.class),
            Mockito.any()
        )).thenReturn(0);

        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        setField(service, "documentMapper", mockDocumentMapper(persistedDocument, updatedDocumentRef));
        setField(service, "documentImportTaskMapper", taskMapper);

        DocumentImportTask task = new DocumentImportTask();
        task.setId(BigInteger.valueOf(82));
        task.setDocumentId(documentId);
        task.setStatus(DocumentImportTaskStatus.RUNNING.name());
        tech.easyflow.ai.entity.Document inputDocument = new tech.easyflow.ai.entity.Document();
        inputDocument.setId(documentId);

        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "markIndexFailed",
            DocumentImportTask.class,
            tech.easyflow.ai.entity.Document.class,
            String.class
        );
        method.setAccessible(true);
        method.invoke(service, task, inputDocument, "迟到错误");

        Assert.assertNull(updatedDocumentRef.get());
        Assert.assertEquals(DocumentProcessStatus.PARSE_FAILED.name(),
            persistedDocument.getProcessStatus());
    }

    /**
     * 验证知识库导入会把解析图片上传到对象存储，并同步改写 Markdown 与结构化引用。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void normalizeParsedImagesForKnowledgeImportShouldUploadAndRewriteReferences() throws Exception {
        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        AtomicReference<String> savedPrePathRef = new AtomicReference<String>();
        AtomicReference<String> savedFilenameRef = new AtomicReference<String>();
        setField(service, "storageService", mockFileStorageService(savedPrePathRef, savedFilenameRef));

        tech.easyflow.ai.entity.Document document = new tech.easyflow.ai.entity.Document();
        document.setId(BigInteger.valueOf(88));
        document.setTitle("产品说明书（终版）.pdf");

        DocumentParsedResult parsedResult = new DocumentParsedResult();
        parsedResult.setMarkdown("图例如下：\n![](images/sample-image.png)");
        parsedResult.setPreferredText(parsedResult.getMarkdown());
        parsedResult.setPlainText(parsedResult.getMarkdown());

        DocumentImage image = new DocumentImage();
        image.setName("sample-image.png");
        image.setSourcePath("images/sample-image.png");
        image.setMimeType("image/png");
        image.setDataUrl("data:image/png;base64," + Base64.getEncoder().encodeToString("demo".getBytes(StandardCharsets.UTF_8)));
        parsedResult.setImages(new ArrayList<DocumentImage>(List.of(image)));

        DocumentBlock block = new DocumentBlock();
        block.setImagePath("images/sample-image.png");
        parsedResult.setBlocks(new ArrayList<DocumentBlock>(List.of(block)));

        DocumentTable table = new DocumentTable();
        table.setImagePath("images/sample-image.png");
        parsedResult.setTables(new ArrayList<DocumentTable>(List.of(table)));

        DocumentParseArtifacts artifacts = new DocumentParseArtifacts();
        List<Map<String, Object>> contentList = new ArrayList<Map<String, Object>>();
        Map<String, Object> contentItem = new LinkedHashMap<String, Object>();
        contentItem.put("img_path", "images/sample-image.png");
        contentList.add(contentItem);
        artifacts.setContentList(contentList);
        Map<String, Object> xlsxArtifact = new LinkedHashMap<String, Object>();
        List<Map<String, Object>> sheetImages = new ArrayList<Map<String, Object>>();
        sheetImages.add(new LinkedHashMap<String, Object>() {{
            put("sheetName", "Sheet1");
            put("sourcePaths", new ArrayList<String>(List.of("images/sample-image.png")));
        }});
        xlsxArtifact.put("sheetImages", sheetImages);
        artifacts.setExtraJsonArtifacts(new LinkedHashMap<String, Object>() {{
            put("xlsx", xlsxArtifact);
        }});
        parsedResult.setArtifacts(artifacts);

        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "normalizeParsedImagesForKnowledgeImport",
            tech.easyflow.ai.entity.Document.class,
            DocumentParsedResult.class
        );
        method.setAccessible(true);
        DocumentParsedResult normalized = (DocumentParsedResult) method.invoke(service, document, parsedResult);

        Assert.assertNotNull(normalized);
        Assert.assertEquals("knowledge-parse/88_产品说明书_终版/images", savedPrePathRef.get());
        Assert.assertEquals("sample-image.png", savedFilenameRef.get());

        String expectedUrl = "http://localhost:39000/easyflow/attachment/knowledge-parse/88_产品说明书_终版/images/sample-image.png";
        Assert.assertTrue(normalized.getMarkdown().contains(expectedUrl));
        Assert.assertEquals(expectedUrl, normalized.getBlocks().get(0).getImagePath());
        Assert.assertEquals(expectedUrl, normalized.getTables().get(0).getImagePath());
        Assert.assertEquals(expectedUrl, normalized.getImages().get(0).getSourcePath());
        Assert.assertNull(normalized.getImages().get(0).getDataUrl());
        Object rewrittenContentList = normalized.getArtifacts().getContentList();
        Assert.assertTrue(rewrittenContentList instanceof List<?>);
        Assert.assertEquals(expectedUrl, ((Map<?, ?>) ((List<?>) rewrittenContentList).get(0)).get("img_path"));
        Object rewrittenSheetImages = ((Map<?, ?>) normalized.getArtifacts().getExtraJsonArtifacts().get("xlsx")).get("sheetImages");
        Assert.assertTrue(rewrittenSheetImages instanceof List<?>);
        Object sourcePaths = ((Map<?, ?>) ((List<?>) rewrittenSheetImages).get(0)).get("sourcePaths");
        Assert.assertEquals(expectedUrl, ((List<?>) sourcePaths).get(0));
    }

    /**
     * 验证 PPTX 会基于页级工件生成稳定的知识库分块。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void buildOfficeDocumentChunksShouldSplitPptxBySlide() throws Exception {
        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        tech.easyflow.ai.entity.Document document = new tech.easyflow.ai.entity.Document();
        document.setId(BigInteger.valueOf(101));
        document.setCollectionId(BigInteger.valueOf(201));
        document.setTitle("季度汇报.pptx");

        Map<String, Object> parseArtifactSummary = new LinkedHashMap<String, Object>();
        List<Map<String, Object>> slides = new ArrayList<Map<String, Object>>();
        slides.add(new LinkedHashMap<String, Object>() {{
            put("slideIndex", 0);
            put("title", "封面");
            put("ocrMarkdown", "本页介绍季度目标。");
            put("imagePath", "https://example.com/slides/slide-001.png");
            put("imageName", "slide-001-page");
        }});
        slides.add(new LinkedHashMap<String, Object>() {{
            put("slideIndex", 1);
            put("title", "经营分析");
            put("ocrMarkdown", "收入同比增长 18%。");
            put("imagePath", "https://example.com/slides/slide-002.png");
            put("imageName", "slide-002-page");
        }});
        parseArtifactSummary.put("slides", slides);

        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "buildOfficeDocumentChunks",
            tech.easyflow.ai.entity.Document.class,
            String.class,
            StrategyConfig.class,
            Map.class
        );
        method.setAccessible(true);

        @SuppressWarnings("unchecked")
        List<DocumentChunk> chunks = (List<DocumentChunk>) method.invoke(
            service,
            document,
            "pptx",
            null,
            parseArtifactSummary
        );

        Assert.assertEquals(2, chunks.size());
        DocumentChunk firstChunk = chunks.get(0);
        Assert.assertNotNull(firstChunk.getId());
        Assert.assertNotEquals(firstChunk.getId(), chunks.get(1).getId());
        Assert.assertTrue(firstChunk.getContent().contains("Slide 1"));
        Assert.assertTrue(firstChunk.getContent().contains("本页介绍季度目标"));
        Assert.assertEquals("https://example.com/slides/slide-001.png",
            ((List<?>) firstChunk.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_IMAGE_REFS)).get(0));
        Assert.assertEquals(1, firstChunk.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_PAGE_INDEX));
        Assert.assertTrue(String.valueOf(firstChunk.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN))
            .contains("slide-001.png"));
    }

    /**
     * 验证 XLSX 纯图片 Sheet 不会退化为空内容，并会输出稳定图片引用。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void buildOfficeDocumentChunksShouldKeepImageOnlyXlsxSheetReferences() throws Exception {
        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        tech.easyflow.ai.entity.Document document = new tech.easyflow.ai.entity.Document();
        document.setId(BigInteger.valueOf(102));
        document.setCollectionId(BigInteger.valueOf(202));
        document.setTitle("巡检记录.xlsx");

        Map<String, Object> parseArtifactSummary = new LinkedHashMap<String, Object>();
        List<Map<String, Object>> sheets = new ArrayList<Map<String, Object>>();
        sheets.add(new LinkedHashMap<String, Object>() {{
            put("sheetName", "图片页");
            put("sheetIndex", 0);
            put("rows", new ArrayList<Map<String, Object>>());
        }});
        parseArtifactSummary.put("sheets", sheets);

        List<Map<String, Object>> cellImages = new ArrayList<Map<String, Object>>();
        cellImages.add(new LinkedHashMap<String, Object>() {{
            put("sheetName", "图片页");
            put("referenceKey", "image-sheet-r2c2-001");
            put("sourcePath", "https://example.com/xlsx/sheet/image-001.jpeg");
            put("anchorCell", "B2");
            put("ocrText", "设备状态正常");
            put("fromRow", 1);
        }});
        parseArtifactSummary.put("cellImages", cellImages);

        StrategyConfig strategyConfig = new StrategyConfig();
        strategyConfig.setRowsPerChunk(10);

        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "buildOfficeDocumentChunks",
            tech.easyflow.ai.entity.Document.class,
            String.class,
            StrategyConfig.class,
            Map.class
        );
        method.setAccessible(true);

        @SuppressWarnings("unchecked")
        List<DocumentChunk> chunks = (List<DocumentChunk>) method.invoke(
            service,
            document,
            "xlsx",
            strategyConfig,
            parseArtifactSummary
        );

        Assert.assertEquals(1, chunks.size());
        DocumentChunk onlyChunk = chunks.get(0);
        Assert.assertTrue(onlyChunk.getContent().contains("图片 OCR"));
        Assert.assertTrue(onlyChunk.getContent().contains("设备状态正常"));
        Assert.assertEquals("图片页", onlyChunk.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_SHEET_NAME));
        Assert.assertEquals("https://example.com/xlsx/sheet/image-001.jpeg",
            ((List<?>) onlyChunk.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_IMAGE_REFS)).get(0));
        String renderMarkdown = String.valueOf(onlyChunk.getOptions().get(DocumentImportKeys.KEY_DOCUMENT_RENDER_MARKDOWN));
        Assert.assertTrue(renderMarkdown.contains("[IMG:image-sheet-r2c2-001]"));
        Assert.assertTrue(renderMarkdown.contains("![image-sheet-r2c2-001](https://example.com/xlsx/sheet/image-001.jpeg)"));
    }

    /**
     * 验证空白 Sheet 不会被误判成纯图片分块。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void buildOfficeDocumentChunksShouldSkipBlankXlsxSheetWithoutImages() throws Exception {
        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        tech.easyflow.ai.entity.Document document = new tech.easyflow.ai.entity.Document();
        document.setId(BigInteger.valueOf(103));
        document.setCollectionId(BigInteger.valueOf(203));
        document.setTitle("空白工作簿.xlsx");

        Map<String, Object> parseArtifactSummary = new LinkedHashMap<String, Object>();
        parseArtifactSummary.put("sheets", new ArrayList<Map<String, Object>>(List.of(new LinkedHashMap<String, Object>() {{
            put("sheetName", "空白页");
            put("sheetIndex", 0);
            put("rows", new ArrayList<Map<String, Object>>());
        }})));
        parseArtifactSummary.put("cellImages", new ArrayList<Map<String, Object>>());

        StrategyConfig strategyConfig = new StrategyConfig();
        strategyConfig.setRowsPerChunk(10);

        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "buildOfficeDocumentChunks",
            tech.easyflow.ai.entity.Document.class,
            String.class,
            StrategyConfig.class,
            Map.class
        );
        method.setAccessible(true);

        @SuppressWarnings("unchecked")
        List<DocumentChunk> chunks = (List<DocumentChunk>) method.invoke(
            service,
            document,
            "xlsx",
            strategyConfig,
            parseArtifactSummary
        );

        Assert.assertTrue(chunks.isEmpty());
    }

    /**
     * 验证向量存储回滚会去重分块 ID，并在 Milvus 删除失败后停止清理搜索索引。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void rollbackStoredChunksShouldValidateVectorDeleteResult() throws Exception {
        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        DocumentStore documentStore = Mockito.mock(DocumentStore.class);
        DocumentSearcher searcher = Mockito.mock(DocumentSearcher.class);
        StoreOptions storeOptions = StoreOptions.ofCollectionName("knowledge-test");
        Mockito.when(documentStore.delete(Mockito.anyCollection(), Mockito.same(storeOptions)))
            .thenReturn(StoreResult.fail("milvus unavailable"));

        DocumentCollection knowledge = new DocumentCollection();
        knowledge.setId(BigInteger.valueOf(501));
        Class<?> contextClass = Class.forName(
            KnowledgeDocumentImportTaskAppService.class.getName() + "$StoreExecutionContext"
        );
        Constructor<?> constructor = contextClass.getDeclaredConstructor(
            DocumentCollection.class,
            EmbeddingModel.class,
            DocumentStore.class,
            StoreOptions.class,
            DocumentSearcher.class
        );
        constructor.setAccessible(true);
        Object context = constructor.newInstance(knowledge, null, documentStore, storeOptions, searcher);

        DocumentChunk first = new DocumentChunk();
        first.setId(BigInteger.valueOf(601));
        DocumentChunk duplicate = new DocumentChunk();
        duplicate.setId(first.getId());
        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "rollbackStoredChunks",
            BigInteger.class,
            BigInteger.class,
            contextClass,
            List.class
        );
        method.setAccessible(true);
        Object rollbackResult = method.invoke(service,
            BigInteger.valueOf(701),
            BigInteger.valueOf(702),
            context,
            List.of(first, duplicate));

        Assert.assertEquals(Boolean.FALSE, rollbackResult);
        @SuppressWarnings("rawtypes")
        ArgumentCaptor<Collection> idsCaptor = ArgumentCaptor.forClass(Collection.class);
        Mockito.verify(documentStore).delete(idsCaptor.capture(), Mockito.same(storeOptions));
        Assert.assertEquals(1, idsCaptor.getValue().size());
        Assert.assertTrue(idsCaptor.getValue().contains(first.getId()));
        Mockito.verifyNoInteractions(searcher);
    }

    /**
     * 验证关键词索引删除失败会被回滚结果显式报告。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void rollbackStoredChunksShouldReportKeywordDeleteFailure() throws Exception {
        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        DocumentStore documentStore = Mockito.mock(DocumentStore.class);
        DocumentSearcher searcher = Mockito.mock(DocumentSearcher.class);
        StoreOptions storeOptions = StoreOptions.ofCollectionName("knowledge-test");
        Mockito.when(documentStore.delete(Mockito.anyCollection(), Mockito.same(storeOptions)))
            .thenReturn(StoreResult.success());
        Mockito.when(searcher.deleteDocuments(Mockito.anyCollection())).thenReturn(false);

        DocumentCollection knowledge = new DocumentCollection();
        knowledge.setId(BigInteger.valueOf(751));
        Class<?> contextClass = Class.forName(
            KnowledgeDocumentImportTaskAppService.class.getName() + "$StoreExecutionContext"
        );
        Constructor<?> constructor = contextClass.getDeclaredConstructor(
            DocumentCollection.class,
            EmbeddingModel.class,
            DocumentStore.class,
            StoreOptions.class,
            DocumentSearcher.class
        );
        constructor.setAccessible(true);
        Object context = constructor.newInstance(knowledge, null, documentStore, storeOptions, searcher);

        DocumentChunk chunk = new DocumentChunk();
        chunk.setId(BigInteger.valueOf(752));
        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "rollbackStoredChunks",
            BigInteger.class,
            BigInteger.class,
            contextClass,
            List.class
        );
        method.setAccessible(true);

        Object rollbackResult = method.invoke(service,
            BigInteger.valueOf(753),
            BigInteger.valueOf(754),
            context,
            List.of(chunk));

        Assert.assertEquals(Boolean.FALSE, rollbackResult);
        Mockito.verify(searcher).deleteDocuments(List.of(chunk.getId()));
    }

    /**
     * 验证索引业务异常会保留准确原因，并在回滚失败时追加处置提示。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void resolveIndexFailureMessageShouldKeepBusinessReason() throws Exception {
        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "resolveIndexFailureMessage",
            Exception.class,
            boolean.class
        );
        method.setAccessible(true);

        String message = (String) method.invoke(
            service,
            new BusinessException("关键词索引写入失败"),
            false
        );

        Assert.assertEquals(
            "关键词索引写入失败；外部索引回滚未完成，请联系管理员处理",
            message
        );
    }

    /**
     * 验证关键词索引批量写入失败会终止导入，避免任务误报完成。
     *
     * @throws Exception 反射调用异常
     */
    @Test
    public void storeDocumentChunksShouldPropagateKeywordIndexFailure() throws Exception {
        KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
        DocumentStore documentStore = Mockito.mock(DocumentStore.class);
        DocumentSearcher searcher = Mockito.mock(DocumentSearcher.class);
        StoreOptions storeOptions = StoreOptions.ofCollectionName("knowledge-test");
        Mockito.when(documentStore.store(Mockito.anyList(), Mockito.same(storeOptions)))
            .thenReturn(StoreResult.success());
        Mockito.when(searcher.addDocuments(Mockito.anyList())).thenReturn(false);

        DocumentCollection knowledge = new DocumentCollection();
        knowledge.setId(BigInteger.valueOf(801));
        Class<?> contextClass = Class.forName(
            KnowledgeDocumentImportTaskAppService.class.getName() + "$StoreExecutionContext"
        );
        Constructor<?> constructor = contextClass.getDeclaredConstructor(
            DocumentCollection.class,
            EmbeddingModel.class,
            DocumentStore.class,
            StoreOptions.class,
            DocumentSearcher.class
        );
        constructor.setAccessible(true);
        Object context = constructor.newInstance(knowledge, null, documentStore, storeOptions, searcher);

        DocumentChunk chunk = new DocumentChunk();
        chunk.setId(BigInteger.valueOf(802));
        chunk.setContent("关键词索引失败测试");
        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "storeDocumentChunks",
            contextClass,
            List.class
        );
        method.setAccessible(true);

        try {
            method.invoke(service, context, List.of(chunk));
            Assert.fail("关键词索引失败时应终止导入");
        } catch (InvocationTargetException expected) {
            Assert.assertTrue(expected.getCause() instanceof BusinessException);
            Assert.assertEquals("关键词索引写入失败", expected.getCause().getMessage());
        }
        Mockito.verify(searcher).addDocuments(Mockito.anyList());
    }

    private static DocumentMapper mockDocumentMapper(tech.easyflow.ai.entity.Document persistedDocument,
                                                     AtomicReference<tech.easyflow.ai.entity.Document> updatedDocumentRef) {
        return (DocumentMapper) Proxy.newProxyInstance(
            DocumentMapper.class.getClassLoader(),
            new Class<?>[]{DocumentMapper.class},
            (proxy, method, args) -> {
                if ("selectOneById".equals(method.getName())) {
                    return persistedDocument;
                }
                if ("update".equals(method.getName())) {
                    updatedDocumentRef.set((tech.easyflow.ai.entity.Document) args[0]);
                    return 1;
                }
                return defaultValue(method.getReturnType());
            }
        );
    }

    private static FileStorageService mockFileStorageService(AtomicReference<String> savedPrePathRef,
                                                             AtomicReference<String> savedFilenameRef) {
        return (FileStorageService) Proxy.newProxyInstance(
            FileStorageService.class.getClassLoader(),
            new Class<?>[]{FileStorageService.class},
            (proxy, method, args) -> {
                if ("save".equals(method.getName()) && args != null && args.length == 2 && args[0] instanceof MultipartFile file) {
                    savedPrePathRef.set((String) args[1]);
                    savedFilenameRef.set(file.getOriginalFilename());
                    return "http://localhost:39000/easyflow/attachment/" + args[1] + "/" + file.getOriginalFilename();
                }
                return defaultValue(method.getReturnType());
            }
        );
    }

    private static void setField(Object target, String fieldName, Object value) throws Exception {
        Field field = KnowledgeDocumentImportTaskAppService.class.getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(target, value);
    }

    private static void assertAutoParagraphFallback(int chunkSize, int overlapSize) throws Exception {
        KnowledgeDocumentImportTaskAppService service =
            new KnowledgeDocumentImportTaskAppService();
        RagIngestionService ragIngestionService = Mockito.mock(RagIngestionService.class);
        setField(service, "ragIngestionService", ragIngestionService);
        AnalysisResult analysis = Mockito.mock(AnalysisResult.class);
        StrategyConfig requestedStrategy = StrategyConfig.defaults();
        requestedStrategy.setChunkSize(chunkSize);
        requestedStrategy.setOverlapSize(overlapSize);
        StrategyConfig effectiveStrategy = requestedStrategy.copy();
        effectiveStrategy.setStrategyCode(RagStrategyCodes.OUTLINE_SECTION);
        RagChunk fallbackChunk = new RagChunk();
        List<String> invokedStrategies = new ArrayList<String>();

        Mockito.when(ragIngestionService.split(
                Mockito.eq(analysis), Mockito.any(StrategyConfig.class)))
            .thenAnswer(invocation -> {
                StrategyConfig config = invocation.getArgument(1);
                invokedStrategies.add(
                    config.getStrategyCode() + ":" + config.getChunkSize() + ":" + config.getOverlapSize());
                return invokedStrategies.size() == 1
                    ? List.of()
                    : List.of(fallbackChunk);
            });

        List<RagChunk> chunks = invokeAutoParagraphFallback(
            service, analysis, requestedStrategy, effectiveStrategy);

        Assert.assertEquals(List.of(fallbackChunk), chunks);
        Assert.assertEquals(
            List.of(
                RagStrategyCodes.OUTLINE_SECTION + ":" + chunkSize + ":" + overlapSize,
                RagStrategyCodes.PARAGRAPH_LENGTH + ":" + chunkSize + ":" + overlapSize),
            invokedStrategies);
        Assert.assertEquals(
            RagStrategyCodes.PARAGRAPH_LENGTH, effectiveStrategy.getStrategyCode());
    }

    @SuppressWarnings("unchecked")
    private static List<RagChunk> invokeAutoParagraphFallback(
        KnowledgeDocumentImportTaskAppService service,
        AnalysisResult analysis,
        StrategyConfig requestedStrategy,
        StrategyConfig effectiveStrategy) throws Exception {
        Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
            "splitWithAutoParagraphFallback",
            AnalysisResult.class,
            StrategyConfig.class,
            StrategyConfig.class);
        method.setAccessible(true);
        return (List<RagChunk>) method.invoke(
            service, analysis, requestedStrategy, effectiveStrategy);
    }

    private static Object defaultValue(Class<?> returnType) {
        if (returnType == boolean.class) {
            return false;
        }
        if (returnType == int.class) {
            return 0;
        }
        if (returnType == long.class) {
            return 0L;
        }
        return null;
    }

    /**
     * 测试用 SSE 推送桩，避免依赖线程池和真实推送。
     */
    private static class NoopTaskStatusStreamService extends DocumentImportTaskStatusStreamService {

        @Override
        public void publishAfterCommit(BigInteger documentId) {
            // no-op
        }
    }
}
