package schedulerefine

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/LoveLosita/smartflow/backend/model"
	"github.com/LoveLosita/smartflow/backend/respond"
	"github.com/cloudwego/eino-ext/components/model/ark"
	einoModel "github.com/cloudwego/eino/components/model"
	"github.com/cloudwego/eino/schema"
	arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)

const (
	nodeTimeout      = 120 * time.Second
	plannerMaxTokens = 420
	reactMaxTokens   = 360
)

const (
	jsonContractForContract    = "只输出单个 JSON 对象，不要 Markdown/代码块/解释。必须包含: intent,strategy,hard_requirements,hard_assertions,keep_relative_order,order_scope。"
	jsonContractForPlanner     = "只输出单个 JSON 对象，不要 Markdown/代码块/解释。必须包含: summary,steps。"
	jsonContractForReact       = "只输出单个 JSON 对象，不要 Markdown/代码块/解释。必须包含: done,summary,goal_check,decision,missing_info,tool_calls。"
	jsonContractForReview      = "只输出单个 JSON 对象，不要 Markdown/代码块/解释。必须包含: pass,reason,unmet。"
	jsonContractForPostReflect = "只输出单个 JSON 对象，不要 Markdown/代码块/解释。必须包含: reflection,next_strategy,should_stop。"
)

type contractOutput struct {
	Intent            string                `json:"intent"`
	Strategy          string                `json:"strategy"`
	HardRequirements  []string              `json:"hard_requirements"`
	HardAssertions    []hardAssertionOutput `json:"hard_assertions"`
	KeepRelativeOrder bool                  `json:"keep_relative_order"`
	OrderScope        string                `json:"order_scope"`
}

type hardAssertionOutput struct {
	Metric     string `json:"metric"`
	Operator   string `json:"operator"`
	Value      int    `json:"value"`
	Min        int    `json:"min"`
	Max        int    `json:"max"`
	Week       int    `json:"week"`
	TargetWeek int    `json:"target_week"`
}

type postReflectOutput struct {
	Reflection   string `json:"reflection"`
	NextStrategy string `json:"next_strategy"`
	ShouldStop   bool   `json:"should_stop"`
}

type plannerOutput struct {
	Summary string   `json:"summary"`
	Steps   []string `json:"steps"`
}

func runContractNode(
	ctx context.Context,
	chatModel *ark.ChatModel,
	st *ScheduleRefineState,
	emitStage func(stage, detail string),
) (*ScheduleRefineState, error) {
	if st == nil {
		return nil, fmt.Errorf("schedule refine: nil state in contract node")
	}
	if chatModel == nil {
		return nil, fmt.Errorf("schedule refine: model is nil in contract node")
	}
	emitStage("schedule_refine.contract.analyzing", "正在抽取本轮微调目标与硬性约束。")

	userPrompt := withNearestJSONContract(
		fmt.Sprintf(
			"当前时间=%s\n用户请求=%s\n当前排程条目数=%d\n可调 suggested 数=%d\n已有约束=%s\n历史摘要=%s",
			st.RequestNowText,
			strings.TrimSpace(st.UserMessage),
			len(st.HybridEntries),
			countSuggested(st.HybridEntries),
			strings.Join(st.Constraints, "；"),
			condenseSummary(st.CandidatePlans),
		),
		jsonContractForContract,
	)
	raw, err := callModelText(ctx, chatModel, contractPrompt, userPrompt, false, 260, 0)
	if err != nil {
		st.Contract = buildFallbackContract(st)
		st.UserIntent = st.Contract.Intent
		emitStage("schedule_refine.contract.fallback", "契约抽取失败，已按兜底策略继续微调。")
		return st, nil
	}
	emitModelRawDebug(emitStage, "contract", raw)
	parsed, parseErr := parseJSON[contractOutput](raw)
	if parseErr != nil {
		st.Contract = buildFallbackContract(st)
		st.UserIntent = st.Contract.Intent
		emitStage("schedule_refine.contract.fallback", fmt.Sprintf("契约解析失败，已按兜底策略继续微调：%s", truncate(parseErr.Error(), 180)))
		return st, nil
	}

	intent := strings.TrimSpace(parsed.Intent)
	if intent == "" {
		intent = strings.TrimSpace(st.UserMessage)
	}
	// 1. 顺序策略以用户表达为准：默认保持顺序，明确授权乱序才放开。
	// 2. 不再让模型自行放宽顺序，避免契约漂移导致“默认乱序”。
	keepOrder := detectOrderIntent(st.UserMessage)
	reqs := append([]string(nil), parsed.HardRequirements...)
	if keepOrder {
		reqs = append(reqs, "保持任务原始相对顺序不变")
	}
	assertions := normalizeHardAssertions(parsed.HardAssertions)
	if len(assertions) == 0 {
		// 1. 当模型未给出结构化断言时，后端基于请求做兜底推断。
		// 2. 目标是保证终审一定可落到“可编程判断”的参数层，而不是停留在自然语言。
		assertions = inferHardAssertionsFromRequest(st.UserMessage, reqs)
	}
	st.UserIntent = intent
	st.Contract = RefineContract{
		Intent:            intent,
		Strategy:          normalizeStrategy(parsed.Strategy),
		HardRequirements:  uniqueNonEmpty(reqs),
		HardAssertions:    assertions,
		KeepRelativeOrder: keepOrder,
		OrderScope:        normalizeOrderScope(parsed.OrderScope),
	}
	emitStage("schedule_refine.contract.done", fmt.Sprintf("契约抽取完成：strategy=%s, keep_relative_order=%t。", st.Contract.Strategy, st.Contract.KeepRelativeOrder))
	return st, nil
}

func runPlanNode(
	ctx context.Context,
	chatModel *ark.ChatModel,
	st *ScheduleRefineState,
	emitStage func(stage, detail string),
) (*ScheduleRefineState, error) {
	if st == nil {
		return nil, fmt.Errorf("schedule refine: nil state in plan node")
	}
	if chatModel == nil {
		return nil, fmt.Errorf("schedule refine: model is nil in plan node")
	}
	if err := runPlannerNode(ctx, chatModel, st, emitStage, "initial"); err != nil {
		return st, err
	}
	return st, nil
}

func runSliceNode(
	ctx context.Context,
	st *ScheduleRefineState,
	emitStage func(stage, detail string),
) (*ScheduleRefineState, error) {
	_ = ctx
	if st == nil {
		return nil, fmt.Errorf("schedule refine: nil state in slice node")
	}
	emitStage("schedule_refine.slice.building", "正在构建本轮微调任务切片。")
	slice := buildSlicePlan(st)
	workset := collectWorksetTaskIDs(st.HybridEntries, slice, st.OriginOrderMap)
	if len(workset) == 0 {
		relaxed := slice
		relaxed.SourceDays = nil
		workset = collectWorksetTaskIDs(st.HybridEntries, relaxed, st.OriginOrderMap)
		if len(workset) > 0 {
			slice = relaxed
			emitStage("schedule_refine.slice.relaxed", "切片首次为空，已放宽来源日过滤。")
		}
	}
	if len(workset) == 0 {
		workset = collectWorksetTaskIDs(st.HybridEntries, RefineSlicePlan{}, st.OriginOrderMap)
		emitStage("schedule_refine.slice.fallback", "切片仍为空，已回退到全量 suggested 任务。")
	}
	st.SlicePlan = slice
	st.Objective = compileRefineObjective(st, slice)
	st.WorksetTaskIDs = workset
	st.WorksetCursor = 0
	st.CurrentTaskID = 0
	st.CurrentTaskAttempt = 0
	emitStage("schedule_refine.slice.done", fmt.Sprintf("切片完成：workset=%d，week_filter=%v，source_days=%v，target_days=%v，exclude_sections=%v。", len(workset), slice.WeekFilter, slice.SourceDays, slice.TargetDays, slice.ExcludeSections))
	if raw, err := json.Marshal(st.Objective); err == nil {
		emitStage("schedule_refine.objective.done", fmt.Sprintf("目标编译完成：%s", string(raw)))
	} else {
		emitStage("schedule_refine.objective.done", "目标编译完成。")
	}
	return st, nil
}

// runCompositeRouteNode 在 ReAct 之前做一次“全局复合动作直达”分流。
//
// 职责边界：
// 1. 负责识别是否命中全局复合目标（SpreadEven/MinContextSwitch）；
// 2. 负责直接调用一次复合工具并按配置重试，争取在进入 ReAct 前完成收口；
// 3. 不负责语义推理与逐任务细调，失败后仅负责切换到“禁复合”的 ReAct 兜底链路。
func runCompositeRouteNode(
	ctx context.Context,
	st *ScheduleRefineState,
	emitStage func(stage, detail string),
) (*ScheduleRefineState, error) {
	_ = ctx
	if st == nil {
		return nil, fmt.Errorf("schedule refine: nil state in route node")
	}
	ensureCompositeStateMaps(st)
	if st.CompositeRetryMax < 0 {
		st.CompositeRetryMax = defaultCompositeRetry
	}
	// 1. 先由后端判定本轮是否需要复合路由，避免把分流复杂度继续交给主 ReAct。
	// 2. 若已被上游标记为“禁复合兜底”，直接跳过该路由。
	if st.DisableCompositeTools {
		emitStage("schedule_refine.route.skip", "当前已处于禁复合兜底模式，跳过复合路由。")
		return st, nil
	}
	if strings.TrimSpace(st.RequiredCompositeTool) == "" {
		st.RequiredCompositeTool = detectRequiredCompositeTool(st)
	}
	required := normalizeCompositeToolName(st.RequiredCompositeTool)
	if required == "" {
		emitStage("schedule_refine.route.skip", "未命中全局复合目标，直接进入 ReAct 兜底链路。")
		return st, nil
	}

	taskIDs := buildCompositeRouteTaskIDs(st)
	if len(taskIDs) == 0 {
		// 1. 没有任务可用于复合规划时，复合路由无法落地。
		// 2. 直接降级到 ReAct，并明确禁用复合工具，避免循环重试同一失败路径。
		st.CompositeRouteTried = true
		st.DisableCompositeTools = true
		st.RequiredCompositeTool = ""
		st.CurrentPlan = buildFallbackPlan(st)
		st.BatchMoveAllowed = false
		emitStage("schedule_refine.route.fallback", "复合路由未获取到可执行任务，已切换到禁复合 ReAct 兜底。")
		return st, nil
	}

	totalAttempts := 1 + st.CompositeRetryMax
	emitStage("schedule_refine.route.start", fmt.Sprintf("命中复合路由：tool=%s，task_count=%d，首次1次+重试%d次。", required, len(taskIDs), st.CompositeRetryMax))
	st.CompositeRouteTried = true

	policy := refineToolPolicy{
		// 1. 路由阶段只解决“坑位分布”。
		// 2. 顺序归位统一放在终审阶段，避免复合路由被顺序约束提前卡死。
		KeepRelativeOrder: false,
		OrderScope:        st.Contract.OrderScope,
		OriginOrderMap:    st.OriginOrderMap,
	}
	window := buildPlanningWindowFromEntries(st.HybridEntries)
	lastReason := ""

	for attempt := 1; attempt <= totalAttempts; attempt++ {
		if st.RoundUsed >= st.ExecuteMax {
			lastReason = "动作预算已耗尽，无法继续复合路由重试"
			break
		}
		call := buildCompositeRouteCall(st, required, taskIDs)
		callJSON, _ := json.Marshal(call.Params)
		emitStage("schedule_refine.route.attempt", fmt.Sprintf("复合路由第 %d/%d 次尝试：调用=%s 参数=%s。", attempt, totalAttempts, required, string(callJSON)))

		nextEntries, rawResult := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), call, window, policy)
		result := normalizeToolResult(rawResult)
		st.RoundUsed++
		markCompositeToolOutcome(st, result.Tool, result.Success)
		emitStage("schedule_refine.route.result", fmt.Sprintf("复合路由第 %d 次结果：success=%t,error_code=%s,detail=%s", attempt, result.Success, fallbackText(result.ErrorCode, "NONE"), truncate(result.Result, 160)))

		if !result.Success {
			lastReason = fallbackText(result.Result, fallbackText(result.ErrorCode, "复合工具执行失败"))
			st.LastFailedCallSignature = buildToolCallSignature(call)
			st.ConsecutiveFailures++
			continue
		}

		st.HybridEntries = nextEntries
		st.EntriesVersion++
		st.LastFailedCallSignature = ""
		st.ConsecutiveFailures = 0
		st.ThinkingBoostArmed = false
		window = buildPlanningWindowFromEntries(st.HybridEntries)

		// 1. 复合动作成功后必须立刻做后端确定性校验，避免“调用成功但目标未达成”被误收口。
		// 2. 仅当业务目标与（若存在）复合门禁同时通过时，才允许跳过 ReAct。
		if pass, reason, unmet, applied := evaluateObjectiveDeterministic(st); applied {
			pass, reason, unmet = applyCompositeGateToIntentResult(st, pass, strings.TrimSpace(reason), unmet)
			if pass {
				st.CompositeRouteSucceeded = true
				emitStage("schedule_refine.route.pass", fmt.Sprintf("复合路由收口成功：%s", truncate(reason, 160)))
				st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由成功收口：tool=%s，reason=%s", required, reason))
				return st, nil
			}
			lastReason = fallbackText(strings.TrimSpace(reason), "确定性目标未达成")
			if len(unmet) > 0 {
				emitStage("schedule_refine.route.unmet", fmt.Sprintf("复合路由第 %d 次后仍未达成：%s", attempt, truncate(strings.Join(unmet, "；"), 180)))
			}
			continue
		}

		// 1. “均匀分散/最少上下文切换”这类复合目标，未必能编译成 deterministic objective；
		// 2. 只要本轮要求的复合工具已经成功执行，就允许独立复合分支直接出站并跳过 ReAct；
		// 3. 最终是否真正达标，继续交给 hard_check 统一裁决，避免“工具成功却被路由误判失败”。
		if reason, ok := allowCompositeRouteExitByToolSuccess(st, result); ok {
			st.CompositeRouteSucceeded = true
			emitStage("schedule_refine.route.handoff", truncate(reason, 180))
			st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由直接出站：tool=%s，reason=%s", required, reason))
			return st, nil
		}

		lastReason = "未启用确定性目标，且复合工具门禁未满足，无法在复合路由直接出站"
	}

	// 1. 复合路由重试后仍失败，切入 ReAct 兜底并强制禁用复合工具。
	// 2. 禁用后仅允许基础工具逐任务搬运，避免再次回到复合失败路径造成震荡。
	st.DisableCompositeTools = true
	st.RequiredCompositeTool = ""
	st.CurrentPlan = buildFallbackPlan(st)
	st.BatchMoveAllowed = false
	emitStage("schedule_refine.route.fallback", fmt.Sprintf("复合路由未收口，切换禁复合 ReAct 兜底：%s", truncate(fallbackText(lastReason, "复合路由达到重试上限"), 180)))
	st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("复合路由失败后降级：%s", fallbackText(lastReason, "无具体失败原因")))
	return st, nil
}

func buildCompositeRouteTaskIDs(st *ScheduleRefineState) []int {
	if st == nil {
		return nil
	}
	ids := uniquePositiveInts(append([]int(nil), st.WorksetTaskIDs...))
	if len(ids) > 0 {
		return ids
	}
	ids = collectSourceTaskIDsForObjective(st.InitialHybridEntries, st.Objective, st.SlicePlan.WeekFilter)
	if len(ids) > 0 {
		return ids
	}
	// 兜底：从当前 suggested 中提取一份稳定任务集，避免因切片异常导致路由空跑。
	seen := make(map[int]struct{}, len(st.HybridEntries))
	out := make([]int, 0, len(st.HybridEntries))
	for _, entry := range st.HybridEntries {
		if !isMovableSuggestedTask(entry) {
			continue
		}
		if _, ok := seen[entry.TaskItemID]; ok {
			continue
		}
		seen[entry.TaskItemID] = struct{}{}
		out = append(out, entry.TaskItemID)
	}
	sort.Ints(out)
	return out
}

// allowCompositeRouteExitByToolSuccess 判断“复合工具成功后，是否允许跳过 ReAct 直接进入终审”。
//
// 步骤化说明：
// 1. 仅在当前没有 deterministic objective 时启用，避免覆盖原有“确定性验收优先”策略；
// 2. 只有本轮要求的复合工具已成功、且成功工具名与门禁一致时才放行；
// 3. 放行后并不代表最终成功，后续仍由 hard_check 做统一裁决。
func allowCompositeRouteExitByToolSuccess(st *ScheduleRefineState, result reactToolResult) (string, bool) {
	if st == nil || !result.Success {
		return "", false
	}
	if strings.TrimSpace(st.Objective.Mode) != "" && strings.TrimSpace(st.Objective.Mode) != "none" {
		return "", false
	}
	required := normalizeCompositeToolName(st.RequiredCompositeTool)
	toolName := normalizeCompositeToolName(result.Tool)
	if required == "" || toolName == "" || required != toolName {
		return "", false
	}
	if !isRequiredCompositeSatisfied(st) {
		return "", false
	}
	return fmt.Sprintf("复合工具 %s 已成功执行；当前目标暂不支持确定性收口，跳过 ReAct，交由终审裁决。", required), true
}

