package automation_test

import (
	"context"
	"fmt"
	"strings"
	"testing"

	automation "github.com/nexuscrm/automation"
	"github.com/nexuscrm/kernel/pkg/logging"
	"github.com/nexuscrm/kernel/pkg/ports"
	sharedapi "github.com/nexuscrm/shared/pkg/api"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/nexuscrm/shared/pkg/queryspec"
)

type actionQueryStub struct {
	currentProcessNodeID       string
	workItemActorID            string
	workItemOriginalActorID    string
	missingProcessDefinition   bool
	processDefinitionQuerySeen bool
	// pendingWorkitemActors, when set, makes the instance-wide workitem read
	// return one pending workitem per actor (paged by limit), to exercise
	// multi-page lifecycle cleanup.
	pendingWorkitemActors []string
}

func (s *actionQueryStub) QueryWithFilter(ctx context.Context, objectName, filter string, user *models.UserSession, sortField, sortDir string, limit int) (*models.QueryResult, error) {
	originalActorID := s.workItemOriginalActorID
	if originalActorID == "" {
		originalActorID = s.workItemActorID
	}
	switch objectName {
	case constants.TableProcessInstanceWorkitem:
		if strings.Contains(filter, "Id == 'wi-1'") {
			return actionQueryResult(models.SObject{
				constants.FieldID:   "wi-1",
				"ProcessInstanceId": "pi-1",
				"ActorId":           s.workItemActorID,
				"OriginalActorId":   originalActorID,
			}), nil
		}
		if strings.Contains(filter, "ProcessInstanceId == 'pi-1'") {
			if len(s.pendingWorkitemActors) > 0 {
				rows := make([]models.SObject, 0, len(s.pendingWorkitemActors))
				for i, actor := range s.pendingWorkitemActors {
					rows = append(rows, models.SObject{
						constants.FieldID:   fmt.Sprintf("wi-p-%d", i),
						"ProcessInstanceId": "pi-1",
						"ActorId":           actor,
						"OriginalActorId":   actor,
					})
				}
				return pagedActionResult(rows, limit), nil
			}
			return actionQueryResult(models.SObject{
				constants.FieldID:   "wi-1",
				"ProcessInstanceId": "pi-1",
				"ActorId":           s.workItemActorID,
				"OriginalActorId":   originalActorID,
			}), nil
		}
	case constants.TableProcessInstance:
		if strings.Contains(filter, "Id == 'pi-1'") {
			return actionQueryResult(models.SObject{
				constants.FieldID:     "pi-1",
				"ProcessDefinitionId": "pd-1",
				"TargetObjectId":      "record-1",
				"SubmittedById":       "005-submitter",
				"Status":              constants.ApprovalStatusPending,
			}), nil
		}
	case constants.TableProcessDefinition:
		s.processDefinitionQuerySeen = true
		if s.missingProcessDefinition {
			return actionQueryResult(), nil
		}
		return actionQueryResult(models.SObject{
			constants.FieldID: "pd-1",
			"Name":            "Generic Approval",
			"DeveloperName":   "Generic_Approval",
			"TableEnumOrId":   "GenericRecord",
			"Metadata":        "{}",
		}), nil
	case constants.TableProcessInstanceNode:
		if strings.Contains(filter, "ProcessInstanceId == 'pi-1'") && strings.Contains(filter, "NodeStatus == 'Pending'") {
			return actionQueryResult(models.SObject{
				constants.FieldID:   "pin-current",
				"ProcessInstanceId": "pi-1",
				"ProcessNodeId":     s.currentProcessNodeID,
				"ProcessNodeName":   actionProcessNodeName(s.currentProcessNodeID),
				"NodeStatus":        constants.ApprovalStatusPending,
			}), nil
		}
	case constants.TableProcessNode:
		if strings.Contains(filter, "ProcessDefinitionId == 'pd-1'") {
			return actionQueryResult(actionProcessNode("pn-1", "First Step", 1, "005-approver-1"), actionProcessNode("pn-2", "Second Step", 2, "005-approver-2")), nil
		}
		if strings.Contains(filter, "Id == 'pn-1'") {
			return actionQueryResult(actionProcessNode("pn-1", "First Step", 1, "005-approver-1")), nil
		}
		if strings.Contains(filter, "Id == 'pn-2'") {
			return actionQueryResult(actionProcessNode("pn-2", "Second Step", 2, "005-approver-2")), nil
		}
	case constants.TableUser:
		if strings.Contains(filter, "Id == '005-approver-1'") {
			return actionQueryResult(actionUserRecord("005-approver-1")), nil
		}
		if strings.Contains(filter, "Id == '005-approver-2'") {
			return actionQueryResult(actionUserRecord("005-approver-2")), nil
		}
	case constants.TableProcessAction:
		return actionQueryResult(), nil
	case constants.TableProcessInstanceHistory:
		if strings.Contains(filter, "Id in ['wi-1']") && strings.Contains(filter, "StepStatus == 'Pending'") {
			return actionQueryResult(models.SObject{
				constants.FieldID: "wi-1",
				constants.FieldProcessInstanceHistory_IsPending:  true,
				constants.FieldProcessInstanceHistory_StepNodeID: "pin-current",
				constants.FieldProcessInstanceHistory_ActorID:    s.workItemActorID,
				constants.FieldProcessInstanceHistory_StepStatus: constants.ApprovalStatusPending,
			}), nil
		}
	}
	return actionQueryResult(), nil
}

