package automation

// executor_picklist_test.go — regression coverage for issue #229
// (Subject-style picklist resolution in flow conditions).
//
// Pins flow `decision`-condition picklist comparison semantics to Salesforce
// v63: EqualTo/NotEqualTo on a multi-picklist field is set-equality (order
// independent, storage-shape independent), and Includes/Excludes treat a
// semicolon-bearing RHS literal as the existing Flow any/none token list.

import (
	"context"
	"testing"

	"github.com/nexuscrm/kernel/pkg/schemautil"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// picklistCondition builds a FlowCondition for Operator/RightValue against a
// LeftValueReference. RightValue is wrapped in the Salesforce typed-value
// envelope so resolveValue treats it as a literal.
func picklistCondition(left, op string, rhs interface{}) models.FlowCondition {
	return models.FlowCondition{
		LeftValueReference: left,
		Operator:           op,
		RightValue:         map[string]interface{}{"stringValue": rhs},
	}
}

// TestEvaluateCondition_PicklistEqualTo_StoredAsArray covers the case where a
// multi-picklist field is held in memory as the JSON-array storage shape
// (`[]interface{}` / `[]string`) and compared against a literal SF wire-format
// scalar via EqualTo. Pre-fix: `fmt.Sprintf("%v", []interface{}{...})` renders
// `"[Meeting]"`, never byte-equal to `"Meeting"` — false negative.
func TestEvaluateCondition_PicklistEqualTo_StoredAsArray(t *testing.T) {
	exec := minimalExecutor()
	ctx := map[string]interface{}{
		"Subject": []interface{}{"Meeting"},
	}
	cond := picklistCondition("Subject", "EqualTo", "Meeting")
	if !exec.evaluateCondition(ctx, &cond) {
		t.Errorf("EqualTo: []interface{}{\"Meeting\"} == \"Meeting\" → want true, got false")
	}
}

// TestEvaluateCondition_PicklistEqualTo_OrderInsensitive covers SF v63
// multi-picklist set-equality through the storage-shape adapter: a slice
// LHS (the JSON-array storage shape from TiDB or inline flow wiring) and
// a `;`-joined wire scalar RHS represent the same selected-set regardless
// of element order. Pre-fix: byte-literal compare returns false.
//
// Note (post-codex on PR #263): two PLAIN `;`-joined scalars (no slice on
// either side) intentionally fall back to strict byte equality — generic
// EqualTo has no field metadata and cannot prove the operands are
// multi-picklist. See TestEvaluateCondition_GenericEqualTo_PreservesStrictEquality.
func TestEvaluateCondition_PicklistEqualTo_OrderInsensitive(t *testing.T) {
	exec := minimalExecutor()
	ctx := map[string]interface{}{
		"Subject": []string{"Email", "Meeting"},
	}
	cond := picklistCondition("Subject", "EqualTo", "Meeting;Email")
	if !exec.evaluateCondition(ctx, &cond) {
		t.Errorf("EqualTo: []string{Email,Meeting} == \"Meeting;Email\" (set, slice-LHS) → want true, got false")
	}
}

// TestEvaluateCondition_GenericEqualTo_PreservesStrictEquality pins the
// codex-review fix on PR #263: when neither side is in a multi-picklist
// storage shape, EqualTo must NOT canonicalise. Otherwise ordinary Text /
// ID / Formula equality breaks in subtle, silent ways.
//
// The four false-positive cases from the codex finding:
//
//  1. order: "A;B" vs "B;A" must NOT be equal as plain strings.
//  2. whitespace: " A " vs "A" must NOT be equal (canonical key trims).
//  3. duplicates: "a;a" vs "a" must NOT be equal (canonical key dedupes).
//  4. nil/blank: nil vs "" must NOT be equal (canonical key collapses both
//     to the empty key).
//
// All four would have returned the wrong branch in a Decision element on
// the original PR head; the storage-shape gate in picklistAwareEqual closes
// them.
func TestEvaluateCondition_GenericEqualTo_PreservesStrictEquality(t *testing.T) {
	exec := minimalExecutor()

	cases := []struct {
		name string
		lhs  interface{}
		rhs  interface{}
	}{
		{"order/plain-strings", "A;B", "B;A"},
		{"whitespace/plain-strings", " A ", "A"},
		{"duplicates/plain-strings", "a;a", "a"},
		{"nil-vs-blank", nil, ""},
		// Codex Round-2 (PR #263): a Text/Formula value that happens to
		// be a JSON-array-shaped string must NOT be canonicalised by
		// generic EqualTo — without field metadata we cannot prove the
		// operand is multi-picklist storage, so byte-equality wins.
		{"json-array-shaped-text", `["B","A"]`, `["A","B"]`},
		// `[]byte` of a JSON array is also a plain scalar from the
		// generic evaluator's perspective (TiDB driver bytes etc.) and
		// must NOT trigger canonicalisation.
		{"json-array-shaped-bytes", []byte(`["B","A"]`), []byte(`["A","B"]`)},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			ctx := map[string]interface{}{"Foo": tc.lhs}
			cond := picklistCondition("Foo", "EqualTo", tc.rhs)
			if exec.evaluateCondition(ctx, &cond) {
				t.Errorf("generic EqualTo lhs=%v rhs=%v → want false (no canonicalisation), got true",
					tc.lhs, tc.rhs)
			}

			// NotEqualTo must agree with EqualTo (XOR identity).
			notCond := picklistCondition("Foo", "NotEqualTo", tc.rhs)
			if !exec.evaluateCondition(ctx, &notCond) {
				t.Errorf("generic NotEqualTo lhs=%v rhs=%v → want true (no canonicalisation), got false",
					tc.lhs, tc.rhs)
			}
		})
	}
}

