package automation_test

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"slices"
	"strings"
	"testing"

	automation "github.com/nexuscrm/automation"
	"github.com/nexuscrm/events"
	appErrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/logging"
	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/kernel/pkg/recordlocks"
	"github.com/nexuscrm/kernel/pkg/recordoverlay"
	sharedapi "github.com/nexuscrm/shared/pkg/api"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/nexuscrm/shared/pkg/queryspec"
	"github.com/nexuscrm/shared/pkg/referenceidentity"
)

type submitQueryCall struct {
	objectName string
	filter     string
	sortField  string
	sortDir    string
	limit      int
}

type submitQueryStub struct {
	calls   []submitQueryCall
	onQuery func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error)
	onReq   func(ctx context.Context, req sharedapi.QueryRequest, user *models.UserSession) (*models.QueryResult, error)
}

func (s *submitQueryStub) QueryWithFilter(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
	s.calls = append(s.calls, submitQueryCall{
		objectName: objectName,
		filter:     filter,
		sortField:  sortField,
		sortDir:    sortDir,
		limit:      limit,
	})
	// The approval engine's share reconciler reads existing approval shares on
	// every lifecycle transition. Lifecycle tests don't model that table, so
	// answer it with an empty set here rather than in every per-test closure.
	if objectName == constants.TableRecordShare {
		return &models.QueryResult{}, nil
	}
	if objectName == constants.TableUser {
		return submitUserLookupResult(filter), nil
	}
	if s.onQuery == nil {
		return &models.QueryResult{}, nil
	}
	return s.onQuery(ctx, objectName, filter, user, sortField, sortDir, limit)
}

func submitUserLookupResult(filter string) *models.QueryResult {
	id := submitQuotedFilterValue(filter, constants.FieldID)
	username := submitQuotedFilterValue(filter, "Username")
	if id == "" && username == "" {
		return &models.QueryResult{}
	}
	if id == "" {
		id = username
	}
	if username == "" {
		username = id + "@example.test"
	}
	return &models.QueryResult{
		TotalSize: 1,
		Records: []models.SObject{{
			constants.FieldID: id,
			"Username":        username,
			"IsActive":        true,
		}},
	}
}

func submitQuotedFilterValue(filter, field string) string {
	prefix := field + " == '"
	if !strings.HasPrefix(filter, prefix) {
		return ""
	}
	rest := strings.TrimPrefix(filter, prefix)
	idx := strings.Index(rest, "'")
	if idx < 0 {
		return ""
	}
	return rest[:idx]
}

func (s *submitQueryStub) Query(ctx context.Context, req sharedapi.QueryRequest, user *models.UserSession) (*models.QueryResult, error) {
	if s.onReq == nil {
		return &models.QueryResult{}, nil
	}
	return s.onReq(ctx, req, user)
}

func (s *submitQueryStub) QueryWithSpec(ctx context.Context, req queryspec.QuerySpec, user *models.UserSession) (*models.QueryResult, error) {
	filter, err := queryspec.LowerPredicate(req.Filter, queryspec.PredicateLoweringExpressionOnly)
	if err != nil {
		return nil, err
	}
	if s.onReq == nil {
		// Criteria evaluation reads through a field-capped SystemContext
		// QueryWithSpec (#3945); route it into the same onQuery switch the
		// legacy QueryWithFilter reads use, reconstructing the canonical
		// formula-expression form ("Id == '...'") for simple comparisons.
		filterExpr := filter.Expression
		if req.Filter.Kind == queryspec.PredicateFieldComparison && req.Filter.Op == "=" {
			filterExpr = fmt.Sprintf("%s == '%v'", req.Filter.Field, req.Filter.Value)
		}
		return s.QueryWithFilter(ctx, req.ObjectAPIName, filterExpr, user, req.SortField, req.SortDirection, req.Limit)
	}
	having, err := queryspec.LowerPredicate(req.Having, queryspec.PredicateLoweringExpressionOnly)
	if err != nil {
		return nil, err
	}
	return s.onReq(ctx, sharedapi.QueryRequest{
		ObjectAPIName: req.ObjectAPIName,
		FilterExpr:    filter.Expression,
		SortField:     req.SortField,
		SortDirection: req.SortDirection,
		Limit:         req.Limit,
		Offset:        req.Offset,
		ScanAll:       req.ScanAll,
		SelectFields:  req.SelectFields,
		GroupBy:       req.GroupBy,
		Having:        having.Expression,
	}, user)
}

func (s *submitQueryStub) RunAnalytics(ctx context.Context, q models.AnalyticsQuery, user *models.UserSession) (interface{}, error) {
	panic("submitQueryStub.RunAnalytics: not implemented")
}

func (s *submitQueryStub) SearchSingleObject(ctx context.Context, objectName, term string, searchFields, returnFields []string, limit, offset int, orderBy, orderDir, filterExpr string, user *models.UserSession) ([]models.SObject, error) {
	panic("submitQueryStub.SearchSingleObject: not implemented")
}

func (s *submitQueryStub) SearchSuggestions(context.Context, string, string, []string, []string, int, *models.UserSession) ([]models.SObject, error) {
	return nil, nil
}

func (s *submitQueryStub) GlobalSearch(ctx context.Context, term string, user *models.UserSession) ([]models.SearchResult, error) {
	panic("submitQueryStub.GlobalSearch: not implemented")
}

func (s *submitQueryStub) Calculate(ctx context.Context, objectName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	panic("submitQueryStub.Calculate: not implemented")
}

func (s *submitQueryStub) ExecuteRawSQL(ctx context.Context, sql string, params []interface{}, user *models.UserSession) ([]models.SObject, error) {
	panic("submitQueryStub.ExecuteRawSQL: not implemented")
}

var _ ports.QueryService = (*submitQueryStub)(nil)

type submitInsertCall struct {
	tableName       string
	record          models.SObject
	parentLockScope *recordlocks.Scope
}

type submitUpdateCall struct {
	tableName       string
	id              string
	updates         models.SObject
	parentLockScope *recordlocks.Scope
}

type submitPreparedPrelockCall struct {
	tableName       string
	id              string
	prepared        *models.PreparedRecordUpdate
	parentLockScope *recordlocks.Scope
}

type submitParentLockCall struct {
	keys            []recordlocks.ParentKey
	parentLockScope *recordlocks.Scope
}

type submitPreparedCommitCall struct {
	tableName string
	id        string
	prepared  *models.PreparedRecordUpdate
}

type submitPersistenceStub struct {
	recordType               string
	recordTypeErr            error
	recordTypeCalls          []string
	objectTypeCandidates     map[string][]string
	objectTypeCandidateErr   error
	objectTypeCandidateCalls [][]string
	rollbackOnError          bool
	txActive                 bool
	runTxCalls               int
	runPessTxCalls           int
	runCoordCalls            int
	inserts                  []submitInsertCall
	updates                  []submitUpdateCall
	prelocks                 []submitUpdateCall
	parentLocks              []submitParentLockCall
	preparedPrelocks         []submitPreparedPrelockCall
	preparedCommits          []submitPreparedCommitCall
	getRawCalls              []string
	getRawObjectCalls        []string
	getRawRecord             models.SObject
	getRawRecords            map[string]models.SObject
	// getRawObject, when set, is the only object under which GetRaw finds the
	// record; a query for any other object returns (nil, nil), modeling that a
	// record lives in exactly one object table. Default (unset) keeps the
	// permissive behavior of returning a synthetic record for any object.
	getRawObject string
	// getRawErr, when set, is returned by GetRaw to model a transient
	// stale-InfoSchema / infra error on the target object's own read table.
	getRawErr        error
	getLockRecord    models.SObject
	getLockErr       error
	events           []string
	onInsert         func(tableName string, record models.SObject)
	onUpdate         func(tableName string, id string, updates models.SObject)
	onPreviewUpdate  func(ctx context.Context, tableName string, id string, updates models.SObject, prepared *models.PreparedRecordUpdate)
	onPreparedCommit func(tableName string, id string, prepared *models.PreparedRecordUpdate)
}

type submitPlannedParentPersistence struct {
	*submitPersistenceStub
	plannedKeys []recordlocks.ParentKey
	planCalls   int
}

func (p *submitPlannedParentPersistence) PlanPreparedUpdateParentKeys(_ context.Context, _ string, _ string, prepared *models.PreparedRecordUpdate, _ models.SObject, _ *models.UserSession) ([]recordlocks.ParentKey, error) {
	p.planCalls++
	if prepared == nil {
		return nil, errors.New("prepared update missing")
	}
	return append([]recordlocks.ParentKey(nil), p.plannedKeys...), nil
}

func (p *submitPersistenceStub) Insert(ctx context.Context, tableName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	cloned := cloneSObject(record)
	if _, ok := cloned[constants.FieldID]; !ok {
		cloned[constants.FieldID] = fmt.Sprintf("%s-%d", tableName, len(p.inserts)+1)
	}
	p.inserts = append(p.inserts, submitInsertCall{tableName: tableName, record: cloned, parentLockScope: recordlocks.ScopeFromContext(ctx)})
	p.events = append(p.events, "insert:"+tableName)
	if p.onInsert != nil {
		p.onInsert(tableName, cloneSObject(cloned))
	}
	return cloned, nil
}

func (p *submitPersistenceStub) PreviewBeforeInsert(ctx context.Context, tableName string, record models.SObject, user *models.UserSession) (models.SObject, error) {
	return cloneSObject(record), nil
}

func (p *submitPersistenceStub) PrelockUpdateParents(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
	p.prelocks = append(p.prelocks, submitUpdateCall{tableName: tableName, id: id, updates: cloneSObject(updates), parentLockScope: recordlocks.ScopeFromContext(ctx)})
	p.events = append(p.events, "prelock:"+tableName+":"+id)
	return nil
}

func (p *submitPersistenceStub) PrelockPreparedUpdateParents(ctx context.Context, tableName string, id string, prepared *models.PreparedRecordUpdate, extraUpdates models.SObject, user *models.UserSession) error {
	p.preparedPrelocks = append(p.preparedPrelocks, submitPreparedPrelockCall{
		tableName:       tableName,
		id:              id,
		prepared:        prepared.Clone(),
		parentLockScope: recordlocks.ScopeFromContext(ctx),
	})
	updates := models.SObject(nil)
	if prepared != nil {
		updates = cloneSObject(prepared.EffectiveUpdates)
	}
	p.prelocks = append(p.prelocks, submitUpdateCall{tableName: tableName, id: id, updates: updates, parentLockScope: recordlocks.ScopeFromContext(ctx)})
	p.events = append(p.events, "prelock:"+tableName+":"+id)
	return nil
}

func (p *submitPersistenceStub) LockParentRows(ctx context.Context, keys []recordlocks.ParentKey) ([]recordlocks.ParentKey, error) {
	ordered := recordlocks.OrderedParentKeys(keys)
	p.parentLocks = append(p.parentLocks, submitParentLockCall{
		keys:            append([]recordlocks.ParentKey(nil), ordered...),
		parentLockScope: recordlocks.ScopeFromContext(ctx),
	})
	for _, key := range ordered {
		p.events = append(p.events, "parentlock:"+key.ObjectName+":"+key.RecordID)
		if scope := recordlocks.ScopeFromContext(ctx); scope != nil {
			scope.MarkAcquired(key)
		}
	}
	if scope := recordlocks.ScopeFromContext(ctx); scope != nil {
		return scope.HeldKeys(), nil
	}
	return ordered, nil
}

func (p *submitPersistenceStub) Update(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
	applied := cloneSObject(updates)
	if p.onUpdate != nil {
		p.onUpdate(tableName, id, applied)
	}
	p.updates = append(p.updates, submitUpdateCall{tableName: tableName, id: id, updates: cloneSObject(applied), parentLockScope: recordlocks.ScopeFromContext(ctx)})
	p.events = append(p.events, "update:"+tableName+":"+id)
	p.applyRecordMutation(tableName, id, applied, nil)
	return nil
}

func (p *submitPersistenceStub) PreviewBeforeUpdate(ctx context.Context, tableName string, id string, updates models.SObject, internalStamps models.SObject, user *models.UserSession) (*models.PreparedRecordUpdate, error) {
	record := models.SObject{constants.FieldID: id}
	if p.getRawRecords != nil {
		if existing, ok := p.getRawRecords[tableName+"/"+id]; ok {
			record = cloneSObject(existing)
		} else if existing, ok := p.getRawRecords[id]; ok {
			record = cloneSObject(existing)
		}
	} else if p.getRawRecord != nil {
		record = cloneSObject(p.getRawRecord)
	}
	for k, v := range updates {
		record[k] = v
	}
	prepared := &models.PreparedRecordUpdate{
		Record:           record,
		EffectiveUpdates: cloneSObject(updates),
		InternalUpdates:  cloneSObject(internalStamps),
	}
	if p.onPreviewUpdate != nil {
		p.onPreviewUpdate(ctx, tableName, id, cloneSObject(updates), prepared)
	}
	return prepared, nil
}

func (p *submitPersistenceStub) CommitPreparedUpdate(ctx context.Context, tableName string, id string, prepared *models.PreparedRecordUpdate, extraUpdates models.SObject, user *models.UserSession) error {
	cloned := prepared.Clone()
	p.preparedCommits = append(p.preparedCommits, submitPreparedCommitCall{
		tableName: tableName,
		id:        id,
		prepared:  cloned,
	})
	updates := models.SObject{}
	if cloned != nil {
		for k, v := range cloned.EffectiveUpdates {
			updates[k] = v
		}
		for k, v := range cloned.InternalUpdates {
			updates[k] = v
		}
	}
	for k, v := range extraUpdates {
		updates[k] = v
	}
	p.updates = append(p.updates, submitUpdateCall{tableName: tableName, id: id, updates: cloneSObject(updates), parentLockScope: recordlocks.ScopeFromContext(ctx)})
	p.events = append(p.events, "update:"+tableName+":"+id)
	p.applyRecordMutation(tableName, id, updates, cloned)
	if p.onPreparedCommit != nil {
		p.onPreparedCommit(tableName, id, cloned)
	}
	return nil
}

func (p *submitPersistenceStub) Delete(ctx context.Context, tableName string, id string, user *models.UserSession) error {
	panic("submitPersistenceStub.Delete: not implemented")
}

func (p *submitPersistenceStub) GetRaw(ctx context.Context, objectName string, id string) (models.SObject, error) {
	p.getRawCalls = append(p.getRawCalls, id)
	p.getRawObjectCalls = append(p.getRawObjectCalls, objectName+"/"+id)
	if p.getRawErr != nil {
		return nil, p.getRawErr
	}
	if p.getRawRecords != nil {
		if record, ok := p.getRawRecords[objectName+"/"+id]; ok {
			return cloneSObject(record), nil
		}
		if record, ok := p.getRawRecords[id]; ok {
			return cloneSObject(record), nil
		}
	}
	if p.getRawRecord != nil {
		return cloneSObject(p.getRawRecord), nil
	}
	if p.getRawObject != "" && !strings.EqualFold(objectName, p.getRawObject) {
		// The record lives under a different object table.
		return nil, nil
	}
	return models.SObject{constants.FieldID: id}, nil
}

func (p *submitPersistenceStub) ReadSelectedSystemRecord(ctx context.Context, objectName, id string, fields []string) (models.SObject, error) {
	record, err := p.GetRaw(ctx, objectName, id)
	if err != nil || record == nil {
		return record, err
	}
	selected := make(models.SObject, len(fields)+1)
	if value, ok := record[constants.FieldID]; ok {
		selected[constants.FieldID] = value
	}
	for _, field := range fields {
		if value, ok := record[field]; ok {
			selected[field] = value
		}
	}
	return selected, nil
}

func (p *submitPersistenceStub) GetLock(ctx context.Context, tableName string, id string) (models.SObject, error) {
	if p.getLockErr != nil {
		return nil, p.getLockErr
	}
	if p.getLockRecord != nil {
		return cloneSObject(p.getLockRecord), nil
	}
	return p.GetRaw(ctx, tableName, id)
}

func (p *submitPersistenceStub) applyRecordMutation(tableName string, id string, updates models.SObject, prepared *models.PreparedRecordUpdate) {
	if p.getRawRecords == nil {
		return
	}
	key := tableName + "/" + id
	record := cloneSObject(p.getRawRecords[key])
	if record == nil {
		record = models.SObject{constants.FieldID: id}
	}
	if prepared != nil && prepared.Record != nil {
		record = cloneSObject(prepared.Record)
	}
	for k, v := range updates {
		record[k] = v
	}
	p.getRawRecords[key] = record
}

func (p *submitPersistenceStub) GetRecordType(ctx context.Context, id string) (string, error) {
	p.recordTypeCalls = append(p.recordTypeCalls, id)
	if p.recordTypeErr != nil {
		return "", p.recordTypeErr
	}
	return p.recordType, nil
}

func (p *submitPersistenceStub) ResolveObjectTypeCandidates(_ context.Context, ids []string) (map[string][]string, error) {
	p.objectTypeCandidateCalls = append(p.objectTypeCandidateCalls, append([]string(nil), ids...))
	if p.objectTypeCandidateErr != nil {
		return nil, p.objectTypeCandidateErr
	}
	if p.objectTypeCandidates != nil {
		out := make(map[string][]string, len(p.objectTypeCandidates))
		for id, candidates := range p.objectTypeCandidates {
			out[id] = append([]string(nil), candidates...)
		}
		return out, nil
	}
	if p.recordTypeErr != nil {
		if appErrors.IsNotFound(p.recordTypeErr) {
			return map[string][]string{}, nil
		}
		return nil, p.recordTypeErr
	}
	out := make(map[string][]string)
	if p.recordType != "" {
		for _, id := range ids {
			out[id] = []string{p.recordType}
		}
	}
	return out, nil
}

func (p *submitPersistenceStub) RunInTransaction(ctx context.Context, fn func(context.Context) error) error {
	p.runTxCalls++
	insertLen := len(p.inserts)
	updateLen := len(p.updates)
	err := fn(ctx)
	if err != nil && p.rollbackOnError {
		p.inserts = p.inserts[:insertLen]
		p.updates = p.updates[:updateLen]
	}
	return err
}

