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

import cn.hutool.core.collection.CollUtil;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.framework.common.pojo.PageResult;
import com.njcn.rdms.framework.common.util.json.JsonUtils;
import com.njcn.rdms.framework.common.util.object.BeanUtils;
import com.njcn.rdms.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectActivityConstants;
import com.njcn.rdms.module.project.constant.ProjectExecutionConstants;
import com.njcn.rdms.module.project.constant.ProjectObjectConstants;
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeletePrecheckRespVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.task.vo.ProjectTaskRespVO;
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.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectRequirementDO;
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.project.task.TaskAssigneeDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskWorklogDO;
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.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.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskAssigneeMapper;
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.attachment.AttachmentValidator;
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.notify.NotifyTemplateCodeConstants;
import com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission;
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.execution.ProjectExecutionService;
import com.njcn.rdms.module.project.service.project.task.assignee.TaskAssigneeService;
import com.njcn.rdms.module.project.service.project.task.worklog.TaskWorklogService;
import com.njcn.rdms.module.project.service.status.StatusActionTextResolver;
import com.google.common.annotations.VisibleForTesting;
import com.njcn.rdms.module.project.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;

/**
 * 项目任务 Service 实现类。
 */
@Service
@Slf4j
public class ProjectTaskServiceImpl implements ProjectTaskService {

    @Resource
    private ProjectMapper projectMapper;
    @Resource
    private ProjectExecutionMapper projectExecutionMapper;
    @Resource
    private ProjectRequirementMapper projectRequirementMapper;
    @Resource
    private ExecutionAssigneeMapper executionAssigneeMapper;
    @Resource
    private ProjectTaskMapper projectTaskMapper;
    @Resource
    private TaskWorklogMapper taskWorklogMapper;
    @Resource
    private TaskAssigneeMapper taskAssigneeMapper;
    @Resource
    private ProjectTaskStatusLogMapper projectTaskStatusLogMapper;
    @Resource
    private BizAuditLogMapper bizAuditLogMapper;
    @Resource
    private ObjectStatusModelMapper objectStatusModelMapper;
    @Resource
    private ObjectStatusTransitionMapper objectStatusTransitionMapper;
    @Resource
    private ProjectService projectService;
    @Resource
    private ProjectExecutionService projectExecutionService;
    @Resource
    private ProjectTaskStatusViewService projectTaskStatusViewService;
    @Resource
    private TaskAssigneeService taskAssigneeService;
    @Resource
    private TaskWorklogService taskWorklogService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private AttachmentFileIdResolver attachmentFileIdResolver;
    @Resource
    private ProjectObjectAuthorizationService projectObjectAuthorizationService;
    @Resource
    private DictDataApi dictDataApi;
    @Resource
    private StatusActionTextResolver statusActionTextResolver;
    @Resource
    private org.springframework.context.ApplicationEventPublisher applicationEventPublisher;

    // 权限：方法体内属主放行 + 权限码兜底（checkOwnerOrProjectPermission），勿挂 @CheckObjectPermission
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
        validateEditableProject(projectId);
        ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
        validateExecutionAllowEdit(execution);
        ProjectTaskDO parentTask = validateParentTask(projectId, executionId, reqVO.getParentTaskId());
        // 上级硬卡：一级任务由"执行负责人 OR 项目负责人（权限码）"建；子任务由"父任务负责人 OR 项目负责人（权限码）"建
        Long upperOwnerId = parentTask == null ? execution.getOwnerId() : parentTask.getOwnerId();
        projectObjectAuthorizationService.checkOwnerOrProjectPermission(
                projectId, upperOwnerId, ProjectTaskConstants.PERMISSION_CREATE);
        Long ownerId = resolveTaskOwner(reqVO.getOwnerId(), parentTask);
        validateOwnerIsActiveExecutionAssignee(executionId, ownerId);
        validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
        // 叶子转父校验：当前 parentTask 还是叶子（无任何子任务）时，要求其进度=0 且无工时记录
        validateLeafToParentSplit(parentTask);
        validatePriority(reqVO.getPriority());
        AttachmentValidator.validate(reqVO.getAttachments());
        attachmentFileIdResolver.resolve(reqVO.getAttachments());

        ProjectTaskDO task = new ProjectTaskDO();
        task.setProjectId(projectId);
        task.setExecutionId(executionId);
        task.setParentTaskId(parentTask == null ? null : parentTask.getId());
        task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
        task.setType(normalizeRequiredType(reqVO.getType(), "任务类型不能为空"));
        task.setOwnerId(ownerId);
        task.setStatusCode(getInitialTaskStatusCode());
        task.setPriority(reqVO.getPriority());
        // 任务进度统一由 worklog 驱动；新建任务强制为 0
        task.setProgressRate(BigDecimal.ZERO);
        task.setPlannedStartDate(reqVO.getPlannedStartDate());
        task.setPlannedEndDate(reqVO.getPlannedEndDate());
        // 实际开始/结束日期不允许人工填写，由 changeTaskStatus 在状态流转时推导
        task.setTaskDesc(normalizeNullableText(reqVO.getTaskDesc()));
        task.setAttachments(reqVO.getAttachments());
        projectTaskMapper.insert(task);

        // 创建任务时初始化协办人列表（同事务，任一项失败整笔回滚；列表为空跳过）
        taskAssigneeService.initializeAssignees(task.getId(), ownerId, executionId, reqVO.getAssigneeUserIds());

        // 站内信通知：创建任务后通知负责人 + 活跃协办人（含操作人自己；通知失败不影响任务创建）
        publishTaskAssignedNotify(projectId, task, ownerId);

        // 父任务（含祖先）进度按子任务平均自动汇总
        if (task.getParentTaskId() != null) {
            recalcParentProgressFrom(task.getParentTaskId());
        }
        // 项目进度推算：无论新建的是根任务（进度=0 进入项目均值）还是子任务（冒泡后根任务进度可能变化），
        // 项目均值都可能发生变化，统一触发一次项目层重算（覆盖根任务边界）
        projectService.recalcProgress(projectId);

        writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_CREATE, null, task.getStatusCode(),
                buildTaskFieldChanges(null, task), null);
        projectService.autoStartProjectIfPending(projectId, ObjectActivityConstants.PROJECT_TRIGGER_CREATE_TASK);
        return task.getId();
    }

    /**
     * 创建任务后发送「任务指派」站内信事件：接收人 = 负责人 + 活跃协办人（操作人自己由监听器统一排除）。
     * 仅 publish 事件，真正发送由 {@link com.njcn.rdms.module.project.framework.notify.NotifySendEventListener}
     * 在事务提交后处理；通知失败不影响任务创建。
     */
    @VisibleForTesting
    void publishTaskAssignedNotify(Long projectId, ProjectTaskDO task, Long ownerId) {
        List<Long> recipients = new ArrayList<>();
        recipients.add(ownerId);
        taskAssigneeMapper.selectActiveListByTaskId(task.getId())
                .forEach(assignee -> recipients.add(assignee.getUserId()));
        Map<String, Object> params = new HashMap<>();
        ProjectDO project = projectMapper.selectById(projectId);
        params.put("projectName", project == null ? "" : project.getProjectName());
        params.put("taskName", task.getTaskTitle());
        params.put(NotifyJumpConstants.PARAM_JUMP_TYPE, NotifyJumpConstants.JUMP_TYPE_TASK);
        params.put(NotifyJumpConstants.PARAM_PROJECT_ID, String.valueOf(projectId));
        applicationEventPublisher.publishEvent(NotifySendEvent.of(recipients,
                NotifyTemplateCodeConstants.TASK_ASSIGNED, params,
                NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId()));
    }

    // 权限：方法体内属主放行 + 权限码兜底（requireTaskEditDeletePermission），勿挂 @CheckObjectPermission
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateTask(Long projectId, Long executionId, ProjectTaskSaveReqVO reqVO) {
        if (reqVO.getId() == null) {
            throw invalidParamException("任务编号不能为空");
        }
        ProjectDO project = validateEditableProject(projectId);
        ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
        validateExecutionAllowEdit(execution);
        ProjectTaskDO task = validateTaskExists(projectId, executionId, reqVO.getId());
        validateTaskAllowEdit(task);
        requireTaskEditDeletePermission(project, execution, task, ProjectTaskConstants.PERMISSION_UPDATE);
        ProjectTaskDO parentTask = validateParentTask(projectId, executionId, reqVO.getParentTaskId());
        if (parentTask != null && Objects.equals(parentTask.getId(), task.getId())) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_PARENT_INVALID);
        }
        Long ownerId = resolveTaskOwner(reqVO.getOwnerId(), parentTask);
        validateOwnerIsActiveExecutionAssignee(executionId, ownerId);
        validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");
        // 父任务变更校验：迁到新父时，新父若仍是叶子（即将变父），要求新父进度=0 且无工时
        Long oldParentId = task.getParentTaskId();
        Long newParentId = parentTask == null ? null : parentTask.getId();
        if (!Objects.equals(oldParentId, newParentId)) {
            validateLeafToParentSplit(parentTask);
        }
        validatePriority(reqVO.getPriority());
        AttachmentValidator.validate(reqVO.getAttachments());
        attachmentFileIdResolver.resolve(reqVO.getAttachments());
        // 任务进度由 worklog 驱动（owner 填工时回写 + 父任务 AVG 汇总），编辑任务接口不接受进度入参

        ProjectTaskDO before = cloneTask(task);
        task.setParentTaskId(newParentId);
        task.setTaskTitle(normalizeRequiredTitle(reqVO.getTaskTitle()));
        task.setType(normalizeRequiredType(reqVO.getType(), "任务类型不能为空"));
        task.setOwnerId(ownerId);
        task.setPriority(reqVO.getPriority());
        task.setPlannedStartDate(reqVO.getPlannedStartDate());
        task.setPlannedEndDate(reqVO.getPlannedEndDate());
        // 实际开始/结束日期不允许人工编辑，保留原值由状态流转维护
        task.setTaskDesc(normalizeNullableText(reqVO.getTaskDesc()));
        task.setAttachments(reqVO.getAttachments());
        projectTaskMapper.updateById(task);

        // 进度联动：旧父链 + 新父链都需要重算（父任务不可手填进度，由本逻辑统一刷新）
        Set<Long> parentsToRecalc = new HashSet<>();
        if (oldParentId != null) {
            parentsToRecalc.add(oldParentId);
        }
        if (newParentId != null) {
            parentsToRecalc.add(newParentId);
        }
        parentsToRecalc.forEach(this::recalcParentProgressFrom);
        // 项目进度推算：updateTask 可能改变 parent_task_id，使 task 进入/离开"根任务集合"，
        // 项目均值的分量构成可能变化；无论 progressRate 是否变化都需触发刷新
        projectService.recalcProgress(projectId);

        writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_UPDATE, before.getStatusCode(),
                task.getStatusCode(), buildTaskFieldChanges(before, task), null);
    }

    // 权限：方法体内属主放行 + 权限码兜底（requireTaskEditDeletePermission），勿挂 @CheckObjectPermission
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteTask(Long projectId, Long executionId, Long taskId, ProjectTaskDeleteReqVO reqVO) {
        ProjectDO project = validateProjectExists(projectId);
        ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
        ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
        requireTaskEditDeletePermission(project, execution, task, ProjectTaskConstants.PERMISSION_DELETE);
        validateDeleteConfirmText(reqVO.getConfirmText());
        if (!Objects.equals(task.getTaskTitle(), reqVO.getTaskName().trim())) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_DELETE_NAME_MISMATCH);
        }
        // 删除路径不复用 validateEditableProject / validateExecutionAllowEdit：已完成任务用专门的硬卡拦截，
        // 其它非 completed 状态（pending / active / paused / cancelled）允许删除。
        if (Objects.equals(task.getStatusCode(), ProjectTaskConstants.STATUS_COMPLETED)) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_ALLOW_DELETE);
        }
        String reason = reqVO.getReason().trim();
        String fromStatus = task.getStatusCode();
        Long parentTaskId = task.getParentTaskId();

        // 收集自身 + 子孙任务 id，级联软删工作日志 / 协办人 / 任务
        List<Long> allTaskIds = projectTaskMapper.selectSelfAndDescendantIds(taskId);
        if (allTaskIds.isEmpty()) {
            // 极端兜底：CTE 未命中（理论不可能，因为 taskId 自己已经 select 出来），保持单条删
            allTaskIds = List.of(taskId);
        }
        taskWorklogMapper.deleteByTaskIdIn(allTaskIds);
        taskAssigneeMapper.deleteByTaskIdIn(allTaskIds);
        int deleteCount = projectTaskMapper.deleteByIdIn(allTaskIds);
        if (deleteCount < 1) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_CONCURRENT_MODIFIED);
        }

        // 父任务进度回算（执行 / 项目需求 progressRate 是读时聚合，无需主动刷新）
        if (parentTaskId != null) {
            recalcParentProgressFrom(parentTaskId);
        }
        // 项目进度推算：无论删除的是根任务（项目分量减一）还是子任务（父链冒泡到根后根任务进度变化），
        // 项目均值都可能发生变化，统一触发一次项目层重算（覆盖根任务边界）
        projectService.recalcProgress(task.getProjectId());

        writeTaskAuditLog(task, ObjectActivityConstants.TASK_ACTION_DELETE, fromStatus, null, null, reason);
    }

    // 权限：方法体内属主放行 + 权限码兜底（requireTaskEditDeletePermission），勿挂 @CheckObjectPermission
    @Override
    public ProjectTaskDeletePrecheckRespVO precheckDeleteTask(Long projectId, Long executionId, Long taskId) {
        ProjectDO project = validateProjectExists(projectId);
        ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
        ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
        // 预检也走同款编辑/删除授权，防止无权限用户调 precheck 试探下挂数量。
        requireTaskEditDeletePermission(project, execution, task, ProjectTaskConstants.PERMISSION_DELETE);

        int childTaskCount = projectTaskMapper.countChildrenByParentTaskId(taskId);
        int worklogCount = taskWorklogMapper.countByTaskId(taskId);

        ProjectTaskDeletePrecheckRespVO respVO = new ProjectTaskDeletePrecheckRespVO();
        respVO.setChildTaskCount(childTaskCount);
        respVO.setWorklogCount(worklogCount);
        respVO.setHasDependentData(childTaskCount > 0 || worklogCount > 0);
        return respVO;
    }

    /**
     * 任务编辑/删除授权：项目负责人、执行负责人、任务负责人、父任务负责人均可操作；
     * 其它用户回落到项目对象角色权限码，作为管理员兜底。
     *
     * <p>红线：调用本方法的入口（updateTask / deleteTask / precheckDeleteTask）<b>故意不挂
     * {@code @CheckObjectPermission}</b>——注解是前置拦截，会把"无权限码的负责人"挡死。
     * 不要误判为漏鉴权把注解补回去；口径见 CLAUDE.md §鉴权。
     */
    private void requireTaskEditDeletePermission(ProjectDO project, ProjectExecutionDO execution,
                                                 ProjectTaskDO task, String permission) {
        List<Long> ownerUserIds = new ArrayList<>();
        addIfNotNull(ownerUserIds, project.getManagerUserId());
        addIfNotNull(ownerUserIds, execution.getOwnerId());
        addIfNotNull(ownerUserIds, task.getOwnerId());
        if (task.getParentTaskId() != null) {
            ProjectTaskDO parent = projectTaskMapper.selectById(task.getParentTaskId());
            if (parent != null) {
                addIfNotNull(ownerUserIds, parent.getOwnerId());
            }
        }
        projectObjectAuthorizationService.checkAnyOwnerOrProjectPermission(project.getId(), ownerUserIds, permission);
    }

    private void addIfNotNull(Collection<Long> values, Long value) {
        if (value != null) {
            values.add(value);
        }
    }

    private void validateDeleteConfirmText(String confirmText) {
        String normalizedConfirmText = normalizeNullableText(confirmText);
        if (normalizedConfirmText == null
                || !ProjectTaskConstants.DELETE_CONFIRM_TEXTS.contains(normalizedConfirmText)) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_DELETE_CONFIRM_TEXT_INVALID);
        }
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void syncOwnerProgressFromLatestWorklog(Long taskId) {
        if (taskId == null) {
            return;
        }
        ProjectTaskDO task = projectTaskMapper.selectById(taskId);
        if (task == null || task.getOwnerId() == null) {
            return;
        }
        // 仅对叶子任务生效：父任务进度由子任务 AVG 汇总，不接受 worklog 驱动
        if (projectTaskMapper.countChildrenByParentTaskId(taskId) > 0) {
            return;
        }
        TaskWorklogDO latest = taskWorklogMapper.selectLatestByTaskIdAndUserId(taskId, task.getOwnerId());
        // owner 已无任何工时记录：保留 task.progress_rate 原值不动（不归零）
        if (latest == null) {
            return;
        }
        BigDecimal newProgress = normalizeProgress(latest.getProgressRate());
        if (progressNumericallyEquals(newProgress, task.getProgressRate())) {
            return;
        }
        projectTaskMapper.updateProgressRateById(taskId, newProgress);
        if (task.getParentTaskId() != null) {
            recalcParentProgressFrom(task.getParentTaskId());
        }
        // 项目进度推算：worklog 驱动的任务进度变化（叶子任务），无论叶子任务是否同时是根任务都需触发
        projectService.recalcProgress(task.getProjectId());
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void internalAutoStartByWorklog(ProjectTaskDO task) {
        if (task == null || task.getId() == null) {
            return;
        }
        // 二次 reload：调用方传入的是工时入库前的快照，以 DB 现状为准判定是否需要自动开始
        ProjectTaskDO current = projectTaskMapper.selectById(task.getId());
        if (current == null) {
            return;
        }
        String fromStatus = current.getStatusCode();
        // 仅在初始态触发：避免对 active/paused/已结束等状态产生副作用
        ObjectStatusModelDO fromModel = objectStatusModelMapper
                .selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, fromStatus);
        if (fromModel == null || !Boolean.TRUE.equals(fromModel.getInitialFlag())) {
            return;
        }
        String actionCode = ObjectActivityConstants.TASK_ACTION_AUTO_START;
        ObjectStatusTransitionDO transition = objectStatusTransitionMapper
                .selectByObjectTypeAndFromStatusAndAction(ProjectTaskConstants.OBJECT_TYPE, fromStatus, actionCode);
        if (transition == null) {
            // transition 配置缺失/被禁用/已删除：静默不影响工时入库，仅 WARN 让运维感知配置漂移
            log.warn("task auto-start by worklog skipped: transition not found or disabled. taskId={}, fromStatus={}, actionCode={}",
                    current.getId(), fromStatus, actionCode);
            return;
        }
        String toStatus = transition.getToStatusCode();
        String reason = "由工时填报自动触发";
        int updateCount = projectTaskMapper.updateStatusByIdAndStatus(current.getId(), fromStatus, toStatus, reason);
        if (updateCount != 1) {
            // 并发抢占（已被别的链路推进）：静默 return
            log.warn("task auto-start by worklog skipped: concurrent modification on status. taskId={}, expectedFromStatus={}",
                    current.getId(), fromStatus);
            return;
        }
        current.setStatusCode(toStatus);
        current.setLastStatusReason(reason);

        writeTaskStatusLog(current, actionCode, fromStatus, toStatus, reason);
        writeTaskAuditLog(current, actionCode, fromStatus, toStatus, null, reason);
        // 通知执行：任务自动开始也属于"任务状态变更"事件，按 §6.4 触发表上报
        projectExecutionService.onTaskStatusChanged(
                current.getExecutionId(), current.getId(), fromStatus, toStatus, actionCode, reason);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void syncActualDatesFromWorklogs(Long taskId) {
        if (taskId == null) {
            return;
        }
        ProjectTaskDO task = projectTaskMapper.selectById(taskId);
        if (task == null) {
            return;
        }
        TaskWorklogDO earliestWorklog = taskWorklogMapper.selectEarliestByTaskId(taskId);
        TaskWorklogDO latestOwnerCompletedWorklog = task.getOwnerId() == null
                ? null
                : taskWorklogMapper.selectLatestCompletedByTaskIdAndUserId(taskId, task.getOwnerId());
        LocalDate actualStartDate = earliestWorklog == null ? null : earliestWorklog.getStartDate();
        LocalDate actualEndDate = latestOwnerCompletedWorklog == null ? null : latestOwnerCompletedWorklog.getEndDate();
        if (Objects.equals(task.getActualStartDate(), actualStartDate)
                && Objects.equals(task.getActualEndDate(), actualEndDate)) {
            return;
        }
        projectTaskMapper.updateActualDatesById(taskId, actualStartDate, actualEndDate);
    }

    @Override
    public ProjectTaskDO getTask(Long projectId, Long executionId, Long taskId) {
        validateExecutionExists(projectId, executionId);
        return validateTaskExists(projectId, executionId, taskId);
    }

    @Override
    @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
    public PageResult<ProjectTaskDO> getTaskPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
        validateExecutionExists(projectId, executionId);
        // 进得来 = 看执行下全部任务，"我参与 / 所有"由前端发不发 reqVO.ownerId / involveUserId 决定。
        // 注：getTaskRespVOPage 内部 this.getTaskPage() 自调用不触发 AOP，但外层注解已守门；
        // 此处独立挂注解是为了堵跨 service 直调 ProjectTaskService.getTaskPage 的鉴权后门。
        return projectTaskMapper.selectPageByExecutionId(projectId, executionId, reqVO);
    }

    @Override
    @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
    public ProjectTaskRespVO getTaskRespVO(Long projectId, Long executionId, Long taskId) {
        // 内联 validate，便于接住 execution 供前端 executionOwnerId 字段使用
        ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
        ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
        ProjectTaskRespVO respVO = BeanUtils.toBean(task, ProjectTaskRespVO.class);
        applyLifecycle(respVO);
        respVO.setOwnerNickname(loadOwnerNickname(task.getOwnerId()));
        respVO.setAssignees(buildAssigneeViews(taskAssigneeService
                .loadActiveAssigneesGroupedByTaskId(List.of(task.getId())).getOrDefault(task.getId(), List.of())));
        respVO.setTotalSpentHours(taskWorklogService.sumDurationByTaskId(task.getId()));
        respVO.setExecutionOwnerId(execution.getOwnerId());
        respVO.setExecutionName(execution.getExecutionName());
        respVO.setExecutionStatusCode(execution.getStatusCode());
        if (task.getParentTaskId() != null) {
            ProjectTaskDO parentTask = projectTaskMapper.selectById(task.getParentTaskId());
            respVO.setParentTaskOwnerId(parentTask == null ? null : parentTask.getOwnerId());
        }
        fillProjectRequirementInfo(List.of(respVO), execution);
        return respVO;
    }

    @Override
    @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
    public PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
        PageResult<ProjectTaskDO> pageResult = getTaskPage(projectId, executionId, reqVO);
        return assembleTaskRespVOPage(projectId, executionId, pageResult);
    }

    /**
     * 把 ProjectTaskDO 分页结果整体装配成 RespVO 分页结果（含 ownerNickname / assignees / 工时合计 / 父任务 owner / 执行 owner / 生命周期）。
     * <p>提供给 /tasks/page 与 /tasks/board-page 共用，保证两个接口的列元素结构与序列化口径完全一致；
     * /tasks/board-page 不应自行重复装配，避免字段演进时漂移。
     */
    @Override
    public PageResult<ProjectTaskRespVO> assembleTaskRespVOPage(Long projectId, Long executionId,
                                                                PageResult<ProjectTaskDO> pageResult) {
        PageResult<ProjectTaskRespVO> voPageResult = BeanUtils.toBean(pageResult, ProjectTaskRespVO.class);
        List<ProjectTaskRespVO> list = voPageResult.getList();
        if (list == null || list.isEmpty()) {
            return voPageResult;
        }
        List<ProjectTaskDO> taskList = pageResult.getList();
        // 执行 owner 单条查询，整页共享（URL 路径定 executionId）
        ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
        Long executionOwnerId = execution == null ? null : execution.getOwnerId();
        fillProjectRequirementInfo(list, execution);
        // 批量回填 ownerNickname / assignees / totalSpentHours / parentTaskOwnerId（与 executionId 无关）
        enrichTaskRelations(taskList, list);
        list.forEach(vo -> {
            vo.setExecutionOwnerId(executionOwnerId);
            // 列表行 cancel/pause/resume/complete 按钮依赖 availableActions，与详情同款装配 lifecycle。
            // 单行装配失败做兜底降级（status_model 缺失等脏数据），避免影响整页返回。
            try {
                applyLifecycle(vo);
            } catch (Exception e) {
                log.warn("task lifecycle apply failed in page assembly. taskId={}, statusCode={}, error={}",
                        vo.getId(), vo.getStatusCode(), e.getMessage());
            }
        });
        enrichExecutionInfo(voPageResult.getList());
        return voPageResult;
    }

    /**
     * 批量回填与 executionId 无关的任务关联字段:ownerNickname / assignees / totalSpentHours / parentTaskOwnerId。
     * <p>由 {@link #assembleTaskRespVOPage} 与 {@link #assembleTaskRespVOPageCrossExecution} 共享,
     * 抽取后两条装配路径保证字段口径一致,避免演进漂移。
     *
     * @param taskList 原始 DO 列表(用于 enrichTaskRelations 内部如有需要直接访问 DO 字段)
     * @param voList   已 BeanUtils.toBean 转换好的 VO 列表,本方法在其上 set 字段
     */
    private void enrichTaskRelations(List<ProjectTaskDO> taskList, List<ProjectTaskRespVO> voList) {
        if (CollUtil.isEmpty(voList)) {
            return;
        }
        // 批量装配 ownerNickname + assignees，统一收集所有需要的 userId 一次性查 nickname，避免 N+1
        Set<Long> taskIds = voList.stream().map(ProjectTaskRespVO::getId)
                .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
        Map<Long, List<TaskAssigneeDO>> assigneeMap = taskAssigneeService
                .loadActiveAssigneesGroupedByTaskId(taskIds);
        Map<Long, BigDecimal> spentHoursMap = taskWorklogService.sumDurationGroupedByTaskIds(taskIds);
        Set<Long> userIdsToResolve = voList.stream()
                .map(ProjectTaskRespVO::getOwnerId)
                .filter(Objects::nonNull)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        assigneeMap.values().forEach(items -> items.forEach(a -> userIdsToResolve.add(a.getUserId())));
        Map<Long, String> nicknameMap = loadOwnerNicknameMap(userIdsToResolve);
        // 批量查父任务 owner，避免按 list 循环 N+1
        Set<Long> parentTaskIds = voList.stream().map(ProjectTaskRespVO::getParentTaskId)
                .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
        Map<Long, Long> parentTaskOwnerMap;
        if (parentTaskIds.isEmpty()) {
            parentTaskOwnerMap = Map.of();
        } else {
            parentTaskOwnerMap = new HashMap<>();
            for (ProjectTaskDO p : projectTaskMapper.selectBatchIds(parentTaskIds)) {
                parentTaskOwnerMap.put(p.getId(), p.getOwnerId());
            }
        }
        voList.forEach(vo -> {
            vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()));
            List<TaskAssigneeDO> activeList = assigneeMap.getOrDefault(vo.getId(), List.of());
            vo.setAssignees(activeList.stream()
                    .map(a -> toAssigneeView(a, nicknameMap.get(a.getUserId())))
                    .collect(Collectors.toList()));
            vo.setTotalSpentHours(spentHoursMap.getOrDefault(vo.getId(), BigDecimal.ZERO));
            if (vo.getParentTaskId() != null) {
                vo.setParentTaskOwnerId(parentTaskOwnerMap.get(vo.getParentTaskId()));
            }
        });
    }

    @Override
    public PageResult<ProjectTaskRespVO> assembleTaskRespVOPageCrossExecution(Long projectId, PageResult<ProjectTaskDO> doPage) {
        if (doPage == null || CollUtil.isEmpty(doPage.getList())) {
            return new PageResult<>(Collections.emptyList(), doPage == null ? 0L : doPage.getTotal());
        }
        List<ProjectTaskDO> taskList = doPage.getList();
        List<ProjectTaskRespVO> voList = BeanUtils.toBean(taskList, ProjectTaskRespVO.class);

        // 批量回填 ownerNickname / assignees / totalSpentHours / parentTaskOwnerId（与 executionId 无关）
        enrichTaskRelations(taskList, voList);

        voList.forEach(vo -> {
            // executionOwnerId 在跨执行场景不设置（涉及多个 execution，无"该执行的 ownerId"概念）
            try {
                applyLifecycle(vo);
            } catch (Exception e) {
                log.warn("[assembleTaskRespVOPageCrossExecution] applyLifecycle 装配失败 taskId={}", vo.getId(), e);
            }
        });

        // 按 task.executionId 分组批量回填 executionName / executionStatusCode
        enrichExecutionInfo(voList);

        return new PageResult<>(voList, doPage.getTotal());
    }

    /**
     * 给一批 ProjectTaskRespVO 批量回填 executionName / executionStatusCode。
     * 一次查询所有涉及的 executionId,避免 N+1。
     */
    private void enrichExecutionInfo(List<ProjectTaskRespVO> list) {
        if (CollUtil.isEmpty(list)) {
            return;
        }
        Set<Long> executionIds = list.stream()
                .map(ProjectTaskRespVO::getExecutionId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        if (executionIds.isEmpty()) {
            return;
        }
        List<ProjectExecutionDO> executions = projectExecutionMapper.selectBatchIds(executionIds);
        Map<Long, ProjectExecutionDO> executionMap = executions.stream()
                .collect(Collectors.toMap(ProjectExecutionDO::getId, e -> e, (a, b) -> a));
        list.forEach(vo -> {
            ProjectExecutionDO exec = executionMap.get(vo.getExecutionId());
            if (exec != null) {
                vo.setExecutionName(exec.getExecutionName());
                vo.setExecutionStatusCode(exec.getStatusCode());
            }
        });
    }

    /**
     * 把任务 RespVO 上的项目需求信息（TD-013）回填。
     * <p>本服务的 task 查询入口均以 executionId 为收口（详情 / page / board-page 共用 assembleTaskRespVOPage），
     * 故整页 task 共享同一 execution，单次 selectById 即可拿到 ProjectRequirementDO，无 N+1。
     * 若 execution 缺失或未关联项目需求则不回填。
     */
    private void fillProjectRequirementInfo(Collection<ProjectTaskRespVO> tasks, ProjectExecutionDO execution) {
        if (tasks == null || tasks.isEmpty() || execution == null || execution.getProjectRequirementId() == null) {
            return;
        }
        Long requirementId = execution.getProjectRequirementId();
        ProjectRequirementDO requirement = projectRequirementMapper.selectById(requirementId);
        if (requirement == null) {
            return;
        }
        tasks.forEach(vo -> {
            vo.setProjectRequirementId(requirementId);
            vo.setProjectRequirementName(requirement.getTitle());
            vo.setProjectRequirementStatusCode(requirement.getStatusCode());
        });
    }

    private List<ProjectTaskRespVO.TaskAssigneeView> buildAssigneeViews(List<TaskAssigneeDO> activeList) {
        if (activeList == null || activeList.isEmpty()) {
            return List.of();
        }
        Set<Long> userIds = activeList.stream().map(TaskAssigneeDO::getUserId)
                .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
        Map<Long, String> nicknameMap = loadOwnerNicknameMap(userIds);
        return activeList.stream()
                .map(a -> toAssigneeView(a, nicknameMap.get(a.getUserId())))
                .collect(Collectors.toList());
    }

    private ProjectTaskRespVO.TaskAssigneeView toAssigneeView(TaskAssigneeDO assignee, String nickname) {
        ProjectTaskRespVO.TaskAssigneeView view = new ProjectTaskRespVO.TaskAssigneeView();
        view.setId(assignee.getId());
        view.setUserId(assignee.getUserId());
        view.setNickname(nickname);
        return view;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
            permission = ProjectTaskConstants.PERMISSION_STATUS)
    public void changeTaskStatus(Long projectId, Long executionId, Long taskId, ProjectTaskStatusActionReqVO reqVO) {
        ProjectTaskDO task = validateTaskExists(projectId, executionId, taskId);
        String actionCode = normalizeRequiredActionCode(reqVO.getActionCode());
        String reason = normalizeNullableText(reqVO.getReason());
        // owner-only 字段硬卡：cancel / pause / resume / complete 必须任务负责人本人触发（与执行同款，不接受角色权限码兜底）
        validateOwnerForAction(task, actionCode);
        doInternalChangeTaskStatus(task, actionCode, reason);
    }

    /**
     * 负责人独占动作校验：complete / cancel / pause / resume 必须由任务负责人触发。
     * 系统级别的 internalAutoStartByWorklog 不经此校验。
     */
    private void validateOwnerForAction(ProjectTaskDO task, String actionCode) {
        if (!isOwnerOnlyAction(actionCode)) {
            return;
        }
        Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
        if (!Objects.equals(loginUserId, task.getOwnerId())) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_OWNER_ONLY,
                    statusActionTextResolver.actionName(ProjectTaskConstants.OBJECT_TYPE, actionCode));
        }
    }

    private boolean isOwnerOnlyAction(String actionCode) {
        return "complete".equals(actionCode)
                || "cancel".equals(actionCode)
                || "pause".equals(actionCode)
                || "resume".equals(actionCode);
    }

    private ProjectDO validateEditableProject(Long projectId) {
        ProjectDO project = validateProjectExists(projectId);
        ObjectStatusModelDO statusModel = objectStatusModelMapper
                .selectByObjectTypeAndStatusCodeEnabled(ProjectObjectConstants.OBJECT_TYPE, project.getStatusCode());
        if (statusModel == null) {
            throw exception(ErrorCodeConstants.PROJECT_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
        }
        if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_ALLOW_EDIT);
        }
        return project;
    }

    private ProjectDO validateProjectExists(Long projectId) {
        if (projectId == null) {
            throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS);
        }
        ProjectDO project = projectMapper.selectById(projectId);
        if (project == null) {
            throw exception(ErrorCodeConstants.PROJECT_NOT_EXISTS);
        }
        return project;
    }

    private ProjectExecutionDO validateExecutionExists(Long projectId, Long executionId) {
        if (executionId == null) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS);
        }
        ProjectExecutionDO execution = projectExecutionMapper.selectByProjectIdAndId(projectId, executionId);
        if (execution == null) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_EXISTS);
        }
        return execution;
    }

    private void validateExecutionAllowEdit(ProjectExecutionDO execution) {
        ObjectStatusModelDO statusModel = objectStatusModelMapper
                .selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, execution.getStatusCode());
        if (statusModel == null) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
        }
        if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_ALLOW_EDIT);
        }
    }

    private ProjectTaskDO validateTaskExists(Long projectId, Long executionId, Long taskId) {
        if (taskId == null) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_EXISTS);
        }
        ProjectTaskDO task = projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, taskId);
        if (task == null) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_NOT_EXISTS);
        }
        return task;
    }

    private ProjectTaskDO validateParentTask(Long projectId, Long executionId, Long parentTaskId) {
        if (parentTaskId == null) {
            return null;
        }
        ProjectTaskDO parentTask = projectTaskMapper.selectByProjectIdAndExecutionIdAndId(projectId, executionId, parentTaskId);
        if (parentTask == null) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_PARENT_INVALID);
        }
        return parentTask;
    }

    private void validateTaskAllowEdit(ProjectTaskDO task) {
        ObjectStatusModelDO statusModel = objectStatusModelMapper
                .selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, task.getStatusCode());
        if (statusModel == null) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
        }
        if (!Boolean.TRUE.equals(statusModel.getAllowEdit())) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_NOT_ALLOW_EDIT);
        }
    }

    private void validateOwnerIsActiveExecutionAssignee(Long executionId, Long ownerId) {
        if (ownerId == null || executionAssigneeMapper.selectActiveByExecutionIdAndUserId(executionId, ownerId) == null) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_OWNER_INVALID);
        }
    }

    private Long resolveTaskOwner(Long reqOwnerId, ProjectTaskDO parentTask) {
        if (reqOwnerId != null) {
            return reqOwnerId;
        }
        if (parentTask != null) {
            return parentTask.getOwnerId();
        }
        throw exception(ErrorCodeConstants.PROJECT_TASK_OWNER_INVALID);
    }

    private ObjectStatusTransitionDO validateTaskTransition(String fromStatusCode, String actionCode, String reason) {
        ObjectStatusTransitionDO transition = objectStatusTransitionMapper
                .selectByObjectTypeAndFromStatusAndAction(ProjectTaskConstants.OBJECT_TYPE, fromStatusCode, actionCode);
        if (transition == null) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_NOT_ALLOWED,
                    statusActionTextResolver.statusName(ProjectTaskConstants.OBJECT_TYPE, fromStatusCode),
                    statusActionTextResolver.actionName(ProjectTaskConstants.OBJECT_TYPE, actionCode));
        }
        if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_ACTION_REASON_REQUIRED, transition.getActionName());
        }
        return transition;
    }

    /**
     * 进入终态前置校验：当目标状态属于终态（terminalFlag=true）时，要求所有子任务已进入终态，
     * 避免父任务关闭后子任务仍处于未结束状态。判定基于通用语义位，不识别具体 statusCode。
     * cancel 动作走"自动级联取消"路径（cascadeCancelChildren），跳过本校验。
     * 进度合理性（如完成时进度 100%）由前端在动作触发前的交互层把关，doInternalChangeTaskStatus
     * 在 complete 落库后会兜底刷一次进度到 100%。
     */
    private void validateBeforeStatusChange(ProjectTaskDO task, String actionCode, String toStatus) {
        if ("cancel".equals(actionCode)) {
            return;
        }
        ObjectStatusModelDO toModel = objectStatusModelMapper
                .selectByObjectTypeAndStatusCodeEnabled(ProjectTaskConstants.OBJECT_TYPE, toStatus);
        if (toModel == null || !Boolean.TRUE.equals(toModel.getTerminalFlag())) {
            return;
        }
        List<String> terminalStatusCodes = objectStatusModelMapper
                .selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
        Integer openChildrenCount = projectTaskMapper.countChildrenNotInStatus(task.getId(), terminalStatusCodes);
        if (openChildrenCount != null && openChildrenCount > 0) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_COMPLETE_CHILDREN_REQUIRED);
        }
    }

    /**
     * 任务状态变更核心逻辑（不做手工入口授权校验）。
     * 手工入口 changeTaskStatus 在做完 owner/项目角色权限校验后调用；
     * 系统级联（cascade*Children）跳过授权校验直接调用，整棵子树状态变更通过链式触发实现。
     */
    private void doInternalChangeTaskStatus(ProjectTaskDO task, String actionCode, String reason) {
        String fromStatus = task.getStatusCode();
        ObjectStatusTransitionDO transition = validateTaskTransition(fromStatus, actionCode, reason);
        String toStatus = transition.getToStatusCode();
        validateBeforeStatusChange(task, actionCode, toStatus);

        int updateCount = projectTaskMapper.updateStatusByIdAndStatus(task.getId(), fromStatus, toStatus, reason);
        if (updateCount != 1) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_CONCURRENT_MODIFIED);
        }
        task.setStatusCode(toStatus);
        task.setLastStatusReason(reason);

        writeTaskStatusLog(task, actionCode, fromStatus, toStatus, reason);
        writeTaskAuditLog(task, actionCode, fromStatus, toStatus, null, reason);

        // 完成动作：兜底把任务进度刷到 100%，并触发父任务 AVG 重算（forceCompleteProgress 内部触发项目刷新）
        if ("complete".equals(actionCode)) {
            forceCompleteProgress(task);
        } else {
            if (task.getParentTaskId() != null) {
                recalcParentProgressFrom(task.getParentTaskId());
            }
            // 项目进度推算：非完成状态变更（cancel/pause/resume）。cancel 进 progress_excluded 影响项目均值；
            // pause/resume 不影响 progress_excluded，但根任务的 pause/resume 不改 progressRate 时 recalc 也是幂等的
            projectService.recalcProgress(task.getProjectId());
        }

        // 取消 / 暂停 / 恢复：触发子任务级联（每个子任务的内部链路自身会再级联自己的子，链式实现整棵子树）
        cascadeIfNeeded(task, actionCode, reason);

        // 通知执行（仅 complete / cancel；pause / resume 不通知；auto_start 在 internalAutoStartByWorklog 单独调用）
        if ("complete".equals(actionCode) || "cancel".equals(actionCode)) {
            projectExecutionService.onTaskStatusChanged(
                    task.getExecutionId(), task.getId(), fromStatus, toStatus, actionCode, reason);
        }
    }

    /**
     * 完成动作后将任务进度兜底置为 100%，并触发父任务 AVG 重算。
     * 正常流程下前端会确保点完成时进度已经是 100%，此处为兜底（防止前端绕过 / bug 导致脏数据）。
     */
    private void forceCompleteProgress(ProjectTaskDO task) {
        BigDecimal full = BigDecimal.valueOf(100);
        if (progressNumericallyEquals(task.getProgressRate(), full)) {
            // 进度已经是 100：completed 不在 progress_excluded 集合内，对项目均值无影响，跳过刷新
            return;
        }
        projectTaskMapper.updateProgressRateById(task.getId(), full);
        task.setProgressRate(full);
        if (task.getParentTaskId() != null) {
            recalcParentProgressFrom(task.getParentTaskId());
        }
        // 项目进度推算：根任务 complete 时父链不冒泡，但任务自身 progressRate=100 已变化，必须触发刷新
        projectService.recalcProgress(task.getProjectId());
    }

    /**
     * 级联触发：根据 actionCode 对子任务批量执行对应动作。
     * - cancel：所有未终态的直接子任务自动取消
     * - pause：所有 active 的直接子任务自动暂停
     * - resume：所有 paused 的直接子任务自动恢复
     * 其他动作不级联。
     */
    private void cascadeIfNeeded(ProjectTaskDO task, String actionCode, String parentReason) {
        switch (actionCode) {
            case "cancel" -> cascadeCancelChildren(task.getId(), parentReason);
            case "pause" -> cascadePauseChildren(task.getId(), parentReason);
            case "resume" -> cascadeResumeChildren(task.getId(), parentReason);
            default -> {
                // 其他动作不级联
            }
        }
    }

    private void cascadeCancelChildren(Long parentTaskId, String parentReason) {
        List<ProjectTaskDO> children = loadDirectChildren(parentTaskId);
        if (children.isEmpty()) {
            return;
        }
        List<String> terminalStatusCodes = objectStatusModelMapper
                .selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
        for (ProjectTaskDO child : children) {
            // 已终态的子任务跳过（cancelled / completed）
            if (terminalStatusCodes.contains(child.getStatusCode())) {
                continue;
            }
            doInternalChangeTaskStatus(child, "cancel",
                    formatCascadeReason("取消", parentTaskId, parentReason));
        }
    }

    private void cascadePauseChildren(Long parentTaskId, String parentReason) {
        List<ProjectTaskDO> children = loadDirectChildren(parentTaskId);
        if (children.isEmpty()) {
            return;
        }
        for (ProjectTaskDO child : children) {
            // 只对 active 子任务级联暂停；其他状态（pending / paused / 终态）按设计跳过
            if (!"active".equals(child.getStatusCode())) {
                continue;
            }
            doInternalChangeTaskStatus(child, "pause",
                    formatCascadeReason("暂停", parentTaskId, parentReason));
        }
    }

    private void cascadeResumeChildren(Long parentTaskId, String parentReason) {
        List<ProjectTaskDO> children = loadDirectChildren(parentTaskId);
        if (children.isEmpty()) {
            return;
        }
        for (ProjectTaskDO child : children) {
            // 只对 paused 子任务级联恢复（含父任务暂停前已主动 paused 的——A 方案接受的"误恢复"边界）
            if (!"paused".equals(child.getStatusCode())) {
                continue;
            }
            doInternalChangeTaskStatus(child, "resume",
                    formatCascadeReason("恢复", parentTaskId, parentReason));
        }
    }

    private List<ProjectTaskDO> loadDirectChildren(Long parentTaskId) {
        return projectTaskMapper.selectList(new LambdaQueryWrapperX<ProjectTaskDO>()
                .eq(ProjectTaskDO::getParentTaskId, parentTaskId));
    }

    private String formatCascadeReason(String actionDisplay, Long parentTaskId, String parentReason) {
        if (StringUtils.hasText(parentReason)) {
            return String.format("父任务%s时自动级联（父任务ID: %d，父任务原因: %s）",
                    actionDisplay, parentTaskId, parentReason);
        }
        return String.format("父任务%s时自动级联（父任务ID: %d）", actionDisplay, parentTaskId);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cascadeCancelTasksByExecutionId(Long executionId, String executionReason) {
        cascadeAllRootTasksByExecutionId(executionId, "cancel", "取消", executionReason);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cascadePauseTasksByExecutionId(Long executionId, String executionReason) {
        cascadeAllRootTasksByExecutionId(executionId, "pause", "暂停", executionReason);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cascadeResumeTasksByExecutionId(Long executionId, String executionReason) {
        cascadeAllRootTasksByExecutionId(executionId, "resume", "恢复", executionReason);
    }

    /**
     * 按动作类型级联执行下所有顶层任务（parentTaskId IS NULL）；顶层任务自身的内部链路会再级联子任务。
     * 跳过 owner 校验，复用 doInternalChangeTaskStatus 的事务 / CAS / 审计 / 日期回填。
     */
    private void cascadeAllRootTasksByExecutionId(Long executionId, String actionCode,
                                                  String actionDisplay, String executionReason) {
        if (executionId == null) {
            return;
        }
        List<ProjectTaskDO> rootTasks = projectTaskMapper.selectList(new LambdaQueryWrapperX<ProjectTaskDO>()
                .eq(ProjectTaskDO::getExecutionId, executionId)
                .isNull(ProjectTaskDO::getParentTaskId));
        if (rootTasks.isEmpty()) {
            return;
        }
        List<String> terminalStatusCodes = "cancel".equals(actionCode)
                ? objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE)
                : null;
        for (ProjectTaskDO task : rootTasks) {
            if (!isTaskEligibleForExecutionCascade(task, actionCode, terminalStatusCodes)) {
                continue;
            }
            doInternalChangeTaskStatus(task, actionCode,
                    formatExecutionCascadeReason(actionDisplay, executionId, executionReason));
        }
    }

    private boolean isTaskEligibleForExecutionCascade(ProjectTaskDO task, String actionCode,
                                                     List<String> terminalStatusCodes) {
        return switch (actionCode) {
            case "cancel" -> terminalStatusCodes != null && !terminalStatusCodes.contains(task.getStatusCode());
            case "pause" -> "active".equals(task.getStatusCode());
            case "resume" -> "paused".equals(task.getStatusCode());
            default -> false;
        };
    }

    private String formatExecutionCascadeReason(String actionDisplay, Long executionId, String executionReason) {
        if (StringUtils.hasText(executionReason)) {
            return String.format("执行%s时自动级联（执行ID: %d，执行原因: %s）",
                    actionDisplay, executionId, executionReason);
        }
        return String.format("执行%s时自动级联（执行ID: %d）", actionDisplay, executionId);
    }

    private String getInitialTaskStatusCode() {
        ObjectStatusModelDO statusModel = objectStatusModelMapper
                .selectInitialByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
        if (statusModel == null || !StringUtils.hasText(statusModel.getStatusCode())) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
        }
        return statusModel.getStatusCode();
    }

    private void writeTaskAuditLog(ProjectTaskDO task,
                                   String actionType,
                                   String fromStatus,
                                   String toStatus,
                                   String fieldChanges,
                                   String reason) {
        BizAuditLogDO auditLog = new BizAuditLogDO();
        auditLog.setBizType(ProjectTaskConstants.BIZ_TYPE);
        auditLog.setBizId(task.getId());
        auditLog.setActionType(actionType);
        auditLog.setFromStatus(fromStatus);
        auditLog.setToStatus(toStatus);
        auditLog.setFieldChanges(fieldChanges);
        auditLog.setReason(reason);
        auditLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
        auditLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
        bizAuditLogMapper.insert(auditLog);
    }

    private void writeTaskStatusLog(ProjectTaskDO task, String actionType, String fromStatus,
                                    String toStatus, String reason) {
        ProjectTaskStatusLogDO statusLog = new ProjectTaskStatusLogDO();
        statusLog.setTaskId(task.getId());
        statusLog.setActionType(actionType);
        statusLog.setFromStatus(fromStatus);
        statusLog.setToStatus(toStatus);
        statusLog.setReason(defaultText(reason));
        statusLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
        statusLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
        statusLog.setTaskTitleSnapshot(task.getTaskTitle());
        projectTaskStatusLogMapper.insert(statusLog);
    }

    private ProjectTaskDO cloneTask(ProjectTaskDO source) {
        ProjectTaskDO target = new ProjectTaskDO();
        target.setId(source.getId());
        target.setProjectId(source.getProjectId());
        target.setExecutionId(source.getExecutionId());
        target.setParentTaskId(source.getParentTaskId());
        target.setTaskTitle(source.getTaskTitle());
        target.setType(source.getType());
        target.setOwnerId(source.getOwnerId());
        target.setStatusCode(source.getStatusCode());
        target.setProgressRate(source.getProgressRate());
        target.setPlannedStartDate(source.getPlannedStartDate());
        target.setPlannedEndDate(source.getPlannedEndDate());
        target.setActualStartDate(source.getActualStartDate());
        target.setActualEndDate(source.getActualEndDate());
        target.setTaskDesc(source.getTaskDesc());
        target.setLastStatusReason(source.getLastStatusReason());
        return target;
    }

    private String buildTaskFieldChanges(ProjectTaskDO before, ProjectTaskDO after) {
        Map<String, Object> fieldChanges = new LinkedHashMap<>();
        appendFieldChange(fieldChanges, "projectId", valueOf(before, ProjectTaskDO::getProjectId),
                valueOf(after, ProjectTaskDO::getProjectId));
        appendFieldChange(fieldChanges, "executionId", valueOf(before, ProjectTaskDO::getExecutionId),
                valueOf(after, ProjectTaskDO::getExecutionId));
        appendFieldChange(fieldChanges, "parentTaskId", valueOf(before, ProjectTaskDO::getParentTaskId),
                valueOf(after, ProjectTaskDO::getParentTaskId));
        appendFieldChange(fieldChanges, "taskTitle", valueOf(before, ProjectTaskDO::getTaskTitle),
                valueOf(after, ProjectTaskDO::getTaskTitle));
        appendFieldChange(fieldChanges, "type", valueOf(before, ProjectTaskDO::getType),
                valueOf(after, ProjectTaskDO::getType));
        appendFieldChange(fieldChanges, "ownerId", valueOf(before, ProjectTaskDO::getOwnerId),
                valueOf(after, ProjectTaskDO::getOwnerId));
        appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectTaskDO::getStatusCode),
                valueOf(after, ProjectTaskDO::getStatusCode));
        appendFieldChange(fieldChanges, "priority",
                valueOf(before, ProjectTaskDO::getPriority),
                valueOf(after, ProjectTaskDO::getPriority));
        appendFieldChange(fieldChanges, "progressRate", valueOf(before, ProjectTaskDO::getProgressRate),
                valueOf(after, ProjectTaskDO::getProgressRate));
        appendFieldChange(fieldChanges, "plannedStartDate", valueOf(before, ProjectTaskDO::getPlannedStartDate),
                valueOf(after, ProjectTaskDO::getPlannedStartDate));
        appendFieldChange(fieldChanges, "plannedEndDate", valueOf(before, ProjectTaskDO::getPlannedEndDate),
                valueOf(after, ProjectTaskDO::getPlannedEndDate));
        appendFieldChange(fieldChanges, "actualStartDate", valueOf(before, ProjectTaskDO::getActualStartDate),
                valueOf(after, ProjectTaskDO::getActualStartDate));
        appendFieldChange(fieldChanges, "actualEndDate", valueOf(before, ProjectTaskDO::getActualEndDate),
                valueOf(after, ProjectTaskDO::getActualEndDate));
        appendFieldChange(fieldChanges, "taskDesc", valueOf(before, ProjectTaskDO::getTaskDesc),
                valueOf(after, ProjectTaskDO::getTaskDesc));
        appendFieldChange(fieldChanges, "lastStatusReason", valueOf(before, ProjectTaskDO::getLastStatusReason),
                valueOf(after, ProjectTaskDO::getLastStatusReason));
        return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
    }

    private <T> T valueOf(ProjectTaskDO task, Function<ProjectTaskDO, T> getter) {
        return task == null ? null : getter.apply(task);
    }

    private void appendFieldChange(Map<String, Object> fieldChanges, String fieldName, Object before, Object after) {
        if (Objects.equals(before, after)) {
            return;
        }
        Map<String, Object> value = new LinkedHashMap<>();
        value.put("before", before);
        value.put("after", after);
        fieldChanges.put(fieldName, value);
    }

    private void validateDateRange(LocalDate startDate, LocalDate endDate, String message) {
        if (startDate == null || endDate == null) {
            return;
        }
        if (endDate.isBefore(startDate)) {
            throw invalidParamException(message);
        }
    }

    private BigDecimal normalizeProgress(BigDecimal value) {
        return value == null ? BigDecimal.ZERO : value;
    }

    /**
     * 校验"叶子转父"前置条件：仅当 parentTask 当前还是叶子（无任何子任务）时校验
     * 自身进度=0 且没有任何工时记录。已是父任务则直接放行。
     */
    private void validateLeafToParentSplit(ProjectTaskDO parentTask) {
        if (parentTask == null) {
            return;
        }
        int childCount = projectTaskMapper.countChildrenByParentTaskId(parentTask.getId());
        if (childCount > 0) {
            return;
        }
        BigDecimal progress = parentTask.getProgressRate();
        if (progress != null && progress.compareTo(BigDecimal.ZERO) > 0) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_PROGRESS);
        }
        if (taskWorklogMapper.existsByTaskId(parentTask.getId())) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_LEAF_TO_PARENT_FORBIDDEN_WORKLOG);
        }
    }

    @VisibleForTesting
    void validatePriority(String priority) {
        try {
            Boolean valid = dictDataApi.validateDictDataList(ProjectDictTypeConstants.REQ_PRIORITY,
                    java.util.List.of(priority.trim())).getCheckedData();
            if (Boolean.TRUE.equals(valid)) {
                return;
            }
        } catch (RuntimeException ex) {
            throw exception(ErrorCodeConstants.PROJECT_TASK_PRIORITY_INVALID);
        }
        throw exception(ErrorCodeConstants.PROJECT_TASK_PRIORITY_INVALID);
    }

    /**
     * 从给定父任务向上递归刷新进度：父进度 = AVG(直接子.progressRate)，scale=2 HALF_UP。
     * 终止条件：parentTaskId 为 null（已到根）；或当前层进度未发生变化（截断进一步刷新）。
     * 若父任务的子集合在并发场景下变为空（极少见），保留原值不动。
     */
    private void recalcParentProgressFrom(Long parentTaskId) {
        Long current = parentTaskId;
        List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
        while (current != null) {
            ProjectTaskDO parent = projectTaskMapper.selectById(current);
            if (parent == null) {
                return;
            }
            List<ProjectTaskDO> children = projectTaskMapper.selectChildrenProgressByParentTaskId(current,
                    excludedStatusCodes);
            BigDecimal sum = BigDecimal.ZERO;
            for (ProjectTaskDO child : children) {
                BigDecimal cp = child.getProgressRate() == null ? BigDecimal.ZERO : child.getProgressRate();
                sum = sum.add(cp);
            }
            BigDecimal avg = children.isEmpty()
                    ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
                    : sum.divide(BigDecimal.valueOf(children.size()), 2, RoundingMode.HALF_UP);
            if (progressNumericallyEquals(avg, parent.getProgressRate())) {
                return;
            }
            projectTaskMapper.updateProgressRateById(current, avg);
            current = parent.getParentTaskId();
        }
    }

    private List<String> loadProgressExcludedTaskStatusCodes() {
        List<String> statusCodes = objectStatusModelMapper
                .selectProgressExcludedStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
        return statusCodes == null ? Collections.emptyList() : statusCodes;
    }

    private boolean progressNumericallyEquals(BigDecimal a, BigDecimal b) {
        if (a == null && b == null) {
            return true;
        }
        if (a == null || b == null) {
            return false;
        }
        return a.compareTo(b) == 0;
    }

    private String normalizeRequiredTitle(String value) {
        if (!StringUtils.hasText(value)) {
            throw invalidParamException("任务标题不能为空");
        }
        return value.trim();
    }

    private String normalizeRequiredActionCode(String value) {
        if (!StringUtils.hasText(value)) {
            throw invalidParamException("动作编码不能为空");
        }
        return value.trim();
    }

    private String normalizeRequiredType(String value, String message) {
        if (!StringUtils.hasText(value)) {
            throw invalidParamException(message);
        }
        return value.trim();
    }

    private String normalizeNullableText(String value) {
        if (!StringUtils.hasText(value)) {
            return null;
        }
        return value.trim();
    }

    private String defaultText(String value) {
        return StringUtils.hasText(value) ? value : "";
    }

    private void applyLifecycle(ProjectTaskRespVO respVO) {
        // 传入 ownerId / progressRate 用于 availableActions 的 owner-only 与完成进度过滤。
        ProjectTaskStatusViewService.ProjectTaskLifecycleView lifecycle =
                projectTaskStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId(),
                        respVO.getProgressRate());
        respVO.setStatusName(lifecycle.statusName());
        respVO.setTerminal(lifecycle.terminal());
        respVO.setAllowEdit(lifecycle.allowEdit());
        respVO.setAvailableActions(lifecycle.availableActions());
    }

    private String loadOwnerNickname(Long ownerId) {
        if (ownerId == null) {
            return null;
        }
        CommonResult<AdminUserRespDTO> result = adminUserApi.getUser(ownerId);
        AdminUserRespDTO user = result == null ? null : result.getCheckedData();
        return user == null ? null : user.getNickname();
    }

    private Map<Long, String> loadOwnerNicknameMap(Collection<Long> ownerIds) {
        if (ownerIds == null || ownerIds.isEmpty()) {
            return Collections.emptyMap();
        }
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(ownerIds);
        if (userMap == null || userMap.isEmpty()) {
            return Collections.emptyMap();
        }
        Map<Long, String> nicknameMap = new HashMap<>(userMap.size());
        userMap.forEach((id, user) -> nicknameMap.put(id, user == null ? null : user.getNickname()));
        return nicknameMap;
    }

}
