// Package pregel provides the Pregel execution algorithm for graph processing.
package pregel

import (
	"context"
	"encoding/json"
	"fmt"
	"reflect"
	"sort"
	"strings"
	"sync"

	"github.com/google/uuid"
	"go.uber.org/zap"

	"ragflow/internal/common"
	"ragflow/internal/harness/graph/channels"
	"ragflow/internal/harness/graph/checkpoint"
	"ragflow/internal/harness/graph/constants"
	"ragflow/internal/harness/graph/errors"
	"ragflow/internal/harness/graph/interrupt"
	"ragflow/internal/harness/graph/types"
)

// Engine implements the Pregel (bulk-synchronous parallel) execution model
// for StateGraph. It manages channel-based state communication, concurrent
// task execution via AsyncPipeline, streaming event emission, and checkpoint
// persistence.
//
// Create an Engine via NewEngine with option functions:
//
//	engine := NewEngine(graph,
//	    WithCheckpointer(cp),
//	    WithRecursionLimit(50),
//	)
type Engine struct {
	graph               types.StateGraph
	checkpointer        checkpoint.BaseCheckpointer
	interrupts          map[string]bool
	interruptsAfter     map[string]bool
	recursionLimit      int
	debug               bool
	config              *types.RunnableConfig
	maxConcurrency      int
	retryPolicy         *types.RetryPolicy
	currentCheckpoint   *checkpoint.Checkpoint
	channelVersions     map[string]int
	versionsSeen        map[string]map[string]int
	cache               Cache
	backgroundExec      *BackgroundExecutor
	callbacks           *CallbackManager     // lifecycle callbacks (event recording, metrics)
	deferredCheckpoints []deferredCheckpoint // for DurabilityExit mode
}

// deferredCheckpoint stores checkpoint data for deferred saving (DurabilityExit mode)
type deferredCheckpoint struct {
	ThreadID     string
	CheckpointID string
	Step         int
	Checkpoint   map[string]any
}

// NewEngine creates a new Pregel engine bound to a StateGraph.
// Options configure checkpointer, recursion limit, concurrency, retry, cache, etc.
//
// The engine is reusable across multiple Run calls. Each call creates its own
// background executor for isolation.
func NewEngine(g types.StateGraph, opts ...EngineOption) *Engine {
	eng := &Engine{
		graph:           g,
		interrupts:      make(map[string]bool),
		interruptsAfter: make(map[string]bool),
		recursionLimit:  25,
		debug:           false,
		config:          types.NewRunnableConfig(),
		maxConcurrency:  10,
		retryPolicy:     nil,
		channelVersions: make(map[string]int),
		versionsSeen:    make(map[string]map[string]int),
		cache:           &NoopCache{},
	}

	for _, opt := range opts {
		opt(eng)
	}

	// Initialize background executor if not already set
	if eng.backgroundExec == nil {
		eng.backgroundExec = NewBackgroundExecutor(eng.maxConcurrency, 100)
	}

	return eng
}

// EngineOption is an option for configuring the Pregel engine.
// Available options: WithCheckpointer, WithInterrupts, WithRecursionLimit,
// WithDebug, WithConfig, WithMaxConcurrency, WithRetryPolicy, WithCache,
// WithBackgroundExecutor.
type EngineOption func(*Engine)

// WithCheckpointer sets the checkpointer.
func WithCheckpointer(cp checkpoint.BaseCheckpointer) EngineOption {
	return func(e *Engine) {
		e.checkpointer = cp
	}
}

// WithInterrupts sets the interrupt nodes.
func WithInterrupts(nodes ...string) EngineOption {
	return func(e *Engine) {
		for _, node := range nodes {
			e.interrupts[node] = true
		}
	}
}

// WithInterruptsAfter sets the after-execution interrupt nodes.
func WithInterruptsAfter(nodes ...string) EngineOption {
	return func(e *Engine) {
		for _, node := range nodes {
			e.interruptsAfter[node] = true
		}
	}
}

// WithRecursionLimit sets the recursion limit.
func WithRecursionLimit(limit int) EngineOption {
	return func(e *Engine) {
		e.recursionLimit = limit
	}
}

// WithDebug enables debug mode.
func WithDebug(debug bool) EngineOption {
	return func(e *Engine) {
		e.debug = debug
	}
}

// WithConfig sets the runnable config.
func WithConfig(cfg *types.RunnableConfig) EngineOption {
	return func(e *Engine) {
		e.config = cfg
	}
}

// WithMaxConcurrency sets the maximum concurrency for node execution.
func WithMaxConcurrency(max int) EngineOption {
	return func(e *Engine) {
		if max > 0 {
			e.maxConcurrency = max
		}
	}
}

// WithRetryPolicy sets the retry policy for node execution.
func WithRetryPolicy(policy *types.RetryPolicy) EngineOption {
	return func(e *Engine) {
		e.retryPolicy = policy
	}
}

// WithCache sets the cache for the engine.
func WithCache(cache Cache) EngineOption {
	return func(e *Engine) {
		e.cache = cache
	}
}

// WithBackgroundExecutor sets the background executor for the engine.
func WithBackgroundExecutor(exec *BackgroundExecutor) EngineOption {
	return func(e *Engine) {
		e.backgroundExec = exec
	}
}

// WithCallbacks sets the callback manager for the engine.
// Callbacks are dispatched during graph execution (run start/end, step start/end,
// node start/end, checkpoint save/load, interrupt/resume).
func WithCallbacks(cb *CallbackManager) EngineOption {
	return func(e *Engine) {
		e.callbacks = cb
	}
}

// ExecuteResult represents the result of graph execution.
type ExecuteResult struct {
	// Final state of the graph.
	State any
	// Checkpoint ID for this execution.
	CheckpointID string
	// Metadata about the execution.
	Metadata map[string]any
}

