package automation

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"testing"

	"github.com/nexuscrm/backend/pkg/logging"
	"github.com/nexuscrm/backend/pkg/utils"
	"github.com/nexuscrm/shared/pkg/api"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

func newTestExecutor() *FlowExecutor {
	return &FlowExecutor{}
}

// ---------------------------------------------------------------------------
// resolveValue
// ---------------------------------------------------------------------------

func TestResolveValue(t *testing.T) {
	executor := newTestExecutor()

	tests := []struct {
		name     string
		context  map[string]interface{}
		value    interface{}
		expected interface{}
	}{
		{
			name:     "nil value returns nil",
			context:  map[string]interface{}{},
			value:    nil,
			expected: nil,
		},
		{
			name:     "non-string literal returned as-is",
			context:  map[string]interface{}{},
			value:    42,
			expected: 42,
		},
		{
			name:     "boolean literal returned as-is",
			context:  map[string]interface{}{},
			value:    true,
			expected: true,
		},
		{
			name:     "simple variable lookup",
			context:  map[string]interface{}{"myVar": "hello"},
			value:    "myVar",
			expected: "hello",
		},
		{
			name:     "unresolved string returned as-is",
			context:  map[string]interface{}{},
			value:    "nonExistent",
			expected: "nonExistent",
		},
		{
			name: "nested SObject reference",
			context: map[string]interface{}{
				"$Record": models.SObject{"Name": "Acme"},
			},
			value:    "$Record.Name",
			expected: "Acme",
		},
		{
			name: "nested map reference",
			context: map[string]interface{}{
				"account": map[string]interface{}{"Name": "Globex"},
			},
			value:    "account.Name",
			expected: "Globex",
		},
		{
			name:     "nested reference with nil root",
			context:  map[string]interface{}{},
			value:    "$Record.Name",
			expected: nil,
		},
		{
			name: "deeply nested reference",
			context: map[string]interface{}{
				"$Record": models.SObject{
					"Account": map[string]interface{}{
						"Owner": map[string]interface{}{
							"Name": "Alice",
						},
					},
				},
			},
			value:    "$Record.Account.Owner.Name",
			expected: "Alice",
		},
		{
			name: "nested reference with missing intermediate",
			context: map[string]interface{}{
				"$Record": models.SObject{"Name": "Acme"},
			},
			value:    "$Record.Account.Name",
			expected: nil,
		},
		{
			name:     "$GlobalConstant.True",
			context:  map[string]interface{}{},
			value:    "$GlobalConstant.True",
			expected: true,
		},
		{
			name:     "$GlobalConstant.False",
			context:  map[string]interface{}{},
			value:    "$GlobalConstant.False",
			expected: false,
		},
		{
			name:     "$GlobalConstant.EmptyString",
			context:  map[string]interface{}{},
			value:    "$GlobalConstant.EmptyString",
			expected: "",
		},
		{
			name:     "$GlobalConstant.Unknown",
			context:  map[string]interface{}{},
			value:    "$GlobalConstant.Unknown",
			expected: nil,
		},
		// Typed value union (map) unwrapping
		{
			name:     "map with elementReference resolves through",
			context:  map[string]interface{}{},
			value:    map[string]interface{}{"elementReference": "$GlobalConstant.True"},
			expected: true,
		},
		{
			name:     "map with elementReference resolves variable",
			context:  map[string]interface{}{"myVar": "hello"},
			value:    map[string]interface{}{"elementReference": "myVar"},
			expected: "hello",
		},
		{
			name:     "map with booleanValue returns bool",
			context:  map[string]interface{}{},
			value:    map[string]interface{}{"booleanValue": false},
			expected: false,
		},
		{
			name:     "map with stringValue returns string",
			context:  map[string]interface{}{},
			value:    map[string]interface{}{"stringValue": "hello"},
			expected: "hello",
		},
		{
			name:     "map with numberValue returns number",
			context:  map[string]interface{}{},
			value:    map[string]interface{}{"numberValue": float64(42)},
			expected: float64(42),
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := executor.resolveValue(tt.context, tt.value)
			assert.Equal(t, tt.expected, result)
		})
	}
}

func TestResolveFlowSystemVariable(t *testing.T) {
	executor := newTestExecutor()

	tests := []struct {
		name     string
		ref      string
		checkNil bool
		checkStr bool
	}{
		{"CurrentDate returns date string", "$Flow.CurrentDate", false, true},
		{"CurrentDateTime returns RFC3339", "$Flow.CurrentDateTime", false, true},
		{"CurrentStage returns empty string", "$Flow.CurrentStage", false, true},
		{"InterviewStartTime returns RFC3339", "$Flow.InterviewStartTime", false, true},
		{"FaultMessage returns empty string", "$Flow.FaultMessage", false, true},
		{"Unknown variable returns nil", "$Flow.SomethingUnknown", true, false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := executor.resolveFlowSystemVariable(tt.ref)
			if tt.checkNil {
				assert.Nil(t, result)
			} else if tt.checkStr {
				_, ok := result.(string)
				assert.True(t, ok, "expected string result")
			}
		})
	}
}

func TestResolveFlowSystemVariable_ActiveStages(t *testing.T) {
	executor := newTestExecutor()
	result := executor.resolveFlowSystemVariable("$Flow.ActiveStages")
	slice, ok := result.([]interface{})
	assert.True(t, ok)
	assert.Empty(t, slice)
}

func TestBuildScheduledStartFilterClause_ResolvesElementReferences(t *testing.T) {
	executor := newTestExecutor()

	clause := executor.buildScheduledStartFilterClause(map[string]interface{}{
		"targetName": "Acme",
	}, models.FlowRecordFilter{
		Field:    "Name",
		Operator: "EqualTo",
		Value:    map[string]interface{}{"elementReference": "targetName"},
	})

	assert.Equal(t, "Name = 'Acme'", clause)
}

func TestBuildScheduledStartFilter_UsesCustomLogicAndOperatorAliases(t *testing.T) {
	executor := newTestExecutor()

	filterExpr := executor.buildScheduledStartFilter(map[string]interface{}{
		"ownerRef": "005-owner",
	}, []models.FlowRecordFilter{
		{Field: "Amount", Operator: "LessOrEqualTo", Value: map[string]interface{}{"numberValue": 100}},
		{Field: "Name", Operator: "StartsWith", Value: map[string]interface{}{"stringValue": "Acme"}},
		{Field: "OwnerId", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "ownerRef"}},
	}, "1 AND (2 OR 3)")

	assert.Equal(t, "((Amount <= '100') AND ((STARTS_WITH(Name, 'Acme')) OR (OwnerId = '005-owner')))", filterExpr)
}

func TestBuildScheduledStartFilterClause_SupportsLikeAndNullOperators(t *testing.T) {
	executor := newTestExecutor()

	assert.Equal(t, "CONTAINS(Description, 'vip')", executor.buildScheduledStartFilterClause(nil, models.FlowRecordFilter{
		Field:    "Description",
		Operator: "Contains",
		Value:    map[string]interface{}{"stringValue": "vip"},
	}))
	assert.Equal(t, "Name = 'Acme\\'s Org'", executor.buildScheduledStartFilterClause(nil, models.FlowRecordFilter{
		Field:    "Name",
		Operator: "EqualTo",
		Value:    map[string]interface{}{"stringValue": "Acme's Org"},
	}))
	assert.Equal(t, "ENDS_WITH(Name, 'Corp')", executor.buildScheduledStartFilterClause(nil, models.FlowRecordFilter{
		Field:    "Name",
		Operator: "EndsWith",
		Value:    map[string]interface{}{"stringValue": "Corp"},
	}))
	assert.Equal(t, "ISBLANK(Notes)", executor.buildScheduledStartFilterClause(nil, models.FlowRecordFilter{
		Field:    "Notes",
		Operator: "IsNull",
		Value:    map[string]interface{}{"booleanValue": true},
	}))
	assert.Equal(t, "NOT(ISBLANK(Notes))", executor.buildScheduledStartFilterClause(nil, models.FlowRecordFilter{
		Field:    "Notes",
		Operator: "IsNull",
		Value:    map[string]interface{}{"booleanValue": false},
	}))
}

// TestEvaluateRecordFilter_IsChanged_MultiPicklistCanonical pins the flow-engine
// counterpart of PR #1684. The AfterSave flow start-filter IsChanged evaluator
// used `fmt.Sprintf("%v", a) != fmt.Sprintf("%v", b)`, which flags a multi-
// picklist field as changed whenever the old and new snapshots happen to carry
// different but semantically-equivalent serializations (DB read returns a
// semicolon-joined scalar "Others"; NormalizeSObject/flow-engine inputs produce
// a JSON-array string `["Others"]` or a []interface{} slice). Each flow
// execution then re-enqueues a record.updated event and the loop never
// terminates. Observed on staging 2026-04-22 as the residual driver of the
// MySQL_Family_Capture cycle after PR #1684 fixed the records dirty-field
// comparator but left the flow filter evaluator unchanged.
func TestEvaluateRecordFilter_IsChanged_MultiPicklistCanonical(t *testing.T) {
	executor := newTestExecutor()
	multiPicklistField := &models.FieldMetadata{
		APIName: "Replacing_Database__c",
		Type:    constants.FieldTypeMultiPicklist,
	}

	cases := []struct {
		name         string
		oldVal       interface{}
		newVal       interface{}
		wantIsChange bool
	}{
		{"scalar vs JSON-array same item", "Others", `["Others"]`, false},
		{"JSON-array vs scalar same item", `["Others"]`, "Others", false},
		{"semicolon vs JSON-array same set", "A;B;C", `["A","B","C"]`, false},
		{"order-insensitive set", "A;B;C", `["C","B","A"]`, false},
		{"slice vs scalar", []interface{}{"Others"}, "Others", false},
		{"slice of strings vs semicolon scalar", []string{"A", "B"}, "A;B", false},
		{"both JSON same set different order", `["A","B"]`, `["B","A"]`, false},
		{"empty string vs empty array", "", `[]`, false},

		{"real change — added item", "A;B", `["A","B","C"]`, true},
		{"real change — removed item", `["A","B","C"]`, "A;B", true},
		{"real change — different items", "A;B", `["X","Y"]`, true},
		{"real change — from empty to one", "", `["A"]`, true},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			record := models.SObject{"Replacing_Database__c": tc.newVal}
			oldRecord := models.SObject{"Replacing_Database__c": tc.oldVal}
			got := executor.evaluateRecordFilterForField(record, oldRecord, &models.FlowRecordFilter{
				Field:    "Replacing_Database__c",
				Operator: "IsChanged",
				Value:    map[string]interface{}{"booleanValue": true},
			}, multiPicklistField)
			assert.Equal(t, tc.wantIsChange, got,
				"IsChanged(%#v, %#v) — expected %v", tc.oldVal, tc.newVal, tc.wantIsChange)
		})
	}
}

