package schedule

import (
	"fmt"
	"sort"
	"strings"
)

const (
	healthProblemNone            = ""
	healthProblemHeavyAdjacent   = "heavy_adjacent"
	analyzeHealthMinBenefitScore = 1

	healthCandidateEffectImprove        = "improve"
	healthCandidateEffectPartialImprove = "partial_improve"
	healthCandidateEffectShift          = "shift"
	healthCandidateEffectNoGain         = "no_gain"
	healthCandidateEffectRegress        = "regress"
	healthCandidateEffectClose          = "close"
)

type analyzeHealthCandidate struct {
	CandidateID string                      `json:"candidate_id"`
	Tool        string                      `json:"tool,omitempty"`
	Arguments   map[string]any              `json:"arguments,omitempty"`
	Summary     string                      `json:"summary"`
	Effect      string                      `json:"effect"`
	After       analyzeHealthCandidateAfter `json:"after"`
}

type analyzeHealthCandidateAfter struct {
	CanClose             bool    `json:"can_close"`
	PrimaryProblem       string  `json:"primary_problem"`
	RecommendedOperation string  `json:"recommended_operation,omitempty"`
	HeavyAdjacentDays    int     `json:"heavy_adjacent_days"`
	MaxSwitchCount       int     `json:"max_switch_count"`
	SameTypeRatio        float64 `json:"same_type_transition_ratio"`
}

type analyzeHealthSnapshot struct {
	Subjects    []analyzeSubjectItem
	Days        []analyzeContextDay
	Rhythm      analyzeRhythmMetrics
	Tightness   analyzeTightnessMetrics
	Profile     analyzeSemanticProfileMetrics
	Feasibility analyzeFeasibility
}

type analyzeHealthProblem struct {
	Kind       string
	DayIndex   int
	Summary    string
	Scope      *analyzeProblemScope
	Pair       *analyzeHeavyAdjacentPair
	PreferSwap bool
}

type analyzeHealthDecisionBase struct {
	ShouldContinueOptimize bool
	PrimaryProblem         string
	ProblemScope           *analyzeProblemScope
	IsForcedImperfection   bool
	RecommendedOperation   string
}

type analyzeHealthCandidateRanked struct {
	Candidate analyzeHealthCandidate
	Score     int
}

type analyzeHealthProblemScanResult struct {
	Problem         analyzeHealthProblem
	Candidates      []analyzeHealthCandidate
	BestScore       int
	BestCandidateID string
	PriorityScore   int
}

type analyzeHealthProblemScoreOnlyResult struct {
	Problem       analyzeHealthProblem
	BestScore     int
	BestOperation string
	PriorityScore int
}

type analyzeHeavyAdjacentPair struct {
	DayIndex int
	Left     ScheduleTask
	Right    ScheduleTask
}

// buildAnalyzeHealthSnapshotFromState 统一计算 analyze_health 需要复用的核心快照。
//
// 职责边界：
// 1. 这里只负责“从 state 派生节奏/紧度/画像/可行性”；
// 2. 不直接生成 issues / 候选动作，避免分析层和决策层耦死；
// 3. 候选模拟复诊时也复用这条路径，保证前后口径一致。
func buildAnalyzeHealthSnapshotFromState(state *ScheduleState) analyzeHealthSnapshot {
	subjects := computeAnalyzeSubjectMetricsV2(state, true, "")
	days := computeAnalyzeContextDaysV2(state)
	rhythmOverview := computeAnalyzeRhythmOverviewV2(subjects, days)
	rhythm := analyzeRhythmMetrics{
		Overview: rhythmOverview,
		Subjects: subjects,
		Days:     days,
	}
	return analyzeHealthSnapshot{
		Subjects:    subjects,
		Days:        days,
		Rhythm:      rhythm,
		Tightness:   computeAnalyzeTightnessMetrics(state, rhythm),
		Profile:     computeSemanticProfileMetrics(subjects),
		Feasibility: computeHealthFeasibilityV2(state),
	}
}

// buildAnalyzeHealthDecisionBase 生成“不带候选动作”的基础裁决结果。
//
// 说明：
// 1. 这层只回答“当前是否还有值得继续修的主问题”，不负责枚举具体 move/swap 参数；
// 2. 主分析与候选模拟都会复用这层，避免出现“主诊断口径”和“候选复诊口径”不一致；
// 3. 当前 P1 只把 heavy_adjacent 作为可进入候选枚举的问题，其它问题仅保留指标展示，不驱动继续优化。
func buildAnalyzeHealthDecisionBase(
	state *ScheduleState,
	snapshot analyzeHealthSnapshot,
) analyzeHealthDecisionBase {
	decision := analyzeHealthDecisionBase{
		PrimaryProblem:       "当前没有发现值得继续处理的局部认知问题",
		RecommendedOperation: "close",
	}

	if !snapshot.Feasibility.IsFeasible {
		decision.PrimaryProblem = fmt.Sprintf("当前时间窗容量不足，还缺 %d 节可用容量", snapshot.Feasibility.CapacityGap)
		decision.IsForcedImperfection = true
		decision.RecommendedOperation = "ask_user"
		return decision
	}
	if snapshot.Profile.MissingCompleteProfileCount > 0 {
		decision.PrimaryProblem = fmt.Sprintf("仍有 %d 门任务类缺少完整语义画像，先补齐再谈主动优化", snapshot.Profile.MissingCompleteProfileCount)
		decision.RecommendedOperation = "ask_user"
		return decision
	}

	problem, ok := pickPrimaryHealthProblem(state, snapshot)
	if !ok {
		return decision
	}
	decision.PrimaryProblem = problem.Summary
	decision.ProblemScope = problem.Scope

	// P1 只支持“高认知相邻”进入候选求解；其他问题先只做观测，不驱动自动挪动。
	if problem.Kind != healthProblemHeavyAdjacent {
		return decision
	}

	if snapshot.Tightness.TightnessLevel == "locked" {
		decision.IsForcedImperfection = true
		return decision
	}

	decision.RecommendedOperation = "swap"
	decision.ShouldContinueOptimize = true
	return decision
}

