package jnpf.attendance.schedule;

import jnpf.model.attendance.vo.DayShiftRevenueStatVo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
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.stream.Collectors;

/**
 * 根据近 90 天按日班次营业额统计与目标日预估营业额，筛选用于排班模板的历史参考日。
 *
 * <p>规则顺序（与产品说明一致）：
 * <ol>
 *   <li>在最近 90 天窗口内（不含目标排班日本身），取与目标「同星期几」的历史日；</li>
 *   <li>营业额优先落在预估 ±λ₁ 区间内（默认 λ₁=0.10）；若样本数 ≥ minBandSampleDays（默认 10）则采用；</li>
 *   <li>否则在同星期几上放宽到 ±λ₂（默认 0.15）；同上样本阈值；</li>
 *   <li>若仍不足，改为「同工作日/周末类型」且 ±λ₂；同上样本阈值；</li>
 *   <li>若仍不足，在全窗口内按 |历史营业额−预估| 升序取前 n 个自然日（n∈[1,10]，默认 10）；</li>
 *   <li>若仍无任何候选日，返回失败及「无参考数据，无法智能排班。」</li>
 * </ol>
 *
 * <p>营业额带（预估非 0、非 null）：历史营业额 R ∈ [预估×(1−λ), 预估×(1+λ)]；tier1 用 λ₁，tier2/tier3 用 λ₂（默认 0.10 / 0.15，可由构造参数或 {@code AutoScheduleService} 配置覆盖）。
 * 预估为 0 或 null 时，不按营业额过滤（该档内窗口内日期在营业额条件上全部通过）。
 */
public final class ScheduleTemplateSimilarDaysAlgorithm {

    private static final Logger LOG = LoggerFactory.getLogger(ScheduleTemplateSimilarDaysAlgorithm.class);

    /** 回看自然日窗口长度（与常见「近 90 天」统计一致） */
    public static final int LOOKBACK_DAYS = 90;

    /**
     * 档位 1～3（及同类型合并分支）要求的最少样本天数默认值；
     * 运行时可由 {@code new ScheduleTemplateSimilarDaysAlgorithm(topCap, minSamples)} 或业务入口注入覆盖。
     */
    public static final int DEFAULT_MIN_BAND_SAMPLE_DAYS = 10;

    /**
     * 与 {@link #DEFAULT_MIN_BAND_SAMPLE_DAYS} 同义，兼容旧代码与文档中的 {@code MIN_SAMPLE_DAYS} 称呼。
     */
    public static final int MIN_SAMPLE_DAYS = DEFAULT_MIN_BAND_SAMPLE_DAYS;

    /** tier1（同星期几·严带）默认半宽 λ，即 ±10%。 */
    public static final BigDecimal DEFAULT_BAND_STRICT = new BigDecimal("0.10");

    /** tier2 / tier3（同星期几·宽带、同工作日周末类型）默认半宽 λ，即 ±15%。 */
    public static final BigDecimal DEFAULT_BAND_RELAXED = new BigDecimal("0.15");

    private final int topNearestCap;

    /** 营业额带各档（tier1～3 及 tier2∪tier3 合并）采用前的最少样本天数，区间 [1, {@link #LOOKBACK_DAYS}] */
    private final int minBandSampleDays;

    /** tier1：`filterSameDowAndBand(..., λ₁)` */
    private final BigDecimal bandStrict;

    /** tier2、tier3：`filterSame...AndBand(..., λ₂)` */
    private final BigDecimal bandRelaxed;

    public ScheduleTemplateSimilarDaysAlgorithm() {
        this(10, DEFAULT_MIN_BAND_SAMPLE_DAYS, DEFAULT_BAND_STRICT, DEFAULT_BAND_RELAXED);
    }

    /**
     * @param topNearestCap 第 6 步 Top n 上限，限制在 1～10
     */
    public ScheduleTemplateSimilarDaysAlgorithm(int topNearestCap) {
        this(topNearestCap, DEFAULT_MIN_BAND_SAMPLE_DAYS, DEFAULT_BAND_STRICT, DEFAULT_BAND_RELAXED);
    }