// Run executes the graph using the Pregel algorithm and returns streaming events.
// outputCh yields StreamEvent values (checkpoints, task start/end, state updates,
// and a final event with the complete state). errCh receives a single error on failure
// or nil on clean completion.
//
// The caller MUST read from outputCh until it is closed to prevent goroutine leaks.
// For synchronous execution, use RunSync instead.
func (e *Engine) Run(ctx context.Context, input any, mode types.StreamMode) (<-chan any, <-chan error) {
	outputCh := make(chan any, 100)
	errCh := make(chan error, 1)

	go func() {
		defer close(errCh)

		// Create stream manager for event streaming
		streamManager := NewStreamManager(mode, 100)

		// WaitGroup ensures the forward goroutine exits before we close outputCh,
		// preventing a data race between close(outputCh) and outputCh <- event.
		var fwWg sync.WaitGroup

		// Forward stream events to output channel
		fwWg.Go(func() {
			for event := range streamManager.Events() {
				select {
				case outputCh <- event:
				case <-ctx.Done():
					return
				}
			}
		})

		// Resolve thread ID early (before deferred cleanup uses it).
		threadID := e.getThreadID()

		// reportRunEnd is defined before the deferred cleanup block so the
		// defer can capture it by closure.
		reportRunEnd := func(err error) {
			if e.callbacks == nil {
				return
			}
			gName := "state_graph"
			if e.graph != nil {
				nodes := e.graph.GetNodes()
				for name := range nodes {
					gName = name
					break
				}
			}
			e.callbacks.RunEnd(context.Background(), gName, threadID, err)
		}

		// Deferred cleanup: dispatch RunEnd, close streamManager,
		// wait for forward goroutine, then close outputCh.
		var exitErr error // captured for RunEnd callback dispatch
		defer func() {
			// Read from errCh to get the exit error for RunEnd dispatch.
			// errCh is still open here (close(errCh) runs after this defer).
			select {
			case exitErr = <-errCh:
				reportRunEnd(exitErr)
				errCh <- exitErr // put back for the caller
			default:
				reportRunEnd(nil)
			}
			streamManager.Close()
			fwWg.Wait()
			close(outputCh)
		}()

		// Create async pipeline for concurrent task execution
		retryPolicy := e.retryPolicy
		if retryPolicy == nil {
			defaultPolicy := types.DefaultRetryPolicy()
			retryPolicy = &defaultPolicy
		}
		asyncPipeline := NewAsyncPipeline(e.maxConcurrency, retryPolicy)
		pipelineCtx := asyncPipeline.Start(ctx)
		defer asyncPipeline.Stop()

		// Reset per-execution engine state.
		// Without this, reusing the same Engine across multiple RunSync calls
		// causes checkpoint maps and channel versions to accumulate indefinitely,
		// leading to unbounded memory growth (soak tests exposed this).
		e.currentCheckpoint = nil
		e.channelVersions = make(map[string]int)
		e.versionsSeen = make(map[string]map[string]int)
		e.deferredCheckpoints = nil

		// Initialize channels
		channelRegistry := channels.NewRegistry()
		graphChannels := e.getGraphChannels()
		for name, ch := range graphChannels {
			channelRegistry.Register(name, ch.Copy())
		}

		// Apply input to channels
		if err := e.applyInput(channelRegistry, input); err != nil {
			errCh <- fmt.Errorf("failed to apply input: %w", err)
			return
		}

		// Get thread ID for checkpointing
		// threadID already resolved above (before deferred cleanup).

		// Load checkpoint when one exists for this thread_id, even when
		// input is non-nil (resume from a previous run).  The canvas
		// always passes a non-nil input ({"query": ...}) on resume, so
		// a strict input==nil guard would prevent checkpoint recovery.
		// We only skip checkpoint loading if the checkpointer reports
		// no data (fresh start).
		// When a checkpoint IS loaded, do NOT apply input — the
		// channel values from the checkpoint already contain the
		// state at the point of interruption.
		var (
			didLoadCheckpoint   bool
			cpCompletedTasks    map[string]bool
			cpLastCompletedNode string
			cpData              map[string]any
		)
		if e.checkpointer != nil {
			var cpErr error
			cpConfig := map[string]any{
				constants.ConfigKeyThreadID: threadID,
			}
			// Support loading a specific checkpoint_id for replay/fork.
			var requestedCPID string
			if e.config != nil && e.config.Configurable != nil {
				if cpid, ok := e.config.Configurable[constants.ConfigKeyCheckpointID]; ok {
					if cpidStr, ok := cpid.(string); ok && cpidStr != "" {
						cpConfig[constants.ConfigKeyCheckpointID] = cpidStr
						requestedCPID = cpidStr
					}
				}
			}
			cpData, cpErr = e.checkpointer.Get(ctx, cpConfig)
			// When a specific checkpoint_id was requested, fail on missing data.
			if requestedCPID != "" && (cpErr != nil || cpData == nil) {
				cpErrMsg := "checkpoint not found"
				if cpErr != nil {
					cpErrMsg = cpErr.Error()
				}
				errCh <- fmt.Errorf("requested checkpoint_id %s: %s", requestedCPID, cpErrMsg)
				return
			}
			if cpErr == nil && cpData != nil {
				didLoadCheckpoint = true
				common.Debug("LOOP_CHECK: loaded checkpoint",
					zap.String("thread", threadID),
					zap.Bool("has_sub", cpData["__sub_state__"] != nil))
				// Restore sub-state (e.g. Loop iteration, currentInput)
				// and inject into interrupt context so Loop node can
				// read it via loadLoopSnapshot on resume.
				if raw, ok := cpData["__sub_state__"]; ok {
					switch v := raw.(type) {
					case []byte:
						pipelineCtx = context.WithValue(pipelineCtx, interrupt.SubGraphStateCtxKey, v)
					case string:
						pipelineCtx = context.WithValue(pipelineCtx, interrupt.SubGraphStateCtxKey, []byte(v))
					}
				}
				// Restore completed task tracking.
				if raw, ok := cpData["__completed_tasks__"]; ok {
					if str, ok := raw.(string); ok {
						cpCompletedTasks = deserializeStringSet(str)
					}
				}
				if raw, ok := cpData["__last_completed_node__"]; ok {
					if str, ok := raw.(string); ok {
						cpLastCompletedNode = str
					}
				}
				// Only restore keys that correspond to registered channels.
				filtered := make(map[string]any)
				for key, val := range cpData {
					if _, ok := channelRegistry.Get(key); ok {
						filtered[key] = val
					}
				}
				if len(filtered) > 0 {
					if err := channelRegistry.RestoreFromCheckpoint(filtered); err != nil {
						errCh <- fmt.Errorf("failed to restore from checkpoint: %w", err)
						return
					}
				}
				if cp, err := checkpoint.FromMap(cpData); err == nil {
					e.currentCheckpoint = cp
				}
				// Dispatch CheckpointLoad callback.
				if e.callbacks != nil {
					cpID := ""
					if cpid, _ := cpData["checkpoint_id"].(string); cpid != "" {
						cpID = cpid
					}
					e.callbacks.CheckpointLoad(ctx, threadID, cpID, 0)
				}
			}
		}
		// Apply input only when no checkpoint was loaded.
		if !didLoadCheckpoint {
			if err := e.applyInput(channelRegistry, input); err != nil {
				errCh <- fmt.Errorf("failed to apply input: %w", err)
				return
			}
		}

		// Initialize new checkpoint if none exists
		if e.currentCheckpoint == nil {
			e.currentCheckpoint = checkpoint.NewCheckpoint(threadID, 0)
		}

		// Create per-run background executor (not shared, so concurrent calls are safe)
		backgroundExec := NewBackgroundExecutor(e.maxConcurrency, 100)
		backgroundExec.Start(ctx)
		defer backgroundExec.Stop()
		// Replace engine-level backgroundExec reference for use by async pipeline
		e.backgroundExec = backgroundExec

		// Execute Pregel loop
		step := 0
		var completedTasks map[string]bool
		lastCompletedNode := cpLastCompletedNode
		if didLoadCheckpoint && cpCompletedTasks != nil {
			completedTasks = cpCompletedTasks
		} else {
			completedTasks = make(map[string]bool)
		}
		var lastState any
		if didLoadCheckpoint {
			if raw, ok := cpData["__last_state__"]; ok {
				var jsonBytes []byte
				switch val := raw.(type) {
				case string:
					jsonBytes = []byte(val)
				case []byte:
					jsonBytes = val
				}
				if jsonBytes != nil {
					var decoded map[string]any
					if json.Unmarshal(jsonBytes, &decoded) == nil {
						lastState = decoded
					}
				}
			}
		} else {
			lastState = input
		}

		// Dispatch RunStart callback.
		if e.callbacks != nil {
			gName := "state_graph"
			if e.graph != nil {
				nodes := e.graph.GetNodes()
				for name := range nodes {
					gName = name
					break
				}
			}
			e.callbacks.RunStart(ctx, gName, threadID)
		}
		for {
			// Check context cancellation at each superstep.
			select {
			case <-ctx.Done():
				errCh <- ctx.Err()
				return
			default:
			}

			// Check recursion limit
			if step >= e.recursionLimit {
				errCh <- &errors.GraphRecursionError{Limit: e.recursionLimit}
				return
			}

			// Dispatch StepStart callback.
			if e.callbacks != nil {
				e.callbacks.StepStart(ctx, step, 0) // taskCount filled after prepareNextTasks
			}

			// Emit checkpoint event via stream manager
			streamManager.EmitCheckpoint(step, channelRegistry.CreateCheckpoint())

			// Determine next tasks
			tasks, triggers, err := e.prepareNextTasks(ctx, channelRegistry, completedTasks, lastCompletedNode, lastState)
			if err != nil {
				errCh <- fmt.Errorf("failed to prepare next tasks: %w", err)
				return
			}

			// Emit task start events
			for _, task := range tasks {
				streamManager.EmitTaskStart(step, task.Name, task.ID)
			}

			// If no tasks, we're done
			if len(tasks) == 0 {
				break
			}

			// Check for interrupts
			interruptedTasks := e.shouldInterrupt(channelRegistry, tasks, triggers)
			if len(interruptedTasks) > 0 {
				// Save checkpoint
				if e.checkpointer != nil {
					checkpoint := channelRegistry.CreateCheckpoint()
					if err := e.checkpointer.Put(ctx, map[string]any{
						constants.ConfigKeyThreadID: threadID,
					}, checkpoint); err != nil {
						errCh <- fmt.Errorf("failed to save checkpoint: %w", err)
						return
					}
				}

				// Emit interrupt event
				interruptNames := make([]string, len(interruptedTasks))
				for i, task := range interruptedTasks {
					interruptNames[i] = task.Name
				}
				streamManager.EmitInterrupt(step, interruptNames)

				// Dispatch Interrupt callback.
				if e.callbacks != nil {
					e.callbacks.Interrupt(ctx, interruptNames, step)
				}

				errCh <- &errors.GraphInterrupt{}
				return
			}

			// Execute tasks using async pipeline
			results, err := e.executeTasksAsync(pipelineCtx, tasks, channelRegistry, asyncPipeline, streamManager, step)
			if err != nil {
				errCh <- fmt.Errorf("failed to execute tasks: %w", err)
				return
			}

			// Mark tasks as completed and track last state
			allFailed := len(results) > 0
			var interruptTaskNames []string
			for _, result := range results {
				if errors.IsGraphInterrupt(result.Err) {
					interruptTaskNames = append(interruptTaskNames, result.Name)
					continue
				}
				if result.Err == nil {
					allFailed = false
					completedTasks[result.Name] = true
					lastCompletedNode = result.Name
					// Merge result into lastState
					lastState = e.mergeStates(lastState, result.Output)
				}
			}
			// If any task was interrupted, handle the interrupt.
			if len(interruptTaskNames) > 0 {
				common.Debug("engine interrupt path",
					zap.Int("step", step),
					zap.Strings("tasks", interruptTaskNames),
					zap.Bool("allFailed", allFailed))
				// Save checkpoint with completed_tasks and sub_state.
				if e.checkpointer != nil {
					checkpointData := channelRegistry.CreateCheckpoint()
					cpPayload := make(map[string]any, len(checkpointData)+4)
					for key, val := range checkpointData {
						cpPayload[key] = val
					}
					cpPayload["__completed_tasks__"] = serializeStringSet(completedTasks)
					cpPayload["__last_completed_node__"] = lastCompletedNode
					cpPayload["__step__"] = float64(step)
					// Persist lastState as string (not []byte) to avoid
					// JSON double-base64-encoding when the checkpointer
					// adapter serializes the whole payload.
					if lastState != nil {
						if ls, err := json.Marshal(lastState); err == nil {
							cpPayload["__last_state__"] = string(ls)
						}
					}
					// Extract sub-state from GraphInterrupt value.
					for _, r := range results {
						if gi, ok := r.Err.(*errors.GraphInterrupt); ok && len(gi.Interrupts) > 0 {
							if intr, ok := gi.Interrupts[0].(*types.Interrupt); ok && intr.Value != nil {
								if b, e := json.Marshal(intr.Value); e == nil {
									cpPayload["__sub_state__"] = b
								}
							}
							break
						}
					}
					if err := e.checkpointer.Put(ctx, map[string]any{
						constants.ConfigKeyThreadID: threadID,
					}, cpPayload); err != nil {
						errCh <- fmt.Errorf("failed to save checkpoint on interrupt: %w", err)
						return
					}
				}
				streamManager.EmitInterrupt(step, interruptTaskNames)
				// Dispatch Interrupt callback.
				if e.callbacks != nil {
					e.callbacks.Interrupt(ctx, interruptTaskNames, step)
				}
				// Preserve the first interrupted task's GraphInterrupt value
				// (with Interrupts populated) instead of creating a bare one,
				// so MustExtractInterruptContexts can extract the original
				// UserFillUp spec / tips / cpn_id from it.
				for _, r := range results {
					if gi, ok := r.Err.(*errors.GraphInterrupt); ok && len(gi.Interrupts) > 0 {
						errCh <- gi
						return
					}
				}
				errCh <- &errors.GraphInterrupt{}
				return
			}
			// If every task in this step failed, the graph cannot make progress.
			// Terminate immediately rather than infinitely re-scheduling the
			// same failing nodes (e.g. a panicking node caught by recover()).
			if allFailed {
				var why string
				for _, r := range results {
					why += fmt.Sprintf(" %s=%T(%v)", r.Name, r.Err, r.Err)
				}
				common.Debug("allFailed",
					zap.Int("step", step),
					zap.String("results", why))
				errCh <- fmt.Errorf("all %d tasks failed in step %d: %s", len(results), step, why)
				return
			}

			// Apply writes to channels
			_, err = e.applyWrites(channelRegistry, results, triggers)
			if err != nil {
				errCh <- fmt.Errorf("failed to apply writes: %w", err)
				return
			}

			// Emit values event
			if values, err := channelRegistry.GetValues(); err == nil {
				streamManager.EmitValues(step, values)
			}

			// Save checkpoint based on durability mode
			if e.checkpointer != nil {
				checkpoint := channelRegistry.CreateCheckpoint()
				checkpointID := uuid.New().String()

				switch e.config.Durability {
				case types.DurabilitySync:
					// Synchronous save - block until complete
					if err := e.saveCheckpoint(ctx, threadID, checkpointID, step, checkpoint); err != nil {
						errCh <- fmt.Errorf("failed to save checkpoint: %w", err)
						return
					}
					// Dispatch CheckpointSave callback.
					if e.callbacks != nil {
						e.callbacks.CheckpointSave(ctx, threadID, checkpointID, step)
					}
				case types.DurabilityAsync:
					// Asynchronous save - don't block next step
					go func(cp map[string]any, cpID string, s int) {
						if err := e.saveCheckpoint(context.Background(), threadID, cpID, s, cp); err != nil {
							// Log async error but don't fail execution
							common.Error("async checkpoint save failed", err, zap.String("thread_id", threadID), zap.String("checkpoint_id", cpID), zap.Int("step", s))
						}
					}(checkpoint, checkpointID, step)
				case types.DurabilityExit:
					// Defer save until exit - accumulate checkpoints in memory
					// Will be saved in final state
					e.deferCheckpoint(threadID, checkpointID, step, checkpoint)
					// Dispatch CheckpointSave callback (deferred save still counts as saved).
					if e.callbacks != nil {
						e.callbacks.CheckpointSave(ctx, threadID, checkpointID, step)
					}
				default:
					// Default to sync behavior
					if err := e.saveCheckpoint(ctx, threadID, checkpointID, step, checkpoint); err != nil {
						errCh <- fmt.Errorf("failed to save checkpoint: %w", err)
						return
					}
				}
			}

			// Check for after-node interrupts. The checkpoint above already
			// captures this step's output.
			if e.shouldInterruptAfter(results) {
				if e.callbacks != nil {
					e.callbacks.Interrupt(ctx, []string{"after_node"}, step)
				}
				errCh <- &errors.GraphInterrupt{}
				return
			}

			// Dispatch StepEnd callback.
			if e.callbacks != nil {
				e.callbacks.StepEnd(ctx, step, nil)
			}

			step++
		}

		// Get final state
		finalState, err := e.buildOutput(channelRegistry, lastState)
		if err != nil {
			errCh <- fmt.Errorf("failed to build output: %w", err)
			return
		}

		// Save deferred checkpoints for DurabilityExit mode
		if e.config.Durability == types.DurabilityExit {
			if err := e.saveDeferredCheckpoints(ctx); err != nil {
				errCh <- fmt.Errorf("failed to save deferred checkpoints: %w", err)
				return
			}
		}

		// Emit final event
		streamManager.EmitFinal(step, finalState)
	}()

	return outputCh, errCh
}