// TestEvaluateRecordFilter_IsChanged_JsonFieldKeepsOrderSemantics pins the
// metadata-based gate: a FieldTypeJson value whose array order changes
// (e.g. '["A","B"]' -> '["B","A"]') MUST be flagged as changed. Without
// the metadata gate, a shape-based heuristic would silently treat these as
// equal (both are JSON-array-shaped strings) and mask real data drift.
// Pinned in response to PR #1710 Pass B adversarial review.
func TestEvaluateRecordFilter_IsChanged_JsonFieldKeepsOrderSemantics(t *testing.T) {
	executor := newTestExecutor()
	jsonField := &models.FieldMetadata{
		APIName: "Payload__c",
		Type:    constants.FieldTypeJson,
	}

	record := models.SObject{"Payload__c": `["B","A"]`}
	oldRecord := models.SObject{"Payload__c": `["A","B"]`}
	got := executor.evaluateRecordFilterForField(record, oldRecord, &models.FlowRecordFilter{
		Field:    "Payload__c",
		Operator: "IsChanged",
		Value:    map[string]interface{}{"booleanValue": true},
	}, jsonField)
	assert.True(t, got, "JSON field with reordered array must register as changed")

	// And identical JSON still registers as unchanged.
	recordSame := models.SObject{"Payload__c": `["A","B"]`}
	gotSame := executor.evaluateRecordFilterForField(recordSame, oldRecord, &models.FlowRecordFilter{
		Field:    "Payload__c",
		Operator: "IsChanged",
		Value:    map[string]interface{}{"booleanValue": true},
	}, jsonField)
	assert.False(t, gotSame)
}

// TestEvaluateRecordFilter_IsChanged_NilFieldMetaFallsBackToStringified pins
// the legacy fallback path. When the lookup cannot resolve FieldMetadata
// (e.g. a flow trigger against an object name that isn't wired in the
// executor's metadata service), the evaluator MUST NOT silently apply set
// semantics — it must use the byte-identical fmt.Sprintf compare so existing
// behavior is preserved for code paths that never had metadata available.
func TestEvaluateRecordFilter_IsChanged_NilFieldMetaFallsBackToStringified(t *testing.T) {
	executor := newTestExecutor()

	record := models.SObject{"Tags__c": `["Others"]`}
	oldRecord := models.SObject{"Tags__c": "Others"}
	got := executor.evaluateRecordFilterForField(record, oldRecord, &models.FlowRecordFilter{
		Field:    "Tags__c",
		Operator: "IsChanged",
		Value:    map[string]interface{}{"booleanValue": true},
	}, nil)
	assert.True(t, got, "nil FieldMetadata must fall back to stringified compare")
}

func TestEvaluateRecordFilter_NormalizesLessOrEqualToAlias(t *testing.T) {
	executor := newTestExecutor()

	matched := executor.evaluateRecordFilter(models.SObject{
		"Amount": float64(100),
	}, nil, &models.FlowRecordFilter{
		Field:    "Amount",
		Operator: "LessOrEqualTo",
		Value:    map[string]interface{}{"numberValue": 100},
	})

	assert.True(t, matched)
}

// ---------------------------------------------------------------------------
// evaluateCondition / evaluateConditions
// ---------------------------------------------------------------------------

func TestEvaluateCondition(t *testing.T) {
	executor := newTestExecutor()

	tests := []struct {
		name     string
		context  map[string]interface{}
		cond     models.FlowCondition
		expected bool
	}{
		{
			name:     "EqualTo - match",
			context:  map[string]interface{}{"status": "Active"},
			cond:     models.FlowCondition{LeftValueReference: "status", Operator: "EqualTo", RightValue: "Active"},
			expected: true,
		},
		{
			name:     "EqualTo - mismatch",
			context:  map[string]interface{}{"status": "Inactive"},
			cond:     models.FlowCondition{LeftValueReference: "status", Operator: "EqualTo", RightValue: "Active"},
			expected: false,
		},
		{
			name:     "NotEqualTo - match",
			context:  map[string]interface{}{"status": "Inactive"},
			cond:     models.FlowCondition{LeftValueReference: "status", Operator: "NotEqualTo", RightValue: "Active"},
			expected: true,
		},
		{
			name:     "NotEqualTo - mismatch",
			context:  map[string]interface{}{"status": "Active"},
			cond:     models.FlowCondition{LeftValueReference: "status", Operator: "NotEqualTo", RightValue: "Active"},
			expected: false,
		},
		{
			name:     "GreaterThan - true",
			context:  map[string]interface{}{"amount": float64(100)},
			cond:     models.FlowCondition{LeftValueReference: "amount", Operator: "GreaterThan", RightValue: float64(50)},
			expected: true,
		},
		{
			name:     "GreaterThan - false",
			context:  map[string]interface{}{"amount": float64(30)},
			cond:     models.FlowCondition{LeftValueReference: "amount", Operator: "GreaterThan", RightValue: float64(50)},
			expected: false,
		},
		{
			name:     "GreaterThanOrEqualTo - equal",
			context:  map[string]interface{}{"amount": float64(50)},
			cond:     models.FlowCondition{LeftValueReference: "amount", Operator: "GreaterThanOrEqualTo", RightValue: float64(50)},
			expected: true,
		},
		{
			name:     "LessThan - true",
			context:  map[string]interface{}{"amount": float64(30)},
			cond:     models.FlowCondition{LeftValueReference: "amount", Operator: "LessThan", RightValue: float64(50)},
			expected: true,
		},
		{
			name:     "LessThanOrEqualTo - equal",
			context:  map[string]interface{}{"amount": float64(50)},
			cond:     models.FlowCondition{LeftValueReference: "amount", Operator: "LessThanOrEqualTo", RightValue: float64(50)},
			expected: true,
		},
		{
			name:     "Contains - true",
			context:  map[string]interface{}{"description": "hello world"},
			cond:     models.FlowCondition{LeftValueReference: "description", Operator: "Contains", RightValue: "world"},
			expected: true,
		},
		{
			name:     "Contains - false",
			context:  map[string]interface{}{"description": "hello"},
			cond:     models.FlowCondition{LeftValueReference: "description", Operator: "Contains", RightValue: "world"},
			expected: false,
		},
		{
			name: "IsNull - nil value in context",
			context: map[string]interface{}{
				"nullField": nil,
			},
			cond:     models.FlowCondition{LeftValueReference: "nullField", Operator: "IsNull"},
			expected: true,
		},
		{
			name:     "IsNull - non-nil value",
			context:  map[string]interface{}{"name": "test"},
			cond:     models.FlowCondition{LeftValueReference: "name", Operator: "IsNull"},
			expected: false,
		},
		{
			name:     "IsNull - missing key resolves to literal string, not nil",
			context:  map[string]interface{}{},
			cond:     models.FlowCondition{LeftValueReference: "missing", Operator: "IsNull"},
			expected: false,
		},
		{
			name:     "Unknown operator returns false",
			context:  map[string]interface{}{"val": "x"},
			cond:     models.FlowCondition{LeftValueReference: "val", Operator: "UnknownOp", RightValue: "x"},
			expected: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := executor.evaluateCondition(tt.context, &tt.cond)
			assert.Equal(t, tt.expected, result)
		})
	}
}

func TestEvaluateConditions(t *testing.T) {
	executor := newTestExecutor()

	tests := []struct {
		name       string
		context    map[string]interface{}
		conditions []models.FlowCondition
		logic      string
		expected   bool
	}{
		{
			name:       "empty conditions returns true",
			context:    map[string]interface{}{},
			conditions: nil,
			logic:      "and",
			expected:   true,
		},
		{
			name:    "AND - all true",
			context: map[string]interface{}{"a": "1", "b": "2"},
			conditions: []models.FlowCondition{
				{LeftValueReference: "a", Operator: "EqualTo", RightValue: "1"},
				{LeftValueReference: "b", Operator: "EqualTo", RightValue: "2"},
			},
			logic:    "and",
			expected: true,
		},
		{
			name:    "AND - one false",
			context: map[string]interface{}{"a": "1", "b": "wrong"},
			conditions: []models.FlowCondition{
				{LeftValueReference: "a", Operator: "EqualTo", RightValue: "1"},
				{LeftValueReference: "b", Operator: "EqualTo", RightValue: "2"},
			},
			logic:    "and",
			expected: false,
		},
		{
			name:    "OR - one true",
			context: map[string]interface{}{"a": "wrong", "b": "2"},
			conditions: []models.FlowCondition{
				{LeftValueReference: "a", Operator: "EqualTo", RightValue: "1"},
				{LeftValueReference: "b", Operator: "EqualTo", RightValue: "2"},
			},
			logic:    "or",
			expected: true,
		},
		{
			name:    "OR - all false",
			context: map[string]interface{}{"a": "wrong", "b": "wrong"},
			conditions: []models.FlowCondition{
				{LeftValueReference: "a", Operator: "EqualTo", RightValue: "1"},
				{LeftValueReference: "b", Operator: "EqualTo", RightValue: "2"},
			},
			logic:    "or",
			expected: false,
		},
		{
			name:    "default logic is AND",
			context: map[string]interface{}{"a": "1", "b": "2"},
			conditions: []models.FlowCondition{
				{LeftValueReference: "a", Operator: "EqualTo", RightValue: "1"},
				{LeftValueReference: "b", Operator: "EqualTo", RightValue: "2"},
			},
			logic:    "",
			expected: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := executor.evaluateConditions(tt.context, tt.conditions, tt.logic)
			assert.Equal(t, tt.expected, result)
		})
	}
}

// ---------------------------------------------------------------------------
// evaluateRecordFilter
// ---------------------------------------------------------------------------

func TestEvaluateRecordFilter(t *testing.T) {
	executor := newTestExecutor()

	tests := []struct {
		name     string
		record   models.SObject
		filter   models.FlowRecordFilter
		expected bool
	}{
		{
			name:     "EqualTo match",
			record:   models.SObject{"Status": "Active"},
			filter:   models.FlowRecordFilter{Field: "Status", Operator: "EqualTo", Value: "Active"},
			expected: true,
		},
		{
			name:     "EqualTo mismatch",
			record:   models.SObject{"Status": "Inactive"},
			filter:   models.FlowRecordFilter{Field: "Status", Operator: "EqualTo", Value: "Active"},
			expected: false,
		},
		{
			name:     "NotEqualTo match",
			record:   models.SObject{"Status": "Inactive"},
			filter:   models.FlowRecordFilter{Field: "Status", Operator: "NotEqualTo", Value: "Active"},
			expected: true,
		},
		{
			name:     "GreaterThan match",
			record:   models.SObject{"Amount": float64(100)},
			filter:   models.FlowRecordFilter{Field: "Amount", Operator: "GreaterThan", Value: float64(50)},
			expected: true,
		},
		{
			name:     "GreaterThanOrEqualTo equal",
			record:   models.SObject{"Amount": float64(50)},
			filter:   models.FlowRecordFilter{Field: "Amount", Operator: "GreaterThanOrEqualTo", Value: float64(50)},
			expected: true,
		},
		{
			name:     "LessThan match",
			record:   models.SObject{"Amount": float64(30)},
			filter:   models.FlowRecordFilter{Field: "Amount", Operator: "LessThan", Value: float64(50)},
			expected: true,
		},
		{
			name:     "LessThanOrEqualTo equal",
			record:   models.SObject{"Amount": float64(50)},
			filter:   models.FlowRecordFilter{Field: "Amount", Operator: "LessThanOrEqualTo", Value: float64(50)},
			expected: true,
		},
		{
			name:     "Contains match",
			record:   models.SObject{"Description": "hello world"},
			filter:   models.FlowRecordFilter{Field: "Description", Operator: "Contains", Value: "world"},
			expected: true,
		},
		{
			name:     "Contains mismatch",
			record:   models.SObject{"Description": "hello"},
			filter:   models.FlowRecordFilter{Field: "Description", Operator: "Contains", Value: "world"},
			expected: false,
		},
		{
			name:     "IsNull - nil field",
			record:   models.SObject{},
			filter:   models.FlowRecordFilter{Field: "Missing", Operator: "IsNull"},
			expected: true,
		},
		{
			name:     "IsNull - empty string",
			record:   models.SObject{"Name": ""},
			filter:   models.FlowRecordFilter{Field: "Name", Operator: "IsNull"},
			expected: true,
		},
		{
			name:     "IsNull - non-empty",
			record:   models.SObject{"Name": "test"},
			filter:   models.FlowRecordFilter{Field: "Name", Operator: "IsNull"},
			expected: false,
		},
		{
			name:     "StartsWith match",
			record:   models.SObject{"Name": "Acme Corp"},
			filter:   models.FlowRecordFilter{Field: "Name", Operator: "StartsWith", Value: "Acme"},
			expected: true,
		},
		{
			name:     "StartsWith mismatch",
			record:   models.SObject{"Name": "Globex Corp"},
			filter:   models.FlowRecordFilter{Field: "Name", Operator: "StartsWith", Value: "Acme"},
			expected: false,
		},
		{
			name:     "EndsWith match",
			record:   models.SObject{"Name": "Acme Corp"},
			filter:   models.FlowRecordFilter{Field: "Name", Operator: "EndsWith", Value: "Corp"},
			expected: true,
		},
		{
			name:     "EndsWith mismatch",
			record:   models.SObject{"Name": "Acme Inc"},
			filter:   models.FlowRecordFilter{Field: "Name", Operator: "EndsWith", Value: "Corp"},
			expected: false,
		},
		{
			name:     "Unknown operator defaults to true",
			record:   models.SObject{"Name": "test"},
			filter:   models.FlowRecordFilter{Field: "Name", Operator: "WeirdOp"},
			expected: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := executor.evaluateRecordFilter(tt.record, nil, &tt.filter)
			assert.Equal(t, tt.expected, result)
		})
	}
}

