package automation

import (
	"context"
	"encoding/xml"
	"errors"
	"fmt"
	"strings"
	"testing"

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

// xmlUnmarshal is a test alias so we can assert xml parsing works
var xmlUnmarshal = xml.Unmarshal

// ---------------------------------------------------------------------------
// MockApprovalPersistence — implements PersistenceProvider
// ---------------------------------------------------------------------------

type MockApprovalPersistence struct {
	mock.Mock
}

var _ PersistenceProvider = (*MockApprovalPersistence)(nil)

func isElevatedSecurityContext(ctx context.Context) bool {
	return constants.GetSecurityContext(ctx) >= constants.ElevatedContext
}

func (m *MockApprovalPersistence) Insert(ctx context.Context, objectName string, data models.SObject, user *models.UserSession) (models.SObject, error) {
	args := m.Called(ctx, objectName, data, user)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(models.SObject), args.Error(1)
}

func (m *MockApprovalPersistence) Update(ctx context.Context, objectName string, id string, data models.SObject, user *models.UserSession) error {
	args := m.Called(ctx, objectName, id, data, user)
	return args.Error(0)
}

func (m *MockApprovalPersistence) Delete(ctx context.Context, objectName string, id string, user *models.UserSession) error {
	args := m.Called(ctx, objectName, id, user)
	return args.Error(0)
}

func (m *MockApprovalPersistence) GetRaw(ctx context.Context, objectName, id string) (models.SObject, error) {
	args := m.Called(ctx, objectName, id)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(models.SObject), args.Error(1)
}

func (m *MockApprovalPersistence) GetLock(ctx context.Context, tableName, id string) (models.SObject, error) {
	args := m.Called(ctx, tableName, id)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(models.SObject), args.Error(1)
}

func (m *MockApprovalPersistence) RunInTransaction(ctx context.Context, fn func(context.Context) error) error {
	return fn(ctx)
}
func (m *MockApprovalPersistence) DetachTx(ctx context.Context) context.Context { return ctx }

// ---------------------------------------------------------------------------
// MockApprovalQuery — implements ports.QueryService
// ---------------------------------------------------------------------------

type MockApprovalQuery struct {
	mock.Mock
}

func (m *MockApprovalQuery) QueryWithFilter(ctx context.Context, objectName string, filterExpr string, user *models.UserSession, sortField string, sortDir string, limit int) (*models.QueryResult, error) {
	args := m.Called(ctx, objectName, filterExpr, user, sortField, sortDir, limit)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*models.QueryResult), args.Error(1)
}

// Implement remaining QueryService methods as no-ops
func (m *MockApprovalQuery) Query(ctx context.Context, req api.QueryRequest, user *models.UserSession) (*models.QueryResult, error) {
	return nil, nil
}
func (m *MockApprovalQuery) RunAnalytics(ctx context.Context, query models.AnalyticsQuery, user *models.UserSession) (interface{}, error) {
	return nil, nil
}
func (m *MockApprovalQuery) SearchSingleObject(ctx context.Context, objectName string, term string, searchFields []string, returnFields []string, limit int, offset int, orderBy string, orderDir string, filterExpr string, user *models.UserSession) ([]models.SObject, error) {
	return nil, nil
}
func (m *MockApprovalQuery) GlobalSearch(ctx context.Context, term string, user *models.UserSession) ([]models.SearchResult, error) {
	return nil, nil
}
func (m *MockApprovalQuery) Calculate(ctx context.Context, objectName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	return nil, nil
}

func (m *MockApprovalQuery) ExecuteRawSQL(ctx context.Context, sql string, params []interface{}, user *models.UserSession) ([]models.SObject, error) {
	return nil, nil
}

// ---------------------------------------------------------------------------
// NewApprovalService
// ---------------------------------------------------------------------------

func TestNewApprovalService(t *testing.T) {
	svc := NewApprovalService(nil, nil, nil, nil, nil, nil, nil, nil)
	assert.NotNil(t, svc)
}

// orderContractIdSchema stubs an Order schema where ContractId → Contract. Used
// by FieldUpdate tests that traverse the ContractId lookup.
func orderContractIdSchema() *stubSchemaLookup {
	return &stubSchemaLookup{metadata: map[string]*models.ObjectMetadata{
		"Order": {
			APIName: "Order",
			FieldsMap: map[string]*models.FieldMetadata{
				"ContractId": {APIName: "ContractId", ReferenceTo: []string{"Contract"}},
			},
		},
	}}
}

