package programchecker

import (
	"context"
	"errors"
	"strings"
	"testing"

	"github.com/nexuscrm/kernel/pkg/formula"
	"github.com/nexuscrm/shared/pkg/constants"
	sharedcontract "github.com/nexuscrm/shared/pkg/contract"
	"github.com/nexuscrm/shared/pkg/models"
)

func TestCheckFlowMetadataAcceptsResolvedRecordAndAssignmentSlice(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
			{APIName: "Score", Type: constants.FieldTypeDouble},
			{APIName: "Active", Type: constants.FieldTypeBoolean},
			{APIName: "ParentId", Type: constants.FieldTypeReference, ReferenceTo: []string{"ProgramParent"}},
		}),
		objectForFlowCheck("ProgramParent", []models.FieldMetadata{
			{APIName: "ParentName", Type: constants.FieldTypeString},
		}),
	)
	reader.relationships["programchild:parent"] = fakeRelationship{foreignKey: "ParentId", targets: []string{"ProgramParent"}}
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			Object:        "ProgramChild",
			FilterFormula: "Active == true",
			Filters: []models.FlowRecordFilter{{
				Field: "Active",
				Value: map[string]interface{}{"booleanValue": true},
			}},
		},
		Variables: []models.FlowVariable{
			{Name: "textVar", DataType: "String"},
			{Name: "scoreVar", DataType: "Number"},
			{Name: "childVar", DataType: "Object", ObjectType: "ProgramChild"},
		},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateChild",
			Object: "ProgramChild",
			InputAssignments: []models.FlowInputAssignment{
				{Field: "Name", Value: map[string]interface{}{"stringValue": "created"}},
				{Field: "Score", Value: map[string]interface{}{"numberValue": 10}},
				{Field: "ParentId", Value: map[string]interface{}{"elementReference": "$Record.ParentId"}},
			},
		}},
		RecordLookups: []models.FlowRecordLookup{{
			Name:   "LookupParent",
			Object: "ProgramParent",
			Filters: []models.FlowRecordFilter{{
				Field: "ParentName",
				Value: map[string]interface{}{"stringValue": "Acme"},
			}},
			OutputAssignments: []models.FlowOutputAssignment{{
				Field:             "ParentName",
				AssignToReference: "textVar",
			}},
		}},
		Assignments: []models.FlowAssignment{{
			Name: "SetText",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "textVar",
				Operator:          "Assign",
				Value:             map[string]interface{}{"elementReference": "$Record.Parent.ParentName"},
			}},
		}},
	}, FlowCheckOptions{
		Metadata:      reader,
		Formula:       NewFormulaCatalogReader(formulas),
		ComponentPath: "flows/program.json",
		OwnerPack:     "program-pack",
	})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataAcceptsConnectorSourceRecordAsDynamicRecord(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramBinding", []models.FieldMetadata{
			{APIName: "ExternalId", Type: constants.FieldTypeString},
			{APIName: "Email", Type: constants.FieldTypeString},
			{APIName: "OwnerUserIdText", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("User", []models.FieldMetadata{
			{APIName: "Email", Type: constants.FieldTypeString},
			{APIName: "IsActive", Type: constants.FieldTypeBoolean},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			TriggerType: "Scheduled",
			ConnectorSource: &models.FlowConnectorSource{
				OwnerPack:   "program-pack",
				ProfileName: "owner-directory",
				ObjectKey:   "owners",
				ItemsPath:   "results",
				ItemIDPath:  "id",
			},
		},
		Formulas: []models.FlowFormula{{
			Name:       "OwnerUserIdText",
			DataType:   "String",
			Expression: "TEXT($Record.userId)",
		}},
		RecordLookups: []models.FlowRecordLookup{{
			Name:   "FindUser",
			Object: "User",
			Filters: []models.FlowRecordFilter{
				{Field: "Email", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "$Record.email"}},
				{Field: "IsActive", Operator: "EqualTo", Value: true},
			},
			GetFirstRecordOnly: true,
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateBinding",
			Object: "ProgramBinding",
			InputAssignments: []models.FlowInputAssignment{
				{Field: "ExternalId", Value: map[string]interface{}{"elementReference": "$Record.id"}},
				{Field: "Email", Value: map[string]interface{}{"elementReference": "$Record.email"}},
				{Field: "OwnerUserIdText", Value: map[string]interface{}{"elementReference": "OwnerUserIdText"}},
			},
		}},
		Assignments: []models.FlowAssignment{{
			Name: "ClassifyOwner",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "ownerType",
				Operator:          "Assign",
				Value:             map[string]interface{}{"elementReference": "$Record.type"},
			}},
		}},
	}, FlowCheckOptions{
		Metadata: reader,
		Formula:  NewFormulaCatalogReader(formulas),
	})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataRejectsFormulaResourceNamespaceCollisions(t *testing.T) {
	registry, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}
	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{{Name: "Shared", DataType: "String"}},
		Formulas: []models.FlowFormula{
			{Name: "Shared", DataType: "String", Expression: `"formula"`},
			{Name: "ActionResult", DataType: "String", Expression: `"formula"`},
		},
		ActionCalls: []models.FlowActionCall{{
			Name: "Call",
			OutputParameters: []models.FlowActionParameter{{
				Name:  "result",
				Value: map[string]interface{}{"elementReference": "ActionResult"},
			}},
		}},
	}, FlowCheckOptions{Formula: NewFormulaCatalogReader(registry)})
	got := 0
	for _, diagnostic := range diags {
		if diagnostic.Code == "metadata_program.duplicate_flow_resource" {
			got++
		}
	}
	if got != 2 {
		t.Fatalf("duplicate resource diagnostics = %d, want 2: %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsFormulaDecisionOutcomeCollision(t *testing.T) {
	registry, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}
	meta := models.FlowMetadata{
		Formulas: []models.FlowFormula{{
			Name:       "SharedOutcome",
			DataType:   "Boolean",
			Expression: "true",
		}},
		Decisions: []models.FlowDecision{{
			Name:  "Route",
			Rules: []models.FlowDecisionRule{{Name: "SharedOutcome"}},
		}},
	}

	diagnostics := CheckFlowFormulaResources(context.Background(), meta, FlowCheckOptions{
		Formula: NewFormulaCatalogReader(registry),
	})
	if !diagnostics.HasErrors() {
		t.Fatal("Formula Resource admission accepted a decision outcome with the same resource name")
	}
	if got := diagnostics.Error(); !strings.Contains(got, "decision outcome") || !strings.Contains(got, "SharedOutcome") {
		t.Fatalf("diagnostics = %q, want decision outcome collision for SharedOutcome", got)
	}
}

func TestCheckFlowMetadataRejectsFormulaDefaultLoopIteratorCollision(t *testing.T) {
	registry, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}
	meta := models.FlowMetadata{
		Formulas: []models.FlowFormula{{
			Name:       "LoopItems",
			DataType:   "Number",
			Expression: "1",
		}},
		Loops: []models.FlowLoop{{
			Name:                "LoopItems",
			CollectionReference: "items",
		}},
	}

	diagnostics := CheckFlowFormulaResources(context.Background(), meta, FlowCheckOptions{
		Formula: NewFormulaCatalogReader(registry),
	})
	if !diagnostics.HasErrors() {
		t.Fatal("Formula Resource admission accepted a default loop iterator with the same resource name")
	}
	if got := diagnostics.Error(); !strings.Contains(got, "loop iterator") || !strings.Contains(got, "LoopItems") {
		t.Fatalf("diagnostics = %q, want default loop iterator collision for LoopItems", got)
	}
}

func TestCheckFlowMetadataRejectsFormulaReadOnlyResourceCollision(t *testing.T) {
	registry, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}
	meta := models.FlowMetadata{
		Constants: []models.FlowConstant{{Name: "SharedResource", DataType: "String"}},
		Formulas: []models.FlowFormula{{
			Name:       "SharedResource",
			DataType:   "String",
			Expression: `"formula"`,
		}},
	}

	diagnostics := CheckFlowFormulaResources(context.Background(), meta, FlowCheckOptions{
		Formula: NewFormulaCatalogReader(registry),
	})
	if !diagnostics.HasErrors() {
		t.Fatal("Formula Resource admission accepted a constant with the same resource name")
	}
	if got := diagnostics.Error(); !strings.Contains(got, "constant") || !strings.Contains(got, "SharedResource") {
		t.Fatalf("diagnostics = %q, want constant collision for SharedResource", got)
	}
}

func TestCheckFlowMetadataFormulaDependenciesAreOrderIndependent(t *testing.T) {
	registry, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}
	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Formulas: []models.FlowFormula{
			{Name: "Second", DataType: "Number", Expression: "First + 1"},
			{Name: "First", DataType: "Number", Expression: "1"},
		},
	}, FlowCheckOptions{Formula: NewFormulaCatalogReader(registry)})
	if diags.HasErrors() {
		t.Fatalf("forward formula dependency diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataRejectsFormulaDependencyCycle(t *testing.T) {
	registry, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}
	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Formulas: []models.FlowFormula{
			{Name: "First", DataType: "Number", Expression: "Second + 1"},
			{Name: "Second", DataType: "Number", Expression: "First + 1"},
		},
	}, FlowCheckOptions{Formula: NewFormulaCatalogReader(registry)})
	assertDiagnosticCode(t, diags, "metadata_program.circular_formula_resource")
}