func (p *submitPersistenceStub) IsTransactionActive(context.Context) bool { return p.txActive }

func (p *submitPersistenceStub) RunInPessimisticTransaction(ctx context.Context, fn func(context.Context) error) error {
	p.runPessTxCalls++
	return p.RunInTransaction(ctx, fn)
}

func (p *submitPersistenceStub) RunWithParentLockCoordinator(ctx context.Context, fn func(context.Context) error) error {
	p.runCoordCalls++
	if fn == nil {
		return nil
	}
	return fn(recordlocks.WithScope(ctx))
}

func (p *submitPersistenceStub) DetachTx(ctx context.Context) context.Context {
	return ctx
}

func (p *submitPersistenceStub) RecomputeActiveFormulaProjection(_ context.Context, _ *models.UserSession, _, _ string, _ []string) (models.SObject, error) {
	return nil, nil
}

func cloneSObject(in models.SObject) models.SObject {
	if in == nil {
		return nil
	}
	out := make(models.SObject, len(in))
	for k, v := range in {
		out[k] = v
	}
	return out
}

func newSubmitTestService(persistence automation.PersistenceProvider, query *submitQueryStub) *automation.ApprovalService {
	return automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		nil,
		logging.Default,
		automation.WithApprovalSubmitLineageStore(automation.NewMemoryApprovalSubmitLineageStore()),
	)
}

func submitTestUser() *models.UserSession {
	return &models.UserSession{
		ID:            "005-submit-user",
		Name:          "Submit Tester",
		ProfileID:     constants.ProfileSystemAdmin,
		IsSystemAdmin: true,
	}
}

func processDefinitionRecord(entryFilter string) models.SObject {
	return models.SObject{
		constants.FieldID: "pd-1",
		"Name":            "Issue351 Process",
		"DeveloperName":   "Issue351_Process",
		"TableEnumOrId":   "Lead",
		"State":           "Active",
		"EntryFilter":     entryFilter,
		"Metadata":        "{}",
	}
}

func emptyResult() *models.QueryResult {
	return &models.QueryResult{TotalSize: 0, Records: []models.SObject{}}
}

func TestApprovalService_GetLatestProcessInstanceIncludesPendingWorkItems(t *testing.T) {
	persistence := &submitPersistenceStub{
		getRawRecords: map[string]models.SObject{
			constants.TableUser + "/005-submit": {
				constants.FieldID: "005-submit",
				"Name":            "Sam Submitter",
			},
			constants.TableUser + "/005-approver": {
				constants.FieldID: "005-approver",
				"Name":            "Ada Approver",
			},
			constants.TableUser + "/005-original": {
				constants.FieldID: "005-original",
				"Name":            "Original Approver",
			},
		},
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case constants.TableProcessInstance:
				if filter != "TargetObjectId == 'rec-1'" {
					t.Fatalf("ProcessInstance filter = %q, want record filter", filter)
				}
				if sortField != "CreatedDate" || sortDir != "desc" || limit != 1 {
					t.Fatalf("ProcessInstance sort/limit = %q/%q/%d, want CreatedDate/desc/1", sortField, sortDir, limit)
				}
				return &models.QueryResult{
					TotalSize: 1,
					Records: []models.SObject{{
						constants.FieldID:     "pi-1",
						"ProcessDefinitionId": "pd-1",
						"TargetObjectId":      "rec-1",
						"Status":              constants.ApprovalStatusPending,
						"SubmittedById":       "005-submit",
						"CreatedDate":         "2026-05-15T01:00:00Z",
					}},
				}, nil
			case constants.TableProcessDefinition:
				if filter != "Id == 'pd-1'" {
					t.Fatalf("ProcessDefinition filter = %q, want definition id filter", filter)
				}
				return &models.QueryResult{
					TotalSize: 1,
					Records: []models.SObject{{
						constants.FieldID: "pd-1",
						"TableEnumOrId":   "GenericRecord",
					}},
				}, nil
			case "GenericRecord":
				if constants.GetSecurityContext(ctx) != constants.UserContext {
					t.Fatalf("target record read security context = %v, want UserContext", constants.GetSecurityContext(ctx))
				}
				if filter != "Id == 'rec-1'" {
					t.Fatalf("target record filter = %q, want exact record id filter", filter)
				}
				return &models.QueryResult{
					TotalSize: 1,
					Records: []models.SObject{{
						constants.FieldID: "rec-1",
					}},
				}, nil
			case constants.TableProcessInstanceWorkitem:
				if filter != "ProcessInstanceId == 'pi-1'" {
					t.Fatalf("ProcessInstanceWorkitem filter = %q, want instance filter", filter)
				}
				if sortField != "CreatedDate" || sortDir != "asc" || limit != 200 {
					t.Fatalf("ProcessInstanceWorkitem sort/limit = %q/%q/%d, want CreatedDate/asc/200", sortField, sortDir, limit)
				}
				return &models.QueryResult{
					TotalSize: 1,
					Records: []models.SObject{{
						constants.FieldID:   "wi-1",
						"ProcessInstanceId": "pi-1",
						"ActorId":           "005-approver",
						"OriginalActorId":   "005-original",
						"CreatedDate":       "2026-05-15T01:01:00Z",
					}},
				}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	summary, err := svc.GetLatestProcessInstance(context.Background(), "rec-1", submitTestUser())
	if err != nil {
		t.Fatalf("GetLatestProcessInstance() error = %v", err)
	}
	if summary == nil {
		t.Fatal("GetLatestProcessInstance() = nil, want summary")
	}
	if summary.SubmittedByName != "Sam Submitter" {
		t.Fatalf("SubmittedByName = %q, want Sam Submitter", summary.SubmittedByName)
	}
	if len(summary.PendingWorkItems) != 1 {
		t.Fatalf("len(PendingWorkItems) = %d, want 1", len(summary.PendingWorkItems))
	}
	got := summary.PendingWorkItems[0]
	if got.ID != "wi-1" || got.ActorID != "005-approver" || got.ActorName != "Ada Approver" {
		t.Fatalf("pending work item = %#v, want wi-1 assigned to Ada Approver", got)
	}
	if got.OriginalActorID != "005-original" || got.OriginalActorName != "Original Approver" {
		t.Fatalf("original actor = %#v, want 005-original Original Approver", got)
	}
}

func TestApprovalService_GetLatestProcessInstanceTerminalOmitsPendingWorkItems(t *testing.T) {
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			if objectName == constants.TableProcessInstanceWorkitem {
				t.Fatal("terminal ProcessInstance should not query ProcessInstanceWorkitem")
			}
			switch objectName {
			case constants.TableProcessInstance:
				return &models.QueryResult{
					TotalSize: 1,
					Records: []models.SObject{{
						constants.FieldID:     "pi-1",
						"ProcessDefinitionId": "pd-1",
						"TargetObjectId":      "rec-1",
						"Status":              constants.ApprovalStatusApproved,
						"SubmittedById":       "005-submit",
						"CreatedDate":         "2026-05-15T01:00:00Z",
						"CompletedDate":       "2026-05-15T01:05:00Z",
					}},
				}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{
					TotalSize: 1,
					Records: []models.SObject{{
						constants.FieldID: "pd-1",
						"TableEnumOrId":   "GenericRecord",
					}},
				}, nil
			case "GenericRecord":
				return &models.QueryResult{
					TotalSize: 1,
					Records: []models.SObject{{
						constants.FieldID: "rec-1",
					}},
				}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	svc := newSubmitTestService(&submitPersistenceStub{}, query)

	summary, err := svc.GetLatestProcessInstance(context.Background(), "rec-1", submitTestUser())
	if err != nil {
		t.Fatalf("GetLatestProcessInstance() error = %v", err)
	}
	if summary == nil {
		t.Fatal("GetLatestProcessInstance() = nil, want summary")
	}
	if len(summary.PendingWorkItems) != 0 {
		t.Fatalf("PendingWorkItems = %#v, want none for terminal instance", summary.PendingWorkItems)
	}
}

func TestApprovalService_GetLatestProcessInstanceOmitsSummaryWhenTargetRecordUnreadable(t *testing.T) {
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case constants.TableProcessInstance:
				return &models.QueryResult{
					TotalSize: 1,
					Records: []models.SObject{{
						constants.FieldID:     "pi-1",
						"ProcessDefinitionId": "pd-1",
						"TargetObjectId":      "rec-1",
						"Status":              constants.ApprovalStatusPending,
						"SubmittedById":       "005-submit",
						"CreatedDate":         "2026-05-15T01:00:00Z",
					}},
				}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{
					TotalSize: 1,
					Records: []models.SObject{{
						constants.FieldID: "pd-1",
						"TableEnumOrId":   "GenericRecord",
					}},
				}, nil
			case "GenericRecord":
				if constants.GetSecurityContext(ctx) != constants.UserContext {
					t.Fatalf("target record read security context = %v, want UserContext", constants.GetSecurityContext(ctx))
				}
				return emptyResult(), nil
			case constants.TableProcessInstanceWorkitem:
				t.Fatal("unreadable target must not query pending work items")
				return nil, nil
			case constants.TableUser:
				t.Fatal("unreadable target must not enrich submitter or actor names")
				return nil, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	svc := newSubmitTestService(&submitPersistenceStub{}, query)

	summary, err := svc.GetLatestProcessInstance(context.Background(), "rec-1", submitTestUser())
	if err != nil {
		t.Fatalf("GetLatestProcessInstance() error = %v", err)
	}
	if summary != nil {
		t.Fatalf("GetLatestProcessInstance() = %#v, want nil for unreadable target record", summary)
	}
}

func TestApprovalService_GetRecordApprovalStepDisplayMetadata_ReturnsSafeProjection(t *testing.T) {
	metadata := `{
		"approvalStep": [
			{
				"name": "Manager_Approval",
				"label": "Manager Approval",
				"stepCriteria": {"formula": "Amount > 100"},
				"ifCriteriaNotMet": "GotoNextStep"
			},
			{
				"name": "Finance_Approval",
				"label": "Finance Approval",
				"stepCriteria": {"formula": "Amount > 1000"},
				"ifCriteriaNotMet": "rejectrecord"
			},
			{
				"name": "Legal_Approval",
				"label": "Legal Approval"
			}
		]
	}`
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-1",
					"ProcessDefinitionId": "pd-1",
					"TargetObjectId":      "acct-1",
					"Status":              constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-1",
					"TableEnumOrId":   "Account",
					"Metadata":        metadata,
				}}}, nil
			case "Account":
				if constants.GetSecurityContext(ctx) != constants.UserContext {
					t.Fatalf("target record read security context = %v, want UserContext", constants.GetSecurityContext(ctx))
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "acct-1",
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 3, Records: []models.SObject{
					{
						constants.FieldID:     "pn-1",
						"Name":                "Manager_Approval",
						"DeveloperName":       "Manager_Approval",
						"ProcessDefinitionId": "pd-1",
						"StepOrder":           1,
					},
					{
						constants.FieldID:     "pn-2",
						"Name":                "Finance_Approval",
						"DeveloperName":       "Finance_Approval",
						"ProcessDefinitionId": "pd-1",
						"StepOrder":           2,
					},
					{
						constants.FieldID:     "pn-3",
						"Name":                "Legal_Approval",
						"DeveloperName":       "Legal_Approval",
						"ProcessDefinitionId": "pd-1",
						"StepOrder":           3,
					},
				}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}

	service := newSubmitTestService(&submitPersistenceStub{}, query)
	facts, err := service.GetRecordApprovalStepDisplayMetadata(context.Background(), "acct-1", submitTestUser())
	if err != nil {
		t.Fatalf("GetRecordApprovalStepDisplayMetadata() error = %v", err)
	}
	if len(facts) != 3 {
		t.Fatalf("facts len = %d, want 3: %#v", len(facts), facts)
	}
	if facts[0].ProcessNodeID != "pn-1" || facts[0].StepOrder != 1 || facts[0].StepLabel != "Manager Approval" || !facts[0].HasEntryCriteria || facts[0].IfCriteriaNotMet != "GotoNextStep" {
		t.Fatalf("first fact = %#v, want Manager GotoNextStep criteria fact", facts[0])
	}
	if facts[1].ProcessNodeID != "pn-2" || facts[1].StepOrder != 2 || facts[1].StepLabel != "Finance Approval" || !facts[1].HasEntryCriteria || facts[1].IfCriteriaNotMet != "RejectRecord" {
		t.Fatalf("second fact = %#v, want canonical downstream RejectRecord", facts[1])
	}
	if facts[2].ProcessNodeID != "pn-3" || facts[2].StepOrder != 3 || facts[2].StepLabel != "Legal Approval" || facts[2].HasEntryCriteria || facts[2].IfCriteriaNotMet != "" {
		t.Fatalf("third fact = %#v, want non-criteria identity fact", facts[2])
	}

	payload, err := json.Marshal(facts)
	if err != nil {
		t.Fatalf("marshal facts: %v", err)
	}
	if strings.Contains(string(payload), "Amount >") || strings.Contains(string(payload), "Metadata") {
		t.Fatalf("safe projection leaked raw metadata/criteria: %s", payload)
	}
}

func TestApprovalService_Submit_NonexistentTargetSkipCriteriaNoMutation(t *testing.T) {
	persistence := &submitPersistenceStub{
		recordTypeErr: appErrors.NewNotFoundError("record", "00Qmissing"),
	}
	query := &submitQueryStub{}
	svc := newSubmitTestService(persistence, query)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		RecordID:          "00Qmissing",
		SkipEntryCriteria: true,
	}, submitTestUser())
	if err == nil {
		t.Fatal("Submit() error = nil, want not-found")
	}
	if !appErrors.IsNotFound(err) {
		t.Fatalf("Submit() error = %T %v, want not-found", err, err)
	}
	if len(query.calls) != 0 {
		t.Fatalf("QueryWithFilter calls = %d, want 0 after candidate lookup found no target", len(query.calls))
	}
	if len(persistence.inserts) != 0 {
		t.Fatalf("Insert calls = %d, want 0 on nonexistent target", len(persistence.inserts))
	}
	if got := persistence.objectTypeCandidateCalls; len(got) != 1 || len(got[0]) != 1 || got[0][0] != "00Qmissing" {
		t.Fatalf("ResolveObjectTypeCandidates calls = %v, want [[00Qmissing]]", got)
	}
	if len(persistence.recordTypeCalls) != 0 {
		t.Fatalf("GetRecordType calls = %v, want no single-winner reverse scan", persistence.recordTypeCalls)
	}
}

func TestApprovalService_Submit_ObjectHintConflictNoMutation(t *testing.T) {
	// The record actually lives under Lead; GetRaw("Account", …) therefore finds
	// nothing, and the all-candidate lookup surfaces the type conflict.
	persistence := &submitPersistenceStub{recordType: "Lead", getRawObject: "Lead"}
	query := &submitQueryStub{}
	svc := newSubmitTestService(persistence, query)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "Account",
		RecordID:      "00Qcanon",
	}, submitTestUser())
	if err == nil {
		t.Fatal("Submit() error = nil, want validation error")
	}
	if !appErrors.IsValidation(err) {
		t.Fatalf("Submit() error = %T %v, want validation error", err, err)
	}
	if len(query.calls) != 0 {
		t.Fatalf("QueryWithFilter calls = %d, want 0 on object hint conflict", len(query.calls))
	}
	if len(persistence.inserts) != 0 {
		t.Fatalf("Insert calls = %d, want 0 on object hint conflict", len(persistence.inserts))
	}
}

func TestApprovalService_ResolveSubmitEligibility_DisambiguatesBusinessRecordFromAuditOutboxCollision(t *testing.T) {
	const targetID = "6a471440-be05-4b77-a970-aba53b769716"
	persistence := &submitPersistenceStub{objectTypeCandidates: map[string][]string{
		targetID: {"Account", constants.TableAuditOutbox},
	}}
	query := &submitQueryStub{onQuery: func(_ context.Context, objectName, filter string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
		switch objectName {
		case constants.TableProcessDefinition:
			if strings.Contains(filter, "TableEnumOrId == 'Account'") {
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-account",
					"TableEnumOrId":   "Account",
					"State":           "Active",
					"EntryFilter":     "",
				}}}, nil
			}
			if strings.Contains(filter, "TableEnumOrId == '"+constants.TableAuditOutbox+"'") {
				return emptyResult(), nil
			}
		case "Account":
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: targetID}}}, nil
		}
		return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
	}}

	eligibility, err := newSubmitTestService(persistence, query).ResolveSubmitEligibility(context.Background(), automation.SubmitRequest{RecordID: targetID}, submitTestUser())
	if err != nil {
		t.Fatalf("ResolveSubmitEligibility() error = %v", err)
	}
	if eligibility.ObjectAPIName != "Account" {
		t.Fatalf("ObjectAPIName = %q, want Account", eligibility.ObjectAPIName)
	}
	if len(persistence.recordTypeCalls) != 0 {
		t.Fatalf("GetRecordType calls = %v, want no catalog-order winner", persistence.recordTypeCalls)
	}
}

func TestApprovalService_ResolveSubmitEligibility_FailsClosedForMultipleApprovableCandidates(t *testing.T) {
	persistence := &submitPersistenceStub{objectTypeCandidates: map[string][]string{
		"shared-id": {"Account", "Quote"},
	}}
	query := &submitQueryStub{onQuery: func(_ context.Context, objectName, _ string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
		if objectName != constants.TableProcessDefinition {
			t.Fatalf("unexpected query object = %q", objectName)
		}
		return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "pd-active"}}}, nil
	}}

	_, err := newSubmitTestService(persistence, query).ResolveSubmitEligibility(context.Background(), automation.SubmitRequest{RecordID: "shared-id"}, submitTestUser())
	if !appErrors.IsValidation(err) {
		t.Fatalf("ResolveSubmitEligibility() error = %T %v, want validation ambiguity", err, err)
	}
	if len(persistence.recordTypeCalls) != 0 {
		t.Fatalf("GetRecordType calls = %v, want no first-match fallback", persistence.recordTypeCalls)
	}
}

