package automation_test

import (
	"context"
	"errors"
	"fmt"
	"slices"
	"strings"
	"testing"

	automation "github.com/nexuscrm/automation"
	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/permissions"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/pingcap-inc/nexuscrm-contract/pkg/approvalir"
)

type failingApprovalDefinitionDecoder struct {
	err error
}

func (d failingApprovalDefinitionDecoder) DecodeApprovalDefinition(string) (*approvalir.Definition, error) {
	return nil, d.err
}

type staticApprovalDefinitionDecoder struct {
	definition *approvalir.Definition
}

func (d staticApprovalDefinitionDecoder) DecodeApprovalDefinition(string) (*approvalir.Definition, error) {
	return d.definition, nil
}

type lifecycleDeleteCall struct {
	tableName string
	id        string
}

type lifecyclePersistenceStub struct {
	rollbackOnError bool
	runTxCalls      int
	runPessTxCalls  int
	records         map[string]models.SObject
	inserts         []submitInsertCall
	updates         []submitUpdateCall
	deletes         []lifecycleDeleteCall
	onUpdate        func(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error
}

type plannedLifecyclePersistenceStub struct {
	*lifecyclePersistenceStub
	plannedParentKeys         []recordlocks.ParentKey
	planPreparedUpdateCalls   int
	parentLockCoordinatorRuns int
	firstDeleteForcedPrelocks []recordlocks.ParentKey
	pessimisticAttempts       int
}

type runtimeActionFencePersistence struct {
	*lifecyclePersistenceStub
	rows      map[string]models.SObject
	lockOrder []string
}

func (p *runtimeActionFencePersistence) GetLock(_ context.Context, tableName, id string) (models.SObject, error) {
	p.lockOrder = append(p.lockOrder, tableName+"/"+id)
	row := p.rows[tableName+"/"+id]
	if row == nil {
		return nil, nil
	}
	return cloneSObject(row), nil
}

func (p *lifecyclePersistenceStub) 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})
	return cloned, nil
}

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

func (p *lifecyclePersistenceStub) PrelockUpdateParents(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
	if p.onUpdate != nil {
		if err := p.onUpdate(ctx, tableName, id, updates, user); err != nil {
			return err
		}
	}
	p.updates = append(p.updates, submitUpdateCall{tableName: tableName, id: id, updates: cloneSObject(updates)})
	return nil
}

func (p *lifecyclePersistenceStub) Update(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
	if p.onUpdate != nil {
		if err := p.onUpdate(ctx, tableName, id, updates, user); err != nil {
			return err
		}
	}
	p.updates = append(p.updates, submitUpdateCall{tableName: tableName, id: id, updates: cloneSObject(updates)})
	return nil
}

func (p *lifecyclePersistenceStub) 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}
	for field, value := range updates {
		record[field] = value
	}
	return &models.PreparedRecordUpdate{
		Record:           record,
		EffectiveUpdates: cloneSObject(updates),
		InternalUpdates:  cloneSObject(internalStamps),
	}, nil
}

func (p *plannedLifecyclePersistenceStub) PlanPreparedUpdateParentKeys(ctx context.Context, _ string, _ string, _ *models.PreparedRecordUpdate, _ models.SObject, _ *models.UserSession) ([]recordlocks.ParentKey, error) {
	p.planPreparedUpdateCalls++
	if recordlocks.ScopeFromContext(ctx) == nil {
		return nil, nil
	}
	return append([]recordlocks.ParentKey(nil), p.plannedParentKeys...), nil
}

func (p *plannedLifecyclePersistenceStub) RunInPessimisticTransaction(ctx context.Context, fn func(context.Context) error) error {
	p.pessimisticAttempts++
	return p.lifecyclePersistenceStub.RunInTransaction(ctx, fn)
}

func (p *plannedLifecyclePersistenceStub) RunWithParentLockCoordinator(ctx context.Context, fn func(context.Context) error) error {
	p.parentLockCoordinatorRuns++
	return fn(recordlocks.WithScope(ctx))
}

func (p *plannedLifecyclePersistenceStub) Update(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
	if tableName == "GenericObject" {
		scope := recordlocks.ScopeFromContext(ctx)
		toAcquire, err := scope.KeysToAcquire(p.plannedParentKeys)
		if err != nil {
			return err
		}
		for _, key := range toAcquire {
			scope.MarkAcquired(key)
		}
	}
	return p.lifecyclePersistenceStub.Update(ctx, tableName, id, updates, user)
}

func (p *lifecyclePersistenceStub) CommitPreparedUpdate(ctx context.Context, tableName string, id string, prepared *models.PreparedRecordUpdate, extraUpdates models.SObject, user *models.UserSession) error {
	panic("lifecyclePersistenceStub.CommitPreparedUpdate: not implemented")
}

func (p *lifecyclePersistenceStub) Delete(ctx context.Context, tableName string, id string, user *models.UserSession) error {
	p.deletes = append(p.deletes, lifecycleDeleteCall{tableName: tableName, id: id})
	return nil
}

func (p *plannedLifecyclePersistenceStub) Delete(ctx context.Context, tableName string, id string, user *models.UserSession) error {
	if p.firstDeleteForcedPrelocks == nil {
		p.firstDeleteForcedPrelocks = recordlocks.ForcedParentPrelocksFromContext(ctx)
		scope := recordlocks.ScopeFromContext(ctx)
		toAcquire, err := scope.KeysToAcquire(p.firstDeleteForcedPrelocks)
		if err != nil {
			return err
		}
		for _, key := range toAcquire {
			scope.MarkAcquired(key)
		}
		// Reproduce the test2 shape: the Approval runtime write creates a
		// ProcessInstanceHistory row before the terminal business update.
		scope.MarkTransactionOwned(recordlocks.ParentKey{
			ObjectName: constants.TableProcessInstanceHistory,
			RecordID:   "history-owned-by-attempt",
		})
	}
	return p.lifecyclePersistenceStub.Delete(ctx, tableName, id, user)
}

func (p *lifecyclePersistenceStub) GetRaw(ctx context.Context, objectName string, id string) (models.SObject, error) {
	key := objectName + "/" + id
	record := p.records[key]
	if record == nil {
		return nil, fmt.Errorf("record %s not found", key)
	}
	return cloneSObject(record), nil
}

func (p *lifecyclePersistenceStub) 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))
	for _, field := range fields {
		if value, ok := record[field]; ok {
			selected[field] = value
		}
	}
	return selected, nil
}

func (p *lifecyclePersistenceStub) GetLock(ctx context.Context, tableName string, id string) (models.SObject, error) {
	panic("lifecyclePersistenceStub.GetLock: not implemented")
}

func (p *lifecyclePersistenceStub) GetRecordType(ctx context.Context, id string) (string, error) {
	panic("lifecyclePersistenceStub.GetRecordType: not implemented")
}

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

func (p *lifecyclePersistenceStub) IsTransactionActive(context.Context) bool { return false }

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

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

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

func newLifecycleTestService(persistence *lifecyclePersistenceStub, query *submitQueryStub, opts ...automation.ApprovalServiceOption) *automation.ApprovalService {
	return automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		nil,
		logging.Default,
		opts...,
	)
}

func TestApprovalService_Reassign_DecoderErrorFailsClosedBeforeMutation(t *testing.T) {
	wantErr := errors.New("invalid approval definition")
	user := submitTestUser()
	persistence := &lifecyclePersistenceStub{}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, currentUser *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case constants.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-reassign-decoder",
					"ProcessInstanceId": "pi-reassign-decoder",
					"ActorId":           user.ID,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					"ProcessNodeName": "Review",
					"NodeStatus":      constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-reassign-decoder",
					"ProcessDefinitionId": "pd-reassign-decoder",
					"TargetObjectId":      "001-record",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-reassign-decoder",
					"Metadata":        `{"label":"broken"}`,
				}}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(
		persistence,
		query,
		automation.WithApprovalDefinitionDecoder(failingApprovalDefinitionDecoder{err: wantErr}),
	)

	err := svc.Reassign(context.Background(), "wi-reassign-decoder", "005-new-actor", user)
	if !errors.Is(err, wantErr) || !appErrors.IsInternal(err) {
		t.Fatalf("Reassign() error = %T %v, want wrapped internal decoder error", err, err)
	}
	if persistence.runPessTxCalls != 0 || len(persistence.updates) != 0 {
		t.Fatalf("Reassign() mutated after decoder error: transactions=%d updates=%d", persistence.runPessTxCalls, len(persistence.updates))
	}
}

func TestApprovalService_ApprovalActionWorkItemLookupErrorFailsClosed(t *testing.T) {
	wantErr := errors.New("work item repository unavailable")
	user := submitTestUser()
	query := &submitQueryStub{onQuery: func(_ context.Context, objectName, _ string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
		if objectName == constants.TableProcessInstanceWorkitem {
			return nil, wantErr
		}
		return emptyResult(), nil
	}}

	for _, operation := range []struct {
		name string
		run  func(*automation.ApprovalService) error
	}{
		{name: "approve", run: func(service *automation.ApprovalService) error {
			_, err := service.Approve(context.Background(), "wi-query-error", "", user)
			return err
		}},
		{name: "reject", run: func(service *automation.ApprovalService) error {
			_, err := service.Reject(context.Background(), "wi-query-error", "", user)
			return err
		}},
		{name: "reassign", run: func(service *automation.ApprovalService) error {
			return service.Reassign(context.Background(), "wi-query-error", "005-next", user)
		}},
		{name: "recall", run: func(service *automation.ApprovalService) error {
			_, err := service.Recall(context.Background(), "wi-query-error", "", user)
			return err
		}},
	} {
		t.Run(operation.name, func(t *testing.T) {
			persistence := &lifecyclePersistenceStub{rollbackOnError: true}
			service := newLifecycleTestService(persistence, query)
			err := operation.run(service)
			if !errors.Is(err, wantErr) || !appErrors.IsInternal(err) {
				t.Fatalf("operation error = %T %v, want internal wrapper preserving repository error", err, err)
			}
			if persistence.runPessTxCalls != 0 || len(persistence.inserts) != 0 || len(persistence.updates) != 0 || len(persistence.deletes) != 0 {
				t.Fatalf("operation reached mutation after work-item lookup failure: tx=%d inserts=%d updates=%d deletes=%d", persistence.runPessTxCalls, len(persistence.inserts), len(persistence.updates), len(persistence.deletes))
			}
		})
	}
}