func buildCompositeRouteCall(st *ScheduleRefineState, tool string, taskIDs []int) reactToolCall {
	limit := len(taskIDs) * 6
	if limit < 12 {
		limit = 12
	}
	params := map[string]any{
		"task_item_ids": append([]int(nil), taskIDs...),
		"allow_embed":   true,
		"limit":         limit,
	}
	targetWeeks := append([]int(nil), st.Objective.TargetWeeks...)
	if len(targetWeeks) == 0 {
		targetWeeks = keysOfIntSet(inferTargetWeekSet(st.SlicePlan))
	}
	if len(targetWeeks) == 0 {
		targetWeeks = append([]int(nil), st.Objective.SourceWeeks...)
	}
	if len(targetWeeks) == 1 {
		params["week"] = targetWeeks[0]
	} else if len(targetWeeks) > 1 {
		params["week_filter"] = targetWeeks
	}

	targetDays := append([]int(nil), st.Objective.TargetDays...)
	if len(targetDays) == 0 {
		targetDays = append([]int(nil), st.SlicePlan.TargetDays...)
	}
	if len(targetDays) > 0 {
		params["day_of_week"] = targetDays
	}
	if len(st.SlicePlan.ExcludeSections) > 0 {
		params["exclude_sections"] = append([]int(nil), st.SlicePlan.ExcludeSections...)
	}
	return reactToolCall{
		Tool:   tool,
		Params: params,
	}
}

func runReactLoopNode(
	ctx context.Context,
	chatModel *ark.ChatModel,
	st *ScheduleRefineState,
	emitStage func(stage, detail string),
) (*ScheduleRefineState, error) {
	if st == nil {
		return nil, fmt.Errorf("schedule refine: nil state in react loop node")
	}
	if chatModel == nil {
		return nil, fmt.Errorf("schedule refine: model is nil in react loop node")
	}
	if st.CompositeRouteSucceeded {
		emitStage("schedule_refine.react.skip", "复合路由已收口成功，跳过 ReAct 兜底循环。")
		return st, nil
	}
	if len(st.HybridEntries) == 0 {
		st.ActionLogs = append(st.ActionLogs, "无可微调条目，跳过动作循环。")
		return st, nil
	}
	if len(st.WorksetTaskIDs) == 0 {
		st.ActionLogs = append(st.ActionLogs, "workset 为空，跳过动作循环。")
		return st, nil
	}
	if st.PerTaskBudget <= 0 {
		st.PerTaskBudget = defaultPerTaskBudget
	}
	if st.ExecuteMax <= 0 {
		st.ExecuteMax = defaultExecuteMax
	}
	if st.ReplanMax < 0 {
		st.ReplanMax = defaultReplanMax
	}
	if st.RepairReserve < 0 {
		st.RepairReserve = 0
	}
	st.MaxRounds = st.ExecuteMax + st.RepairReserve
	if st.TaskActionUsed == nil {
		st.TaskActionUsed = make(map[int]int)
	}
	if st.SeenSlotQueries == nil {
		st.SeenSlotQueries = make(map[string]struct{})
	}
	ensureCompositeStateMaps(st)
	if st.DisableCompositeTools {
		st.RequiredCompositeTool = ""
		emitStage("schedule_refine.react.fallback_mode", "当前为禁复合兜底模式：仅允许基础工具逐任务调整。")
	} else if strings.TrimSpace(st.RequiredCompositeTool) == "" {
		st.RequiredCompositeTool = detectRequiredCompositeTool(st)
	}
	if strings.TrimSpace(st.CurrentPlan.Summary) == "" {
		st.CurrentPlan = applyCompositeHardConditionToPlan(st, buildFallbackPlan(st))
		st.BatchMoveAllowed = shouldAllowBatchMove(st.CurrentPlan)
	}

	window := buildPlanningWindowFromEntries(st.HybridEntries)
	sourceWeekSet := inferSourceWeekSet(st.SlicePlan)
	policy := refineToolPolicy{
		// 1. 执行期不再用顺序约束卡住 Move/Swap；
		// 2. LLM 只负责把坑位排好，顺序由后端在收口阶段统一归位。
		KeepRelativeOrder: false,
		OrderScope:        st.Contract.OrderScope,
		OriginOrderMap:    st.OriginOrderMap,
	}
	emitStage(
		"schedule_refine.react.start",
		fmt.Sprintf(
			"开始执行单任务微步 ReAct，workset=%d，per_task_budget=%d，execute_max=%d，replan_max=%d，required_composite=%s，required_success=%t。",
			len(st.WorksetTaskIDs),
			st.PerTaskBudget,
			st.ExecuteMax,
			st.ReplanMax,
			fallbackText(normalizeCompositeToolName(st.RequiredCompositeTool), "无"),
			isRequiredCompositeSatisfied(st),
		),
	)

outer:
	for st.WorksetCursor < len(st.WorksetTaskIDs) && st.RoundUsed < st.ExecuteMax {
		// 1. 每次取下一个任务前先做一次全局目标短路判断。
		// 2. 目标已满足时，直接结束整个 workset 循环，避免“任务6~10 空转”。
		if pass, reason, _, applied := evaluateObjectiveDeterministic(st); applied && pass {
			if isRequiredCompositeSatisfied(st) {
				emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("全局目标已满足，提前结束任务循环：%s", truncate(reason, 160)))
				st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("全局目标提前达成，触发短路结束：%s", reason))
				break
			}
			st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("全局目标看似达成但未满足复合工具门禁：required=%s", normalizeCompositeToolName(st.RequiredCompositeTool)))
		}
		taskID := st.WorksetTaskIDs[st.WorksetCursor]
		current, ok := findSuggestedEntryByTaskID(st.HybridEntries, taskID)
		if !ok {
			st.WorksetCursor++
			continue
		}
		if len(sourceWeekSet) > 0 {
			if _, inSourceWeek := sourceWeekSet[current.Week]; !inSourceWeek {
				emitStage("schedule_refine.react.task_skip_scope", fmt.Sprintf("任务 id=%d 当前位于 W%d，不在来源周范围，已跳过。", taskID, current.Week))
				st.WorksetCursor++
				continue
			}
		}
		st.CurrentTaskID = taskID
		st.CurrentTaskAttempt = 0
		emitStage("schedule_refine.react.task_start", fmt.Sprintf("开始处理任务 %d/%d：id=%d，%s。", st.WorksetCursor+1, len(st.WorksetTaskIDs), taskID, strings.TrimSpace(current.Name)))

		taskDone := false
		for st.CurrentTaskAttempt < st.PerTaskBudget && st.RoundUsed < st.ExecuteMax {
			// 1. 每轮开头先刷新“当前任务”的最新位置，避免模型基于旧坐标决策。
			// 2. 若该任务已满足切片目标（例如“已从周末迁出到工作日”），则直接收口当前任务。
			latest, exists := findSuggestedEntryByTaskID(st.HybridEntries, taskID)
			if !exists {
				taskDone = true
				emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 已不在 suggested 列表，视为当前任务已完成。", taskID))
				st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成：任务条目已不再可调 suggested。", taskID))
				break
			}
			current = latest
			if isCurrentTaskSatisfiedBySlice(current, st.SlicePlan) {
				// 1. 自动收口前必须通过复合工具门禁。
				// 2. 这样可避免“切片已满足但未执行必需复合工具”直接跳过执行阶段。
				if isRequiredCompositeSatisfied(st) {
					taskDone = true
					emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 已满足切片目标，自动收口并切换下一任务。", taskID))
					st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成：已满足切片目标。", taskID))
					break
				}
				emitStage("schedule_refine.react.task_auto_done_blocked", fmt.Sprintf("任务 id=%d 虽满足切片目标，但复合工具门禁未通过，继续执行。", taskID))
				st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 阻止自动收口：required_composite=%s 尚未成功。", taskID, fallbackText(normalizeCompositeToolName(st.RequiredCompositeTool), "无")))
			}

			round := st.RoundUsed + 1
			remainingAction := st.ExecuteMax - st.RoundUsed
			remainingTotal := st.MaxRounds - st.RoundUsed
			useThinking, reason := shouldEnableRecoveryThinking(st)
			st.CurrentTaskAttempt++
			emitStage("schedule_refine.react.round_start", fmt.Sprintf("第 %d 轮微调开始（任务id=%d，第 %d/%d 次尝试），动作剩余=%d，总剩余=%d。", round, taskID, st.CurrentTaskAttempt, st.PerTaskBudget, remainingAction, remainingTotal))
			if useThinking {
				emitStage("schedule_refine.react.reasoning_switch", fmt.Sprintf("第 %d 轮已启用恢复态 thinking：%s", round, reason))
			}

			userPrompt := buildMicroReactUserPrompt(st, current, remainingAction, remainingTotal)
			raw, err := callModelText(ctx, chatModel, reactPrompt, userPrompt, useThinking, reactMaxTokens, 0)
			if err != nil {
				errDetail := formatRoundModelErrorDetail(round, err, ctx)
				st.ActionLogs = append(st.ActionLogs, errDetail)
				emitStage("schedule_refine.react.round_error", errDetail)
				if errors.Is(err, context.DeadlineExceeded) && st.RoundUsed > 0 {
					st.WorksetCursor = len(st.WorksetTaskIDs)
					break
				}
				return st, err
			}
			emitModelRawDebug(emitStage, fmt.Sprintf("react.round.%d.plan", round), raw)
			parsed, parseErr := parseReactOutputWithRetryOnce(ctx, chatModel, userPrompt, raw, round, emitStage, st)
			if parseErr != nil {
				return st, parseErr
			}

			observation := ReactRoundObservation{
				Round:     round,
				GoalCheck: strings.TrimSpace(parsed.GoalCheck),
				Decision:  strings.TrimSpace(parsed.Decision),
			}
			emitStage("schedule_refine.react.plan", formatReactPlanStageDetail(round, parsed, remainingAction, useThinking))
			emitStage("schedule_refine.react.need_info", formatReactNeedInfoStageDetail(round, parsed.MissingInfo))

			if parsed.Done {
				allowDone := isCurrentTaskSatisfiedBySlice(current, st.SlicePlan)
				if allowDone && !isRequiredCompositeSatisfied(st) {
					allowDone = false
				}
				if !allowDone {
					if pass, _, _, applied := evaluateObjectiveDeterministic(st); applied && pass && isRequiredCompositeSatisfied(st) {
						allowDone = true
					}
				}
				if !allowDone {
					observation.Reflect = fmt.Sprintf("模型返回 done=true，但任务 id=%d 尚未满足切片目标或复合工具门禁未通过，继续执行。", taskID)
					st.ObservationHistory = append(st.ObservationHistory, observation)
					emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect))
					st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 拒绝提前 done：当前任务未满足目标。", taskID))
					continue
				}
				reasonText := fallbackText(strings.TrimSpace(parsed.Summary), "模型判定当前任务已满足目标。")
				observation.Reflect = reasonText
				st.ObservationHistory = append(st.ObservationHistory, observation)
				st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 完成：%s", taskID, reasonText))
				emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect))
				taskDone = true
				break
			}

			call, warn := pickSingleToolCall(parsed.ToolCalls)
			if warn != "" {
				emitStage("schedule_refine.react.round_warn", fmt.Sprintf("第 %d 轮告警：%s", round, warn))
			}
			if call == nil {
				observation.Reflect = "本轮未生成可执行工具动作。"
				st.ObservationHistory = append(st.ObservationHistory, observation)
				emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect))
				break
			}
			normalizedCall := canonicalizeToolCall(*call)
			call = &normalizedCall
			emitStage("schedule_refine.react.tool_call", formatToolCallStageDetail(round, *call, remainingAction))

			callSignature := buildToolCallSignature(*call)
			taskIDs := listTaskIDsFromToolCall(*call)
			if blockedResult, blocked := precheckCurrentTaskOwnership(*call, taskIDs, taskID); blocked {
				if stop, err := handleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, blockedResult, &observation); err != nil {
					return st, err
				} else if stop {
					taskDone = true
					break
				}
				continue
			}
			if blockedResult, blocked := precheckToolCallPolicy(st, *call, taskIDs); blocked {
				if stop, err := handleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, blockedResult, &observation); err != nil {
					return st, err
				} else if stop {
					taskDone = true
					break
				}
				continue
			}
			if isRepeatedFailedCall(st, callSignature) {
				repeat := reactToolResult{Tool: strings.TrimSpace(call.Tool), Success: false, ErrorCode: "REPEAT_FAILED_ACTION", Result: "重复失败动作：与上一轮失败动作完全相同，请更换目标时段或改用 Swap。"}
				if stop, err := handleBlockedToolResult(ctx, chatModel, st, emitStage, round, parsed, call, callSignature, repeat, &observation); err != nil {
					return st, err
				} else if stop {
					taskDone = true
					break
				}
				continue
			}

			for _, id := range taskIDs {
				st.TaskActionUsed[id]++
			}
			nextEntries, rawResult := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), *call, window, policy)
			result := normalizeToolResult(rawResult)
			st.RoundUsed++
			markCompositeToolOutcome(st, result.Tool, result.Success)

			observation.ToolName = strings.TrimSpace(result.Tool)
			observation.ToolParams = cloneToolParams(call.Params)
			observation.ToolSuccess = result.Success
			observation.ToolErrorCode = strings.TrimSpace(result.ErrorCode)
			observation.ToolResult = strings.TrimSpace(result.Result)
			postReflectText, _, shouldStop := runPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage)
			observation.Reflect = postReflectText
			st.ObservationHistory = append(st.ObservationHistory, observation)

			emitStage("schedule_refine.react.tool_result", formatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds))
			emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect))
			if result.Success {
				st.HybridEntries = nextEntries
				window = buildPlanningWindowFromEntries(st.HybridEntries)
				if isMutatingToolName(result.Tool) {
					st.EntriesVersion++
				}
				st.LastFailedCallSignature = ""
				st.ConsecutiveFailures = 0
				st.ThinkingBoostArmed = false
				// 1. 动作成功后立即尝试全局短路，避免继续拉着后续任务空转。
				// 2. 只要 deterministic 目标达成，直接收口整个 ReAct 循环。
				if pass, reason, _, applied := evaluateObjectiveDeterministic(st); applied && pass {
					if isRequiredCompositeSatisfied(st) {
						emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("动作后全局目标达成，提前结束任务循环：%s", truncate(reason, 160)))
						st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("动作后全局目标达成，触发短路结束：%s", reason))
						taskDone = true
						break outer
					}
					st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("动作后目标达成但复合工具门禁未通过：required=%s", normalizeCompositeToolName(st.RequiredCompositeTool)))
				}
				if latest, exists := findSuggestedEntryByTaskID(st.HybridEntries, taskID); exists {
					current = latest
					if isCurrentTaskSatisfiedBySlice(current, st.SlicePlan) {
						if isRequiredCompositeSatisfied(st) {
							taskDone = true
							emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 动作后已满足切片目标，自动结束当前任务。", taskID))
							st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成：动作后已满足切片目标。", taskID))
							break
						}
						emitStage("schedule_refine.react.task_auto_done_blocked", fmt.Sprintf("任务 id=%d 动作后满足切片目标，但复合工具门禁未通过，继续执行。", taskID))
						st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 阻止动作后自动收口：required_composite=%s 尚未成功。", taskID, fallbackText(normalizeCompositeToolName(st.RequiredCompositeTool), "无")))
					}
				} else {
					taskDone = true
					emitStage("schedule_refine.react.task_auto_done", fmt.Sprintf("任务 id=%d 动作后已不在 suggested 列表，自动结束当前任务。", taskID))
					st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("任务 id=%d 自动完成：动作后不再可调。", taskID))
					break
				}
			} else {
				st.LastFailedCallSignature = callSignature
				st.ConsecutiveFailures++
				if shouldTriggerReplan(st, result) {
					if replanned, err := tryReplan(ctx, chatModel, st, emitStage); err != nil {
						return st, err
					} else if replanned {
						continue
					}
				}
			}
			if shouldStop {
				// 1. 模型建议 should_stop 只作为“候选中断信号”，必须经后端目标校验确认。
				// 2. 若全局目标未达成，则继续本地循环，避免模型误停。
				if pass, reason, _, applied := evaluateObjectiveDeterministic(st); applied && pass {
					if isRequiredCompositeSatisfied(st) {
						emitStage("schedule_refine.react.short_circuit", fmt.Sprintf("模型建议停止且全局目标达成，提前收口：%s", truncate(reason, 160)))
						st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("模型建议停止且目标达成，触发短路结束：%s", reason))
						taskDone = true
						break outer
					}
					st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("模型建议停止但复合工具门禁未通过：required=%s", normalizeCompositeToolName(st.RequiredCompositeTool)))
				}
			}
		}

		emitStage("schedule_refine.react.task_done", fmt.Sprintf("任务 id=%d 处理完成：status=%s。", taskID, taskProgressLabel(taskDone, st.CurrentTaskAttempt, st.PerTaskBudget)))
		st.WorksetCursor++
		st.CurrentTaskID = 0
		st.CurrentTaskAttempt = 0
	}
	emitStage("schedule_refine.react.done", fmt.Sprintf("单任务微步 ReAct 结束：已执行轮次=%d，重规划次数=%d，已处理任务=%d/%d。", st.RoundUsed, st.ReplanUsed, st.WorksetCursor, len(st.WorksetTaskIDs)))
	return st, nil
}

