package jnpf.attendance.service.impl;

import jnpf.attendance.schedule.*;
import jnpf.attendance.service.*;
import jnpf.base.ActionResult;
import jnpf.exception.HandleException;
import jnpf.exception.QueryException;
import jnpf.model.attendance.dto.UserWorkSituationDto;
import jnpf.model.attendance.vo.DayShiftRevenueStatVo;
import jnpf.model.attendance.vo.ShiftPeriodVo;
import jnpf.model.attendance.vo.WorkstationGroupUserVo;
import jnpf.model.attendance.vo.WorkstationWithUsersVo;
import jnpf.model.attendance.vo.attendance.UserWorkSituationVo;
import jnpf.model.attendance.vo.scheduling.ScheduleGroupRuleConfigVo;
import jnpf.permission.V2PositionApi;
import jnpf.permission.vo.v2.position.PositionBaseInfoVO;
import jnpf.util.UserProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;

/**
 * 自动排班业务入口（实现待补全）。
 */
@Service
public class AutoScheduleService {

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

    /**
     * 「按营业额带状」选相似日时，tier1～3（及 tier2+tier3 合并）的最少样本天数默认值，与算法侧一致。
     * <p>可在 {@code application.yml} 中用键 {@value #PROPERTY_TEMPLATE_SIMILAR_DAYS_MIN_BAND_SAMPLES} 覆盖，无需改代码。
     */
    public static final int DEFAULT_TEMPLATE_SIMILAR_DAYS_MIN_BAND_SAMPLES =
            ScheduleTemplateSimilarDaysAlgorithm.DEFAULT_MIN_BAND_SAMPLE_DAYS;

    /** 配置键：带状相似档最少样本天数（1～90，算法_ctor 再 clamp）。 */
    public static final String PROPERTY_TEMPLATE_SIMILAR_DAYS_MIN_BAND_SAMPLES =
            "jnpf.ftb.schedule.template-similar-days-min-band-sample-days";

    /** 配置键：相似日营业额严带 λ₁（tier1），开区间 (0,1)，默认 0.10。 */
    public static final String PROPERTY_TEMPLATE_SIMILAR_DAYS_BAND_STRICT =
            "jnpf.ftb.schedule.template-similar-days-band-strict";

    /** 配置键：相似日营业额宽带 λ₂（tier2/tier3），开区间 (0,1)，默认 0.15。 */
    public static final String PROPERTY_TEMPLATE_SIMILAR_DAYS_BAND_RELAXED =
            "jnpf.ftb.schedule.template-similar-days-band-relaxed";

    /** 配置键：考勤班次模糊匹配——计划时段相对考勤班次的覆盖率下限 {@code [0,1]} ，见 {@link AttendanceGroupShiftMatchConfig}。 */
    public static final String PROPERTY_ATTEND_MATCH_MIN_HISTORY_COVERAGE =
            "jnpf.ftb.schedule.attendance-shift-match-min-history-coverage";

    /** 配置键：考勤班次模糊匹配——考勤班次相对重叠的效率下限 {@code [0,1]} 。 */
    public static final String PROPERTY_ATTEND_MATCH_MIN_CANDIDATE_EFFICIENCY =
            "jnpf.ftb.schedule.attendance-shift-match-min-candidate-efficiency";

    @Autowired
    private AttendanceDayStatisticsService attendanceDayStatisticsService;
    @Autowired
    private AttendanceShiftSettingService attendanceShiftSettingService;
    @Autowired
    private AttendanceDailyRuleService attendanceDailyRuleService;
    @Autowired
    private AttendanceUserService attendanceUserService;

    @Autowired
    private WorkstationService workstationService;

    @Autowired
    private ScheduleGroupRuleConfigService scheduleGroupRuleConfigService;

    @Autowired
    private V2PositionApi v2PositionApi;

    /** 产品与文档 §7.4：历史中出现的岗位在主数据已无定义时的事后文案。 */
    private static final String MSG_HISTORICAL_POST_NOT_IN_MASTER =
            "某时段下的历史岗位已被删除，请手动排班。";

    /**
     * 营业额带状相似日的最少样本天数；默认 10。
     * <p>YAML：{@code jnpf.ftb.schedule.template-similar-days-min-band-sample-days: 7}
     */
    @Value("${jnpf.ftb.schedule.template-similar-days-min-band-sample-days:10}")
    private int templateSimilarDaysMinBandSampleDays = DEFAULT_TEMPLATE_SIMILAR_DAYS_MIN_BAND_SAMPLES;

    /** tier1 营业额半宽 λ₁；YAML 见 {@link #PROPERTY_TEMPLATE_SIMILAR_DAYS_BAND_STRICT}。 */
    @Value("${jnpf.ftb.schedule.template-similar-days-band-strict:0.10}")
    private String templateSimilarDaysBandStrict =
            ScheduleTemplateSimilarDaysAlgorithm.DEFAULT_BAND_STRICT.toPlainString();

    /** tier2/tier3 营业额半宽 λ₂；YAML 见 {@link #PROPERTY_TEMPLATE_SIMILAR_DAYS_BAND_RELAXED}。 */
    @Value("${jnpf.ftb.schedule.template-similar-days-band-relaxed:0.15}")
    private String templateSimilarDaysBandRelaxed =
            ScheduleTemplateSimilarDaysAlgorithm.DEFAULT_BAND_RELAXED.toPlainString();

