package schedulerefine

import (
	"encoding/json"
	"fmt"
	"sort"
	"strconv"
	"strings"

	"github.com/LoveLosita/smartflow/backend/logic"
	"github.com/LoveLosita/smartflow/backend/model"
)

// reactToolCall 表示模型输出的单个工具调用指令。
type reactToolCall struct {
	Tool   string         `json:"tool"`
	Params map[string]any `json:"params"`
}

// reactToolResult 表示工具调用的结构化执行结果。
type reactToolResult struct {
	Tool      string `json:"tool"`
	Success   bool   `json:"success"`
	ErrorCode string `json:"error_code,omitempty"`
	Result    string `json:"result"`
}

// reactLLMOutput 表示“强 ReAct”要求的固定 JSON 输出结构。
//
// 字段语义：
// 1. goal_check：本轮要先验证的目标点；
// 2. decision：本轮动作选择依据；
// 3. tool_calls：本轮工具动作列表（业务侧只取第一条）。
type reactLLMOutput struct {
	Done        bool            `json:"done"`
	Summary     string          `json:"summary"`
	GoalCheck   string          `json:"goal_check"`
	Decision    string          `json:"decision"`
	MissingInfo []string        `json:"missing_info,omitempty"`
	ToolCalls   []reactToolCall `json:"tool_calls"`
}

// reviewOutput 表示终审节点要求的固定 JSON 输出结构。
type reviewOutput struct {
	Pass   bool     `json:"pass"`
	Reason string   `json:"reason"`
	Unmet  []string `json:"unmet"`
}

// planningWindow 表示微调工具允许活动的 week/day 边界窗口。
//
// 设计说明：
// 1. 这里用已有 HybridEntries 自动推导窗口，避免把任务移动到完全无关的周；
// 2. 若窗口不可用（没有任何 entry），则降级为“仅做基础合法性校验”。
type planningWindow struct {
	Enabled   bool
	StartWeek int
	StartDay  int
	EndWeek   int
	EndDay    int
}

// refineToolPolicy 是工具层硬约束策略。
//
// 职责边界：
// 1. 负责承载“是否强制保持相对顺序”的策略开关；
// 2. 负责承载顺序校验需要的 origin_order 映射；
// 3. 不负责语义判定（语义仍由 LLM 终审节点负责）。
type refineToolPolicy struct {
	KeepRelativeOrder bool
	OrderScope        string
	OriginOrderMap    map[int]int
}

// dispatchRefineTool 负责把模型输出的 tool_call 分发到具体工具实现。
//
// 步骤化说明：
// 1. 先识别工具名并路由到对应实现；
// 2. 工具实现内部负责参数校验、冲突校验、边界校验、顺序校验；
// 3. 任何失败都返回 Success=false 的结构化结果，而不是直接 panic。
func dispatchRefineTool(entries []model.HybridScheduleEntry, call reactToolCall, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
	switch strings.TrimSpace(call.Tool) {
	case "QueryTargetTasks":
		return refineToolQueryTargetTasks(entries, call.Params, policy)
	case "QueryAvailableSlots":
		return refineToolQueryAvailableSlots(entries, call.Params, window)
	case "Move":
		return refineToolMove(entries, call.Params, window, policy)
	case "Swap":
		return refineToolSwap(entries, call.Params, window, policy)
	case "BatchMove":
		return refineToolBatchMove(entries, call.Params, window, policy)
	case "SpreadEven":
		return refineToolSpreadEven(entries, call.Params, window, policy)
	case "MinContextSwitch":
		return refineToolMinContextSwitch(entries, call.Params, window, policy)
	case "Verify":
		return refineToolVerify(entries, call.Params, policy)
	default:
		return entries, reactToolResult{
			Tool:    strings.TrimSpace(call.Tool),
			Success: false,
			Result:  fmt.Sprintf("不支持的工具：%s（仅允许 QueryTargetTasks/QueryAvailableSlots/Move/Swap/BatchMove/SpreadEven/MinContextSwitch/Verify）", strings.TrimSpace(call.Tool)),
		}
	}
}

// pickSingleToolCall 在“单步动作”策略下选取一个工具调用。
//
// 返回语义：
// 1. call=nil：本轮无可执行动作；
// 2. warn 非空：模型返回了多个调用，本轮只执行第一个并记录告警。
func pickSingleToolCall(calls []reactToolCall) (*reactToolCall, string) {
	if len(calls) == 0 {
		return nil, ""
	}
	call := calls[0]
	if len(calls) == 1 {
		return &call, ""
	}
	return &call, fmt.Sprintf("模型返回了 %d 个工具调用，本轮仅执行第一个：%s", len(calls), call.Tool)
}

// parseReactLLMOutput 解析模型输出的 ReAct JSON。
//
// 容错策略：
// 1. 兼容 ```json 代码块包装；
// 2. 兼容 JSON 前后有解释性文字（提取最外层对象）。
func parseReactLLMOutput(raw string) (*reactLLMOutput, error) {
	clean := strings.TrimSpace(raw)
	if clean == "" {
		return nil, fmt.Errorf("ReAct 输出为空")
	}
	if strings.HasPrefix(clean, "```") {
		clean = strings.TrimPrefix(clean, "```json")
		clean = strings.TrimPrefix(clean, "```")
		clean = strings.TrimSuffix(clean, "```")
		clean = strings.TrimSpace(clean)
	}

	var out reactLLMOutput
	if err := json.Unmarshal([]byte(clean), &out); err == nil {
		return &out, nil
	}
	obj, objErr := extractFirstJSONObject(clean)
	if objErr != nil {
		return nil, fmt.Errorf("无法从输出中提取 JSON：%s", truncate(clean, 220))
	}
	if err := json.Unmarshal([]byte(obj), &out); err != nil {
		return nil, err
	}
	return &out, nil
}

// parseReviewOutput 解析终审评估节点输出。
func parseReviewOutput(raw string) (*reviewOutput, error) {
	clean := strings.TrimSpace(raw)
	if clean == "" {
		return nil, fmt.Errorf("review 输出为空")
	}
	if strings.HasPrefix(clean, "```") {
		clean = strings.TrimPrefix(clean, "```json")
		clean = strings.TrimPrefix(clean, "```")
		clean = strings.TrimSuffix(clean, "```")
		clean = strings.TrimSpace(clean)
	}

	var out reviewOutput
	if err := json.Unmarshal([]byte(clean), &out); err == nil {
		return &out, nil
	}
	obj, objErr := extractFirstJSONObject(clean)
	if objErr != nil {
		return nil, fmt.Errorf("无法从 review 输出中提取 JSON：%s", truncate(clean, 220))
	}
	if err := json.Unmarshal([]byte(obj), &out); err != nil {
		return nil, err
	}
	return &out, nil
}

// refineToolMove 执行“移动一个 suggested 任务到指定时段”。
//
// 步骤化说明：
// 1. 先校验参数完整性与目标时段合法性，避免写入脏坐标；
// 2. 再校验原任务是否存在、跨度是否一致（防止任务长度被模型改坏）；
// 3. 再校验窗口边界与冲突，确保不会穿透到不可用位置；
// 4. 若启用顺序硬约束，再校验“移动后是否打乱原相对顺序”；
// 5. 全部通过后才真正修改 entries 并返回 Success=true。
func refineToolMove(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
	// 0. task_id 兼容策略：
	// 0.1 标准键是 task_item_id；
	// 0.2 为了兼容模型偶发输出别名 task_id，这里做兜底兼容，避免“语义正确但参数名不一致”导致整轮白跑；
	// 0.3 两者都不存在时，仍按参数缺失返回失败，由上层 ReAct 继续下一轮决策。
	taskID, ok := paramIntAny(params, "task_item_id", "task_id")
	if !ok {
		return entries, reactToolResult{Tool: "Move", Success: false, Result: "参数缺失：task_item_id"}
	}
	// 1. 参数兼容策略：
	// 1.1 优先读取标准键（to_week/to_day/...）；
	// 1.2 若模型输出了历史别名（target_xxx/day_of_week 等），也兼容解析；
	// 1.3 目标是减少“仅参数名不一致导致的无效失败轮次”。
	toWeek, okWeek := paramIntAny(params, "to_week", "target_week", "new_week", "week")
	toDay, okDay := paramIntAny(params, "to_day", "target_day", "new_day", "target_day_of_week", "day_of_week", "day")
	toSF, okSF := paramIntAny(params, "to_section_from", "target_section_from", "new_section_from", "section_from")
	toST, okST := paramIntAny(params, "to_section_to", "target_section_to", "new_section_to", "section_to")
	if !okWeek || !okDay || !okSF || !okST {
		return entries, reactToolResult{
			Tool:    "Move",
			Success: false,
			Result:  "参数缺失：需要 to_week/to_day/to_section_from/to_section_to",
		}
	}
	if toDay < 1 || toDay > 7 {
		return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("day_of_week=%d 非法，必须在 1~7", toDay)}
	}
	if toSF < 1 || toST > 12 || toSF > toST {
		return entries, reactToolResult{Tool: "Move", Success: false, Result: fmt.Sprintf("节次区间 %d-%d 非法", toSF, toST)}
	}
	allowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding")

	idx, locateErr := findUniqueSuggestedByID(entries, taskID)
	if locateErr != nil {
		return entries, reactToolResult{Tool: "Move", Success: false, Result: locateErr.Error()}
	}
	origSpan := entries[idx].SectionTo - entries[idx].SectionFrom
	newSpan := toST - toSF
	if origSpan != newSpan {
		return entries, reactToolResult{
			Tool:    "Move",
			Success: false,
			Result:  fmt.Sprintf("任务跨度不一致：原跨度=%d，目标跨度=%d", origSpan+1, newSpan+1),
		}
	}

	if !isWithinWindow(window, toWeek, toDay) {
		return entries, reactToolResult{
			Tool:    "Move",
			Success: false,
			Result:  fmt.Sprintf("目标 W%dD%d 超出允许窗口", toWeek, toDay),
		}
	}

	if conflict, name := hasConflict(entries, toWeek, toDay, toSF, toST, map[int]bool{idx: true}, allowEmbed); conflict {
		return entries, reactToolResult{
			Tool:    "Move",
			Success: false,
			Result:  fmt.Sprintf("目标时段已被 %s 占用", name),
		}
	}

	beforeEntries := cloneHybridEntries(entries)
	entry := &entries[idx]
	before := fmt.Sprintf("W%dD%d %d-%d", entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo)
	entry.Week = toWeek
	entry.DayOfWeek = toDay
	entry.SectionFrom = toSF
	entry.SectionTo = toST
	after := fmt.Sprintf("W%dD%d %d-%d", entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo)

	sortHybridEntries(entries)
	if issues := validateRelativeOrder(entries, policy); len(issues) > 0 {
		return beforeEntries, reactToolResult{
			Tool:    "Move",
			Success: false,
			Result:  "顺序约束不满足：" + strings.Join(issues, "；"),
		}
	}

	return entries, reactToolResult{
		Tool:    "Move",
		Success: true,
		Result:  fmt.Sprintf("已将任务[%s](id=%d,type=%s,status=%s) 从 %s 移动到 %s", entry.Name, taskID, strings.TrimSpace(entry.Type), strings.TrimSpace(entry.Status), before, after),
	}
}

