package automation

// TestDecisionChain_ProcessBuilderStyle_MultiDecision tests that a 4-decision
// chain with Process-Builder-style rightValue wrappers (map[string]interface{}
// with a single non-nil stringValue/booleanValue and all other keys null) fires
// the correct recordUpdate for each input region.
//
// Reproduces issue #1817 Bug A: only the second decision's rule (Japan path)
// fired at runtime; the first (China Outbound), third (NA), and fourth
// (APAC/default) decision paths silently dropped their recordUpdates.
//
// Each rule has two conditions combined with AND:
//   - Region__c EqualTo <region>  (stringValue wrapper)
//   - Entity__c IsNull true       (booleanValue wrapper)
//
// All recordUpdates use a Process-Builder implicit filter:
//   {field: "Id", operator: "EqualTo", value: {elementReference: "$Record.Id"}}
//
// This is the migrated form produced by the workflow→flow migration for
// a real-world tenant entity-mapping flow. It must work for any SObject and
// any field values — no hardcoded region names appear in the engine fix.

import (
	"context"
	"testing"

	"github.com/nexuscrm/shared/pkg/models"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
)

// pbStringValue builds a Process-Builder-style typed-value wrapper with only
// stringValue set (all other fields are nil maps/false). This is the exact
// shape the migrator produces for picklist/text comparisons.
func pbStringValue(s string) map[string]interface{} {
	return map[string]interface{}{
		"stringValue":            s,
		"booleanValue":           nil,
		"numberValue":            nil,
		"dateValue":              nil,
		"dateTimeValue":          nil,
		"elementReference":       nil,
		"apexValue":              nil,
		"complexValue":           nil,
		"complexValueType":       nil,
		"formulaDataType":        nil,
		"formulaExpression":      nil,
		"numberValue2":           nil,
		"setupReference":         nil,
		"setupReferenceType":     nil,
		"sobjectValue":           nil,
		"transform":              nil,
		"transformValueReference": nil,
	}
}

// pbBoolValue builds a Process-Builder-style typed-value wrapper with only
// booleanValue set.
func pbBoolValue(b bool) map[string]interface{} {
	return map[string]interface{}{
		"stringValue":            nil,
		"booleanValue":           b,
		"numberValue":            nil,
		"dateValue":              nil,
		"dateTimeValue":          nil,
		"elementReference":       nil,
		"apexValue":              nil,
		"complexValue":           nil,
		"complexValueType":       nil,
		"formulaDataType":        nil,
		"formulaExpression":      nil,
		"numberValue2":           nil,
		"setupReference":         nil,
		"setupReferenceType":     nil,
		"sobjectValue":           nil,
		"transform":              nil,
		"transformValueReference": nil,
	}
}

// pbElementRef builds a Process-Builder-style typed-value wrapper with only
// elementReference set, used for the implicit $Record.Id filter.
func pbElementRef(ref string) map[string]interface{} {
	return map[string]interface{}{
		"stringValue":            nil,
		"booleanValue":           nil,
		"numberValue":            nil,
		"dateValue":              nil,
		"dateTimeValue":          nil,
		"elementReference":       ref,
		"apexValue":              nil,
		"complexValue":           nil,
		"complexValueType":       nil,
		"formulaDataType":        nil,
		"formulaExpression":      nil,
		"numberValue2":           nil,
		"setupReference":         nil,
		"setupReferenceType":     nil,
		"sobjectValue":           nil,
		"transform":              nil,
		"transformValueReference": nil,
	}
}