    /** YAML：{@linkplain #PROPERTY_ATTEND_MATCH_MIN_HISTORY_COVERAGE 历史覆盖比例下限（0～1）}。 */
    @Value("${jnpf.ftb.schedule.attendance-shift-match-min-history-coverage:0.7}")
    private String attendanceShiftMatchMinHistoryCoverage = "0.7";

    /** YAML：{@linkplain #PROPERTY_ATTEND_MATCH_MIN_CANDIDATE_EFFICIENCY 候选班次效率下限（0～1）}。 */
    @Value("${jnpf.ftb.schedule.attendance-shift-match-min-candidate-efficiency:0.6}")
    private String attendanceShiftMatchMinCandidateEfficiency = "0.6";

    /**
     * 按排班区间与每日预估营业额启动排班。
     *
     * @param scheduleStartDate     排班区间起始日（含）
     * @param scheduleEndDate       排班区间结束日（含）
     * @param estimatedRevenueByDay 各自然日预估营业额，key 为排班日
     * @return 排班结果；占位实现返回 {@code null}，接入算法后返回非空
     */
    public ShiftPlanAssignmentResult start(
            String groupId,
            LocalDate scheduleStartDate,
            LocalDate scheduleEndDate,
            Map<LocalDate, BigDecimal> estimatedRevenueByDay) throws HandleException, QueryException {
        int scheduleDayCount =
                estimatedRevenueByDay == null ? 0 : (int) estimatedRevenueByDay.keySet().stream().distinct().count();
        AutoSchedulePipelineLog.info(
                log,
                AutoSchedulePipelineLog.PHASE_START,
                String.format(
                        "groupId=%s schedule=[%s..%s] revenueDays(unique)=%d",
                        groupId, scheduleStartDate, scheduleEndDate, scheduleDayCount));
        //查询最近90天的历史
        List<DayShiftRevenueStatVo> dayShiftRevenueStatVoList =
                getLast90DaysHistoryData(groupId, scheduleStartDate, scheduleEndDate);
        AutoSchedulePipelineLog.info(
                log,
                AutoSchedulePipelineLog.PHASE_HISTORY_SNAPSHOT,
                "historyDayRows(group90dWindow)=" + dayShiftRevenueStatVoList.size());

        ScheduleGroupRuleConfigVo scheduleGroupRuleConfigVo = queryScheduleRuleConfig(groupId);
        Boolean revenueAvailableToggle =
                scheduleGroupRuleConfigVo == null ? null : scheduleGroupRuleConfigVo.getIntelligentSchedulingHistoricalRevenueAvailable();
        boolean usePatternSimilarDays =
                SchedulingSimilarDaysMode.shouldUseSchedulingPatternSimilarDays(
                        revenueAvailableToggle, dayShiftRevenueStatVoList);
        AutoSchedulePipelineLog.info(
                log,
                AutoSchedulePipelineLog.PHASE_SIMILAR_DAYS,
                "similar-days mode="
                        + (usePatternSimilarDays ? "SCHED_PATTERN_NO_REVENUE" : "REVENUE_BAND")
                        + ", historyRevenueAvailableFlag="
                        + revenueAvailableToggle
                        + ", autoPatternDueToBlankRevenue="
                        + SchedulingSimilarDaysMode.allHistoricalRevenueNullOrZero(dayShiftRevenueStatVoList)
                        + (!usePatternSimilarDays
                                ? ", templateMinBandSamples(config)=" + templateSimilarDaysMinBandSampleDays
                                        + ", revenueBandλ12(config)="
                                        + templateSimilarDaysBandStrict
                                        + "/"
                                        + templateSimilarDaysBandRelaxed
                                : ""));

        BigDecimal bandStrictCfg =
                parseBigDecimalConfig(
                        templateSimilarDaysBandStrict, ScheduleTemplateSimilarDaysAlgorithm.DEFAULT_BAND_STRICT);
        BigDecimal bandRelaxedCfg =
                parseBigDecimalConfig(
                        templateSimilarDaysBandRelaxed, ScheduleTemplateSimilarDaysAlgorithm.DEFAULT_BAND_RELAXED);

        Map<LocalDate, ScheduleTemplateSimilarDaysResult> similarTemplateDays =
                usePatternSimilarDays
                        ? new SchedulePatternSimilarDaysAlgorithm()
                                .findForScheduleDays(dayShiftRevenueStatVoList, estimatedRevenueByDay)
                        : new ScheduleTemplateSimilarDaysAlgorithm(
                                        10, templateSimilarDaysMinBandSampleDays, bandStrictCfg, bandRelaxedCfg)
                                .findForScheduleDays(dayShiftRevenueStatVoList, estimatedRevenueByDay);
        for (Map.Entry<LocalDate, ScheduleTemplateSimilarDaysResult> e : similarTemplateDays.entrySet()) {
            ScheduleTemplateSimilarDaysResult r = e.getValue();
            SchedulingForTestCheckLog.info(
                    log,
                    SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                    "pipeline_similar_days_per_schedule_day",
                    String.format(
                            "algo=AutoScheduleService.groupId=%s | scheduleDay=%s | similarDaysMode=%s | success=%s | tier=%s "
                                    + "| similarReferenceDayCount=%d | message=%s | similarDatesBrief=%s",
                            groupId,
                            e.getKey(),
                            usePatternSimilarDays ? "PATTERN_SCHEDULE_NO_REVENUE_BAND" : "REVENUE_BAND_SIMILAR_DAYS",
                            r == null ? "null_result" : Boolean.toString(r.isSuccess()),
                            r == null ? "NONE" : r.getTier().name(),
                            r == null ? -1 : r.getSimilarDates().size(),
                            r == null ? "" : r.getMessage(),
                            r == null || r.getSimilarDates().isEmpty()
                                    ? "[]"
                                    : SchedulingForTestCheckLog.formatDatesBrief(
                                            r.getSimilarDates(), SchedulingForTestCheckLog.DEFAULT_DATE_CAP)));
        }
        AutoSchedulePipelineLog.info(
                log,
                AutoSchedulePipelineLog.PHASE_SIMILAR_DAYS,
                "scheduleDaysWithSimilarTemplateKey=" + similarTemplateDays.size());
//        if(CollectionUtils.isEmpty(similarTemplateDays)){
//
//        }

        List<SimilarHistoricalJobSlotRow> similarHistoricalHalfHourRows =
                SimilarHistoricalDaySlotTableBuilder.buildHalfHourRowsFromSuccessfulSimilarDays(
                        dayShiftRevenueStatVoList, similarTemplateDays);
        List<ShiftPlanAssignmentResult.IncompleteScheduleReason> similarDaysNoHistoricalShiftNotes =
                buildSuccessfulSimilarDaysNoHistoricalShiftExpansionNotes(similarHistoricalHalfHourRows, similarTemplateDays);
        AutoSchedulePipelineLog.info(
                log,
                AutoSchedulePipelineLog.PHASE_HALF_HOUR_TABLE,
                "halfHourHistoricalRows(forSuccessfulSimilarDays)=" + similarHistoricalHalfHourRows.size());
        log.error(
                "similar historical half-hour staffing rows: count={} (for successful template days only)",
                similarHistoricalHalfHourRows.size());

        SlotPostRobustTargetConfig slotPostRobustTargetConfig = SlotPostRobustTargetConfig.defaults();
        Map<LocalDate, HalfHourPostDemandMatrix> halfHourPostDemandByScheduleDay =
                SimilarDayDemandSteps4And5.buildPerScheduleDayDemandMatrices(
                        similarHistoricalHalfHourRows, similarTemplateDays, slotPostRobustTargetConfig);
        SchedulingForTestCheckLog.info(
                log,
                SchedulingForTestCheckLog.TEST_ISSUE_2_HALF_HOUR_DEMAND_MATRIX,
                "pipeline_after_demand_matrices",
                String.format(
                        "algo=AutoScheduleService | groupId=%s | scheduleDaysWithDemandMatrix=%d | slotRobustConfig.maxMinusMinUseMedianThreshold=%d "
                                + "| imputeZeroForMissingSimilarDays=%s",
                        groupId,
                        halfHourPostDemandByScheduleDay.size(),
                        slotPostRobustTargetConfig.getMaxMinusMinUseMedianThreshold(),
                        slotPostRobustTargetConfig.isImputeZeroForMissingSimilarDays()));
        AutoSchedulePipelineLog.info(
                log,
                AutoSchedulePipelineLog.PHASE_DEMAND_MATRIX,
                "scheduleDaysWithDemandMatrix=" + halfHourPostDemandByScheduleDay.size());
        log.error(
                "half-hour post demand matrices (step 4-5): scheduleDayCount={}",
                halfHourPostDemandByScheduleDay.size());
        for (Map.Entry<LocalDate, HalfHourPostDemandMatrix> e : halfHourPostDemandByScheduleDay.entrySet()) {
            log.error(
                    "demand matrix scheduleDay={} slots={} posts={}\n{}",
                    e.getKey(),
                    e.getValue().getSlotRangeLabelsOrdered().size(),
                    e.getValue().getPostIdsOrdered().size(),
                    e.getValue().toTsvTable());
        }

        // 第六～八步：历史常见固定班 + 贪心覆盖 + 划线兜底（规则配置已在流水线前段加载）
        LineShiftCoverConfig lineShiftCoverConfig = resolveLineShiftCoverConfig(scheduleGroupRuleConfigVo);
        Map<String, DayShiftRevenueStatVo> historyByDay =
                ScheduleDemandCoverSteps678.indexHistoryByDay(dayShiftRevenueStatVoList);
        AttendanceGroupShiftMatchConfig attendanceShiftMatchConfig = resolveAttendanceGroupShiftMatchConfig();
        List<ShiftPeriodVo> shiftPeriodVoList = getPeriodListForIntelligentSchedule(groupId);
        if (shiftPeriodVoList == null) {
            AutoSchedulePipelineLog.info(
                    log,
                    AutoSchedulePipelineLog.PHASE_COVER_678,
                    "attendance period list unresolved (null) → step6-8 no attendance pre-filter; §6.1 matcher will preserve blocks without shift id");
        }
        Map<LocalDate, ScheduleDemandCoverResult> demandCoverByScheduleDay =
                ScheduleDemandCoverSteps678.coverAllScheduleDays(
                        halfHourPostDemandByScheduleDay,
                        similarTemplateDays,
                        historyByDay,
                        CommonFixedShiftDiscoveryConfig.defaults(),
                        lineShiftCoverConfig,
                        shiftPeriodVoList,
                        attendanceShiftMatchConfig,
                        null);
        AutoSchedulePipelineLog.info(
                log,
                AutoSchedulePipelineLog.PHASE_COVER_678,
                "scheduleDaysWithCoverResult=" + demandCoverByScheduleDay.size());
        log.error(
                "demand cover steps 6-8: scheduleDayCount={}",
                demandCoverByScheduleDay.size());

        Map<LocalDate, List<ShiftPlanBlock>> shiftPlanByScheduleDay =
                ShiftPlanBlocksFromDemandCoverBuilder.build(
                        demandCoverByScheduleDay, halfHourPostDemandByScheduleDay);

        Map<LocalDate, Integer> draftBlockCountByDay = countBlocksPerScheduleDay(shiftPlanByScheduleDay);
        int draftBlockCount = countShiftPlanBlocks(shiftPlanByScheduleDay);
        AutoSchedulePipelineLog.info(
                log,
                AutoSchedulePipelineLog.PHASE_SHIFT_PLAN_DRAFT,
                summarizeShiftPlan(shiftPlanByScheduleDay)
                        + String.format(
                                " | attendancePeriodCandidates=%d (before attendance shift-id match)",
                                shiftPeriodVoList == null ? 0 : shiftPeriodVoList.size()));
        log.error(
                "shift plan from demand cover (6-8): scheduleDayCount={}",
                shiftPlanByScheduleDay.size());
        for (Map.Entry<LocalDate, List<ShiftPlanBlock>> e : shiftPlanByScheduleDay.entrySet()) {
            log.error("shift plan scheduleDay={} blockCount={}", e.getKey(), e.getValue().size());
            for (ShiftPlanBlock block : e.getValue()) {
                log.error(
                        "  {} fixed={} posts={}",
                        block.getTimeRangeText(),
                        block.isFixedScheduling(),
                        block.getPostNeeds());
            }
        }

        shiftPlanByScheduleDay =
                ShiftPlanAttendanceShiftIdResolver.assignGroupShiftIdsOrRemoveUnmatched(
                        shiftPlanByScheduleDay,
                        shiftPeriodVoList,
                        attendanceShiftMatchConfig,
                        null);
        int blocksAfterMatcher = countShiftPlanBlocks(shiftPlanByScheduleDay);
        AutoSchedulePipelineLog.info(
                log,
                AutoSchedulePipelineLog.PHASE_ATTEND_SHIFT_MATCH,
                summarizeShiftPlan(shiftPlanByScheduleDay)
                        + String.format(" | droppedByMatcher=%d", draftBlockCount - blocksAfterMatcher));
        if (shiftPeriodVoList != null && shiftPeriodVoList.isEmpty()) {
            AutoSchedulePipelineLog.warn(
                    log,
                    AutoSchedulePipelineLog.PHASE_ATTEND_SHIFT_MATCH,
                    "attendance period list is empty → matcher cleared all draft blocks");
        } else if (shiftPeriodVoList == null) {
            AutoSchedulePipelineLog.info(
                    log,
                    AutoSchedulePipelineLog.PHASE_ATTEND_SHIFT_MATCH,
                    "attendance period list is null → preserving draft blocks without shift id binding (§6.1)");
        }
        log.error(
                "shift plan after attendance period id match: scheduleDayCount={}",
                shiftPlanByScheduleDay.size());
        for (Map.Entry<LocalDate, List<ShiftPlanBlock>> e : shiftPlanByScheduleDay.entrySet()) {
            for (ShiftPlanBlock block : e.getValue()) {
                log.error(
                        "  shiftId={} {} fixed={} posts={}",
                        block.getShiftId(),
                        block.getTimeRangeText(),
                        block.isFixedScheduling(),
                        block.getPostNeeds());
            }
        }

        List<ShiftPlanAssignmentResult.IncompleteScheduleReason> matcherIncompleteNotes =
                buildAttendanceMatcherIncompleteNotes(draftBlockCountByDay, shiftPlanByScheduleDay);
        List<ShiftPlanAssignmentResult.IncompleteScheduleReason> pipelineIncompleteNotes = new ArrayList<>();
        pipelineIncompleteNotes.addAll(
                buildPatternScheduleInsufficientHistoryNotes(similarTemplateDays));
        pipelineIncompleteNotes.addAll(similarDaysNoHistoricalShiftNotes);
        pipelineIncompleteNotes.addAll(matcherIncompleteNotes);
        pipelineIncompleteNotes.addAll(
                buildHistoricalPostsMissingMasterDataNotes(shiftPlanByScheduleDay));

        List<WorkstationWithUsersVo> workstationWithUsersVoList =
                queryWorkstationConfig(groupId, scheduleStartDate, scheduleEndDate);
        AutoSchedulePipelineLog.info(
                log,
                AutoSchedulePipelineLog.PHASE_WORKSTATION_POOL,
                "workstations=" + workstationWithUsersVoList.size());
        // 排班规则（已与第六～八步划线参数、第九～十步人员规则共用同一次查询）
        //用户的已工作状态
        Map<String, UserWorkSituationVo> userWorkSituationVoMap =
                queryUserWorkSituation(
                        countScheduleDays(scheduleStartDate, scheduleEndDate), workstationWithUsersVoList);

        SchedulePeriodWorkTracker workTracker = SchedulePeriodWorkTracker.from(userWorkSituationVoMap);
        StaffRuleEvaluationPort staffRuleEvaluationPort =
                buildStaffRuleEvaluationPort(scheduleGroupRuleConfigVo, workTracker.asSituationMap());
        AutoSchedulePipelineLog.info(
                log,
                AutoSchedulePipelineLog.PHASE_STAFF_RULES,
                "evaluator=" + staffRuleEvaluationPort.getClass().getSimpleName());
        log.error("staff rule evaluation (steps 9-10): {}", staffRuleEvaluationPort.getClass().getSimpleName());

        ShiftPlanAssignmentResult finalAssignment =
                ShiftPlanAssignmentResult.prependIncompleteReasons(
                        pipelineIncompleteNotes,
                        ShiftPlanFinalStaffAssigner.assign(
                                shiftPlanByScheduleDay,
                                workstationWithUsersVoList,
                                scheduleGroupRuleConfigVo,
                                staffRuleEvaluationPort,
                                workTracker,
                                groupId,
                                null));
        log.error(
                "final staff assignment: shiftViews={} employeeViews={} assigned={}/need={}",
                finalAssignment.getByShift().size(),
                finalAssignment.getByEmployee().size(),
                finalAssignment.countAssignedStaff(),
                finalAssignment.countTotalNeed());
        ShiftPlanAssignmentResultLogger.logInfo(log, finalAssignment);
        ShiftPlanAssignmentResultLogger.logDebug(log, finalAssignment);
        SchedulingForTestCheckLog.info(
                log,
                SchedulingForTestCheckLog.TEST_ISSUE_3_STAFF_ASSIGNMENT,
                "pipeline_staff_assignment_summary",
                String.format(
                        "algo=AutoScheduleService | groupId=%s | incompleteReasonCount=%d | shiftBlockViews=%d | employeeViews=%d | assignedHeadcount=%d | totalNeed=%d",
                        groupId,
                        finalAssignment.getIncompleteScheduleReasons() == null
                                ? 0
                                : finalAssignment.getIncompleteScheduleReasons().size(),
                        finalAssignment.getByShift().size(),
                        finalAssignment.getByEmployee().size(),
                        finalAssignment.countAssignedStaff(),
                        finalAssignment.countTotalNeed()));
        return finalAssignment;
    }

