package automation

import (
	"context"
	"encoding/json"
	"sync"
	"testing"

	"github.com/nexuscrm/shared/pkg/models"
)

// helper builds a minimal *FlowRuntimeContext for tests that only need a
// non-nil context and no user session.
func newTestFRC() *FlowRuntimeContext {
	return NewFlowRuntimeContext(context.Background(), nil)
}

// helper builds a test user session
func newTestUser(id string) *models.UserSession {
	return &models.UserSession{ID: id}
}

// ---------------------------------------------------------------------------
// T1 — Constructor returns non-nil; all accessors return zero values; no panic
// ---------------------------------------------------------------------------

func TestFlowRuntimeContext_Constructor_NonNil(t *testing.T) {
	frc := NewFlowRuntimeContext(context.Background(), nil)
	if frc == nil {
		t.Fatal("NewFlowRuntimeContext returned nil")
	}
}

func TestFlowRuntimeContext_Constructor_ZeroValues(t *testing.T) {
	frc := newTestFRC()

	// InterviewPersistMode: zero is Save
	if got := frc.GetInterviewPersistMode(); got != InterviewPersistModeSave {
		t.Errorf("GetInterviewPersistMode() = %v; want InterviewPersistModeSave", got)
	}
	if !frc.ShouldPersistInterview() {
		t.Error("ShouldPersistInterview() = false on zero-value; want true")
	}

	// BeforeSaveFlow: zero is false
	if frc.IsBeforeSaveFlow() {
		t.Error("IsBeforeSaveFlow() = true on zero-value; want false")
	}

	// AtomicBoundary: zero is false
	if frc.IsWithinAtomicBoundary() {
		t.Error("IsWithinAtomicBoundary() = true on zero-value; want false")
	}

	// PreviewMode: zero is false (default — no preview suppression on
	// production execution paths).
	if frc.IsPreviewMode() {
		t.Error("IsPreviewMode() = true on zero-value; want false")
	}
	if identity, ok := frc.CurrentFlow(); ok {
		t.Errorf("CurrentFlow() = %#v, true on zero-value; want false", identity)
	}
	if got := frc.CurrentFlowOwnerPack(); got != "" {
		t.Errorf("CurrentFlowOwnerPack() = %q on zero-value; want empty", got)
	}

	// CallStack: empty
	if d := frc.CallStackDepth(); d != 0 {
		t.Errorf("CallStackDepth() = %d on zero-value; want 0", d)
	}
	if s := frc.GetCallStack(); s != nil {
		t.Errorf("GetCallStack() = %v on zero-value; want nil", s)
	}

	// RecordTriggerStack: empty
	if s := frc.GetRecordTriggerStack(); s != nil {
		t.Errorf("GetRecordTriggerStack() = %v on zero-value; want nil", s)
	}
}

func TestFlowRuntimeContext_Constructor_ContextAndUser(t *testing.T) {
	ctx := context.Background()
	user := newTestUser("u1")
	frc := NewFlowRuntimeContext(ctx, user)

	if got := frc.Context(); got != ctx {
		t.Errorf("Context() = %v; want %v", got, ctx)
	}
	if got := frc.User(); got != user {
		t.Errorf("User() = %v; want %v", got, user)
	}
}

func TestMarshalPersistableContext_StripsLoopRuntimeKeys(t *testing.T) {
	ctxData := map[string]interface{}{
		"kept":              "value",
		"_loop_Merge_index": 2,
		"_loop_Merge_items": []interface{}{"a", "b"},
		"$Record":           models.SObject{"Id": "001"},
		"$Record__Old":      models.SObject{"Id": "000"},
		"$Record__Prior":    models.SObject{"Id": "000"},
	}

	data, err := marshalPersistableContext(ctxData)
	if err != nil {
		t.Fatalf("marshalPersistableContext returned error: %v", err)
	}

	var got map[string]interface{}
	if err := json.Unmarshal(data, &got); err != nil {
		t.Fatalf("json.Unmarshal returned error: %v", err)
	}

	if got["kept"] != "value" {
		t.Fatalf("kept = %v; want value preserved", got["kept"])
	}
	for _, key := range []string{"_loop_Merge_index", "_loop_Merge_items", "$Record", "$Record__Old", "$Record__Prior"} {
		if _, exists := got[key]; exists {
			t.Fatalf("%s leaked into persistable context: %#v", key, got)
		}
	}
}

func TestFlowRuntimeContext_LoopStateRoundTrip(t *testing.T) {
	frc := newTestFRC()

	if got, ok := frc.LoopIndex("Loop_Merge"); ok || got != 0 {
		t.Fatalf("LoopIndex before set = (%d, %v); want (0, false)", got, ok)
	}

	frc.SetLoopIndex("Loop_Merge", 2)

	if got, ok := frc.LoopIndex("Loop_Merge"); !ok || got != 2 {
		t.Fatalf("LoopIndex after set = (%d, %v); want (2, true)", got, ok)
	}

	frc.ClearLoopState("Loop_Merge")
	if got, ok := frc.LoopIndex("Loop_Merge"); ok || got != 0 {
		t.Fatalf("LoopIndex after clear = (%d, %v); want (0, false)", got, ok)
	}
}

