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

import com.njcn.rdms.framework.common.exception.ServiceException;
import com.njcn.rdms.framework.test.core.ut.BaseMockitoUnitTest;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskSaveReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskStatusActionReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.attachment.AttachmentItem;
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.task.ProjectTaskDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskStatusLogDO;
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.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.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
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.framework.attachment.AttachmentFileIdResolver;
import com.njcn.rdms.module.project.framework.security.service.ProjectObjectAuthorizationService;
import com.njcn.rdms.module.project.service.project.ProjectService;
import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService;
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.util.List;

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

class ProjectTaskServiceImplTest extends BaseMockitoUnitTest {

    @InjectMocks
    private ProjectTaskServiceImpl projectTaskService;
    @Mock
    private ProjectMapper projectMapper;
    @Mock
    private ProjectExecutionMapper projectExecutionMapper;
    @Mock
    private ExecutionAssigneeMapper executionAssigneeMapper;
    @Mock
    private ProjectTaskMapper projectTaskMapper;
    @Mock
    private TaskWorklogMapper taskWorklogMapper;
    @Mock
    private ProjectTaskStatusLogMapper projectTaskStatusLogMapper;
    @Mock
    private BizAuditLogMapper bizAuditLogMapper;
    @Mock
    private ObjectStatusModelMapper objectStatusModelMapper;
    @Mock
    private ObjectStatusTransitionMapper objectStatusTransitionMapper;
    @Mock
    private ProjectService projectService;
    @Mock
    private TaskAssigneeService taskAssigneeService;
    @Mock
    private AttachmentFileIdResolver attachmentFileIdResolver;
    @Mock
    private ProjectObjectAuthorizationService projectObjectAuthorizationService;
    @Mock
    private com.njcn.rdms.module.project.service.project.permission.VisibilityScopeResolver visibilityScopeResolver;

