package com.ruoyi.ccdi.project.service.impl;

import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import com.alibaba.excel.EasyExcel;
import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.enums.TriggerType;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankStatement;
import com.ruoyi.ccdi.project.domain.vo.CcdiFileUploadStatisticsVO;
import com.ruoyi.ccdi.project.domain.entity.CcdiFileUploadRecord;
import com.ruoyi.ccdi.project.mapper.CcdiBankStatementMapper;
import com.ruoyi.ccdi.project.mapper.CcdiFileUploadRecordMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiBankTagService;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
import com.ruoyi.lsfx.constants.LsfxConstants;
import com.ruoyi.lsfx.domain.request.FetchInnerFlowRequest;
import com.ruoyi.lsfx.domain.request.GetBankStatementRequest;
import com.ruoyi.lsfx.domain.response.CheckParseStatusResponse;
import com.ruoyi.lsfx.domain.response.DeleteFilesResponse;
import com.ruoyi.lsfx.domain.response.FetchInnerFlowResponse;
import com.ruoyi.lsfx.domain.response.GetBankStatementResponse;
import com.ruoyi.lsfx.domain.response.GetFileUploadStatusResponse;
import com.ruoyi.lsfx.domain.response.UploadFileResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicInteger;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class CcdiFileUploadServiceImplTest {

    private static final Long PROJECT_ID = 100L;
    private static final Integer LSFX_PROJECT_ID = 200;
    private static final Long RECORD_ID = 300L;
    private static final Integer LOG_ID = 400;
    private static final int MAX_ERROR_MESSAGE_LENGTH = 2000;

    @InjectMocks
    private CcdiFileUploadServiceImpl service;

    @Mock
    private CcdiFileUploadRecordMapper recordMapper;

    @Mock
    private CcdiProjectMapper projectMapper;

    @Mock
    private LsfxAnalysisClient lsfxClient;

    @Mock
    private CcdiBankStatementMapper bankStatementMapper;

    @Mock
    private Executor fileUploadExecutor;

    @Mock
    private ICcdiBankTagService bankTagService;

    @Mock
    private ICcdiProjectService projectService;

    @TempDir
    Path tempDir;

    @Test
    void parseIdCardFile_shouldReadFirstSheetFirstColumnAndDeduplicate() throws Exception {
        MultipartFile file = createIdCardExcel(
            "身份证号",
            "110101199001018888",
            "",
            "110101199001018888",
            "110101199001019999"
        );

        List<String> result = service.parseIdCardFile(file);

        assertEquals(List.of("110101199001018888", "110101199001019999"), result);
    }

    @Test
    void parseIdCardFile_shouldRejectInvalidIdCard() throws Exception {
        MultipartFile file = createIdCardExcel("身份证号", "123456");

        RuntimeException exception = assertThrows(RuntimeException.class, () -> service.parseIdCardFile(file));

        assertTrue(exception.getMessage().contains("身份证"));
    }

    @Test
    void submitPullBankInfo_shouldInsertUploadingRecordsWithIdCardAsAccountNo() {
        CcdiProject project = new CcdiProject();
        project.setProjectId(PROJECT_ID);
        project.setLsfxProjectId(LSFX_PROJECT_ID);
        when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);

        AtomicReference<List<CcdiFileUploadRecord>> inserted = new AtomicReference<>();
        doAnswer(invocation -> {
            List<CcdiFileUploadRecord> records = invocation.getArgument(0);
            for (int i = 0; i < records.size(); i++) {
                records.get(i).setId((long) (i + 1));
            }
            inserted.set(new ArrayList<>(records));
            return records.size();
        }).when(recordMapper).insertBatch(any());

        TransactionSynchronizationManager.initSynchronization();
        try {
            String batchId = service.submitPullBankInfo(
                PROJECT_ID,
                List.of("110101199001018888", "110101199001019999"),
                LsfxConstants.DATA_CHANNEL_ZJRCU,
                "2026-03-01",
                "2026-03-10",
                9527L,
                "admin"
            );

            assertNotNull(batchId);
            assertEquals("110101199001018888", inserted.get().get(0).getAccountNos());
            assertEquals("admin", inserted.get().get(0).getUploadUser());
            assertEquals("uploading", inserted.get().get(0).getFileStatus());
            assertEquals(1, TransactionSynchronizationManager.getSynchronizations().size());
            verify(projectService).ensureProjectNotArchived(PROJECT_ID, "已归档项目暂不允许上传或拉取数据");
            verify(projectService).ensureProjectWritable(PROJECT_ID, "当前项目正在进行银行流水打标，暂不允许上传或拉取数据");
        } finally {
            TransactionSynchronizationManager.clearSynchronization();
        }
    }

    @Test
    void shouldRejectPullBankInfoWhenProjectIsTagging() {
        org.mockito.Mockito.doThrow(new ServiceException("当前项目正在进行银行流水打标，暂不允许上传或拉取数据"))
            .when(projectService).ensureProjectWritable(PROJECT_ID, "当前项目正在进行银行流水打标，暂不允许上传或拉取数据");

        assertThrows(ServiceException.class,
            () -> service.submitPullBankInfo(
                PROJECT_ID,
                List.of("3301"),
                LsfxConstants.DATA_CHANNEL_ZJRCU,
                "2026-01-01",
                "2026-01-31",
                1L,
                "tester"
            ));
    }

    @Test
    void shouldRejectBatchUploadWhenProjectIsTagging() {
        org.mockito.Mockito.doThrow(new ServiceException("当前项目正在进行银行流水打标，暂不允许上传或拉取数据"))
            .when(projectService).ensureProjectWritable(PROJECT_ID, "当前项目正在进行银行流水打标，暂不允许上传或拉取数据");

        MockMultipartFile file = new MockMultipartFile(
            "files",
            "test.xlsx",
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            "content".getBytes()
        );

        assertThrows(ServiceException.class,
            () -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"));
    }

    @Test
    void batchUploadFiles_shouldNormalizeFilenameBeforeSavingRecordAndTempFile() throws Exception {
        setField("uploadPath", tempDir.toString());
        mockProjectWithLsfxProjectId();

        AtomicReference<List<CcdiFileUploadRecord>> inserted = new AtomicReference<>();
        doAnswer(invocation -> {
            List<CcdiFileUploadRecord> records = invocation.getArgument(0);
            for (int i = 0; i < records.size(); i++) {
                records.get(i).setId((long) (i + 1));
            }
            inserted.set(new ArrayList<>(records));
            return records.size();
        }).when(recordMapper).insertBatch(any());

        MultipartFile file = new MockMultipartFile(
            "files",
            "330101199001010011 - 流 水 .xl sx",
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            "content".getBytes()
        );

        TransactionSynchronizationManager.initSynchronization();
        try {
            String batchId = service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester");

            assertNotNull(batchId);
            assertNotNull(inserted.get());
            assertEquals("330101199001010011-流水.xlsx", inserted.get().get(0).getFileName());
            Path tempRoot = tempDir.resolve("temp");
            assertTrue(Files.exists(tempRoot));
            try (var paths = Files.list(tempRoot)) {
                assertTrue(paths.anyMatch(path ->
                    path.getFileName().toString().endsWith("_330101199001010011-流水.xlsx")));
            }
        } finally {
            TransactionSynchronizationManager.clearSynchronization();
        }
    }

    @Test
    void batchUploadFiles_shouldRejectFileNameWithoutIdCardBeforeSavingTempFile() throws Exception {
        setField("uploadPath", tempDir.toString());
        mockProjectWithLsfxProjectId();

        MultipartFile file = new MockMultipartFile(
            "files",
            "普通流水.xlsx",
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            "content".getBytes()
        );

        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
            () -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"));

        assertTrue(exception.getMessage().contains("身份证"));
        assertFalse(Files.exists(tempDir.resolve("temp")));
        verify(recordMapper, never()).insertBatch(any());
        verify(lsfxClient, never()).uploadFile(any(), org.mockito.ArgumentMatchers.<java.io.File>any(), any());
    }

    @Test
    void batchUploadFiles_shouldRejectBlankNormalizedFilenameBeforeSavingTempFile() throws Exception {
        setField("uploadPath", tempDir.toString());
        mockProjectWithLsfxProjectId();

        MultipartFile file = new MockMultipartFile(
            "files",
            " \u3000 ",
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            "content".getBytes()
        );

        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
            () -> service.batchUploadFiles(PROJECT_ID, new MultipartFile[]{file}, "tester"));

        assertTrue(exception.getMessage().contains("文件名不能为空"));
        assertFalse(Files.exists(tempDir.resolve("temp")));
        verify(recordMapper, never()).insertBatch(any());
        verify(lsfxClient, never()).uploadFile(any(), org.mockito.ArgumentMatchers.<java.io.File>any(), any());
    }

    @Test
    void submitTasksAsync_shouldNotCreateLocalBatchLogFiles() throws Exception {
        setField("uploadPath", tempDir.toString());

        Path tempFile = createTempFile();
        CcdiFileUploadRecord record = buildRecord();

        invokeSubmitTasksAsync(List.of(tempFile.toString()), List.of(record), "batch-1");

        Path batchLogDir = tempDir.resolve("logs").resolve("file-upload").resolve(String.valueOf(PROJECT_ID));
        assertFalse(Files.exists(batchLogDir));
    }

    @Test
    void handleTagRebuildAfterBatchCompletion_shouldLogSkipWhenAllRecordsFailed() {
        Logger logger = (Logger) LoggerFactory.getLogger(CcdiFileUploadServiceImpl.class);
        ListAppender<ILoggingEvent> logAppender = new ListAppender<>();
        logAppender.start();
        logger.addAppender(logAppender);

        try {
            ReflectionTestUtils.invokeMethod(
                service,
                "handleTagRebuildAfterBatchCompletion",
                PROJECT_ID,
                TriggerType.AUTO_BATCH_UPLOAD,
                Boolean.FALSE
            );

            verify(bankTagService, never()).submitAutoRebuild(any(), any());
            assertTrue(logAppender.list.stream().map(ILoggingEvent::getFormattedMessage)
                .anyMatch(message -> message.contains("跳过自动重算")
                    && message.contains("projectId=100")
                    && message.contains("AUTO_BATCH_UPLOAD")));
        } finally {
            logger.detachAppender(logAppender);
        }
    }

    @Test
    void handleTagRebuildAfterBatchCompletion_shouldLogSubmissionWhenAnyRecordSucceeded() {
        Logger logger = (Logger) LoggerFactory.getLogger(CcdiFileUploadServiceImpl.class);
        ListAppender<ILoggingEvent> logAppender = new ListAppender<>();
        logAppender.start();
        logger.addAppender(logAppender);

        try {
            ReflectionTestUtils.invokeMethod(
                service,
                "handleTagRebuildAfterBatchCompletion",
                PROJECT_ID,
                TriggerType.AUTO_PULL_BANK_INFO,
                Boolean.TRUE
            );

            verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_PULL_BANK_INFO);
            assertTrue(logAppender.list.stream().map(ILoggingEvent::getFormattedMessage)
                .anyMatch(message -> message.contains("准备触发自动重算")
                    && message.contains("projectId=100")
                    && message.contains("AUTO_PULL_BANK_INFO")
                    && message.contains("anySuccess=true")));
        } finally {
            logger.detachAppender(logAppender);
        }
    }

    @Test
    void processFileAsync_shouldKeepParsingUntilBankStatementsSaved() throws IOException {
        List<String> events = new ArrayList<>();
        AtomicInteger sequence = new AtomicInteger();
        captureRecordStatus(events, sequence);

        when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
            .thenReturn(buildUploadResponse());
        when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
            .thenReturn(buildCheckParseStatusResponse(false));
        when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
        when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
            .thenThrow(new RuntimeException("bank statement fetch failed"));

        CcdiFileUploadRecord record = buildRecord();
        Path tempFile = createTempFile();

        service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);

        assertTrue(events.stream().anyMatch(event -> event.endsWith("record:parsed_failed")));
        assertFalse(events.stream().anyMatch(event -> event.endsWith("record:parsed_success")));
    }

    @Test
    void processFileAsync_shouldMarkSuccessAfterBankStatementsSaved() throws IOException {
        List<String> events = new ArrayList<>();
        AtomicInteger sequence = new AtomicInteger();
        captureRecordStatus(events, sequence);
        CcdiProject project = new CcdiProject();
        project.setProjectId(PROJECT_ID);
        when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
        when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(1);

        when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
            .thenReturn(buildUploadResponse());
        when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
            .thenReturn(buildCheckParseStatusResponse(false));
        when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
        when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
            .thenAnswer(invocation -> {
                events.add(sequence.incrementAndGet() + ":bank-fetch");
                return buildEmptyBankStatementResponse();
            });

        CcdiFileUploadRecord record = buildRecord();
        Path tempFile = createTempFile();

        service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);

        int fetchIndex = findEventIndex(events, "bank-fetch");
        int successIndex = findEventIndex(events, "record:parsed_success");
        assertTrue(fetchIndex >= 0);
        assertTrue(successIndex > fetchIndex);
        verify(projectMapper).updateById(org.mockito.ArgumentMatchers.<CcdiProject>argThat(item ->
            PROJECT_ID.equals(item.getProjectId()) && Integer.valueOf(1).equals(item.getTargetCount())
        ));
    }

    @Test
    void processFileAsync_shouldCleanupInsertedStatementsWhenFetchFails() throws IOException {
        when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
            .thenReturn(buildUploadResponse());
        when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
            .thenReturn(buildCheckParseStatusResponse(false));
        when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
        when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
            .thenThrow(new RuntimeException("bank statement fetch failed"));

        CcdiFileUploadRecord record = buildRecord();
        Path tempFile = createTempFile();

        service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);

        verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
    }

    @Test
    void processPullBankInfoAsync_shouldUpdateFileSizeFromStatusResponse() {
        GetFileUploadStatusResponse statusResponse = buildParsedSuccessStatusResponse("XX身份证.xlsx");
        statusResponse.getData().getLogs().get(0).setFileSize(2048L);

        when(lsfxClient.fetchInnerFlow(any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID));
        when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
            .thenReturn(buildCheckParseStatusResponse(false));
        when(lsfxClient.getFileUploadStatus(any())).thenReturn(statusResponse);
        when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
            .thenReturn(buildEmptyBankStatementResponse());

        CcdiFileUploadRecord record = buildRecord();
        record.setFileSize(0L);

        service.processPullBankInfoAsync(
            PROJECT_ID,
            LSFX_PROJECT_ID,
            record,
            "110101199001018888",
            LsfxConstants.DATA_CHANNEL_ZJRCU,
            "2026-03-01",
            "2026-03-10"
        );

        verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
            org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
                Long.valueOf(2048L).equals(item.getFileSize())
                    && "XX身份证.xlsx".equals(item.getFileName())
                    && "parsed_success".equals(item.getFileStatus()))
        );
    }

    @Test
    void processPullBankInfoAsync_shouldFetchJzlWithZeroDateRange() {
        when(lsfxClient.fetchInnerFlow(any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID));
        when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
            .thenReturn(buildCheckParseStatusResponse(false));
        when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
        when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
            .thenReturn(buildEmptyBankStatementResponse());

        CcdiFileUploadRecord record = buildRecord();

        service.processPullBankInfoAsync(
            PROJECT_ID,
            LSFX_PROJECT_ID,
            record,
            "110101199001018888",
            LsfxConstants.DATA_CHANNEL_JZL,
            null,
            null
        );

        verify(lsfxClient).fetchInnerFlow(argThat((FetchInnerFlowRequest request) ->
            LsfxConstants.DATA_CHANNEL_JZL.equals(request.getDataChannelCode())
                && Integer.valueOf(0).equals(request.getDataStartDateId())
                && Integer.valueOf(0).equals(request.getDataEndDateId())
        ));
    }

    @Test
    void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException {
        when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx")))
            .thenReturn(buildUploadResponse());
        when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
            .thenReturn(buildCheckParseStatusResponse(false));
        when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
        when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
            .thenReturn(buildEmptyBankStatementResponse());

        CcdiFileUploadRecord record = buildRecord();
        record.setFileName("原始流水.xlsx");
        Path tempFile = createTempFile();

        service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);

        verify(lsfxClient).uploadFile(eq(LSFX_PROJECT_ID), argThat(file ->
            file.getName().startsWith("upload-") && file.getName().endsWith(".xlsx")
        ), eq("原始流水.xlsx"));
    }

    @Test
    void processFileAsync_shouldBackfillMissingCretNoFromUploadFileName() throws IOException {
        AtomicReference<List<CcdiBankStatement>> insertedStatements = new AtomicReference<>();
        doAnswer(invocation -> {
            List<CcdiBankStatement> statements = invocation.getArgument(0);
            insertedStatements.set(new ArrayList<>(statements));
            return statements.size();
        }).when(bankStatementMapper).insertBatch(any());

        CcdiProject project = new CcdiProject();
        project.setProjectId(PROJECT_ID);
        when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
        when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(1);
        when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("张三_330101199001010011_流水.xlsx")))
            .thenReturn(buildUploadResponse());
        when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
            .thenReturn(buildCheckParseStatusResponse(false));
        when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
        when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
            .thenAnswer(invocation -> {
                GetBankStatementRequest request = invocation.getArgument(0);
                if (Integer.valueOf(1).equals(request.getPageSize())) {
                    return buildBankStatementCountResponse(1);
                }
                return buildBankStatementResponseWithBlankCretNo();
            });

        CcdiFileUploadRecord record = buildRecord();
        record.setFileName("张三_330101199001010011_流水.xlsx");
        Path tempFile = createTempFile();

        service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);

        assertNotNull(insertedStatements.get());
        assertEquals(1, insertedStatements.get().size());
        assertEquals("330101199001010011", insertedStatements.get().get(0).getCretNo());
    }

    @Test
    void processFileAsync_shouldKeepOriginalFileNameWhenStatusReturnsDifferentName() throws IOException {
        when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
            .thenReturn(buildUploadResponse());
        when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
            .thenReturn(buildCheckParseStatusResponse(false));
        when(lsfxClient.getFileUploadStatus(any()))
            .thenReturn(buildParsedSuccessStatusResponse("平台返回文件名.xlsx"));
        when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
            .thenReturn(buildEmptyBankStatementResponse());

        CcdiFileUploadRecord record = buildRecord();
        record.setFileName("原始流水.xlsx");
        Path tempFile = createTempFile();

        service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);

        verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
            org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
                "parsed_success".equals(item.getFileStatus())
                    && "原始流水.xlsx".equals(item.getFileName()))
        );
    }

    @Test
    void processFileAsync_shouldKeepOriginalFileNameWhenParseStatusFails() throws IOException {
        GetFileUploadStatusResponse statusResponse = buildParsedSuccessStatusResponse("平台失败文件名.xlsx");
        GetFileUploadStatusResponse.LogItem logItem = statusResponse.getData().getLogs().get(0);
        logItem.setStatus(-1);
        logItem.setUploadStatusDesc("parse.failed");

        when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
            .thenReturn(buildUploadResponse());
        when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
            .thenReturn(buildCheckParseStatusResponse(false));
        when(lsfxClient.getFileUploadStatus(any())).thenReturn(statusResponse);

        CcdiFileUploadRecord record = buildRecord();
        record.setFileName("原始流水.xlsx");
        Path tempFile = createTempFile();

        service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);

        verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
            org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
                "parsed_failed".equals(item.getFileStatus())
                    && "原始流水.xlsx".equals(item.getFileName()))
        );
    }

    @Test
    void deleteFileUploadRecord_shouldDeleteLocalBankStatementsAndMarkDeleted() {
        CcdiFileUploadRecord record = buildRecord();
        record.setProjectId(PROJECT_ID);
        record.setLsfxProjectId(LSFX_PROJECT_ID);
        record.setLogId(LOG_ID);
        record.setFileStatus("parsed_success");
        CcdiProject project = new CcdiProject();
        project.setProjectId(PROJECT_ID);

        when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
        when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
        when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(2);
        when(recordMapper.updateById(any(CcdiFileUploadRecord.class))).thenReturn(1);

        String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);

        assertEquals("删除成功，已开始项目重新打标", result);
        verify(lsfxClient, never()).deleteFiles(any());
        verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
        verify(recordMapper).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
            RECORD_ID.equals(item.getId()) && "deleted".equals(item.getFileStatus())
        ));
        verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_FILE_DELETE);
        verify(projectMapper).updateById(org.mockito.ArgumentMatchers.<CcdiProject>argThat(item ->
            PROJECT_ID.equals(item.getProjectId()) && Integer.valueOf(2).equals(item.getTargetCount())
        ));
    }

    @Test
    void deleteFileUploadRecord_shouldRejectNonParsedSuccessStatus() {
        CcdiFileUploadRecord record = buildRecord();
        record.setFileStatus("parsed_failed");
        when(recordMapper.selectById(RECORD_ID)).thenReturn(record);

        RuntimeException exception = assertThrows(RuntimeException.class,
            () -> service.deleteFileUploadRecord(RECORD_ID, 9527L));

        assertTrue(exception.getMessage().contains("仅支持删除解析成功文件"));
    }

    @Test
    void deleteFileUploadRecord_shouldRejectHistoryImportRecord() {
        CcdiFileUploadRecord record = buildRecord();
        record.setFileStatus("parsed_success");
        record.setSourceType("HISTORY_IMPORT");
        when(recordMapper.selectById(RECORD_ID)).thenReturn(record);

        ServiceException exception = assertThrows(ServiceException.class,
            () -> service.deleteFileUploadRecord(RECORD_ID, 9527L));

        assertTrue(exception.getMessage().contains("历史导入文件不支持删除"));
        verify(lsfxClient, never()).deleteFiles(any());
    }

    @Test
    void deleteFileUploadRecord_shouldSkipLsfxDeleteApi() {
        CcdiFileUploadRecord record = buildRecord();
        record.setFileStatus("parsed_success");
        record.setLogId(LOG_ID);
        record.setLsfxProjectId(LSFX_PROJECT_ID);
        when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
        when(recordMapper.updateById(any(CcdiFileUploadRecord.class))).thenReturn(1);

        String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);

        assertEquals("删除成功，已开始项目重新打标", result);
        verify(lsfxClient, never()).deleteFiles(any());
        verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
        verify(recordMapper).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
            "deleted".equals(item.getFileStatus())
        ));
        verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_FILE_DELETE);
    }