func TestExecuteProcessActions_FieldUpdateSupportsRelatedLookupPath(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{
		query:       mockQ,
		persistence: mockP,
		schema:      orderContractIdSchema(),
		logger:      logging.Default,
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessAction, mock.Anything, user, "Order", "ASC", 100).
		Return(&models.QueryResult{
			TotalSize: 1,
			Records: []models.SObject{
				{
					"Name":         "ActivateLinkedContract",
					"ActionType":   "FieldUpdate",
					"ActionEvent":  "FinalApproval",
					"FieldApiName": "ContractId.Status",
					"FieldValue":   "Literal: Effective",
				},
			},
		}, nil).Once()
	mockP.On("GetRaw", ctx, "Order", "ord-1").Return(models.SObject{
		"ContractId": "con-1",
	}, nil).Once()
	mockP.On("Update", ctx, "Contract", "con-1", models.SObject{
		"Status": "Effective",
	}, user).Return(nil).Once()

	actions, err := svc.fetchProcessActions(ctx, "pd-1", "", "FinalApproval", user)
	assert.NoError(t, err)
	assert.Len(t, actions, 1)
	mutErr := svc.executeProcessActionMutations(ctx, actions, "Order", "ord-1", user)
	assert.NoError(t, mutErr)

	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestExecuteProcessActions_FieldUpdate_MissingLookupValue(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{
		query:       mockQ,
		persistence: mockP,
		schema:      orderContractIdSchema(),
		logger:      logging.Default,
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessAction, mock.Anything, user, "Order", "ASC", 100).
		Return(&models.QueryResult{
			TotalSize: 1,
			Records: []models.SObject{
				{
					"Name":         "ActivateLinkedContract",
					"ActionType":   "FieldUpdate",
					"ActionEvent":  "FinalApproval",
					"FieldApiName": "ContractId.Status",
					"FieldValue":   "Literal: Effective",
				},
			},
		}, nil).Once()
	// ContractId is empty → resolveFieldUpdateTarget returns an error and no Update is called.
	mockP.On("GetRaw", ctx, "Order", "ord-1").Return(models.SObject{
		"ContractId": "",
	}, nil).Once()

	actions, fetchErr := svc.fetchProcessActions(ctx, "pd-1", "", "FinalApproval", user)
	assert.NoError(t, fetchErr)
	mutErr := svc.executeProcessActionMutations(ctx, actions, "Order", "ord-1", user)

	assert.Error(t, mutErr)
	actionErr, ok := mutErr.(*ApprovalActionError)
	assert.True(t, ok, "expected *ApprovalActionError, got %T", mutErr)
	assert.Equal(t, "ActivateLinkedContract", actionErr.ActionName)
	assert.Equal(t, "FieldUpdate", actionErr.ActionType)
	assert.Contains(t, actionErr.Reason, "empty")
	mockP.AssertNotCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

// stubSchemaLookup returns a fixed ObjectMetadata from GetSchema.
type stubSchemaLookup struct {
	metadata map[string]*models.ObjectMetadata
}

func (s *stubSchemaLookup) GetSchema(ctx context.Context, apiName string) *models.ObjectMetadata {
	if s.metadata == nil {
		return nil
	}
	return s.metadata[apiName]
}

func TestExecuteProcessActions_FieldUpdate_UnresolvableTargetObject(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	// Schema declares CustomLookup__c but with empty referenceTo, forcing the
	// resolver to fail (rather than silently trimming `__c`).
	schema := &stubSchemaLookup{metadata: map[string]*models.ObjectMetadata{
		"Order": {
			APIName: "Order",
			FieldsMap: map[string]*models.FieldMetadata{
				"CustomLookup__c": {APIName: "CustomLookup__c", ReferenceTo: nil},
			},
		},
	}}
	svc := &ApprovalService{
		query:       mockQ,
		persistence: mockP,
		schema:      schema,
		logger:      logging.Default,
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessAction, mock.Anything, user, "Order", "ASC", 100).
		Return(&models.QueryResult{
			TotalSize: 1,
			Records: []models.SObject{
				{
					"Name":         "UpdateCustomTarget",
					"ActionType":   "FieldUpdate",
					"ActionEvent":  "FinalApproval",
					"FieldApiName": "CustomLookup__c.Status",
					"FieldValue":   "Literal: X",
				},
			},
		}, nil).Once()
	mockP.On("GetRaw", ctx, "Order", "ord-1").Return(models.SObject{
		"CustomLookup__c": "some-id",
	}, nil).Once()

	actions, fetchErr := svc.fetchProcessActions(ctx, "pd-1", "", "FinalApproval", user)
	assert.NoError(t, fetchErr)
	mutErr := svc.executeProcessActionMutations(ctx, actions, "Order", "ord-1", user)

	assert.Error(t, mutErr)
	actionErr, ok := mutErr.(*ApprovalActionError)
	assert.True(t, ok, "expected *ApprovalActionError, got %T", mutErr)
	assert.Contains(t, actionErr.Reason, "referenceTo")
	mockP.AssertNotCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestExecuteProcessActions_FieldUpdate_UpdateReturnsError(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{
		query:       mockQ,
		persistence: mockP,
		schema:      orderContractIdSchema(),
		logger:      logging.Default,
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessAction, mock.Anything, user, "Order", "ASC", 100).
		Return(&models.QueryResult{
			TotalSize: 1,
			Records: []models.SObject{
				{
					"Name":         "ActivateLinkedContract",
					"ActionType":   "FieldUpdate",
					"ActionEvent":  "FinalApproval",
					"FieldApiName": "ContractId.Status",
					"FieldValue":   "Literal: Effective",
				},
			},
		}, nil).Once()
	mockP.On("GetRaw", ctx, "Order", "ord-1").Return(models.SObject{
		"ContractId": "con-1",
	}, nil).Once()
	mockP.On("Update", ctx, "Contract", "con-1", models.SObject{
		"Status": "Effective",
	}, user).Return(errors.New("database is locked")).Once()

	actions, fetchErr := svc.fetchProcessActions(ctx, "pd-1", "", "FinalApproval", user)
	assert.NoError(t, fetchErr)
	mutErr := svc.executeProcessActionMutations(ctx, actions, "Order", "ord-1", user)

	assert.Error(t, mutErr)
	actionErr, ok := mutErr.(*ApprovalActionError)
	assert.True(t, ok, "expected *ApprovalActionError, got %T", mutErr)
	assert.Equal(t, "Contract", actionErr.TargetObject)
	assert.Equal(t, "con-1", actionErr.TargetID)
	assert.Equal(t, "Status", actionErr.Field)
	assert.Contains(t, actionErr.Reason, "database is locked")
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

// TestExecuteProcessActionMutations_PolymorphicLookup_RejectedLoudly verifies
// that a FieldUpdate on a polymorphic lookup (referenceTo len > 1) fails
// loudly rather than silently picking one branch. This is the split-brain
// avoidance guard: without it, the approval state transition could commit
// while the FieldUpdate silently targeted the wrong object.
func TestExecuteProcessActionMutations_PolymorphicLookup_RejectedLoudly(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	schema := &stubSchemaLookup{metadata: map[string]*models.ObjectMetadata{
		"Task": {
			APIName: "Task",
			FieldsMap: map[string]*models.FieldMetadata{
				"WhatId": {APIName: "WhatId", ReferenceTo: []string{"Account", "Contact"}},
			},
		},
	}}
	svc := &ApprovalService{
		query:       mockQ,
		persistence: mockP,
		schema:      schema,
		logger:      logging.Default,
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}

	mockP.On("GetRaw", ctx, "Task", "task-1").Return(models.SObject{
		"WhatId": "some-id",
	}, nil).Once()

	actions := []models.SObject{
		{
			"Name":         "UpdatePolymorphicTarget",
			"ActionType":   "FieldUpdate",
			"FieldApiName": "WhatId.Status",
			"FieldValue":   "Literal: X",
		},
	}
	mutErr := svc.executeProcessActionMutations(ctx, actions, "Task", "task-1", user)

	assert.Error(t, mutErr)
	actionErr, ok := mutErr.(*ApprovalActionError)
	assert.True(t, ok, "expected *ApprovalActionError, got %T", mutErr)
	assert.Contains(t, actionErr.Reason, "referenceTo")
	mockP.AssertNotCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

// TestExecuteProcessActionNotifications_UnknownTypeCollectsFailure verifies
// that Alert/unknown action failures are returned as ProcessActionFailures
// rather than aborting a tx — they are best-effort by design.
func TestExecuteProcessActionNotifications_UnknownTypeCollectsFailure(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{
		query:       mockQ,
		persistence: mockP,
		schema:      orderContractIdSchema(),
		logger:      logging.Default,
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}

	actions := []models.SObject{
		{
			"Name":       "WeirdAction",
			"ActionType": "FlowTrigger",
		},
	}
	failures := svc.executeProcessActionNotifications(ctx, actions, "Order", "ord-1", nil, user)

	assert.Len(t, failures, 1)
	assert.Equal(t, "WeirdAction", failures[0].ActionName)
	assert.Equal(t, "FlowTrigger", failures[0].ActionType)
	assert.Contains(t, failures[0].Reason, "unknown ActionType")
}

// TestMixedActions_MutationsAndNotifications verifies the full split: a batch
// containing both FieldUpdate and Alert actions routes FieldUpdates through
// executeProcessActionMutations (inside tx) and Alerts through
// executeProcessActionNotifications (best-effort post-commit). This is the
// integration-level test for the transactional split introduced in this PR.
func TestMixedActions_MutationsAndNotifications(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{
		query:       mockQ,
		persistence: mockP,
		schema:      orderContractIdSchema(),
		logger:      logging.Default,
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}

	mixedActions := []models.SObject{
		{
			"Name":         "ActivateLinkedContract",
			"ActionType":   "FieldUpdate",
			"FieldApiName": "ContractId.Status",
			"FieldValue":   "Literal: Effective",
		},
		{
			"Name":       "NotifyApprover",
			"ActionType": "Alert",
		},
		{
			"Name":         "SetOrderStatus",
			"ActionType":   "FieldUpdate",
			"FieldApiName": "Status",
			"FieldValue":   "Literal: Activated",
		},
	}

	// Mutations: FieldUpdate actions execute, Alert is skipped.
	mockP.On("GetRaw", ctx, "Order", "ord-1").Return(models.SObject{
		"ContractId": "con-1",
	}, nil).Once()
	mockP.On("Update", ctx, "Contract", "con-1", models.SObject{
		"Status": "Effective",
	}, user).Return(nil).Once()
	mockP.On("Update", ctx, "Order", "ord-1", models.SObject{
		"Status": "Activated",
	}, user).Return(nil).Once()

	mutErr := svc.executeProcessActionMutations(ctx, mixedActions, "Order", "ord-1", user)
	assert.NoError(t, mutErr, "mutations should succeed for both FieldUpdate actions")

	// Notifications: Alert is processed, FieldUpdate entries are skipped.
	// Alert handler will call GetRaw to load the target record for the email.
	mockP.On("GetRaw", ctx, "Order", "ord-1").Return(models.SObject{
		constants.FieldID: "ord-1",
		"ContractId":      "con-1",
	}, nil).Once()

	failures := svc.executeProcessActionNotifications(ctx, mixedActions, "Order", "ord-1", nil, user)
	// The key assertion: FieldUpdate names should NOT appear in notification failures.
	for _, f := range failures {
		assert.NotEqual(t, "FieldUpdate", f.ActionType,
			"FieldUpdate actions must not be processed by notification handler")
	}

	mockP.AssertExpectations(t)
}

// TestExecuteProcessActionMutations_SecondFieldUpdateFails verifies that when
// the second FieldUpdate in a batch fails, the error is returned with the
// correct action details — the first Update has already executed within the
// same transaction, so the caller can roll back the entire batch.
func TestExecuteProcessActionMutations_SecondFieldUpdateFails(t *testing.T) {
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{
		persistence: mockP,
		schema:      orderContractIdSchema(),
		logger:      logging.Default,
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}

	actions := []models.SObject{
		{
			"Name":         "SetOrderStatus",
			"ActionType":   "FieldUpdate",
			"FieldApiName": "Status",
			"FieldValue":   "Literal: Activated",
		},
		{
			"Name":         "ActivateLinkedContract",
			"ActionType":   "FieldUpdate",
			"FieldApiName": "ContractId.Status",
			"FieldValue":   "Literal: Effective",
		},
	}

	// First FieldUpdate (direct field) succeeds.
	mockP.On("Update", ctx, "Order", "ord-1", models.SObject{
		"Status": "Activated",
	}, user).Return(nil).Once()

	// Second FieldUpdate traverses ContractId lookup.
	mockP.On("GetRaw", ctx, "Order", "ord-1").Return(models.SObject{
		"ContractId": "con-1",
	}, nil).Once()
	mockP.On("Update", ctx, "Contract", "con-1", models.SObject{
		"Status": "Effective",
	}, user).Return(errors.New("database locked")).Once()

	mutErr := svc.executeProcessActionMutations(ctx, actions, "Order", "ord-1", user)
	assert.Error(t, mutErr)
	actionErr, ok := mutErr.(*ApprovalActionError)
	assert.True(t, ok, "expected *ApprovalActionError, got %T", mutErr)
	assert.Equal(t, "ActivateLinkedContract", actionErr.ActionName)
	assert.Contains(t, actionErr.Reason, "database locked")

	mockP.AssertExpectations(t)
}

// TestExecuteProcessActionMutations_SkipsNonFieldUpdateActions verifies the
// split: Alert entries in a ProcessAction slice are ignored by the mutation
// helper so they survive to be run best-effort after tx commit.
func TestExecuteProcessActionMutations_SkipsNonFieldUpdateActions(t *testing.T) {
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{
		persistence: mockP,
		logger:      logging.Default,
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}

	actions := []models.SObject{
		{
			"Name":       "NotifyApprover",
			"ActionType": "Alert",
		},
	}
	err := svc.executeProcessActionMutations(ctx, actions, "Order", "ord-1", user)

	assert.NoError(t, err)
	mockP.AssertNotCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
	mockP.AssertNotCalled(t, "GetRaw", mock.Anything, mock.Anything, mock.Anything)
}

// ---------------------------------------------------------------------------
// ApprovalProcessXML parsing
// ---------------------------------------------------------------------------

func TestApprovalProcessXML_Unmarshal(t *testing.T) {
	xmlData := `<ApprovalProcess>
		<label>Test Process</label>
		<active>true</active>
		<description>A test approval process</description>
		<approvalStep>
			<name>Step1</name>
			<label>First Step</label>
			<assignedApprover>
				<approver>
					<name>admin@test.com</name>
					<type>user</type>
				</approver>
			</assignedApprover>
		</approvalStep>
	</ApprovalProcess>`

	var process ApprovalProcessXML
	err := xmlUnmarshal([]byte(xmlData), &process)
	assert.NoError(t, err)
	assert.Equal(t, "Test Process", process.Label)
	assert.True(t, process.Active)
	assert.Equal(t, "A test approval process", process.Description)
	assert.Len(t, process.Steps, 1)
	assert.Equal(t, "Step1", process.Steps[0].Name)
	assert.Equal(t, "First Step", process.Steps[0].Label)
	assert.NotNil(t, process.Steps[0].AssignedApprover)
	assert.Len(t, process.Steps[0].AssignedApprover.Approvers, 1)
	assert.Equal(t, "admin@test.com", process.Steps[0].AssignedApprover.Approvers[0].Name)
}

// ---------------------------------------------------------------------------
// Submit — validation
// ---------------------------------------------------------------------------

func TestSubmit_MissingObjectAPIName(t *testing.T) {
	svc := NewApprovalService(nil, nil, nil, nil, nil, nil, nil, nil)
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	req := SubmitRequest{
		ObjectAPIName: "", // Missing
		RecordID:      "opp-123",
	}

	result, err := svc.Submit(ctx, req, user)

	assert.Error(t, err)
	assert.Nil(t, result)
	assert.Contains(t, err.Error(), "ObjectApiName")
}

func TestCanUserSubmitForProcess_LegacyWithoutAllowedSubmittersAllows(t *testing.T) {
	svc := &ApprovalService{}

	canSubmit, err := svc.canUserSubmitForProcess(context.Background(), models.SObject{
		"Metadata": `{"approvalStep":{"name":"Step_1","label":"Step 1"}}`,
	}, "Lead", "lead-1", &models.UserSession{ID: "user-1"})

	assert.NoError(t, err)
	assert.True(t, canSubmit)
}

func TestCanUserSubmitForProcess_RecordCreator(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ}
	user := &models.UserSession{ID: "user-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Lead", "Id == 'lead-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldCreatedByID: "user-1"}}}, nil).Once()

	canSubmit, err := svc.canUserSubmitForProcess(context.Background(), models.SObject{
		"Metadata": `{"allowedSubmitters":{"type":"recordCreator"}}`,
	}, "Lead", "lead-1", user)

	assert.NoError(t, err)
	assert.True(t, canSubmit)
	mockQ.AssertExpectations(t)
}

func TestCanUserSubmitForProcess_RecordOwner(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ}
	user := &models.UserSession{ID: "user-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Lead", "Id == 'lead-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldOwnerID: "user-1"}}}, nil).Once()

	canSubmit, err := svc.canUserSubmitForProcess(context.Background(), models.SObject{
		"Metadata": `{"allowedSubmitters":{"type":"recordOwner"}}`,
	}, "Lead", "lead-1", user)

	assert.NoError(t, err)
	assert.True(t, canSubmit)
	mockQ.AssertExpectations(t)
}

func TestCanUserSubmitForProcess_SpecificUserByUsername(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ}
	user := &models.UserSession{ID: "user-1"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Id == 'user-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{"Username": "admin@test.com"}}}, nil).Once()

	canSubmit, err := svc.canUserSubmitForProcess(context.Background(), models.SObject{
		"Metadata": `{"allowedSubmitters":{"type":"user","submitter":"admin@test.com"}}`,
	}, "Lead", "lead-1", user)

	assert.NoError(t, err)
	assert.True(t, canSubmit)
	mockQ.AssertExpectations(t)
}

func TestCanUserSubmitForProcess_SpecificRole(t *testing.T) {
	roleID := "role-emea"
	svc := &ApprovalService{}
	user := &models.UserSession{ID: "user-1", RoleID: &roleID}

	canSubmit, err := svc.canUserSubmitForProcess(context.Background(), models.SObject{
		"Metadata": `{"allowedSubmitters":{"type":"role","submitter":"role-emea"}}`,
	}, "Lead", "lead-1", user)

	assert.NoError(t, err)
	assert.True(t, canSubmit)
}

func TestCanUserSubmitForProcess_RoleAndSubordinates(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ}
	roleID := "role-child"
	user := &models.UserSession{ID: "user-1", RoleID: &roleID}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUserRole, "Id == 'role-child'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldUserRole_ParentRoleID: "role-parent"}}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUserRole, "Id == 'role-parent'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{}}}, nil).Once()

	canSubmit, err := svc.canUserSubmitForProcess(context.Background(), models.SObject{
		"Metadata": `{"allowedSubmitters":{"type":"roleAndSubordinates","submitter":"role-parent"}}`,
	}, "Lead", "lead-1", user)

	assert.NoError(t, err)
	assert.True(t, canSubmit)
	mockQ.AssertExpectations(t)
}

func TestCanUserSubmitForProcess_AllInternalUsers(t *testing.T) {
	svc := &ApprovalService{}

	canSubmit, err := svc.canUserSubmitForProcess(context.Background(), models.SObject{
		"Metadata": `{"allowedSubmitters":{"type":"allInternalUsers"}}`,
	}, "Lead", "lead-1", &models.UserSession{ID: "user-1"})

	assert.NoError(t, err)
	assert.True(t, canSubmit)
}

func TestSubmit_ReturnsPermissionDeniedWhenCurrentUserIsNotAllowedSubmitter(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ}
	ctx := context.Background()
	user := &models.UserSession{ID: "user-1"}
	processDef := models.SObject{
		constants.FieldID: "pd-1",
		"Metadata":        `{"allowedSubmitters":{"type":"recordOwner"}}`,
		"TableEnumOrId":   "Lead",
	}

	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, "Lead", "Id == 'lead-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldOwnerID: "someone-else"}}}, nil).Once()

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

	assert.Nil(t, result)
	assert.EqualError(t, err, ErrSubmitPermissionDenied.Error())
	mockQ.AssertExpectations(t)
}