// TestEvaluateCondition_PicklistEqualTo_SinglePicklist_Regression confirms
// single-picklist EqualTo continues to work (canonical-key path is idempotent
// for scalar values).
func TestEvaluateCondition_PicklistEqualTo_SinglePicklist_Regression(t *testing.T) {
	exec := minimalExecutor()
	ctx := map[string]interface{}{"Status": "Open"}

	matchCond := picklistCondition("Status", "EqualTo", "Open")
	if !exec.evaluateCondition(ctx, &matchCond) {
		t.Errorf("single picklist EqualTo match: want true, got false")
	}

	missCond := picklistCondition("Status", "EqualTo", "Closed")
	if exec.evaluateCondition(ctx, &missCond) {
		t.Errorf("single picklist EqualTo miss: want false, got true")
	}
}

// TestEvaluateCondition_PicklistNotEqualTo_OrderInsensitive — NotEqualTo
// must agree with EqualTo on set semantics. Pre-fix: returns true for the
// same set serialised differently.
func TestEvaluateCondition_PicklistNotEqualTo_OrderInsensitive(t *testing.T) {
	exec := minimalExecutor()
	ctx := map[string]interface{}{
		"Subject": []interface{}{"Email", "Meeting"},
	}
	cond := picklistCondition("Subject", "NotEqualTo", "Meeting;Email")
	if exec.evaluateCondition(ctx, &cond) {
		t.Errorf("NotEqualTo: [Email Meeting] != \"Meeting;Email\" (set) → want false, got true")
	}
}

// TestEvaluateCondition_PicklistIncludes_StoredAsArray covers SF v63
// INCLUDES on a multi-picklist field held as an array. Pre-fix:
// `fmt.Sprintf("%v")` renders `"[Meeting Email]"`, split on `;` returns one
// bracketed garbage token, INCLUDES returns false even when the value is
// clearly present.
func TestEvaluateCondition_PicklistIncludes_StoredAsArray(t *testing.T) {
	exec := minimalExecutor()
	ctx := map[string]interface{}{
		"Subject": []interface{}{"Meeting", "Email"},
	}
	cond := picklistCondition("Subject", "Includes", "Meeting")
	if !exec.evaluateCondition(ctx, &cond) {
		t.Errorf("Includes: [Meeting Email] INCLUDES \"Meeting\" → want true, got false")
	}
}