func runHardCheckNode(
	ctx context.Context,
	chatModel *ark.ChatModel,
	st *ScheduleRefineState,
	emitStage func(stage, detail string),
) (*ScheduleRefineState, error) {
	if st == nil {
		return nil, fmt.Errorf("schedule refine: nil state in hard check node")
	}
	if chatModel == nil {
		return nil, fmt.Errorf("schedule refine: model is nil in hard check node")
	}
	emitStage("schedule_refine.hard_check.start", "正在执行终审硬校验。")
	// 1. 先锁定“业务目标是否达成”的判定结果（未排序前）。
	// 2. 后续顺序归位仅用于最终展示与顺序一致性，不得反向改变业务目标成败。
	intentPassLocked, intentReasonLocked, intentUnmetLocked := evaluateIntentForJudgement(ctx, chatModel, st, emitStage)
	emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("终审业务目标已锁定：pass=%t，reason=%s", intentPassLocked, truncate(intentReasonLocked, 120)))
	if changed, skipped := tryNormalizeMovableTaskOrderByOrigin(st); skipped {
		emitStage("schedule_refine.hard_check.order_normalized", "已跳过顺序归位：MinContextSwitch 结果需要保留重排后的任务顺序。")
	} else if changed {
		emitStage("schedule_refine.hard_check.order_normalized", "已在终审前按 origin_rank 对坑位做顺序归位。")
	}
	report := evaluateHardChecks(ctx, chatModel, st, emitStage)
	report.IntentPassed = intentPassLocked
	report.IntentReason = intentReasonLocked
	report.IntentUnmet = append([]string(nil), intentUnmetLocked...)
	st.HardCheck = report
	if report.PhysicsPassed && report.OrderPassed && report.IntentPassed {
		emitStage("schedule_refine.hard_check.pass", "终审通过。")
		return st, nil
	}
	if st.RoundUsed >= st.MaxRounds {
		emitStage("schedule_refine.hard_check.fail", "终审未通过，且动作预算已耗尽，无法继续修复。")
		return st, nil
	}
	emitStage("schedule_refine.hard_check.repairing", "终审未通过，正在尝试一次修复动作。")
	st.HardCheck.RepairTried = true
	if err := runSingleRepairAction(ctx, chatModel, st, emitStage); err != nil {
		st.ActionLogs = append(st.ActionLogs, fmt.Sprintf("修复动作失败：%v", err))
		emitStage("schedule_refine.hard_check.fail", "修复动作失败，保留当前方案。")
		return st, nil
	}
	intentPassLocked, intentReasonLocked, intentUnmetLocked = evaluateIntentForJudgement(ctx, chatModel, st, emitStage)
	emitStage("schedule_refine.hard_check.intent_locked", fmt.Sprintf("修复后业务目标已锁定：pass=%t，reason=%s", intentPassLocked, truncate(intentReasonLocked, 120)))
	if changed, skipped := tryNormalizeMovableTaskOrderByOrigin(st); skipped {
		emitStage("schedule_refine.hard_check.order_normalized", "修复后跳过顺序归位：MinContextSwitch 结果需要保留重排后的任务顺序。")
	} else if changed {
		emitStage("schedule_refine.hard_check.order_normalized", "修复后已按 origin_rank 对坑位做顺序归位。")
	}
	report = evaluateHardChecks(ctx, chatModel, st, emitStage)
	report.IntentPassed = intentPassLocked
	report.IntentReason = intentReasonLocked
	report.IntentUnmet = append([]string(nil), intentUnmetLocked...)
	report.RepairTried = true
	st.HardCheck = report
	if report.PhysicsPassed && report.OrderPassed && report.IntentPassed {
		emitStage("schedule_refine.hard_check.pass", "修复后终审通过。")
		return st, nil
	}
	emitStage("schedule_refine.hard_check.fail", "修复后仍未完全满足要求，已返回当前最优结果。")
	return st, nil
}

func runSummaryNode(
	ctx context.Context,
	chatModel *ark.ChatModel,
	st *ScheduleRefineState,
	emitStage func(stage, detail string),
) (*ScheduleRefineState, error) {
	if st == nil {
		return nil, fmt.Errorf("schedule refine: nil state in summary node")
	}
	if chatModel == nil {
		return nil, fmt.Errorf("schedule refine: model is nil in summary node")
	}
	emitStage("schedule_refine.summary.generating", "正在生成微调结果总结。")
	updateAllocatedItemsFromEntries(st)
	st.CandidatePlans = hybridEntriesToWeekSchedules(st.HybridEntries)
	reportJSON, _ := json.Marshal(st.HardCheck)
	contractJSON, _ := json.Marshal(st.Contract)
	userPrompt := fmt.Sprintf("用户请求=%s\n契约=%s\n终审报告=%s\n动作日志=%s", strings.TrimSpace(st.UserMessage), string(contractJSON), string(reportJSON), summarizeActionLogs(st.ActionLogs, 24))
	raw, err := callModelText(ctx, chatModel, summaryPrompt, userPrompt, false, 280, 0.35)
	summary := strings.TrimSpace(raw)
	if err == nil {
		emitModelRawDebug(emitStage, "summary", raw)
	}
	if err != nil || summary == "" {
		if FinalHardCheckPassed(st) {
			summary = fmt.Sprintf("微调已完成，共执行 %d 轮动作，方案已通过终审。", st.RoundUsed)
		} else {
			summary = fmt.Sprintf("已完成微调并返回当前最优结果（执行 %d 轮动作）。终审仍有未满足项：%s。", st.RoundUsed, fallbackText(st.HardCheck.IntentReason, "请进一步明确微调目标"))
		}
	}
	summary = alignSummaryWithHardCheck(st, summary)
	st.FinalSummary = summary
	// 1. Completed 只代表“最终终审已通过”，不再把“链路执行完毕”误写成成功；
	// 2. 这样外层持久化与展示层可以准确区分“已通过方案”与“当前最优但未达标方案”；
	// 3. 若只是返回 best-effort 结果，FinalSummary 仍会保留，但 Completed=false。
	st.Completed = FinalHardCheckPassed(st)
	emitStage("schedule_refine.summary.done", "微调总结已生成。")
	return st, nil
}

func evaluateHardChecks(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) HardCheckReport {
	report := HardCheckReport{}
	report.PhysicsIssues = physicsCheck(st.HybridEntries, len(st.AllocatedItems))
	report.PhysicsPassed = len(report.PhysicsIssues) == 0
	// 1. 顺序校验默认开启：即便执行期放开顺序限制，终审也要验证“后端归位”后的顺序正确性。
	// 2. 但 MinContextSwitch 成功后，重排后的顺序本身就是业务目标，不能再拿 origin_rank 反向判错。
	// 3. 当 origin_order_map 为空时同样降级跳过，避免无基线时误报。
	needOrderCheck := len(st.OriginOrderMap) > 0 && !shouldSkipOrderConstraintCheck(st)
	report.OrderIssues = validateRelativeOrder(st.HybridEntries, refineToolPolicy{
		KeepRelativeOrder: needOrderCheck,
		OrderScope:        st.Contract.OrderScope,
		OriginOrderMap:    st.OriginOrderMap,
	})
	report.OrderPassed = len(report.OrderIssues) == 0

	// 1. 优先使用“契约编译后”的确定性终审，执行与终审共用同一份目标约束。
	// 2. 仅当目标约束不可判定时，才回退语义终审兜底。
	if pass, reason, unmet, applied := evaluateObjectiveDeterministic(st); applied {
		pass, reason, unmet = applyCompositeGateToIntentResult(st, pass, reason, unmet)
		report.IntentPassed = pass
		report.IntentReason = strings.TrimSpace(reason)
		report.IntentUnmet = append([]string(nil), unmet...)
		return report
	}

	review, err := runSemanticReview(ctx, chatModel, st, emitStage)
	if err != nil {
		report.IntentPassed = false
		report.IntentReason = fmt.Sprintf("语义校验失败：%v", err)
		report.IntentUnmet = []string{"语义校验阶段异常"}
		return report
	}
	pass, reason, unmet := applyCompositeGateToIntentResult(st, review.Pass, strings.TrimSpace(review.Reason), review.Unmet)
	report.IntentPassed = pass
	report.IntentReason = strings.TrimSpace(reason)
	report.IntentUnmet = append([]string(nil), unmet...)
	return report
}

// evaluateIntentForJudgement 在“最终排序前”计算业务目标是否达成。
//
// 说明：
// 1. 优先走 deterministic objective；
// 2. objective 不可判定时退回语义 review；
// 3. 返回值会在 hard_check 中被锁定，避免后置排序反向干扰业务目标判定。
func evaluateIntentForJudgement(
	ctx context.Context,
	chatModel *ark.ChatModel,
	st *ScheduleRefineState,
	emitStage func(stage, detail string),
) (pass bool, reason string, unmet []string) {
	if pass, reason, unmet, applied := evaluateObjectiveDeterministic(st); applied {
		pass, reason, unmet = applyCompositeGateToIntentResult(st, pass, strings.TrimSpace(reason), unmet)
		return pass, strings.TrimSpace(reason), append([]string(nil), unmet...)
	}
	review, err := runSemanticReview(ctx, chatModel, st, emitStage)
	if err != nil {
		return false, fmt.Sprintf("语义校验失败：%v", err), []string{"语义校验阶段异常"}
	}
	pass, reason, unmet = applyCompositeGateToIntentResult(st, review.Pass, strings.TrimSpace(review.Reason), review.Unmet)
	return pass, strings.TrimSpace(reason), append([]string(nil), unmet...)
}

// compileRefineObjective 把自然语言契约编译为“可执行且可校验”的目标参数。
func compileRefineObjective(st *ScheduleRefineState, slice RefineSlicePlan) RefineObjective {
	obj := RefineObjective{
		Mode:            "none",
		SourceWeeks:     keysOfIntSet(inferSourceWeekSet(slice)),
		TargetWeeks:     keysOfIntSet(inferTargetWeekSet(slice)),
		SourceDays:      uniquePositiveInts(append([]int(nil), slice.SourceDays...)),
		TargetDays:      uniquePositiveInts(append([]int(nil), slice.TargetDays...)),
		ExcludeSections: uniquePositiveInts(append([]int(nil), slice.ExcludeSections...)),
	}
	// 1. 若契约断言显式给出来源/目标周，优先回填到 objective；
	// 2. 避免后续终审只能依赖自然语言猜测。
	for _, assertion := range st.Contract.HardAssertions {
		if assertion.Week > 0 && len(obj.SourceWeeks) == 0 {
			obj.SourceWeeks = []int{assertion.Week}
		}
		if assertion.TargetWeek > 0 && len(obj.TargetWeeks) == 0 {
			obj.TargetWeeks = []int{assertion.TargetWeek}
		}
	}

	if len(obj.SourceWeeks) == 0 && len(slice.WeekFilter) == 1 && slice.WeekFilter[0] > 0 {
		obj.SourceWeeks = []int{slice.WeekFilter[0]}
	}
	if len(obj.TargetWeeks) == 0 && len(slice.WeekFilter) == 1 && (len(obj.SourceDays) > 0 || len(obj.TargetDays) > 0) {
		obj.TargetWeeks = []int{slice.WeekFilter[0]}
	}

	// 来源范围为空时无法构造目标，交给语义终审兜底。
	if len(obj.SourceWeeks) == 0 && len(obj.SourceDays) == 0 {
		obj.Reason = "来源范围为空，未启用确定性目标。"
		return obj
	}
	// 目标范围为空时同样不启用确定性目标。
	if len(obj.TargetWeeks) == 0 && len(obj.TargetDays) == 0 {
		obj.Reason = "目标范围为空，未启用确定性目标。"
		return obj
	}

	sourceTaskIDs := collectSourceTaskIDsForObjective(st.InitialHybridEntries, obj, slice.WeekFilter)
	obj.BaselineSourceTaskCount = len(sourceTaskIDs)

	halfIntent := hasHalfTransferIntent(st)
	if halfIntent && len(obj.SourceWeeks) > 0 && len(obj.TargetWeeks) > 0 && !isSameWeeks(obj.SourceWeeks, obj.TargetWeeks) {
		obj.Mode = "move_ratio"
		obj.RequiredMoveMin = obj.BaselineSourceTaskCount / 2
		obj.RequiredMoveMax = (obj.BaselineSourceTaskCount + 1) / 2
		obj.Reason = "检测到“半数迁移”意图，按比例目标执行与终审。"
		return obj
	}

	obj.Mode = "move_all"
	obj.RequiredMoveMin = obj.BaselineSourceTaskCount
	obj.RequiredMoveMax = obj.BaselineSourceTaskCount
	obj.Reason = "默认按来源范围任务全部进入目标范围执行与终审。"
	return obj
}

// evaluateObjectiveDeterministic 基于编译后的目标做确定性终审。
func evaluateObjectiveDeterministic(st *ScheduleRefineState) (pass bool, reason string, unmet []string, applied bool) {
	if st == nil {
		return false, "", nil, false
	}
	obj := st.Objective
	if strings.TrimSpace(obj.Mode) == "" || strings.TrimSpace(obj.Mode) == "none" {
		return false, "", nil, false
	}

	sourceTaskIDs := collectSourceTaskIDsForObjective(st.InitialHybridEntries, obj, st.SlicePlan.WeekFilter)
	if len(sourceTaskIDs) == 0 {
		return true, "确定性校验通过：来源范围无可调任务。", nil, true
	}

	byTaskID := buildMovableTaskIndex(st.HybridEntries)
	movedCount := 0
	violations := make([]string, 0, 8)
	for _, taskID := range sourceTaskIDs {
		entries := byTaskID[taskID]
		if len(entries) == 0 {
			violations = append(violations, fmt.Sprintf("任务id=%d 未在结果中找到可移动条目", taskID))
			continue
		}
		if len(entries) > 1 {
			violations = append(violations, fmt.Sprintf("任务id=%d 命中 %d 条可移动条目，状态不唯一", taskID, len(entries)))
			continue
		}
		entry := entries[0]
		moved, why := isTaskMovedIntoObjectiveTarget(entry, obj)
		if moved {
			movedCount++
			continue
		}
		if obj.Mode == "move_all" {
			violations = append(violations, fmt.Sprintf("任务id=%d 未满足目标范围：%s", taskID, why))
			continue
		}
		// 比例模式下，允许部分任务不迁移；但若任务落在来源/目标之外，视为异常。
		if !isTaskInObjectiveSource(entry, obj) {
			violations = append(violations, fmt.Sprintf("任务id=%d 既不在来源也不在目标范围（W%dD%d）", taskID, entry.Week, entry.DayOfWeek))
		}
	}

	if movedCount < obj.RequiredMoveMin || movedCount > obj.RequiredMoveMax {
		violations = append(violations, fmt.Sprintf("迁移数量未达标：要求在[%d,%d]，实际=%d", obj.RequiredMoveMin, obj.RequiredMoveMax, movedCount))
	}

	if len(violations) == 0 {
		return true, fmt.Sprintf("确定性校验通过：迁移数量达标（%d/%d）。", movedCount, len(sourceTaskIDs)), nil, true
	}
	return false, fmt.Sprintf("确定性校验未通过：仍有 %d 项约束未满足。", len(violations)), violations, true
}

func collectSourceTaskIDsForObjective(entries []model.HybridScheduleEntry, obj RefineObjective, fallbackWeekFilter []int) []int {
	if len(entries) == 0 {
		return nil
	}
	sourceWeekSet := intSliceToWeekSet(obj.SourceWeeks)
	sourceDaySet := intSliceToDaySet(obj.SourceDays)
	fallbackWeekSet := intSliceToWeekSet(fallbackWeekFilter)

	seen := make(map[int]struct{}, len(entries))
	ids := make([]int, 0, len(entries))
	for _, entry := range entries {
		if !isMovableSuggestedTask(entry) {
			continue
		}
		if len(sourceWeekSet) > 0 {
			if _, ok := sourceWeekSet[entry.Week]; !ok {
				continue
			}
		} else if len(fallbackWeekSet) > 0 {
			if _, ok := fallbackWeekSet[entry.Week]; !ok {
				continue
			}
		}
		if len(sourceDaySet) > 0 {
			if _, ok := sourceDaySet[entry.DayOfWeek]; !ok {
				continue
			}
		}
		if _, exists := seen[entry.TaskItemID]; exists {
			continue
		}
		seen[entry.TaskItemID] = struct{}{}
		ids = append(ids, entry.TaskItemID)
	}
	sort.Ints(ids)
	return ids
}

func buildMovableTaskIndex(entries []model.HybridScheduleEntry) map[int][]model.HybridScheduleEntry {
	index := make(map[int][]model.HybridScheduleEntry, len(entries))
	for _, entry := range entries {
		if !isMovableSuggestedTask(entry) {
			continue
		}
		index[entry.TaskItemID] = append(index[entry.TaskItemID], entry)
	}
	return index
}

func hasHalfTransferIntent(st *ScheduleRefineState) bool {
	if st == nil {
		return false
	}
	if hasHalfTransferAssertion(st.Contract.HardAssertions) {
		return true
	}
	joined := strings.ToLower(strings.Join(append([]string{st.UserMessage, st.Contract.Intent}, st.Contract.HardRequirements...), " "))
	for _, key := range []string{"一半", "半数", "对半", "50%"} {
		if strings.Contains(joined, key) {
			return true
		}
	}
	return false
}