// ---------------------------------------------------------------------------
// checkEntryConditions
// ---------------------------------------------------------------------------

func TestCheckEntryConditions(t *testing.T) {
	executor := newTestExecutor()

	tests := []struct {
		name     string
		flowMeta *models.FlowMetadata
		context  map[string]interface{}
		expected bool
	}{
		{
			name:     "nil Start returns true",
			flowMeta: &models.FlowMetadata{},
			context:  map[string]interface{}{},
			expected: true,
		},
		{
			name: "empty filters returns true",
			flowMeta: &models.FlowMetadata{
				Start: &models.FlowStart{Filters: nil},
			},
			context:  map[string]interface{}{},
			expected: true,
		},
		{
			name: "no record in context returns true (AutoLaunched)",
			flowMeta: &models.FlowMetadata{
				Start: &models.FlowStart{
					Filters: []models.FlowRecordFilter{
						{Field: "Status", Operator: "EqualTo", Value: "Active"},
					},
				},
			},
			context:  map[string]interface{}{},
			expected: true,
		},
		{
			name: "AND - all filters match",
			flowMeta: &models.FlowMetadata{
				Start: &models.FlowStart{
					FilterLogic: "and",
					Filters: []models.FlowRecordFilter{
						{Field: "Status", Operator: "EqualTo", Value: "Active"},
						{Field: "Amount", Operator: "GreaterThan", Value: float64(50)},
					},
				},
			},
			context: map[string]interface{}{
				"$Record": models.SObject{"Status": "Active", "Amount": float64(100)},
			},
			expected: true,
		},
		{
			name: "AND - one filter fails",
			flowMeta: &models.FlowMetadata{
				Start: &models.FlowStart{
					FilterLogic: "and",
					Filters: []models.FlowRecordFilter{
						{Field: "Status", Operator: "EqualTo", Value: "Active"},
						{Field: "Amount", Operator: "GreaterThan", Value: float64(200)},
					},
				},
			},
			context: map[string]interface{}{
				"$Record": models.SObject{"Status": "Active", "Amount": float64(100)},
			},
			expected: false,
		},
		{
			name: "OR - one filter matches",
			flowMeta: &models.FlowMetadata{
				Start: &models.FlowStart{
					FilterLogic: "or",
					Filters: []models.FlowRecordFilter{
						{Field: "Status", Operator: "EqualTo", Value: "Closed"},
						{Field: "Amount", Operator: "GreaterThan", Value: float64(50)},
					},
				},
			},
			context: map[string]interface{}{
				"$Record": models.SObject{"Status": "Active", "Amount": float64(100)},
			},
			expected: true,
		},
		{
			name: "OR - no filter matches",
			flowMeta: &models.FlowMetadata{
				Start: &models.FlowStart{
					FilterLogic: "or",
					Filters: []models.FlowRecordFilter{
						{Field: "Status", Operator: "EqualTo", Value: "Closed"},
						{Field: "Amount", Operator: "GreaterThan", Value: float64(200)},
					},
				},
			},
			context: map[string]interface{}{
				"$Record": models.SObject{"Status": "Active", "Amount": float64(100)},
			},
			expected: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := executor.checkEntryConditions(tt.flowMeta, tt.context)
			assert.Equal(t, tt.expected, result)
		})
	}
}

// ---------------------------------------------------------------------------
// initializeFlowContext
// ---------------------------------------------------------------------------

func TestInitializeFlowContext(t *testing.T) {
	tests := []struct {
		name     string
		flowMeta *models.FlowMetadata
		inputs   map[string]interface{}
		check    func(t *testing.T, ctx map[string]interface{})
	}{
		{
			name: "variables with defaults",
			flowMeta: &models.FlowMetadata{
				Variables: []models.FlowVariable{
					{Name: "counter", Value: float64(0)},
					{Name: "label", Value: "default"},
				},
			},
			inputs: nil,
			check: func(t *testing.T, ctx map[string]interface{}) {
				assert.Equal(t, float64(0), ctx["counter"])
				assert.Equal(t, "default", ctx["label"])
			},
		},
		{
			name: "inputs override defaults",
			flowMeta: &models.FlowMetadata{
				Variables: []models.FlowVariable{
					{Name: "counter", Value: float64(0)},
				},
			},
			inputs: map[string]interface{}{"counter": float64(10)},
			check: func(t *testing.T, ctx map[string]interface{}) {
				assert.Equal(t, float64(10), ctx["counter"])
			},
		},
		{
			name: "variables without defaults are not set",
			flowMeta: &models.FlowMetadata{
				Variables: []models.FlowVariable{
					{Name: "noDefault"},
				},
			},
			inputs: nil,
			check: func(t *testing.T, ctx map[string]interface{}) {
				_, exists := ctx["noDefault"]
				assert.False(t, exists)
			},
		},
		{
			name:     "empty metadata returns empty context with inputs",
			flowMeta: &models.FlowMetadata{},
			inputs:   map[string]interface{}{"key": "value"},
			check: func(t *testing.T, ctx map[string]interface{}) {
				assert.Equal(t, "value", ctx["key"])
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ctx := initializeFlowContext(tt.flowMeta, tt.inputs)
			tt.check(t, ctx)
		})
	}
}

// ---------------------------------------------------------------------------
// buildFilterFromConditions
// ---------------------------------------------------------------------------

func TestBuildFilterFromConditions(t *testing.T) {
	executor := newTestExecutor()

	tests := []struct {
		name     string
		context  map[string]interface{}
		filters  []models.FlowRecordFilter
		logic    string
		expected string
	}{
		{
			name:     "empty filters returns empty",
			context:  map[string]interface{}{},
			filters:  nil,
			logic:    "and",
			expected: "",
		},
		{
			name:    "single filter with AND",
			context: map[string]interface{}{},
			filters: []models.FlowRecordFilter{
				{Field: "Status", Operator: "=", Value: "Active"},
			},
			logic:    "and",
			expected: "Status = 'Active'",
		},
		{
			name:    "multiple filters with AND",
			context: map[string]interface{}{},
			filters: []models.FlowRecordFilter{
				{Field: "Status", Operator: "=", Value: "Active"},
				{Field: "Amount", Operator: ">", Value: float64(100)},
			},
			logic:    "and",
			expected: "Status = 'Active' AND Amount > '100'",
		},
		{
			name:    "multiple filters with OR",
			context: map[string]interface{}{},
			filters: []models.FlowRecordFilter{
				{Field: "Status", Operator: "=", Value: "Active"},
				{Field: "Status", Operator: "=", Value: "Pending"},
			},
			logic:    "or",
			expected: "(Status = 'Active' OR Status = 'Pending')",
		},
		{
			name:    "resolves variable references",
			context: map[string]interface{}{"statusVar": "Active"},
			filters: []models.FlowRecordFilter{
				{Field: "Status", Operator: "=", Value: "statusVar"},
			},
			logic:    "and",
			expected: "Status = 'Active'",
		},
		{
			name:    "Contains generates CONTAINS() function",
			context: map[string]interface{}{},
			filters: []models.FlowRecordFilter{
				{Field: "Name", Operator: "Contains", Value: "Premium"},
			},
			logic:    "and",
			expected: "CONTAINS(Name, 'Premium')",
		},
		{
			name:    "Contains with OR logic",
			context: map[string]interface{}{},
			filters: []models.FlowRecordFilter{
				{Field: "Name", Operator: "Contains", Value: "Premium Subscription"},
				{Field: "Name", Operator: "Contains", Value: "Consulting Services"},
			},
			logic:    "or",
			expected: "(CONTAINS(Name, 'Premium Subscription') OR CONTAINS(Name, 'Consulting Services'))",
		},
		{
			name:    "single quote in value is escaped",
			context: map[string]interface{}{},
			filters: []models.FlowRecordFilter{
				{Field: "Name", Operator: "=", Value: "Iltonys's Org"},
			},
			logic:    "and",
			expected: "Name = 'Iltonys\\'s Org'",
		},
		{
			name:    "StartsWith generates STARTS_WITH() function",
			context: map[string]interface{}{},
			filters: []models.FlowRecordFilter{
				{Field: "Name", Operator: "StartsWith", Value: "Acme"},
			},
			logic:    "and",
			expected: "STARTS_WITH(Name, 'Acme')",
		},
		{
			name:    "EndsWith generates ENDS_WITH() function",
			context: map[string]interface{}{},
			filters: []models.FlowRecordFilter{
				{Field: "Email", Operator: "EndsWith", Value: "example"},
			},
			logic:    "and",
			expected: "ENDS_WITH(Email, 'example')",
		},
		{
			name:    "complex filter logic preserves grouping",
			context: map[string]interface{}{},
			filters: []models.FlowRecordFilter{
				{Field: "Name", Operator: "=", Value: "Acme"},
				{Field: "Website", Operator: "=", Value: "AcmeWeb"},
				{Field: "Region__c", Operator: "=", Value: "NA"},
			},
			logic:    "(1 OR 2) AND 3",
			expected: "(((Name = 'Acme') OR (Website = 'AcmeWeb')) AND (Region__c = 'NA'))",
		},
		{
			name:    "IsNull false becomes IS NOT NULL",
			context: map[string]interface{}{},
			filters: []models.FlowRecordFilter{
				{Field: "Website", Operator: "IsNull", Value: false},
			},
			logic:    "and",
			expected: "(Website IS NOT NULL AND Website != '')",
		},
		{
			name:    "boolean equality keeps typed literal",
			context: map[string]interface{}{},
			filters: []models.FlowRecordFilter{
				{Field: "IsPrimary", Operator: "EqualTo", Value: true},
				{Field: "IsDeleted", Operator: "NotEqualTo", Value: false},
			},
			logic:    "and",
			expected: "IsPrimary = true AND IsDeleted != false",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := executor.buildFilterFromConditions(tt.context, tt.filters, tt.logic)
			assert.Equal(t, tt.expected, result)
		})
	}
}