// prepareNextTasks determines which tasks to execute next.
// This is the standard version that prepares tasks for execution.
func (e *Engine) prepareNextTasks(
	ctx context.Context,
	registry *channels.Registry,
	completedTasks map[string]bool,
	lastCompletedNode string,
	currentState any,
) ([]*Task, map[string]struct{}, error) {
	return e.prepareNextTasksWithMode(ctx, registry, completedTasks, lastCompletedNode, currentState, true)
}

// prepareNextTasksWithMode determines which tasks to execute next with for_execution mode.
// When forExecution is true, tasks are prepared for actual execution.
// When forExecution is false, only task information is prepared (for inspection/planning).
//
// In AllPredecessor (DAG) mode, a node is triggered only when ALL of its incoming edges'
// source nodes have completed. In AnyPredecessor (Pregel/BSP) mode (default), a node is
// triggered when any predecessor completes. AllPredecessor does not support cycles.
func (e *Engine) prepareNextTasksWithMode(
	ctx context.Context,
	registry *channels.Registry,
	completedTasks map[string]bool,
	lastCompletedNode string,
	currentState any,
	forExecution bool,
) ([]*Task, map[string]struct{}, error) {
	tasks := make([]*Task, 0)
	triggerToNodes := make(map[string]struct{})

	// If this is the first step
	if len(completedTasks) == 0 {
		entryPoint := e.getEntryPoint()
		if entryPoint == "" {
			return nil, nil, fmt.Errorf("no entry point set")
		}

		// Handle direct edge Start → End (empty/trivial graph)
		if entryPoint == constants.End {
			return tasks, triggerToNodes, nil
		}

		node := e.getNode(entryPoint)
		if node == nil {
			return nil, nil, &errors.NodeNotFoundError{NodeName: entryPoint}
		}

		// Pass node Triggers as task Channels so the first task reads from
		// registered channels rather than receiving a nil state.
		// When the entry point has no explicit triggers, use all registered
		// channel names so it can read the initial input values. This is
		// needed by systems (e.g. canvas) that route data via context
		// rather than channels but still register input channels for
		// the engine's input validation.
		triggers := e.getTriggers(node)
		if len(triggers) == 0 {
			triggers = registry.Names()
		}
		task := e.createTask(node, currentState, triggers, []string{})
		tasks = append(tasks, task)
		triggerToNodes["__start__"] = struct{}{}
		return tasks, triggerToNodes, nil
	}

	// AllPredecessor (DAG) mode: scan all uncompleted nodes and check if
	// ALL of their incoming-edge source nodes have completed.
	if e.graph.GetNodeTriggerMode() == types.NodeTriggerAllPredecessor {
		return e.prepareNextTasksDAG(completedTasks, currentState, forExecution)
	}

	// AnyPredecessor (Pregel/BSP) mode: determine next nodes from the
	// last completed node's outgoing edges.
	nextNodes := e.getNextNodes(ctx, lastCompletedNode, currentState)

	for nodeName := range nextNodes {
		node := e.getNode(nodeName)
		if node == nil {
			continue
		}

		// Determine triggers for this node
		triggers := e.getTriggers(node)
		if len(triggers) == 0 {
			triggers = registry.Names()
		}

		// BSP mode: always schedule, even if previously completed (supports loops).
		var task *Task
		if forExecution {
			task = e.createTask(node, currentState, triggers, []string{})
		} else {
			task = e.createTaskInfo(node, currentState, triggers, []string{})
		}
		tasks = append(tasks, task)

		// Build trigger to nodes mapping
		for _, trigger := range triggers {
			triggerToNodes[trigger] = struct{}{}
		}
	}

	return tasks, triggerToNodes, nil
}