func TestResolveApproverIDsForNode_LegacyCommaSeparatedApprovers_DedupesAndSkipsInvalid(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ}

	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}
	node := models.SObject{
		"ApproverType":   "User",
		"ApproverUserId": "user-direct, approver@example.com, user-direct, missing@example.com, , approver@example.com",
	}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Id == 'user-direct'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "user-direct"}}}, nil).Twice()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Id == 'approver@example.com'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Twice()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Username == 'approver@example.com'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "user-email"}}}, nil).Twice()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Id == 'missing@example.com'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Username == 'missing@example.com'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()

	resolved := svc.resolveApproverIDsForNode(ctx, nil, node, "", user)

	assert.Equal(t, []string{"user-direct", "user-email"}, resolved)
	mockQ.AssertExpectations(t)
}

func TestResolveApproverIDsForNode_LegacySingleApproverBackwardCompatible(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ}

	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}
	node := models.SObject{
		"ApproverType":   "User",
		"ApproverUserId": "solo@example.com",
	}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Id == 'solo@example.com'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Username == 'solo@example.com'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "user-solo"}}}, nil).Once()

	resolved := svc.resolveApproverIDsForNode(ctx, nil, node, "", user)

	assert.Equal(t, []string{"user-solo"}, resolved)
	mockQ.AssertExpectations(t)
}

// ---------------------------------------------------------------------------
// processAction (Approve/Reject) — work item not found
// ---------------------------------------------------------------------------

func TestProcessAction_WorkItemNotFound_QueryError(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{
		query: mockQ,
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem,
		mock.Anything, user, "", "", 1).
		Return((*models.QueryResult)(nil), errors.New("query failed"))

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

	assert.Error(t, err)
	assert.Nil(t, result)
	mockQ.AssertExpectations(t)
}

func TestProcessAction_WorkItemNotFound_EmptyResult(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{
		query: mockQ,
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem,
		mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil)

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

	assert.Error(t, err)
	assert.Nil(t, result)
	mockQ.AssertExpectations(t)
}

func TestProcessAction_WorkItem_MissingProcessInstanceId(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{
		query: mockQ,
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	// Work item without ProcessInstanceId
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem,
		mock.Anything, user, "", "", 1).
		Return(&models.QueryResult{
			TotalSize: 1,
			Records: []models.SObject{
				{constants.FieldID: "wi-001", "ActorId": "u-1"}, // no ProcessInstanceId, but ActorId matches user
			},
		}, nil)

	result, err := svc.Approve(ctx, "wi-001", "", user)

	assert.Error(t, err)
	assert.Nil(t, result)
	mockQ.AssertExpectations(t)
}

// ---------------------------------------------------------------------------
// Domain errors
// ---------------------------------------------------------------------------

func TestDomainErrors(t *testing.T) {
	assert.NotNil(t, ErrNoActiveApprovalProcess)
	assert.NotNil(t, ErrRecordAlreadyPending)
	assert.NotNil(t, ErrWorkItemNotFound)
	assert.NotNil(t, ErrNotAuthorized)
	assert.NotNil(t, ErrSubmitPermissionDenied)
}

func TestClone_RemapsStepActionsAndKeepsProcessLevelActions(t *testing.T) {
	mockP := new(MockApprovalPersistence)
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{
		persistence: mockP,
		query:       mockQ,
		permissions: &permissions.PermissionService{},
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "u-1", IsSystemAdmin: true}
	originalProcess := models.SObject{
		constants.FieldID:  "proc-src",
		"Name":             "Original Process",
		"DeveloperName":    "Original_Process",
		"TableEnumOrId":    "Account",
		"State":            "Active",
		"IsActive":         true,
		"CreatedById_Name": "System Administrator",
	}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "DeveloperName == 'Cloned_Process'", user, "", "", 1).
		Return(&models.QueryResult{Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "Id == 'proc-src'", user, "", "", 1).
		Return(&models.QueryResult{Records: []models.SObject{originalProcess}}, nil).Once()
	mockP.On("Insert", mock.Anything, constants.TableProcessDefinition, mock.MatchedBy(func(data models.SObject) bool {
		return data["Name"] == "Cloned Label" &&
			data["DeveloperName"] == "Cloned_Process" &&
			data["State"] == "Inactive" &&
			data["IsActive"] == false &&
			data["TableEnumOrId"] == "Account" &&
			data["CreatedById_Name"] == nil
	}), user).Return(models.SObject{
		constants.FieldID: "proc-clone",
		"Name":            "Cloned Label",
		"DeveloperName":   "Cloned_Process",
	}, nil).Once()

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, "ProcessDefinitionId == 'proc-src'", user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{Records: []models.SObject{
			{constants.FieldID: "node-1", "Name": "Manager Review", "ProcessDefinitionId": "proc-src", "ProcessDefinitionId_Name": "Original Process", "StepOrder": 1},
			{constants.FieldID: "node-2", "Name": "VP Approval", "ProcessDefinitionId": "proc-src", "ProcessDefinitionId_Name": "Original Process", "StepOrder": 2},
		}}, nil).Once()
	mockP.On("Insert", mock.Anything, constants.TableProcessNode, mock.MatchedBy(func(data models.SObject) bool {
		return data["Name"] == "Manager Review" &&
			data["ProcessDefinitionId"] == "proc-clone" &&
			data["ProcessDefinitionId_Name"] == nil
	}), user).Return(models.SObject{constants.FieldID: "node-101"}, nil).Once()
	mockP.On("Insert", mock.Anything, constants.TableProcessNode, mock.MatchedBy(func(data models.SObject) bool {
		return data["Name"] == "VP Approval" &&
			data["ProcessDefinitionId"] == "proc-clone" &&
			data["ProcessDefinitionId_Name"] == nil
	}), user).Return(models.SObject{constants.FieldID: "node-102"}, nil).Once()

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessAction, "ProcessDefinitionId == 'proc-src'", user, "Name", "ASC", 100).
		Return(&models.QueryResult{Records: []models.SObject{
			{constants.FieldID: "action-1", "Name": "Notify Submitter", "ProcessDefinitionId": "proc-src", "ActionType": "Alert", "ActionEvent": "Submit"},
			{constants.FieldID: "action-2", "Name": "Lock Record", "ProcessDefinitionId": "proc-src", "ProcessNodeId": "node-2", "ProcessNodeId_Name": "VP Approval", "ActionType": "RecordLock", "ActionEvent": "StepApproval"},
		}}, nil).Once()
	mockP.On("Insert", mock.Anything, constants.TableProcessAction, mock.MatchedBy(func(data models.SObject) bool {
		_, hasProcessNode := data["ProcessNodeId"]
		return data["Name"] == "Notify Submitter" &&
			data["ProcessDefinitionId"] == "proc-clone" &&
			(!hasProcessNode || data["ProcessNodeId"] == nil) &&
			data["ProcessNodeId_Name"] == nil
	}), user).Return(models.SObject{constants.FieldID: "action-101"}, nil).Once()
	mockP.On("Insert", mock.Anything, constants.TableProcessAction, mock.MatchedBy(func(data models.SObject) bool {
		return data["Name"] == "Lock Record" &&
			data["ProcessDefinitionId"] == "proc-clone" &&
			data["ProcessNodeId"] == "node-102" &&
			data["ProcessNodeId_Name"] == nil
	}), user).Return(models.SObject{constants.FieldID: "action-102"}, nil).Once()

	cloned, err := svc.Clone(ctx, "proc-src", "Cloned_Process", "Cloned Label", user)

	assert.NoError(t, err)
	assert.NotNil(t, cloned)
	assert.Equal(t, "proc-clone", (*cloned)[constants.FieldID])
	mockP.AssertExpectations(t)
	mockQ.AssertExpectations(t)
}

func TestClone_RejectsDuplicateDeveloperName(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{
		query:       mockQ,
		permissions: &permissions.PermissionService{},
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "u-1", IsSystemAdmin: true}
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "DeveloperName == 'Existing_Process'", user, "", "", 1).
		Return(&models.QueryResult{Records: []models.SObject{{constants.FieldID: "proc-existing"}}}, nil).Once()

	cloned, err := svc.Clone(ctx, "proc-src", "Existing_Process", "Cloned Label", user)

	assert.Nil(t, cloned)
	assert.ErrorContains(t, err, "newDeveloperName")
	mockQ.AssertExpectations(t)
}

// ---------------------------------------------------------------------------
// matchEntryCriteria — RecordTypeId Name vs ID resolution (issue #532)
// ---------------------------------------------------------------------------

func TestMatchEntryCriteria_RecordTypeIdEqualsName_ResolvesAndMatches(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	// Account record stores RecordTypeId as an ID
	mockQ.On("QueryWithFilter", mock.Anything, "Account", "Id == 'acc-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "acc-1", "RecordTypeId": "rt-001"},
		}}, nil).Once()
	// Service should resolve rt-001 -> Name "Partner"
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableRecordType, "Id == 'rt-001'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldSysRecordType_ID: "rt-001", constants.FieldSysRecordType_Name: "Partner"},
		}}, nil).Once()

	matched, err := svc.matchEntryCriteria(ctx, "RecordTypeId equals Partner", "Account", "acc-1", user)
	assert.NoError(t, err)
	assert.True(t, matched, "criterion using RecordType Name should match after resolving ID -> Name")
	mockQ.AssertExpectations(t)
}