func TestApprovalService_ResolveSubmitEligibility_SystemOnlyCandidateHasNoApprovalProcess(t *testing.T) {
	persistence := &submitPersistenceStub{objectTypeCandidates: map[string][]string{
		"audit-only": {constants.TableAuditOutbox},
	}}
	query := &submitQueryStub{onQuery: func(_ context.Context, objectName, _ string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
		switch objectName {
		case constants.TableAuditOutbox:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "audit-only"}}}, nil
		case constants.TableProcessDefinition:
			return emptyResult(), nil
		default:
			return nil, fmt.Errorf("unexpected query object: %s", objectName)
		}
	}}

	_, err := newSubmitTestService(persistence, query).ResolveSubmitEligibility(context.Background(), automation.SubmitRequest{RecordID: "audit-only"}, submitTestUser())
	if !appErrors.IsNotFound(err) {
		t.Fatalf("ResolveSubmitEligibility() error = %T %v, want no-active-process not-found", err, err)
	}
}

func TestApprovalService_ResolveSubmitEligibility_CandidateProbeFailureIsInternal(t *testing.T) {
	persistence := &submitPersistenceStub{objectTypeCandidateErr: errors.New("candidate probe connection failed")}
	query := &submitQueryStub{}

	_, err := newSubmitTestService(persistence, query).ResolveSubmitEligibility(context.Background(), automation.SubmitRequest{RecordID: "target-id"}, submitTestUser())
	if !appErrors.IsInternal(err) {
		t.Fatalf("ResolveSubmitEligibility() error = %T %v, want internal", err, err)
	}
	if len(query.calls) != 0 {
		t.Fatalf("query calls = %d, want none after candidate probe failure", len(query.calls))
	}
}

func TestApprovalService_HasPendingApprovalInstance_LineageDisambiguatesInactiveProcessCollision(t *testing.T) {
	const (
		targetID  = "shared-id"
		chainID   = "chain-1"
		processID = "pd-account-inactive"
	)
	persistence := &submitPersistenceStub{objectTypeCandidates: map[string][]string{
		targetID: {"Account", constants.TableAuditOutbox},
	}}
	query := &submitQueryStub{onQuery: func(_ context.Context, objectName, filter string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
		switch objectName {
		case constants.TableProcessDefinition:
			if strings.Contains(filter, "Id == '"+processID+"'") {
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: processID,
					"TableEnumOrId":   "Account",
					"State":           "Inactive",
					"Metadata":        "{}",
				}}}, nil
			}
			if strings.Contains(filter, "State == 'Active'") {
				return emptyResult(), nil
			}
		case "Account":
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: targetID}}}, nil
		}
		return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
	}}
	lineage := automation.NewMemoryApprovalSubmitLineageStore()
	if err := lineage.RecordSubmitted(context.Background(), chainID, targetID, processID); err != nil {
		t.Fatalf("RecordSubmitted() error = %v", err)
	}
	svc := automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		nil,
		logging.Default,
		automation.WithApprovalSubmitLineageStore(lineage),
	)

	pending, err := svc.HasPendingApprovalInstance(events.WithLineage(context.Background(), chainID, 0), automation.SubmitRequest{RecordID: targetID}, submitTestUser())
	if err != nil {
		t.Fatalf("HasPendingApprovalInstance() error = %v", err)
	}
	if !pending {
		t.Fatal("HasPendingApprovalInstance() = false, want lineage-backed true")
	}
	if len(persistence.recordTypeCalls) != 0 {
		t.Fatalf("GetRecordType calls = %v, want no first-match fallback", persistence.recordTypeCalls)
	}
}

func TestApprovalService_HasPendingApprovalInstance_PendingDefinitionDisambiguatesInactiveProcessCollision(t *testing.T) {
	const (
		targetID  = "shared-pending-id"
		processID = "pd-account-inactive"
	)
	persistence := &submitPersistenceStub{objectTypeCandidates: map[string][]string{
		targetID: {"Account", constants.TableAuditOutbox},
	}}
	query := &submitQueryStub{onQuery: func(_ context.Context, objectName, filter string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
		switch objectName {
		case constants.TableProcessInstance:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "pi-1",
				constants.ColProcessInstance_ProcessDefinitionID: processID,
				"TargetObjectId": targetID,
				"Status":         constants.ApprovalStatusPending,
			}}}, nil
		case constants.TableProcessDefinition:
			if strings.Contains(filter, "Id == '"+processID+"'") {
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: processID,
					"TableEnumOrId":   "Account",
					"State":           "Inactive",
					"Metadata":        "{}",
				}}}, nil
			}
		case "Account":
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: targetID}}}, nil
		}
		return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
	}}

	pending, err := newSubmitTestService(persistence, query).HasPendingApprovalInstance(context.Background(), automation.SubmitRequest{RecordID: targetID}, submitTestUser())
	if err != nil {
		t.Fatalf("HasPendingApprovalInstance() error = %v", err)
	}
	if !pending {
		t.Fatal("HasPendingApprovalInstance() = false, want pending-instance-backed true")
	}
	if len(persistence.recordTypeCalls) != 0 {
		t.Fatalf("GetRecordType calls = %v, want no first-match fallback", persistence.recordTypeCalls)
	}
}

// TestApprovalService_Submit_ObjectHint_StaleTargetTable_FailsClosedRetryable is
// the NX#3478 regression at the submit boundary: with the object supplied, the
// target is read via its own table (GetRaw). If that read errors (a transient
// stale-InfoSchema 1146 on a freshly-created object's own table on multi-node
// TiDB, or any infra error), Submit must fail closed with a RETRYABLE Internal —
// never a false NotFound that silently drops the approval — and must not mutate.
func TestApprovalService_Submit_ObjectHint_StaleTargetTable_FailsClosedRetryable(t *testing.T) {
	persistence := &submitPersistenceStub{
		getRawErr: errors.New("Error 1146: Table 'app.Widget__c' doesn't exist"),
	}
	query := &submitQueryStub{}
	svc := newSubmitTestService(persistence, query)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "Widget__c",
		RecordID:      "wid-fresh-1",
	}, submitTestUser())
	if appErrors.IsNotFound(err) {
		t.Fatalf("Submit() returned NotFound for a stale-table read error (NX#3478); want retryable Internal")
	}
	if !appErrors.IsInternal(err) {
		t.Fatalf("Submit() error = %T %v, want Internal (retryable)", err, err)
	}
	if len(persistence.recordTypeCalls) != 0 {
		t.Fatalf("GetRecordType calls = %v, want 0 — a stale target read must not degrade to the reverse scan", persistence.recordTypeCalls)
	}
	if len(query.calls) != 0 || len(persistence.inserts) != 0 {
		t.Fatalf("no approval work should run on a failed target resolution (query=%d inserts=%d)", len(query.calls), len(persistence.inserts))
	}
}

// TestApprovalService_Submit_ObjectHint_ResolvesViaOwnReadPath verifies the
// happy path takes the scoped read (GetRaw on the hinted object) and does NOT
// fall back to the full-table reverse scan (GetRecordType) when the record is
// present under the asserted object.
func TestApprovalService_Submit_ObjectHint_ResolvesViaOwnReadPath(t *testing.T) {
	persistence := &submitPersistenceStub{getRawObject: "Widget__c"}
	query := &submitQueryStub{}
	svc := newSubmitTestService(persistence, query)

	_, _ = svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "Widget__c",
		RecordID:      "wid-1",
	}, submitTestUser())

	if len(persistence.getRawCalls) == 0 {
		t.Fatal("GetRaw was not called — submit must resolve via the object's own read path")
	}
	if len(persistence.recordTypeCalls) != 0 {
		t.Fatalf("GetRecordType calls = %v, want 0 — the reverse scan must be skipped when the object is known and present", persistence.recordTypeCalls)
	}
}

func TestApprovalService_ResolveSubmitEligibility_PreservesCanonicalObjectAPIName(t *testing.T) {
	persistence := &submitPersistenceStub{recordType: "ns__Thing__c"}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "ns__Thing__c":
				return &models.QueryResult{
					TotalSize: 1,
					Records:   []models.SObject{{constants.FieldID: "a01canon"}},
				}, nil
			case constants.TableProcessDefinition:
				if !strings.Contains(filter, "TableEnumOrId == 'ns__Thing__c'") {
					t.Fatalf("ProcessDefinition filter = %q, want canonical object api name preserved", filter)
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-1",
					"TableEnumOrId":   "ns__Thing__c",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        "{}",
				}}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	eligibility, err := svc.ResolveSubmitEligibility(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "ns__Thing__c",
		RecordID:      "a01canon",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("ResolveSubmitEligibility() error = %v", err)
	}
	if got := eligibility.ObjectAPIName; got != "ns__Thing__c" {
		t.Fatalf("eligibility.ObjectAPIName = %q, want %q", got, "ns__Thing__c")
	}
}

func TestApprovalService_ResolveSubmitEligibilityCachesCrossObjectCriteriaLookups(t *testing.T) {
	persistence := &submitPersistenceStub{recordType: "WorkItem__c"}
	criteriaForRegion := func(region string) string {
		return fmt.Sprintf(`{"criteriaItems":[{"field":"Related__c.Region__c","operation":"equals","value":%q}]}`, region)
	}
	definitions := []models.SObject{
		{
			constants.FieldID: "pd-first",
			"TableEnumOrId":   "WorkItem__c",
			"State":           "Active",
			"EntryFilter":     criteriaForRegion("APAC"),
		},
		{
			constants.FieldID: "pd-second",
			"TableEnumOrId":   "WorkItem__c",
			"State":           "Active",
			"EntryFilter":     criteriaForRegion("EMEA"),
		},
		{
			constants.FieldID: "pd-match",
			"TableEnumOrId":   "WorkItem__c",
			"State":           "Active",
			"EntryFilter":     criteriaForRegion("NA"),
		},
	}
	var relatedReadCount int
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "WorkItem__c":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "work-1",
					"Related__cId":    "related-1",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: len(definitions), Records: definitions}, nil
			case "Related__c":
				relatedReadCount++
				if filter != "Id == 'related-1'" {
					t.Fatalf("related record filter = %q, want canonical related id filter", filter)
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "related-1",
					"Region__c":       "NA",
				}}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	eligibility, err := svc.ResolveSubmitEligibility(context.Background(), automation.SubmitRequest{
		RecordID: "work-1",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("ResolveSubmitEligibility() error = %v", err)
	}
	if got := eligibility.ProcessDefinitionID; got != "pd-match" {
		t.Fatalf("ProcessDefinitionID = %q, want %q", got, "pd-match")
	}
	if relatedReadCount != 1 {
		t.Fatalf("related record reads = %d, want 1 per eligibility resolution", relatedReadCount)
	}
}

func TestApprovalService_ResolveSubmitEligibilityCachesRootCriteriaSnapshotAcrossCandidates(t *testing.T) {
	persistence := &submitPersistenceStub{recordType: "WorkItem__c"}
	definitions := []models.SObject{
		{constants.FieldID: "pd-first", "TableEnumOrId": "WorkItem__c", "State": "Active", "EntryFilter": "Region__c equals APAC"},
		{constants.FieldID: "pd-second", "TableEnumOrId": "WorkItem__c", "State": "Active", "EntryFilter": "Region__c equals EMEA"},
		{constants.FieldID: "pd-match", "TableEnumOrId": "WorkItem__c", "State": "Active", "EntryFilter": "Region__c equals NA"},
	}
	rootReads := 0
	query := &submitQueryStub{
		onQuery: func(_ context.Context, objectName, _ string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
			switch objectName {
			case "WorkItem__c":
				rootReads++
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "work-1",
					"Region__c":       "NA",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: len(definitions), Records: definitions}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s", objectName)
			}
		},
	}

	eligibility, err := newSubmitTestService(persistence, query).ResolveSubmitEligibility(context.Background(), automation.SubmitRequest{
		RecordID: "work-1",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("ResolveSubmitEligibility() error = %v", err)
	}
	if got := eligibility.ProcessDefinitionID; got != "pd-match" {
		t.Fatalf("ProcessDefinitionID = %q, want %q", got, "pd-match")
	}
	// One user-visible target load plus one field-capped system snapshot. The
	// three ProcessOrder candidates must not reread the same root record.
	if rootReads != 2 {
		t.Fatalf("root record reads = %d, want 2 (visible + one criteria snapshot)", rootReads)
	}
}

func TestApprovalService_Submit_CanonicalTargetIDWithSkipEntryCriteria(t *testing.T) {
	persistence := &submitPersistenceStub{
		recordType:   "Lead",
		getRawRecord: models.SObject{constants.FieldID: "00Qcanon", "Email": "approver@example.test"},
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{
					TotalSize: 1,
					Records:   []models.SObject{{constants.FieldID: "00Qcanon", "Status": "Qualified"}},
				}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{processDefinitionRecord("Status equals Qualified")}}, nil
			case constants.TableProcessInstance:
				return emptyResult(), nil
			case constants.TableProcessNode:
				return emptyResult(), nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 2, Records: []models.SObject{
					{
						"Name":         "FU-1",
						"ActionType":   "FieldUpdate",
						"FieldApiName": "Status",
						"FieldValue":   "Approved",
					},
					{
						"Name":            "Alert-1",
						"ActionType":      "Alert",
						"EmailTemplateId": "00Xtemplate",
					},
				}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	result, err := svc.Submit(context.Background(), automation.SubmitRequest{
		RecordID:          " 00Qcanon ",
		Comments:          "hello",
		SkipEntryCriteria: true,
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}

	assertNoSubmitQueryUsesRawID(t, query.calls, " 00Qcanon ")
	assertSubmitInsertsUseCanonicalID(t, persistence.inserts, "00Qcanon")
	if len(persistence.updates) != 1 {
		t.Fatalf("Update calls = %d, want 1 FieldUpdate action", len(persistence.updates))
	}
	if got := persistence.updates[0].id; got != "00Qcanon" {
		t.Fatalf("FieldUpdate target id = %q, want canonical %q", got, "00Qcanon")
	}
	if got := persistence.getRawCalls; len(got) == 0 {
		t.Fatalf("GetRaw ids = %v, want canonical target reads for submit FieldUpdate planning", got)
	} else {
		for _, id := range got {
			if id != "00Qcanon" {
				t.Fatalf("GetRaw ids = %v, want only canonical target id %q even when ProcessAction.Alert is disabled", got, "00Qcanon")
			}
		}
	}
	if persistence.runPessTxCalls != 1 {
		t.Fatalf("RunInPessimisticTransaction calls = %d, want 1", persistence.runPessTxCalls)
	}
	if persistence.runTxCalls != 1 {
		t.Fatalf("RunInTransaction calls = %d, want 1 inner execution", persistence.runTxCalls)
	}
	if got, _ := result["TargetObjectId"].(string); got != "00Qcanon" {
		t.Fatalf("response TargetObjectId = %q, want canonical %q", got, "00Qcanon")
	}
	failures, ok := result["ActionFailures"].([]automation.ProcessActionFailure)
	if !ok || len(failures) != 1 {
		t.Fatalf("ActionFailures = %#v, want one cutover failure", result["ActionFailures"])
	}
	if failures[0].ActionName != "Alert-1" || failures[0].ActionType != constants.ProcessActionTypeAlert {
		t.Fatalf("ActionFailures[0] = %#v, want Alert-1 Alert", failures[0])
	}
	for _, want := range []string{"WorkflowAlert runtime dispatch is disabled", "Flow sendEmail", "remove it"} {
		if !strings.Contains(failures[0].Reason, want) {
			t.Fatalf("ActionFailures[0].Reason = %q, want mention %q", failures[0].Reason, want)
		}
	}
}

func TestApprovalService_Submit_CanonicalTargetIDWithEmptyEntryFilter(t *testing.T) {
	persistence := &submitPersistenceStub{recordType: "Lead"}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{
					TotalSize: 1,
					Records:   []models.SObject{{constants.FieldID: "00Qcanon2"}},
				}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{processDefinitionRecord("")}}, nil
			case constants.TableProcessInstance, constants.TableProcessNode, constants.TableProcessAction:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	result, err := svc.Submit(context.Background(), automation.SubmitRequest{
		RecordID: " 00Qcanon2 ",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}

	assertNoSubmitQueryUsesRawID(t, query.calls, " 00Qcanon2 ")
	assertSubmitInsertsUseCanonicalID(t, persistence.inserts, "00Qcanon2")
	if got, _ := result["TargetObjectId"].(string); got != "00Qcanon2" {
		t.Fatalf("response TargetObjectId = %q, want canonical %q", got, "00Qcanon2")
	}
}

func TestApprovalService_Submit_PendingRecheckFailureRollsBackBeforeMutation(t *testing.T) {
	persistence := &submitPersistenceStub{recordType: "Lead", rollbackOnError: true}
	pendingChecks := 0
	query := &submitQueryStub{
		onQuery: func(_ context.Context, objectName, _ string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "00Qpending"}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{processDefinitionRecord("")}}, nil
			case constants.TableProcessNode, constants.TableProcessAction:
				return emptyResult(), nil
			case constants.TableProcessInstance:
				pendingChecks++
				if pendingChecks == 1 {
					return emptyResult(), nil
				}
				return nil, errors.New("pending read unavailable")
			default:
				return nil, fmt.Errorf("unexpected query: %s", objectName)
			}
		},
	}

	_, err := newSubmitTestService(persistence, query).Submit(context.Background(), automation.SubmitRequest{
		RecordID: "00Qpending",
	}, submitTestUser())
	if err == nil || !strings.Contains(err.Error(), "failed to check for an existing pending approval") {
		t.Fatalf("Submit() error = %v, want fail-closed pending recheck error", err)
	}
	if pendingChecks != 2 {
		t.Fatalf("pending checks = %d, want early check plus locked transaction recheck", pendingChecks)
	}
	if len(persistence.parentLocks) != 1 || len(persistence.parentLocks[0].keys) == 0 ||
		!slices.Contains(persistence.parentLocks[0].keys, recordlocks.ParentKey{ObjectName: "Lead", RecordID: "00Qpending"}) {
		t.Fatalf("parent locks = %#v, want target-row serialization lock", persistence.parentLocks)
	}
	if len(persistence.inserts) != 0 || len(persistence.updates) != 0 {
		t.Fatalf("mutations after failed pending recheck: inserts=%#v updates=%#v", persistence.inserts, persistence.updates)
	}
}

