package core

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"ragflow/internal/harness/core/schema"
)

// ======================== Helpers ========================

type turnLoopMockAgent struct {
	name          string
	response      string
	GenerateFn    func(ctx context.Context, msgs []Message) (Message, error)
	captureCancel bool
	canceled      atomic.Bool
}

func (a *turnLoopMockAgent) Name(_ context.Context) string        { return a.name }
func (a *turnLoopMockAgent) Description(_ context.Context) string { return "mock agent" }
func (a *turnLoopMockAgent) Run(ctx context.Context, input *AgentInput, opts ...RunOption) *AsyncIterator[*AgentEvent] {
	m := &mockModel{}
	response := a.response
	if response == "" {
		response = "mock"
	}
	m.addResp(response)
	agent := NewReActAgent(&ReActConfig[*schema.Message]{Model: m})
	agent.name = a.name
	return agent.Run(ctx, input, opts...)
}
func (a *turnLoopMockAgent) GetType() string { return "ReActAgent" }

func (a *turnLoopMockAgent) new() Agent {
	return &turnLoopMockAgent{
		name:          a.name,
		response:      a.response,
		GenerateFn:    a.GenerateFn,
		captureCancel: a.captureCancel,
	}
}

type turnLoopMockRunner struct {
	responses []string
	idx       int
}

type turnCancellableModel struct {
	inner          Model[*schema.Message]
	cancelDetected atomic.Bool
}

func (m *turnCancellableModel) Generate(ctx context.Context, msgs []Message, opts ...modelOption) (Message, error) {
	select {
	case <-ctx.Done():
		m.cancelDetected.Store(true)
		return nil, ctx.Err()
	default:
	}
	return m.inner.Generate(ctx, msgs, opts...)
}
func (m *turnCancellableModel) Stream(ctx context.Context, msgs []Message, opts ...modelOption) (*schema.StreamReader[Message], error) {
	return m.inner.Stream(ctx, msgs, opts...)
}
func (m *turnCancellableModel) BindTools(tools []*schema.ToolInfo) error {
	return m.inner.BindTools(tools)
}

func newTurnCheckpointStore() *memStore { return &memStore{data: make(map[string][]byte)} }

// simpleTurnLoop creates a minimal AgentLoop for quick tests
func simpleTurnLoop(onEvents func(context.Context, *TurnContext[string], *AsyncIterator[*AgentEvent]) error) *AgentLoop[string] {
	return NewAgentLoop(AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, loop *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			if len(items) == 0 {
				return nil, nil
			}
			return &GenInputResult[string]{
				Input:    &AgentInput{Messages: []Message{schema.UserMessage(items[0])}},
				Consumed: items[:1], Remaining: items[1:],
			}, nil
		},
		PrepareAgent: func(ctx context.Context, loop *AgentLoop[string], consumed []string) (Agent, error) {
			m := &mockModel{}
			m.addResp("Echo: " + consumed[0])
			return NewReActAgent(&ReActConfig[*schema.Message]{Model: m}), nil
		},
		OnAgentEvents: onEvents,
	})
}

// newAndRunTurnLoop creates and runs a AgentLoop in one call.
func newAndRunTurnLoop[T any](ctx context.Context, cfg AgentLoopConfig[T]) *AgentLoop[T] {
	l := NewAgentLoop(cfg)
	l.Run(ctx)
	return l
}

// genInputConsumeAll consumes all items at once.
func genInputConsumeAll(_ context.Context, _ *AgentLoop[string], items []string) (*GenInputResult[string], error) {
	if len(items) == 0 {
		return nil, nil
	}
	return &GenInputResult[string]{Input: &AgentInput{Messages: []Message{schema.UserMessage(items[0])}}, Consumed: items, Remaining: nil}, nil
}

// genInputConsumeFirst consumes the first item, leaves rest for later.
func genInputConsumeFirst(_ context.Context, _ *AgentLoop[string], items []string) (*GenInputResult[string], error) {
	if len(items) == 0 {
		return nil, nil
	}
	return &GenInputResult[string]{
		Input:     &AgentInput{Messages: []Message{schema.UserMessage(items[0])}},
		Consumed:  []string{items[0]},
		Remaining: items[1:],
	}, nil
}

// genInputConsumeAllWithMsg consumes all items and produces a user message.
func genInputConsumeAllWithMsg(_ context.Context, _ *AgentLoop[string], items []string) (*GenInputResult[string], error) {
	if len(items) == 0 {
		return nil, nil
	}
	return &GenInputResult[string]{
		Input:    &AgentInput{Messages: []Message{schema.UserMessage(items[0])}},
		Consumed: items,
	}, nil
}

// prepareTestAgent returns a default mock agent.
var prepareTestAgent = func(_ context.Context, _ *AgentLoop[string], _ []string) (Agent, error) {
	m := &mockModel{}
	m.addResp("test")
	agent := NewReActAgent(&ReActConfig[*schema.Message]{Model: m})
	agent.name = "test"
	return agent, nil
}

func prepareAgent(a Agent) func(context.Context, *AgentLoop[string], []string) (Agent, error) {
	return func(_ context.Context, _ *AgentLoop[string], _ []string) (Agent, error) {
		return a, nil
	}
}

func waitOrFail(t *testing.T, ch <-chan struct{}, msg string) {
	t.Helper()
	select {
	case <-ch:
	case <-time.After(2 * time.Second):
		t.Fatal(msg)
	}
}

func newTestStore() *memStore {
	return &memStore{data: make(map[string][]byte)}
}

// turnLoopCancellableMockAgent is a mock Agent that supports cancel observation.
type turnLoopCancellableMockAgent struct {
	name     string
	runFunc  func(ctx context.Context, input *AgentInput) (*AgentOutput, error)
	onCancel func(cc *cancelContext)
	cancel   context.CancelFunc
	mu       sync.Mutex
}

func (a *turnLoopCancellableMockAgent) Name(_ context.Context) string        { return a.name }
func (a *turnLoopCancellableMockAgent) Description(_ context.Context) string { return "mock agent" }
func (a *turnLoopCancellableMockAgent) Run(ctx context.Context, input *AgentInput, opts ...RunOption) *AsyncIterator[*AgentEvent] {
	iter, gen := NewAsyncIteratorPair[*AgentEvent]()

	o := getCommonOptions(nil, opts...)
	cc := o.cancelCtx

	a.mu.Lock()
	var cancelCtx context.Context
	cancelCtx, a.cancel = context.WithCancel(ctx)
	a.mu.Unlock()

	go func() {
		defer gen.Close()
		if cc != nil {
			go func() {
				<-cc.cancelChan
				if a.onCancel != nil {
					a.onCancel(cc)
				}
				a.mu.Lock()
				if a.cancel != nil {
					a.cancel()
				}
				a.mu.Unlock()
			}()
		}

		output, err := a.runFunc(cancelCtx, input)
		if err != nil {
			gen.Send(&AgentEvent{Err: err})
			return
		}
		gen.Send(&AgentEvent{Output: output})
	}()
	return iter
}