    private static int countShiftPlanBlocks(Map<LocalDate, List<ShiftPlanBlock>> shiftPlanByScheduleDay) {
        if (shiftPlanByScheduleDay == null || shiftPlanByScheduleDay.isEmpty()) {
            return 0;
        }
        int n = 0;
        for (List<ShiftPlanBlock> blocks : shiftPlanByScheduleDay.values()) {
            if (blocks != null) {
                n += blocks.size();
            }
        }
        return n;
    }

    /**
     * 单行摘要：各日块数合计、固定/划线块、已绑定考勤班次 id 的块数（用于线上快速判断「计划是否被 matcher 剔空」）。
     */
    private static String summarizeShiftPlan(Map<LocalDate, List<ShiftPlanBlock>> shiftPlanByScheduleDay) {
        if (shiftPlanByScheduleDay == null || shiftPlanByScheduleDay.isEmpty()) {
            return "scheduleDays=0 totalBlocks=0 fixedBlocks=0 lineBlocks=0 blocksWithAttendanceShiftId=0";
        }
        int days = shiftPlanByScheduleDay.size();
        int total = 0;
        int fixed = 0;
        int line = 0;
        int withShiftId = 0;
        for (List<ShiftPlanBlock> blocks : shiftPlanByScheduleDay.values()) {
            if (blocks == null) {
                continue;
            }
            for (ShiftPlanBlock b : blocks) {
                total++;
                if (b.isFixedScheduling()) {
                    fixed++;
                } else {
                    line++;
                }
                String sid = b.getShiftId();
                if (sid != null && !sid.trim().isEmpty()) {
                    withShiftId++;
                }
            }
        }
        return String.format(
                "scheduleDays=%d totalBlocks=%d fixedBlocks=%d lineBlocks=%d blocksWithAttendanceShiftId=%d",
                days, total, fixed, line, withShiftId);
    }