    /**
     * @param topNearestCap 第 6 步 Top n 上限，限制在 1～10
     * @param minBandSampleDays 档位 1～3 要求的最少样本天数，clamp 到 [1, {@link #LOOKBACK_DAYS}]
     */
    public ScheduleTemplateSimilarDaysAlgorithm(int topNearestCap, int minBandSampleDays) {
        this(topNearestCap, minBandSampleDays, DEFAULT_BAND_STRICT, DEFAULT_BAND_RELAXED);
    }

    /**
     * @param bandStrict  tier1 营业额半宽 λ₁，须在 (0,1)，否则回退 {@link #DEFAULT_BAND_STRICT}
     * @param bandRelaxed tier2/tier3 营业额半宽 λ₂，须在 (0,1)，否则回退 {@link #DEFAULT_BAND_RELAXED}
     */
    public ScheduleTemplateSimilarDaysAlgorithm(
            int topNearestCap, int minBandSampleDays, BigDecimal bandStrict, BigDecimal bandRelaxed) {
        int cap = topNearestCap;
        if (cap < 1) {
            cap = 1;
        }
        if (cap > 10) {
            cap = 10;
        }
        this.topNearestCap = cap;

        int ms = minBandSampleDays;
        if (ms < 1) {
            ms = 1;
        }
        if (ms > LOOKBACK_DAYS) {
            ms = LOOKBACK_DAYS;
        }
        this.minBandSampleDays = ms;

        this.bandStrict = coerceRevenueHalfWidthLambda(bandStrict, DEFAULT_BAND_STRICT, "bandStrict");
        this.bandRelaxed = coerceRevenueHalfWidthLambda(bandRelaxed, DEFAULT_BAND_RELAXED, "bandRelaxed");
    }

    private static BigDecimal coerceRevenueHalfWidthLambda(BigDecimal value, BigDecimal fallback, String label) {
        BigDecimal v = value != null ? value : fallback;
        if (v.compareTo(BigDecimal.ZERO) <= 0 || v.compareTo(BigDecimal.ONE) >= 0) {
            LOG.error(
                    "[排班模板] 营业额带 {}={} 非法（须在开区间 (0,1)），使用默认 {}",
                    label,
                    Objects.toString(value, "null"),
                    fallback.toPlainString());
            return fallback;
        }
        return v;
    }

    /**
     * 对排班区间内每个目标日，分别计算参考历史日。
     *
     * @param dayShiftRevenueStatVoList 近窗按日统计（通常含约 90 天）
     * @param estimatedRevenueByDay     目标自然日 → 预估营业额（key 为排班日）
     */
    public Map<LocalDate, ScheduleTemplateSimilarDaysResult> findForScheduleDays(
            List<DayShiftRevenueStatVo> dayShiftRevenueStatVoList,
            Map<LocalDate, BigDecimal> estimatedRevenueByDay) {
        if (estimatedRevenueByDay == null || estimatedRevenueByDay.isEmpty()) {
            LOG.error("[排班模板][多日] estimatedRevenueByDay 为空，跳过");
            return Collections.emptyMap();
        }
        LOG.error(
                "[排班模板][多日] 开始：排班日数={}，历史 VO 条数={}",
                estimatedRevenueByDay.size(),
                dayShiftRevenueStatVoList == null ? 0 : dayShiftRevenueStatVoList.size());
        Map<LocalDate, ScheduleTemplateSimilarDaysResult> out = new LinkedHashMap<>();
        for (Map.Entry<LocalDate, BigDecimal> e : estimatedRevenueByDay.entrySet()) {
            if (e.getKey() == null) {
                continue;
            }
            ScheduleTemplateSimilarDaysResult one =
                    findSimilarTemplateDays(dayShiftRevenueStatVoList, e.getKey(), e.getValue());
            out.put(e.getKey(), one);
            LOG.error(
                    "[排班模板][多日] 单日完成：排班日={} 预估营业额={} success={} tier={} similarCount={}",
                    e.getKey(),
                    e.getValue(),
                    one.isSuccess(),
                    one.getTier(),
                    one.getSimilarDates().size());
        }
        return out;
    }