// TestEvaluateCondition_PicklistIncludes_MultiValueRHS covers existing Flow
// INCLUDES with a `;`-joined RHS literal. Existing Flow metadata treats the
// RHS as a token list and matches when any token is selected.
func TestEvaluateCondition_PicklistIncludes_MultiValueRHS(t *testing.T) {
	exec := minimalExecutor()
	ctx := map[string]interface{}{"Subject": "Phone;Meeting;Email"}
	cond := picklistCondition("Subject", "Includes", "Meeting;Email")
	if !exec.evaluateCondition(ctx, &cond) {
		t.Errorf("Includes any-token: \"Phone;Meeting;Email\" INCLUDES \"Meeting;Email\" -> want true, got false")
	}
}

func TestEvaluateCondition_PicklistIncludes_MultiValueRHSPartialOverlap(t *testing.T) {
	exec := minimalExecutor()
	ctx := map[string]interface{}{"Subject": "Phone;Meeting"}
	cond := picklistCondition("Subject", "Includes", "Meeting;Email")
	if !exec.evaluateCondition(ctx, &cond) {
		t.Errorf("Includes any-token: \"Phone;Meeting\" INCLUDES \"Meeting;Email\" -> want true, got false")
	}
}

// TestEvaluateCondition_PicklistIncludes_NoOverlap — neither RHS option
// is present in the field — must return false.
func TestEvaluateCondition_PicklistIncludes_NoOverlap(t *testing.T) {
	exec := minimalExecutor()
	ctx := map[string]interface{}{"Subject": "Phone;Fax"}
	cond := picklistCondition("Subject", "Includes", "Meeting;Email")
	if exec.evaluateCondition(ctx, &cond) {
		t.Errorf("Includes any-token: \"Phone;Fax\" INCLUDES \"Meeting;Email\" -> want false, got true")
	}
}

// TestEvaluateCondition_PicklistExcludes_StoredAsArray confirms EXCLUDES
// is strictly the inverse of INCLUDES on the same set semantics. Pre-fix:
// because INCLUDES on []interface{} returns a false negative, EXCLUDES
// flips it to a false positive.
func TestEvaluateCondition_PicklistExcludes_StoredAsArray(t *testing.T) {
	exec := minimalExecutor()
	ctx := map[string]interface{}{
		"Subject": []interface{}{"Meeting", "Email"},
	}
	cond := picklistCondition("Subject", "Excludes", "Meeting")
	if exec.evaluateCondition(ctx, &cond) {
		t.Errorf("Excludes: [Meeting Email] EXCLUDES \"Meeting\" → want false, got true")
	}
}

func TestEvaluateCondition_PicklistExcludes_MultiValueRHSMissingToken(t *testing.T) {
	exec := minimalExecutor()
	ctx := map[string]interface{}{"Subject": "Phone;Meeting"}
	cond := picklistCondition("Subject", "Excludes", "Meeting;Email")
	if exec.evaluateCondition(ctx, &cond) {
		t.Errorf("Excludes none-of: \"Phone;Meeting\" EXCLUDES \"Meeting;Email\" -> want false, got true")
	}
}