func TestCheckFlowMetadataRejectsObjectlessRecordWithoutConnectorSource(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramBinding", []models.FieldMetadata{
			{APIName: "ExternalId", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateBinding",
			Object: "ProgramBinding",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "ExternalId",
				Value: map[string]interface{}{"elementReference": "$Record.id"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.missing_object")
}

func TestCheckFlowMetadataAcceptsDollarPrefixedLookupOutputReference(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Id", Type: constants.FieldTypeString},
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "sourceId", DataType: "String"},
		},
		RecordLookups: []models.FlowRecordLookup{{
			Name:   "LookupSource",
			Object: "ProgramSource",
			Filters: []models.FlowRecordFilter{{
				Field: "Id",
				Value: map[string]interface{}{"elementReference": "sourceId"},
			}},
			OutputReference:    "$source",
			GetFirstRecordOnly: true,
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			FieldMapping: &models.FlowFieldMappingRef{
				SourceObject:    "ProgramSource",
				SourceReference: "$source",
				TargetObject:    "ProgramTarget",
				ActionType:      "copy",
			},
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$source.Name"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataAcceptsDollarPrefixedCollectionLookupLoopOutputReferences(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("QuoteLineItem", []models.FieldMetadata{
			{APIName: "Id", Type: constants.FieldTypeString},
			{APIName: "QuoteId", Type: constants.FieldTypeString},
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("OrderItem", []models.FieldMetadata{
			{APIName: "Description", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "quoteId", DataType: "String"},
		},
		RecordLookups: []models.FlowRecordLookup{{
			Name:   "GetQuoteLineItems",
			Object: "QuoteLineItem",
			Filters: []models.FlowRecordFilter{{
				Field: "QuoteId",
				Value: map[string]interface{}{"elementReference": "quoteId"},
			}},
			OutputReference:    "$quoteLineItems",
			GetFirstRecordOnly: false,
			Connector:          &models.FlowConnector{TargetReference: "CopyQuoteLineItems"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "CopyQuoteLineItems",
			CollectionReference:        "$quoteLineItems",
			AssignNextValueToReference: "$currentQuoteLineItem",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateOrderItem"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateOrderItem",
			Object: "OrderItem",
			FieldMapping: &models.FlowFieldMappingRef{
				SourceObject:    "QuoteLineItem",
				SourceReference: "$currentQuoteLineItem",
				TargetObject:    "OrderItem",
				ActionType:      "createOrder",
			},
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Description",
				Value: map[string]interface{}{"elementReference": "$currentQuoteLineItem.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "CopyQuoteLineItems"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataAcceptsDollarPrefixedLoopIteratorForLooseSubflowCollection(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Subflows: []models.FlowSubflow{{
			Name:     "LoadChildren",
			FlowName: "ChildLoader",
			OutputAssignments: []models.FlowSubflowOutput{{
				Name:              "children",
				AssignToReference: "childRows",
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopChildren"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopChildren",
			CollectionReference:        "childRows",
			AssignNextValueToReference: "$child",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$child.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopChildren"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataAcceptsDollarPrefixedLoopIteratorForActionCollectionAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)
	actionReader := ActionSignatureReaderFunc(func(_ context.Context, actionName string) (ActionSignature, bool, error) {
		if actionName != "loadRows" {
			return ActionSignature{}, false, nil
		}
		return ActionSignature{
			Name: "loadRows",
			Outputs: []sharedcontract.StandardActionSchemaField{{
				Name:        "rows",
				Type:        "object[]",
				ReferenceTo: "ProgramSource",
			}},
		}, true, nil
	})

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{{
			Name:       "LoadRows",
			ActionName: "loadRows",
			ActionType: "flow",
			OutputParameters: []models.FlowActionParameter{{
				Name:  "rows",
				Value: "$rows",
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "$rows",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$row.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
	}, FlowCheckOptions{Metadata: reader, Actions: actionReader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataAcceptsDollarPrefixedLoopIteratorForSchemaLessActionCollectionAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)
	actionReader := ActionSignatureReaderFunc(func(_ context.Context, actionName string) (ActionSignature, bool, error) {
		if actionName != "loadRows" {
			return ActionSignature{}, false, nil
		}
		return ActionSignature{
			Name: "loadRows",
			Outputs: []sharedcontract.StandardActionSchemaField{{
				Name: "rows",
				Type: "object[]",
			}},
		}, true, nil
	})

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{{
			Name:       "LoadRows",
			ActionName: "loadRows",
			ActionType: "flow",
			OutputParameters: []models.FlowActionParameter{{
				Name:  "rows",
				Value: "$rows",
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "$rows",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$row.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
	}, FlowCheckOptions{Metadata: reader, Actions: actionReader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataAcceptsGenericPayloadDottedLoopCollection(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "payload", DataType: "Object", IsInput: true},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopPayloadChildren",
			CollectionReference:        "payload.sourceChildren",
			AssignNextValueToReference: "currentPayloadChild",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "currentPayloadChild.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopPayloadChildren"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataRejectsDeepGenericPayloadDottedLoopCollection(t *testing.T) {
	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "payload", DataType: "Object", IsInput: true},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopPayloadName",
			CollectionReference:        "payload.sourceChild.Name",
			AssignNextValueToReference: "currentPayloadName",
		}},
	}, FlowCheckOptions{})

	assertDiagnosticReference(t, diags, "payload.sourceChild.Name")
}

func TestCheckFlowMetadataAcceptsLoopIteratorDuringAssignmentPreload(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "children", DataType: "Object", ObjectType: "ProgramChild", IsCollection: true},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopChildren",
			CollectionReference:        "children",
			AssignNextValueToReference: "currentChild",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CollectNames"},
		}},
		Assignments: []models.FlowAssignment{{
			Name: "CollectNames",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "names",
				Operator:          "Add",
				Value:             map[string]interface{}{"elementReference": "currentChild.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopChildren"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataAcceptsDollarPrefixedActionCollectionAliasWithoutSignature(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{{
			Name:       "SendEmail",
			ActionName: "sendEmail",
			ActionType: "sendEmail",
			OutputParameters: []models.FlowActionParameter{{
				Name:  "recipientResults",
				Value: "$recipients",
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRecipients"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRecipients",
			CollectionReference:        "$recipients",
			AssignNextValueToReference: "$recipient",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$recipient.address"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRecipients"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataRejectsUnsupportedDollarPrefixedBuiltInActionOutputAliasWithoutSignature(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{{
			Name:       "ResolveRecipients",
			ActionName: "resolveEmailRecipients",
			ActionType: "resolveEmailRecipients",
			OutputParameters: []models.FlowActionParameter{{
				Name:  "recipients",
				Value: "$emails",
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopEmails"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopEmails",
			CollectionReference:        "$emails",
			AssignNextValueToReference: "$email",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$email.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopEmails"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticReference(t, diags, "$emails")
}

func TestCheckFlowMetadataRejectsActionNameBuiltinOutputAliasWhenActionTypeDoesNotOwnOutputsWithoutSignature(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{{
			Name:       "SubflowNamedLikeBuiltIn",
			ActionName: "sendEmail",
			ActionType: "flow",
			OutputParameters: []models.FlowActionParameter{{
				Name:  "recipientResults",
				Value: "$recipients",
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRecipients"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRecipients",
			CollectionReference:        "$recipients",
			AssignNextValueToReference: "$recipient",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$recipient.address"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRecipients"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticReference(t, diags, "$recipients")
}

func TestCheckFlowMetadataPreservesConcreteAliasForUntypedActionOutput(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		RecordLookups: []models.FlowRecordLookup{{
			Name:               "LookupSource",
			Object:             "ProgramSource",
			OutputReference:    "$source",
			GetFirstRecordOnly: true,
			Connector:          &models.FlowConnector{TargetReference: "ResolveRecipients"},
		}},
		ActionCalls: []models.FlowActionCall{{
			Name:       "ResolveRecipients",
			ActionName: "resolveEmailRecipients",
			ActionType: "resolveEmailRecipients",
			OutputParameters: []models.FlowActionParameter{{
				Name:  "recipients",
				Value: "$source",
			}},
			Connector: &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$source.Bogus"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticReference(t, diags, "ProgramSource.Bogus")
	if got := countDiagnosticsForReference(diags, "ProgramSource.Bogus"); got != 1 {
		t.Fatalf("ProgramSource.Bogus diagnostics = %d, want concrete lookup alias preserved in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsDollarPrefixedLoopIteratorAfterLoopExit(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "rows", DataType: "Object", ObjectType: "ProgramSource", IsCollection: true},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "rows",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateInsideLoop"},
			NoMoreValuesConnector:      &models.FlowConnector{TargetReference: "CreateAfterLoop"},
		}},
		RecordCreates: []models.FlowRecordCreate{
			{
				Name:   "CreateInsideLoop",
				Object: "ProgramTarget",
				InputAssignments: []models.FlowInputAssignment{{
					Field: "Name",
					Value: map[string]interface{}{"elementReference": "$row.Name"},
				}},
				Connector: &models.FlowConnector{TargetReference: "LoopRows"},
			},
			{
				Name:   "CreateAfterLoop",
				Object: "ProgramTarget",
				InputAssignments: []models.FlowInputAssignment{{
					Field: "Name",
					Value: map[string]interface{}{"elementReference": "$row.Name"},
				}},
			},
		},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticReference(t, diags, "$row.Name")
	if got := countDiagnosticsForReference(diags, "$row.Name"); got != 1 {
		t.Fatalf("$row.Name diagnostics = %d, want only the post-loop reference rejected in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsDollarLookupOutputLoopIteratorCollision(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramParent", []models.FieldMetadata{
			{APIName: "ParentOnly", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "ChildOnly", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "children", DataType: "Object", ObjectType: "ProgramChild", IsCollection: true},
		},
		RecordLookups: []models.FlowRecordLookup{{
			Name:               "LookupParent",
			Object:             "ProgramParent",
			OutputReference:    "$current",
			GetFirstRecordOnly: true,
			Connector:          &models.FlowConnector{TargetReference: "LoopChildren"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopChildren",
			CollectionReference:        "children",
			AssignNextValueToReference: "$current",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateInsideLoop"},
			NoMoreValuesConnector:      &models.FlowConnector{TargetReference: "CreateAfterLoop"},
		}},
		RecordCreates: []models.FlowRecordCreate{
			{
				Name:   "CreateInsideLoop",
				Object: "ProgramTarget",
				InputAssignments: []models.FlowInputAssignment{{
					Field: "Name",
					Value: map[string]interface{}{"elementReference": "$current.ChildOnly"},
				}},
				Connector: &models.FlowConnector{TargetReference: "LoopChildren"},
			},
			{
				Name:   "CreateAfterLoop",
				Object: "ProgramTarget",
				InputAssignments: []models.FlowInputAssignment{{
					Field: "Name",
					Value: map[string]interface{}{"elementReference": "$current.ParentOnly"},
				}},
			},
		},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "$current")
}

func TestCheckFlowMetadataRejectsCompatibleDollarLookupOutputLoopIteratorCollision(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "rows", DataType: "Object", ObjectType: "ProgramSource", IsCollection: true},
		},
		RecordLookups: []models.FlowRecordLookup{{
			Name:               "LookupSource",
			Object:             "ProgramSource",
			OutputReference:    "$current",
			GetFirstRecordOnly: true,
			Connector:          &models.FlowConnector{TargetReference: "LoopRows"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "rows",
			AssignNextValueToReference: "$current",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateInsideLoop"},
			NoMoreValuesConnector:      &models.FlowConnector{TargetReference: "CreateAfterLoop"},
		}},
		RecordCreates: []models.FlowRecordCreate{
			{
				Name:   "CreateInsideLoop",
				Object: "ProgramTarget",
				InputAssignments: []models.FlowInputAssignment{{
					Field: "Name",
					Value: map[string]interface{}{"elementReference": "$current.Name"},
				}},
				Connector: &models.FlowConnector{TargetReference: "LoopRows"},
			},
			{
				Name:   "CreateAfterLoop",
				Object: "ProgramTarget",
				InputAssignments: []models.FlowInputAssignment{{
					Field: "Name",
					Value: map[string]interface{}{"elementReference": "$current.Name"},
				}},
			},
		},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "$current")
}

func TestCheckFlowMetadataRejectsLoopIteratorOnSharedIncomingElement(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			Connector: &models.FlowConnector{TargetReference: "CreateShared"},
		},
		Variables: []models.FlowVariable{
			{Name: "rows", DataType: "Object", ObjectType: "ProgramSource", IsCollection: true},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "rows",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateShared"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateShared",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$row.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticReference(t, diags, "$row.Name")
}

func TestCheckFlowMetadataRejectsLoopIteratorOnStartElementReferenceWithoutStart(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		StartElementReference: "CreateShared",
		Variables: []models.FlowVariable{
			{Name: "rows", DataType: "Object", ObjectType: "ProgramSource", IsCollection: true},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "rows",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateShared"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateShared",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$row.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticReference(t, diags, "$row.Name")
}

func TestCheckFlowMetadataAcceptsLoopIteratorWhenStartConnectorOverridesStaleStartElementReference(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			Connector: &models.FlowConnector{TargetReference: "ActualStart"},
		},
		StartElementReference: "CreateShared",
		Variables: []models.FlowVariable{
			{Name: "rows", DataType: "Object", ObjectType: "ProgramSource", IsCollection: true},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "rows",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateShared"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateShared",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$row.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataAcceptsLoopIteratorWhenFirstScheduledPathOverridesLaterStalePath(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			ScheduledPaths: []models.FlowScheduledPath{
				{Connector: &models.FlowConnector{TargetReference: "ActualStart"}},
				{Connector: &models.FlowConnector{TargetReference: "CreateShared"}},
			},
		},
		Variables: []models.FlowVariable{
			{Name: "rows", DataType: "Object", ObjectType: "ProgramSource", IsCollection: true},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "rows",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateShared"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateShared",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$row.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataRejectsNestedLoopsReusingIteratorAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramAccount", []models.FieldMetadata{
			{APIName: "AccountOnly", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "ChildOnly", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "accounts", DataType: "Object", ObjectType: "ProgramAccount", IsCollection: true},
			{Name: "children", DataType: "Object", ObjectType: "ProgramChild", IsCollection: true},
		},
		Loops: []models.FlowLoop{
			{
				Name:                       "OuterLoop",
				CollectionReference:        "accounts",
				AssignNextValueToReference: "current",
				NextValueConnector:         &models.FlowConnector{TargetReference: "InnerLoop"},
			},
			{
				Name:                       "InnerLoop",
				CollectionReference:        "children",
				AssignNextValueToReference: "current",
				NextValueConnector:         &models.FlowConnector{TargetReference: "CreateInner"},
				NoMoreValuesConnector:      &models.FlowConnector{TargetReference: "CreateAfterInner"},
			},
		},
		RecordCreates: []models.FlowRecordCreate{
			{
				Name:   "CreateInner",
				Object: "ProgramTarget",
				InputAssignments: []models.FlowInputAssignment{{
					Field: "Name",
					Value: map[string]interface{}{"elementReference": "current.ChildOnly"},
				}},
				Connector: &models.FlowConnector{TargetReference: "InnerLoop"},
			},
			{
				Name:   "CreateAfterInner",
				Object: "ProgramTarget",
				InputAssignments: []models.FlowInputAssignment{{
					Field: "Name",
					Value: map[string]interface{}{"elementReference": "current.AccountOnly"},
				}},
				Connector: &models.FlowConnector{TargetReference: "OuterLoop"},
			},
		},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "current")
}

func TestCheckFlowMetadataAcceptsNestedLoopCollectionFromOuterDollarIterator(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "parents", DataType: "Object", IsCollection: true},
		},
		Loops: []models.FlowLoop{
			{
				Name:                       "LoopParents",
				CollectionReference:        "parents",
				AssignNextValueToReference: "$parent",
				NextValueConnector:         &models.FlowConnector{TargetReference: "LoopChildren"},
			},
			{
				Name:                       "LoopChildren",
				CollectionReference:        "$parent.children",
				AssignNextValueToReference: "$child",
				NextValueConnector:         &models.FlowConnector{TargetReference: "CreateTarget"},
				NoMoreValuesConnector:      &models.FlowConnector{TargetReference: "LoopParents"},
			},
		},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$child.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopChildren"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataRejectsLoopBodyAssignmentToIteratorAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "children", DataType: "Object", ObjectType: "ProgramChild", IsCollection: true},
			{Name: "payload", DataType: "Object"},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopChildren",
			CollectionReference:        "children",
			AssignNextValueToReference: "currentChild",
			NextValueConnector:         &models.FlowConnector{TargetReference: "ReplaceCurrent"},
		}},
		Assignments: []models.FlowAssignment{{
			Name: "ReplaceCurrent",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "currentChild",
				Value:             map[string]interface{}{"elementReference": "payload"},
			}},
			Connector: &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "currentChild.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopChildren"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "currentChild")
	if got := countDiagnosticsForReference(diags, "currentChild.Name"); got != 0 {
		t.Fatalf("currentChild.Name diagnostics = %d, want collision reported at iterator root in %#v", got, diags)
	}
}

func TestCheckFlowMetadataAcceptsNestedLoopCollectionFromOuterDollarIteratorBeforeOuterMetadata(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "parents", DataType: "Object", IsCollection: true},
		},
		Loops: []models.FlowLoop{
			{
				Name:                       "LoopChildren",
				CollectionReference:        "$parent.children",
				AssignNextValueToReference: "$child",
				NextValueConnector:         &models.FlowConnector{TargetReference: "CreateTarget"},
				NoMoreValuesConnector:      &models.FlowConnector{TargetReference: "LoopParents"},
			},
			{
				Name:                       "LoopParents",
				CollectionReference:        "parents",
				AssignNextValueToReference: "$parent",
				NextValueConnector:         &models.FlowConnector{TargetReference: "LoopChildren"},
			},
		},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$child.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopChildren"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataRejectsLoopBodyDottedAssignmentToIteratorAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "children", DataType: "Object", ObjectType: "ProgramChild", IsCollection: true},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopChildren",
			CollectionReference:        "children",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "MutateRow"},
		}},
		Assignments: []models.FlowAssignment{{
			Name: "MutateRow",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "$row.Name",
				Value:             map[string]interface{}{"stringValue": "mutated"},
			}},
			Connector: &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$row.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopChildren"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "$row.Name")
	if got := countDiagnosticsForReference(diags, "$row.Name"); got != 1 {
		t.Fatalf("$row.Name diagnostics = %d, want only the dotted writer rejected in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsSkippedSignatureActionOutputToIteratorAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)
	actionReader := ActionSignatureReaderFunc(func(_ context.Context, actionName string) (ActionSignature, bool, error) {
		if actionName != "replaceCurrent" {
			return ActionSignature{}, false, nil
		}
		return ActionSignature{Name: "replaceCurrent", SkipParameterValidation: true}, true, nil
	})

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "children", DataType: "Object", ObjectType: "ProgramChild", IsCollection: true},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopChildren",
			CollectionReference:        "children",
			AssignNextValueToReference: "currentChild",
			NextValueConnector:         &models.FlowConnector{TargetReference: "ReplaceCurrent"},
		}},
		ActionCalls: []models.FlowActionCall{{
			Name:       "ReplaceCurrent",
			ActionName: "replaceCurrent",
			ActionType: "flow",
			OutputParameters: []models.FlowActionParameter{{
				Name:  "payload",
				Value: "currentChild",
			}},
			Connector: &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "currentChild.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopChildren"},
		}},
	}, FlowCheckOptions{Metadata: reader, Actions: actionReader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "currentChild")
	if got := countDiagnosticsForReference(diags, "currentChild.Name"); got != 0 {
		t.Fatalf("currentChild.Name diagnostics = %d, want collision reported at iterator root in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsLoopBodyDottedActionOutputToIteratorAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "children", DataType: "Object", ObjectType: "ProgramChild", IsCollection: true},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopChildren",
			CollectionReference:        "children",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "MutateRow"},
		}},
		ActionCalls: []models.FlowActionCall{{
			Name:       "MutateRow",
			ActionName: "mutateRow",
			ActionType: "flow",
			OutputParameters: []models.FlowActionParameter{{
				Name:  "name",
				Value: "$row.Name",
			}},
			Connector: &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$row.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopChildren"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "$row.Name")
	if got := countDiagnosticsForReference(diags, "$row.Name"); got != 1 {
		t.Fatalf("$row.Name diagnostics = %d, want only the dotted writer rejected in %#v", got, diags)
	}
}

func TestCheckFlowMetadataKeepsLooseLoopIteratorFromWideningConcreteAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramParent", []models.FieldMetadata{
			{APIName: "ParentOnly", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		RecordLookups: []models.FlowRecordLookup{{
			Name:               "LookupParent",
			Object:             "ProgramParent",
			OutputReference:    "current",
			GetFirstRecordOnly: true,
			Connector:          &models.FlowConnector{TargetReference: "LoadChildren"},
		}},
		Subflows: []models.FlowSubflow{{
			Name:     "LoadChildren",
			FlowName: "ChildLoader",
			OutputAssignments: []models.FlowSubflowOutput{{
				Name:              "children",
				AssignToReference: "childRows",
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopChildren"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopChildren",
			CollectionReference:        "childRows",
			AssignNextValueToReference: "current",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateInsideLoop"},
			NoMoreValuesConnector:      &models.FlowConnector{TargetReference: "CreateAfterLoop"},
		}},
		RecordCreates: []models.FlowRecordCreate{
			{
				Name:   "CreateInsideLoop",
				Object: "ProgramTarget",
				InputAssignments: []models.FlowInputAssignment{{
					Field: "Name",
					Value: map[string]interface{}{"elementReference": "current.ChildOnly"},
				}},
				Connector: &models.FlowConnector{TargetReference: "LoopChildren"},
			},
			{
				Name:   "CreateAfterLoop",
				Object: "ProgramTarget",
				InputAssignments: []models.FlowInputAssignment{{
					Field: "Name",
					Value: map[string]interface{}{"elementReference": "current.ChildOnly"},
				}},
			},
		},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticReference(t, diags, "ProgramParent.ChildOnly")
	if got := countDiagnosticsForReference(diags, "ProgramParent.ChildOnly"); got != 1 {
		t.Fatalf("ProgramParent.ChildOnly diagnostics = %d, want only the post-loop concrete-alias read rejected in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsLoopBodyRecordCreateAutoStoreNameToIteratorAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramCreated", nil),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Subflows: []models.FlowSubflow{{
			Name:     "LoadRows",
			FlowName: "RowLoader",
			OutputAssignments: []models.FlowSubflowOutput{{
				Name:              "rows",
				AssignToReference: "rows",
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "rows",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "$row"},
		}},
		RecordCreates: []models.FlowRecordCreate{
			{
				Name:                     "$row",
				Object:                   "ProgramCreated",
				StoreOutputAutomatically: true,
				Connector:                &models.FlowConnector{TargetReference: "CreateTarget"},
			},
			{
				Name:   "CreateTarget",
				Object: "ProgramTarget",
				InputAssignments: []models.FlowInputAssignment{{
					Field: "Name",
					Value: map[string]interface{}{"elementReference": "$row.Name"},
				}},
				Connector: &models.FlowConnector{TargetReference: "LoopRows"},
			},
		},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "$row")
	if got := countDiagnosticsForReference(diags, "$row.Name"); got != 0 {
		t.Fatalf("$row.Name diagnostics = %d, want collision reported at iterator root in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsLoopBodyRecordCreateIDToIteratorAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramCreated", nil),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Subflows: []models.FlowSubflow{{
			Name:     "LoadRows",
			FlowName: "RowLoader",
			OutputAssignments: []models.FlowSubflowOutput{{
				Name:              "rows",
				AssignToReference: "rows",
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "rows",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateRow"},
		}},
		RecordCreates: []models.FlowRecordCreate{
			{
				Name:                      "CreateRow",
				Object:                    "ProgramCreated",
				AssignRecordIdToReference: "$row",
				Connector:                 &models.FlowConnector{TargetReference: "CreateTarget"},
			},
			{
				Name:   "CreateTarget",
				Object: "ProgramTarget",
				InputAssignments: []models.FlowInputAssignment{{
					Field: "Name",
					Value: map[string]interface{}{"elementReference": "$row.Name"},
				}},
				Connector: &models.FlowConnector{TargetReference: "LoopRows"},
			},
		},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "$row")
	if got := countDiagnosticsForReference(diags, "$row.Name"); got != 0 {
		t.Fatalf("$row.Name diagnostics = %d, want collision reported at iterator root in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsLoopBodyRecordLookupNameToIteratorAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Subflows: []models.FlowSubflow{{
			Name:     "LoadRows",
			FlowName: "RowLoader",
			OutputAssignments: []models.FlowSubflowOutput{{
				Name:              "rows",
				AssignToReference: "rows",
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "rows",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "$row"},
		}},
		RecordLookups: []models.FlowRecordLookup{{
			Name:               "$row",
			Object:             "ProgramSource",
			GetFirstRecordOnly: true,
			Connector:          &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$row.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "$row")
	if got := countDiagnosticsForReference(diags, "$row.Name"); got != 0 {
		t.Fatalf("$row.Name diagnostics = %d, want collision reported at iterator root in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsLoopBodyLookupOutputAssignmentToIteratorAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Subflows: []models.FlowSubflow{{
			Name:     "LoadRows",
			FlowName: "RowLoader",
			OutputAssignments: []models.FlowSubflowOutput{{
				Name:              "rows",
				AssignToReference: "rows",
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "rows",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "LookupName"},
		}},
		RecordLookups: []models.FlowRecordLookup{{
			Name:               "LookupName",
			Object:             "ProgramSource",
			GetFirstRecordOnly: true,
			OutputAssignments: []models.FlowOutputAssignment{{
				Field:             "Name",
				AssignToReference: "$row",
			}},
			Connector: &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$row.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "$row")
	if got := countDiagnosticsForReference(diags, "$row.Name"); got != 0 {
		t.Fatalf("$row.Name diagnostics = %d, want collision reported at iterator root in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsLoopBodySubflowOutputAssignmentToIteratorAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Subflows: []models.FlowSubflow{
			{
				Name:     "LoadRows",
				FlowName: "RowLoader",
				OutputAssignments: []models.FlowSubflowOutput{{
					Name:              "rows",
					AssignToReference: "rows",
				}},
				Connector: &models.FlowConnector{TargetReference: "LoopRows"},
			},
			{
				Name:     "ReplaceRow",
				FlowName: "RowRewriter",
				OutputAssignments: []models.FlowSubflowOutput{{
					Name:              "row",
					AssignToReference: "$row",
				}},
				Connector: &models.FlowConnector{TargetReference: "CreateTarget"},
			},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "rows",
			AssignNextValueToReference: "$row",
			NextValueConnector:         &models.FlowConnector{TargetReference: "ReplaceRow"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$row.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopRows"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "$row")
	if got := countDiagnosticsForReference(diags, "$row.Name"); got != 0 {
		t.Fatalf("$row.Name diagnostics = %d, want collision reported at iterator root in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRetypesLoopIteratorAfterAssignmentCollectionInference(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "seed", DataType: "String"},
		},
		Assignments: []models.FlowAssignment{{
			Name: "BuildItems",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "items",
				Operator:          "Add",
				Value:             map[string]interface{}{"elementReference": "seed"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopItems"},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopItems",
			CollectionReference:        "items",
			AssignNextValueToReference: "item",
			NextValueConnector:         &models.FlowConnector{TargetReference: "CreateTarget"},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "item.Name"},
			}},
			Connector: &models.FlowConnector{TargetReference: "LoopItems"},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticReference(t, diags, "item.Name")
}

func TestCheckFlowMetadataRejectsMergeWrappedActionOutputReference(t *testing.T) {
	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{{
			Name:       "LoadRows",
			ActionName: "ChildLoader",
			ActionType: "flow",
			OutputParameters: []models.FlowActionParameter{{
				Name:  "rows",
				Value: "{!$rows}",
			}},
		}},
	}, FlowCheckOptions{})

	assertDiagnosticCode(t, diags, "metadata_program.invalid_reference")
	assertDiagnosticReference(t, diags, "{!$rows}")
}

func TestCheckFlowMetadataRejectsMergeWrappedPreviewRecordUpdateOutputReference(t *testing.T) {
	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{{
			Name:       "PreviewRecord",
			ActionType: "previewRecordUpdate",
			InputParameters: []models.FlowActionParameter{{
				Name:  "outputReference",
				Value: map[string]interface{}{"stringValue": "{!$preview}"},
			}},
		}},
	}, FlowCheckOptions{})

	assertDiagnosticCode(t, diags, "metadata_program.invalid_reference")
	assertDiagnosticReference(t, diags, "{!$preview}")
}

func TestCheckFlowMetadataRejectsDottedPreviewRecordUpdateOutputReference(t *testing.T) {
	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{{
			Name:       "PreviewRecord",
			ActionType: "previewRecordUpdate",
			InputParameters: []models.FlowActionParameter{{
				Name:  "outputReference",
				Value: map[string]interface{}{"stringValue": "$preview.Name"},
			}},
		}},
	}, FlowCheckOptions{})

	assertDiagnosticCode(t, diags, "metadata_program.invalid_reference")
	assertDiagnosticReference(t, diags, "$preview.Name")
}

func TestCheckFlowMetadataAcceptsExplicitRecordPreviewRecordUpdateOutputReference(t *testing.T) {
	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		ActionCalls: []models.FlowActionCall{{
			Name:       "PreviewRecord",
			ActionType: "previewRecordUpdate",
			InputParameters: []models.FlowActionParameter{{
				Name:  "outputReference",
				Value: map[string]interface{}{"stringValue": "$Record"},
			}},
		}},
	}, FlowCheckOptions{})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataRejectsLookupOutputReferenceLoopIteratorCollision(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramParent", []models.FieldMetadata{
			{APIName: "ParentName", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "children", DataType: "Object", ObjectType: "ProgramChild", IsCollection: true},
		},
		RecordLookups: []models.FlowRecordLookup{{
			Name:               "LookupParent",
			Object:             "ProgramParent",
			OutputReference:    "current",
			GetFirstRecordOnly: true,
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopChildren",
			CollectionReference:        "children",
			AssignNextValueToReference: "current",
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "current.Name"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "current")
	if got := countDiagnosticsForReference(diags, "current.Name"); got != 0 {
		t.Fatalf("current.Name diagnostics = %d, want collision to be reported at the writer root in %#v", got, diags)
	}
}

func TestCheckFlowMetadataLoopIteratorPreservesFormulaAlias(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "children", DataType: "Object", ObjectType: "ProgramChild", IsCollection: true},
		},
		Formulas: []models.FlowFormula{{
			Name:       "current",
			DataType:   "Number",
			Expression: "1",
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopChildren",
			CollectionReference:        "children",
			AssignNextValueToReference: "current",
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "current.Name"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader, Formula: NewFormulaCatalogReader(formulas)})

	assertDiagnosticReference(t, diags, "current.Name")
}

func TestCheckFlowMetadataRejectsDollarPrefixedLoopIteratorForScalarCollection(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "textVar", DataType: "String"},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopText",
			CollectionReference:        "textVar",
			AssignNextValueToReference: "$item",
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$item.Name"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "textVar")
	assertDiagnosticReference(t, diags, "$item.Name")
}

func TestCheckFlowMetadataRejectsMergeWrappedLoopCollectionReference(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramTarget", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "rows", DataType: "Object", ObjectType: "ProgramSource", IsCollection: true},
		},
		Loops: []models.FlowLoop{{
			Name:                       "LoopRows",
			CollectionReference:        "{!rows}",
			AssignNextValueToReference: "$row",
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateTarget",
			Object: "ProgramTarget",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "$row.Name"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.invalid_reference")
	assertDiagnosticReference(t, diags, "{!rows}")
}

func TestCheckFlowMetadataRejectsDottedDollarLookupOutputReference(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Id", Type: constants.FieldTypeString},
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "sourceId", DataType: "String"},
		},
		RecordLookups: []models.FlowRecordLookup{{
			Name:   "LookupSource",
			Object: "ProgramSource",
			Filters: []models.FlowRecordFilter{{
				Field: "Id",
				Value: map[string]interface{}{"elementReference": "sourceId"},
			}},
			OutputReference:    "$source.Name",
			GetFirstRecordOnly: true,
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.invalid_reference")
	assertDiagnosticReference(t, diags, "$source.Name")
}

func TestCheckFlowMetadataRejectsReservedLookupOutputReference(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Id", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "sourceId", DataType: "String"},
		},
		RecordLookups: []models.FlowRecordLookup{{
			Name:   "LookupSource",
			Object: "ProgramSource",
			Filters: []models.FlowRecordFilter{{
				Field: "Id",
				Value: map[string]interface{}{"elementReference": "sourceId"},
			}},
			OutputReference:    "$Flow",
			GetFirstRecordOnly: true,
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.invalid_reference")
	assertDiagnosticReference(t, diags, "$Flow")
}

func TestCheckFlowMetadataRejectsMergeWrappedLookupOutputReference(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Id", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "sourceId", DataType: "String"},
		},
		RecordLookups: []models.FlowRecordLookup{{
			Name:   "LookupSource",
			Object: "ProgramSource",
			Filters: []models.FlowRecordFilter{{
				Field: "Id",
				Value: map[string]interface{}{"elementReference": "sourceId"},
			}},
			OutputReference:    "{!$source}",
			GetFirstRecordOnly: true,
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.invalid_reference")
	assertDiagnosticReference(t, diags, "{!$source}")
}

func TestCheckFlowMetadataRejectsLookupOutputReferenceVariableTypeCollision(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Id", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "sourceId", DataType: "String"},
			{Name: "sourceOut", DataType: "String"},
		},
		RecordLookups: []models.FlowRecordLookup{{
			Name:   "LookupSource",
			Object: "ProgramSource",
			Filters: []models.FlowRecordFilter{{
				Field: "Id",
				Value: map[string]interface{}{"elementReference": "sourceId"},
			}},
			OutputReference:    "sourceOut",
			GetFirstRecordOnly: true,
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticReference(t, diags, "sourceOut")
}

func TestCheckFlowMetadataDoesNotCaptureRuntimeGlobalOutputReference(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramSource", []models.FieldMetadata{
			{APIName: "Id", Type: constants.FieldTypeString},
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "sourceId", DataType: "String"},
			{Name: "textVar", DataType: "String"},
		},
		RecordLookups: []models.FlowRecordLookup{{
			Name:   "LookupSource",
			Object: "ProgramSource",
			Filters: []models.FlowRecordFilter{{
				Field: "Id",
				Value: map[string]interface{}{"elementReference": "sourceId"},
			}},
			OutputReference:    "$Flow",
			GetFirstRecordOnly: true,
		}},
		Assignments: []models.FlowAssignment{{
			Name: "UseUnsupportedFlowGlobal",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "textVar",
				Operator:          "Assign",
				Value:             map[string]interface{}{"elementReference": "$Flow.Name"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticReference(t, diags, "$Flow")
	assertDiagnosticReference(t, diags, "$Flow.Name")
}

func TestCheckFlowMetadataRejectsDollarPrefixedDecisionOutcomeReference(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Active", Type: constants.FieldTypeBoolean},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		Variables: []models.FlowVariable{
			{Name: "decisionResult", DataType: "Boolean"},
		},
		Decisions: []models.FlowDecision{{
			Name: "CheckActive",
			Rules: []models.FlowDecisionRule{{
				Name: "$approved",
				Conditions: []models.FlowCondition{{
					LeftValueReference: "Active",
					Operator:           "EqualTo",
					RightValue:         true,
				}},
			}},
		}},
		Assignments: []models.FlowAssignment{{
			Name: "UseDecisionResult",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "decisionResult",
				Operator:          "Assign",
				Value:             map[string]interface{}{"elementReference": "$approved"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticReference(t, diags, "ProgramChild.$approved")
}

func TestCheckFlowMetadataRejectsDollarPrefixedAssignmentTargetWithoutWriter(t *testing.T) {
	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Assignments: []models.FlowAssignment{{
			Name: "WriteTypoAlias",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "$typo.Name",
				Operator:          "Assign",
				Value:             map[string]interface{}{"stringValue": "Acme"},
			}},
		}},
	}, FlowCheckOptions{})

	assertDiagnosticReference(t, diags, "$typo.Name")
}

func TestCheckFlowMetadataAcceptsRuntimeGlobalsAndUnaryStartFilters(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
			{APIName: "Active", Type: constants.FieldTypeBoolean},
			{APIName: "EventDate", Type: constants.FieldTypeDate},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			Object:      "ProgramChild",
			TriggerType: "RecordAfterSave",
			Filters: []models.FlowRecordFilter{
				{Field: "Status", Operator: "IsChanged"},
				{Field: "Active", Operator: "IsChanged", Value: map[string]interface{}{"booleanValue": true}},
			},
		},
		Variables: []models.FlowVariable{
			{Name: "currentDate", DataType: "Date"},
			{Name: "currentDateTime", DataType: "DateTime"},
			{Name: "textVar", DataType: "Text"},
			{Name: "boolVar", DataType: "Boolean"},
		},
		Assignments: []models.FlowAssignment{{
			Name: "StoreRuntimeGlobals",
			Items: []models.FlowAssignmentItem{
				{AssignToReference: "currentDate", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$Flow.CurrentDate"}},
				{AssignToReference: "currentDateTime", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$Flow.CurrentDateTime"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$User.Id"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$User.ID"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$User.Name"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$User.Email"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$User.ProfileId"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$User.UserRoleId"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$Profile.Id"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$Profile.Name"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$UserRole.Id"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$UserRole.Name"}},
				{AssignToReference: "boolVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$GlobalConstant.True"}},
				{AssignToReference: "boolVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$GlobalConstant.False"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$GlobalConstant.EmptyString"}},
				{AssignToReference: "boolVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$SystemContext.IsDerivedRecompute"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$SystemContext.ConnectorSyncOrigin"}},
			},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateChild",
			Object: "ProgramChild",
			InputAssignments: []models.FlowInputAssignment{
				{Field: "EventDate", Value: map[string]interface{}{"elementReference": "$Flow.CurrentDate"}},
				{Field: "Status", Value: map[string]interface{}{"elementReference": "$GlobalConstant.EmptyString"}},
			},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataRejectsRuntimeGlobalsInStartFilterValues(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
			{APIName: "Active", Type: constants.FieldTypeBoolean},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			Object: "ProgramChild",
			Filters: []models.FlowRecordFilter{
				{Field: "Status", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "$User.Id"}},
				{Field: "Active", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "$GlobalConstant.True"}},
			},
		},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticReference(t, diags, "$User.Id")
	assertDiagnosticReference(t, diags, "$GlobalConstant.True")
}

func TestCheckFlowMetadataAcceptsRuntimeGlobalsInRecordOperationFilters(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
			{APIName: "Active", Type: constants.FieldTypeBoolean},
			{APIName: "OwnerId", Type: constants.FieldTypeString},
		}),
	)
	runtimeFilters := []models.FlowRecordFilter{
		{Field: "OwnerId", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "$User.Id"}},
		{Field: "Active", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "$GlobalConstant.True"}},
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild", TriggerType: "RecordAfterSave"},
		RecordUpdates: []models.FlowRecordUpdate{{
			Name:    "UpdateChildren",
			Object:  "ProgramChild",
			Filters: runtimeFilters,
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Status", Value: map[string]interface{}{"stringValue": "matched"},
			}},
		}},
		RecordDeletes: []models.FlowRecordDelete{{
			Name:    "DeleteChildren",
			Object:  "ProgramChild",
			Filters: runtimeFilters,
		}},
		RecordLookups: []models.FlowRecordLookup{{
			Name:    "LookupChildren",
			Object:  "ProgramChild",
			Filters: runtimeFilters,
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataValidatesGuardedRecordUpdateShape(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Id", Type: constants.FieldTypeString},
			{APIName: "Status", Type: constants.FieldTypeString},
		}),
	)
	valid := models.FlowRecordUpdate{
		Name:                  "GuardedUpdate",
		Object:                "ProgramChild",
		RecheckFiltersAtWrite: true,
		Filters: []models.FlowRecordFilter{{
			Field: "Id", Operator: "EqualTo", Value: map[string]interface{}{"stringValue": "row-1"},
		}},
		InputAssignments: []models.FlowInputAssignment{{
			Field: "Status", Value: map[string]interface{}{"stringValue": "matched"},
		}},
	}
	if diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{RecordUpdates: []models.FlowRecordUpdate{valid}}, FlowCheckOptions{Metadata: reader}); len(diags) != 0 {
		t.Fatalf("valid guarded RecordUpdate diagnostics = %#v, want none", diags)
	}

	tests := []struct {
		name   string
		mutate func(*models.FlowRecordUpdate)
	}{
		{name: "missing object", mutate: func(update *models.FlowRecordUpdate) { update.Object = "" }},
		{name: "missing filters", mutate: func(update *models.FlowRecordUpdate) { update.Filters = nil }},
		{name: "input reference", mutate: func(update *models.FlowRecordUpdate) { update.InputReference = "$Record" }},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			update := valid
			tt.mutate(&update)
			diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{RecordUpdates: []models.FlowRecordUpdate{update}}, FlowCheckOptions{Metadata: reader})
			assertDiagnosticCode(t, diags, "metadata_program.invalid_record_update_guard")
			assertDiagnosticPointer(t, diags, "/recordUpdates/0/recheckFiltersAtWrite")
		})
	}
}

func TestCheckFlowMetadataRejectsInvalidGuardedRecordUpdateFilterContract(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Id", Type: constants.FieldTypeString},
			{APIName: "Status", Type: constants.FieldTypeString},
			{APIName: "Active", Type: constants.FieldTypeBoolean},
			{APIName: "RemoteKey", Type: constants.FieldTypeString},
		}),
	)
	base := models.FlowRecordUpdate{
		Name:                  "GuardedUpdate",
		Object:                "ProgramChild",
		RecheckFiltersAtWrite: true,
		Filters: []models.FlowRecordFilter{{
			Field: "Id", Operator: "EqualTo", Value: map[string]interface{}{"stringValue": "row-1"},
		}},
		InputAssignments: []models.FlowInputAssignment{{
			Field: "Status", Value: map[string]interface{}{"stringValue": "matched"},
		}},
	}

	tests := []struct {
		name     string
		mutate   func(*models.FlowRecordUpdate)
		wantCode string
	}{
		{
			name: "unknown operator",
			mutate: func(update *models.FlowRecordUpdate) {
				update.Filters[0].Operator = "EqualTypo"
			},
			wantCode: "metadata_program.invalid_record_update_guard_filter",
		},
		{
			name: "unsupported between operator",
			mutate: func(update *models.FlowRecordUpdate) {
				update.Filters[0].Operator = "Between"
				update.Filters[0].Value = []interface{}{"row-1"}
			},
			wantCode: "metadata_program.invalid_record_update_guard_filter",
		},
		{
			name: "unsupported in operator",
			mutate: func(update *models.FlowRecordUpdate) {
				update.Filters[0].Operator = "In"
				update.Filters[0].Value = []interface{}{}
			},
			wantCode: "metadata_program.invalid_record_update_guard_filter",
		},
		{
			name: "blank equality value",
			mutate: func(update *models.FlowRecordUpdate) {
				update.Filters[0].Value = nil
			},
			wantCode: "metadata_program.invalid_record_update_guard_filter",
		},
		{
			name: "out of range filter logic",
			mutate: func(update *models.FlowRecordUpdate) {
				update.FilterLogic = "1 AND 2"
			},
			wantCode: "metadata_program.invalid_record_update_guard_logic",
		},
		{
			name: "bad filter logic parentheses",
			mutate: func(update *models.FlowRecordUpdate) {
				update.Filters = []models.FlowRecordFilter{
					{Field: "Id", Operator: "EqualTo", Value: map[string]interface{}{"stringValue": "row-1"}},
					{Field: "Active", Operator: "EqualTo", Value: false},
					{Field: "RemoteKey", Operator: "IsBlank"},
				}
				update.FilterLogic = "1 AND (2 OR 3"
			},
			wantCode: "metadata_program.invalid_record_update_guard_logic",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			update := base
			update.Filters = append([]models.FlowRecordFilter(nil), base.Filters...)
			tt.mutate(&update)
			diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{RecordUpdates: []models.FlowRecordUpdate{update}}, FlowCheckOptions{Metadata: reader})
			assertDiagnosticCode(t, diags, tt.wantCode)
		})
	}
}

func TestCheckFlowMetadataDerivesRelatedRecordUpdateObjectFromIdFilter(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "AccountId", Type: constants.FieldTypeReference, ReferenceTo: []string{"ProgramAccount"}},
		}),
		objectForFlowCheck("ProgramAccount", []models.FieldMetadata{
			{APIName: "Id", Type: constants.FieldTypeString},
			{APIName: "Status", Type: constants.FieldTypeString},
			{APIName: "Active", Type: constants.FieldTypeBoolean},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild", TriggerType: "RecordAfterSave"},
		RecordUpdates: []models.FlowRecordUpdate{{
			Name: "UpdateRelatedAccount",
			Filters: []models.FlowRecordFilter{
				{Field: "Id", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "$Record.AccountId"}},
				{Field: "Active", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "$GlobalConstant.True"}},
			},
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Status", Value: map[string]interface{}{"stringValue": "matched"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataRejectsAmbiguousRelatedRecordUpdateObject(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "OwnerId", Type: constants.FieldTypeReference, ReferenceTo: []string{"User", "Queue"}},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild", TriggerType: "RecordAfterSave"},
		RecordUpdates: []models.FlowRecordUpdate{{
			Name: "UpdateRelatedOwner",
			Filters: []models.FlowRecordFilter{{
				Field: "Id", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "$Record.OwnerId"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.missing_object")
}

func TestCheckFlowMetadataUsesRuntimeGlobalTypeForImplicitAssignmentTargets(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild", TriggerType: "RecordAfterSave"},
		Assignments: []models.FlowAssignment{{
			Name: "StoreImplicitFlag",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "implicitFlag",
				Operator:          "Assign",
				Value:             map[string]interface{}{"elementReference": "$GlobalConstant.True"},
			}},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateChild",
			Object: "ProgramChild",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Status", Value: map[string]interface{}{"elementReference": "implicitFlag"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticReference(t, diags, "ProgramChild.Status")
}

func TestCheckFlowMetadataAcceptsIsChangedForRecordUpdateRecordTarget(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
			{APIName: "Active", Type: constants.FieldTypeBoolean},
			{APIName: "OwnerId", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild", TriggerType: "RecordAfterSave"},
		RecordUpdates: []models.FlowRecordUpdate{{
			Name:           "UpdateRecord",
			Object:         "ProgramChild",
			InputReference: "$Record",
			Filters: []models.FlowRecordFilter{
				{Field: "Active", Operator: "IsChanged", Value: map[string]interface{}{"booleanValue": true}},
				{Field: "OwnerId", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "$User.Id"}},
				{Field: "Active", Operator: "EqualTo", Value: map[string]interface{}{"elementReference": "$GlobalConstant.True"}},
			},
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Status", Value: map[string]interface{}{"stringValue": "changed"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataAcceptsIsChangedForImplicitRecordUpdateRecordTarget(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
			{APIName: "Active", Type: constants.FieldTypeBoolean},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild", TriggerType: "RecordAfterSave"},
		RecordUpdates: []models.FlowRecordUpdate{{
			Name: "UpdateImplicitRecord",
			Filters: []models.FlowRecordFilter{{
				Field: "Active", Operator: "IsChanged", Value: map[string]interface{}{"booleanValue": true},
			}},
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Status", Value: map[string]interface{}{"stringValue": "changed"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataRejectsIsChangedForScheduledStartFilters(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			Object:   "ProgramChild",
			Schedule: &models.FlowSchedule{Frequency: "Daily"},
			Filters: []models.FlowRecordFilter{{
				Field: "Status", Operator: "IsChanged",
			}},
		},
	}, FlowCheckOptions{Metadata: reader})

	if got := countDiagnosticsForCode(diags, "metadata_program.unsupported_operator"); got != 1 {
		t.Fatalf("unsupported_operator diagnostics = %d, want 1 in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsIsChangedForBlankTriggerStartFilters(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			Object: "ProgramChild",
			Filters: []models.FlowRecordFilter{{
				Field: "Status", Operator: "IsChanged",
			}},
		},
	}, FlowCheckOptions{Metadata: reader})

	if got := countDiagnosticsForCode(diags, "metadata_program.unsupported_operator"); got != 1 {
		t.Fatalf("unsupported_operator diagnostics = %d, want 1 in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsIsChangedForDeleteStartFilters(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
		}),
	)

	for _, triggerType := range []string{"RecordBeforeDelete", "RecordAfterDelete"} {
		diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
			Start: &models.FlowStart{
				Object:      "ProgramChild",
				TriggerType: triggerType,
				Filters: []models.FlowRecordFilter{{
					Field: "Status", Operator: "IsChanged",
				}},
			},
		}, FlowCheckOptions{Metadata: reader})

		if got := countDiagnosticsForCode(diags, "metadata_program.unsupported_operator"); got != 1 {
			t.Fatalf("%s unsupported_operator diagnostics = %d, want 1 in %#v", triggerType, got, diags)
		}
	}
}

func TestCheckFlowMetadataRejectsIsChangedForScheduledRecordUpdateFilters(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			Object:   "ProgramChild",
			Schedule: &models.FlowSchedule{Frequency: "Daily"},
		},
		RecordUpdates: []models.FlowRecordUpdate{{
			Name:           "UpdateScheduledRecord",
			Object:         "ProgramChild",
			InputReference: "$Record",
			Filters: []models.FlowRecordFilter{{
				Field: "Status", Operator: "IsChanged",
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if got := countDiagnosticsForCode(diags, "metadata_program.unsupported_operator"); got != 1 {
		t.Fatalf("unsupported_operator diagnostics = %d, want 1 in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsIsChangedOutsideStartFilters(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		RecordUpdates: []models.FlowRecordUpdate{{
			Name:   "UpdateChildren",
			Object: "ProgramChild",
			Filters: []models.FlowRecordFilter{{
				Field: "Status", Operator: "IsChanged",
			}},
		}},
		RecordDeletes: []models.FlowRecordDelete{{
			Name:   "DeleteChildren",
			Object: "ProgramChild",
			Filters: []models.FlowRecordFilter{{
				Field: "Status", Operator: "IsChanged",
			}},
		}},
		RecordLookups: []models.FlowRecordLookup{{
			Name:   "LookupChildren",
			Object: "ProgramChild",
			Filters: []models.FlowRecordFilter{{
				Field: "Status", Operator: "IsChanged",
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if got := countDiagnosticsForCode(diags, "metadata_program.unsupported_operator"); got != 3 {
		t.Fatalf("unsupported_operator diagnostics = %d, want 3 in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsUnknownRuntimeGlobals(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		Variables: []models.FlowVariable{
			{Name: "textVar", DataType: "Text"},
		},
		Assignments: []models.FlowAssignment{{
			Name: "StoreUnknownGlobal",
			Items: []models.FlowAssignmentItem{
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$Flow.DoesNotExist"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$Flow.FaultMessage"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$Flow.InterviewStartTime"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$Flow.CurrentStage"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$Flow.ActiveStages"}},
				{AssignToReference: "textVar", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$User.DoesNotExist"}},
			},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.missing_variable")
	assertDiagnosticReference(t, diags, "$Flow.DoesNotExist")
	assertDiagnosticReference(t, diags, "$Flow.FaultMessage")
	assertDiagnosticReference(t, diags, "$Flow.InterviewStartTime")
	assertDiagnosticReference(t, diags, "$Flow.CurrentStage")
	assertDiagnosticReference(t, diags, "$Flow.ActiveStages")
	assertDiagnosticReference(t, diags, "$User.DoesNotExist")
	if got := countDiagnosticsForReference(diags, "$Flow"); got != 0 {
		t.Fatalf("$Flow root diagnostics = %d, want none in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsMergeWrappedRuntimeGlobals(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		Variables: []models.FlowVariable{
			{Name: "textVar", DataType: "Text"},
		},
		Assignments: []models.FlowAssignment{{
			Name: "StoreWrappedGlobal",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "textVar",
				Operator:          "Assign",
				Value:             map[string]interface{}{"elementReference": "{!$User.Id}"},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.missing_variable")
	assertDiagnosticReference(t, diags, "{!$User.Id}")
}

func TestCheckFlowMetadataRejectsRuntimeGlobalsInsideCollectionLiterals(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild", TriggerType: "RecordAfterSave"},
		Variables: []models.FlowVariable{
			{Name: "flags", DataType: "Boolean", IsCollection: true},
		},
		Assignments: []models.FlowAssignment{{
			Name: "StoreFlagList",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "flags",
				Operator:          "Assign",
				Value: []interface{}{
					map[string]interface{}{"elementReference": "$GlobalConstant.True"},
				},
			}},
		}},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticReference(t, diags, "$GlobalConstant.True")
}

func TestCheckFlowMetadataRejectsRuntimeGlobalsAsVariableDefaults(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Status", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		Variables: []models.FlowVariable{
			{Name: "currentDate", DataType: "Date", Value: map[string]interface{}{"elementReference": "$Flow.CurrentDate"}},
			{Name: "currentUser", DataType: "Text", Value: "$User.Id"},
		},
	}, FlowCheckOptions{Metadata: reader})

	if got := countDiagnosticsForCode(diags, "metadata_program.unsupported_variable_default"); got != 2 {
		t.Fatalf("unsupported_variable_default diagnostics = %d, want 2 in %#v", got, diags)
	}
	assertDiagnosticReference(t, diags, "$Flow.CurrentDate")
	assertDiagnosticReference(t, diags, "$User.Id")
}

func TestCheckFlowMetadataRejectsRuntimeGlobalsAsWritableTargets(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		RecordCreates: []models.FlowRecordCreate{{
			Name:                      "CreateChild",
			Object:                    "ProgramChild",
			AssignRecordIdToReference: "$SystemContext.IsDerivedRecompute",
		}},
		RecordLookups: []models.FlowRecordLookup{{
			Name:               "LookupChild",
			Object:             "ProgramChild",
			GetFirstRecordOnly: true,
			OutputAssignments: []models.FlowOutputAssignment{{
				Field:             "Name",
				AssignToReference: "$User.Name",
			}},
		}},
		Assignments: []models.FlowAssignment{{
			Name: "AttemptGlobalWrites",
			Items: []models.FlowAssignmentItem{
				{AssignToReference: "$Flow.CurrentDate", Operator: "Assign", Value: map[string]interface{}{"dateValue": "2026-01-01"}},
				{AssignToReference: "$GlobalConstant.True", Operator: "Assign", Value: map[string]interface{}{"booleanValue": false}},
			},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if got := countDiagnosticsForCode(diags, "metadata_program.read_only_reference"); got != 4 {
		t.Fatalf("read_only_reference diagnostics = %d, want 4 in %#v", got, diags)
	}
	assertDiagnosticReference(t, diags, "$SystemContext.IsDerivedRecompute")
	assertDiagnosticReference(t, diags, "$User.Name")
	assertDiagnosticReference(t, diags, "$Flow.CurrentDate")
	assertDiagnosticReference(t, diags, "$GlobalConstant.True")
}

func TestCheckFlowMetadataReportsMissingRefsAndTypeMismatches(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
			{APIName: "Score", Type: constants.FieldTypeDouble},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			Object:        "ProgramChild",
			FilterFormula: "Name",
		},
		Variables: []models.FlowVariable{
			{Name: "missingObjectVar", DataType: "Object", ObjectType: "MissingObject"},
			{Name: "textVar", DataType: "String"},
		},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateChild",
			Object: "ProgramChild",
			InputAssignments: []models.FlowInputAssignment{
				{Field: "MissingField", Value: map[string]interface{}{"stringValue": "created"}},
				{Field: "Score", Value: map[string]interface{}{"stringValue": "not-a-number"}},
				{Field: "Name", Value: map[string]interface{}{"booleanValue": true}},
			},
		}},
		RecordLookups: []models.FlowRecordLookup{{
			Name:   "LookupChild",
			Object: "ProgramChild",
			OutputAssignments: []models.FlowOutputAssignment{{
				Field:             "Score",
				AssignToReference: "textVar",
			}},
		}},
	}, FlowCheckOptions{
		Metadata:      reader,
		Formula:       NewFormulaCatalogReader(formulas),
		ComponentPath: "flows/bad-program.json",
		OwnerPack:     "program-pack",
	})

	if !diags.HasErrors() {
		t.Fatal("HasErrors = false, want true")
	}
	assertDiagnosticCode(t, diags, "metadata_program.missing_object")
	assertDiagnosticCode(t, diags, "metadata_program.missing_field")
	assertDiagnosticCode(t, diags, "metadata_program.type_mismatch")
	assertDiagnosticCode(t, diags, "metadata_program.invalid_formula")
	assertDiagnosticReference(t, diags, "ProgramChild.MissingField")
	assertDiagnosticReference(t, diags, "ProgramChild.Score")
	for _, diag := range diags {
		if diag.ComponentPath != "flows/bad-program.json" {
			t.Fatalf("ComponentPath = %q, want flows/bad-program.json", diag.ComponentPath)
		}
		if diag.OwnerPack != "program-pack" {
			t.Fatalf("OwnerPack = %q, want program-pack", diag.OwnerPack)
		}
	}
}

func TestCheckFlowMetadataChecksFormulaReturnTypeAndAssignments(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
			{APIName: "Score", Type: constants.FieldTypeDouble},
			{APIName: "Active", Type: constants.FieldTypeBoolean},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		Variables: []models.FlowVariable{
			{Name: "scoreVar", DataType: "Number"},
		},
		Formulas: []models.FlowFormula{
			{Name: "ScorePlusOne", DataType: "Number", Expression: "$Record.Score + 1"},
			{Name: "DeclaredNumberButText", DataType: "Number", Expression: "$Record.Name"},
			{Name: "IsActive", DataType: "Boolean", Expression: "$Record.Active"},
		},
		Assignments: []models.FlowAssignment{{
			Name: "StoreScoreFormula",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "scoreVar",
				Operator:          "Assign",
				Value:             map[string]interface{}{"elementReference": "ScorePlusOne"},
			}},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateChild",
			Object: "ProgramChild",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "IsActive"},
			}},
		}},
	}, FlowCheckOptions{
		Metadata:      reader,
		Formula:       NewFormulaCatalogReader(formulas),
		ComponentPath: "flows/formula-program.json",
		OwnerPack:     "program-pack",
	})

	assertDiagnosticReference(t, diags, "DeclaredNumberButText")
	assertDiagnosticReference(t, diags, "ProgramChild.Name")
	if got := countDiagnosticsForReference(diags, "ScorePlusOne"); got != 0 {
		t.Fatalf("ScorePlusOne diagnostics = %d, want none in %#v", got, diags)
	}
}

func TestCheckFlowMetadataRejectsDateFormulaForDateTimeTargets(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "StartsAt", Type: constants.FieldTypeDatetime},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		Variables: []models.FlowVariable{
			{Name: "dateTimeVar", DataType: "DateTime"},
		},
		Formulas: []models.FlowFormula{
			{Name: "DateFormula", DataType: "Date", Expression: "__DATE_ADD_MONTHS__(NOW(), 15)"},
			{Name: "ClampedDateFormula", DataType: "Date", Expression: "__DATE_ADD_MONTHS_TARGET_CLAMP__(NOW(), 15)"},
			{Name: "DateDifferenceFormula", DataType: "Number", Expression: "__DATE_DIFF_DAYS__(TODAY(), DATE(2025, 1, 1))"},
			{Name: "DeclaredDateTimeButDate", DataType: "DateTime", Expression: "__DATE_ADD_MONTHS__(NOW(), 15)"},
			{Name: "DateTimeIntoDate", DataType: "Date", Expression: "NOW()"},
		},
		Assignments: []models.FlowAssignment{{
			Name: "StoreDateFormula",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "dateTimeVar",
				Operator:          "Assign",
				Value:             map[string]interface{}{"elementReference": "DateFormula"},
			}},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateChild",
			Object: "ProgramChild",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "StartsAt",
				Value: map[string]interface{}{"elementReference": "DateFormula"},
			}},
		}},
	}, FlowCheckOptions{
		Metadata: reader,
		Formula:  NewFormulaCatalogReader(formulas),
	})

	assertDiagnosticReference(t, diags, "DeclaredDateTimeButDate")
	assertDiagnosticReference(t, diags, "DateTimeIntoDate")
	assertDiagnosticReference(t, diags, "dateTimeVar")
	assertDiagnosticReference(t, diags, "ProgramChild.StartsAt")
}

