package automation

// executor_sobject_variable_seeding_test.go — tests for lazy SObject
// vivification in the flow runtime.
//
// Design: declared SObject variables are NOT pre-seeded.  They stay ABSENT
// (null) until the first dotted Assignment writes into them via
// ensureDeclaredMapRoot.  This matches Salesforce flow semantics:
//   - IsNull(unprovided_var) == true  — correct, not a false "has value"
//   - Output collection omits the var  — correct, no synthetic empty record
//   - After first assignment the var is a real SObject — correct
//
// The RecordUpdate still resolves the target object from the variable's
// declared type in flow metadata (executeRecordUpdate objectName fallback),
// so no attributes envelope is needed.

import (
	"context"
	"testing"

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

// ---------------------------------------------------------------------------
// Unit tests — initializeFlowContext: no SObject variable is pre-seeded
// ---------------------------------------------------------------------------

// TestInitFlowCtx_SObjectVar_AbsentUntilAssigned verifies that a declared
// SObject variable (DataType="SObject", no default, not a collection) is
// NOT placed in the context by initializeFlowContext.  It must stay absent
// (null) until the first Assignment writes a dotted field into it.
func TestInitFlowCtx_SObjectVar_AbsentUntilAssigned(t *testing.T) {
	flowMeta := &models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "acctVar", DataType: "SObject", ObjectType: "Account"},
		},
	}
	ctx := initializeFlowContext(flowMeta, nil, "AutoLaunchedFlow")

	if _, exists := ctx["acctVar"]; exists {
		t.Error("acctVar present in context — SObject variables must not be pre-seeded; they are created on first assignment")
	}
}

// TestInitFlowCtx_NoSObjectSeeding verifies that initializeFlowContext seeds
// NO SObject variable regardless of how many are declared.
func TestInitFlowCtx_NoSObjectSeeding(t *testing.T) {
	flowMeta := &models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "varA", DataType: "SObject", ObjectType: "Account"},
			{Name: "varB", DataType: "SObject", ObjectType: "Contact"},
			{Name: "varC", DataType: "SObject", ObjectType: ""},
			{Name: "varD", DataType: "SObject", IsCollection: true, ObjectType: "Account"},
		},
	}
	ctx := initializeFlowContext(flowMeta, nil, "AutoLaunchedFlow")

	for _, name := range []string{"varA", "varB", "varC", "varD"} {
		if _, exists := ctx[name]; exists {
			t.Errorf("%s present in context — initializeFlowContext must not seed any SObject variable", name)
		}
	}
}

// ---------------------------------------------------------------------------
// Null-contract test — unprovided SObject is IsNull=true before assignment
// ---------------------------------------------------------------------------