// turnLoopStopModeProbeAgent allows inspecting cancel mode.
type turnLoopStopModeProbeAgent struct {
	ccCh chan *cancelContext
}

func (a *turnLoopStopModeProbeAgent) Name(_ context.Context) string        { return "probe" }
func (a *turnLoopStopModeProbeAgent) Description(_ context.Context) string { return "probe" }
func (a *turnLoopStopModeProbeAgent) Run(_ context.Context, _ *AgentInput, opts ...RunOption) *AsyncIterator[*AgentEvent] {
	iter, gen := NewAsyncIteratorPair[*AgentEvent]()
	o := getCommonOptions(nil, opts...)
	cc := o.cancelCtx
	a.ccCh <- cc
	go func() {
		defer gen.Close()
		<-cc.cancelChan
		for {
			if cc.getMode() == CancelImmediate {
				gen.Send(&AgentEvent{Err: cc.createError()})
				return
			}
			time.Sleep(1 * time.Millisecond)
		}
	}()
	return iter
}

// turnLoopInterruptAgent is an agent that produces a business interrupt.
type turnLoopInterruptAgent struct {
	interruptInfo any
}

func (a *turnLoopInterruptAgent) Name(_ context.Context) string { return "InterruptAgent" }
func (a *turnLoopInterruptAgent) Description(_ context.Context) string {
	return "agent that interrupts"
}
func (a *turnLoopInterruptAgent) Run(ctx context.Context, _ *AgentInput, _ ...RunOption) *AsyncIterator[*AgentEvent] {
	iter, gen := NewAsyncIteratorPair[*AgentEvent]()
	go func() {
		defer gen.Close()
		gen.Send(Interrupt(ctx, a.interruptInfo))
	}()
	return iter
}

func containsString(s, substr string) bool {
	return len(s) >= len(substr) && s[:len(substr)] == substr
}

// ======================== NewAgentLoop & Panic Tests ========================

func TestTurnLoop_NewPanicsWithNilGenInput(t *testing.T) {
	defer func() {
		if r := recover(); r == nil {
			t.Fatal("expected panic")
		}
	}()
	NewAgentLoop[string](AgentLoopConfig[string]{PrepareAgent: func(_ context.Context, _ *AgentLoop[string], _ []string) (Agent, error) { return nil, nil }})
}

func TestTurnLoop_NewPanicsWithNilPrepareAgent(t *testing.T) {
	defer func() {
		if r := recover(); r == nil {
			t.Fatal("expected panic")
		}
	}()
	NewAgentLoop[string](AgentLoopConfig[string]{GenInput: func(_ context.Context, _ *AgentLoop[string], _ []string) (*GenInputResult[string], error) {
		return nil, nil
	}})
}

// ======================== Basic Push-Stop-Run ========================

func TestTurnLoop_PushRunAndWait(t *testing.T) {
	tl := simpleTurnLoop(nil)
	tl.Push("a")
	tl.Push("b")
	ctx := context.Background()
	tl.Stop()
	tl.Run(ctx)
	result := tl.Wait()
	if result == nil {
		t.Fatal("nil result")
	}
	t.Logf("basic: unhandled=%d", len(result.UnhandledItems))
}

func TestTurnLoop_StopCause(t *testing.T) {
	tl := simpleTurnLoop(nil)
	tl.Push("x")
	tl.Stop(WithStopCause("max_tokens"))
	tl.Run(context.Background())
	result := tl.Wait()
	if result.StopCause != "max_tokens" {
		t.Errorf("StopCause = %q", result.StopCause)
	}
}

func TestTurnLoop_OnAgentEventsCalled(t *testing.T) {
	var called atomic.Bool
	tl := simpleTurnLoop(func(ctx context.Context, tc *TurnContext[string], events *AsyncIterator[*AgentEvent]) error {
		called.Store(true)
		for {
			ev, ok := events.Next()
			if !ok {
				break
			}
			_ = ev
		}
		return nil
	})
	ctx := context.Background()
	tl.Run(ctx)
	tl.Push("ev")
	time.Sleep(50 * time.Millisecond)
	tl.Stop()
	tl.Wait()
	if !called.Load() {
		t.Error("OnAgentEvents not called")
	}
}

func TestTurnLoop_OnAgentEventsReturnsError(t *testing.T) {
	tl := simpleTurnLoop(func(ctx context.Context, tc *TurnContext[string], events *AsyncIterator[*AgentEvent]) error {
		return errors.New("custom_events_error")
	})
	ctx := context.Background()
	tl.Run(ctx)
	tl.Push("fail")
	time.Sleep(50 * time.Millisecond)
	tl.Stop()
	result := tl.Wait()
	if result.ExitReason == nil || !containsString(result.ExitReason.Error(), "custom_events_error") {
		t.Errorf("expected custom_events_error, got %v", result.ExitReason)
	}
}

// ======================== GenInput / PrepareAgent Errors ========================

func TestTurnLoop_GenInputErrors(t *testing.T) {
	tl := NewAgentLoop(AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, loop *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			if len(items) == 0 {
				return nil, nil
			}
			return nil, errors.New("gen_input_err")
		},
		PrepareAgent: func(ctx context.Context, loop *AgentLoop[string], consumed []string) (Agent, error) {
			return nil, nil
		},
	})
	tl.Push("bad")
	tl.Stop()
	tl.Run(context.Background())
	result := tl.Wait()
	if result.ExitReason == nil {
		t.Log("no exit error (may not reach GenInput before stop)")
	}
}

func TestTurnLoop_PrepareAgentErrors(t *testing.T) {
	tl := NewAgentLoop(AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, loop *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			return &GenInputResult[string]{Consumed: items, Remaining: nil}, nil
		},
		PrepareAgent: func(ctx context.Context, loop *AgentLoop[string], consumed []string) (Agent, error) {
			return nil, errors.New("prepare_err")
		},
	})
	tl.Push("bad")
	tl.Stop()
	tl.Run(context.Background())
	result := tl.Wait()
	if result.ExitReason == nil {
		t.Log("no exit error (may not reach PrepareAgent)")
	}
}

// ======================== Multiple Items ========================