// prepareNextTasksDAG prepares tasks in DAG (AllPredecessor) mode.
// It scans all nodes and schedules those whose incoming-edge sources
// have all completed. This is O(n) per call but correct for fan-in patterns.
func (e *Engine) prepareNextTasksDAG(
	completedTasks map[string]bool,
	currentState any,
	forExecution bool,
) ([]*Task, map[string]struct{}, error) {
	tasks := make([]*Task, 0)
	triggerToNodes := make(map[string]struct{})

	// Build reverse adjacency: for each node, which nodes have edges TO it.
	incomingEdges := e.buildIncomingEdges()

	for _, node := range e.graph.GetNodes() {
		n := e.getNode(node.Name)
		if n == nil {
			continue
		}
		if completedTasks[node.Name] {
			continue
		}

		// Check if all incoming-edge sources have completed.
		predecessors := incomingEdges[node.Name]
		allDone := true
		for _, pred := range predecessors {
			// constants.Start and constants.End are always considered completed.
			if pred == constants.Start || pred == constants.End {
				continue
			}
			if !completedTasks[pred] {
				allDone = false
				break
			}
		}
		// Nodes with no incoming edges (beyond start) can run.
		if !allDone {
			continue
		}

		triggers := e.getTriggers(n)
		if len(triggers) == 0 {
			chMap := e.graph.GetChannels()
			triggers = make([]string, 0, len(chMap))
			for name := range chMap {
				triggers = append(triggers, name)
			}
		}
		var task *Task
		if forExecution {
			task = e.createTask(n, currentState, triggers, []string{})
		} else {
			task = e.createTaskInfo(n, currentState, triggers, []string{})
		}
		tasks = append(tasks, task)
		for _, trigger := range triggers {
			triggerToNodes[trigger] = struct{}{}
		}
	}

	// No tasks means all reachable nodes are done.
	return tasks, triggerToNodes, nil
}