func TestApprovalService_RuntimeActionFenceUsesCurrentStateAndStableLockOrder(t *testing.T) {
	const instanceID = "pi-runtime-fence"
	const workItemID = "wi-runtime-fence"
	const actorID = "005-runtime-actor"
	const submitterID = "005-runtime-submitter"
	binding := automation.ApprovalWorkItemBinding{
		ProcessInstanceID: instanceID, ProcessDefinitionID: "pd-runtime-fence", TargetRecordID: "record-runtime-fence",
	}
	newService := func() (*automation.ApprovalService, *runtimeActionFencePersistence) {
		persistence := &runtimeActionFencePersistence{
			lifecyclePersistenceStub: &lifecyclePersistenceStub{},
			rows: map[string]models.SObject{
				constants.TableProcessInstance + "/" + instanceID: {
					constants.FieldID: instanceID, "ProcessDefinitionId": binding.ProcessDefinitionID,
					"TargetObjectId": binding.TargetRecordID, "Status": constants.ApprovalStatusPending,
					"SubmittedById": submitterID,
				},
				constants.TableProcessInstanceWorkitem + "/" + workItemID: {
					constants.FieldID: workItemID, "ProcessInstanceId": instanceID, "ActorId": actorID,
				},
			},
		}
		service := automation.NewApprovalService(
			persistence, &submitQueryStub{}, nil, nil, nil, nil, nil, logging.Default,
		)
		return service, persistence
	}

	service, persistence := newService()
	if err := service.FenceRuntimeApprovalAction(
		context.Background(), workItemID, binding, automation.RuntimeApprovalActionApprove,
		&models.UserSession{ID: actorID},
	); err != nil {
		t.Fatalf("approve fence = %v", err)
	}
	wantOrder := []string{
		constants.TableProcessInstance + "/" + instanceID,
		constants.TableProcessInstanceWorkitem + "/" + workItemID,
	}
	if !slices.Equal(persistence.lockOrder, wantOrder) {
		t.Fatalf("lock order = %v, want %v", persistence.lockOrder, wantOrder)
	}

	service, _ = newService()
	if err := service.FenceRuntimeApprovalAction(
		context.Background(), workItemID, binding, automation.RuntimeApprovalActionRecall,
		&models.UserSession{ID: submitterID},
	); err != nil {
		t.Fatalf("recall fence = %v", err)
	}

	service, _ = newService()
	if err := service.FenceRuntimeApprovalAction(
		context.Background(), workItemID, binding, automation.RuntimeApprovalActionApprove,
		&models.UserSession{ID: "005-stale-actor"},
	); !errors.Is(err, automation.ErrNotAuthorized) {
		t.Fatalf("stale actor fence = %v, want not authorized", err)
	}

	service, persistence = newService()
	delete(persistence.rows, constants.TableProcessInstanceWorkitem+"/"+workItemID)
	if err := service.FenceRuntimeApprovalAction(
		context.Background(), workItemID, binding, automation.RuntimeApprovalActionApprove,
		&models.UserSession{ID: actorID},
	); !errors.Is(err, automation.ErrWorkItemNotFound) {
		t.Fatalf("consumed work-item fence = %v, want work item not found", err)
	}
}

func TestApprovalService_Approve_ProcessNodeAuthorityErrorFailsClosedBeforeMutation(t *testing.T) {
	wantErr := errors.New("ProcessNode authority unavailable")
	user := submitTestUser()
	persistence := &lifecyclePersistenceStub{rollbackOnError: true}
	query := &submitQueryStub{onQuery: func(_ context.Context, objectName, filter string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
		switch objectName {
		case constants.TableProcessInstanceWorkitem:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "wi-node-error", "ProcessInstanceId": "pi-node-error", "ActorId": user.ID,
			}}}, nil
		case constants.TableProcessInstance:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "pi-node-error", "ProcessDefinitionId": "pd-node-error", "TargetObjectId": "record-node-error", "SubmittedById": "005-submit",
			}}}, nil
		case constants.TableProcessDefinition:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "pd-node-error", "TableEnumOrId": "GenericObject", "Metadata": "{}",
			}}}, nil
		case constants.TableProcessInstanceNode:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "pin-node-error", "ProcessNodeId": "pn-node-error", "ProcessNodeName": "Review", "NodeStatus": constants.ApprovalStatusPending,
			}}}, nil
		case constants.TableProcessNode:
			if strings.Contains(filter, "pn-node-error") {
				return nil, wantErr
			}
		}
		return emptyResult(), nil
	}}
	service := newLifecycleTestService(persistence, query)

	_, err := service.Approve(context.Background(), "wi-node-error", "", user)
	if !errors.Is(err, wantErr) || !appErrors.IsInternal(err) {
		t.Fatalf("Approve() error = %T %v, want internal ProcessNode authority error", err, err)
	}
	if len(persistence.inserts) != 0 || len(persistence.updates) != 0 || len(persistence.deletes) != 0 {
		t.Fatalf("Approve() mutated after ProcessNode authority failure: inserts=%d updates=%d deletes=%d", len(persistence.inserts), len(persistence.updates), len(persistence.deletes))
	}
}

func TestApprovalService_Reassign_DelegationAuthorityErrorsFailClosed(t *testing.T) {
	user := submitTestUser()
	for _, failureObject := range []string{
		constants.TableProcessInstanceNode,
		constants.TableProcessInstance,
		constants.TableProcessDefinition,
	} {
		t.Run(failureObject, func(t *testing.T) {
			wantErr := fmt.Errorf("%s authority unavailable", failureObject)
			persistence := &lifecyclePersistenceStub{rollbackOnError: true}
			query := &submitQueryStub{onQuery: func(_ context.Context, objectName, _ string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
				if objectName == failureObject {
					return nil, wantErr
				}
				switch objectName {
				case constants.TableProcessInstanceWorkitem:
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID: "wi-delegation-error", "ProcessInstanceId": "pi-delegation-error", "ActorId": user.ID,
					}}}, nil
				case constants.TableProcessInstanceNode:
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID: "pin-delegation-error", "ProcessNodeId": "pn-delegation-error", "ProcessNodeName": "Review", "NodeStatus": constants.ApprovalStatusPending,
					}}}, nil
				case constants.TableProcessInstance:
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID: "pi-delegation-error", "ProcessDefinitionId": "pd-delegation-error", "TargetObjectId": "record-delegation-error",
					}}}, nil
				case constants.TableProcessDefinition:
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID: "pd-delegation-error", "Metadata": `{}`,
					}}}, nil
				}
				return emptyResult(), nil
			}}
			service := newLifecycleTestService(persistence, query)

			err := service.Reassign(context.Background(), "wi-delegation-error", "005-next", user)
			if !errors.Is(err, wantErr) || !appErrors.IsInternal(err) {
				t.Fatalf("Reassign() error = %T %v, want internal authority error", err, err)
			}
			if persistence.runPessTxCalls != 0 || len(persistence.updates) != 0 {
				t.Fatalf("Reassign() reached mutation after delegation authority failure: tx=%d updates=%d", persistence.runPessTxCalls, len(persistence.updates))
			}
		})
	}
}

func TestApprovalService_Recall_PendingNodeFailuresRollBack(t *testing.T) {
	user := submitTestUser()
	for _, test := range []struct {
		name        string
		queryError  error
		updateError error
	}{
		{name: "query", queryError: errors.New("pending node query failed")},
		{name: "update", updateError: errors.New("pending node update failed")},
	} {
		t.Run(test.name, func(t *testing.T) {
			persistence := &lifecyclePersistenceStub{rollbackOnError: true}
			if test.updateError != nil {
				persistence.onUpdate = func(_ context.Context, tableName, _ string, updates models.SObject, _ *models.UserSession) error {
					if tableName == constants.TableProcessInstanceNode && updates["NodeStatus"] == constants.ApprovalStatusRemoved {
						return test.updateError
					}
					return nil
				}
			}
			query := &submitQueryStub{onQuery: func(_ context.Context, objectName, _ string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
				switch objectName {
				case constants.TableProcessInstanceWorkitem:
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID: "wi-recall-node", "ProcessInstanceId": "pi-recall-node", "ActorId": "005-approver",
					}}}, nil
				case constants.TableProcessInstance:
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID: "pi-recall-node", "ProcessDefinitionId": "pd-recall-node", "TargetObjectId": "record-recall-node", "SubmittedById": user.ID, "Status": constants.ApprovalStatusPending,
					}}}, nil
				case constants.TableProcessDefinition:
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID: "pd-recall-node", "TableEnumOrId": "GenericObject", "Metadata": `{}`,
					}}}, nil
				case constants.TableProcessAction:
					return emptyResult(), nil
				case constants.TableProcessInstanceNode:
					if test.queryError != nil {
						return nil, test.queryError
					}
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID: "pin-recall-node", "ProcessNodeId": "pn-recall-node", "NodeStatus": constants.ApprovalStatusPending,
					}}}, nil
				}
				return emptyResult(), nil
			}}
			service := newLifecycleTestService(persistence, query)

			_, err := service.Recall(context.Background(), "wi-recall-node", "withdraw", user)
			wantErr := test.queryError
			if wantErr == nil {
				wantErr = test.updateError
			}
			if !errors.Is(err, wantErr) || !appErrors.IsInternal(err) {
				t.Fatalf("Recall() error = %T %v, want internal pending-node failure", err, err)
			}
			if len(persistence.inserts) != 0 || len(persistence.updates) != 0 || len(persistence.deletes) != 0 {
				t.Fatalf("Recall() left mutations after rollback: inserts=%d updates=%d deletes=%d", len(persistence.inserts), len(persistence.updates), len(persistence.deletes))
			}
		})
	}
}