// TestEvaluateConditionCtx_RecordTriggerMultiPicklist_NormalizedJSONString
// pins the Codex Round-3 finding on PR #263. In production, the before-save
// path runs NormalizeSObject which converts multi-picklist values into
// JSON-array strings (`["Meeting"]`). The trigger listener then publishes
// that record into `$Record`. Under the storage-shape-only gate, an
// EqualTo on `$Record.Subject` would compare two strings (`"[\"Meeting\"]"`
// vs `"Meeting"`) and return false — exactly the original #229 break.
//
// The metadata-aware path in evaluateConditionCtx detects that
// `$Record.Subject` is a FieldTypeMultiPicklist field on the trigger object
// and forces canonical multi-picklist set-equality, so the production case
// works regardless of which storage shape NormalizeSObject happens to
// emit.
func TestEvaluateConditionCtx_RecordTriggerMultiPicklist_NormalizedJSONString(t *testing.T) {
	multiPicklistMeta := func(obj, field string) *models.FieldMetadata {
		if obj == "Task" && field == "Subject" {
			return &models.FieldMetadata{Type: constants.FieldTypeMultiPicklist}
		}
		return nil
	}
	exec := &FlowExecutor{fieldMetaLookup: multiPicklistMeta}

	// Production NormalizeSObject shape: multi-picklist serialised as a
	// JSON-array string.
	record := models.SObject{"Subject": `["Meeting"]`}
	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{"$Record": record},
	}

	frc := NewFlowRuntimeContext(context.Background(), nil)
	frc.SetObjectType("Task")
	ctx := withFlowRuntimeContext(context.Background(), frc)

	// Case 1: scalar wire-format RHS vs JSON-array-string LHS.
	cond1 := models.FlowCondition{
		LeftValueReference: "$Record.Subject",
		Operator:           "EqualTo",
		RightValue:         map[string]interface{}{"stringValue": "Meeting"},
	}
	if !exec.evaluateConditionCtx(ctx, interview, nil, &cond1) {
		t.Errorf("metadata-gated MultiPicklist EqualTo: $Record.Subject=`[\"Meeting\"]` EqualTo \"Meeting\" → want true (set-equal), got false")
	}

	// Case 2: order-different JSON-array strings on both sides — must
	// compare set-equal under the metadata gate.
	record["Subject"] = `["Email","Meeting"]`
	cond2 := models.FlowCondition{
		LeftValueReference: "$Record.Subject",
		Operator:           "EqualTo",
		RightValue:         map[string]interface{}{"stringValue": `["Meeting","Email"]`},
	}
	if !exec.evaluateConditionCtx(ctx, interview, nil, &cond2) {
		t.Errorf("metadata-gated MultiPicklist EqualTo: `[\"Email\",\"Meeting\"]` EqualTo `[\"Meeting\",\"Email\"]` → want true (set-equal), got false")
	}

	// Case 3: NotEqualTo agrees with EqualTo under the metadata gate.
	cond3 := models.FlowCondition{
		LeftValueReference: "$Record.Subject",
		Operator:           "NotEqualTo",
		RightValue:         map[string]interface{}{"stringValue": `["Meeting","Email"]`},
	}
	if exec.evaluateConditionCtx(ctx, interview, nil, &cond3) {
		t.Errorf("metadata-gated MultiPicklist NotEqualTo: order-different set-equal strings → want false, got true")
	}

	aliasExec := &FlowExecutor{fieldMetaLookup: func(obj, field string) *models.FieldMetadata {
		if obj == "Task" && field == "Subject" {
			return &models.FieldMetadata{Type: constants.SchemaFieldType("multiselectpicklist")}
		}
		return nil
	}}
	if !aliasExec.evaluateConditionCtx(ctx, interview, nil, &cond2) {
		t.Errorf("metadata-gated MultiPicklist alias EqualTo: want true (set-equal), got false")
	}

	// Case 4: WITHOUT the metadata gate (no fieldMetaLookup), case 2 falls
	// through to picklistAwareEqual which sees two plain strings and
	// returns byte-inequality. Confirms the metadata path is what closes
	// the production case rather than some accidental behaviour of
	// the canonical multi-picklist set key.
	plainExec := &FlowExecutor{}
	if plainExec.evaluateConditionCtx(ctx, interview, nil, &cond2) {
		t.Errorf("storage-shape-only path: order-different JSON-array strings → want false (no metadata, byte-equality), got true")
	}
}

// TestLookupTriggerFieldMeta verifies the helper isolates correctly:
// only direct two-segment $Record / $Record__Old / $Record__Prior LVRs return non-nil,
// and only when both the FlowRuntimeContext object type and the lookup
// function are populated.
func TestLookupTriggerFieldMeta(t *testing.T) {
	always := func(obj, field string) *models.FieldMetadata {
		return &models.FieldMetadata{Type: constants.FieldTypeMultiPicklist}
	}
	exec := &FlowExecutor{fieldMetaLookup: always}
	frc := NewFlowRuntimeContext(context.Background(), nil)
	frc.SetObjectType("Task")
	ctx := withFlowRuntimeContext(context.Background(), frc)

	cases := []struct {
		name    string
		lvr     string
		wantNil bool
	}{
		{"direct-record-field", "$Record.Subject", false},
		{"direct-record-old-field", "$Record__Old.Subject", false},
		{"direct-record-prior-field", "$Record__Prior.Subject", false},
		{"bare-variable", "myVar", true},
		{"related-record-traversal", "$Record.Account.Name", true},
		{"empty-lvr", "", true},
		{"non-record-root", "$User.ProfileId", true},
		{"three-dots-record", "$Record.A.B.C", true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got := exec.lookupTriggerFieldMeta(ctx, tc.lvr)
			if (got == nil) != tc.wantNil {
				t.Errorf("lookupTriggerFieldMeta(%q): wantNil=%v got nil=%v", tc.lvr, tc.wantNil, got == nil)
			}
		})
	}

	// Empty trigger object type must also fall through to nil — this is the
	// non-record-triggered flow case where lookupTriggerFieldMeta cannot
	// authoritatively classify the field.
	frcNoType := NewFlowRuntimeContext(context.Background(), nil)
	ctxNoType := withFlowRuntimeContext(context.Background(), frcNoType)
	if got := exec.lookupTriggerFieldMeta(ctxNoType, "$Record.Subject"); got != nil {
		t.Errorf("empty trigger object type: lookupTriggerFieldMeta should return nil, got %v", got)
	}
}