func TestFlowRuntimeContext_PushCallStackResetsLoopState(t *testing.T) {
	frc := newTestFRC()
	frc.SetLoopIndex("Loop_Merge", 1)

	child := frc.PushCallStack("Subflow_A")
	if got, ok := child.LoopIndex("Loop_Merge"); ok || got != 0 {
		t.Fatalf("child LoopIndex = (%d, %v); want (0, false)", got, ok)
	}
	if got, ok := frc.LoopIndex("Loop_Merge"); !ok || got != 1 {
		t.Fatalf("parent LoopIndex after PushCallStack = (%d, %v); want (1, true)", got, ok)
	}
}

// ---------------------------------------------------------------------------
// T2 — NilSafety: nil receiver must not panic
// ---------------------------------------------------------------------------

func TestFlowRuntimeContext_NilSafety(t *testing.T) {
	var frc *FlowRuntimeContext

	// None of these must panic
	_ = frc.Context()
	_ = frc.User()
	_ = frc.GetInterviewPersistMode()
	_ = frc.ShouldPersistInterview()
	_ = frc.IsBeforeSaveFlow()
	_ = frc.IsWithinAtomicBoundary()
	_ = frc.IsPreviewMode()
	_, _ = frc.CurrentFlow()
	_ = frc.CurrentFlowOwnerPack()
	_ = frc.GetCallStack()
	_ = frc.CallStackDepth()
	_ = frc.HasFlowInCallStack("anything")
	_ = frc.GetRecordTriggerStack()
	_ = frc.HasRecordTrigger("Obj", "r1")

	// PushCallStack on nil produces a non-nil result
	if pushed := frc.PushCallStack("X"); pushed == nil {
		t.Error("PushCallStack on nil returned nil")
	}

	// PopCallStack on nil returns ok=false, no panic
	_, _, ok := frc.PopCallStack()
	if ok {
		t.Error("PopCallStack on nil returned ok=true; want false")
	}

	// PushRecordTrigger on nil returns non-nil clone
	pushed, alreadyPresent := frc.PushRecordTrigger("Obj", "r1")
	if pushed == nil {
		t.Error("PushRecordTrigger on nil returned nil")
	}
	if alreadyPresent {
		t.Error("PushRecordTrigger on nil returned alreadyPresent=true; want false")
	}
}

// ---------------------------------------------------------------------------
// T3 — WithInterviewPersistMode round-trip + ShouldPersistInterview semantics
// ---------------------------------------------------------------------------

func TestFlowRuntimeContext_InterviewPersistMode_RoundTrip(t *testing.T) {
	frc := newTestFRC()

	transient := frc.WithInterviewPersistMode(InterviewPersistModeTransient)
	if got := transient.GetInterviewPersistMode(); got != InterviewPersistModeTransient {
		t.Errorf("after WithInterviewPersistMode(Transient): got %v; want Transient", got)
	}
	if transient.ShouldPersistInterview() {
		t.Error("ShouldPersistInterview() should be false when mode is Transient")
	}

	save := frc.WithInterviewPersistMode(InterviewPersistModeSave)
	if got := save.GetInterviewPersistMode(); got != InterviewPersistModeSave {
		t.Errorf("after WithInterviewPersistMode(Save): got %v; want Save", got)
	}
	if !save.ShouldPersistInterview() {
		t.Error("ShouldPersistInterview() should be true when mode is Save")
	}
}

func TestFlowRuntimeContext_InterviewPersistMode_Immutability(t *testing.T) {
	p1 := newTestFRC() // Save by default
	p2 := p1.WithInterviewPersistMode(InterviewPersistModeTransient)

	// p1 must be unchanged
	if got := p1.GetInterviewPersistMode(); got != InterviewPersistModeSave {
		t.Errorf("p1.GetInterviewPersistMode() = %v after WithInterviewPersistMode on derived; want Save", got)
	}
	// p2 must reflect the new mode
	if got := p2.GetInterviewPersistMode(); got != InterviewPersistModeTransient {
		t.Errorf("p2.GetInterviewPersistMode() = %v; want Transient", got)
	}
}

// ---------------------------------------------------------------------------
// T4 — WithBeforeSaveFlow round-trip + immutability
// ---------------------------------------------------------------------------

func TestFlowRuntimeContext_BeforeSaveFlow_RoundTrip(t *testing.T) {
	frc := newTestFRC()
	marked := frc.WithBeforeSaveFlow(true)
	if !marked.IsBeforeSaveFlow() {
		t.Error("IsBeforeSaveFlow() = false after WithBeforeSaveFlow(true); want true")
	}

	cleared := marked.WithBeforeSaveFlow(false)
	if cleared.IsBeforeSaveFlow() {
		t.Error("IsBeforeSaveFlow() = true after WithBeforeSaveFlow(false); want false")
	}
}

func TestFlowRuntimeContext_BeforeSaveFlow_Immutability(t *testing.T) {
	p1 := newTestFRC()
	p2 := p1.WithBeforeSaveFlow(true)

	// p1 must be unchanged
	if p1.IsBeforeSaveFlow() {
		t.Error("p1.IsBeforeSaveFlow() = true after WithBeforeSaveFlow on derived; want false")
	}
	// p2 must reflect the new value
	if !p2.IsBeforeSaveFlow() {
		t.Error("p2.IsBeforeSaveFlow() = false; want true")
	}
}

// ---------------------------------------------------------------------------
// T5 — WithAtomicBoundary round-trip + immutability
// ---------------------------------------------------------------------------