func TestRuntimeApprovalPersistenceRouterForwardsSubmitLockCoordinator(t *testing.T) {
	baseStub := &submitPersistenceStub{}
	wantPlanned := recordlocks.ParentKey{ObjectName: "Opportunity", RecordID: "opp-planned"}
	base := &submitPlannedParentPersistence{
		submitPersistenceStub: baseStub,
		plannedKeys:           []recordlocks.ParentKey{wantPlanned},
	}
	router, err := automation.NewRuntimeApprovalPersistenceRouter(base)
	if err != nil {
		t.Fatal(err)
	}
	want := recordlocks.ParentKey{ObjectName: "Lead", RecordID: "00Qlock"}
	if err := router.RunWithParentLockCoordinator(t.Context(), func(ctx context.Context) error {
		held, lockErr := router.LockParentRows(ctx, []recordlocks.ParentKey{want})
		if lockErr != nil {
			return lockErr
		}
		if !slices.Contains(held, want) {
			t.Fatalf("held locks = %#v, want %#v", held, want)
		}
		return nil
	}); err != nil {
		t.Fatal(err)
	}
	if base.runCoordCalls != 1 || len(base.parentLocks) != 1 ||
		!slices.Contains(base.parentLocks[0].keys, want) {
		t.Fatalf("forwarded lock calls: coordinator=%d locks=%#v", base.runCoordCalls, base.parentLocks)
	}
	prepared := &models.PreparedRecordUpdate{Record: models.SObject{constants.FieldID: "quote-1"}, EffectiveUpdates: models.SObject{"Status": "Pending"}}
	planned, err := router.PlanPreparedUpdateParentKeys(t.Context(), "Quote", "quote-1", prepared, nil, submitTestUser())
	if err != nil {
		t.Fatal(err)
	}
	if !slices.Contains(planned, wantPlanned) || base.planCalls != 1 {
		t.Fatalf("planned keys = %#v calls=%d, want forwarded %#v", planned, base.planCalls, wantPlanned)
	}
	if err := router.PrelockPreparedUpdateParents(t.Context(), "Quote", "quote-1", prepared, nil, submitTestUser()); err != nil {
		t.Fatal(err)
	}
	if len(base.preparedPrelocks) != 1 {
		t.Fatalf("prepared prelock calls = %d, want forwarded call", len(base.preparedPrelocks))
	}
}

type runtimeApprovalImmutableIdentityStub struct {
	validated []recordlocks.ParentKey
	rejectID  string
}

func (*runtimeApprovalImmutableIdentityStub) QueryProcessDefinitions(context.Context, string, string, string, int) (*models.QueryResult, error) {
	panic("unused")
}
func (*runtimeApprovalImmutableIdentityStub) QueryProcessNodes(context.Context, string, string, string, int) (*models.QueryResult, error) {
	panic("unused")
}
func (*runtimeApprovalImmutableIdentityStub) QueryProcessActions(context.Context, string, string, string, int) (*models.QueryResult, error) {
	panic("unused")
}
func (*runtimeApprovalImmutableIdentityStub) GetProcessDefinition(context.Context, string) (models.SObject, error) {
	panic("unused")
}
func (*runtimeApprovalImmutableIdentityStub) CatalogDefinitionID(context.Context, string) (string, error) {
	panic("unused")
}
func (s *runtimeApprovalImmutableIdentityStub) ValidateImmutableIdentity(_ context.Context, objectName, id string) error {
	s.validated = append(s.validated, recordlocks.ParentKey{ObjectName: objectName, RecordID: id})
	if id == s.rejectID {
		return errors.New("identity is outside the immutable Catalog closure")
	}
	return nil
}

func (s *runtimeApprovalImmutableIdentityStub) ResolveReferenceIdentities(_ context.Context, objectName string, ids []string, _ []string) (referenceidentity.Resolution, error) {
	result := referenceidentity.Resolution{Identities: make(map[string]referenceidentity.IdentityResolution, len(ids))}
	for _, id := range ids {
		if err := s.ValidateImmutableIdentity(context.Background(), objectName, id); err != nil {
			return referenceidentity.Resolution{}, err
		}
		result.Identities[id] = referenceidentity.IdentityResolution{
			Disposition: referenceidentity.Immutable, Record: models.SObject{constants.FieldID: id},
		}
	}
	return result, nil
}

func TestRuntimeApprovalPersistenceRouterDelegatesParentIdentityOwnershipToRecords(t *testing.T) {
	base := &submitPersistenceStub{}
	router, err := automation.NewRuntimeApprovalPersistenceRouter(base)
	if err != nil {
		t.Fatal(err)
	}
	authority := &runtimeApprovalImmutableIdentityStub{}
	definition := recordlocks.ParentKey{ObjectName: constants.TableProcessDefinition, RecordID: "approval.catalog.v1"}
	node := recordlocks.ParentKey{ObjectName: constants.TableProcessNode, RecordID: "catalog-node-v1"}
	target := recordlocks.ParentKey{ObjectName: "Account", RecordID: "001-target"}
	actor := recordlocks.ParentKey{ObjectName: constants.TableUser, RecordID: "005-actor"}

	ctx := automation.WithRuntimeApprovalAuthority(t.Context(), authority)
	if err := router.RunWithParentLockCoordinator(ctx, func(txCtx context.Context) error {
		held, lockErr := router.LockParentRows(txCtx, []recordlocks.ParentKey{definition, node, target, actor})
		if lockErr != nil {
			return lockErr
		}
		if !slices.Contains(held, definition) || !slices.Contains(held, node) ||
			!slices.Contains(held, target) || !slices.Contains(held, actor) {
			t.Fatalf("held locks = %#v, want the base Records result unchanged", held)
		}
		return nil
	}); err != nil {
		t.Fatal(err)
	}
	if len(authority.validated) != 0 {
		t.Fatalf("router performed reference validation outside Records: %#v", authority.validated)
	}
	if len(base.parentLocks) != 1 || !slices.Contains(base.parentLocks[0].keys, definition) ||
		!slices.Contains(base.parentLocks[0].keys, node) ||
		!slices.Contains(base.parentLocks[0].keys, target) || !slices.Contains(base.parentLocks[0].keys, actor) {
		t.Fatalf("delegated parent locks = %#v, want unchanged input", base.parentLocks)
	}
}

func TestApprovalService_Submit_AppendsStartedAndPendingHistoryRows(t *testing.T) {
	persistence := &submitPersistenceStub{recordType: "Lead"}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{
					TotalSize: 1,
					Records:   []models.SObject{{constants.FieldID: "00Qhistory", "Status": "Qualified"}},
				}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{processDefinitionRecord("")}}, nil
			case constants.TableProcessInstance:
				return emptyResult(), nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pn-history",
					"Name":                "Manager Review",
					"DeveloperName":       "Manager_Review",
					"ProcessDefinitionId": "pd-1",
					"StepOrder":           1,
					"ApproverType":        "user",
					"ApproverUserId":      submitTestUser().ID,
					"AllowComments":       true,
					"RequireComments":     false,
				}}}, nil
			case constants.TableProcessAction:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		RecordID: "00Qhistory",
		Comments: "please review",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "StepStatus", "Started") {
		t.Fatalf("inserts = %#v, want Started history row", persistence.inserts)
	}
	startedHasComment := false
	for _, insert := range persistence.inserts {
		if insert.tableName == constants.TableProcessInstanceHistory &&
			insert.record["StepStatus"] == "Started" &&
			insert.record["Comments"] == "please review" {
			startedHasComment = true
		}
	}
	if !startedHasComment {
		t.Fatalf("inserts = %#v, want submit comment on durable Started history row", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "OriginalActorId", submitTestUser().ID) {
		t.Fatalf("inserts = %#v, want submitter stamped as OriginalActorId on durable history", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "StepStatus", constants.ApprovalStatusPending) {
		t.Fatalf("inserts = %#v, want Pending history row", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "ActorId", submitTestUser().ID) {
		t.Fatalf("inserts = %#v, want pending history actor", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "Comments", "please review") {
		t.Fatalf("inserts = %#v, want submit comment on pending history row", persistence.inserts)
	}
}

func TestApprovalService_Submit_AutoApprovedAppendsTerminalHistoryRow(t *testing.T) {
	persistence := &submitPersistenceStub{recordType: "Lead"}
	autoApprovedDefinition := processDefinitionRecord("")
	autoApprovedDefinition["Metadata"] = `{
		"approvalStep": {
			"name": "Auto_Approve_Gate",
			"label": "Auto Approve Gate",
			"entryCriteria": {
				"criteriaItems": {
					"field": "Lead.Status",
					"operation": "equals",
					"value": "Closed"
				}
			},
			"ifCriteriaNotMet": "ApproveRecord"
		}
	}`
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{
					TotalSize: 1,
					Records:   []models.SObject{{constants.FieldID: "00Qauto", "Status": "Qualified"}},
				}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{autoApprovedDefinition}}, nil
			case constants.TableProcessInstance:
				return emptyResult(), nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pn-auto",
					"Name":                "Auto Approve Gate",
					"DeveloperName":       "Auto_Approve_Gate",
					"ProcessDefinitionId": "pd-1",
					"StepOrder":           1,
				}}}, nil
			case constants.TableProcessAction:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	result, err := svc.Submit(context.Background(), automation.SubmitRequest{
		RecordID: "00Qauto",
		Comments: "auto-approved",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if got, _ := result["InstanceStatus"].(string); got != constants.ApprovalStatusApproved {
		t.Fatalf("response InstanceStatus = %q, want Approved", got)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "StepStatus", constants.ApprovalStatusApproved) {
		t.Fatalf("inserts = %#v, want terminal approved history row for auto-approved submit", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "Comments", "auto-approved") {
		t.Fatalf("inserts = %#v, want submit comments on terminal approved history row", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "OriginalActorId", submitTestUser().ID) {
		t.Fatalf("inserts = %#v, want submitter stamped as OriginalActorId on auto-approved history", persistence.inserts)
	}
	if hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "StepStatus", constants.ApprovalStatusPending) {
		t.Fatalf("inserts = %#v, want no pending history row for auto-approved submit", persistence.inserts)
	}
}

func TestApprovalService_Submit_RejectRecordTerminatesRejectedWithoutWorkitem(t *testing.T) {
	persistence := &submitPersistenceStub{recordType: "Lead"}
	definition := processDefinitionRecord("")
	definition["Metadata"] = `{
		"approvalStep": {
			"name": "Reject_Gate",
			"label": "Reject Gate",
			"entryCriteria": {
				"criteriaItems": {"field": "Status", "operation": "equals", "value": "Closed"}
			},
			"ifCriteriaNotMet": "rejectrecord"
		}
	}`
	var actionFilters []string
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "00Qreject",
					"Status":          "Qualified",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{definition}}, nil
			case constants.TableProcessInstance:
				return emptyResult(), nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pn-reject",
					"Name":                "Reject Gate",
					"DeveloperName":       "Reject_Gate",
					"ProcessDefinitionId": "pd-1",
					"StepOrder":           1,
				}}}, nil
			case constants.TableProcessAction:
				actionFilters = append(actionFilters, filter)
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	result, err := svc.Submit(context.Background(), automation.SubmitRequest{RecordID: "00Qreject"}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if got := result["InstanceStatus"]; got != constants.ApprovalStatusRejected {
		t.Fatalf("InstanceStatus = %v, want %s", got, constants.ApprovalStatusRejected)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstance, "Status", constants.ApprovalStatusRejected) || !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "StepStatus", constants.ApprovalStatusRejected) {
		t.Fatalf("inserts = %#v, want rejected instance and terminal history", persistence.inserts)
	}
	if hasInsertValue(persistence.inserts, constants.TableProcessInstanceNode, "ProcessNodeId", "pn-reject") || hasInsertValue(persistence.inserts, constants.TableProcessInstanceWorkitem, "ProcessInstanceId", result["ProcessInstanceId"]) {
		t.Fatalf("inserts = %#v, want no node or workitem", persistence.inserts)
	}
	filters := strings.Join(actionFilters, "\n")
	if !strings.Contains(filters, "ActionEvent == 'FinalRejection'") || strings.Contains(filters, "ActionEvent == 'FinalApproval'") {
		t.Fatalf("ProcessAction filters = %q, want FinalRejection only", filters)
	}
}

func TestApprovalService_Submit_ProcessActionFetchErrorRollsBack(t *testing.T) {
	actionFetchErr := errors.New("process action query failed")
	persistence := &submitPersistenceStub{
		recordType:      "Lead",
		getRawRecord:    models.SObject{constants.FieldID: "00Qfetcherr"},
		rollbackOnError: true,
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{
					TotalSize: 1,
					Records:   []models.SObject{{constants.FieldID: "00Qfetcherr"}},
				}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{processDefinitionRecord("")}}, nil
			case constants.TableProcessInstance, constants.TableProcessNode:
				return emptyResult(), nil
			case constants.TableProcessAction:
				return nil, actionFetchErr
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		RecordID: "00Qfetcherr",
	}, submitTestUser())
	if !errors.Is(err, actionFetchErr) {
		t.Fatalf("Submit() error = %T %v, want wrapped process action fetch error", err, err)
	}
	if !appErrors.IsInternal(err) {
		t.Fatalf("Submit() error = %T %v, want internal app error classification", err, err)
	}
	if len(persistence.inserts) != 0 {
		t.Fatalf("Insert calls after rolled-back Submit = %d, want 0", len(persistence.inserts))
	}
	if len(persistence.updates) != 0 {
		t.Fatalf("Update calls after rolled-back Submit = %d, want 0", len(persistence.updates))
	}
}

func TestApprovalService_Submit_PrelocksFieldUpdateParentsBeforeRuntimeRows(t *testing.T) {
	persistence := &submitPersistenceStub{
		recordType: "Credits_Topup__c",
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Credits_Topup__c":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "topup-1",
					"Name":            "Topup 1",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-1",
					"TableEnumOrId":   "Credits_Topup__c",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        `{"steps":[{"id":"step-1","name":"Step 1","label":"Step 1","stepOrder":1}]}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "node-1",
					"ProcessDefinitionId": "pd-1",
					"Name":                "Step 1",
					"StepOrder":           1,
					"ProcessNodeType":     "ApprovalStep",
					"ApproverType":        "User",
					"ApproverUserId":      "005-approver",
				}}}, nil
			case constants.TableUser:
				if strings.Contains(filter, "005-approver") {
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID: "005-approver",
						"Name":            "Approver",
					}}}, nil
				}
				return emptyResult(), nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pa-1",
					"ProcessDefinitionId": "pd-1",
					"ProcessNodeId":       "",
					"ActionEvent":         "Submit",
					"ActionType":          constants.ProcessActionTypeFieldUpdate,
					"Name":                "Submit Sets Pending",
					"FieldApiName":        "Approval_Status__c",
					"FieldValue":          "Pending",
					"Order":               1,
				}}}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "Credits_Topup__c",
		RecordID:      "topup-1",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if len(persistence.prelocks) != 1 {
		t.Fatalf("PrelockUpdateParents calls = %d, want 1", len(persistence.prelocks))
	}
	if got := persistence.prelocks[0]; got.tableName != "Credits_Topup__c" || got.id != "topup-1" || got.updates["Approval_Status__c"] != "Pending" {
		t.Fatalf("PrelockUpdateParents call = %#v, want Credits_Topup__c/topup-1 Approval_Status__c=Pending", got)
	}
	if len(persistence.inserts) < 3 {
		t.Fatalf("Insert calls = %d, want ProcessInstance/Node/Workitem inserts", len(persistence.inserts))
	}
	if len(persistence.updates) != 1 {
		t.Fatalf("Update calls = %d, want 1", len(persistence.updates))
	}
	prelockIdx := indexOfSubmitEvent(persistence.events, "prelock:Credits_Topup__c:topup-1")
	updateIdx := indexOfSubmitEvent(persistence.events, "update:Credits_Topup__c:topup-1")
	processInstanceIdx := indexOfSubmitEvent(persistence.events, "insert:"+constants.TableProcessInstance)
	historyIdx := indexOfSubmitEvent(persistence.events, "insert:"+constants.TableProcessInstanceHistory)
	nodeIdx := indexOfSubmitEvent(persistence.events, "insert:"+constants.TableProcessInstanceNode)
	workitemIdx := indexOfSubmitEvent(persistence.events, "insert:"+constants.TableProcessInstanceWorkitem)
	if prelockIdx < 0 || updateIdx < 0 || processInstanceIdx < 0 || historyIdx < 0 || nodeIdx < 0 || workitemIdx < 0 {
		t.Fatalf("events = %#v, want prelock/update plus ProcessInstance/History/Node/Workitem inserts", persistence.events)
	}
	for label, idx := range map[string]int{
		"ProcessInstance insert":         processInstanceIdx,
		"ProcessInstanceHistory insert":  historyIdx,
		"ProcessInstanceNode insert":     nodeIdx,
		"ProcessInstanceWorkitem insert": workitemIdx,
	} {
		if prelockIdx > idx || updateIdx > idx {
			t.Fatalf("events = %#v, want submit prelock/update before %s", persistence.events, label)
		}
	}
}

func TestApprovalService_Submit_ParentLockCoordinatorScopeSpansRuntimeRows(t *testing.T) {
	basePersistence := &submitPersistenceStub{
		recordType: "Credits_Topup__c",
	}
	plannedParent := recordlocks.ParentKey{ObjectName: "Opportunity", RecordID: "opp-derived-before-runtime"}
	persistence := &submitPlannedParentPersistence{
		submitPersistenceStub: basePersistence,
		plannedKeys:           []recordlocks.ParentKey{plannedParent},
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Credits_Topup__c":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "topup-1",
					"Name":            "Topup 1",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-1",
					"TableEnumOrId":   "Credits_Topup__c",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        `{"steps":[{"id":"step-1","name":"Step 1","label":"Step 1","stepOrder":1}]}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "node-1",
					"ProcessDefinitionId": "pd-1",
					"Name":                "Step 1",
					"StepOrder":           1,
					"ProcessNodeType":     "ApprovalStep",
					"ApproverType":        "User",
					"ApproverUserId":      "005-approver",
				}}}, nil
			case constants.TableUser:
				if strings.Contains(filter, "005-approver") {
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID: "005-approver",
						"Name":            "Approver",
					}}}, nil
				}
				return emptyResult(), nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pa-1",
					"ProcessDefinitionId": "pd-1",
					"ProcessNodeId":       "",
					"ActionEvent":         "Submit",
					"ActionType":          constants.ProcessActionTypeFieldUpdate,
					"Name":                "Submit Sets Pending",
					"FieldApiName":        "Approval_Status__c",
					"FieldValue":          "Pending",
					"Order":               1,
				}}}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "Credits_Topup__c",
		RecordID:      "topup-1",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if persistence.runCoordCalls != 1 {
		t.Fatalf("RunWithParentLockCoordinator calls = %d, want 1", persistence.runCoordCalls)
	}
	if len(persistence.prelocks) == 0 {
		t.Fatalf("PrelockUpdateParents calls = 0, want submit field-update prelock")
	}
	submitScope := persistence.prelocks[0].parentLockScope
	if submitScope == nil {
		t.Fatal("submit field-update prelock did not receive parent lock coordinator scope")
	}
	if len(persistence.parentLocks) != 1 {
		t.Fatalf("LockParentRows calls = %d, want approval runtime parent prelock", len(persistence.parentLocks))
	}
	if persistence.parentLocks[0].parentLockScope != submitScope {
		t.Fatalf("approval runtime parent prelock used coordinator scope %p, want submit prelock scope %p", persistence.parentLocks[0].parentLockScope, submitScope)
	}
	wantRuntimeParents := []recordlocks.ParentKey{
		{ObjectName: "Credits_Topup__c", RecordID: "topup-1"},
		plannedParent,
		{ObjectName: constants.TableProcessDefinition, RecordID: "pd-1"},
		{ObjectName: constants.TableProcessNode, RecordID: "node-1"},
		{ObjectName: constants.TableUser, RecordID: "005-approver"},
		{ObjectName: constants.TableUser, RecordID: "005-submit-user"},
	}
	if persistence.planCalls != 1 {
		t.Fatalf("PlanPreparedUpdateParentKeys calls = %d, want one early lifecycle parent plan", persistence.planCalls)
	}
	if len(persistence.parentLocks[0].keys) != len(wantRuntimeParents) {
		t.Fatalf("approval runtime parent keys = %#v, want %#v", persistence.parentLocks[0].keys, wantRuntimeParents)
	}
	for i := range wantRuntimeParents {
		if persistence.parentLocks[0].keys[i] != wantRuntimeParents[i] {
			t.Fatalf("approval runtime parent key[%d] = %#v, want %#v; full=%#v", i, persistence.parentLocks[0].keys[i], wantRuntimeParents[i], persistence.parentLocks[0].keys)
		}
	}
	if lockIdx, insertIdx := indexOfSubmitEvent(persistence.events, "parentlock:"+constants.TableProcessNode+":node-1"), indexOfSubmitEvent(persistence.events, "insert:"+constants.TableProcessInstance); lockIdx == -1 || insertIdx == -1 || lockIdx > insertIdx {
		t.Fatalf("events = %#v, want ProcessNode parent prelock before ProcessInstance insert", persistence.events)
	}
	for _, tableName := range []string{
		constants.TableProcessInstance,
		constants.TableProcessInstanceHistory,
		constants.TableProcessInstanceNode,
		constants.TableProcessInstanceWorkitem,
	} {
		var insertScope *recordlocks.Scope
		for _, insert := range persistence.inserts {
			if insert.tableName == tableName {
				insertScope = insert.parentLockScope
				break
			}
		}
		if insertScope == nil {
			t.Fatalf("%s insert did not receive parent lock coordinator scope; inserts=%#v", tableName, persistence.inserts)
		}
		if insertScope != submitScope {
			t.Fatalf("%s insert used coordinator scope %p, want submit prelock scope %p", tableName, insertScope, submitScope)
		}
	}
}

