package com.njcn.rdms.module.project.service.project.execution;

import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionOwnerChangeReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ExecutionAssigneeDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionStatusLogDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusModelMapper;
import com.njcn.rdms.module.project.dal.mysql.status.ObjectStatusTransitionMapper;
import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class ProjectExecutionServiceImplTest extends BaseMockitoUnitTest {

    @InjectMocks
    private ProjectExecutionServiceImpl projectExecutionService;
    @Mock
    private ProjectMapper projectMapper;
    @Mock
    private ProjectExecutionMapper projectExecutionMapper;
    @Mock
    private ExecutionAssigneeMapper executionAssigneeMapper;
    @Mock
    private UserObjectRoleMapper userObjectRoleMapper;
    @Mock
    private BizAuditLogMapper bizAuditLogMapper;
    @Mock
    private ObjectStatusModelMapper objectStatusModelMapper;
    @Mock
    private ObjectStatusTransitionMapper objectStatusTransitionMapper;
    @Mock
    private ProjectExecutionStatusLogMapper projectExecutionStatusLogMapper;
    @Mock
    private ProjectTaskMapper projectTaskMapper;
    @Mock
    private DictDataApi dictDataApi;
    @Mock
    private AdminUserApi adminUserApi;
    @Mock
    private ProjectExecutionStatusViewService projectExecutionStatusViewService;
    @Mock
    private ProjectExecutionAssigneeService projectExecutionAssigneeService;
    @Mock
    private com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver visibilityScopeResolver;

    /**
     * 默认让 VisibilityScopeResolver 放行（seesAll=true），既有测试无需关心 scope。
     * 真正需要测试 scope 行为的用例可在方法内显式覆盖。
     */
    @BeforeEach
    void setupVisibilityScopeAll() {
        lenient().when(visibilityScopeResolver.resolveForProject(any(), any()))
                .thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all());
        lenient().when(visibilityScopeResolver.resolveForExecution(any(), any(), any()))
                .thenReturn(com.njcn.rdms.module.project.service.project.permission.VisibilityScope.all());
    }

    @Test
    void createExecution_shouldInsertPendingExecutionAndMembers() {
        Long projectId = 2001L;
        ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
        reqVO.setExecutionName("后端接口联调");
        reqVO.setExecutionType("feature");
        reqVO.setOwnerId(3001L);
        reqVO.setProjectRequirementId(null);
        reqVO.setAssigneeUserIds(List.of(3002L, 3003L));

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createProjectStatus("pending", true));
        when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("execution"))
                .thenReturn(createExecutionStatus("pending", true));
        when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
                .thenReturn(createProjectMember(projectId, 3001L, 3101L));
        when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3002L))
                .thenReturn(createProjectMember(projectId, 3002L, 3102L));
        when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3003L))
                .thenReturn(createProjectMember(projectId, 3003L, 3102L));
        when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
        when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
                .thenReturn(success(true));
        when(projectExecutionMapper.insert(any(ProjectExecutionDO.class))).thenAnswer(invocation -> {
            ProjectExecutionDO execution = invocation.getArgument(0);
            execution.setId(5001L);
            return 1;
        });

        Long executionId = projectExecutionService.createExecution(projectId, reqVO);

        assertNotNull(executionId);
        ArgumentCaptor<ProjectExecutionDO> executionCaptor = ArgumentCaptor.forClass(ProjectExecutionDO.class);
        verify(projectExecutionMapper).insert(executionCaptor.capture());
        assertEquals("pending", executionCaptor.getValue().getStatusCode());
        assertEquals(BigDecimal.ZERO, executionCaptor.getValue().getProgressRate());
        assertEquals(projectId, executionCaptor.getValue().getProjectId());
        assertNull(executionCaptor.getValue().getProjectRequirementId());
        assertNull(executionCaptor.getValue().getActualStartDate());
        assertNull(executionCaptor.getValue().getActualEndDate());
        verify(executionAssigneeMapper, times(2)).insert(any(ExecutionAssigneeDO.class));
        verify(bizAuditLogMapper, atLeastOnce()).insert(any(BizAuditLogDO.class));
    }

    @Test
    void createExecution_whenRequirementIdNotNull_shouldThrowNotReady() {
        Long projectId = 2001L;
        ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
        reqVO.setExecutionName("后端接口联调");
        reqVO.setOwnerId(3001L);
        reqVO.setProjectRequirementId(9001L);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createProjectStatus("pending", true));

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectExecutionService.createExecution(projectId, reqVO));

        assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_NOT_READY.getCode(), ex.getCode());
    }

    @Test
    void createExecution_whenExecutionTypeInvalid_shouldThrow() {
        Long projectId = 2001L;
        ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
        reqVO.setExecutionName("后端接口联调");
        reqVO.setExecutionType("unknown");
        reqVO.setOwnerId(3001L);
        reqVO.setAssigneeUserIds(List.of(3002L));

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createProjectStatus("pending", true));
        when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
        when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
                .thenReturn(createProjectMember(projectId, 3001L, 3101L));
        when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("unknown")))
                .thenThrow(new RuntimeException("invalid dict"));

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectExecutionService.createExecution(projectId, reqVO));

        assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_TYPE_INVALID.getCode(), ex.getCode());
    }

    @Test
    void createExecution_whenMemberListEmpty_shouldThrowRequired() {
        Long projectId = 2001L;
        ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
        reqVO.setExecutionName("后端接口联调");
        reqVO.setExecutionType("feature");
        reqVO.setOwnerId(3001L);
        reqVO.setAssigneeUserIds(List.of());

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createProjectStatus("pending", true));
        when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
        when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
                .thenReturn(createProjectMember(projectId, 3001L, 3101L));
        when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
                .thenReturn(success(true));

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectExecutionService.createExecution(projectId, reqVO));

        assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_REQUIRED.getCode(), ex.getCode());
    }

    @Test
    void updateExecution_shouldKeepPendingStatusAndRejectManualStatusInput() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
        ProjectExecutionUpdateReqVO reqVO = new ProjectExecutionUpdateReqVO();
        reqVO.setId(executionId);
        reqVO.setExecutionName("接口联调-修订");
        reqVO.setExecutionType("feature");
        reqVO.setProjectRequirementId(null);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createProjectStatus("pending", true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
                .thenReturn(createExecutionStatus("pending", true));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
        when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调-修订")).thenReturn(null);
        when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3001L))
                .thenReturn(createProjectMember(projectId, 3001L, 3101L));
        when(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
                .thenReturn(success(true));

        projectExecutionService.updateExecution(projectId, reqVO);

        assertEquals("pending", execution.getStatusCode());
        verify(projectExecutionMapper).updateById(execution);
    }

    @Test
    void changeOwner_shouldUpdateOwnerAndWriteOwnerTransferLogs() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createProjectStatus("pending", true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
                .thenReturn(createExecutionStatus("pending", true));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
        when(userObjectRoleMapper.selectActiveByObjectAndUserId("project", projectId, 3002L))
                .thenReturn(createProjectMember(projectId, 3002L, 3102L));

        ProjectExecutionOwnerChangeReqVO reqVO = new ProjectExecutionOwnerChangeReqVO();
        reqVO.setNewOwnerId(3002L);
        reqVO.setReason("负责人调整");

        projectExecutionService.changeOwner(projectId, executionId, reqVO);

        assertEquals(3002L, execution.getOwnerId());
        verify(projectExecutionMapper).updateById(execution);
        verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class));
        // 双向写入成员变更历史：原 owner 转出 + 新 owner 转入
        verify(projectExecutionAssigneeService).writeAssigneeLog(executionId, 3001L,
                ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_OUT, "负责人调整");
        verify(projectExecutionAssigneeService).writeAssigneeLog(executionId, 3002L,
                ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, "负责人调整");
    }

    @Test
    void changeExecutionStatus_shouldUpdateByTransitionAndWriteStatusAndAuditLogs() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
        ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO();
        reqVO.setActionCode("start");

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
        when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "pending", "start"))
                .thenReturn(createTransition("start", "active", false));
        when(projectExecutionMapper.updateStatusByIdAndStatus(executionId, "pending", "active", null)).thenReturn(1);

        projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO);

        verify(projectExecutionMapper).updateStatusByIdAndStatus(executionId, "pending", "active", null);
        ArgumentCaptor<ProjectExecutionDO> executionCaptor = ArgumentCaptor.forClass(ProjectExecutionDO.class);
        verify(projectExecutionMapper).updateById(executionCaptor.capture());
        assertNotNull(executionCaptor.getValue().getActualStartDate());
        ArgumentCaptor<ProjectExecutionStatusLogDO> statusCaptor =
                ArgumentCaptor.forClass(ProjectExecutionStatusLogDO.class);
        verify(projectExecutionStatusLogMapper).insert(statusCaptor.capture());
        assertEquals("start", statusCaptor.getValue().getActionType());
        assertEquals("pending", statusCaptor.getValue().getFromStatus());
        assertEquals("active", statusCaptor.getValue().getToStatus());
        verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class));
    }

    @Test
    void changeExecutionStatus_whenReasonRequiredButBlank_shouldThrow() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
        ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO();
        reqVO.setActionCode("cancel");
        reqVO.setReason(" ");

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
        when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "pending", "cancel"))
                .thenReturn(createTransition("cancel", "cancelled", true));

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO));

        assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED.getCode(), ex.getCode());
    }

    @Test
    void changeExecutionStatus_whenConcurrentModified_shouldThrow() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
        ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO();
        reqVO.setActionCode("start");

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
        when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "pending", "start"))
                .thenReturn(createTransition("start", "active", false));
        when(projectExecutionMapper.updateStatusByIdAndStatus(executionId, "pending", "active", null)).thenReturn(0);

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO));

        assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED.getCode(), ex.getCode());
    }

    @Test
    void changeExecutionStatus_whenCompleteTransitionMissing_shouldThrow() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
        execution.setStatusCode("active");
        ProjectExecutionStatusActionReqVO reqVO = new ProjectExecutionStatusActionReqVO();
        reqVO.setActionCode("complete");

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
        when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("execution", "active", "complete"))
                .thenReturn(null);

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO));

        assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode());
    }

    @Test
    void updateExecution_whenExecutionPaused_shouldThrowNotAllowEdit() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
        execution.setStatusCode("paused");
        ProjectExecutionUpdateReqVO reqVO = new ProjectExecutionUpdateReqVO();
        reqVO.setId(executionId);
        reqVO.setExecutionName("接口联调-修订");

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createProjectStatus("pending", true));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused"))
                .thenReturn(createExecutionStatus("paused", false));

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectExecutionService.updateExecution(projectId, reqVO));

        assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode());
    }

    @Test
    void changeOwner_whenExecutionPaused_shouldThrowNotAllowEdit() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
        execution.setStatusCode("paused");
        ProjectExecutionOwnerChangeReqVO reqVO = new ProjectExecutionOwnerChangeReqVO();
        reqVO.setNewOwnerId(3002L);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createProjectStatus("pending", true));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "paused"))
                .thenReturn(createExecutionStatus("paused", false));

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectExecutionService.changeOwner(projectId, executionId, reqVO));

        assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_NOT_ALLOW_EDIT.getCode(), ex.getCode());
    }

    @Test
    void getExecutionRespVO_shouldOverwriteCachedProgressWithAggregatedRootTaskProgress() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
        execution.setProgressRate(BigDecimal.ZERO);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
        when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId))
                .thenReturn(new BigDecimal("66.666"));
        when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
                .thenReturn(createLifecycleView());

        ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);

        assertEquals(new BigDecimal("66.67"), result.getProgressRate());
        verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId);
    }

    @Test
    void getExecutionRespVO_whenExecutionHasNoRootTasks_shouldReturnZeroProgress() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
        execution.setProgressRate(new BigDecimal("100.00"));

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
        when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId)).thenReturn(null);
        when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
                .thenReturn(createLifecycleView());

        ProjectExecutionRespVO result = projectExecutionService.getExecutionRespVO(projectId, executionId);

        assertEquals(new BigDecimal("0.00"), result.getProgressRate());
    }

    @Test
    void getExecutionRespVOPage_shouldBatchOverwriteCachedProgress() {
        Long projectId = 2001L;
        ProjectExecutionPageReqVO reqVO = new ProjectExecutionPageReqVO();
        reqVO.setPageNo(1);
        reqVO.setPageSize(10);
        ProjectExecutionDO first = createExecution(projectId, 5001L, 3001L);
        first.setProgressRate(BigDecimal.ZERO);
        ProjectExecutionDO second = createExecution(projectId, 5002L, 3002L);
        second.setProgressRate(new BigDecimal("100.00"));

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
                .thenReturn(new PageResult<>(List.of(first, second), 2L));
        when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
                .thenReturn(List.of(Map.of("execution_id", 5001L, "progress_rate", new BigDecimal("25.555"))));
        when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
                .thenReturn(createLifecycleView());
        when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
                .thenReturn(createLifecycleView());

        PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);

        assertEquals(new BigDecimal("25.56"), result.getList().get(0).getProgressRate());
        assertEquals(new BigDecimal("0.00"), result.getList().get(1).getProgressRate());
        ArgumentCaptor<Collection<Long>> executionIdsCaptor = ArgumentCaptor.forClass(Collection.class);
        verify(projectTaskMapper).selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), executionIdsCaptor.capture());
        assertEquals(List.of(5001L, 5002L), List.copyOf(executionIdsCaptor.getValue()));
    }

    @Test
    void getExecutionRespVOPage_shouldTolerateLooseAggregationRows() {
        Long projectId = 2001L;
        ProjectExecutionPageReqVO reqVO = new ProjectExecutionPageReqVO();
        reqVO.setPageNo(1);
        reqVO.setPageSize(10);
        ProjectExecutionDO first = createExecution(projectId, 5001L, 3001L);
        ProjectExecutionDO second = createExecution(projectId, 5002L, 3002L);

        List<Map<String, Object>> rows = new ArrayList<>();
        rows.add(null);
        rows.add(Map.of("executionId", " 5001 ", "progressRate", " 25.555 "));
        rows.add(Map.of("executionId", 5002L, "progressRate", 10));

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
                .thenReturn(new PageResult<>(List.of(first, second), 2L));
        when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection()))
                .thenReturn(rows);
        when(projectExecutionStatusViewService.getLifecycle("pending", 3001L))
                .thenReturn(createLifecycleView());
        when(projectExecutionStatusViewService.getLifecycle("pending", 3002L))
                .thenReturn(createLifecycleView());

        PageResult<ProjectExecutionRespVO> result = projectExecutionService.getExecutionRespVOPage(projectId, reqVO);

        assertEquals(new BigDecimal("25.56"), result.getList().get(0).getProgressRate());
        assertEquals(new BigDecimal("10.00"), result.getList().get(1).getProgressRate());
    }

    @Test
    void getExecutionPage_shouldDelegateMapper() {
        Long projectId = 2001L;
        ProjectExecutionPageReqVO reqVO = new ProjectExecutionPageReqVO();
        reqVO.setPageNo(1);
        reqVO.setPageSize(20);
        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectPageByProjectId(eq(projectId), any(), eq(reqVO)))
                .thenReturn(new PageResult<>(List.of(), 0L));

        PageResult<ProjectExecutionDO> result = projectExecutionService.getExecutionPage(projectId, reqVO);

        assertEquals(0L, result.getTotal());
    }

    private ProjectDO createEditableProject(Long projectId) {
        ProjectDO project = new ProjectDO();
        project.setId(projectId);
        project.setProjectCode("CNPJ2026001");
        project.setProjectName("测试项目");
        project.setStatusCode("pending");
        project.setManagerUserId(3001L);
        return project;
    }

    private ObjectStatusModelDO createProjectStatus(String statusCode, boolean allowEdit) {
        ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
        statusModel.setObjectType("project");
        statusModel.setStatusCode(statusCode);
        statusModel.setAllowEdit(allowEdit);
        return statusModel;
    }

    private ObjectStatusModelDO createExecutionStatus(String statusCode, boolean allowEdit) {
        ObjectStatusModelDO statusModel = new ObjectStatusModelDO();
        statusModel.setObjectType("execution");
        statusModel.setStatusCode(statusCode);
        statusModel.setAllowEdit(allowEdit);
        return statusModel;
    }

    private ObjectStatusTransitionDO createTransition(String actionCode, String toStatus, boolean needReason) {
        ObjectStatusTransitionDO transition = new ObjectStatusTransitionDO();
        transition.setActionCode(actionCode);
        transition.setActionName(actionCode);
        transition.setToStatusCode(toStatus);
        transition.setNeedReason(needReason);
        return transition;
    }

    private ProjectExecutionStatusViewService.ProjectExecutionLifecycleView createLifecycleView() {
        return new ProjectExecutionStatusViewService.ProjectExecutionLifecycleView(
                "待处理", false, true, Collections.emptyList());
    }

    private UserObjectRoleDO createProjectMember(Long projectId, Long userId, Long roleId) {
        UserObjectRoleDO member = new UserObjectRoleDO();
        member.setId(projectId + userId);
        member.setObjectType("project");
        member.setObjectId(projectId);
        member.setUserId(userId);
        member.setRoleId(roleId);
        member.setStatus(0);
        member.setJoinedTime(LocalDateTime.now());
        return member;
    }

    private ProjectExecutionDO createExecution(Long projectId, Long executionId, Long ownerId) {
        ProjectExecutionDO execution = new ProjectExecutionDO();
        execution.setId(executionId);
        execution.setProjectId(projectId);
        execution.setExecutionName("接口联调");
        execution.setOwnerId(ownerId);
        execution.setStatusCode("pending");
        execution.setProgressRate(BigDecimal.ZERO);
        execution.setDeleted(Boolean.FALSE);
        return execution;
    }
}