// TestSObjectVar_UnprovidedIsNull verifies the core Salesforce null contract:
// a declared SObject variable that is NOT provided as an input evaluates as
// null (IsNull condition == true) in a Decision element BEFORE any Assignment
// writes into it.
//
// This is the regression that lazy vivification fixes: pre-seeding an empty
// SObject{} makes the variable observably non-null (IsNull returns false)
// even though the flow never assigned a real value.
func TestSObjectVar_UnprovidedIsNull(t *testing.T) {
	// Flow: Start → Decide (is acctVar null?) → outcome "WasNull" or "WasNotNull"
	flowMeta := &models.FlowMetadata{
		Start: &models.FlowStart{
			Connector: &models.FlowConnector{TargetReference: "NullCheck"},
		},
		Variables: []models.FlowVariable{
			{Name: "acctVar", DataType: "SObject", ObjectType: "Account"},
		},
		Decisions: []models.FlowDecision{
			{
				Name:                  "NullCheck",
				DefaultConnectorLabel: "WasNotNull",
				DefaultConnector:      &models.FlowConnector{TargetReference: "WasNotNull"},
				Rules: []models.FlowDecisionRule{
					{
						Name:      "IsNullRule",
						Label:     "Is acctVar null?",
						Connector: &models.FlowConnector{TargetReference: "WasNull"},
						Conditions: []models.FlowCondition{
							{
								LeftValueReference: "acctVar",
								Operator:           "IsNull",
								RightValue:         map[string]interface{}{"booleanValue": true},
							},
						},
						ConditionLogic: "and",
					},
				},
			},
		},
		Assignments: []models.FlowAssignment{
			// Placeholder terminals so executeElement doesn't 404.
			{Name: "WasNull", Items: []models.FlowAssignmentItem{}},
			{Name: "WasNotNull", Items: []models.FlowAssignmentItem{}},
		},
	}

	cap := &captureUpdate{}
	exec := minimalPersistenceWithCapture(cap)

	ctxData := initializeFlowContext(flowMeta, nil, "AutoLaunchedFlow")
	// Pre-condition: variable is absent (null) — not pre-seeded.
	if _, seeded := ctxData["acctVar"]; seeded {
		t.Fatal("pre-condition violated: acctVar is seeded by initializeFlowContext — lazy vivify not applied")
	}

	interview := &models.FlowInterview{
		Id:          "interview-null-contract",
		ContextData: ctxData,
	}

	outputs, _, err := exec.executeFlowMetadata(context.Background(), interview, flowMeta, nil)
	if err != nil {
		t.Fatalf("executeFlowMetadata error = %v", err)
	}
	_ = outputs

	// The Decision reached "WasNull" → ran the WasNull Assignment (no-op) → flow
	// ends there (nil Connector).  We verify by checking that the Decision's
	// IsNull rule evaluated true, which caused it to take the "WasNull" branch
	// rather than the default "WasNotNull" branch.
	//
	// Indirect proof: if the variable were non-null (empty SObject seed), the
	// Decision default branch ("WasNotNull") would have been taken.  We cannot
	// easily inspect which branch ran, so we use the condition evaluator directly.
	conds := []models.FlowCondition{
		{
			LeftValueReference: "acctVar",
			Operator:           "IsNull",
			RightValue:         map[string]interface{}{"booleanValue": true},
		},
	}
	// Build a fresh context after init (no assignments yet).
	freshCtx := initializeFlowContext(flowMeta, nil, "AutoLaunchedFlow")
	freshInterview := &models.FlowInterview{
		Id:          "interview-null-cond",
		ContextData: freshCtx,
	}
	isNull := exec.evaluateConditionsCtx(context.Background(), freshInterview, nil, conds, "and")
	if !isNull {
		t.Error("IsNull(acctVar) returned false for an unprovided SObject variable — must be true before any assignment (null contract regression)")
	}
}

func TestDecisionMatchedRuleWithoutConnectorTerminates(t *testing.T) {
	exec := &FlowExecutor{}
	interview := &models.FlowInterview{
		Id: "interview-decision-terminal-rule",
		ContextData: map[string]interface{}{
			"shouldSkip": true,
		},
	}
	decision := &models.FlowDecision{
		Name:             "SkipOrContinue",
		DefaultConnector: &models.FlowConnector{TargetReference: "DefaultPath"},
		Rules: []models.FlowDecisionRule{
			{
				Name: "SkipRule",
				Conditions: []models.FlowCondition{
					{
						LeftValueReference: "shouldSkip",
						Operator:           "EqualTo",
						RightValue:         map[string]interface{}{"booleanValue": true},
					},
				},
				ConditionLogic: "and",
			},
		},
	}

	next, err := exec.executeDecision(context.Background(), interview, decision, nil)
	if err != nil {
		t.Fatalf("executeDecision error = %v", err)
	}
	if next != "" {
		t.Fatalf("next = %q, want flow termination when matched rule has no connector", next)
	}
	if got, _ := interview.ContextData["SkipRule"].(bool); !got {
		t.Fatalf("decision outcome SkipRule = %v, want true", interview.ContextData["SkipRule"])
	}
}

// ---------------------------------------------------------------------------
// Lazy vivification — ensureDeclaredMapRoot behaviour
// ---------------------------------------------------------------------------