func TestFlowRuntimeContext_AtomicBoundary_RoundTrip(t *testing.T) {
	frc := newTestFRC()
	marked := frc.WithAtomicBoundary(true)
	if !marked.IsWithinAtomicBoundary() {
		t.Error("IsWithinAtomicBoundary() = false after WithAtomicBoundary(true); want true")
	}

	cleared := marked.WithAtomicBoundary(false)
	if cleared.IsWithinAtomicBoundary() {
		t.Error("IsWithinAtomicBoundary() = true after WithAtomicBoundary(false); want false")
	}
}

func TestFlowRuntimeContext_AtomicBoundary_Immutability(t *testing.T) {
	p1 := newTestFRC()
	p2 := p1.WithAtomicBoundary(true)

	if p1.IsWithinAtomicBoundary() {
		t.Error("p1.IsWithinAtomicBoundary() = true after WithAtomicBoundary on derived; want false")
	}
	if !p2.IsWithinAtomicBoundary() {
		t.Error("p2.IsWithinAtomicBoundary() = false; want true")
	}
}

func TestFlowRuntimeContext_CurrentFlow_RoundTrip(t *testing.T) {
	frc := newTestFRC()
	marked := frc.WithCurrentFlow(FlowExecutionIdentity{
		APIName:                "  SyncContact  ",
		DefinitionID:           " def-1 ",
		VersionID:              " version-1 ",
		OwnerPack:              " connector-data ",
		DefinitionIsCustomized: true,
		VersionSource:          " standard ",
	})

	identity, ok := marked.CurrentFlow()
	if !ok {
		t.Fatal("CurrentFlow() ok = false; want true")
	}
	if identity.APIName != "SyncContact" {
		t.Errorf("APIName = %q; want SyncContact", identity.APIName)
	}
	if identity.DefinitionID != "def-1" {
		t.Errorf("DefinitionID = %q; want def-1", identity.DefinitionID)
	}
	if identity.VersionID != "version-1" {
		t.Errorf("VersionID = %q; want version-1", identity.VersionID)
	}
	if identity.OwnerPack != "connector-data" {
		t.Errorf("OwnerPack = %q; want connector-data", identity.OwnerPack)
	}
	if !identity.DefinitionIsCustomized {
		t.Error("DefinitionIsCustomized = false; want true")
	}
	if identity.VersionSource != "standard" {
		t.Errorf("VersionSource = %q; want standard", identity.VersionSource)
	}
	if got := marked.CurrentFlowOwnerPack(); got != "" {
		t.Errorf("CurrentFlowOwnerPack() = %q for customized flow; want empty", got)
	}
}

func TestFlowRuntimeContext_CurrentFlowOwnerPackRequiresPackOwnedStandardFlow(t *testing.T) {
	tests := []struct {
		name     string
		identity FlowExecutionIdentity
		want     string
	}{
		{
			name: "standard pack flow",
			identity: FlowExecutionIdentity{
				OwnerPack:     " connector-data ",
				VersionSource: flowSourceStandard,
				DefinitionID:  "def-1",
				VersionID:     "flow-version-1",
			},
			want: "connector-data",
		},
		{
			name: "customized pack definition",
			identity: FlowExecutionIdentity{
				OwnerPack:              "connector-data",
				DefinitionIsCustomized: true,
				VersionSource:          flowSourceStandard,
			},
		},
		{
			name: "custom source version",
			identity: FlowExecutionIdentity{
				OwnerPack:     "connector-data",
				VersionSource: "custom",
				DefinitionID:  "def-1",
				VersionID:     "flow-version-1",
			},
		},
		{
			name: "missing owner pack",
			identity: FlowExecutionIdentity{
				VersionSource: flowSourceStandard,
			},
		},
		{
			name: "missing version source",
			identity: FlowExecutionIdentity{
				OwnerPack: "connector-data",
			},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			frc := newTestFRC().WithCurrentFlow(tc.identity)
			if got := frc.CurrentFlowOwnerPack(); got != tc.want {
				t.Errorf("CurrentFlowOwnerPack() = %q; want %q", got, tc.want)
			}
		})
	}
}

func TestFlowRuntimeContext_CurrentFlow_Immutability(t *testing.T) {
	p1 := newTestFRC()
	p2 := p1.WithCurrentFlow(FlowExecutionIdentity{OwnerPack: "pack-a", VersionSource: flowSourceStandard})
	p3 := p2.WithCurrentFlow(FlowExecutionIdentity{OwnerPack: "pack-b", VersionSource: flowSourceStandard})

	if _, ok := p1.CurrentFlow(); ok {
		t.Fatal("p1.CurrentFlow() ok = true after WithCurrentFlow on derived; want false")
	}
	if got := p2.CurrentFlowOwnerPack(); got != "pack-a" {
		t.Errorf("p2.CurrentFlowOwnerPack() = %q; want pack-a", got)
	}
	if got := p3.CurrentFlowOwnerPack(); got != "pack-b" {
		t.Errorf("p3.CurrentFlowOwnerPack() = %q; want pack-b", got)
	}
}

func TestFlowRuntimeContext_CurrentFlow_PreservedAcrossCallStackPush(t *testing.T) {
	frc := newTestFRC().WithCurrentFlow(FlowExecutionIdentity{OwnerPack: "parent-pack", VersionSource: flowSourceStandard})
	pushed := frc.PushCallStack("ChildFlow")

	if got := pushed.CurrentFlowOwnerPack(); got != "parent-pack" {
		t.Errorf("CurrentFlowOwnerPack() after PushCallStack = %q; want parent-pack until executeFlowVersion replaces it", got)
	}
}

