package configrefresh

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"reflect"
	"sort"
	"strings"

	"iop/apps/edge/internal/edgevalidate"
	"iop/packages/go/config"
	"iop/packages/go/singlerequesttemplate"
)

// applyRuntimeNormalization normalizes candidate config so that classification
// uses the same effective values as a running serve process.
func applyRuntimeNormalization(cfg *config.EdgeConfig) {
	// Normalize logging.path – must match edgecmd.ResolveEdgeLogPath exactly:
	//   - empty     → resolve to binary-dir/logs/edge.log
	//   - absolute  → preserve exactly
	//   - relative  → preserve exactly (include ./ segments)
	// Serve keeps explicit relative paths like "./logs/edge.log" unchanged,
	// so classifying against a cleaned value produces false restart_required.
	if cfg.Logging.Path == "" {
		cfg.Logging.Path = defaultEdgeLogPath()
	}
	// NOTE: explicit absolute / relative paths are intentionally left untouched.

	// Normalize bootstrap.artifact_dir – resolve relative paths to absolute.
	cfg.Bootstrap.ArtifactDir = resolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir)
}

func defaultEdgeLogPath() string {
	if exe, err := os.Executable(); err == nil {
		return filepath.Join(filepath.Dir(exe), "logs", "edge.log")
	}
	if wd, err := os.Getwd(); err == nil {
		return filepath.Join(wd, "logs", "edge.log")
	}
	return "logs/edge.log"
}

func resolveBootstrapArtifactDir(dir string) string {
	if dir == "" {
		dir = "artifacts"
	}
	if filepath.IsAbs(dir) {
		return dir
	}
	return filepath.Join(binaryDir(), dir)
}

func binaryDir() string {
	if exe, err := os.Executable(); err == nil {
		return filepath.Dir(exe)
	}
	if wd, err := os.Getwd(); err == nil {
		return wd
	}
	return "."
}

// LoadCandidate loads and validates an EdgeConfig from the given path, then
// applies the same runtime normalization that iop-edge serve uses.  This
// ensures that classification compares effective (normalized) values and
// prevents false restart_required results when source-level YAML is
// identical but paths are relative or empty.
func LoadCandidate(path string) (*config.EdgeConfig, error) {
	cfg, err := config.LoadEdge(path)
	if err != nil {
		return nil, err
	}
	if err := edgevalidate.ValidateEdgeConfig(cfg); err != nil {
		return nil, err
	}
	applyRuntimeNormalization(cfg)
	return cfg, nil
}

// providerKey identifies a provider entry for diffing.
type providerKey struct {
	NodeKey               string
	Type                  string
	Category              config.Category
	Adapter               string
	Profile               string
	Models                []string
	Health                string
	Capacity              int
	TotalContextTokens    int
	LongContextCapacity   int
	Priority              int
	LifecycleCapabilities []string
	Enabled               bool

	Provider               string
	Endpoint               string
	BaseURL                string
	Headers                map[string]string
	ContextSize            int
	RequestTimeoutMS       int
	ResponseStallTimeoutMS int64
}

func buildProviderIndex(cfg *config.EdgeConfig) map[string]providerKey {
	idx := make(map[string]providerKey)
	for i, node := range cfg.Nodes {
		nodeKey := nodeIdentity(node, i)
		for _, p := range node.Providers {
			idx[p.ID] = providerKey{
				NodeKey:                nodeKey,
				Type:                   p.Type,
				Category:               p.Category,
				Adapter:                p.Adapter,
				Profile:                p.Profile,
				Models:                 append([]string(nil), p.Models...),
				Health:                 p.Health,
				Capacity:               p.Capacity,
				TotalContextTokens:     p.TotalContextTokens,
				LongContextCapacity:    p.LongContextCapacity,
				Priority:               p.Priority,
				LifecycleCapabilities:  append([]string(nil), p.LifecycleCapabilities...),
				Enabled:                config.ProviderEnabled(p),
				Provider:               p.Provider,
				Endpoint:               p.Endpoint,
				BaseURL:                p.BaseURL,
				Headers:                cloneStringMap(p.Headers),
				ContextSize:            p.ContextSize,
				RequestTimeoutMS:       p.RequestTimeoutMS,
				ResponseStallTimeoutMS: p.EffectiveResponseStallTimeoutMS(),
			}
		}
	}
	return idx
}