func TestApprovalService_Submit_OrderedLookupFieldUpdatesPrelockAndExecuteSameResolvedTarget(t *testing.T) {
	records := map[string]models.SObject{
		"GenericObject/target-ordered": {
			constants.FieldID: "target-ordered",
			"ParentId":        "",
		},
		"RelatedObject/parent-after": {
			constants.FieldID: "parent-after",
		},
	}
	persistence := &submitPersistenceStub{
		recordType:    "GenericObject",
		getRawRecords: records,
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "GenericObject":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "target-ordered",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-ordered",
					"TableEnumOrId":   "GenericObject",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        `{"steps":[{"id":"step-1","name":"Step 1","label":"Step 1","stepOrder":1}]}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "node-ordered",
					"ProcessDefinitionId": "pd-ordered",
					"Name":                "Step 1",
					"StepOrder":           1,
					"ProcessNodeType":     "ApprovalStep",
					"ApproverType":        "User",
					"ApproverUserId":      "005-approver",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 2, Records: []models.SObject{
					{
						constants.FieldID:     "pa-set-parent",
						"ProcessDefinitionId": "pd-ordered",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Set Parent",
						"FieldApiName":        "ParentId",
						"FieldValue":          "parent-after",
						"Order":               1,
					},
					{
						constants.FieldID:     "pa-update-parent",
						"ProcessDefinitionId": "pd-ordered",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Update Parent",
						"FieldApiName":        "ParentId.LifecycleState",
						"FieldValue":          "pending",
						"Order":               2,
					},
				}}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	schema := lifecycleSchemaStub{schemas: map[string]*models.ObjectMetadata{
		"GenericObject": {
			APIName: "GenericObject",
			FieldsMap: map[string]*models.FieldMetadata{
				"ParentId": {APIName: "ParentId", ReferenceTo: []string{"RelatedObject"}},
			},
		},
	}}
	svc := automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		schema,
		logging.Default,
		automation.WithApprovalSubmitLineageStore(automation.NewMemoryApprovalSubmitLineageStore()),
	)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "GenericObject",
		RecordID:      "target-ordered",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if len(persistence.prelocks) != 2 {
		t.Fatalf("PrelockUpdateParents calls = %d, want 2", len(persistence.prelocks))
	}
	if got := persistence.prelocks[1]; got.tableName != "RelatedObject" || got.id != "parent-after" || got.updates["LifecycleState"] != "pending" {
		t.Fatalf("prelocks[1] = %#v, want RelatedObject/parent-after LifecycleState=pending", got)
	}
	if len(persistence.updates) != 2 {
		t.Fatalf("Update calls = %d, want 2", len(persistence.updates))
	}
	if got := persistence.updates[1]; got.tableName != "RelatedObject" || got.id != "parent-after" || got.updates["LifecycleState"] != "pending" {
		t.Fatalf("updates[1] = %#v, want RelatedObject/parent-after LifecycleState=pending", got)
	}
}

func TestApprovalService_Submit_PrelockPlanExecutesBeforeIntermediateLookupMutation(t *testing.T) {
	records := map[string]models.SObject{
		"GenericObject/target-frozen": {
			constants.FieldID: "target-frozen",
			"ParentId":        "parent-before",
		},
		"RelatedObject/parent-before": {
			constants.FieldID: "parent-before",
		},
		"RelatedObject/parent-after": {
			constants.FieldID: "parent-after",
		},
	}
	persistence := &submitPersistenceStub{
		recordType:    "GenericObject",
		getRawRecords: records,
		onInsert: func(tableName string, record models.SObject) {
			if tableName != constants.TableProcessInstanceWorkitem {
				return
			}
			records["GenericObject/target-frozen"]["ParentId"] = "parent-after"
		},
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "GenericObject":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "target-frozen",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-frozen",
					"TableEnumOrId":   "GenericObject",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        `{"steps":[{"id":"step-1","name":"Step 1","label":"Step 1","stepOrder":1}]}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "node-frozen",
					"ProcessDefinitionId": "pd-frozen",
					"Name":                "Step 1",
					"StepOrder":           1,
					"ProcessNodeType":     "ApprovalStep",
					"ApproverType":        "User",
					"ApproverUserId":      "005-approver",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{
					{
						constants.FieldID:     "pa-update-parent",
						"ProcessDefinitionId": "pd-frozen",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Update Parent",
						"FieldApiName":        "ParentId.LifecycleState",
						"FieldValue":          "pending",
						"Order":               1,
					},
				}}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	schema := lifecycleSchemaStub{schemas: map[string]*models.ObjectMetadata{
		"GenericObject": {
			APIName: "GenericObject",
			FieldsMap: map[string]*models.FieldMetadata{
				"ParentId": {APIName: "ParentId", ReferenceTo: []string{"RelatedObject"}},
			},
		},
	}}
	svc := automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		schema,
		logging.Default,
		automation.WithApprovalSubmitLineageStore(automation.NewMemoryApprovalSubmitLineageStore()),
	)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "GenericObject",
		RecordID:      "target-frozen",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if len(persistence.prelocks) != 1 {
		t.Fatalf("PrelockUpdateParents calls = %d, want 1", len(persistence.prelocks))
	}
	if got := persistence.prelocks[0]; got.tableName != "RelatedObject" || got.id != "parent-before" || got.updates["LifecycleState"] != "pending" {
		t.Fatalf("prelocks[0] = %#v, want RelatedObject/parent-before LifecycleState=pending", got)
	}
	if len(persistence.updates) != 1 {
		t.Fatalf("Update calls = %d, want 1", len(persistence.updates))
	}
	if got := persistence.updates[0]; got.tableName != "RelatedObject" || got.id != "parent-before" || got.updates["LifecycleState"] != "pending" {
		t.Fatalf("updates[0] = %#v, want RelatedObject/parent-before LifecycleState=pending", got)
	}
}

func TestApprovalService_Submit_OrderedFieldUpdatesUsePreviewSideEffectLookupRewrite(t *testing.T) {
	records := map[string]models.SObject{
		"GenericObject/target-preview": {
			constants.FieldID: "target-preview",
			"ParentId":        "parent-before",
			"Status__c":       "Draft",
		},
		"RelatedObject/parent-before": {
			constants.FieldID: "parent-before",
		},
		"RelatedObject/parent-after": {
			constants.FieldID: "parent-after",
		},
	}
	persistence := &submitPersistenceStub{
		recordType:    "GenericObject",
		getRawRecords: records,
		onPreviewUpdate: func(ctx context.Context, tableName string, id string, updates models.SObject, prepared *models.PreparedRecordUpdate) {
			if tableName != "GenericObject" || id != "target-preview" {
				return
			}
			if updates["Status__c"] == "Submitted" {
				prepared.Record["ParentId"] = "parent-after"
			}
		},
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "GenericObject":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "target-preview",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-preview",
					"TableEnumOrId":   "GenericObject",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        `{"steps":[{"id":"step-1","name":"Step 1","label":"Step 1","stepOrder":1}]}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "node-preview",
					"ProcessDefinitionId": "pd-preview",
					"Name":                "Step 1",
					"StepOrder":           1,
					"ProcessNodeType":     "ApprovalStep",
					"ApproverType":        "User",
					"ApproverUserId":      "005-approver",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 2, Records: []models.SObject{
					{
						constants.FieldID:     "pa-status",
						"ProcessDefinitionId": "pd-preview",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Set Status",
						"FieldApiName":        "Status__c",
						"FieldValue":          "Submitted",
						"Order":               1,
					},
					{
						constants.FieldID:     "pa-parent",
						"ProcessDefinitionId": "pd-preview",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Update Parent",
						"FieldApiName":        "ParentId.LifecycleState",
						"FieldValue":          "pending",
						"Order":               2,
					},
				}}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	schema := lifecycleSchemaStub{schemas: map[string]*models.ObjectMetadata{
		"GenericObject": {
			APIName: "GenericObject",
			FieldsMap: map[string]*models.FieldMetadata{
				"ParentId":  {APIName: "ParentId", ReferenceTo: []string{"RelatedObject"}},
				"Status__c": {APIName: "Status__c"},
			},
		},
	}}
	svc := automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		schema,
		logging.Default,
		automation.WithApprovalSubmitLineageStore(automation.NewMemoryApprovalSubmitLineageStore()),
	)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "GenericObject",
		RecordID:      "target-preview",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if len(persistence.prelocks) != 2 {
		t.Fatalf("PrelockUpdateParents calls = %d, want 2", len(persistence.prelocks))
	}
	if got := persistence.prelocks[1]; got.tableName != "RelatedObject" || got.id != "parent-after" || got.updates["LifecycleState"] != "pending" {
		t.Fatalf("prelocks[1] = %#v, want RelatedObject/parent-after LifecycleState=pending", got)
	}
	if len(persistence.updates) != 2 {
		t.Fatalf("Update calls = %d, want 2", len(persistence.updates))
	}
	if got := persistence.updates[1]; got.tableName != "RelatedObject" || got.id != "parent-after" || got.updates["LifecycleState"] != "pending" {
		t.Fatalf("updates[1] = %#v, want RelatedObject/parent-after LifecycleState=pending", got)
	}
	if len(persistence.preparedPrelocks) != 2 {
		t.Fatalf("PrelockPreparedUpdateParents calls = %d, want 2", len(persistence.preparedPrelocks))
	}
	if got := persistence.preparedPrelocks[0]; got.id != "target-preview" || got.prepared == nil || got.prepared.Record["ParentId"] != "parent-after" {
		t.Fatalf("preparedPrelocks[0] = %#v, want target-preview prepared ParentId=parent-after", got)
	}
}

func TestApprovalService_Submit_OrderedFieldUpdatesPreviewDiffHandlesNonScalarFields(t *testing.T) {
	records := map[string]models.SObject{
		"GenericObject/target-non-scalar": {
			constants.FieldID: "target-non-scalar",
			"Tags__c":         []interface{}{"alpha", "beta"},
			"Status__c":       "Draft",
		},
	}
	persistence := &submitPersistenceStub{
		recordType:    "GenericObject",
		getRawRecords: records,
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "GenericObject":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "target-non-scalar",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-non-scalar",
					"TableEnumOrId":   "GenericObject",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        `{"steps":[{"id":"step-1","name":"Step 1","label":"Step 1","stepOrder":1}]}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "node-non-scalar",
					"ProcessDefinitionId": "pd-non-scalar",
					"Name":                "Step 1",
					"StepOrder":           1,
					"ProcessNodeType":     "ApprovalStep",
					"ApproverType":        "User",
					"ApproverUserId":      "005-approver",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pa-status",
					"ProcessDefinitionId": "pd-non-scalar",
					"ProcessNodeId":       "",
					"ActionEvent":         "Submit",
					"ActionType":          constants.ProcessActionTypeFieldUpdate,
					"Name":                "Set Status",
					"FieldApiName":        "Status__c",
					"FieldValue":          "Submitted",
					"Order":               1,
				}}}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	schema := lifecycleSchemaStub{schemas: map[string]*models.ObjectMetadata{
		"GenericObject": {
			APIName: "GenericObject",
			FieldsMap: map[string]*models.FieldMetadata{
				"Status__c": {APIName: "Status__c"},
				"Tags__c":   {APIName: "Tags__c", Type: "multipicklist"},
			},
		},
	}}
	svc := automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		schema,
		logging.Default,
		automation.WithApprovalSubmitLineageStore(automation.NewMemoryApprovalSubmitLineageStore()),
	)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "GenericObject",
		RecordID:      "target-non-scalar",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if len(persistence.prelocks) != 1 {
		t.Fatalf("PrelockUpdateParents calls = %d, want 1", len(persistence.prelocks))
	}
	if got := persistence.prelocks[0]; got.tableName != "GenericObject" || got.id != "target-non-scalar" || got.updates["Status__c"] != "Submitted" {
		t.Fatalf("prelocks[0] = %#v, want GenericObject/target-non-scalar Status__c=Submitted", got)
	}
	if got := persistence.getRawRecords["GenericObject/target-non-scalar"]["Tags__c"]; got == nil {
		t.Fatal("final Tags__c = nil, want existing non-scalar field preserved")
	}
}