// ---------------------------------------------------------------------------
// T6 — PushCallStack / PopCallStack round-trip + immutability + slice-copy
// ---------------------------------------------------------------------------

func TestFlowRuntimeContext_CallStack_RoundTrip(t *testing.T) {
	frc := newTestFRC()
	p1 := frc.PushCallStack("FlowA")
	p2 := p1.PushCallStack("FlowB")

	if d := p2.CallStackDepth(); d != 2 {
		t.Errorf("CallStackDepth() = %d after two pushes; want 2", d)
	}

	stack := p2.GetCallStack()
	if len(stack) != 2 || stack[0] != "FlowA" || stack[1] != "FlowB" {
		t.Errorf("GetCallStack() = %v; want [FlowA FlowB]", stack)
	}

	// Pop
	p3, top, ok := p2.PopCallStack()
	if !ok {
		t.Error("PopCallStack() ok=false; want true")
	}
	if top != "FlowB" {
		t.Errorf("PopCallStack() top = %q; want FlowB", top)
	}
	if p3.CallStackDepth() != 1 {
		t.Errorf("CallStackDepth() after pop = %d; want 1", p3.CallStackDepth())
	}
}

func TestFlowRuntimeContext_CallStack_PopEmpty(t *testing.T) {
	frc := newTestFRC()
	_, _, ok := frc.PopCallStack()
	if ok {
		t.Error("PopCallStack on empty returned ok=true; want false")
	}
}

func TestFlowRuntimeContext_CallStack_Immutability(t *testing.T) {
	p1 := newTestFRC()
	p2 := p1.PushCallStack("FlowA")

	// p1 must still be empty
	if p1.CallStackDepth() != 0 {
		t.Errorf("p1.CallStackDepth() = %d after push on derived; want 0", p1.CallStackDepth())
	}
	// p2 must have 1
	if p2.CallStackDepth() != 1 {
		t.Errorf("p2.CallStackDepth() = %d; want 1", p2.CallStackDepth())
	}

	// Pop from p2 must not affect p2
	p3, _, _ := p2.PopCallStack()
	if p2.CallStackDepth() != 1 {
		t.Errorf("p2.CallStackDepth() changed after pop on derived; want 1")
	}
	if p3.CallStackDepth() != 0 {
		t.Errorf("p3.CallStackDepth() = %d; want 0", p3.CallStackDepth())
	}
}

func TestFlowRuntimeContext_CallStack_SliceCopyOnPush(t *testing.T) {
	// Verifies that modifying the slice returned by GetCallStack does not affect the stored stack.
	p1 := newTestFRC().PushCallStack("FlowA")
	returned := p1.GetCallStack()
	returned[0] = "MUTATED"

	// The stored call stack must still show FlowA
	stored := p1.GetCallStack()
	if len(stored) == 0 || stored[0] != "FlowA" {
		t.Errorf("CallStack corrupted by external mutation: got %v; want [FlowA]", stored)
	}
}

func TestFlowRuntimeContext_CallStack_PushDoesNotShareStorage(t *testing.T) {
	// Verifies that two Push calls from the same parent do not share underlying array.
	parent := newTestFRC().PushCallStack("FlowA")
	childB := parent.PushCallStack("FlowB")
	childC := parent.PushCallStack("FlowC")

	// childB stack: [FlowA, FlowB]
	// childC stack: [FlowA, FlowC]
	bStack := childB.GetCallStack()
	cStack := childC.GetCallStack()

	if len(bStack) != 2 || bStack[1] != "FlowB" {
		t.Errorf("childB stack = %v; want [FlowA FlowB]", bStack)
	}
	if len(cStack) != 2 || cStack[1] != "FlowC" {
		t.Errorf("childC stack = %v; want [FlowA FlowC]", cStack)
	}
}

func TestFlowRuntimeContext_HasFlowInCallStack(t *testing.T) {
	frc := newTestFRC().PushCallStack("FlowA").PushCallStack("FlowB")

	if !frc.HasFlowInCallStack("FlowA") {
		t.Error("HasFlowInCallStack('FlowA') = false; want true")
	}
	if !frc.HasFlowInCallStack("flowa") { // case-insensitive
		t.Error("HasFlowInCallStack('flowa') = false; want true (case-insensitive)")
	}
	if frc.HasFlowInCallStack("FlowC") {
		t.Error("HasFlowInCallStack('FlowC') = true; want false")
	}
}

// ---------------------------------------------------------------------------
// T7 — PushRecordTrigger round-trip + immutability + map-copy
// ---------------------------------------------------------------------------

func TestFlowRuntimeContext_RecordTrigger_RoundTrip(t *testing.T) {
	frc := newTestFRC()
	p1, alreadyPresent := frc.PushRecordTrigger("Opportunity", "rec001")
	if alreadyPresent {
		t.Error("PushRecordTrigger first push: alreadyPresent=true; want false")
	}

	// Pushing the same pair again must return alreadyPresent=true
	p2, alreadyPresent2 := p1.PushRecordTrigger("Opportunity", "rec001")
	if !alreadyPresent2 {
		t.Error("PushRecordTrigger duplicate push: alreadyPresent=false; want true")
	}

	// p2 must still have the entry (alreadyPresent clone preserves state)
	if !p2.HasRecordTrigger("Opportunity", "rec001") {
		t.Error("HasRecordTrigger after duplicate push: false; want true")
	}

	// Different recordID is allowed
	p3, alreadyPresent3 := p1.PushRecordTrigger("Opportunity", "rec002")
	if alreadyPresent3 {
		t.Error("PushRecordTrigger different ID: alreadyPresent=true; want false")
	}
	if !p3.HasRecordTrigger("Opportunity", "rec002") {
		t.Error("HasRecordTrigger rec002: false; want true")
	}
}