type nodeKey struct {
	Alias      string
	Token      string
	Adapters   config.AdaptersConf
	Runtime    config.RuntimeConf
	Workspaces []config.WorkspaceDefinition
}

func buildNodeIndex(cfg *config.EdgeConfig) map[string]nodeKey {
	idx := make(map[string]nodeKey, len(cfg.Nodes))
	for i, node := range cfg.Nodes {
		var workspaces []config.WorkspaceDefinition
		if len(node.Workspaces) > 0 {
			workspaces = make([]config.WorkspaceDefinition, len(node.Workspaces))
			copy(workspaces, node.Workspaces)
		}
		idx[nodeIdentity(node, i)] = nodeKey{
			Alias:      node.Alias,
			Token:      node.Token,
			Adapters:   node.Adapters,
			Runtime:    node.Runtime,
			Workspaces: workspaces,
		}
	}
	return idx
}

func nodeIdentity(node config.NodeDefinition, index int) string {
	if node.ID != "" {
		return node.ID
	}
	if node.Alias != "" {
		return node.Alias
	}
	return fmt.Sprintf("#%d", index)
}

func buildModelIndex(cfg *config.EdgeConfig) map[string]config.ModelCatalogEntry {
	idx := make(map[string]config.ModelCatalogEntry, len(cfg.Models))
	for _, model := range cfg.Models {
		idx[model.ID] = model
	}
	return idx
}

func appendIfChanged[T comparable](changes *[]Change, path string, class Status, previous, next T) {
	if previous == next {
		return
	}
	*changes = append(*changes, Change{
		Path:     path,
		Class:    class,
		Previous: fmt.Sprint(previous),
		Next:     fmt.Sprint(next),
	})
}

func appendDeepIfChanged(changes *[]Change, path string, class Status, previous, next interface{}) {
	if reflect.DeepEqual(previous, next) {
		return
	}
	*changes = append(*changes, Change{
		Path:     path,
		Class:    class,
		Previous: fmt.Sprintf("%v", previous),
		Next:     fmt.Sprintf("%v", next),
	})
}

func appendEdgeChanges(changes *[]Change, current, candidate *config.EdgeConfig) {
	appendIfChanged(changes, "edge.id", StatusRestartRequired, current.Edge.ID, candidate.Edge.ID)
	appendIfChanged(changes, "edge.name", StatusRestartRequired, current.Edge.Name, candidate.Edge.Name)
	appendIfChanged(changes, "server.listen", StatusRestartRequired, current.Server.Listen, candidate.Server.Listen)
	appendIfChanged(changes, "server.advertise_host", StatusRestartRequired, current.Server.AdvertiseHost, candidate.Server.AdvertiseHost)
	appendIfChanged(changes, "bootstrap.listen", StatusRestartRequired, current.Bootstrap.Listen, candidate.Bootstrap.Listen)
	appendIfChanged(changes, "bootstrap.artifact_base_url", StatusRestartRequired, current.Bootstrap.ArtifactBaseURL, candidate.Bootstrap.ArtifactBaseURL)
	appendIfChanged(changes, "bootstrap.artifact_dir", StatusRestartRequired, current.Bootstrap.ArtifactDir, candidate.Bootstrap.ArtifactDir)
	appendDeepIfChanged(changes, "tls", StatusRestartRequired, current.TLS, candidate.TLS)
	appendDeepIfChanged(changes, "logging", StatusRestartRequired, current.Logging, candidate.Logging)
	appendIfChanged(changes, "metrics.port", StatusRestartRequired, current.Metrics.Port, candidate.Metrics.Port)
	appendDeepIfChanged(changes, "console", StatusRestartRequired, current.Console, candidate.Console)
	appendDeepIfChanged(changes, "control_plane", StatusRestartRequired, current.ControlPlane, candidate.ControlPlane)
	appendDeepIfChanged(changes, "credential_plane", StatusRestartRequired, current.CredentialPlane, candidate.CredentialPlane)
	appendDeepIfChanged(changes, "refresh", StatusRestartRequired, current.Refresh, candidate.Refresh)
	appendDeepIfChanged(changes, "openai", StatusRestartRequired, current.OpenAI, candidate.OpenAI)
	appendDeepIfChanged(changes, "a2a", StatusRestartRequired, current.A2A, candidate.A2A)
	appendDeepIfChanged(changes, "protocol_profiles", StatusRestartRequired, current.ProtocolProfiles, candidate.ProtocolProfiles)
	appendIfChanged(changes, "long_context_threshold_tokens", StatusApplied, current.LongContextThresholdTokens, candidate.LongContextThresholdTokens)
}

