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.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionRespVO;
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.ProjectExecutionDeleteReqVO;
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.constant.ProjectExecutionConstants;
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.ProjectRequirementMapper;
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.project.framework.notify.NotifyJumpConstants;
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService;
import com.njcn.rdms.module.project.service.project.ProjectRequirementService;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
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 org.mockito.MockedStatic;

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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mockStatic;
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 ProjectRequirementService projectRequirementService;
    @Mock
    private ProjectRequirementMapper projectRequirementMapper;
    @Mock
    private ProjectObjectAuthorizationService projectObjectAuthorizationService;
    @Mock
    private com.njcn.rdms.module.project.service.status.StatusActionTextResolver statusActionTextResolver;
    @Mock
    private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;

    /**
     * 默认让 dictDataApi.validateDictDataList 对 REQ_PRIORITY 返回 true，既有测试不因 priority 校验失败。
     * 读路径鉴权由 @CheckObjectPermission 的 AOP 处理，单测 @InjectMocks 不走 AOP，无须在此 mock。
     */
    @BeforeEach
    void setupDefaultPriorityValidation() {
        lenient().when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any()))
                .thenReturn(success(true));
        lenient().when(userObjectRoleMapper.selectActiveListByObjectAndUserId(eq("project"), any(), eq(3001L)))
                .thenAnswer(invocation -> List.of(createProjectMember(invocation.getArgument(1), 3001L, 3101L)));
    }

    @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));
        reqVO.setPriority("1");

        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.selectActiveListByObjectAndUserId("project", projectId, 3001L))
                .thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
        when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3002L))
                .thenReturn(List.of(createProjectMember(projectId, 3002L, 3102L)));
        when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3003L))
                .thenReturn(List.of(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));
    }

    /**
     * TD-013：传入非空 projectRequirementId 时，必须调用 ProjectRequirementService.validateUsableForExecution；
     * 校验抛错时执行模块应原样透传错误码（不存在 / 不属本项目 / 终态三类内部分支由项目需求模块自测）。
     */
    @Test
    void createExecution_whenRequirementValidationFails_shouldPropagateError() {
        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));
        doThrow(new ServiceException(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_TERMINAL))
                .when(projectRequirementService).validateUsableForExecution(projectId, 9001L);

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

        assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_REQUIREMENT_TERMINAL.getCode(), ex.getCode());
        verify(projectRequirementService).validateUsableForExecution(projectId, 9001L);
    }

    /**
     * TD-013：传入合法 projectRequirementId 且校验通过时，落库时该字段应原样写入，不再被清空。
     */
    @Test
    void createExecution_whenRequirementIdValid_shouldInsertWithRequirementId() {
        Long projectId = 2001L;
        ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
        reqVO.setExecutionName("后端接口联调");
        reqVO.setExecutionType("feature");
        reqVO.setOwnerId(3001L);
        reqVO.setProjectRequirementId(9001L);
        reqVO.setAssigneeUserIds(List.of(3002L));
        reqVO.setPriority("1");

        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.selectActiveListByObjectAndUserId("project", projectId, 3001L))
                .thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
        when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3002L))
                .thenReturn(List.of(createProjectMember(projectId, 3002L, 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(9001L, executionCaptor.getValue().getProjectRequirementId());
        verify(projectRequirementService).validateUsableForExecution(projectId, 9001L);
    }

    @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.selectActiveListByObjectAndUserId("project", projectId, 3001L))
                .thenReturn(List.of(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.setPriority("1");
        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(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.setPriority("1");
        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(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 updateExecution_shouldAuthorizeProjectManagerOrExecutionOwnerBeforeUpdating() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectDO project = createEditableProject(projectId);
        project.setManagerUserId(3001L);
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3002L);
        ProjectExecutionUpdateReqVO reqVO = new ProjectExecutionUpdateReqVO();
        reqVO.setId(executionId);
        reqVO.setExecutionName("执行权限联调");
        reqVO.setExecutionType("feature");
        reqVO.setPriority("1");
        reqVO.setProjectRequirementId(null);

        when(projectMapper.selectById(projectId)).thenReturn(project);
        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(dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE, List.of("feature")))
                .thenReturn(success(true));

        projectExecutionService.updateExecution(projectId, reqVO);

        verify(projectObjectAuthorizationService).checkAnyOwnerOrProjectPermission(projectId,
                List.of(3001L, 3002L), ProjectExecutionConstants.PERMISSION_UPDATE);
    }

    @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.selectActiveListByObjectAndUserId("project", projectId, 3002L))
                .thenReturn(List.of(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 deleteExecution_shouldAuthorizeProjectManagerOrExecutionOwnerBeforeDeleting() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectDO project = createEditableProject(projectId);
        project.setManagerUserId(3001L);
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3002L);
        execution.setExecutionName("Execution auth");
        ProjectExecutionDeleteReqVO reqVO = new ProjectExecutionDeleteReqVO();
        reqVO.setExecutionName("Execution auth");
        reqVO.setConfirmText("DELETE");
        reqVO.setReason("录入错误");

        when(projectMapper.selectById(projectId)).thenReturn(project);
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
        when(projectTaskMapper.selectIdsByExecutionId(executionId)).thenReturn(Collections.emptyList());
        when(projectExecutionMapper.deleteById(executionId)).thenReturn(1);

        projectExecutionService.deleteExecution(projectId, executionId, reqVO);

        verify(projectObjectAuthorizationService).checkAnyOwnerOrProjectPermission(projectId,
                List.of(3001L, 3002L), ProjectExecutionConstants.PERMISSION_DELETE);
        verify(projectExecutionMapper).deleteById(executionId);
    }

    @Test
    void precheckDeleteExecution_shouldAuthorizeProjectManagerOrExecutionOwnerBeforeCountingTasks() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectDO project = createEditableProject(projectId);
        project.setManagerUserId(3001L);
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3002L);

        when(projectMapper.selectById(projectId)).thenReturn(project);
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
        when(projectTaskMapper.countByExecutionId(executionId)).thenReturn(3);

        var result = projectExecutionService.precheckDeleteExecution(projectId, executionId);

        verify(projectObjectAuthorizationService).checkAnyOwnerOrProjectPermission(projectId,
                List.of(3001L, 3002L), ProjectExecutionConstants.PERMISSION_DELETE);
        assertEquals(3, result.getTaskCount());
        assertEquals(true, result.getHasDependentData());
    }

    @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);
        // maybeFillActualDates：fromStatus="pending" 的 initialFlag=true → 触发 actualStartDate 填充并调 updateById
        ObjectStatusModelDO pendingInitialStatus = createExecutionStatus("pending", true);
        pendingInitialStatus.setInitialFlag(Boolean.TRUE);
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
                .thenReturn(pendingInitialStatus);

        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;
        // ownerId=3001L；cancel 是 owner-only 动作，登录用户必须是负责人才能通过 validateOwnerForAction
        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));

        try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3001L)) {
            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;
        // ownerId=3001L；complete 是 owner-only 动作，登录用户必须是负责人才能通过 validateOwnerForAction
        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);
        when(statusActionTextResolver.statusName("execution", "active")).thenReturn("进行中");
        when(statusActionTextResolver.actionName("execution", "complete")).thenReturn("完成");

        try (MockedStatic<SecurityFrameworkUtils> mockedStatic = mockLoginUser(3001L)) {
            ServiceException ex = assertThrows(ServiceException.class,
                    () -> projectExecutionService.changeExecutionStatus(projectId, executionId, reqVO));
            assertEquals(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED.getCode(), ex.getCode());
            org.junit.jupiter.api.Assertions.assertTrue(ex.getMessage().contains("进行中"));
            org.junit.jupiter.api.Assertions.assertTrue(ex.getMessage().contains("完成"));
            org.junit.jupiter.api.Assertions.assertFalse(ex.getMessage().contains("complete"));
        }
    }

    @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(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
                .thenReturn(List.of("cancelled"));
        when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled")))
                .thenReturn(new BigDecimal("66.666"));
        when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("66.67")), anyBoolean()))
                .thenReturn(createLifecycleView());

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

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

    @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(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
                .thenReturn(List.of("cancelled"));
        when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled")))
                .thenReturn(null);
        when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("0.00")), anyBoolean()))
                .thenReturn(createLifecycleView());

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

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

    @Test
    void getExecutionRespVO_shouldPassConfiguredExcludedStatusesToProgressAggregation() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId)).thenReturn(execution);
        when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
                .thenReturn(List.of("cancelled"));
        when(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled")))
                .thenReturn(new BigDecimal("100.00"));
        when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("100.00")), anyBoolean()))
                .thenReturn(createLifecycleView());

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

        assertEquals(new BigDecimal("100.00"), result.getProgressRate());
        verify(projectTaskMapper).selectRootTaskAvgProgressByExecutionId(projectId, executionId, List.of("cancelled"));
    }

    @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), eq(reqVO), any(), any(), any(), any()))
                .thenReturn(new PageResult<>(List.of(first, second), 2L));
        when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
                .thenReturn(List.of("cancelled"));
        when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection(), eq(List.of("cancelled"))))
                .thenReturn(List.of(Map.of("execution_id", 5001L, "progress_rate", new BigDecimal("25.555"))));
        when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("25.56")), anyBoolean()))
                .thenReturn(createLifecycleView());
        when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3002L), eq(new BigDecimal("0.00")), anyBoolean()))
                .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(),
                eq(List.of("cancelled")));
        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), eq(reqVO), any(), any(), any(), any()))
                .thenReturn(new PageResult<>(List.of(first, second), 2L));
        when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
                .thenReturn(List.of("cancelled"));
        when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection(), eq(List.of("cancelled"))))
                .thenReturn(rows);
        when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3001L), eq(new BigDecimal("25.56")), anyBoolean()))
                .thenReturn(createLifecycleView());
        when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(3002L), eq(new BigDecimal("10.00")), anyBoolean()))
                .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 getCurrentUserExecutionList_shouldQueryByLoginUserAndAssembleAcrossProjects() {
        Long loginUserId = 3001L;
        ProjectExecutionDO first = createExecution(2001L, 5001L, loginUserId);
        ProjectExecutionDO second = createExecution(2002L, 5002L, loginUserId);

        when(projectExecutionMapper.selectListByOwnerId(loginUserId)).thenReturn(List.of(first, second));
        when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
                .thenReturn(List.of("cancelled"));
        when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(2001L), anyCollection(), eq(List.of("cancelled"))))
                .thenReturn(List.of(Map.of("execution_id", 5001L, "progress_rate", new BigDecimal("25.555"))));
        when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(2002L), anyCollection(), eq(List.of("cancelled"))))
                .thenReturn(List.of(Map.of("execution_id", 5002L, "progress_rate", new BigDecimal("66.666"))));
        when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(loginUserId), eq(new BigDecimal("25.56")), anyBoolean()))
                .thenReturn(createLifecycleView());
        when(projectExecutionStatusViewService.getLifecycle(eq("pending"), eq(loginUserId), eq(new BigDecimal("66.67")), anyBoolean()))
                .thenReturn(createLifecycleView());
        when(adminUserApi.getUserMap(anyCollection())).thenReturn(Map.of(loginUserId, createUser(loginUserId, "测试负责人")));

        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);

            List<ProjectExecutionRespVO> result = projectExecutionService.getCurrentUserExecutionList();

            assertEquals(2, result.size());
            assertEquals(new BigDecimal("25.56"), result.get(0).getProgressRate());
            assertEquals(new BigDecimal("66.67"), result.get(1).getProgressRate());
            assertEquals("测试负责人", result.get(0).getOwnerNickname());
            assertEquals("测试负责人", result.get(1).getOwnerNickname());
            verify(projectExecutionMapper).selectListByOwnerId(loginUserId);
            verify(projectTaskMapper).selectRootTaskAvgProgressGroupByExecutionIds(eq(2001L), anyCollection(),
                    eq(List.of("cancelled")));
            verify(projectTaskMapper).selectRootTaskAvgProgressGroupByExecutionIds(eq(2002L), anyCollection(),
                    eq(List.of("cancelled")));
        }
    }

    @Test
    void getMyExecutionPage_shouldExcludeTerminalAndFullProgressAndAssembleProjectName() {
        Long loginUserId = 3001L;
        Long projectId = 2001L;
        // 5001 active 进度 25.555 → 保留(26)；5002 completed 终态 → 排除；
        // 5003 active 进度 100 → 排除；5004 active 无根任务 → 进度 0 → 保留
        ProjectExecutionDO e1 = createExecution(projectId, 5001L, loginUserId);
        e1.setStatusCode("active");
        ProjectExecutionDO e2 = createExecution(projectId, 5002L, loginUserId);
        e2.setStatusCode("completed");
        ProjectExecutionDO e3 = createExecution(projectId, 5003L, loginUserId);
        e3.setStatusCode("active");
        ProjectExecutionDO e4 = createExecution(projectId, 5004L, loginUserId);
        e4.setStatusCode("active");

        when(projectExecutionMapper.selectListByOwnerId(loginUserId))
                .thenReturn(List.of(e1, e2, e3, e4));
        // 执行域终态
        when(objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled("execution"))
                .thenReturn(List.of("completed", "cancelled"));
        // 任务域进度排除状态（loadProgressExcludedTaskStatusCodes 内部按 "task" 查）
        when(objectStatusModelMapper.selectProgressExcludedStatusCodesByObjectTypeEnabled("task"))
                .thenReturn(List.of("cancelled"));
        // 聚合进度：5001=25.555, 5003=100；5004 缺失 → 0
        when(projectTaskMapper.selectRootTaskAvgProgressGroupByExecutionIds(eq(projectId), anyCollection(), eq(List.of("cancelled"))))
                .thenReturn(List.of(
                        Map.of("execution_id", 5001L, "progress_rate", new BigDecimal("25.555")),
                        Map.of("execution_id", 5003L, "progress_rate", new BigDecimal("100.00"))));
        // 执行域状态模型（建 statusCode -> statusName）
        ObjectStatusModelDO activeStatus = new ObjectStatusModelDO();
        activeStatus.setObjectType("execution");
        activeStatus.setStatusCode("active");
        activeStatus.setStatusName("进行中");
        when(objectStatusModelMapper.selectListByObjectTypeEnabled("execution"))
                .thenReturn(List.of(activeStatus));
        // 项目名回填
        when(projectMapper.selectBatchIds(anyCollection()))
                .thenReturn(List.of(createEditableProject(projectId)));

        MyProjectExecutionPageReqVO reqVO = new MyProjectExecutionPageReqVO();
        reqVO.setPageNo(1);
        reqVO.setPageSize(-1);

        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);

            PageResult<MyProjectExecutionRespVO> result = projectExecutionService.getMyExecutionPage(reqVO);

            // 仅 5001、5004 保留
            assertEquals(2L, result.getTotal());
            assertEquals(2, result.getList().size());
            assertEquals(List.of(5001L, 5004L),
                    result.getList().stream().map(MyProjectExecutionRespVO::getId).collect(java.util.stream.Collectors.toList()));
            // 进度：25.555 → 四舍五入 26；无根任务 → 0
            assertEquals(Integer.valueOf(26), result.getList().get(0).getProgressRate());
            assertEquals(Integer.valueOf(0), result.getList().get(1).getProgressRate());
            // projectName / statusName 回填
            assertEquals("测试项目", result.getList().get(0).getProjectName());
            assertEquals("进行中", result.getList().get(0).getStatusName());
            assertEquals("active", result.getList().get(0).getStatusCode());
        }
    }

    @Test
    void getMyExecutionPage_whenNoExecutions_shouldReturnEmptyPage() {
        Long loginUserId = 3001L;
        when(projectExecutionMapper.selectListByOwnerId(loginUserId)).thenReturn(List.of());

        MyProjectExecutionPageReqVO reqVO = new MyProjectExecutionPageReqVO();
        reqVO.setPageNo(1);
        reqVO.setPageSize(-1);

        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);

            PageResult<MyProjectExecutionRespVO> result = projectExecutionService.getMyExecutionPage(reqVO);

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

    @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), eq(reqVO), any(), any(), any(), any()))
                .thenReturn(new PageResult<>(List.of(), 0L));

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

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

    // -------------------- priority 单测 --------------------

    /**
     * 传 null priority → validatePriority(null) 内部 NPE 被 catch → 抛 PROJECT_EXECUTION_PRIORITY_INVALID。
     * 注：测试架构直接调 service，不经过 Bean Validation，@NotBlank 不触发。
     */
    @Test
    void createExecution_whenPriorityNull_shouldThrowPriorityInvalid() {
        Long projectId = 2001L;
        ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
        reqVO.setExecutionName("接口联调");
        reqVO.setExecutionType("feature");
        reqVO.setOwnerId(3001L);
        reqVO.setAssigneeUserIds(List.of(3002L));
        reqVO.setPriority(null);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createProjectStatus("pending", true));
        when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调")).thenReturn(null);
        when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any()))
                .thenReturn(success(true));

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

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

    /**
     * 传非字典值 "99" → dictDataApi 对 REQ_PRIORITY 抛 RuntimeException → 抛 PROJECT_EXECUTION_PRIORITY_INVALID。
     */
    @Test
    void createExecution_whenPriorityInvalid_shouldThrowPriorityInvalid() {
        Long projectId = 2001L;
        ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
        reqVO.setExecutionName("接口联调");
        reqVO.setExecutionType("feature");
        reqVO.setOwnerId(3001L);
        reqVO.setAssigneeUserIds(List.of(3002L));
        reqVO.setPriority("99");

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createProjectStatus("pending", true));
        when(projectExecutionMapper.selectByProjectIdAndName(projectId, "接口联调")).thenReturn(null);
        when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3001L))
                .thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
        // EXECUTION_TYPE 校验通过
        when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any()))
                .thenReturn(success(true));
        // REQ_PRIORITY 校验抛异常
        when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any()))
                .thenThrow(new RuntimeException("非法字典值"));

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

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

    /**
     * 传合法值 "0" → 落库，DO.priority == "0"。
     */
    @Test
    void createExecution_whenPriorityValid_shouldPersistPriority() {
        Long projectId = 2001L;
        ProjectExecutionCreateReqVO reqVO = new ProjectExecutionCreateReqVO();
        reqVO.setExecutionName("后端接口联调");
        reqVO.setExecutionType("feature");
        reqVO.setOwnerId(3001L);
        reqVO.setAssigneeUserIds(List.of(3002L));
        reqVO.setPriority("0");

        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.selectActiveListByObjectAndUserId("project", projectId, 3001L))
                .thenReturn(List.of(createProjectMember(projectId, 3001L, 3101L)));
        when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, 3002L))
                .thenReturn(List.of(createProjectMember(projectId, 3002L, 3102L)));
        when(projectExecutionMapper.selectByProjectIdAndName(projectId, "后端接口联调")).thenReturn(null);
        when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any()))
                .thenReturn(success(true));
        when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any()))
                .thenReturn(success(true));
        when(projectExecutionMapper.insert(any(ProjectExecutionDO.class))).thenAnswer(invocation -> {
            ProjectExecutionDO execution = invocation.getArgument(0);
            execution.setId(5001L);
            return 1;
        });

        projectExecutionService.createExecution(projectId, reqVO);

        ArgumentCaptor<ProjectExecutionDO> executionCaptor = ArgumentCaptor.forClass(ProjectExecutionDO.class);
        verify(projectExecutionMapper).insert(executionCaptor.capture());
        assertEquals("0", executionCaptor.getValue().getPriority());
    }

    /**
     * updateExecution 时 priority 从 "1" 改为 "0" → bizAuditLog 写入的 fieldChanges JSON 含 "priority" 键。
     */
    @Test
    void updateExecution_whenPriorityChanged_shouldRecordPriorityFieldChange() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectExecutionDO execution = createExecution(projectId, executionId, 3001L);
        execution.setPriority("1");
        ProjectExecutionUpdateReqVO reqVO = new ProjectExecutionUpdateReqVO();
        reqVO.setId(executionId);
        reqVO.setExecutionName("接口联调");
        reqVO.setExecutionType("feature");
        reqVO.setProjectRequirementId(null);
        reqVO.setPriority("0");

        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(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.EXECUTION_TYPE), any()))
                .thenReturn(success(true));
        when(dictDataApi.validateDictDataList(eq(ProjectDictTypeConstants.REQ_PRIORITY), any()))
                .thenReturn(success(true));

        projectExecutionService.updateExecution(projectId, reqVO);

        ArgumentCaptor<BizAuditLogDO> auditCaptor = ArgumentCaptor.forClass(BizAuditLogDO.class);
        verify(bizAuditLogMapper).insert(auditCaptor.capture());
        assertNotNull(auditCaptor.getValue().getFieldChanges());
        // fieldChanges JSON 必须包含 priority 字段变更
        assertEquals(true, auditCaptor.getValue().getFieldChanges().contains("priority"));
    }

    /**
     * 创建执行后应 publish「执行指派」站内信事件：接收人 = 负责人 + 活跃协办人，
     * 模板码 execution_assigned，operatorUserId = 当前登录用户（监听器统一排除），params 含项目名 / 执行名。
     */
    @Test
    void createExecution_publishesAssignedNotify() {
        try (MockedStatic<SecurityFrameworkUtils> mocked = mockStatic(SecurityFrameworkUtils.class)) {
            mocked.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(7L);
            ProjectExecutionDO exec = new ProjectExecutionDO();
            exec.setId(1L);
            exec.setProjectId(10L);
            exec.setExecutionName("一阶段");
            exec.setOwnerId(5L);
            ExecutionAssigneeDO a = new ExecutionAssigneeDO();
            a.setUserId(6L);
            when(executionAssigneeMapper.selectActiveListByExecutionId(1L)).thenReturn(List.of(a));
            ProjectDO project = new ProjectDO();
            project.setProjectName("灿能项目");
            when(projectMapper.selectById(10L)).thenReturn(project);

            ArgumentCaptor<NotifySendEvent> captor = ArgumentCaptor.forClass(NotifySendEvent.class);
            projectExecutionService.publishExecutionAssignedNotify(exec);
            verify(applicationEventPublisher).publishEvent(captor.capture());
            NotifySendEvent ev = captor.getValue();
            assertEquals("execution_assigned", ev.getTemplateCode());
            assertEquals(7L, ev.getOperatorUserId());
            assertTrue(ev.getUserIds().containsAll(List.of(5L, 6L)));
            assertEquals(NotifyJumpConstants.JUMP_TYPE_EXECUTION,
                    ev.getParams().get(NotifyJumpConstants.PARAM_JUMP_TYPE));
            assertEquals("10", ev.getParams().get(NotifyJumpConstants.PARAM_PROJECT_ID));
            assertEquals("灿能项目", ev.getParams().get("projectName"));
            assertEquals("一阶段", ev.getParams().get("executionName"));
        }
    }

    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;
    }

    private AdminUserRespDTO createUser(Long userId, String nickname) {
        AdminUserRespDTO user = new AdminUserRespDTO();
        user.setId(userId);
        user.setNickname(nickname);
        return user;
    }

    private MockedStatic<SecurityFrameworkUtils> mockLoginUser(Long loginUserId) {
        MockedStatic<SecurityFrameworkUtils> mockedStatic = mockStatic(SecurityFrameworkUtils.class);
        mockedStatic.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(loginUserId);
        return mockedStatic;
    }
}