    private static Map<LocalDate, Integer> countBlocksPerScheduleDay(
            Map<LocalDate, List<ShiftPlanBlock>> planByDay) {
        if (planByDay == null || planByDay.isEmpty()) {
            return Collections.emptyMap();
        }
        Map<LocalDate, Integer> m = new LinkedHashMap<>();
        for (Map.Entry<LocalDate, List<ShiftPlanBlock>> e : planByDay.entrySet()) {
            LocalDate d = e.getKey();
            List<ShiftPlanBlock> blocks = e.getValue();
            if (d == null || blocks == null) {
                continue;
            }
            m.put(d, blocks.size());
        }
        return m;
    }

    private static BigDecimal parseBigDecimalConfig(String raw, BigDecimal fallback) {
        if (raw == null || raw.trim().isEmpty()) {
            return fallback;
        }
        try {
            return new BigDecimal(raw.trim());
        } catch (NumberFormatException ex) {
            return fallback;
        }
    }

    /**
     * 规律模式第三步失败（租户无营业额补充文档之 (3)：历史排班数据不足）。
     *
     * <p>每条对应一个无法在相似日维度启动的 {@code estimatedRevenueByDay.key}（其它日期仍照常预排）。
     */
    private static List<ShiftPlanAssignmentResult.IncompleteScheduleReason> buildPatternScheduleInsufficientHistoryNotes(
            Map<LocalDate, ScheduleTemplateSimilarDaysResult> similarTemplateDaysByScheduleDay) {
        if (similarTemplateDaysByScheduleDay == null || similarTemplateDaysByScheduleDay.isEmpty()) {
            return Collections.emptyList();
        }
        List<ShiftPlanAssignmentResult.IncompleteScheduleReason> notes = new ArrayList<>();
        List<LocalDate> days = new ArrayList<>(similarTemplateDaysByScheduleDay.keySet());
        days.sort(Comparator.naturalOrder());
        for (LocalDate d : days) {
            if (d == null) {
                continue;
            }
            ScheduleTemplateSimilarDaysResult sim = similarTemplateDaysByScheduleDay.get(d);
            if (sim == null || sim.isSuccess()) {
                continue;
            }
            if (!ScheduleTemplateSimilarDaysResult.isInsufficientHistoryForPatternScheduleMessage(sim.getMessage())) {
                continue;
            }
            notes.add(
                    new ShiftPlanAssignmentResult.IncompleteScheduleReason(
                            d,
                            "",
                            "",
                            ShiftPlanAssignmentResult.INCOMPLETE_PATTERN_HISTORY_INSUFFICIENT,
                            sim.getMessage()));
        }
        return notes;
    }