func (s *actionQueryStub) Query(ctx context.Context, req sharedapi.QueryRequest, user *models.UserSession) (*models.QueryResult, error) {
	return actionQueryResult(), nil
}

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

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

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

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

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

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

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

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

type actionPersistenceStub struct {
	runTxCalls     int
	runPessTxCalls int
	inserts        []submitInsertCall
	updates        []submitUpdateCall
	deletes        []struct {
		tableName string
		id        string
	}
	// deleteHook, when set, can fail a Delete to exercise fail-closed paths.
	deleteHook func(tableName, id string) error
}

func (p *actionPersistenceStub) 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-created-%d", tableName, len(p.inserts)+1)
	}
	p.inserts = append(p.inserts, submitInsertCall{tableName: tableName, record: cloned})
	return cloned, nil
}

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

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

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

func (p *actionPersistenceStub) PreviewBeforeUpdate(ctx context.Context, tableName string, id string, updates models.SObject, internalStamps models.SObject, user *models.UserSession) (*models.PreparedRecordUpdate, error) {
	panic("actionPersistenceStub.PreviewBeforeUpdate: not implemented")
}

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

func (p *actionPersistenceStub) Delete(ctx context.Context, tableName string, id string, user *models.UserSession) error {
	if p.deleteHook != nil {
		if err := p.deleteHook(tableName, id); err != nil {
			return err
		}
	}
	p.deletes = append(p.deletes, struct {
		tableName string
		id        string
	}{tableName: tableName, id: id})
	return nil
}

func (p *actionPersistenceStub) GetRaw(ctx context.Context, objectName string, id string) (models.SObject, error) {
	return models.SObject{constants.FieldID: id}, nil
}

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

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

func (p *actionPersistenceStub) RunInTransaction(ctx context.Context, fn func(context.Context) error) error {
	p.runTxCalls++
	return fn(ctx)
}

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

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

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

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

func newActionTestService(persistence *actionPersistenceStub, query *actionQueryStub) *automation.ApprovalService {
	return automation.NewApprovalService(
		persistence,
		query,
		nil,
		nil,
		nil,
		nil,
		nil,
		logging.Default,
	)
}

func actionQueryResult(records ...models.SObject) *models.QueryResult {
	cloned := make([]models.SObject, 0, len(records))
	for _, record := range records {
		cloned = append(cloned, cloneSObject(record))
	}
	return &models.QueryResult{TotalSize: len(cloned), Records: cloned}
}