func TestTurnLoop_MultipleItems(t *testing.T) {
	tl := simpleTurnLoop(nil)
	for i := 0; i < 10; i++ {
		tl.Push(fmt.Sprintf("item-%d", i))
	}
	tl.Stop()
	tl.Run(context.Background())
	result := tl.Wait()
	t.Logf("10 items: unhandled=%d interrupted=%d", len(result.UnhandledItems), len(result.InterruptedItems))
}

func TestTurnLoop_ConcurrentPush(t *testing.T) {
	tl := simpleTurnLoop(nil)
	var wg sync.WaitGroup
	for i := 0; i < 50; i++ {
		wg.Go(func() { ; tl.Push("c") })
	}
	wg.Wait()
	tl.Stop()
	tl.Run(context.Background())
	result := tl.Wait()
	t.Logf("50 concurrent: unhandled=%d", len(result.UnhandledItems))
}

// ======================== Checkpoint ========================

func TestTurnLoop_WithCheckpoint(t *testing.T) {
	store := newTurnCheckpointStore()
	tl := NewAgentLoop(AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, loop *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			if len(items) == 0 {
				return nil, nil
			}
			return &GenInputResult[string]{
				Input:    &AgentInput{Messages: []Message{schema.UserMessage(items[0])}},
				Consumed: items[:1], Remaining: items[1:],
			}, nil
		},
		PrepareAgent: func(ctx context.Context, loop *AgentLoop[string], consumed []string) (Agent, error) {
			m := &mockModel{}
			m.addResp("cp:" + consumed[0])
			return NewReActAgent(&ReActConfig[*schema.Message]{Model: m}), nil
		},
		Store: store,
	})
	tl.Push("cp1")
	tl.Stop()
	tl.Run(context.Background())
	result := tl.Wait()
	t.Logf("checkpoint: unhandled=%d", len(result.UnhandledItems))
}

// ======================== Stop Mode Tests ========================

func TestTurnLoop_ImmediateStop(t *testing.T) {
	tl := simpleTurnLoop(nil)
	tl.Push("urgent")
	tl.Run(context.Background())
	tl.Stop(WithImmediateStop(), WithSkipCheckpoint())
	result := tl.Wait()
	t.Logf("immediate: err=%v", result.ExitReason)
}

func TestTurnLoop_StopWithNoItems(t *testing.T) {
	tl := simpleTurnLoop(nil)
	tl.Stop(WithStopCause("empty"))
	tl.Run(context.Background())
	result := tl.Wait()
	if result.StopCause != "empty" {
		t.Errorf("StopCause = %q", result.StopCause)
	}
}

func TestTurnLoop_StopMultipleTimes(t *testing.T) {
	tl := simpleTurnLoop(nil)
	tl.Push("x")
	tl.Stop(WithStopCause("first"))
	tl.Stop(WithStopCause("second"))
	tl.Run(context.Background())
	result := tl.Wait()
	_ = result
}

// ======================== Context Cancel ========================

func TestTurnLoop_ContextCancel(t *testing.T) {
	tl := simpleTurnLoop(nil)
	tl.Push("task")
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	tl.Stop()
	tl.Run(ctx)
	result := tl.Wait()
	t.Logf("ctx cancel: err=%v", result.ExitReason)
}

// ======================== Items State ========================

func TestTurnLoop_PushAfterStop(t *testing.T) {
	tl := simpleTurnLoop(nil)
	tl.Push("a")
	tl.Push("b")
	tl.Stop()
	tl.Push("c")
	tl.Run(context.Background())
	tl.Wait()
}

// ======================== AgentLoop with Tools ========================

func TestTurnLoop_WithToolAgent(t *testing.T) {
	tool := &mockTool{name: "calc", desc: "calculator"}
	tl := NewAgentLoop(AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, loop *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			if len(items) == 0 {
				return nil, nil
			}
			return &GenInputResult[string]{
				Input:    &AgentInput{Messages: []Message{schema.UserMessage(items[0])}},
				Consumed: items[:1], Remaining: items[1:],
			}, nil
		},
		PrepareAgent: func(ctx context.Context, loop *AgentLoop[string], consumed []string) (Agent, error) {
			wrapperModel := &forcedToolModel{
				toolCalls: []schema.ToolCall{{ID: "c1", Function: schema.ToolCallFunction{Name: "calc", Arguments: "{}"}}},
				finalResp: "Tool done", firstCall: true,
			}
			return NewReActAgent(&ReActConfig[*schema.Message]{
				Model: wrapperModel, Tools: []Tool{tool},
			}), nil
		},
	})
	tl.Push("use tool")
	tl.Stop()
	ctx := context.Background()
	tl.Run(ctx)
	result := tl.Wait()
	t.Logf("tool agent: unhandled=%d", len(result.UnhandledItems))
}

// ======================== GenInput variants ========================

func TestTurnLoop_GenInputAllConsumed(t *testing.T) {
	tl := NewAgentLoop(AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, loop *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			if len(items) == 0 {
				return nil, nil
			}
			return &GenInputResult[string]{Input: &AgentInput{Messages: []Message{schema.UserMessage(items[0])}}, Consumed: items, Remaining: nil}, nil
		},
		PrepareAgent: func(ctx context.Context, loop *AgentLoop[string], consumed []string) (Agent, error) {
			m := &mockModel{}
			m.addResp("all")
			return NewReActAgent(&ReActConfig[*schema.Message]{Model: m}), nil
		},
	})
	tl.Push("1")
	tl.Push("2")
	tl.Stop()
	tl.Run(context.Background())
	tl.Wait()
}

func TestTurnLoop_GenInputOneByOne(t *testing.T) {
	tl := NewAgentLoop(AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, loop *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			if len(items) == 0 {
				return nil, nil
			}
			return &GenInputResult[string]{
				Input:    &AgentInput{Messages: []Message{schema.UserMessage(items[0])}},
				Consumed: items[:1], Remaining: items[1:],
			}, nil
		},
		PrepareAgent: func(ctx context.Context, loop *AgentLoop[string], consumed []string) (Agent, error) {
			m := &mockModel{}
			m.addResp("one:" + consumed[0])
			return NewReActAgent(&ReActConfig[*schema.Message]{Model: m}), nil
		},
	})
	tl.Push("x")
	tl.Push("y")
	tl.Push("z")
	tl.Stop()
	tl.Run(context.Background())
	result := tl.Wait()
	t.Logf("stream: unhandled=%d", len(result.UnhandledItems))
}