func TestCheckFlowMetadataFormulaScopeUsesFlowVariablesBeforeRecordFields(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
			{APIName: "Score", Type: constants.FieldTypeDouble},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		Variables: []models.FlowVariable{
			{Name: "Score", DataType: "String", IsInput: true},
			{Name: "textVar", DataType: "String"},
		},
		Formulas: []models.FlowFormula{
			{
				Name:       "ScoreText",
				DataType:   "Text",
				Expression: "Score",
			},
			{
				Name:       "DerivedRecompute",
				DataType:   "Boolean",
				Expression: "$SystemContext.IsDerivedRecompute",
			},
			{
				Name:       "ScoreChanged",
				DataType:   "Boolean",
				Expression: "ISCHANGED(Score)",
			},
		},
		Assignments: []models.FlowAssignment{{
			Name: "StoreScoreText",
			Items: []models.FlowAssignmentItem{{
				AssignToReference: "textVar",
				Operator:          "Assign",
				Value:             map[string]interface{}{"elementReference": "ScoreText"},
			}},
		}},
	}, FlowCheckOptions{
		Metadata: reader,
		Formula:  NewFormulaCatalogReader(formulas),
	})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataAllowsResumeTimeFormulaDependencies(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
			{APIName: "Score", Type: constants.FieldTypeDouble},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		Variables: []models.FlowVariable{
			{Name: "lateVar", DataType: "Number"},
			{Name: "numbers", DataType: "Number", IsCollection: true, IsInput: true},
		},
		Formulas: []models.FlowFormula{
			{Name: "MissingFieldFormula", DataType: "Number", Expression: "$Record.Typo__c + 1"},
			{Name: "CollectionMath", DataType: "Number", Expression: "numbers + 1"},
			{Name: "StructuralNumber", DataType: "Number", Expression: "[1, 2]"},
			{Name: "UsesLater", DataType: "Number", Expression: "LaterFormula + 1"},
			{Name: "LaterFormula", DataType: "Number", Expression: "1"},
			{Name: "AbsentVarFormula", DataType: "Number", Expression: "lateVar + 1"},
			{Name: "LateTextMismatch", DataType: "Text", Expression: "lateVar"},
		},
	}, FlowCheckOptions{
		Metadata: reader,
		Formula:  NewFormulaCatalogReader(formulas),
	})

	assertDiagnosticReference(t, diags, "MissingFieldFormula")
	assertDiagnosticReference(t, diags, "CollectionMath")
	assertDiagnosticReference(t, diags, "StructuralNumber")
	if got := countDiagnosticsForReference(diags, "UsesLater"); got != 0 {
		t.Fatalf("UsesLater diagnostics = %d, want none because later formula values may be persisted before resume in %#v", got, diags)
	}
	if got := countDiagnosticsForReference(diags, "AbsentVarFormula"); got != 0 {
		t.Fatalf("AbsentVarFormula diagnostics = %d, want none because variables may be populated before resume in %#v", got, diags)
	}
	if got := countDiagnosticsForReference(diags, "LaterFormula"); got != 0 {
		t.Fatalf("LaterFormula diagnostics = %d, want none in %#v", got, diags)
	}
	assertDiagnosticReference(t, diags, "LateTextMismatch")
}