// pagedActionResult mimics the query layer's single-read row cap: it returns at
// most `limit` rows and reports Done=false when more exist, so a lifecycle path
// that fails to page would observe a truncated workitem set.
func pagedActionResult(rows []models.SObject, limit int) *models.QueryResult {
	cloned := make([]models.SObject, 0, len(rows))
	for _, r := range rows {
		cloned = append(cloned, cloneSObject(r))
	}
	if limit > 0 && len(cloned) > limit {
		return &models.QueryResult{TotalSize: len(cloned), Records: cloned[:limit], Done: false}
	}
	return &models.QueryResult{TotalSize: len(cloned), Records: cloned, Done: true}
}

func actionProcessNode(id, name string, order int, approverID string) models.SObject {
	return models.SObject{
		constants.FieldID:       id,
		"Name":                  name,
		"DeveloperName":         strings.ReplaceAll(name, " ", "_"),
		"ProcessDefinitionId":   "pd-1",
		"StepOrder":             order,
		"ApproverType":          "user",
		"ApproverUserId":        approverID,
		"AllowComments":         true,
		"RequireComments":       false,
		"RejectBehavior":        "FinalRejection",
		"IfCriteriaNotMet":      "ApproveRecord",
		"NextAutomatedApprover": "",
	}
}

func actionProcessNodeName(id string) string {
	switch id {
	case "pn-1":
		return "First Step"
	case "pn-2":
		return "Second Step"
	default:
		return ""
	}
}

func actionUserRecord(id string) models.SObject {
	return models.SObject{
		constants.FieldID: id,
		"Name":            id,
		"Username":        id + "@example.test",
	}
}

func actionUser(id string) *models.UserSession {
	return &models.UserSession{
		ID:        id,
		Name:      id,
		ProfileID: constants.ProfileStandardUser,
	}
}