func appendNodeChanges(changes *[]Change, current, candidate *config.EdgeConfig) {
	currentNodes := buildNodeIndex(current)
	candidateNodes := buildNodeIndex(candidate)
	for key, cur := range currentNodes {
		next, exists := candidateNodes[key]
		if !exists {
			*changes = append(*changes, Change{
				Path:     fmt.Sprintf("nodes[%q]", key),
				Class:    StatusRestartRequired,
				Previous: "present",
				Next:     "absent",
			})
			continue
		}
		appendIfChanged(changes, fmt.Sprintf("nodes[%q].alias", key), StatusRestartRequired, cur.Alias, next.Alias)
		appendIfChanged(changes, fmt.Sprintf("nodes[%q].token", key), StatusRestartRequired, cur.Token, next.Token)
		appendDeepIfChanged(changes, fmt.Sprintf("nodes[%q].adapters", key), StatusRestartRequired, cur.Adapters, next.Adapters)
		// Legacy runtime concurrency metadata is live-applyable for compat.
		// Runtime admission is owned by provider/resource capacity.
		appendIfChanged(changes, fmt.Sprintf("nodes[%q].runtime.concurrency", key), StatusApplied, cur.Runtime.Concurrency, next.Runtime.Concurrency)
		// Workspace definitions are restart-required on any change (root,
		// capability, command template, environment allowlist, or limits).
		// Active requests must never observe a root/capability mutation.
		appendDeepIfChanged(changes, fmt.Sprintf("nodes[%q].workspaces", key), StatusRestartRequired, cur.Workspaces, next.Workspaces)
	}
	for key := range candidateNodes {
		if _, exists := currentNodes[key]; !exists {
			*changes = append(*changes, Change{
				Path:     fmt.Sprintf("nodes[%q]", key),
				Class:    StatusRestartRequired,
				Previous: "absent",
				Next:     "present",
			})
		}
	}
}

func appendProviderStructuralChanges(changes *[]Change, current, candidate map[string]providerKey) {
	for provID, cur := range current {
		next, exists := candidate[provID]
		if !exists {
			*changes = append(*changes, Change{
				Path:     fmt.Sprintf("nodes[].providers[%q]", provID),
				Class:    StatusRestartRequired,
				Previous: "present",
				Next:     "absent",
			})
			continue
		}
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].node", provID), StatusRestartRequired, cur.NodeKey, next.NodeKey)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].type", provID), StatusRestartRequired, cur.Type, next.Type)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].category", provID), StatusRestartRequired, cur.Category, next.Category)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].adapter", provID), StatusRestartRequired, cur.Adapter, next.Adapter)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].profile", provID), StatusRestartRequired, cur.Profile, next.Profile)
		appendDeepIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].models", provID), StatusRestartRequired, cur.Models, next.Models)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].health", provID), StatusRestartRequired, cur.Health, next.Health)
		appendDeepIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].lifecycle_capabilities", provID), StatusRestartRequired, cur.LifecycleCapabilities, next.LifecycleCapabilities)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].provider", provID), StatusRestartRequired, cur.Provider, next.Provider)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].endpoint", provID), StatusRestartRequired, cur.Endpoint, next.Endpoint)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].base_url", provID), StatusRestartRequired, cur.BaseURL, next.BaseURL)
		appendDeepIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].headers", provID), StatusRestartRequired, cur.Headers, next.Headers)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].context_size", provID), StatusRestartRequired, cur.ContextSize, next.ContextSize)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].request_timeout_ms", provID), StatusRestartRequired, cur.RequestTimeoutMS, next.RequestTimeoutMS)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].response_stall_timeout_ms", provID), StatusRestartRequired, cur.ResponseStallTimeoutMS, next.ResponseStallTimeoutMS)
	}
	for provID := range candidate {
		if _, exists := current[provID]; !exists {
			*changes = append(*changes, Change{
				Path:     fmt.Sprintf("nodes[].providers[%q]", provID),
				Class:    StatusRestartRequired,
				Previous: "absent",
				Next:     "present",
			})
		}
	}
}