    /**
     * 默认让 VisibilityScopeResolver 放行（seesAll=true），既有测试无需关心 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 createTask_shouldInsertPendingTaskAndAutoStartProject() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectTaskSaveReqVO reqVO = createTaskReqVO();
        reqVO.setOwnerId(3002L);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
                .thenReturn(createEditableExecution(projectId, executionId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createStatus("project", "pending", true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
                .thenReturn(createStatus("execution", "pending", true));
        when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
                .thenReturn(createStatus("task", "pending", true));
        when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
                .thenReturn(createExecutionAssignee(executionId, 3002L));
        when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> {
            ProjectTaskDO task = invocation.getArgument(0);
            task.setId(9001L);
            return 1;
        });

        Long taskId = projectTaskService.createTask(projectId, executionId, reqVO);

        assertNotNull(taskId);
        ArgumentCaptor<ProjectTaskDO> taskCaptor = ArgumentCaptor.forClass(ProjectTaskDO.class);
        verify(projectTaskMapper).insert(taskCaptor.capture());
        assertEquals(projectId, taskCaptor.getValue().getProjectId());
        assertEquals(executionId, taskCaptor.getValue().getExecutionId());
        assertEquals("接口联调任务", taskCaptor.getValue().getTaskTitle());
        assertEquals(3002L, taskCaptor.getValue().getOwnerId());
        assertEquals("pending", taskCaptor.getValue().getStatusCode());
        assertEquals(BigDecimal.ZERO, taskCaptor.getValue().getProgressRate());
        verify(projectService).autoStartProjectIfPending(projectId, "create_task");
        verify(bizAuditLogMapper).insert(any(BizAuditLogDO.class));
    }

    @Test
    void createTask_shouldPersistAttachmentFileId() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectTaskSaveReqVO reqVO = createTaskReqVO();
        reqVO.setOwnerId(3002L);
        AttachmentItem attachment = new AttachmentItem();
        attachment.setId("10001");
        attachment.setUrl("http://oss.example.com/task/2026/05/report.txt");
        attachment.setName("report.txt");
        attachment.setSize(3164L);
        attachment.setContentType("text/plain");
        reqVO.setAttachments(List.of(attachment));

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
                .thenReturn(createEditableExecution(projectId, executionId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createStatus("project", "pending", true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
                .thenReturn(createStatus("execution", "pending", true));
        when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
                .thenReturn(createStatus("task", "pending", true));
        when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
                .thenReturn(createExecutionAssignee(executionId, 3002L));
        when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> {
            ProjectTaskDO task = invocation.getArgument(0);
            task.setId(9001L);
            return 1;
        });

        projectTaskService.createTask(projectId, executionId, reqVO);

        ArgumentCaptor<ProjectTaskDO> taskCaptor = ArgumentCaptor.forClass(ProjectTaskDO.class);
        verify(projectTaskMapper).insert(taskCaptor.capture());
        verify(attachmentFileIdResolver).resolve(reqVO.getAttachments());
        assertEquals("10001", taskCaptor.getValue().getAttachments().get(0).getId());
    }

    @Test
    void createSubTask_whenOwnerBlank_shouldInheritParentOwner() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectTaskDO parentTask = createTask(projectId, executionId, 8001L, 3002L);
        ProjectTaskSaveReqVO reqVO = createTaskReqVO();
        reqVO.setParentTaskId(parentTask.getId());
        reqVO.setOwnerId(null);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
                .thenReturn(createEditableExecution(projectId, executionId));
        when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTask.getId()))
                .thenReturn(parentTask);
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createStatus("project", "pending", true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
                .thenReturn(createStatus("execution", "pending", true));
        when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
                .thenReturn(createStatus("task", "pending", true));
        when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
                .thenReturn(createExecutionAssignee(executionId, 3002L));
        when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(invocation -> {
            ProjectTaskDO task = invocation.getArgument(0);
            task.setId(9002L);
            return 1;
        });

        projectTaskService.createTask(projectId, executionId, reqVO);

        ArgumentCaptor<ProjectTaskDO> taskCaptor = ArgumentCaptor.forClass(ProjectTaskDO.class);
        verify(projectTaskMapper).insert(taskCaptor.capture());
        assertEquals(3002L, taskCaptor.getValue().getOwnerId());
        assertEquals(parentTask.getId(), taskCaptor.getValue().getParentTaskId());
    }

    @Test
    void createTask_whenOwnerNotActiveExecutionAssignee_shouldThrow() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectTaskSaveReqVO reqVO = createTaskReqVO();
        reqVO.setOwnerId(3999L);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
                .thenReturn(createEditableExecution(projectId, executionId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createStatus("project", "pending", true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
                .thenReturn(createStatus("execution", "pending", true));
        when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3999L)).thenReturn(null);

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectTaskService.createTask(projectId, executionId, reqVO));

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

    @Test
    void createTask_whenParentNotInSameExecution_shouldThrow() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        ProjectTaskSaveReqVO reqVO = createTaskReqVO();
        reqVO.setParentTaskId(8001L);
        reqVO.setOwnerId(3002L);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
                .thenReturn(createEditableExecution(projectId, executionId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createStatus("project", "pending", true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
                .thenReturn(createStatus("execution", "pending", true));
        when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, 8001L)).thenReturn(null);

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectTaskService.createTask(projectId, executionId, reqVO));

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

    @Test
    void changeTaskStatus_shouldUseTransitionAndWriteLogs() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        Long taskId = 9001L;
        ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
        ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
        reqVO.setActionCode("start");

        when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
        when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "start"))
                .thenReturn(createTransition("start", "active", false));
        when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "active", null)).thenReturn(1);

        projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO);

        verify(projectObjectAuthorizationService).checkOwnerOrProjectPermission(projectId, 3002L,
                "project:task:status");
        verify(projectTaskMapper).updateStatusByIdAndStatus(taskId, "pending", "active", null);
        ArgumentCaptor<ProjectTaskStatusLogDO> statusCaptor = ArgumentCaptor.forClass(ProjectTaskStatusLogDO.class);
        verify(projectTaskStatusLogMapper).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 changeTaskStatus_whenReasonRequiredButBlank_shouldThrow() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        Long taskId = 9001L;
        ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
        ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
        reqVO.setActionCode("cancel");
        reqVO.setReason(" ");

        when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
        when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "cancel"))
                .thenReturn(createTransition("cancel", "cancelled", true));

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO));

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

    @Test
    void changeTaskStatus_whenConcurrentModified_shouldThrow() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        Long taskId = 9001L;
        ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
        ProjectTaskStatusActionReqVO reqVO = new ProjectTaskStatusActionReqVO();
        reqVO.setActionCode("start");

        when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
        when(objectStatusTransitionMapper.selectByObjectTypeAndFromStatusAndAction("task", "pending", "start"))
                .thenReturn(createTransition("start", "active", false));
        when(projectTaskMapper.updateStatusByIdAndStatus(taskId, "pending", "active", null)).thenReturn(0);

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectTaskService.changeTaskStatus(projectId, executionId, taskId, reqVO));

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

    // -------------------- Phase 3 进度自动汇总 + 叶子转父限制 --------------------

    @Test
    void createTask_whenParentIsLeafWithProgress_shouldThrowLeafToParentForbiddenProgress() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        Long parentTaskId = 8001L;
        ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L);
        parentTask.setProgressRate(new BigDecimal("30.00"));
        ProjectTaskSaveReqVO reqVO = createTaskReqVO();
        reqVO.setParentTaskId(parentTaskId);
        reqVO.setOwnerId(3002L);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
                .thenReturn(createEditableExecution(projectId, executionId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createStatus("project", "pending", true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
                .thenReturn(createStatus("execution", "pending", true));
        when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
                .thenReturn(parentTask);
        when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
                .thenReturn(createExecutionAssignee(executionId, 3002L));
        when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectTaskService.createTask(projectId, executionId, reqVO));
        assertEquals(ErrorCodeConstants.PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_PROGRESS.getCode(), ex.getCode());
        verify(projectTaskMapper, never()).insert(any(ProjectTaskDO.class));
    }

    @Test
    void createTask_whenParentIsLeafWithWorklog_shouldThrowLeafToParentForbiddenWorklog() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        Long parentTaskId = 8001L;
        ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L);
        parentTask.setProgressRate(BigDecimal.ZERO);
        ProjectTaskSaveReqVO reqVO = createTaskReqVO();
        reqVO.setParentTaskId(parentTaskId);
        reqVO.setOwnerId(3002L);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
                .thenReturn(createEditableExecution(projectId, executionId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createStatus("project", "pending", true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
                .thenReturn(createStatus("execution", "pending", true));
        when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
                .thenReturn(parentTask);
        when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
                .thenReturn(createExecutionAssignee(executionId, 3002L));
        when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
        when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(true);

        ServiceException ex = assertThrows(ServiceException.class,
                () -> projectTaskService.createTask(projectId, executionId, reqVO));
        assertEquals(ErrorCodeConstants.PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_WORKLOG.getCode(), ex.getCode());
        verify(projectTaskMapper, never()).insert(any(ProjectTaskDO.class));
    }

    @Test
    void createTask_whenParentAlreadyHasChildren_shouldSkipLeafCheckAndRecalc() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        Long parentTaskId = 8001L;
        // 父任务进度 60%，但已有 1 个子任务，已是父任务，不再触发"叶子转父"校验
        ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L);
        parentTask.setProgressRate(new BigDecimal("60.00"));
        ProjectTaskSaveReqVO reqVO = createTaskReqVO();
        reqVO.setParentTaskId(parentTaskId);
        reqVO.setOwnerId(3002L);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
                .thenReturn(createEditableExecution(projectId, executionId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createStatus("project", "pending", true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
                .thenReturn(createStatus("execution", "pending", true));
        when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
                .thenReturn(createStatus("task", "pending", true));
        when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
                .thenReturn(parentTask);
        when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
                .thenReturn(createExecutionAssignee(executionId, 3002L));
        // 已有 1 个子任务 → 跳过叶子转父校验
        when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(1);
        // 插入新任务（id=9002）后，父任务下有 2 个子，进度分别 60.00 与 0.00 → AVG 30.00
        when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(inv -> {
            ProjectTaskDO task = inv.getArgument(0);
            task.setId(9002L);
            return 1;
        });
        when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parentTask);
        ProjectTaskDO existingChild = createTask(projectId, executionId, 9001L, 3002L);
        existingChild.setProgressRate(new BigDecimal("60.00"));
        ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
        newChild.setProgressRate(BigDecimal.ZERO);
        when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId))
                .thenReturn(List.of(existingChild, newChild));

        projectTaskService.createTask(projectId, executionId, reqVO);

        ArgumentCaptor<BigDecimal> progressCaptor = ArgumentCaptor.forClass(BigDecimal.class);
        verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId), progressCaptor.capture());
        assertEquals(0, new BigDecimal("30.00").compareTo(progressCaptor.getValue()));
        verify(taskWorklogMapper, never()).existsByTaskId(parentTaskId);
    }

    @Test
    void createTask_withLeafParentZeroProgress_shouldRecalcGrandparentChain() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        Long grandparentId = 7000L;
        Long parentTaskId = 8001L;
        ProjectTaskDO parentTask = createTask(projectId, executionId, parentTaskId, 3002L);
        parentTask.setProgressRate(BigDecimal.ZERO);
        parentTask.setParentTaskId(grandparentId);
        // 爷爷的初值刻意设为 50.00，便于稳定验证"递归爬到爷爷且发生写入"
        ProjectTaskDO grandparent = createTask(projectId, executionId, grandparentId, 3002L);
        grandparent.setProgressRate(new BigDecimal("50.00"));
        ProjectTaskSaveReqVO reqVO = createTaskReqVO();
        reqVO.setParentTaskId(parentTaskId);
        reqVO.setOwnerId(3002L);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
                .thenReturn(createEditableExecution(projectId, executionId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createStatus("project", "pending", true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
                .thenReturn(createStatus("execution", "pending", true));
        when(objectStatusModelMapper.selectInitialByObjectTypeEnabled("task"))
                .thenReturn(createStatus("task", "pending", true));
        when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId))
                .thenReturn(parentTask);
        when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
                .thenReturn(createExecutionAssignee(executionId, 3002L));
        when(projectTaskMapper.countChildrenByParentTaskId(parentTaskId)).thenReturn(0);
        when(taskWorklogMapper.existsByTaskId(parentTaskId)).thenReturn(false);
        when(projectTaskMapper.insert(any(ProjectTaskDO.class))).thenAnswer(inv -> {
            ProjectTaskDO t = inv.getArgument(0);
            t.setId(9002L);
            return 1;
        });
        // 父链：parent(8001) -> grandparent(7000) -> null
        when(projectTaskMapper.selectById(parentTaskId)).thenReturn(parentTask);
        when(projectTaskMapper.selectById(grandparentId)).thenReturn(grandparent);
        ProjectTaskDO newChild = createTask(projectId, executionId, 9002L, 3002L);
        newChild.setProgressRate(new BigDecimal("80.00"));
        when(projectTaskMapper.selectChildrenProgressByParentTaskId(parentTaskId))
                .thenReturn(List.of(newChild));
        when(projectTaskMapper.selectChildrenProgressByParentTaskId(grandparentId))
                .thenReturn(List.of(parentTask));

        projectTaskService.createTask(projectId, executionId, reqVO);

        // parent: AVG([80.00]) = 80.00
        verify(projectTaskMapper).updateProgressRateById(eq(parentTaskId),
                argThat(v -> new BigDecimal("80.00").compareTo(v) == 0));
        // 爷爷：mock 返回的 parentTask 仍是初始引用（progress=0，未被自动改写）
        // → 爷爷应被设为 0；而其原值 50.00 不等，会触发更新
        verify(projectTaskMapper).updateProgressRateById(eq(grandparentId), any(BigDecimal.class));
    }

    @Test
    void updateTask_movedToNewParent_shouldValidateAndRecalcBothChains() {
        Long projectId = 2001L;
        Long executionId = 5001L;
        Long taskId = 9001L;
        Long oldParentId = 8001L;
        Long newParentId = 8002L;
        ProjectTaskDO task = createTask(projectId, executionId, taskId, 3002L);
        task.setParentTaskId(oldParentId);
        task.setProgressRate(new BigDecimal("70.00"));
        ProjectTaskDO oldParent = createTask(projectId, executionId, oldParentId, 3002L);
        ProjectTaskDO newParent = createTask(projectId, executionId, newParentId, 3002L);
        newParent.setProgressRate(BigDecimal.ZERO);

        ProjectTaskSaveReqVO reqVO = createTaskReqVO();
        reqVO.setId(taskId);
        reqVO.setParentTaskId(newParentId);
        reqVO.setOwnerId(3002L);

        when(projectMapper.selectById(projectId)).thenReturn(createEditableProject(projectId));
        when(projectExecutionMapper.selectByProjectIdAndId(projectId, executionId))
                .thenReturn(createEditableExecution(projectId, executionId));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("project", "pending"))
                .thenReturn(createStatus("project", "pending", true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("execution", "pending"))
                .thenReturn(createStatus("execution", "pending", true));
        when(objectStatusModelMapper.selectByObjectTypeAndStatusCodeEnabled("task", "pending"))
                .thenReturn(createStatus("task", "pending", true));
        when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId)).thenReturn(task);
        when(projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, newParentId))
                .thenReturn(newParent);
        when(executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, 3002L))
                .thenReturn(createExecutionAssignee(executionId, 3002L));
        // 校验"叶子转父"：新父 8002 当前是叶子，进度=0，无工时 → 通过
        when(projectTaskMapper.countChildrenByParentTaskId(newParentId)).thenReturn(0);
        when(taskWorklogMapper.existsByTaskId(newParentId)).thenReturn(false);
        // 当前任务自身：是叶子
        when(projectTaskMapper.countChildrenByParentTaskId(taskId)).thenReturn(0);
        // 递归刷新两条链：旧父 8001 与 新父 8002
        when(projectTaskMapper.selectById(oldParentId)).thenReturn(oldParent);
        when(projectTaskMapper.selectById(newParentId)).thenReturn(newParent);
        when(projectTaskMapper.selectChildrenProgressByParentTaskId(oldParentId))
                .thenReturn(List.of()); // 旧父在迁移后无子，保留原值
        when(projectTaskMapper.selectChildrenProgressByParentTaskId(newParentId))
                .thenReturn(List.of(task));

        projectTaskService.updateTask(projectId, executionId, reqVO);

        verify(projectTaskMapper).updateProgressRateById(eq(newParentId),
                argThat(v -> new BigDecimal("70.00").compareTo(v) == 0));
        // 旧父无子，recalcParentProgressFrom 早退，不调 updateProgressRateById
        verify(projectTaskMapper, never()).updateProgressRateById(eq(oldParentId), any(BigDecimal.class));
    }

    private ProjectTaskSaveReqVO createTaskReqVO() {
        ProjectTaskSaveReqVO reqVO = new ProjectTaskSaveReqVO();
        reqVO.setTaskTitle("接口联调任务");
        reqVO.setTaskDesc("完成接口联调");
        return reqVO;
    }

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

    private ProjectExecutionDO createEditableExecution(Long projectId, Long executionId) {
        ProjectExecutionDO execution = new ProjectExecutionDO();
        execution.setId(executionId);
        execution.setProjectId(projectId);
        execution.setExecutionName("接口联调");
        execution.setStatusCode("pending");
        return execution;
    }

    private ProjectTaskDO createTask(Long projectId, Long executionId, Long taskId, Long ownerId) {
        ProjectTaskDO task = new ProjectTaskDO();
        task.setId(taskId);
        task.setProjectId(projectId);
        task.setExecutionId(executionId);
        task.setTaskTitle("接口联调任务");
        task.setOwnerId(ownerId);
        task.setStatusCode("pending");
        task.setProgressRate(BigDecimal.ZERO);
        return task;
    }

    private ExecutionAssigneeDO createExecutionAssignee(Long executionId, Long userId) {
        ExecutionAssigneeDO member = new ExecutionAssigneeDO();
        member.setExecutionId(executionId);
        member.setUserId(userId);
        return member;
    }

    private ObjectStatusModelDO createStatus(String objectType, String statusCode, boolean allowEdit) {
        ObjectStatusModelDO status = new ObjectStatusModelDO();
        status.setObjectType(objectType);
        status.setStatusCode(statusCode);
        status.setStatusName(statusCode);
        status.setAllowEdit(allowEdit);
        return status;
    }

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

}