func TestMatchEntryCriteria_RecordTypeIdEqualsID_DirectMatch(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Account", "Id == 'acc-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "acc-1", "RecordTypeId": "rt-001"},
		}}, nil).Once()

	// Direct ID compare should succeed without any RecordType lookup
	matched, err := svc.matchEntryCriteria(ctx, "RecordTypeId equals rt-001", "Account", "acc-1", user)
	assert.NoError(t, err)
	assert.True(t, matched)
	mockQ.AssertExpectations(t)
}

func TestMatchEntryCriteria_RecordTypeIdEqualsName_NonMatchingNameFails(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Account", "Id == 'acc-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "acc-1", "RecordTypeId": "rt-002"},
		}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableRecordType, "Id == 'rt-002'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldSysRecordType_ID: "rt-002", constants.FieldSysRecordType_Name: "Customer"},
		}}, nil).Once()

	matched, err := svc.matchEntryCriteria(ctx, "RecordTypeId equals Partner", "Account", "acc-1", user)
	assert.NoError(t, err)
	assert.False(t, matched, "RecordType Name 'Customer' should not match criterion 'Partner'")
	mockQ.AssertExpectations(t)
}

func TestMatchEntryCriteria_RecordTypeFieldFromJSON_ResolvesViaRecordTypeIdColumn(t *testing.T) {
	// Mirrors real-world data: JSON entry criteria field "Contract.RecordType"
	// (which parseJSONEntryCriteria strips to "RecordType"). Record column is
	// still "RecordTypeId".
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Contract", "Id == 'ctr-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "ctr-1", "RecordTypeId": "rt-cust-ref"},
		}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableRecordType, "Id == 'rt-cust-ref'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldSysRecordType_ID: "rt-cust-ref", constants.FieldSysRecordType_Name: "Customer Reference Agreement"},
		}}, nil).Once()

	jsonCriteria := `{"criteriaItems":[{"field":"Contract.RecordType","operation":"equals","value":"Customer Reference Agreement,TiDB Cloud Services Agreement"}]}`
	matched, err := svc.matchEntryCriteria(ctx, jsonCriteria, "Contract", "ctr-1", user)
	assert.NoError(t, err)
	assert.True(t, matched, "JSON criterion 'Contract.RecordType equals <Name>' should match after resolving record's RecordTypeId -> Name")
	mockQ.AssertExpectations(t)
}

func TestMatchEntryCriteria_NonRecordTypeIdField_BehavesNormally(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Account", "Id == 'acc-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "acc-1", "Region__c": "EMEA", "RecordTypeId": "rt-001"},
		}}, nil).Once()

	matched, err := svc.matchEntryCriteria(ctx, "Region__c equals EMEA", "Account", "acc-1", user)
	assert.NoError(t, err)
	assert.True(t, matched)
	mockQ.AssertExpectations(t)
}

func TestMatchEntryCriteria_SimpleObjectPrefixedField_IsNormalized(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Quote", "Id == 'quote-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "quote-1", "Autoapproval__c": "False"},
		}}, nil).Once()

	matched, err := svc.matchEntryCriteria(ctx, "Quote.Autoapproval__c equals False", "Quote", "quote-1", user)
	assert.NoError(t, err)
	assert.True(t, matched, "object-prefixed fields should match after normalization")
	mockQ.AssertExpectations(t)
}

func TestMatchEntryCriteria_JSONBooleanFilter_UsesORInsteadOfImplicitAND(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Account", "Id == 'acc-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "acc-1", "Region": "NA", "Status": "Active"},
		}}, nil).Once()

	jsonCriteria := `{"booleanFilter":"1 OR 2","criteriaItems":[{"field":"Account.Region","operation":"equals","value":"NA"},{"field":"Account.Status","operation":"equals","value":"Inactive"}]}`
	matched, err := svc.matchEntryCriteria(ctx, jsonCriteria, "Account", "acc-1", user)
	assert.NoError(t, err)
	assert.True(t, matched, "booleanFilter should be evaluated for entry criteria instead of forcing criteriaItems through implicit AND")
	mockQ.AssertExpectations(t)
}

func TestMatchEntryCriteria_SameFieldEqualsCriteria_ImplicitlyOrsValues(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Account", "Id == 'acc-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "acc-1", "Region": "NA", "Status": "Active"},
		}}, nil).Once()

	matched, err := svc.matchEntryCriteria(ctx, "Region equals NA AND Region equals EMEA AND Status equals Active", "Account", "acc-1", user)
	assert.NoError(t, err)
	assert.True(t, matched, "same-field equals criteria should behave like OR while distinct fields continue to AND")
	mockQ.AssertExpectations(t)
}

// TestMatchEntryCriteria_CrossObjectRef_ResolvesViaForeignKey covers the
// core of the #1616 fix: a criterion whose field is prefixed with a
// different object (e.g. "Opportunity.Region__c" on a Quote) must be
// resolved by following the standard Salesforce FK column
// "<Parent>Id" on the loaded record.
func TestMatchEntryCriteria_CrossObjectRef_ResolvesViaForeignKey(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Quote", "Id == 'quote-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "quote-1", "OpportunityId": "opp-1"},
		}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "Opportunity", "Id == 'opp-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "opp-1", "Region__c": "Japan"},
		}}, nil).Once()

	jsonCriteria := `{"criteriaItems":[{"field":"Opportunity.Region__c","operation":"equals","value":"Japan"}]}`
	matched, err := svc.matchEntryCriteria(ctx, jsonCriteria, "Quote", "quote-1", user)
	assert.NoError(t, err)
	assert.True(t, matched, "cross-object criterion should resolve via the standard <Parent>Id FK column")
	mockQ.AssertExpectations(t)
}

// TestMatchEntryCriteria_CrossObjectRef_MissingForeignKey_FailsCleanly
// guards against panics / misfires when the record has no FK for the
// referenced parent object. The criterion should evaluate to false
// without an error and without a second DB lookup.
func TestMatchEntryCriteria_CrossObjectRef_MissingForeignKey_FailsCleanly(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Quote", "Id == 'quote-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "quote-1"}, // no OpportunityId
		}}, nil).Once()

	jsonCriteria := `{"criteriaItems":[{"field":"Opportunity.Region__c","operation":"equals","value":"Japan"}]}`
	matched, err := svc.matchEntryCriteria(ctx, jsonCriteria, "Quote", "quote-1", user)
	assert.NoError(t, err)
	assert.False(t, matched, "criterion on a missing parent FK should not match")
	mockQ.AssertExpectations(t)
}

// TestMatchEntryCriteria_NullField_EqualsBlank_Matches verifies the
// nil-safe record-value formatting: a null record field must compare
// equal to the empty-string criterion value (Salesforce "is blank"
// semantics), not the Go literal "<nil>".
func TestMatchEntryCriteria_NullField_EqualsBlank_Matches(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Quote", "Id == 'quote-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "quote-1", "Rebate_Fee_Rate__c": nil},
		}}, nil).Once()

	jsonCriteria := `{"criteriaItems":[{"field":"Quote.Rebate_Fee_Rate__c","operation":"equals","value":""}]}`
	matched, err := svc.matchEntryCriteria(ctx, jsonCriteria, "Quote", "quote-1", user)
	assert.NoError(t, err)
	assert.True(t, matched, "a null field must match `equals \"\"` (Salesforce is-blank)")
	mockQ.AssertExpectations(t)
}

// TestMatchEntryCriteria_NullField_EqualsZero_DoesNotMatch pairs with the
// blank-match case: `equals "0"` against a null field must NOT match.
// Combined with the OR semantics from PR #1770 this guards the
// `(blank OR 0)` shape that staging filters like JP_DBaaS_250110 rely on.
func TestMatchEntryCriteria_NullField_EqualsZero_DoesNotMatch(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Quote", "Id == 'quote-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "quote-1", "Rebate_Fee_Rate__c": nil},
		}}, nil).Once()

	jsonCriteria := `{"criteriaItems":[{"field":"Quote.Rebate_Fee_Rate__c","operation":"equals","value":"0"}]}`
	matched, err := svc.matchEntryCriteria(ctx, jsonCriteria, "Quote", "quote-1", user)
	assert.NoError(t, err)
	assert.False(t, matched, "null field must not match `equals \"0\"`")
	mockQ.AssertExpectations(t)
}

// TestMatchEntryCriteria_StagingJPDBaaSFilter_MatchesJPCloudQuote is the
// end-to-end regression guard for #1616. It reproduces the exact
// ProcessDefinition.EntryFilter shape used by the JP_DBaaS_250110 process
// on staging (7 criteria + booleanFilter combining cross-object Region__c
// with null-equals-blank and null-equals-0 OR branches) against a Quote
// whose linked Opportunity.Region__c is "Japan". If this test ever
// regresses, Quote approval submission in the JP tenant is broken again.
func TestMatchEntryCriteria_StagingJPDBaaSFilter_MatchesJPCloudQuote(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, "Quote", "Id == 'quote-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{
				constants.FieldID:      "quote-1",
				"OpportunityId":        "opp-1",
				"Product_Family__c":    "TiDB Cloud",
				"Rebate_Fee_Rate__c":   nil,
				"Referral_Fee_Rate__c": "0",
				"Partner_Margin__c":    nil,
			},
		}}, nil).Once()
	// Opportunity lookup is invoked once per cross-object criterion that
	// references Opportunity (just criterion #1 in this filter).
	mockQ.On("QueryWithFilter", mock.Anything, "Opportunity", "Id == 'opp-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "opp-1", "Region__c": "Japan"},
		}}, nil).Once()

	// This JSON is trimmed from the live ProcessDefinition row
	// (DeveloperName=JP_DBaaS_250110) on nexuscrm-staging.pingcap.com.
	entryFilter := `{"criteriaItems": [` +
		`{"field": "Opportunity.Region__c", "operation": "equals", "value": "Japan"},` +
		`{"field": "Quote.Product_Family__c", "operation": "contains", "value": "Cloud"},` +
		`{"field": "Quote.Rebate_Fee_Rate__c", "operation": "equals"},` +
		`{"field": "Quote.Referral_Fee_Rate__c", "operation": "lessOrEqual", "value": "5"},` +
		`{"field": "Quote.Rebate_Fee_Rate__c", "operation": "equals", "value": "0"},` +
		`{"field": "Quote.Partner_Margin__c", "operation": "equals"},` +
		`{"field": "Quote.Partner_Margin__c", "operation": "equals", "value": "0"}` +
		`], "booleanFilter": "1 AND 2 AND (3 OR 5) AND 4 AND (6 OR 7)"}`

	matched, err := svc.matchEntryCriteria(ctx, entryFilter, "Quote", "quote-1", user)
	assert.NoError(t, err)
	assert.True(t, matched, "JP_DBaaS_250110 filter must match a JP TiDB Cloud Quote with blank fee rates (#1616 regression guard)")
	mockQ.AssertExpectations(t)
}

func TestClone_RequiresModifyMetadataPermission(t *testing.T) {
	svc := &ApprovalService{
		permissions: &permissions.PermissionService{},
	}

	ctx := context.Background()
	user := &models.UserSession{ID: "u-1"}

	cloned, err := svc.Clone(ctx, "proc-src", "Cloned_Process", "Cloned Label", user)

	assert.Nil(t, cloned)
	assert.ErrorContains(t, err, "permission denied")
}