func TestApprovalService_Reassign_UsesProcessNodeStepOrderWhenInstanceNodeNameDrifts(t *testing.T) {
	afterAuthorizationErr := errors.New("stop after delegation authorization")
	user := submitTestUser()
	persistence := &lifecyclePersistenceStub{}
	instanceQueries := 0
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, currentUser *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case constants.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-reassign-drift",
					"ProcessInstanceId": "pi-reassign-drift",
					"ActorId":           user.ID,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					"ProcessNodeId":   "pn-reassign-drift",
					"ProcessNodeName": "Stale Instance Label",
					"NodeStatus":      constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessInstance:
				instanceQueries++
				if instanceQueries > 1 {
					return nil, afterAuthorizationErr
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-reassign-drift",
					"ProcessDefinitionId": "pd-reassign-drift",
					"TargetObjectId":      "001-record",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-reassign-drift",
					"Metadata":        `{"label":"delegation"}`,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pn-reassign-drift",
					"DeveloperName":   "Stale_Runtime_Name",
					"Name":            "Stale Runtime Label",
					"StepOrder":       1,
				}}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(
		persistence,
		query,
		automation.WithApprovalDefinitionDecoder(staticApprovalDefinitionDecoder{definition: &approvalir.Definition{
			Steps: []approvalir.Step{{Name: "Authored_Step", Label: "Authored Step", AllowDelegate: true}},
		}}),
	)

	err := svc.Reassign(context.Background(), "wi-reassign-drift", "005-new-actor", user)
	if !errors.Is(err, afterAuthorizationErr) {
		t.Fatalf("Reassign() error = %T %v, want post-authorization sentinel", err, err)
	}
	if persistence.runPessTxCalls != 1 {
		t.Fatalf("RunInPessimisticTransaction calls = %d, want 1 after ProcessNode step-order authorization", persistence.runPessTxCalls)
	}
	if len(persistence.updates) != 0 {
		t.Fatalf("Reassign() updates = %d, want 0 after sentinel", len(persistence.updates))
	}
}

func TestApprovalService_Approve_DecoderErrorFailsClosedBeforeMutation(t *testing.T) {
	wantErr := errors.New("invalid approval definition")
	user := submitTestUser()
	persistence := &lifecyclePersistenceStub{rollbackOnError: true}
	query := &submitQueryStub{
		onQuery: func(ctx context.Context, objectName, filter string, currentUser *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
			switch objectName {
			case constants.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-approve-decoder",
					"ProcessInstanceId": "pi-approve-decoder",
					"ActorId":           user.ID,
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-approve-decoder",
					"ProcessDefinitionId": "pd-approve-decoder",
					"TargetObjectId":      "001-record",
					"SubmittedById":       user.ID,
					"Status":              constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-approve-decoder",
					"TableEnumOrId":   "Account",
					"Metadata":        `{"label":"broken"}`,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pin-approve-decoder",
					"ProcessNodeId":   "pn-approve-decoder",
					"ProcessNodeName": "Current Step",
					"NodeStatus":      constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pn-approve-decoder",
					"Name":            "Current Step",
					"StepOrder":       1,
				}}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(
		persistence,
		query,
		automation.WithApprovalDefinitionDecoder(failingApprovalDefinitionDecoder{err: wantErr}),
	)

	_, err := svc.Approve(context.Background(), "wi-approve-decoder", "approved", user)
	if !errors.Is(err, wantErr) || !appErrors.IsInternal(err) {
		t.Fatalf("Approve() error = %T %v, want wrapped internal decoder error", err, err)
	}
	if len(persistence.updates) != 0 || len(persistence.deletes) != 0 {
		t.Fatalf("Approve() mutated after decoder error: updates=%d deletes=%d", len(persistence.updates), len(persistence.deletes))
	}
}

type lifecycleSchemaStub struct {
	schemas map[string]*models.ObjectMetadata
}

func (s lifecycleSchemaStub) GetSchema(ctx context.Context, apiName string) *models.ObjectMetadata {
	return s.schemas[apiName]
}

func TestApprovalService_Approve_MalformedProcessInstanceFailsBeforeMutation(t *testing.T) {
	persistence := &lifecyclePersistenceStub{}
	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.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-approve-malformed",
					"ProcessInstanceId": "pi-approve-malformed",
					"ActorId":           user.ID,
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-approve-malformed",
					"ProcessDefinitionId": "pd-approve-malformed",
					"Status":              constants.ApprovalStatusPending,
					"SubmittedById":       user.ID,
				}}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	_, err := svc.Approve(context.Background(), "wi-approve-malformed", "approved", submitTestUser())
	if err == nil {
		t.Fatal("Approve() error = nil, want internal malformed ProcessInstance failure")
	}
	if !appErrors.IsInternal(err) {
		t.Fatalf("Approve() error = %T %v, want internal app error classification", err, err)
	}
	if len(persistence.updates) != 0 {
		t.Fatalf("Update calls after malformed Approve = %d, want 0", len(persistence.updates))
	}
	if len(persistence.deletes) != 0 {
		t.Fatalf("Delete calls after malformed Approve = %d, want 0", len(persistence.deletes))
	}
}

func TestApprovalService_Approve_StepActionFetchErrorFailsBeforeMutation(t *testing.T) {
	actionFetchErr := errors.New("step action query failed")
	persistence := &lifecyclePersistenceStub{}
	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.TableProcessInstanceWorkitem:
				if strings.Contains(filter, "Id == 'wi-approve-step-fetch'") {
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID:   "wi-approve-step-fetch",
						"ProcessInstanceId": "pi-approve-step-fetch",
						"ActorId":           user.ID,
					}}}, nil
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-approve-step-fetch",
					"ProcessInstanceId": "pi-approve-step-fetch",
					"ActorId":           user.ID,
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-approve-step-fetch",
					"ProcessDefinitionId": "pd-approve-step-fetch",
					"TargetObjectId":      "target-approve-step-fetch",
					"Status":              constants.ApprovalStatusPending,
					"SubmittedById":       user.ID,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pd-approve-step-fetch",
					"TableEnumOrId":       "GenericObject",
					"ProcessDefinitionId": "pd-approve-step-fetch",
				}}}, nil
			case constants.TableProcessInstanceNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "node-approve-step-fetch",
					"ProcessNodeId":   "pn-approve-step-fetch",
					"ProcessNodeName": "Current Step",
					"NodeStatus":      constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pn-approve-step-fetch",
					"Name":            "Current Step",
				}}}, nil
			case constants.TableProcessAction:
				return nil, actionFetchErr
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	_, err := svc.Approve(context.Background(), "wi-approve-step-fetch", "approved", submitTestUser())
	if !errors.Is(err, actionFetchErr) {
		t.Fatalf("Approve() error = %T %v, want wrapped step action fetch error", err, err)
	}
	if !appErrors.IsInternal(err) {
		t.Fatalf("Approve() error = %T %v, want internal app error classification", err, err)
	}
	if len(persistence.updates) != 0 {
		t.Fatalf("Update calls after step action fetch failure = %d, want 0", len(persistence.updates))
	}
	if len(persistence.deletes) != 0 {
		t.Fatalf("Delete calls after step action fetch failure = %d, want 0", len(persistence.deletes))
	}
}

func TestApprovalService_Approve_FinalActionFetchErrorFailsBeforeMutation(t *testing.T) {
	finalFetchErr := errors.New("final action query failed")
	processActionCalls := 0
	persistence := &lifecyclePersistenceStub{}
	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.TableProcessInstanceWorkitem:
				if strings.Contains(filter, "Id == 'wi-approve-final-fetch'") {
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID:   "wi-approve-final-fetch",
						"ProcessInstanceId": "pi-approve-final-fetch",
						"ActorId":           user.ID,
					}}}, nil
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-approve-final-fetch",
					"ProcessInstanceId": "pi-approve-final-fetch",
					"ActorId":           user.ID,
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-approve-final-fetch",
					"ProcessDefinitionId": "pd-approve-final-fetch",
					"TargetObjectId":      "target-approve-final-fetch",
					"Status":              constants.ApprovalStatusPending,
					"SubmittedById":       user.ID,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-approve-final-fetch",
					"TableEnumOrId":   "GenericObject",
				}}}, nil
			case constants.TableProcessInstanceNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "node-approve-final-fetch",
					"ProcessNodeId":   "pn-approve-final-fetch",
					"ProcessNodeName": "Current Step",
					"NodeStatus":      constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessNode:
				if strings.Contains(filter, "ProcessDefinitionId") {
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID: "pn-approve-final-fetch",
						"Name":            "Current Step",
					}}}, nil
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pn-approve-final-fetch",
					"Name":            "Current Step",
				}}}, nil
			case constants.TableProcessAction:
				processActionCalls++
				if processActionCalls == 1 {
					return emptyResult(), nil
				}
				return nil, finalFetchErr
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	_, err := svc.Approve(context.Background(), "wi-approve-final-fetch", "approved", submitTestUser())
	if !errors.Is(err, finalFetchErr) {
		t.Fatalf("Approve() error = %T %v, want wrapped final action fetch error", err, err)
	}
	if !appErrors.IsInternal(err) {
		t.Fatalf("Approve() error = %T %v, want internal app error classification", err, err)
	}
	if len(persistence.updates) != 0 {
		t.Fatalf("Update calls after final action fetch failure = %d, want 0", len(persistence.updates))
	}
	if len(persistence.deletes) != 0 {
		t.Fatalf("Delete calls after final action fetch failure = %d, want 0", len(persistence.deletes))
	}
}