// refineToolSwap 执行“交换两个 suggested 任务的位置”。
//
// 步骤化说明：
// 1. 先校验两端 task_item_id；
// 2. 再双向验证交换后的落点是否与其他条目冲突；
// 3. 若启用顺序硬约束，再校验“交换后是否打乱相对顺序”；
// 4. 校验通过后提交交换并返回成功。
func refineToolSwap(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
	// 1. 参数兼容策略同 Move：
	// 1.1 兼容 task_a/task_b 与 task_item_a/task_item_b 等常见别名；
	// 1.2 目标是减少模型输出字段差异导致的无效失败。
	idA, okA := paramIntAny(params, "task_a", "task_item_a", "task_item_id_a")
	idB, okB := paramIntAny(params, "task_b", "task_item_b", "task_item_id_b")
	if !okA || !okB {
		return entries, reactToolResult{Tool: "Swap", Success: false, Result: "参数缺失：task_a/task_b"}
	}
	allowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding")
	if idA == idB {
		return entries, reactToolResult{Tool: "Swap", Success: false, Result: "task_a 与 task_b 不能相同"}
	}

	idxA, errA := findUniqueSuggestedByID(entries, idA)
	if errA != nil {
		return entries, reactToolResult{Tool: "Swap", Success: false, Result: errA.Error()}
	}
	idxB, errB := findUniqueSuggestedByID(entries, idB)
	if errB != nil {
		return entries, reactToolResult{Tool: "Swap", Success: false, Result: errB.Error()}
	}

	a := entries[idxA]
	b := entries[idxB]
	if !isWithinWindow(window, b.Week, b.DayOfWeek) || !isWithinWindow(window, a.Week, a.DayOfWeek) {
		return entries, reactToolResult{Tool: "Swap", Success: false, Result: "交换目标超出允许窗口"}
	}

	excludes := map[int]bool{idxA: true, idxB: true}
	if conflict, name := hasConflict(entries, b.Week, b.DayOfWeek, b.SectionFrom, b.SectionTo, excludes, allowEmbed); conflict {
		return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("任务A交换后将与 %s 冲突", name)}
	}
	if conflict, name := hasConflict(entries, a.Week, a.DayOfWeek, a.SectionFrom, a.SectionTo, excludes, allowEmbed); conflict {
		return entries, reactToolResult{Tool: "Swap", Success: false, Result: fmt.Sprintf("任务B交换后将与 %s 冲突", name)}
	}

	beforeEntries := cloneHybridEntries(entries)
	entries[idxA].Week, entries[idxB].Week = entries[idxB].Week, entries[idxA].Week
	entries[idxA].DayOfWeek, entries[idxB].DayOfWeek = entries[idxB].DayOfWeek, entries[idxA].DayOfWeek
	entries[idxA].SectionFrom, entries[idxB].SectionFrom = entries[idxB].SectionFrom, entries[idxA].SectionFrom
	entries[idxA].SectionTo, entries[idxB].SectionTo = entries[idxB].SectionTo, entries[idxA].SectionTo

	sortHybridEntries(entries)
	if issues := validateRelativeOrder(entries, policy); len(issues) > 0 {
		return beforeEntries, reactToolResult{
			Tool:    "Swap",
			Success: false,
			Result:  "顺序约束不满足：" + strings.Join(issues, "；"),
		}
	}

	return entries, reactToolResult{
		Tool:    "Swap",
		Success: true,
		Result:  fmt.Sprintf("已交换任务 id=%d 与 id=%d 的时段", idA, idB),
	}
}

// refineToolBatchMove 执行“原子批量移动 suggested 任务”。
//
// 步骤化说明：
// 1. 参数要求：params.moves 必须是数组，每个元素都满足 Move 的参数格式；
// 2. 执行策略：在 working 副本上按顺序逐条执行 Move；
// 3. 原子语义：任一步失败，整批回滚（返回原 entries）；全部成功才一次性提交；
// 4. 适用场景：用户明确希望“同一轮挪多个任务”，减少 ReAct 往返轮次。
func refineToolBatchMove(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
	moveParamsList, parseErr := parseBatchMoveParams(params)
	if parseErr != nil {
		return entries, reactToolResult{
			Tool:      "BatchMove",
			Success:   false,
			ErrorCode: "PARAM_MISSING",
			Result:    parseErr.Error(),
		}
	}
	// 2. 批级 allow_embed 默认值：
	// 2.1 如果子动作未显式声明 allow_embed/allow_embedding，则继承批级开关；
	// 2.2 默认 true，和 Move/Swap 一致：允许嵌入，但由 QueryAvailableSlots 先给纯空位。
	batchAllowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding")
	for i := range moveParamsList {
		if _, ok := moveParamsList[i]["allow_embed"]; ok {
			continue
		}
		if _, ok := moveParamsList[i]["allow_embedding"]; ok {
			continue
		}
		moveParamsList[i]["allow_embed"] = batchAllowEmbed
	}

	// 1. 在副本上执行，保证原子性：
	// 1.1 每一步都复用 refineToolMove 的全部校验逻辑（冲突、窗口、顺序、跨度）；
	// 1.2 只要任一步失败就中止并回滚到原 entries；
	// 1.3 全部成功后再返回 working，作为整批提交结果。
	working := cloneHybridEntries(entries)
	stepSummary := make([]string, 0, len(moveParamsList))
	currentWindow := buildPlanningWindowFromEntries(working)
	if !currentWindow.Enabled {
		currentWindow = window
	}
	for idx, moveParams := range moveParamsList {
		nextEntries, stepResult := refineToolMove(working, moveParams, currentWindow, policy)
		if !stepResult.Success {
			return entries, reactToolResult{
				Tool:      "BatchMove",
				Success:   false,
				ErrorCode: classifyBatchMoveErrorCode(stepResult.Result),
				Result:    fmt.Sprintf("BatchMove 第%d步失败：%s", idx+1, stepResult.Result),
			}
		}
		working = nextEntries
		currentWindow = buildPlanningWindowFromEntries(working)
		stepSummary = append(stepSummary, fmt.Sprintf("第%d步:%s", idx+1, truncate(stepResult.Result, 120)))
	}

	return working, reactToolResult{
		Tool:    "BatchMove",
		Success: true,
		Result:  fmt.Sprintf("BatchMove 原子提交成功，共执行%d步。%s", len(moveParamsList), strings.Join(stepSummary, " | ")),
	}
}

type compositePlannerFn func(
	tasks []logic.RefineTaskCandidate,
	slots []logic.RefineSlotCandidate,
	options logic.RefineCompositePlanOptions,
) ([]logic.RefineMovePlanItem, error)

// refineToolSpreadEven 执行“均匀铺开”复合动作。
//
// 职责边界：
// 1. 负责参数解析、候选收集、调用确定性规划器；
// 2. 不直接改写 entries，统一通过 BatchMove 原子落地；
// 3. 规划算法实现位于 logic 包，工具层只负责编排。
func refineToolSpreadEven(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
	return refineToolCompositeMove(entries, params, window, policy, "SpreadEven", logic.PlanEvenSpreadMoves)
}