// buildAnalyzeHealthFinalDecisionBrief 基于当前 active 扫描器语义，生成候选 after 视图所需的最小判定。
//
// 职责边界：
// 1. 这里只产出 should_continue_optimize / primary_problem / recommended_operation / is_forced_imperfection。
// 2. 这里通过 score-only 扫描层复用“全局扫描 + 最小收益阈值”的收口规则，但不构造 candidates / summary / after。
// 3. score-only 扫描只能读取 state/snapshot 并计算最佳收益，绝不能回流到 simulate 后再调用 brief，避免递归闭环。
func buildAnalyzeHealthFinalDecisionBrief(
	state *ScheduleState,
	snapshot analyzeHealthSnapshot,
) analyzeHealthDecisionBase {
	base := buildAnalyzeHealthDecisionBase(state, snapshot)
	decision := analyzeHealthDecisionBase{
		ShouldContinueOptimize: base.ShouldContinueOptimize,
		PrimaryProblem:         base.PrimaryProblem,
		ProblemScope:           base.ProblemScope,
		IsForcedImperfection:   base.IsForcedImperfection,
		RecommendedOperation:   base.RecommendedOperation,
	}

	if !shouldEnterHealthCandidateLoop(base) {
		decision.ShouldContinueOptimize = false
		return decision
	}

	bestScoreOnly, ok := findBestHealthProblemScoreOnly(state, snapshot)
	if !ok || bestScoreOnly.Problem.Kind != healthProblemHeavyAdjacent || bestScoreOnly.Problem.Pair == nil {
		decision.ShouldContinueOptimize = false
		decision.PrimaryProblem = "当前没有发现值得继续处理的局部认知问题"
		decision.ProblemScope = nil
		decision.RecommendedOperation = "close"
		if snapshot.Tightness.TightnessLevel == "locked" || snapshot.Tightness.TightnessLevel == "tight" {
			decision.IsForcedImperfection = true
		}
		return decision
	}

	decision.ShouldContinueOptimize = true
	decision.PrimaryProblem = bestScoreOnly.Problem.Summary
	decision.ProblemScope = bestScoreOnly.Problem.Scope
	decision.RecommendedOperation = strings.TrimSpace(bestScoreOnly.BestOperation)
	return decision
}

// pickPrimaryHealthProblem 选择当前最值得处理的局部问题。
func pickPrimaryHealthProblem(state *ScheduleState, snapshot analyzeHealthSnapshot) (analyzeHealthProblem, bool) {
	best := analyzeHealthProblem{}
	bestScore := -1
	bestBalanceAlignment := -1
	for _, day := range snapshot.Rhythm.Days {
		if !day.HeavyAdjacent || shouldTreatHeavyAdjacencyAsAcceptable(snapshot.Rhythm, day) {
			continue
		}
		pair, ok := findHeavyAdjacentPairOnDay(state, day.DayIndex)
		if !ok {
			continue
		}
		score := scoreHeavyAdjacentProblem(day, pair)
		balanceAlignment := scoreHeavyAdjacentBalanceAlignment(snapshot.Rhythm.Overview.BlockBalance, day)
		if score < bestScore {
			continue
		}
		if score == bestScore && balanceAlignment <= bestBalanceAlignment {
			continue
		}
		bestScore = score
		bestBalanceAlignment = balanceAlignment
		best = analyzeHealthProblem{
			Kind:     healthProblemHeavyAdjacent,
			DayIndex: day.DayIndex,
			Summary:  fmt.Sprintf("第 %d 天存在高认知强度任务相邻，学起来会发紧", day.DayIndex),
			Scope: &analyzeProblemScope{
				DayRange: []int{day.DayIndex},
				TaskIDs:  []int{pair.Left.StateID, pair.Right.StateID},
			},
			Pair:       pair,
			PreferSwap: true,
		}
	}
	if best.Kind == "" {
		return analyzeHealthProblem{}, false
	}
	return best, true
}