func TestTurnLoop_GenInputConsumedNone(t *testing.T) {
	tl := NewAgentLoop(AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, loop *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			return &GenInputResult[string]{Consumed: nil, Remaining: items}, nil
		},
		PrepareAgent: func(ctx context.Context, loop *AgentLoop[string], consumed []string) (Agent, error) {
			return nil, nil
		},
	})
	tl.Push("x")
	tl.Stop()
	tl.Run(context.Background())
	result := tl.Wait()
	t.Logf("none consumed: unhandled=%d", len(result.UnhandledItems))
}

// ======================== OnStop / Intercepted Items ========================

func TestTurnLoop_InterceptedItems(t *testing.T) {
	tl := NewAgentLoop(AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, loop *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			if len(items) == 0 {
				return nil, nil
			}
			return &GenInputResult[string]{
				Input:    &AgentInput{Messages: []Message{schema.UserMessage(items[0])}},
				Consumed: items[:1], Remaining: items[1:],
			}, nil
		},
		PrepareAgent: func(ctx context.Context, loop *AgentLoop[string], consumed []string) (Agent, error) {
			m := &mockModel{}
			m.addResp("intercepted")
			return NewReActAgent(&ReActConfig[*schema.Message]{Model: m}), nil
		},
	})
	tl.Push("a")
	tl.Run(context.Background())
	tl.Stop(WithImmediateStop(), WithSkipCheckpoint())
	result := tl.Wait()
	_ = result
}

// ======================== Edge Cases ========================

func TestTurnLoop_NoPushBeforeRun(t *testing.T) {
	tl := simpleTurnLoop(nil)
	tl.Stop()
	tl.Run(context.Background())
	result := tl.Wait()
	if result == nil {
		t.Fatal("nil result")
	}
}

func TestTurnLoop_DoubleRunPanics(t *testing.T) {
	tl := simpleTurnLoop(nil)
	tl.Push("x")
	tl.Stop()
	tl.Run(context.Background())
	tl.Run(context.Background()) // should be no-op
	tl.Wait()
}

func TestTurnLoop_RunThenStopThenWait(t *testing.T) {
	tl := simpleTurnLoop(nil)
	tl.Push("x")
	ctx := context.Background()
	tl.Run(ctx)
	tl.Stop()
	result := tl.Wait()
	if result == nil {
		t.Fatal("nil result")
	}
}

// ======================== edge-case tests ========================

// TestTurnLoop_StopIsIdempotent verifies multiple Stop() calls are safe.
func TestTurnLoop_StopIsIdempotent(t *testing.T) {
	loop := newAndRunTurnLoop(context.Background(), AgentLoopConfig[string]{
		GenInput:     genInputConsumeAll,
		PrepareAgent: prepareTestAgent,
	})

	loop.Stop()
	loop.Stop()
	loop.Stop()

	result := loop.Wait()
	if result.ExitReason != nil {
		t.Errorf("expected nil exit reason, got %v", result.ExitReason)
	}
}

// TestTurnLoop_WaitMultipleGoroutines verifies Wait() is safe for concurrent callers.
func TestTurnLoop_WaitMultipleGoroutines(t *testing.T) {
	loop := newAndRunTurnLoop(context.Background(), AgentLoopConfig[string]{
		GenInput:     genInputConsumeAll,
		PrepareAgent: prepareTestAgent,
	})

	loop.Stop()

	var wg sync.WaitGroup
	results := make([]*AgentLoopState[string], 3)

	for i := 0; i < 3; i++ {
		i := i
		wg.Go(func() {
			results[i] = loop.Wait()
		})
	}

	wg.Wait()
	// All should return the same pointer
	for i := 1; i < 3; i++ {
		if results[0] != results[i] {
			t.Errorf("Wait returned different results for goroutines")
		}
	}
}

// TestTurnLoop_GetAgentError verifies PrepareAgent errors propagate.
func TestTurnLoop_GetAgentError(t *testing.T) {
	agentErr := errors.New("get agent error")

	loop := newAndRunTurnLoop(context.Background(), AgentLoopConfig[string]{
		GenInput: genInputConsumeAll,
		PrepareAgent: func(ctx context.Context, _ *AgentLoop[string], consumed []string) (Agent, error) {
			return nil, agentErr
		},
	})

	loop.Push("msg1")

	result := loop.Wait()
	if !errors.Is(result.ExitReason, agentErr) {
		t.Errorf("expected agentErr, got %v", result.ExitReason)
	}
}

// TestTurnLoop_BatchProcessing verifies GenInput receives batched items.
func TestTurnLoop_BatchProcessing(t *testing.T) {
	var batches [][]string
	var mu sync.Mutex

	loop := newAndRunTurnLoop(context.Background(), AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, _ *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			mu.Lock()
			batches = append(batches, items)
			mu.Unlock()
			return &GenInputResult[string]{
				Input:     &AgentInput{},
				Consumed:  items[:1],
				Remaining: items[1:],
			}, nil
		},
		PrepareAgent: prepareTestAgent,
	})

	loop.Push("msg1")
	loop.Push("msg2")
	loop.Push("msg3")

	time.Sleep(200 * time.Millisecond)

	loop.Stop()
	loop.Wait()

	mu.Lock()
	defer mu.Unlock()

	if len(batches) == 0 {
		t.Error("should have processed at least one batch")
	}
}

// TestTurnLoop_StopWithMode verifies Stop with WithGracefulStop works.
func TestTurnLoop_StopWithMode(t *testing.T) {
	loop := newAndRunTurnLoop(context.Background(), AgentLoopConfig[string]{
		GenInput:     genInputConsumeAll,
		PrepareAgent: prepareTestAgent,
	})

	loop.Stop(WithGracefulStop())

	result := loop.Wait()
	if result.ExitReason != nil {
		t.Errorf("expected nil, got %v", result.ExitReason)
	}
}

// ======================== Context Cancel Variants ========================

func TestTurnLoop_ContextDeadlineExceeded(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()

	loop := newAndRunTurnLoop(ctx, AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, _ *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			select {
			case <-time.After(100 * time.Millisecond):
				return &GenInputResult[string]{Input: &AgentInput{}, Consumed: items}, nil
			case <-ctx.Done():
				return nil, ctx.Err()
			}
		},
		PrepareAgent: prepareTestAgent,
	})

	loop.Push("msg1")

	result := loop.Wait()
	if !errors.Is(result.ExitReason, context.DeadlineExceeded) {
		t.Logf("expected DeadlineExceeded, got %v (may be nil if loop stopped before timeout)", result.ExitReason)
	}
}

