package automation

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

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

func TestParseApprovalProcessMetadata_XMLAndJSON(t *testing.T) {
	t.Run("xml", func(t *testing.T) {
		metadata, err := ParseApprovalProcessMetadata(`<?xml version="1.0" encoding="UTF-8"?>
<ApprovalProcess xmlns="http://soap.sforce.com/2006/04/metadata">
	<allowedSubmitters>
		<type>owner</type>
	</allowedSubmitters>
	<allowedSubmitters>
		<submitter>005-user-1</submitter>
		<type>user</type>
	</allowedSubmitters>
	<nextAutomatedApprover>
		<useApproverFieldOfRecordOwner>false</useApproverFieldOfRecordOwner>
		<userHierarchyField>ManagerId</userHierarchyField>
	</nextAutomatedApprover>
	<approvalStep>
		<name>Region_Approval</name>
		<label>Region Approval</label>
		<assignedApprover>
			<approver>
				<name>kevin.lu@pingcap.com</name>
				<type>user</type>
			</approver>
			<approver>
				<name>ManagerId</name>
				<type>relatedUserField</type>
			</approver>
			<whenMultipleApprovers>Unanimous</whenMultipleApprovers>
		</assignedApprover>
		<rejectBehavior>
			<type>BackToPrevious</type>
		</rejectBehavior>
	</approvalStep>
</ApprovalProcess>`)

		assert.NoError(t, err)
		assert.True(t, metadata.HasExplicitAllowedSubmitters)
		assert.Equal(t, []NormalizedAllowedSubmitter{
			{Type: "recordOwner"},
			{Type: "user", Submitter: "005-user-1"},
		}, metadata.AllowedSubmitters)
		assert.NotNil(t, metadata.NextAutomatedApprover)
		assert.Equal(t, "ManagerId", metadata.NextAutomatedApprover.UserHierarchyField)
		assert.False(t, metadata.NextAutomatedApprover.UseApproverFieldOfRecordOwner)
		assert.Len(t, metadata.Steps, 1)
		assert.Equal(t, "Region_Approval", metadata.Steps[0].Name)
		assert.Equal(t, "Unanimous", metadata.Steps[0].WhenMultipleApprovers)
		assert.Equal(t, "BackToPrevious", metadata.Steps[0].RejectBehavior)
		assert.Equal(t, []NormalizedApprovalApprover{
			{Name: "kevin.lu@pingcap.com", Type: "user"},
			{Name: "ManagerId", Type: "relatedUserField"},
		}, metadata.Steps[0].Approvers)
	})

	t.Run("json", func(t *testing.T) {
		metadata, err := ParseApprovalProcessMetadata(`{
			"allowedSubmitters": [
				{ "type": "creator" },
				{ "type": "hierarchy", "submitter": "00E-role-1" }
			],
			"nextAutomatedApprover": {
				"userHierarchyField": "ManagerId",
				"useApproverFieldOfRecordOwner": false
			},
			"approvalStep": {
				"name": "Region_Approval",
				"label": "Region Approval",
				"assignedApprover": {
					"approver": [
						{ "name": "kevin.lu@pingcap.com", "type": "user" },
						{ "name": "stephan.debray@pingcap.com", "type": "user" }
					],
					"whenMultipleApprovers": "FirstResponse"
				},
				"rejectBehavior": "RejectRequest"
			}
		}`)

		assert.NoError(t, err)
		assert.True(t, metadata.HasExplicitAllowedSubmitters)
		assert.Equal(t, []NormalizedAllowedSubmitter{
			{Type: "recordCreator"},
			{Type: "roleAndSubordinates", Submitter: "00E-role-1"},
		}, metadata.AllowedSubmitters)
		assert.NotNil(t, metadata.NextAutomatedApprover)
		assert.Equal(t, "ManagerId", metadata.NextAutomatedApprover.UserHierarchyField)
		assert.False(t, metadata.NextAutomatedApprover.UseApproverFieldOfRecordOwner)
		assert.Len(t, metadata.Steps, 1)
		assert.Equal(t, "FirstResponse", metadata.Steps[0].WhenMultipleApprovers)
		assert.Equal(t, "RejectRequest", metadata.Steps[0].RejectBehavior)
		assert.Equal(t, []NormalizedApprovalApprover{
			{Name: "kevin.lu@pingcap.com", Type: "user"},
			{Name: "stephan.debray@pingcap.com", Type: "user"},
		}, metadata.Steps[0].Approvers)
	})

	t.Run("legacy metadata without allowed submitters stays implicit", func(t *testing.T) {
		metadata, err := ParseApprovalProcessMetadata(`{"approvalStep":{"name":"Region_Approval","label":"Region Approval","assignedApprover":{"approver":{"name":"kevin.lu@pingcap.com","type":"user"}}}}`)

		assert.NoError(t, err)
		assert.False(t, metadata.HasExplicitAllowedSubmitters)
		assert.Empty(t, metadata.AllowedSubmitters)
	})
}