// ---------------------------------------------------------------------------
// Utility functions
// ---------------------------------------------------------------------------

func TestCompareNumbers(t *testing.T) {
	tests := []struct {
		name     string
		a, b     interface{}
		expected int
	}{
		{"equal floats", float64(5), float64(5), 0},
		{"a > b", float64(10), float64(5), 1},
		{"a < b", float64(3), float64(5), -1},
		{"int comparison", 5, 3, 1},
		{"int64 comparison", int64(5), int64(3), 1},
		{"float32 comparison", float32(5.0), float32(3.0), 1},
		{"non-numeric returns 0", "abc", "def", 0},
		{"mixed: one non-numeric", float64(5), "abc", 0},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := compareNumbers(tt.a, tt.b)
			assert.Equal(t, tt.expected, result)
		})
	}
}

func TestToFloat64(t *testing.T) {
	tests := []struct {
		name     string
		value    interface{}
		expected float64
		ok       bool
	}{
		{"float64", float64(3.14), 3.14, true},
		{"float32", float32(2.5), float64(float32(2.5)), true},
		{"int", 42, 42.0, true},
		{"int64", int64(99), 99.0, true},
		{"string", "abc", 0, false},
		{"nil", nil, 0, false},
		{"bool", true, 0, false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result, ok := utils.ToFloat64(tt.value)
			assert.Equal(t, tt.ok, ok)
			if ok {
				assert.InDelta(t, tt.expected, result, 0.001)
			}
		})
	}
}

func TestContainsString(t *testing.T) {
	tests := []struct {
		name     string
		a, b     interface{}
		expected bool
	}{
		{"substring present", "hello world", "world", true},
		{"substring absent", "hello", "world", false},
		{"numeric values", 12345, 234, true},
		{"empty substring", "hello", "", true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := containsString(tt.a, tt.b)
			assert.Equal(t, tt.expected, result)
		})
	}
}

func TestEscapeSQLValue(t *testing.T) {
	tests := []struct {
		name     string
		value    interface{}
		expected string
	}{
		{"nil value", nil, ""},
		{"plain string", "hello", "hello"},
		{"string with single quote", "it's", "it\\'s"},
		{"multiple quotes", "a'b'c", "a\\'b\\'c"},
		{"string with backslash", `path\\name`, `path\\\\name`},
		{"numeric", 42, "42"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := escapeSQLValue(tt.value)
			assert.Equal(t, tt.expected, result)
		})
	}
}

func TestValidateIdentifier(t *testing.T) {
	tests := []struct {
		name      string
		id        string
		expectErr bool
	}{
		{"valid alphanumeric", "Account", false},
		{"valid with underscore", "My_Object__c", false},
		{"valid UUID-style", "a1b2c3d4-e5f6", false},
		{"empty string", "", true},
		{"SQL injection attempt", "'; DROP TABLE --", true},
		{"spaces", "My Object", true},
		{"too long", string(make([]byte, 256)), true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Fill the "too long" case with valid characters
			id := tt.id
			if tt.name == "too long" {
				b := make([]byte, 256)
				for i := range b {
					b[i] = 'a'
				}
				id = string(b)
			}
			err := validateIdentifier(id)
			if tt.expectErr {
				assert.Error(t, err)
			} else {
				assert.NoError(t, err)
			}
		})
	}
}

// ---------------------------------------------------------------------------
// resolveNestedReferenceCtx — __r lazy-load (GAP 6, Phase 2.1)
// ---------------------------------------------------------------------------

// stubQuery records invocations of QueryWithFilter and returns canned records
// keyed by (objectName, filter). It implements only the methods the
// resolveNestedReferenceCtx path touches; the remaining ports.QueryService
// members are no-ops so we can plug the stub directly into a FlowExecutor.
type stubQuery struct {
	// records maps "{objectType}|{filterExpr}" to the records to return.
	records map[string][]models.SObject
	// err is returned from the next QueryWithFilter call if non-nil.
	err error
	// calls records every QueryWithFilter invocation for assertion purposes.
	calls []stubQueryCall
}

type stubQueryCall struct {
	Object string
	Filter string
}

func (s *stubQuery) QueryWithFilter(ctx context.Context, objectName string, filterExpr string, user *models.UserSession, sortField string, sortDir string, limit int) (*models.QueryResult, error) {
	s.calls = append(s.calls, stubQueryCall{Object: objectName, Filter: filterExpr})
	if s.err != nil {
		return nil, s.err
	}
	recs := s.records[objectName+"|"+filterExpr]
	return &models.QueryResult{
		Records:   recs,
		TotalSize: len(recs),
	}, nil
}

// seedFieldMetaLookup builds a test fieldMetaLookup from a nested map:
//
//	{"Order": {"Contract__c": {ReferenceTo: ["Contract"]}}}
func seedFieldMetaLookup(m map[string]map[string]*models.FieldMetadata) func(string, string) *models.FieldMetadata {
	return func(object, field string) *models.FieldMetadata {
		if fields, ok := m[object]; ok {
			return fields[field]
		}
		return nil
	}
}

// newRCacheTestExecutor builds an executor wired with stubs sufficient to
// exercise resolveNestedReferenceCtx end-to-end.
func newRCacheTestExecutor(q *stubQuery, lookup func(string, string) *models.FieldMetadata) *FlowExecutor {
	return &FlowExecutor{
		query:           stubQueryAdapter{stub: q},
		logger:          logging.Default,
		fieldMetaLookup: lookup,
	}
}

// stubQueryAdapter satisfies ports.QueryService by delegating the one method we
// care about to stubQuery and returning zero-values from the rest. It exists
// because embedding would require implementing every method on stubQuery.
type stubQueryAdapter struct {
	stub *stubQuery
}

func (a stubQueryAdapter) Query(ctx context.Context, req api.QueryRequest, user *models.UserSession) (*models.QueryResult, error) {
	return nil, nil
}
func (a stubQueryAdapter) QueryWithFilter(ctx context.Context, objectName string, filterExpr string, user *models.UserSession, sortField string, sortDir string, limit int) (*models.QueryResult, error) {
	return a.stub.QueryWithFilter(ctx, objectName, filterExpr, user, sortField, sortDir, limit)
}
func (a stubQueryAdapter) RunAnalytics(context.Context, models.AnalyticsQuery, *models.UserSession) (interface{}, error) {
	return nil, nil
}
func (a stubQueryAdapter) SearchSingleObject(context.Context, string, string, []string, []string, int, int, string, string, string, *models.UserSession) ([]models.SObject, error) {
	return nil, nil
}
func (a stubQueryAdapter) GlobalSearch(context.Context, string, *models.UserSession) ([]models.SearchResult, error) {
	return nil, nil
}
func (a stubQueryAdapter) Calculate(context.Context, string, models.SObject, *models.UserSession) (models.SObject, error) {
	return nil, nil
}
func (a stubQueryAdapter) ExecuteRawSQL(context.Context, string, []interface{}, *models.UserSession) ([]models.SObject, error) {
	return nil, nil
}

func TestResolveNestedReferenceCtx_SingleHopR(t *testing.T) {
	orderRecord := models.SObject{"Id": "ord-1", "Order__c": "ord-1", "Name": "Acme Order"}
	q := &stubQuery{
		records: map[string][]models.SObject{
			"Order|Id = 'ord-1'": {orderRecord},
		},
	}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Invoice__c": {
			"Order__c": {APIName: "Order__c", ReferenceTo: []string{"Order"}},
		},
	}))

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c",
			"$Record": models.SObject{
				"Id":       "inv-1",
				"Order__c": "ord-1",
			},
		},
	}

	result := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Order__r.Name")
	assert.Equal(t, "Acme Order", result)
	assert.Len(t, q.calls, 1, "expected exactly one DB lookup for single hop")
}

func TestResolveNestedReferenceCtx_DeepR(t *testing.T) {
	orderRecord := models.SObject{"Id": "ord-1", "Account__c": "acc-1", "Name": "Ord"}
	accountRecord := models.SObject{"Id": "acc-1", "Name": "Acme Inc"}
	q := &stubQuery{
		records: map[string][]models.SObject{
			"Order|Id = 'ord-1'":   {orderRecord},
			"Account|Id = 'acc-1'": {accountRecord},
		},
	}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Invoice__c": {
			"Order__c": {APIName: "Order__c", ReferenceTo: []string{"Order"}},
		},
		"Order": {
			"Account__c": {APIName: "Account__c", ReferenceTo: []string{"Account"}},
		},
	}))

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c",
			"$Record": models.SObject{
				"Id":       "inv-1",
				"Order__c": "ord-1",
			},
		},
	}

	result := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Order__r.Account__r.Name")
	assert.Equal(t, "Acme Inc", result)
	assert.Len(t, q.calls, 2, "expected one DB lookup per __r hop")
}

// TestResolveNestedReferenceCtx_DeepStandardRelationship_NoFieldMeta — issue #2093.
//
// Mirrors the audit reproduction scenario: 2-hop traversal of a standard User
// reference (`$Record.Owner.UserRole.Name`) when the executor cannot resolve
// User.UserRoleId via per-field metadata (cache miss / partial schema load on
// the system_core User table).
//
// Pre-fix: `relationshipFKField("UserRole")` misses because "UserRole" is not
// in the standard relationship-name map, AND `lookupFieldMeta("User",
// "UserRoleId")` returns nil, so `resolveRelationshipFK` Tier 4's metadata gate
// rejects the candidate FK. The walker falls into the literal-field branch,
// `user["UserRole"]` is nil, and the assignment skips. Even if Tier 4 did
// fire, `loadRelatedRecord` step 2 cannot resolve `relatedType` because the
// fallback `LookupStandardRelationTarget("UserRoleId")` also misses.
//
// The fix extends the SSoT standard relationship/FK table in
// shared/pkg/salesforce to cover the standard User references (UserRole,
// Profile, Manager → UserRoleId/ProfileId/ManagerId), so both the resolver and
// the loadRelatedRecord fallback agree without per-field metadata.
func TestResolveNestedReferenceCtx_DeepStandardRelationship_NoFieldMeta(t *testing.T) {
	userRecord := models.SObject{"Id": "user-1", "Name": "Admin", "UserRoleId": "role-1"}
	roleRecord := models.SObject{"Id": "role-1", "Name": "Japan Sales Leader"}
	q := &stubQuery{
		records: map[string][]models.SObject{
			"User|Id = 'user-1'":     {userRecord},
			"UserRole|Id = 'role-1'": {roleRecord},
		},
	}
	// fieldMetaLookup deliberately returns nil for every (object, field) pair
	// to simulate a metadata-cache miss. Hop 1 must still reach User via the
	// standard "Owner" → OwnerId map, and hop 2 must still reach UserRole via
	// the standard "UserRole" → UserRoleId map.
	executor := newRCacheTestExecutor(q, func(string, string) *models.FieldMetadata { return nil })

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Opportunity",
			"$Record": models.SObject{
				"Id":      "opp-1",
				"OwnerId": "user-1",
			},
		},
	}

	gotVal, present := executor.resolveNestedReferenceCtxWithPresence(context.Background(), interview, nil, "$Record.Owner.UserRole.Name")
	assert.Equal(t, "Japan Sales Leader", gotVal, "2-hop resolution must succeed via standard SSoT even without per-field metadata")
	assert.True(t, present, "Assignment must observe present=true so it writes through")
	assert.Len(t, q.calls, 2, "expected one DB lookup per hop")
}