    /**
     * 为单个目标排班日寻找模板参考历史日。
     */
    public ScheduleTemplateSimilarDaysResult findSimilarTemplateDays(
            List<DayShiftRevenueStatVo> dayShiftRevenueStatVoList,
            LocalDate targetScheduleDay,
            BigDecimal targetEstimatedRevenue) {
        Objects.requireNonNull(targetScheduleDay, "targetScheduleDay");

        LOG.error(
                "[排班模板][单日] 输入参数：dayShiftRevenueStatVoList={} (size={})，targetScheduleDay={}，targetEstimatedRevenue={}",
                summarizeDayShiftRevenueStatVoList(dayShiftRevenueStatVoList),
                dayShiftRevenueStatVoList == null ? 0 : dayShiftRevenueStatVoList.size(),
                targetScheduleDay,
                targetEstimatedRevenue == null ? "null" : targetEstimatedRevenue.toPlainString());

        LOG.error(
                "[排班模板][单日] ========== 开始：目标排班日={} 星期={} 预估营业额={} 回看天数={} 带状档最少样本={} λ1={} λ2={} TopN上限={}",
                targetScheduleDay,
                targetScheduleDay.getDayOfWeek(),
                targetEstimatedRevenue,
                LOOKBACK_DAYS,
                minBandSampleDays,
                bandStrict.toPlainString(),
                bandRelaxed.toPlainString(),
                topNearestCap);

        LocalDate windowStart = targetScheduleDay.minusDays(LOOKBACK_DAYS);
        List<DayRow> inWindow = filterWindow(dayShiftRevenueStatVoList, windowStart, targetScheduleDay);
        LOG.error(
                "[排班模板][单日] 步骤0-窗口：左闭右开 [{}, {}) 内有效历史日数={}",
                windowStart,
                targetScheduleDay,
                inWindow.size());
        if (inWindow.isEmpty()) {
            LOG.error("[排班模板][单日] 目标日={} 窗口内无有效历史日 → 失败", targetScheduleDay);
            SchedulingForTestCheckLog.info(
                    LOG,
                    SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                    "similar_days_revenue",
                    String.format(
                            "algo=ScheduleTemplateSimilarDaysAlgorithm | targetScheduleDay=%s | dow=%s | targetEstimatedRevenue=%s "
                                    + "| historyWindowLeftClosedRightOpen=[%s,%s) | inWindowEffectiveDays=%d | minBandSampleDays=%d | topNearestCap=%d "
                                    + "| bandStrict_lambda=%s | bandRelaxed_lambda=%s | outcome=FAIL | reason=WINDOW_EMPTY | bizMessage=%s",
                            targetScheduleDay,
                            targetScheduleDay.getDayOfWeek(),
                            targetEstimatedRevenue == null ? "null" : targetEstimatedRevenue.toPlainString(),
                            windowStart,
                            targetScheduleDay,
                            inWindow.size(),
                            minBandSampleDays,
                            topNearestCap,
                            bandStrict.toPlainString(),
                            bandRelaxed.toPlainString(),
                            ScheduleTemplateSimilarDaysResult.MSG_NO_REFERENCE_DATA));
            return ScheduleTemplateSimilarDaysResult.failure(ScheduleTemplateSimilarDaysResult.MSG_NO_REFERENCE_DATA);
        }

        SchedulingForTestCheckLog.info(
                LOG,
                SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                "similar_days_revenue",
                String.format(
                        "algo=ScheduleTemplateSimilarDaysAlgorithm | targetScheduleDay=%s | dow=%s | targetEstimatedRevenue=%s "
                                + "| historyWindowLeftClosedRightOpen=[%s,%s) | inWindowEffectiveDays=%d | minBandSampleDays=%d | topNearestCap=%d "
                                + "| bandStrict_lambda=%s | bandRelaxed_lambda=%s",
                        targetScheduleDay,
                        targetScheduleDay.getDayOfWeek(),
                        targetEstimatedRevenue == null ? "null" : targetEstimatedRevenue.toPlainString(),
                        windowStart,
                        targetScheduleDay,
                        inWindow.size(),
                        minBandSampleDays,
                        topNearestCap,
                        bandStrict.toPlainString(),
                        bandRelaxed.toPlainString()));

        DayOfWeek targetDow = targetScheduleDay.getDayOfWeek();
        boolean targetWeekend = isWeekend(targetDow);
        int sameDowInPool = countSameDowInPool(inWindow, targetDow);
        LOG.error(
                "[排班模板][单日] 步骤0-统计：目标为{} 窗口内同星期几历史日数={}",
                targetWeekend ? "周末" : "工作日",
                sameDowInPool);
        logRevenueBand("步骤1-同星期几·严带(λ1)", targetEstimatedRevenue, bandStrict);

        // tier1：同星期几 × λ₁
        List<DayRow> tier1 = filterSameDowAndBand(inWindow, targetDow, targetEstimatedRevenue, bandStrict);
        LOG.error("[排班模板][单日] 步骤1-同星期几·严带：命中样本数={}（阈值>={}）", tier1.size(), minBandSampleDays);
        SchedulingForTestCheckLog.info(
                LOG,
                SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                "similar_days_revenue_tier_decision",
                String.format(
                        "algo=ScheduleTemplateSimilarDaysAlgorithm | step=STEP1_SAME_WEEKDAY_STRICT_BAND_lambda1 "
                                + "| targetScheduleDay=%s | tierSampleCount=%d | minBandSampleDays=%s | decision=%s",
                        targetScheduleDay,
                        tier1.size(),
                        minBandSampleDays,
                        tier1.size() >= minBandSampleDays ? "ACCEPT" : "SKIP_TO_STEP2"));
        if (tier1.size() >= minBandSampleDays) {
            List<LocalDate> dates = datesOf(tier1);
            logResultReturn(targetScheduleDay, ScheduleTemplateSimilarDaysResult.SelectionTier.SAME_WEEKDAY_STRICT_BAND, dates);
            SchedulingForTestCheckLog.info(
                    LOG,
                    SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                    "similar_days_revenue_outcome",
                    String.format(
                            "algo=ScheduleTemplateSimilarDaysAlgorithm | outcome=OK | tier=%s | similarCount=%d | similarDates=%s",
                            ScheduleTemplateSimilarDaysResult.SelectionTier.SAME_WEEKDAY_STRICT_BAND,
                            dates.size(),
                            SchedulingForTestCheckLog.formatDatesBrief(dates, SchedulingForTestCheckLog.DEFAULT_DATE_CAP)));
            return ScheduleTemplateSimilarDaysResult.ok(
                    dates, ScheduleTemplateSimilarDaysResult.SelectionTier.SAME_WEEKDAY_STRICT_BAND);
        }

        logRevenueBand("步骤2-同星期几·宽带(λ2)", targetEstimatedRevenue, bandRelaxed);
        // tier2：同星期几 × λ₂
        List<DayRow> tier2 = filterSameDowAndBand(inWindow, targetDow, targetEstimatedRevenue, bandRelaxed);
        LOG.error("[排班模板][单日] 步骤2-同星期几·宽带：命中样本数={}（阈值>={}）", tier2.size(), minBandSampleDays);
        SchedulingForTestCheckLog.info(
                LOG,
                SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                "similar_days_revenue_tier_decision",
                String.format(
                        "algo=ScheduleTemplateSimilarDaysAlgorithm | step=STEP2_SAME_WEEKDAY_RELAXED_BAND_lambda2 "
                                + "| targetScheduleDay=%s | tierSampleCount=%d | minBandSampleDays=%s | decision=%s",
                        targetScheduleDay,
                        tier2.size(),
                        minBandSampleDays,
                        tier2.size() >= minBandSampleDays ? "ACCEPT" : "SKIP_TO_STEP3"));
        if (tier2.size() >= minBandSampleDays) {
            List<LocalDate> dates = datesOf(tier2);
            logResultReturn(targetScheduleDay, ScheduleTemplateSimilarDaysResult.SelectionTier.SAME_WEEKDAY_RELAXED_BAND, dates);
            SchedulingForTestCheckLog.info(
                    LOG,
                    SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                    "similar_days_revenue_outcome",
                    String.format(
                            "algo=ScheduleTemplateSimilarDaysAlgorithm | outcome=OK | tier=%s | similarCount=%d | similarDates=%s",
                            ScheduleTemplateSimilarDaysResult.SelectionTier.SAME_WEEKDAY_RELAXED_BAND,
                            dates.size(),
                            SchedulingForTestCheckLog.formatDatesBrief(dates, SchedulingForTestCheckLog.DEFAULT_DATE_CAP)));
            return ScheduleTemplateSimilarDaysResult.ok(
                    dates, ScheduleTemplateSimilarDaysResult.SelectionTier.SAME_WEEKDAY_RELAXED_BAND);
        }

        LOG.error(
                "[排班模板][单日] 步骤3-同工作日/周末类型·宽带(λ2)：开始（λ2={}）",
                bandRelaxed.toPlainString());
        // tier3：同工作日/周末类型 × λ₂
        List<DayRow> tier3 =
                filterSameWorkdayTypeAndBand(inWindow, targetWeekend, targetEstimatedRevenue, bandRelaxed);
        int sameTypeInPool = countSameWorkdayTypeInPool(inWindow, targetWeekend);
        LOG.error(
                "[排班模板][单日] 步骤3-同类型·宽带：同类型历史日数={} 命中营业额带样本数={}（阈值>={}）",
                sameTypeInPool,
                tier3.size(),
                minBandSampleDays);
        if (tier3.size() >= minBandSampleDays) {
            List<LocalDate> dates = datesOf(tier3);
            logResultReturn(
                    targetScheduleDay,
                    ScheduleTemplateSimilarDaysResult.SelectionTier.SAME_WORKDAY_TYPE_RELAXED_BAND,
                    dates);
            SchedulingForTestCheckLog.info(
                    LOG,
                    SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                    "similar_days_revenue_outcome",
                    String.format(
                            "algo=ScheduleTemplateSimilarDaysAlgorithm | outcome=OK | tier=%s | path=PURE_TIER3_BAND "
                                    + "| tier3SampleCount=%d | combinedNotUsed=true | similarCount=%d | similarDates=%s",
                            ScheduleTemplateSimilarDaysResult.SelectionTier.SAME_WORKDAY_TYPE_RELAXED_BAND,
                            tier3.size(),
                            dates.size(),
                            SchedulingForTestCheckLog.formatDatesBrief(dates, SchedulingForTestCheckLog.DEFAULT_DATE_CAP)));
            return ScheduleTemplateSimilarDaysResult.ok(
                    dates, ScheduleTemplateSimilarDaysResult.SelectionTier.SAME_WORKDAY_TYPE_RELAXED_BAND);
        } else if (tier3.size() > 0) {
            Set<DayRow> combined = new LinkedHashSet<>(tier2);
            combined.addAll(tier3);
            SchedulingForTestCheckLog.info(
                    LOG,
                    SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                    "similar_days_revenue_tier_decision",
                    String.format(
                            "algo=ScheduleTemplateSimilarDaysAlgorithm | step=STEP3_SAME_WORKDAY_TYPE_RELAXED_BAND_fallback "
                                    + "| targetScheduleDay=%s | tier3AloneSampleCount=%d | tier3BelowThreshold=yes "
                                    + "| tier2_union_tier3_distinct_days=%d | minBandSampleDays=%s | decision=%s",
                            targetScheduleDay,
                            tier3.size(),
                            combined.size(),
                            minBandSampleDays,
                            combined.size() >= minBandSampleDays ? "ACCEPT_COMBINED_T2_PLUS_T3" : "SKIP_TO_TOPN"));
            if (combined.size() >= minBandSampleDays) {
                List<LocalDate> dates = combined.stream().map(d -> d.date).collect(Collectors.toList());
                logResultReturn(
                        targetScheduleDay,
                        ScheduleTemplateSimilarDaysResult.SelectionTier.SAME_WORKDAY_TYPE_RELAXED_BAND,
                        dates);
                SchedulingForTestCheckLog.info(
                        LOG,
                        SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                        "similar_days_revenue_outcome",
                        String.format(
                                "algo=ScheduleTemplateSimilarDaysAlgorithm | outcome=OK | tier=%s | path=COMBINED_TIER2_PLUS_TIER3 "
                                        + "| similarCount=%d | similarDates=%s",
                                ScheduleTemplateSimilarDaysResult.SelectionTier.SAME_WORKDAY_TYPE_RELAXED_BAND,
                                dates.size(),
                                SchedulingForTestCheckLog.formatDatesBrief(dates, SchedulingForTestCheckLog.DEFAULT_DATE_CAP)));
                return ScheduleTemplateSimilarDaysResult.ok(
                        dates, ScheduleTemplateSimilarDaysResult.SelectionTier.SAME_WORKDAY_TYPE_RELAXED_BAND);
            }
        } else {
            SchedulingForTestCheckLog.info(
                    LOG,
                    SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                    "similar_days_revenue_tier_decision",
                    String.format(
                            "algo=ScheduleTemplateSimilarDaysAlgorithm | step=STEP3_SAME_WORKDAY_TYPE_RELAXED_BAND "
                                    + "| targetScheduleDay=%s | tier3SampleCount=0 | decision=SKIP_TO_TOPN",
                            targetScheduleDay));
        }

        SchedulingForTestCheckLog.info(
                LOG,
                SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                "similar_days_revenue_tier_decision",
                String.format(
                        "algo=ScheduleTemplateSimilarDaysAlgorithm | targetScheduleDay=%s | step=STEP4_TOP_NEAREST_ENTER "
                                + "| precedingBandTiers=ALL_SKIPPED_OR_INSUFFICIENT_SAMPLE",
                        targetScheduleDay));

        // 6：营业额最接近 Top n
        LOG.error("[排班模板][单日] 步骤4-Top最近营业额：全窗口候选数={} 取前 n={}", inWindow.size(), topNearestCap);
        List<LocalDate> top = topNearestByRevenue(inWindow, targetEstimatedRevenue, topNearestCap);
        if (top.isEmpty()) {
            LOG.error("[排班模板][单日] 目标日={} TopN 阶段无候选 → 失败", targetScheduleDay);
            SchedulingForTestCheckLog.info(
                    LOG,
                    SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                    "similar_days_revenue",
                    String.format(
                            "algo=ScheduleTemplateSimilarDaysAlgorithm | targetScheduleDay=%s | step=STEP4_TOP_NEAREST_REVENUE "
                                    + "| inWindowCandidates=%d | topNearestCap=%d | outcome=FAIL | bizMessage=%s",
                            targetScheduleDay,
                            inWindow.size(),
                            topNearestCap,
                            ScheduleTemplateSimilarDaysResult.MSG_NO_REFERENCE_DATA));
            return ScheduleTemplateSimilarDaysResult.failure(ScheduleTemplateSimilarDaysResult.MSG_NO_REFERENCE_DATA);
        }
        logResultReturn(targetScheduleDay, ScheduleTemplateSimilarDaysResult.SelectionTier.TOP_NEAREST_REVENUE, top);
        SchedulingForTestCheckLog.info(
                LOG,
                SchedulingForTestCheckLog.TEST_ISSUE_1_SIMILAR_HISTORICAL_DATES,
                "similar_days_revenue_outcome",
                String.format(
                        "algo=ScheduleTemplateSimilarDaysAlgorithm | outcome=OK | tier=%s | path=TOP_NEAREST_AFTER_BAND_FAIL "
                                + "| similarCount=%d | similarDates=%s",
                        ScheduleTemplateSimilarDaysResult.SelectionTier.TOP_NEAREST_REVENUE,
                        top.size(),
                        SchedulingForTestCheckLog.formatDatesBrief(top, SchedulingForTestCheckLog.DEFAULT_DATE_CAP)));
        return ScheduleTemplateSimilarDaysResult.ok(top, ScheduleTemplateSimilarDaysResult.SelectionTier.TOP_NEAREST_REVENUE);
    }