func TestApprovalService_Approve_StepFieldUpdateFeedsNextStepCriteria(t *testing.T) {
	targetRecord := models.SObject{
		constants.FieldID: "target-step-criteria",
		"ReadyForNext":    "no",
	}
	criteriaSawUpdatedValue := false
	processActionCalls := 0
	persistence := &lifecyclePersistenceStub{
		onUpdate: func(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
			if tableName == "GenericObject" && id == "target-step-criteria" {
				for field, value := range updates {
					targetRecord[field] = value
				}
			}
			return nil
		},
	}
	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.TableProcessInstanceWorkitem:
				if strings.Contains(filter, "Id == 'wi-step-criteria'") {
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID:   "wi-step-criteria",
						"ProcessInstanceId": "pi-step-criteria",
						"ActorId":           user.ID,
					}}}, nil
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-step-criteria",
					"ProcessInstanceId": "pi-step-criteria",
					"ActorId":           user.ID,
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-step-criteria",
					"ProcessDefinitionId": "pd-step-criteria",
					"TargetObjectId":      "target-step-criteria",
					"Status":              constants.ApprovalStatusPending,
					"SubmittedById":       user.ID,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-step-criteria",
					"TableEnumOrId":   "GenericObject",
					"Metadata": `{
						"approvalStep": [
							{"name": "Current Step", "label": "Current Step"},
							{
								"name": "Next Step",
								"label": "Next Step",
								"entryCriteria": {
									"criteriaItems": [
										{"field": "ReadyForNext", "operation": "equals", "value": "yes"}
									]
								},
								"ifCriteriaNotMet": "ApproveRecord"
							}
						]
					}`,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "node-step-criteria",
					"ProcessNodeId":   "pn-current",
					"ProcessNodeName": "Current Step",
					"NodeStatus":      constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessNode:
				if strings.Contains(filter, "ProcessDefinitionId") {
					return &models.QueryResult{TotalSize: 2, Records: []models.SObject{
						{
							constants.FieldID: "pn-current",
							"Name":            "Current Step",
							"DeveloperName":   "Current Step",
							"StepOrder":       1,
							"ApproverType":    "user",
							"ApproverUserId":  user.ID,
						},
						{
							constants.FieldID: "pn-next",
							"Name":            "Next Step",
							"DeveloperName":   "Next Step",
							"StepOrder":       2,
							"ApproverType":    "user",
							"ApproverUserId":  "005-next-actor",
						},
					}}, nil
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pn-current",
					"Name":            "Current Step",
					"DeveloperName":   "Current Step",
				}}}, nil
			case constants.TableProcessAction:
				processActionCalls++
				if processActionCalls == 1 {
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID:     "pa-step-field-update",
						"ProcessDefinitionId": "pd-step-criteria",
						"ProcessNodeId":       "pn-current",
						"ActionEvent":         "StepApproval",
						"ActionType":          "FieldUpdate",
						"Name":                "Set Ready For Next",
						"FieldApiName":        "ReadyForNext",
						"FieldValue":          "yes",
					}}}, nil
				}
				return emptyResult(), nil
			case "GenericObject":
				if strings.Contains(filter, "Id == 'target-step-criteria'") {
					if targetRecord["ReadyForNext"] == "yes" {
						criteriaSawUpdatedValue = true
					}
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{cloneSObject(targetRecord)}}, nil
				}
				return emptyResult(), nil
			case constants.TableUser:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "005-next-actor",
					"Username":        "next-actor@example.test",
				}}}, nil
			case constants.TableProcessInstanceHistory:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	result, err := svc.Approve(context.Background(), "wi-step-criteria", "approved", submitTestUser())
	if err != nil {
		t.Fatalf("Approve() error = %v", err)
	}
	if !criteriaSawUpdatedValue {
		t.Fatal("next-step criteria did not observe the StepApproval FieldUpdate")
	}
	if result["InstanceStatus"] != constants.ApprovalStatusPending {
		t.Fatalf("InstanceStatus = %v, want pending next-step transition", result["InstanceStatus"])
	}
	var insertedNextWorkItem bool
	for _, insert := range persistence.inserts {
		if insert.tableName == constants.TableProcessInstanceWorkitem && insert.record["ActorId"] == "005-next-actor" {
			insertedNextWorkItem = true
		}
	}
	if !insertedNextWorkItem {
		t.Fatalf("next-step work item inserts = %#v, want actor 005-next-actor", persistence.inserts)
	}
	for _, update := range persistence.updates {
		if update.tableName == constants.TableProcessInstance && update.updates["Status"] == constants.ApprovalStatusApproved {
			t.Fatalf("ProcessInstance was finalized instead of advancing: %#v", update)
		}
	}
}

func TestApprovalService_ApprovePlansTerminalMutationParentsBeforeRuntimeRows(t *testing.T) {
	user := submitTestUser()
	wantParent := recordlocks.ParentKey{ObjectName: "AlphaParent__c", RecordID: "parent-001"}
	base := &lifecyclePersistenceStub{records: map[string]models.SObject{
		"GenericObject/target-terminal-plan": {
			constants.FieldID: "target-terminal-plan",
			"Status__c":       "Pending",
		},
	}}
	persistence := &plannedLifecyclePersistenceStub{
		lifecyclePersistenceStub: base,
		plannedParentKeys:        []recordlocks.ParentKey{wantParent},
	}
	query := &submitQueryStub{onQuery: func(_ context.Context, objectName, filter string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
		switch objectName {
		case constants.TableProcessInstanceWorkitem:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID:   "wi-terminal-plan",
				"ProcessInstanceId": "pi-terminal-plan",
				"ActorId":           user.ID,
				"OriginalActorId":   user.ID,
			}}}, nil
		case constants.TableProcessInstance:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID:     "pi-terminal-plan",
				"ProcessDefinitionId": "pd-terminal-plan",
				"TargetObjectId":      "target-terminal-plan",
				"SubmittedById":       user.ID,
				"Status":              constants.ApprovalStatusPending,
			}}}, nil
		case constants.TableProcessDefinition:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "pd-terminal-plan",
				"TableEnumOrId":   "GenericObject",
				"Metadata":        `{}`,
			}}}, nil
		case constants.TableProcessInstanceNode:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "pin-terminal-plan",
				"ProcessNodeId":   "pn-terminal-plan",
				"ProcessNodeName": "Terminal Step",
				"NodeStatus":      constants.ApprovalStatusPending,
			}}}, nil
		case constants.TableProcessNode:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "pn-terminal-plan",
				"Name":            "Terminal Step",
				"DeveloperName":   "Terminal_Step",
				"StepOrder":       1,
			}}}, nil
		case constants.TableProcessAction:
			if strings.Contains(filter, "ActionEvent == 'FinalApproval'") {
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pa-terminal-plan",
					"ProcessDefinitionId": "pd-terminal-plan",
					"ActionEvent":         constants.ApprovalActionEventFinalApproval,
					"ActionType":          constants.ProcessActionTypeFieldUpdate,
					"Name":                "Finalize target",
					"FieldApiName":        "Status__c",
					"FieldValue":          "Approved",
				}}}, nil
			}
			return emptyResult(), nil
		case constants.TableProcessInstanceHistory:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "wi-terminal-plan",
				constants.FieldProcessInstanceHistory_IsPending:  true,
				constants.FieldProcessInstanceHistory_StepStatus: constants.ApprovalStatusPending,
			}}}, nil
		default:
			return emptyResult(), nil
		}
	}}
	service := automation.NewApprovalService(persistence, query, nil, nil, nil, nil, nil, logging.Default)

	if _, err := service.Approve(context.Background(), "wi-terminal-plan", "approved", user); err != nil {
		t.Fatalf("Approve() error = %v", err)
	}
	if persistence.planPreparedUpdateCalls != 1 {
		t.Fatalf("PlanPreparedUpdateParentKeys calls = %d, want one selected terminal-action plan", persistence.planPreparedUpdateCalls)
	}
	if persistence.pessimisticAttempts != 1 {
		t.Fatalf("pessimistic transaction attempts = %d, want one clean attempt", persistence.pessimisticAttempts)
	}
	if persistence.parentLockCoordinatorRuns != 1 {
		t.Fatalf("parent-lock coordinator runs = %d, want one scope around the complete attempt", persistence.parentLockCoordinatorRuns)
	}
	if !slices.Contains(persistence.firstDeleteForcedPrelocks, wantParent) {
		t.Fatalf("first Approval runtime delete forced prelocks = %#v, want planned parent %#v", persistence.firstDeleteForcedPrelocks, wantParent)
	}
}