func TestTurnLoop_ContextCancelBeforeReceive(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	loop := NewAgentLoop(AgentLoopConfig[string]{
		GenInput:     genInputConsumeAll,
		PrepareAgent: prepareTestAgent,
	})

	loop.Push("msg1")
	loop.Run(ctx)

	result := loop.Wait()
	if !errors.Is(result.ExitReason, context.Canceled) {
		t.Logf("expected Canceled, got %v", result.ExitReason)
	}
}

func TestTurnLoop_ContextCancelDuringBlockingReceive(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())

	loop := newAndRunTurnLoop(ctx, AgentLoopConfig[string]{
		GenInput:     genInputConsumeAll,
		PrepareAgent: prepareTestAgent,
	})

	time.Sleep(50 * time.Millisecond)
	cancel()

	result := loop.Wait()
	if !errors.Is(result.ExitReason, context.Canceled) {
		t.Logf("expected Canceled, got %v", result.ExitReason)
	}
}

func TestTurnLoop_ContextCancelAfterGenInput_RecoverItems(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())

	genInputCount := 0
	loop := newAndRunTurnLoop(ctx, AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, _ *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			genInputCount++
			if genInputCount == 1 {
				cancel()
			}
			return &GenInputResult[string]{
				Input:     &AgentInput{},
				Consumed:  items[:1],
				Remaining: items[1:],
			}, nil
		},
		PrepareAgent: func(ctx context.Context, _ *AgentLoop[string], c []string) (Agent, error) {
			if err := ctx.Err(); err != nil {
				return nil, err
			}
			return NewReActAgent(&ReActConfig[*schema.Message]{Model: &mockModel{}}), nil
		},
	})

	loop.Push("msg1")
	loop.Push("msg2")

	result := loop.Wait()
	if !errors.Is(result.ExitReason, context.Canceled) {
		t.Logf("expected Canceled, got %v", result.ExitReason)
	}
	if len(result.UnhandledItems) == 0 {
		t.Log("no unhandled items (may have been consumed before cancel)")
	}
}

// ======================== OnAgentEvents Tests ========================

func TestTurnLoop_OnAgentEventsReceivesEvents(t *testing.T) {
	var receivedEvents []*AgentEvent
	var receivedConsumed []string
	var mu sync.Mutex

	loop := newAndRunTurnLoop(context.Background(), AgentLoopConfig[string]{
		GenInput:     genInputConsumeAllWithMsg,
		PrepareAgent: prepareTestAgent,
		OnAgentEvents: func(ctx context.Context, tc *TurnContext[string], events *AsyncIterator[*AgentEvent]) error {
			mu.Lock()
			receivedConsumed = append(receivedConsumed, tc.Consumed...)
			mu.Unlock()
			for {
				event, ok := events.Next()
				if !ok {
					break
				}
				mu.Lock()
				receivedEvents = append(receivedEvents, event)
				mu.Unlock()
			}
			return nil
		},
	})

	loop.Push("msg1")

	time.Sleep(100 * time.Millisecond)

	loop.Stop()
	result := loop.Wait()

	mu.Lock()
	defer mu.Unlock()

	if result.ExitReason != nil {
		t.Logf("exit reason: %v", result.ExitReason)
	}
	if len(receivedConsumed) == 0 {
		t.Error("should have received consumed items")
	}
}

// ======================== Stop with Checkpoint Cancel ========================

func TestTurnLoop_StopCheckPointIDInCancelError(t *testing.T) {
	ctx := context.Background()
	modelStarted := make(chan struct{}, 1)
	checkpointID := "turn-loop-cancel-ckpt-1"
	store := newTestStore()

	slowModel := &cancelTestChatModel{
		delayNs:     int64(500 * time.Millisecond),
		startedChan: modelStarted,
		doneChan:    make(chan struct{}, 1),
	}
	slowModel.addResp("Hello")

	agent := NewReActAgent(&ReActConfig[*schema.Message]{
		Instruction: "You are a test assistant",
		Model:       slowModel,
	}).WithName("TestAgent").WithDescription("Test agent")

	loop := newAndRunTurnLoop(ctx, AgentLoopConfig[string]{
		Store:        store,
		CheckpointID: checkpointID,
		GenInput:     genInputConsumeAllWithMsg,
		PrepareAgent: prepareAgent(agent),
	})

	loop.Push("msg1")

	<-modelStarted
	loop.Stop(WithImmediateStop())

	result := loop.Wait()
	t.Logf("exit reason: %v", result.ExitReason)
}

// ======================== CancelError Captured Independently ========================

func TestTurnLoop_CancelError_CapturedIndependentlyOfCallback(t *testing.T) {
	ctx := context.Background()
	modelStarted := make(chan struct{}, 1)
	checkpointID := "cancel-capture-independent-1"
	store := newTestStore()

	slowModel := &cancelTestChatModel{
		delayNs:     int64(500 * time.Millisecond),
		startedChan: modelStarted,
		doneChan:    make(chan struct{}, 1),
	}
	slowModel.addResp("Hello")

	agent := NewReActAgent(&ReActConfig[*schema.Message]{
		Instruction: "You are a test assistant",
		Model:       slowModel,
	}).WithName("TestAgent").WithDescription("Test agent")

	loop := newAndRunTurnLoop(ctx, AgentLoopConfig[string]{
		Store:        store,
		CheckpointID: checkpointID,
		GenInput:     genInputConsumeAllWithMsg,
		PrepareAgent: prepareAgent(agent),
		OnAgentEvents: func(ctx context.Context, tc *TurnContext[string], events *AsyncIterator[*AgentEvent]) error {
			for {
				_, ok := events.Next()
				if !ok {
					break
				}
			}
			return nil // swallow everything
		},
	})

	loop.Push("msg1")

	<-modelStarted
	loop.Stop(WithImmediateStop())

	result := loop.Wait()
	t.Logf("exit reason: %v", result.ExitReason)
}

// ======================== Stop Without CheckpointID ========================

func TestTurnLoop_StopWithoutCheckpointIDDoesNotPersist(t *testing.T) {
	ctx := context.Background()
	modelStarted := make(chan struct{}, 1)
	store := newTestStore()

	slowModel := &cancelTestChatModel{
		delayNs:     int64(500 * time.Millisecond),
		startedChan: modelStarted,
		doneChan:    make(chan struct{}, 1),
	}
	slowModel.addResp("Hello")

	agent := NewReActAgent(&ReActConfig[*schema.Message]{
		Instruction: "You are a test assistant",
		Model:       slowModel,
	}).WithName("TestAgent").WithDescription("Test agent")

	loop := newAndRunTurnLoop(ctx, AgentLoopConfig[string]{
		Store:        store,
		GenInput:     genInputConsumeAllWithMsg,
		PrepareAgent: prepareAgent(agent),
	})

	loop.Push("msg1")

	<-modelStarted
	loop.Stop(WithImmediateStop())

	loop.Wait()
}