func TestCheckFlowMetadataFormulaScopeUsesPersistedLookupOutputs(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramParent", []models.FieldMetadata{
			{APIName: "ParentName", Type: constants.FieldTypeString},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		RecordLookups: []models.FlowRecordLookup{{
			Name:               "LookupParent",
			Object:             "ProgramParent",
			OutputReference:    "parentRecord",
			GetFirstRecordOnly: true,
			OutputAssignments: []models.FlowOutputAssignment{{
				Field:             "ParentName",
				AssignToReference: "lookupName",
			}},
		}},
		Formulas: []models.FlowFormula{
			{Name: "LookupElementName", DataType: "Text", Expression: "LookupParent.ParentName"},
			{Name: "LookupOutputReference", DataType: "Text", Expression: "parentRecord.ParentName"},
			{Name: "LookupAssignedTextAsNumber", DataType: "Number", Expression: "lookupName + 1"},
		},
	}, FlowCheckOptions{
		Metadata: reader,
		Formula:  NewFormulaCatalogReader(formulas),
	})

	if got := countDiagnosticsForReference(diags, "LookupElementName"); got != 0 {
		t.Fatalf("LookupElementName diagnostics = %d, want none in %#v", got, diags)
	}
	if got := countDiagnosticsForReference(diags, "LookupOutputReference"); got != 0 {
		t.Fatalf("LookupOutputReference diagnostics = %d, want none in %#v", got, diags)
	}
	assertDiagnosticReference(t, diags, "LookupAssignedTextAsNumber")
}