// refineToolMinContextSwitch 执行“最少上下文切换”复合动作。
//
// 职责边界：
// 1. 负责锁定“当前任务已占坑位集合”，避免为了聚类把任务远距离迁移；
// 2. 负责在固定坑位集合内调用确定性规划器，只重排“任务 -> 坑位”的映射；
// 3. 不直接改写 entries，统一通过 BatchMove 原子落地。
func refineToolMinContextSwitch(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
	taskIDs := collectCompositeTaskIDs(params)
	if len(taskIDs) == 0 {
		return entries, reactToolResult{
			Tool:      "MinContextSwitch",
			Success:   false,
			ErrorCode: "PARAM_MISSING",
			Result:    "参数缺失：复合工具需要 task_item_ids 或 task_item_id",
		}
	}
	tasks, taskResult, ok := collectCompositeTasks(entries, taskIDs, policy, "MinContextSwitch")
	if !ok {
		return entries, taskResult
	}

	// 1. MinContextSwitch 的产品语义是“尽量少切换，同时尽量少折腾坑位”；
	// 2. 因此这里不再查询整周新坑位，而是直接复用当前任务已占据的坑位集合；
	// 3. 这样最终只会发生“任务之间互换位置”，不会跳到用户意料之外的远处时段。
	currentSlots := buildCompositeCurrentTaskSlots(tasks)
	plannedMoves, planErr := logic.PlanMinContextSwitchMoves(tasks, currentSlots, logic.RefineCompositePlanOptions{})
	if planErr != nil {
		return entries, reactToolResult{
			Tool:      "MinContextSwitch",
			Success:   false,
			ErrorCode: "PLAN_FAILED",
			Result:    planErr.Error(),
		}
	}
	return applyFixedSlotCompositeMoves(entries, policy, "MinContextSwitch", plannedMoves)
}

// refineToolCompositeMove 是复合动作工具的统一执行框架。
//
// 步骤化说明：
// 1. 先解析“目标任务集合”，确保任务来源明确且可唯一落到 task_item_id；
// 2. 再按任务跨度查询候选坑位，避免跨度不一致导致执行期失败；
// 3. 调用 logic 包的确定性规划函数，得到 moves；
// 4. 最后复用 BatchMove 原子提交，任一步失败整批回滚。
func refineToolCompositeMove(
	entries []model.HybridScheduleEntry,
	params map[string]any,
	window planningWindow,
	policy refineToolPolicy,
	toolName string,
	planner compositePlannerFn,
) ([]model.HybridScheduleEntry, reactToolResult) {
	taskIDs := collectCompositeTaskIDs(params)
	if len(taskIDs) == 0 {
		return entries, reactToolResult{
			Tool:      toolName,
			Success:   false,
			ErrorCode: "PARAM_MISSING",
			Result:    "参数缺失：复合工具需要 task_item_ids 或 task_item_id",
		}
	}
	tasks, taskResult, ok := collectCompositeTasks(entries, taskIDs, policy, toolName)
	if !ok {
		return entries, taskResult
	}
	idSet := intSliceToIDSet(taskIDs)
	spanNeed := buildCompositeSpanNeed(tasks)

	slots, slotErr := collectCompositeSlotsBySpan(entries, params, window, spanNeed)
	if slotErr != nil {
		return entries, reactToolResult{
			Tool:      toolName,
			Success:   false,
			ErrorCode: "SLOT_QUERY_FAILED",
			Result:    slotErr.Error(),
		}
	}
	options := logic.RefineCompositePlanOptions{
		ExistingDayLoad: buildCompositeDayLoadBaseline(entries, idSet, slots),
	}
	plannedMoves, planErr := planner(tasks, slots, options)
	if planErr != nil {
		return entries, reactToolResult{
			Tool:      toolName,
			Success:   false,
			ErrorCode: "PLAN_FAILED",
			Result:    planErr.Error(),
		}
	}
	return applyCompositePlannedMoves(entries, params, window, policy, toolName, plannedMoves)
}

// collectCompositeTasks 收集复合动作参与的可移动任务，并做唯一性校验。
//
// 步骤化说明：
// 1. 只收 suggested 且可移动的 task，避免误改 existing/course；
// 2. task_item_id 必须一一命中，命中多条或缺失都直接失败；
// 3. 输出顺序保持 entries 原始遍历顺序，后续再由规划器做稳定排序。
func collectCompositeTasks(entries []model.HybridScheduleEntry, taskIDs []int, policy refineToolPolicy, toolName string) ([]logic.RefineTaskCandidate, reactToolResult, bool) {
	idSet := intSliceToIDSet(taskIDs)
	tasks := make([]logic.RefineTaskCandidate, 0, len(taskIDs))
	found := make(map[int]struct{}, len(taskIDs))
	for _, entry := range entries {
		if !isMovableSuggestedTask(entry) {
			continue
		}
		if _, ok := idSet[entry.TaskItemID]; !ok {
			continue
		}
		if _, duplicated := found[entry.TaskItemID]; duplicated {
			return nil, reactToolResult{
				Tool:      toolName,
				Success:   false,
				ErrorCode: "TASK_ID_AMBIGUOUS",
				Result:    fmt.Sprintf("task_item_id=%d 命中多条可移动 suggested 任务，无法唯一定位", entry.TaskItemID),
			}, false
		}
		found[entry.TaskItemID] = struct{}{}
		tasks = append(tasks, logic.RefineTaskCandidate{
			TaskItemID:  entry.TaskItemID,
			Week:        entry.Week,
			DayOfWeek:   entry.DayOfWeek,
			SectionFrom: entry.SectionFrom,
			SectionTo:   entry.SectionTo,
			Name:        strings.TrimSpace(entry.Name),
			ContextTag:  strings.TrimSpace(entry.ContextTag),
			OriginRank:  policy.OriginOrderMap[entry.TaskItemID],
		})
	}
	if len(tasks) != len(taskIDs) {
		missing := make([]int, 0, len(taskIDs))
		for _, id := range taskIDs {
			if _, ok := found[id]; !ok {
				missing = append(missing, id)
			}
		}
		return nil, reactToolResult{
			Tool:      toolName,
			Success:   false,
			ErrorCode: "TASK_NOT_FOUND",
			Result:    fmt.Sprintf("未找到以下 task_item_id 的可移动 suggested 任务：%v", missing),
		}, false
	}
	return tasks, reactToolResult{}, true
}

func buildCompositeSpanNeed(tasks []logic.RefineTaskCandidate) map[int]int {
	spanNeed := make(map[int]int, len(tasks))
	for _, task := range tasks {
		spanNeed[task.SectionTo-task.SectionFrom+1]++
	}
	return spanNeed
}

func buildCompositeCurrentTaskSlots(tasks []logic.RefineTaskCandidate) []logic.RefineSlotCandidate {
	slots := make([]logic.RefineSlotCandidate, 0, len(tasks))
	for _, task := range tasks {
		slots = append(slots, logic.RefineSlotCandidate{
			Week:        task.Week,
			DayOfWeek:   task.DayOfWeek,
			SectionFrom: task.SectionFrom,
			SectionTo:   task.SectionTo,
		})
	}
	return slots
}

func applyCompositePlannedMoves(
	entries []model.HybridScheduleEntry,
	params map[string]any,
	window planningWindow,
	policy refineToolPolicy,
	toolName string,
	plannedMoves []logic.RefineMovePlanItem,
) ([]model.HybridScheduleEntry, reactToolResult) {
	if len(plannedMoves) == 0 {
		return entries, reactToolResult{
			Tool:      toolName,
			Success:   false,
			ErrorCode: "PLAN_EMPTY",
			Result:    "规划结果为空：未生成任何可执行移动",
		}
	}

	moveParams := make([]any, 0, len(plannedMoves))
	for _, move := range plannedMoves {
		moveParams = append(moveParams, map[string]any{
			"task_item_id":    move.TaskItemID,
			"to_week":         move.ToWeek,
			"to_day":          move.ToDay,
			"to_section_from": move.ToSectionFrom,
			"to_section_to":   move.ToSectionTo,
		})
	}
	batchParams := map[string]any{
		"moves":       moveParams,
		"allow_embed": paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding"),
	}
	nextEntries, batchResult := refineToolBatchMove(entries, batchParams, window, policy)
	if !batchResult.Success {
		return entries, reactToolResult{
			Tool:      toolName,
			Success:   false,
			ErrorCode: batchResult.ErrorCode,
			Result:    fmt.Sprintf("%s 执行失败：%s", toolName, batchResult.Result),
		}
	}
	return nextEntries, reactToolResult{
		Tool:    toolName,
		Success: true,
		Result:  fmt.Sprintf("%s 执行成功：已规划并提交 %d 条移动。", toolName, len(plannedMoves)),
	}
}