// TestLazyVivify_DottedAssignmentCreatesRoot verifies that executing an
// Assignment element that writes `acctVar.Id` when acctVar is absent creates
// the SObject root and stores the value.  Before the assignment acctVar is
// absent; after it is a non-empty SObject.
func TestLazyVivify_DottedAssignmentCreatesRoot(t *testing.T) {
	const recordID = "acc-lazy-001"

	flowMeta := &models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "acctVar", DataType: "SObject", ObjectType: "Account"},
		},
	}

	cap := &captureUpdate{}
	exec := minimalPersistenceWithCapture(cap)

	ctxData := initializeFlowContext(flowMeta, nil, "AutoLaunchedFlow")
	if _, seeded := ctxData["acctVar"]; seeded {
		t.Fatal("pre-condition: acctVar must be absent before assignment")
	}

	interview := &models.FlowInterview{
		Id:          "interview-vivify",
		ContextData: ctxData,
	}

	assignment := &models.FlowAssignment{
		Name: "SetId",
		Items: []models.FlowAssignmentItem{
			{
				AssignToReference: "acctVar.Id",
				Operator:          "Assign",
				Value:             map[string]interface{}{"stringValue": recordID},
			},
		},
	}

	_, err := exec.executeAssignment(context.Background(), interview, flowMeta, assignment, nil)
	if err != nil {
		t.Fatalf("executeAssignment error = %v", err)
	}

	raw, exists := interview.ContextData["acctVar"]
	if !exists {
		t.Fatal("acctVar absent after assignment — lazy vivification did not create the root SObject")
	}
	so, ok := raw.(models.SObject)
	if !ok {
		t.Fatalf("acctVar has type %T after assignment, want models.SObject", raw)
	}
	if so["Id"] != recordID {
		t.Errorf("acctVar.Id = %v, want %q after assignment", so["Id"], recordID)
	}
}

// TestLazyVivify_DottedAssignmentCreatesAnyMapRoot covers connector flows
// that assemble a sparse external record in a declared Any variable.
func TestLazyVivify_DottedAssignmentCreatesAnyMapRoot(t *testing.T) {
	flowMeta := &models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "mappingSourceRecord", DataType: "Any"},
		},
	}

	exec := minimalPersistenceWithCapture(&captureUpdate{})
	interview := &models.FlowInterview{
		Id:          "interview-any-vivify",
		ContextData: initializeFlowContext(flowMeta, nil, "AutoLaunchedFlow"),
	}
	assignment := &models.FlowAssignment{
		Name: "SetSparseFirstname",
		Items: []models.FlowAssignmentItem{
			{
				AssignToReference: "mappingSourceRecord.firstname",
				Operator:          "Assign",
				Value:             map[string]interface{}{"stringValue": "Updated"},
			},
		},
	}

	if _, err := exec.executeAssignment(context.Background(), interview, flowMeta, assignment, nil); err != nil {
		t.Fatalf("executeAssignment error = %v", err)
	}

	record, ok := interview.ContextData["mappingSourceRecord"].(map[string]interface{})
	if !ok {
		t.Fatalf("mappingSourceRecord has type %T, want map[string]interface{}", interview.ContextData["mappingSourceRecord"])
	}
	if got := record["firstname"]; got != "Updated" {
		t.Fatalf("mappingSourceRecord.firstname = %v, want Updated", got)
	}

	otherInterview := &models.FlowInterview{
		Id:          "interview-any-vivify-other",
		ContextData: initializeFlowContext(flowMeta, nil, "AutoLaunchedFlow"),
	}
	assignment.Items[0].Value = map[string]interface{}{"stringValue": "Other"}
	if _, err := exec.executeAssignment(context.Background(), otherInterview, flowMeta, assignment, nil); err != nil {
		t.Fatalf("executeAssignment for second interview error = %v", err)
	}
	otherRecord, ok := otherInterview.ContextData["mappingSourceRecord"].(map[string]interface{})
	if !ok {
		t.Fatalf("second mappingSourceRecord has type %T, want map[string]interface{}", otherInterview.ContextData["mappingSourceRecord"])
	}
	if got := otherRecord["firstname"]; got != "Other" {
		t.Fatalf("second mappingSourceRecord.firstname = %v, want Other", got)
	}
	if got := record["firstname"]; got != "Updated" {
		t.Fatalf("first mappingSourceRecord.firstname = %v after second interview, want Updated", got)
	}

	assignment.Items[0].Value = map[string]interface{}{"stringValue": ""}
	if _, err := exec.executeAssignment(context.Background(), interview, flowMeta, assignment, nil); err != nil {
		t.Fatalf("executeAssignment for explicit empty value error = %v", err)
	}
	if got := record["firstname"]; got != "" {
		t.Fatalf("mappingSourceRecord.firstname = %v, want explicit empty string", got)
	}
}