func TestCheckFlowMetadataFormulaScopeUsesRecordCreateIDOutput(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		RecordCreates: []models.FlowRecordCreate{{
			Name:                     "CreateChild",
			Object:                   "ProgramChild",
			StoreOutputAutomatically: true,
		}},
		Formulas: []models.FlowFormula{
			{Name: "CreatedIDPrefix", DataType: "Text", Expression: "LEFT(CreateChild, 3)"},
			{Name: "CreatedIDAsNumber", DataType: "Number", Expression: "CreateChild + 1"},
		},
	}, FlowCheckOptions{
		Metadata: reader,
		Formula:  NewFormulaCatalogReader(formulas),
	})

	if got := countDiagnosticsForReference(diags, "CreatedIDPrefix"); got != 0 {
		t.Fatalf("CreatedIDPrefix diagnostics = %d, want none in %#v", got, diags)
	}
	assertDiagnosticReference(t, diags, "CreatedIDAsNumber")
}

func TestCheckFlowMetadataFormulaScopeRejectsMultiRecordLookupScalars(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("ProgramParent", []models.FieldMetadata{
			{APIName: "ParentName", Type: constants.FieldTypeString},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		RecordLookups: []models.FlowRecordLookup{{
			Name:               "LookupParents",
			Object:             "ProgramParent",
			GetFirstRecordOnly: false,
			OutputAssignments: []models.FlowOutputAssignment{{
				Field:             "ParentName",
				AssignToReference: "lookupName",
			}},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateChildFromMultiLookup",
			Object: "ProgramChild",
			InputAssignments: []models.FlowInputAssignment{{
				Field: "Name",
				Value: map[string]interface{}{"elementReference": "LookupParents.ParentName"},
			}},
		}},
		Formulas: []models.FlowFormula{
			{Name: "MultiLookupDereference", DataType: "Text", Expression: "LookupParents.ParentName"},
			{Name: "MultiLookupOutputAssignment", DataType: "Text", Expression: "lookupName"},
		},
	}, FlowCheckOptions{
		Metadata: reader,
		Formula:  NewFormulaCatalogReader(formulas),
	})

	assertDiagnosticReference(t, diags, "MultiLookupDereference")
	assertDiagnosticReference(t, diags, "MultiLookupOutputAssignment")
	assertDiagnosticReference(t, diags, "LookupParents.ParentName")
}