// applyFixedSlotCompositeMoves 以“同时改写坐标”的方式提交固定坑位重排结果。
//
// 步骤化说明：
// 1. 该函数专门服务“坑位集合固定”的复合工具，避免 BatchMove 顺序执行时出现互相占位冲突；
// 2. 先在副本上一次性改写所有目标任务的坐标，再统一排序与校验；
// 3. 若发现目标坑位重复、任务缺失、或顺序约束不满足，则整批失败并回滚。
func applyFixedSlotCompositeMoves(
	entries []model.HybridScheduleEntry,
	policy refineToolPolicy,
	toolName string,
	plannedMoves []logic.RefineMovePlanItem,
) ([]model.HybridScheduleEntry, reactToolResult) {
	if len(plannedMoves) == 0 {
		return entries, reactToolResult{
			Tool:      toolName,
			Success:   false,
			ErrorCode: "PLAN_EMPTY",
			Result:    "规划结果为空：未生成任何可执行移动",
		}
	}

	working := cloneHybridEntries(entries)
	indexByTaskID := make(map[int]int, len(working))
	for idx, entry := range working {
		if !isMovableSuggestedTask(entry) {
			continue
		}
		if _, exists := indexByTaskID[entry.TaskItemID]; exists {
			return entries, reactToolResult{
				Tool:      toolName,
				Success:   false,
				ErrorCode: "TASK_ID_AMBIGUOUS",
				Result:    fmt.Sprintf("%s 执行失败：task_item_id=%d 命中多条可移动 suggested 任务", toolName, entry.TaskItemID),
			}
		}
		indexByTaskID[entry.TaskItemID] = idx
	}

	targetSeen := make(map[string]int, len(plannedMoves))
	for _, move := range plannedMoves {
		if _, ok := indexByTaskID[move.TaskItemID]; !ok {
			return entries, reactToolResult{
				Tool:      toolName,
				Success:   false,
				ErrorCode: "TASK_NOT_FOUND",
				Result:    fmt.Sprintf("%s 执行失败：task_item_id=%d 未找到可移动 suggested 任务", toolName, move.TaskItemID),
			}
		}
		key := fmt.Sprintf("%d-%d-%d-%d", move.ToWeek, move.ToDay, move.ToSectionFrom, move.ToSectionTo)
		if prevID, exists := targetSeen[key]; exists {
			return entries, reactToolResult{
				Tool:      toolName,
				Success:   false,
				ErrorCode: "PLAN_CONFLICT",
				Result:    fmt.Sprintf("%s 执行失败：任务 id=%d 与 id=%d 目标坑位重复", toolName, prevID, move.TaskItemID),
			}
		}
		targetSeen[key] = move.TaskItemID
	}

	for _, move := range plannedMoves {
		idx := indexByTaskID[move.TaskItemID]
		working[idx].Week = move.ToWeek
		working[idx].DayOfWeek = move.ToDay
		working[idx].SectionFrom = move.ToSectionFrom
		working[idx].SectionTo = move.ToSectionTo
	}
	sortHybridEntries(working)
	if issues := validateRelativeOrder(working, policy); len(issues) > 0 {
		return entries, reactToolResult{
			Tool:      toolName,
			Success:   false,
			ErrorCode: "ORDER_CONSTRAINT_VIOLATED",
			Result:    "顺序约束不满足：" + strings.Join(issues, "；"),
		}
	}
	return working, reactToolResult{
		Tool:    toolName,
		Success: true,
		Result:  fmt.Sprintf("%s 执行成功：已在固定坑位集合内重排 %d 条任务。", toolName, len(plannedMoves)),
	}
}

func collectCompositeTaskIDs(params map[string]any) []int {
	ids := readIntSlice(params, "task_item_ids", "task_ids")
	if id, ok := paramIntAny(params, "task_item_id", "task_id"); ok {
		ids = append(ids, id)
	}
	return uniquePositiveInts(ids)
}

func collectCompositeSlotsBySpan(
	entries []model.HybridScheduleEntry,
	params map[string]any,
	window planningWindow,
	spanNeed map[int]int,
) ([]logic.RefineSlotCandidate, error) {
	if len(spanNeed) == 0 {
		return nil, fmt.Errorf("未识别到任务跨度需求")
	}

	spans := make([]int, 0, len(spanNeed))
	for span := range spanNeed {
		spans = append(spans, span)
	}
	sort.Ints(spans)

	allSlots := make([]logic.RefineSlotCandidate, 0, 16)
	for _, span := range spans {
		required := spanNeed[span]
		queryParams := buildCompositeSlotQueryParams(params, span, required)
		_, queryResult := refineToolQueryAvailableSlots(entries, queryParams, window)
		if !queryResult.Success {
			return nil, fmt.Errorf("查询跨度=%d 的候选坑位失败：%s", span, queryResult.Result)
		}

		var payload struct {
			Slots []struct {
				Week        int `json:"week"`
				DayOfWeek   int `json:"day_of_week"`
				SectionFrom int `json:"section_from"`
				SectionTo   int `json:"section_to"`
			} `json:"slots"`
		}
		if err := json.Unmarshal([]byte(queryResult.Result), &payload); err != nil {
			return nil, fmt.Errorf("解析跨度=%d 的空位结果失败：%v", span, err)
		}
		if len(payload.Slots) < required {
			return nil, fmt.Errorf("跨度=%d 可用坑位不足：required=%d, got=%d", span, required, len(payload.Slots))
		}
		for _, slot := range payload.Slots {
			allSlots = append(allSlots, logic.RefineSlotCandidate{
				Week:        slot.Week,
				DayOfWeek:   slot.DayOfWeek,
				SectionFrom: slot.SectionFrom,
				SectionTo:   slot.SectionTo,
			})
		}
	}
	return allSlots, nil
}

func buildCompositeSlotQueryParams(params map[string]any, span int, required int) map[string]any {
	query := make(map[string]any, 12)
	query["span"] = span

	// 1. limit 以“任务数 * 兜底系数”估算，给规划器保留可选空间；
	// 2. 若调用方显式给了 limit，则采用更大的那个，避免被过小 limit 限死。
	limit := required * 6
	if limit < required {
		limit = required
	}
	if customLimit, ok := paramIntAny(params, "limit"); ok && customLimit > limit {
		limit = customLimit
	}
	query["limit"] = limit
	query["allow_embed"] = paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding")

	for _, key := range []string{"week", "week_from", "week_to", "day_scope", "after_section", "before_section"} {
		if value, ok := params[key]; ok {
			query[key] = value
		}
	}

	// 1. 复合路由主链路自身使用的是 week_filter/day_of_week/exclude_sections；
	// 2. 这里必须优先透传这些“规范键”，再兼容历史别名；
	// 3. 否则会出现复合工具已被调用，但内部查坑位时丢失目标范围，导致规划结果漂移。
	copyIntSliceParam(params, query, "week_filter", "week_filter", "weeks")
	copyIntSliceParam(params, query, "day_of_week", "day_of_week", "days", "day_filter")
	copyIntSliceParam(params, query, "exclude_sections", "exclude_sections", "exclude_section")

	// 兼容 Move 风格别名，降低模型参数名漂移导致的失败。
	if week, ok := paramIntAny(params, "to_week", "target_week", "new_week"); ok {
		query["week"] = week
	}
	if day, ok := paramIntAny(params, "to_day", "target_day", "target_day_of_week", "new_day", "day"); ok {
		query["day_of_week"] = []int{day}
	}
	return query
}

func copyIntSliceParam(src map[string]any, dst map[string]any, dstKey string, srcKeys ...string) {
	values := readIntSlice(src, srcKeys...)
	if len(values) == 0 {
		return
	}
	normalized := uniquePositiveInts(values)
	if len(normalized) == 0 {
		return
	}
	dst[dstKey] = normalized
}

func buildCompositeDayLoadBaseline(
	entries []model.HybridScheduleEntry,
	excludeTaskIDs map[int]struct{},
	slots []logic.RefineSlotCandidate,
) map[string]int {
	if len(slots) == 0 {
		return nil
	}
	targetDays := make(map[string]struct{}, len(slots))
	for _, slot := range slots {
		targetDays[fmt.Sprintf("%d-%d", slot.Week, slot.DayOfWeek)] = struct{}{}
	}

	load := make(map[string]int, len(targetDays))
	for _, entry := range entries {
		if !isMovableSuggestedTask(entry) {
			continue
		}
		if _, excluded := excludeTaskIDs[entry.TaskItemID]; excluded {
			continue
		}
		key := fmt.Sprintf("%d-%d", entry.Week, entry.DayOfWeek)
		if _, inTarget := targetDays[key]; !inTarget {
			continue
		}
		load[key]++
	}
	return load
}