// ======================== Stop While Idle ========================

func TestTurnLoop_StopWhileIdle_SkipsCheckpoint(t *testing.T) {
	ctx := context.Background()
	store := newTestStore()
	cpID := "idle-session"

	loop := newAndRunTurnLoop(ctx, AgentLoopConfig[string]{
		Store:        store,
		CheckpointID: cpID,
		GenInput:     genInputConsumeAll,
		PrepareAgent: prepareTestAgent,
	})

	loop.Stop()
	exit := loop.Wait()
	if exit.ExitReason != nil {
		t.Errorf("expected nil, got %v", exit.ExitReason)
	}
}

// ======================== Stop Call From GenInput ========================

func TestTurnLoop_StopCallFromGenInput(t *testing.T) {
	loop := newAndRunTurnLoop(context.Background(), AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, loop *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			loop.Stop()
			return &GenInputResult[string]{Input: &AgentInput{}, Consumed: items}, nil
		},
		PrepareAgent: prepareTestAgent,
	})

	loop.Push("msg1")

	result := loop.Wait()
	if result.ExitReason != nil {
		t.Errorf("expected nil, got %v", result.ExitReason)
	}
}

// ======================== Push From OnAgentEvents ========================

func TestTurnLoop_PushFromOnAgentEvents(t *testing.T) {
	pushCount := int32(0)

	loop := newAndRunTurnLoop(context.Background(), AgentLoopConfig[string]{
		GenInput:     genInputConsumeFirst,
		PrepareAgent: prepareTestAgent,
		OnAgentEvents: func(ctx context.Context, tc *TurnContext[string], events *AsyncIterator[*AgentEvent]) error {
			for {
				_, ok := events.Next()
				if !ok {
					break
				}
			}
			count := atomic.AddInt32(&pushCount, 1)
			if count == 1 {
				tc.Loop.Push("follow-up")
			} else {
				tc.Loop.Stop()
			}
			return nil
		},
	})

	loop.Push("initial")

	result := loop.Wait()
	if result.ExitReason != nil {
		t.Errorf("expected nil, got %v", result.ExitReason)
	}
	if atomic.LoadInt32(&pushCount) != 2 {
		t.Errorf("expected 2 pushes, got %d", atomic.LoadInt32(&pushCount))
	}
}

// ======================== NewAgentLoop: Push Before Run ========================

func TestNewTurnLoop_PushBeforeRun(t *testing.T) {
	var processedItems []string
	var mu sync.Mutex

	loop := NewAgentLoop(AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, _ *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			mu.Lock()
			processedItems = append(processedItems, items...)
			mu.Unlock()
			return &GenInputResult[string]{
				Input:    &AgentInput{Messages: []Message{schema.UserMessage(items[0])}},
				Consumed: items,
			}, nil
		},
		PrepareAgent: prepareTestAgent,
	})

	ok, _ := loop.Push("msg1")
	if !ok {
		t.Error("Push returned false")
	}
	ok, _ = loop.Push("msg2")
	if !ok {
		t.Error("Push returned false")
	}

	loop.Run(context.Background())

	time.Sleep(100 * time.Millisecond)

	loop.Stop()
	result := loop.Wait()

	mu.Lock()
	defer mu.Unlock()

	if result.ExitReason != nil {
		t.Errorf("expected nil, got %v", result.ExitReason)
	}
	if len(processedItems) == 0 {
		t.Error("expected processed items")
	}
}

// ======================== NewAgentLoop: Wait Before Run ========================

func TestNewTurnLoop_WaitBeforeRun(t *testing.T) {
	loop := NewAgentLoop(AgentLoopConfig[string]{
		GenInput:     genInputConsumeAll,
		PrepareAgent: prepareTestAgent,
	})

	waitDone := make(chan *AgentLoopState[string], 1)
	go func() {
		waitDone <- loop.Wait()
	}()

	select {
	case <-waitDone:
		t.Fatal("Wait returned before Run was called")
	case <-time.After(50 * time.Millisecond):
	}

	loop.Push("msg1")
	loop.Stop()
	loop.Run(context.Background())

	select {
	case result := <-waitDone:
		if result.ExitReason != nil {
			t.Errorf("expected nil, got %v", result.ExitReason)
		}
	case <-time.After(1 * time.Second):
		t.Fatal("Wait did not return after Run + Stop")
	}
}

// ======================== NewAgentLoop: Run Is Idempotent ========================

func TestNewTurnLoop_RunIsIdempotent(t *testing.T) {
	var genInputCalls int32

	loop := NewAgentLoop(AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, _ *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			atomic.AddInt32(&genInputCalls, 1)
			return &GenInputResult[string]{Input: &AgentInput{}, Consumed: items}, nil
		},
		PrepareAgent: prepareTestAgent,
	})

	loop.Push("msg1")
	loop.Run(context.Background())
	loop.Run(context.Background())
	loop.Run(context.Background())

	time.Sleep(100 * time.Millisecond)

	loop.Stop()
	result := loop.Wait()

	if result.ExitReason != nil {
		t.Errorf("expected nil, got %v", result.ExitReason)
	}
	if atomic.LoadInt32(&genInputCalls) < 1 {
		t.Error("expected at least 1 GenInput call")
	}
}

// ======================== NewAgentLoop: Concurrent Push And Run ========================

func TestNewTurnLoop_ConcurrentPushAndRun(t *testing.T) {
	for i := 0; i < 50; i++ {
		var count int32

		loop := NewAgentLoop(AgentLoopConfig[string]{
			GenInput: func(ctx context.Context, _ *AgentLoop[string], items []string) (*GenInputResult[string], error) {
				atomic.AddInt32(&count, int32(len(items)))
				return &GenInputResult[string]{Input: &AgentInput{}, Consumed: items}, nil
			},
			PrepareAgent: func(ctx context.Context, _ *AgentLoop[string], consumed []string) (Agent, error) {
				return NewReActAgent(&ReActConfig[*schema.Message]{Model: &mockModel{}}), nil
			},
		})

		var wg sync.WaitGroup
		wg.Add(2)

		go func() {
			defer wg.Done()
			loop.Push("item")
		}()

		go func() {
			defer wg.Done()
			loop.Run(context.Background())
		}()

		wg.Wait()

		time.Sleep(50 * time.Millisecond)

		loop.Stop()
		result := loop.Wait()

		processed := atomic.LoadInt32(&count)
		unhandled := len(result.UnhandledItems)
		if int(processed)+unhandled > 1 {
			t.Errorf("total should not exceed pushed amount: processed=%d unhandled=%d", processed, unhandled)
		}
	}
}