// TestParseApprovalProcessMetadata_StepCriteria exercises the shapes observed
// in production ProcessDefinition.Metadata imports from Salesforce: per-step
// "entryCriteria" (not "stepCriteria"), criteriaItems as either a dict or a
// list, and ifCriteriaNotMet normalization.
func TestParseApprovalProcessMetadata_StepCriteria(t *testing.T) {
	t.Run("salesforce entryCriteria at step level (single dict criteriaItems)", func(t *testing.T) {
		md, err := ParseApprovalProcessMetadata(`{
			"approvalStep": {
				"name": "Manager_Review",
				"label": "Manager Review",
				"entryCriteria": {
					"criteriaItems": { "field": "Opportunity.Amount", "operation": "greaterThan", "value": "10000" }
				},
				"ifCriteriaNotMet": "GotoNextStep",
				"assignedApprover": { "approver": { "name": "u@x.com", "type": "user" } }
			}
		}`)
		assert.NoError(t, err)
		assert.Len(t, md.Steps, 1)
		sc := md.Steps[0].StepCriteria
		assert.NotNil(t, sc)
		assert.Equal(t, []NormalizedCriteriaItem{{Field: "Opportunity.Amount", Operation: "greaterThan", Value: "10000"}}, sc.CriteriaItems)
		assert.Equal(t, "GotoNextStep", md.Steps[0].IfCriteriaNotMet)
	})

	t.Run("salesforce entryCriteria at step level (list criteriaItems with booleanFilter)", func(t *testing.T) {
		md, err := ParseApprovalProcessMetadata(`{
			"approvalStep": {
				"name": "Sales_Leader",
				"label": "Sales Leader",
				"entryCriteria": {
					"booleanFilter": "1 OR 2",
					"criteriaItems": [
						{ "field": "Quote.Autoapproval__c", "operation": "equals", "value": "False" },
						{ "field": "Quote.ApprovedPrice__c", "operation": "equals", "value": "True" }
					]
				},
				"ifCriteriaNotMet": "ApproveRecord",
				"assignedApprover": { "approver": { "name": "u@x.com", "type": "user" } }
			}
		}`)
		assert.NoError(t, err)
		assert.Len(t, md.Steps, 1)
		sc := md.Steps[0].StepCriteria
		assert.NotNil(t, sc)
		assert.Equal(t, "1 OR 2", sc.BooleanFilter)
		assert.Len(t, sc.CriteriaItems, 2)
		assert.Equal(t, "ApproveRecord", md.Steps[0].IfCriteriaNotMet)
	})

	t.Run("frontend stepCriteria tag still parses", func(t *testing.T) {
		md, err := ParseApprovalProcessMetadata(`{
			"approvalStep": {
				"name": "S1",
				"label": "S1",
				"stepCriteria": {
					"criteriaItems": [ { "field": "Lead.Industry", "operation": "equals", "value": "Technology" } ]
				},
				"assignedApprover": { "approver": { "name": "u@x.com", "type": "user" } }
			}
		}`)
		assert.NoError(t, err)
		assert.Len(t, md.Steps, 1)
		sc := md.Steps[0].StepCriteria
		assert.NotNil(t, sc)
		assert.Equal(t, []NormalizedCriteriaItem{{Field: "Lead.Industry", Operation: "equals", Value: "Technology"}}, sc.CriteriaItems)
		assert.Equal(t, "", md.Steps[0].IfCriteriaNotMet)
	})

	t.Run("entryCriteria takes precedence over stepCriteria if both present", func(t *testing.T) {
		md, err := ParseApprovalProcessMetadata(`{
			"approvalStep": {
				"name": "S1", "label": "S1",
				"stepCriteria": { "criteriaItems": { "field": "A", "operation": "equals", "value": "x" } },
				"entryCriteria": { "criteriaItems": { "field": "B", "operation": "equals", "value": "y" } },
				"assignedApprover": { "approver": { "name": "u@x.com", "type": "user" } }
			}
		}`)
		assert.NoError(t, err)
		assert.Equal(t, "B", md.Steps[0].StepCriteria.CriteriaItems[0].Field)
	})

	t.Run("ifCriteriaNotMet normalizes case-insensitively", func(t *testing.T) {
		for raw, want := range map[string]string{
			"GotoNextStep":  "GotoNextStep",
			"gotonextstep":  "GotoNextStep",
			"ApproveRecord": "ApproveRecord",
			"APPROVERECORD": "ApproveRecord",
		} {
			assert.Equalf(t, want, normalizeIfCriteriaNotMet(raw), "input=%q", raw)
		}
		assert.Equal(t, "", normalizeIfCriteriaNotMet(""))
	})

	t.Run("XML entryCriteria alias", func(t *testing.T) {
		md, err := ParseApprovalProcessMetadata(`<?xml version="1.0" encoding="UTF-8"?>
<ApprovalProcess xmlns="http://soap.sforce.com/2006/04/metadata">
	<approvalStep>
		<name>S1</name>
		<label>S1</label>
		<ifCriteriaNotMet>GotoNextStep</ifCriteriaNotMet>
		<entryCriteria>
			<criteriaItems>
				<field>Lead.Industry</field>
				<operation>equals</operation>
				<value>Technology</value>
			</criteriaItems>
		</entryCriteria>
		<assignedApprover>
			<approver>
				<name>u@x.com</name>
				<type>user</type>
			</approver>
		</assignedApprover>
	</approvalStep>
</ApprovalProcess>`)
		assert.NoError(t, err)
		assert.Len(t, md.Steps, 1)
		assert.NotNil(t, md.Steps[0].StepCriteria)
		assert.Equal(t, "Technology", md.Steps[0].StepCriteria.CriteriaItems[0].Value)
		assert.Equal(t, "GotoNextStep", md.Steps[0].IfCriteriaNotMet)
	})
}