    private static void logRevenueBand(String stepLabel, BigDecimal targetRevenue, BigDecimal lambda) {
        if (targetRevenue == null || targetRevenue.signum() == 0) {
            LOG.error("[排班模板][单日] {}：预估为 null/0 → 不按营业额过滤", stepLabel);
            return;
        }
        BigDecimal low = targetRevenue.multiply(BigDecimal.ONE.subtract(lambda));
        BigDecimal high = targetRevenue.multiply(BigDecimal.ONE.add(lambda));
        LOG.error(
                "[排班模板][单日] {}：R∈[预估×(1-λ), 预估×(1+λ)] 预估={} λ={} → [{}, {}]",
                stepLabel,
                targetRevenue.toPlainString(),
                lambda.toPlainString(),
                low.toPlainString(),
                high.toPlainString());
    }

    private static void logResultReturn(
            LocalDate target, ScheduleTemplateSimilarDaysResult.SelectionTier tier, List<LocalDate> dates) {
        LOG.error(
                "[排班模板][单日] ========== 返回：目标日={} tier={} similarDates.size={} dates={}",
                target,
                tier,
                dates.size(),
                dates);
    }

    /**
     * 输入列表摘要（避免 90 天全量刷屏）：前几条 day/revenue，条数多时用省略号。
     */
    private static String summarizeDayShiftRevenueStatVoList(List<DayShiftRevenueStatVo> list) {
        if (list == null) {
            return "null";
        }
        if (list.isEmpty()) {
            return "[]";
        }
        int n = list.size();
        int head = Math.min(5, n);
        StringBuilder sb = new StringBuilder(128);
        sb.append('[');
        for (int i = 0; i < head; i++) {
            if (i > 0) {
                sb.append(", ");
            }
            DayShiftRevenueStatVo vo = list.get(i);
            if (vo == null) {
                sb.append("null");
            } else {
                sb.append('{')
                        .append(vo.getDay())
                        .append(',')
                        .append(vo.getRevenue() == null ? "null" : vo.getRevenue().toPlainString())
                        .append('}');
            }
        }
        if (n > head) {
            sb.append(", ...(共").append(n).append("条)");
        }
        sb.append(']');
        return sb.toString();
    }