// collectRepairableHeavyAdjacentProblems 收集当前所有仍值得扫描的 heavy_adjacent 问题天。
//
// 职责边界：
// 1. 这里只负责把可扫描的问题天列出来，不负责最终选哪一天。
// 2. 仍然只收集 heavy_adjacent，继续复用当前“可接受相邻天”放宽语义。
// 3. 若当天找不到对应相邻任务对，则直接跳过，避免把不完整问题送入候选试算。
func collectRepairableHeavyAdjacentProblems(
	state *ScheduleState,
	snapshot analyzeHealthSnapshot,
) []analyzeHealthProblem {
	if state == nil {
		return nil
	}

	out := make([]analyzeHealthProblem, 0, len(snapshot.Rhythm.Days))
	for _, day := range snapshot.Rhythm.Days {
		if !day.HeavyAdjacent || shouldTreatHeavyAdjacencyAsAcceptable(snapshot.Rhythm, day) {
			continue
		}
		pair, ok := findHeavyAdjacentPairOnDay(state, day.DayIndex)
		if !ok {
			continue
		}
		out = append(out, analyzeHealthProblem{
			Kind:     healthProblemHeavyAdjacent,
			DayIndex: day.DayIndex,
			Summary:  fmt.Sprintf("第 %d 天存在高认知强度任务相邻，学起来会发紧", day.DayIndex),
			Scope: &analyzeProblemScope{
				DayRange: []int{day.DayIndex},
				TaskIDs:  []int{pair.Left.StateID, pair.Right.StateID},
			},
			Pair:       pair,
			PreferSwap: true,
		})
	}
	return out
}

// scoreHeavyAdjacentBalanceAlignment 在 heavy_adjacent 同类问题里提供极轻的节奏倾向参考。
//
// 职责边界：
// 1. 这里只在 heavy_adjacent 候选内部做同分细排，不改变“只处理 heavy_adjacent”的主链路边界。
// 2. 当整体更偏碎时，优先选择同样更碎的 heavy_adjacent 天；当整体更偏压缩时，优先选择块更长的 heavy_adjacent 天。
// 3. 若整体 block_balance 接近 0，则返回 0，保持原有排序语义不变。
func scoreHeavyAdjacentBalanceAlignment(blockBalance int, day analyzeContextDay) int {
	switch {
	case blockBalance > 0:
		return int(day.Fragmentation * 100)
	case blockBalance < 0:
		return day.MaxBlock*10 - day.SwitchCount
	default:
		return 0
	}
}

func scoreHeavyAdjacentProblem(day analyzeContextDay, pair *analyzeHeavyAdjacentPair) int {
	score := 300 + day.SwitchCount*8 + int(day.Fragmentation*20)
	if pair != nil && pair.Left.TaskClassID > 0 && pair.Right.TaskClassID > 0 && pair.Left.TaskClassID != pair.Right.TaskClassID {
		score += 15
	}
	return score
}

func scoreHeavyAdjacentProblemDay(rhythm analyzeRhythmMetrics, dayIndex int) int {
	for _, day := range rhythm.Days {
		if day.DayIndex != dayIndex {
			continue
		}
		return scoreHeavyAdjacentProblem(day, nil)
	}
	return 0
}

// findBestHealthProblemScoreOnly 扫描当前所有 heavy_adjacent 问题天，并只返回“是否存在过阈值候选”所需的最小信息。
//
// 步骤化说明：
// 1. 这里只做 score-only 扫描：复用合法 move/swap 枚举与收益计算，但不构造候选文案、after 视图或 summary。
// 2. 返回值只关心最值得修的问题天、最佳收益和最佳操作类型，供 after brief / 其他只读裁决器复用。
// 3. 一旦最佳收益未过阈值，就直接返回 false，让上层按正式扫描器语义收口。
func findBestHealthProblemScoreOnly(
	state *ScheduleState,
	snapshot analyzeHealthSnapshot,
) (analyzeHealthProblemScoreOnlyResult, bool) {
	problems := collectRepairableHeavyAdjacentProblems(state, snapshot)
	if len(problems) == 0 {
		return analyzeHealthProblemScoreOnlyResult{}, false
	}

	results := make([]analyzeHealthProblemScoreOnlyResult, 0, len(problems))
	for _, problem := range problems {
		result, ok := buildHealthProblemScoreOnly(state, snapshot, problem)
		if !ok {
			continue
		}
		results = append(results, result)
	}
	return selectBestHealthProblemScoreOnly(results)
}