func appendProviderAppliedChanges(changes *[]Change, current, candidate map[string]providerKey) {
	for provID, next := range candidate {
		cur, exists := current[provID]
		if !exists {
			continue
		}
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].capacity", provID), StatusApplied, cur.Capacity, next.Capacity)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].total_context_tokens", provID), StatusApplied, cur.TotalContextTokens, next.TotalContextTokens)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].long_context_capacity", provID), StatusApplied, cur.LongContextCapacity, next.LongContextCapacity)
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].priority", provID), StatusApplied, cur.Priority, next.Priority)
		// Provider-First execution fields (provider_pool policy is no longer
		// reported via per-provider path; canonical path below owns queue policy).
		appendIfChanged(changes, fmt.Sprintf("nodes[].providers[%q].enabled", provID), StatusApplied, cur.Enabled, next.Enabled)
	}
}

// appendProviderPoolPolicyApplied records the effective root provider_pool
// queue policy diff as live-apply. The policy owner is the Edge root
// provider_pool, not per-provider legacy fields.
func appendProviderPoolPolicyApplied(changes *[]Change, current, candidate *config.EdgeConfig) {
	appendIfChanged(changes, "provider_pool.max_queue", StatusApplied, current.ProviderPool.MaxQueue, candidate.ProviderPool.MaxQueue)
	appendIfChanged(changes, "provider_pool.queue_timeout_ms", StatusApplied, current.ProviderPool.QueueTimeoutMS, candidate.ProviderPool.QueueTimeoutMS)
}

func appendProviderChanges(changes *[]Change, current, candidate *config.EdgeConfig) {
	currentProviders := buildProviderIndex(current)
	candidateProviders := buildProviderIndex(candidate)
	appendProviderStructuralChanges(changes, currentProviders, candidateProviders)
	appendProviderAppliedChanges(changes, currentProviders, candidateProviders)
	// Effective root provider_pool queue policy diff.
	appendProviderPoolPolicyApplied(changes, current, candidate)
}

func appendModelChanges(changes *[]Change, current, candidate *config.EdgeConfig) {
	currentModels := buildModelIndex(current)
	candidateModels := buildModelIndex(candidate)
	for modelID, cur := range currentModels {
		next, exists := candidateModels[modelID]
		if !exists {
			*changes = append(*changes, Change{
				Path:     fmt.Sprintf("models[%q]", modelID),
				Class:    StatusApplied,
				Previous: "present",
				Next:     "absent",
			})
			continue
		}
		appendIfChanged(changes, fmt.Sprintf("models[%q].display_name", modelID), StatusApplied, cur.DisplayName, next.DisplayName)
		appendIfChanged(changes, fmt.Sprintf("models[%q].context_window_tokens", modelID), StatusApplied, cur.ContextWindowTokens, next.ContextWindowTokens)
		appendIfChanged(changes, fmt.Sprintf("models[%q].default_max_tokens", modelID), StatusApplied, cur.DefaultMaxTokens, next.DefaultMaxTokens)
		appendIfChanged(changes, fmt.Sprintf("models[%q].min_max_tokens", modelID), StatusApplied, cur.MinMaxTokens, next.MinMaxTokens)
		appendIfChanged(changes, fmt.Sprintf("models[%q].default_thinking_token_budget", modelID), StatusApplied, cur.DefaultThinkingTokenBudget, next.DefaultThinkingTokenBudget)
		appendIfChanged(changes, fmt.Sprintf("models[%q].execution_preset", modelID), StatusApplied, cur.ExecutionPreset, next.ExecutionPreset)
		appendDeepIfChanged(changes, fmt.Sprintf("models[%q].providers", modelID), StatusApplied, cur.Providers, next.Providers)
		appendDeepIfChanged(changes, fmt.Sprintf("models[%q].token_counter", modelID), StatusApplied, cur.TokenCounter, next.TokenCounter)
	}
	for modelID := range candidateModels {
		if _, exists := currentModels[modelID]; !exists {
			*changes = append(*changes, Change{
				Path:     fmt.Sprintf("models[%q]", modelID),
				Class:    StatusApplied,
				Previous: "absent",
				Next:     "present",
			})
		}
	}
}