func TestCheckFlowMetadataFormulaScopeUsesAssignmentAndLoopTypes(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		Variables: []models.FlowVariable{{
			Name:         "children",
			DataType:     "Object",
			ObjectType:   "ProgramChild",
			IsCollection: true,
		}},
		Assignments: []models.FlowAssignment{{
			Name: "SetLateResult",
			Items: []models.FlowAssignmentItem{
				{
					AssignToReference: "lateResult",
					Operator:          "Assign",
					Value:             map[string]interface{}{"stringValue": "done"},
				},
				{
					AssignToReference: "lateItems",
					Operator:          "Add",
					Value:             map[string]interface{}{"stringValue": "item"},
				},
			},
		}},
		Loops: []models.FlowLoop{{
			Name:                       "LoopChildren",
			CollectionReference:        "children",
			AssignNextValueToReference: "currentChild",
		}},
		Formulas: []models.FlowFormula{
			{Name: "LateResultText", DataType: "Text", Expression: "lateResult"},
			{Name: "LateResultAsNumber", DataType: "Number", Expression: "lateResult + 1"},
			{Name: "LateItemsText", DataType: "Text", Expression: "lateItems"},
			{Name: "CurrentChildName", DataType: "Text", Expression: "currentChild.Name"},
			{Name: "CurrentChildNameAsNumber", DataType: "Number", Expression: "currentChild.Name + 1"},
		},
	}, FlowCheckOptions{
		Metadata: reader,
		Formula:  NewFormulaCatalogReader(formulas),
	})

	if got := countDiagnosticsForReference(diags, "LateResultText"); got != 0 {
		t.Fatalf("LateResultText diagnostics = %d, want none in %#v", got, diags)
	}
	assertDiagnosticReference(t, diags, "LateResultAsNumber")
	assertDiagnosticReference(t, diags, "LateItemsText")
	if got := countDiagnosticsForReference(diags, "CurrentChildName"); got != 0 {
		t.Fatalf("CurrentChildName diagnostics = %d, want none in %#v", got, diags)
	}
	assertDiagnosticReference(t, diags, "CurrentChildNameAsNumber")
}