// ---------------------------------------------------------------------------
// stepCriteriaMatches
// ---------------------------------------------------------------------------

// buildStepCriteriaProcessDef wraps a single-step JSON metadata fragment so
// tests can stuff a StepCriteria into metadataStepForNode's lookup path.
func buildStepCriteriaProcessDef(stepName, stepLabel, criteriaJSON, ifCriteriaNotMet string) models.SObject {
	md := `{"approvalStep":{"name":"` + stepName + `","label":"` + stepLabel + `"`
	if criteriaJSON != "" {
		md += `,"entryCriteria":` + criteriaJSON
	}
	if ifCriteriaNotMet != "" {
		md += `,"ifCriteriaNotMet":"` + ifCriteriaNotMet + `"`
	}
	md += `,"assignedApprover":{"approver":{"name":"u@x.com","type":"user"}}}}`
	return models.SObject{
		constants.FieldID: "pd-1",
		"TableEnumOrId":   "Lead",
		"Metadata":        md,
	}
}

func TestStepCriteriaMatches_NoCriteria_IsMatch(t *testing.T) {
	svc := &ApprovalService{logger: logging.New(context.Background())}
	def := buildStepCriteriaProcessDef("S1", "S1", "", "")
	node := models.SObject{constants.FieldID: "n1", "DeveloperName": "S1", "Name": "S1"}
	assert.True(t, svc.stepCriteriaMatches(context.Background(), def, node, "Lead", "rec-1", &models.UserSession{ID: "u-1"}))
}

func TestStepCriteriaMatches_BooleanFilterTreatedAsMatch(t *testing.T) {
	user := &models.UserSession{ID: "u-1"}
	crit := `{"booleanFilter":"1 OR 2","criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`
	def := buildStepCriteriaProcessDef("S1", "S1", crit, "")
	node := models.SObject{constants.FieldID: "n1", "DeveloperName": "S1", "Name": "S1"}

	t.Run("with matching record", func(t *testing.T) {
		mockQ := new(MockApprovalQuery)
		svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
		mockQ.On("QueryWithFilter", mock.Anything, "Lead", "Id == 'rec-1'", user, "", "", 1).
			Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{"Industry": "Technology"}}}, nil).Once()

		// With the BooleanFilter "1 OR 2", where condition 1 (Industry=Technology) matches the record,
		// and condition 2 refers to a non-existent second criteria (treated as false),
		// "true OR false" should evaluate to true
		assert.True(t, svc.stepCriteriaMatches(context.Background(), def, node, "Lead", "rec-1", user))
		mockQ.AssertExpectations(t)
	})

	t.Run("with non-matching record", func(t *testing.T) {
		mockQ := new(MockApprovalQuery)
		svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
		mockQ.On("QueryWithFilter", mock.Anything, "Lead", "Id == 'rec-1'", user, "", "", 1).
			Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{"Industry": "Healthcare"}}}, nil).Once()

		// With the BooleanFilter "1 OR 2", where condition 1 (Industry=Technology) does not match the record (Industry=Healthcare),
		// and condition 2 refers to a non-existent second criteria (treated as false),
		// "false OR false" should evaluate to false
		assert.False(t, svc.stepCriteriaMatches(context.Background(), def, node, "Lead", "rec-1", user))
		mockQ.AssertExpectations(t)
	})

	t.Run("with query failure falls back to match", func(t *testing.T) {
		mockQ := new(MockApprovalQuery)
		svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
		mockQ.On("QueryWithFilter", mock.Anything, "Lead", "Id == 'rec-1'", user, "", "", 1).
			Return(nil, fmt.Errorf("database error")).Once()

		// When query fails, should fall back to permissive behavior (return true)
		assert.True(t, svc.stepCriteriaMatches(context.Background(), def, node, "Lead", "rec-1", user))
		mockQ.AssertExpectations(t)
	})
}

func TestStepCriteriaMatches_FormulaOnlyTreatedAsMatch(t *testing.T) {
	svc := &ApprovalService{logger: logging.New(context.Background())}
	crit := `{"formula":"AND(1,2)"}`
	def := buildStepCriteriaProcessDef("S1", "S1", crit, "")
	node := models.SObject{constants.FieldID: "n1", "DeveloperName": "S1", "Name": "S1"}
	assert.True(t, svc.stepCriteriaMatches(context.Background(), def, node, "Lead", "rec-1", &models.UserSession{ID: "u-1"}))
}

func TestStepCriteriaMatches_MatchingAndNonMatching(t *testing.T) {
	user := &models.UserSession{ID: "u-1"}
	crit := `{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`
	def := buildStepCriteriaProcessDef("S1", "S1", crit, "")
	node := models.SObject{constants.FieldID: "n1", "DeveloperName": "S1", "Name": "S1"}

	t.Run("matches", func(t *testing.T) {
		mockQ := new(MockApprovalQuery)
		svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
		mockQ.On("QueryWithFilter", mock.Anything, "Lead", "Id == 'rec-1'", user, "", "", 1).
			Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{"Lead.Industry": "Technology", "Industry": "Technology"}}}, nil).Once()
		assert.True(t, svc.stepCriteriaMatches(context.Background(), def, node, "Lead", "rec-1", user))
		mockQ.AssertExpectations(t)
	})
	t.Run("does not match", func(t *testing.T) {
		mockQ := new(MockApprovalQuery)
		svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
		mockQ.On("QueryWithFilter", mock.Anything, "Lead", "Id == 'rec-1'", user, "", "", 1).
			Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{"Lead.Industry": "Finance"}}}, nil).Once()
		assert.False(t, svc.stepCriteriaMatches(context.Background(), def, node, "Lead", "rec-1", user))
		mockQ.AssertExpectations(t)
	})
	t.Run("record fetch fails -> permissive match", func(t *testing.T) {
		mockQ := new(MockApprovalQuery)
		svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
		mockQ.On("QueryWithFilter", mock.Anything, "Lead", "Id == 'rec-1'", user, "", "", 1).
			Return(nil, errors.New("boom")).Once()
		assert.True(t, svc.stepCriteriaMatches(context.Background(), def, node, "Lead", "rec-1", user))
		mockQ.AssertExpectations(t)
	})
}

// ---------------------------------------------------------------------------
// tryAdvanceToNextStep — skip semantics
// ---------------------------------------------------------------------------

// multiStepProcessDef builds a ProcessDefinition whose Metadata lists three
// steps. Each step's criteria/ifCriteriaNotMet are provided by the caller.
func multiStepProcessDef(step2Criteria, step2IfNotMet, step3Criteria, step3IfNotMet string) models.SObject {
	mk := func(name, crit, ifNotMet string) string {
		s := `{"name":"` + name + `","label":"` + name + `"`
		if crit != "" {
			s += `,"entryCriteria":` + crit
		}
		if ifNotMet != "" {
			s += `,"ifCriteriaNotMet":"` + ifNotMet + `"`
		}
		s += `,"assignedApprover":{"approver":{"name":"u@x.com","type":"user"}}}`
		return s
	}
	md := `{"approvalStep":[` +
		mk("S1", "", "") + `,` +
		mk("S2", step2Criteria, step2IfNotMet) + `,` +
		mk("S3", step3Criteria, step3IfNotMet) + `]}`
	return models.SObject{
		constants.FieldID: "pd-1",
		"TableEnumOrId":   "Lead",
		"Metadata":        md,
	}
}

// setupTryAdvanceNodes returns the three-node ProcessNode list that
// tryAdvanceToNextStep will walk. Names match multiStepProcessDef.
func setupTryAdvanceNodes() []models.SObject {
	return []models.SObject{
		{constants.FieldID: "n1", "DeveloperName": "S1", "Name": "S1"},
		{constants.FieldID: "n2", "DeveloperName": "S2", "Name": "S2"},
		{constants.FieldID: "n3", "DeveloperName": "S3", "Name": "S3"},
	}
}