// refineToolQueryTargetTasks 查询“本轮潜在目标任务集合”。
//
// 步骤化说明：
// 1. 支持按 day_scope(weekend/workday/all)、week 范围、limit 过滤；
// 2. 只读查询，不修改 entries；
// 3. 返回结构化 JSON 字符串，供下一轮模型直接消费。
func refineToolQueryTargetTasks(entries []model.HybridScheduleEntry, params map[string]any, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
	scope := normalizeDayScope(readString(params, "day_scope", "all"))
	statusFilter := normalizeStatusFilter(readString(params, "status", "suggested"))
	weekFilter := intSliceToWeekSet(readIntSlice(params, "week_filter", "weeks"))
	weekFrom, hasWeekFrom := paramIntAny(params, "week_from", "from_week")
	weekTo, hasWeekTo := paramIntAny(params, "week_to", "to_week")
	if week, hasWeek := paramIntAny(params, "week"); hasWeek {
		weekFrom, weekTo = week, week
		hasWeekFrom, hasWeekTo = true, true
	}
	if hasWeekFrom && hasWeekTo && weekFrom > weekTo {
		weekFrom, weekTo = weekTo, weekFrom
	}
	if !hasWeekFrom || !hasWeekTo {
		startWeek, endWeek := inferWeekBounds(entries, planningWindow{Enabled: false})
		if !hasWeekFrom {
			weekFrom = startWeek
		}
		if !hasWeekTo {
			weekTo = endWeek
		}
	}
	limit, okLimit := paramIntAny(params, "limit")
	if !okLimit || limit <= 0 {
		limit = 16
	}
	dayFilter := intSliceToDaySet(readIntSlice(params, "day_of_week", "days", "day_filter"))
	taskIDs := readIntSlice(params, "task_item_ids", "task_ids")
	if taskID, ok := paramIntAny(params, "task_item_id", "task_id"); ok {
		taskIDs = append(taskIDs, taskID)
	}
	taskIDSet := intSliceToIDSet(taskIDs)

	type targetTask struct {
		TaskItemID   int    `json:"task_item_id"`
		Name         string `json:"name"`
		Week         int    `json:"week"`
		DayOfWeek    int    `json:"day_of_week"`
		SectionFrom  int    `json:"section_from"`
		SectionTo    int    `json:"section_to"`
		OriginRank   int    `json:"origin_rank,omitempty"`
		ContextTag   string `json:"context_tag,omitempty"`
		CurrentState string `json:"status"`
	}

	list := make([]targetTask, 0, 32)
	for _, entry := range entries {
		if !matchStatusFilter(entry.Status, statusFilter) {
			continue
		}
		// suggested 视图只允许看到“可移动任务”，避免把课程类条目当成可调任务暴露给模型。
		if statusFilter == "suggested" && !isMovableSuggestedTask(entry) {
			continue
		}
		if entry.TaskItemID <= 0 {
			continue
		}
		if len(taskIDSet) > 0 {
			if _, ok := taskIDSet[entry.TaskItemID]; !ok {
				continue
			}
		}
		if len(dayFilter) > 0 {
			if _, ok := dayFilter[entry.DayOfWeek]; !ok {
				continue
			}
		} else if !matchDayScope(entry.DayOfWeek, scope) {
			continue
		}
		if len(weekFilter) > 0 {
			if _, ok := weekFilter[entry.Week]; !ok {
				continue
			}
		}
		if hasWeekFrom && entry.Week < weekFrom {
			continue
		}
		if hasWeekTo && entry.Week > weekTo {
			continue
		}
		list = append(list, targetTask{
			TaskItemID:   entry.TaskItemID,
			Name:         strings.TrimSpace(entry.Name),
			Week:         entry.Week,
			DayOfWeek:    entry.DayOfWeek,
			SectionFrom:  entry.SectionFrom,
			SectionTo:    entry.SectionTo,
			OriginRank:   policy.OriginOrderMap[entry.TaskItemID],
			ContextTag:   strings.TrimSpace(entry.ContextTag),
			CurrentState: entry.Status,
		})
	}
	sort.SliceStable(list, func(i, j int) bool {
		if list[i].Week != list[j].Week {
			return list[i].Week < list[j].Week
		}
		if list[i].DayOfWeek != list[j].DayOfWeek {
			return list[i].DayOfWeek < list[j].DayOfWeek
		}
		if list[i].SectionFrom != list[j].SectionFrom {
			return list[i].SectionFrom < list[j].SectionFrom
		}
		return list[i].TaskItemID < list[j].TaskItemID
	})
	if len(list) > limit {
		list = list[:limit]
	}

	payload := map[string]any{
		"tool":        "QueryTargetTasks",
		"count":       len(list),
		"status":      statusFilter,
		"day_scope":   scope,
		"week_filter": keysOfIntSet(weekFilter),
		"week_from":   weekFrom,
		"week_to":     weekTo,
		"day_of_week": keysOfIntSet(dayFilter),
		"items":       list,
	}
	raw, err := json.Marshal(payload)
	if err != nil {
		return entries, reactToolResult{
			Tool:      "QueryTargetTasks",
			Success:   false,
			ErrorCode: "QUERY_ENCODE_FAILED",
			Result:    fmt.Sprintf("序列化查询结果失败：%v", err),
		}
	}
	return entries, reactToolResult{
		Tool:    "QueryTargetTasks",
		Success: true,
		Result:  string(raw),
	}
}

// refineToolQueryAvailableSlots 查询“可放置 suggested 的空位”。
//
// 步骤化说明：
// 1. 根据 day_scope/week 范围/span/exclude_sections 过滤候选时段；
// 2. 默认先收集“纯空位”，不足 limit 再补“可嵌入课程位”（第二优先级）；
// 3. 返回结构化 JSON 字符串，不修改 entries。
func refineToolQueryAvailableSlots(entries []model.HybridScheduleEntry, params map[string]any, window planningWindow) ([]model.HybridScheduleEntry, reactToolResult) {
	scope := normalizeDayScope(readString(params, "day_scope", "all"))
	dayFilter := intSliceToDaySet(readIntSlice(params, "day_of_week", "days", "day_filter"))
	weekFilter := intSliceToWeekSet(readIntSlice(params, "week_filter", "weeks"))
	// 1. 空位优先策略：
	// 1.1 默认 allow_embed=true，但查询分两阶段执行；
	// 1.2 第一阶段只收集“纯空白位”（不与 existing 重叠）；
	// 1.3 第二阶段仅在空白位不足 limit 时，补充“可嵌入课程位”。
	allowEmbed := paramBoolAnyWithDefault(params, true, "allow_embed", "allow_embedding")
	// 1.4 兼容 slot_type/slot_types：
	// 1.4.1 当明确请求 pure/empty/strict 时，强制只查纯空位（关闭嵌入候选）。
	// 1.4.2 当未声明时，维持“空位优先，空位不足再补嵌入候选”的默认策略。
	slotTypeHints := readStringSlice(params, "slot_types")
	if single := strings.TrimSpace(readString(params, "slot_type", "")); single != "" {
		slotTypeHints = append(slotTypeHints, single)
	}
	for _, hint := range slotTypeHints {
		normalized := strings.ToLower(strings.TrimSpace(hint))
		if normalized == "pure" || normalized == "empty" || normalized == "strict" {
			allowEmbed = false
			break
		}
	}
	span, okSpan := paramIntAny(params, "span", "section_duration", "task_duration")
	if !okSpan || span <= 0 {
		span = 2
	}
	if span > 12 {
		return entries, reactToolResult{
			Tool:      "QueryAvailableSlots",
			Success:   false,
			ErrorCode: "SPAN_INVALID",
			Result:    fmt.Sprintf("span=%d 非法，必须在 1~12", span),
		}
	}
	limit, okLimit := paramIntAny(params, "limit")
	if !okLimit || limit <= 0 {
		limit = 12
	}

	weekFrom, hasWeekFrom := paramIntAny(params, "week_from", "from_week")
	weekTo, hasWeekTo := paramIntAny(params, "week_to", "to_week")
	if week, hasWeek := paramIntAny(params, "week"); hasWeek {
		weekFrom, weekTo = week, week
		hasWeekFrom, hasWeekTo = true, true
	}
	if hasWeekFrom && hasWeekTo && weekFrom > weekTo {
		weekFrom, weekTo = weekTo, weekFrom
	}
	if !hasWeekFrom || !hasWeekTo {
		startWeek, endWeek := inferWeekBounds(entries, window)
		if !hasWeekFrom {
			weekFrom = startWeek
		}
		if !hasWeekTo {
			weekTo = endWeek
		}
	}
	weeksToIterate := buildWeekIterList(weekFilter, weekFrom, weekTo)
	if len(weeksToIterate) == 0 {
		return entries, reactToolResult{
			Tool:      "QueryAvailableSlots",
			Success:   false,
			ErrorCode: "PARAM_MISSING",
			Result:    "周范围为空：请提供 week / week_filter 或确保排程窗口有效",
		}
	}
	weekFrom = weeksToIterate[0]
	weekTo = weeksToIterate[len(weeksToIterate)-1]

	excludedSet := make(map[int]struct{})
	for _, sec := range readIntSlice(params, "exclude_sections", "exclude_section") {
		if sec >= 1 && sec <= 12 {
			excludedSet[sec] = struct{}{}
		}
	}
	afterSection, hasAfter := paramIntAny(params, "after_section")
	beforeSection, hasBefore := paramIntAny(params, "before_section")
	exactSectionFrom, hasExactFrom := paramIntAny(params, "section_from", "target_section_from")
	exactSectionTo, hasExactTo := paramIntAny(params, "section_to", "target_section_to")
	if hasExactFrom != hasExactTo {
		return entries, reactToolResult{
			Tool:      "QueryAvailableSlots",
			Success:   false,
			ErrorCode: "PARAM_MISSING",
			Result:    "精确节次查询需同时提供 section_from 和 section_to",
		}
	}
	if hasExactFrom {
		if exactSectionFrom < 1 || exactSectionTo > 12 || exactSectionFrom > exactSectionTo {
			return entries, reactToolResult{
				Tool:      "QueryAvailableSlots",
				Success:   false,
				ErrorCode: "SPAN_INVALID",
				Result:    fmt.Sprintf("精确节次区间非法：%d-%d", exactSectionFrom, exactSectionTo),
			}
		}
		span = exactSectionTo - exactSectionFrom + 1
	}

	type slot struct {
		Week        int    `json:"week"`
		DayOfWeek   int    `json:"day_of_week"`
		SectionFrom int    `json:"section_from"`
		SectionTo   int    `json:"section_to"`
		SlotType    string `json:"slot_type,omitempty"`
	}
	slots := make([]slot, 0, limit)
	seen := make(map[string]struct{}, limit*2)
	strictCount := 0
	collect := func(embedAllowed bool, slotType string) {
		if len(slots) >= limit {
			return
		}
		for _, week := range weeksToIterate {
			for day := 1; day <= 7; day++ {
				if len(dayFilter) > 0 {
					if _, ok := dayFilter[day]; !ok {
						continue
					}
				} else if !matchDayScope(day, scope) {
					continue
				}
				if !isWithinWindow(window, week, day) {
					continue
				}
				for sf := 1; sf+span-1 <= 12; sf++ {
					st := sf + span - 1
					if hasExactFrom && (sf != exactSectionFrom || st != exactSectionTo) {
						continue
					}
					if hasAfter && sf <= afterSection {
						continue
					}
					if hasBefore && st >= beforeSection {
						continue
					}
					if intersectsExcludedSections(sf, st, excludedSet) {
						continue
					}
					if conflict, _ := hasConflict(entries, week, day, sf, st, nil, embedAllowed); conflict {
						continue
					}
					key := fmt.Sprintf("%d-%d-%d-%d", week, day, sf, st)
					if _, ok := seen[key]; ok {
						continue
					}
					seen[key] = struct{}{}
					slots = append(slots, slot{
						Week:        week,
						DayOfWeek:   day,
						SectionFrom: sf,
						SectionTo:   st,
						SlotType:    slotType,
					})
					if len(slots) >= limit {
						return
					}
				}
			}
		}
	}
	collect(false, "empty")
	strictCount = len(slots)
	if allowEmbed && len(slots) < limit {
		collect(true, "embedded_candidate")
	}
	embeddedCount := len(slots) - strictCount

	payload := map[string]any{
		"tool":             "QueryAvailableSlots",
		"count":            len(slots),
		"strict_count":     strictCount,
		"embedded_count":   embeddedCount,
		"fallback_used":    embeddedCount > 0,
		"day_scope":        scope,
		"day_of_week":      keysOfIntSet(dayFilter),
		"week_filter":      keysOfIntSet(weekFilter),
		"week_from":        weekFrom,
		"week_to":          weekTo,
		"span":             span,
		"allow_embed":      allowEmbed,
		"exclude_sections": keysOfIntSet(excludedSet),
		"slots":            slots,
	}
	if hasAfter {
		payload["after_section"] = afterSection
	}
	if hasBefore {
		payload["before_section"] = beforeSection
	}
	if hasExactFrom {
		payload["section_from"] = exactSectionFrom
		payload["section_to"] = exactSectionTo
	}
	raw, err := json.Marshal(payload)
	if err != nil {
		return entries, reactToolResult{
			Tool:      "QueryAvailableSlots",
			Success:   false,
			ErrorCode: "QUERY_ENCODE_FAILED",
			Result:    fmt.Sprintf("序列化空位结果失败：%v", err),
		}
	}
	return entries, reactToolResult{
		Tool:    "QueryAvailableSlots",
		Success: true,
		Result:  string(raw),
	}
}