// =====================================================================
// Issue #902 — field-aware Includes/Excludes routing through
// includesForField / excludesForField at evaluateConditionCtx
// =====================================================================

// TestEvaluateConditionCtx_PicklistIncludes_FieldAwareRouting_BytesJSON
// proves the crossfire R3 BLOCKER no longer reproduces. Under the
// field-aware path, RHS as a `[]byte` carrying a JSON array decodes as an
// authoritative storage shape, so set membership on
// `$Record.Subject INCLUDES <bytes JSON>` returns true when the token is
// present on both sides.
func TestEvaluateConditionCtx_PicklistIncludes_FieldAwareRouting_BytesJSON(t *testing.T) {
	exec := &FlowExecutor{fieldMetaLookup: func(obj, field string) *models.FieldMetadata {
		if obj == "Task" && field == "Subject" {
			return &models.FieldMetadata{Type: constants.FieldTypeMultiPicklist}
		}
		return nil
	}}

	record := models.SObject{"Subject": "Meeting;Email"}
	interview := &models.FlowInterview{ContextData: map[string]interface{}{"$Record": record}}

	frc := NewFlowRuntimeContext(context.Background(), nil)
	frc.SetObjectType("Task")
	ctx := withFlowRuntimeContext(context.Background(), frc)

	cond := models.FlowCondition{
		LeftValueReference: "$Record.Subject",
		Operator:           "Includes",
		RightValue:         []byte(`["Meeting"]`),
	}
	if !exec.evaluateConditionCtx(ctx, interview, nil, &cond) {
		t.Errorf(`$Record.Subject INCLUDES ["Meeting"] (JSON-shaped RHS) → want true, got false`)
	}
}

func TestEvaluateRecordFilterForFieldCtx_PicklistContains_FieldAwareRouting_StorageJSON(t *testing.T) {
	exec := &FlowExecutor{}
	field := &models.FieldMetadata{
		APIName: "Subject",
		Type:    constants.FieldTypeMultiPicklist,
	}
	record := models.SObject{
		"Subject": schemautil.NormalizedMultiPicklistJSON(`["Aurora for MySQL"]`),
	}

	if !exec.evaluateRecordFilterForFieldCtx(context.Background(), record, nil, &models.FlowRecordFilter{
		Field:    "Subject",
		Operator: "Contains",
		Value:    map[string]interface{}{"stringValue": "Aurora for MySQL"},
	}, field) {
		t.Fatal("multi-picklist Contains should match storage JSON by selected token")
	}

	if exec.evaluateRecordFilterForFieldCtx(context.Background(), record, nil, &models.FlowRecordFilter{
		Field:    "Subject",
		Operator: "NotContains",
		Value:    map[string]interface{}{"stringValue": "Aurora for MySQL"},
	}, field) {
		t.Fatal("multi-picklist NotContains should be false when the selected token is present")
	}
}