func TestApprovalService_Submit_OrderedFieldUpdatesKeepAccumulatedShadowAcrossPreviewCalls(t *testing.T) {
	records := map[string]models.SObject{
		"GenericObject/target-shadow": {
			constants.FieldID: "target-shadow",
			"ParentId":        "parent-before",
			"Status__c":       "Draft",
		},
		"RelatedObject/parent-before": {
			constants.FieldID: "parent-before",
		},
		"RelatedObject/parent-a": {
			constants.FieldID: "parent-a",
		},
	}
	persistence := &submitPersistenceStub{
		recordType:    "GenericObject",
		getRawRecords: records,
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "GenericObject":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "target-shadow",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-shadow",
					"TableEnumOrId":   "GenericObject",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        `{"steps":[{"id":"step-1","name":"Step 1","label":"Step 1","stepOrder":1}]}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "node-shadow",
					"ProcessDefinitionId": "pd-shadow",
					"Name":                "Step 1",
					"StepOrder":           1,
					"ProcessNodeType":     "ApprovalStep",
					"ApproverType":        "User",
					"ApproverUserId":      "005-approver",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 3, Records: []models.SObject{
					{
						constants.FieldID:     "pa-parent",
						"ProcessDefinitionId": "pd-shadow",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Set Parent",
						"FieldApiName":        "ParentId",
						"FieldValue":          "parent-a",
						"Order":               1,
					},
					{
						constants.FieldID:     "pa-status",
						"ProcessDefinitionId": "pd-shadow",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Set Status",
						"FieldApiName":        "Status__c",
						"FieldValue":          "Submitted",
						"Order":               2,
					},
					{
						constants.FieldID:     "pa-stage",
						"ProcessDefinitionId": "pd-shadow",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Update Parent Stage",
						"FieldApiName":        "ParentId.Stage__c",
						"FieldValue":          "Closed",
						"Order":               3,
					},
				}}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	schema := lifecycleSchemaStub{schemas: map[string]*models.ObjectMetadata{
		"GenericObject": {
			APIName: "GenericObject",
			FieldsMap: map[string]*models.FieldMetadata{
				"ParentId":  {APIName: "ParentId", ReferenceTo: []string{"RelatedObject"}},
				"Status__c": {APIName: "Status__c"},
			},
		},
	}}
	svc := automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		schema,
		logging.Default,
		automation.WithApprovalSubmitLineageStore(automation.NewMemoryApprovalSubmitLineageStore()),
	)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "GenericObject",
		RecordID:      "target-shadow",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if len(persistence.prelocks) != 3 {
		t.Fatalf("PrelockUpdateParents calls = %d, want 3", len(persistence.prelocks))
	}
	if got := persistence.prelocks[2]; got.tableName != "RelatedObject" || got.id != "parent-a" || got.updates["Stage__c"] != "Closed" {
		t.Fatalf("prelocks[2] = %#v, want RelatedObject/parent-a Stage__c=Closed", got)
	}
	if len(persistence.updates) != 3 {
		t.Fatalf("Update calls = %d, want 3", len(persistence.updates))
	}
	if got := persistence.updates[2]; got.tableName != "RelatedObject" || got.id != "parent-a" || got.updates["Stage__c"] != "Closed" {
		t.Fatalf("updates[2] = %#v, want RelatedObject/parent-a Stage__c=Closed", got)
	}
}

func TestApprovalService_Submit_OrderedFieldUpdatesPreviewUsesAccumulatedState(t *testing.T) {
	records := map[string]models.SObject{
		"GenericObject/target-conditional-preview": {
			constants.FieldID: "target-conditional-preview",
			"ParentId":        "parent-before",
			"Status__c":       "Draft",
		},
		"RelatedObject/parent-before": {
			constants.FieldID: "parent-before",
		},
		"RelatedObject/parent-a": {
			constants.FieldID: "parent-a",
		},
		"RelatedObject/parent-b": {
			constants.FieldID: "parent-b",
		},
	}
	persistence := &submitPersistenceStub{
		recordType:    "GenericObject",
		getRawRecords: records,
		onPreviewUpdate: func(ctx context.Context, tableName string, id string, updates models.SObject, prepared *models.PreparedRecordUpdate) {
			if tableName != "GenericObject" || id != "target-conditional-preview" {
				return
			}
			if prepared != nil && prepared.Record["ParentId"] == "parent-a" && updates["Status__c"] == "Submitted" {
				prepared.Record["ParentId"] = "parent-b"
				prepared.EffectiveUpdates["ParentId"] = "parent-b"
			}
		},
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "GenericObject":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "target-conditional-preview",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-conditional-preview",
					"TableEnumOrId":   "GenericObject",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        `{"steps":[{"id":"step-1","name":"Step 1","label":"Step 1","stepOrder":1}]}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "node-conditional-preview",
					"ProcessDefinitionId": "pd-conditional-preview",
					"Name":                "Step 1",
					"StepOrder":           1,
					"ProcessNodeType":     "ApprovalStep",
					"ApproverType":        "User",
					"ApproverUserId":      "005-approver",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 3, Records: []models.SObject{
					{
						constants.FieldID:     "pa-parent",
						"ProcessDefinitionId": "pd-conditional-preview",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Set Parent",
						"FieldApiName":        "ParentId",
						"FieldValue":          "parent-a",
						"Order":               1,
					},
					{
						constants.FieldID:     "pa-status",
						"ProcessDefinitionId": "pd-conditional-preview",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Set Status",
						"FieldApiName":        "Status__c",
						"FieldValue":          "Submitted",
						"Order":               2,
					},
					{
						constants.FieldID:     "pa-stage",
						"ProcessDefinitionId": "pd-conditional-preview",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Update Parent Stage",
						"FieldApiName":        "ParentId.Stage__c",
						"FieldValue":          "Closed",
						"Order":               3,
					},
				}}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	schema := lifecycleSchemaStub{schemas: map[string]*models.ObjectMetadata{
		"GenericObject": {
			APIName: "GenericObject",
			FieldsMap: map[string]*models.FieldMetadata{
				"ParentId":  {APIName: "ParentId", ReferenceTo: []string{"RelatedObject"}},
				"Status__c": {APIName: "Status__c"},
			},
		},
	}}
	svc := automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		schema,
		logging.Default,
		automation.WithApprovalSubmitLineageStore(automation.NewMemoryApprovalSubmitLineageStore()),
	)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "GenericObject",
		RecordID:      "target-conditional-preview",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if len(persistence.prelocks) != 3 {
		t.Fatalf("PrelockUpdateParents calls = %d, want 3", len(persistence.prelocks))
	}
	if got := persistence.prelocks[2]; got.tableName != "RelatedObject" || got.id != "parent-b" || got.updates["Stage__c"] != "Closed" {
		t.Fatalf("prelocks[2] = %#v, want RelatedObject/parent-b Stage__c=Closed", got)
	}
	if len(persistence.updates) != 3 {
		t.Fatalf("Update calls = %d, want 3", len(persistence.updates))
	}
	if got := persistence.updates[2]; got.tableName != "RelatedObject" || got.id != "parent-b" || got.updates["Stage__c"] != "Closed" {
		t.Fatalf("updates[2] = %#v, want RelatedObject/parent-b Stage__c=Closed", got)
	}
}

func TestApprovalService_Submit_OrderedFieldUpdatesPreviewUsesCrossRecordOverlayState(t *testing.T) {
	records := map[string]models.SObject{
		"GenericObject/target-cross-record-preview": {
			constants.FieldID:    "target-cross-record-preview",
			"ParentId":           "parent-live",
			"EscalationTargetId": "queue-live",
			"Status__c":          "Draft",
		},
		"ParentObject/parent-live": {
			constants.FieldID: "parent-live",
			"Stage__c":        "Open",
		},
		"QueueObject/queue-live": {
			constants.FieldID: "queue-live",
		},
		"QueueObject/queue-closed": {
			constants.FieldID: "queue-closed",
		},
	}
	persistence := &submitPersistenceStub{
		recordType:    "GenericObject",
		getRawRecords: records,
		onPreviewUpdate: func(ctx context.Context, tableName string, id string, updates models.SObject, prepared *models.PreparedRecordUpdate) {
			if tableName != "GenericObject" || id != "target-cross-record-preview" {
				return
			}
			if updates["Status__c"] != "Submitted" {
				return
			}
			parent, ok := recordoverlay.Lookup(ctx, "ParentObject", "parent-live")
			if (!ok || parent == nil) && records["ParentObject/parent-live"] != nil {
				parent = cloneSObject(records["ParentObject/parent-live"])
				ok = true
			}
			if ok && parent["Stage__c"] == "Closed" {
				prepared.Record["EscalationTargetId"] = "queue-closed"
				prepared.EffectiveUpdates["EscalationTargetId"] = "queue-closed"
			}
		},
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "GenericObject":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "target-cross-record-preview",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-cross-record-preview",
					"TableEnumOrId":   "GenericObject",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        `{"steps":[{"id":"step-1","name":"Step 1","label":"Step 1","stepOrder":1}]}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "node-cross-record-preview",
					"ProcessDefinitionId": "pd-cross-record-preview",
					"Name":                "Step 1",
					"StepOrder":           1,
					"ProcessNodeType":     "ApprovalStep",
					"ApproverType":        "User",
					"ApproverUserId":      "005-approver",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 3, Records: []models.SObject{
					{
						constants.FieldID:     "pa-parent-stage",
						"ProcessDefinitionId": "pd-cross-record-preview",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Close Parent",
						"FieldApiName":        "ParentId.Stage__c",
						"FieldValue":          "Closed",
						"Order":               1,
					},
					{
						constants.FieldID:     "pa-status",
						"ProcessDefinitionId": "pd-cross-record-preview",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Submit Record",
						"FieldApiName":        "Status__c",
						"FieldValue":          "Submitted",
						"Order":               2,
					},
					{
						constants.FieldID:     "pa-queue-state",
						"ProcessDefinitionId": "pd-cross-record-preview",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Update Escalation Queue",
						"FieldApiName":        "EscalationTargetId.QueueState__c",
						"FieldValue":          "pending",
						"Order":               3,
					},
				}}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	schema := lifecycleSchemaStub{schemas: map[string]*models.ObjectMetadata{
		"GenericObject": {
			APIName: "GenericObject",
			FieldsMap: map[string]*models.FieldMetadata{
				"ParentId":           {APIName: "ParentId", ReferenceTo: []string{"ParentObject"}},
				"EscalationTargetId": {APIName: "EscalationTargetId", ReferenceTo: []string{"QueueObject"}},
				"Status__c":          {APIName: "Status__c"},
			},
		},
	}}
	svc := automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		schema,
		logging.Default,
		automation.WithApprovalSubmitLineageStore(automation.NewMemoryApprovalSubmitLineageStore()),
	)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "GenericObject",
		RecordID:      "target-cross-record-preview",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if len(persistence.prelocks) != 3 {
		t.Fatalf("PrelockUpdateParents calls = %d, want 3", len(persistence.prelocks))
	}
	if got := persistence.prelocks[2]; got.tableName != "QueueObject" || got.id != "queue-closed" || got.updates["QueueState__c"] != "pending" {
		t.Fatalf("prelocks[2] = %#v, want QueueObject/queue-closed QueueState__c=pending", got)
	}
	if len(persistence.updates) != 3 {
		t.Fatalf("Update calls = %d, want 3", len(persistence.updates))
	}
	if got := persistence.updates[2]; got.tableName != "QueueObject" || got.id != "queue-closed" || got.updates["QueueState__c"] != "pending" {
		t.Fatalf("updates[2] = %#v, want QueueObject/queue-closed QueueState__c=pending", got)
	}
}

func TestApprovalService_Submit_OrderedFieldUpdatesPlanNextActionAfterPriorCommitSideEffects(t *testing.T) {
	records := map[string]models.SObject{
		"GenericObject/target-commit-side-effect": {
			constants.FieldID:    "target-commit-side-effect",
			"ParentId":           "parent-live",
			"EscalationTargetId": "queue-live",
			"Status__c":          "Draft",
		},
		"ParentObject/parent-live": {
			constants.FieldID: "parent-live",
			"Stage__c":        "Open",
		},
		"QueueObject/queue-live": {
			constants.FieldID: "queue-live",
		},
		"QueueObject/queue-rollup": {
			constants.FieldID: "queue-rollup",
		},
	}
	persistence := &submitPersistenceStub{
		recordType:    "GenericObject",
		getRawRecords: records,
		onPreparedCommit: func(tableName string, id string, prepared *models.PreparedRecordUpdate) {
			if tableName != "ParentObject" || id != "parent-live" || prepared == nil || prepared.Record["Stage__c"] != "Closed" {
				return
			}
			records["ParentObject/parent-live"]["RollupFlag__c"] = "ClosedRollup"
		},
		onPreviewUpdate: func(ctx context.Context, tableName string, id string, updates models.SObject, prepared *models.PreparedRecordUpdate) {
			if tableName != "GenericObject" || id != "target-commit-side-effect" || updates["Status__c"] != "Submitted" {
				return
			}
			parent, ok := recordoverlay.Lookup(ctx, "ParentObject", "parent-live")
			if (!ok || parent == nil) && records["ParentObject/parent-live"] != nil {
				parent = cloneSObject(records["ParentObject/parent-live"])
			}
			if parent == nil || parent["RollupFlag__c"] != "ClosedRollup" {
				return
			}
			prepared.Record["EscalationTargetId"] = "queue-rollup"
			prepared.EffectiveUpdates["EscalationTargetId"] = "queue-rollup"
		},
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "GenericObject":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "target-commit-side-effect",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-commit-side-effect",
					"TableEnumOrId":   "GenericObject",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        `{"steps":[{"id":"step-1","name":"Step 1","label":"Step 1","stepOrder":1}]}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "node-commit-side-effect",
					"ProcessDefinitionId": "pd-commit-side-effect",
					"Name":                "Step 1",
					"StepOrder":           1,
					"ProcessNodeType":     "ApprovalStep",
					"ApproverType":        "User",
					"ApproverUserId":      "005-approver",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 3, Records: []models.SObject{
					{
						constants.FieldID:     "pa-parent-stage",
						"ProcessDefinitionId": "pd-commit-side-effect",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Close Parent",
						"FieldApiName":        "ParentId.Stage__c",
						"FieldValue":          "Closed",
						"Order":               1,
					},
					{
						constants.FieldID:     "pa-status",
						"ProcessDefinitionId": "pd-commit-side-effect",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Submit Record",
						"FieldApiName":        "Status__c",
						"FieldValue":          "Submitted",
						"Order":               2,
					},
					{
						constants.FieldID:     "pa-queue-state",
						"ProcessDefinitionId": "pd-commit-side-effect",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Update Queue State",
						"FieldApiName":        "EscalationTargetId.QueueState__c",
						"FieldValue":          "pending",
						"Order":               3,
					},
				}}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	schema := lifecycleSchemaStub{schemas: map[string]*models.ObjectMetadata{
		"GenericObject": {
			APIName: "GenericObject",
			FieldsMap: map[string]*models.FieldMetadata{
				"ParentId":           {APIName: "ParentId", ReferenceTo: []string{"ParentObject"}},
				"EscalationTargetId": {APIName: "EscalationTargetId", ReferenceTo: []string{"QueueObject"}},
				"Status__c":          {APIName: "Status__c"},
			},
		},
	}}
	svc := automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		schema,
		logging.Default,
		automation.WithApprovalSubmitLineageStore(automation.NewMemoryApprovalSubmitLineageStore()),
	)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "GenericObject",
		RecordID:      "target-commit-side-effect",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if got := persistence.getRawRecords["GenericObject/target-commit-side-effect"]["EscalationTargetId"]; got != "queue-rollup" {
		t.Fatalf("final EscalationTargetId = %#v, want queue-rollup from prior commit side effect", got)
	}
	if got := persistence.updates[2]; got.tableName != "QueueObject" || got.id != "queue-rollup" || got.updates["QueueState__c"] != "pending" {
		t.Fatalf("updates[2] = %#v, want QueueObject/queue-rollup QueueState__c=pending", got)
	}
}