func TestApprovalService_Approve_NextStepActionFetchErrorRollsBackStepFieldUpdate(t *testing.T) {
	nextStepFetchErr := errors.New("next step action query failed")
	processActionCalls := 0
	persistence := &lifecyclePersistenceStub{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 constants.TableProcessInstanceWorkitem:
				if strings.Contains(filter, "Id == 'wi-next-fetch'") {
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID:   "wi-next-fetch",
						"ProcessInstanceId": "pi-next-fetch",
						"ActorId":           user.ID,
					}}}, nil
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-next-fetch",
					"ProcessInstanceId": "pi-next-fetch",
					"ActorId":           user.ID,
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-next-fetch",
					"ProcessDefinitionId": "pd-next-fetch",
					"TargetObjectId":      "target-next-fetch",
					"Status":              constants.ApprovalStatusPending,
					"SubmittedById":       user.ID,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-next-fetch",
					"TableEnumOrId":   "GenericObject",
					"Metadata":        "{}",
				}}}, nil
			case constants.TableProcessInstanceNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "node-next-fetch",
					"ProcessNodeId":   "pn-current-next-fetch",
					"ProcessNodeName": "Current Step",
					"NodeStatus":      constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessNode:
				if strings.Contains(filter, "ProcessDefinitionId") {
					return &models.QueryResult{TotalSize: 2, Records: []models.SObject{
						{
							constants.FieldID: "pn-current-next-fetch",
							"Name":            "Current Step",
							"DeveloperName":   "Current Step",
							"StepOrder":       1,
						},
						{
							constants.FieldID: "pn-next-fetch",
							"Name":            "Next Step",
							"DeveloperName":   "Next Step",
							"StepOrder":       2,
							"ApproverType":    "user",
							"ApproverUserId":  "005-next-fetch-actor",
						},
					}}, nil
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pn-current-next-fetch",
					"Name":            "Current Step",
					"DeveloperName":   "Current Step",
				}}}, nil
			case constants.TableProcessAction:
				processActionCalls++
				if processActionCalls == 1 {
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID:     "pa-current-step-update",
						"ProcessDefinitionId": "pd-next-fetch",
						"ProcessNodeId":       "pn-current-next-fetch",
						"ActionEvent":         "StepApproval",
						"ActionType":          "FieldUpdate",
						"Name":                "Set Transition Input",
						"FieldApiName":        "TransitionInput",
						"FieldValue":          "ready",
					}}}, nil
				}
				return nil, nextStepFetchErr
			case constants.TableUser:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "005-next-fetch-actor",
					"Username":        "next-fetch-actor@example.test",
				}}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	_, err := svc.Approve(context.Background(), "wi-next-fetch", "approved", submitTestUser())
	if !errors.Is(err, nextStepFetchErr) {
		t.Fatalf("Approve() error = %T %v, want wrapped next-step action fetch error", err, err)
	}
	if !appErrors.IsInternal(err) {
		t.Fatalf("Approve() error = %T %v, want internal app error classification", err, err)
	}
	if len(persistence.updates) != 0 {
		t.Fatalf("Update calls after rolled-back next-step action fetch failure = %d, want 0", len(persistence.updates))
	}
	if len(persistence.inserts) != 0 {
		t.Fatalf("Insert calls after rolled-back next-step action fetch failure = %d, want 0", len(persistence.inserts))
	}
	if len(persistence.deletes) != 0 {
		t.Fatalf("Delete calls after rolled-back next-step action fetch failure = %d, want 0", len(persistence.deletes))
	}
}

func TestApprovalService_Reassign_ObjectResolveErrorRollsBack(t *testing.T) {
	resolveErr := errors.New("process definition lookup failed")
	persistence := &lifecyclePersistenceStub{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 constants.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:        "wi-reassign",
					"ProcessInstanceId":      "pi-reassign",
					"ActorId":                user.ID,
					"OriginalActorId":        user.ID,
					constants.FieldCreatedBy: user.ID,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return emptyResult(), nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-reassign",
					"ProcessDefinitionId": "pd-reassign",
					"TargetObjectId":      "target-reassign",
				}}}, nil
			case constants.TableProcessDefinition:
				return nil, resolveErr
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	err := svc.Reassign(context.Background(), "wi-reassign", "005-new-actor", submitTestUser())
	if !errors.Is(err, resolveErr) {
		t.Fatalf("Reassign() error = %T %v, want wrapped object resolve error", err, err)
	}
	if !appErrors.IsInternal(err) {
		t.Fatalf("Reassign() error = %T %v, want internal app error classification", err, err)
	}
	if len(persistence.updates) != 0 {
		t.Fatalf("Update calls after rolled-back Reassign = %d, want 0", len(persistence.updates))
	}
	if len(persistence.deletes) != 0 {
		t.Fatalf("Delete calls after rolled-back Reassign = %d, want 0", len(persistence.deletes))
	}
	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)
	}
}

func TestApprovalService_Reassign_UpdatesPendingHistoryActor(t *testing.T) {
	user := submitTestUser()
	persistence := &lifecyclePersistenceStub{}
	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.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-reassign-history",
					"ProcessInstanceId": "pi-reassign-history",
					"ActorId":           user.ID,
					"OriginalActorId":   user.ID,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return emptyResult(), nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-reassign-history",
					"ProcessDefinitionId": "pd-reassign-history",
					"TargetObjectId":      "target-reassign-history",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-reassign-history",
					"TableEnumOrId":   "GenericObject",
				}}}, nil
			case constants.TableProcessAction:
				return emptyResult(), nil
			case constants.TableProcessInstanceHistory:
				if constants.GetSecurityContext(ctx) != constants.SystemContext {
					t.Fatalf("ProcessInstanceHistory reassignment query security context = %v, want SystemContext", constants.GetSecurityContext(ctx))
				}
				if !strings.Contains(filter, "Id == 'wi-reassign-history'") {
					t.Fatalf("ProcessInstanceHistory reassignment filter = %q, want work-item keyed lookup", filter)
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "wi-reassign-history",
					constants.FieldProcessInstanceHistory_IsPending:  true,
					constants.FieldProcessInstanceHistory_ActorID:    user.ID,
					constants.FieldProcessInstanceHistory_StepStatus: constants.ApprovalStatusPending,
				}}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	if err := svc.Reassign(context.Background(), "wi-reassign-history", "005-new-actor", user); err != nil {
		t.Fatalf("Reassign() error = %v", err)
	}
	if !hasUpdateStatus(persistence.updates, constants.TableProcessInstanceWorkitem, "wi-reassign-history", "ActorId", "005-new-actor") {
		t.Fatalf("updates = %#v, want work item actor reassigned", persistence.updates)
	}
	if !hasUpdateStatus(persistence.updates, constants.TableProcessInstanceHistory, "wi-reassign-history", constants.FieldProcessInstanceHistory_ActorID, "005-new-actor") {
		t.Fatalf("updates = %#v, want pending history actor reassigned", persistence.updates)
	}
	for _, update := range persistence.updates {
		if update.tableName == constants.TableProcessInstanceHistory {
			if _, explicitlyAuthored := update.updates[constants.FieldProcessInstanceHistory_LastModifiedDate]; explicitlyAuthored {
				t.Fatalf("updates = %#v, system audit timestamp must remain persistence-owned", persistence.updates)
			}
		}
	}
	for _, ins := range persistence.inserts {
		if ins.tableName == constants.TableProcessInstanceHistory {
			t.Fatalf("inserts = %#v, want no fallback history insert when pending row exists", persistence.inserts)
		}
	}
}

func TestApprovalService_Reassign_LegacyHistoryInsertPreservesOriginalActor(t *testing.T) {
	user := submitTestUser()
	persistence := &lifecyclePersistenceStub{}
	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.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-legacy-reassign",
					"ProcessInstanceId": "pi-legacy-reassign",
					"ActorId":           user.ID,
					"OriginalActorId":   "005-original-before-history",
				}}}, nil
			case constants.TableProcessInstanceNode:
				return emptyResult(), nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-legacy-reassign",
					"ProcessDefinitionId": "pd-legacy-reassign",
					"TargetObjectId":      "target-legacy-reassign",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-legacy-reassign",
					"TableEnumOrId":   "GenericObject",
				}}}, nil
			case constants.TableProcessAction:
				return emptyResult(), nil
			case constants.TableProcessInstanceHistory:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	if err := svc.Reassign(context.Background(), "wi-legacy-reassign", "005-new-actor", user); err != nil {
		t.Fatalf("Reassign() error = %v", err)
	}

	var historyInsert *submitInsertCall
	for i := range persistence.inserts {
		if persistence.inserts[i].tableName == constants.TableProcessInstanceHistory {
			historyInsert = &persistence.inserts[i]
			break
		}
	}
	if historyInsert == nil {
		t.Fatalf("inserts = %#v, want fallback ProcessInstanceHistory insert", persistence.inserts)
	}
	if historyInsert.record[constants.FieldID] != "wi-legacy-reassign" ||
		historyInsert.record[constants.FieldProcessInstanceHistory_ActorID] != "005-new-actor" ||
		historyInsert.record[constants.FieldProcessInstanceHistory_OriginalActorID] != "005-original-before-history" {
		t.Fatalf("fallback history insert = %#v, want workitem id, new actor, and persisted OriginalActorId", historyInsert.record)
	}
}