// buildIncomingEdges builds a reverse-adjacency map: node → list of nodes with edges TO it.
func (e *Engine) buildIncomingEdges() map[string][]string {
	adj := make(map[string][]string)
	for _, edge := range e.graph.GetEdges() {
		adj[edge.To] = append(adj[edge.To], edge.From)
	}
	return adj
}

// shouldInterrupt checks if graph should be interrupted.
func (e *Engine) shouldInterrupt(
	registry *channels.Registry,
	tasks []*Task,
	triggerToNodes map[string]struct{},
) []*Task {
	interrupted := make([]*Task, 0)

	if len(e.interrupts) == 0 {
		return interrupted
	}

	interruptAll := e.interrupts[types.All]

	for _, task := range tasks {
		if interruptAll || e.interrupts[task.Name] {
			interrupted = append(interrupted, task)
		}
	}

	return interrupted
}

// shouldInterruptAfter checks if any SUCCESSFULLY completed task's node name
// is in interruptsAfter. Called AFTER execution and checkpoint save so the
// checkpoint already captures the node's output.
func (e *Engine) shouldInterruptAfter(results []*TaskResult) bool {
	if len(e.interruptsAfter) == 0 {
		return false
	}
	interruptAll := e.interruptsAfter[types.All]
	for _, r := range results {
		if r.Err != nil {
			continue
		}
		if interruptAll || e.interruptsAfter[r.Name] {
			return true
		}
	}
	return false
}

// applyWrites applies task outputs to channels with version management and write merging.
func (e *Engine) applyWrites(
	registry *channels.Registry,
	results []*TaskResult,
	triggerToNodes map[string]struct{},
) (map[string]struct{}, error) {
	updatedChannels := make(map[string]struct{})

	// Sort results for deterministic order
	sort.Slice(results, func(i, j int) bool {
		return results[i].Name < results[j].Name
	})

	// Group writes by channel with write merging
	writesByChannel := make(map[string][]any)
	pendingWrites := make(map[string]*checkpoint.PendingWrite)

	for _, result := range results {
		if result.Err != nil {
			continue
		}
		// Skip nil outputs (node returned nil, nil — no state update)
		if result.Output == nil {
			continue
		}

		// Convert output to map of writes
		outputMap, err := toMap(result.Output)
		if err != nil {
			return nil, fmt.Errorf("failed to convert output to map: %w", err)
		}

		// Apply FieldMapping if the node has field-level routing configured.
		if node := e.getNode(result.Name); node != nil && len(node.FieldMapping) > 0 {
			outputMap = applyFieldMapping(outputMap, node.FieldMapping)
		}

		for key, value := range outputMap {
			// Skip nil values
			if value == nil {
				continue
			}

			// Check for Overwrite wrapper
			overwrite := false
			if ow, ok := value.(*types.Overwrite); ok {
				value = ow.Value
				overwrite = true
			}

			// Add to writes
			writesByChannel[key] = append(writesByChannel[key], value)

			// Track pending write
			pendingWrites[key] = &checkpoint.PendingWrite{
				Channel:   key,
				Value:     value,
				Overwrite: overwrite,
				Node:      result.Name,
			}
		}
	}

	// Apply writes to channels with version management
	for channelName, values := range writesByChannel {
		ch, ok := registry.Get(channelName)
		if !ok {
			// Auto-create a LastValue channel for map-based schemas where no
			// channels were pre-configured (e.g. map[string]any{} schema).
			newCh := channels.NewLastValue(nil)
			registry.Register(channelName, newCh)
			ch = newCh
		}

		// Filter out nil values
		filtered := make([]any, 0, len(values))
		for _, val := range values {
			if val != nil {
				filtered = append(filtered, val)
			}
		}

		// When multiple values target a LastValue channel in the same step
		// (star-topology pattern), keep only the last value to avoid channel
		// conflict errors.  BinaryOperatorAggregate and ReducerChannel handle
		// multiple writes via their accumulator logic.
		if len(filtered) > 1 {
			_, isBO := ch.(*channels.BinaryOperatorAggregate)
			_, isRC := ch.(*channels.ReducerChannel)
			if !isBO && !isRC {
				last := filtered[len(filtered)-1]
				filtered = filtered[:1]
				filtered[0] = last
			}
		}

		// Update channel
		updated, err := ch.Update(filtered)
		if err != nil {
			return nil, fmt.Errorf("failed to update channel %s: %w", channelName, err)
		}

		if updated && ch.IsAvailable() {
			updatedChannels[channelName] = struct{}{}

			// Increment channel version (engine-level tracking).
			e.channelVersions[channelName]++

			// Also bump the version on the channel itself for ChannelChangedTrigger.
			if vc, ok := ch.(interface{ SetVersion(int) }); ok {
				vc.SetVersion(e.channelVersions[channelName])
			}

			// Update checkpoint if available
			if e.currentCheckpoint != nil {
				e.currentCheckpoint.IncrementChannel(channelName)
			}
		}
	}

	// Store pending writes to checkpoint
	if e.currentCheckpoint != nil {
		for _, pw := range pendingWrites {
			e.currentCheckpoint.AddPendingWrite(pw.Channel, pw.Value, pw.Overwrite, pw.Node)
		}
	}

	// Mark channels as seen by nodes
	for resultName := range writesByChannel {
		if _, ok := triggerToNodes[resultName]; ok {
			for channelName := range updatedChannels {
				e.markSeen(resultName, channelName)
			}
		}
	}

	return updatedChannels, nil
}

// markSeen marks that a node has seen a channel's version.
func (e *Engine) markSeen(node, channel string) {
	if e.versionsSeen[node] == nil {
		e.versionsSeen[node] = make(map[string]int)
	}
	e.versionsSeen[node][channel] = e.channelVersions[channel]

	if e.currentCheckpoint != nil {
		e.currentCheckpoint.MarkSeen(node, channel)
	}
}

// hasSeen checks if a node has seen a channel's current version.
func (e *Engine) hasSeen(node, channel string) bool {
	if versions, ok := e.versionsSeen[node]; ok {
		if version, ok := versions[channel]; ok {
			return version == e.channelVersions[channel]
		}
	}
	return false
}

// executeTasks executes the given tasks concurrently.
func (e *Engine) executeTasks(
	ctx context.Context,
	tasks []*Task,
	registry *channels.Registry,
) ([]*TaskResult, error) {
	results := make([]*TaskResult, len(tasks))
	var wg sync.WaitGroup
	var mu sync.Mutex

	for i, task := range tasks {
		wg.Add(1)
		go func(idx int, t *Task) {
			defer wg.Done()

			result := e.executeTask(ctx, t, registry)

			mu.Lock()
			results[idx] = result
			mu.Unlock()
		}(i, task)
	}

	wg.Wait()

	return results, nil
}