// buildDecisionChainFlow constructs the minimal 2-decision-2-rule flow that
// mirrors the a real-world tenant entity-mapping flow migrated structure. Using
// generic field/value names ("Status", "ValueA", "ValueB") — no business
// semantics are hardcoded in the engine path under test.
//
// Flow graph:
//
//	start → decisionA
//	  decisionA: ruleA (Status==ValueA AND Entity==null) → updateA [terminal]
//	             default → decisionB
//	  decisionB: ruleB (Status==ValueB AND Entity==null) → updateB [terminal]
//	             default → (end)
func buildDecisionChainFlow(regionField, entityField, valueA, valueB, objectName, recordAName, recordBName string) models.FlowMetadata {
	// Implicit Process-Builder filter: targets triggering record by Id
	implicitIdFilter := []models.FlowRecordFilter{
		{
			Field:    "Id",
			Operator: "EqualTo",
			Value:    pbElementRef("$Record.Id"),
		},
	}

	return models.FlowMetadata{
		Start: &models.FlowStart{
			Connector: &models.FlowConnector{TargetReference: "decisionA"},
			Object:    objectName,
			TriggerType: "RecordAfterSave",
		},
		Decisions: []models.FlowDecision{
			{
				Name: "decisionA",
				Rules: []models.FlowDecisionRule{
					{
						Name:           "ruleA",
						ConditionLogic: "and",
						Conditions: []models.FlowCondition{
							{
								LeftValueReference: "$Record." + regionField,
								Operator:           "EqualTo",
								RightValue:         pbStringValue(valueA),
							},
							{
								LeftValueReference: "$Record." + entityField,
								Operator:           "IsNull",
								RightValue:         pbBoolValue(true),
							},
						},
						Connector: &models.FlowConnector{TargetReference: recordAName},
					},
				},
				DefaultConnector: &models.FlowConnector{TargetReference: "decisionB"},
			},
			{
				Name: "decisionB",
				Rules: []models.FlowDecisionRule{
					{
						Name:           "ruleB",
						ConditionLogic: "and",
						Conditions: []models.FlowCondition{
							{
								LeftValueReference: "$Record." + regionField,
								Operator:           "EqualTo",
								RightValue:         pbStringValue(valueB),
							},
							{
								LeftValueReference: "$Record." + entityField,
								Operator:           "IsNull",
								RightValue:         pbBoolValue(true),
							},
						},
						Connector: &models.FlowConnector{TargetReference: recordBName},
					},
				},
				DefaultConnector: nil,
			},
		},
		RecordUpdates: []models.FlowRecordUpdate{
			{
				Name:         recordAName,
				Object:       objectName,
				FilterLogic:  "and",
				Filters:      implicitIdFilter,
				InputAssignments: []models.FlowInputAssignment{
					{Field: entityField, Value: pbStringValue("Result-A")},
				},
				Connector: nil, // terminal
			},
			{
				Name:         recordBName,
				Object:       objectName,
				FilterLogic:  "and",
				Filters:      implicitIdFilter,
				InputAssignments: []models.FlowInputAssignment{
					{Field: entityField, Value: pbStringValue("Result-B")},
				},
				Connector: nil, // terminal
			},
		},
	}
}

// TestDecisionChain_FirstDecisionRuleFires asserts that when the trigger record
// satisfies the FIRST decision's rule, its recordUpdate executes.
//
// Before the fix: ruleA fired correctly (decision 1 → update A), FAILS because
// only the second decision's rule was working. The test must FAIL on the
// unfixed engine.
func TestDecisionChain_FirstDecisionRuleFires(t *testing.T) {
	const (
		recordID   = "acct-001"
		objectName = "Widget"
		valueA     = "Alpha"
		valueB     = "Beta"
	)
	flowMeta := buildDecisionChainFlow("Status__c", "Entity__c", valueA, valueB, objectName, "updateA", "updateB")

	mockPersist := new(MockPersistence)
	mockQuery := new(MockQueryService)
	executor := newTransientExecutor(mockPersist)
	executor.query = mockQuery

	// Trigger record: Status__c matches valueA, Entity__c is null (absent from map).
	triggerRecord := models.SObject{
		"Id":         recordID,
		"Status__c":  valueA,
		"attributes": map[string]interface{}{"type": objectName},
	}

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"$Record": triggerRecord,
		},
	}

	// QueryWithFilter should be called to locate the record by Id.
	expectedFilter := "Id = '" + recordID + "'"
	mockQuery.On("QueryWithFilter",
		mock.Anything, objectName, expectedFilter,
		(*models.UserSession)(nil), "", "", 201,
	).Return(&models.QueryResult{
		TotalSize: 1,
		Records:   []models.SObject{{"Id": recordID}},
	}, nil).Once()

	// persistence.Update should be called with the field assignment for updateA.
	mockPersist.On("Update",
		mock.Anything, objectName, recordID,
		mock.MatchedBy(func(updates models.SObject) bool {
			return updates["Entity__c"] == "Result-A"
		}),
		(*models.UserSession)(nil),
	).Return(nil).Once()

	_, err := executor.executeFlowMetadata(
		WithTransientFlowInterviews(context.Background()),
		interview,
		&flowMeta,
		nil,
	)

	require.NoError(t, err)
	mockQuery.AssertExpectations(t)
	mockPersist.AssertExpectations(t)
	// updateB must NOT have been called.
	mockPersist.AssertNotCalled(t, "Update", mock.Anything, objectName, recordID,
		mock.MatchedBy(func(updates models.SObject) bool {
			return updates["Entity__c"] == "Result-B"
		}),
		mock.Anything)
}