    /** §2.4：第三步 success，但召回日下均无可用班次明细 → 单日阻断说明（不影响其他已成功展开的排班日）。 */
    private static List<ShiftPlanAssignmentResult.IncompleteScheduleReason> buildSuccessfulSimilarDaysNoHistoricalShiftExpansionNotes(
            List<SimilarHistoricalJobSlotRow> halfHourRows,
            Map<LocalDate, ScheduleTemplateSimilarDaysResult> similarTemplateDaysByScheduleDay) {
        List<LocalDate> scheduleDays =
                SimilarHistoricalDaySlotTableBuilder.listScheduleDaysWithSuccessfulSimilarityButZeroHalfHourRows(
                        similarTemplateDaysByScheduleDay, halfHourRows);
        if (scheduleDays.isEmpty()) {
            return Collections.emptyList();
        }
        List<ShiftPlanAssignmentResult.IncompleteScheduleReason> notes = new ArrayList<>(scheduleDays.size());
        for (LocalDate d : scheduleDays) {
            notes.add(
                    new ShiftPlanAssignmentResult.IncompleteScheduleReason(
                            d,
                            "",
                            "",
                            ShiftPlanAssignmentResult.INCOMPLETE_SIMILAR_HISTORY_NO_SHIFT_DATA,
                            d.toString()
                                    + " 没找到可参考的历史排班数据，无法进行预排班。"));
        }
        return notes;
    }