func TestCheckFlowMetadataChecksVariableAndAssignmentCollectionShapes(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString, Required: true},
			{APIName: "Code", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		Variables: []models.FlowVariable{
			{Name: "names", DataType: "String", IsCollection: true, Value: []interface{}{"seed"}},
			{Name: "score", DataType: "Number", Value: map[string]interface{}{"numberValue": 1}},
			{Name: "badNames", DataType: "String", IsCollection: true, Value: map[string]interface{}{"stringValue": "not-a-collection"}},
			{Name: "emptyText", DataType: "String", Value: []interface{}{}},
			{Name: "nullOnlyText", DataType: "String", Value: []interface{}{map[string]interface{}{"nullValue": true}}},
			{Name: "nestedNames", DataType: "String", IsCollection: true, Value: []interface{}{[]interface{}{"seed"}}},
			{Name: "textVar", DataType: "String"},
		},
		Assignments: []models.FlowAssignment{{
			Name: "UpdateVariables",
			Items: []models.FlowAssignmentItem{
				{AssignToReference: "names", Operator: "Add", Value: map[string]interface{}{"stringValue": "next"}},
				{AssignToReference: "names", Operator: "AddAtStart", Value: map[string]interface{}{"stringValue": "first"}},
				{AssignToReference: "score", Operator: "Add", Value: map[string]interface{}{"numberValue": 2}},
				{AssignToReference: "textVar", Operator: "AddAtStart", Value: map[string]interface{}{"stringValue": "bad"}},
				{AssignToReference: "implicitNames", Operator: "AddAtStart", Value: map[string]interface{}{"stringValue": "implicit"}},
				{AssignToReference: "implicitPruned", Operator: "RemoveFirst", Value: map[string]interface{}{"stringValue": "missing"}},
			},
		}},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateChild",
			Object: "ProgramChild",
			InputAssignments: []models.FlowInputAssignment{
				{Field: "Name", Value: map[string]interface{}{"nullValue": true}},
				{Field: "Code", Value: map[string]interface{}{"elementReference": "names"}},
			},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if got := countDiagnosticsForReference(diags, "names"); got != 0 {
		t.Fatalf("names diagnostics = %d, want none in %#v", got, diags)
	}
	if got := countDiagnosticsForReference(diags, "score"); got != 0 {
		t.Fatalf("score diagnostics = %d, want none in %#v", got, diags)
	}
	if got := countDiagnosticsForReference(diags, "implicitNames"); got != 0 {
		t.Fatalf("implicitNames diagnostics = %d, want none in %#v", got, diags)
	}
	if got := countDiagnosticsForReference(diags, "implicitPruned"); got != 0 {
		t.Fatalf("implicitPruned diagnostics = %d, want none in %#v", got, diags)
	}
	assertDiagnosticReference(t, diags, "badNames")
	assertDiagnosticReference(t, diags, "emptyText")
	assertDiagnosticReference(t, diags, "nullOnlyText")
	assertDiagnosticReference(t, diags, "nestedNames")
	assertDiagnosticReference(t, diags, "textVar")
	assertDiagnosticReference(t, diags, "ProgramChild.Name")
	assertDiagnosticReference(t, diags, "ProgramChild.Code")
}

func TestCheckFlowMetadataAcceptsArrayFilterOperatorsAndMultipicklistInputs(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
			{APIName: "Status", Type: constants.FieldTypeString},
			{APIName: "SortKey", Type: constants.FieldTypeString},
			{APIName: "Tags", Type: constants.FieldTypeMultiPicklist},
			{APIName: "Email", Type: constants.FieldTypeEmail},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			Object: "ProgramChild",
			Filters: []models.FlowRecordFilter{
				{Field: "Status", Operator: "In", Value: []interface{}{"Open", "Pending"}},
				{Field: "Status", Operator: "NotIn", Value: []interface{}{"Closed"}},
				{Field: "SortKey", Operator: "Between", Value: []interface{}{"A", "Z"}},
				{Field: "SortKey", Operator: "NotBetween", Value: []interface{}{"Q", "R"}},
				{Field: "Tags", Operator: "EqualTo", Value: []interface{}{"North", "East"}},
				{Field: "Email", Operator: "In", Value: []interface{}{"person@example.com", "v1.2.3"}},
			},
		},
		RecordCreates: []models.FlowRecordCreate{{
			Name:   "CreateChild",
			Object: "ProgramChild",
			InputAssignments: []models.FlowInputAssignment{
				{Field: "Name", Value: map[string]interface{}{"stringValue": "created"}},
				{Field: "Tags", Value: []interface{}{"North", "East"}},
			},
		}},
		RecordUpdates: []models.FlowRecordUpdate{{
			Name:   "UpdateChild",
			Object: "ProgramChild",
			InputAssignments: []models.FlowInputAssignment{
				{Field: "Tags", Value: []interface{}{"South"}},
			},
		}},
	}, FlowCheckOptions{Metadata: reader})

	if len(diags) != 0 {
		t.Fatalf("diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataRejectsInvalidBetweenBoundCounts(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "SortKey", Type: constants.FieldTypeString},
		}),
	)

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			Object: "ProgramChild",
			Filters: []models.FlowRecordFilter{
				{Field: "SortKey", Operator: "Between", Value: []interface{}{"A"}},
				{Field: "SortKey", Operator: "NotBetween", Value: []interface{}{"A", "M", "Z"}},
			},
		},
	}, FlowCheckOptions{Metadata: reader})

	if got := countDiagnosticsForReference(diags, "ProgramChild.SortKey"); got != 2 {
		t.Fatalf("ProgramChild.SortKey diagnostics = %d, want 2 in %#v", got, diags)
	}
}

func TestCheckFlowMetadataAcceptsPolymorphicFormulaRelationship(t *testing.T) {
	ownerRel := "Owner"
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramTask", []models.FieldMetadata{
			{APIName: "Subject", Type: constants.FieldTypeString},
			{APIName: "OwnerId", RelationshipName: &ownerRel, Type: constants.FieldTypeReference, ReferenceTo: []string{"User", "Queue"}},
		}),
		objectForFlowCheck("User", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("Queue", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramTask"},
		Formulas: []models.FlowFormula{
			{Name: "OwnerName", DataType: "Text", Expression: "Owner.Name"},
			{Name: "TypedOwnerName", DataType: "Text", Expression: "Owner:User.Name"},
			{Name: "OwnerNameAsNumber", DataType: "Number", Expression: "Owner.Name + 1"},
		},
	}, FlowCheckOptions{
		Metadata: reader,
		Formula:  NewFormulaCatalogReader(formulas),
	})

	if got := countDiagnosticsForReference(diags, "OwnerName"); got != 0 {
		t.Fatalf("OwnerName diagnostics = %d, want none in %#v", got, diags)
	}
	if got := countDiagnosticsForReference(diags, "TypedOwnerName"); got != 0 {
		t.Fatalf("TypedOwnerName diagnostics = %d, want none in %#v", got, diags)
	}
	assertDiagnosticReference(t, diags, "OwnerNameAsNumber")
}

func TestCheckFlowMetadataWorkflowAliasesAreInitialFormulaScope(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Score", Type: constants.FieldTypeDouble},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
		Variables: []models.FlowVariable{
			{Name: "myVariable_current", DataType: "Object", ObjectType: "ProgramChild"},
		},
		Formulas: []models.FlowFormula{{
			Name:       "CurrentScore",
			DataType:   "Number",
			Expression: "myVariable_current.Score + 1",
		}},
	}, FlowCheckOptions{
		Metadata:    reader,
		Formula:     NewFormulaCatalogReader(formulas),
		ProcessType: "Workflow",
	})

	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataReportsReaderErrorsAsDiagnostics(t *testing.T) {
	reader := &fakeProgramMetadataReader{err: errors.New("metadata unavailable")}

	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{Object: "ProgramChild"},
	}, FlowCheckOptions{Metadata: reader})

	assertDiagnosticCode(t, diags, "metadata_program.reader_error")
}

func assertDiagnosticCode(t *testing.T, diags Diagnostics, code string) {
	t.Helper()
	for _, diag := range diags {
		if diag.Code == code {
			return
		}
	}
	t.Fatalf("diagnostic code %q not found in %#v", code, diags)
}

func assertDiagnosticReference(t *testing.T, diags Diagnostics, ref string) {
	t.Helper()
	for _, diag := range diags {
		if diag.Reference == ref {
			return
		}
	}
	t.Fatalf("diagnostic reference %q not found in %#v", ref, diags)
}

func countDiagnosticsForReference(diags Diagnostics, ref string) int {
	count := 0
	for _, diag := range diags {
		if diag.Reference == ref {
			count++
		}
	}
	return count
}