//    @Test
//    void processPullBankInfoAsync_shouldMarkParsedFailedWhenFetchInnerFlowThrows() {
//        when(lsfxClient.fetchInnerFlow(any())).thenThrow(new RuntimeException("fetch inner flow failed"));
//
//        CcdiFileUploadRecord record = buildRecord();
//        service.processPullBankInfoAsync(
//            PROJECT_ID,
//            LSFX_PROJECT_ID,
//            record,
//            "110101199001018888",
//            "2026-03-01",
//            "2026-03-10",
//            9527L
//        );
//
//        verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
//            "parsed_failed".equals(item.getFileStatus())));
//    }

    @Test
    void processFileAsync_shouldFailWhenPagedFetchThrows() throws IOException {
        List<String> events = new ArrayList<>();
        AtomicInteger sequence = new AtomicInteger();
        captureRecordStatus(events, sequence);

        when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
            .thenReturn(buildUploadResponse());
        when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
            .thenReturn(buildCheckParseStatusResponse(false));
        when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
        when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
            .thenReturn(buildBankStatementResponseWithTotalCount(1))
            .thenThrow(new RuntimeException("paged fetch failed"));

        CcdiFileUploadRecord record = buildRecord();
        Path tempFile = createTempFile();

        service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);

        assertTrue(events.stream().anyMatch(event -> event.endsWith("record:parsed_failed")));
        assertFalse(events.stream().anyMatch(event -> event.endsWith("record:parsed_success")));
        verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
    }

    @Test
    void processFileAsync_shouldTruncateLongErrorMessageWhenBankStatementFetchFails() throws IOException {
        List<CcdiFileUploadRecord> updates = new ArrayList<>();
        captureUpdatedRecords(updates);

        when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
            .thenReturn(buildUploadResponse());
        when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
            .thenReturn(buildCheckParseStatusResponse(false));
        when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
        when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
            .thenThrow(new RuntimeException("bank statement fetch failed:" + "x".repeat(3000)));

        CcdiFileUploadRecord record = buildRecord();
        Path tempFile = createTempFile();

        service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);

        CcdiFileUploadRecord failedRecord = findLastUpdatedRecordByStatus(updates, "parsed_failed");
        assertTrue(failedRecord.getErrorMessage().length() <= MAX_ERROR_MESSAGE_LENGTH);
    }

    @Test
    void processFileAsync_shouldTruncateLongErrorMessageWhenUnexpectedFailureOccurs() throws IOException {
        List<CcdiFileUploadRecord> updates = new ArrayList<>();
        captureUpdatedRecords(updates);

        when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
            .thenThrow(new RuntimeException("upload failed:" + "x".repeat(3000)));

        CcdiFileUploadRecord record = buildRecord();
        Path tempFile = createTempFile();

        service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);

        CcdiFileUploadRecord failedRecord = findLastUpdatedRecordByStatus(updates, "parsed_failed");
        assertTrue(failedRecord.getErrorMessage().length() <= MAX_ERROR_MESSAGE_LENGTH);
    }

    @Test
    void fetchAndSaveBankStatements_shouldTrimLeAccountNoBeforeInsert() throws IOException {
        when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
            .thenReturn(buildUploadResponse());
        when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
            .thenReturn(buildCheckParseStatusResponse(false));
        when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
        when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
            .thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem(" 62220001 "))))
            .thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem(" 62220001 "))));

        CcdiFileUploadRecord record = buildRecord();
        Path tempFile = createTempFile();

        service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);

        verify(bankStatementMapper).insertBatch(any());
        verify(bankStatementMapper).insertBatch(org.mockito.ArgumentMatchers.argThat(list ->
            list.size() == 1 && "62220001".equals(list.get(0).getLeAccountNo())));
    }

    @Test
    void fetchAndSaveBankStatements_shouldLogConservativeCountsWhenAffectedRowsAreAmbiguous() {
        when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
            .thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem("62220001"))))
            .thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem("62220001"))));
        when(bankStatementMapper.insertBatch(any())).thenReturn(1);

        Logger logger = (Logger) LoggerFactory.getLogger(CcdiFileUploadServiceImpl.class);
        ListAppender<ILoggingEvent> logAppender = new ListAppender<>();
        logAppender.start();
        logger.addAppender(logAppender);

        try {
            Object result = ReflectionTestUtils.invokeMethod(
                service,
                "fetchAndSaveBankStatements",
                PROJECT_ID,
                LSFX_PROJECT_ID,
                LOG_ID,
                null
            );

            assertTrue(Boolean.TRUE.equals(ReflectionTestUtils.getField(result, "success")));
            assertEquals(1, ReflectionTestUtils.getField(result, "totalCount"));
            assertEquals(1, ReflectionTestUtils.getField(result, "attemptedCount"));
            assertTrue(logAppender.list.stream().map(ILoggingEvent::getFormattedMessage)
                .anyMatch(message -> message.contains("流水入库完成: fetchedCount=1, attemptedCount=1")));
            assertFalse(logAppender.list.stream().map(ILoggingEvent::getFormattedMessage)
                .anyMatch(message -> message.contains("insertedCount=")));
        } finally {
            logger.detachAppender(logAppender);
        }
    }

    @Test
    void refreshProjectTargetCount_shouldUseMatchedStaffCountOnly() {
        CcdiProject project = new CcdiProject();
        project.setProjectId(PROJECT_ID);
        when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
        when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(2);

        ReflectionTestUtils.invokeMethod(service, "refreshProjectTargetCount", PROJECT_ID);

        verify(projectMapper).updateById(org.mockito.ArgumentMatchers.<CcdiProject>argThat(item ->
            PROJECT_ID.equals(item.getProjectId()) && Integer.valueOf(2).equals(item.getTargetCount())
        ));
    }

    @Test
    void processFileAsync_shouldMarkParsedFailedWhenInsertBatchThrowsUnexpectedSqlError() throws IOException {
        List<String> events = new ArrayList<>();
        AtomicInteger sequence = new AtomicInteger();
        captureRecordStatus(events, sequence);

        when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
            .thenReturn(buildUploadResponse());
        when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
            .thenReturn(buildCheckParseStatusResponse(false));
        when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
        when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
            .thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem("62220001"))))
            .thenReturn(buildBankStatementResponseWithItems(1, List.of(buildBankStatementItem("62220001"))));
        when(bankStatementMapper.insertBatch(any()))
            .thenThrow(new RuntimeException("sql syntax error"));

        CcdiFileUploadRecord record = buildRecord();
        Path tempFile = createTempFile();

        service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);

        assertTrue(events.stream().anyMatch(event -> event.endsWith("record:parsed_failed")));
        assertFalse(events.stream().anyMatch(event -> event.endsWith("record:parsed_success")));
    }

    @Test
    void countByStatus_shouldIncludeDeletedCount() {
        when(recordMapper.countByStatus(PROJECT_ID)).thenReturn(List.of(
            Map.of("status", "uploading", "count", 1),
            Map.of("status", "deleted", "count", 2)
        ));

        CcdiFileUploadStatisticsVO result = service.countByStatus(PROJECT_ID);

        assertEquals(1L, result.getUploading());
        assertEquals(2L, result.getDeleted());
        assertEquals(3L, result.getTotal());
    }

    @Test
    void batchUploadCompletion_shouldSubmitProjectTagRebuildWhenAnyFileSucceeded() {
        ReflectionTestUtils.invokeMethod(
            service,
            "handleTagRebuildAfterBatchCompletion",
            PROJECT_ID,
            TriggerType.AUTO_BATCH_UPLOAD,
            Boolean.TRUE
        );

        verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_BATCH_UPLOAD);
    }

    @Test
    void pullBankInfoCompletion_shouldSubmitProjectTagRebuildWhenAnyTaskSucceeded() {
        ReflectionTestUtils.invokeMethod(
            service,
            "handleTagRebuildAfterBatchCompletion",
            PROJECT_ID,
            TriggerType.AUTO_PULL_BANK_INFO,
            Boolean.TRUE
        );

        verify(bankTagService).submitAutoRebuild(PROJECT_ID, TriggerType.AUTO_PULL_BANK_INFO);
    }

    private void captureRecordStatus(List<String> events, AtomicInteger sequence) {
        doAnswer(invocation -> {
            CcdiFileUploadRecord record = invocation.getArgument(0);
            events.add(sequence.incrementAndGet() + ":record:" + record.getFileStatus());
            return 1;
        }).when(recordMapper).updateById(any(CcdiFileUploadRecord.class));
    }

    private void captureUpdatedRecords(List<CcdiFileUploadRecord> updates) {
        doAnswer(invocation -> {
            CcdiFileUploadRecord record = invocation.getArgument(0);
            CcdiFileUploadRecord snapshot = new CcdiFileUploadRecord();
            snapshot.setId(record.getId());
            snapshot.setFileStatus(record.getFileStatus());
            snapshot.setErrorMessage(record.getErrorMessage());
            updates.add(snapshot);
            return 1;
        }).when(recordMapper).updateById(any(CcdiFileUploadRecord.class));
    }

    private void mockProjectWithLsfxProjectId() {
        CcdiProject project = new CcdiProject();
        project.setProjectId(PROJECT_ID);
        project.setLsfxProjectId(LSFX_PROJECT_ID);
        when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
    }

    private CcdiFileUploadRecord buildRecord() {
        CcdiFileUploadRecord record = new CcdiFileUploadRecord();
        record.setId(RECORD_ID);
        record.setProjectId(PROJECT_ID);
        record.setLsfxProjectId(LSFX_PROJECT_ID);
        record.setFileName("test.xlsx");
        record.setFileStatus("uploading");
        return record;
    }

    private Path createTempFile() throws IOException {
        Path tempFile = Files.createTempFile(tempDir, "upload-", ".xlsx");
        Files.writeString(tempFile, "test");
        return tempFile;
    }

    private UploadFileResponse buildUploadResponse() {
        UploadFileResponse.UploadLogItem logItem = new UploadFileResponse.UploadLogItem();
        logItem.setLogId(LOG_ID);

        UploadFileResponse.UploadData uploadData = new UploadFileResponse.UploadData();
        uploadData.setUploadLogList(List.of(logItem));

        UploadFileResponse response = new UploadFileResponse();
        response.setData(uploadData);
        return response;
    }

    private CheckParseStatusResponse buildCheckParseStatusResponse(boolean parsing) {
        CheckParseStatusResponse.ParseStatusData data = new CheckParseStatusResponse.ParseStatusData();
        data.setParsing(parsing);

        CheckParseStatusResponse response = new CheckParseStatusResponse();
        response.setData(data);
        return response;
    }

    private GetFileUploadStatusResponse buildParsedSuccessStatusResponse() {
        GetFileUploadStatusResponse.LogItem logItem = new GetFileUploadStatusResponse.LogItem();
        logItem.setStatus(-5);
        logItem.setUploadStatusDesc("data.wait.confirm.newaccount");
        logItem.setEnterpriseNameList(List.of("测试主体"));
        logItem.setAccountNoList(List.of("62220001"));

        GetFileUploadStatusResponse.FileUploadStatusData data =
            new GetFileUploadStatusResponse.FileUploadStatusData();
        data.setLogs(List.of(logItem));

        GetFileUploadStatusResponse response = new GetFileUploadStatusResponse();
        response.setData(data);
        return response;
    }

    private FetchInnerFlowResponse buildFetchInnerFlowResponse(Integer logId) {
        FetchInnerFlowResponse response = new FetchInnerFlowResponse();
        response.setData(List.of(logId));
        return response;
    }

    private DeleteFilesResponse buildDeleteFilesResponse() {
        DeleteFilesResponse response = new DeleteFilesResponse();
        response.setSuccessResponse(Boolean.TRUE);
        return response;
    }

    private GetFileUploadStatusResponse buildParsedSuccessStatusResponse(String uploadFileName) {
        GetFileUploadStatusResponse response = buildParsedSuccessStatusResponse();
        response.getData().getLogs().get(0).setUploadFileName(uploadFileName);
        return response;
    }

    private GetBankStatementResponse buildEmptyBankStatementResponse() {
        GetBankStatementResponse.BankStatementData data = new GetBankStatementResponse.BankStatementData();
        data.setTotalCount(0);

        GetBankStatementResponse response = new GetBankStatementResponse();
        response.setData(data);
        return response;
    }

    private GetBankStatementResponse buildBankStatementResponseWithTotalCount(int totalCount) {
        GetBankStatementResponse.BankStatementData data = new GetBankStatementResponse.BankStatementData();
        data.setTotalCount(totalCount);
        data.setBankStatementList(List.of());

        GetBankStatementResponse response = new GetBankStatementResponse();
        response.setData(data);
        return response;
    }

    private GetBankStatementResponse buildBankStatementCountResponse(int totalCount) {
        GetBankStatementResponse.BankStatementData data = new GetBankStatementResponse.BankStatementData();
        data.setTotalCount(totalCount);

        GetBankStatementResponse response = new GetBankStatementResponse();
        response.setData(data);
        return response;
    }

    private GetBankStatementResponse buildBankStatementResponseWithBlankCretNo() {
        GetBankStatementResponse.BankStatementItem item = new GetBankStatementResponse.BankStatementItem();
        item.setBankStatementId(1L);
        item.setLeName("测试主体");
        item.setAccountNo("62220001");
        item.setCustomerName("交易对手");
        item.setCustomerAccountNo("62220002");
        item.setDrAmount(BigDecimal.TEN);
        item.setCretNo(null);
        return buildBankStatementResponseWithItems(1, List.of(item));
    }

    private void invokeSubmitTasksAsync(List<String> tempFilePaths,
                                        List<CcdiFileUploadRecord> records,
                                        String batchId) throws Exception {
        Method method = CcdiFileUploadServiceImpl.class.getDeclaredMethod("submitTasksAsync",
            Long.class, Integer.class, List.class, List.class, String.class);
        method.setAccessible(true);
        method.invoke(service, PROJECT_ID, LSFX_PROJECT_ID, tempFilePaths, records, batchId);
    }

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

    private GetBankStatementResponse buildBankStatementResponseWithItems(
        int totalCount,
        List<GetBankStatementResponse.BankStatementItem> items
    ) {
        GetBankStatementResponse.BankStatementData data = new GetBankStatementResponse.BankStatementData();
        data.setTotalCount(totalCount);
        data.setBankStatementList(items);

        GetBankStatementResponse response = new GetBankStatementResponse();
        response.setData(data);
        return response;
    }

    private GetBankStatementResponse.BankStatementItem buildBankStatementItem(String accountNo) {
        GetBankStatementResponse.BankStatementItem item = new GetBankStatementResponse.BankStatementItem();
        item.setAccountNo(accountNo);
        item.setAccountingDateId(20260310);
        item.setDrAmount(new BigDecimal("100.00"));
        item.setCrAmount(new BigDecimal("0.00"));
        return item;
    }

    private MultipartFile createIdCardExcel(String... values) throws IOException {
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        List<List<String>> rows = new ArrayList<>();
        for (String value : values) {
            rows.add(List.of(value));
        }
        EasyExcel.write(outputStream).sheet("Sheet1").doWrite(rows);
        return new MockMultipartFile(
            "file",
            "id-cards.xlsx",
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            outputStream.toByteArray()
        );
    }

    private int findEventIndex(List<String> events, String suffix) {
        for (int i = 0; i < events.size(); i++) {
            if (events.get(i).endsWith(suffix)) {
                return i;
            }
        }
        return -1;
    }

    private CcdiFileUploadRecord findLastUpdatedRecordByStatus(List<CcdiFileUploadRecord> updates,
                                                               String status) {
        for (int i = updates.size() - 1; i >= 0; i--) {
            if (status.equals(updates.get(i).getFileStatus())) {
                return updates.get(i);
            }
        }
        throw new AssertionError("未找到状态为 " + status + " 的更新记录");
    }
}