func TestEvaluateConditionCtx_PicklistContains_FieldAwareRouting_StorageJSON(t *testing.T) {
	exec := &FlowExecutor{fieldMetaLookup: func(obj, field string) *models.FieldMetadata {
		if obj == "Task" && field == "Subject" {
			return &models.FieldMetadata{
				APIName: "Subject",
				Type:    constants.FieldTypeMultiPicklist,
			}
		}
		return nil
	}}
	record := models.SObject{
		"Subject": schemautil.NormalizedMultiPicklistJSON(`["Aurora for MySQL"]`),
	}
	interview := &models.FlowInterview{ContextData: map[string]interface{}{"$Record": record}}

	frc := NewFlowRuntimeContext(context.Background(), nil)
	frc.SetObjectType("Task")
	ctx := withFlowRuntimeContext(context.Background(), frc)

	if !exec.evaluateConditionCtx(ctx, interview, nil, &models.FlowCondition{
		LeftValueReference: "$Record.Subject",
		Operator:           "Contains",
		RightValue:         map[string]interface{}{"stringValue": "Aurora for MySQL"},
	}) {
		t.Fatal("$Record.Subject Contains should match multi-picklist storage JSON by selected token")
	}

	if exec.evaluateConditionCtx(ctx, interview, nil, &models.FlowCondition{
		LeftValueReference: "$Record.Subject",
		Operator:           "NotContains",
		RightValue:         map[string]interface{}{"stringValue": "Aurora for MySQL"},
	}) {
		t.Fatal("$Record.Subject NotContains should be false when the selected token is present")
	}
}

func TestEvaluateConditionCtx_PicklistIncludes_DeclaredJSONLookingOption(t *testing.T) {
	exec := &FlowExecutor{fieldMetaLookup: func(obj, field string) *models.FieldMetadata {
		if obj == "Task" && field == "Subject" {
			return &models.FieldMetadata{
				APIName: "Subject",
				Type:    constants.FieldTypeMultiPicklist,
				Options: models.PicklistOptions{
					{Value: `["Others"]`, Label: `["Others"]`},
					{Value: "Others", Label: "Others"},
				},
			}
		}
		return nil
	}}

	record := models.SObject{
		"Subject": schemautil.NormalizedMultiPicklistJSON(`["[\"Others\"]"]`),
	}
	interview := &models.FlowInterview{ContextData: map[string]interface{}{"$Record": record}}

	frc := NewFlowRuntimeContext(context.Background(), nil)
	frc.SetObjectType("Task")
	ctx := withFlowRuntimeContext(context.Background(), frc)

	cond := models.FlowCondition{
		LeftValueReference: "$Record.Subject",
		Operator:           "Includes",
		RightValue:         map[string]interface{}{"stringValue": `["Others"]`},
	}
	if !exec.evaluateConditionCtx(ctx, interview, nil, &cond) {
		t.Errorf(`declared literal ["Others"] INCLUDES exact RHS literal ["Others"] → want true, got false`)
	}

	cond.RightValue = map[string]interface{}{"stringValue": "Others"}
	if exec.evaluateConditionCtx(ctx, interview, nil, &cond) {
		t.Errorf(`declared literal ["Others"] INCLUDES plain RHS Others → want false, got true`)
	}
}

func TestEvaluateConditionCtx_PicklistIncludes_AuthoredJSONLookingRHSStaysLiteral(t *testing.T) {
	exec := &FlowExecutor{fieldMetaLookup: func(obj, field string) *models.FieldMetadata {
		if obj == "Task" && field == "Subject" {
			return &models.FieldMetadata{
				APIName: "Subject",
				Type:    constants.FieldTypeMultiPicklist,
				Options: models.PicklistOptions{
					{Value: `["Others"]`, Label: `["Others"]`},
					{Value: "Others", Label: "Others"},
				},
			}
		}
		return nil
	}}

	record := models.SObject{
		"Subject": schemautil.NormalizedMultiPicklistJSON(`["Others"]`),
	}
	interview := &models.FlowInterview{ContextData: map[string]interface{}{"$Record": record}}

	frc := NewFlowRuntimeContext(context.Background(), nil)
	frc.SetObjectType("Task")
	ctx := withFlowRuntimeContext(context.Background(), frc)

	cond := models.FlowCondition{
		LeftValueReference: "$Record.Subject",
		Operator:           "Includes",
		RightValue:         map[string]interface{}{"stringValue": `["Others"]`},
	}
	if exec.evaluateConditionCtx(ctx, interview, nil, &cond) {
		t.Errorf(`storage selection Others INCLUDES authored literal ["Others"] → want false, got true`)
	}

	cond.RightValue = map[string]interface{}{"stringValue": "Others"}
	if !exec.evaluateConditionCtx(ctx, interview, nil, &cond) {
		t.Errorf(`storage selection Others INCLUDES authored literal Others → want true, got false`)
	}
}