// TestResolveNestedReferenceCtx_DeepStandardRelationship — issue #2093.
//
// 2-hop traversal of standard relationship segments (no `__r` suffix) must
// resolve identically to the 2-hop `__r` case exercised by
// TestResolveNestedReferenceCtx_DeepR.
//
// Reproduces the bug: BeforeSave Opportunity Assignment writes
// `$Record.Owner.UserRole.Name` to a custom field. Hop 1 (Owner -> User)
// resolves via the standard-relationship table; hop 2 (UserRole -> UserRole)
// has neither a `__r` suffix nor a standard-table entry, so it falls through
// to the metadata-driven `<segment>Id` convention. The walker must take the
// same metadata-aware path as hop 1 so the second fetch occurs.
//
// Pre-fix: `Opt_Owner_Role__c` stays NULL because the second hop fails to
// resolve a foreign-key column for "UserRole" on User.
func TestResolveNestedReferenceCtx_DeepStandardRelationship(t *testing.T) {
	userRecord := models.SObject{"Id": "user-1", "Name": "Admin", "UserRoleId": "role-1"}
	roleRecord := models.SObject{"Id": "role-1", "Name": "Japan Sales Leader"}
	q := &stubQuery{
		records: map[string][]models.SObject{
			"User|Id = 'user-1'":     {userRecord},
			"UserRole|Id = 'role-1'": {roleRecord},
		},
	}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Opportunity": {
			"OwnerId": {APIName: "OwnerId", ReferenceTo: []string{"User"}},
		},
		"User": {
			"UserRoleId": {APIName: "UserRoleId", ReferenceTo: []string{"UserRole"}},
		},
	}))

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Opportunity",
			"$Record": models.SObject{
				"Id":      "opp-1",
				"OwnerId": "user-1",
			},
		},
	}

	result := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Owner.UserRole.Name")
	assert.Equal(t, "Japan Sales Leader", result, "2-hop standard relationship traversal must resolve")
	assert.Len(t, q.calls, 2, "expected one DB lookup per hop")

	// Re-verify via the presence-tracking variant — Assignment uses this path.
	q.calls = nil
	gotVal, present := executor.resolveNestedReferenceCtxWithPresence(context.Background(), interview, nil, "$Record.Owner.UserRole.Name")
	assert.Equal(t, "Japan Sales Leader", gotVal, "WithPresence variant must resolve identically")
	assert.True(t, present, "leaf must report present=true so Assign writes the value")
}

func TestResolveNestedReferenceCtx_CacheHit(t *testing.T) {
	orderRecord := models.SObject{"Id": "ord-1", "Name": "Acme Order", "Status__c": "Open"}
	q := &stubQuery{
		records: map[string][]models.SObject{
			"Order|Id = 'ord-1'": {orderRecord},
		},
	}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Invoice__c": {
			"Order__c": {APIName: "Order__c", ReferenceTo: []string{"Order"}},
		},
	}))

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c",
			"$Record":     models.SObject{"Id": "inv-1", "Order__c": "ord-1"},
		},
	}

	// First lookup triggers DB query.
	first := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Order__r.Name")
	assert.Equal(t, "Acme Order", first)
	// Second lookup on a different field of the same related record must hit cache.
	second := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Order__r.Status__c")
	assert.Equal(t, "Open", second)
	assert.Len(t, q.calls, 1, "cache should prevent the second DB hit")
}

func TestResolveNestedReferenceCtx_MissingRelatedRecord(t *testing.T) {
	q := &stubQuery{records: map[string][]models.SObject{}} // Empty — no match
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Invoice__c": {
			"Order__c": {APIName: "Order__c", ReferenceTo: []string{"Order"}},
		},
	}))

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c",
			"$Record":     models.SObject{"Id": "inv-1", "Order__c": "ord-gone"},
		},
	}

	// Must NOT panic.
	assert.NotPanics(t, func() {
		result := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Order__r.Name")
		assert.Nil(t, result)
	})
}

func TestResolveNestedReferenceCtx_NilFKReturnsNil(t *testing.T) {
	q := &stubQuery{records: map[string][]models.SObject{}}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Invoice__c": {
			"Order__c": {APIName: "Order__c", ReferenceTo: []string{"Order"}},
		},
	}))

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c",
			"$Record":     models.SObject{"Id": "inv-1"}, // no Order__c set
		},
	}

	result := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Order__r.Name")
	assert.Nil(t, result)
	assert.Empty(t, q.calls, "no DB call when FK is nil")
}

func TestResolveNestedReferenceCtx_QueryErrorReturnsNil(t *testing.T) {
	q := &stubQuery{err: errors.New("db down")}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Invoice__c": {
			"Order__c": {APIName: "Order__c", ReferenceTo: []string{"Order"}},
		},
	}))

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c",
			"$Record":     models.SObject{"Id": "inv-1", "Order__c": "ord-1"},
		},
	}

	assert.NotPanics(t, func() {
		result := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Order__r.Name")
		assert.Nil(t, result)
	})
}

func TestResolveNestedReferenceCtx_LoopWarn(t *testing.T) {
	// Smoke test: verify the code path executes without error when _loopDepth
	// is set. We can't easily assert on zerolog output without an adapter,
	// so the key invariant is that the resolution still succeeds and the
	// call doesn't panic. The warn call is covered by the logger's nil-safe
	// contract (see pkg/logging/logger.go).
	orderRecord := models.SObject{"Id": "ord-1", "Name": "Acme"}
	q := &stubQuery{
		records: map[string][]models.SObject{
			"Order|Id = 'ord-1'": {orderRecord},
		},
	}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Invoice__c": {
			"Order__c": {APIName: "Order__c", ReferenceTo: []string{"Order"}},
		},
	}))

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c",
			"_loopDepth":  float64(1), // simulate being inside a Loop
			"$Record":     models.SObject{"Id": "inv-1", "Order__c": "ord-1"},
		},
	}

	result := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Order__r.Name")
	assert.Equal(t, "Acme", result)
	assert.Len(t, q.calls, 1)
	// loopDepth read back as 1 (not mutated).
	assert.Equal(t, 1, loopDepth(interview.ContextData))
}

func TestResolveNestedReferenceCtx_NonRUnchanged(t *testing.T) {
	// Regression: paths without __r must behave identically to the legacy
	// resolveNestedReference — no DB calls, pure in-memory walk.
	q := &stubQuery{}
	executor := newRCacheTestExecutor(q, nil)

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"$Record": models.SObject{
				"Name":    "Acme",
				"Account": map[string]interface{}{"Name": "Acme Parent"},
			},
		},
	}

	// Shallow field.
	assert.Equal(t, "Acme",
		executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Name"))
	// Nested embedded map (no __r).
	assert.Equal(t, "Acme Parent",
		executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Account.Name"))

	assert.Empty(t, q.calls, "non-__r paths must not hit the DB")
}

func TestLoopDepthHelpers(t *testing.T) {
	ctx := map[string]interface{}{}

	assert.Equal(t, 0, loopDepth(ctx))

	setLoopDepth(ctx, 2)
	assert.Equal(t, 2, loopDepth(ctx))
	assert.Equal(t, float64(2), ctx["_loopDepth"])

	setLoopDepth(ctx, 0)
	_, exists := ctx["_loopDepth"]
	assert.False(t, exists, "zero depth should delete the key")

	// Negative is coerced to delete.
	setLoopDepth(ctx, -1)
	_, exists = ctx["_loopDepth"]
	assert.False(t, exists)
}

func TestResolveValueCtx_ResolvesUnderscoreR(t *testing.T) {
	orderRecord := models.SObject{"Id": "ord-1", "Name": "Acme Order"}
	q := &stubQuery{
		records: map[string][]models.SObject{
			"Order|Id = 'ord-1'": {orderRecord},
		},
	}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Invoice__c": {
			"Order__c": {APIName: "Order__c", ReferenceTo: []string{"Order"}},
		},
	}))
	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c",
			"$Record":     models.SObject{"Id": "inv-1", "Order__c": "ord-1"},
		},
	}

	// elementReference wrapper, common in flow metadata.
	got := executor.resolveValueCtx(context.Background(), interview, nil,
		map[string]interface{}{"elementReference": "$Record.Order__r.Name"})
	assert.Equal(t, "Acme Order", got)
}

// TestResolveValueCtx_UnresolvedElementReferenceReturnsNil locks in the fix for
// issue #1123: when flow metadata references a formula (or any element) by name
// via {"elementReference": "X"} and the name is NOT present in ContextData
// (e.g., because evaluateFlowFormulas logged a WARN and skipped the formula on
// eval failure), the resolver MUST return nil — not the raw reference string.
//
// The bug produced this DB state for an AfterSave Order flow:
//
//	Order.PingCAP_RevRec_Entity__c = "formula_2_myRule_1_A1_6505267583"
//
// i.e. the formula element's NAME written as a literal string, because the
// resolver echoed the reference back on unresolved lookup. Returning nil
// instead means executeRecordUpdate either leaves the column alone (the
// persistence layer may treat nil as "do not write") or writes NULL —
// either is correct per the issue, and both are strictly better than
// writing the element's name.
//
// Raw literal strings (no wrapper) keep their legacy passthrough behavior —
// that path is covered by the existing "unresolved string returned as-is"
// case in TestResolveValue above. Only the WRAPPER form changes.
func TestResolveValueCtx_UnresolvedElementReferenceReturnsNil(t *testing.T) {
	executor := newTestExecutor()
	interview := &models.FlowInterview{
		// Intentionally no entry for "formula_2_myRule_1_A1_6505267583" —
		// mirrors what evaluateFlowFormulas does when engine.Evaluate errors.
		ContextData: map[string]interface{}{},
	}

	got := executor.resolveValueCtx(context.Background(), interview, nil,
		map[string]interface{}{"elementReference": "formula_2_myRule_1_A1_6505267583"})

	assert.Nil(t, got,
		"unresolved elementReference must return nil so recordUpdate does not "+
			"write the element's NAME (e.g. %q) into the DB",
		"formula_2_myRule_1_A1_6505267583")
}