// expectLoadRecord registers a mock for the record fetch performed by
// stepCriteriaMatches when evaluating a step's criteria.
func expectLoadRecord(mockQ *MockApprovalQuery, user *models.UserSession, record models.SObject) {
	normalized := models.SObject{}
	for key, value := range record {
		normalized[key] = value
		if idx := strings.Index(key, "."); idx >= 0 && idx+1 < len(key) {
			normalized[key[idx+1:]] = value
		}
	}
	mockQ.On("QueryWithFilter", mock.Anything, "Lead", "Id == 'lead-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{normalized}}, nil).Once()
}

// expectCreateNextStep mocks the insert path createNextStepRecords hits when a
// step is started. Returns IDs distinct per step so assertions can tell which
// step was started. Also mocks the User resolver for the "u@x.com" approver
// declared by multiStepProcessDef — returning empty so the caller falls back
// to user.ID per tryAdvanceToNextStep's existing safety net.
func expectCreateNextStep(mockQ *MockApprovalQuery, mockP *MockApprovalPersistence, user *models.UserSession, nodeId string) {
	// Approver resolver: User by Id then by Username — both miss, caller falls back.
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Id == 'u@x.com'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Username == 'u@x.com'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	// ProcessInstanceNode insert
	mockP.On("Insert", mock.Anything, constants.TableProcessInstanceNode, mock.MatchedBy(func(data models.SObject) bool {
		return data["ProcessNodeId"] == nodeId
	}), user).Return(models.SObject{constants.FieldID: "pin-" + nodeId}, nil).Once()
	// ProcessInstanceWorkitem insert
	mockP.On("Insert", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user).
		Return(models.SObject{constants.FieldID: "wi-" + nodeId}, nil).Once()
}

func initialSubmitProcessDef(step1IfNotMet string) models.SObject {
	md := `{"approvalStep":[` +
		`{"name":"S1","label":"S1","entryCriteria":{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`
	if step1IfNotMet != "" {
		md += `,"ifCriteriaNotMet":"` + step1IfNotMet + `"`
	}
	md += `,"assignedApprover":{"approver":{"name":"reviewer1@example.com","type":"user"}}},` +
		`{"name":"S2","label":"S2","assignedApprover":{"approver":{"name":"reviewer2@example.com","type":"user"}}}` +
		`]}`
	return models.SObject{
		constants.FieldID: "pd-1",
		"TableEnumOrId":   "Lead",
		"State":           "Active",
		"Metadata":        md,
	}
}

func setupInitialSubmitNodes() []models.SObject {
	return []models.SObject{
		{constants.FieldID: "n1", "DeveloperName": "S1", "Name": "S1"},
		{constants.FieldID: "n2", "DeveloperName": "S2", "Name": "S2"},
	}
}

func expectInitialSubmitBaseQueries(mockQ *MockApprovalQuery, user *models.UserSession, processDef models.SObject, nodes []models.SObject) {
	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, "TargetObjectId == 'lead-1' && Status == 'Pending'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessNode, "ProcessDefinitionId == 'pd-1'", user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: len(nodes), Records: nodes}, nil).Once()
}

func TestSubmit_InitialStepNonMatchGotoNextStep_StartsNextMatchingStep(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "submitter-1"}
	processDef := initialSubmitProcessDef("GotoNextStep")
	nodes := setupInitialSubmitNodes()

	expectInitialSubmitBaseQueries(mockQ, user, processDef, nodes)
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Finance"})
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Id == 'reviewer2@example.com'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableUser, "Username == 'reviewer2@example.com'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "reviewer-2"}}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessAction, "ProcessDefinitionId == 'pd-1' && ActionEvent == 'Submit'", user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()

	mockP.On("Insert", mock.Anything, constants.TableProcessInstance, mock.MatchedBy(func(data models.SObject) bool {
		return data["TargetObjectId"] == "lead-1" && data["Status"] == constants.ApprovalStatusPending
	}), user).Return(models.SObject{constants.FieldID: "pi-1"}, nil).Once()
	mockP.On("Insert", mock.Anything, constants.TableProcessInstanceNode, mock.MatchedBy(func(data models.SObject) bool {
		return data["ProcessInstanceId"] == "pi-1" &&
			data["ProcessNodeId"] == "n2" &&
			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["ProcessInstanceId"] == "pi-1" && data["ActorId"] == "reviewer-2"
	}), user).Return(models.SObject{constants.FieldID: "wi-2"}, nil).Once()

	result, err := svc.Submit(context.Background(), SubmitRequest{
		ObjectAPIName: "Lead",
		RecordID:      "lead-1",
		Comments:      "submit for approval",
	}, user)

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

func TestSubmit_InitialStepNonMatchDefaultApproveRecord_AutoApproves(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "submitter-1"}
	processDef := initialSubmitProcessDef("")
	nodes := setupInitialSubmitNodes()

	expectInitialSubmitBaseQueries(mockQ, user, processDef, nodes)
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Finance"})
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessAction, "ProcessDefinitionId == 'pd-1' && ActionEvent == 'Submit'", user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessAction, "ProcessDefinitionId == 'pd-1' && ActionEvent == 'FinalApproval'", user, "Order", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()
	mockP.On("Insert", mock.Anything, constants.TableProcessInstance, mock.MatchedBy(func(data models.SObject) bool {
		return data["TargetObjectId"] == "lead-1" &&
			data["Status"] == constants.ApprovalStatusApproved &&
			data["CompletedDate"] != nil
	}), user).Return(models.SObject{constants.FieldID: "pi-1"}, nil).Once()

	result, err := svc.Submit(context.Background(), SubmitRequest{
		ObjectAPIName: "Lead",
		RecordID:      "lead-1",
	}, user)

	assert.NoError(t, err)
	assert.Equal(t, constants.ApprovalStatusApproved, result["InstanceStatus"])
	assert.Empty(t, result["ActorIds"])
	assert.Empty(t, result["NewWorkitemIds"])
	mockP.AssertNotCalled(t, "Insert", mock.Anything, constants.TableProcessInstanceNode, mock.Anything, user)
	mockP.AssertNotCalled(t, "Insert", mock.Anything, constants.TableProcessInstanceWorkitem, mock.Anything, user)
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestTryAdvanceToNextStep_CriteriaMatches_AdvancesToThatStep(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef(
		`{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "GotoNextStep",
		"", "",
	)
	currentNode := models.SObject{constants.FieldID: "n1", "DeveloperName": "S1", "Name": "S1"}

	// Nodes queried by tryAdvanceToNextStep
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", "ProcessDefinitionId == 'pd-1'", user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 3, Records: setupTryAdvanceNodes()}, nil).Once()
	// Record load for step 2 evaluation — matches
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Technology"})
	expectCreateNextStep(mockQ, mockP, user, "n2")

	advanced, actors, workItems, err := svc.tryAdvanceToNextStep(context.Background(), def, "inst-1", currentNode, "lead-1", "submitter-1", "now", user)
	assert.NoError(t, err)
	assert.True(t, advanced)
	assert.Equal(t, []string{"u-1"}, actors) // approver "u@x.com" not found, falls back to caller user.ID
	assert.Equal(t, []string{"wi-n2"}, workItems)
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestTryAdvanceToNextStep_NonMatchGotoNextStep_SkipsToMatchingStep(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef(
		`{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "GotoNextStep",
		"", "",
	)
	currentNode := models.SObject{constants.FieldID: "n1", "DeveloperName": "S1", "Name": "S1"}

	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", "ProcessDefinitionId == 'pd-1'", user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 3, Records: setupTryAdvanceNodes()}, nil).Once()
	// Step 2 record load — non-match
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Finance"})
	// Step 3 has no criteria — no record load — directly starts
	expectCreateNextStep(mockQ, mockP, user, "n3")

	advanced, _, workItems, err := svc.tryAdvanceToNextStep(context.Background(), def, "inst-1", currentNode, "lead-1", "submitter-1", "now", user)
	assert.NoError(t, err)
	assert.True(t, advanced)
	assert.Equal(t, []string{"wi-n3"}, workItems)
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestTryAdvanceToNextStep_NonMatchApproveRecord_FinalizesWithoutScanning(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef(
		`{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "ApproveRecord",
		// Step 3 has criteria that *would* match — but ApproveRecord on step 2 must short-circuit.
		`{"criteriaItems":[{"field":"Lead.Country","operation":"equals","value":"USA"}]}`, "ApproveRecord",
	)
	currentNode := models.SObject{constants.FieldID: "n1", "DeveloperName": "S1", "Name": "S1"}

	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", "ProcessDefinitionId == 'pd-1'", user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 3, Records: setupTryAdvanceNodes()}, nil).Once()
	// Step 2 load — non-match; step 3 must NOT be evaluated (no extra load expected).
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Finance", "Lead.Country": "USA"})

	advanced, _, _, err := svc.tryAdvanceToNextStep(context.Background(), def, "inst-1", currentNode, "lead-1", "submitter-1", "now", user)
	assert.NoError(t, err)
	assert.False(t, advanced, "ApproveRecord on non-match must finalize — caller sets Status=Approved")
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestTryAdvanceToNextStep_NonMatchDefaultIsApproveRecord(t *testing.T) {
	// When ifCriteriaNotMet is absent, SF default is ApproveRecord.
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef(
		`{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "",
		"", "",
	)
	currentNode := models.SObject{constants.FieldID: "n1", "DeveloperName": "S1", "Name": "S1"}

	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", "ProcessDefinitionId == 'pd-1'", user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 3, Records: setupTryAdvanceNodes()}, nil).Once()
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Finance"})

	advanced, _, _, err := svc.tryAdvanceToNextStep(context.Background(), def, "inst-1", currentNode, "lead-1", "submitter-1", "now", user)
	assert.NoError(t, err)
	assert.False(t, advanced)
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

func TestTryAdvanceToNextStep_AllSkipped_Finalizes(t *testing.T) {
	// Two downstream steps, both GotoNextStep, both non-match → run off end → finalize.
	mockQ := new(MockApprovalQuery)
	mockP := new(MockApprovalPersistence)
	svc := &ApprovalService{query: mockQ, persistence: mockP, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef(
		`{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "GotoNextStep",
		`{"criteriaItems":[{"field":"Lead.Country","operation":"equals","value":"USA"}]}`, "GotoNextStep",
	)
	currentNode := models.SObject{constants.FieldID: "n1", "DeveloperName": "S1", "Name": "S1"}

	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", "ProcessDefinitionId == 'pd-1'", user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 3, Records: setupTryAdvanceNodes()}, nil).Once()
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Finance", "Lead.Country": "Finance"})
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Finance", "Lead.Country": "Finance"})

	advanced, _, _, err := svc.tryAdvanceToNextStep(context.Background(), def, "inst-1", currentNode, "lead-1", "submitter-1", "now", user)
	assert.NoError(t, err)
	assert.False(t, advanced, "exhausted GotoNextStep chain must finalize as Approved via caller")
	mockQ.AssertExpectations(t)
	mockP.AssertExpectations(t)
}

// ---------------------------------------------------------------------------
// ProjectChain — pre-evaluation of step entry criteria for issue #1946.
//
// Mirrors the multiStepProcessDef helper used above: 3 steps S1/S2/S3 where
// S1 has no criteria (always matches → WillRun), S2/S3 carry whatever criteria
// the test passes. ProjectChain first loads the ProcessDefinition by Id, then
// the ProcessNodes ordered by StepOrder, then for each node calls
// stepCriteriaMatches (which loads the target record).
// ---------------------------------------------------------------------------

// expectProjectChainSetup registers the four table-load mocks every
// successful ProjectChain run executes — ProcessDefinition lookup, the
// participant-gate ProcessInstance lookup (returning an instance whose
// SubmittedById matches the test user, so the submitter path is taken and
// no ProcessInstanceWorkitem load is needed), the ProcessNode list, then
// the ProcessInstanceNode list (returning a single runtime node for n1 so
// currentIdx=0 — n1 is auto-WillRun without criteria evaluation, matching
// the post-Submit reality where step 1 is always instantiated).
// Returns the same node list as setupTryAdvanceNodes() for parity with
// multiStepProcessDef.
func expectProjectChainSetup(mockQ *MockApprovalQuery, user *models.UserSession, def models.SObject) {
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "Id == 'pd-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{def}}, nil).Once()
	// Participant gate — submitter path. ProcessInstance lookup keyed on
	// (TargetObjectId, ProcessDefinitionId), ORDER BY CreatedDate DESC LIMIT 1.
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance,
		"TargetObjectId == 'lead-1' AND ProcessDefinitionId == 'pd-1' AND Status == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "pi-1", "SubmittedById": user.ID},
		}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", "ProcessDefinitionId == 'pd-1'", user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 3, Records: setupTryAdvanceNodes()}, nil).Once()
	// Active PENDING ProcessInstanceNode — Submit always instantiates step 1
	// unconditionally as PENDING, so the projection's criteria walk must
	// start at currentIdx+1 (= 1) to match the runtime's tryAdvanceToNextStep
	// loop. The query filters on NodeStatus=Pending so a BackToPrevious
	// rollback's higher-index historical node (e.g. n3 rejected) doesn't
	// outrank the rolled-back pending one (e.g. n2).
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceNode,
		"ProcessInstanceId == 'pi-1' AND NodeStatus == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "rn-1", "ProcessNodeId": "n1"},
		}}, nil).Once()
}

func TestProjectChain_AllCriteriaMatch_AllWillRun(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	// S1 has no criteria; S2/S3 each have a criterion the record satisfies.
	def := multiStepProcessDef(
		`{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "GotoNextStep",
		`{"criteriaItems":[{"field":"Lead.Country","operation":"equals","value":"USA"}]}`, "GotoNextStep",
	)
	expectProjectChainSetup(mockQ, user, def)
	// Record load happens once per criteria-bearing step (S2, S3); S1 short-circuits.
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Technology", "Lead.Country": "USA"})
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Technology", "Lead.Country": "USA"})

	got, err := svc.ProjectChain(context.Background(), "pd-1", "Lead", "lead-1", user)
	assert.NoError(t, err)
	assert.Equal(t, []ProjectedNode{
		{ProcessNodeID: "n1", Outcome: ProjectedOutcomeWillRun},
		{ProcessNodeID: "n2", Outcome: ProjectedOutcomeWillRun},
		{ProcessNodeID: "n3", Outcome: ProjectedOutcomeWillRun},
	}, got)
	mockQ.AssertExpectations(t)
}

func TestProjectChain_NonMatchGotoNextStep_MarksOnlyThatStepWillSkip(t *testing.T) {
	// S2 fails criteria but ifCriteriaNotMet=GotoNextStep keeps evaluation
	// going, so S3 is still evaluated against the record. Mirrors the
	// real-world "skip a conditional approver, continue" case.
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef(
		`{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "GotoNextStep",
		`{"criteriaItems":[{"field":"Lead.Country","operation":"equals","value":"USA"}]}`, "GotoNextStep",
	)
	expectProjectChainSetup(mockQ, user, def)
	// S2 evaluates → non-match (Industry=Finance ≠ Technology).
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Finance", "Lead.Country": "USA"})
	// S3 still evaluated — match.
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Finance", "Lead.Country": "USA"})

	got, err := svc.ProjectChain(context.Background(), "pd-1", "Lead", "lead-1", user)
	assert.NoError(t, err)
	assert.Equal(t, []ProjectedNode{
		{ProcessNodeID: "n1", Outcome: ProjectedOutcomeWillRun},
		{ProcessNodeID: "n2", Outcome: ProjectedOutcomeWillSkip},
		{ProcessNodeID: "n3", Outcome: ProjectedOutcomeWillRun},
	}, got)
	mockQ.AssertExpectations(t)
}

func TestProjectChain_NonMatchApproveRecord_PropagatesWillSkipToLaterSteps(t *testing.T) {
	// S2 fails criteria with ifCriteriaNotMet=ApproveRecord → process would
	// auto-finalize at runtime; S3 must NOT be evaluated and must come back
	// WillSkip even though its own criteria *would* match. This is the path
	// most likely to misclassify and silently mislead users about which
	// approvers their record requires.
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef(
		`{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "ApproveRecord",
		`{"criteriaItems":[{"field":"Lead.Country","operation":"equals","value":"USA"}]}`, "ApproveRecord",
	)
	expectProjectChainSetup(mockQ, user, def)
	// Only S2 load expected. S3 must short-circuit — additional load would
	// fail mockQ.AssertExpectations.
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Finance", "Lead.Country": "USA"})

	got, err := svc.ProjectChain(context.Background(), "pd-1", "Lead", "lead-1", user)
	assert.NoError(t, err)
	assert.Equal(t, []ProjectedNode{
		{ProcessNodeID: "n1", Outcome: ProjectedOutcomeWillRun},
		{ProcessNodeID: "n2", Outcome: ProjectedOutcomeWillSkip},
		{ProcessNodeID: "n3", Outcome: ProjectedOutcomeWillSkip},
	}, got)
	mockQ.AssertExpectations(t)
}

func TestProjectChain_NonMatchDefaultBehaviorIsApproveRecord(t *testing.T) {
	// Salesforce default for missing ifCriteriaNotMet is ApproveRecord.
	// Test must not require ifCriteriaNotMet to be present in metadata for
	// the propagation path to fire.
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef(
		`{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "",
		`{"criteriaItems":[{"field":"Lead.Country","operation":"equals","value":"USA"}]}`, "",
	)
	expectProjectChainSetup(mockQ, user, def)
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Finance", "Lead.Country": "USA"})

	got, err := svc.ProjectChain(context.Background(), "pd-1", "Lead", "lead-1", user)
	assert.NoError(t, err)
	assert.Equal(t, []ProjectedNode{
		{ProcessNodeID: "n1", Outcome: ProjectedOutcomeWillRun},
		{ProcessNodeID: "n2", Outcome: ProjectedOutcomeWillSkip},
		{ProcessNodeID: "n3", Outcome: ProjectedOutcomeWillSkip},
	}, got, "missing ifCriteriaNotMet must default to ApproveRecord and propagate WillSkip")
	mockQ.AssertExpectations(t)
}

func TestProjectChain_ResolvesObjectAPINameFromProcessDefinition(t *testing.T) {
	// Caller may pass empty objectAPIName; ProjectChain should fall back to
	// ProcessDefinition.TableEnumOrId. Verifies the record-load filter targets
	// the right table.
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef(
		`{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "GotoNextStep",
		"", "",
	)
	expectProjectChainSetup(mockQ, user, def)
	// expectLoadRecord asserts the load targets "Lead", which is the
	// TableEnumOrId set by multiStepProcessDef.
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Technology"})

	got, err := svc.ProjectChain(context.Background(), "pd-1", "" /* objectAPIName */, "lead-1", user)
	assert.NoError(t, err)
	assert.Len(t, got, 3)
	mockQ.AssertExpectations(t)
}

func TestProjectChain_ValidationErrors(t *testing.T) {
	svc := &ApprovalService{logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	t.Run("empty processDefID", func(t *testing.T) {
		got, err := svc.ProjectChain(context.Background(), "", "Lead", "lead-1", user)
		assert.Error(t, err)
		assert.Nil(t, got)
	})

	t.Run("empty recordID", func(t *testing.T) {
		got, err := svc.ProjectChain(context.Background(), "pd-1", "Lead", "", user)
		assert.Error(t, err)
		assert.Nil(t, got)
	})
}

func TestProjectChain_ProcessDefinitionNotFound_ReturnsErrNoActiveApprovalProcess(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "Id == 'pd-missing'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()

	got, err := svc.ProjectChain(context.Background(), "pd-missing", "Lead", "lead-1", user)
	assert.ErrorIs(t, err, ErrNoActiveApprovalProcess)
	assert.Nil(t, got)
	mockQ.AssertExpectations(t)
}

func TestProjectChain_NoProcessNodes_ReturnsEmpty(t *testing.T) {
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef("", "", "", "")
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "Id == 'pd-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{def}}, nil).Once()
	// Participant gate — submitter path.
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance,
		"TargetObjectId == 'lead-1' AND ProcessDefinitionId == 'pd-1' AND Status == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "pi-1", "SubmittedById": user.ID},
		}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", "ProcessDefinitionId == 'pd-1'", user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()

	got, err := svc.ProjectChain(context.Background(), "pd-1", "Lead", "lead-1", user)
	assert.NoError(t, err)
	assert.Empty(t, got)
	mockQ.AssertExpectations(t)
}

// ---------------------------------------------------------------------------
// ProjectChain — Salesforce-parity participant gate
// ---------------------------------------------------------------------------
// The projection is exposed to participants of an active ProcessInstance:
//   1. Submitter (ProcessInstance.SubmittedById == user.ID).
//   2. Approver (ProcessInstanceWorkitem.ActorId/OriginalActorId == user.ID).
//   3. Admin holding ModifyAllData.
// Non-participants get a permission error before any criteria load. The
// rationale: criteria evaluation runs in sysCtx (matches Salesforce + this
// codebase's runtime path, codified in commit 800dac2e), so the gate must
// sit at the entry point — restricting WHO sees the projection rather than
// trying to gate per-field FLS during evaluation.

func TestProjectChain_NoActiveProcessInstance_Refuses(t *testing.T) {
	// Without a live ProcessInstance for (recordId, processDefId), the
	// projection refuses up-front. Closes the "speculative pre-submission"
	// probe window: a caller can't ask "what would this approval do for
	// record X?" before someone has actually submitted X.
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef(
		`{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "",
		"", "",
	)
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "Id == 'pd-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{def}}, nil).Once()
	// ProcessInstance lookup: 0 rows.
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance,
		"TargetObjectId == 'lead-1' AND ProcessDefinitionId == 'pd-1' AND Status == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()

	got, err := svc.ProjectChain(context.Background(), "pd-1", "Lead", "lead-1", user)
	assert.ErrorIs(t, err, ErrNoActiveApprovalProcess)
	assert.Nil(t, got)
	// No Workitem load, no ProcessNode load, no criteria load.
	mockQ.AssertExpectations(t)
}

func TestProjectChain_CallerIsApprover_AllowedViaWorkitem(t *testing.T) {
	// Different SubmittedById, but the caller has a Workitem assigned to
	// them on this instance — Salesforce's "implicit sharing during
	// approval" model. The projection runs the full happy path, with the
	// Workitem lookup consumed exactly once (no admin perm-check needed).
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef("", "", "", "")
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "Id == 'pd-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{def}}, nil).Once()
	// SubmittedById is someone else.
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance,
		"TargetObjectId == 'lead-1' AND ProcessDefinitionId == 'pd-1' AND Status == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "pi-1", "SubmittedById": "u-other"},
		}}, nil).Once()
	// Workitem lookup finds a row — caller is an approver.
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem,
		"ProcessInstanceId == 'pi-1' AND (ActorId == 'u-1' OR OriginalActorId == 'u-1')", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "wi-1"}}}, nil).Once()
	// ProcessNode load + (no criteria-bearing steps in this def → no record loads).
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", "ProcessDefinitionId == 'pd-1'", user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 3, Records: setupTryAdvanceNodes()}, nil).Once()
	// Active PENDING ProcessInstanceNode — n1 pending post-Submit.
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceNode,
		"ProcessInstanceId == 'pi-1' AND NodeStatus == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "rn-1", "ProcessNodeId": "n1"},
		}}, nil).Once()

	got, err := svc.ProjectChain(context.Background(), "pd-1", "Lead", "lead-1", user)
	assert.NoError(t, err)
	// All three nodes have empty criteria — all WillRun.
	assert.Len(t, got, 3)
	for _, p := range got {
		assert.Equal(t, ProjectedOutcomeWillRun, p.Outcome)
	}
	mockQ.AssertExpectations(t)
}

func TestProjectChain_CallerNotParticipant_Refuses(t *testing.T) {
	// Caller is neither submitter nor approver, and the service is
	// configured without a permissions backend (so the admin escape hatch
	// is closed). The projection refuses with a permission error before any
	// ProcessNode load — closing Codex's side-channel: a user who can read
	// the record but is not party to the approval cannot probe criteria
	// fields they lack FLS to via WillRun/WillSkip.
	mockQ := new(MockApprovalQuery)
	// permissions: nil — admin path is skipped (the implementation guards on nil).
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef(
		`{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "",
		"", "",
	)
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "Id == 'pd-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{def}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance,
		"TargetObjectId == 'lead-1' AND ProcessDefinitionId == 'pd-1' AND Status == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "pi-1", "SubmittedById": "u-other"},
		}}, nil).Once()
	// No Workitem either.
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceWorkitem,
		"ProcessInstanceId == 'pi-1' AND (ActorId == 'u-1' OR OriginalActorId == 'u-1')", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()

	got, err := svc.ProjectChain(context.Background(), "pd-1", "Lead", "lead-1", user)
	assert.Error(t, err, "non-participant must be refused before any ProcessNode load")
	assert.Nil(t, got)
	// AssertExpectations fails if ProcessNode load or any criteria load fires.
	mockQ.AssertExpectations(t)
}

func TestProjectChain_MismatchedObjectApiName_Rejected(t *testing.T) {
	// Caller supplies an objectApiName that disagrees with the bound process.
	// Refuse — accepting a mismatch would let an attacker bind a low-impact
	// process to one object but probe records on a different table via the
	// visibility check + criteria side-channel.
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef("", "", "", "")
	// PD has TableEnumOrId="Lead" via multiStepProcessDef. Caller passes "Account".
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "Id == 'pd-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{def}}, nil).Once()

	got, err := svc.ProjectChain(context.Background(), "pd-1", "Account", "lead-1", user)
	assert.Error(t, err, "must reject mismatched objectApiName before any record read")
	assert.Nil(t, got)
	// No visibility load, no ProcessNode load — AssertExpectations fails on extras.
	mockQ.AssertExpectations(t)
}

func TestProjectChain_DoesNotReEvaluateInstantiatedSteps(t *testing.T) {
	// Regression for Ian's P1 (PR #2026): the runtime path
	// (tryAdvanceToNextStep) starts at currentIdx + 1 and never re-checks
	// the current step's entry criteria when advancing. Submit creates the
	// first ProcessInstanceNode unconditionally without evaluating that
	// step's criteria, so n1's criteria can be false against the current
	// record state while the runtime is happily mid-flight.
	//
	// Pre-fix bug: ProjectChain walked ProcessNodes from index 0, so n1's
	// non-matching criteria + ApproveRecord behavior set
	// processFinalized=true, cascading WillSkip to n2 and n3 — even though
	// the runtime would evaluate them after n1 was approved.
	//
	// Post-fix: nodes at i ≤ currentIdx are emitted as WillRun without
	// criteria evaluation; the cascade is impossible because n1 never
	// touches processFinalized.
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	// n1 has the failing ApproveRecord criteria the bug would trigger on.
	// n2 and n3 are clean — they should remain WillRun.
	def := multiStepProcessDef(
		// step 2 in multiStepProcessDef is the SECOND argument pair, but the
		// helper builds n1=S1 (no criteria), n2=S2, n3=S3. To exercise the
		// bug we need n1 to be the criteria-bearing failing step. Build the
		// metadata directly so the failing criteria is on n1.
		"", "",
		"", "",
	)
	// Override: rebuild the def so S1 carries failing ApproveRecord criteria.
	mkStep := func(name, crit, ifNotMet string) string {
		s := `{"name":"` + name + `","label":"` + name + `"`
		if crit != "" {
			s += `,"entryCriteria":` + crit
		}
		if ifNotMet != "" {
			s += `,"ifCriteriaNotMet":"` + ifNotMet + `"`
		}
		return s + `,"assignedApprover":{"approver":{"name":"u@x.com","type":"user"}}}`
	}
	md := `{"approvalStep":[` +
		mkStep("S1", `{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "ApproveRecord") + `,` +
		mkStep("S2", "", "") + `,` +
		mkStep("S3", "", "") + `]}`
	def["Metadata"] = md

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "Id == 'pd-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{def}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance,
		"TargetObjectId == 'lead-1' AND ProcessDefinitionId == 'pd-1' AND Status == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "pi-1", "SubmittedById": user.ID},
		}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", "ProcessDefinitionId == 'pd-1'", user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 3, Records: setupTryAdvanceNodes()}, nil).Once()
	// n1 pending (post-Submit). currentIdx=0; n1 must NOT have its
	// criteria re-evaluated. AssertExpectations is load-bearing — if the
	// fix regressed and n1 were re-evaluated, an unexpected
	// expectLoadRecord-style call would fire and fail the test.
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceNode,
		"ProcessInstanceId == 'pi-1' AND NodeStatus == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "rn-1", "ProcessNodeId": "n1"},
		}}, nil).Once()
	// No expectLoadRecord registered — n1's criteria must NOT load the record,
	// and n2/n3 have empty criteria so they don't load either.

	got, err := svc.ProjectChain(context.Background(), "pd-1", "Lead", "lead-1", user)
	assert.NoError(t, err)
	assert.Equal(t, []ProjectedNode{
		{ProcessNodeID: "n1", Outcome: ProjectedOutcomeWillRun},
		{ProcessNodeID: "n2", Outcome: ProjectedOutcomeWillRun},
		{ProcessNodeID: "n3", Outcome: ProjectedOutcomeWillRun},
	}, got, "current-step criteria mismatch must NOT cascade WillSkip to future steps")
	mockQ.AssertExpectations(t)
}

func TestProjectChain_CompletedInstance_RefusesEvenForSubmitter(t *testing.T) {
	// Codex regression: the participant gate must filter on Status == 'Pending'.
	// Without it, a stale submitter of an already-Approved/Rejected/Removed
	// instance can keep calling the projection endpoint long after their
	// approval is closed, receiving WillRun/WillSkip outcomes computed from
	// the record's CURRENT criteria fields under SystemContext — even if the
	// caller's RLS / FLS access has changed since the approval ran.
	//
	// Test setup: PD load OK, ProcessInstance lookup with Status==Pending
	// returns 0 rows (the existing instance is APPROVED, so the active-only
	// filter excludes it). The function refuses with ErrNoActiveApprovalProcess.
	// AssertExpectations is load-bearing — fires if any later mock would be
	// reached, proving criteria evaluation never runs.
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef("", "", "", "")
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "Id == 'pd-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{def}}, nil).Once()
	// Active-only filter — instance exists but Status != 'Pending', so the
	// query returns 0 rows.
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance,
		"TargetObjectId == 'lead-1' AND ProcessDefinitionId == 'pd-1' AND Status == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()

	got, err := svc.ProjectChain(context.Background(), "pd-1", "Lead", "lead-1", user)
	assert.ErrorIs(t, err, ErrNoActiveApprovalProcess,
		"submitter of a completed (non-Pending) instance must NOT be admitted")
	assert.Nil(t, got)
	// No Workitem load, no ProcessNode load, no criteria load.
	mockQ.AssertExpectations(t)
}

func TestProjectChain_BackToPreviousRollback_UsesPendingNotHistoricalMax(t *testing.T) {
	// Codex regression: in a BackToPrevious flow like
	//   n1 approved → n2 approved → n3 rejected → n2 pending (rollback)
	// the runtime treats the rolled-back PENDING node (n2, idx 1) as the
	// current step. tryAdvanceToNextStep, when n2 is approved next, will
	// start at idx 2 (n3) and re-evaluate it.
	//
	// Pre-fix bug: the projection picked the highest historical instantiated
	// index (n3, idx 2 — visible in the historical instance-node set) and
	// walked from idx 3 (n4). If n4's criteria matches → WillRun. But
	// runtime would never reach n4 if n3 fails ApproveRecord on re-eval.
	//
	// Post-fix: the projection queries for NodeStatus=Pending only. Pending
	// is n2 (idx 1) — the rolled-back step. Walk starts at idx 2 (n3) and
	// evaluates n3's current criteria. If n3 fails ApproveRecord, the
	// projection cascades WillSkip to n4. Matches the runtime exactly.
	//
	// AssertExpectations is load-bearing: a regressed query that loaded
	// historical (non-Pending) instance nodes would not match the
	// Pending-only mock and the test would fail.
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	// 4-step process: n1 (no criteria), n2 (no criteria), n3 (failing
	// ApproveRecord criteria — record's Industry is Finance, not Technology),
	// n4 (matching criteria).
	mkStep := func(name, crit, ifNotMet string) string {
		s := `{"name":"` + name + `","label":"` + name + `"`
		if crit != "" {
			s += `,"entryCriteria":` + crit
		}
		if ifNotMet != "" {
			s += `,"ifCriteriaNotMet":"` + ifNotMet + `"`
		}
		return s + `,"assignedApprover":{"approver":{"name":"u@x.com","type":"user"}}}`
	}
	md := `{"approvalStep":[` +
		mkStep("S1", "", "") + `,` +
		mkStep("S2", "", "") + `,` +
		mkStep("S3", `{"criteriaItems":[{"field":"Lead.Industry","operation":"equals","value":"Technology"}]}`, "ApproveRecord") + `,` +
		mkStep("S4", "", "") + `]}`
	def := models.SObject{
		constants.FieldID: "pd-1",
		"TableEnumOrId":   "Lead",
		"Metadata":        md,
	}

	// Four ProcessNodes — extends setupTryAdvanceNodes() by one.
	processNodes := []models.SObject{
		{constants.FieldID: "n1", "DeveloperName": "S1", "Name": "S1"},
		{constants.FieldID: "n2", "DeveloperName": "S2", "Name": "S2"},
		{constants.FieldID: "n3", "DeveloperName": "S3", "Name": "S3"},
		{constants.FieldID: "n4", "DeveloperName": "S4", "Name": "S4"},
	}

	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "Id == 'pd-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{def}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance,
		"TargetObjectId == 'lead-1' AND ProcessDefinitionId == 'pd-1' AND Status == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "pi-1", "SubmittedById": user.ID},
		}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", "ProcessDefinitionId == 'pd-1'", user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 4, Records: processNodes}, nil).Once()
	// Pending node is n2 — the rolled-back one. The historical set includes
	// n1 (approved), n2 (approved), n3 (rejected), but the projection MUST
	// pick the PENDING n2, not the historical max n3.
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceNode,
		"ProcessInstanceId == 'pi-1' AND NodeStatus == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "rn-rollback", "ProcessNodeId": "n2"},
		}}, nil).Once()
	// stepCriteriaMatches loads the record once for n3's criteria evaluation.
	// Industry=Finance fails the Technology criterion → ApproveRecord
	// finalization → cascade WillSkip to n4.
	expectLoadRecord(mockQ, user, models.SObject{"Lead.Industry": "Finance"})

	got, err := svc.ProjectChain(context.Background(), "pd-1", "Lead", "lead-1", user)
	assert.NoError(t, err)
	assert.Equal(t, []ProjectedNode{
		{ProcessNodeID: "n1", Outcome: ProjectedOutcomeWillRun}, // i ≤ currentIdx (1)
		{ProcessNodeID: "n2", Outcome: ProjectedOutcomeWillRun}, // pending — currentIdx
		{ProcessNodeID: "n3", Outcome: ProjectedOutcomeWillSkip}, // criteria fails → cascade
		{ProcessNodeID: "n4", Outcome: ProjectedOutcomeWillSkip}, // process auto-finalizes — runtime never reaches n4
	}, got, "BackToPrevious must use pending n2 as currentIdx, not historical max n3")
	mockQ.AssertExpectations(t)
}

func TestProjectChain_NoPendingProcessInstanceNode_ReturnsEmpty(t *testing.T) {
	// Edge case: instance Status=Pending but no PENDING ProcessInstanceNode
	// is found. Without a clear current-step source, we can't safely project
	// (the runtime's evaluation start point is undefined). Return an empty
	// projection list — frontend falls back to UPCOMING for un-instantiated
	// nodes via the no-projection path, which is the safe pre-#1946 default.
	mockQ := new(MockApprovalQuery)
	svc := &ApprovalService{query: mockQ, logger: logging.New(context.Background())}
	user := &models.UserSession{ID: "u-1"}

	def := multiStepProcessDef("", "", "", "")
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessDefinition, "Id == 'pd-1'", user, "", "", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{def}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstance,
		"TargetObjectId == 'lead-1' AND ProcessDefinitionId == 'pd-1' AND Status == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 1, Records: []models.SObject{
			{constants.FieldID: "pi-1", "SubmittedById": user.ID},
		}}, nil).Once()
	mockQ.On("QueryWithFilter", mock.Anything, "ProcessNode", "ProcessDefinitionId == 'pd-1'", user, "StepOrder", "ASC", 100).
		Return(&models.QueryResult{TotalSize: 3, Records: setupTryAdvanceNodes()}, nil).Once()
	// No PENDING ProcessInstanceNode.
	mockQ.On("QueryWithFilter", mock.Anything, constants.TableProcessInstanceNode,
		"ProcessInstanceId == 'pi-1' AND NodeStatus == 'Pending'", user, "CreatedDate", "DESC", 1).
		Return(&models.QueryResult{TotalSize: 0, Records: []models.SObject{}}, nil).Once()

	got, err := svc.ProjectChain(context.Background(), "pd-1", "Lead", "lead-1", user)
	assert.NoError(t, err)
	assert.Empty(t, got, "no pending node → no projection rather than guess")
	// AssertExpectations fails if any criteria load fires after the early exit.
	mockQ.AssertExpectations(t)
}