func countDiagnosticsForCode(diags Diagnostics, code string) int {
	count := 0
	for _, diag := range diags {
		if diag.Code == code {
			count++
		}
	}
	return count
}

type fakeRelationship struct {
	foreignKey string
	targets    []string
}

type fakeProgramMetadataReader struct {
	objects       map[string]*models.ObjectMetadata
	fields        map[string]map[string]*models.FieldMetadata
	relationships map[string]fakeRelationship
	err           error
}

func newFakeProgramMetadataReader(objects ...*models.ObjectMetadata) *fakeProgramMetadataReader {
	reader := &fakeProgramMetadataReader{
		objects:       map[string]*models.ObjectMetadata{},
		fields:        map[string]map[string]*models.FieldMetadata{},
		relationships: map[string]fakeRelationship{},
	}
	for _, object := range objects {
		if object == nil {
			continue
		}
		objectKey := strings.ToLower(strings.TrimSpace(object.APIName))
		reader.objects[objectKey] = object
		reader.fields[objectKey] = map[string]*models.FieldMetadata{}
		for i := range object.Fields {
			field := &object.Fields[i]
			reader.fields[objectKey][strings.ToLower(strings.TrimSpace(field.APIName))] = field
		}
	}
	return reader
}

func objectForFlowCheck(apiName string, fields []models.FieldMetadata) *models.ObjectMetadata {
	return &models.ObjectMetadata{APIName: apiName, Fields: fields}
}

func (r *fakeProgramMetadataReader) Object(_ context.Context, apiName string) (*models.ObjectMetadata, bool, error) {
	if r.err != nil {
		return nil, false, r.err
	}
	object, ok := r.objects[strings.ToLower(strings.TrimSpace(apiName))]
	return object, ok, nil
}

func (r *fakeProgramMetadataReader) Field(_ context.Context, objectAPIName, fieldAPIName string) (*models.FieldMetadata, bool, error) {
	if r.err != nil {
		return nil, false, r.err
	}
	field, ok := r.fields[strings.ToLower(strings.TrimSpace(objectAPIName))][strings.ToLower(strings.TrimSpace(fieldAPIName))]
	return field, ok, nil
}

func (r *fakeProgramMetadataReader) Schemas(context.Context) ([]*models.ObjectMetadata, error) {
	if r.err != nil {
		return nil, r.err
	}
	out := make([]*models.ObjectMetadata, 0, len(r.objects))
	for _, object := range r.objects {
		out = append(out, object)
	}
	return out, nil
}

func (r *fakeProgramMetadataReader) ForeignKeyForRelationship(_ context.Context, objectAPIName, relationshipName string) (string, bool, error) {
	if r.err != nil {
		return "", false, r.err
	}
	rel, ok := r.relationships[strings.ToLower(strings.TrimSpace(objectAPIName))+":"+strings.ToLower(strings.TrimSpace(relationshipName))]
	return rel.foreignKey, ok, nil
}

func (r *fakeProgramMetadataReader) TargetObjectsForForeignKey(_ context.Context, objectAPIName, foreignKey string) ([]string, bool, error) {
	if r.err != nil {
		return nil, false, r.err
	}
	for key, rel := range r.relationships {
		if !strings.HasPrefix(key, strings.ToLower(strings.TrimSpace(objectAPIName))+":") {
			continue
		}
		if strings.EqualFold(rel.foreignKey, strings.TrimSpace(foreignKey)) {
			return append([]string(nil), rel.targets...), true, nil
		}
	}
	return nil, false, nil
}

// Boolean-operand filter operators (IsNull/IsBlank/IsChanged family) carry a
// presence/change switch as their right-hand value, not a field value. The
// checker must type that operand as boolean instead of the field's own type —
// otherwise every legitimate change/null guard on a non-boolean field is a
// false-positive type_mismatch (NX#3451, round 2).
func TestCheckFlowMetadataBooleanOperandFilterOperators(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("ProgramChild", []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
			{APIName: "Score", Type: constants.FieldTypeDouble},
		}),
	)
	formulas, err := formula.ExecutableFunctionRegistryWithBundles(nil)
	if err != nil {
		t.Fatalf("ExecutableFunctionRegistryWithBundles: %v", err)
	}

	for _, operator := range []string{"IsNull", "IsNotNull", "IsBlank", "IsNotBlank", "IsChanged"} {
		// Both spellings the flow contract allows: an explicit boolean
		// operand, and an omitted operand (FlowRecordFilter.Value is
		// omitempty; the runtime ignores a missing RHS for the
		// fixed-positive forms).
		for name, value := range map[string]interface{}{
			"boolean operand": map[string]interface{}{"booleanValue": true},
			"omitted operand": nil,
		} {
			start := &models.FlowStart{
				Object: "ProgramChild",
				Filters: []models.FlowRecordFilter{{
					Field:    "Name",
					Operator: operator,
					Value:    value,
				}},
			}
			if operator == "IsChanged" {
				start.TriggerType = "RecordAfterSave"
			}
			diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
				Start: start,
			}, FlowCheckOptions{
				Metadata:      reader,
				Formula:       NewFormulaCatalogReader(formulas),
				ComponentPath: "flows/program.json",
				OwnerPack:     "program-pack",
			})
			if len(diags) != 0 {
				t.Fatalf("%s with %s on string field: diagnostics = %#v, want none", operator, name, diags)
			}
		}
	}

	// The exemption must not swallow REAL mismatches: a string operand on a
	// boolean-operand operator is still wrong ...
	diags := CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			Object: "ProgramChild",
			Filters: []models.FlowRecordFilter{{
				Field:    "Name",
				Operator: "IsChanged",
				Value:    map[string]interface{}{"stringValue": "yes"},
			}},
		},
	}, FlowCheckOptions{
		Metadata:      reader,
		Formula:       NewFormulaCatalogReader(formulas),
		ComponentPath: "flows/program.json",
		OwnerPack:     "program-pack",
	})
	if len(diags) == 0 {
		t.Fatal("IsChanged with string operand: want a type_mismatch diagnostic, got none")
	}

	// ... and value-comparison operators keep their field-typed check.
	diags = CheckFlowMetadata(context.Background(), models.FlowMetadata{
		Start: &models.FlowStart{
			Object: "ProgramChild",
			Filters: []models.FlowRecordFilter{{
				Field:    "Name",
				Operator: "EqualTo",
				Value:    map[string]interface{}{"booleanValue": true},
			}},
		},
	}, FlowCheckOptions{
		Metadata:      reader,
		Formula:       NewFormulaCatalogReader(formulas),
		ComponentPath: "flows/program.json",
		OwnerPack:     "program-pack",
	})
	if len(diags) == 0 {
		t.Fatal("EqualTo with boolean operand on string field: want a type_mismatch diagnostic, got none")
	}
}

func TestCheckFlowMetadataAcceptsBoundedAtomicParentPrelockDiscovery(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("Account", []models.FieldMetadata{
			{APIName: constants.FieldID, Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("Campaign", []models.FieldMetadata{
			{APIName: constants.FieldID, Type: constants.FieldTypeString},
		}),
		objectForFlowCheck("CampaignMember", []models.FieldMetadata{
			{APIName: "LeadId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Lead"}},
			{APIName: "CampaignId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Campaign"}},
		}),
		objectForFlowCheck("Lead", []models.FieldMetadata{
			{APIName: constants.FieldID, Type: constants.FieldTypeString},
		}),
	)
	meta := models.FlowMetadata{
		RunAtomically: true,
		Variables: []models.FlowVariable{
			{Name: "leadId", DataType: "String", IsInput: true},
			{Name: "accountId", DataType: "String", IsInput: true},
		},
		AtomicParentPrelocks: []models.FlowAtomicParentPrelock{
			{
				Name:                 "ExistingAccount",
				ParentObject:         "Account",
				SourceObject:         "Account",
				ParentReferenceField: constants.FieldID,
				Filters: []models.FlowRecordFilter{{
					Field:    constants.FieldID,
					Operator: "EqualTo",
					Value:    map[string]interface{}{"elementReference": "accountId"},
				}},
			},
			{
				Name:                 "LeadCampaigns",
				ParentObject:         "Campaign",
				SourceObject:         "CampaignMember",
				ParentReferenceField: "CampaignId",
				Filters: []models.FlowRecordFilter{{
					Field:    "LeadId",
					Operator: "EqualTo",
					Value:    map[string]interface{}{"elementReference": "leadId"},
				}},
			},
		},
	}

	diags := CheckFlowMetadata(context.Background(), meta, FlowCheckOptions{Metadata: reader})
	if len(diags) != 0 {
		t.Fatalf("CheckFlowMetadata() diagnostics = %#v, want none", diags)
	}
}

func TestCheckFlowMetadataRejectsAtomicParentPrelockWithWrongParentReference(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("Campaign", nil),
		objectForFlowCheck("CampaignMember", []models.FieldMetadata{
			{APIName: "ContactId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Contact"}},
			{APIName: "LeadId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Lead"}},
		}),
	)
	meta := models.FlowMetadata{
		RunAtomically: true,
		Variables:     []models.FlowVariable{{Name: "leadId", DataType: "String", IsInput: true}},
		AtomicParentPrelocks: []models.FlowAtomicParentPrelock{{
			Name:                 "WrongParent",
			ParentObject:         "Campaign",
			SourceObject:         "CampaignMember",
			ParentReferenceField: "ContactId",
			Filters: []models.FlowRecordFilter{{
				Field:    "LeadId",
				Operator: "EqualTo",
				Value:    map[string]interface{}{"elementReference": "leadId"},
			}},
		}},
	}

	diags := CheckFlowMetadata(context.Background(), meta, FlowCheckOptions{Metadata: reader})
	if got := countDiagnosticsForCode(diags, "metadata_program.invalid_parent_prelock"); got != 1 {
		t.Fatalf("invalid parent prelock diagnostics = %d (%#v), want 1", got, diags)
	}
}

func TestCheckFlowMetadataRejectsPostStartAtomicParentPrelockReferences(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("Campaign", nil),
		objectForFlowCheck("CampaignMember", []models.FieldMetadata{
			{APIName: "LeadId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Lead"}},
			{APIName: "CampaignId", Type: constants.FieldTypeReference, ReferenceTo: []string{"Campaign"}},
		}),
	)
	meta := models.FlowMetadata{
		RunAtomically: true,
		Variables:     []models.FlowVariable{{Name: "leadId", DataType: "String", IsInput: true}},
		Formulas:      []models.FlowFormula{{Name: "ComputedLeadId", DataType: "String", Expression: "leadId"}},
		AtomicParentPrelocks: []models.FlowAtomicParentPrelock{{
			Name:                 "LateFormula",
			ParentObject:         "Campaign",
			SourceObject:         "CampaignMember",
			ParentReferenceField: "CampaignId",
			Filters: []models.FlowRecordFilter{{
				Field:    "LeadId",
				Operator: "EqualTo",
				Value:    map[string]interface{}{"elementReference": "ComputedLeadId"},
			}},
		}},
	}

	diags := CheckFlowMetadata(context.Background(), meta, FlowCheckOptions{Metadata: reader})
	if got := countDiagnosticsForCode(diags, "metadata_program.invalid_parent_prelock"); got != 1 {
		t.Fatalf("invalid parent prelock diagnostics = %d (%#v), want 1", got, diags)
	}
	if !strings.Contains(diags[0].Message, "not available before the first Flow element") {
		t.Fatalf("diagnostic = %#v, want pre-start reference error", diags[0])
	}
}

func TestCheckFlowMetadataRejectsMissingAtomicParentPrelockRecordIDField(t *testing.T) {
	reader := newFakeProgramMetadataReader(
		objectForFlowCheck("Account", nil),
		objectForFlowCheck("Lead", []models.FieldMetadata{{
			APIName:     "AccountId",
			Type:        constants.FieldTypeReference,
			ReferenceTo: []string{"Account"},
		}}),
	)
	meta := models.FlowMetadata{
		RunAtomically: true,
		Start:         &models.FlowStart{Object: "Lead"},
		AtomicParentPrelocks: []models.FlowAtomicParentPrelock{{
			Name:         "ExistingAccount",
			ParentObject: "Account",
			RecordIDs: []interface{}{
				map[string]interface{}{"elementReference": "$Record.Accountd"},
			},
		}},
	}

	diags := CheckFlowMetadata(context.Background(), meta, FlowCheckOptions{Metadata: reader})
	for _, diagnostic := range diags {
		if diagnostic.Code == "metadata_program.missing_field" && diagnostic.JSONPointer == "/atomicParentPrelocks/0/recordIds/0" {
			return
		}
	}
	t.Fatalf("CheckFlowMetadata() diagnostics = %#v, want missing recordIds field", diags)
}