// refineToolVerify 进行“轻量确定性自检”。
//
// 说明：
// 1. 当前只做 deterministic 校验（冲突/顺序），不做语义 LLM 终审；
// 2. 语义层终审仍在 hard_check 节点统一处理；
// 3. 该工具用于给执行阶段一个“可提前自查”的信号。
func refineToolVerify(entries []model.HybridScheduleEntry, params map[string]any, policy refineToolPolicy) ([]model.HybridScheduleEntry, reactToolResult) {
	physicsIssues := physicsCheck(entries, 0)
	orderIssues := validateRelativeOrder(entries, policy)
	if len(physicsIssues) > 0 || len(orderIssues) > 0 {
		payload := map[string]any{
			"tool":           "Verify",
			"pass":           false,
			"physics_issues": physicsIssues,
			"order_issues":   orderIssues,
		}
		raw, err := json.Marshal(payload)
		if err != nil {
			return entries, reactToolResult{
				Tool:      "Verify",
				Success:   false,
				ErrorCode: "VERIFY_FAILED",
				Result:    "Verify 校验失败且结果无法序列化",
			}
		}
		return entries, reactToolResult{
			Tool:      "Verify",
			Success:   false,
			ErrorCode: "VERIFY_FAILED",
			Result:    string(raw),
		}
	}

	// 1. 若携带 task_item_id / 目标坐标参数，则执行“针对性核验”，避免“全局 pass”掩盖当前任务不匹配。
	// 2. 该核验是可选增强：没传 task_id 时仍维持全局 deterministic 行为。
	taskID, hasTaskID := paramIntAny(params, "task_item_id", "task_id")
	if hasTaskID {
		idx, locateErr := findUniqueSuggestedByID(entries, taskID)
		if locateErr != nil {
			return entries, reactToolResult{
				Tool:      "Verify",
				Success:   false,
				ErrorCode: "VERIFY_FAILED",
				Result:    fmt.Sprintf(`{"tool":"Verify","pass":false,"reason":"%s"}`, locateErr.Error()),
			}
		}
		target := entries[idx]
		verifyWeek, hasWeek := paramIntAny(params, "week", "to_week", "target_week")
		verifyDay, hasDay := paramIntAny(params, "day_of_week", "to_day", "target_day_of_week")
		verifyFrom, hasFrom := paramIntAny(params, "section_from", "to_section_from", "target_section_from")
		verifyTo, hasTo := paramIntAny(params, "section_to", "to_section_to", "target_section_to")

		mismatch := make([]string, 0, 4)
		if hasWeek && target.Week != verifyWeek {
			mismatch = append(mismatch, fmt.Sprintf("week=%d(实际=%d)", verifyWeek, target.Week))
		}
		if hasDay && target.DayOfWeek != verifyDay {
			mismatch = append(mismatch, fmt.Sprintf("day_of_week=%d(实际=%d)", verifyDay, target.DayOfWeek))
		}
		if hasFrom && target.SectionFrom != verifyFrom {
			mismatch = append(mismatch, fmt.Sprintf("section_from=%d(实际=%d)", verifyFrom, target.SectionFrom))
		}
		if hasTo && target.SectionTo != verifyTo {
			mismatch = append(mismatch, fmt.Sprintf("section_to=%d(实际=%d)", verifyTo, target.SectionTo))
		}
		if len(mismatch) > 0 {
			return entries, reactToolResult{
				Tool:      "Verify",
				Success:   false,
				ErrorCode: "VERIFY_FAILED",
				Result:    fmt.Sprintf(`{"tool":"Verify","pass":false,"reason":"任务坐标不匹配：%s"}`, strings.Join(mismatch, "；")),
			}
		}
		return entries, reactToolResult{
			Tool:    "Verify",
			Success: true,
			Result:  `{"tool":"Verify","pass":true,"reason":"task-level deterministic checks passed"}`,
		}
	}

	return entries, reactToolResult{
		Tool:    "Verify",
		Success: true,
		Result:  `{"tool":"Verify","pass":true,"reason":"deterministic checks passed"}`,
	}
}

// validateRelativeOrder 校验 suggested 任务是否保持“初始相对顺序”。
//
// 步骤化说明：
// 1. 若策略未启用 keep_relative_order，直接通过；
// 2. 否则按时间位置排序 suggested 任务，并映射到 origin_rank；
// 3. 检查 rank 是否单调不降；一旦逆序即判定失败；
// 4. 支持 week 作用域：仅要求每周内保持相对顺序。
func validateRelativeOrder(entries []model.HybridScheduleEntry, policy refineToolPolicy) []string {
	if !policy.KeepRelativeOrder {
		return nil
	}
	if len(policy.OriginOrderMap) == 0 {
		return []string{"未提供顺序基线(origin_order_map)"}
	}

	suggested := make([]model.HybridScheduleEntry, 0, len(entries))
	for _, entry := range entries {
		// 1. 顺序校验与执行口径必须一致：
		// 1.1 这里只校验“可移动 suggested 任务”，避免把 course 等不可移动条目误纳入顺序约束；
		// 1.2 若把不可移动条目纳入，会出现“动作层不允许改、顺序层却报错”的左右脑互搏。
		if isMovableSuggestedTask(entry) {
			suggested = append(suggested, entry)
		}
	}
	if len(suggested) <= 1 {
		return nil
	}
	sort.SliceStable(suggested, func(i, j int) bool {
		left := suggested[i]
		right := suggested[j]
		if left.Week != right.Week {
			return left.Week < right.Week
		}
		if left.DayOfWeek != right.DayOfWeek {
			return left.DayOfWeek < right.DayOfWeek
		}
		if left.SectionFrom != right.SectionFrom {
			return left.SectionFrom < right.SectionFrom
		}
		if left.SectionTo != right.SectionTo {
			return left.SectionTo < right.SectionTo
		}
		return left.TaskItemID < right.TaskItemID
	})

	scope := normalizeOrderScope(policy.OrderScope)
	issues := make([]string, 0, 4)
	if scope == "week" {
		lastRankByWeek := make(map[int]int)
		lastNameByWeek := make(map[int]string)
		lastIDByWeek := make(map[int]int)
		for _, entry := range suggested {
			rank, ok := policy.OriginOrderMap[entry.TaskItemID]
			if !ok {
				issues = append(issues, fmt.Sprintf("任务 id=%d 缺少 origin_rank", entry.TaskItemID))
				continue
			}
			last, exists := lastRankByWeek[entry.Week]
			if exists && rank < last {
				issues = append(issues, fmt.Sprintf(
					"W%d 出现逆序：任务[%s](id=%d,rank=%d) 被排在 [%s](id=%d,rank=%d) 之后",
					entry.Week, entry.Name, entry.TaskItemID, rank, lastNameByWeek[entry.Week], lastIDByWeek[entry.Week], last,
				))
			}
			lastRankByWeek[entry.Week] = rank
			lastNameByWeek[entry.Week] = entry.Name
			lastIDByWeek[entry.Week] = entry.TaskItemID
		}
		return issues
	}

	lastRank := -1
	lastName := ""
	lastID := 0
	for _, entry := range suggested {
		rank, ok := policy.OriginOrderMap[entry.TaskItemID]
		if !ok {
			issues = append(issues, fmt.Sprintf("任务 id=%d 缺少 origin_rank", entry.TaskItemID))
			continue
		}
		if lastRank >= 0 && rank < lastRank {
			issues = append(issues, fmt.Sprintf(
				"出现逆序：任务[%s](id=%d,rank=%d) 被排在 [%s](id=%d,rank=%d) 之后",
				entry.Name, entry.TaskItemID, rank, lastName, lastID, lastRank,
			))
		}
		lastRank = rank
		lastName = entry.Name
		lastID = entry.TaskItemID
	}
	return issues
}