// TestDecisionChain_SecondDecisionRuleFires asserts that when the trigger
// record satisfies the SECOND decision's rule (and not the first), its
// recordUpdate executes.
//
// This is the Japan path that works before the fix — so this test should
// PASS both before and after the fix (it is a regression guard).
func TestDecisionChain_SecondDecisionRuleFires(t *testing.T) {
	const (
		recordID   = "acct-002"
		objectName = "Widget"
		valueA     = "Alpha"
		valueB     = "Beta"
	)
	flowMeta := buildDecisionChainFlow("Status__c", "Entity__c", valueA, valueB, objectName, "updateA", "updateB")

	mockPersist := new(MockPersistence)
	mockQuery := new(MockQueryService)
	executor := newTransientExecutor(mockPersist)
	executor.query = mockQuery

	// Trigger record: Status__c matches valueB (second decision), Entity__c absent.
	triggerRecord := models.SObject{
		"Id":         recordID,
		"Status__c":  valueB,
		"attributes": map[string]interface{}{"type": objectName},
	}

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"$Record": triggerRecord,
		},
	}

	expectedFilter := "Id = '" + recordID + "'"
	mockQuery.On("QueryWithFilter",
		mock.Anything, objectName, expectedFilter,
		(*models.UserSession)(nil), "", "", 201,
	).Return(&models.QueryResult{
		TotalSize: 1,
		Records:   []models.SObject{{"Id": recordID}},
	}, nil).Once()

	mockPersist.On("Update",
		mock.Anything, objectName, recordID,
		mock.MatchedBy(func(updates models.SObject) bool {
			return updates["Entity__c"] == "Result-B"
		}),
		(*models.UserSession)(nil),
	).Return(nil).Once()

	_, err := executor.executeFlowMetadata(
		WithTransientFlowInterviews(context.Background()),
		interview,
		&flowMeta,
		nil,
	)

	require.NoError(t, err)
	mockQuery.AssertExpectations(t)
	mockPersist.AssertExpectations(t)
}