// TestExecuteRecordUpdate_SkipsAssignmentOnFailedFormulaRef is the end-to-end
// companion for #1123: wire a recordUpdate whose input value is a wrapper
// referencing a formula that failed to evaluate (its name is absent from
// ContextData), and assert the persisted update does NOT carry the formula
// element's name as a literal string for the target column.
func TestExecuteRecordUpdate_SkipsAssignmentOnFailedFormulaRef(t *testing.T) {
	mockPersist := new(MockPersistence)
	executor := &FlowExecutor{persistence: mockPersist}

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			// $Record has an ID so executeRecordUpdate takes the persistence
			// path. No "formula_2_..." key — simulates eval-failed formula.
			"$Record": models.SObject{
				constants.FieldID:          "ord-001",
				"PingCAP_RevRec_Entity__c": "",
			},
		},
	}
	update := &models.FlowRecordUpdate{
		Name:   "Order_PingCAP_RevRec_Entity",
		Object: "Order",
		InputAssignments: []models.FlowInputAssignment{
			{
				Field: "PingCAP_RevRec_Entity__c",
				Value: map[string]interface{}{
					"elementReference": "formula_2_myRule_1_A1_6505267583",
				},
			},
		},
		Connector: &models.FlowConnector{TargetReference: "End"},
	}

	// Capture the updates payload so we can assert on the exact value.
	var captured models.SObject
	mockPersist.On("Update", mock.Anything, "Order", "ord-001",
		mock.MatchedBy(func(u models.SObject) bool {
			captured = u
			return true
		}), mock.Anything).Return(nil)

	// Use WithTransientFlowInterviews so the executor takes the persistence
	// path (AfterSave behavior) rather than the in-place $Record branch.
	ctx := WithTransientFlowInterviews(context.Background())
	next, err := executor.executeRecordUpdate(ctx, interview, update, nil)

	assert.NoError(t, err)
	assert.Equal(t, "End", next)

	persistedVal, present := captured["PingCAP_RevRec_Entity__c"]
	if present {
		assert.Nil(t, persistedVal,
			"on failed formula eval, field must be nil (or omitted); got %v (%T)",
			persistedVal, persistedVal)
		assert.NotEqual(t, "formula_2_myRule_1_A1_6505267583", persistedVal,
			"must not persist the formula element's NAME as a literal string")
	}
	mockPersist.AssertExpectations(t)
}

func TestEvaluateConditionCtx_UsesUnderscoreR(t *testing.T) {
	orderRecord := models.SObject{"Id": "ord-1", "Status__c": "Active"}
	q := &stubQuery{
		records: map[string][]models.SObject{
			"Order|Id = 'ord-1'": {orderRecord},
		},
	}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Invoice__c": {
			"Order__c": {APIName: "Order__c", ReferenceTo: []string{"Order"}},
		},
	}))
	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c",
			"$Record":     models.SObject{"Id": "inv-1", "Order__c": "ord-1"},
		},
	}

	cond := &models.FlowCondition{
		LeftValueReference: "$Record.Order__r.Status__c",
		Operator:           "EqualTo",
		RightValue:         "Active",
	}
	assert.True(t, executor.evaluateConditionCtx(context.Background(), interview, nil, cond))
}

func TestRootObjectType(t *testing.T) {
	// $Record always uses _objectType.
	ctxData := map[string]interface{}{"_objectType": "Invoice__c"}
	record := models.SObject{"Id": "inv-1"}
	assert.Equal(t, "Invoice__c", rootObjectType(ctxData, "$Record", record))
	assert.Equal(t, "Invoice__c", rootObjectType(ctxData, "$Record__Old", record))

	// Non-$Record variables prefer attributes.type when available.
	lookupRecord := models.SObject{
		"Id":         "acc-1",
		"attributes": map[string]interface{}{"type": "Account"},
	}
	assert.Equal(t, "Account", rootObjectType(ctxData, "MyLookup", lookupRecord))

	// Without attributes.type, fall back to _objectType (imperfect but safer
	// than empty).
	assert.Equal(t, "Invoice__c", rootObjectType(ctxData, "MyLookup", record))
}

func TestResolveNestedReferenceCtx_NonRecordRootHonorsAttributesType(t *testing.T) {
	// A non-$Record variable (e.g., a RecordLookup result) carries its own
	// object type via attributes.type. When that variable then references a
	// __r field, the resolver must use the variable's OWN type for metadata
	// lookup, NOT the flow's trigger object type.
	accountRecord := models.SObject{"Id": "acc-1", "Name": "Acme"}
	q := &stubQuery{
		records: map[string][]models.SObject{
			"Account|Id = 'acc-1'": {accountRecord},
		},
	}
	// Metadata: Order has AccountLookup__c pointing to Account.
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Order": {
			"AccountLookup__c": {APIName: "AccountLookup__c", ReferenceTo: []string{"Account"}},
		},
		// The trigger object has NO such field — if the resolver fell back to
		// _objectType, the metadata lookup would fail.
		"Invoice__c": {},
	}))
	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c", // Trigger object is Invoice__c
			"OrderLookup": models.SObject{
				"Id":               "ord-1",
				"AccountLookup__c": "acc-1",
				"attributes":       map[string]interface{}{"type": "Order"},
			},
		},
	}

	result := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "OrderLookup.AccountLookup__r.Name")
	assert.Equal(t, "Acme", result)
}

func TestResolveNestedReferenceCtx_InlineRelationshipWins(t *testing.T) {
	// When $Record carries an inline Order__r sObject (expanded by the caller),
	// the resolver must NOT issue a DB lookup — it should read from memory.
	q := &stubQuery{} // panics on call via .calls check below
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Invoice__c": {
			"Order__c": {APIName: "Order__c", ReferenceTo: []string{"Order"}},
		},
	}))
	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c",
			"$Record": models.SObject{
				"Id":       "inv-1",
				"Order__c": "ord-1",
				"Order__r": models.SObject{
					"Id":         "ord-1",
					"Name":       "Inline Order",
					"attributes": map[string]interface{}{"type": "Order"},
				},
			},
		},
	}

	got := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Order__r.Name")
	assert.Equal(t, "Inline Order", got)
	assert.Empty(t, q.calls, "inline relationship record must short-circuit the DB lookup")
}

func TestResolveNestedReferenceCtx_RefreshesObjectTypeAfterNestedMap(t *testing.T) {
	// After stepping into an embedded Account (non-__r), a subsequent __r hop
	// must look up metadata on Account, not on the trigger object Invoice__c.
	ownerRecord := models.SObject{"Id": "usr-1", "Name": "Alice"}
	q := &stubQuery{
		records: map[string][]models.SObject{
			"User|Id = 'usr-1'": {ownerRecord},
		},
	}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		// The FK field lives on Account, not on Invoice__c. If the resolver
		// failed to refresh currentObjectType after walking into Account, the
		// metadata lookup would fall through to Invoice__c and miss.
		"Account": {
			"Owner__c": {APIName: "Owner__c", ReferenceTo: []string{"User"}},
		},
		"Invoice__c": {}, // no fields
	}))
	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c",
			"$Record": models.SObject{
				"Id": "inv-1",
				"Account": models.SObject{
					"Id":         "acc-1",
					"Owner__c":   "usr-1",
					"attributes": map[string]interface{}{"type": "Account"},
				},
			},
		},
	}

	got := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Account.Owner__r.Name")
	assert.Equal(t, "Alice", got)
}

func TestLoadRelatedRecord_StampsAttributesType(t *testing.T) {
	// A deep __r walk `$Record.Order__r.Account__r.Name` starts from $Record
	// (Invoice__c), lazy-loads Order, then has to hop from Order → Account.
	// The second hop can only succeed if the first lazy-loaded record
	// (Order) carries attributes.type so the resolver knows which object to
	// ask metadata about. Regression test for the lazy-load stamping fix.
	orderRecord := models.SObject{"Id": "ord-1", "Account__c": "acc-1"}
	accountRecord := models.SObject{"Id": "acc-1", "Name": "Acme Inc"}
	q := &stubQuery{
		records: map[string][]models.SObject{
			"Order|Id = 'ord-1'":   {orderRecord},
			"Account|Id = 'acc-1'": {accountRecord},
		},
	}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Invoice__c": {
			"Order__c": {APIName: "Order__c", ReferenceTo: []string{"Order"}},
		},
		"Order": {
			"Account__c": {APIName: "Account__c", ReferenceTo: []string{"Account"}},
		},
	}))
	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c",
			"$Record":     models.SObject{"Id": "inv-1", "Order__c": "ord-1"},
		},
	}

	result := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Order__r.Account__r.Name")
	assert.Equal(t, "Acme Inc", result)

	// The Order record in the cache must now carry attributes.type so later
	// unrelated reuse of that record (assignment, subflow input) preserves
	// its type.
	cache, _ := interview.ContextData["__r_cache"].(map[string]models.SObject)
	cachedOrder := cache["Order:ord-1"]
	assert.NotNil(t, cachedOrder)
	attrs, _ := cachedOrder["attributes"].(map[string]interface{})
	assert.Equal(t, "Order", attrs["type"])
}

func TestInferRootObjectType(t *testing.T) {
	// No $Record / $Record__Old → empty.
	assert.Equal(t, "", inferRootObjectType(map[string]interface{}{}))

	// $Record with attributes.type.
	ctxData := map[string]interface{}{
		"$Record": models.SObject{
			"Id":         "inv-1",
			"attributes": map[string]interface{}{"type": "Invoice__c"},
		},
	}
	assert.Equal(t, "Invoice__c", inferRootObjectType(ctxData))

	// $Record__Old when $Record is absent.
	ctxData2 := map[string]interface{}{
		"$Record__Old": map[string]interface{}{
			"attributes": map[string]interface{}{"type": "Account"},
		},
	}
	assert.Equal(t, "Account", inferRootObjectType(ctxData2))

	// $Record without attributes → empty (fallback responsibility moves up).
	ctxData3 := map[string]interface{}{
		"$Record": models.SObject{"Id": "1"},
	}
	assert.Equal(t, "", inferRootObjectType(ctxData3))
}

func TestBuildFilterFromConditionsCtx_ResolvesUnderscoreR(t *testing.T) {
	orderRecord := models.SObject{"Id": "ord-1", "Status__c": "Active"}
	q := &stubQuery{
		records: map[string][]models.SObject{
			"Order|Id = 'ord-1'": {orderRecord},
		},
	}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Invoice__c": {
			"Order__c": {APIName: "Order__c", ReferenceTo: []string{"Order"}},
		},
	}))
	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Invoice__c",
			"$Record":     models.SObject{"Id": "inv-1", "Order__c": "ord-1"},
		},
	}

	filters := []models.FlowRecordFilter{
		{Field: "LinkedStatus__c", Operator: "=", Value: "$Record.Order__r.Status__c"},
	}
	got := executor.buildFilterFromConditionsCtx(context.Background(), interview, nil, filters, "and")
	assert.Equal(t, "LinkedStatus__c = 'Active'", got)
}

func TestStampAttributesType(t *testing.T) {
	// No-op on nil / empty.
	stampAttributesType(nil, "Order")
	stampAttributesType(models.SObject{}, "")

	// Stamps when attributes is absent.
	r := models.SObject{"Id": "1"}
	stampAttributesType(r, "Order")
	attrs, _ := r["attributes"].(map[string]interface{})
	assert.Equal(t, "Order", attrs["type"])

	// Preserves existing type.
	r2 := models.SObject{"attributes": map[string]interface{}{"type": "Foo"}}
	stampAttributesType(r2, "Order")
	attrs2, _ := r2["attributes"].(map[string]interface{})
	assert.Equal(t, "Foo", attrs2["type"])

	// Adds to existing attributes map without type.
	r3 := models.SObject{"attributes": map[string]interface{}{"url": "/x"}}
	stampAttributesType(r3, "Order")
	attrs3, _ := r3["attributes"].(map[string]interface{})
	assert.Equal(t, "Order", attrs3["type"])
	assert.Equal(t, "/x", attrs3["url"])
}