// normalizeOrderScope 规范化顺序约束作用域。
func normalizeOrderScope(scope string) string {
	switch strings.TrimSpace(strings.ToLower(scope)) {
	case "week":
		return "week"
	default:
		return "global"
	}
}

// buildPlanningWindowFromEntries 根据现有条目推导允许移动窗口。
func buildPlanningWindowFromEntries(entries []model.HybridScheduleEntry) planningWindow {
	if len(entries) == 0 {
		return planningWindow{Enabled: false}
	}
	startWeek, startDay := entries[0].Week, entries[0].DayOfWeek
	endWeek, endDay := entries[0].Week, entries[0].DayOfWeek
	for _, entry := range entries {
		if compareWeekDay(entry.Week, entry.DayOfWeek, startWeek, startDay) < 0 {
			startWeek, startDay = entry.Week, entry.DayOfWeek
		}
		if compareWeekDay(entry.Week, entry.DayOfWeek, endWeek, endDay) > 0 {
			endWeek, endDay = entry.Week, entry.DayOfWeek
		}
	}
	return planningWindow{
		Enabled:   true,
		StartWeek: startWeek,
		StartDay:  startDay,
		EndWeek:   endWeek,
		EndDay:    endDay,
	}
}

// isWithinWindow 判断目标 week/day 是否落在窗口内。
func isWithinWindow(window planningWindow, week, day int) bool {
	if !window.Enabled {
		return true
	}
	if day < 1 || day > 7 {
		return false
	}
	if compareWeekDay(week, day, window.StartWeek, window.StartDay) < 0 {
		return false
	}
	if compareWeekDay(week, day, window.EndWeek, window.EndDay) > 0 {
		return false
	}
	return true
}

// compareWeekDay 比较两个 week/day 坐标。
// 返回：
// 1) <0：left 更早；
// 2) =0：相同；
// 3) >0：left 更晚。
func compareWeekDay(leftWeek, leftDay, rightWeek, rightDay int) int {
	if leftWeek != rightWeek {
		return leftWeek - rightWeek
	}
	return leftDay - rightDay
}

// findSuggestedByID 在 entries 中查找指定 task_item_id 的 suggested 条目索引。
func findSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) int {
	for i, entry := range entries {
		if isMovableSuggestedTask(entry) && entry.TaskItemID == taskItemID {
			return i
		}
	}
	return -1
}

// findUniqueSuggestedByID 查找可唯一定位的可移动 suggested 任务。
//
// 说明：
// 1. “可移动”定义由 isMovableSuggestedTask 统一控制；
// 2. 当 task_item_id 命中 0 条或 >1 条时都返回错误，避免把动作落到错误任务上。
func findUniqueSuggestedByID(entries []model.HybridScheduleEntry, taskItemID int) (int, error) {
	first := -1
	count := 0
	for idx, entry := range entries {
		if !isMovableSuggestedTask(entry) {
			continue
		}
		if entry.TaskItemID != taskItemID {
			continue
		}
		if first < 0 {
			first = idx
		}
		count++
	}
	if count == 0 {
		return -1, fmt.Errorf("未找到 task_item_id=%d 的可移动 suggested 任务", taskItemID)
	}
	if count > 1 {
		return -1, fmt.Errorf("task_item_id=%d 命中 %d 条可移动 suggested 任务，无法唯一定位", taskItemID, count)
	}
	return first, nil
}

// isMovableSuggestedTask 判断条目是否属于“可被微调工具改写”的任务。
//
// 规则：
// 1. 必须是 suggested 且 task_item_id>0；
// 2. type=course 明确禁止移动（即便被错误标记为 suggested）；
// 3. 其余类型（含空值）按任务处理，兼容历史快照。
func isMovableSuggestedTask(entry model.HybridScheduleEntry) bool {
	if strings.TrimSpace(entry.Status) != "suggested" || entry.TaskItemID <= 0 {
		return false
	}
	if strings.EqualFold(strings.TrimSpace(entry.Type), "course") {
		return false
	}
	return true
}

// hasConflict 检查目标时段是否与其他条目冲突。
//
// 判断规则：
// 1. 仅把“会阻塞 suggested 的条目”纳入冲突判断；
// 2. excludes 中的索引会被跳过（常用于 Move 自身排除或 Swap 双排除）。
func hasConflict(entries []model.HybridScheduleEntry, week, day, sf, st int, excludes map[int]bool, allowEmbed bool) (bool, string) {
	for idx, entry := range entries {
		if excludes != nil && excludes[idx] {
			continue
		}
		if !entryBlocksSuggestedWithPolicy(entry, allowEmbed) {
			continue
		}
		if entry.Week == week && entry.DayOfWeek == day && sectionsOverlap(entry.SectionFrom, entry.SectionTo, sf, st) {
			return true, fmt.Sprintf("%s(%s)", entry.Name, entry.Type)
		}
	}
	return false, ""
}

// entryBlocksSuggested 判断条目是否会阻塞 suggested 任务落位。
func entryBlocksSuggested(entry model.HybridScheduleEntry) bool {
	return entryBlocksSuggestedWithPolicy(entry, true)
}

// entryBlocksSuggestedWithPolicy 判断条目是否阻塞 suggested 落位。
//
// 策略说明：
// 1. allowEmbed=true：沿用 block_for_suggested 语义；
// 2. allowEmbed=false：existing 一律阻塞，只允许纯空白课位；
// 3. unknown status 保守阻塞，防止漏检。
func entryBlocksSuggestedWithPolicy(entry model.HybridScheduleEntry, allowEmbed bool) bool {
	if entry.Status == "suggested" {
		return true
	}
	if entry.Status == "existing" {
		if !allowEmbed {
			return true
		}
		return entry.BlockForSuggested
	}
	// 未知状态保守处理为阻塞，避免写入潜在冲突。
	return true
}

// sectionsOverlap 判断两个节次区间是否有交叠。
func sectionsOverlap(aFrom, aTo, bFrom, bTo int) bool {
	return aFrom <= bTo && bFrom <= aTo
}

// paramInt 从 map 中提取 int 参数，兼容 JSON 常见数值类型。
func paramInt(params map[string]any, key string) (int, bool) {
	raw, ok := params[key]
	if !ok {
		return 0, false
	}
	switch v := raw.(type) {
	case int:
		return v, true
	case float64:
		return int(v), true
	case string:
		n, err := strconv.Atoi(strings.TrimSpace(v))
		if err != nil {
			return 0, false
		}
		return n, true
	default:
		return 0, false
	}
}

// paramIntAny 按“候选键优先级”提取 int 参数。
//
// 步骤化说明：
// 1. 按传入顺序依次尝试每个 key；
// 2. 命中第一个合法值即返回；
// 3. 全部未命中则返回 false，由上层统一抛参数缺失错误。
func paramIntAny(params map[string]any, keys ...string) (int, bool) {
	for _, key := range keys {
		if v, ok := paramInt(params, key); ok {
			return v, true
		}
	}
	return 0, false
}

// paramBool 从 map 中提取 bool 参数，兼容 JSON 常见布尔表示。
func paramBool(params map[string]any, key string) (bool, bool) {
	raw, ok := params[key]
	if !ok {
		return false, false
	}
	switch v := raw.(type) {
	case bool:
		return v, true
	case string:
		text := strings.TrimSpace(strings.ToLower(v))
		switch text {
		case "true", "1", "yes", "y":
			return true, true
		case "false", "0", "no", "n":
			return false, true
		default:
			return false, false
		}
	case int:
		if v == 1 {
			return true, true
		}
		if v == 0 {
			return false, true
		}
		return false, false
	case float64:
		if v == 1 {
			return true, true
		}
		if v == 0 {
			return false, true
		}
		return false, false
	default:
		return false, false
	}
}

// paramBoolAnyWithDefault 按候选键提取 bool，未命中时返回 fallback。
func paramBoolAnyWithDefault(params map[string]any, fallback bool, keys ...string) bool {
	for _, key := range keys {
		if v, ok := paramBool(params, key); ok {
			return v
		}
	}
	return fallback
}

// readString 读取字符串参数，缺失时返回默认值。
func readString(params map[string]any, key string, fallback string) string {
	raw, ok := params[key]
	if !ok {
		return fallback
	}
	text := strings.TrimSpace(fmt.Sprintf("%v", raw))
	if text == "" {
		return fallback
	}
	return text
}

// normalizeDayScope 规范化 day_scope 取值。
func normalizeDayScope(scope string) string {
	switch strings.ToLower(strings.TrimSpace(scope)) {
	case "weekend":
		return "weekend"
	case "workday":
		return "workday"
	default:
		return "all"
	}
}

// normalizeStatusFilter 规范化 status 过滤条件。
func normalizeStatusFilter(status string) string {
	switch strings.ToLower(strings.TrimSpace(status)) {
	case "existing":
		return "existing"
	case "all":
		return "all"
	default:
		return "suggested"
	}
}

// matchStatusFilter 判断条目状态是否命中 status 过滤。
func matchStatusFilter(entryStatus string, statusFilter string) bool {
	switch strings.ToLower(strings.TrimSpace(statusFilter)) {
	case "all":
		return true
	case "existing":
		return strings.TrimSpace(entryStatus) == "existing"
	default:
		return strings.TrimSpace(entryStatus) == "suggested"
	}
}