func buildPresetIndex(cfg *config.EdgeConfig) map[string]config.ExecutionPreset {
	idx := make(map[string]config.ExecutionPreset, len(cfg.ExecutionPresets))
	for _, preset := range cfg.ExecutionPresets {
		idx[preset.ID] = preset
	}
	return idx
}

func appendExecutionPresetChanges(changes *[]Change, current, candidate *config.EdgeConfig) {
	currentPresets := buildPresetIndex(current)
	candidatePresets := buildPresetIndex(candidate)
	for id, cur := range currentPresets {
		next, exists := candidatePresets[id]
		if !exists {
			*changes = append(*changes, Change{
				Path:     fmt.Sprintf("execution_presets[%q]", id),
				Class:    StatusApplied,
				Previous: "present",
				Next:     "absent",
			})
			continue
		}
		appendDeepIfChanged(changes, fmt.Sprintf("execution_presets[%q].selector", id), StatusApplied, cur.Selector, next.Selector)
		appendDeepIfChanged(changes, fmt.Sprintf("execution_presets[%q].allowed_modes", id), StatusApplied, cur.AllowedModes, next.AllowedModes)
		appendDeepIfChanged(changes, fmt.Sprintf("execution_presets[%q].routes", id), StatusApplied, cur.Routes, next.Routes)
		appendSingleRequestChanged(changes, fmt.Sprintf("execution_presets[%q].single_request", id), StatusApplied, cur.SingleRequest, next.SingleRequest)
		appendDeepIfChanged(changes, fmt.Sprintf("execution_presets[%q].workspace_tools", id), StatusApplied, cur.WorkspaceTools, next.WorkspaceTools)
	}
	for id := range candidatePresets {
		if _, exists := currentPresets[id]; !exists {
			*changes = append(*changes, Change{
				Path:     fmt.Sprintf("execution_presets[%q]", id),
				Class:    StatusApplied,
				Previous: "absent",
				Next:     "present",
			})
		}
	}
}

func appendSingleRequestChanged(changes *[]Change, path string, class Status, cur, next *config.ExecutionSingleRequestPolicy) {
	if reflect.DeepEqual(cur, next) {
		return
	}
	*changes = append(*changes, Change{
		Path:     path,
		Class:    class,
		Previous: redactedSingleRequestSummary(cur),
		Next:     redactedSingleRequestSummary(next),
	})
}

func redactedSingleRequestSummary(sr *config.ExecutionSingleRequestPolicy) string {
	if sr == nil {
		return "<nil>"
	}
	planDigest := singlerequesttemplate.Digest(sr.Templates.EffectivePlan)
	reviewDigest := singlerequesttemplate.Digest(sr.Templates.EffectiveReview)
	return fmt.Sprintf("&{WorkspaceRef:%s Limits:%v Stages:%v Templates:{PlanDigest:%s ReviewDigest:%s}}",
		sr.WorkspaceRef, sr.Limits, sr.Stages, planDigest, reviewDigest)
}

func resultFromChanges(changes []Change) Result {
	sort.SliceStable(changes, func(i, j int) bool {
		if changes[i].Path == changes[j].Path {
			return changes[i].Class < changes[j].Class
		}
		return changes[i].Path < changes[j].Path
	})

	overallStatus := StatusApplied
	summary := "no changes detected"
	for _, change := range changes {
		if change.Class == StatusRestartRequired {
			overallStatus = StatusRestartRequired
			summary = "some changes require a process restart"
			break
		}
	}
	if overallStatus == StatusApplied && len(changes) > 0 {
		summary = "all changes can be applied without restart"
	}

	result := Result{
		Status:  overallStatus,
		Changes: changes,
		Summary: summary,
	}
	deriveReport(&result)
	return result
}