// TestDecisionChain_NeitherRuleFires_NoUpdate asserts that when neither
// decision's rule matches, no recordUpdate is executed and the flow terminates
// cleanly.
func TestDecisionChain_NeitherRuleFires_NoUpdate(t *testing.T) {
	const (
		recordID   = "acct-003"
		objectName = "Widget"
		valueA     = "Alpha"
		valueB     = "Beta"
	)
	flowMeta := buildDecisionChainFlow("Status__c", "Entity__c", valueA, valueB, objectName, "updateA", "updateB")

	mockPersist := new(MockPersistence)
	mockQuery := new(MockQueryService)
	executor := newTransientExecutor(mockPersist)
	executor.query = mockQuery

	// Trigger record: Status__c matches neither rule.
	triggerRecord := models.SObject{
		"Id":         recordID,
		"Status__c":  "Gamma",
		"attributes": map[string]interface{}{"type": objectName},
	}

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"$Record": triggerRecord,
		},
	}

	_, err := executor.executeFlowMetadata(
		WithTransientFlowInterviews(context.Background()),
		interview,
		&flowMeta,
		nil,
	)

	require.NoError(t, err)
	mockQuery.AssertNotCalled(t, "QueryWithFilter", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	mockPersist.AssertNotCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

// TestDecisionChain_LiteralStringWithDots_WritesCorrectly reproduces issue #1817
// Bug A: a recordUpdate whose inputAssignment.value is a stringValue containing
// dots (e.g. "Acme Corp. Ltd.") was being mis-parsed as a nested variable
// reference path, causing resolveValueCtx to return nil and write NULL to the
// field instead of the literal string.
//
// This test uses a valueA string that contains a period to trigger the exact
// condition. The fix adds looksLikeVariableReference() to resolveValueCtxWithPresence
// so dot-containing literal strings are passed through unchanged instead of
// being routed through resolveNestedReferenceCtxWithPresence.
//
// This test FAILS on origin/main (before the fix) because the string "Acme Corp. Ltd."
// is misrouted to the nested-reference resolver, which returns nil, and the Update
// call receives nil for the field instead of the literal string.
func TestDecisionChain_LiteralStringWithDots_WritesCorrectly(t *testing.T) {
	const (
		recordID        = "acct-005"
		objectName      = "Widget"
		// The critical value: a picklist label that contains a period.
		// Before the fix, extractTypedValue returns "Acme Corp. Ltd." and
		// resolveValueCtxWithPresence sees the dot and tries nested reference lookup,
		// returning nil. After the fix, looksLikeVariableReference rejects the string
		// (it has a space) and the literal "Acme Corp. Ltd." is returned unchanged.
		dotLiteralValue = "Acme Corp. Ltd."
		plainValue      = "Plain"
	)

	// Build a two-decision flow where ruleA compares Status__c to dotLiteralValue
	// and the recordUpdate writes that same dot-containing string to Entity__c.
	implicitIdFilter := []models.FlowRecordFilter{
		{Field: "Id", Operator: "EqualTo", Value: pbElementRef("$Record.Id")},
	}
	flowMeta := models.FlowMetadata{
		Start: &models.FlowStart{
			Connector:   &models.FlowConnector{TargetReference: "decA"},
			Object:      objectName,
			TriggerType: "RecordAfterSave",
		},
		Decisions: []models.FlowDecision{
			{
				Name: "decA",
				Rules: []models.FlowDecisionRule{
					{
						Name:           "ruleA",
						ConditionLogic: "and",
						Conditions: []models.FlowCondition{
							{
								LeftValueReference: "$Record.Status__c",
								Operator:           "EqualTo",
								RightValue:         pbStringValue(dotLiteralValue),
							},
							{
								LeftValueReference: "$Record.Entity__c",
								Operator:           "IsNull",
								RightValue:         pbBoolValue(true),
							},
						},
						Connector: &models.FlowConnector{TargetReference: "updateA"},
					},
				},
				DefaultConnector: nil,
			},
		},
		RecordUpdates: []models.FlowRecordUpdate{
			{
				Name:        "updateA",
				Object:      objectName,
				FilterLogic: "and",
				Filters:     implicitIdFilter,
				// The assignment value has a dot in the literal string — this is what
				// triggers the bug. The resolver must return "Acme Corp. Ltd." not nil.
				InputAssignments: []models.FlowInputAssignment{
					{Field: "Entity__c", Value: pbStringValue(dotLiteralValue)},
				},
				Connector: nil,
			},
		},
	}

	mockPersist := new(MockPersistence)
	mockQuery := new(MockQueryService)
	executor := newTransientExecutor(mockPersist)
	executor.query = mockQuery

	triggerRecord := models.SObject{
		"Id":         recordID,
		"Status__c":  dotLiteralValue,
		"attributes": map[string]interface{}{"type": objectName},
	}
	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{"$Record": triggerRecord},
	}

	mockQuery.On("QueryWithFilter",
		mock.Anything, objectName, "Id = '"+recordID+"'",
		(*models.UserSession)(nil), "", "", 201,
	).Return(&models.QueryResult{
		TotalSize: 1,
		Records:   []models.SObject{{"Id": recordID}},
	}, nil).Once()

	// The key assertion: Update must receive the literal string "Acme Corp. Ltd."
	// NOT nil. Before the fix this assertion fails because updates["Entity__c"] is nil.
	mockPersist.On("Update",
		mock.Anything, objectName, recordID,
		mock.MatchedBy(func(updates models.SObject) bool {
			return updates["Entity__c"] == dotLiteralValue
		}),
		(*models.UserSession)(nil),
	).Return(nil).Once()

	_, err := executor.executeFlowMetadata(
		WithTransientFlowInterviews(context.Background()),
		interview,
		&flowMeta,
		nil,
	)

	require.NoError(t, err)
	mockQuery.AssertExpectations(t)
	mockPersist.AssertExpectations(t)
}

// TestDecisionChain_EntityAlreadySet_NoRuleFires asserts that when the entity
// field is already set (non-null), neither rule fires because the IsNull
// condition fails.
func TestDecisionChain_EntityAlreadySet_NoRuleFires(t *testing.T) {
	const (
		recordID   = "acct-004"
		objectName = "Widget"
		valueA     = "Alpha"
		valueB     = "Beta"
	)
	flowMeta := buildDecisionChainFlow("Status__c", "Entity__c", valueA, valueB, objectName, "updateA", "updateB")

	mockPersist := new(MockPersistence)
	mockQuery := new(MockQueryService)
	executor := newTransientExecutor(mockPersist)
	executor.query = mockQuery

	// Entity__c is already set — IsNull condition must return false for ruleA and ruleB.
	triggerRecord := models.SObject{
		"Id":        recordID,
		"Status__c": valueA,
		"Entity__c": "Already Set",
		"attributes": map[string]interface{}{"type": objectName},
	}

	interview := &models.FlowInterview{
		ContextData: map[string]interface{}{
			"$Record": triggerRecord,
		},
	}

	_, err := executor.executeFlowMetadata(
		WithTransientFlowInterviews(context.Background()),
		interview,
		&flowMeta,
		nil,
	)

	require.NoError(t, err)
	mockQuery.AssertNotCalled(t, "QueryWithFilter", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	mockPersist.AssertNotCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