func TestApprovalService_Reassign_ProcessInstanceLookupErrorRollsBack(t *testing.T) {
	lookupErr := errors.New("process instance lookup failed")
	persistence := &lifecyclePersistenceStub{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 constants.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-reassign-lookup",
					"ProcessInstanceId": "pi-reassign-lookup",
					"ActorId":           user.ID,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return emptyResult(), nil
			case constants.TableProcessInstance:
				return nil, lookupErr
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	err := svc.Reassign(context.Background(), "wi-reassign-lookup", "005-new-actor", submitTestUser())
	if !errors.Is(err, lookupErr) {
		t.Fatalf("Reassign() error = %T %v, want wrapped ProcessInstance lookup error", err, err)
	}
	if !appErrors.IsInternal(err) {
		t.Fatalf("Reassign() error = %T %v, want internal app error classification", err, err)
	}
	if len(persistence.updates) != 0 {
		t.Fatalf("Update calls after rolled-back Reassign = %d, want 0", len(persistence.updates))
	}
	if len(persistence.deletes) != 0 {
		t.Fatalf("Delete calls after rolled-back Reassign = %d, want 0", len(persistence.deletes))
	}
}

func TestApprovalService_Reassign_MissingProcessInstanceRollsBack(t *testing.T) {
	persistence := &lifecyclePersistenceStub{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 constants.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-reassign-missing",
					"ProcessInstanceId": "pi-reassign-missing",
					"ActorId":           user.ID,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return emptyResult(), nil
			case constants.TableProcessInstance:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	err := svc.Reassign(context.Background(), "wi-reassign-missing", "005-new-actor", submitTestUser())
	if err == nil {
		t.Fatal("Reassign() error = nil, want internal ProcessInstance lookup failure")
	}
	if !appErrors.IsInternal(err) {
		t.Fatalf("Reassign() error = %T %v, want internal app error classification", err, err)
	}
	if len(persistence.updates) != 0 {
		t.Fatalf("Update calls after rolled-back Reassign = %d, want 0", len(persistence.updates))
	}
	if len(persistence.deletes) != 0 {
		t.Fatalf("Delete calls after rolled-back Reassign = %d, want 0", len(persistence.deletes))
	}
}

func TestApprovalService_Reassign_MalformedProcessInstanceRollsBack(t *testing.T) {
	persistence := &lifecyclePersistenceStub{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 constants.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-reassign-malformed",
					"ProcessInstanceId": "pi-reassign-malformed",
					"ActorId":           user.ID,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return emptyResult(), nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-reassign-malformed",
					"ProcessDefinitionId": "pd-reassign-malformed",
				}}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	err := svc.Reassign(context.Background(), "wi-reassign-malformed", "005-new-actor", submitTestUser())
	if err == nil {
		t.Fatal("Reassign() error = nil, want internal malformed ProcessInstance failure")
	}
	if !appErrors.IsInternal(err) {
		t.Fatalf("Reassign() error = %T %v, want internal app error classification", err, err)
	}
	if len(persistence.updates) != 0 {
		t.Fatalf("Update calls after rolled-back Reassign = %d, want 0", len(persistence.updates))
	}
	if len(persistence.deletes) != 0 {
		t.Fatalf("Delete calls after rolled-back Reassign = %d, want 0", len(persistence.deletes))
	}
}

func TestApprovalService_Recall_ObjectResolveErrorRollsBack(t *testing.T) {
	resolveErr := errors.New("process definition lookup failed")
	persistence := &lifecyclePersistenceStub{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 constants.TableProcessInstanceWorkitem:
				if strings.Contains(filter, "Id == 'wi-recall'") {
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID:   "wi-recall",
						"ProcessInstanceId": "pi-recall",
						"ActorId":           user.ID,
					}}}, nil
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-recall",
					"ProcessInstanceId": "pi-recall",
					"ActorId":           user.ID,
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-recall",
					"ProcessDefinitionId": "pd-recall",
					"TargetObjectId":      "target-recall",
					"Status":              constants.ApprovalStatusPending,
					"SubmittedById":       user.ID,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "node-recall",
					"NodeStatus":      constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessDefinition:
				return nil, resolveErr
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	_, err := svc.Recall(context.Background(), "wi-recall", "withdraw", submitTestUser())
	if !errors.Is(err, resolveErr) {
		t.Fatalf("Recall() error = %T %v, want wrapped object resolve error", err, err)
	}
	if !appErrors.IsInternal(err) {
		t.Fatalf("Recall() error = %T %v, want internal app error classification", err, err)
	}
	if len(persistence.updates) != 0 {
		t.Fatalf("Update calls after rolled-back Recall = %d, want 0", len(persistence.updates))
	}
	if len(persistence.deletes) != 0 {
		t.Fatalf("Delete calls after rolled-back Recall = %d, want 0", len(persistence.deletes))
	}
}

func TestApprovalService_Recall_FieldUpdateAppliesInlineBeforeReturn(t *testing.T) {
	persistence := &lifecyclePersistenceStub{
		records: map[string]models.SObject{
			"GenericObject/target-replay": {
				constants.FieldID: "target-replay",
			},
		},
	}
	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.TableProcessInstanceWorkitem:
				if strings.Contains(filter, "Id == 'wi-replay'") {
					return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
						constants.FieldID:   "wi-replay",
						"ProcessInstanceId": "pi-replay",
						"ActorId":           "005-approver",
					}}}, nil
				}
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-replay",
					"ProcessInstanceId": "pi-replay",
					"ActorId":           "005-approver",
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-replay",
					"ProcessDefinitionId": "pd-replay",
					"TargetObjectId":      "target-replay",
					"Status":              constants.ApprovalStatusPending,
					"SubmittedById":       user.ID,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "node-replay",
					"NodeStatus":      constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-replay",
					"TableEnumOrId":   "GenericObject",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pa-recall-field",
					"ProcessDefinitionId": "pd-replay",
					"ActionEvent":         "Recall",
					"ActionType":          "FieldUpdate",
					"Name":                "Recall Field Update",
					"FieldApiName":        "LifecycleState",
					"FieldValue":          "recalled",
				}}}, nil
			case constants.TableProcessInstanceHistory:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	if _, err := svc.Recall(context.Background(), "wi-replay", "withdraw", submitTestUser()); err != nil {
		t.Fatalf("Recall() error = %v", err)
	}
	if !hasUpdateStatus(persistence.updates, "GenericObject", "target-replay", "LifecycleState", "recalled") {
		t.Fatalf("updates = %#v, want inline GenericObject recall FieldUpdate", persistence.updates)
	}
}

func TestApprovalService_Recall_RelatedFieldUpdateUsesConcreteTargetInline(t *testing.T) {
	persistence := &lifecyclePersistenceStub{
		records: map[string]models.SObject{
			"GenericObject/target-related": {
				constants.FieldID: "target-related",
				"ParentId":        "parent-at-recall",
			},
			"RelatedObject/parent-at-recall": {
				constants.FieldID: "parent-at-recall",
			},
		},
	}
	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.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-related-replay",
					"ProcessInstanceId": "pi-related-replay",
					"ActorId":           "005-approver",
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-related-replay",
					"ProcessDefinitionId": "pd-related-replay",
					"TargetObjectId":      "target-related",
					"Status":              constants.ApprovalStatusPending,
					"SubmittedById":       user.ID,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "node-related-replay",
					"NodeStatus":      constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-related-replay",
					"TableEnumOrId":   "GenericObject",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pa-related-field",
					"ProcessDefinitionId": "pd-related-replay",
					"ActionEvent":         "Recall",
					"ActionType":          "FieldUpdate",
					"Name":                "Recall Related Field Update",
					"FieldApiName":        "ParentId.LifecycleState",
					"FieldValue":          "recalled",
				}}}, nil
			case constants.TableProcessInstanceHistory:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	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,
	)

	if _, err := svc.Recall(context.Background(), "wi-related-replay", "withdraw", submitTestUser()); err != nil {
		t.Fatalf("Recall() error = %v", err)
	}
	if !hasUpdateStatus(persistence.updates, "RelatedObject", "parent-at-recall", "LifecycleState", "recalled") {
		t.Fatalf("updates = %#v, want inline related-object recall FieldUpdate", persistence.updates)
	}
}

func TestApprovalService_Recall_FieldUpdateTargetsAreFrozenBeforeInlineMutation(t *testing.T) {
	records := map[string]models.SObject{
		"GenericObject/target-frozen-recall": {
			constants.FieldID: "target-frozen-recall",
			"ParentId":        "parent-at-recall",
		},
		"RelatedObject/parent-at-recall": {
			constants.FieldID: "parent-at-recall",
		},
		"RelatedObject/parent-after-recall": {
			constants.FieldID: "parent-after-recall",
		},
	}
	persistence := &lifecyclePersistenceStub{
		records: records,
		onUpdate: func(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
			if record := records[tableName+"/"+id]; record != nil {
				for field, value := range updates {
					record[field] = value
				}
			}
			return nil
		},
	}
	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.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-frozen-recall",
					"ProcessInstanceId": "pi-frozen-recall",
					"ActorId":           "005-approver",
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-frozen-recall",
					"ProcessDefinitionId": "pd-frozen-recall",
					"TargetObjectId":      "target-frozen-recall",
					"Status":              constants.ApprovalStatusPending,
					"SubmittedById":       user.ID,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "node-frozen-recall",
					"NodeStatus":      constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-frozen-recall",
					"TableEnumOrId":   "GenericObject",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 2, Records: []models.SObject{
					{
						constants.FieldID:     "pa-frozen-change-lookup",
						"ProcessDefinitionId": "pd-frozen-recall",
						"ActionEvent":         "Recall",
						"ActionType":          "FieldUpdate",
						"Name":                "Change Parent",
						"FieldApiName":        "ParentId",
						"FieldValue":          "parent-after-recall",
					},
					{
						constants.FieldID:     "pa-frozen-related-update",
						"ProcessDefinitionId": "pd-frozen-recall",
						"ActionEvent":         "Recall",
						"ActionType":          "FieldUpdate",
						"Name":                "Update Original Parent",
						"FieldApiName":        "ParentId.LifecycleState",
						"FieldValue":          "recalled",
					},
				}}, nil
			case constants.TableProcessInstanceHistory:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	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,
	)

	if _, err := svc.Recall(context.Background(), "wi-frozen-recall", "withdraw", submitTestUser()); err != nil {
		t.Fatalf("Recall() error = %v", err)
	}
	if !hasUpdateStatus(persistence.updates, "GenericObject", "target-frozen-recall", "ParentId", "parent-after-recall") {
		t.Fatalf("updates = %#v, want target ParentId changed", persistence.updates)
	}
	if !hasUpdateStatus(persistence.updates, "RelatedObject", "parent-at-recall", "LifecycleState", "recalled") {
		t.Fatalf("updates = %#v, want related update on pre-recall parent", persistence.updates)
	}
	if hasUpdateStatus(persistence.updates, "RelatedObject", "parent-after-recall", "LifecycleState", "recalled") {
		t.Fatalf("updates = %#v, related update followed mutated lookup target", persistence.updates)
	}
}