// buildHealthProblemScoreOnly 只计算单个问题天的最佳候选收益，不构造任何候选展示字段。
//
// 职责边界：
// 1. 这里仍然只允许 move / swap，并复用现有合法性过滤与收益口径。
// 2. 这里不产出 candidate.after / candidate.summary，也不调用 brief helper，避免递归。
// 3. 这条路径存在的唯一目的，是回答“这个问题天还有没有过阈值收益的候选”。
func buildHealthProblemScoreOnly(
	state *ScheduleState,
	snapshot analyzeHealthSnapshot,
	problem analyzeHealthProblem,
) (analyzeHealthProblemScoreOnlyResult, bool) {
	if state == nil || problem.Kind != healthProblemHeavyAdjacent || problem.Pair == nil {
		return analyzeHealthProblemScoreOnlyResult{}, false
	}

	bestScore := 0
	bestOperation := ""
	found := false

	pool := collectSuggestedTaskItems(state)
	movable := extractSuggestedProblemTasks(problem.Pair)
	if len(movable) == 0 {
		return analyzeHealthProblemScoreOnlyResult{}, false
	}

	checked := 0
	for _, anchor := range movable {
		for _, other := range pool {
			if anchor.StateID == other.StateID {
				continue
			}
			if other.TaskClassID <= 0 || anchor.TaskClassID <= 0 || other.TaskClassID == anchor.TaskClassID {
				continue
			}
			if taskDuration(anchor) != taskDuration(other) {
				continue
			}
			checked++
			score, ok := simulateHealthSwapScoreOnly(state, snapshot, problem, anchor, other)
			if ok {
				bestScore, bestOperation, found = updateBestHealthScoreOnly(score, "swap", bestScore, bestOperation, found)
			}
			if checked >= 48 {
				goto movePhase
			}
		}
	}

movePhase:
	for _, task := range movable {
		placements := enumerateLegalMovePlacements(state, task, 24)
		for _, target := range placements {
			score, ok := simulateHealthMoveScoreOnly(state, snapshot, problem, task, target)
			if ok {
				bestScore, bestOperation, found = updateBestHealthScoreOnly(score, "move", bestScore, bestOperation, found)
			}
		}
	}

	if !found {
		return analyzeHealthProblemScoreOnlyResult{}, false
	}
	return analyzeHealthProblemScoreOnlyResult{
		Problem:       problem,
		BestScore:     bestScore,
		BestOperation: bestOperation,
		PriorityScore: scoreHeavyAdjacentProblemDay(snapshot.Rhythm, problem.DayIndex),
	}, true
}

func simulateHealthSwapScoreOnly(
	state *ScheduleState,
	baseline analyzeHealthSnapshot,
	problem analyzeHealthProblem,
	anchor ScheduleTask,
	other ScheduleTask,
) (int, bool) {
	clone := state.Clone()
	if clone == nil {
		return 0, false
	}
	result := Swap(clone, anchor.StateID, other.StateID)
	if strings.Contains(result, "失败") || strings.Contains(result, "澶辫触") {
		return 0, false
	}

	after := buildAnalyzeHealthSnapshotFromState(clone)
	_, score, ok := evaluateHealthCandidateScoreOnly(baseline, after, problem, "swap", 4)
	if !ok {
		return 0, false
	}
	return applyHealthCandidateRankingBias("swap", score), true
}

func simulateHealthMoveScoreOnly(
	state *ScheduleState,
	baseline analyzeHealthSnapshot,
	problem analyzeHealthProblem,
	task ScheduleTask,
	target TaskSlot,
) (int, bool) {
	clone := state.Clone()
	if clone == nil {
		return 0, false
	}
	result := Move(clone, task.StateID, target.Day, target.SlotStart)
	if strings.Contains(result, "失败") || strings.Contains(result, "澶辫触") {
		return 0, false
	}

	after := buildAnalyzeHealthSnapshotFromState(clone)
	moveCost := absInt(target.Day-task.Slots[0].Day)*2 + absInt(target.SlotStart-task.Slots[0].SlotStart)
	_, score, ok := evaluateHealthCandidateScoreOnly(baseline, after, problem, "move", moveCost)
	if !ok {
		return 0, false
	}
	return applyHealthCandidateRankingBias("move", score), true
}

func selectBestHealthProblemScoreOnly(
	results []analyzeHealthProblemScoreOnlyResult,
) (analyzeHealthProblemScoreOnlyResult, bool) {
	if len(results) == 0 {
		return analyzeHealthProblemScoreOnlyResult{}, false
	}

	best := analyzeHealthProblemScoreOnlyResult{}
	bestSet := false
	for _, item := range results {
		if !meetsHealthCandidateBenefitThreshold(item.BestScore) {
			continue
		}
		if !bestSet || shouldPreferHealthScoreCandidate(
			item.BestScore,
			item.PriorityScore,
			item.Problem.DayIndex,
			best.BestScore,
			best.PriorityScore,
			best.Problem.DayIndex,
		) {
			best = item
			bestSet = true
		}
	}
	if !bestSet {
		return analyzeHealthProblemScoreOnlyResult{}, false
	}
	return best, true
}

func updateBestHealthScoreOnly(
	score int,
	operation string,
	bestScore int,
	bestOperation string,
	found bool,
) (int, string, bool) {
	if !found {
		return score, operation, true
	}
	if score > bestScore {
		return score, operation, true
	}
	if score == bestScore && strings.TrimSpace(operation) < strings.TrimSpace(bestOperation) {
		return score, operation, true
	}
	return bestScore, bestOperation, true
}

// applyHealthCandidateRankingBias 统一补齐候选最终排序分里的轻量工具偏置。
//
// 职责边界：
// 1. 这里只负责把“基础收益分”映射成“最终排序分”，避免正式扫描器和 score-only 扫描口径漂移。
// 2. 当前只有 swap 额外加分；move 保持原分不变。
// 3. 该函数不参与候选合法性判断，只参与“谁更值得优先处理”的排序。
func applyHealthCandidateRankingBias(operation string, baseScore int) int {
	score := baseScore
	if strings.EqualFold(strings.TrimSpace(operation), "swap") {
		score += 12
	}
	return score
}