func TestFlowRuntimeContext_RecordTrigger_EmptyRecordID(t *testing.T) {
	frc := newTestFRC()
	// Empty recordID must be a no-op (BeforeCreate path)
	p, alreadyPresent := frc.PushRecordTrigger("Lead", "")
	if alreadyPresent {
		t.Error("PushRecordTrigger empty recordID: alreadyPresent=true; want false")
	}
	if p.HasRecordTrigger("Lead", "") {
		t.Error("HasRecordTrigger empty recordID: true; want false")
	}
	// Stack must still be empty
	if len(p.GetRecordTriggerStack()) != 0 {
		t.Errorf("GetRecordTriggerStack after empty-ID push: len=%d; want 0", len(p.GetRecordTriggerStack()))
	}
}

func TestFlowRuntimeContext_RecordTrigger_Immutability(t *testing.T) {
	p1 := newTestFRC()
	p2, _ := p1.PushRecordTrigger("Account", "acc001")

	// p1 must not have the entry
	if p1.HasRecordTrigger("Account", "acc001") {
		t.Error("p1 has record trigger after push on derived; want false")
	}
	// p2 must have it
	if !p2.HasRecordTrigger("Account", "acc001") {
		t.Error("p2 missing record trigger; want true")
	}
}

func TestFlowRuntimeContext_RecordTrigger_MapCopyOnPush(t *testing.T) {
	// Two parallel pushes from the same parent must not interfere
	parent := newTestFRC()
	childA, _ := parent.PushRecordTrigger("Contact", "c001")
	childB, _ := parent.PushRecordTrigger("Contact", "c002")

	// childA must NOT see c002
	if childA.HasRecordTrigger("Contact", "c002") {
		t.Error("childA has Contact:c002 that was only pushed to childB")
	}
	// childB must NOT see c001
	if childB.HasRecordTrigger("Contact", "c001") {
		t.Error("childB has Contact:c001 that was only pushed to childA")
	}
}

func TestFlowRuntimeContext_RecordTrigger_CaseInsensitiveKey(t *testing.T) {
	frc := newTestFRC()
	p1, _ := frc.PushRecordTrigger("Opportunity", "REC001")

	// HasRecordTrigger with same recordID but different-cased objectName
	if !p1.HasRecordTrigger("opportunity", "REC001") {
		t.Error("HasRecordTrigger: case-insensitive objectApiName not matched")
	}
}

// ---------------------------------------------------------------------------
// T8 — Goroutine-safety smoke: concurrent pushes from same parent are independent
// ---------------------------------------------------------------------------

func TestFlowRuntimeContext_Goroutine_IndependentPushes(t *testing.T) {
	parent := newTestFRC().PushCallStack("Root")

	const n = 20
	results := make([]*FlowRuntimeContext, n)
	var wg sync.WaitGroup
	wg.Add(n)
	for i := 0; i < n; i++ {
		i := i
		go func() {
			defer wg.Done()
			name := "Flow" + string(rune('A'+i%26))
			results[i] = parent.PushCallStack(name)
		}()
	}
	wg.Wait()

	// Each derived context must have exactly 2 entries: Root + its own entry
	for i, r := range results {
		if r.CallStackDepth() != 2 {
			t.Errorf("results[%d].CallStackDepth() = %d; want 2", i, r.CallStackDepth())
		}
		// First entry must still be Root
		stack := r.GetCallStack()
		if stack[0] != "Root" {
			t.Errorf("results[%d].CallStack()[0] = %q; want Root", i, stack[0])
		}
	}

	// The parent must still have 1 entry
	if parent.CallStackDepth() != 1 {
		t.Errorf("parent.CallStackDepth() = %d after concurrent pushes; want 1", parent.CallStackDepth())
	}
}

func TestFlowRuntimeContext_Goroutine_IndependentRecordTriggerPushes(t *testing.T) {
	parent := newTestFRC()

	const n = 20
	results := make([]*FlowRuntimeContext, n)
	var wg sync.WaitGroup
	wg.Add(n)
	for i := 0; i < n; i++ {
		i := i
		go func() {
			defer wg.Done()
			recID := "r" + string(rune('0'+i%10))
			// Each goroutine pushes a different recordID from the same parent
			derived, _ := parent.PushRecordTrigger("Account", recID)
			results[i] = derived
		}()
	}
	wg.Wait()

	// Each derived context must only contain its own entry (parent was empty)
	for i, r := range results {
		if len(r.GetRecordTriggerStack()) != 1 {
			t.Errorf("results[%d] has %d entries; want 1", i, len(r.GetRecordTriggerStack()))
		}
	}

	// Parent must remain empty
	if len(parent.GetRecordTriggerStack()) != 0 {
		t.Errorf("parent.GetRecordTriggerStack() len=%d after concurrent pushes; want 0",
			len(parent.GetRecordTriggerStack()))
	}
}

// ---------------------------------------------------------------------------
// T9 — Pop behavior on nil receiver and empty stack
// ---------------------------------------------------------------------------