func TestSubmit_CreatesWorkitemsForMultipleApproversFromMetadata(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}

	ctx := context.Background()
	user := &models.UserSession{ID: "submitter-1"}
	processDef := models.SObject{
		constants.FieldID: "pd-1",
		"Metadata": `{
			"approvalStep": {
				"name": "Region_Approval",
				"label": "Region Approval",
				"assignedApprover": {
					"approver": [
						{ "name": "kevin.lu@pingcap.com", "type": "user" },
						{ "name": "stephan.debray@pingcap.com", "type": "user" }
					],
					"whenMultipleApprovers": "Unanimous"
				}
			}
		}`,
		"TableEnumOrId": "Opportunity",
	}
	firstNode := models.SObject{
		constants.FieldID: "node-1",
		"DeveloperName":   "Region_Approval",
		"Name":            "Region Approval",
	}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, mock.Anything, user, constants.ColProcessDefinition_ProcessOrder, "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processDef}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, mock.Anything, user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{firstNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, mock.MatchedBy(func(filter string) bool {
		return strings.Contains(filter, "Id == 'kevin.lu@pingcap.com'")
	}), user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, mock.MatchedBy(func(filter string) bool {
		return strings.Contains(filter, "Username == 'kevin.lu@pingcap.com'")
	}), user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "user-kevin"}}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, mock.MatchedBy(func(filter string) bool {
		return strings.Contains(filter, "Id == 'stephan.debray@pingcap.com'")
	}), user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, mock.MatchedBy(func(filter string) bool {
		return strings.Contains(filter, "Username == 'stephan.debray@pingcap.com'")
	}), user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "user-stephan"}}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessAction", mock.Anything, user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Maybe()

	mockP.On("Insert", mock.MatchedBy(isElevatedSecurityContext), constants.TableProcessInstance, mock.Anything, user).
		Return(models.SObject{constants.FieldID: "inst-1"}, nil).Once()
	mockP.On("Insert", mock.MatchedBy(isElevatedSecurityContext), constants.TableProcessInstanceNode, mock.Anything, user).
		Return(models.SObject{constants.FieldID: "pin-1"}, nil).Once()
	mockP.On("Insert", mock.MatchedBy(isElevatedSecurityContext), constants.TableProcessInstanceWorkitem, mock.Anything, user).
		Return(models.SObject{constants.FieldID: "wi-1"}, nil).Once()
	mockP.On("Insert", mock.MatchedBy(isElevatedSecurityContext), constants.TableProcessInstanceWorkitem, mock.Anything, user).
		Return(models.SObject{constants.FieldID: "wi-2"}, nil).Once()

	result, err := svc.Submit(ctx, SubmitRequest{
		ObjectAPIName: "Opportunity",
		RecordID:      "opp-1",
		Comments:      "Please review",
	}, user)

	assert.NoError(t, err)
	assert.Equal(t, []string{"user-kevin", "user-stephan"}, result["ActorIds"])
	assert.Equal(t, []string{"wi-1", "wi-2"}, result["NewWorkitemIds"])
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestSubmit_RebuildsMissingProcessNodesFromMetadata(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}

	ctx := context.Background()
	user := &models.UserSession{ID: "submitter-1"}
	processDef := models.SObject{
		constants.FieldID: "pd-1",
		"Metadata": `{
			"approvalStep": {
				"name": "Region_Approval",
				"label": "Region Approval",
				"description": "Recovered from metadata",
				"assignedApprover": {
					"approver": [
						{ "name": "kevin.lu@pingcap.com", "type": "user" },
						{ "name": "stephan.debray@pingcap.com", "type": "user" }
					],
					"whenMultipleApprovers": "Unanimous"
				},
				"rejectBehavior": "RejectRequest"
			}
		}`,
		"TableEnumOrId": "Opportunity",
	}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, mock.Anything, user, constants.ColProcessDefinition_ProcessOrder, "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processDef}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, mock.Anything, user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, mock.MatchedBy(func(filter string) bool {
		return strings.Contains(filter, "Id == 'kevin.lu@pingcap.com'")
	}), user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, mock.MatchedBy(func(filter string) bool {
		return strings.Contains(filter, "Username == 'kevin.lu@pingcap.com'")
	}), user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "user-kevin"}}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, mock.MatchedBy(func(filter string) bool {
		return strings.Contains(filter, "Id == 'stephan.debray@pingcap.com'")
	}), user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, mock.MatchedBy(func(filter string) bool {
		return strings.Contains(filter, "Username == 'stephan.debray@pingcap.com'")
	}), user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "user-stephan"}}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessAction, mock.Anything, user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Maybe()

	mockP.On("Insert", mock.MatchedBy(isElevatedSecurityContext), constants.TableProcessNode, mock.MatchedBy(func(data models.SObject) bool {
		return data["ProcessDefinitionId"] == "pd-1" &&
			data["DeveloperName"] == "Region_Approval" &&
			data["Name"] == "Region Approval" &&
			data["StepOrder"] == 1 &&
			data["RejectBehavior"] == "FinalRejection"
	}), user).
		Return(models.SObject{
			constants.FieldID: "node-1",
			"DeveloperName":   "Region_Approval",
			"Name":            "Region Approval",
		}, nil).Once()
	mockP.On("Insert", mock.MatchedBy(isElevatedSecurityContext), constants.TableProcessInstance, mock.Anything, user).
		Return(models.SObject{constants.FieldID: "inst-1"}, nil).Once()
	mockP.On("Insert", mock.MatchedBy(isElevatedSecurityContext), constants.TableProcessInstanceNode, mock.MatchedBy(func(data models.SObject) bool {
		return data["ProcessNodeId"] == "node-1" && data["ProcessNodeName"] == "Region Approval"
	}), user).
		Return(models.SObject{constants.FieldID: "pin-1"}, nil).Once()
	mockP.On("Insert", mock.MatchedBy(isElevatedSecurityContext), constants.TableProcessInstanceWorkitem, mock.Anything, user).
		Return(models.SObject{constants.FieldID: "wi-1"}, nil).Once()
	mockP.On("Insert", mock.MatchedBy(isElevatedSecurityContext), constants.TableProcessInstanceWorkitem, mock.Anything, user).
		Return(models.SObject{constants.FieldID: "wi-2"}, nil).Once()

	result, err := svc.Submit(ctx, SubmitRequest{
		ObjectAPIName: "Opportunity",
		RecordID:      "opp-1",
	}, user)

	assert.NoError(t, err)
	assert.Equal(t, []string{"user-kevin", "user-stephan"}, result["ActorIds"])
	assert.Equal(t, []string{"wi-1", "wi-2"}, result["NewWorkitemIds"])
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestSubmit_FailsClosedWhenProcessNodeQueryErrors(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}

	ctx := context.Background()
	user := &models.UserSession{ID: "submitter-1"}
	processDef := models.SObject{
		constants.FieldID: "pd-1",
		"Metadata": `{
			"approvalStep": {
				"name": "Region_Approval",
				"label": "Region Approval"
			}
		}`,
		"TableEnumOrId": "Opportunity",
	}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, mock.Anything, user, constants.ColProcessDefinition_ProcessOrder, "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processDef}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, mock.Anything, user, "StepOrder", "ASC", 100).
		Return((*models.QueryResult)(nil), errors.New("transient process node query failure")).Once()

	result, err := svc.Submit(ctx, SubmitRequest{
		ObjectAPIName: "Opportunity",
		RecordID:      "opp-1",
	}, user)

	assert.Nil(t, result)
	assert.Error(t, err)
	assert.Contains(t, err.Error(), "failed to load approval process steps")
	mockQ.AssertExpectations(t)
	mockP.AssertNotCalled(t, "Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

func TestApprove_FirstResponseDeletesSiblingWorkitems(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}
	processDef := models.SObject{
		constants.FieldID: "pd-1",
		"TableEnumOrId":   "Opportunity",
		"Metadata": `{
			"approvalStep": {
				"name": "Region_Approval",
				"label": "Region Approval",
				"assignedApprover": {
					"approver": [
						{ "name": "kevin.lu@pingcap.com", "type": "user" },
						{ "name": "stephan.debray@pingcap.com", "type": "user" }
					],
					"whenMultipleApprovers": "FirstResponse"
				}
			}
		}`,
	}
	workItem := models.SObject{constants.FieldID: "wi-1", "ProcessInstanceId": "inst-1", "ActorId": "user-1"}
	instance := models.SObject{constants.FieldID: "inst-1", "ProcessDefinitionId": "pd-1", "TargetObjectId": "opp-1"}
	nodeInstance := models.SObject{constants.FieldID: "pin-1", "ProcessNodeId": "node-1", "ProcessNodeName": "Region Approval"}
	processNode := models.SObject{constants.FieldID: "node-1", "DeveloperName": "Region_Approval", "Name": "Region Approval"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{workItem}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{instance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processDef}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceNode, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{nodeInstance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, "Id == 'node-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 100).
		Return(&models.QueryResult{TotalSize: 2, Records: []models.SObject{
			{constants.FieldID: "wi-1", "ActorId": "user-1"},
			{constants.FieldID: "wi-2", "ActorId": "user-2"},
		}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", mock.Anything, user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessAction", mock.Anything, user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Maybe()

	mockP.On("Delete", mock.Anything, constants.TableProcessInstanceWorkitem, "wi-1", user).Return(nil).Once()
	mockP.On("Delete", mock.Anything, constants.TableProcessInstanceWorkitem, "wi-2", user).Return(nil).Once()
	mockP.On("Update", mock.Anything, constants.TableProcessInstanceNode, "pin-1", mock.MatchedBy(func(data models.SObject) bool {
		return data["NodeStatus"] == constants.ApprovalStatusApproved && data["LastActorId"] == "user-1"
	}), user).Return(nil).Once()
	mockP.On("Update", mock.Anything, constants.TableProcessInstance, "inst-1", mock.MatchedBy(func(data models.SObject) bool {
		return data["Status"] == constants.ApprovalStatusApproved
	}), user).Return(nil).Once()

	result, err := svc.Approve(ctx, "wi-1", "Looks good", user)

	assert.NoError(t, err)
	assert.Equal(t, constants.ApprovalStatusApproved, result["InstanceStatus"])
	assert.Equal(t, "wi-1", result[constants.FieldID])
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestApprove_UnanimousKeepsStepPendingUntilFinalApproval(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}
	processDef := models.SObject{
		constants.FieldID: "pd-1",
		"Metadata": `{
			"approvalStep": {
				"name": "Region_Approval",
				"label": "Region Approval",
				"assignedApprover": {
					"approver": [
						{ "name": "kevin.lu@pingcap.com", "type": "user" },
						{ "name": "stephan.debray@pingcap.com", "type": "user" }
					],
					"whenMultipleApprovers": "Unanimous"
				}
			}
		}`,
	}
	workItem := models.SObject{constants.FieldID: "wi-1", "ProcessInstanceId": "inst-1", "ActorId": "user-1"}
	instance := models.SObject{constants.FieldID: "inst-1", "ProcessDefinitionId": "pd-1", "TargetObjectId": "opp-1"}
	nodeInstance := models.SObject{constants.FieldID: "pin-1", "ProcessNodeId": "node-1", "ProcessNodeName": "Region Approval"}
	processNode := models.SObject{constants.FieldID: "node-1", "DeveloperName": "Region_Approval", "Name": "Region Approval"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{workItem}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{instance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processDef}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceNode, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{nodeInstance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, "Id == 'node-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 100).
		Return(&models.QueryResult{TotalSize: 2, Records: []models.SObject{
			{constants.FieldID: "wi-1", "ActorId": "user-1"},
			{constants.FieldID: "wi-2", "ActorId": "user-2"},
		}}, nil).Once()

	mockP.On("Delete", mock.Anything, constants.TableProcessInstanceWorkitem, "wi-1", user).Return(nil).Once()

	result, err := svc.Approve(ctx, "wi-1", "Looks good", user)

	assert.NoError(t, err)
	assert.Equal(t, constants.ApprovalStatusPending, result["InstanceStatus"])
	assert.Equal(t, []string{"user-2"}, result["ActorIds"])
	assert.Equal(t, []string{"wi-2"}, result["NewWorkitemIds"])
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestReject_DeletesSiblingWorkitemsEvenInUnanimousMode(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}
	processDef := models.SObject{
		constants.FieldID: "pd-1",
		"TableEnumOrId":   "Opportunity",
		"Metadata": `{
			"approvalStep": {
				"name": "Region_Approval",
				"label": "Region Approval",
				"assignedApprover": {
					"approver": [
						{ "name": "kevin.lu@pingcap.com", "type": "user" },
						{ "name": "stephan.debray@pingcap.com", "type": "user" }
					],
					"whenMultipleApprovers": "Unanimous"
				},
				"rejectBehavior": "RejectRequest"
			}
		}`,
	}
	workItem := models.SObject{constants.FieldID: "wi-1", "ProcessInstanceId": "inst-1", "ActorId": "user-1"}
	instance := models.SObject{constants.FieldID: "inst-1", "ProcessDefinitionId": "pd-1", "TargetObjectId": "opp-1"}
	nodeInstance := models.SObject{constants.FieldID: "pin-1", "ProcessNodeId": "node-1", "ProcessNodeName": "Region Approval"}
	processNode := models.SObject{constants.FieldID: "node-1", "DeveloperName": "Region_Approval", "Name": "Region Approval", "RejectBehavior": "FinalRejection"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{workItem}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{instance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processDef}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceNode, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{nodeInstance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, "Id == 'node-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 100).
		Return(&models.QueryResult{TotalSize: 2, Records: []models.SObject{
			{constants.FieldID: "wi-1", "ActorId": "user-1"},
			{constants.FieldID: "wi-2", "ActorId": "user-2"},
		}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", mock.Anything, user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessAction", mock.Anything, user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Maybe()

	mockP.On("Delete", mock.Anything, constants.TableProcessInstanceWorkitem, "wi-1", user).Return(nil).Once()
	mockP.On("Delete", mock.Anything, constants.TableProcessInstanceWorkitem, "wi-2", user).Return(nil).Once()
	mockP.On("Update", mock.Anything, constants.TableProcessInstanceNode, "pin-1", mock.MatchedBy(func(data models.SObject) bool {
		return data["NodeStatus"] == constants.ApprovalStatusRejected
	}), user).Return(nil).Once()
	mockP.On("Update", mock.Anything, constants.TableProcessInstance, "inst-1", mock.MatchedBy(func(data models.SObject) bool {
		return data["Status"] == constants.ApprovalStatusRejected
	}), user).Return(nil).Once()

	result, err := svc.Reject(ctx, "wi-1", "Rejected", user)

	assert.NoError(t, err)
	assert.Equal(t, constants.ApprovalStatusRejected, result["InstanceStatus"])
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestApprove_ExecutesStepAndFinalApprovalActions(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}
	processDef := models.SObject{
		constants.FieldID: "pd-1",
		"TableEnumOrId":   "Opportunity",
	}
	workItem := models.SObject{constants.FieldID: "wi-1", "ProcessInstanceId": "inst-1", "ActorId": "user-1"}
	instance := models.SObject{constants.FieldID: "inst-1", "ProcessDefinitionId": "pd-1", "TargetObjectId": "opp-1"}
	nodeInstance := models.SObject{constants.FieldID: "pin-1", "ProcessNodeId": "node-1", "ProcessNodeName": "Region Approval"}
	processNode := models.SObject{constants.FieldID: "node-1", "DeveloperName": "Region_Approval", "Name": "Region Approval"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{workItem}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{instance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processDef}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceNode, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{nodeInstance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, "Id == 'node-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 100).
		Return(&models.QueryResult{TotalSize: 2, Records: []models.SObject{
			{constants.FieldID: "wi-1", "ActorId": "user-1"},
			{constants.FieldID: "wi-2", "ActorId": "user-2"},
		}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", mock.Anything, user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessAction", mock.MatchedBy(func(filter string) bool {
		return strings.Contains(filter, "ActionEvent == 'StepApproval'")
	}), user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{
			"Name":          "Step Approval Website",
			"ProcessNodeId": "node-1",
			"ActionType":    "FieldUpdate",
			"FieldApiName":  "Website",
			"FieldValue":    "https://step-approval.example.com",
		}}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessAction", mock.MatchedBy(func(filter string) bool {
		return strings.Contains(filter, "ActionEvent == 'FinalApproval'")
	}), user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{
			"Name":         "Final Approval Description",
			"ActionType":   "FieldUpdate",
			"FieldApiName": "Description",
			"FieldValue":   "FINAL_APPROVED",
		}}}, nil).Once()

	mockP.On("Delete", mock.Anything, constants.TableProcessInstanceWorkitem, "wi-1", user).Return(nil).Once()
	mockP.On("Delete", mock.Anything, constants.TableProcessInstanceWorkitem, "wi-2", user).Return(nil).Once()
	mockP.On("Update", mock.Anything, constants.TableProcessInstanceNode, "pin-1", mock.MatchedBy(func(data models.SObject) bool {
		return data["NodeStatus"] == constants.ApprovalStatusApproved
	}), user).Return(nil).Once()
	mockP.On("Update", mock.Anything, constants.TableProcessInstance, "inst-1", mock.MatchedBy(func(data models.SObject) bool {
		return data["Status"] == constants.ApprovalStatusApproved
	}), user).Return(nil).Once()
	mockP.On("Update", mock.Anything, "Opportunity", "opp-1", mock.MatchedBy(func(data models.SObject) bool {
		return data["Website"] == "https://step-approval.example.com"
	}), user).Return(nil).Once()
	mockP.On("Update", mock.Anything, "Opportunity", "opp-1", mock.MatchedBy(func(data models.SObject) bool {
		return data["Description"] == "FINAL_APPROVED"
	}), user).Return(nil).Once()

	result, err := svc.Approve(ctx, "wi-1", "Looks good", user)

	assert.NoError(t, err)
	assert.Equal(t, constants.ApprovalStatusApproved, result["InstanceStatus"])
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestReject_ExecutesStepAndFinalRejectionActions(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}
	processDef := models.SObject{
		constants.FieldID: "pd-1",
		"TableEnumOrId":   "Opportunity",
	}
	workItem := models.SObject{constants.FieldID: "wi-1", "ProcessInstanceId": "inst-1", "ActorId": "user-1"}
	instance := models.SObject{constants.FieldID: "inst-1", "ProcessDefinitionId": "pd-1", "TargetObjectId": "opp-1"}
	nodeInstance := models.SObject{constants.FieldID: "pin-1", "ProcessNodeId": "node-1", "ProcessNodeName": "Region Approval"}
	processNode := models.SObject{constants.FieldID: "node-1", "DeveloperName": "Region_Approval", "Name": "Region Approval", "RejectBehavior": "FinalRejection"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{workItem}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{instance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processDef}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceNode, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{nodeInstance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, "Id == 'node-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 100).
		Return(&models.QueryResult{TotalSize: 2, Records: []models.SObject{
			{constants.FieldID: "wi-1", "ActorId": "user-1"},
			{constants.FieldID: "wi-2", "ActorId": "user-2"},
		}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", mock.Anything, user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessAction", mock.MatchedBy(func(filter string) bool {
		return strings.Contains(filter, "ActionEvent == 'StepRejection'")
	}), user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{
			"Name":          "Step Rejection Website",
			"ProcessNodeId": "node-1",
			"ActionType":    "FieldUpdate",
			"FieldApiName":  "Website",
			"FieldValue":    "https://step-rejection.example.com",
		}}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessAction", mock.MatchedBy(func(filter string) bool {
		return strings.Contains(filter, "ActionEvent == 'FinalRejection'")
	}), user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{
			"Name":         "Final Rejection Description",
			"ActionType":   "FieldUpdate",
			"FieldApiName": "Description",
			"FieldValue":   "FINAL_REJECTED",
		}}}, nil).Once()

	mockP.On("Delete", mock.Anything, constants.TableProcessInstanceWorkitem, "wi-1", user).Return(nil).Once()
	mockP.On("Delete", mock.Anything, constants.TableProcessInstanceWorkitem, "wi-2", user).Return(nil).Once()
	mockP.On("Update", mock.Anything, constants.TableProcessInstanceNode, "pin-1", mock.MatchedBy(func(data models.SObject) bool {
		return data["NodeStatus"] == constants.ApprovalStatusRejected
	}), user).Return(nil).Once()
	mockP.On("Update", mock.Anything, constants.TableProcessInstance, "inst-1", mock.MatchedBy(func(data models.SObject) bool {
		return data["Status"] == constants.ApprovalStatusRejected
	}), user).Return(nil).Once()
	mockP.On("Update", mock.Anything, "Opportunity", "opp-1", mock.MatchedBy(func(data models.SObject) bool {
		return data["Website"] == "https://step-rejection.example.com"
	}), user).Return(nil).Once()
	mockP.On("Update", mock.Anything, "Opportunity", "opp-1", mock.MatchedBy(func(data models.SObject) bool {
		return data["Description"] == "FINAL_REJECTED"
	}), user).Return(nil).Once()

	result, err := svc.Reject(ctx, "wi-1", "Rejected", user)

	assert.NoError(t, err)
	assert.Equal(t, constants.ApprovalStatusRejected, result["InstanceStatus"])
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestSubmit_HierarchyUsesSubmitterManager(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}

	ctx := context.Background()
	user := &models.UserSession{ID: "submitter-1"}
	processDef := models.SObject{
		constants.FieldID: "pd-1",
		"Metadata": `{
			"nextAutomatedApprover": {
				"userHierarchyField": "ManagerId",
				"useApproverFieldOfRecordOwner": false
			},
			"approvalStep": {
				"name": "Manager_Review",
				"label": "Manager Review",
				"assignedApprover": {
					"approver": { "type": "hierarchy" }
				}
			}
		}`,
		"TableEnumOrId": "Opportunity",
	}
	firstNode := models.SObject{
		constants.FieldID: "node-1",
		"DeveloperName":   "Manager_Review",
		"Name":            "Manager Review",
	}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, mock.Anything, user, constants.ColProcessDefinition_ProcessOrder, "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processDef}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, mock.Anything, user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{firstNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Id == 'submitter-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{
			constants.FieldID: "submitter-1",
			"ManagerId":       "manager-1",
		}}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessAction, mock.Anything, user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Maybe()

	mockP.On("Insert", mock.Anything, constants.TableProcessInstance, mock.Anything, user).
		Return(models.SObject{constants.FieldID: "inst-1"}, nil).Once()
	mockP.On("Insert", mock.Anything, constants.TableProcessInstanceNode, mock.Anything, user).
		Return(models.SObject{constants.FieldID: "pin-1"}, nil).Once()
	mockP.On("Insert", mock.Anything, constants.TableProcessInstanceWorkitem, mock.MatchedBy(func(data models.SObject) bool {
		return data["ActorId"] == "manager-1"
	}), user).
		Return(models.SObject{constants.FieldID: "wi-1"}, nil).Once()

	result, err := svc.Submit(ctx, SubmitRequest{
		ObjectAPIName: "Opportunity",
		RecordID:      "opp-1",
	}, user)

	assert.NoError(t, err)
	assert.Equal(t, []string{"manager-1"}, result["ActorIds"])
	assert.Equal(t, []string{"wi-1"}, result["NewWorkitemIds"])
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestSubmit_HierarchyFailsClosedWhenManagerMissing(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}

	ctx := context.Background()
	user := &models.UserSession{ID: "submitter-1"}
	processDef := models.SObject{
		constants.FieldID: "pd-1",
		"Metadata": `{
			"nextAutomatedApprover": {
				"userHierarchyField": "ManagerId",
				"useApproverFieldOfRecordOwner": false
			},
			"approvalStep": {
				"name": "Manager_Review",
				"label": "Manager Review",
				"assignedApprover": {
					"approver": { "type": "hierarchy" }
				}
			}
		}`,
		"TableEnumOrId": "Opportunity",
	}
	firstNode := models.SObject{
		constants.FieldID: "node-1",
		"DeveloperName":   "Manager_Review",
		"Name":            "Manager Review",
	}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, mock.Anything, user, constants.ColProcessDefinition_ProcessOrder, "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processDef}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, mock.Anything, user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{firstNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Id == 'submitter-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "submitter-1"}}}, nil).Once()

	result, err := svc.Submit(ctx, SubmitRequest{
		ObjectAPIName: "Opportunity",
		RecordID:      "opp-1",
	}, user)

	assert.Nil(t, result)
	assert.Error(t, err)
	assert.Contains(t, err.Error(), "ManagerId")
	mockQ.AssertExpectations(t)
	mockP.AssertNotCalled(t, "Insert", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

func TestApprove_HierarchyStepAdvancesFromPreviousActor(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}

	ctx := context.Background()
	user := &models.UserSession{ID: "manager-user"}
	processDef := models.SObject{
		constants.FieldID: "pd-1",
		"TableEnumOrId":   "Opportunity",
		"Metadata": `{
			"nextAutomatedApprover": {
				"userHierarchyField": "ManagerId",
				"useApproverFieldOfRecordOwner": false
			},
			"approvalStep": [
				{
					"name": "Manager_Review",
					"label": "Manager Review",
					"assignedApprover": {
						"approver": { "name": "manager@test.com", "type": "user" }
					}
				},
				{
					"name": "Director_Review",
					"label": "Director Review",
					"assignedApprover": {
						"approver": { "type": "hierarchy" }
					}
				}
			]
		}`,
	}
	workItem := models.SObject{constants.FieldID: "wi-1", "ProcessInstanceId": "inst-1", "ActorId": "manager-user"}
	instance := models.SObject{constants.FieldID: "inst-1", "ProcessDefinitionId": "pd-1", "TargetObjectId": "opp-1", "SubmittedById": "submitter-1"}
	nodeInstance := models.SObject{constants.FieldID: "pin-1", "ProcessNodeId": "node-1", "ProcessNodeName": "Manager Review"}
	currentNode := models.SObject{constants.FieldID: "node-1", "DeveloperName": "Manager_Review", "Name": "Manager Review", "StepOrder": 1}
	nextNode := models.SObject{constants.FieldID: "node-2", "DeveloperName": "Director_Review", "Name": "Director Review", "StepOrder": 2}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{workItem}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{instance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processDef}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceNode, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{nodeInstance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, "Id == 'node-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{currentNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{workItem}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, mock.Anything, user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 2, Records: []models.SObject{currentNode, nextNode}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Id == 'manager-user'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{
			constants.FieldID: "manager-user",
			"ManagerId":       "director-user",
		}}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessAction, mock.Anything, user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Maybe()

	mockP.On("Delete", mock.Anything, constants.TableProcessInstanceWorkitem, "wi-1", user).Return(nil).Once()
	mockP.On("Update", mock.Anything, constants.TableProcessInstanceNode, "pin-1", mock.MatchedBy(func(data models.SObject) bool {
		return data["NodeStatus"] == constants.ApprovalStatusApproved && data["LastActorId"] == "manager-user"
	}), user).Return(nil).Once()
	mockP.On("Insert", mock.Anything, constants.TableProcessInstanceNode, mock.MatchedBy(func(data models.SObject) bool {
		return data["ProcessNodeId"] == "node-2" && data["NodeStatus"] == constants.ApprovalStatusPending
	}), user).Return(models.SObject{constants.FieldID: "pin-2"}, nil).Once()
	mockP.On("Insert", mock.Anything, constants.TableProcessInstanceWorkitem, mock.MatchedBy(func(data models.SObject) bool {
		return data["ActorId"] == "director-user"
	}), user).Return(models.SObject{constants.FieldID: "wi-2"}, nil).Once()

	result, err := svc.Approve(ctx, "wi-1", "Looks good", user)

	assert.NoError(t, err)
	assert.Equal(t, constants.ApprovalStatusPending, result["InstanceStatus"])
	assert.Equal(t, []string{"director-user"}, result["ActorIds"])
	assert.Equal(t, []string{"wi-2"}, result["NewWorkitemIds"])
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestReject_BackToPreviousRecomputesHierarchyFromHistoricalActor(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}

	ctx := context.Background()
	user := &models.UserSession{ID: "final-approver"}
	processDef := models.SObject{
		constants.FieldID: "pd-1",
		"TableEnumOrId":   "Opportunity",
		"Metadata": `{
			"nextAutomatedApprover": {
				"userHierarchyField": "ManagerId",
				"useApproverFieldOfRecordOwner": false
			},
			"approvalStep": [
				{
					"name": "Manager_Review",
					"label": "Manager Review",
					"assignedApprover": {
						"approver": { "name": "manager@test.com", "type": "user" }
					}
				},
				{
					"name": "Director_Review",
					"label": "Director Review",
					"assignedApprover": {
						"approver": { "type": "hierarchy" }
					}
				},
				{
					"name": "Finance_Review",
					"label": "Finance Review",
					"assignedApprover": {
						"approver": { "name": "finance@test.com", "type": "user" }
					},
					"rejectBehavior": { "type": "BackToPrevious" }
				}
			]
		}`,
	}
	workItem := models.SObject{constants.FieldID: "wi-3", "ProcessInstanceId": "inst-1", "ActorId": "final-approver"}
	instance := models.SObject{constants.FieldID: "inst-1", "ProcessDefinitionId": "pd-1", "TargetObjectId": "opp-1", "SubmittedById": "submitter-1"}
	nodeInstance := models.SObject{constants.FieldID: "pin-3", "ProcessNodeId": "node-3", "ProcessNodeName": "Finance Review"}
	node1 := models.SObject{constants.FieldID: "node-1", "DeveloperName": "Manager_Review", "Name": "Manager Review", "StepOrder": 1}
	node2 := models.SObject{constants.FieldID: "node-2", "DeveloperName": "Director_Review", "Name": "Director Review", "StepOrder": 2}
	node3 := models.SObject{constants.FieldID: "node-3", "DeveloperName": "Finance_Review", "Name": "Finance Review", "StepOrder": 3, "RejectBehavior": "BackToPrevious"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{workItem}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{instance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{processDef}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceNode, mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{nodeInstance}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, "Id == 'node-3'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{node3}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user, "", "", 100).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{workItem}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, mock.Anything, user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 3, Records: []models.SObject{node1, node2, node3}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceNode, "ProcessInstanceId == 'inst-1'", user, "", "", 100).
		Return(&models.QueryResult{TotalSize: 2, Records: []models.SObject{
			{constants.FieldID: "pin-1", "ProcessNodeId": "node-1", "LastActorId": "manager-user"},
			{constants.FieldID: "pin-3", "ProcessNodeId": "node-3"},
		}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Id == 'manager-user'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{
			constants.FieldID: "manager-user",
			"ManagerId":       "director-user",
		}}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessAction, mock.Anything, user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Maybe()

	mockP.On("Delete", mock.Anything, constants.TableProcessInstanceWorkitem, "wi-3", user).Return(nil).Once()
	mockP.On("Update", mock.Anything, constants.TableProcessInstanceNode, "pin-3", mock.MatchedBy(func(data models.SObject) bool {
		return data["NodeStatus"] == constants.ApprovalStatusRejected && data["LastActorId"] == "final-approver"
	}), user).Return(nil).Once()
	mockP.On("Insert", mock.Anything, constants.TableProcessInstanceNode, mock.MatchedBy(func(data models.SObject) bool {
		return data["ProcessNodeId"] == "node-2" && data["NodeStatus"] == constants.ApprovalStatusPending
	}), user).Return(models.SObject{constants.FieldID: "pin-2b"}, nil).Once()
	mockP.On("Insert", mock.Anything, constants.TableProcessInstanceWorkitem, mock.MatchedBy(func(data models.SObject) bool {
		return data["ActorId"] == "director-user"
	}), user).Return(models.SObject{constants.FieldID: "wi-2b"}, nil).Once()

	result, err := svc.Reject(ctx, "wi-3", "Needs changes", user)

	assert.NoError(t, err)
	assert.Equal(t, constants.ApprovalStatusPending, result["InstanceStatus"])
	assert.Equal(t, []string{"director-user"}, result["ActorIds"])
	assert.Equal(t, []string{"wi-2b"}, result["NewWorkitemIds"])
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

// TestParseApprovalProcessMetadata_RealProdSamples feeds PII-scrubbed metadata
// payloads captured from prod TiDB through the parser to guard against drift
// from the real Salesforce-imported shape (entryCriteria alias, ifCriteriaNotMet,
// criteriaItems as dict vs list, booleanFilter).
func TestParseApprovalProcessMetadata_RealProdSamples(t *testing.T) {
	// Sample A: Contract approval — single-dict criteriaItems, ifCriteriaNotMet=ApproveRecord.
	const sampleA = `{"fullName":"Contract.Non_standard_Contract_Approval","active":"false","allowRecall":"false","allowedSubmitters":{"type":"owner"},"approvalPageFields":{"field":["ContractNumber","Owner"]},"approvalStep":[{"allowDelegate":"false","assignedApprover":{"approver":{"name":"approver@example.com","type":"user"},"whenMultipleApprovers":"FirstResponse"},"entryCriteria":{"criteriaItems":{"field":"Contract.Non_standard_type__c","operation":"notEqual"}},"ifCriteriaNotMet":"ApproveRecord","label":"DM Approve","name":"DM_Approve"},{"allowDelegate":"false","assignedApprover":{"approver":{"name":"approver@example.com","type":"user"},"whenMultipleApprovers":"Unanimous"},"label":"Finance/Legal Approve","name":"FinanceLegal","rejectBehavior":{"type":"RejectRequest"}},{"allowDelegate":"false","assignedApprover":{"approver":{"name":"approver@example.com","type":"user"},"whenMultipleApprovers":"FirstResponse"},"label":"DM Review","name":"DM_Review","rejectBehavior":{"type":"RejectRequest"}},{"allowDelegate":"false","assignedApprover":{"approver":{"name":"approver@example.com","type":"user"},"whenMultipleApprovers":"FirstResponse"},"label":"DM Leader Approve","name":"DM_Leader_Approve","rejectBehavior":{"type":"RejectRequest"}}],"label":"Non-standard Contract Approval","processOrder":"1"}`

	// Sample B: Quote approval — list criteriaItems, booleanFilter "1 OR 2", ifCriteriaNotMet=ApproveRecord.
	const sampleB = `{"fullName":"Quote.QuoteApprovenew","active":"false","allowRecall":"true","allowedSubmitters":[{"type":"allInternalUsers"},{"type":"owner"}],"approvalStep":[{"allowDelegate":"false","assignedApprover":{"approver":{"type":"userHierarchyField"}},"entryCriteria":{"booleanFilter":"1 OR 2","criteriaItems":[{"field":"Quote.Autoapproval__c","operation":"equals","value":"False"},{"field":"Quote.ApprovedPrice__c","operation":"equals","value":"True"}]},"ifCriteriaNotMet":"ApproveRecord","label":"Sales Leader Approve","name":"SalesLeaderApprove"},{"allowDelegate":"false","assignedApprover":{"approver":{"name":"approver@example.com","type":"user"},"whenMultipleApprovers":"FirstResponse"},"label":"CBM Leader Approve","name":"CBMLeaderApprove","rejectBehavior":{"type":"RejectRequest"}},{"allowDelegate":"false","assignedApprover":{"approver":{"name":"approver@example.com","type":"user"},"whenMultipleApprovers":"FirstResponse"},"label":"CSG Leader Approve","name":"Jason_Approve","rejectBehavior":{"type":"RejectRequest"}}],"label":"Quote Approve","processOrder":"1"}`

	t.Run("contract_approval_single_dict_criteria", func(t *testing.T) {
		md, err := ParseApprovalProcessMetadata(sampleA)
		assert.NoError(t, err)
		assert.Len(t, md.Steps, 4)

		// Step 0: DM_Approve has entryCriteria (single dict) + ifCriteriaNotMet=ApproveRecord.
		assert.Equal(t, "DM_Approve", md.Steps[0].Name)
		assert.NotNil(t, md.Steps[0].StepCriteria, "entryCriteria alias must populate StepCriteria")
		assert.Len(t, md.Steps[0].StepCriteria.CriteriaItems, 1)
		assert.Equal(t, "Contract.Non_standard_type__c", md.Steps[0].StepCriteria.CriteriaItems[0].Field)
		assert.Equal(t, "notEqual", md.Steps[0].StepCriteria.CriteriaItems[0].Operation)
		assert.Equal(t, "ApproveRecord", md.Steps[0].IfCriteriaNotMet)

		// Remaining steps: no criteria, no ifCriteriaNotMet → empty string.
		for i := 1; i < 4; i++ {
			assert.Nil(t, md.Steps[i].StepCriteria)
			assert.Equal(t, "", md.Steps[i].IfCriteriaNotMet)
		}
	})

	t.Run("quote_approval_list_criteria_with_boolean_filter", func(t *testing.T) {
		md, err := ParseApprovalProcessMetadata(sampleB)
		assert.NoError(t, err)
		assert.Len(t, md.Steps, 3)

		sc := md.Steps[0].StepCriteria
		assert.NotNil(t, sc)
		assert.Equal(t, "1 OR 2", sc.BooleanFilter)
		assert.Len(t, sc.CriteriaItems, 2)
		assert.Equal(t, "Quote.Autoapproval__c", sc.CriteriaItems[0].Field)
		assert.Equal(t, "equals", sc.CriteriaItems[0].Operation)
		assert.Equal(t, "False", sc.CriteriaItems[0].Value)
		assert.Equal(t, "ApproveRecord", md.Steps[0].IfCriteriaNotMet)
	})
}