// selectBestHealthProblemScanResult 从所有单天扫描结果中挑出本轮最值得修的一天。
//
// 职责边界：
// 1. 这里只比较“各天最佳候选”的收益，不拼接多天候选，也不改变候选内容。
// 2. 先按 benefit_score 选天；若收益相同，再退回到原有问题优先级与天序做稳定 tie-break。
// 3. 若第一名收益不足最小阈值，则返回 false，交给上层直接收口。
func selectBestHealthProblemScanResult(
	results []analyzeHealthProblemScanResult,
) (analyzeHealthProblemScanResult, bool) {
	if len(results) == 0 {
		return analyzeHealthProblemScanResult{}, false
	}

	best := analyzeHealthProblemScanResult{}
	bestSet := false
	for _, item := range results {
		if !meetsHealthCandidateBenefitThreshold(item.BestScore) {
			continue
		}
		if !bestSet || shouldPreferHealthScoreCandidate(
			item.BestScore,
			item.PriorityScore,
			item.Problem.DayIndex,
			best.BestScore,
			best.PriorityScore,
			best.Problem.DayIndex,
		) {
			best = item
			bestSet = true
		}
	}
	if !bestSet {
		return analyzeHealthProblemScanResult{}, false
	}
	return best, true
}

func meetsHealthCandidateBenefitThreshold(score int) bool {
	return score >= analyzeHealthMinBenefitScore
}

func shouldPreferHealthScoreCandidate(
	leftScore int,
	leftPriority int,
	leftDay int,
	rightScore int,
	rightPriority int,
	rightDay int,
) bool {
	switch {
	case leftScore > rightScore:
		return true
	case leftScore < rightScore:
		return false
	case leftPriority > rightPriority:
		return true
	case leftPriority < rightPriority:
		return false
	default:
		return leftDay < rightDay
	}
}

// buildHealthCandidatesForProblem 为主问题生成“可直接抄去调用写工具”的合法候选。
//
// 设计说明：
// 1. 候选空间完全由现有写工具的合法性约束定义，尤其复用“前驱/后继顺序边界”；
// 2. 后端会先在内存里模拟，再做一次 analyze_health 同口径复诊；
// 3. 只保留真正减轻问题的 top5，过滤掉平移、无增益和恶化候选。
func buildHealthCandidatesForProblem(
	state *ScheduleState,
	snapshot analyzeHealthSnapshot,
	problem analyzeHealthProblem,
) []analyzeHealthCandidate {
	scan, ok := buildHealthProblemScanResult(state, snapshot, problem)
	if !ok {
		return nil
	}
	return scan.Candidates
}

// buildHealthProblemScanResult 生成单个 heavy_adjacent 问题天的候选扫描结果。
//
// 步骤化说明：
// 1. 先复用现有 move / swap 枚举与复诊过滤，保证只比较“原本就合法且确实变好”的候选。
// 2. 再按现有 score 规则排序并截取该天 top5 候选，保持返回给 LLM 的候选语义不变。
// 3. 额外保留该天最佳候选的 score，供上层做“先选天，再返回该天候选集”的全局比较。
func buildHealthProblemScanResult(
	state *ScheduleState,
	snapshot analyzeHealthSnapshot,
	problem analyzeHealthProblem,
) (analyzeHealthProblemScanResult, bool) {
	if state == nil || problem.Kind != healthProblemHeavyAdjacent || problem.Pair == nil {
		return analyzeHealthProblemScanResult{}, false
	}

	ranked := make([]analyzeHealthCandidateRanked, 0, 16)
	ranked = append(ranked, enumerateSwapCandidates(state, snapshot, problem)...)
	ranked = append(ranked, enumerateMoveCandidates(state, snapshot, problem)...)
	if len(ranked) == 0 {
		return analyzeHealthProblemScanResult{}, false
	}

	sort.SliceStable(ranked, func(i, j int) bool {
		if ranked[i].Score != ranked[j].Score {
			return ranked[i].Score > ranked[j].Score
		}
		leftTool := strings.TrimSpace(ranked[i].Candidate.Tool)
		rightTool := strings.TrimSpace(ranked[j].Candidate.Tool)
		if leftTool != rightTool {
			return leftTool < rightTool
		}
		return ranked[i].Candidate.CandidateID < ranked[j].Candidate.CandidateID
	})

	out := make([]analyzeHealthCandidate, 0, minInt(len(ranked), 5))
	seen := make(map[string]struct{}, len(ranked))
	for _, item := range ranked {
		key := item.Candidate.Tool + "::" + marshalCandidateArgumentsKey(item.Candidate.Arguments)
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}
		out = append(out, item.Candidate)
		if len(out) >= 5 {
			break
		}
	}
	if len(out) == 0 {
		return analyzeHealthProblemScanResult{}, false
	}

	return analyzeHealthProblemScanResult{
		Problem:         problem,
		Candidates:      out,
		BestScore:       ranked[0].Score,
		BestCandidateID: ranked[0].Candidate.CandidateID,
		PriorityScore:   scoreHeavyAdjacentProblemDay(snapshot.Rhythm, problem.DayIndex),
	}, true
}