func TestFlowRuntimeContext_PopCallStack_Empty(t *testing.T) {
	frc := newTestFRC()
	result, top, ok := frc.PopCallStack()
	if ok {
		t.Error("PopCallStack on empty returned ok=true; want false")
	}
	if top != "" {
		t.Errorf("PopCallStack on empty returned top=%q; want empty string", top)
	}
	if result == nil {
		t.Error("PopCallStack on empty returned nil result")
	}
}

func TestFlowRuntimeContext_PopCallStack_NilReceiver(t *testing.T) {
	var frc *FlowRuntimeContext
	result, top, ok := frc.PopCallStack()
	if ok {
		t.Error("PopCallStack on nil returned ok=true; want false")
	}
	if top != "" {
		t.Errorf("PopCallStack on nil returned top=%q; want empty string", top)
	}
	if result == nil {
		t.Error("PopCallStack on nil returned nil result")
	}
}

// ---------------------------------------------------------------------------
// T10 — GetCallStack returned slice mutation does not affect stored state
// ---------------------------------------------------------------------------

func TestFlowRuntimeContext_GetCallStack_MutationIsolation(t *testing.T) {
	frc := newTestFRC().PushCallStack("FlowA").PushCallStack("FlowB")

	// Mutate the returned slice
	s := frc.GetCallStack()
	if len(s) > 0 {
		s[0] = "MUTATED"
	}

	// The stored stack must be unchanged
	stored := frc.GetCallStack()
	if len(stored) == 0 || stored[0] != "FlowA" {
		t.Errorf("stored stack corrupted by external mutation: got %v", stored)
	}
}

// ---------------------------------------------------------------------------
// T11 — InterviewPersistMode enum: both values are distinct
// ---------------------------------------------------------------------------

func TestInterviewPersistMode_EnumValues(t *testing.T) {
	if InterviewPersistModeSave == InterviewPersistModeTransient {
		t.Error("InterviewPersistModeSave and InterviewPersistModeTransient must be distinct")
	}
	// Zero value is Save (important: the default must match the original
	// context.WithValue absence behavior which was persist=true)
	var zero InterviewPersistMode
	if zero != InterviewPersistModeSave {
		t.Errorf("zero InterviewPersistMode = %v; want InterviewPersistModeSave", zero)
	}
}

// ---------------------------------------------------------------------------
// T22a — Typed accessors for runtime context-data keys (RED → GREEN)
// ---------------------------------------------------------------------------

// TestFlowRuntimeContext_ObjectType_RoundTrip asserts that ObjectType and
// SetObjectType form a typed accessor pair with zero-value safety (A5, A9).
func TestFlowRuntimeContext_ObjectType_RoundTrip(t *testing.T) {
	frc := newTestFRC()

	// Zero value must be empty string (not panic)
	if got := frc.ObjectType(); got != "" {
		t.Errorf("ObjectType() zero value = %q; want empty string", got)
	}

	// Set and retrieve
	frc.SetObjectType("Contact")
	if got := frc.ObjectType(); got != "Contact" {
		t.Errorf("ObjectType() after SetObjectType(%q) = %q; want %q", "Contact", got, "Contact")
	}

	// Overwrite
	frc.SetObjectType("Opportunity")
	if got := frc.ObjectType(); got != "Opportunity" {
		t.Errorf("ObjectType() after second SetObjectType = %q; want Opportunity", got)
	}
}

// TestFlowRuntimeContext_ObjectType_NilSafety asserts that ObjectType and
// SetObjectType do not panic on a nil receiver (A9).
func TestFlowRuntimeContext_ObjectType_NilSafety(t *testing.T) {
	var frc *FlowRuntimeContext
	if got := frc.ObjectType(); got != "" {
		t.Errorf("ObjectType on nil = %q; want empty string", got)
	}
	// SetObjectType on nil must not panic
	frc.SetObjectType("Account")
}

// TestFlowRuntimeContext_LoopDepth_IncrDecr asserts that LoopDepth,
// IncrLoopDepth, and DecrLoopDepth form a typed accessor triple (A6, A9).
func TestFlowRuntimeContext_LoopDepth_IncrDecr(t *testing.T) {
	frc := newTestFRC()

	// Zero value must be 0 (not panic)
	if got := frc.LoopDepth(); got != 0 {
		t.Errorf("LoopDepth() zero value = %d; want 0", got)
	}

	// Increment once
	frc.IncrLoopDepth()
	if got := frc.LoopDepth(); got != 1 {
		t.Errorf("LoopDepth() after one IncrLoopDepth = %d; want 1", got)
	}

	// Increment again
	frc.IncrLoopDepth()
	if got := frc.LoopDepth(); got != 2 {
		t.Errorf("LoopDepth() after two IncrLoopDepth = %d; want 2", got)
	}

	// Decrement back to 1
	frc.DecrLoopDepth()
	if got := frc.LoopDepth(); got != 1 {
		t.Errorf("LoopDepth() after one DecrLoopDepth = %d; want 1", got)
	}

	// Decrement to 0
	frc.DecrLoopDepth()
	if got := frc.LoopDepth(); got != 0 {
		t.Errorf("LoopDepth() after decrement to zero = %d; want 0", got)
	}

	// Decrement below 0 must clamp to 0 (defensive)
	frc.DecrLoopDepth()
	if got := frc.LoopDepth(); got != 0 {
		t.Errorf("LoopDepth() after decrement below zero = %d; want 0 (clamped)", got)
	}
}