func TestRelationshipFKField(t *testing.T) {
	tests := []struct {
		in       string
		expected string
	}{
		{"Order__r", "Order__c"},
		{"Nested_Lookup__r", "Nested_Lookup__c"},
		{"Account", "AccountId"}, // standard relationship
		{"Order", "OrderId"},     // standard relationship
		{"", ""},                 // empty
	}
	for _, tt := range tests {
		t.Run(tt.in, func(t *testing.T) {
			assert.Equal(t, tt.expected, relationshipFKField(tt.in))
		})
	}
}

// TestResolveNestedReferenceCtx_TwoLevelStandardRelationshipViaMetadata
// reproduces issue #1743: `$Record.Owner.UserRole.Name` on Opportunity
// silently returns nil because `UserRole` is a standard relationship name
// outside the hardcoded map. The fix discovers the FK field (`UserRoleId`)
// via metadata on the current object (User), not a hardcoded table.
func TestResolveNestedReferenceCtx_TwoLevelStandardRelationshipViaMetadata(t *testing.T) {
	userRecord := models.SObject{"Id": "usr-1", "UserRoleId": "role-1", "Name": "Alice"}
	roleRecord := models.SObject{"Id": "role-1", "Name": "TestRole"}
	q := &stubQuery{
		records: map[string][]models.SObject{
			"User|Id = 'usr-1'":      {userRecord},
			"UserRole|Id = 'role-1'": {roleRecord},
		},
	}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Opportunity": {
			// OwnerId is covered by the hardcoded standard-relationship map, but
			// metadata must still answer ReferenceTo so the first hop knows to
			// query the User object.
			"OwnerId": {APIName: "OwnerId", ReferenceTo: []string{"User"}},
		},
		"User": {
			// UserRoleId is the critical one: not in the hardcoded map, so the
			// resolver must derive it from metadata ("<Segment>Id" convention).
			"UserRoleId": {APIName: "UserRoleId", ReferenceTo: []string{"UserRole"}},
		},
	}))
	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Opportunity",
			"$Record": models.SObject{
				"Id":      "opp-1",
				"OwnerId": "usr-1",
			},
		},
	}

	got := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Owner.UserRole.Name")
	assert.Equal(t, "TestRole", got,
		"2-level standard relationship must resolve via metadata convention — issue #1743")
}

// TestResolveNestedReferenceCtx_PolymorphicRefWithoutDiscriminator_FailsClosed
// pins the indeterminate behaviour for a polymorphic FK when the row carries
// no `<FK>_type` discriminator. Pre-#2057-round-8 loadRelatedRecord pre-computed
// ReferenceTo[0] and would silently query Contact for any WhoId; round-8 routes
// pre-compute through formula.ResolveRelationTarget which fails closed without
// a discriminator, matching FetchRelatedRecord's contract.
func TestResolveNestedReferenceCtx_PolymorphicRefWithoutDiscriminator_FailsClosed(t *testing.T) {
	q := &stubQuery{records: map[string][]models.SObject{}}
	executor := newRCacheTestExecutor(q, seedFieldMetaLookup(map[string]map[string]*models.FieldMetadata{
		"Task": {
			// Polymorphic — Salesforce's classic WhoId target.
			"WhoId": {APIName: "WhoId", ReferenceTo: []string{"Contact", "Lead"}},
		},
	}))
	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Task",
			"$Record":     models.SObject{"Id": "task-1", "WhoId": "lead-1"},
		},
	}

	got := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Who.Name")
	assert.Nil(t, got,
		"polymorphic FK without `<FK>_type` discriminator MUST be indeterminate — silent refTo[0] would query the wrong SObject")
}

// Note: the positive polymorphic-with-discriminator path is covered by direct
// loadRelatedRecord tests in executor_r10_read_paths_test.go. The
// resolveNestedReferenceCtx entry point still gates polymorphic refs at
// resolveRelationshipFK (Tier 4) as defense-in-depth — that gate is
// orthogonal to the round-8 fix at loadRelatedRecord pre-compute and is
// kept until polymorphic segment-name traversal is wired through the
// shared resolver.

// TestResolveNestedReferenceCtx_TwoLevelStandardRelationshipWithoutMetadata
// ensures no regression when metadata is unavailable (cold cache / unit tests
// wired without a metadata service). The hardcoded map remains the fast-path
// fallback for the 16 known standard relationships; anything outside it
// returns nil — same behavior as before the fix.
func TestResolveNestedReferenceCtx_TwoLevelStandardRelationshipWithoutMetadata(t *testing.T) {
	q := &stubQuery{records: map[string][]models.SObject{}}
	// No fieldMetaLookup wired → only hardcoded map applies.
	executor := newRCacheTestExecutor(q, nil)
	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"_objectType": "Opportunity",
			"$Record": models.SObject{
				"Id":      "opp-1",
				"OwnerId": "usr-1",
			},
		},
	}

	got := executor.resolveNestedReferenceCtx(context.Background(), interview, nil, "$Record.Owner.UserRole.Name")
	assert.Nil(t, got,
		"without metadata, non-hardcoded standard relationships return nil (no regression)")
}

// TestExecuteRecordUpdate_FilteredOverflowRoutesToFault verifies the
// filtered cross-object RecordUpdate path surfaces a governor-limit fault
// instead of silently truncating at 200 records. Without this guard, a
// flow operating on 201+ matching rows would update the first 200 and
// drop the rest with no signal to the flow author
// (PR #1583 review round-1 H-2).
func TestExecuteRecordUpdate_FilteredOverflowRoutesToFault(t *testing.T) {
	mockQuery := new(MockQueryService)
	mockPersist := new(MockPersistence)
	executor := &FlowExecutor{query: mockQuery, persistence: mockPersist}

	// Build 201 result records — one over the governor limit.
	overflowRecords := make([]models.SObject, 0, 201)
	for i := 0; i < 201; i++ {
		overflowRecords = append(overflowRecords, models.SObject{constants.FieldID: fmt.Sprintf("cm-%03d", i)})
	}

	// The executor is expected to query with limit+1 (201) to detect overflow.
	mockQuery.On("QueryWithFilter",
		mock.Anything, "CampaignMember", mock.Anything, mock.Anything,
		"", "", 201,
	).Return(&models.QueryResult{Records: overflowRecords, TotalSize: 201}, nil)

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"$Record": models.SObject{constants.FieldID: "cmp-1"},
		},
	}
	update := &models.FlowRecordUpdate{
		Name:   "UpdateAllCampaignMembers",
		Object: "CampaignMember",
		Filters: []models.FlowRecordFilter{
			{Field: "CampaignId", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "$Record.Id"}},
		},
		FilterLogic: "and",
		InputAssignments: []models.FlowInputAssignment{
			{Field: "Status", Value: "Approved"},
		},
		FaultConnector: &models.FlowConnector{TargetReference: "HandleFault"},
		Connector:      &models.FlowConnector{TargetReference: "End"},
	}

	next, err := executor.executeRecordUpdate(context.Background(), interview, update, nil)
	assert.NoError(t, err)
	assert.Equal(t, "HandleFault", next,
		"overflow must route to FaultConnector, not silently succeed")
	assert.Contains(t, fmt.Sprintf("%v", interview.ContextData["$Flow.FaultMessage"]), "200",
		"fault message should mention the 200-record governor limit")
	// Persistence must NOT have been called — silent partial update is the bug.
	mockPersist.AssertNotCalled(t, "Update",
		mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

// TestExecuteRecordDelete_FilteredOverflowRoutesToFault mirrors the
// RecordUpdate overflow test for RecordDelete.
func TestExecuteRecordDelete_FilteredOverflowRoutesToFault(t *testing.T) {
	mockQuery := new(MockQueryService)
	mockPersist := new(MockPersistence)
	executor := &FlowExecutor{query: mockQuery, persistence: mockPersist}

	overflowRecords := make([]models.SObject, 0, 201)
	for i := 0; i < 201; i++ {
		overflowRecords = append(overflowRecords, models.SObject{constants.FieldID: fmt.Sprintf("t-%03d", i)})
	}
	mockQuery.On("QueryWithFilter",
		mock.Anything, "Task", mock.Anything, mock.Anything,
		"", "", 201,
	).Return(&models.QueryResult{Records: overflowRecords, TotalSize: 201}, nil)

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"$Record": models.SObject{constants.FieldID: "acc-1"},
		},
	}
	del := &models.FlowRecordDelete{
		Name:   "DeleteOldTasks",
		Object: "Task",
		Filters: []models.FlowRecordFilter{
			{Field: "AccountId", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "$Record.Id"}},
		},
		FilterLogic:    "and",
		FaultConnector: &models.FlowConnector{TargetReference: "HandleFault"},
		Connector:      &models.FlowConnector{TargetReference: "End"},
	}

	next, err := executor.executeRecordDelete(context.Background(), interview, del, nil)
	assert.NoError(t, err)
	assert.Equal(t, "HandleFault", next)
	assert.Contains(t, fmt.Sprintf("%v", interview.ContextData["$Flow.FaultMessage"]), "200")
	mockPersist.AssertNotCalled(t, "Delete",
		mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

// TestExecuteRecordUpdate_FilteredAtLimitProcessesAll verifies the boundary:
// exactly 200 matches is NOT overflow — all 200 must be updated.
func TestExecuteRecordUpdate_FilteredAtLimitProcessesAll(t *testing.T) {
	mockQuery := new(MockQueryService)
	mockPersist := new(MockPersistence)
	executor := &FlowExecutor{query: mockQuery, persistence: mockPersist}

	atLimit := make([]models.SObject, 0, 200)
	for i := 0; i < 200; i++ {
		atLimit = append(atLimit, models.SObject{constants.FieldID: fmt.Sprintf("cm-%03d", i)})
	}
	mockQuery.On("QueryWithFilter",
		mock.Anything, "CampaignMember", mock.Anything, mock.Anything,
		"", "", 201,
	).Return(&models.QueryResult{Records: atLimit, TotalSize: 200}, nil)

	mockPersist.On("Update", mock.Anything, "CampaignMember",
		mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return(nil)

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"$Record": models.SObject{constants.FieldID: "cmp-1"},
		},
	}
	update := &models.FlowRecordUpdate{
		Name:    "UpdateUpTo200",
		Object:  "CampaignMember",
		Filters: []models.FlowRecordFilter{{Field: "CampaignId", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "$Record.Id"}}},
		InputAssignments: []models.FlowInputAssignment{
			{Field: "Status", Value: "Approved"},
		},
		FaultConnector: &models.FlowConnector{TargetReference: "HandleFault"},
		Connector:      &models.FlowConnector{TargetReference: "End"},
	}

	next, err := executor.executeRecordUpdate(context.Background(), interview, update, nil)
	assert.NoError(t, err)
	assert.Equal(t, "End", next, "at exactly 200 matches should complete normally")
	_, faultSet := interview.ContextData["$Flow.FaultMessage"]
	assert.False(t, faultSet, "no fault expected when count == limit")
	mockPersist.AssertNumberOfCalls(t, "Update", 200)
}