// ---------------------------------------------------------------------------
// Output-collection tests — unassigned SObject output must be absent
// ---------------------------------------------------------------------------

// TestSObjectOutputVar_UnassignedNotEmitted verifies that a declared IsOutput
// SObject variable that is NEVER assigned by any flow element is NOT present
// in the outputs map returned by executeFlowMetadata.  Salesforce returns
// null/absent for unassigned output variables.
func TestSObjectOutputVar_UnassignedNotEmitted(t *testing.T) {
	flowMeta := &models.FlowMetadata{
		Start: &models.FlowStart{
			Connector: &models.FlowConnector{TargetReference: "noop"},
		},
		Variables: []models.FlowVariable{
			{Name: "outputVar", DataType: "SObject", ObjectType: "GenericObj", IsOutput: true},
		},
		Assignments: []models.FlowAssignment{
			{
				Name:      "noop",
				Items:     []models.FlowAssignmentItem{},
				Connector: nil, // terminates flow
			},
		},
	}

	cap := &captureUpdate{}
	exec := minimalPersistenceWithCapture(cap)

	ctxData := initializeFlowContext(flowMeta, nil, "AutoLaunchedFlow")
	// Variable must be absent (not pre-seeded) — lazy vivify guarantee.
	if _, seeded := ctxData["outputVar"]; seeded {
		t.Fatal("pre-condition: outputVar must not be seeded by initializeFlowContext")
	}

	interview := &models.FlowInterview{
		Id:          "interview-unassigned-output",
		ContextData: ctxData,
	}

	outputs, _, err := exec.executeFlowMetadata(context.Background(), interview, flowMeta, nil)
	if err != nil {
		t.Fatalf("executeFlowMetadata error = %v", err)
	}

	if val, present := outputs["outputVar"]; present {
		t.Errorf("unassigned IsOutput SObject outputVar emitted in outputs = %v; want absent", val)
	}
}

// TestSObjectOutputVar_AssignedIsEmitted verifies the complementary case: an
// IsOutput SObject variable that IS assigned by a flow element DOES appear in
// the outputs map with the assigned data.
func TestSObjectOutputVar_AssignedIsEmitted(t *testing.T) {
	const assignedID = "obj-assigned-123"

	flowMeta := &models.FlowMetadata{
		Start: &models.FlowStart{
			Connector: &models.FlowConnector{TargetReference: "assignStep"},
		},
		Variables: []models.FlowVariable{
			{Name: "outputVar", DataType: "SObject", ObjectType: "GenericObj", IsOutput: true},
		},
		Assignments: []models.FlowAssignment{
			{
				Name: "assignStep",
				Items: []models.FlowAssignmentItem{
					{
						AssignToReference: "outputVar.Id",
						Operator:          "Assign",
						Value:             map[string]interface{}{"stringValue": assignedID},
					},
				},
				Connector: nil, // terminates after assignment
			},
		},
	}

	cap := &captureUpdate{}
	exec := minimalPersistenceWithCapture(cap)

	ctxData := initializeFlowContext(flowMeta, nil, "AutoLaunchedFlow")
	interview := &models.FlowInterview{
		Id:          "interview-assigned-output",
		ContextData: ctxData,
	}

	outputs, _, err := exec.executeFlowMetadata(context.Background(), interview, flowMeta, nil)
	if err != nil {
		t.Fatalf("executeFlowMetadata error = %v", err)
	}

	val, present := outputs["outputVar"]
	if !present {
		t.Fatal("assigned IsOutput SObject outputVar absent from outputs; want present")
	}
	so, ok := val.(models.SObject)
	if !ok {
		t.Fatalf("outputs[outputVar] type %T, want models.SObject", val)
	}
	if so["Id"] != assignedID {
		t.Errorf("outputs[outputVar].Id = %v, want %q", so["Id"], assignedID)
	}
}