func TestApprovalService_Submit_OrderedFieldUpdatesPreviewUsesSameObjectRecordLookupOverlayState(t *testing.T) {
	records := map[string]models.SObject{
		"GenericObject/source-a": {
			constants.FieldID: "source-a",
			"Status__c":       "Open",
		},
		"GenericObject/source-b": {
			constants.FieldID:    "source-b",
			"ParentId":           "source-a",
			"EscalationTargetId": "queue-live",
			"Status__c":          "Draft",
		},
		"QueueObject/queue-live": {
			constants.FieldID: "queue-live",
		},
		"QueueObject/queue-closed": {
			constants.FieldID: "queue-closed",
		},
	}
	persistence := &submitPersistenceStub{
		recordType:    "GenericObject",
		getRawRecords: records,
		onPreviewUpdate: func(ctx context.Context, tableName string, id string, updates models.SObject, prepared *models.PreparedRecordUpdate) {
			if tableName != "GenericObject" || id != "source-b" || updates["Status__c"] != "Submitted" {
				return
			}
			var parent models.SObject
			for _, synthetic := range constants.PreviewSyntheticRecordsForObject(ctx, "GenericObject") {
				if synthetic.Fields[constants.FieldID] == "source-a" {
					parent = models.SObject(synthetic.Fields)
					break
				}
			}
			if parent == nil && records["GenericObject/source-a"] != nil {
				parent = cloneSObject(records["GenericObject/source-a"])
			}
			if parent == nil || parent["Status__c"] != "Closed" {
				return
			}
			prepared.Record["EscalationTargetId"] = "queue-closed"
			prepared.EffectiveUpdates["EscalationTargetId"] = "queue-closed"
		},
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "GenericObject":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "source-b",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-same-object-lookup",
					"TableEnumOrId":   "GenericObject",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        `{"steps":[{"id":"step-1","name":"Step 1","label":"Step 1","stepOrder":1}]}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "node-same-object-lookup",
					"ProcessDefinitionId": "pd-same-object-lookup",
					"Name":                "Step 1",
					"StepOrder":           1,
					"ProcessNodeType":     "ApprovalStep",
					"ApproverType":        "User",
					"ApproverUserId":      "005-approver",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 3, Records: []models.SObject{
					{
						constants.FieldID:     "pa-parent-close",
						"ProcessDefinitionId": "pd-same-object-lookup",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Close Source A",
						"FieldApiName":        "ParentId.Status__c",
						"FieldValue":          "Closed",
						"Order":               1,
					},
					{
						constants.FieldID:     "pa-submit-source-b",
						"ProcessDefinitionId": "pd-same-object-lookup",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Submit Source B",
						"FieldApiName":        "Status__c",
						"FieldValue":          "Submitted",
						"Order":               2,
					},
					{
						constants.FieldID:     "pa-queue-state",
						"ProcessDefinitionId": "pd-same-object-lookup",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Update Queue State",
						"FieldApiName":        "EscalationTargetId.QueueState__c",
						"FieldValue":          "pending",
						"Order":               3,
					},
				}}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	schema := lifecycleSchemaStub{schemas: map[string]*models.ObjectMetadata{
		"GenericObject": {
			APIName: "GenericObject",
			FieldsMap: map[string]*models.FieldMetadata{
				"ParentId":           {APIName: "ParentId", ReferenceTo: []string{"GenericObject"}},
				"EscalationTargetId": {APIName: "EscalationTargetId", ReferenceTo: []string{"QueueObject"}},
				"Status__c":          {APIName: "Status__c"},
			},
		},
	}}
	svc := automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		schema,
		logging.Default,
		automation.WithApprovalSubmitLineageStore(automation.NewMemoryApprovalSubmitLineageStore()),
	)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "GenericObject",
		RecordID:      "source-b",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if got := persistence.getRawRecords["GenericObject/source-b"]["EscalationTargetId"]; got != "queue-closed" {
		t.Fatalf("final EscalationTargetId = %#v, want queue-closed from same-object lookup overlay", got)
	}
	if got := persistence.updates[2]; got.tableName != "QueueObject" || got.id != "queue-closed" || got.updates["QueueState__c"] != "pending" {
		t.Fatalf("updates[2] = %#v, want QueueObject/queue-closed QueueState__c=pending", got)
	}
}

func TestApprovalService_Submit_OrderedFieldUpdatesExecuteBeforeWorkitemInsert(t *testing.T) {
	records := map[string]models.SObject{
		"GenericObject/target-prepared-exec": {
			constants.FieldID:    "target-prepared-exec",
			"EscalationTargetId": "queue-live",
			"Status__c":          "Draft",
		},
		"QueueObject/queue-before": {
			constants.FieldID: "queue-before",
		},
		"QueueObject/queue-after": {
			constants.FieldID: "queue-after",
		},
	}
	workitemInserted := false
	persistence := &submitPersistenceStub{
		recordType:    "GenericObject",
		getRawRecords: records,
		onInsert: func(tableName string, record models.SObject) {
			if tableName == constants.TableProcessInstanceWorkitem {
				workitemInserted = true
			}
		},
		onPreviewUpdate: func(ctx context.Context, tableName string, id string, updates models.SObject, prepared *models.PreparedRecordUpdate) {
			if tableName != "GenericObject" || id != "target-prepared-exec" {
				return
			}
			if updates["Status__c"] == "Submitted" {
				queueID := "queue-before"
				if workitemInserted {
					queueID = "queue-after"
				}
				prepared.Record["EscalationTargetId"] = queueID
				prepared.EffectiveUpdates["EscalationTargetId"] = queueID
			}
		},
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "GenericObject":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "target-prepared-exec",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-prepared-exec",
					"TableEnumOrId":   "GenericObject",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        `{"steps":[{"id":"step-1","name":"Step 1","label":"Step 1","stepOrder":1}]}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "node-prepared-exec",
					"ProcessDefinitionId": "pd-prepared-exec",
					"Name":                "Step 1",
					"StepOrder":           1,
					"ProcessNodeType":     "ApprovalStep",
					"ApproverType":        "User",
					"ApproverUserId":      "005-approver",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 2, Records: []models.SObject{
					{
						constants.FieldID:     "pa-status",
						"ProcessDefinitionId": "pd-prepared-exec",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Submit Record",
						"FieldApiName":        "Status__c",
						"FieldValue":          "Submitted",
						"Order":               1,
					},
					{
						constants.FieldID:     "pa-queue",
						"ProcessDefinitionId": "pd-prepared-exec",
						"ProcessNodeId":       "",
						"ActionEvent":         "Submit",
						"ActionType":          constants.ProcessActionTypeFieldUpdate,
						"Name":                "Update Queue State",
						"FieldApiName":        "EscalationTargetId.QueueState__c",
						"FieldValue":          "pending",
						"Order":               2,
					},
				}}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	schema := lifecycleSchemaStub{schemas: map[string]*models.ObjectMetadata{
		"GenericObject": {
			APIName: "GenericObject",
			FieldsMap: map[string]*models.FieldMetadata{
				"EscalationTargetId": {APIName: "EscalationTargetId", ReferenceTo: []string{"QueueObject"}},
				"Status__c":          {APIName: "Status__c"},
			},
		},
	}}
	svc := automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		schema,
		logging.Default,
		automation.WithApprovalSubmitLineageStore(automation.NewMemoryApprovalSubmitLineageStore()),
	)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "GenericObject",
		RecordID:      "target-prepared-exec",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if len(persistence.preparedCommits) != 2 {
		t.Fatalf("CommitPreparedUpdate calls = %d, want 2 before workitem insert", len(persistence.preparedCommits))
	}
	if len(persistence.prelocks) != 2 {
		t.Fatalf("PrelockUpdateParents calls = %d, want 2", len(persistence.prelocks))
	}
	if got := persistence.prelocks[1]; got.tableName != "QueueObject" || got.id != "queue-before" || got.updates["QueueState__c"] != "pending" {
		t.Fatalf("prelocks[1] = %#v, want QueueObject/queue-before QueueState__c=pending", got)
	}
	if got := persistence.getRawRecords["GenericObject/target-prepared-exec"]["EscalationTargetId"]; got != "queue-before" {
		t.Fatalf("final EscalationTargetId = %#v, want queue-before from pre-workitem submit execution", got)
	}
	if len(persistence.updates) != 2 {
		t.Fatalf("Update calls = %d, want 2", len(persistence.updates))
	}
	if got := persistence.updates[1]; got.tableName != "QueueObject" || got.id != "queue-before" || got.updates["QueueState__c"] != "pending" {
		t.Fatalf("updates[1] = %#v, want QueueObject/queue-before QueueState__c=pending", got)
	}
	if workitemInserted != true {
		t.Fatal("workitemInserted = false, want true after submit finishes")
	}
	if idxUpdate, idxWorkitem := indexOfSubmitEvent(persistence.events, "update:QueueObject:queue-before"), indexOfSubmitEvent(persistence.events, "insert:"+constants.TableProcessInstanceWorkitem); idxUpdate == -1 || idxWorkitem == -1 || idxUpdate > idxWorkitem {
		t.Fatalf("event order = %#v, want queue-before update before workitem insert", persistence.events)
	}
}