// ======================== Context Propagation ========================

type turnCtxKey struct{}

// TestTurnLoop_CtxPropagation verifies the parent context is propagated to
// PrepareAgent, the agent run, and OnAgentEvents.
func TestTurnLoop_CtxPropagation(t *testing.T) {
	const traceVal = "trace-123"
	var prepareCtxVal, eventsCtxVal string

	ctx := context.WithValue(context.Background(), turnCtxKey{}, traceVal)

	cfg := AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, loop *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			return &GenInputResult[string]{
				Input:    &AgentInput{Messages: []Message{schema.UserMessage(items[0])}},
				Consumed: items,
			}, nil
		},
		PrepareAgent: func(ctx context.Context, loop *AgentLoop[string], consumed []string) (Agent, error) {
			if v, ok := ctx.Value(turnCtxKey{}).(string); ok {
				prepareCtxVal = v
			}
			return &turnLoopMockAgent{
				name: "trace-agent",
				GenerateFn: func(ctx context.Context, msgs []Message) (Message, error) {
					return &schema.Message{Role: schema.RoleAssistant, Content: "done"}, nil
				},
			}, nil
		},
		OnAgentEvents: func(ctx context.Context, tc *TurnContext[string], events *AsyncIterator[*AgentEvent]) error {
			if v, ok := ctx.Value(turnCtxKey{}).(string); ok {
				eventsCtxVal = v
			}
			for {
				if _, ok := events.Next(); !ok {
					break
				}
			}
			tc.Loop.Stop()
			return nil
		},
	}

	loop := NewAgentLoop(cfg)
	loop.Push("hello")
	loop.Run(ctx)
	result := loop.Wait()

	if result.ExitReason != nil {
		t.Errorf("expected nil, got %v", result.ExitReason)
	}
	if prepareCtxVal != traceVal {
		t.Errorf("PrepareAgent should receive parent context: got %q", prepareCtxVal)
	}
	if eventsCtxVal != traceVal {
		t.Errorf("OnAgentEvents should receive parent context: got %q", eventsCtxVal)
	}
}

// ======================== TurnContext Stopped Channel ========================

func TestTurnLoop_TurnContext_StoppedChannel(t *testing.T) {
	stoppedSeen := make(chan struct{})
	agentStarted := make(chan struct{})

	loop := newAndRunTurnLoop(context.Background(), AgentLoopConfig[string]{
		GenInput: genInputConsumeAllWithMsg,
		PrepareAgent: func(ctx context.Context, _ *AgentLoop[string], consumed []string) (Agent, error) {
			return &turnLoopCancellableMockAgent{
				name: "slow",
				runFunc: func(ctx context.Context, input *AgentInput) (*AgentOutput, error) {
					<-ctx.Done()
					return nil, ctx.Err()
				},
			}, nil
		},
		OnAgentEvents: func(ctx context.Context, tc *TurnContext[string], events *AsyncIterator[*AgentEvent]) error {
			close(agentStarted)
			select {
			case <-tc.Stopped:
				close(stoppedSeen)
			case <-time.After(5 * time.Second):
				t.Error("timed out waiting for Stopped channel")
			}
			for {
				if _, ok := events.Next(); !ok {
					break
				}
			}
			return nil
		},
	})

	loop.Push("msg1")
	<-agentStarted
	loop.Stop(WithImmediateStop())

	select {
	case <-stoppedSeen:
		// success
	case <-time.After(5 * time.Second):
		t.Fatal("stopped channel was never observed in OnAgentEvents")
	}

	loop.Wait()
}

// ======================== Stop With Skip Checkpoint ========================

func TestTurnLoop_StopWithSkipCheckpoint(t *testing.T) {
	ctx := context.Background()
	store := newTestStore()
	cpID := "skip-cp-session"

	loop := NewAgentLoop(AgentLoopConfig[string]{
		Store:        store,
		CheckpointID: cpID,
		GenInput:     genInputConsumeAll,
		PrepareAgent: prepareTestAgent,
	})

	loop.Push("a")
	loop.Push("b")
	loop.Stop(WithSkipCheckpoint())
	loop.Run(ctx)

	exit := loop.Wait()
	if exit.ExitReason != nil {
		t.Errorf("expected nil, got %v", exit.ExitReason)
	}
}

// ======================== Stop With Stop Cause ========================

func TestTurnLoop_StopWithStopCause(t *testing.T) {
	ctx := context.Background()
	cause := "user session timeout"

	loop := newAndRunTurnLoop(ctx, AgentLoopConfig[string]{
		GenInput:     genInputConsumeAll,
		PrepareAgent: prepareTestAgent,
	})

	loop.Push("a")
	loop.Stop(WithStopCause(cause))

	exit := loop.Wait()
	if exit.StopCause != cause {
		t.Errorf("expected %q, got %q", cause, exit.StopCause)
	}
}

func TestTurnLoop_StopCause_EmptyWhenNoStop(t *testing.T) {
	ctx := context.Background()

	loop := newAndRunTurnLoop(ctx, AgentLoopConfig[string]{
		GenInput:     genInputConsumeAll,
		PrepareAgent: prepareTestAgent,
	})

	loop.Stop()
	exit := loop.Wait()
	if exit.StopCause != "" {
		t.Errorf("expected empty, got %q", exit.StopCause)
	}
}

func TestTurnLoop_StopCause_InTurnContext(t *testing.T) {
	cause := "business shutdown"
	gotCause := make(chan string, 1)
	agentStarted := make(chan struct{})

	loop := newAndRunTurnLoop(context.Background(), AgentLoopConfig[string]{
		GenInput: genInputConsumeAllWithMsg,
		PrepareAgent: func(ctx context.Context, _ *AgentLoop[string], consumed []string) (Agent, error) {
			return &turnLoopCancellableMockAgent{
				name: "slow",
				runFunc: func(ctx context.Context, input *AgentInput) (*AgentOutput, error) {
					<-ctx.Done()
					return nil, ctx.Err()
				},
			}, nil
		},
		OnAgentEvents: func(ctx context.Context, tc *TurnContext[string], events *AsyncIterator[*AgentEvent]) error {
			close(agentStarted)
			select {
			case <-tc.Stopped:
				gotCause <- tc.StopCause()
			case <-time.After(5 * time.Second):
				t.Error("timed out waiting for Stopped channel")
			}
			for {
				if _, ok := events.Next(); !ok {
					break
				}
			}
			return nil
		},
	})

	loop.Push("msg1")
	<-agentStarted
	loop.Stop(WithImmediateStop(), WithStopCause(cause))

	select {
	case c := <-gotCause:
		if c != cause {
			t.Errorf("expected %q, got %q", cause, c)
		}
	case <-time.After(5 * time.Second):
		t.Fatal("timed out waiting for StopCause in TurnContext")
	}

	exit := loop.Wait()
	if exit.StopCause != cause {
		t.Errorf("expected %q, got %q", cause, exit.StopCause)
	}
}