    /** 考勤班次 matcher 剔除的班段数 → 单日一条说明（仅剔除数 &gt; 0）。 */
    private static List<ShiftPlanAssignmentResult.IncompleteScheduleReason> buildAttendanceMatcherIncompleteNotes(
            Map<LocalDate, Integer> draftBlockCountByDay,
            Map<LocalDate, List<ShiftPlanBlock>> planAfterMatcher) {
        if (draftBlockCountByDay == null || draftBlockCountByDay.isEmpty()) {
            return Collections.emptyList();
        }
        List<ShiftPlanAssignmentResult.IncompleteScheduleReason> notes = new ArrayList<>();
        for (Map.Entry<LocalDate, Integer> e : draftBlockCountByDay.entrySet()) {
            LocalDate d = e.getKey();
            int before = e.getValue() == null ? 0 : Math.max(0, e.getValue());
            List<ShiftPlanBlock> kept =
                    planAfterMatcher == null ? Collections.emptyList() : planAfterMatcher.get(d);
            int after = kept == null ? 0 : kept.size();
            if (before <= after) {
                continue;
            }
            int dropped = before - after;
            notes.add(
                    new ShiftPlanAssignmentResult.IncompleteScheduleReason(
                            d,
                            "",
                            "",
                            ShiftPlanAssignmentResult.INCOMPLETE_ATTEND_SHIFT_BLOCKS_DROPPED,
                            String.format(
                                    "有%d个班段未匹配考勤组班次时段并已移除（请核对班段时间与班次配置是否一致）",
                                    dropped)));
        }
        notes.sort(
                Comparator.comparing(ShiftPlanAssignmentResult.IncompleteScheduleReason::getScheduleDay));
        return notes;
    }