// TestFlowRuntimeContext_LoopDepth_NilSafety asserts nil-safe behaviour (A9).
func TestFlowRuntimeContext_LoopDepth_NilSafety(t *testing.T) {
	var frc *FlowRuntimeContext
	if got := frc.LoopDepth(); got != 0 {
		t.Errorf("LoopDepth on nil = %d; want 0", got)
	}
	// IncrLoopDepth / DecrLoopDepth on nil must not panic
	frc.IncrLoopDepth()
	frc.DecrLoopDepth()
}

// TestFlowRuntimeContext_RCache_LazyInit asserts that RCache returns a
// non-nil map on first call (lazy init) and the same map on subsequent
// calls (A4).
func TestFlowRuntimeContext_RCache_LazyInit(t *testing.T) {
	frc := newTestFRC()

	cache := frc.RCache()
	if cache == nil {
		t.Fatal("RCache() on new FRC returned nil; want non-nil map")
	}

	// Store a value and verify it persists on the next call
	cache["k1:v1"] = models.SObject{"Id": "v1"}
	cache2 := frc.RCache()
	if cache2 == nil {
		t.Fatal("RCache() second call returned nil")
	}
	if _, ok := cache2["k1:v1"]; !ok {
		t.Error("RCache() second call returned a different map (value not found)")
	}
}

// TestFlowRuntimeContext_RCache_SharedAcrossClones asserts that non-subflow clones
// (e.g. WithBeforeSaveFlow) still share executionState, while PushCallStack (subflow
// boundary) gives each child interview its own isolated rCache.
func TestFlowRuntimeContext_RCache_SharedAcrossClones(t *testing.T) {
	parent := newTestFRC()

	// Non-subflow clone (WithBeforeSaveFlow) must share rCache with parent.
	sibling := parent.WithBeforeSaveFlow(true)
	parentCache := parent.RCache()
	parentCache["obj:rec1"] = models.SObject{"Id": "rec1"}
	siblingCache := sibling.RCache()
	if _, ok := siblingCache["obj:rec1"]; !ok {
		t.Error("WithBeforeSaveFlow clone must share parent rCache — sharing broken")
	}

	// PushCallStack starts a new child interview: rCache must be isolated.
	child := parent.PushCallStack("FlowA")
	childCache := child.RCache()
	if childCache == nil {
		// lazily initialised — write something first
		childCache = child.RCache()
	}
	// Parent's entry must NOT be visible in the child's cache.
	if _, ok := childCache["obj:rec1"]; ok {
		t.Error("PushCallStack child must NOT inherit parent rCache (per-interview isolation)")
	}
	// Writes to child cache must not pollute parent.
	childCache["obj:child1"] = models.SObject{"Id": "child1"}
	if _, ok := parent.RCache()["obj:child1"]; ok {
		t.Error("child rCache write must NOT be visible in parent (per-interview isolation)")
	}
}

// TestFlowRuntimeContext_RCache_NilSafety asserts nil-safe behaviour (A9).
func TestFlowRuntimeContext_RCache_NilSafety(t *testing.T) {
	var frc *FlowRuntimeContext
	// RCache on nil must not panic; returns nil (no interview context to cache for)
	if got := frc.RCache(); got != nil {
		t.Errorf("RCache on nil = %v; want nil", got)
	}
}

func TestFlowRuntimeContext_RecordLookupCache_DefensiveCopyAndClear(t *testing.T) {
	frc := newTestFRC()
	key := "lookup-key"
	result := &models.QueryResult{
		Done:      true,
		TotalSize: 1,
		Records: []models.SObject{{
			"Id":   "001",
			"Name": "Original",
		}},
	}

	frc.SetCachedRecordLookupResult(key, result)
	result.Records[0]["Name"] = "mutated-after-store"

	cached, ok := frc.CachedRecordLookupResult(key)
	if !ok {
		t.Fatal("CachedRecordLookupResult() miss, want hit")
	}
	if got := cached.Records[0]["Name"]; got != "Original" {
		t.Fatalf("cached Name = %v, want Original defensive copy", got)
	}
	cached.Records[0]["Name"] = "mutated-after-read"

	cachedAgain, ok := frc.CachedRecordLookupResult(key)
	if !ok {
		t.Fatal("CachedRecordLookupResult() second read miss, want hit")
	}
	if got := cachedAgain.Records[0]["Name"]; got != "Original" {
		t.Fatalf("cached second Name = %v, want Original read clone", got)
	}

	frc.AddPostUpsertRecord("Account", models.SObject{"Id": "001"})
	if _, ok := frc.CachedRecordLookupResult(key); ok {
		t.Fatal("CachedRecordLookupResult() hit after AddPostUpsertRecord; want cache cleared")
	}
}

