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

import com.google.common.annotations.VisibleForTesting;
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.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.controller.admin.project.execution.vo.execution.MyProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.MyProjectExecutionRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionOwnerChangeReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionPageReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionCreateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeletePrecheckRespVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionDeleteReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionUpdateReqVO;
import com.njcn.rdms.module.project.controller.admin.project.execution.vo.execution.ProjectExecutionStatusActionReqVO;
import com.njcn.rdms.module.project.dal.dataobject.audit.BizAuditLogDO;
import com.njcn.rdms.module.project.dal.dataobject.member.UserObjectRoleDO;
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.ExecutionAssigneeDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionDO;
import com.njcn.rdms.module.project.dal.dataobject.project.execution.ProjectExecutionStatusLogDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusModelDO;
import com.njcn.rdms.module.project.dal.dataobject.status.ObjectStatusTransitionDO;
import com.njcn.rdms.module.project.dal.mysql.audit.BizAuditLogMapper;
import com.njcn.rdms.module.project.dal.mysql.member.UserObjectRoleMapper;
import com.njcn.rdms.module.project.constant.ProjectTaskConstants;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectRequirementMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ExecutionAssigneeMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionMapper;
import com.njcn.rdms.module.project.dal.mysql.project.execution.ProjectExecutionStatusLogMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.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.enums.ProjectDictTypeConstants;
import com.njcn.rdms.module.project.framework.notify.NotifyJumpConstants;
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
import com.njcn.rdms.module.project.util.DueRangeSupport;
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.ProjectRequirementService;
import com.njcn.rdms.module.project.service.project.ProjectService;
import com.njcn.rdms.module.project.service.project.task.ProjectTaskService;
import com.njcn.rdms.module.project.service.status.StatusActionTextResolver;
import com.njcn.rdms.module.system.api.dict.DictDataApi;
import com.njcn.rdms.module.system.api.user.AdminUserApi;
import com.njcn.rdms.module.system.api.user.dto.AdminUserRespDTO;
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Lazy;
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.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
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 ProjectExecutionServiceImpl implements ProjectExecutionService {

    @Resource
    private ProjectMapper projectMapper;
    @Resource
    private ProjectExecutionMapper projectExecutionMapper;
    @Resource
    private ExecutionAssigneeMapper executionAssigneeMapper;
    @Resource
    private UserObjectRoleMapper userObjectRoleMapper;
    @Resource
    private BizAuditLogMapper bizAuditLogMapper;
    @Resource
    private ObjectStatusModelMapper objectStatusModelMapper;
    @Resource
    private ObjectStatusTransitionMapper objectStatusTransitionMapper;
    @Resource
    private ProjectExecutionStatusLogMapper projectExecutionStatusLogMapper;
    @Resource
    private DictDataApi dictDataApi;
    @Resource
    private ProjectExecutionStatusViewService projectExecutionStatusViewService;
    @Resource
    private ProjectExecutionAssigneeService projectExecutionAssigneeService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private ProjectTaskMapper projectTaskMapper;
    @Resource
    private TaskWorklogMapper taskWorklogMapper;
    @Resource
    private TaskAssigneeMapper taskAssigneeMapper;
    @Resource
    private ProjectService projectService;
    @Resource
    private ProjectRequirementService projectRequirementService;
    @Resource
    private ProjectRequirementMapper projectRequirementMapper;
    @Resource
    private ProjectObjectAuthorizationService projectObjectAuthorizationService;
    @Resource
    private StatusActionTextResolver statusActionTextResolver;
    /** 站内信事件发布：创建执行后发「执行指派」通知，由 NotifySendEventListener 事务提交后统一发送。 */
    @Resource
    private ApplicationEventPublisher applicationEventPublisher;
    /**
     * 任务服务：执行 cancel / pause / resume 时级联调任务侧 internal 入口。
     * 与 ProjectTaskService 互相依赖（任务侧已注入 ProjectExecutionService 用于通知执行），用 @Lazy 打破循环。
     */
    @Resource
    @Lazy
    private ProjectTaskService projectTaskService;

    @Override
    @Transactional(rollbackFor = Exception.class)
    @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
            permission = ProjectExecutionConstants.PERMISSION_CREATE)
    public Long createExecution(Long projectId, ProjectExecutionCreateReqVO reqVO) {
        validateEditableProject(projectId);
        projectRequirementService.validateUsableForExecution(projectId, reqVO.getProjectRequirementId());
        String executionName = normalizeRequiredName(reqVO.getExecutionName());
        validateExecutionNameUnique(projectId, null, executionName);
        validateOwnerIsActiveProjectMember(projectId, reqVO.getOwnerId());
        String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType());
        validateExecutionType(executionType);
        validatePriority(reqVO.getPriority());
        Set<Long> assigneeUserIds = normalizeRequiredAssigneeUserIds(reqVO.getAssigneeUserIds());
        validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");

        ProjectExecutionDO execution = new ProjectExecutionDO();
        execution.setProjectId(projectId);
        execution.setProjectRequirementId(reqVO.getProjectRequirementId());
        execution.setExecutionName(executionName);
        execution.setExecutionType(executionType);
        execution.setOwnerId(reqVO.getOwnerId());
        String initialStatusCode = getInitialExecutionStatusCode();
        execution.setStatusCode(initialStatusCode);
        execution.setPriority(reqVO.getPriority());
        execution.setPlannedStartDate(reqVO.getPlannedStartDate());
        execution.setPlannedEndDate(reqVO.getPlannedEndDate());
        execution.setActualStartDate(null);
        execution.setActualEndDate(null);
        execution.setProgressRate(BigDecimal.ZERO);
        execution.setExecutionDesc(normalizeNullableText(reqVO.getExecutionDesc()));
        projectExecutionMapper.insert(execution);

        writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_CREATE, null,
                initialStatusCode, buildExecutionFieldChanges(null, execution), null);
        createExecutionAssignees(execution.getId(), projectId, assigneeUserIds);
        publishExecutionAssignedNotify(execution);
        return execution.getId();
    }

    /**
     * 创建执行后发送「执行指派」站内信事件：接收人 = 负责人 + 活跃协办人（操作人自己由监听器统一排除）。
     * 仅 publish 事件，发送由 NotifySendEventListener 事务提交后处理；通知失败不影响执行创建。
     */
    @VisibleForTesting
    void publishExecutionAssignedNotify(ProjectExecutionDO execution) {
        List<Long> recipients = new ArrayList<>();
        recipients.add(execution.getOwnerId());
        executionAssigneeMapper.selectActiveListByExecutionId(execution.getId())
                .forEach(a -> recipients.add(a.getUserId()));
        Map<String, Object> params = new HashMap<>();
        ProjectDO project = projectMapper.selectById(execution.getProjectId());
        params.put("projectName", project == null ? "" : project.getProjectName());
        params.put("executionName", execution.getExecutionName());
        params.put(NotifyJumpConstants.PARAM_JUMP_TYPE, NotifyJumpConstants.JUMP_TYPE_EXECUTION);
        params.put(NotifyJumpConstants.PARAM_PROJECT_ID, String.valueOf(execution.getProjectId()));
        applicationEventPublisher.publishEvent(NotifySendEvent.of(recipients,
                NotifyTemplateCodeConstants.EXECUTION_ASSIGNED, params,
                NotifyMessageLevelConstants.NORMAL, SecurityFrameworkUtils.getLoginUserId()));
    }

    // 权限：方法体内属主放行 + 权限码兜底（requireExecutionEditDeletePermission），勿挂 @CheckObjectPermission
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateExecution(Long projectId, ProjectExecutionUpdateReqVO reqVO) {
        if (reqVO.getId() == null) {
            throw invalidParamException("执行编号不能为空");
        }
        ProjectDO project = validateEditableProject(projectId);
        ProjectExecutionDO execution = validateExecutionExists(projectId, reqVO.getId());
        requireExecutionEditDeletePermission(project, execution, ProjectExecutionConstants.PERMISSION_UPDATE);
        validateExecutionAllowEdit(execution);
        projectRequirementService.validateUsableForExecution(projectId, reqVO.getProjectRequirementId());
        String executionName = normalizeRequiredName(reqVO.getExecutionName());
        validateExecutionNameUnique(projectId, execution.getId(), executionName);
        String executionType = normalizeRequiredExecutionType(reqVO.getExecutionType());
        validateExecutionType(executionType);
        validatePriority(reqVO.getPriority());
        validateDateRange(reqVO.getPlannedStartDate(), reqVO.getPlannedEndDate(), "计划结束日期不能早于计划开始日期");

        // 不再处理 ownerId：换负责人必须走 /change-owner 端点（spec §6.3 契约收口）
        ProjectExecutionDO before = cloneExecution(execution);
        execution.setExecutionName(executionName);
        execution.setExecutionType(executionType);
        execution.setPriority(reqVO.getPriority());
        execution.setProjectRequirementId(reqVO.getProjectRequirementId());
        execution.setPlannedStartDate(reqVO.getPlannedStartDate());
        execution.setPlannedEndDate(reqVO.getPlannedEndDate());
        execution.setExecutionDesc(normalizeNullableText(reqVO.getExecutionDesc()));
        projectExecutionMapper.updateById(execution);

        writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_UPDATE, before.getStatusCode(),
                execution.getStatusCode(), buildExecutionFieldChanges(before, execution), null);
    }

    @Override
    public ProjectExecutionDO getExecution(Long projectId, Long executionId) {
        validateProjectExists(projectId);
        return validateExecutionExists(projectId, executionId);
    }

    @Override
    @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
    public PageResult<ProjectExecutionDO> getExecutionPage(Long projectId, ProjectExecutionPageReqVO reqVO) {
        validateProjectExists(projectId);
        // "我参与 / 所有"视角完全由前端发不发 reqVO.involveUserId 决定。
        // 注：getExecutionRespVOPage 内部 this.getExecutionPage() 自调用不触发 AOP，但外层注解已守门；
        // 此处独立挂注解是为了堵跨 service 直调 ProjectExecutionService.getExecutionPage 的鉴权后门。
        // dueRange 截止时间过滤所需的日期边界与执行终态码（终态排除口径对齐任务 summary）。
        LocalDate today = DueRangeSupport.today();
        return projectExecutionMapper.selectPageByProjectId(projectId, reqVO,
                loadExecutionTerminalStatusCodes(), today,
                DueRangeSupport.weekStart(today), DueRangeSupport.weekEnd(today));
    }

    @Override
    @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
    public ProjectExecutionRespVO getExecutionRespVO(Long projectId, Long executionId) {
        ProjectExecutionDO execution = getExecution(projectId, executionId);
        ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class);
        respVO.setProgressRate(loadExecutionProgress(projectId, executionId));
        boolean rootTasksAllCompleted = loadExecutionRootTasksAllCompleted(projectId, executionId);
        applyLifecycle(respVO, rootTasksAllCompleted);
        respVO.setOwnerNickname(loadOwnerNickname(execution.getOwnerId()));
        fillProjectRequirementInfo(List.of(respVO));
        return respVO;
    }

    @Override
    @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", accessible = true)
    public PageResult<ProjectExecutionRespVO> getExecutionRespVOPage(Long projectId, ProjectExecutionPageReqVO reqVO) {
        PageResult<ProjectExecutionDO> pageResult = getExecutionPage(projectId, reqVO);
        PageResult<ProjectExecutionRespVO> voPageResult = BeanUtils.toBean(pageResult, ProjectExecutionRespVO.class);
        List<ProjectExecutionRespVO> list = voPageResult.getList();
        if (list == null || list.isEmpty()) {
            return voPageResult;
        }
        fillExecutionProgress(projectId, list);
        fillProjectRequirementInfo(list);
        // 批量预聚合根任务完成态，避免列表 N+1。未命中执行 → 缺省 false，complete 按钮不下发。
        Map<Long, Boolean> rootTasksAllCompletedMap = loadExecutionRootTasksAllCompletedMap(projectId, list);
        // 批量补负责人昵称，避免 N+1
        Set<Long> ownerIds = list.stream()
                .map(ProjectExecutionRespVO::getOwnerId)
                .filter(Objects::nonNull)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        Map<Long, String> nicknameMap = loadOwnerNicknameMap(ownerIds);
        list.forEach(vo -> {
            vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()));
            // 列表行 cancel/pause/resume/complete 按钮依赖 availableActions，与详情同款装配 lifecycle。
            // 单行装配失败做兜底降级（status_model 缺失等脏数据），避免影响整页返回。
            try {
                applyLifecycle(vo, rootTasksAllCompletedMap.getOrDefault(vo.getId(), false));
            } catch (Exception e) {
                log.warn("execution lifecycle apply failed in page assembly. executionId={}, statusCode={}, error={}",
                        vo.getId(), vo.getStatusCode(), e.getMessage());
            }
        });
        return voPageResult;
    }

    @Override
    public List<ProjectExecutionRespVO> getCurrentUserExecutionList() {
        Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
        List<ProjectExecutionDO> executions = projectExecutionMapper.selectListByOwnerId(loginUserId);
        if (executions == null || executions.isEmpty()) {
            return Collections.emptyList();
        }
        List<ProjectExecutionRespVO> list = BeanUtils.toBean(executions, ProjectExecutionRespVO.class);
        Map<Long, Boolean> rootTasksAllCompletedMap = new HashMap<>();
        list.stream()
                .filter(vo -> vo.getProjectId() != null)
                .collect(Collectors.groupingBy(ProjectExecutionRespVO::getProjectId, LinkedHashMap::new,
                        Collectors.toList()))
                .forEach((groupProjectId, projectList) -> {
                    fillExecutionProgress(groupProjectId, projectList);
                    rootTasksAllCompletedMap.putAll(loadExecutionRootTasksAllCompletedMap(groupProjectId, projectList));
                });
        Set<Long> ownerIds = list.stream()
                .map(ProjectExecutionRespVO::getOwnerId)
                .filter(Objects::nonNull)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        Map<Long, String> nicknameMap = loadOwnerNicknameMap(ownerIds);
        list.forEach(vo -> {
            vo.setOwnerNickname(nicknameMap.get(vo.getOwnerId()));
            try {
                applyLifecycle(vo, rootTasksAllCompletedMap.getOrDefault(vo.getId(), false));
            } catch (Exception e) {
                log.warn("execution lifecycle apply failed in current user list assembly. executionId={}, statusCode={}, error={}",
                        vo.getId(), vo.getStatusCode(), e.getMessage());
            }
        });
        return list;
    }

    @Override
    public PageResult<MyProjectExecutionRespVO> getMyExecutionPage(MyProjectExecutionPageReqVO reqVO) {
        Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
        List<ProjectExecutionDO> executions = projectExecutionMapper.selectListByOwnerId(loginUserId);
        if (executions == null || executions.isEmpty()) {
            return new PageResult<>(Collections.emptyList(), 0L);
        }
        // 1. 排除终态状态（completed/cancelled，DB 权威，不硬编码）
        List<String> terminalStatusCodes =
                objectStatusModelMapper.selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
        List<ProjectExecutionDO> nonTerminal = executions.stream()
                .filter(e -> terminalStatusCodes == null || !terminalStatusCodes.contains(e.getStatusCode()))
                .collect(Collectors.toList());
        if (nonTerminal.isEmpty()) {
            return new PageResult<>(Collections.emptyList(), 0L);
        }
        // 2. 按 projectId 分组，逐组聚合一级任务真实进度（复用 loadExecutionProgressMap）
        List<String> excludedTaskStatusCodes = loadProgressExcludedTaskStatusCodes();
        Map<Long, BigDecimal> progressMap = new HashMap<>();
        nonTerminal.stream()
                .filter(e -> e.getProjectId() != null)
                .collect(Collectors.groupingBy(ProjectExecutionDO::getProjectId, LinkedHashMap::new, Collectors.toList()))
                .forEach((groupProjectId, groupList) -> {
                    Set<Long> executionIds = groupList.stream()
                            .map(ProjectExecutionDO::getId)
                            .filter(Objects::nonNull)
                            .collect(Collectors.toCollection(LinkedHashSet::new));
                    progressMap.putAll(loadExecutionProgressMap(groupProjectId, executionIds, excludedTaskStatusCodes));
                });
        // 3. 排除进度已满（progressRate >= 100）；缺失进度按 0 处理
        BigDecimal full = BigDecimal.valueOf(100);
        List<ProjectExecutionDO> filtered = nonTerminal.stream()
                .filter(e -> progressMap.getOrDefault(e.getId(), normalizeProgress(null)).compareTo(full) < 0)
                .collect(Collectors.toList());
        if (filtered.isEmpty()) {
            return new PageResult<>(Collections.emptyList(), 0L);
        }
        // 4. 批量回填 projectName / statusName / projectRequirementName
        Set<Long> projectIds = filtered.stream()
                .map(ProjectExecutionDO::getProjectId).filter(Objects::nonNull)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        Map<Long, String> projectNameMap = projectIds.isEmpty() ? Collections.emptyMap()
                : projectMapper.selectBatchIds(projectIds).stream()
                        .collect(Collectors.toMap(ProjectDO::getId, ProjectDO::getProjectName, (a, b) -> a));
        Map<String, String> statusNameMap = objectStatusModelMapper
                .selectListByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE).stream()
                .collect(Collectors.toMap(ObjectStatusModelDO::getStatusCode, ObjectStatusModelDO::getStatusName, (a, b) -> a));
        Set<Long> requirementIds = filtered.stream()
                .map(ProjectExecutionDO::getProjectRequirementId).filter(Objects::nonNull)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        Map<Long, String> requirementNameMap = requirementIds.isEmpty() ? Collections.emptyMap()
                : projectRequirementMapper.selectBatchIds(requirementIds).stream()
                        .collect(Collectors.toMap(ProjectRequirementDO::getId, ProjectRequirementDO::getTitle, (a, b) -> a));
        // 5. 组装精简 VO（progressRate BigDecimal → Integer 四舍五入）
        List<MyProjectExecutionRespVO> all = filtered.stream().map(e -> {
            MyProjectExecutionRespVO vo = new MyProjectExecutionRespVO();
            vo.setId(e.getId());
            vo.setExecutionName(e.getExecutionName());
            vo.setProjectId(e.getProjectId());
            vo.setProjectName(projectNameMap.get(e.getProjectId()));
            vo.setStatusCode(e.getStatusCode());
            vo.setStatusName(statusNameMap.get(e.getStatusCode()));
            vo.setPriority(e.getPriority());
            vo.setPlannedStartDate(e.getPlannedStartDate());
            vo.setPlannedEndDate(e.getPlannedEndDate());
            vo.setActualStartDate(e.getActualStartDate());
            vo.setActualEndDate(e.getActualEndDate());
            BigDecimal progress = progressMap.getOrDefault(e.getId(), normalizeProgress(null));
            vo.setProgressRate(progress.setScale(0, RoundingMode.HALF_UP).intValue());
            vo.setProjectRequirementId(e.getProjectRequirementId());
            vo.setProjectRequirementName(requirementNameMap.get(e.getProjectRequirementId()));
            return vo;
        }).collect(Collectors.toList());
        // 6. 分页：pageSize<0（PAGE_SIZE_NONE）= 全部；否则内存切片（兼容，前端本期不使用）
        long total = all.size();
        Integer pageSize = reqVO.getPageSize();
        if (pageSize == null || pageSize < 0) {
            return new PageResult<>(all, total);
        }
        int pageNo = reqVO.getPageNo() == null || reqVO.getPageNo() < 1 ? 1 : reqVO.getPageNo();
        int fromIndex = Math.min((pageNo - 1) * pageSize, all.size());
        int toIndex = Math.min(fromIndex + pageSize, all.size());
        return new PageResult<>(all.subList(fromIndex, toIndex), total);
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
            permission = ProjectExecutionConstants.PERMISSION_OWNER)
    public void changeOwner(Long projectId, Long executionId, ProjectExecutionOwnerChangeReqVO reqVO) {
        validateEditableProject(projectId);
        ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
        validateExecutionAllowEdit(execution);
        Long oldOwnerId = execution.getOwnerId();
        if (Objects.equals(oldOwnerId, reqVO.getNewOwnerId())) {
            return;
        }
        validateOwnerIsActiveProjectMember(projectId, reqVO.getNewOwnerId());
        ProjectExecutionDO before = cloneExecution(execution);
        execution.setOwnerId(reqVO.getNewOwnerId());
        projectExecutionMapper.updateById(execution);
        String reason = normalizeNullableText(reqVO.getReason());
        writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_CHANGE_OWNER, before.getStatusCode(),
                execution.getStatusCode(), buildExecutionFieldChanges(before, execution), reason);
        // 双向写入成员变更历史：原 owner 转出 / 新 owner 转入；oldOwnerId 为 null 时跳过转出（首次设负责人场景）
        if (oldOwnerId != null) {
            projectExecutionAssigneeService.writeAssigneeLog(executionId, oldOwnerId,
                    ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_OUT, reason);
        }
        projectExecutionAssigneeService.writeAssigneeLog(executionId, reqVO.getNewOwnerId(),
                ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_OWNER_TRANSFER_IN, reason);
    }

    // 权限：方法体内属主放行 + 权限码兜底（requireExecutionEditDeletePermission），勿挂 @CheckObjectPermission
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteExecution(Long projectId, Long executionId, ProjectExecutionDeleteReqVO reqVO) {
        ProjectDO project = validateProjectExists(projectId);
        ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
        requireExecutionEditDeletePermission(project, execution, ProjectExecutionConstants.PERMISSION_DELETE);
        validateDeleteConfirmText(reqVO.getConfirmText());
        if (!Objects.equals(execution.getExecutionName(), reqVO.getExecutionName().trim())) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_NAME_MISMATCH);
        }
        // 删除路径不复用 validateEditableProject / 状态 allow_edit：已完成执行用专门的硬卡拦截，
        // 其它非 completed 状态（pending / active / paused / cancelled）允许删除。
        if (Objects.equals(execution.getStatusCode(), ProjectExecutionConstants.STATUS_COMPLETED)) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NOT_ALLOW_DELETE);
        }
        String reason = reqVO.getReason().trim();
        String fromStatus = execution.getStatusCode();

        // 级联软删：先清子级（worklog / task assignee / task），再清同级协办，最后软删执行本身
        List<Long> taskIds = projectTaskMapper.selectIdsByExecutionId(executionId);
        if (!taskIds.isEmpty()) {
            taskWorklogMapper.deleteByTaskIdIn(taskIds);
            taskAssigneeMapper.deleteByTaskIdIn(taskIds);
            projectTaskMapper.deleteByIdIn(taskIds);
        }
        executionAssigneeMapper.deleteByExecutionId(executionId);
        int deleteCount = projectExecutionMapper.deleteById(executionId);
        if (deleteCount != 1) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED);
        }
        writeExecutionAuditLog(execution, ObjectActivityConstants.EXECUTION_ACTION_DELETE, fromStatus, null, null, reason);
    }

    // 权限：方法体内属主放行 + 权限码兜底（requireExecutionEditDeletePermission），勿挂 @CheckObjectPermission
    @Override
    public ProjectExecutionDeletePrecheckRespVO precheckDeleteExecution(Long projectId, Long executionId) {
        ProjectDO project = validateProjectExists(projectId);
        ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
        requireExecutionEditDeletePermission(project, execution, ProjectExecutionConstants.PERMISSION_DELETE);
        int taskCount = projectTaskMapper.countByExecutionId(executionId);
        ProjectExecutionDeletePrecheckRespVO respVO = new ProjectExecutionDeletePrecheckRespVO();
        respVO.setTaskCount(taskCount);
        respVO.setHasDependentData(taskCount > 0);
        return respVO;
    }

    /**
     * 执行编辑/删除授权：项目负责人、执行负责人直接放行；其它用户回落对象域权限码。
     *
     * <p>红线：调用本方法的入口（updateExecution / deleteExecution / precheckDeleteExecution）<b>故意不挂
     * {@code @CheckObjectPermission}</b>——注解是前置拦截，会把"无权限码的负责人"挡死。
     * 不要误判为漏鉴权把注解补回去；口径见 CLAUDE.md §鉴权。
     */
    private void requireExecutionEditDeletePermission(ProjectDO project, ProjectExecutionDO execution,
                                                      String permission) {
        List<Long> ownerUserIds = new ArrayList<>();
        addIfNotNull(ownerUserIds, project.getManagerUserId());
        addIfNotNull(ownerUserIds, execution.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
                || !ProjectExecutionConstants.DELETE_CONFIRM_TEXTS.contains(normalizedConfirmText)) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_DELETE_CONFIRM_TEXT_INVALID);
        }
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
            permission = ProjectExecutionConstants.PERMISSION_STATUS)
    public void changeExecutionStatus(Long projectId, Long executionId, ProjectExecutionStatusActionReqVO reqVO) {
        validateProjectExists(projectId);
        ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
        String actionCode = normalizeRequiredActionCode(reqVO.getActionCode());
        String reason = normalizeNullableText(reqVO.getReason());
        // 负责人独占动作：complete / cancel / pause / resume 必须由执行负责人触发
        validateOwnerForAction(execution, actionCode);
        String fromStatus = execution.getStatusCode();
        ObjectStatusTransitionDO transition = validateExecutionTransition(fromStatus, actionCode, reason);
        String toStatus = transition.getToStatusCode();
        // complete 前置：执行下所有任务必须终态
        if ("complete".equals(actionCode)) {
            validateAllTasksTerminal(executionId);
        }

        int updateCount = projectExecutionMapper.updateStatusByIdAndStatus(executionId, fromStatus, toStatus, reason);
        if (updateCount != 1) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_CONCURRENT_MODIFIED);
        }
        execution.setStatusCode(toStatus);
        execution.setLastStatusReason(reason);

        writeExecutionStatusLog(execution, actionCode, fromStatus, toStatus, reason);
        writeExecutionAuditLog(execution, actionCode, fromStatus, toStatus, null, reason);
        maybeFillActualDates(execution, fromStatus, toStatus);

        // 完成动作：兜底把执行进度刷到 100%
        if ("complete".equals(actionCode)) {
            forceCompleteProgress(execution);
            onExecutionCompleted(execution);
        }

        // 取消 / 暂停 / 恢复：级联执行下任务
        cascadeTasksIfNeeded(executionId, actionCode, reason);
    }

    /**
     * TD-015 推模式钩子：执行完成后触发。本期仅占位（通知 / 催办待后续独立实现）：
     * 1) 触发所属项目「能否完成」判定（项目层 complete 按钮即时联动）；
     * 2) 若该执行关联了项目需求，触发需求催办。
     */
    private void onExecutionCompleted(ProjectExecutionDO execution) {
        // TODO(TD-015 推模式)：项目完成判定钩子 —— 通知项目负责人项目可能已满足完成条件
        if (execution.getProjectRequirementId() != null) {
            // TODO(TD-015 推模式)：需求催办钩子 —— 提醒处理人推进关联需求至终态
        }
        log.debug("[TD-015] 执行完成钩子占位: executionId={}, projectId={}, projectRequirementId={}",
                execution.getId(), execution.getProjectId(), execution.getProjectRequirementId());
    }

    @VisibleForTesting
    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_EXECUTION_NOT_ALLOW_EDIT);
        }
        return project;
    }

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

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

    @VisibleForTesting
    void validateOwnerIsActiveProjectMember(Long projectId, Long ownerId) {
        // 多角色支持：user 在项目内有任意 ACTIVE 角色即视为项目成员
        if (userObjectRoleMapper
                .selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, ownerId)
                .isEmpty()) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_OWNER_INVALID);
        }
    }

    @VisibleForTesting
    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_EXECUTION_STATUS_NOT_ALLOW_EDIT);
        }
    }

    @VisibleForTesting
    ObjectStatusTransitionDO validateExecutionTransition(String fromStatusCode, String actionCode, String reason) {
        ObjectStatusTransitionDO transition = objectStatusTransitionMapper
                .selectByObjectTypeAndFromStatusAndAction(ProjectExecutionConstants.OBJECT_TYPE, fromStatusCode, actionCode);
        if (transition == null) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_NOT_ALLOWED,
                    statusActionTextResolver.statusName(ProjectExecutionConstants.OBJECT_TYPE, fromStatusCode),
                    statusActionTextResolver.actionName(ProjectExecutionConstants.OBJECT_TYPE, actionCode));
        }
        if (Boolean.TRUE.equals(transition.getNeedReason()) && !StringUtils.hasText(reason)) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_ACTION_REASON_REQUIRED, transition.getActionName());
        }
        return transition;
    }

    @VisibleForTesting
    String getInitialExecutionStatusCode() {
        ObjectStatusModelDO statusModel = objectStatusModelMapper
                .selectInitialByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
        if (statusModel == null || !StringUtils.hasText(statusModel.getStatusCode())) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_STATUS_MODEL_NOT_EXISTS_OR_DISABLED);
        }
        return statusModel.getStatusCode();
    }

    @VisibleForTesting
    void validateExecutionNameUnique(Long projectId, Long executionId, String executionName) {
        ProjectExecutionDO existingExecution = projectExecutionMapper.selectByProjectIdAndName(projectId, executionName);
        if (existingExecution == null) {
            return;
        }
        if (executionId == null || !Objects.equals(existingExecution.getId(), executionId)) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_NAME_DUPLICATE, executionName);
        }
    }

    @VisibleForTesting
    void validateExecutionType(String executionType) {
        try {
            Boolean valid = dictDataApi.validateDictDataList(ProjectDictTypeConstants.EXECUTION_TYPE,
                    List.of(executionType)).getCheckedData();
            if (Boolean.TRUE.equals(valid)) {
                return;
            }
        } catch (RuntimeException ex) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_TYPE_INVALID);
        }
        throw exception(ErrorCodeConstants.PROJECT_EXECUTION_TYPE_INVALID);
    }

    @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_EXECUTION_PRIORITY_INVALID);
        }
        throw exception(ErrorCodeConstants.PROJECT_EXECUTION_PRIORITY_INVALID);
    }

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

    private void createExecutionAssignees(Long executionId, Long projectId, Set<Long> assigneeUserIds) {
        LocalDateTime now = LocalDateTime.now();
        for (Long assigneeUserId : assigneeUserIds) {
            validateExecutionAssigneeProjectScope(projectId, assigneeUserId);
            ExecutionAssigneeDO member = new ExecutionAssigneeDO();
            member.setExecutionId(executionId);
            member.setUserId(assigneeUserId);
            member.setJoinedAt(now);
            member.setRemovedAt(null);
            member.setRemovedReason(null);
            executionAssigneeMapper.insert(member);
            writeExecutionAuditLogByBizId(executionId, ObjectActivityConstants.EXECUTION_ASSIGNEE_ACTION_ADD,
                    null, null, buildExecutionAssigneeFieldChanges(null, member), null);
            // B 模型：每次创建活跃段同步写一条 join 事件，确保活跃成员都能在变更历史中追溯加入时间
            projectExecutionAssigneeService.writeAssigneeLog(executionId, assigneeUserId,
                    ObjectActivityConstants.EXECUTION_ASSIGNEE_LOG_ACTION_JOIN, null);
        }
    }

    private void validateExecutionAssigneeProjectScope(Long projectId, Long userId) {
        // 多角色支持：user 在项目内有任意 ACTIVE 角色即可作为 assignee
        if (userObjectRoleMapper
                .selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, projectId, userId)
                .isEmpty()) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_INVALID);
        }
    }

    private Set<Long> normalizeAssigneeUserIds(List<Long> assigneeUserIds) {
        if (assigneeUserIds == null || assigneeUserIds.isEmpty()) {
            return Collections.emptySet();
        }
        Set<Long> normalizedUserIds = new LinkedHashSet<>();
        for (Long assigneeUserId : assigneeUserIds) {
            if (assigneeUserId != null) {
                normalizedUserIds.add(assigneeUserId);
            }
        }
        return normalizedUserIds;
    }

    private Set<Long> normalizeRequiredAssigneeUserIds(List<Long> assigneeUserIds) {
        Set<Long> normalizedUserIds = normalizeAssigneeUserIds(assigneeUserIds);
        if (normalizedUserIds.isEmpty()) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_ASSIGNEE_REQUIRED);
        }
        return normalizedUserIds;
    }

    private void writeExecutionAuditLog(ProjectExecutionDO execution,
                                        String actionType,
                                        String fromStatus,
                                        String toStatus,
                                        String fieldChanges,
                                        String reason) {
        writeExecutionAuditLogByBizId(execution.getId(), actionType, fromStatus, toStatus, fieldChanges, reason);
    }

    private void writeExecutionAuditLogByBizId(Long executionId,
                                               String actionType,
                                               String fromStatus,
                                               String toStatus,
                                               String fieldChanges,
                                               String reason) {
        BizAuditLogDO auditLog = new BizAuditLogDO();
        auditLog.setBizType(ProjectExecutionConstants.BIZ_TYPE);
        auditLog.setBizId(executionId);
        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 writeExecutionStatusLog(ProjectExecutionDO execution, String actionType, String fromStatus,
                                         String toStatus, String reason) {
        ProjectExecutionStatusLogDO statusLog = new ProjectExecutionStatusLogDO();
        statusLog.setExecutionId(execution.getId());
        statusLog.setActionType(actionType);
        statusLog.setFromStatus(fromStatus);
        statusLog.setToStatus(toStatus);
        statusLog.setReason(defaultText(reason));
        statusLog.setOperatorUserId(SecurityFrameworkUtils.getLoginUserId());
        statusLog.setOperatorName(defaultText(SecurityFrameworkUtils.getLoginUserNickname()));
        statusLog.setExecutionNameSnapshot(execution.getExecutionName());
        projectExecutionStatusLogMapper.insert(statusLog);
    }

    private ProjectExecutionDO cloneExecution(ProjectExecutionDO source) {
        ProjectExecutionDO target = new ProjectExecutionDO();
        target.setId(source.getId());
        target.setProjectId(source.getProjectId());
        target.setProjectRequirementId(source.getProjectRequirementId());
        target.setExecutionName(source.getExecutionName());
        target.setExecutionType(source.getExecutionType());
        target.setOwnerId(source.getOwnerId());
        target.setStatusCode(source.getStatusCode());
        target.setPlannedStartDate(source.getPlannedStartDate());
        target.setPlannedEndDate(source.getPlannedEndDate());
        target.setActualStartDate(source.getActualStartDate());
        target.setActualEndDate(source.getActualEndDate());
        target.setProgressRate(source.getProgressRate());
        target.setExecutionDesc(source.getExecutionDesc());
        target.setLastStatusReason(source.getLastStatusReason());
        return target;
    }

    private String buildExecutionFieldChanges(ProjectExecutionDO before, ProjectExecutionDO after) {
        Map<String, Object> fieldChanges = new LinkedHashMap<>();
        appendFieldChange(fieldChanges, "executionName", valueOf(before, ProjectExecutionDO::getExecutionName),
                valueOf(after, ProjectExecutionDO::getExecutionName));
        appendFieldChange(fieldChanges, "executionType", valueOf(before, ProjectExecutionDO::getExecutionType),
                valueOf(after, ProjectExecutionDO::getExecutionType));
        appendFieldChange(fieldChanges, "ownerId", valueOf(before, ProjectExecutionDO::getOwnerId),
                valueOf(after, ProjectExecutionDO::getOwnerId));
        appendFieldChange(fieldChanges, "statusCode", valueOf(before, ProjectExecutionDO::getStatusCode),
                valueOf(after, ProjectExecutionDO::getStatusCode));
        appendFieldChange(fieldChanges, "priority",
                valueOf(before, ProjectExecutionDO::getPriority),
                valueOf(after, ProjectExecutionDO::getPriority));
        appendFieldChange(fieldChanges, "plannedStartDate", valueOf(before, ProjectExecutionDO::getPlannedStartDate),
                valueOf(after, ProjectExecutionDO::getPlannedStartDate));
        appendFieldChange(fieldChanges, "plannedEndDate", valueOf(before, ProjectExecutionDO::getPlannedEndDate),
                valueOf(after, ProjectExecutionDO::getPlannedEndDate));
        appendFieldChange(fieldChanges, "actualStartDate", valueOf(before, ProjectExecutionDO::getActualStartDate),
                valueOf(after, ProjectExecutionDO::getActualStartDate));
        appendFieldChange(fieldChanges, "actualEndDate", valueOf(before, ProjectExecutionDO::getActualEndDate),
                valueOf(after, ProjectExecutionDO::getActualEndDate));
        appendFieldChange(fieldChanges, "progressRate", valueOf(before, ProjectExecutionDO::getProgressRate),
                valueOf(after, ProjectExecutionDO::getProgressRate));
        appendFieldChange(fieldChanges, "executionDesc", valueOf(before, ProjectExecutionDO::getExecutionDesc),
                valueOf(after, ProjectExecutionDO::getExecutionDesc));
        appendFieldChange(fieldChanges, "lastStatusReason", valueOf(before, ProjectExecutionDO::getLastStatusReason),
                valueOf(after, ProjectExecutionDO::getLastStatusReason));
        return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
    }

    private String buildExecutionAssigneeFieldChanges(ExecutionAssigneeDO before, ExecutionAssigneeDO after) {
        Map<String, Object> fieldChanges = new LinkedHashMap<>();
        appendFieldChange(fieldChanges, "executionId", valueOf(before, ExecutionAssigneeDO::getExecutionId),
                valueOf(after, ExecutionAssigneeDO::getExecutionId));
        appendFieldChange(fieldChanges, "userId", valueOf(before, ExecutionAssigneeDO::getUserId),
                valueOf(after, ExecutionAssigneeDO::getUserId));
        appendFieldChange(fieldChanges, "joinedAt", valueOf(before, ExecutionAssigneeDO::getJoinedAt),
                valueOf(after, ExecutionAssigneeDO::getJoinedAt));
        appendFieldChange(fieldChanges, "removedAt", valueOf(before, ExecutionAssigneeDO::getRemovedAt),
                valueOf(after, ExecutionAssigneeDO::getRemovedAt));
        appendFieldChange(fieldChanges, "removedReason", valueOf(before, ExecutionAssigneeDO::getRemovedReason),
                valueOf(after, ExecutionAssigneeDO::getRemovedReason));
        return fieldChanges.isEmpty() ? null : JsonUtils.toJsonString(fieldChanges);
    }

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

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

    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 String normalizeRequiredName(String value) {
        if (!StringUtils.hasText(value)) {
            throw invalidParamException("执行名称不能为空");
        }
        return value.trim();
    }

    private String normalizeRequiredExecutionType(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 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(ProjectExecutionRespVO respVO, boolean rootTasksAllCompleted) {
        // 传入 ownerId / progressRate / rootTasksAllCompleted 用于 availableActions 的 owner-only、完成进度、
        // 根任务完成态过滤。rootTasksAllCompleted=false 时不下发 complete，避免任务仍进行中时执行被闭环。
        ProjectExecutionStatusViewService.ProjectExecutionLifecycleView lifecycle =
                projectExecutionStatusViewService.getLifecycle(respVO.getStatusCode(), respVO.getOwnerId(),
                        respVO.getProgressRate(), rootTasksAllCompleted);
        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 BigDecimal loadExecutionProgress(Long projectId, Long executionId) {
        List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
        return normalizeProgress(projectTaskMapper.selectRootTaskAvgProgressByExecutionId(projectId, executionId,
                excludedStatusCodes));
    }

    private void fillExecutionProgress(Long projectId, List<ProjectExecutionRespVO> list) {
        Set<Long> executionIds = list.stream()
                .map(ProjectExecutionRespVO::getId)
                .filter(Objects::nonNull)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
        Map<Long, BigDecimal> progressMap = loadExecutionProgressMap(projectId, executionIds, excludedStatusCodes);
        list.forEach(vo -> vo.setProgressRate(progressMap.getOrDefault(vo.getId(), normalizeProgress(null))));
    }

    /**
     * 批量回填执行 RespVO 上的项目需求名称 / 状态编码（TD-013）。
     * 一次性 selectBatchIds 拉取所有出现过的 projectRequirementId，避免列表场景 N+1。
     */
    private void fillProjectRequirementInfo(Collection<ProjectExecutionRespVO> list) {
        if (list == null || list.isEmpty()) {
            return;
        }
        Set<Long> requirementIds = list.stream()
                .map(ProjectExecutionRespVO::getProjectRequirementId)
                .filter(Objects::nonNull)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        if (requirementIds.isEmpty()) {
            return;
        }
        Map<Long, ProjectRequirementDO> map = projectRequirementMapper.selectBatchIds(requirementIds)
                .stream()
                .collect(Collectors.toMap(ProjectRequirementDO::getId, Function.identity()));
        list.forEach(vo -> {
            ProjectRequirementDO requirement = map.get(vo.getProjectRequirementId());
            if (requirement != null) {
                vo.setProjectRequirementName(requirement.getTitle());
                vo.setProjectRequirementStatusCode(requirement.getStatusCode());
            }
        });
    }

    /**
     * 列表口径批量聚合：一次 GROUP BY 取本页所有执行的一级任务平均进度。
     * 未命中的 executionId（执行下无一级任务）不入 map，由调用方 normalizeProgress 兜底为 0.00。
     */
    private Map<Long, BigDecimal> loadExecutionProgressMap(Long projectId, Collection<Long> executionIds,
                                                           Collection<String> excludedStatusCodes) {
        if (executionIds == null || executionIds.isEmpty()) {
            return Collections.emptyMap();
        }
        List<Map<String, Object>> rows = projectTaskMapper
                .selectRootTaskAvgProgressGroupByExecutionIds(projectId, executionIds, excludedStatusCodes);
        if (rows == null || rows.isEmpty()) {
            return Collections.emptyMap();
        }
        Map<Long, BigDecimal> result = new HashMap<>(rows.size());
        for (Map<String, Object> row : rows) {
            if (row == null) {
                continue;
            }
            Long executionId = toLong(row.getOrDefault("executionId", row.get("execution_id")));
            BigDecimal progress = toBigDecimal(row.getOrDefault("progressRate", row.get("progress_rate")));
            if (executionId != null) {
                result.put(executionId, normalizeProgress(progress));
            }
        }
        return result;
    }

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

    /** dueRange 终态排除用：执行对象域的终态码（动态查，不硬编码）。 */
    private List<String> loadExecutionTerminalStatusCodes() {
        List<String> statusCodes = objectStatusModelMapper
                .selectTerminalStatusCodesByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
        return statusCodes == null ? Collections.emptyList() : statusCodes;
    }

    /**
     * 执行详情完成态：判断"参与聚合的根任务"是否全部为 completed。
     * 筛选口径与 loadExecutionProgress 同源；空集（无参与聚合的根任务）返回 false，禁止下发 complete。
     */
    private boolean loadExecutionRootTasksAllCompleted(Long projectId, Long executionId) {
        List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
        Map<String, Object> row = projectTaskMapper.selectRootTaskCompletionStateByExecutionId(projectId, executionId,
                ProjectTaskConstants.STATUS_COMPLETED, excludedStatusCodes);
        return isRootTasksAllCompleted(row);
    }

    /**
     * 列表口径批量聚合：一次 GROUP BY 取本页所有执行的根任务完成态。
     * 未命中的 executionId（执行下无参与聚合的根任务）不入 map，由调用方按缺省 false 处理（不下发 complete）。
     */
    private Map<Long, Boolean> loadExecutionRootTasksAllCompletedMap(Long projectId,
                                                                    List<ProjectExecutionRespVO> list) {
        Set<Long> executionIds = list.stream()
                .map(ProjectExecutionRespVO::getId)
                .filter(Objects::nonNull)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        if (executionIds.isEmpty()) {
            return Collections.emptyMap();
        }
        List<String> excludedStatusCodes = loadProgressExcludedTaskStatusCodes();
        List<Map<String, Object>> rows = projectTaskMapper.selectRootTaskCompletionStateGroupByExecutionIds(
                projectId, executionIds, ProjectTaskConstants.STATUS_COMPLETED, excludedStatusCodes);
        if (rows == null || rows.isEmpty()) {
            return Collections.emptyMap();
        }
        Map<Long, Boolean> result = new HashMap<>(rows.size());
        for (Map<String, Object> row : rows) {
            if (row == null) {
                continue;
            }
            Long executionId = toLong(row.getOrDefault("executionId", row.get("execution_id")));
            if (executionId == null) {
                continue;
            }
            result.put(executionId, isRootTasksAllCompleted(row));
        }
        return result;
    }

    private boolean isRootTasksAllCompleted(Map<String, Object> row) {
        if (row == null) {
            return false;
        }
        Long totals = toLong(row.get("totals"));
        Long completedCount = toLong(row.getOrDefault("completedCount", row.get("completed_count")));
        return totals != null && totals > 0 && completedCount != null && totals.equals(completedCount);
    }

    private Long toLong(Object value) {
        if (value instanceof Number number) {
            return number.longValue();
        }
        if (value instanceof String text && StringUtils.hasText(text)) {
            return Long.valueOf(text.trim());
        }
        return null;
    }

    private BigDecimal toBigDecimal(Object value) {
        if (value instanceof BigDecimal decimal) {
            return decimal;
        }
        if (value instanceof Number number) {
            return new BigDecimal(number.toString());
        }
        if (value instanceof String text && StringUtils.hasText(text)) {
            return new BigDecimal(text.trim());
        }
        return null;
    }

    private BigDecimal normalizeProgress(BigDecimal progress) {
        if (progress == null) {
            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
        }
        return progress.setScale(2, RoundingMode.HALF_UP);
    }

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

    /**
     * 通用语义位驱动的实际日期回填：
     * - 首次离开初始态（fromStatus.initialFlag=true）且 actualStartDate 未填时，写今天
     * - 进入终态（toStatus.terminalFlag=true）且 actualEndDate 未填时，写今天
     * 与任务侧 maybeFillActualDates 同款风格，消除原 applyExecutionActualDateByStatusAction 的硬编码 actionCode 判断。
     */
    private void maybeFillActualDates(ProjectExecutionDO execution, String fromStatus, String toStatus) {
        LocalDate today = LocalDate.now();
        boolean changed = false;
        if (execution.getActualStartDate() == null) {
            ObjectStatusModelDO fromModel = objectStatusModelMapper
                    .selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, fromStatus);
            if (fromModel != null && Boolean.TRUE.equals(fromModel.getInitialFlag())) {
                execution.setActualStartDate(today);
                changed = true;
            }
        }
        if (execution.getActualEndDate() == null) {
            ObjectStatusModelDO toModel = objectStatusModelMapper
                    .selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, toStatus);
            if (toModel != null && Boolean.TRUE.equals(toModel.getTerminalFlag())) {
                execution.setActualEndDate(today);
                changed = true;
            }
        }
        if (changed) {
            projectExecutionMapper.updateById(execution);
        }
    }

    /**
     * 完成动作兜底：把执行进度刷到 100%。
     * 正常流程下前端会确保点完成时进度已经是 100%；此处为防止前端绕过 / bug 导致脏数据。
     */
    private void forceCompleteProgress(ProjectExecutionDO execution) {
        BigDecimal full = BigDecimal.valueOf(100);
        BigDecimal current = execution.getProgressRate() == null ? BigDecimal.ZERO : execution.getProgressRate();
        if (full.compareTo(current) == 0) {
            return;
        }
        execution.setProgressRate(full);
        projectExecutionMapper.updateById(execution);
    }

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

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

    /**
     * 完成执行前置校验：执行下所有任务必须已经进入终态（completed / cancelled）。
     */
    private void validateAllTasksTerminal(Long executionId) {
        List<String> terminalStatusCodes = objectStatusModelMapper
                .selectTerminalStatusCodesByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
        Integer openTaskCount = projectTaskMapper.countByExecutionIdNotInStatus(executionId, terminalStatusCodes);
        if (openTaskCount != null && openTaskCount > 0) {
            throw exception(ErrorCodeConstants.PROJECT_EXECUTION_COMPLETE_TASKS_REQUIRED);
        }
    }

    /**
     * 级联触发：根据 actionCode 调任务侧 internal 入口批量处理执行下任务。
     */
    private void cascadeTasksIfNeeded(Long executionId, String actionCode, String executionReason) {
        switch (actionCode) {
            case "cancel" -> projectTaskService.cascadeCancelTasksByExecutionId(executionId, executionReason);
            case "pause" -> projectTaskService.cascadePauseTasksByExecutionId(executionId, executionReason);
            case "resume" -> projectTaskService.cascadeResumeTasksByExecutionId(executionId, executionReason);
            default -> {
                // 其他动作不级联
            }
        }
    }

    /**
     * 由任务 auto_start 事件驱动的执行自动开始。
     * 仅在执行处于初始态时触发；失败均 WARN 静默 return，不抛错（保证不阻断任务事件主路径）。
     * 成功后联动项目 auto_start（同事务，若项目联动抛错则整体回滚 —— 与任务侧 createTask 调用 autoStartProjectIfPending 同款）。
     */
    private void internalAutoStartExecutionByTask(Long executionId) {
        if (executionId == null) {
            return;
        }
        ProjectExecutionDO current = projectExecutionMapper.selectById(executionId);
        if (current == null) {
            return;
        }
        String fromStatus = current.getStatusCode();
        ObjectStatusModelDO fromModel = objectStatusModelMapper
                .selectByObjectTypeAndStatusCodeEnabled(ProjectExecutionConstants.OBJECT_TYPE, fromStatus);
        if (fromModel == null || !Boolean.TRUE.equals(fromModel.getInitialFlag())) {
            return;
        }
        String actionCode = "auto_start";
        ObjectStatusTransitionDO transition = objectStatusTransitionMapper
                .selectByObjectTypeAndFromStatusAndAction(ProjectExecutionConstants.OBJECT_TYPE, fromStatus, actionCode);
        if (transition == null) {
            log.warn("execution auto-start skipped: transition not found. executionId={}, fromStatus={}",
                    executionId, fromStatus);
            return;
        }
        String toStatus = transition.getToStatusCode();
        String reason = "由任务推进自动触发";
        int updateCount = projectExecutionMapper.updateStatusByIdAndStatus(executionId, fromStatus, toStatus, reason);
        if (updateCount != 1) {
            log.warn("execution auto-start skipped: concurrent modification. executionId={}", executionId);
            return;
        }
        current.setStatusCode(toStatus);
        current.setLastStatusReason(reason);

        writeExecutionStatusLog(current, actionCode, fromStatus, toStatus, reason);
        writeExecutionAuditLog(current, actionCode, fromStatus, toStatus, null, reason);
        maybeFillActualDates(current, fromStatus, toStatus);

        // 联动项目 auto_start（项目仍在 pending 时跟着进 active）
        projectService.autoStartProjectIfPending(current.getProjectId(),
                ObjectActivityConstants.PROJECT_TRIGGER_EXECUTION_AUTO_START);
    }

    @Override
    public void onTaskStatusChanged(Long executionId, Long taskId, String fromStatus, String toStatus,
                                    String actionCode, String reason) {
        log.info("[onTaskStatusChanged] executionId={}, taskId={}, action={}, status: {} -> {}, reason={}",
                executionId, taskId, actionCode, fromStatus, toStatus, reason);
        if (executionId == null) {
            return;
        }
        // 任务 auto_start：执行如果还在 pending，自动跟着进入进行中（事件驱动，§8.1）
        if ("auto_start".equals(actionCode)) {
            try {
                internalAutoStartExecutionByTask(executionId);
            } catch (Exception e) {
                // 副作用失败不阻断任务事件主路径
                log.warn("execution auto-start failed during onTaskStatusChanged. executionId={}, error={}",
                        executionId, e.getMessage());
            }
        }
        // complete / cancel：当前仅 INFO 日志，不立即响应（§8.1 拉模式：项目主动点完成时统一扫表校验）
    }

}