    private static int countSameDowInPool(List<DayRow> pool, DayOfWeek dow) {
        int c = 0;
        for (DayRow d : pool) {
            if (d.date.getDayOfWeek() == dow) {
                c++;
            }
        }
        return c;
    }

    private static int countSameWorkdayTypeInPool(List<DayRow> pool, boolean targetWeekend) {
        int c = 0;
        for (DayRow d : pool) {
            if (isWeekend(d.date.getDayOfWeek()) == targetWeekend) {
                c++;
            }
        }
        return c;
    }

    private static final class DayRow {
        final LocalDate date;
        final BigDecimal revenue;

        DayRow(LocalDate date, BigDecimal revenue) {
            this.date = date;
            this.revenue = revenue;
        }
    }

    private static List<DayRow> filterWindow(
            List<DayShiftRevenueStatVo> list, LocalDate windowStartInclusive, LocalDate targetExclusive) {
        if (list == null || list.isEmpty()) {
            return new ArrayList<>();
        }
        List<DayRow> out = new ArrayList<>();
        for (DayShiftRevenueStatVo vo : list) {
            if (vo == null || vo.getDay() == null || vo.getDay().isEmpty()) {
                continue;
            }
            LocalDate date =
                    AttendanceScheduleDayParse.tryParse(vo.getDay()).orElse(null);
            if (date == null) {
                continue;
            }
            if (!date.isBefore(windowStartInclusive) && date.isBefore(targetExclusive)) {
                out.add(new DayRow(date, vo.getRevenue()));
            }
        }
        return out;
    }