func TestApprovalService_Recall_FieldUpdateFailureRollsBack(t *testing.T) {
	updateErr := errors.New("target update failed")
	persistence := &lifecyclePersistenceStub{
		rollbackOnError: true,
		records: map[string]models.SObject{
			"GenericObject/target-recall-update-fails": {
				constants.FieldID: "target-recall-update-fails",
			},
		},
		onUpdate: func(ctx context.Context, tableName string, id string, updates models.SObject, user *models.UserSession) error {
			if tableName == "GenericObject" {
				return updateErr
			}
			return nil
		},
	}
	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.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-recall-update-fails",
					"ProcessInstanceId": "pi-recall-update-fails",
					"ActorId":           "005-approver",
				}}}, nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-recall-update-fails",
					"ProcessDefinitionId": "pd-recall-update-fails",
					"TargetObjectId":      "target-recall-update-fails",
					"Status":              constants.ApprovalStatusPending,
					"SubmittedById":       user.ID,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "node-recall-update-fails",
					"NodeStatus":      constants.ApprovalStatusPending,
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-recall-update-fails",
					"TableEnumOrId":   "GenericObject",
				}}}, nil
			case constants.TableProcessAction:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pa-recall-update-fails",
					"ProcessDefinitionId": "pd-recall-update-fails",
					"ActionEvent":         "Recall",
					"ActionType":          "FieldUpdate",
					"Name":                "Recall Field Update",
					"FieldApiName":        "LifecycleState",
					"FieldValue":          "recalled",
				}}}, nil
			case constants.TableProcessInstanceHistory:
				return emptyResult(), nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	_, err := svc.Recall(context.Background(), "wi-recall-update-fails", "withdraw", submitTestUser())
	var actionErr *automation.ApprovalActionError
	if !errors.As(err, &actionErr) {
		t.Fatalf("Recall() error = %T %v, want ApprovalActionError", err, err)
	}
	if actionErr.Reason != updateErr.Error() {
		t.Fatalf("ApprovalActionError reason = %q, want %q", actionErr.Reason, updateErr.Error())
	}
	if len(persistence.updates) != 0 {
		t.Fatalf("Update calls after rolled-back Recall = %d, want 0", len(persistence.updates))
	}
	if len(persistence.inserts) != 0 {
		t.Fatalf("Insert calls after rolled-back Recall = %d, want 0", len(persistence.inserts))
	}
	if len(persistence.deletes) != 0 {
		t.Fatalf("Delete calls after rolled-back Recall = %d, want 0", len(persistence.deletes))
	}
}

func TestApprovalService_Recall_MalformedProcessInstanceFailsBeforeMutation(t *testing.T) {
	for _, tt := range []struct {
		name     string
		instance models.SObject
	}{
		{
			name: "missing process definition",
			instance: models.SObject{
				constants.FieldID: "pi-recall-malformed",
				"TargetObjectId":  "target-recall-malformed",
				"Status":          constants.ApprovalStatusPending,
				"SubmittedById":   submitTestUser().ID,
			},
		},
		{
			name: "missing target object",
			instance: models.SObject{
				constants.FieldID:     "pi-recall-malformed",
				"ProcessDefinitionId": "pd-recall-malformed",
				"Status":              constants.ApprovalStatusPending,
				"SubmittedById":       submitTestUser().ID,
			},
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			persistence := &lifecyclePersistenceStub{}
			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.TableProcessInstanceWorkitem:
						return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
							constants.FieldID:   "wi-recall-malformed",
							"ProcessInstanceId": "pi-recall-malformed",
							"ActorId":           user.ID,
						}}}, nil
					case constants.TableProcessInstance:
						return &models.QueryResult{TotalSize: 1, Records: []models.SObject{tt.instance}}, nil
					default:
						return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
					}
				},
			}
			svc := newLifecycleTestService(persistence, query)

			_, err := svc.Recall(context.Background(), "wi-recall-malformed", "withdraw", submitTestUser())
			if err == nil {
				t.Fatal("Recall() error = nil, want internal malformed ProcessInstance failure")
			}
			if !appErrors.IsInternal(err) {
				t.Fatalf("Recall() error = %T %v, want internal app error classification", err, err)
			}
			if len(persistence.updates) != 0 {
				t.Fatalf("Update calls after malformed Recall = %d, want 0", len(persistence.updates))
			}
			if len(persistence.deletes) != 0 {
				t.Fatalf("Delete calls after malformed Recall = %d, want 0", len(persistence.deletes))
			}
		})
	}
}

func TestApprovalService_Reassign_EmptyObjectResolveRollsBack(t *testing.T) {
	persistence := &lifecyclePersistenceStub{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 constants.TableProcessInstanceWorkitem:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:   "wi-reassign-empty",
					"ProcessInstanceId": "pi-reassign-empty",
					"ActorId":           user.ID,
				}}}, nil
			case constants.TableProcessInstanceNode:
				return emptyResult(), nil
			case constants.TableProcessInstance:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID:     "pi-reassign-empty",
					"ProcessDefinitionId": "pd-reassign-empty",
					"TargetObjectId":      "target-reassign-empty",
				}}}, nil
			case constants.TableProcessDefinition:
				return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
					constants.FieldID: "pd-reassign-empty",
				}}}, nil
			default:
				return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
			}
		},
	}
	svc := newLifecycleTestService(persistence, query)

	err := svc.Reassign(context.Background(), "wi-reassign-empty", "005-new-actor", submitTestUser())
	if err == nil {
		t.Fatal("Reassign() error = nil, want internal object resolution failure")
	}
	if !appErrors.IsInternal(err) {
		t.Fatalf("Reassign() error = %T %v, want internal app error classification", err, err)
	}
	if len(persistence.updates) != 0 {
		t.Fatalf("Update calls after rolled-back Reassign = %d, want 0", len(persistence.updates))
	}
}

// reassignSuperUserPermissionRepo backs a real PermissionService with a fixed
// effective-permission answer, keeping ModifyAllData on the single
// permission-service read path (no test-only capability seam).
type reassignSuperUserPermissionRepo struct {
	ports.PermissionRepository

	superUser bool
}

func (r *reassignSuperUserPermissionRepo) GetAllRoles(context.Context) ([]*models.UserRole, error) {
	return nil, nil
}

func (r *reassignSuperUserPermissionRepo) GetEffectiveSystemPermissions(context.Context, string, string) (map[string]bool, error) {
	return map[string]bool{string(constants.SuperUserCapability): r.superUser}, nil
}

func newLifecycleTestServiceWithSuperUser(persistence *lifecyclePersistenceStub, query *submitQueryStub, superUser bool, opts ...automation.ApprovalServiceOption) *automation.ApprovalService {
	permissionSvc := permissions.NewPermissionService(&reassignSuperUserPermissionRepo{superUser: superUser}, nil, nil)
	return automation.NewApprovalService(
		persistence,
		query,
		permissionSvc,
		nil,
		nil,
		nil,
		nil,
		logging.Default,
		opts...,
	)
}

// reassignDelegationLockedQueryStub models a pending approval whose current
// step explicitly sets allowDelegate=false, assigned to actorID.
func reassignDelegationLockedQueryStub(actorID string) *submitQueryStub {
	return &submitQueryStub{onQuery: func(_ context.Context, objectName, filter string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
		switch objectName {
		case constants.TableProcessInstanceWorkitem:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID:   "wi-delegation-locked",
				"ProcessInstanceId": "pi-delegation-locked",
				"ActorId":           actorID,
				"OriginalActorId":   actorID,
			}}}, nil
		case constants.TableProcessInstanceNode:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "pin-delegation-locked",
				"ProcessNodeId":   "pn-delegation-locked",
				"ProcessNodeName": "Review",
				"NodeStatus":      constants.ApprovalStatusPending,
			}}}, nil
		case constants.TableProcessNode:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "pn-delegation-locked",
				"Name":            "Review",
				"DeveloperName":   "Review",
			}}}, nil
		case constants.TableProcessInstance:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID:     "pi-delegation-locked",
				"ProcessDefinitionId": "pd-delegation-locked",
				"TargetObjectId":      "target-delegation-locked",
				"SubmittedById":       "005-submitter",
			}}}, nil
		case constants.TableProcessDefinition:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "pd-delegation-locked",
				"TableEnumOrId":   "GenericObject",
				"Metadata":        `{"label":"Chain","approvalStep":[{"name":"Review","label":"Review","allowDelegate":false}]}`,
			}}}, nil
		case constants.TableProcessAction:
			return emptyResult(), nil
		case constants.TableProcessInstanceHistory:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "wi-delegation-locked",
				constants.FieldProcessInstanceHistory_IsPending:  true,
				constants.FieldProcessInstanceHistory_ActorID:    actorID,
				constants.FieldProcessInstanceHistory_StepStatus: constants.ApprovalStatusPending,
			}}}, nil
		default:
			return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
		}
	}}
}