// TestInitFlowCtx_SObjectVar_InputWins verifies that when a caller supplies
// an input value for a declared SObject variable, the input is preserved and
// no seeding overwrites it.
func TestInitFlowCtx_SObjectVar_InputWins(t *testing.T) {
	provided := models.SObject{
		"Id":         "acc-999",
		"Name":       "Input Record",
		"attributes": map[string]interface{}{"type": "Account"},
	}
	flowMeta := &models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "acctVar", DataType: "SObject", ObjectType: "Account", IsInput: true},
		},
	}
	inputs := map[string]interface{}{
		"acctVar": provided,
	}
	ctx := initializeFlowContext(flowMeta, inputs, "AutoLaunchedFlow")

	got, ok := ctx["acctVar"].(models.SObject)
	if !ok {
		t.Fatalf("acctVar has type %T, want models.SObject", ctx["acctVar"])
	}
	if got["Id"] != "acc-999" {
		t.Errorf("acctVar.Id = %v, want acc-999 — input value must not be overwritten", got["Id"])
	}
}

// ---------------------------------------------------------------------------
// Integration test — Assignment → Update Records round-trip
//
// Mirrors the bug scenario end-to-end with generic Salesforce-standard names:
//   - Flow declares variable acctVar (SObject, objectType=Account, isInput+isOutput)
//   - Assignment sets acctVar.Id from a literal and acctVar.Name = "Updated"
//     (variable starts ABSENT — lazy vivify creates it on first write)
//   - Update Records references acctVar with no explicit Object
//
// Post-lazy-vivify: acctVar is absent after init → vivified by first Assignment
// write → RecordUpdate resolves objectName "Account" from flow metadata →
// persistence.Update called exactly once with {Name: "Updated"}.
// ---------------------------------------------------------------------------

func TestSObjectVar_AssignmentThenUpdate_EndToEnd(t *testing.T) {
	const recordID = "acc-e2e-001"

	flowMeta := &models.FlowMetadata{
		Variables: []models.FlowVariable{
			{Name: "acctVar", DataType: "SObject", ObjectType: "Account", IsInput: true, IsOutput: true},
		},
	}

	// No inputs — the variable carries no default and no caller-provided value.
	ctxData := initializeFlowContext(flowMeta, nil, "AutoLaunchedFlow")

	// Verify the variable is ABSENT after init (lazy vivify guarantee).
	if _, ok := ctxData["acctVar"]; ok {
		t.Fatal("pre-condition: acctVar must be absent after initializeFlowContext — lazy vivify not applied")
	}

	interview := &models.FlowInterview{
		Id:          "interview-e2e",
		ContextData: ctxData,
	}

	cap := &captureUpdate{}
	exec := minimalPersistenceWithCapture(cap)

	assignment := &models.FlowAssignment{
		Name: "SetAcctFields",
		Items: []models.FlowAssignmentItem{
			{
				AssignToReference: "acctVar.Id",
				Operator:          "Assign",
				Value:             map[string]interface{}{"stringValue": recordID},
			},
			{
				AssignToReference: "acctVar.Name",
				Operator:          "Assign",
				Value:             map[string]interface{}{"stringValue": "Updated"},
			},
		},
		Connector: &models.FlowConnector{TargetReference: "UpdateAcct"},
	}

	nextElem, err := exec.executeAssignment(context.Background(), interview, flowMeta, assignment, nil)
	if err != nil {
		t.Fatalf("executeAssignment error = %v", err)
	}
	if nextElem != "UpdateAcct" {
		t.Fatalf("nextElem after assignment = %q, want UpdateAcct", nextElem)
	}

	// After assignment, acctVar must be vivified and contain the written fields.
	acctVarRaw, _ := interview.ContextData["acctVar"].(models.SObject)
	if acctVarRaw == nil {
		t.Fatal("acctVar not an SObject after assignment — lazy vivification did not fire")
	}
	if acctVarRaw["Id"] != recordID {
		t.Errorf("acctVar.Id = %v, want %q after assignment", acctVarRaw["Id"], recordID)
	}
	if acctVarRaw["Name"] != "Updated" {
		t.Errorf("acctVar.Name = %v, want %q after assignment", acctVarRaw["Name"], "Updated")
	}

	// Step 2: Update Records — object="" must resolve from variable's declared type
	update := &models.FlowRecordUpdate{
		Name:           "UpdateAcct",
		InputReference: "acctVar",
		Object:         "", // must resolve from the variable's declared type in flow metadata
		InputAssignments: []models.FlowInputAssignment{
			{Field: "Name", Value: map[string]interface{}{"stringValue": "Updated"}},
		},
		Connector: &models.FlowConnector{TargetReference: "Done"},
	}

	nextElem, err = exec.executeRecordUpdate(context.Background(), interview, flowMeta, update, nil)
	if err != nil {
		t.Fatalf("executeRecordUpdate error = %v", err)
	}
	if nextElem != "Done" {
		t.Fatalf("nextElem after update = %q, want Done", nextElem)
	}

	if len(cap.calls) != 1 {
		t.Fatalf("persistence.Update called %d times, want 1", len(cap.calls))
	}
	call := cap.calls[0]
	if call.objectName != "Account" {
		t.Errorf("Update objectName = %q, want Account — declared variable type must resolve the object", call.objectName)
	}
	if call.id != recordID {
		t.Errorf("Update id = %q, want %q", call.id, recordID)
	}
	if call.updates["Name"] != "Updated" {
		t.Errorf("Update Name = %v, want Updated", call.updates["Name"])
	}
	// attributes metadata envelope must not leak into DML payload
	if _, leaked := call.updates["attributes"]; leaked {
		t.Errorf("attributes metadata envelope leaked into DML update payload: %v", call.updates)
	}
	// Id field is the record identifier and must not appear as a column in the payload
	if _, leaked := call.updates["Id"]; leaked {
		t.Errorf("Id field leaked into DML update payload: %v", call.updates)
	}
}