func TestApprovalService_Submit_ExclusiveSiblingTargetsBypassPreparedCommitInsideActiveTx(t *testing.T) {
	records := map[string]models.SObject{
		"ExclusiveObject/exclusive-1": {
			constants.FieldID: "exclusive-1",
			"Flag__c":         false,
		},
	}
	persistence := &submitPersistenceStub{
		recordType:    "ExclusiveObject",
		getRawRecords: records,
		txActive:      true,
	}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "ExclusiveObject":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: "exclusive-1"}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-exclusive-submit",
					"TableEnumOrId":   "ExclusiveObject",
					"State":           "Active",
					"EntryFilter":     "",
					"Metadata":        `{"steps":[{"id":"step-1","name":"Step 1","label":"Step 1","stepOrder":1}]}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "node-exclusive-submit",
					"ProcessDefinitionId": "pd-exclusive-submit",
					"Name":                "Step 1",
					"StepOrder":           1,
					"ProcessNodeType":     "ApprovalStep",
					"ApproverType":        "User",
					"ApproverUserId":      "005-approver",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pa-exclusive-flag",
					"ProcessDefinitionId": "pd-exclusive-submit",
					"ProcessNodeId":       "",
					"ActionEvent":         "Submit",
					"ActionType":          constants.ProcessActionTypeFieldUpdate,
					"Name":                "Set Flag",
					"FieldApiName":        "Flag__c",
					"FieldValue":          "true",
					"Order":               1,
				}}}, nil
			default:
				return emptyResult(), nil
			}
		},
	}
	schema := lifecycleSchemaStub{schemas: map[string]*models.ObjectMetadata{
		"ExclusiveObject": {
			APIName: "ExclusiveObject",
			FieldsMap: map[string]*models.FieldMetadata{
				"Flag__c": {APIName: "Flag__c", Type: "checkbox"},
			},
			ExclusiveSiblingFields: []models.ExclusiveSiblingField{{
				FieldAPIName:          "Flag__c",
				PartitionFieldAPIName: "OwnerId",
			}},
		},
	}}
	svc := automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		schema,
		logging.Default,
		automation.WithApprovalSubmitLineageStore(automation.NewMemoryApprovalSubmitLineageStore()),
	)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "ExclusiveObject",
		RecordID:      "exclusive-1",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v", err)
	}
	if len(persistence.preparedCommits) != 0 {
		t.Fatalf("CommitPreparedUpdate calls = %d, want 0 for ExclusiveSiblingFields target inside active tx", len(persistence.preparedCommits))
	}
	if len(persistence.updates) == 0 || persistence.updates[0].tableName != "ExclusiveObject" || persistence.updates[0].id != "exclusive-1" || persistence.updates[0].updates["Flag__c"] != "true" {
		t.Fatalf("updates[0] = %#v, want direct Update on ExclusiveObject/exclusive-1 Flag__c=true", persistence.updates)
	}
}

type retryableSubmitParentLockError struct {
	keys []recordlocks.ParentKey
}

func (e retryableSubmitParentLockError) Error() string {
	return "retryable parent lock"
}

func (e retryableSubmitParentLockError) ParentPrelockKeys() []recordlocks.ParentKey {
	return append([]recordlocks.ParentKey(nil), e.keys...)
}

func TestApprovalActionErrorPreservesParentPrelockKeys(t *testing.T) {
	want := []recordlocks.ParentKey{{ObjectName: "Account", RecordID: "001-parent"}}
	actionErr := &automation.ApprovalActionError{
		ActionName:   "Submit Sets Pending",
		ActionType:   constants.ProcessActionTypeFieldUpdate,
		TargetObject: "GenericObject",
		TargetID:     "target-1",
		Field:        "Status__c",
		Reason:       "retryable parent lock",
		Cause:        retryableSubmitParentLockError{keys: want},
	}

	var retryErr recordlocks.RetryablePrelockError
	if !errors.As(actionErr, &retryErr) {
		t.Fatalf("errors.As(... RetryablePrelockError) = false, want true")
	}
	got := retryErr.ParentPrelockKeys()
	if len(got) != 1 || got[0] != want[0] {
		t.Fatalf("ParentPrelockKeys() = %#v, want %#v", got, want)
	}
}

func TestApprovalActionErrorOrdinaryFailureDoesNotImplementParentLockRetry(t *testing.T) {
	actionErr := &automation.ApprovalActionError{
		ActionName: "Submit Sets Pending",
		ActionType: constants.ProcessActionTypeFieldUpdate,
		Reason:     "ordinary validation failure",
		Cause:      errors.New("ordinary validation failure"),
	}

	var retryErr recordlocks.RetryablePrelockError
	if errors.As(actionErr, &retryErr) {
		t.Fatalf("ordinary ApprovalActionError matched RetryablePrelockError via %T; only a genuinely retryable wrapped cause may opt into parent-lock replay", retryErr)
	}
}

func indexOfSubmitEvent(events []string, want string) int {
	for i, event := range events {
		if event == want {
			return i
		}
	}
	return -1
}

func TestApprovalService_Submit_CriteriaFalseNoMutation(t *testing.T) {
	persistence := &submitPersistenceStub{recordType: "Lead"}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case constants.TableProcessInstance:
				return emptyResult(), nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{processDefinitionRecord("Status equals Qualified")}}, nil
			case "Lead":
				if filter != "Id == '00Qcriteria'" {
					return nil, fmt.Errorf("Lead filter = %q, want canonical id filter", filter)
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "00Qcriteria",
					"Status":          "Open - Not Contacted",
				}}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		RecordID: " 00Qcriteria ",
	}, submitTestUser())
	if err == nil {
		t.Fatal("Submit() error = nil, want entry criteria validation failure")
	}
	if !errors.Is(err, automation.ErrEntryCriteriaNotMet) && !appErrors.IsValidation(err) {
		t.Fatalf("Submit() error = %T %v, want ErrEntryCriteriaNotMet/validation", err, err)
	}
	if len(persistence.inserts) != 0 {
		t.Fatalf("Insert calls = %d, want 0 when entry criteria are false", len(persistence.inserts))
	}
	assertNoSubmitQueryUsesRawID(t, query.calls, " 00Qcriteria ")
}

func newPendingCriteriaDriftSubmitTestService() (*automation.ApprovalService, *submitPersistenceStub) {
	persistence := &submitPersistenceStub{recordType: "Lead"}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "00Qpending-drift",
					"Status":          "Submitted",
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pi-pending-drift",
					constants.ColProcessInstance_ProcessDefinitionID: "pd-1",
					"TargetObjectId": "00Qpending-drift",
					"Status":         constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{
					processDefinitionRecord("Status equals Qualified"),
				}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	return newSubmitTestService(persistence, query), persistence
}

func TestApprovalService_Submit_PendingConflictPrecedesFreshEntryCriteria(t *testing.T) {
	service, persistence := newPendingCriteriaDriftSubmitTestService()
	_, err := service.Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "Lead",
		RecordID:      "00Qpending-drift",
	}, submitTestUser())
	if !errors.Is(err, automation.ErrRecordAlreadyPending) {
		t.Fatalf("Submit() error = %T %v, want ErrRecordAlreadyPending even though current entry criteria are false", err, err)
	}
	if len(persistence.inserts) != 0 {
		t.Fatalf("Insert calls = %d, want 0 for duplicate pending submit", len(persistence.inserts))
	}
}

func TestApprovalService_Submit_PendingConflictIsScopedToRequestedObject(t *testing.T) {
	const targetID = "shared-object-id"
	persistence := &submitPersistenceStub{getRawObject: "Account"}
	quoteDefinition := processDefinitionRecord("")
	quoteDefinition[constants.FieldID] = "pd-quote"
	quoteDefinition["TableEnumOrId"] = "Quote"
	query := &submitQueryStub{
		onQuery: func(_ context.Context, objectName, filter string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
			switch objectName {
			case "Account":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: targetID}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pi-quote",
					constants.ColProcessInstance_ProcessDefinitionID: "pd-quote",
					"TargetObjectId": targetID,
					"Status":         constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessDefinition:
				if strings.Contains(filter, "Id == 'pd-quote'") {
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{quoteDefinition}}, nil
				}
				if strings.Contains(filter, "TableEnumOrId == 'Account'") {
					return emptyResult(), nil
				}
			}
			return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
		},
	}

	_, err := newSubmitTestService(persistence, query).Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "Account",
		RecordID:      targetID,
	}, submitTestUser())
	if !errors.Is(err, automation.ErrNoActiveApprovalProcess) {
		t.Fatalf("Submit() error = %T %v, want Account eligibility error; Quote pending must not block Account", err, err)
	}
	if errors.Is(err, automation.ErrRecordAlreadyPending) {
		t.Fatalf("Submit() error = %v, Quote pending leaked into Account submit admission", err)
	}
	if len(persistence.inserts) != 0 {
		t.Fatalf("Insert calls = %d, want 0 when Account has no active approval process", len(persistence.inserts))
	}
}

func TestApprovalService_Submit_PendingConflictFindsRequestedObjectAfterCollision(t *testing.T) {
	const targetID = "shared-pending-object-id"
	persistence := &submitPersistenceStub{getRawObject: "Account"}
	quoteDefinition := processDefinitionRecord("")
	quoteDefinition[constants.FieldID] = "pd-quote"
	quoteDefinition["TableEnumOrId"] = "Quote"
	accountDefinition := processDefinitionRecord("")
	accountDefinition[constants.FieldID] = "pd-account"
	accountDefinition["TableEnumOrId"] = "Account"
	query := &submitQueryStub{
		onQuery: func(_ context.Context, objectName, filter string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
			switch objectName {
			case "Account":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: targetID}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 2, Records: []models.SObject{
					{
						constants.FieldID: "pi-quote",
						constants.ColProcessInstance_ProcessDefinitionID: "pd-quote",
						"TargetObjectId": targetID,
						"Status":         constants.ApprovalStatusPending,
					},
					{
						constants.FieldID: "pi-account",
						constants.ColProcessInstance_ProcessDefinitionID: "pd-account",
						"TargetObjectId": targetID,
						"Status":         constants.ApprovalStatusPending,
					},
				}}, nil
			case constants.TableProcessDefinition:
				switch {
				case strings.Contains(filter, "Id == 'pd-quote'"):
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{quoteDefinition}}, nil
				case strings.Contains(filter, "Id == 'pd-account'"):
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{accountDefinition}}, nil
				case strings.Contains(filter, "TableEnumOrId == 'Account'"):
					return emptyResult(), nil
				}
			}
			return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
		},
	}

	_, err := newSubmitTestService(persistence, query).Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "Account",
		RecordID:      targetID,
	}, submitTestUser())
	if !errors.Is(err, automation.ErrRecordAlreadyPending) {
		t.Fatalf("Submit() error = %T %v, want Account pending conflict after skipping Quote collision", err, err)
	}
	if len(persistence.inserts) != 0 {
		t.Fatalf("Insert calls = %d, want 0 for Account pending conflict", len(persistence.inserts))
	}
}

func TestApprovalService_Submit_ObjectlessSingleCandidateDoesNotSynthesizeHint(t *testing.T) {
	const targetID = "widget-stale-schema"
	persistence := &submitPersistenceStub{
		objectTypeCandidates: map[string][]string{targetID: {"Widget__c"}},
		getRawErr:            errors.New("Error 1146: Table 'app.Widget__c' doesn't exist"),
	}
	query := &submitQueryStub{
		onQuery: func(_ context.Context, objectName, _ string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
			switch objectName {
			case constants.TableProcessInstance, constants.TableProcessDefinition:
				return emptyResult(), nil
			case "Widget__c":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{constants.FieldID: targetID}}}, nil
			default:
				return nil, fmt.Errorf("unexpected query object: %s", objectName)
			}
		},
	}

	_, err := newSubmitTestService(persistence, query).Submit(context.Background(), automation.SubmitRequest{
		RecordID: targetID,
	}, submitTestUser())
	if !errors.Is(err, automation.ErrNoActiveApprovalProcess) {
		t.Fatalf("Submit() error = %T %v, want ordinary objectless eligibility result", err, err)
	}
	if len(persistence.getRawCalls) != 0 {
		t.Fatalf("GetRaw calls = %v, want objectless candidate resolution to avoid synthesized hint path", persistence.getRawCalls)
	}
}

func TestApprovalService_ResolveRuntimeSubmitEligibility_PendingConflictPrecedesFreshEntryCriteria(t *testing.T) {
	service, _ := newPendingCriteriaDriftSubmitTestService()
	_, err := service.ResolveRuntimeSubmitEligibility(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "Lead",
		RecordID:      "00Qpending-drift",
	}, submitTestUser())
	if !errors.Is(err, automation.ErrRecordAlreadyPending) {
		t.Fatalf("ResolveRuntimeSubmitEligibility() error = %T %v, want ErrRecordAlreadyPending even though current entry criteria are false", err, err)
	}
}

func TestApprovalService_ResolveRuntimeSubmitEligibilityWithinObjectScope_UsesOnlyAuthorizedCatalogObjects(t *testing.T) {
	const targetID = "topup-1"
	persistence := &submitPersistenceStub{
		getRawRecords: map[string]models.SObject{
			"Credits_Topup__c/" + targetID: {constants.FieldID: targetID, "Status__c": "Draft"},
		},
		getRawObject:           "Credits_Topup__c",
		objectTypeCandidateErr: errors.New("repository-global candidate scan must not run"),
	}
	query := &submitQueryStub{onQuery: func(_ context.Context, objectName, filter string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
		switch objectName {
		case constants.TableProcessInstance:
			return emptyResult(), nil
		case "Credits_Topup__c":
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: targetID,
				"Status__c":       "Draft",
			}}}, nil
		case constants.TableProcessDefinition:
			definition := processDefinitionRecord("")
			definition["TableEnumOrId"] = "Credits_Topup__c"
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{definition}}, nil
		default:
			return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
		}
	}}

	eligibility, err := newSubmitTestService(persistence, query).ResolveRuntimeSubmitEligibilityWithinObjectScope(
		context.Background(),
		automation.SubmitRequest{RecordID: targetID},
		[]string{"Quote", "Credits_Topup__c", "credits_topup__c"},
		submitTestUser(),
	)
	if err != nil {
		t.Fatalf("ResolveRuntimeSubmitEligibilityWithinObjectScope() error = %v", err)
	}
	if eligibility.ObjectAPIName != "Credits_Topup__c" || eligibility.TargetRecordID != targetID {
		t.Fatalf("eligibility = %#v, want scoped Credits_Topup__c target", eligibility)
	}
	if len(persistence.objectTypeCandidateCalls) != 0 {
		t.Fatalf("repository-global candidate calls = %v, want none", persistence.objectTypeCandidateCalls)
	}
	if got := persistence.getRawObjectCalls; len(got) != 4 || got[0] != "Quote/"+targetID ||
		got[1] != "Credits_Topup__c/"+targetID || got[2] != "Credits_Topup__c/"+targetID ||
		got[3] != "Credits_Topup__c/"+targetID {
		// The two distinct Catalog candidates are probed once. Existing
		// pending-conflict and eligibility checks then re-verify only the
		// selected object; the case-folded duplicate is never probed.
		t.Fatalf("GetRaw object calls = %v, want one Quote probe then only Credits_Topup__c", got)
	}
}

func TestApprovalService_ResolveRuntimeSubmitEligibilityWithinObjectScope_ResolvesAccountWithOrWithoutHint(t *testing.T) {
	for _, testCase := range []struct {
		name       string
		objectHint string
	}{
		{name: "explicit ObjectAPIName", objectHint: "Account"},
		{name: "objectless"},
	} {
		t.Run(testCase.name, func(t *testing.T) {
			const targetID = "account-1"
			persistence := &submitPersistenceStub{
				getRawRecords: map[string]models.SObject{
					"Account/" + targetID: {constants.FieldID: targetID, "Status__c": "Draft"},
				},
				getRawObject:           "Account",
				objectTypeCandidateErr: errors.New("repository-global candidate scan must not run"),
			}
			query := &submitQueryStub{onQuery: func(_ context.Context, objectName, filter string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
				switch objectName {
				case constants.TableProcessInstance:
					return emptyResult(), nil
				case "Account":
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID: targetID,
						"Status__c":       "Draft",
					}}}, nil
				case constants.TableProcessDefinition:
					definition := processDefinitionRecord("")
					definition["TableEnumOrId"] = "Account"
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{definition}}, nil
				default:
					return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
				}
			}}

			eligibility, err := newSubmitTestService(persistence, query).ResolveRuntimeSubmitEligibilityWithinObjectScope(
				context.Background(),
				automation.SubmitRequest{ObjectAPIName: testCase.objectHint, RecordID: targetID},
				[]string{"Account", "Quote"},
				submitTestUser(),
			)
			if err != nil {
				t.Fatalf("ResolveRuntimeSubmitEligibilityWithinObjectScope() error = %v", err)
			}
			if eligibility.ObjectAPIName != "Account" || eligibility.TargetRecordID != targetID {
				t.Fatalf("eligibility = %#v, want Account/%s", eligibility, targetID)
			}
			if len(persistence.objectTypeCandidateCalls) != 0 {
				t.Fatalf("repository-global candidate calls = %v, want none", persistence.objectTypeCandidateCalls)
			}
			if testCase.objectHint != "" {
				for _, call := range persistence.getRawObjectCalls {
					if call == "Quote/"+targetID {
						t.Fatalf("explicit Account hint probed unrelated Catalog candidate: %v", persistence.getRawObjectCalls)
					}
				}
			}
		})
	}
}

func TestApprovalService_ResolveRuntimeSubmitEligibilityWithinObjectScope_FailsClosedForCollision(t *testing.T) {
	const targetID = "shared-id"
	persistence := &submitPersistenceStub{
		getRawRecords: map[string]models.SObject{
			"Account/" + targetID: {constants.FieldID: targetID},
			"Quote/" + targetID:   {constants.FieldID: targetID},
		},
		objectTypeCandidateErr: errors.New("repository-global candidate scan must not run"),
	}

	_, err := newSubmitTestService(persistence, &submitQueryStub{}).ResolveRuntimeSubmitEligibilityWithinObjectScope(
		context.Background(),
		automation.SubmitRequest{RecordID: targetID},
		[]string{"Account", "Quote"},
		submitTestUser(),
	)
	if !appErrors.IsValidation(err) {
		t.Fatalf("ResolveRuntimeSubmitEligibilityWithinObjectScope() error = %T %v, want validation", err, err)
	}
	if len(persistence.objectTypeCandidateCalls) != 0 {
		t.Fatalf("repository-global candidate calls = %v, want none", persistence.objectTypeCandidateCalls)
	}
}

func TestApprovalService_ResolveRuntimeSubmitEligibilityWithinObjectScope_FailsClosedWhenMissing(t *testing.T) {
	persistence := &submitPersistenceStub{
		getRawRecords:          map[string]models.SObject{},
		getRawObject:           "ObjectOutsideAuthorizedScope",
		objectTypeCandidateErr: errors.New("repository-global candidate scan must not run"),
	}

	_, err := newSubmitTestService(persistence, &submitQueryStub{}).ResolveRuntimeSubmitEligibilityWithinObjectScope(
		context.Background(),
		automation.SubmitRequest{RecordID: "missing"},
		[]string{"Account", "Quote"},
		submitTestUser(),
	)
	if !appErrors.IsNotFound(err) {
		t.Fatalf("ResolveRuntimeSubmitEligibilityWithinObjectScope() error = %T %v, want not-found", err, err)
	}
	if len(persistence.objectTypeCandidateCalls) != 0 {
		t.Fatalf("repository-global candidate calls = %v, want none", persistence.objectTypeCandidateCalls)
	}
}

func TestApprovalService_ResolveRuntimeSubmitEligibilityWithinObjectScope_ReturnsNoActiveProcessForObjectHintOutsideAuthority(t *testing.T) {
	persistence := &submitPersistenceStub{
		objectTypeCandidateErr: errors.New("repository-global candidate scan must not run"),
	}

	_, err := newSubmitTestService(persistence, &submitQueryStub{}).ResolveRuntimeSubmitEligibilityWithinObjectScope(
		context.Background(),
		automation.SubmitRequest{ObjectAPIName: "Secret__c", RecordID: "secret-1"},
		[]string{"Credits_Topup__c"},
		submitTestUser(),
	)
	if !errors.Is(err, automation.ErrNoActiveApprovalProcess) {
		t.Fatalf("ResolveRuntimeSubmitEligibilityWithinObjectScope() error = %T %v, want ErrNoActiveApprovalProcess", err, err)
	}
	if len(persistence.getRawCalls) != 0 || len(persistence.objectTypeCandidateCalls) != 0 {
		t.Fatalf("out-of-scope object was probed: GetRaw=%v globalCandidates=%v", persistence.getRawObjectCalls, persistence.objectTypeCandidateCalls)
	}
}

func TestApprovalService_Submit_ReconcilesPendingConflictAfterConcurrentCriteriaDrift(t *testing.T) {
	persistence := &submitPersistenceStub{recordType: "Lead"}
	pendingChecks := 0
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "00Qconcurrent-drift",
					"Status":          "Submitted",
				}}}, nil
			case constants.TableProcessInstance:
				pendingChecks++
				if pendingChecks == 1 {
					return emptyResult(), nil
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pi-concurrent-drift",
					constants.ColProcessInstance_ProcessDefinitionID: "pd-1",
					"TargetObjectId": "00Qconcurrent-drift",
					"Status":         constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{
					processDefinitionRecord("Status equals Qualified"),
				}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}

	_, err := newSubmitTestService(persistence, query).Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "Lead",
		RecordID:      "00Qconcurrent-drift",
	}, submitTestUser())
	if !errors.Is(err, automation.ErrRecordAlreadyPending) {
		t.Fatalf("Submit() error = %T %v, want ErrRecordAlreadyPending when a concurrent submit commits before criteria resolution returns", err, err)
	}
	if len(persistence.inserts) != 0 {
		t.Fatalf("Insert calls = %d, want 0 for concurrent duplicate submit", len(persistence.inserts))
	}
}

func TestApprovalService_SubmitResolved_RuntimePendingConflictPrecedesLockedCriteriaDrift(t *testing.T) {
	persistence := &submitPersistenceStub{
		recordType: "Lead",
		getLockRecord: models.SObject{
			constants.FieldID: "00Qruntime-concurrent-drift",
			"Status":          "Submitted",
		},
	}
	concurrentCommitted := false
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "00Qruntime-concurrent-drift",
					"Status":          "Qualified",
				}}}, nil
			case constants.TableProcessInstance:
				if !concurrentCommitted {
					return emptyResult(), nil
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pi-runtime-concurrent-drift",
					constants.ColProcessInstance_ProcessDefinitionID: "pd-1",
					"TargetObjectId": "00Qruntime-concurrent-drift",
					"Status":         constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{
					processDefinitionRecord("Status equals Qualified"),
				}}, nil
			case constants.TableProcessNode, constants.TableProcessAction:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	service := newSubmitTestService(persistence, query)
	request := automation.SubmitRequest{
		ObjectAPIName: "Lead",
		RecordID:      "00Qruntime-concurrent-drift",
	}
	eligibility, err := service.ResolveRuntimeSubmitEligibility(context.Background(), request, submitTestUser())
	if err != nil {
		t.Fatalf("ResolveRuntimeSubmitEligibility() error = %v", err)
	}

	concurrentCommitted = true
	_, err = service.SubmitResolved(context.Background(), request, eligibility, submitTestUser())
	if !errors.Is(err, automation.ErrRecordAlreadyPending) {
		t.Fatalf("SubmitResolved() error = %T %v, want ErrRecordAlreadyPending before locked criteria revalidation", err, err)
	}
	if len(persistence.inserts) != 0 {
		t.Fatalf("Insert calls = %d, want 0 for locked concurrent duplicate submit", len(persistence.inserts))
	}
}

func TestApprovalService_SubmitResolved_RuntimePendingConflictAuthorizesCurrentDefinitionBeforeDisclosure(t *testing.T) {
	persistence := &submitPersistenceStub{
		recordType: "Lead",
		getLockRecord: models.SObject{
			constants.FieldID:          "00Qruntime-pending-denied",
			constants.FieldCreatedByID: "005-record-creator",
			"Status":                   "Submitted",
		},
	}
	initialDefinition := processDefinitionRecord("Status equals Qualified")
	initialDefinition[constants.FieldID] = "pd-initial"
	pendingDefinition := processDefinitionRecord("Status equals Submitted")
	pendingDefinition[constants.FieldID] = "pd-pending"
	pendingDefinition["Metadata"] = `{"allowedSubmitters":[{"type":"recordCreator"}]}`
	concurrentCommitted := false
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:          "00Qruntime-pending-denied",
					constants.FieldCreatedByID: "005-record-creator",
					"Status":                   "Qualified",
				}}}, nil
			case constants.TableProcessInstance:
				if !concurrentCommitted {
					return emptyResult(), nil
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pi-runtime-pending-denied",
					constants.ColProcessInstance_ProcessDefinitionID: "pd-pending",
					"TargetObjectId": "00Qruntime-pending-denied",
					"Status":         constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessDefinition:
				if strings.Contains(filter, "Id == 'pd-pending'") {
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{pendingDefinition}}, nil
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{initialDefinition}}, nil
			case constants.TableProcessNode, constants.TableProcessAction:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	service := newSubmitTestService(persistence, query)
	request := automation.SubmitRequest{
		ObjectAPIName: "Lead",
		RecordID:      "00Qruntime-pending-denied",
	}
	eligibility, err := service.ResolveRuntimeSubmitEligibility(context.Background(), request, submitTestUser())
	if err != nil {
		t.Fatalf("ResolveRuntimeSubmitEligibility() error = %v", err)
	}

	concurrentCommitted = true
	_, err = service.SubmitResolved(context.Background(), request, eligibility, submitTestUser())
	if !appErrors.IsPermission(err) {
		t.Fatalf("SubmitResolved() error = %T %v, want current pending definition submitter permission error", err, err)
	}
	if errors.Is(err, automation.ErrRecordAlreadyPending) {
		t.Fatalf("SubmitResolved() error = %v, pending state leaked before current definition authorization", err)
	}
	if len(persistence.inserts) != 0 {
		t.Fatalf("Insert calls = %d, want 0 for unauthorized concurrent pending submit", len(persistence.inserts))
	}
}

func TestApprovalService_Submit_PendingStateDoesNotBypassSubmitterAuthorization(t *testing.T) {
	persistence := &submitPersistenceStub{recordType: "Lead"}
	definition := processDefinitionRecord("Status equals Qualified")
	definition["Metadata"] = `{"allowedSubmitters":[{"type":"recordCreator"}]}`
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:          "00Qpending-denied",
					constants.FieldCreatedByID: "005-record-creator",
					"Status":                   "Submitted",
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pi-pending-denied",
					constants.ColProcessInstance_ProcessDefinitionID: "pd-1",
					"TargetObjectId": "00Qpending-denied",
					"Status":         constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{definition}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}

	_, err := newSubmitTestService(persistence, query).Submit(context.Background(), automation.SubmitRequest{
		ObjectAPIName: "Lead",
		RecordID:      "00Qpending-denied",
	}, submitTestUser())
	if !appErrors.IsPermission(err) {
		t.Fatalf("Submit() error = %T %v, want submitter permission error before pending state is disclosed", err, err)
	}
	if errors.Is(err, automation.ErrRecordAlreadyPending) {
		t.Fatalf("Submit() error = %v, pending state leaked to an unauthorized submitter", err)
	}
}

func TestApprovalService_Submit_SingleProcessCriteriaEvaluationErrorAllowsSubmit(t *testing.T) {
	persistence := &submitPersistenceStub{recordType: "Lead"}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{
					TotalSize: 1,
					Records:   []models.SObject{{constants.FieldID: "00Qsingle-eval-error"}},
				}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{processDefinitionRecord("not a valid criteria expression")}}, nil
			case constants.TableProcessInstance, constants.TableProcessNode, constants.TableProcessAction:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	result, err := svc.Submit(context.Background(), automation.SubmitRequest{
		RecordID: "00Qsingle-eval-error",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v, want preserved single-process allow-on-eval-error behavior", err)
	}
	assertSubmitInsertsUseCanonicalID(t, persistence.inserts, "00Qsingle-eval-error")
	if got, _ := result["TargetObjectId"].(string); got != "00Qsingle-eval-error" {
		t.Fatalf("response TargetObjectId = %q, want %q", got, "00Qsingle-eval-error")
	}
}

func TestApprovalService_Submit_MultiProcessCriteriaEvaluationErrorSkipsToLaterMatch(t *testing.T) {
	persistence := &submitPersistenceStub{recordType: "Lead"}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case "Lead":
				return &models.QueryResult{
					TotalSize: 1,
					Records:   []models.SObject{{constants.FieldID: "00Qmulti-eval-error"}},
				}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 2, Records: []models.SObject{
					{
						constants.FieldID: "pd-bad",
						"Name":            "Bad First",
						"DeveloperName":   "Bad_First",
						"TableEnumOrId":   "Lead",
						"State":           "Active",
						"EntryFilter":     "not a valid criteria expression",
						"Metadata":        "{}",
					},
					{
						constants.FieldID: "pd-good",
						"Name":            "Good Second",
						"DeveloperName":   "Good_Second",
						"TableEnumOrId":   "Lead",
						"State":           "Active",
						"EntryFilter":     "",
						"Metadata":        "{}",
					},
				}}, nil
			case constants.TableProcessInstance, constants.TableProcessNode, constants.TableProcessAction:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newSubmitTestService(persistence, query)

	_, err := svc.Submit(context.Background(), automation.SubmitRequest{
		RecordID: "00Qmulti-eval-error",
	}, submitTestUser())
	if err != nil {
		t.Fatalf("Submit() error = %v, want later matching process to be selected", err)
	}

	found := false
	for _, insert := range persistence.inserts {
		if insert.tableName != constants.TableProcessInstance {
			continue
		}
		found = true
		if got, _ := insert.record["ProcessDefinitionId"].(string); got != "pd-good" {
			t.Fatalf("ProcessInstance.ProcessDefinitionId = %q, want later matching process %q", got, "pd-good")
		}
	}
	if !found {
		t.Fatal("no ProcessInstance insert recorded")
	}
}

func assertNoSubmitQueryUsesRawID(t *testing.T, calls []submitQueryCall, rawID string) {
	t.Helper()
	for _, call := range calls {
		if strings.Contains(call.filter, rawID) {
			t.Fatalf("query filter %q contains raw id %q", call.filter, rawID)
		}
	}
}

func assertSubmitInsertsUseCanonicalID(t *testing.T, inserts []submitInsertCall, canonicalID string) {
	t.Helper()
	foundInstance := false
	for _, call := range inserts {
		targetID, _ := call.record["TargetObjectId"].(string)
		if targetID == "" {
			continue
		}
		foundInstance = true
		if targetID != canonicalID {
			t.Fatalf("%s.TargetObjectId = %q, want canonical %q", call.tableName, targetID, canonicalID)
		}
	}
	if !foundInstance {
		t.Fatal("no inserted record carried TargetObjectId; want ProcessInstance insert")
	}
}