func TestApprovalService_Reassign_SuperUserBypassesDelegationPolicy(t *testing.T) {
	user := submitTestUser()
	persistence := &lifecyclePersistenceStub{}
	query := reassignDelegationLockedQueryStub("005-assigned-approver")
	svc := newLifecycleTestServiceWithSuperUser(persistence, query, true)

	if err := svc.Reassign(context.Background(), "wi-delegation-locked", "005-new-actor", user); err != nil {
		t.Fatalf("Reassign() error = %v, want super-user to bypass allowDelegate=false", err)
	}
	if !hasUpdateStatus(persistence.updates, constants.TableProcessInstanceWorkitem, "wi-delegation-locked", "ActorId", "005-new-actor") {
		t.Fatalf("updates = %#v, want work item actor reassigned", persistence.updates)
	}
}

func TestApprovalService_Reassign_NonSuperUserStillBlockedByDelegationPolicy(t *testing.T) {
	user := submitTestUser()
	persistence := &lifecyclePersistenceStub{}
	query := reassignDelegationLockedQueryStub(user.ID)
	svc := newLifecycleTestServiceWithSuperUser(persistence, query, false)

	err := svc.Reassign(context.Background(), "wi-delegation-locked", "005-new-actor", user)
	if !errors.Is(err, automation.ErrDelegationNotAllowed) {
		t.Fatalf("Reassign() error = %v, want ErrDelegationNotAllowed for the assigned approver", err)
	}
	if persistence.runPessTxCalls != 0 || len(persistence.updates) != 0 {
		t.Fatalf("Reassign() mutated despite delegation policy: tx=%d updates=%d", persistence.runPessTxCalls, len(persistence.updates))
	}
}

func TestApprovalService_Reassign_NonActorWithoutSuperUserStaysDenied(t *testing.T) {
	user := submitTestUser()
	persistence := &lifecyclePersistenceStub{}
	query := reassignDelegationLockedQueryStub("005-assigned-approver")
	svc := newLifecycleTestServiceWithSuperUser(persistence, query, false)

	err := svc.Reassign(context.Background(), "wi-delegation-locked", "005-new-actor", user)
	if !errors.Is(err, automation.ErrNotAuthorized) {
		t.Fatalf("Reassign() error = %v, want ErrNotAuthorized", err)
	}
	if persistence.runPessTxCalls != 0 || len(persistence.updates) != 0 {
		t.Fatalf("Reassign() mutated despite authorization failure: tx=%d updates=%d", persistence.runPessTxCalls, len(persistence.updates))
	}
}

func TestApprovalService_Reassign_SuperUserStillFailsClosedOnDecoderError(t *testing.T) {
	wantErr := errors.New("invalid approval definition")
	user := submitTestUser()
	persistence := &lifecyclePersistenceStub{rollbackOnError: true}
	query := reassignDelegationLockedQueryStub("005-assigned-approver")
	svc := newLifecycleTestServiceWithSuperUser(persistence, query, true,
		automation.WithApprovalDefinitionDecoder(failingApprovalDefinitionDecoder{err: wantErr}))

	err := svc.Reassign(context.Background(), "wi-delegation-locked", "005-new-actor", user)
	if !errors.Is(err, wantErr) || !appErrors.IsInternal(err) {
		t.Fatalf("Reassign() error = %T %v, want internal decoder error even for super user", err, err)
	}
	if persistence.runPessTxCalls != 0 || len(persistence.updates) != 0 {
		t.Fatalf("Reassign() mutated after decoder error under super-user probe: tx=%d updates=%d", persistence.runPessTxCalls, len(persistence.updates))
	}
}

// recallOnBehalfQueryStub models a pending approval submitted by someone other
// than the acting user.
func recallOnBehalfQueryStub(submitterID string) *submitQueryStub {
	return &submitQueryStub{onQuery: func(_ context.Context, objectName, filter string, _ *models.UserSession, _, _ string, _ int) (*models.QueryResult, error) {
		switch objectName {
		case constants.TableProcessInstanceWorkitem:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID:   "wi-recall-behalf",
				"ProcessInstanceId": "pi-recall-behalf",
				"ActorId":           "005-approver",
				"OriginalActorId":   "005-approver",
			}}}, nil
		case constants.TableProcessInstance:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID:     "pi-recall-behalf",
				"ProcessDefinitionId": "pd-recall-behalf",
				"TargetObjectId":      "target-recall-behalf",
				"Status":              constants.ApprovalStatusPending,
				"SubmittedById":       submitterID,
			}}}, nil
		case constants.TableProcessInstanceNode:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "node-recall-behalf",
				"NodeStatus":      constants.ApprovalStatusPending,
			}}}, nil
		case constants.TableProcessDefinition:
			return &models.QueryResult{TotalSize: 1, Records: []models.SObject{{
				constants.FieldID: "pd-recall-behalf",
				"TableEnumOrId":   "GenericObject",
			}}}, nil
		case constants.TableProcessAction, constants.TableProcessInstanceHistory:
			return emptyResult(), nil
		default:
			return nil, fmt.Errorf("unexpected query: %s filter=%s", objectName, filter)
		}
	}}
}

func TestApprovalService_Recall_SuperUserRecallsOnBehalfOfSubmitter(t *testing.T) {
	user := submitTestUser()
	persistence := &lifecyclePersistenceStub{
		records: map[string]models.SObject{
			"GenericObject/target-recall-behalf": {constants.FieldID: "target-recall-behalf"},
		},
	}
	svc := newLifecycleTestServiceWithSuperUser(persistence, recallOnBehalfQueryStub("005-original-submitter"), true)

	if _, err := svc.Recall(context.Background(), "wi-recall-behalf", "unstick", user); err != nil {
		t.Fatalf("Recall() error = %v, want super-user recall on behalf of the submitter", err)
	}
	if !hasUpdateStatus(persistence.updates, constants.TableProcessInstance, "pi-recall-behalf", "Status", constants.ApprovalStatusRemoved) {
		t.Fatalf("updates = %#v, want ProcessInstance recalled to Removed", persistence.updates)
	}
	var recallHistoryActor string
	for _, ins := range persistence.inserts {
		if ins.tableName == constants.TableProcessInstanceHistory {
			recallHistoryActor, _ = ins.record[constants.FieldProcessInstanceHistory_ActorID].(string)
		}
	}
	if recallHistoryActor != user.ID {
		t.Fatalf("recall history ActorID = %q, want acting super user %q", recallHistoryActor, user.ID)
	}
}

func TestApprovalService_Recall_NonSubmitterWithoutSuperUserStaysDenied(t *testing.T) {
	user := submitTestUser()
	persistence := &lifecyclePersistenceStub{rollbackOnError: true}
	svc := newLifecycleTestServiceWithSuperUser(persistence, recallOnBehalfQueryStub("005-original-submitter"), false)

	_, err := svc.Recall(context.Background(), "wi-recall-behalf", "unstick", user)
	if !errors.Is(err, automation.ErrNotSubmitter) {
		t.Fatalf("Recall() error = %v, want ErrNotSubmitter", err)
	}
	if len(persistence.updates) != 0 || len(persistence.inserts) != 0 || len(persistence.deletes) != 0 {
		t.Fatalf("Recall() mutated despite submitter denial: updates=%d inserts=%d deletes=%d", len(persistence.updates), len(persistence.inserts), len(persistence.deletes))
	}
}

func TestApprovalService_RuntimeActionFenceRecallSuperUserOverride(t *testing.T) {
	const instanceID = "pi-fence-recall-behalf"
	const workItemID = "wi-fence-recall-behalf"
	binding := automation.ApprovalWorkItemBinding{
		ProcessInstanceID: instanceID, ProcessDefinitionID: "pd-fence-recall-behalf", TargetRecordID: "record-fence-recall-behalf",
	}
	newFence := func(superUser bool) *automation.ApprovalService {
		persistence := &runtimeActionFencePersistence{
			lifecyclePersistenceStub: &lifecyclePersistenceStub{},
			rows: map[string]models.SObject{
				constants.TableProcessInstance + "/" + instanceID: {
					constants.FieldID: instanceID, "ProcessDefinitionId": binding.ProcessDefinitionID,
					"TargetObjectId": binding.TargetRecordID, "Status": constants.ApprovalStatusPending,
					"SubmittedById": "005-original-submitter",
				},
				constants.TableProcessInstanceWorkitem + "/" + workItemID: {
					constants.FieldID: workItemID, "ProcessInstanceId": instanceID, "ActorId": "005-approver",
				},
			},
		}
		permissionSvc := permissions.NewPermissionService(&reassignSuperUserPermissionRepo{superUser: superUser}, nil, nil)
		return automation.NewApprovalService(
			persistence, &submitQueryStub{}, permissionSvc, nil, nil, nil, nil, logging.Default,
		)
	}

	admin := &models.UserSession{ID: "005-admin", ProfileID: "00e-admin"}
	if err := newFence(true).FenceRuntimeApprovalAction(
		context.Background(), workItemID, binding, automation.RuntimeApprovalActionRecall, admin,
	); err != nil {
		t.Fatalf("recall fence = %v, want super-user recall on behalf of the submitter to pass", err)
	}
	if err := newFence(false).FenceRuntimeApprovalAction(
		context.Background(), workItemID, binding, automation.RuntimeApprovalActionRecall, admin,
	); !errors.Is(err, automation.ErrNotSubmitter) {
		t.Fatalf("recall fence = %v, want ErrNotSubmitter without the capability", err)
	}
}
