package jnpf.attendance.schedule;

import jnpf.model.attendance.vo.WorkstationGroupUserVo;
import jnpf.model.attendance.vo.WorkstationWithUsersVo;
import jnpf.model.attendance.vo.scheduling.FixedSchedulingRuleVo;
import jnpf.model.attendance.vo.scheduling.ScheduleGroupRuleConfigVo;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
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.Random;
import java.util.Set;
import java.util.function.Function;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * 智能排班流水线<strong>第九、十步</strong>：在已有 {@link ShiftPlanBlock}（班次时段 + 各岗位需求人数）基础上，
 * 为每个岗位需求选出具体员工，并产出「按班次」「按员工」两种视图的 {@link ShiftPlanAssignmentResult}。
 * <p>
 * 与 {@link AutoScheduleService#start} 衔接方式：上游完成第六～八步班次计划后，将
 * {@code shiftPlanByScheduleDay}、工作站人员、{@link ScheduleGroupRuleConfigVo}、{@link StaffRuleEvaluationPort}、
 * {@link SchedulePeriodWorkTracker} 传入 {@link #assign}。
 * <p>
 * <b>核心策略（按处理顺序）</b>
 * <ol>
 *   <li>按排班日升序、同日内按班次开始时间升序遍历班次块；</li>
 *   <li>每块内先 {@link CrossPostGeneralStaffAllocator} 统筹多岗缺口（马六类跨岗），再按岗位落位；</li>
 *   <li>同一块内一人只排一个岗位（{@code usedInBlock}）；</li>
 *   <li>候选人：专岗优先、通岗次之（{@link #orderedCandidates}），余量选人前打乱（五-5）；</li>
 *   <li>第九步：{@link StaffRuleEvaluationPort#passesOperationalHardConstraints}（请假/借调等，当前实现多为占位）；</li>
 *   <li>第十步：固定班应用 {@link FixedSchedulingRuleVo}（必须项不可放松、尽量项按序放松）；
 *       划线班不传固定规则（{@link ScheduleStaffAssignmentSteps910} 同约定）；</li>
 *   <li>同日互斥：{@link StaffAssignmentDayLedger} 禁止同人同日既固定又划线；</li>
 *   <li>{@link StaffAssignmentIntervalLedger} 禁止同人同日<strong>时段重叠</strong>（划线多段、同 logical key 多段均受约束）；</li>
 *   <li>选中后 {@link SchedulePeriodWorkTracker#recordAssignment} 累计工时，供后续班次规则判断。</li>
 * </ol>
 */
public final class ShiftPlanFinalStaffAssigner {

    private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ISO_LOCAL_DATE;

    private static final Logger log = LoggerFactory.getLogger(ShiftPlanFinalStaffAssigner.class);

    private ShiftPlanFinalStaffAssigner() {}

    /**
     * 为排班区间内的全部班次块分配人员，并组装双视图结果。
     *
     * @param shiftPlanByScheduleDay     排班日 → 当日 {@link ShiftPlanBlock} 列表（来自
     *                                   {@link HalfHourDemandBasedShiftPlanBuilder} + 考勤班次 id 匹配）
     * @param workstationWithUsersVoList 工作站及绑定员工（专岗/通岗、在组日、是否可划线）
     * @param scheduleGroupRuleConfigVo  考勤组规则；仅使用其中 {@link ScheduleGroupRuleConfigVo#getFixedScheduling()}，
     *                                   划线规则在第六～八步已体现在班次块形态上
     * @param staffRuleEvaluationPort    第九～十步规则判定（固定规则 + 员工工作状态 VO 等）
     * @param workTracker                排班期内每人已排工时/班段，选人时构造 {@link StaffAssignmentContext}，
     *                                   选中后回写本段 assignment
     * @param groupId                    考勤组 ID，写入 {@link StaffAssignmentContext}
     * @param random                     打乱/均衡用随机源；{@code null} 时内部新建
     * @return 按班次、按员工两种视图的最终排班结果
     */
    public static ShiftPlanAssignmentResult assign(
            Map<LocalDate, List<ShiftPlanBlock>> shiftPlanByScheduleDay,
            List<WorkstationWithUsersVo> workstationWithUsersVoList,
            ScheduleGroupRuleConfigVo scheduleGroupRuleConfigVo,
            StaffRuleEvaluationPort staffRuleEvaluationPort,
            SchedulePeriodWorkTracker workTracker,
            String groupId,
            Random random) {
        Objects.requireNonNull(workTracker, "workTracker");
        Objects.requireNonNull(staffRuleEvaluationPort, "staffRuleEvaluationPort");

        // ---------- 步骤 0：入参规范化与索引结构 ----------
        Map<LocalDate, List<ShiftPlanBlock>> plan =
                shiftPlanByScheduleDay == null ? Collections.emptyMap() : shiftPlanByScheduleDay;
        // 按岗位汇总专岗/通岗员工 ID（positionId 与 ShiftPlanPostNeed.postId 对齐）
        WorkstationPostStaffBuckets buckets =
                WorkstationPostStaffBuckets.fromWorkstations(workstationWithUsersVoList);
        // userId → 员工元数据（姓名、在组日、可划线等），用于候选过滤与结果展示
        Map<String, WorkstationGroupUserVo> userMeta = indexUserMeta(workstationWithUsersVoList);
        Map<String, String> positionNameByPostId = indexPositionNameByPostId(workstationWithUsersVoList);
        // 固定排班规则 VO；划线块选人时不使用（见 pickForPostDemandWithPerEmployeeContext）
        FixedSchedulingRuleVo fixedRules =
                scheduleGroupRuleConfigVo == null
                        ? null
                        : scheduleGroupRuleConfigVo.getFixedScheduling();
        // 同日「固定 vs 划线」互斥账本，跨班次块共享
        StaffAssignmentDayLedger dayLedger = new StaffAssignmentDayLedger();
        // 同人同日已排班时段，防重叠（划线多段、多班块）
        StaffAssignmentIntervalLedger intervalLedger = new StaffAssignmentIntervalLedger();
        Random rnd = random == null ? new Random() : random;
        String gid = groupId == null ? "" : groupId;

        List<ShiftPlanAssignmentResult.IncompleteScheduleReason> incompleteNotes = new ArrayList<>();

        // 按班次维度累积视图；按员工维度用 shiftsByEmployee 暂存后一次性转 AssignedEmployeeView
        List<ShiftPlanAssignmentResult.AssignedShiftBlockView> byShift = new ArrayList<>();
        Map<String, List<ShiftPlanAssignmentResult.AssignedShiftRef>> shiftsByEmployee =
                new LinkedHashMap<>();

        // ---------- 步骤 1：按排班日升序遍历（忽略 null 排班日 key，避免 NPE） ----------
        List<LocalDate> days = new ArrayList<>();
        for (LocalDate d : plan.keySet()) {
            if (d != null) {
                days.add(d);
            }
        }
        if (days.size() < plan.size()) {
            log.error(
                    "{} phase=ASSIGN | ignored {} null LocalDate key(s) in shiftPlanByScheduleDay (total keys={})",
                    AutoSchedulePipelineLog.MARKER,
                    plan.size() - days.size(),
                    plan.size());
            List<ShiftPlanBlock> orphan = plan.get(null);
            if (orphan != null && !orphan.isEmpty()) {
                log.error(
                        "{} phase=ASSIGN | dropping {} ShiftPlanBlock(s) under null schedule day key — assign by calendar day only",
                        AutoSchedulePipelineLog.MARKER,
                        orphan.size());
            }
        }
        days.sort(Comparator.naturalOrder());
        for (LocalDate day : days) {
            List<ShiftPlanBlock> blocks = plan.get(day);
            if (blocks == null || blocks.isEmpty()) {
                continue;
            }
            // ---------- 步骤 2：同日内按班次开始时间排序（先早后晚，利于休息间隔/日工时累计） ----------
            List<ShiftPlanBlock> ordered = new ArrayList<>(blocks);
            ordered.sort(
                    Comparator.comparingInt(ShiftPlanBlock::getStartMinuteInclusive)
                            .thenComparingInt(ShiftPlanBlock::getEndMinuteExclusive));

            // ---------- 步骤 3：逐班次块处理 ----------
            for (ShiftPlanBlock block : ordered) {
                if (block == null) {
                    log.error(
                            "{} phase=ASSIGN | skip null ShiftPlanBlock under scheduleDay={}",
                            AutoSchedulePipelineLog.MARKER,
                            day);
                    continue;
                }
                if (!ShiftPlanBlock.isValidTimeWindow(
                        block.getStartMinuteInclusive(), block.getEndMinuteExclusive())) {
                    log.error(
                            "{} phase=ASSIGN | skip ShiftPlanBlock with invalid window: day={} startMin={} endMin={} fixed={}",
                            AutoSchedulePipelineLog.MARKER,
                            day,
                            block.getStartMinuteInclusive(),
                            block.getEndMinuteExclusive(),
                            block.isFixedScheduling());
                    continue;
                }
                List<ShiftPlanPostNeed> blockNeeds = mergePostNeedsByPostId(block.getPostNeeds());
                List<ShiftPlanAssignmentResult.AssignedPostStaffView> postViews = new ArrayList<>();
                Set<String> usedInBlock = new HashSet<>();

                double shiftHours =
                        Math.max(
                                0d,
                                (block.getEndMinuteExclusive() - block.getStartMinuteInclusive())
                                        / 60.0);
                int blockStartInclusive = block.getStartMinuteInclusive();
                int blockEndExclusive = block.getEndMinuteExclusive();
                boolean lineAssignment = !block.isFixedScheduling();
                String fixedKey = fixedShiftGroupKey(block);
                FixedSchedulingRuleVo fixedRulesForBlock =
                        lineAssignment ? null : fixedRules;
                String dayStr = day.format(DAY_FMT);

                // ---------- 步骤 4a：块级多岗统筹（跨岗通岗优先补缺口大岗位） ----------
                Map<String, Integer> demandByPost = new LinkedHashMap<>();
                Map<String, Set<String>> eligSpecByPost = new LinkedHashMap<>();
                Map<String, Set<String>> eligGenByPost = new LinkedHashMap<>();
                for (ShiftPlanPostNeed need : blockNeeds) {
                    if (need.getNeedCount() <= 0) {
                        continue;
                    }
                    String postId = need.getPostId();
                    demandByPost.put(postId, need.getNeedCount());
                    eligSpecByPost.put(
                            postId,
                            filterEligibleIds(
                                    buckets.getSpecialistIds(postId),
                                    userMeta,
                                    dayStr,
                                    block.isFixedScheduling()));
                    eligGenByPost.put(
                            postId,
                            filterEligibleIds(
                                    buckets.getGeneralIds(postId),
                                    userMeta,
                                    dayStr,
                                    block.isFixedScheduling()));
                }
                SchedulingForTestCheckLog.info(
                        log,
                        SchedulingForTestCheckLog.TEST_ISSUE_3_STAFF_ASSIGNMENT,
                        "staff_assign_block_inputs",
                        String.format(
                                "algo=ShiftPlanFinalStaffAssigner | step=BLOCK_OPEN | groupId=%s | scheduleDay=%s "
                                        + "| attendanceShiftId=%s | minuteWindow=[%d,%d) | timeRangeText=%s | fixedScheduling=%s "
                                        + "| mergedDemandByPost=%s | eligibleSpecialistCountByPost=%s "
                                        + "| eligibleGeneralCountByPost=%s",
                                gid,
                                day,
                                block.getShiftId(),
                                blockStartInclusive,
                                blockEndExclusive,
                                block.getTimeRangeText(),
                                Boolean.toString(!lineAssignment),
                                demandByPost,
                                stringifyPoolSizes(eligSpecByPost),
                                stringifyPoolSizes(eligGenByPost)));

                Map<String, List<String>> crossAlloc =
                        demandByPost.isEmpty()
                                ? Collections.emptyMap()
                                : CrossPostGeneralStaffAllocator.allocateForOneTimeWindow(
                                        demandByPost, eligSpecByPost, eligGenByPost, rnd);
                SchedulingForTestCheckLog.info(
                        log,
                        SchedulingForTestCheckLog.TEST_ISSUE_3_STAFF_ASSIGNMENT,
                        "staff_assign_cross_post_allocation",
                        String.format(
                                "algo=ShiftPlanFinalStaffAssigner | step=CROSS_POST_RESULT | groupId=%s | scheduleDay=%s "
                                        + "| attendanceShiftId=%s | minuteWindow=[%d,%d) | allocatedByPost=%s",
                                gid,
                                day,
                                block.getShiftId(),
                                blockStartInclusive,
                                blockEndExclusive,
                                crossAlloc));

                // ---------- 步骤 4b：按岗位落位（先跨岗建议，再规则选人；块内去重） ----------
                for (ShiftPlanPostNeed need : blockNeeds) {
                    if (need.getNeedCount() <= 0) {
                        continue;
                    }
                    String postId = need.getPostId();
                    Function<String, StaffAssignmentContext> contextFor =
                            uid ->
                                    buildContext(
                                            workTracker, gid, day, postId, block, shiftHours, uid);

                    List<String> picked = new ArrayList<>();
                    List<String> preferred =
                            crossAlloc.getOrDefault(postId, Collections.emptyList());
                    for (String uid : preferred) {
                        if (picked.size() >= need.getNeedCount()) {
                            break;
                        }
                        if (usedInBlock.contains(uid)) {
                            continue;
                        }
                        List<String> one =
                                pickForPostDemandWithPerEmployeeContext(
                                        1,
                                        Collections.singletonList(uid),
                                        buckets,
                                        postId,
                                        staffRuleEvaluationPort,
                                        contextFor,
                                        fixedRulesForBlock,
                                        dayLedger,
                                        lineAssignment,
                                        fixedKey,
                                        usedInBlock,
                                        rnd,
                                        intervalLedger,
                                        blockStartInclusive,
                                        blockEndExclusive);
                        if (!one.isEmpty()) {
                            picked.add(one.get(0));
                            usedInBlock.add(one.get(0));
                        }
                    }

                    int remaining = need.getNeedCount() - picked.size();
                    if (remaining > 0) {
                        List<String> candidates =
                                orderedCandidates(
                                        postId,
                                        buckets,
                                        userMeta,
                                        day,
                                        block.isFixedScheduling());
                        List<String> more =
                                pickForPostDemandWithPerEmployeeContext(
                                        remaining,
                                        candidates,
                                        buckets,
                                        postId,
                                        staffRuleEvaluationPort,
                                        contextFor,
                                        fixedRulesForBlock,
                                        dayLedger,
                                        lineAssignment,
                                        fixedKey,
                                        usedInBlock,
                                        rnd,
                                        intervalLedger,
                                        blockStartInclusive,
                                        blockEndExclusive);
                        picked.addAll(more);
                        usedInBlock.addAll(more);
                    }

                    if (picked.size() < need.getNeedCount()) {
                        incompleteNotes.add(
                                explainStaffShortageGap(
                                        day,
                                        block,
                                        postId,
                                        need.getNeedCount(),
                                        picked.size(),
                                        buckets,
                                        userMeta,
                                        staffRuleEvaluationPort,
                                        fixedRulesForBlock,
                                        lineAssignment,
                                        contextFor));
                    }

                    List<ShiftPlanAssignmentResult.AssignedStaffRef> staffRefs = new ArrayList<>();
                    for (String uid : picked) {
                        workTracker.recordAssignment(
                                uid,
                                day,
                                block.getStartMinuteInclusive(),
                                block.getEndMinuteExclusive());
                        WorkstationGroupUserVo meta = userMeta.get(uid);
                        staffRefs.add(
                                new ShiftPlanAssignmentResult.AssignedStaffRef(
                                        uid,
                                        meta == null ? "" : nullToEmpty(meta.getUserName()),
                                        meta != null && Boolean.TRUE.equals(meta.getIsExtra())));
                        shiftsByEmployee
                                .computeIfAbsent(uid, k -> new ArrayList<>())
                                .add(
                                        new ShiftPlanAssignmentResult.AssignedShiftRef(
                                                day,
                                                block.getShiftId(),
                                                block.getTimeRangeText(),
                                                block.isFixedScheduling(),
                                                postId));
                    }
                    postViews.add(
                            new ShiftPlanAssignmentResult.AssignedPostStaffView(
                                    postId,
                                    positionNameForPost(positionNameByPostId, postId),
                                    need.getNeedCount(),
                                    staffRefs));
                }

                byShift.add(
                        new ShiftPlanAssignmentResult.AssignedShiftBlockView(
                                day,
                                block.getShiftId(),
                                block.getTimeRangeText(),
                                block.isFixedScheduling(),
                                postViews));
            }
        }

        // ---------- 步骤 5：由 shiftsByEmployee 构建按员工视图并排序 ----------
        List<ShiftPlanAssignmentResult.AssignedEmployeeView> byEmployee = new ArrayList<>();
        for (Map.Entry<String, List<ShiftPlanAssignmentResult.AssignedShiftRef>> e :
                shiftsByEmployee.entrySet()) {
            WorkstationGroupUserVo meta = userMeta.get(e.getKey());
            byEmployee.add(
                    new ShiftPlanAssignmentResult.AssignedEmployeeView(
                            e.getKey(),
                            meta == null ? "" : nullToEmpty(meta.getUserName()),
                            e.getValue()));
        }
        byEmployee.sort(Comparator.comparing(ShiftPlanAssignmentResult.AssignedEmployeeView::getUserId));
        return new ShiftPlanAssignmentResult(byShift, byEmployee, incompleteNotes);
    }

    /**
     * 当某班段某岗「需求 &gt; 已选」时归类原因，供 {@link ShiftPlanAssignmentResult#getIncompleteScheduleReasons()} 展示。
     */
    private static ShiftPlanAssignmentResult.IncompleteScheduleReason explainStaffShortageGap(
            LocalDate day,
            ShiftPlanBlock block,
            String postId,
            int needCount,
            int assignedCount,
            WorkstationPostStaffBuckets buckets,
            Map<String, WorkstationGroupUserVo> userMeta,
            StaffRuleEvaluationPort staffRuleEvaluationPort,
            FixedSchedulingRuleVo fixedRulesForBlock,
            boolean lineAssignment,
            Function<String, StaffAssignmentContext> contextFor) {
        int deficit = Math.max(0, needCount - assignedCount);
        String timeRange = block.getTimeRangeText() == null ? "" : block.getTimeRangeText();
        List<String> allOrdered =
                orderedCandidates(postId, buckets, userMeta, day, block.isFixedScheduling());
        List<String> pool = intersectWorkstationPool(postId, allOrdered, buckets);
        String pidDisplay = postId == null ? "" : postId.trim();
        if (allOrdered.isEmpty()) {
            return new ShiftPlanAssignmentResult.IncompleteScheduleReason(
                    day,
                    timeRange,
                    pidDisplay,
                    ShiftPlanAssignmentResult.INCOMPLETE_NO_CANDIDATE,
                    String.format("还差%d人：在组日与班种过滤后无专岗/通岗候选人", deficit));
        }
        if (pool.isEmpty()) {
            return new ShiftPlanAssignmentResult.IncompleteScheduleReason(
                    day,
                    timeRange,
                    pidDisplay,
                    ShiftPlanAssignmentResult.INCOMPLETE_WORKSTATION_POOL_EMPTY,
                    String.format("还差%d人：候选人不落在工作站该岗位绑定名单内", deficit));
        }
        if (!lineAssignment
                && fixedRulesForBlock != null
                && assignedCount == 0
                && !anyPassesMustOnly(
                        pool, fixedRulesForBlock, staffRuleEvaluationPort, contextFor)) {
            return new ShiftPlanAssignmentResult.IncompleteScheduleReason(
                    day,
                    timeRange,
                    pidDisplay,
                    ShiftPlanAssignmentResult.INCOMPLETE_FIXED_MUST_BLOCK,
                    String.format(
                            "还差%d人：固定班「必须」规则下没有人能排该班段的该岗位",
                            deficit));
        }
        return new ShiftPlanAssignmentResult.IncompleteScheduleReason(
                day,
                timeRange,
                pidDisplay,
                ShiftPlanAssignmentResult.INCOMPLETE_RULES_OR_CONFLICT,
                String.format(
                        "还差%d人：硬约束/固定尽量项、同人占用或时段重叠等，仅能排到%d/%d",
                        deficit, assignedCount, needCount));
    }

    /**
     * 为单个岗位需求选出最多 {@code targetCount} 名员工。
     * <p>
     * 与 {@link ScheduleStaffAssignmentSteps910#pickForPostDemand} 逻辑等价，但为<strong>每位员工</strong>
     * 单独调用 {@code contextForEmployee} 构造 {@link StaffAssignmentContext}（含该员工当日已排工时、
     * 与当前班段开始时间的休息间隔），以便日工时、最小休息等规则按人准确判断。
     * <p>
     * <b>内层循环</b>
     * <ol>
     *   <li>{@link #intersectWorkstationPool}：候选人与工作站绑定求交；</li>
     *   <li>固定班且全员不满足「必须」规则 → 直接空列表（本岗位本班次不排）；</li>
     *   <li>扫描候选池：第九步硬约束 → 第十步固定规则（含已放松的尽量项）→ 账本同日互斥 → 时段不重叠；</li>
     *   <li>本轮无人入选则 {@link #nextRelaxable} 放开下一档「尽量」规则后重试。</li>
     * </ol>
     */
    private static List<String> pickForPostDemandWithPerEmployeeContext(
            int targetCount,
            List<String> orderedCandidates,
            WorkstationPostStaffBuckets workstationBuckets,
            String postId,
            StaffRuleEvaluationPort port,
            Function<String, StaffAssignmentContext> contextForEmployee,
            FixedSchedulingRuleVo fixedSchedulingRules,
            StaffAssignmentDayLedger ledger,
            boolean lineAssignment,
            String fixedShiftGroupKey,
            Set<String> excludeEmployeeIds,
            Random random,
            StaffAssignmentIntervalLedger intervalLedger,
            int blockStartInclusive,
            int blockEndExclusive) {
        Objects.requireNonNull(intervalLedger, "intervalLedger");
        if (targetCount <= 0) {
            return Collections.emptyList();
        }
        List<String> pool =
                intersectWorkstationPool(postId, orderedCandidates, workstationBuckets);
        if (pool.isEmpty()) {
            return Collections.emptyList();
        }
        if (excludeEmployeeIds != null && !excludeEmployeeIds.isEmpty()) {
            List<String> filtered = new ArrayList<>();
            for (String id : pool) {
                if (id != null && !excludeEmployeeIds.contains(id)) {
                    filtered.add(id);
                }
            }
            pool = filtered;
            if (pool.isEmpty()) {
                return Collections.emptyList();
            }
        }
        if (pool.size() > 1 && random != null) {
            Collections.shuffle(pool, random);
        }
        // 划线班不参与 FixedSchedulingRuleVo 的必须/尽量判断
        FixedSchedulingRuleVo fixedVo = lineAssignment ? null : fixedSchedulingRules;
        // 固定班：若存在启用的「必须」规则且候选池中无人同时满足硬约束+全部必须项，则本需求放弃排人
        if (fixedVo != null && !pool.isEmpty() && !anyPassesMustOnly(pool, fixedVo, port, contextForEmployee)) {
            return Collections.emptyList();
        }
        List<String> picked = new ArrayList<>();
        Set<FixedSoftRuleKind> relaxed = new LinkedHashSet<>();
        while (picked.size() < targetCount) {
            boolean progressed = false;
            for (String id : pool) {
                if (picked.size() >= targetCount || picked.contains(id)) {
                    continue;
                }
                StaffAssignmentContext ctx = contextForEmployee.apply(id);
                if (!port.passesOperationalHardConstraints(id, ctx)) {
                    continue;
                }
                if (!passesFixedRules(id, fixedVo, port, ctx, relaxed)) {
                    continue;
                }
                LocalDate day = dayFrom(ctx);
                if (!intervalLedger.canAssign(id, day, blockStartInclusive, blockEndExclusive)) {
                    continue;
                }
                if (lineAssignment) {
                    if (!ledger.canAssignLine(id, day)) {
                        continue;
                    }
                    ledger.markLineShift(id, day);
                } else {
                    if (!ledger.canAssignFixed(id, day, fixedShiftGroupKey)) {
                        continue;
                    }
                    ledger.markFixed(id, day, fixedShiftGroupKey);
                }
                intervalLedger.mark(id, day, blockStartInclusive, blockEndExclusive);
                picked.add(id);
                progressed = true;
            }
            if (picked.size() >= targetCount) {
                break;
            }
            if (!progressed) {
                FixedSoftRuleKind next = nextRelaxable(fixedVo, relaxed);
                if (next == null) {
                    break;
                }
                relaxed.add(next);
                if (log.isErrorEnabled() && !pool.isEmpty()) {
                    StaffAssignmentContext ctxProbe = contextForEmployee.apply(pool.get(0));
                    SchedulingForTestCheckLog.info(
                            log,
                            SchedulingForTestCheckLog.TEST_ISSUE_3_STAFF_ASSIGNMENT,
                            "staff_assign_soft_constraint_relax",
                            String.format(
                                    "algo=ShiftPlanFinalStaffAssigner.pickForPostDemandWithPerEmployeeContext | "
                                            + "scheduleDay=%s | postId=%s | groupId=%s | lineScheduling=%s "
                                            + "| shiftWindowMinute=[%d,%d) | targetCount=%d | poolSize=%d | pickedSoFar=%d "
                                            + "| newlyRelaxedSoftRule=%s | accumulatedRelaxedSoftRules=%s",
                                    ctxProbe.getScheduleDay(),
                                    ctxProbe.getPostId(),
                                    ctxProbe.getGroupId(),
                                    Boolean.toString(lineAssignment),
                                    blockStartInclusive,
                                    blockEndExclusive,
                                    targetCount,
                                    pool.size(),
                                    picked.size(),
                                    next.name(),
                                    String.valueOf(relaxed)));
                }
            }
        }
        return Collections.unmodifiableList(picked);
    }

    /**
     * 将「全局有序候选」与工作站该岗位绑定名单求交。
     * <p>
     * 若岗位未配置工作站绑定（{@link WorkstationPostStaffBuckets#hasWorkstationBinding} 为 false），
     * 原样返回 {@code orderedCandidates}；若有绑定则与专岗∪通岗求交，交集为空返回空列表（不再退化为工作站全集）。
     */
    private static List<String> intersectWorkstationPool(
            String postId,
            List<String> orderedCandidates,
            WorkstationPostStaffBuckets workstationBuckets) {
        if (orderedCandidates == null || orderedCandidates.isEmpty()) {
            return Collections.emptyList();
        }
        if (workstationBuckets == null || !workstationBuckets.hasWorkstationBinding(postId)) {
            return new ArrayList<>(orderedCandidates);
        }
        Set<String> allowed = new LinkedHashSet<>();
        allowed.addAll(workstationBuckets.getSpecialistIds(postId));
        allowed.addAll(workstationBuckets.getGeneralIds(postId));
        List<String> out = new ArrayList<>();
        for (String id : orderedCandidates) {
            if (id != null && allowed.contains(id.trim())) {
                out.add(id.trim());
            }
        }
        return out;
    }

    /**
     * 同窗若重复出现同一岗位 id 的 {@link ShiftPlanPostNeed}，合并需求人数，避免跨岗统筹阶段 {@code demandByPost} 被覆盖。
     */
    static List<ShiftPlanPostNeed> mergePostNeedsByPostId(List<ShiftPlanPostNeed> raw) {
        if (raw == null || raw.isEmpty()) {
            return Collections.emptyList();
        }
        Map<String, Integer> sum = new LinkedHashMap<>();
        for (ShiftPlanPostNeed n : raw) {
            if (n == null) {
                continue;
            }
            String pid = n.getPostId() == null ? "" : n.getPostId().trim();
            if (pid.isEmpty()) {
                continue;
            }
            int c = n.getNeedCount();
            if (c <= 0) {
                continue;
            }
            sum.merge(pid, c, Integer::sum);
        }
        List<ShiftPlanPostNeed> out = new ArrayList<>();
        for (Map.Entry<String, Integer> e : sum.entrySet()) {
            if (e.getValue() != null && e.getValue() > 0) {
                out.add(new ShiftPlanPostNeed(e.getKey(), e.getValue()));
            }
        }
        return out;
    }

    /**
     * 固定班选人前置检查：候选池中是否至少有一人通过「第九步硬约束 + 全部启用的必须级固定规则」。
     * <p>
     * 若返回 false，{@link #pickForPostDemandWithPerEmployeeContext} 直接返回空，表示本岗位本班次在必须规则下无法排班
     * （放松「尽量」规则也不能增加人选，与 {@link FixedSoftConstraintRelaxationPlanner} 约定一致）。
     */
    private static boolean anyPassesMustOnly(
            List<String> pool,
            FixedSchedulingRuleVo fixedRules,
            StaffRuleEvaluationPort port,
            Function<String, StaffAssignmentContext> contextForEmployee) {
        for (String id : pool) {
            if (!port.passesOperationalHardConstraints(id, contextForEmployee.apply(id))) {
                continue;
            }
            if (passesMustRulesOnly(id, fixedRules, port, contextForEmployee.apply(id))) {
                return true;
            }
        }
        return false;
    }

    /**
     * 仅校验 {@link FixedSoftRuleKind} 中「已启用且 priority=必须(1)」的维度是否全部不违反。
     */
    private static boolean passesMustRulesOnly(
            String employeeId,
            FixedSchedulingRuleVo fixedRules,
            StaffRuleEvaluationPort port,
            StaffAssignmentContext context) {
        for (FixedSoftRuleKind kind : FixedSoftRuleKind.relaxOrder()) {
            if (!kind.isEnabled(fixedRules) || !kind.isMustPriority(fixedRules)) {
                continue;
            }
            if (kind.violates(employeeId, context, port)) {
                return false;
            }
        }
        return true;
    }

    /** 从上下文中取排班日；缺省为 {@link LocalDate#EPOCH}。 */
    private static LocalDate dayFrom(StaffAssignmentContext ctx) {
        return ctx == null ? LocalDate.EPOCH : ctx.getScheduleDay();
    }

    /**
     * 第十步：按 {@link FixedSoftRuleKind#relaxOrder()} 检查固定排班规则。
     * <ul>
     *   <li>必须项：始终生效，违反则不可选；</li>
     *   <li>尽量项：未列入 {@code relaxed} 时生效；已放松的维度跳过。</li>
     * </ul>
     * {@code fixedRules == null}（划线班）时恒为 true。
     */
    private static boolean passesFixedRules(
            String employeeId,
            FixedSchedulingRuleVo fixedRules,
            StaffRuleEvaluationPort port,
            StaffAssignmentContext context,
            Set<FixedSoftRuleKind> relaxed) {
        if (fixedRules == null) {
            return true;
        }
        for (FixedSoftRuleKind kind : FixedSoftRuleKind.relaxOrder()) {
            if (!kind.isEnabled(fixedRules)) {
                continue;
            }
            if (kind.isMustPriority(fixedRules)) {
                if (kind.violates(employeeId, context, port)) {
                    return false;
                }
                continue;
            }
            if (relaxed.contains(kind)) {
                continue;
            }
            if (kind.violates(employeeId, context, port)) {
                return false;
            }
        }
        return true;
    }

    /**
     * 在尚未放松的「尽量」规则中，取 {@link FixedSoftRuleKind#relaxOrder()} 的下一项。
     * <p>
     * 用于 {@link #pickForPostDemandWithPerEmployeeContext} 外层 while：本轮无人入选时放开一档尽量规则后重扫候选池。
     */
    private static FixedSoftRuleKind nextRelaxable(
            FixedSchedulingRuleVo fixedRules, Set<FixedSoftRuleKind> relaxed) {
        if (fixedRules == null) {
            return null;
        }
        for (FixedSoftRuleKind kind : FixedSoftRuleKind.relaxOrder()) {
            if (!kind.isEnabled(fixedRules) || kind.isMustPriority(fixedRules) || relaxed.contains(kind)) {
                continue;
            }
            return kind;
        }
        return null;
    }

    /**
     * 为指定员工构造本段班次的 {@link StaffAssignmentContext}。
     * <p>
     * 从 {@link SchedulePeriodWorkTracker} 读取：
     * <ul>
     *   <li>{@code existingSameDayScheduledWorkHours}：该员工排班日当天已排工时（含此前已选中的块）；</li>
     *   <li>{@code minutesRestSincePriorShiftEnd}：当前块开始时刻与上一段班结束时刻的间隔（分钟），
     *       供「班次间最小休息」规则使用；无上一班时为 null（不判违反）。</li>
     * </ul>
     */
    private static StaffAssignmentContext buildContext(
            SchedulePeriodWorkTracker tracker,
            String groupId,
            LocalDate day,
            String postId,
            ShiftPlanBlock block,
            double shiftHours,
            String sampleEmployeeForRest) {
        double existing = 0d;
        Integer restMin = null;
        if (sampleEmployeeForRest != null) {
            existing = tracker.getSameDayScheduledHours(sampleEmployeeForRest, day);
            restMin =
                    tracker.minutesRestSincePriorShiftEnd(
                            sampleEmployeeForRest, day, block.getStartMinuteInclusive());
        }
        return new StaffAssignmentContext(
                day,
                postId,
                block.getTimeRangeText(),
                groupId,
                existing,
                shiftHours,
                restMin);
    }

    /**
     * 固定班「逻辑班次」键：用于 {@link StaffAssignmentDayLedger} 判断同日是否可兼多个固定段。
     * <ul>
     *   <li>优先使用 {@link ShiftPlanBlock#getShiftId()}；</li>
     *   <li>无 shiftId 时退化为「排班日 + 时段文本」。</li>
     * </ul>
     * 同一键下允许多个 {@link ShiftPlanBlock} 段分配给同一人；不同键在同日互斥。
     */
    private static String fixedShiftGroupKey(ShiftPlanBlock block) {
        if (block.getShiftId() != null && !block.getShiftId().isEmpty()) {
            return block.getShiftId();
        }
        return block.getScheduleDay() + "|" + block.getTimeRangeText();
    }

    /**
     * 生成某岗位在某日的有序候选员工 ID 列表。
     * <p>
     * 顺序：先 {@link WorkstationPostStaffBuckets#getSpecialistIds}（专岗），
     * 再 {@link WorkstationPostStaffBuckets#getGeneralIds}（通岗）；
     * 仅保留 {@link #eligible} 通过者，去重且保持插入顺序。
     */
    private static List<String> orderedCandidates(
            String postId,
            WorkstationPostStaffBuckets buckets,
            Map<String, WorkstationGroupUserVo> userMeta,
            LocalDate day,
            boolean fixedScheduling) {
        String dayStr = day.format(DAY_FMT);
        LinkedHashSet<String> ordered = new LinkedHashSet<>();
        for (String uid : buckets.getSpecialistIds(postId)) {
            if (eligible(uid, userMeta, dayStr, fixedScheduling)) {
                ordered.add(uid);
            }
        }
        for (String uid : buckets.getGeneralIds(postId)) {
            if (eligible(uid, userMeta, dayStr, fixedScheduling)) {
                ordered.add(uid);
            }
        }
        return new ArrayList<>(ordered);
    }

    /**
     * 候选资格过滤（第九步之前的业务硬过滤）。
     * <ul>
     *   <li>员工元数据存在；</li>
     *   <li>{@link WorkstationGroupUserVo#getInGroupDays()} 包含当前排班日；</li>
     *   <li>划线班要求 {@link WorkstationGroupUserVo#getCanLineSchedule()} 为 true。</li>
     * </ul>
     */
    private static Set<String> filterEligibleIds(
            Set<String> userIds,
            Map<String, WorkstationGroupUserVo> userMeta,
            String dayStr,
            boolean fixedScheduling) {
        Set<String> out = new LinkedHashSet<>();
        if (userIds == null) {
            return out;
        }
        for (String uid : userIds) {
            if (eligible(uid, userMeta, dayStr, fixedScheduling)) {
                out.add(uid);
            }
        }
        return out;
    }

    private static boolean eligible(
            String userId,
            Map<String, WorkstationGroupUserVo> userMeta,
            String dayStr,
            boolean fixedScheduling) {
        WorkstationGroupUserVo u = userMeta.get(userId);
        if (u == null) {
            return false;
        }
        List<String> inGroup = u.getInGroupDays();
        if (inGroup == null || inGroup.isEmpty() || !inGroup.contains(dayStr)) {
            return false;
        }
        if (!fixedScheduling && !Boolean.TRUE.equals(u.getCanLineSchedule())) {
            return false;
        }
        return true;
    }

    /**
     * 岗位 ID → {@link WorkstationWithUsersVo#getPositionName()}；
     * 同一 {@code positionId} 多条工作站记录时保留首次非 null 名称。
     */
    private static Map<String, String> indexPositionNameByPostId(List<WorkstationWithUsersVo> workstations) {
        Map<String, String> map = new LinkedHashMap<>();
        if (workstations == null) {
            return map;
        }
        for (WorkstationWithUsersVo w : workstations) {
            if (w == null || w.getPositionId() == null || w.getPositionId().trim().isEmpty()) {
                continue;
            }
            String pid = w.getPositionId().trim();
            map.putIfAbsent(pid, nullToEmpty(w.getPositionName()));
        }
        return map;
    }

    private static String positionNameForPost(Map<String, String> positionNameByPostId, String postId) {
        if (postId == null || postId.trim().isEmpty()) {
            return "";
        }
        String trimmed = postId.trim();
        String n = positionNameByPostId.get(trimmed);
        return n != null ? n : "";
    }

    /**
     * 由工作站列表构建 userId → {@link WorkstationGroupUserVo} 索引（同一 userId 仅保留首次出现）。
     */
    private static Map<String, WorkstationGroupUserVo> indexUserMeta(
            List<WorkstationWithUsersVo> workstations) {
        Map<String, WorkstationGroupUserVo> map = new LinkedHashMap<>();
        if (workstations == null) {
            return map;
        }
        for (WorkstationWithUsersVo w : workstations) {
            if (w == null || w.getUserList() == null) {
                continue;
            }
            for (WorkstationGroupUserVo u : w.getUserList()) {
                if (u != null && u.getUserId() != null && !u.getUserId().trim().isEmpty()) {
                    map.putIfAbsent(u.getUserId().trim(), u);
                }
            }
        }
        return map;
    }

    private static String nullToEmpty(String s) {
        return s == null ? "" : s;
    }

    /**
     * [for_test_check] 专供日志：各岗位池人数（不列出具体 userId，防止隐私/体积问题）。
     */
    private static String stringifyPoolSizes(Map<String, Set<String>> pools) {
        if (pools == null || pools.isEmpty()) {
            return "{}";
        }
        Map<String, Integer> counts = new LinkedHashMap<>();
        for (Map.Entry<String, Set<String>> e : pools.entrySet()) {
            Set<String> ids = e.getValue();
            counts.put(e.getKey(), ids == null ? 0 : ids.size());
        }
        return counts.toString();
    }
}