func TestApprovalServiceApproveFirstStepAdvancesAndKeepsInstancePending(t *testing.T) {
	persistence := &actionPersistenceStub{}
	query := &actionQueryStub{
		currentProcessNodeID: "pn-1",
		workItemActorID:      "005-approver-1",
	}
	svc := newActionTestService(persistence, query)

	result, err := svc.Approve(context.Background(), "wi-1", "looks good", actionUser("005-approver-1"))
	if err != nil {
		t.Fatalf("Approve() error = %v", err)
	}
	if got := result["InstanceStatus"]; got != constants.ApprovalStatusPending {
		t.Fatalf("InstanceStatus = %v, want %s", got, constants.ApprovalStatusPending)
	}
	if !query.processDefinitionQuerySeen {
		t.Fatal("Approve() did not load ProcessDefinition")
	}
	if !hasDelete(persistence.deletes, constants.TableProcessInstanceWorkitem, "wi-1") {
		t.Fatalf("deletes = %v, want ProcessInstanceWorkitem wi-1", persistence.deletes)
	}
	if !hasDelete(persistence.deletes, constants.TableProcessInstanceHistory, "wi-1") {
		t.Fatalf("deletes = %v, want pending ProcessInstanceHistory retired", persistence.deletes)
	}
	if !hasUpdateStatus(persistence.updates, constants.TableProcessInstanceNode, "pin-current", "NodeStatus", constants.ApprovalStatusApproved) {
		t.Fatalf("updates = %#v, want current ProcessInstanceNode approved", persistence.updates)
	}
	if hasUpdateStatus(persistence.updates, constants.TableProcessInstance, "pi-1", "Status", constants.ApprovalStatusApproved) {
		t.Fatalf("ProcessInstance was finalized on non-final step: %#v", persistence.updates)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceNode, "ProcessNodeId", "pn-2") {
		t.Fatalf("inserts = %#v, want next ProcessInstanceNode for pn-2", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceWorkitem, "ActorId", "005-approver-2") {
		t.Fatalf("inserts = %#v, want next ProcessInstanceWorkitem for approver 2", persistence.inserts)
	}
	if persistence.runPessTxCalls != 1 {
		t.Fatalf("RunInPessimisticTransaction calls = %d, want 1", persistence.runPessTxCalls)
	}
	if persistence.runTxCalls != 1 {
		t.Fatalf("RunInTransaction calls = %d, want 1 inner execution", persistence.runTxCalls)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "Comments", "looks good") {
		t.Fatalf("inserts = %#v, want approval comment history row", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "StepStatus", constants.ApprovalStatusApproved) {
		t.Fatalf("inserts = %#v, want approved history row", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "ActorId", "005-approver-2") {
		t.Fatalf("inserts = %#v, want next-step pending history row for approver 2", persistence.inserts)
	}
}

func TestApprovalServiceApproveFinalStepFinalizesInstance(t *testing.T) {
	persistence := &actionPersistenceStub{}
	query := &actionQueryStub{
		currentProcessNodeID:    "pn-2",
		workItemActorID:         "005-approver-2",
		workItemOriginalActorID: "005-original-approver",
	}
	svc := newActionTestService(persistence, query)

	result, err := svc.Approve(context.Background(), "wi-1", "final approval", actionUser("005-approver-2"))
	if err != nil {
		t.Fatalf("Approve() error = %v", err)
	}
	if got := result["InstanceStatus"]; got != constants.ApprovalStatusApproved {
		t.Fatalf("InstanceStatus = %v, want %s", got, constants.ApprovalStatusApproved)
	}
	if !hasUpdateStatus(persistence.updates, constants.TableProcessInstance, "pi-1", "Status", constants.ApprovalStatusApproved) {
		t.Fatalf("updates = %#v, want ProcessInstance finalized", persistence.updates)
	}
	if !hasDelete(persistence.deletes, constants.TableProcessInstanceHistory, "wi-1") {
		t.Fatalf("deletes = %v, want pending ProcessInstanceHistory retired", persistence.deletes)
	}
	if hasInsertValue(persistence.inserts, constants.TableProcessInstanceWorkitem, "ActorId", "005-approver-2") {
		t.Fatalf("created an extra workitem on final approval: %#v", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "Comments", "final approval") {
		t.Fatalf("inserts = %#v, want final approval comment history row", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "OriginalActorId", "005-original-approver") {
		t.Fatalf("inserts = %#v, want final approval history to preserve OriginalActorId", persistence.inserts)
	}
}

func TestApprovalServiceRecallPersistsCommentsOnRemovedNode(t *testing.T) {
	persistence := &actionPersistenceStub{}
	query := &actionQueryStub{
		currentProcessNodeID:    "pn-1",
		workItemActorID:         "005-approver-1",
		workItemOriginalActorID: "005-original-approver",
	}
	svc := newActionTestService(persistence, query)

	result, err := svc.Recall(context.Background(), "wi-1", "Pulling this back for edits", actionUser("005-submitter"))
	if err != nil {
		t.Fatalf("Recall() error = %v", err)
	}
	if got := result["ProcessInstanceId"]; got != "pi-1" {
		t.Fatalf("ProcessInstanceId = %v, want pi-1", got)
	}
	if !hasDelete(persistence.deletes, constants.TableProcessInstanceWorkitem, "wi-1") {
		t.Fatalf("deletes = %v, want ProcessInstanceWorkitem wi-1", persistence.deletes)
	}
	if !hasDelete(persistence.deletes, constants.TableProcessInstanceHistory, "wi-1") {
		t.Fatalf("deletes = %v, want pending ProcessInstanceHistory retired", persistence.deletes)
	}
	if !hasUpdateStatus(persistence.updates, constants.TableProcessInstance, "pi-1", "Status", constants.ApprovalStatusRemoved) {
		t.Fatalf("updates = %#v, want ProcessInstance removed", persistence.updates)
	}
	if !hasUpdateStatus(persistence.updates, constants.TableProcessInstanceNode, "pin-current", "NodeStatus", constants.ApprovalStatusRemoved) {
		t.Fatalf("updates = %#v, want current ProcessInstanceNode removed", persistence.updates)
	}
	if !hasUpdateStatus(persistence.updates, constants.TableProcessInstanceNode, "pin-current", "LastActorId", "005-submitter") {
		t.Fatalf("updates = %#v, want recall actor stamped on ProcessInstanceNode", persistence.updates)
	}
	if !hasUpdateStatus(persistence.updates, constants.TableProcessInstanceNode, "pin-current", "Comments", "Pulling this back for edits") {
		t.Fatalf("updates = %#v, want recall comments persisted on ProcessInstanceNode", persistence.updates)
	}
	if persistence.runPessTxCalls != 1 {
		t.Fatalf("RunInPessimisticTransaction calls = %d, want 1", persistence.runPessTxCalls)
	}
	if persistence.runTxCalls != 1 {
		t.Fatalf("RunInTransaction calls = %d, want 1 inner execution", persistence.runTxCalls)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "Comments", "Pulling this back for edits") {
		t.Fatalf("inserts = %#v, want recall comment history row", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "StepStatus", constants.ApprovalStatusRemoved) {
		t.Fatalf("inserts = %#v, want removed history row", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "OriginalActorId", "005-original-approver") {
		t.Fatalf("inserts = %#v, want removed history to preserve OriginalActorId", persistence.inserts)
	}
}

// TestApprovalServiceRecallPagesAllPendingWorkitems proves Recall retires every
// pending workitem even when the instance has more than one page of them, so
// reconcileApprovalShares cannot strand approval shares for the dropped tail.
func TestApprovalServiceRecallPagesAllPendingWorkitems(t *testing.T) {
	const n = 250 // > the 200-row page size
	actors := make([]string, n)
	for i := range actors {
		actors[i] = fmt.Sprintf("005-a-%03d", i)
	}
	persistence := &actionPersistenceStub{}
	query := &actionQueryStub{
		currentProcessNodeID:  "pn-1",
		workItemActorID:       "005-approver-1",
		pendingWorkitemActors: actors,
	}
	svc := newActionTestService(persistence, query)

	if _, err := svc.Recall(context.Background(), "wi-1", "withdraw", actionUser("005-submitter")); err != nil {
		t.Fatalf("Recall() error = %v", err)
	}
	wiDeletes := 0
	for _, d := range persistence.deletes {
		if d.tableName == constants.TableProcessInstanceWorkitem {
			wiDeletes++
		}
	}
	if wiDeletes != n {
		t.Fatalf("ProcessInstanceWorkitem deletes = %d, want %d (all pending workitems retired, no page cap)", wiDeletes, n)
	}
}

// TestApprovalServiceRecallFailsClosedOnWorkitemDeleteError proves a workitem
// delete failure during Recall aborts before share reconciliation, rather than
// marking the instance Removed while a former approver keeps record access.
func TestApprovalServiceRecallFailsClosedOnWorkitemDeleteError(t *testing.T) {
	persistence := &actionPersistenceStub{
		deleteHook: func(tableName, id string) error {
			if tableName == constants.TableProcessInstanceWorkitem {
				return fmt.Errorf("simulated delete failure for %s", id)
			}
			return nil
		},
	}
	query := &actionQueryStub{
		currentProcessNodeID: "pn-1",
		workItemActorID:      "005-approver-1",
	}
	svc := newActionTestService(persistence, query)

	_, err := svc.Recall(context.Background(), "wi-1", "withdraw", actionUser("005-submitter"))
	if err == nil {
		t.Fatal("Recall() error = nil, want failure propagated so the transaction aborts")
	}
	// Reconciliation runs only after workitem cleanup; failing closed means it
	// never granted/touched approval shares for this instance.
	for _, ins := range persistence.inserts {
		if ins.tableName == constants.TableRecordShare {
			t.Fatalf("approval share inserted despite recall delete failure: %#v", ins)
		}
	}
}

func TestApprovalServiceApproveMissingProcessDefinitionDoesNotFinalize(t *testing.T) {
	persistence := &actionPersistenceStub{}
	query := &actionQueryStub{
		currentProcessNodeID:     "pn-1",
		workItemActorID:          "005-approver-1",
		missingProcessDefinition: true,
	}
	svc := newActionTestService(persistence, query)

	_, err := svc.Approve(context.Background(), "wi-1", "looks good", actionUser("005-approver-1"))
	if err == nil {
		t.Fatal("Approve() error = nil, want missing ProcessDefinition error")
	}
	if !strings.Contains(err.Error(), "approval process definition") {
		t.Fatalf("Approve() error = %v, want approval process definition context", err)
	}
	if len(persistence.inserts) != 0 || len(persistence.updates) != 0 || len(persistence.deletes) != 0 {
		t.Fatalf("mutations after missing ProcessDefinition: inserts=%#v updates=%#v deletes=%#v", persistence.inserts, persistence.updates, persistence.deletes)
	}
}

func TestApprovalServiceRejectFinalStepFinalizesInstance(t *testing.T) {
	persistence := &actionPersistenceStub{}
	query := &actionQueryStub{
		currentProcessNodeID: "pn-2",
		workItemActorID:      "005-approver-2",
	}
	svc := newActionTestService(persistence, query)

	result, err := svc.Reject(context.Background(), "wi-1", "not good enough", actionUser("005-approver-2"))
	if err != nil {
		t.Fatalf("Reject() error = %v", err)
	}
	if got := result["InstanceStatus"]; got != constants.ApprovalStatusRejected {
		t.Fatalf("InstanceStatus = %v, want %s", got, constants.ApprovalStatusRejected)
	}
	if !hasUpdateStatus(persistence.updates, constants.TableProcessInstance, "pi-1", "Status", constants.ApprovalStatusRejected) {
		t.Fatalf("updates = %#v, want ProcessInstance rejected", persistence.updates)
	}
	if !hasDelete(persistence.deletes, constants.TableProcessInstanceWorkitem, "wi-1") {
		t.Fatalf("deletes = %v, want ProcessInstanceWorkitem wi-1 deleted", persistence.deletes)
	}
	if !hasDelete(persistence.deletes, constants.TableProcessInstanceHistory, "wi-1") {
		t.Fatalf("deletes = %v, want pending ProcessInstanceHistory retired", persistence.deletes)
	}
	if hasInsertValue(persistence.inserts, constants.TableProcessInstanceWorkitem, "ActorId", "005-approver-2") {
		t.Fatalf("created an extra workitem on terminal Reject: %#v", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "Comments", "not good enough") {
		t.Fatalf("inserts = %#v, want reject comment history row", persistence.inserts)
	}
	if !hasInsertValue(persistence.inserts, constants.TableProcessInstanceHistory, "StepStatus", constants.ApprovalStatusRejected) {
		t.Fatalf("inserts = %#v, want rejected history row", persistence.inserts)
	}
}

func hasDelete(deletes []struct {
	tableName string
	id        string
}, tableName, id string) bool {
	for _, del := range deletes {
		if del.tableName == tableName && del.id == id {
			return true
		}
	}
	return false
}

func hasUpdateStatus(updates []submitUpdateCall, tableName, id, field string, value interface{}) bool {
	for _, update := range updates {
		if update.tableName == tableName && update.id == id && update.updates[field] == value {
			return true
		}
	}
	return false
}

func hasInsertValue(inserts []submitInsertCall, tableName, field string, value interface{}) bool {
	for _, insert := range inserts {
		if insert.tableName == tableName && insert.record[field] == value {
			return true
		}
	}
	return false
}