// executeTasksAsync executes tasks using async pipeline with streaming.
func (e *Engine) executeTasksAsync(
	ctx context.Context,
	tasks []*Task,
	registry *channels.Registry,
	asyncPipeline *AsyncPipeline,
	streamManager *StreamManager,
	step int,
) ([]*TaskResult, error) {
	results := make([]*TaskResult, len(tasks))
	var wg sync.WaitGroup
	var mu sync.Mutex

	for i, task := range tasks {
		wg.Add(1)
		go func(idx int, t *Task) {
			defer wg.Done()

			// Read input for this task
			input, err := e.readTaskInput(registry, t)
			if err != nil {
				mu.Lock()
				results[idx] = &TaskResult{
					Name: t.Name,
					Err:  fmt.Errorf("failed to read task input: %w", err),
				}
				mu.Unlock()
				return
			}

			// Convert map input to struct type if state schema is a struct
			convertedInput := e.mapToStateSchema(input)

			// Define the function to execute
			executeFn := func(ctx context.Context) (any, error) {
				return t.Func(ctx, convertedInput)
			}

			// Use task's retry policy or default
			retryPolicy := t.RetryPolicy
			if retryPolicy == nil {
				defaultPolicy := types.DefaultRetryPolicy()
				retryPolicy = &defaultPolicy
			}

			// Execute with async pipeline
			resultCh := asyncPipeline.ExecuteNode(ctx, t.Name, executeFn, &RetryConfig{Policy: retryPolicy})

			// Wait for result
			select {
			case <-ctx.Done():
				mu.Lock()
				results[idx] = &TaskResult{
					Name: t.Name,
					Err:  ctx.Err(),
				}
				mu.Unlock()
			case asyncResult, ok := <-resultCh:
				if !ok {
					mu.Lock()
					results[idx] = &TaskResult{
						Name: t.Name,
						Err:  fmt.Errorf("async result channel closed unexpectedly"),
					}
					mu.Unlock()
					return
				}

				// Convert async result to task result
				taskResult := &TaskResult{
					Name:   t.Name,
					Output: asyncResult.Output,
					Err:    asyncResult.Err,
				}

				// Emit task end event
				streamManager.EmitTaskEnd(step, t.Name, t.ID, asyncResult.Output, asyncResult.Duration, asyncResult.Err)

				// Emit update event if successful
				if asyncResult.Err == nil {
					streamManager.EmitUpdate(step, t.Name, asyncResult.Output)
				} else {
					// Emit error event
					streamManager.EmitError(step, asyncResult.Err, t.Name)
				}

				mu.Lock()
				results[idx] = taskResult
				mu.Unlock()
			}
		}(i, task)
	}

	wg.Wait()
	return results, nil
}

// executeTask executes a single task with retry logic.
func (e *Engine) executeTask(
	ctx context.Context,
	task *Task,
	registry *channels.Registry,
) *TaskResult {
	// Read input for this task
	input, err := e.readTaskInput(registry, task)
	if err != nil {
		return &TaskResult{
			Name: task.Name,
			Err:  fmt.Errorf("failed to read task input: %w", err),
		}
	}

	// Convert map input to struct type if the state schema is a struct
	input = e.mapToStateSchema(input)

	// Use RetryExecutor for retry logic
	retryPolicy := task.RetryPolicy
	if retryPolicy == nil {
		defaultPolicy := types.DefaultRetryPolicy()
		retryPolicy = &defaultPolicy
	}

	retryExecutor := NewRetryExecutor(retryPolicy)

	// Define the function to execute
	executeFn := func(ctx context.Context) (any, error) {
		return task.Func(ctx, input)
	}

	// Execute with retry
	output, err := retryExecutor.Execute(ctx, task.Name, executeFn)
	if err != nil {
		// Check if it's a retry exhausted error
		if IsRetryExhausted(err) {
			return &TaskResult{
				Name: task.Name,
				Err:  fmt.Errorf("max retries exceeded: %w", err),
			}
		}
		// Check for interrupt
		if errors.IsGraphInterrupt(err) {
			return &TaskResult{
				Name: task.Name,
				Err:  err,
			}
		}
		// Other errors
		return &TaskResult{
			Name: task.Name,
			Err:  err,
		}
	}

	// Success
	return &TaskResult{
		Name:   task.Name,
		Output: output,
		Err:    nil,
	}
}

// readTaskInput reads the input for a task from channels.
// mapToStateSchema converts a map[string]any state to the graph's state schema
// type if it is a struct (or pointer to struct). If the schema is a map or
// nil, the map input is returned as-is.
func (e *Engine) mapToStateSchema(input any) any {
	if input == nil {
		return nil
	}
	inputMap, ok := input.(map[string]any)
	if !ok {
		return input
	}

	schema := e.graph.GetStateSchema()
	if schema == nil {
		return inputMap
	}

	rv := reflect.ValueOf(schema)
	for rv.Kind() == reflect.Ptr {
		rv = rv.Elem()
	}
	if rv.Kind() != reflect.Struct {
		return inputMap
	}

	// State schema is a struct (possibly wrapped in pointer): create a new
	// instance and populate fields from the input map.
	// Preserve whether the original schema was a pointer or value.
	schemaVal := reflect.ValueOf(schema)
	isPtr := schemaVal.Kind() == reflect.Ptr
	structType := rv.Type() // underlying struct type
	structPtr := reflect.New(structType)
	structVal := structPtr.Elem()

	for i := 0; i < structType.NumField(); i++ {
		field := structType.Field(i)
		if field.PkgPath != "" {
			continue
		}
		if val, exists := inputMap[field.Name]; exists {
			fv := structVal.Field(i)
			if fv.CanSet() {
				rvVal := reflect.ValueOf(val)
				if rvVal.Type().AssignableTo(fv.Type()) {
					fv.Set(rvVal)
				} else if rvVal.Type().ConvertibleTo(fv.Type()) {
					fv.Set(rvVal.Convert(fv.Type()))
				}
			}
		}
	}

	if isPtr {
		return structPtr.Interface() // *StructType
	}
	return structVal.Interface() // StructType (value)
}

func (e *Engine) readTaskInput(registry *channels.Registry, task *Task) (any, error) {
	if len(task.Channels) == 0 {
		// Return empty map instead of nil so that node functions expecting
		// map[string]any receive a usable zero value rather than nil.
		return map[string]any{}, nil
	}

	// Read values from specified channels
	values := make(map[string]any)
	for _, channelName := range task.Channels {
		if ch, ok := registry.Get(channelName); ok {
			value, err := ch.Get()
			if err != nil {
				if _, isEmpty := err.(*errors.EmptyChannelError); !isEmpty {
					return nil, err
				}
				// Empty channels are OK
				continue
			}
			values[channelName] = value
		}
	}

	return values, nil
}

// Task represents a task to execute.
type Task struct {
	ID          string
	Name        string
	Func        types.NodeFunc
	Channels    []string
	Path        []string
	Triggers    map[string]struct{}
	RetryPolicy *types.RetryPolicy
}

// TaskResult represents the result of executing a task.
type TaskResult struct {
	Name   string
	Output any
	Err    error
	Path   []string // Task path for deterministic ordering (like Python's task_path)
}

// TaskPathStr generates a deterministic string representation of the task path.
// This corresponds to Python's task_path_str function in _algo.py
func TaskPathStr(path []string) string {
	if len(path) == 0 {
		return ""
	}
	// Join path components with separator for deterministic ordering
	return strings.Join(path, "/")
}