func TestTurnLoop_StopCause_FirstNonEmptyWins(t *testing.T) {
	agentStarted := make(chan struct{})

	loop := newAndRunTurnLoop(context.Background(), AgentLoopConfig[string]{
		GenInput: genInputConsumeAllWithMsg,
		PrepareAgent: func(ctx context.Context, _ *AgentLoop[string], consumed []string) (Agent, error) {
			return &turnLoopCancellableMockAgent{
				name: "slow",
				runFunc: func(ctx context.Context, input *AgentInput) (*AgentOutput, error) {
					<-ctx.Done()
					return nil, ctx.Err()
				},
			}, nil
		},
		OnAgentEvents: func(ctx context.Context, tc *TurnContext[string], events *AsyncIterator[*AgentEvent]) error {
			close(agentStarted)
			for {
				if _, ok := events.Next(); !ok {
					break
				}
			}
			return nil
		},
	})

	loop.Push("msg1")
	<-agentStarted
	loop.Stop(WithGracefulStop(), WithStopCause("first cause"))
	loop.Stop(WithStopCause("second cause"))

	exit := loop.Wait()
	if exit.StopCause != "first cause" {
		t.Errorf("expected 'first cause', got %q", exit.StopCause)
	}
}

// ======================== Stop Before Run ========================

func TestTurnLoop_StopBeforeRun_PushThenStop(t *testing.T) {
	loop := NewAgentLoop(AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, _ *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			t.Fatal("GenInput should not be called when Stop is called before Run")
			return nil, nil
		},
		PrepareAgent: func(ctx context.Context, _ *AgentLoop[string], consumed []string) (Agent, error) {
			t.Fatal("PrepareAgent should not be called when Stop is called before Run")
			return nil, nil
		},
	})

	ok, _ := loop.Push("item1")
	if !ok {
		t.Error("Push returned false")
	}
	ok, _ = loop.Push("item2")
	if !ok {
		t.Error("Push returned false")
	}

	loop.Stop()
	loop.Run(context.Background())
	result := loop.Wait()

	if result.ExitReason != nil {
		t.Errorf("expected nil, got %v", result.ExitReason)
	}
}

// ======================== Skip Checkpoint Sticky ========================

func TestTurnLoop_SkipCheckpoint_Sticky(t *testing.T) {
	agentStarted := make(chan struct{})

	store := newTestStore()
	cpID := "sticky-skip-session"

	loop := newAndRunTurnLoop(context.Background(), AgentLoopConfig[string]{
		Store:        store,
		CheckpointID: cpID,
		GenInput:     genInputConsumeAllWithMsg,
		PrepareAgent: func(ctx context.Context, _ *AgentLoop[string], consumed []string) (Agent, error) {
			return &turnLoopCancellableMockAgent{
				name: "slow",
				runFunc: func(ctx context.Context, input *AgentInput) (*AgentOutput, error) {
					<-ctx.Done()
					return nil, ctx.Err()
				},
			}, nil
		},
		OnAgentEvents: func(ctx context.Context, tc *TurnContext[string], events *AsyncIterator[*AgentEvent]) error {
			close(agentStarted)
			for {
				if _, ok := events.Next(); !ok {
					break
				}
			}
			return nil
		},
	})

	loop.Push("msg1")
	<-agentStarted
	loop.Stop(WithGracefulStop(), WithSkipCheckpoint())
	loop.Stop()

	exit := loop.Wait()
	_ = exit
	t.Logf("skip checkpoint sticky: exit=%v", exit.ExitReason)
}

// ======================== GenInput Error Recovery ========================

func TestTurnLoop_GenInputError_RecoverItems(t *testing.T) {
	genErr := errors.New("gen input error")

	loop := newAndRunTurnLoop(context.Background(), AgentLoopConfig[string]{
		GenInput: func(ctx context.Context, _ *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			return nil, genErr
		},
		PrepareAgent: prepareTestAgent,
	})

	loop.Push("msg1")
	loop.Push("msg2")

	result := loop.Wait()
	if !errors.Is(result.ExitReason, genErr) {
		t.Errorf("expected genErr, got %v", result.ExitReason)
	}
}

// ======================== Checkpoint Not Found ========================

func TestTurnLoop_CheckpointNotFound_FreshStart(t *testing.T) {
	ctx := context.Background()
	store := newTestStore()
	var genInputCalled bool
	loop := NewAgentLoop(AgentLoopConfig[string]{
		Store:        store,
		CheckpointID: "nonexistent-id",
		GenInput: func(ctx context.Context, _ *AgentLoop[string], items []string) (*GenInputResult[string], error) {
			genInputCalled = true
			return &GenInputResult[string]{Input: &AgentInput{}, Consumed: items}, nil
		},
		PrepareAgent: prepareTestAgent,
		OnAgentEvents: func(ctx context.Context, tc *TurnContext[string], events *AsyncIterator[*AgentEvent]) error {
			for {
				if _, ok := events.Next(); !ok {
					break
				}
			}
			tc.Loop.Stop()
			return nil
		},
	})
	loop.Push("a")
	loop.Run(ctx)
	exit := loop.Wait()
	if exit.ExitReason != nil {
		t.Errorf("expected nil, got %v", exit.ExitReason)
	}
	if !genInputCalled {
		t.Error("GenInput should be called when checkpoint not found")
	}
}

// ======================== TurnBuffer Tests ========================

func TestAttack_TurnBuffer_WakeupDoesNotLoseItems(t *testing.T) {
	tb := newTurnBuffer[string]()

	tb.TrySend("a")
	tb.TrySend("b")
	tb.Wakeup()
	tb.TrySend("c")

	var got []string
	for i := 0; i < 3; i++ {
		val, ok := tb.Receive()
		if !ok {
			t.Fatal("expected ok")
		}
		got = append(got, val)
	}

	if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" {
		t.Errorf("expected [a b c], got %v", got)
	}
}

// ======================== AgentLoop Preempt During Planning ========================