// TestEvaluateConditionCtx_PicklistExcludes_FieldAwareRouting — symmetric
// to the Includes case, with De Morgan semantics. Asserts the field-aware
// path on Excludes returns the inverse of the Includes match.
func TestEvaluateConditionCtx_PicklistExcludes_FieldAwareRouting(t *testing.T) {
	exec := &FlowExecutor{fieldMetaLookup: func(obj, field string) *models.FieldMetadata {
		if obj == "Task" && field == "Subject" {
			return &models.FieldMetadata{Type: constants.FieldTypeMultiPicklist}
		}
		return nil
	}}

	record := models.SObject{"Subject": "Phone;Fax"}
	interview := &models.FlowInterview{ContextData: map[string]interface{}{"$Record": record}}

	frc := NewFlowRuntimeContext(context.Background(), nil)
	frc.SetObjectType("Task")
	ctx := withFlowRuntimeContext(context.Background(), frc)

	// "Phone;Fax" EXCLUDES "Meeting" → true (no overlap).
	cond := models.FlowCondition{
		LeftValueReference: "$Record.Subject",
		Operator:           "Excludes",
		RightValue:         map[string]interface{}{"stringValue": "Meeting"},
	}
	if !exec.evaluateConditionCtx(ctx, interview, nil, &cond) {
		t.Errorf(`$Record.Subject EXCLUDES "Meeting" with {Phone,Fax} → want true (no overlap), got false`)
	}

	// "Phone;Fax" EXCLUDES "Phone;Fax" -> false (every RHS token selected).
	cond2 := models.FlowCondition{
		LeftValueReference: "$Record.Subject",
		Operator:           "Excludes",
		RightValue:         map[string]interface{}{"stringValue": "Phone;Fax"},
	}
	if exec.evaluateConditionCtx(ctx, interview, nil, &cond2) {
		t.Errorf(`$Record.Subject EXCLUDES "Phone;Fax" with {Phone,Fax} -> want false (every token selected), got true`)
	}

	// "Phone;Fax" EXCLUDES "Phone;Email" -> false (Phone is present).
	cond3 := models.FlowCondition{
		LeftValueReference: "$Record.Subject",
		Operator:           "Excludes",
		RightValue:         map[string]interface{}{"stringValue": "Phone;Email"},
	}
	if exec.evaluateConditionCtx(ctx, interview, nil, &cond3) {
		t.Errorf(`$Record.Subject EXCLUDES "Phone;Email" with {Phone,Fax} -> want false (Phone present), got true`)
	}
}

// TestEvaluateConditionCtx_PicklistIncludes_NonMultipicklistField_FallsBack
// covers the back-compat path: a Decision Includes on a non-trigger
// reference (no metadata gate matches) MUST fall through to legacy
// includes() so existing behaviour for $User-style and inline-literal
// LVRs is preserved.
func TestEvaluateConditionCtx_PicklistIncludes_NonMultipicklistField_FallsBack(t *testing.T) {
	// fieldMetaLookup that returns nil for everything — no trigger metadata
	// → metadata gate doesn't activate → legacy includes() is called.
	exec := &FlowExecutor{fieldMetaLookup: func(obj, field string) *models.FieldMetadata {
		return nil
	}}

	interview := &models.FlowInterview{ContextData: map[string]interface{}{
		"varSubject": "Meeting;Email",
	}}

	frc := NewFlowRuntimeContext(context.Background(), nil)
	frc.SetObjectType("Task")
	ctx := withFlowRuntimeContext(context.Background(), frc)

	cond := models.FlowCondition{
		LeftValueReference: "varSubject",
		Operator:           "Includes",
		RightValue:         map[string]interface{}{"stringValue": "Meeting"},
	}
	if !exec.evaluateConditionCtx(ctx, interview, nil, &cond) {
		t.Errorf("non-trigger LVR Includes via legacy includes(): want true, got false")
	}
}