// ParseTaskPath parses a task path string back into a path array.
func ParseTaskPath(pathStr string) []string {
	if pathStr == "" {
		return []string{}
	}
	return strings.Split(pathStr, "/")
}

// BuildTaskPath builds a task path from components.
// Supports nested paths like Python's tuple-based paths.
func BuildTaskPath(components ...any) []string {
	path := make([]string, 0, len(components))
	for _, comp := range components {
		switch val := comp.(type) {
		case string:
			path = append(path, val)
		case int:
			path = append(path, fmt.Sprintf("%d", val))
		case []string:
			path = append(path, val...)
		default:
			if stringer, ok := val.(fmt.Stringer); ok {
				path = append(path, stringer.String())
			} else {
				path = append(path, fmt.Sprintf("%v", val))
			}
		}
	}
	return path
}

// Helper methods that access the StateGraph
func (e *Engine) getGraphChannels() map[string]channels.Channel {
	raw := e.graph.GetChannels()
	result := make(map[string]channels.Channel, len(raw))
	for k, v := range raw {
		if ch, ok := v.(channels.Channel); ok {
			result[k] = ch
		}
	}
	return result
}

func (e *Engine) getEntryPoint() string {
	return e.graph.GetEntryPoint()
}

func (e *Engine) getNode(name string) *types.Node {
	node, _ := e.graph.GetNode(name)
	return node
}

func (e *Engine) getNextNodes(ctx context.Context, node string, state any) map[string]bool {
	common.Debug("getNextNodes",
		zap.String("node", node),
		zap.Any("state", state))
	nextNodes := make(map[string]bool)

	// (1) Check conditional edges.  When a node has conditional edges,
	// ONLY the matched target(s) are scheduled — the regular-edge
	// fallback is skipped entirely so branchable nodes (Switch,
	// Categorize) route exclusively via the _next value.
	hasConditional := false
	for _, condEdge := range e.graph.GetConditionalEdges() {
		if condEdge.From != node {
			continue
		}
		hasConditional = true
		conditionResult, err := condEdge.Condition(ctx, state)
		if err != nil {
			common.Debug("conditional edge failed", zap.String("from", node), zap.Error(err))
		}
		conditionKey := fmt.Sprintf("%v", conditionResult)
		targetNode, ok := condEdge.Mapping[conditionKey]
		if !ok {
			continue
		}
		if targetNode == constants.End {
			return nextNodes
		}
		nextNodes[targetNode] = true
	}

	// (2) Regular edges: ONLY when this node has no conditional edges.
	if !hasConditional && len(nextNodes) == 0 {
		for _, edge := range e.graph.GetEdges() {
			if edge.From == node {
				if edge.To == constants.End {
					return nextNodes
				}
				nextNodes[edge.To] = true
			}
		}
	}

	// (3) Resume fallback: when the last completed node has no outgoing
	// edges but the graph state contains _next (persisted from a
	// Switch/Categorize branch), route directly from _next.  This
	// happens on checkpoint resume because the conditional edge is
	// registered on the Switch node, not on __loop_init__.
	if len(nextNodes) == 0 {
		if st, ok := state.(map[string]any); ok {
			if raw, has := st["_next"]; has && raw != nil {
				switch tv := raw.(type) {
				case string:
					if _, exists := e.graph.GetNode(tv); exists {
						nextNodes[tv] = true
					}
				case []any:
					if len(tv) > 0 {
						if str, ok := tv[0].(string); ok {
							if _, exists := e.graph.GetNode(str); exists {
								nextNodes[str] = true
							}
						}
					}
				}
			}
		}
	}

	// (4) Branches: always included on top of whatever was scheduled.
	for _, branch := range e.graph.GetBranches() {
		if branch.From == node {
			branchResult, err := branch.Condition(ctx, state)
			if err != nil {
				continue
			}
			targets := branch.Then(branchResult)
			for _, target := range targets {
				if target == constants.End {
					continue
				}
				nextNodes[target] = true
			}
		}
	}

	return nextNodes
}

func (e *Engine) getTriggers(node *types.Node) []string {
	if node == nil {
		return []string{}
	}
	return node.Triggers
}

func (e *Engine) createTask(node *types.Node, state any, channels []string, triggers []string) *Task {
	task := &Task{
		ID:       uuid.New().String(),
		Name:     node.Name,
		Channels: channels,
		Triggers: make(map[string]struct{}),
	}
	if node.Function != nil {
		task.Func = node.Function
	}
	for _, trigger := range triggers {
		task.Triggers[trigger] = struct{}{}
	}
	return task
}

// createTaskInfo creates a task info object for inspection/planning (for_execution=false mode).
// This is similar to Python's prepare_next_tasks with for_execution=False.
func (e *Engine) createTaskInfo(node *types.Node, state any, channels []string, triggers []string) *Task {
	task := &Task{
		ID:       uuid.New().String(),
		Name:     node.Name,
		Channels: channels,
		Triggers: make(map[string]struct{}),
		Func:     nil,
	}
	for _, trigger := range triggers {
		task.Triggers[trigger] = struct{}{}
	}
	return task
}

// PrepareNextTasksForInspection prepares tasks for inspection/planning only (for_execution=false).
// This corresponds to Python's prepare_next_tasks with for_execution=False.
func (e *Engine) PrepareNextTasksForInspection(
	ctx context.Context,
	registry *channels.Registry,
	completedTasks map[string]bool,
	lastCompletedNode string,
	currentState any,
) ([]*Task, map[string]struct{}, error) {
	return e.prepareNextTasksWithMode(ctx, registry, completedTasks, lastCompletedNode, currentState, false)
}

func (e *Engine) applyInput(registry *channels.Registry, input any) error {
	inputMap, err := toMap(input)
	if err != nil {
		return err
	}

	// Auto-create channels for any input keys not yet registered, then write.
	for key, value := range inputMap {
		if _, ok := registry.Get(key); ok {
			continue
		}
		guessed := caseFoldKey(registry, key)
		if guessed != "" {
			delete(inputMap, key)
			inputMap[guessed] = value
		} else {
			registry.Register(key, channels.NewLastValue(value))
		}
	}

	writes := make(map[string][]any, len(inputMap))
	for key, value := range inputMap {
		writes[key] = []any{value}
	}

	if len(writes) > 0 {
		return registry.UpdateChannels(writes)
	}
	return nil
}

// caseFoldKey attempts to locate a registered channel whose name differs from
// key only by the case of the first character (e.g. struct field "Counter" vs
// input map key "counter").  Returns the matched channel name, or "".
func caseFoldKey(registry *channels.Registry, key string) string {
	if len(key) == 0 {
		return ""
	}
	// Try uppercase first (e.g. "counter" → "Counter")
	bs := []byte(key)
	if bs[0] >= 'a' && bs[0] <= 'z' {
		bs[0] -= 32
		candidate := string(bs)
		if _, ok := registry.Get(candidate); ok {
			return candidate
		}
	}
	// Try lowercase first (e.g. "Counter" → "counter")
	bs[0] = key[0]
	if bs[0] >= 'A' && bs[0] <= 'Z' {
		bs[0] += 32
		candidate := string(bs)
		if _, ok := registry.Get(candidate); ok {
			return candidate
		}
	}
	return ""
}

func (e *Engine) getThreadID() string {
	if e.config != nil && e.config.Configurable != nil {
		if tid, ok := e.config.Configurable["thread_id"].(string); ok {
			return tid
		}
	}
	return uuid.New().String()
}