func hasHalfTransferAssertion(assertions []RefineAssertion) bool {
	for _, item := range assertions {
		metric := strings.ToLower(strings.TrimSpace(item.Metric))
		if metric == "" {
			continue
		}
		switch metric {
		case "source_move_ratio_percent", "move_ratio_percent", "half_transfer_ratio":
			switch strings.TrimSpace(item.Operator) {
			case "==", ">=", "<=", "between":
				if item.Value == 50 || item.Min == 50 || item.Max == 50 {
					return true
				}
			}
		case "source_remaining_count":
			// 1. 该断言常用于“迁走一半后来源剩余=一半”。
			// 2. 具体阈值是否满足由 objective + deterministic 校验统一判定。
			return true
		}
	}
	return false
}

func normalizeHardAssertions(raw []hardAssertionOutput) []RefineAssertion {
	if len(raw) == 0 {
		return nil
	}
	out := make([]RefineAssertion, 0, len(raw))
	for _, item := range raw {
		metric := strings.TrimSpace(item.Metric)
		if metric == "" {
			continue
		}
		operator := strings.TrimSpace(item.Operator)
		if operator == "" {
			operator = "=="
		}
		assertion := RefineAssertion{
			Metric:     metric,
			Operator:   operator,
			Value:      item.Value,
			Min:        item.Min,
			Max:        item.Max,
			Week:       item.Week,
			TargetWeek: item.TargetWeek,
		}
		out = append(out, assertion)
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

func inferHardAssertionsFromRequest(message string, requirements []string) []RefineAssertion {
	joined := strings.TrimSpace(message + " " + strings.Join(requirements, " "))
	if joined == "" {
		return nil
	}
	weeks := extractWeekFilters(joined)
	if !containsAny(strings.ToLower(joined), []string{"一半", "半数", "对半", "50%"}) {
		return nil
	}
	// 1. 兜底断言：要求来源任务迁移比例为 50%。
	// 2. week/target_week 使用文本中前两个周次，便于后续 objective 编译。
	assertion := RefineAssertion{
		Metric:   "source_move_ratio_percent",
		Operator: "==",
		Value:    50,
	}
	if len(weeks) > 0 {
		assertion.Week = weeks[0]
	}
	if len(weeks) > 1 {
		assertion.TargetWeek = weeks[1]
	}
	return []RefineAssertion{assertion}
}

func isTaskMovedIntoObjectiveTarget(entry model.HybridScheduleEntry, obj RefineObjective) (bool, string) {
	targetWeekSet := intSliceToWeekSet(obj.TargetWeeks)
	targetDaySet := intSliceToDaySet(obj.TargetDays)
	excludedSections := intSliceToSectionSet(obj.ExcludeSections)
	if len(targetWeekSet) > 0 {
		if _, ok := targetWeekSet[entry.Week]; !ok {
			return false, fmt.Sprintf("week=%d 不在目标周", entry.Week)
		}
	}
	if len(targetDaySet) > 0 {
		if _, ok := targetDaySet[entry.DayOfWeek]; !ok {
			return false, fmt.Sprintf("day_of_week=%d 不在目标日", entry.DayOfWeek)
		}
	}
	if len(excludedSections) > 0 && intersectsExcludedSections(entry.SectionFrom, entry.SectionTo, excludedSections) {
		return false, fmt.Sprintf("section=%d-%d 命中排除节次", entry.SectionFrom, entry.SectionTo)
	}
	return true, ""
}

func isTaskInObjectiveSource(entry model.HybridScheduleEntry, obj RefineObjective) bool {
	sourceWeekSet := intSliceToWeekSet(obj.SourceWeeks)
	sourceDaySet := intSliceToDaySet(obj.SourceDays)
	if len(sourceWeekSet) > 0 {
		if _, ok := sourceWeekSet[entry.Week]; !ok {
			return false
		}
	}
	if len(sourceDaySet) > 0 {
		if _, ok := sourceDaySet[entry.DayOfWeek]; !ok {
			return false
		}
	}
	return true
}

func isSameWeeks(left []int, right []int) bool {
	if len(left) == 0 || len(right) == 0 {
		return false
	}
	lset := intSliceToWeekSet(left)
	rset := intSliceToWeekSet(right)
	if len(lset) != len(rset) {
		return false
	}
	for w := range lset {
		if _, ok := rset[w]; !ok {
			return false
		}
	}
	return true
}

// normalizeMovableTaskOrderByOrigin 在“坑位不变”的前提下，按 origin_rank 归位任务顺序。
//
// 步骤化说明：
// 1. 先提取所有可移动任务的当前坑位（week/day/section）；
// 2. 再按任务跨度分组，避免把 2 节任务塞进 3 节坑位；
// 3. 每个跨度组内按坑位时间升序与 origin_rank 升序做一一映射；
// 4. 最终只改“任务身份落到哪个坑位”，不改坑位分布本身。
func normalizeMovableTaskOrderByOrigin(st *ScheduleRefineState) bool {
	if st == nil || len(st.HybridEntries) <= 1 || len(st.OriginOrderMap) == 0 {
		return false
	}
	entries := cloneHybridEntries(st.HybridEntries)
	indices := make([]int, 0, len(entries))
	for idx, entry := range entries {
		if isMovableSuggestedTask(entry) {
			indices = append(indices, idx)
		}
	}
	if len(indices) <= 1 {
		return false
	}

	type slot struct {
		Week        int
		DayOfWeek   int
		SectionFrom int
		SectionTo   int
	}
	groupSlots := make(map[int][]slot)                      // key=span
	groupTasks := make(map[int][]model.HybridScheduleEntry) // key=span
	for _, idx := range indices {
		entry := entries[idx]
		span := entry.SectionTo - entry.SectionFrom + 1
		groupSlots[span] = append(groupSlots[span], slot{
			Week:        entry.Week,
			DayOfWeek:   entry.DayOfWeek,
			SectionFrom: entry.SectionFrom,
			SectionTo:   entry.SectionTo,
		})
		groupTasks[span] = append(groupTasks[span], entry)
	}

	changed := false
	spanKeys := make([]int, 0, len(groupSlots))
	for span := range groupSlots {
		spanKeys = append(spanKeys, span)
	}
	sort.Ints(spanKeys)

	groupCursor := make(map[int]int, len(groupSlots))
	for _, span := range spanKeys {
		slots := groupSlots[span]
		tasks := groupTasks[span]
		if len(slots) != len(tasks) || len(slots) == 0 {
			continue
		}
		sort.SliceStable(slots, func(i, j int) bool {
			if slots[i].Week != slots[j].Week {
				return slots[i].Week < slots[j].Week
			}
			if slots[i].DayOfWeek != slots[j].DayOfWeek {
				return slots[i].DayOfWeek < slots[j].DayOfWeek
			}
			if slots[i].SectionFrom != slots[j].SectionFrom {
				return slots[i].SectionFrom < slots[j].SectionFrom
			}
			return slots[i].SectionTo < slots[j].SectionTo
		})
		sort.SliceStable(tasks, func(i, j int) bool {
			ri := st.OriginOrderMap[tasks[i].TaskItemID]
			rj := st.OriginOrderMap[tasks[j].TaskItemID]
			if ri <= 0 {
				ri = 1 << 30
			}
			if rj <= 0 {
				rj = 1 << 30
			}
			if ri != rj {
				return ri < rj
			}
			if tasks[i].Week != tasks[j].Week {
				return tasks[i].Week < tasks[j].Week
			}
			if tasks[i].DayOfWeek != tasks[j].DayOfWeek {
				return tasks[i].DayOfWeek < tasks[j].DayOfWeek
			}
			if tasks[i].SectionFrom != tasks[j].SectionFrom {
				return tasks[i].SectionFrom < tasks[j].SectionFrom
			}
			return tasks[i].TaskItemID < tasks[j].TaskItemID
		})
		for i := range tasks {
			tasks[i].Week = slots[i].Week
			tasks[i].DayOfWeek = slots[i].DayOfWeek
			tasks[i].SectionFrom = slots[i].SectionFrom
			tasks[i].SectionTo = slots[i].SectionTo
		}
		groupTasks[span] = tasks
	}

	for _, idx := range indices {
		entry := entries[idx]
		span := entry.SectionTo - entry.SectionFrom + 1
		cursor := groupCursor[span]
		if cursor >= len(groupTasks[span]) {
			continue
		}
		nextEntry := groupTasks[span][cursor]
		groupCursor[span] = cursor + 1
		if entry.TaskItemID != nextEntry.TaskItemID ||
			entry.Week != nextEntry.Week ||
			entry.DayOfWeek != nextEntry.DayOfWeek ||
			entry.SectionFrom != nextEntry.SectionFrom ||
			entry.SectionTo != nextEntry.SectionTo {
			changed = true
		}
		entries[idx] = nextEntry
	}
	if !changed {
		return false
	}
	sortHybridEntries(entries)
	st.HybridEntries = entries
	return true
}

// tryNormalizeMovableTaskOrderByOrigin 决定是否执行“按 origin_rank 顺序归位”。
//
// 步骤化说明：
// 1. 默认仍保持旧行为，继续在终审前做展示侧顺序归位；
// 2. 但当 MinContextSwitch 已成功执行时，重排后的顺序本身就是业务目标的一部分；
// 3. 此时若再按 origin_rank 归位，会把复合工具效果直接抹掉，因此必须跳过。
func tryNormalizeMovableTaskOrderByOrigin(st *ScheduleRefineState) (changed bool, skipped bool) {
	if shouldSkipOriginOrderNormalization(st) {
		return false, true
	}
	return normalizeMovableTaskOrderByOrigin(st), false
}

func shouldSkipOriginOrderNormalization(st *ScheduleRefineState) bool {
	if st == nil {
		return false
	}
	ensureCompositeStateMaps(st)
	if st.CompositeToolSuccess["MinContextSwitch"] {
		return true
	}
	return false
}

func shouldSkipOrderConstraintCheck(st *ScheduleRefineState) bool {
	return shouldSkipOriginOrderNormalization(st)
}

func runSingleRepairAction(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) error {
	if st == nil {
		return fmt.Errorf("nil state")
	}
	if chatModel == nil {
		return fmt.Errorf("nil model")
	}
	if st.RoundUsed >= st.MaxRounds {
		return fmt.Errorf("动作预算已耗尽")
	}
	entriesJSON, _ := json.Marshal(st.HybridEntries)
	contractJSON, _ := json.Marshal(st.Contract)
	userPrompt := withNearestJSONContract(
		fmt.Sprintf(
			"用户请求=%s\n契约=%s\n未满足点=%s\n当前混合日程JSON=%s\nMove标准Schema={task_item_id,to_week,to_day,to_section_from,to_section_to}\nSwap标准Schema={task_a,task_b}",
			strings.TrimSpace(st.UserMessage),
			string(contractJSON),
			strings.Join(st.HardCheck.IntentUnmet, "；"),
			string(entriesJSON),
		),
		jsonContractForReact,
	)
	raw, err := callModelText(ctx, chatModel, repairPrompt, userPrompt, false, 240, 0.15)
	if err != nil {
		return err
	}
	emitModelRawDebug(emitStage, "repair", raw)
	parsed, parseErr := parseReactLLMOutput(raw)
	if parseErr != nil {
		return parseErr
	}
	call, warn := pickSingleToolCall(parsed.ToolCalls)
	if warn != "" {
		st.ActionLogs = append(st.ActionLogs, "修复阶段告警："+warn)
	}
	if call == nil {
		return fmt.Errorf("修复阶段未给出可执行动作")
	}
	normalizedCall := canonicalizeToolCall(*call)
	call = &normalizedCall
	if !isMutatingToolName(strings.TrimSpace(call.Tool)) {
		return fmt.Errorf("修复阶段工具不允许：%s（仅允许 Move/Swap/BatchMove）", strings.TrimSpace(call.Tool))
	}
	emitStage("schedule_refine.hard_check.repair_call", formatToolCallStageDetail(st.RoundUsed+1, *call, st.MaxRounds-st.RoundUsed))
	nextEntries, result := dispatchRefineTool(cloneHybridEntries(st.HybridEntries), *call, buildPlanningWindowFromEntries(st.HybridEntries), refineToolPolicy{
		KeepRelativeOrder: false,
		OrderScope:        st.Contract.OrderScope,
		OriginOrderMap:    st.OriginOrderMap,
	})
	result = normalizeToolResult(result)
	st.RoundUsed++
	emitStage("schedule_refine.hard_check.repair_result", formatToolResultStageDetail(st.RoundUsed, result, st.RoundUsed, st.MaxRounds))
	if !result.Success {
		st.LastFailedCallSignature = buildToolCallSignature(*call)
		return fmt.Errorf("修复动作执行失败：%s", result.Result)
	}
	st.LastFailedCallSignature = ""
	st.HybridEntries = nextEntries
	if isMutatingToolName(result.Tool) {
		st.EntriesVersion++
	}
	return nil
}

func runSemanticReview(ctx context.Context, chatModel *ark.ChatModel, st *ScheduleRefineState, emitStage func(stage, detail string)) (*reviewOutput, error) {
	entriesJSON, _ := json.Marshal(st.HybridEntries)
	contractJSON, _ := json.Marshal(st.Contract)
	userPrompt := withNearestJSONContract(
		fmt.Sprintf(
			"用户请求=%s\n契约=%s\nday_of_week映射=1周一,2周二,3周三,4周四,5周五,6周六,7周日\nsuggested简表=%s\n动作日志=%s\n当前混合日程JSON=%s",
			strings.TrimSpace(st.UserMessage),
			string(contractJSON),
			buildSuggestedDigest(st.HybridEntries, 80),
			summarizeActionLogs(st.ActionLogs, 12),
			string(entriesJSON),
		),
		jsonContractForReview,
	)
	raw, err := callModelText(ctx, chatModel, reviewPrompt, userPrompt, false, 240, 0)
	if err != nil {
		return nil, err
	}
	emitModelRawDebug(emitStage, "review", raw)
	return parseReviewOutput(raw)
}

func runPostReflectAfterTool(
	ctx context.Context,
	chatModel *ark.ChatModel,
	st *ScheduleRefineState,
	round int,
	plan *reactLLMOutput,
	call *reactToolCall,
	result reactToolResult,
	emitStage func(stage, detail string),
) (string, string, bool) {
	if st == nil || chatModel == nil || call == nil {
		return buildPostReflectFallback(plan, result), "", false
	}
	emitStage("schedule_refine.react.post_reflect.start", fmt.Sprintf("第 %d 轮|正在基于工具真实结果进行反思。", round))
	contractJSON, _ := json.Marshal(st.Contract)
	callJSON, _ := json.Marshal(call)
	resultJSON, _ := json.Marshal(result)
	planDecision := ""
	if plan != nil {
		planDecision = strings.TrimSpace(plan.Decision)
	}
	userPrompt := withNearestJSONContract(
		fmt.Sprintf(
			"用户请求=%s\n契约=%s\n本轮计划.decision=%s\n本轮工具调用=%s\n本轮工具结果=%s\n最近观察=%s",
			strings.TrimSpace(st.UserMessage),
			string(contractJSON),
			planDecision,
			string(callJSON),
			string(resultJSON),
			buildObservationPrompt(st.ObservationHistory, 2),
		),
		jsonContractForPostReflect,
	)
	raw, err := callModelText(ctx, chatModel, postReflectPrompt, userPrompt, false, 220, 0)
	if err != nil {
		fallback := buildPostReflectFallback(plan, result)
		emitStage("schedule_refine.react.post_reflect.fallback", fmt.Sprintf("第 %d 轮|模型反思失败，改用后端兜底复盘：%s", round, truncate(err.Error(), 160)))
		return fallback, "", false
	}
	emitModelRawDebug(emitStage, fmt.Sprintf("post_reflect.round.%d", round), raw)
	parsed, parseErr := parseJSON[postReflectOutput](raw)
	if parseErr != nil {
		fallback := buildPostReflectFallback(plan, result)
		emitStage("schedule_refine.react.post_reflect.fallback", fmt.Sprintf("第 %d 轮|模型反思解析失败，改用后端兜底复盘：%s", round, truncate(parseErr.Error(), 160)))
		return fallback, "", false
	}
	reflection := strings.TrimSpace(parsed.Reflection)
	if reflection == "" {
		reflection = buildPostReflectFallback(plan, result)
	}
	nextStrategy := strings.TrimSpace(parsed.NextStrategy)
	if nextStrategy != "" {
		reflection = fmt.Sprintf("%s；下一步建议：%s", reflection, nextStrategy)
	}
	shouldStop := parsed.ShouldStop
	emitStage("schedule_refine.react.post_reflect.done", fmt.Sprintf("第 %d 轮|模型反思=%s|下一步=%s|should_stop=%t", round, truncate(strings.TrimSpace(parsed.Reflection), 120), truncate(nextStrategy, 120), shouldStop))
	return reflection, nextStrategy, shouldStop
}

func buildPostReflectFallback(plan *reactLLMOutput, result reactToolResult) string {
	modelReflect := ""
	if plan != nil {
		modelReflect = strings.TrimSpace(plan.Decision)
	}
	return buildRuntimeReflect(modelReflect, result)
}

func runPlannerNode(
	ctx context.Context,
	chatModel *ark.ChatModel,
	st *ScheduleRefineState,
	emitStage func(stage, detail string),
	mode string,
) error {
	if st == nil || chatModel == nil {
		return fmt.Errorf("planner: invalid input")
	}
	ensureCompositeStateMaps(st)
	// 1. 正常模式下由后端判定“本轮必用复合工具”。
	// 2. 若已进入禁复合兜底模式，必须清空该标记，避免规划阶段再次把复合门禁写回去。
	if st.DisableCompositeTools {
		st.RequiredCompositeTool = ""
	} else {
		st.RequiredCompositeTool = detectRequiredCompositeTool(st)
	}
	if st.PlanUsed >= st.PlanMax {
		return nil
	}
	stage := "schedule_refine.plan.generating"
	if strings.TrimSpace(mode) == "replan" {
		stage = "schedule_refine.plan.regenerating"
	}
	emitStage(stage, fmt.Sprintf("正在生成执行计划（mode=%s，已用%d/%d）。", mode, st.PlanUsed, st.PlanMax))
	contractJSON, _ := json.Marshal(st.Contract)
	userPrompt := withNearestJSONContract(
		fmt.Sprintf(
			"mode=%s\n用户请求=%s\n契约=%s\n上一轮工具观察=%s\n最近观察=%s\nsuggested简表=%s",
			mode,
			strings.TrimSpace(st.UserMessage),
			string(contractJSON),
			buildLastToolObservationPrompt(st.ObservationHistory),
			buildObservationPrompt(st.ObservationHistory, 2),
			buildSuggestedDigest(st.HybridEntries, 40),
		),
		jsonContractForPlanner,
	)
	raw, err := callModelText(ctx, chatModel, plannerPrompt, userPrompt, false, plannerMaxTokens, 0)
	if err != nil {
		st.CurrentPlan = applyCompositeHardConditionToPlan(st, buildFallbackPlan(st))
		st.BatchMoveAllowed = shouldAllowBatchMove(st.CurrentPlan)
		st.PlanUsed++
		emitStage("schedule_refine.plan.fallback", "Planner 调用失败，已切换后端兜底计划。")
		return nil
	}
	emitModelRawDebug(emitStage, fmt.Sprintf("planner.%s", mode), raw)
	parsed, parseErr := parsePlannerOutputWithRetryOnce(ctx, chatModel, userPrompt, raw, mode, emitStage)
	if parseErr != nil {
		st.CurrentPlan = applyCompositeHardConditionToPlan(st, buildFallbackPlan(st))
		st.BatchMoveAllowed = shouldAllowBatchMove(st.CurrentPlan)
		st.PlanUsed++
		emitStage("schedule_refine.plan.fallback", fmt.Sprintf("Planner 输出解析失败，已切换后端兜底计划：%s", truncate(parseErr.Error(), 180)))
		return nil
	}
	st.CurrentPlan = PlannerPlan{
		Summary: fallbackText(strings.TrimSpace(parsed.Summary), "已生成可执行计划。"),
		Steps:   uniqueNonEmpty(parsed.Steps),
	}
	st.CurrentPlan = applyCompositeHardConditionToPlan(st, st.CurrentPlan)
	st.BatchMoveAllowed = shouldAllowBatchMove(st.CurrentPlan)
	if st.DisableCompositeTools {
		st.BatchMoveAllowed = false
	}
	st.PlanUsed++
	emitStage("schedule_refine.plan.done", fmt.Sprintf("规划完成：%s", truncate(st.CurrentPlan.Summary, 180)))
	return nil
}

func handleBlockedToolResult(
	ctx context.Context,
	chatModel *ark.ChatModel,
	st *ScheduleRefineState,
	emitStage func(stage, detail string),
	round int,
	parsed *reactLLMOutput,
	call *reactToolCall,
	callSignature string,
	blockedResult reactToolResult,
	observation *ReactRoundObservation,
) (bool, error) {
	result := normalizeToolResult(blockedResult)
	st.RoundUsed++
	st.LastFailedCallSignature = callSignature
	st.ConsecutiveFailures++
	observation.ToolName = strings.TrimSpace(result.Tool)
	observation.ToolParams = cloneToolParams(call.Params)
	observation.ToolSuccess = result.Success
	observation.ToolErrorCode = strings.TrimSpace(result.ErrorCode)
	observation.ToolResult = strings.TrimSpace(result.Result)
	postReflectText, _, shouldStop := runPostReflectAfterTool(ctx, chatModel, st, round, parsed, call, result, emitStage)
	observation.Reflect = postReflectText
	st.ObservationHistory = append(st.ObservationHistory, *observation)
	emitStage("schedule_refine.react.tool_blocked", fmt.Sprintf("第 %d 轮|动作被后端策略拦截：%s", round, truncate(result.Result, 120)))
	emitStage("schedule_refine.react.tool_result", formatToolResultStageDetail(round, result, st.RoundUsed, st.MaxRounds))
	emitStage("schedule_refine.react.reflect", formatReactReflectStageDetail(round, observation.Reflect))
	if shouldTriggerReplan(st, result) {
		if replanned, err := tryReplan(ctx, chatModel, st, emitStage); err != nil {
			return false, err
		} else if replanned {
			return false, nil
		}
	}
	return shouldStop, nil
}

func buildFallbackPlan(st *ScheduleRefineState) PlannerPlan {
	summary := "兜底计划：先取证再动作，优先复合工具，其次 Move，冲突时尝试 Swap。"
	if st != nil && st.Contract.KeepRelativeOrder {
		summary = "兜底计划：先取证再动作，严格保持相对顺序，优先复合工具，其次 Move，冲突时尝试 Swap。"
	}
	return PlannerPlan{
		Summary: summary,
		Steps: []string{
			"1) QueryTargetTasks 定位目标任务",
			"2) QueryAvailableSlots 获取可用空位",
			"3) 优先 SpreadEven/MinContextSwitch，其次 Move/Swap 执行动作并复盘",
			"4) 收尾前执行 Verify 自检",
		},
	}
}

// ensureCompositeStateMaps 确保复合工具状态容器已初始化。
func ensureCompositeStateMaps(st *ScheduleRefineState) {
	if st == nil {
		return
	}
	if st.CompositeToolCalled == nil {
		st.CompositeToolCalled = map[string]bool{
			"SpreadEven":       false,
			"MinContextSwitch": false,
		}
	}
	if st.CompositeToolSuccess == nil {
		st.CompositeToolSuccess = map[string]bool{
			"SpreadEven":       false,
			"MinContextSwitch": false,
		}
	}
}

// detectRequiredCompositeTool 根据请求语义识别本轮必用复合工具。
//
// 规则：
// 1. “上下文切换最少/同科目连续”优先映射 MinContextSwitch；
// 2. “均匀分散/铺开”映射 SpreadEven；
// 3. 未命中时返回空串，不强制复合工具。
func detectRequiredCompositeTool(st *ScheduleRefineState) string {
	if st == nil {
		return ""
	}
	joined := strings.TrimSpace(strings.Join([]string{
		st.UserMessage,
		st.Contract.Intent,
		strings.Join(st.Contract.HardRequirements, " "),
	}, " "))
	if joined == "" {
		return ""
	}
	contextKeys := []string{"上下文切换", "切换最少", "同个科目", "同科目", "连续处理", "连续学习", "min context", "context switch"}
	if containsAny(strings.ToLower(joined), contextKeys) || containsAny(joined, contextKeys) {
		return "MinContextSwitch"
	}
	evenKeys := []string{"均匀", "分散", "铺开", "平摊", "均摊", "spread even", "even spread"}
	if containsAny(strings.ToLower(joined), evenKeys) || containsAny(joined, evenKeys) {
		return "SpreadEven"
	}
	return ""
}

// applyCompositeHardConditionToPlan 把“必用复合工具”硬条件注入计划文本。
func applyCompositeHardConditionToPlan(st *ScheduleRefineState, plan PlannerPlan) PlannerPlan {
	required := ""
	if st != nil {
		required = normalizeCompositeToolName(st.RequiredCompositeTool)
	}
	if required == "" {
		return plan
	}

	hardStep := fmt.Sprintf("硬条件：必须成功调用 %s（COMPOSITE_SUCCESS[%s]=true）后才允许整体收口", required, required)
	hasHardStep := false
	for _, step := range plan.Steps {
		if strings.Contains(step, required) && strings.Contains(step, "COMPOSITE_SUCCESS") {
			hasHardStep = true
			break
		}
	}
	if !hasHardStep {
		plan.Steps = append([]string{hardStep}, plan.Steps...)
	}
	if !strings.Contains(plan.Summary, required) {
		plan.Summary = strings.TrimSpace(plan.Summary + "；硬条件：" + required + " 成功==true")
	}
	return plan
}

func normalizeCompositeToolName(name string) string {
	switch strings.TrimSpace(name) {
	case "SpreadEven":
		return "SpreadEven"
	case "MinContextSwitch":
		return "MinContextSwitch"
	default:
		return ""
	}
}

func isCompositeToolName(toolName string) bool {
	switch normalizeCompositeToolName(toolName) {
	case "SpreadEven", "MinContextSwitch":
		return true
	default:
		return false
	}
}

func isBaseMutatingToolName(toolName string) bool {
	switch strings.TrimSpace(toolName) {
	case "Move", "Swap", "BatchMove":
		return true
	default:
		return false
	}
}

func isRequiredCompositeSatisfied(st *ScheduleRefineState) bool {
	if st == nil {
		return true
	}
	required := normalizeCompositeToolName(st.RequiredCompositeTool)
	if required == "" {
		return true
	}
	ensureCompositeStateMaps(st)
	return st.CompositeToolSuccess[required]
}

// applyCompositeGateToIntentResult 把“必用复合工具成功”并入业务目标判定。
//
// 步骤化说明：
// 1. 先判断原始业务判定是否通过；未通过则原样返回；
// 2. 再判断是否配置了必用复合工具；未配置则原样返回；
// 3. 若配置但未成功，强制改判为失败并补充 unmet 原因。
func applyCompositeGateToIntentResult(st *ScheduleRefineState, pass bool, reason string, unmet []string) (bool, string, []string) {
	if !pass {
		return pass, reason, append([]string(nil), unmet...)
	}
	required := normalizeCompositeToolName("")
	if st != nil {
		required = normalizeCompositeToolName(st.RequiredCompositeTool)
	}
	if required == "" {
		return pass, reason, append([]string(nil), unmet...)
	}
	if isRequiredCompositeSatisfied(st) {
		return pass, reason, append([]string(nil), unmet...)
	}
	newUnmet := append([]string(nil), unmet...)
	newUnmet = append(newUnmet, fmt.Sprintf("复合工具门禁未通过：%s 尚未成功调用", required))
	return false, fmt.Sprintf("复合工具门禁未通过：要求 %s 成功==true。", required), newUnmet
}

func markCompositeToolOutcome(st *ScheduleRefineState, toolName string, success bool) {
	if st == nil {
		return
	}
	tool := normalizeCompositeToolName(toolName)
	if tool == "" {
		return
	}
	ensureCompositeStateMaps(st)
	st.CompositeToolCalled[tool] = true
	if success {
		st.CompositeToolSuccess[tool] = true
	}
}

func shouldAllowBatchMove(plan PlannerPlan) bool {
	text := strings.ToLower(strings.TrimSpace(plan.Summary))
	if strings.Contains(text, "batchmove") || strings.Contains(text, "batch move") {
		return true
	}
	for _, step := range plan.Steps {
		s := strings.ToLower(strings.TrimSpace(step))
		if strings.Contains(s, "batchmove") || strings.Contains(s, "batch move") {
			return true
		}
	}
	return false
}

func shouldEnableRecoveryThinking(st *ScheduleRefineState) (bool, string) {
	if st == nil {
		return false, ""
	}
	if st.ConsecutiveFailures < 2 || st.ThinkingBoostArmed {
		return false, ""
	}
	st.ThinkingBoostArmed = true
	return true, fmt.Sprintf("连续失败=%d，触发 1 轮恢复态 thinking", st.ConsecutiveFailures)
}

func shouldTriggerReplan(st *ScheduleRefineState, result reactToolResult) bool {
	if st == nil {
		return false
	}
	if st.ConsecutiveFailures < 3 {
		return false
	}
	switch strings.TrimSpace(result.ErrorCode) {
	case "SLOT_CONFLICT", "ORDER_VIOLATION", "REPEAT_FAILED_ACTION", "PARAM_MISSING", "BATCH_MOVE_FAILED", "VERIFY_FAILED", "TASK_BUDGET_EXCEEDED", "BATCH_MOVE_DISABLED", "CURRENT_TASK_MISMATCH", "QUERY_REDUNDANT", "SLOT_QUERY_FAILED", "PLAN_FAILED", "PLAN_EMPTY", "COMPOSITE_REQUIRED", "COMPOSITE_DISABLED":
		return true
	default:
		return false
	}
}

func tryReplan(
	ctx context.Context,
	chatModel *ark.ChatModel,
	st *ScheduleRefineState,
	emitStage func(stage, detail string),
) (bool, error) {
	if st == nil {
		return false, nil
	}
	if st.ReplanUsed >= st.ReplanMax || st.PlanUsed >= st.PlanMax {
		return false, nil
	}
	st.ReplanUsed++
	emitStage("schedule_refine.plan.replan_trigger", fmt.Sprintf("连续失败=%d，触发重规划（%d/%d）。", st.ConsecutiveFailures, st.ReplanUsed, st.ReplanMax))
	if err := runPlannerNode(ctx, chatModel, st, emitStage, "replan"); err != nil {
		return true, err
	}
	st.ConsecutiveFailures = 0
	st.ThinkingBoostArmed = false
	return true, nil
}

func callModelText(
	ctx context.Context,
	chatModel *ark.ChatModel,
	systemPrompt string,
	userPrompt string,
	useThinking bool,
	maxTokens int,
	temperature float32,
) (string, error) {
	if chatModel == nil {
		return "", fmt.Errorf("model is nil")
	}
	nodeCtx, cancel := context.WithTimeout(ctx, nodeTimeout)
	defer cancel()
	thinkingType := arkModel.ThinkingTypeDisabled
	if useThinking {
		thinkingType = arkModel.ThinkingTypeEnabled
	}
	opts := []einoModel.Option{
		ark.WithThinking(&arkModel.Thinking{Type: thinkingType}),
		einoModel.WithTemperature(temperature),
	}
	if maxTokens > 0 {
		opts = append(opts, einoModel.WithMaxTokens(maxTokens))
	}
	resp, err := chatModel.Generate(nodeCtx, []*schema.Message{
		schema.SystemMessage(systemPrompt),
		schema.UserMessage(userPrompt),
	}, opts...)
	if err != nil {
		if errors.Is(nodeCtx.Err(), context.DeadlineExceeded) {
			return "", fmt.Errorf("model call node timeout(%dms): %w", nodeTimeout.Milliseconds(), err)
		}
		if nodeCtx.Err() != nil {
			return "", fmt.Errorf("model call node canceled(%v): %w", nodeCtx.Err(), err)
		}
		if ctx.Err() != nil {
			return "", fmt.Errorf("model call parent canceled(%v): %w", ctx.Err(), err)
		}
		return "", err
	}
	if resp == nil {
		return "", fmt.Errorf("model response is nil")
	}
	content := strings.TrimSpace(resp.Content)
	if content == "" {
		return "", fmt.Errorf("model response content is empty")
	}
	return content, nil
}

func parseJSON[T any](raw string) (*T, error) {
	clean := strings.TrimSpace(raw)
	if clean == "" {
		return nil, fmt.Errorf("empty response")
	}
	if strings.HasPrefix(clean, "```") {
		clean = strings.TrimPrefix(clean, "```json")
		clean = strings.TrimPrefix(clean, "```")
		clean = strings.TrimSuffix(clean, "```")
		clean = strings.TrimSpace(clean)
	}
	var out T
	if err := json.Unmarshal([]byte(clean), &out); err == nil {
		return &out, nil
	}
	obj, err := extractFirstJSONObject(clean)
	if err != nil {
		return nil, err
	}
	if err := json.Unmarshal([]byte(obj), &out); err != nil {
		return nil, err
	}
	return &out, nil
}

func extractFirstJSONObject(text string) (string, error) {
	start := strings.Index(text, "{")
	if start < 0 {
		return "", fmt.Errorf("no json object found")
	}
	depth := 0
	inString := false
	escape := false
	for i := start; i < len(text); i++ {
		ch := text[i]
		if inString {
			if escape {
				escape = false
				continue
			}
			if ch == '\\' {
				escape = true
				continue
			}
			if ch == '"' {
				inString = false
			}
			continue
		}
		if ch == '"' {
			inString = true
			continue
		}
		if ch == '{' {
			depth++
			continue
		}
		if ch == '}' {
			depth--
			if depth == 0 {
				return text[start : i+1], nil
			}
		}
	}
	return "", fmt.Errorf("json object not closed")
}

func emitModelRawDebug(emitStage func(stage, detail string), tag string, raw string) {
	if emitStage == nil {
		return
	}
	clean := strings.TrimSpace(raw)
	if clean == "" {
		clean = "<empty>"
	}
	const chunkSize = 1600
	runes := []rune(clean)
	if len(runes) <= chunkSize {
		emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s] %s", strings.TrimSpace(tag), clean))
		return
	}
	total := (len(runes) + chunkSize - 1) / chunkSize
	for i := 0; i < total; i++ {
		start := i * chunkSize
		end := start + chunkSize
		if end > len(runes) {
			end = len(runes)
		}
		emitStage("schedule_refine.debug.raw", fmt.Sprintf("[debug][%s][part %d/%d] %s", strings.TrimSpace(tag), i+1, total, string(runes[start:end])))
	}
}

func physicsCheck(entries []model.HybridScheduleEntry, allocatedCount int) []string {
	issues := make([]string, 0, 8)
	slotMap := make(map[string]string, len(entries)*2)
	for _, entry := range entries {
		if entry.SectionFrom < 1 || entry.SectionTo > 12 || entry.SectionFrom > entry.SectionTo {
			issues = append(issues, fmt.Sprintf("节次越界：%s W%dD%d %d-%d", entry.Name, entry.Week, entry.DayOfWeek, entry.SectionFrom, entry.SectionTo))
		}
		if !entryBlocksSuggested(entry) {
			continue
		}
		for sec := entry.SectionFrom; sec <= entry.SectionTo; sec++ {
			key := fmt.Sprintf("%d-%d-%d", entry.Week, entry.DayOfWeek, sec)
			if existed, ok := slotMap[key]; ok {
				issues = append(issues, fmt.Sprintf("冲突：%s 与 %s 同时占用 W%dD%d 第%d节", existed, entry.Name, entry.Week, entry.DayOfWeek, sec))
			} else {
				slotMap[key] = entry.Name
			}
		}
	}
	if allocatedCount > 0 {
		suggested := countSuggested(entries)
		if suggested != allocatedCount {
			issues = append(issues, fmt.Sprintf("数量不一致：suggested=%d，allocated_items=%d", suggested, allocatedCount))
		}
	}
	return issues
}

func updateAllocatedItemsFromEntries(st *ScheduleRefineState) {
	if st == nil || len(st.AllocatedItems) == 0 || len(st.HybridEntries) == 0 {
		return
	}
	byTaskID := make(map[int]model.HybridScheduleEntry, len(st.HybridEntries))
	for _, entry := range st.HybridEntries {
		if isMovableSuggestedTask(entry) {
			byTaskID[entry.TaskItemID] = entry
		}
	}
	for i := range st.AllocatedItems {
		item := &st.AllocatedItems[i]
		entry, ok := byTaskID[item.ID]
		if !ok {
			continue
		}
		if item.EmbeddedTime == nil {
			item.EmbeddedTime = &model.TargetTime{}
		}
		item.EmbeddedTime.Week = entry.Week
		item.EmbeddedTime.DayOfWeek = entry.DayOfWeek
		item.EmbeddedTime.SectionFrom = entry.SectionFrom
		item.EmbeddedTime.SectionTo = entry.SectionTo
	}
}

func countSuggested(entries []model.HybridScheduleEntry) int {
	count := 0
	for _, entry := range entries {
		if isMovableSuggestedTask(entry) {
			count++
		}
	}
	return count
}

func summarizeActionLogs(logs []string, tail int) string {
	if len(logs) == 0 {
		return "无"
	}
	if tail <= 0 || len(logs) <= tail {
		return strings.Join(logs, "\n")
	}
	return strings.Join(logs[len(logs)-tail:], "\n")
}

func fallbackText(text string, fallback string) string {
	clean := strings.TrimSpace(text)
	if clean == "" {
		return fallback
	}
	return clean
}

func withNearestJSONContract(userPrompt string, jsonContract string) string {
	base := strings.TrimSpace(userPrompt)
	rule := strings.TrimSpace(jsonContract)
	if rule == "" {
		return base
	}
	if base == "" {
		return rule
	}
	return base + "\n\n" + rule
}

// alignSummaryWithHardCheck 对齐总结文案与硬校验事实，避免“通过/失败”口径冲突。
//
// 步骤化说明：
// 1. 先以 hard_check 最终结果作为唯一真值；
// 2. pass=true 且 round_used=0 时，强制输出“未执行动作但已满足”的口径；
// 3. pass=true 但文案含失败词，或 pass=false 但文案含通过词，统一纠偏。
func alignSummaryWithHardCheck(st *ScheduleRefineState, summary string) string {
	clean := strings.TrimSpace(summary)
	if st == nil {
		return clean
	}
	passed := FinalHardCheckPassed(st)
	if passed {
		if st.RoundUsed == 0 {
			return "本轮未执行调度动作（0轮），当前排程已满足终审条件。"
		}
		if clean == "" || containsAny(clean, []string{"未完全", "未达标", "未能", "差距", "失败", "未通过"}) {
			return fmt.Sprintf("微调已完成，共执行 %d 轮动作，方案已通过终审。", st.RoundUsed)
		}
		return clean
	}

	if clean == "" || containsAny(clean, []string{"终审通过", "已通过终审", "完全达成", "全部满足"}) {
		return fmt.Sprintf("已完成微调并返回当前最优结果（执行 %d 轮动作）。终审仍有未满足项：%s。", st.RoundUsed, fallbackText(st.HardCheck.IntentReason, "请进一步明确微调目标"))
	}
	return clean
}

func formatReactPlanStageDetail(round int, out *reactLLMOutput, remaining int, useThinking bool) string {
	if out == nil {
		return fmt.Sprintf("第 %d 轮：缺少计划输出。", round)
	}
	return fmt.Sprintf("第 %d 轮|thinking=%t|动作剩余=%d|goal_check=%s|decision=%s", round, useThinking, remaining, truncate(strings.TrimSpace(out.GoalCheck), 180), truncate(strings.TrimSpace(out.Decision), 180))
}

func formatReactNeedInfoStageDetail(round int, missing []string) string {
	if len(missing) == 0 {
		return fmt.Sprintf("第 %d 轮|模型缺口信息=无。", round)
	}
	return fmt.Sprintf("第 %d 轮|模型缺口信息=%s", round, strings.Join(uniqueNonEmpty(missing), "；"))
}

func formatReactReflectStageDetail(round int, reflect string) string {
	return fmt.Sprintf("第 %d 轮|复盘=%s", round, truncate(strings.TrimSpace(reflect), 260))
}

func formatToolCallStageDetail(round int, call reactToolCall, remaining int) string {
	paramsText := "{}"
	if len(call.Params) > 0 {
		if raw, err := json.Marshal(call.Params); err == nil {
			paramsText = string(raw)
		}
	}
	return fmt.Sprintf("第 %d 轮|调用工具=%s|参数=%s|调用前剩余轮次=%d", round, strings.TrimSpace(call.Tool), truncate(paramsText, 320), remaining)
}

func formatToolResultStageDetail(round int, result reactToolResult, used int, total int) string {
	errorCode := strings.TrimSpace(result.ErrorCode)
	if !result.Success && errorCode == "" {
		errorCode = "TOOL_EXEC_FAILED"
	}
	if errorCode == "" {
		errorCode = "NONE"
	}
	return fmt.Sprintf("第 %d 轮|工具=%s|success=%t|error_code=%s|结果=%s|轮次进度=%d/%d", round, strings.TrimSpace(result.Tool), result.Success, errorCode, truncate(strings.TrimSpace(result.Result), 320), used, total)
}

func condenseSummary(plans []model.UserWeekSchedule) string {
	if len(plans) == 0 {
		return "无历史排程摘要"
	}
	totalEvents := 0
	startWeek := plans[0].Week
	endWeek := plans[0].Week
	for _, week := range plans {
		totalEvents += len(week.Events)
		if week.Week < startWeek {
			startWeek = week.Week
		}
		if week.Week > endWeek {
			endWeek = week.Week
		}
	}
	return fmt.Sprintf("共 %d 周，周次范围 W%d~W%d，事件总数 %d。", len(plans), startWeek, endWeek, totalEvents)
}

func hybridEntriesToWeekSchedules(entries []model.HybridScheduleEntry) []model.UserWeekSchedule {
	sectionTimeMap := map[int][2]string{
		1: {"08:00", "08:45"}, 2: {"08:55", "09:40"},
		3: {"10:15", "11:00"}, 4: {"11:10", "11:55"},
		5: {"14:00", "14:45"}, 6: {"14:55", "15:40"},
		7: {"16:15", "17:00"}, 8: {"17:10", "17:55"},
		9: {"19:00", "19:45"}, 10: {"19:55", "20:40"},
		11: {"20:50", "21:35"}, 12: {"21:45", "22:30"},
	}
	weekMap := make(map[int][]model.WeeklyEventBrief)
	for _, entry := range entries {
		start, end := "", ""
		if val, ok := sectionTimeMap[entry.SectionFrom]; ok {
			start = val[0]
		}
		if val, ok := sectionTimeMap[entry.SectionTo]; ok {
			end = val[1]
		}
		weekMap[entry.Week] = append(weekMap[entry.Week], model.WeeklyEventBrief{
			ID:        entry.EventID,
			DayOfWeek: entry.DayOfWeek,
			Name:      entry.Name,
			StartTime: start,
			EndTime:   end,
			Type:      entry.Type,
			Span:      entry.SectionTo - entry.SectionFrom + 1,
			Status:    entry.Status,
		})
	}
	result := make([]model.UserWeekSchedule, 0, len(weekMap))
	for week, events := range weekMap {
		result = append(result, model.UserWeekSchedule{Week: week, Events: events})
	}
	sort.SliceStable(result, func(i, j int) bool { return result[i].Week < result[j].Week })
	return result
}

func buildFallbackContract(st *ScheduleRefineState) RefineContract {
	intent := strings.TrimSpace(st.UserMessage)
	keepOrder := detectOrderIntent(st.UserMessage)
	reqs := append([]string(nil), st.Constraints...)
	if keepOrder {
		reqs = append(reqs, "保持任务原始相对顺序不变")
	}
	assertions := inferHardAssertionsFromRequest(st.UserMessage, reqs)
	return RefineContract{
		Intent:            intent,
		Strategy:          "local_adjust",
		HardRequirements:  uniqueNonEmpty(reqs),
		HardAssertions:    assertions,
		KeepRelativeOrder: keepOrder,
		OrderScope:        "global",
	}
}

func normalizeStrategy(strategy string) string {
	switch strings.TrimSpace(strings.ToLower(strategy)) {
	case "keep":
		return "keep"
	default:
		return "local_adjust"
	}
}

func detectOrderIntent(userMessage string) bool {
	msg := strings.TrimSpace(userMessage)
	if msg == "" {
		return true
	}
	// 1. 默认启用顺序约束，除非用户明确授权可打乱顺序。
	// 2. 这样可避免“用户没提顺序但结果被打乱”的违和体验。
	for _, k := range []string{"可以打乱顺序", "允许打乱顺序", "顺序无所谓", "不考虑顺序", "不用保持顺序", "无需保持顺序", "随便排顺序", "乱序也行"} {
		if strings.Contains(msg, k) {
			return false
		}
	}
	return true
}

func uniqueNonEmpty(items []string) []string {
	if len(items) == 0 {
		return nil
	}
	seen := make(map[string]struct{}, len(items))
	out := make([]string, 0, len(items))
	for _, item := range items {
		clean := strings.TrimSpace(item)
		if clean == "" {
			continue
		}
		if _, ok := seen[clean]; ok {
			continue
		}
		seen[clean] = struct{}{}
		out = append(out, clean)
	}
	return out
}

func buildObservationPrompt(history []ReactRoundObservation, tail int) string {
	if len(history) == 0 {
		return "无"
	}
	start := 0
	if tail > 0 && len(history) > tail {
		start = len(history) - tail
	}
	raw, err := json.Marshal(history[start:])
	if err != nil {
		return err.Error()
	}
	return string(raw)
}

func buildLastToolObservationPrompt(history []ReactRoundObservation) string {
	for i := len(history) - 1; i >= 0; i-- {
		item := history[i]
		if strings.TrimSpace(item.ToolName) == "" {
			continue
		}
		raw, err := json.Marshal(item)
		if err != nil {
			return "无"
		}
		return string(raw)
	}
	return "无"
}

func buildToolCallSignature(call reactToolCall) string {
	paramsText := "{}"
	if len(call.Params) > 0 {
		if raw, err := json.Marshal(call.Params); err == nil {
			paramsText = string(raw)
		}
	}
	return fmt.Sprintf("%s|%s", strings.ToUpper(strings.TrimSpace(call.Tool)), paramsText)
}

func buildSlotQuerySignature(st *ScheduleRefineState, params map[string]any) string {
	normalized := canonicalizeToolCall(reactToolCall{Tool: "QueryAvailableSlots", Params: params})
	raw, _ := json.Marshal(normalized.Params)
	version := 0
	if st != nil {
		version = st.EntriesVersion
	}
	return fmt.Sprintf("v=%d|%s", version, string(raw))
}

func canonicalizeToolCall(call reactToolCall) reactToolCall {
	canonical := reactToolCall{
		Tool:   strings.TrimSpace(call.Tool),
		Params: cloneToolParams(call.Params),
	}
	switch canonical.Tool {
	case "Move":
		canonical.Params = canonicalizeMoveParams(canonical.Params)
	case "BatchMove":
		canonical.Params = canonicalizeBatchMoveParams(canonical.Params)
	case "SpreadEven", "MinContextSwitch":
		canonical.Params = canonicalizeCompositeMoveParams(canonical.Params)
	case "QueryAvailableSlots":
		canonical.Params = canonicalizeSlotQueryParams(canonical.Params)
	}
	return canonical
}

func canonicalizeMoveParams(params map[string]any) map[string]any {
	out := cloneToolParams(params)
	setCanonicalInt(out, "task_item_id", out, "task_item_id", "task_id")
	setCanonicalInt(out, "to_week", out, "to_week", "target_week", "new_week", "week")
	setCanonicalInt(out, "to_day", out, "to_day", "target_day", "new_day", "target_day_of_week", "day_of_week", "day")
	setCanonicalInt(out, "to_section_from", out, "to_section_from", "target_section_from", "new_section_from", "section_from")
	setCanonicalInt(out, "to_section_to", out, "to_section_to", "target_section_to", "new_section_to", "section_to")
	return out
}

func canonicalizeBatchMoveParams(params map[string]any) map[string]any {
	out := cloneToolParams(params)
	rawMoves, ok := out["moves"]
	if !ok {
		return out
	}
	moves, ok := rawMoves.([]any)
	if !ok {
		return out
	}
	normalized := make([]any, 0, len(moves))
	for _, item := range moves {
		moveMap, ok := item.(map[string]any)
		if !ok {
			continue
		}
		normalized = append(normalized, canonicalizeMoveParams(moveMap))
	}
	out["moves"] = normalized
	return out
}

func canonicalizeCompositeMoveParams(params map[string]any) map[string]any {
	out := cloneToolParams(params)
	ids := readIntSlice(out, "task_item_ids", "task_ids")
	if taskID, ok := paramIntAny(out, "task_item_id", "task_id"); ok {
		ids = append(ids, taskID)
	}
	if len(ids) > 0 {
		out["task_item_ids"] = uniquePositiveInts(ids)
	}

	setCanonicalInt(out, "week", out, "week", "to_week", "target_week", "new_week")
	if day, ok := paramIntAny(out, "day_of_week", "to_day", "target_day_of_week", "target_day", "new_day", "day"); ok {
		out["day_of_week"] = []int{day}
	}
	if weeks := readIntSlice(out, "week_filter", "weeks"); len(weeks) > 0 {
		out["week_filter"] = uniquePositiveInts(weeks)
	}
	if days := readIntSlice(out, "day_of_week", "days", "day_filter"); len(days) > 0 {
		out["day_of_week"] = uniquePositiveInts(days)
	}
	if sections := readIntSlice(out, "exclude_sections", "exclude_section"); len(sections) > 0 {
		out["exclude_sections"] = uniquePositiveInts(sections)
	}
	return out
}

func canonicalizeSlotQueryParams(params map[string]any) map[string]any {
	out := cloneToolParams(params)
	setCanonicalInt(out, "week", out, "week")
	if weeks := readIntSlice(out, "week_filter", "weeks"); len(weeks) > 0 {
		out["week_filter"] = uniquePositiveInts(weeks)
	}
	if days := readIntSlice(out, "day_of_week", "days", "day_filter"); len(days) > 0 {
		out["day_filter"] = uniquePositiveInts(days)
	}
	setCanonicalInt(out, "section_duration", out, "section_duration", "span", "task_duration")
	setCanonicalInt(out, "section_from", out, "section_from", "target_section_from")
	setCanonicalInt(out, "section_to", out, "section_to", "target_section_to")
	setCanonicalInt(out, "limit", out, "limit")
	return out
}

func setCanonicalInt(dst map[string]any, dstKey string, src map[string]any, keys ...string) {
	if dst == nil || src == nil {
		return
	}
	if value, ok := paramIntAny(src, keys...); ok {
		dst[dstKey] = value
	}
}

func listTaskIDsFromToolCall(call reactToolCall) []int {
	switch strings.TrimSpace(call.Tool) {
	case "Move":
		taskID, ok := paramIntAny(call.Params, "task_item_id", "task_id")
		if !ok {
			return nil
		}
		return []int{taskID}
	case "Swap":
		taskA, okA := paramIntAny(call.Params, "task_a", "task_item_a", "task_item_id_a")
		taskB, okB := paramIntAny(call.Params, "task_b", "task_item_b", "task_item_id_b")
		return uniquePositiveInts([]int{taskA, taskB}, okA, okB)
	case "BatchMove":
		rawMoves, ok := call.Params["moves"]
		if !ok {
			return nil
		}
		moves, ok := rawMoves.([]any)
		if !ok {
			return nil
		}
		ids := make([]int, 0, len(moves))
		for _, item := range moves {
			moveMap, ok := item.(map[string]any)
			if !ok {
				continue
			}
			if taskID, ok := paramIntAny(moveMap, "task_item_id", "task_id"); ok {
				ids = append(ids, taskID)
			}
		}
		return uniquePositiveInts(ids)
	case "SpreadEven", "MinContextSwitch":
		ids := readIntSlice(call.Params, "task_item_ids", "task_ids")
		if taskID, ok := paramIntAny(call.Params, "task_item_id", "task_id"); ok {
			ids = append(ids, taskID)
		}
		return uniquePositiveInts(ids)
	default:
		return nil
	}
}

func precheckCurrentTaskOwnership(call reactToolCall, taskIDs []int, currentTaskID int) (reactToolResult, bool) {
	if currentTaskID <= 0 {
		return reactToolResult{}, false
	}
	if !isMutatingToolName(strings.TrimSpace(call.Tool)) {
		return reactToolResult{}, false
	}
	for _, id := range taskIDs {
		if id == currentTaskID {
			return reactToolResult{}, false
		}
	}
	return reactToolResult{
		Tool:      strings.TrimSpace(call.Tool),
		Success:   false,
		ErrorCode: "CURRENT_TASK_MISMATCH",
		Result:    fmt.Sprintf("当前微循环任务为 id=%d，本轮改写动作未包含该任务，请改为围绕当前任务执行。", currentTaskID),
	}, true
}

func precheckToolCallPolicy(st *ScheduleRefineState, call reactToolCall, taskIDs []int) (reactToolResult, bool) {
	if st == nil {
		return reactToolResult{}, false
	}
	toolName := strings.TrimSpace(call.Tool)
	if st.DisableCompositeTools && isCompositeToolName(toolName) {
		return reactToolResult{
			Tool:      toolName,
			Success:   false,
			ErrorCode: "COMPOSITE_DISABLED",
			Result:    "当前已进入 ReAct 兜底模式，禁止调用复合工具，请使用 Move/Swap 逐步处理。",
		}, true
	}
	if st.DisableCompositeTools && toolName == "BatchMove" {
		return reactToolResult{
			Tool:      toolName,
			Success:   false,
			ErrorCode: "BATCH_MOVE_DISABLED",
			Result:    "当前兜底模式要求逐任务挪动，禁止使用 BatchMove。",
		}, true
	}
	if toolName == "BatchMove" && !st.BatchMoveAllowed {
		return reactToolResult{Tool: toolName, Success: false, ErrorCode: "BATCH_MOVE_DISABLED", Result: "当前计划未显式允许 BatchMove，请改用单步 Move/Swap。"}, true
	}
	if toolName == "QueryAvailableSlots" {
		if st.SeenSlotQueries == nil {
			st.SeenSlotQueries = make(map[string]struct{})
		}
		signature := buildSlotQuerySignature(st, call.Params)
		if _, exists := st.SeenSlotQueries[signature]; exists {
			return reactToolResult{
				Tool:      toolName,
				Success:   false,
				ErrorCode: "QUERY_REDUNDANT",
				Result:    "同版本排程下重复查询同一空位范围，已拒绝；请直接基于 ENV_SLOT_HINT 选择落点。",
			}, true
		}
		st.SeenSlotQueries[signature] = struct{}{}
		return reactToolResult{}, false
	}
	// 1. 当计划声明“必用复合工具”且尚未成功时，先锁住基础写工具。
	// 2. 这样可避免模型绕开复合工具直接 Move，导致“命中率低 + 语义漂移”。
	requiredComposite := normalizeCompositeToolName(st.RequiredCompositeTool)
	if requiredComposite != "" && !isRequiredCompositeSatisfied(st) && isMutatingToolName(toolName) {
		if toolName != requiredComposite {
			return reactToolResult{
				Tool:      toolName,
				Success:   false,
				ErrorCode: "COMPOSITE_REQUIRED",
				Result:    fmt.Sprintf("当前计划要求先成功调用 %s；在其成功前禁止使用 %s。", requiredComposite, toolName),
			}, true
		}
	}
	if !isMutatingToolName(toolName) {
		return reactToolResult{}, false
	}
	if st.PerTaskBudget <= 0 || len(taskIDs) == 0 {
		return reactToolResult{}, false
	}
	for _, taskID := range taskIDs {
		if st.TaskActionUsed[taskID] >= st.PerTaskBudget {
			return reactToolResult{Tool: toolName, Success: false, ErrorCode: "TASK_BUDGET_EXCEEDED", Result: fmt.Sprintf("任务 id=%d 已达到单任务动作预算上限=%d，请重规划或更换目标任务。", taskID, st.PerTaskBudget)}, true
		}
	}
	return reactToolResult{}, false
}

func isMutatingToolName(toolName string) bool {
	switch strings.TrimSpace(toolName) {
	case "Move", "Swap", "BatchMove", "SpreadEven", "MinContextSwitch":
		return true
	default:
		return false
	}
}

func uniquePositiveInts(ids []int, oks ...bool) []int {
	allowAll := len(oks) == 0
	seen := make(map[int]struct{}, len(ids))
	out := make([]int, 0, len(ids))
	for i, id := range ids {
		if !allowAll {
			if i >= len(oks) || !oks[i] {
				continue
			}
		}
		if id <= 0 {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		out = append(out, id)
	}
	return out
}

func isRepeatedFailedCall(st *ScheduleRefineState, signature string) bool {
	if st == nil {
		return false
	}
	current := strings.TrimSpace(signature)
	last := strings.TrimSpace(st.LastFailedCallSignature)
	return current != "" && last != "" && current == last
}

func normalizeToolResult(result reactToolResult) reactToolResult {
	if result.Success {
		return result
	}
	if strings.TrimSpace(result.ErrorCode) != "" {
		return result
	}
	result.ErrorCode = classifyToolFailureCode(result.Result)
	return result
}

func classifyToolFailureCode(detail string) string {
	text := strings.TrimSpace(detail)
	switch {
	case strings.Contains(text, "单任务动作预算上限"):
		return "TASK_BUDGET_EXCEEDED"
	case strings.Contains(text, "未显式允许 BatchMove"):
		return "BATCH_MOVE_DISABLED"
	case strings.Contains(text, "重复失败动作"):
		return "REPEAT_FAILED_ACTION"
	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 "TASK_ID_AMBIGUOUS"
	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"
	case strings.Contains(text, "不支持的工具"):
		return "TOOL_NOT_ALLOWED"
	case strings.Contains(text, "BatchMove"):
		return "BATCH_MOVE_FAILED"
	case strings.Contains(text, "Verify"):
		return "VERIFY_FAILED"
	case strings.Contains(text, "序列化查询结果失败"), strings.Contains(text, "序列化空位结果失败"):
		return "QUERY_ENCODE_FAILED"
	default:
		return "TOOL_EXEC_FAILED"
	}
}

func cloneToolParams(params map[string]any) map[string]any {
	if len(params) == 0 {
		return nil
	}
	raw, err := json.Marshal(params)
	if err != nil {
		dst := make(map[string]any, len(params))
		for k, v := range params {
			dst[k] = v
		}
		return dst
	}
	var out map[string]any
	if err = json.Unmarshal(raw, &out); err != nil {
		dst := make(map[string]any, len(params))
		for k, v := range params {
			dst[k] = v
		}
		return dst
	}
	return out
}

func formatRoundModelErrorDetail(round int, err error, parentCtx context.Context) string {
	parentState := "alive"
	if parentCtx == nil {
		parentState = "nil"
	} else if parentCtx.Err() != nil {
		parentState = parentCtx.Err().Error()
	}
	parentDeadline := "none"
	if parentCtx != nil {
		if deadline, ok := parentCtx.Deadline(); ok {
			parentDeadline = fmt.Sprintf("%dms", time.Until(deadline).Milliseconds())
		}
	}
	return fmt.Sprintf("第 %d 轮模型调用失败：%v | parent_ctx=%s | parent_deadline_in_ms=%s | node_timeout_ms=%d", round, err, parentState, parentDeadline, nodeTimeout.Milliseconds())
}

func buildRuntimeReflect(modelReflect string, result reactToolResult) string {
	modelText := strings.TrimSpace(modelReflect)
	resultText := truncate(strings.TrimSpace(result.Result), 220)
	if result.Success {
		if modelText == "" {
			return fmt.Sprintf("后端复盘：工具执行成功。%s", resultText)
		}
		return fmt.Sprintf("后端复盘：工具执行成功。%s。模型预期（动作前）：%s", resultText, truncate(modelText, 180))
	}
	if modelText == "" {
		return fmt.Sprintf("后端复盘：工具执行失败。%s。本轮调整未生效，已保留原方案。", resultText)
	}
	return fmt.Sprintf("后端复盘：工具执行失败。%s。本轮调整未生效，已保留原方案。模型预期（动作前，仅供参考）：%s", resultText, truncate(modelText, 160))
}

func buildSuggestedDigest(entries []model.HybridScheduleEntry, limit int) string {
	if len(entries) == 0 {
		return "无"
	}
	list := make([]model.HybridScheduleEntry, 0, len(entries))
	for _, entry := range entries {
		if isMovableSuggestedTask(entry) {
			list = append(list, entry)
		}
	}
	if len(list) == 0 {
		return "无 suggested 条目"
	}
	sortHybridEntries(list)
	if limit <= 0 {
		limit = len(list)
	}
	if len(list) > limit {
		list = list[:limit]
	}
	lines := make([]string, 0, len(list))
	for _, item := range list {
		lines = append(lines, fmt.Sprintf("id=%d|W%d|D%d(%s)|%d-%d|%s", item.TaskItemID, item.Week, item.DayOfWeek, weekdayLabel(item.DayOfWeek), item.SectionFrom, item.SectionTo, strings.TrimSpace(item.Name)))
	}
	return strings.Join(lines, "\n")
}

func buildSuggestedDigestByWeek(entries []model.HybridScheduleEntry, week int, limit int) string {
	if week <= 0 {
		return buildSuggestedDigest(entries, limit)
	}
	filtered := make([]model.HybridScheduleEntry, 0, len(entries))
	for _, entry := range entries {
		if isMovableSuggestedTask(entry) && entry.Week == week {
			filtered = append(filtered, entry)
		}
	}
	if len(filtered) == 0 {
		return "无同周 suggested 条目"
	}
	return buildSuggestedDigest(filtered, limit)
}

func weekdayLabel(day int) string {
	switch day {
	case 1:
		return "周一"
	case 2:
		return "周二"
	case 3:
		return "周三"
	case 4:
		return "周四"
	case 5:
		return "周五"
	case 6:
		return "周六"
	case 7:
		return "周日"
	default:
		return "未知"
	}
}

func parseReactOutputWithRetryOnce(
	ctx context.Context,
	chatModel *ark.ChatModel,
	userPrompt string,
	firstRaw string,
	round int,
	emitStage func(stage, detail string),
	st *ScheduleRefineState,
) (*reactLLMOutput, error) {
	if st == nil {
		return nil, respond.ScheduleRefineOutputParseFailed
	}
	parsed, parseErr := parseReactLLMOutput(firstRaw)
	if parseErr == nil {
		return parsed, nil
	}
	emitStage("schedule_refine.react.parse_retry", fmt.Sprintf("第 %d 轮输出解析失败，准备重试1次：%s", round, truncate(parseErr.Error(), 260)))
	retryRaw, retryErr := callModelText(ctx, chatModel, reactPrompt, userPrompt, false, reactMaxTokens, 0)
	if retryErr != nil {
		emitStage("schedule_refine.react.round_error", formatRoundModelErrorDetail(round, fmt.Errorf("解析重试调用失败: %w", retryErr), ctx))
		return nil, respond.ScheduleRefineOutputParseFailed
	}
	emitModelRawDebug(emitStage, fmt.Sprintf("react.round.%d.retry", round), retryRaw)
	retryParsed, retryParseErr := parseReactLLMOutput(retryRaw)
	if retryParseErr != nil {
		emitStage("schedule_refine.react.round_error", fmt.Sprintf("第 %d 轮输出二次解析失败：%s", round, truncate(retryParseErr.Error(), 260)))
		return nil, respond.ScheduleRefineOutputParseFailed
	}
	emitStage("schedule_refine.react.parse_retry_success", fmt.Sprintf("第 %d 轮输出重试解析成功，继续执行。", round))
	return retryParsed, nil
}

func parsePlannerOutputWithRetryOnce(
	ctx context.Context,
	chatModel *ark.ChatModel,
	originUserPrompt string,
	firstRaw string,
	mode string,
	emitStage func(stage, detail string),
) (*plannerOutput, error) {
	parsed, parseErr := parseJSON[plannerOutput](firstRaw)
	if parseErr == nil {
		return parsed, nil
	}
	emitStage("schedule_refine.plan.parse_retry", fmt.Sprintf("Planner 解析失败，准备重试1次（mode=%s）：%s", strings.TrimSpace(mode), truncate(parseErr.Error(), 160)))
	retryPrompt := withNearestJSONContract(
		fmt.Sprintf("%s\n\n上一轮输出解析失败（原因：JSON 不完整或不闭合）。请缩短内容并严格输出完整 JSON。", originUserPrompt),
		jsonContractForPlanner,
	)
	retryRaw, retryErr := callModelText(ctx, chatModel, plannerPrompt, retryPrompt, false, plannerMaxTokens, 0)
	if retryErr != nil {
		return nil, retryErr
	}
	emitModelRawDebug(emitStage, fmt.Sprintf("planner.%s.retry", strings.TrimSpace(mode)), retryRaw)
	retryParsed, retryParseErr := parseJSON[plannerOutput](retryRaw)
	if retryParseErr != nil {
		return nil, retryParseErr
	}
	emitStage("schedule_refine.plan.parse_retry_success", fmt.Sprintf("Planner 重试解析成功（mode=%s）。", strings.TrimSpace(mode)))
	return retryParsed, nil
}

func buildSlicePlan(st *ScheduleRefineState) RefineSlicePlan {
	msg := strings.TrimSpace(st.UserMessage)
	lower := strings.ToLower(msg)
	plan := RefineSlicePlan{
		WeekFilter:      extractWeekFilters(msg),
		ExcludeSections: extractExcludeSections(msg),
		Reason:          "根据用户请求抽取得到执行切片",
	}
	// 1. 优先解析“从A收敛到B”这类方向型表达，防止把 source/target 反向识别。
	// 2. 例如“周四到周五收敛到周一到周三”应得到 source=[4,5], target=[1,2,3]。
	if src, tgt, ok := extractDirectionalSourceTargetDays(msg); ok {
		plan.SourceDays = src
		plan.TargetDays = tgt
		return plan
	}
	if strings.Contains(msg, "工作日") || strings.Contains(msg, "周一到周五") || strings.Contains(msg, "周1到周5") {
		plan.TargetDays = []int{1, 2, 3, 4, 5}
	} else if containsAny(lower, []string{"移到周末", "挪到周末", "安排在周末", "放到周末"}) {
		plan.TargetDays = []int{6, 7}
	} else if days := extractTargetDaysFromMessage(msg); len(days) > 0 {
		plan.TargetDays = days
	}
	if len(plan.TargetDays) == 5 && isSameDays(plan.TargetDays, []int{1, 2, 3, 4, 5}) && strings.Contains(msg, "周末") {
		plan.SourceDays = []int{6, 7}
	}
	if day := detectOverloadedDay(msg); day > 0 {
		plan.SourceDays = uniquePositiveInts(append(plan.SourceDays, day))
	}
	if fromDays := extractSourceDaysFromMessage(msg); len(fromDays) > 0 {
		plan.SourceDays = uniquePositiveInts(append(plan.SourceDays, fromDays...))
	}
	return plan
}

// extractDirectionalSourceTargetDays 解析“来源日 -> 目标日”表达。
//
// 规则：
// 1. 以“收敛到/移到/挪到/调整到”等方向词为分割；
// 2. 分割前提取 source days，分割后提取 target days；
// 3. 两侧都提取成功才返回 true，避免误判。
func extractDirectionalSourceTargetDays(text string) ([]int, []int, bool) {
	verbIdx := -1
	verbLen := 0
	for _, key := range []string{"收敛到", "移到", "挪到", "调整到", "安排到", "放到", "改到", "迁移到", "分散到"} {
		if idx := strings.Index(text, key); idx >= 0 {
			verbIdx = idx
			verbLen = len(key)
			break
		}
	}
	if verbIdx < 0 {
		return nil, nil, false
	}
	left := strings.TrimSpace(text[:verbIdx])
	right := strings.TrimSpace(text[verbIdx+verbLen:])
	if left == "" || right == "" {
		return nil, nil, false
	}
	source := extractDayExpr(left)
	target := extractDayExpr(right)
	if len(source) == 0 || len(target) == 0 {
		return nil, nil, false
	}
	return source, target, true
}

// extractDayExpr 提取文本中的“星期表达式”。
// 优先提取区间（周一到周三），提不到再提取离散天。
func extractDayExpr(text string) []int {
	if days := extractRangeDays(text); len(days) > 0 {
		return days
	}
	return extractDays(text)
}

// inferSourceWeekSet 推断“来源周”集合。
//
// 规则：
// 1. 当 week_filter 至少两个值时，默认第一个值视为来源周（保留用户原话顺序）；
// 2. 当 week_filter 少于两个值时，不强制来源周过滤，返回空集合；
// 3. 该规则用于收敛 workset，避免把目标周任务误纳入当前微循环。
func inferSourceWeekSet(slice RefineSlicePlan) map[int]struct{} {
	if len(slice.WeekFilter) < 2 {
		return nil
	}
	sourceWeek := slice.WeekFilter[0]
	if sourceWeek <= 0 {
		return nil
	}
	return map[int]struct{}{sourceWeek: {}}
}

// inferTargetWeekSet 推断“目标周”集合。
//
// 规则：
// 1. 当 week_filter 至少两个值时，除首个来源周外，其余周视为目标周；
// 2. 当 week_filter 少于两个值时，不构造目标周集合，交由其他约束判定；
// 3. 返回升维集合用于 O(1) 命中判断。
func inferTargetWeekSet(slice RefineSlicePlan) map[int]struct{} {
	if len(slice.WeekFilter) < 2 {
		return nil
	}
	set := make(map[int]struct{}, len(slice.WeekFilter)-1)
	for _, week := range slice.WeekFilter[1:] {
		if week > 0 {
			set[week] = struct{}{}
		}
	}
	if len(set) == 0 {
		return nil
	}
	return set
}

func collectWorksetTaskIDs(entries []model.HybridScheduleEntry, slice RefineSlicePlan, originOrder map[int]int) []int {
	type candidate struct {
		TaskID      int
		Week        int
		Day         int
		SectionFrom int
		Rank        int
	}
	list := make([]candidate, 0, len(entries))
	seen := make(map[int]struct{}, len(entries))
	weekSet := intSliceToWeekSet(slice.WeekFilter)
	sourceWeekSet := inferSourceWeekSet(slice)
	sourceSet := intSliceToDaySet(slice.SourceDays)
	for _, entry := range entries {
		if !isMovableSuggestedTask(entry) {
			continue
		}
		// 1. 方向型周次请求（例如“14周挪到13周”）下，只把“来源周”任务放入 workset。
		// 2. 这样做可以避免目标周/其他周任务被误当成当前微循环任务，触发串改。
		if len(sourceWeekSet) > 0 {
			if _, ok := sourceWeekSet[entry.Week]; !ok {
				continue
			}
		}
		if len(weekSet) > 0 {
			if _, ok := weekSet[entry.Week]; !ok {
				continue
			}
		}
		if len(sourceSet) > 0 {
			if _, ok := sourceSet[entry.DayOfWeek]; !ok {
				continue
			}
		}
		if _, ok := seen[entry.TaskItemID]; ok {
			continue
		}
		seen[entry.TaskItemID] = struct{}{}
		rank := originOrder[entry.TaskItemID]
		if rank <= 0 {
			rank = 1 << 30
		}
		list = append(list, candidate{
			TaskID:      entry.TaskItemID,
			Week:        entry.Week,
			Day:         entry.DayOfWeek,
			SectionFrom: entry.SectionFrom,
			Rank:        rank,
		})
	}
	sort.SliceStable(list, func(i, j int) bool {
		if list[i].Rank != list[j].Rank {
			return list[i].Rank < list[j].Rank
		}
		if list[i].Week != list[j].Week {
			return list[i].Week < list[j].Week
		}
		if list[i].Day != list[j].Day {
			return list[i].Day < list[j].Day
		}
		if list[i].SectionFrom != list[j].SectionFrom {
			return list[i].SectionFrom < list[j].SectionFrom
		}
		return list[i].TaskID < list[j].TaskID
	})
	ids := make([]int, 0, len(list))
	for _, item := range list {
		ids = append(ids, item.TaskID)
	}
	return ids
}

func findSuggestedEntryByTaskID(entries []model.HybridScheduleEntry, taskID int) (model.HybridScheduleEntry, bool) {
	for _, entry := range entries {
		if isMovableSuggestedTask(entry) && entry.TaskItemID == taskID {
			return entry, true
		}
	}
	return model.HybridScheduleEntry{}, false
}

// isCurrentTaskSatisfiedBySlice 判断“当前任务”是否已满足本轮切片目标。
//
// 步骤化说明：
// 1. 该判断只用于“当前任务自动收口”，不替代全局 hard_check；
// 2. 若切片包含 source_days，则任务离开 source_days 视为关键进展；
// 3. 若切片包含 target_days / exclude_sections / week_filter，则需同时满足；
// 4. 若切片没有任何约束，返回 false，避免误判导致提前结束。
func isCurrentTaskSatisfiedBySlice(entry model.HybridScheduleEntry, slice RefineSlicePlan) bool {
	if !isMovableSuggestedTask(entry) {
		return false
	}
	weekSet := intSliceToWeekSet(slice.WeekFilter)
	sourceWeekSet := inferSourceWeekSet(slice)
	sourceSet := intSliceToDaySet(slice.SourceDays)
	targetSet := intSliceToDaySet(slice.TargetDays)
	excludedSet := intSliceToSectionSet(slice.ExcludeSections)

	hasConstraint := len(sourceWeekSet) > 0 || len(weekSet) > 0 || len(sourceSet) > 0 || len(targetSet) > 0 || len(excludedSet) > 0
	if !hasConstraint {
		return false
	}
	if len(sourceWeekSet) > 0 {
		if _, stillInSourceWeek := sourceWeekSet[entry.Week]; stillInSourceWeek {
			return false
		}
	}
	if len(weekSet) > 0 {
		if _, ok := weekSet[entry.Week]; !ok {
			return false
		}
	}
	if len(sourceSet) > 0 {
		if _, stillInSource := sourceSet[entry.DayOfWeek]; stillInSource {
			return false
		}
	}
	if len(targetSet) > 0 {
		if _, ok := targetSet[entry.DayOfWeek]; !ok {
			return false
		}
	}
	if len(excludedSet) > 0 && intersectsExcludedSections(entry.SectionFrom, entry.SectionTo, excludedSet) {
		return false
	}
	return true
}

func taskProgressLabel(done bool, attemptUsed int, perTaskBudget int) string {
	if done {
		return "done"
	}
	if perTaskBudget > 0 && attemptUsed >= perTaskBudget {
		return "budget_exhausted"
	}
	return "paused"
}

func buildMicroReactUserPrompt(st *ScheduleRefineState, current model.HybridScheduleEntry, remainingAction int, remainingTotal int) string {
	ensureCompositeStateMaps(st)
	contractJSON, _ := json.Marshal(st.Contract)
	planJSON, _ := json.Marshal(st.CurrentPlan)
	sliceJSON, _ := json.Marshal(st.SlicePlan)
	objectiveJSON, _ := json.Marshal(st.Objective)
	currentJSON, _ := json.Marshal(current)
	sourceWeeks := keysOfIntSet(inferSourceWeekSet(st.SlicePlan))
	requiredComposite := normalizeCompositeToolName(st.RequiredCompositeTool)
	requiredSuccess := isRequiredCompositeSatisfied(st)
	compositeToolsAllowed := !st.DisableCompositeTools
	compositeCalledJSON, _ := json.Marshal(st.CompositeToolCalled)
	compositeSuccessJSON, _ := json.Marshal(st.CompositeToolSuccess)
	envSlotHint := buildEnvSlotHint(st, current)
	userPrompt := fmt.Sprintf(
		"用户本轮请求=%s\n契约=%s\n执行计划=%s\n切片=%s\n目标约束=%s\nCURRENT_TASK=%s\nSOURCE_WEEK_FILTER=%v\nBACKEND_GUARD=本轮只允许改写 task_item_id=%d；若该任务已满足切片目标或目标约束已整体达成且复合工具门禁通过，请直接 done=true；下一任务由后端自动切换。\nREQUIRED_COMPOSITE_TOOL=%s\nCOMPOSITE_TOOLS_ALLOWED=%t\nCOMPOSITE_REQUIRED_SUCCESS=%t\nCOMPOSITE_CALLED=%s\nCOMPOSITE_SUCCESS=%s\nCURRENT_TASK_ACTION_USED=%d\nPER_TASK_BUDGET=%d\n动作预算剩余=%d\n总预算剩余=%d\nENV_SLOT_HINT=%s\nLAST_TOOL_OBSERVATION=%s\nLAST_FAILED_CALL_SIGNATURE=%s\n最近观察=%s\n同周suggested摘要=%s",
		strings.TrimSpace(st.UserMessage),
		string(contractJSON),
		string(planJSON),
		string(sliceJSON),
		string(objectiveJSON),
		string(currentJSON),
		sourceWeeks,
		current.TaskItemID,
		fallbackText(requiredComposite, "无"),
		compositeToolsAllowed,
		requiredSuccess,
		string(compositeCalledJSON),
		string(compositeSuccessJSON),
		st.TaskActionUsed[current.TaskItemID],
		st.PerTaskBudget,
		remainingAction,
		remainingTotal,
		envSlotHint,
		buildLastToolObservationPrompt(st.ObservationHistory),
		fallbackText(st.LastFailedCallSignature, "无"),
		buildObservationPrompt(st.ObservationHistory, 2),
		buildSuggestedDigestByWeek(st.HybridEntries, current.Week, 24),
	)
	return withNearestJSONContract(userPrompt, jsonContractForReact)
}

type slotHintPayload struct {
	Count         int `json:"count"`
	StrictCount   int `json:"strict_count"`
	EmbeddedCount int `json:"embedded_count"`
	Slots         []struct {
		Week        int `json:"week"`
		DayOfWeek   int `json:"day_of_week"`
		SectionFrom int `json:"section_from"`
		SectionTo   int `json:"section_to"`
	} `json:"slots"`
}

func buildEnvSlotHint(st *ScheduleRefineState, current model.HybridScheduleEntry) string {
	if st == nil || !isMovableSuggestedTask(current) {
		return "无可用提示"
	}
	span := current.SectionTo - current.SectionFrom + 1
	if span <= 0 {
		span = 2
	}
	targetWeeks := append([]int(nil), st.Objective.TargetWeeks...)
	if len(targetWeeks) == 0 {
		targetWeeks = keysOfIntSet(inferTargetWeekSet(st.SlicePlan))
	}
	if len(targetWeeks) == 0 && current.Week > 0 {
		targetWeeks = []int{current.Week}
	}
	targetDays := append([]int(nil), st.Objective.TargetDays...)
	if len(targetDays) == 0 {
		targetDays = append([]int(nil), st.SlicePlan.TargetDays...)
	}
	params := map[string]any{
		"week_filter":      targetWeeks,
		"day_filter":       targetDays,
		"section_duration": span,
		"limit":            8,
		"slot_type":        "pure",
		"exclude_sections": st.SlicePlan.ExcludeSections,
	}
	_, pureResult := refineToolQueryAvailableSlots(st.HybridEntries, params, buildPlanningWindowFromEntries(st.HybridEntries))
	if !pureResult.Success {
		return fmt.Sprintf("pure_slot_query_failed=%s", truncate(pureResult.Result, 100))
	}
	purePayload, ok := decodeSlotHintPayload(pureResult.Result)
	if !ok {
		return "pure_slot_parse_failed"
	}

	embedParams := map[string]any{
		"week_filter":      targetWeeks,
		"day_filter":       targetDays,
		"section_duration": span,
		"limit":            8,
		"exclude_sections": st.SlicePlan.ExcludeSections,
	}
	_, fallbackResult := refineToolQueryAvailableSlots(st.HybridEntries, embedParams, buildPlanningWindowFromEntries(st.HybridEntries))
	if !fallbackResult.Success {
		return fmt.Sprintf("pure=%d fallback_query_failed=%s", purePayload.Count, truncate(fallbackResult.Result, 100))
	}
	fallbackPayload, ok := decodeSlotHintPayload(fallbackResult.Result)
	if !ok {
		return fmt.Sprintf("pure=%d fallback_parse_failed", purePayload.Count)
	}

	top := fallbackPayload.Slots
	if len(top) > 3 {
		top = top[:3]
	}
	slotText := make([]string, 0, len(top))
	for _, item := range top {
		slotText = append(slotText, fmt.Sprintf("W%dD%d %d-%d", item.Week, item.DayOfWeek, item.SectionFrom, item.SectionTo))
	}
	if len(slotText) == 0 {
		slotText = append(slotText, "无")
	}
	return fmt.Sprintf("target_weeks=%v target_days=%v pure=%d embed_candidate=%d top=%s", targetWeeks, targetDays, purePayload.Count, fallbackPayload.EmbeddedCount, strings.Join(slotText, ","))
}

func decodeSlotHintPayload(raw string) (slotHintPayload, bool) {
	var payload slotHintPayload
	if err := json.Unmarshal([]byte(raw), &payload); err != nil {
		return slotHintPayload{}, false
	}
	return payload, true
}

func extractWeekFilters(text string) []int {
	patterns := []string{
		`第\s*(\d{1,2})\s*周`,
		`W\s*(\d{1,2})`,
		`(\d{1,2})\s*周`,
	}
	out := make([]int, 0, 8)
	for _, p := range patterns {
		re := regexp.MustCompile(p)
		for _, m := range re.FindAllStringSubmatch(text, -1) {
			if len(m) < 2 {
				continue
			}
			v, err := strconv.Atoi(strings.TrimSpace(m[1]))
			if err != nil || v <= 0 {
				continue
			}
			out = append(out, v)
		}
	}
	return uniquePositiveInts(out)
}

func extractExcludeSections(text string) []int {
	normalized := strings.ReplaceAll(strings.ToLower(text), " ", "")
	if containsAny(normalized, []string{
		"不要早八", "避开早八", "不想早八", "别在早八",
		"不要1-2", "避开1-2", "不要第一节", "不要一二节",
	}) {
		return []int{1, 2}
	}
	return nil
}

func extractTargetDaysFromMessage(text string) []int {
	verbIdx := -1
	for _, key := range []string{"移到", "挪到", "改到", "安排到", "放到", "分散到", "调整到", "收敛到", "迁移到"} {
		if idx := strings.Index(text, key); idx >= 0 {
			verbIdx = idx + len(key)
			break
		}
	}
	if verbIdx < 0 || verbIdx >= len(text) {
		return nil
	}
	targetPart := strings.TrimSpace(text[verbIdx:])
	return extractDayExpr(targetPart)
}

func extractSourceDaysFromMessage(text string) []int {
	source := make([]int, 0, 4)
	re := regexp.MustCompile(`从\s*(周[一二三四五六日天]|星期[一二三四五六日天])`)
	for _, m := range re.FindAllStringSubmatch(text, -1) {
		if len(m) < 2 {
			continue
		}
		if day := dayTokenToInt(m[1]); day > 0 {
			source = append(source, day)
		}
	}
	re2 := regexp.MustCompile(`把\s*(周[一二三四五六日天]|星期[一二三四五六日天])`)
	for _, m := range re2.FindAllStringSubmatch(text, -1) {
		if len(m) < 2 {
			continue
		}
		if day := dayTokenToInt(m[1]); day > 0 {
			source = append(source, day)
		}
	}
	return uniquePositiveInts(source)
}

func detectOverloadedDay(text string) int {
	re := regexp.MustCompile(`(周[一二三四五六日天]|星期[一二三四五六日天]).{0,8}(太多|过多|太满|过满|拥挤|太挤|塞满)`)
	m := re.FindStringSubmatch(text)
	if len(m) < 2 {
		return 0
	}
	return dayTokenToInt(m[1])
}

func extractRangeDays(text string) []int {
	re := regexp.MustCompile(`(周[一二三四五六日天]|星期[一二三四五六日天])\s*[到至\-]\s*(周[一二三四五六日天]|星期[一二三四五六日天])`)
	m := re.FindStringSubmatch(text)
	if len(m) < 3 {
		return nil
	}
	start := dayTokenToInt(m[1])
	end := dayTokenToInt(m[2])
	if start <= 0 || end <= 0 {
		return nil
	}
	if start > end {
		start, end = end, start
	}
	out := make([]int, 0, end-start+1)
	for day := start; day <= end; day++ {
		out = append(out, day)
	}
	return out
}

func extractDays(text string) []int {
	re := regexp.MustCompile(`周[一二三四五六日天]|星期[一二三四五六日天]`)
	matches := re.FindAllString(text, -1)
	days := make([]int, 0, len(matches))
	for _, token := range matches {
		if day := dayTokenToInt(token); day > 0 {
			days = append(days, day)
		}
	}
	return uniquePositiveInts(days)
}

func dayTokenToInt(token string) int {
	switch strings.TrimSpace(token) {
	case "周一", "星期一":
		return 1
	case "周二", "星期二":
		return 2
	case "周三", "星期三":
		return 3
	case "周四", "星期四":
		return 4
	case "周五", "星期五":
		return 5
	case "周六", "星期六":
		return 6
	case "周日", "周天", "星期日", "星期天":
		return 7
	default:
		return 0
	}
}

func containsAny(text string, keys []string) bool {
	for _, k := range keys {
		if strings.Contains(text, k) {
			return true
		}
	}
	return false
}

func isSameDays(days []int, target []int) bool {
	if len(days) != len(target) {
		return false
	}
	for i := range days {
		if days[i] != target[i] {
			return false
		}
	}
	return true
}