// TestSObjectVar_GuardedUpdate_RecordTriggered_EndToEnd locks in a
// record-triggered flow shape that PR #1690 fixed but its own end-to-end test
// did not exercise: a declared SObject working variable updated through a
// Decision guard and an object-less Update Records element.
//
// PR #1690 fixed the underlying bug — a declared SObject working variable with
// no default was never in the context, so `var.Field = …` silently no-opped,
// the `var.Id IsNull false` guard never passed, and the object-less Update
// Records was unreachable. That PR's own end-to-end test
// (TestSObjectVar_AssignmentThenUpdate_EndToEnd above) drove
// assignment → update with NO Decision element in between. Record-triggered
// flows commonly gate the update behind exactly that guard, so this test adds
// the missing middle stage on a tenant-neutral Account flow:
//
//	assignment vivifies the SObject var  ->  Decision `var.Id IsNull false`
//	routes to the Update branch (not the default)  ->  object-less Update
//	Records resolves object=Account from the variable's declared type and
//	persists once.
//
// Pre-#1690 every stage breaks: the assignment no-ops (Id unset), the guard
// takes the default path, and the object-less update is skipped — so this is a
// genuine regression lock, not a duplicate of the assignment→update test.
func TestSObjectVar_GuardedUpdate_RecordTriggered_EndToEnd(t *testing.T) {
	flowMeta := &models.FlowMetadata{
		Status: "Active",
		Start: &models.FlowStart{
			Object:      "Account",
			TriggerType: "RecordAfterSave",
		},
		Variables: []models.FlowVariable{
			{Name: "acctVar", DataType: "SObject", ObjectType: "Account", IsInput: true, IsOutput: true},
		},
		Assignments: []models.FlowAssignment{
			{
				Name: "Set_Acct",
				Items: []models.FlowAssignmentItem{
					{AssignToReference: "acctVar.Id", Operator: "Assign", Value: map[string]interface{}{"elementReference": "$Record.AccountId"}},
					{AssignToReference: "acctVar.Name", Operator: "Assign", Value: map[string]interface{}{"elementReference": "Lookup_Acct.Name"}},
				},
				Connector: &models.FlowConnector{TargetReference: "if_acct_not_null"},
			},
		},
		Decisions: []models.FlowDecision{
			{
				Name: "if_acct_not_null",
				Rules: []models.FlowDecisionRule{
					{
						Name:           "notnull",
						ConditionLogic: "and",
						Conditions: []models.FlowCondition{
							{LeftValueReference: "acctVar.Id", Operator: "IsNull", RightValue: map[string]interface{}{"booleanValue": false}},
						},
						Connector: &models.FlowConnector{TargetReference: "update_acct"},
					},
				},
				DefaultConnector: &models.FlowConnector{TargetReference: "End_NoOp"},
			},
		},
		RecordUpdates: []models.FlowRecordUpdate{
			// No Object — must resolve from the variable's declared type.
			{Name: "update_acct", InputReference: "acctVar"},
		},
	}

	cap := &captureUpdate{}
	exec := minimalPersistenceWithCapture(cap)

	ctxData := initializeFlowContext(flowMeta, nil, "AutoLaunchedFlow")
	ctxData["$Record"] = models.SObject{"Id": "evt-1", "AccountId": "acc-001"}
	ctxData["Lookup_Acct"] = models.SObject{"Id": "acc-001", "Name": "Acme"}
	interview := &models.FlowInterview{Id: "iv-guarded", ContextData: ctxData}

	// Lazy vivify: acctVar absent until the first dotted assignment.
	if _, ok := ctxData["acctVar"]; ok {
		t.Fatal("acctVar present before assignment — lazy vivify not applied")
	}

	// Stage 1 — assignment vivifies acctVar and writes Id + Name.
	next, err := exec.executeAssignment(context.Background(), interview, flowMeta, &flowMeta.Assignments[0], nil)
	if err != nil {
		t.Fatalf("executeAssignment error = %v", err)
	}
	if next != "if_acct_not_null" {
		t.Fatalf("assignment next = %q, want if_acct_not_null", next)
	}
	av, _ := interview.ContextData["acctVar"].(models.SObject)
	if av == nil {
		t.Fatal("acctVar not vivified after assignment — declared SObject root was not created")
	}
	if av["Id"] != "acc-001" {
		t.Fatalf("acctVar.Id = %v, want acc-001 after assignment", av["Id"])
	}

	// Stage 2 — Decision guard `acctVar.Id IsNull false` must route to the
	// update branch. The default path means the guard saw a null Id (the
	// pre-#1690 failure) and the update would never run.
	next, err = exec.executeDecision(context.Background(), interview, &flowMeta.Decisions[0], nil)
	if err != nil {
		t.Fatalf("executeDecision error = %v", err)
	}
	if next != "update_acct" {
		t.Fatalf("guard routed to %q, want update_acct — acctVar.Id IsNull false must be false once vivified", next)
	}

	// Stage 3 — object-less Update resolves object=Account from the variable's
	// declared type and persists once.
	_, err = exec.executeRecordUpdate(context.Background(), interview, flowMeta, &flowMeta.RecordUpdates[0], nil)
	if err != nil {
		t.Fatalf("executeRecordUpdate error = %v", err)
	}
	if len(cap.calls) != 1 {
		t.Fatalf("persistence.Update called %d times, want 1 — object-less update on a declared SObject var must persist", len(cap.calls))
	}
	call := cap.calls[0]
	if call.objectName != "Account" {
		t.Errorf("Update objectName = %q, want Account — declared variable type must resolve the object", call.objectName)
	}
	if call.id != "acc-001" {
		t.Errorf("Update id = %q, want acc-001", call.id)
	}
	if call.updates["Name"] != "Acme" {
		t.Errorf("Update Name = %v, want Acme", call.updates["Name"])
	}
	if _, leaked := call.updates["Id"]; leaked {
		t.Errorf("Id field leaked into DML update payload: %v", call.updates)
	}
	if _, leaked := call.updates["attributes"]; leaked {
		t.Errorf("attributes metadata envelope leaked into DML update payload: %v", call.updates)
	}
}