func enumerateSwapCandidates(
	state *ScheduleState,
	snapshot analyzeHealthSnapshot,
	problem analyzeHealthProblem,
) []analyzeHealthCandidateRanked {
	pair := problem.Pair
	if pair == nil {
		return nil
	}
	pool := collectSuggestedTaskItems(state)
	movable := extractSuggestedProblemTasks(pair)
	if len(movable) == 0 || len(pool) == 0 {
		return nil
	}

	out := make([]analyzeHealthCandidateRanked, 0, 12)
	checked := 0
	for _, anchor := range movable {
		for _, other := range pool {
			if anchor.StateID == other.StateID {
				continue
			}
			if other.TaskClassID <= 0 || anchor.TaskClassID <= 0 || other.TaskClassID == anchor.TaskClassID {
				continue
			}
			if taskDuration(anchor) != taskDuration(other) {
				continue
			}
			checked++
			candidate, score, ok := simulateHealthSwapCandidate(state, snapshot, problem, anchor, other)
			if ok {
				out = append(out, analyzeHealthCandidateRanked{
					Candidate: candidate,
					Score:     applyHealthCandidateRankingBias("swap", score),
				})
			}
			if checked >= 48 {
				return out
			}
		}
	}
	return out
}

func enumerateMoveCandidates(
	state *ScheduleState,
	snapshot analyzeHealthSnapshot,
	problem analyzeHealthProblem,
) []analyzeHealthCandidateRanked {
	pair := problem.Pair
	if pair == nil {
		return nil
	}
	movable := extractSuggestedProblemTasks(pair)
	if len(movable) == 0 {
		return nil
	}

	out := make([]analyzeHealthCandidateRanked, 0, 20)
	for _, task := range movable {
		placements := enumerateLegalMovePlacements(state, task, 24)
		for _, target := range placements {
			candidate, score, ok := simulateHealthMoveCandidate(state, snapshot, problem, task, target)
			if !ok {
				continue
			}
			out = append(out, analyzeHealthCandidateRanked{Candidate: candidate, Score: score})
		}
	}
	return out
}

func simulateHealthSwapCandidate(
	state *ScheduleState,
	baseline analyzeHealthSnapshot,
	problem analyzeHealthProblem,
	anchor ScheduleTask,
	other ScheduleTask,
) (analyzeHealthCandidate, int, bool) {
	clone := state.Clone()
	if clone == nil {
		return analyzeHealthCandidate{}, 0, false
	}
	result := Swap(clone, anchor.StateID, other.StateID)
	if strings.Contains(result, "失败") {
		return analyzeHealthCandidate{}, 0, false
	}

	after := buildAnalyzeHealthSnapshotFromState(clone)
	effect, score, afterDecision, ok := evaluateHealthCandidateOutcome(baseline, after, clone, problem, "swap", 4)
	if !ok {
		return analyzeHealthCandidate{}, 0, false
	}

	candidate := analyzeHealthCandidate{
		CandidateID: fmt.Sprintf("swap_%d_%d", anchor.StateID, other.StateID),
		Tool:        "swap",
		Arguments: map[string]any{
			"task_a": anchor.StateID,
			"task_b": other.StateID,
		},
		Summary: buildHealthCandidateSummary(
			fmt.Sprintf("交换 [%d]%s 与 [%d]%s", anchor.StateID, anchor.Name, other.StateID, other.Name),
			baseline,
			after,
			afterDecision,
		),
		Effect: effect,
		After:  buildHealthCandidateAfter(after, afterDecision),
	}
	return candidate, score, true
}

func simulateHealthMoveCandidate(
	state *ScheduleState,
	baseline analyzeHealthSnapshot,
	problem analyzeHealthProblem,
	task ScheduleTask,
	target TaskSlot,
) (analyzeHealthCandidate, int, bool) {
	clone := state.Clone()
	if clone == nil {
		return analyzeHealthCandidate{}, 0, false
	}
	result := Move(clone, task.StateID, target.Day, target.SlotStart)
	if strings.Contains(result, "失败") {
		return analyzeHealthCandidate{}, 0, false
	}

	after := buildAnalyzeHealthSnapshotFromState(clone)
	moveCost := absInt(target.Day-task.Slots[0].Day)*2 + absInt(target.SlotStart-task.Slots[0].SlotStart)
	effect, score, afterDecision, ok := evaluateHealthCandidateOutcome(baseline, after, clone, problem, "move", moveCost)
	if !ok {
		return analyzeHealthCandidate{}, 0, false
	}

	candidate := analyzeHealthCandidate{
		CandidateID: fmt.Sprintf("move_%d_%d_%d", task.StateID, target.Day, target.SlotStart),
		Tool:        "move",
		Arguments: map[string]any{
			"task_id":        task.StateID,
			"new_day":        target.Day,
			"new_slot_start": target.SlotStart,
		},
		Summary: buildHealthCandidateSummary(
			fmt.Sprintf("移动 [%d]%s 到第 %d 天第 %d 节", task.StateID, task.Name, target.Day, target.SlotStart),
			baseline,
			after,
			afterDecision,
		),
		Effect: effect,
		After:  buildHealthCandidateAfter(after, afterDecision),
	}
	return candidate, score, true
}