// TestFlowRuntimeContext_RuntimeKeysNotInContextData_JSONRoundTrip pins the
// contract that the 3 runtime-internal keys (__r_cache, _objectType,
// _loopDepth) are NOT stored in interview.ContextData and therefore do NOT
// appear in a paused-interview JSON serialization (A8).
//
// This test constructs an interview, sets the typed fields on FlowRuntimeContext,
// serializes interview.ContextData as JSON, and asserts the 3 sentinel keys are
// absent — proving that paused interviews resume cleanly without these keys.
func TestFlowRuntimeContext_RuntimeKeysNotInContextData_JSONRoundTrip(t *testing.T) {
	// Simulate what happens during a flow: build an interview ContextData with
	// user-visible variables only.
	contextData := map[string]interface{}{
		"myVar":    "hello",
		"$Record":  models.SObject{"Id": "rec1"},
		"aCounter": float64(42),
	}

	// Build an FRC with the 3 typed runtime fields populated.
	frc := newTestFRC()
	frc.SetObjectType("Lead")
	frc.IncrLoopDepth()
	cache := frc.RCache()
	cache["Lead:rec1"] = models.SObject{"Id": "rec1"}

	// Verify: contextData must NOT contain any of the 3 runtime sentinel keys.
	// (They live on FlowRuntimeContext, not in the map.)
	for _, forbidden := range []string{"__r_cache", "_objectType", "_loopDepth"} {
		if _, present := contextData[forbidden]; present {
			t.Errorf("contextData contains forbidden runtime key %q — must not be stored in ContextData", forbidden)
		}
	}

	// JSON round-trip: serialize and deserialize.
	data, err := json.Marshal(contextData)
	if err != nil {
		t.Fatalf("json.Marshal failed: %v", err)
	}
	var restored map[string]interface{}
	if err := json.Unmarshal(data, &restored); err != nil {
		t.Fatalf("json.Unmarshal failed: %v", err)
	}

	// Restored map must not contain runtime-internal keys.
	for _, forbidden := range []string{"__r_cache", "_objectType", "_loopDepth"} {
		if _, present := restored[forbidden]; present {
			t.Errorf("restored contextData contains forbidden runtime key %q", forbidden)
		}
	}

	// User variables must survive the round-trip.
	if got, ok := restored["myVar"].(string); !ok || got != "hello" {
		t.Errorf("restored[myVar] = %v; want %q", restored["myVar"], "hello")
	}
	if got, ok := restored["aCounter"].(float64); !ok || got != 42 {
		t.Errorf("restored[aCounter] = %v; want 42", restored["aCounter"])
	}

	// FRC typed state is unchanged by the JSON round-trip.
	if got := frc.ObjectType(); got != "Lead" {
		t.Errorf("frc.ObjectType() after JSON round-trip = %q; want Lead", got)
	}
	if got := frc.LoopDepth(); got != 1 {
		t.Errorf("frc.LoopDepth() after JSON round-trip = %d; want 1", got)
	}
}

// ---------------------------------------------------------------------------
// T22b — User field on FlowRuntimeContext + formula.Context wiring (RED → GREEN)
// ---------------------------------------------------------------------------

// TestBuildFlowFormulaContext_UserPropagates asserts that buildFlowFormulaContext
// populates formula.Context.UserSession from the user parameter when non-nil (B3, B4).
func TestBuildFlowFormulaContext_UserPropagates(t *testing.T) {
	user := &models.UserSession{
		ID:          "u001",
		Name:        "Test User",
		ProfileID:   "prof1",
		ProfileName: "Standard User",
	}
	contextData := map[string]interface{}{}
	fCtx := buildFlowFormulaContext(contextData, nil, user)
	if fCtx.UserSession == nil {
		t.Fatal("formula.Context.UserSession is nil; want non-nil when user is provided")
	}
	if fCtx.UserSession.ID != "u001" {
		t.Errorf("formula.Context.UserSession.ID = %q; want %q", fCtx.UserSession.ID, "u001")
	}
	if fCtx.UserSession.ProfileID != "prof1" {
		t.Errorf("formula.Context.UserSession.ProfileID = %q; want %q", fCtx.UserSession.ProfileID, "prof1")
	}
}

// TestBuildFlowFormulaContext_NilUser asserts that buildFlowFormulaContext does
// not panic and sets formula.Context.UserSession to nil when user is nil (B6).
func TestBuildFlowFormulaContext_NilUser(t *testing.T) {
	contextData := map[string]interface{}{}
	fCtx := buildFlowFormulaContext(contextData, nil, nil)
	if fCtx == nil {
		t.Fatal("buildFlowFormulaContext returned nil")
	}
	if fCtx.UserSession != nil {
		t.Errorf("formula.Context.UserSession = %v; want nil when user is nil", fCtx.UserSession)
	}
}

// TestBuildFlowFormulaContext_UserSessionAndVarsPopulated asserts that when user is non-nil,
// (a) UserSession is set on the context (typed access for T22c resolver), and
// (b) Variables["__User"] is populated so $User.<field> expressions resolve correctly
//
// Note: formula.Context.User (the flat map) is intentionally NOT set — the engine
// flattens ctx.User into the top-level expression env, which would overwrite same-named
// record fields (Id, Name, …) with the running user's values. The __User Variables path
// is the correct resolution surface after normalizeFormulaExpression rewrites $User → __User.
func TestBuildFlowFormulaContext_UserSessionAndVarsPopulated(t *testing.T) {
	user := &models.UserSession{ID: "u002", Name: "Jane Doe", ProfileID: "prof2"}
	contextData := map[string]interface{}{}
	fCtx := buildFlowFormulaContext(contextData, nil, user)
	if fCtx.UserSession == nil {
		t.Fatal("formula.Context.UserSession is nil; want non-nil when user is provided")
	}
	if fCtx.User != nil {
		t.Errorf("formula.Context.User (flat map) = non-nil; want nil to avoid overwriting record fields in formula env")
	}
	uRaw, ok := fCtx.Variables["__User"]
	if !ok {
		t.Fatal("Variables[\"__User\"] absent; want populated for $User.* formula resolution")
	}
	uMap, _ := uRaw.(map[string]interface{})
	if uMap["Id"] != "u002" {
		t.Errorf("Variables[\"__User\"][\"Id\"] = %v; want %q", uMap["Id"], "u002")
	}
}