func (e *Engine) buildOutput(registry *channels.Registry, lastState any) (any, error) {
	values, err := registry.GetValues()
	if err != nil {
		return lastState, nil
	}

	if len(values) > 0 {
		return values, nil
	}

	return lastState, nil
}

func (e *Engine) mergeStates(existing, next any) any {
	if existing == nil {
		return next
	}

	if next == nil {
		return existing
	}

	// Try to merge maps
	existingMap, ok1 := existing.(map[string]any)
	nextMap, ok2 := next.(map[string]any)

	if ok1 && ok2 {
		result := make(map[string]any)
		for key, val := range existingMap {
			result[key] = val
		}
		for key, val := range nextMap {
			result[key] = val
		}
		return result
	}

	return next
}

// toMap converts a struct or map to a map[string]any.
func toMap(val any) (map[string]any, error) {
	if val == nil {
		return nil, fmt.Errorf("nil value")
	}

	// If it's already a map
	if m, ok := val.(map[string]any); ok {
		return m, nil
	}

	// Use reflection to convert struct to map
	rv := reflect.ValueOf(val)
	if rv.Kind() == reflect.Ptr {
		rv = rv.Elem()
	}

	if rv.Kind() != reflect.Struct && rv.Kind() != reflect.Map {
		return map[string]any{"__root__": val}, nil
	}

	result := make(map[string]any)

	if rv.Kind() == reflect.Map {
		for _, key := range rv.MapKeys() {
			result[fmt.Sprintf("%v", key.Interface())] = rv.MapIndex(key).Interface()
		}
		return result, nil
	}

	// Struct
	rt := rv.Type()
	for i := 0; i < rv.NumField(); i++ {
		field := rt.Field(i)
		// Skip unexported fields
		if field.PkgPath != "" {
			continue
		}
		val := rv.Field(i).Interface()

		// Use original field name to match channel registration
		// (configureChannelsFromSchema registers channels with field.Name).
		result[field.Name] = val
	}

	return result, nil
}

// saveCheckpoint saves a checkpoint to the checkpointer.
func (e *Engine) saveCheckpoint(ctx context.Context, threadID, checkpointID string, step int, checkpoint map[string]any) error {
	if e.checkpointer == nil {
		return nil
	}
	return e.checkpointer.Put(ctx, map[string]any{
		constants.ConfigKeyThreadID:     threadID,
		constants.ConfigKeyCheckpointID: checkpointID,
		"step":                          step,
	}, checkpoint)
}

// deferCheckpoint defers a checkpoint save for DurabilityExit mode.
func (e *Engine) deferCheckpoint(threadID, checkpointID string, step int, checkpoint map[string]any) {
	e.deferredCheckpoints = append(e.deferredCheckpoints, deferredCheckpoint{
		ThreadID:     threadID,
		CheckpointID: checkpointID,
		Step:         step,
		Checkpoint:   checkpoint,
	})
}

// saveDeferredCheckpoints saves all deferred checkpoints (called at exit for DurabilityExit mode).
func (e *Engine) saveDeferredCheckpoints(ctx context.Context) error {
	if e.checkpointer == nil || len(e.deferredCheckpoints) == 0 {
		return nil
	}

	var lastErr error
	for _, dc := range e.deferredCheckpoints {
		if err := e.saveCheckpoint(ctx, dc.ThreadID, dc.CheckpointID, dc.Step, dc.Checkpoint); err != nil {
			lastErr = err
			// Continue saving other checkpoints even if one fails
		}
	}

	// Clear deferred checkpoints after attempting to save
	e.deferredCheckpoints = nil
	return lastErr
}

// RunSync executes the graph synchronously and returns the final state.
// This is a convenience wrapper around Run() for callers that want a blocking API.
//
// RunSync first drains all events from outputCh (reading until it is closed),
// then checks errCh for any execution error. This ordering avoids a race
// between the EventTypeFinal arriving on outputCh and errCh being closed
// (the defer calling close(errCh) runs AFTER close(outputCh)).
func (e *Engine) RunSync(ctx context.Context, input any) (any, error) {
	outputCh, errCh := e.Run(ctx, input, types.StreamModeValues)
	var finalState any

	// Drain outputCh to capture the final state event.
	// Must read until closed to avoid leaking the forward goroutine.
	for result := range outputCh {
		if se, ok := result.(*StreamEvent); ok && se.Type == EventTypeFinal {
			if data, ok := se.Data.(map[string]any); ok {
				if state, ok := data["state"]; ok {
					finalState = state
				}
			}
		}
	}

	// Check for execution errors (non-blocking; errCh is closed after outputCh).
	select {
	case err := <-errCh:
		if err != nil {
			return nil, err
		}
	default:
	}

	return finalState, nil
}

// applyFieldMapping filters and remaps an output map according to FieldMapping rules.
// If no mappings are specified, the entire output map is passed through unchanged.
// Each mapping specifies a source field path (From) and a target field path (To).
func applyFieldMapping(output map[string]any, mappings []types.FieldMapping) map[string]any {
	if len(mappings) == 0 {
		return output
	}
	result := make(map[string]any, len(mappings))
	for _, mapping := range mappings {
		val := getNestedField(output, mapping.From)
		if val != nil {
			setNestedField(result, mapping.To, val)
		}
	}
	return result
}

// getNestedField retrieves a value from a nested map using a dot-separated path.
func getNestedField(m map[string]any, path string) any {
	if path == "" {
		return m // return entire map
	}
	parts := strings.Split(path, ".")
	var cur any = m
	for _, part := range parts {
		cm, ok := cur.(map[string]any)
		if !ok {
			return nil
		}
		cur = cm[part]
		if cur == nil {
			return nil
		}
	}
	return cur
}

// setNestedField sets a value in a nested map using a dot-separated path.
func setNestedField(m map[string]any, path string, val any) {
	if path == "" {
		for k, v := range val.(map[string]any) {
			m[k] = v
		}
		return
	}
	parts := strings.Split(path, ".")
	for i := 0; i < len(parts)-1; i++ {
		sub, ok := m[parts[i]]
		if !ok {
			sub = make(map[string]any)
			m[parts[i]] = sub
		}
		var ok2 bool
		m, ok2 = sub.(map[string]any)
		if !ok2 {
			nm := make(map[string]any)
			m[parts[i]] = nm
			m = nm
		}
	}
	m[parts[len(parts)-1]] = val
}

// serializeStringSet encodes a map[string]bool to a NUL-separated string
// for storage in the checkpoint payload.
func serializeStringSet(set map[string]bool) string {
	if len(set) == 0 {
		return ""
	}
	keys := make([]string, 0, len(set))
	for key := range set {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	out := make([]byte, 0, 256)
	for i, key := range keys {
		if i > 0 {
			out = append(out, 0)
		}
		out = append(out, key...)
	}
	return string(out)
}

// deserializeStringSet decodes a NUL-separated string back to a
// map[string]bool.
func deserializeStringSet(encoded string) map[string]bool {
	if encoded == "" {
		return nil
	}
	parts := strings.Split(encoded, "\x00")
	out := make(map[string]bool, len(parts))
	for _, part := range parts {
		out[part] = true
	}
	return out
}