// matchDayScope 判断 day_of_week 是否满足 scope 过滤条件。
func matchDayScope(day int, scope string) bool {
	switch scope {
	case "weekend":
		return day == 6 || day == 7
	case "workday":
		return day >= 1 && day <= 5
	default:
		return day >= 1 && day <= 7
	}
}

// intSliceToDaySet 把 day 切片转换为 set，并去除非法 day 值。
func intSliceToDaySet(items []int) map[int]struct{} {
	if len(items) == 0 {
		return nil
	}
	set := make(map[int]struct{}, len(items))
	for _, item := range items {
		if item < 1 || item > 7 {
			continue
		}
		set[item] = struct{}{}
	}
	if len(set) == 0 {
		return nil
	}
	return set
}

// intSliceToWeekSet 把周次切片转换为 set，并去除非正数。
func intSliceToWeekSet(items []int) map[int]struct{} {
	if len(items) == 0 {
		return nil
	}
	set := make(map[int]struct{}, len(items))
	for _, item := range items {
		if item <= 0 {
			continue
		}
		set[item] = struct{}{}
	}
	if len(set) == 0 {
		return nil
	}
	return set
}

// intSliceToSectionSet 把节次切片转换为 set，并去除非法节次。
func intSliceToSectionSet(items []int) map[int]struct{} {
	if len(items) == 0 {
		return nil
	}
	set := make(map[int]struct{}, len(items))
	for _, item := range items {
		if item < 1 || item > 12 {
			continue
		}
		set[item] = struct{}{}
	}
	if len(set) == 0 {
		return nil
	}
	return set
}

// intSliceToIDSet 把正整数 ID 切片转换为 set。
func intSliceToIDSet(items []int) map[int]struct{} {
	if len(items) == 0 {
		return nil
	}
	set := make(map[int]struct{}, len(items))
	for _, item := range items {
		if item <= 0 {
			continue
		}
		set[item] = struct{}{}
	}
	if len(set) == 0 {
		return nil
	}
	return set
}

// inferWeekBounds 推断查询周区间。
func inferWeekBounds(entries []model.HybridScheduleEntry, window planningWindow) (int, int) {
	if window.Enabled {
		return window.StartWeek, window.EndWeek
	}
	if len(entries) == 0 {
		return 1, 1
	}
	minWeek, maxWeek := entries[0].Week, entries[0].Week
	for _, entry := range entries {
		if entry.Week < minWeek {
			minWeek = entry.Week
		}
		if entry.Week > maxWeek {
			maxWeek = entry.Week
		}
	}
	return minWeek, maxWeek
}

// buildWeekIterList 构建周次迭代列表。
//
// 规则：
// 1. weekFilter 非空时，严格按过滤集合遍历；
// 2. weekFilter 为空时，按 weekFrom~weekTo 连续区间遍历；
// 3. 返回结果升序，便于日志与排查。
func buildWeekIterList(weekFilter map[int]struct{}, weekFrom, weekTo int) []int {
	if len(weekFilter) > 0 {
		return keysOfIntSet(weekFilter)
	}
	if weekFrom <= 0 || weekTo <= 0 || weekFrom > weekTo {
		return nil
	}
	out := make([]int, 0, weekTo-weekFrom+1)
	for w := weekFrom; w <= weekTo; w++ {
		out = append(out, w)
	}
	return out
}

// readIntSlice 读取 int 切片参数，兼容 []any / []int / 单个数值。
func readIntSlice(params map[string]any, keys ...string) []int {
	for _, key := range keys {
		raw, ok := params[key]
		if !ok {
			continue
		}
		switch v := raw.(type) {
		case []int:
			out := make([]int, len(v))
			copy(out, v)
			return out
		case []any:
			out := make([]int, 0, len(v))
			for _, item := range v {
				switch n := item.(type) {
				case int:
					out = append(out, n)
				case float64:
					out = append(out, int(n))
				case string:
					if parsed, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
						out = append(out, parsed)
					}
				}
			}
			return out
		default:
			if n, okNum := paramInt(params, key); okNum {
				return []int{n}
			}
		}
	}
	return nil
}

// readStringSlice 读取 string 切片参数，兼容 []any / []string / 单个字符串。
func readStringSlice(params map[string]any, keys ...string) []string {
	for _, key := range keys {
		raw, ok := params[key]
		if !ok || raw == nil {
			continue
		}
		switch vv := raw.(type) {
		case []string:
			out := make([]string, 0, len(vv))
			for _, item := range vv {
				text := strings.TrimSpace(item)
				if text != "" {
					out = append(out, text)
				}
			}
			return out
		case []any:
			out := make([]string, 0, len(vv))
			for _, item := range vv {
				text := strings.TrimSpace(fmt.Sprintf("%v", item))
				if text != "" {
					out = append(out, text)
				}
			}
			return out
		case string:
			text := strings.TrimSpace(vv)
			if text != "" {
				return []string{text}
			}
		default:
			text := strings.TrimSpace(fmt.Sprintf("%v", vv))
			if text != "" {
				return []string{text}
			}
		}
	}
	return nil
}

// intersectsExcludedSections 判断候选区间是否与排除节次有交集。
func intersectsExcludedSections(from, to int, excluded map[int]struct{}) bool {
	if len(excluded) == 0 {
		return false
	}
	for sec := from; sec <= to; sec++ {
		if _, ok := excluded[sec]; ok {
			return true
		}
	}
	return false
}

// keysOfIntSet 返回 int set 的有序键。
func keysOfIntSet(set map[int]struct{}) []int {
	if len(set) == 0 {
		return nil
	}
	keys := make([]int, 0, len(set))
	for k := range set {
		keys = append(keys, k)
	}
	sort.Ints(keys)
	return keys
}

// parseBatchMoveParams 解析 BatchMove 的 moves 参数。
//
// 步骤化说明：
// 1. 先读取 params["moves"]，必须存在且为非空数组；
// 2. 再把数组元素逐条转换成 map[string]any，便于复用 refineToolMove；
// 3. 任一元素类型非法即整体失败，避免“部分可执行、部分不可执行”带来的语义歧义。
func parseBatchMoveParams(params map[string]any) ([]map[string]any, error) {
	rawMoves, ok := params["moves"]
	if !ok {
		return nil, fmt.Errorf("参数缺失：BatchMove 需要 moves 数组")
	}

	var items []any
	switch v := rawMoves.(type) {
	case []any:
		items = v
	case []map[string]any:
		items = make([]any, 0, len(v))
		for _, item := range v {
			items = append(items, item)
		}
	default:
		return nil, fmt.Errorf("参数类型错误：BatchMove 的 moves 必须是数组")
	}
	if len(items) == 0 {
		return nil, fmt.Errorf("参数错误：BatchMove 的 moves 不能为空")
	}

	moveParamsList := make([]map[string]any, 0, len(items))
	for idx, item := range items {
		paramMap, ok := item.(map[string]any)
		if !ok {
			return nil, fmt.Errorf("参数类型错误：BatchMove 第%d步不是对象", idx+1)
		}
		moveParamsList = append(moveParamsList, paramMap)
	}
	return moveParamsList, nil
}

// classifyBatchMoveErrorCode 把单步 Move 失败原因映射为 BatchMove 层错误码。
//
// 说明：
// 1. 映射保持与普通 Move 的错误语义一致，便于模型统一处理；
// 2. 这里按失败文案做轻量推断，避免引入跨文件循环依赖。
func classifyBatchMoveErrorCode(detail string) string {
	text := strings.TrimSpace(detail)
	switch {
	case strings.Contains(text, "顺序约束不满足"):
		return "ORDER_VIOLATION"
	case strings.Contains(text, "参数缺失"):
		return "PARAM_MISSING"
	case strings.Contains(text, "目标时段已被"):
		return "SLOT_CONFLICT"
	case strings.Contains(text, "任务跨度不一致"):
		return "SPAN_MISMATCH"
	case strings.Contains(text, "超出允许窗口"):
		return "OUT_OF_WINDOW"
	case strings.Contains(text, "day_of_week"):
		return "DAY_INVALID"
	case strings.Contains(text, "节次区间"):
		return "SECTION_INVALID"
	case strings.Contains(text, "未找到 task_item_id"):
		return "TASK_NOT_FOUND"
	default:
		return "BATCH_MOVE_FAILED"
	}
}

// sortHybridEntries 对混合条目做稳定排序，保证日志与预览输出稳定。
func sortHybridEntries(entries []model.HybridScheduleEntry) {
	sort.SliceStable(entries, func(i, j int) bool {
		left := entries[i]
		right := entries[j]
		if left.Week != right.Week {
			return left.Week < right.Week
		}
		if left.DayOfWeek != right.DayOfWeek {
			return left.DayOfWeek < right.DayOfWeek
		}
		if left.SectionFrom != right.SectionFrom {
			return left.SectionFrom < right.SectionFrom
		}
		if left.SectionTo != right.SectionTo {
			return left.SectionTo < right.SectionTo
		}
		return left.Name < right.Name
	})
}

// truncate 截断日志内容，避免错误信息无上限增长。
func truncate(text string, maxLen int) string {
	if maxLen <= 0 {
		return ""
	}
	runes := []rune(text)
	if len(runes) <= maxLen {
		return text
	}
	return string(runes[:maxLen]) + "..."
}