func enumerateLegalMovePlacements(state *ScheduleState, task ScheduleTask, limit int) []TaskSlot {
	if state == nil || len(task.Slots) == 0 || limit <= 0 {
		return nil
	}
	duration := taskDuration(task)
	if duration <= 0 {
		return nil
	}

	out := make([]TaskSlot, 0, limit)
	for day := 1; day <= state.Window.TotalDays; day++ {
		for _, gap := range findFreeRangesOnDay(state, day) {
			maxStart := gap.slotEnd - duration + 1
			for slotStart := gap.slotStart; slotStart <= maxStart; slotStart++ {
				target := TaskSlot{Day: day, SlotStart: slotStart, SlotEnd: slotStart + duration - 1}
				if sameTaskSlots(task.Slots, []TaskSlot{target}) {
					continue
				}
				if err := validateLocalOrderForSinglePlacement(state, task.StateID, []TaskSlot{target}); err != nil {
					continue
				}
				if conflict := findConflict(state, target.Day, target.SlotStart, target.SlotEnd, task.StateID); conflict != nil {
					continue
				}
				out = append(out, target)
				if len(out) >= limit {
					return out
				}
			}
		}
	}
	return out
}

func evaluateHealthCandidateOutcome(
	baseline analyzeHealthSnapshot,
	after analyzeHealthSnapshot,
	afterState *ScheduleState,
	problem analyzeHealthProblem,
	operation string,
	moveCost int,
) (string, int, analyzeHealthDecisionBase, bool) {
	afterDecision := buildAnalyzeHealthFinalDecisionBrief(afterState, after)
	effect, score, ok := evaluateHealthCandidateScoreOnly(
		baseline,
		after,
		problem,
		operation,
		moveCost,
	)
	return effect, score, afterDecision, ok
}

// evaluateHealthCandidateScoreOnly 只计算候选的收益与是否保留，不构造任何 after 视图。
//
// 职责边界：
// 1. 这里复用当前 active 候选过滤口径：只保留真正改善的候选，并返回排序所需 score。
// 2. 这里绝不依赖 afterDecision / brief helper，供 score-only 扫描层安全复用。
// 3. 这样正式扫描器与 after brief 都能共享同一套收益定义，同时避免递归闭环。
func evaluateHealthCandidateScoreOnly(
	baseline analyzeHealthSnapshot,
	after analyzeHealthSnapshot,
	problem analyzeHealthProblem,
	operation string,
	moveCost int,
) (string, int, bool) {
	baselineRepairable := countRepairableHeavyAdjacentDays(baseline.Rhythm)
	afterRepairable := countRepairableHeavyAdjacentDays(after.Rhythm)
	afterProblemStill := isRepairableHeavyAdjacentDay(after.Rhythm, problem.DayIndex)

	effect := healthCandidateEffectNoGain
	switch {
	case afterRepairable < baselineRepairable:
		if afterRepairable > 0 {
			effect = healthCandidateEffectPartialImprove
		} else {
			effect = healthCandidateEffectImprove
		}
	case afterRepairable > baselineRepairable:
		effect = healthCandidateEffectRegress
	case afterProblemStill:
		effect = healthCandidateEffectNoGain
	default:
		effect = healthCandidateEffectShift
	}
	if effect != healthCandidateEffectImprove && effect != healthCandidateEffectPartialImprove {
		return effect, 0, false
	}

	score := (baselineRepairable-afterRepairable)*1000 +
		(baseline.Rhythm.Overview.MaxSwitchCount-after.Rhythm.Overview.MaxSwitchCount)*20 +
		int((after.Rhythm.Overview.SameTypeTransitionRatio-baseline.Rhythm.Overview.SameTypeTransitionRatio)*100) -
		moveCost
	if strings.EqualFold(strings.TrimSpace(operation), "swap") {
		score += 10
	}
	return effect, score, true
}

func buildHealthCandidateSummary(
	actionText string,
	baseline analyzeHealthSnapshot,
	after analyzeHealthSnapshot,
	afterDecision analyzeHealthDecisionBase,
) string {
	return fmt.Sprintf(
		"%s；可修复的高认知相邻天数 %d -> %d，max_switch_count %d -> %d，same_type_ratio %.2f -> %.2f；复诊后主问题：%s",
		actionText,
		countRepairableHeavyAdjacentDays(baseline.Rhythm),
		countRepairableHeavyAdjacentDays(after.Rhythm),
		baseline.Rhythm.Overview.MaxSwitchCount,
		after.Rhythm.Overview.MaxSwitchCount,
		baseline.Rhythm.Overview.SameTypeTransitionRatio,
		after.Rhythm.Overview.SameTypeTransitionRatio,
		fallbackHealthProblemText(afterDecision.PrimaryProblem),
	)
}