    private static List<DayRow> filterSameDowAndBand(
            List<DayRow> pool, DayOfWeek dow, BigDecimal targetRevenue, BigDecimal relativeBand) {
        List<DayRow> out = new ArrayList<>();
        for (DayRow d : pool) {
            if (d.date.getDayOfWeek() == dow && inRevenueBand(d.revenue, targetRevenue, relativeBand)) {
                out.add(d);
            }
        }
        return out;
    }

    private static List<DayRow> filterSameWorkdayTypeAndBand(
            List<DayRow> pool, boolean targetWeekend, BigDecimal targetRevenue, BigDecimal relativeBand) {
        List<DayRow> out = new ArrayList<>();
        for (DayRow d : pool) {
            if (isWeekend(d.date.getDayOfWeek()) == targetWeekend && inRevenueBand(d.revenue, targetRevenue, relativeBand)) {
                out.add(d);
            }
        }
        return out;
    }

    /**
     * 预估为 0 或 null 时：不按营业额过滤（该档内全部通过营业额条件）。
     */
    private static boolean inRevenueBand(BigDecimal historicalRevenue, BigDecimal targetRevenue, BigDecimal relativeHalfWidth) {
        if (targetRevenue == null) {
            throw new IllegalArgumentException("预估营业额不能为null");
        }
        if (targetRevenue.signum() < 0) {
            throw new IllegalArgumentException("预估营业额不能为负数: " + targetRevenue);
        }
        BigDecimal low = targetRevenue.multiply(BigDecimal.ONE.subtract(relativeHalfWidth));
        BigDecimal high = targetRevenue.multiply(BigDecimal.ONE.add(relativeHalfWidth));
        BigDecimal r = historicalRevenue == null ? BigDecimal.ZERO : historicalRevenue;
        return r.compareTo(low) >= 0 && r.compareTo(high) <= 0;
    }

    private static boolean isWeekend(DayOfWeek dow) {
        return dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY;
    }

    private static List<LocalDate> datesOf(List<DayRow> rows) {
        return rows.stream().map(d -> d.date).collect(Collectors.toList());
    }

    private static List<LocalDate> topNearestByRevenue(List<DayRow> pool, BigDecimal targetRevenue, int n) {
        BigDecimal center = targetRevenue == null ? BigDecimal.ZERO : targetRevenue;
        List<DayRow> sorted = new ArrayList<>(pool);
        sorted.sort(Comparator.comparing(d -> diffAbs(d.revenue, center)));
        Set<LocalDate> seen = new LinkedHashSet<>();
        List<LocalDate> out = new ArrayList<>();
        for (DayRow d : sorted) {
            if (seen.add(d.date)) {
                out.add(d.date);
                if (out.size() >= n) {
                    break;
                }
            }
        }
        return out;
    }

    private static BigDecimal diffAbs(BigDecimal revenue, BigDecimal center) {
        BigDecimal r = revenue == null ? BigDecimal.ZERO : revenue;
        return r.subtract(center).abs().setScale(2, RoundingMode.HALF_UP);
    }
}