// TestExecuteDecision_SkipsReservedRuleNames confirms that a decision rule
// whose Name would collide with a system variable (`$Record`, `$Flow.*`,
// `$Organization.*`, etc.) or is empty is not written to ContextData.
// Writing the bool outcome under a reserved name would clobber the trigger
// record, fault message, or flow metadata (PR #1583 review round-1 H-5).
func TestExecuteDecision_SkipsReservedRuleNames(t *testing.T) {
	executor := newTestExecutor()
	recordSentinel := models.SObject{"Id": "001", "Name": "Acme"}

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"$Record":            recordSentinel,
			"$Flow.FaultMessage": "",
			"leftValue":          "Active",
		},
	}

	decision := &models.FlowDecision{
		Name: "MultiRuleDecision",
		Rules: []models.FlowDecisionRule{
			// Reserved name — must be skipped.
			{
				Name:           "$Record",
				ConditionLogic: "and",
				Conditions: []models.FlowCondition{
					{LeftValueReference: "leftValue", Operator: "EqualTo", RightValue: "Active"},
				},
			},
			// Empty name — must be skipped.
			{
				Name:           "",
				ConditionLogic: "and",
				Conditions: []models.FlowCondition{
					{LeftValueReference: "leftValue", Operator: "EqualTo", RightValue: "Active"},
				},
			},
			// Another reserved name — must be skipped.
			{
				Name:           "$Flow.FaultMessage",
				ConditionLogic: "and",
				Conditions: []models.FlowCondition{
					{LeftValueReference: "leftValue", Operator: "EqualTo", RightValue: "Active"},
				},
			},
			// Safe user-facing name — must be written.
			{
				Name:           "isActive",
				ConditionLogic: "and",
				Conditions: []models.FlowCondition{
					{LeftValueReference: "leftValue", Operator: "EqualTo", RightValue: "Active"},
				},
				Connector: &models.FlowConnector{TargetReference: "ActivePath"},
			},
		},
	}
	user := &models.UserSession{ID: "u1"}

	next, err := executor.executeDecision(context.Background(), interview, decision, user)
	assert.NoError(t, err)
	assert.Equal(t, "ActivePath", next)

	// $Record must be unchanged — not replaced by a bool.
	assert.Equal(t, recordSentinel, interview.ContextData["$Record"],
		"decision rule named $Record must not overwrite the trigger record")

	// $Flow.FaultMessage must be unchanged.
	assert.Equal(t, "", interview.ContextData["$Flow.FaultMessage"],
		"decision rule named $Flow.FaultMessage must not overwrite flow state")

	// Empty-name rule must not pollute ContextData[""].
	_, emptyKeyPresent := interview.ContextData[""]
	assert.False(t, emptyKeyPresent,
		"empty rule name must not be written to ContextData")

	// Safe named rule is still recorded.
	assert.Equal(t, true, interview.ContextData["isActive"],
		"non-reserved rule outcome should be written")
}

// TestIsAllNilRightValue guards the all-nil-typed-wrapper gate. Process Builder
// emits conditions like `<ref> EqualTo <wrapper-with-all-null-fields>` where
// the intent is "is <ref> truthy?". A plain nil right operand, in contrast,
// is the author writing `<ref> EqualTo null` — an explicit comparison, not
// a truthiness check. Conflating the two flipped condition outcomes for any
// flow whose rightValue resolved to nil (PR #1583 review round-1 H-3).
func TestIsAllNilRightValue(t *testing.T) {
	tests := []struct {
		name     string
		raw      interface{}
		expected bool
	}{
		{"plain nil is NOT an all-nil typed wrapper", nil, false},
		{"typed wrapper with all nil arms", map[string]interface{}{
			"stringValue": nil, "numberValue": nil, "booleanValue": nil,
			"dateValue": nil, "dateTimeValue": nil, "elementReference": nil,
		}, true},
		{"typed wrapper with one set arm", map[string]interface{}{
			"stringValue": "foo",
		}, false},
		{"typed wrapper with stringValue nil only", map[string]interface{}{
			"stringValue": nil,
		}, true},
		{"non-wrapper map without typed keys", map[string]interface{}{
			"foo": nil,
		}, false},
		{"primitive string", "bar", false},
		{"primitive number", float64(0), false},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assert.Equal(t, tt.expected, isAllNilRightValue(tt.raw))
		})
	}
}

// TestIsTruthy covers numeric types the TiDB driver emits (int64) and
// JSON decoders (json.Number) so that "X EqualTo null" style truthiness
// checks on DB-sourced leftValues return the correct result.
// Regression: int64(0) used to fall through to `default: return true`
// (PR #1583 review round-1 H-4).
func TestIsTruthy(t *testing.T) {
	tests := []struct {
		name     string
		v        interface{}
		expected bool
	}{
		{"nil", nil, false},
		{"bool true", true, true},
		{"bool false", false, false},
		{"empty string", "", false},
		{"non-empty string", "x", true},
		{"float64 zero", float64(0), false},
		{"float64 non-zero", float64(1.5), true},
		{"int zero", int(0), false},
		{"int non-zero", int(42), true},
		{"int64 zero", int64(0), false},
		{"int64 non-zero", int64(42), true},
		{"int32 zero", int32(0), false},
		{"int32 non-zero", int32(1), true},
		{"uint zero", uint(0), false},
		{"uint non-zero", uint(7), true},
		{"uint64 zero", uint64(0), false},
		{"uint64 non-zero", uint64(7), true},
		{"json.Number zero", json.Number("0"), false},
		{"json.Number non-zero", json.Number("42"), true},
		{"json.Number invalid falls back to non-empty", json.Number("nope"), true},
		{"empty []byte", []byte{}, false},
		{"non-empty []byte", []byte("x"), true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assert.Equal(t, tt.expected, isTruthy(tt.v))
		})
	}
}

// ── Issue #2259 Round-2 regression lock ─────────────────────────────────────
// TestEvaluateConditionCtx_ProfileNameDecisionCondition is the test that would
// have caught Pass B's CRITICAL had it existed in Round-1.
//
// Decision conditions (the most common flow shape) resolve $Profile.Name via
// evaluateConditionCtx → resolveValueCtx → resolveNestedReferenceCtx, reading
// interview.ContextData["$Profile"].Name.  The Round-1 fix wired $Profile only
// into the formula.Engine scope (evaluateFlowFormulas / Path A); this test
// locks Path B — direct Decision conditions — to ensure injectUserGlobalsIntoContext
// populates ContextData before evaluateConditionCtx is called.

// TestEvaluateConditionCtx_ProfileName_AdminUser asserts that a Decision
// condition "$Profile.Name NotEqualTo 'System Administrator'" evaluates FALSE
// (does NOT fire the bypass) when the running user IS a System Administrator.
func TestEvaluateConditionCtx_ProfileName_AdminUser(t *testing.T) {
	executor := &FlowExecutor{}

	user := &models.UserSession{
		ID:          "usr-admin-001",
		Name:        "Admin User",
		ProfileID:   "system_admin",
		ProfileName: "System Administrator",
	}

	contextData := map[string]interface{}{
		"$Record": models.SObject{"Id": "rec-001", "Name": "Test"},
	}
	// Simulate what executeFlowVersion does before running the flow.
	injectUserGlobalsIntoContext(contextData, user)

	interview := &models.FlowInterview{
		ContextData: contextData,
	}

	// Condition: $Profile.Name NotEqualTo "System Administrator"
	// For a System Administrator, this must be FALSE (they ARE equal).
	cond := &models.FlowCondition{
		LeftValueReference: "$Profile.Name",
		Operator:           "NotEqualTo",
		RightValue:         "System Administrator",
	}
	result := executor.evaluateConditionCtx(context.Background(), interview, user, cond)
	assert.False(t, result,
		"$Profile.Name NotEqualTo 'System Administrator' must be FALSE for a System Administrator user — "+
			"if this fires the bypass incorrectly, the #2259 Decision-condition bug is not fixed")
}

// TestEvaluateConditionCtx_ProfileName_NonAdminUser asserts that a Decision
// condition "$Profile.Name NotEqualTo 'System Administrator'" evaluates TRUE
// (fires correctly) when the running user is NOT a System Administrator.
func TestEvaluateConditionCtx_ProfileName_NonAdminUser(t *testing.T) {
	executor := &FlowExecutor{}

	user := &models.UserSession{
		ID:          "usr-standard-001",
		Name:        "Standard User",
		ProfileID:   "standard_user_profile",
		ProfileName: "Standard User",
	}

	contextData := map[string]interface{}{
		"$Record": models.SObject{"Id": "rec-002", "Name": "Test"},
	}
	injectUserGlobalsIntoContext(contextData, user)

	interview := &models.FlowInterview{
		ContextData: contextData,
	}

	cond := &models.FlowCondition{
		LeftValueReference: "$Profile.Name",
		Operator:           "NotEqualTo",
		RightValue:         "System Administrator",
	}
	result := executor.evaluateConditionCtx(context.Background(), interview, user, cond)
	assert.True(t, result,
		"$Profile.Name NotEqualTo 'System Administrator' must be TRUE for a non-admin user")
}

// TestInjectUserGlobalsIntoContext_NilSafety verifies the function is no-op
// when contextData or user is nil (guards against panic in edge paths).
func TestInjectUserGlobalsIntoContext_NilSafety(t *testing.T) {
	// nil user — must not panic
	ctxData := map[string]interface{}{}
	injectUserGlobalsIntoContext(ctxData, nil)
	assert.Empty(t, ctxData, "nil user must leave contextData unchanged")

	// nil contextData — must not panic
	user := &models.UserSession{ID: "u1", ProfileName: "Admin"}
	injectUserGlobalsIntoContext(nil, user)
}

// TestInjectUserGlobalsIntoContext_ContextDataShape verifies the shape of
// the injected maps matches what resolveNestedReferenceCtx expects when
// walking "$Profile.Name" or "$User.Email".
func TestInjectUserGlobalsIntoContext_ContextDataShape(t *testing.T) {
	email := "user@example.com"
	roleID := "role-001"
	roleName := "Sales Manager"
	user := &models.UserSession{
		ID:          "usr-001",
		Name:        "Alice",
		Email:       &email,
		ProfileID:   "prof-001",
		ProfileName: "Sales Profile",
		RoleID:      &roleID,
		RoleName:    &roleName,
	}
	ctxData := map[string]interface{}{}
	injectUserGlobalsIntoContext(ctxData, user)

	profile, ok := ctxData["$Profile"].(map[string]interface{})
	assert.True(t, ok, "$Profile must be a map[string]interface{}")
	assert.Equal(t, "prof-001", profile["Id"])
	assert.Equal(t, "Sales Profile", profile["Name"])

	userMap, ok := ctxData["$User"].(map[string]interface{})
	assert.True(t, ok, "$User must be a map[string]interface{}")
	assert.Equal(t, "usr-001", userMap["Id"])
	assert.Equal(t, "Alice", userMap["Name"])
	assert.Equal(t, "user@example.com", userMap["Email"])
	assert.Equal(t, "prof-001", userMap["ProfileId"])

	role, ok := ctxData["$UserRole"].(map[string]interface{})
	assert.True(t, ok, "$UserRole must be a map[string]interface{}")
	assert.Equal(t, "role-001", role["Id"])
	assert.Equal(t, "Sales Manager", role["Name"])
}