func buildHealthCandidateAfter(after analyzeHealthSnapshot, decision analyzeHealthDecisionBase) analyzeHealthCandidateAfter {
	return analyzeHealthCandidateAfter{
		CanClose:             !decision.ShouldContinueOptimize,
		PrimaryProblem:       decision.PrimaryProblem,
		RecommendedOperation: decision.RecommendedOperation,
		HeavyAdjacentDays:    after.Rhythm.Overview.HeavyAdjacentDays,
		MaxSwitchCount:       after.Rhythm.Overview.MaxSwitchCount,
		SameTypeRatio:        after.Rhythm.Overview.SameTypeTransitionRatio,
	}
}

func buildHealthCloseCandidate(summary string, baseline analyzeHealthSnapshot, decisionBase analyzeHealthDecisionBase) analyzeHealthCandidate {
	return analyzeHealthCandidate{
		CandidateID: "close",
		Summary:     summary,
		Effect:      healthCandidateEffectClose,
		After:       buildHealthCandidateAfter(baseline, decisionBase),
	}
}

func countRepairableHeavyAdjacentDays(rhythm analyzeRhythmMetrics) int {
	count := 0
	for _, day := range rhythm.Days {
		if !day.HeavyAdjacent {
			continue
		}
		if shouldTreatHeavyAdjacencyAsAcceptable(rhythm, day) {
			continue
		}
		count++
	}
	return count
}

func isRepairableHeavyAdjacentDay(rhythm analyzeRhythmMetrics, dayIndex int) bool {
	for _, day := range rhythm.Days {
		if day.DayIndex != dayIndex {
			continue
		}
		return day.HeavyAdjacent && !shouldTreatHeavyAdjacencyAsAcceptable(rhythm, day)
	}
	return false
}

func findHeavyAdjacentPairOnDay(state *ScheduleState, dayIndex int) (*analyzeHeavyAdjacentPair, bool) {
	if state == nil || dayIndex <= 0 {
		return nil, false
	}
	tasks := collectPlacedTaskBlocksOnDay(state, dayIndex)
	if len(tasks) < 2 {
		return nil, false
	}
	for i := 1; i < len(tasks); i++ {
		left := tasks[i-1]
		right := tasks[i]
		if strings.TrimSpace(left.Category) == "" || strings.TrimSpace(right.Category) == "" {
			continue
		}
		if strings.TrimSpace(left.Category) == strings.TrimSpace(right.Category) {
			continue
		}
		if !isHighIntensityTaskForHealth(state, left) || !isHighIntensityTaskForHealth(state, right) {
			continue
		}
		pair := analyzeHeavyAdjacentPair{
			DayIndex: dayIndex,
			Left:     left,
			Right:    right,
		}
		return &pair, true
	}
	return nil, false
}

func collectPlacedTaskBlocksOnDay(state *ScheduleState, dayIndex int) []ScheduleTask {
	if state == nil || dayIndex <= 0 {
		return nil
	}
	out := make([]ScheduleTask, 0)
	for _, task := range state.Tasks {
		if len(task.Slots) == 0 || isCourseScheduleTask(task) {
			continue
		}
		for _, slot := range task.Slots {
			if slot.Day != dayIndex {
				continue
			}
			out = append(out, task)
			break
		}
	}
	sort.SliceStable(out, func(i, j int) bool {
		left := out[i].Slots[0]
		right := out[j].Slots[0]
		if left.Day != right.Day {
			return left.Day < right.Day
		}
		if left.SlotStart != right.SlotStart {
			return left.SlotStart < right.SlotStart
		}
		if left.SlotEnd != right.SlotEnd {
			return left.SlotEnd < right.SlotEnd
		}
		return out[i].StateID < out[j].StateID
	})
	return out
}

func isHighIntensityTaskForHealth(state *ScheduleState, task ScheduleTask) bool {
	meta := findTaskClassMetaForTask(state, task)
	if meta == nil {
		return false
	}
	return isHighIntensityMeta(*meta)
}

func findTaskClassMetaForTask(state *ScheduleState, task ScheduleTask) *TaskClassMeta {
	if state == nil {
		return nil
	}
	if task.TaskClassID > 0 {
		for i := range state.TaskClasses {
			if state.TaskClasses[i].ID == task.TaskClassID {
				return &state.TaskClasses[i]
			}
		}
	}
	return findTaskClassMetaByName(state, task.Category)
}

func extractSuggestedProblemTasks(pair *analyzeHeavyAdjacentPair) []ScheduleTask {
	if pair == nil {
		return nil
	}
	out := make([]ScheduleTask, 0, 2)
	if IsSuggestedTask(pair.Left) {
		out = append(out, pair.Left)
	}
	if IsSuggestedTask(pair.Right) {
		out = append(out, pair.Right)
	}
	return out
}

func fallbackHealthProblemText(value string) string {
	text := strings.TrimSpace(value)
	if text == "" {
		return "可直接收口"
	}
	return text
}

func marshalCandidateArgumentsKey(args map[string]any) string {
	if len(args) == 0 {
		return ""
	}
	parts := make([]string, 0, len(args))
	for key, value := range args {
		parts = append(parts, fmt.Sprintf("%s=%v", key, value))
	}
	sort.Strings(parts)
	return strings.Join(parts, "|")
}

func minInt(a, b int) int {
	if a <= b {
		return a
	}
	return b
}

func absInt(v int) int {
	if v < 0 {
		return -v
	}
	return v
}