// Classify computes the diff between current and candidate configs and returns
// the classified change list and an overall status.
func Classify(current, candidate *config.EdgeConfig) Result {
	var changes []Change

	appendEdgeChanges(&changes, current, candidate)
	appendNodeChanges(&changes, current, candidate)
	appendProviderChanges(&changes, current, candidate)
	appendModelChanges(&changes, current, candidate)
	appendExecutionPresetChanges(&changes, current, candidate)
	return resultFromChanges(changes)
}

// deriveReport populates the ops-report fields (changed node/provider/model ids
// and restart-required paths) from the classified change list. All slices are
// initialized non-nil so the JSON response stays stable for ops assertions.
func deriveReport(r *Result) {
	nodes := newStringSet()
	providers := newStringSet()
	models := newStringSet()
	var restartPaths []string
	for _, c := range r.Changes {
		if c.Class == StatusRestartRequired {
			restartPaths = append(restartPaths, c.Path)
		}
		// Provider paths are nested under nodes[].providers[...]; check them
		// before the broader nodes[ prefix so they are attributed correctly.
		if id, ok := extractBracketID(c.Path, "providers["); ok {
			providers.add(id)
			continue
		}
		if id, ok := extractBracketID(c.Path, "nodes["); ok {
			nodes.add(id)
			continue
		}
		if id, ok := extractBracketID(c.Path, "models["); ok {
			models.add(id)
		}
	}
	r.ChangedNodes = nodes.sorted()
	r.ChangedProviders = providers.sorted()
	r.ChangedModels = models.sorted()
	sort.Strings(restartPaths)
	if restartPaths == nil {
		restartPaths = []string{}
	}
	r.RestartRequiredPaths = restartPaths
}

// extractBracketID returns the quoted id immediately following prefix in path,
// e.g. extractBracketID(`nodes["node-1"].alias`, "nodes[") returns "node-1".
func extractBracketID(path, prefix string) (string, bool) {
	i := strings.Index(path, prefix)
	if i < 0 {
		return "", false
	}
	rest := path[i+len(prefix):]
	if len(rest) == 0 || rest[0] != '"' {
		return "", false
	}
	rest = rest[1:]
	j := strings.IndexByte(rest, '"')
	if j <= 0 {
		return "", false
	}
	return rest[:j], true
}

func cloneStringMap(m map[string]string) map[string]string {
	if m == nil {
		return nil
	}
	out := make(map[string]string, len(m))
	for k, v := range m {
		out[k] = v
	}
	return out
}

type stringSet struct {
	m map[string]struct{}
}

func newStringSet() stringSet { return stringSet{m: make(map[string]struct{})} }

func (s stringSet) add(v string) { s.m[v] = struct{}{} }

func (s stringSet) sorted() []string {
	out := make([]string, 0, len(s.m))
	for v := range s.m {
		out = append(out, v)
	}
	sort.Strings(out)
	return out
}

// Evaluate loads the candidate config from req.ConfigPath, validates it, and
// classifies changes against current. On success it also returns the loaded
// candidate so the caller can apply mutable changes in apply mode.
func Evaluate(_ context.Context, current *config.EdgeConfig, req Request) (Result, *config.EdgeConfig, error) {
	if req.Mode != ModeDryRun && req.Mode != ModeApply {
		return RejectedResult(req, fmt.Sprintf("invalid mode %q: must be dry_run or apply", req.Mode)), nil, nil
	}

	candidate, err := LoadCandidate(req.ConfigPath)
	if err != nil {
		return RejectedResult(req, fmt.Sprintf("load candidate config: %v", err)), nil, nil
	}

	result := Classify(current, candidate)
	result.Mode = req.Mode
	result.RequestID = req.RequestID
	if result.Changes == nil {
		result.Changes = []Change{}
	}
	return result, candidate, nil
}