    /**
     * 排班区间包含的自然日天数（{@code scheduleStartDate}、{@code scheduleEndDate} 均为闭区间，含首尾两日）。
     * <p>
     * 例如 2025-01-15～2025-01-17 返回 3。
     */
    public static int countScheduleDays(LocalDate scheduleStartDate, LocalDate scheduleEndDate) {
        Objects.requireNonNull(scheduleStartDate, "scheduleStartDate");
        Objects.requireNonNull(scheduleEndDate, "scheduleEndDate");
        if (scheduleEndDate.isBefore(scheduleStartDate)) {
            throw new IllegalArgumentException("scheduleEndDate must not be before scheduleStartDate");
        }
        return (int) ChronoUnit.DAYS.between(scheduleStartDate, scheduleEndDate) + 1;
    }

    /**
     * 获取最近90天的营业额及排班数据
     *
     * @param scheduleStartDate
     * @param scheduleEndDate
     */
    private List<DayShiftRevenueStatVo> getLast90DaysHistoryData(
            String groupId, LocalDate scheduleStartDate, LocalDate scheduleEndDate) {
        return attendanceDailyRuleService.getGroupShiftHistory90Days(groupId);

    }

    /**
     * 查询用户已工作状态
     */
    private Map<String, UserWorkSituationVo> queryUserWorkSituation(int days, List<WorkstationWithUsersVo> workstations) {
        if (workstations == null || workstations.isEmpty()) {
            return Collections.emptyMap();
        }
        Set<String> userIds = new LinkedHashSet<>();
        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()) {
                    userIds.add(u.getUserId().trim());
                }
            }
        }
        if (userIds.isEmpty()) {
            return Collections.emptyMap();
        }
        UserWorkSituationDto workSituationDto = new UserWorkSituationDto(new ArrayList<>(userIds), days);
        return attendanceDayStatisticsService.queryUserWorkSituation(workSituationDto);
    }

    /**
     * 第九、十步人员规则：{@link ScheduleGroupRuleConfigVo#getFixedScheduling()} + {@link UserWorkSituationVo}；
     * 无配置且无工作状态数据时使用全放行实现便于联调。
     */
    private StaffRuleEvaluationPort buildStaffRuleEvaluationPort(
            ScheduleGroupRuleConfigVo scheduleGroupRuleConfigVo,
            Map<String, UserWorkSituationVo> userWorkSituationVoMap) {
        if (scheduleGroupRuleConfigVo == null
                && (userWorkSituationVoMap == null || userWorkSituationVoMap.isEmpty())) {
            return new StaffRuleEvaluationPortPermissive();
        }
        return new StaffRuleEvaluationPortUsingScheduleRulesAndWorkSituation(
                scheduleGroupRuleConfigVo == null ? null : scheduleGroupRuleConfigVo.getFixedScheduling(),
                userWorkSituationVoMap == null ? Collections.emptyMap() : userWorkSituationVoMap);
    }

    /**
     * 查询工作站配置（门店/组织在排班区间内可用工作站、岗位能力、可排人员绑定等）。
     *
     * @param groupId           考勤组 ID
     * @param scheduleStartDate 排班区间起始日（含）
     * @param scheduleEndDate   排班区间结束日（含）
     */
    private List<WorkstationWithUsersVo> queryWorkstationConfig(
            String groupId, LocalDate scheduleStartDate, LocalDate scheduleEndDate) {
        return workstationService.listWorkstationsByGroupId(
                groupId, toDateAtStartOfDay(scheduleStartDate), toDateAtStartOfDay(scheduleEndDate));
    }

    private static Date toDateAtStartOfDay(LocalDate day) {
        Objects.requireNonNull(day, "day");
        return Date.from(day.atStartOfDay(ZoneId.systemDefault()).toInstant());
    }

    /**
     * 查询排班规则（第二步～第八步算法侧配置：相似日、需求聚合、固定班发现、划线兜底等）。
     *
     * @param groupId           门店/组织等业务主键

     * @return 规则数据；占位返回 {@code null}，实现时建议组装为 {@link } 所需各 Config
     */
    private ScheduleGroupRuleConfigVo queryScheduleRuleConfig(String groupId) throws HandleException, QueryException {
       return scheduleGroupRuleConfigService.getRuleConfig(groupId);

    }

    /**
     * 第八步划线参数：有考勤组划线配置时映射最短/最长/段数；否则算法默认（2h～4h、每日每岗最多 4 段）。
     */
    private static LineShiftCoverConfig resolveLineShiftCoverConfig(ScheduleGroupRuleConfigVo scheduleGroupRuleConfigVo) {
        if (scheduleGroupRuleConfigVo != null && scheduleGroupRuleConfigVo.getLineScheduling() != null) {
            return LineShiftCoverConfig.fromLineSchedulingRule(scheduleGroupRuleConfigVo.getLineScheduling());
        }
        return LineShiftCoverConfig.defaults();
    }

    /**
     * 获取指定考勤组班次列表——智能排班：`null` 与空列表语义见 §6.1 / §6.2。
     *
     * @param groupId 考勤组 ID
     * @return 班次 VO 列表，或 {@code null}
     */
    private List<ShiftPeriodVo> getPeriodListForIntelligentSchedule(String groupId) {
        return attendanceShiftSettingService.periodListForIntelligentSchedule(groupId);
    }

    /** 考勤班次匹配阈值：YAML {@value #PROPERTY_ATTEND_MATCH_MIN_HISTORY_COVERAGE} / {@value #PROPERTY_ATTEND_MATCH_MIN_CANDIDATE_EFFICIENCY}。 */
    private AttendanceGroupShiftMatchConfig resolveAttendanceGroupShiftMatchConfig() {
        AttendanceGroupShiftMatchConfig defaults = AttendanceGroupShiftMatchConfig.defaults();
        double cov = parseRatio01Inclusive(attendanceShiftMatchMinHistoryCoverage, defaults.getMinHistoryCoverageRatio());
        double eff =
                parseRatio01Inclusive(
                        attendanceShiftMatchMinCandidateEfficiency,
                        defaults.getMinCandidateEfficiencyRatio());
        return new AttendanceGroupShiftMatchConfig(cov, eff);
    }

    private static double parseRatio01Inclusive(String raw, double fallback) {
        if (raw == null || raw.trim().isEmpty()) {
            return clamp01(fallback);
        }
        try {
            double v = Double.parseDouble(raw.trim().replace(",", "."));
            return clamp01(v);
        } catch (NumberFormatException ex) {
            return clamp01(fallback);
        }
    }

    private static double clamp01(double v) {
        if (v < 0) {
            return 0;
        }
        if (v > 1) {
            return 1;
        }
        return v;
    }

    private String resolveTenantIdForPermissionApi() {
        try {
            if (UserProvider.getUser() != null) {
                return UserProvider.getUser().getTenantId();
            }
        } catch (Exception ex) {
            log.error(
                    "{} phase=POSITION_LOOKUP | tenant unavailable ({}); permission tenantId=null",
                    AutoSchedulePipelineLog.MARKER,
                    ex.toString());
        }
        return null;
    }

    /** 产品与文档 §7.4：历史需求中的岗位 id 在主数据不存在时追加 incomplete（不阻断流水线）。 */
    private List<ShiftPlanAssignmentResult.IncompleteScheduleReason> buildHistoricalPostsMissingMasterDataNotes(
            Map<LocalDate, List<ShiftPlanBlock>> planAfterMatcher) {
        if (planAfterMatcher == null || planAfterMatcher.isEmpty()) {
            return Collections.emptyList();
        }
        LinkedHashSet<String> postIds = new LinkedHashSet<>();
        for (List<ShiftPlanBlock> blocks : planAfterMatcher.values()) {
            if (blocks == null) {
                continue;
            }
            for (ShiftPlanBlock block : blocks) {
                for (ShiftPlanPostNeed pn : block.getPostNeeds()) {
                    if (pn == null) {
                        continue;
                    }
                    String pid = pn.getPostId();
                    if (pid == null || pid.isBlank()) {
                        continue;
                    }
                    postIds.add(pid.trim());
                }
            }
        }
        if (postIds.isEmpty()) {
            return Collections.emptyList();
        }
        ActionResult<List<PositionBaseInfoVO>> resolved =
                v2PositionApi.listPositionBaseInfoByIds(new ArrayList<>(postIds), resolveTenantIdForPermissionApi());
        if (resolved == null || resolved.getCode() != 200 || resolved.getData() == null) {
            log.error(
                    "{} phase=POSITION_LOOKUP | listPositionBaseInfoByIds failed — skip §7.4 master-post checks (code={})",
                    AutoSchedulePipelineLog.MARKER,
                    resolved == null ? "null" : resolved.getCode());
            return Collections.emptyList();
        }
        Set<String> existingIds =
                resolved.getData().stream()
                        .map(PositionBaseInfoVO::getId)
                        .filter(Objects::nonNull)
                        .map(String::trim)
                        .filter(id -> !id.isEmpty())
                        .collect(Collectors.toSet());
        LinkedHashSet<String> missing = new LinkedHashSet<>();
        for (String pid : postIds) {
            if (!existingIds.contains(pid)) {
                missing.add(pid);
            }
        }
        if (missing.isEmpty()) {
            return Collections.emptyList();
        }

        ArrayList<LocalDate> sortedDays = new ArrayList<>(planAfterMatcher.keySet());
        sortedDays.sort(Comparator.naturalOrder());
        LinkedHashMap<String, ShiftPlanAssignmentResult.IncompleteScheduleReason> byDayPost = new LinkedHashMap<>();
        for (LocalDate d : sortedDays) {
            List<ShiftPlanBlock> blocks = planAfterMatcher.get(d);
            if (d == null || blocks == null) {
                continue;
            }
            for (ShiftPlanBlock block : blocks) {
                for (ShiftPlanPostNeed pn : block.getPostNeeds()) {
                    if (pn == null) {
                        continue;
                    }
                    String pid = pn.getPostId();
                    if (pid == null || pid.isBlank()) {
                        continue;
                    }
                    pid = pid.trim();
                    if (!missing.contains(pid)) {
                        continue;
                    }
                    String composite = d + "|" + pid;
                    byDayPost.putIfAbsent(
                            composite,
                            new ShiftPlanAssignmentResult.IncompleteScheduleReason(
                                    d,
                                    "",
                                    pid,
                                    ShiftPlanAssignmentResult.INCOMPLETE_HISTORICAL_POST_MISSING_MASTER,
                                    MSG_HISTORICAL_POST_NOT_IN_MASTER + "（岗位ID=" + pid + "）"));
                }
            }
        }
        return new ArrayList<>(byDayPost.values());
    }
}
