package runtimeapi

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"regexp"
	"sort"
	"strconv"
	"strings"

	"github.com/nexuscrm/metadata/catalogsnapshot"
	"github.com/nexuscrm/persistence"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/nexuscrm/shared/pkg/referenceidentity"
	"github.com/pingcap-inc/nexuscrm-contract/pkg/approvalir"
)

var approvalAuthorityClausePattern = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9_]*)\s*==\s*'(.*)'$`)

type catalogApprovalBinding struct {
	catalog catalogsnapshot.ApprovalDefinition
	liveID  string
	nodes   []models.SObject
}

// catalogApprovalAuthority adapts one immutable request-bound Catalog view to
// the existing Approval engine's internal metadata ports. New approval state
// uses Catalog definition identities and deterministic Catalog node identities
// directly. Live definition/node rows are consulted only to map approval state
// created by the retired monolith; they never contribute executable metadata.
type catalogApprovalAuthority struct {
	view     *catalogsnapshot.RuntimeMetadataView
	anchors  approvalIdentityAnchorStore
	bindings map[string]*catalogApprovalBinding
}

type approvalIdentityAnchorStore interface {
	FindDefinitionByID(context.Context, string) ([]persistence.RuntimeApprovalDefinitionAnchor, error)
	FindDefinitionByNaturalKey(context.Context, string, string) ([]persistence.RuntimeApprovalDefinitionAnchor, error)
	FindNodeByID(context.Context, string) ([]persistence.RuntimeApprovalNodeAnchor, error)
	ListNodesByDefinitionID(context.Context, string) ([]persistence.RuntimeApprovalNodeAnchor, error)
}

func newCatalogApprovalAuthority(
	view *catalogsnapshot.RuntimeMetadataView,
	anchors approvalIdentityAnchorStore,
) (*catalogApprovalAuthority, error) {
	if view == nil || anchors == nil {
		return nil, errors.New("runtime Approval Catalog authority requires an immutable view and identity anchors")
	}
	return &catalogApprovalAuthority{
		view: view, anchors: anchors,
		bindings: make(map[string]*catalogApprovalBinding),
	}, nil
}

func (a *catalogApprovalAuthority) QueryProcessDefinitions(
	ctx context.Context,
	filterExpr string,
	sortField string,
	sortDir string,
	limit int,
) (*models.QueryResult, error) {
	filter, err := parseApprovalAuthorityFilter(filterExpr)
	if err != nil {
		return nil, fmt.Errorf("runtime Approval ProcessDefinition query: %w", err)
	}
	var records []models.SObject
	objectName, objectBound := filter["TableEnumOrId"]
	state, stateBound := filter["State"]
	if objectBound && stateBound && state == "Active" {
		definitions := a.view.ActiveApprovalDefinitionsForObject(objectName)
		records = make([]models.SObject, 0, len(definitions))
		for _, definition := range definitions {
			binding, bindErr := a.bindCatalogDefinition(definition)
			if bindErr != nil {
				return nil, bindErr
			}
			records = append(records, synthesizeProcessDefinition(binding))
		}
	} else {
		var live []persistence.RuntimeApprovalDefinitionAnchor
		var queryErr error
		if id := strings.TrimSpace(filter[constants.FieldID]); id != "" {
			// Runtime state stores either the Catalog-native definition identity
			// (new instances) or a legacy physical anchor identity (instances
			// created by the retired monolith).  Resolve both through the one
			// request-bound authority.  Consulting the physical anchor repository
			// directly here made action/share reconciliation reject every new
			// Catalog-native instance even though the same authority had already
			// admitted and executed its definition.
			binding, bindErr := a.bindingForStorageID(ctx, id)
			if bindErr != nil {
				return nil, bindErr
			}
			records = append(records, synthesizeProcessDefinition(binding))
		} else if object := strings.TrimSpace(filter["TableEnumOrId"]); object != "" {
			developerName := strings.TrimSpace(filter["DeveloperName"])
			if developerName == "" {
				return nil, errors.New("runtime Approval ProcessDefinition query is not identity-bound")
			}
			live, queryErr = a.anchors.FindDefinitionByNaturalKey(ctx, object, developerName)
		} else {
			return nil, errors.New("runtime Approval ProcessDefinition query is not identity-bound")
		}
		if queryErr != nil {
			return nil, fmt.Errorf("runtime Approval read ProcessDefinition identity anchors: %w", queryErr)
		}
		for _, anchor := range live {
			binding, bindErr := a.bindLiveDefinition(ctx, definitionAnchorRecord(anchor))
			if bindErr != nil {
				return nil, bindErr
			}
			records = append(records, synthesizeProcessDefinition(binding))
		}
	}
	return finishApprovalAuthorityQuery(records, filter, sortField, sortDir, limit)
}

func (a *catalogApprovalAuthority) QueryProcessNodes(
	ctx context.Context,
	filterExpr string,
	sortField string,
	sortDir string,
	limit int,
) (*models.QueryResult, error) {
	filter, err := parseApprovalAuthorityFilter(filterExpr)
	if err != nil {
		return nil, fmt.Errorf("runtime Approval ProcessNode query: %w", err)
	}
	var binding *catalogApprovalBinding
	if definitionID := strings.TrimSpace(filter["ProcessDefinitionId"]); definitionID != "" {
		binding, err = a.bindingForStorageID(ctx, definitionID)
	} else if nodeID := strings.TrimSpace(filter[constants.FieldID]); nodeID != "" {
		if catalogBinding := a.catalogBindingForNodeID(nodeID); catalogBinding != nil {
			return finishApprovalAuthorityQuery(cloneApprovalAuthorityRecords(catalogBinding.nodes), filter, sortField, sortDir, limit)
		}
		live, queryErr := a.anchors.FindNodeByID(ctx, nodeID)
		if queryErr != nil {
			return nil, fmt.Errorf("runtime Approval read ProcessNode identity anchor: %w", queryErr)
		}
		if len(live) != 1 {
			return nil, errors.New("runtime Approval ProcessNode identity anchor is missing or ambiguous")
		}
		definitionID = strings.TrimSpace(live[0].ProcessDefinitionID)
		if definitionID == "" {
			return nil, errors.New("runtime Approval ProcessNode identity anchor has no definition")
		}
		binding, err = a.bindingForStorageID(ctx, definitionID)
	} else {
		return nil, errors.New("runtime Approval ProcessNode query is not definition-bound")
	}
	if err != nil {
		return nil, err
	}
	return finishApprovalAuthorityQuery(cloneApprovalAuthorityRecords(binding.nodes), filter, sortField, sortDir, limit)
}

func (a *catalogApprovalAuthority) catalogBindingForNodeID(nodeID string) *catalogApprovalBinding {
	nodeID = strings.TrimSpace(nodeID)
	for _, binding := range a.bindings {
		for _, node := range binding.nodes {
			if strings.TrimSpace(approvalAuthorityString(node[constants.FieldID])) == nodeID {
				return binding
			}
		}
	}
	return nil
}

func (a *catalogApprovalAuthority) QueryProcessActions(
	ctx context.Context,
	filterExpr string,
	sortField string,
	sortDir string,
	limit int,
) (*models.QueryResult, error) {
	filter, err := parseApprovalAuthorityFilter(filterExpr)
	if err != nil {
		return nil, fmt.Errorf("runtime Approval ProcessAction query: %w", err)
	}
	definitionID := strings.TrimSpace(filter["ProcessDefinitionId"])
	if definitionID == "" {
		return nil, errors.New("runtime Approval ProcessAction query is not definition-bound")
	}
	binding, err := a.bindingForStorageID(ctx, definitionID)
	if err != nil {
		return nil, err
	}
	actions, err := synthesizeProcessActions(binding)
	if err != nil {
		return nil, err
	}
	return finishApprovalAuthorityQuery(actions, filter, sortField, sortDir, limit)
}

func (a *catalogApprovalAuthority) GetProcessDefinition(
	ctx context.Context,
	storageID string,
) (models.SObject, error) {
	binding, err := a.bindingForStorageID(ctx, storageID)
	if err != nil {
		return nil, err
	}
	return synthesizeProcessDefinition(binding), nil
}

func (a *catalogApprovalAuthority) CatalogDefinitionID(
	ctx context.Context,
	storageID string,
) (string, error) {
	binding, err := a.bindingForStorageID(ctx, storageID)
	if err != nil {
		return "", err
	}
	return binding.catalog.DefinitionID, nil
}

// ResolveReferenceIdentities exposes the same immutable Catalog identity
// closure to generic Records and Analytics reference consumers. Objects not
// owned by Approval remain on their existing physical-record path.
func (a *catalogApprovalAuthority) ResolveReferenceIdentities(
	ctx context.Context,
	objectName string,
	storageIDs []string,
	_ []string,
) (referenceidentity.Resolution, error) {
	if objectName != constants.TableProcessDefinition && objectName != constants.TableProcessNode {
		return referenceidentity.Resolution{}, nil
	}
	resolution := referenceidentity.Resolution{
		Identities: make(map[string]referenceidentity.IdentityResolution, len(storageIDs)),
	}
	for _, storageID := range storageIDs {
		storageID = strings.TrimSpace(storageID)
		if storageID == "" {
			continue
		}
		switch objectName {
		case constants.TableProcessDefinition:
			if definition, ok := a.view.ApprovalDefinition(storageID); ok {
				binding, err := a.bindCatalogDefinition(definition)
				if err != nil {
					return referenceidentity.Resolution{}, err
				}
				resolution.Identities[storageID] = referenceidentity.IdentityResolution{
					Disposition: referenceidentity.Immutable, Record: synthesizeProcessDefinition(binding),
				}
				continue
			}
			anchors, err := a.anchors.FindDefinitionByID(ctx, storageID)
			if err != nil {
				return referenceidentity.Resolution{}, fmt.Errorf("runtime Approval read ProcessDefinition identity anchor: %w", err)
			}
			if len(anchors) > 1 {
				return referenceidentity.Resolution{}, errors.New("runtime Approval ProcessDefinition identity anchor is ambiguous")
			}
			if len(anchors) == 0 {
				resolution.Identities[storageID] = referenceidentity.IdentityResolution{Disposition: referenceidentity.OwnedMissing}
			}
		case constants.TableProcessNode:
			if err := a.bindCurrentCatalogDefinitions(); err != nil {
				return referenceidentity.Resolution{}, err
			}
			if binding := a.catalogBindingForNodeID(storageID); binding != nil {
				for _, node := range binding.nodes {
					if approvalAuthorityString(node[constants.FieldID]) == storageID {
						resolution.Identities[storageID] = referenceidentity.IdentityResolution{
							Disposition: referenceidentity.Immutable,
							Record:      cloneApprovalAuthorityRecords([]models.SObject{node})[0],
						}
						break
					}
				}
				continue
			}
			anchors, err := a.anchors.FindNodeByID(ctx, storageID)
			if err != nil {
				return referenceidentity.Resolution{}, fmt.Errorf("runtime Approval read ProcessNode identity anchor: %w", err)
			}
			if len(anchors) > 1 {
				return referenceidentity.Resolution{}, errors.New("runtime Approval ProcessNode identity anchor is ambiguous")
			}
			if len(anchors) == 0 {
				resolution.Identities[storageID] = referenceidentity.IdentityResolution{Disposition: referenceidentity.OwnedMissing}
			}
		}
	}
	return resolution, nil
}

func (a *catalogApprovalAuthority) bindCurrentCatalogDefinitions() error {
	for _, objectAPIName := range a.view.ActiveApprovalObjectAPINames() {
		for _, definition := range a.view.ActiveApprovalDefinitionsForObject(objectAPIName) {
			if _, err := a.bindCatalogDefinition(definition); err != nil {
				return err
			}
		}
	}
	return nil
}

// ValidateImmutableIdentity proves that a metadata parent referenced by the
// Approval engine belongs to this request's immutable Catalog closure. The
// identity is deliberately not translated into a database row lock: new
// Catalog-native definitions and steps have no live sidecar rows, while legacy
// identities have already been mapped to this same immutable closure.
func (a *catalogApprovalAuthority) ValidateImmutableIdentity(
	ctx context.Context,
	objectName string,
	storageID string,
) error {
	storageID = strings.TrimSpace(storageID)
	if storageID == "" {
		return errors.New("runtime Approval immutable identity is empty")
	}
	switch objectName {
	case constants.TableProcessDefinition:
		_, err := a.bindingForStorageID(ctx, storageID)
		return err
	case constants.TableProcessNode:
		if a.catalogBindingForNodeID(storageID) != nil {
			return nil
		}
		// Validation must not depend on whether an earlier engine query happened
		// to populate the request-local cache. Reconstruct the bounded Catalog
		// node closure deterministically before consulting legacy anchors.
		if err := a.bindCurrentCatalogDefinitions(); err != nil {
			return err
		}
		if a.catalogBindingForNodeID(storageID) != nil {
			return nil
		}
		anchors, err := a.anchors.FindNodeByID(ctx, storageID)
		if err != nil {
			return fmt.Errorf("runtime Approval read ProcessNode identity anchor: %w", err)
		}
		if len(anchors) != 1 {
			return errors.New("runtime Approval ProcessNode identity anchor is missing or ambiguous")
		}
		binding, err := a.bindingForStorageID(ctx, anchors[0].ProcessDefinitionID)
		if err != nil {
			return err
		}
		for _, node := range binding.nodes {
			if strings.TrimSpace(approvalAuthorityString(node[constants.FieldID])) == storageID {
				return nil
			}
		}
		return errors.New("runtime Approval ProcessNode identity is absent from the request Catalog revision")
	default:
		return fmt.Errorf("runtime Approval immutable parent object %q is unsupported", objectName)
	}
}

// ApprovalLockType maps the immutable Catalog editability policy to the
// existing permission engine's closed lock vocabulary. The storage definition
// contributes identity only and is validated against this request's Catalog
// revision before any decision is returned.
func (a *catalogApprovalAuthority) ApprovalLockType(
	ctx context.Context,
	storageID string,
) (string, error) {
	binding, err := a.bindingForStorageID(ctx, storageID)
	if err != nil {
		return "", err
	}
	switch binding.catalog.Definition.Policy.RecordEditability {
	case approvalir.RecordEditabilityAdminOnly:
		return "admin", nil
	case approvalir.RecordEditabilityAdminOrCurrentApprover:
		return "workitem", nil
	default:
		return "", errors.New("runtime Approval Catalog record-editability policy is unsupported")
	}
}

func (a *catalogApprovalAuthority) bindingForStorageID(
	ctx context.Context,
	storageID string,
) (*catalogApprovalBinding, error) {
	storageID = strings.TrimSpace(storageID)
	if storageID == "" {
		return nil, errors.New("runtime Approval definition identity is empty")
	}
	if cached := a.bindings[storageID]; cached != nil {
		return cached, nil
	}
	// Independent Runtime writes Catalog identities into new durable approval
	// state. Resolve that authority before touching the compatibility anchor
	// repository; a definition published directly by L1/L2 may intentionally
	// have no legacy ProcessDefinition/ProcessNode rows at all.
	if definition, ok := a.view.ApprovalDefinition(storageID); ok {
		return a.bindCatalogDefinition(definition)
	}
	// Existing ProcessInstance rows created by the monolith still contain its
	// live ProcessDefinition ID. Keep the narrow natural-key bridge for those
	// rows until they drain.
	result, err := a.anchors.FindDefinitionByID(ctx, storageID)
	if err != nil {
		return nil, fmt.Errorf("runtime Approval read ProcessDefinition identity anchor: %w", err)
	}
	if len(result) != 1 {
		return nil, errors.New("runtime Approval ProcessDefinition identity anchor is missing or ambiguous")
	}
	return a.bindLiveDefinition(ctx, definitionAnchorRecord(result[0]))
}

func (a *catalogApprovalAuthority) bindCatalogDefinition(
	definition catalogsnapshot.ApprovalDefinition,
) (*catalogApprovalBinding, error) {
	storageID := strings.TrimSpace(definition.DefinitionID)
	if storageID == "" {
		return nil, errors.New("runtime Approval Catalog definition identity is empty")
	}
	if cached := a.bindings[storageID]; cached != nil {
		return cached, nil
	}
	binding := &catalogApprovalBinding{catalog: definition, liveID: storageID}
	binding.nodes = synthesizeCatalogProcessNodes(binding)
	a.bindings[storageID] = binding
	return binding, nil
}

func synthesizeCatalogProcessNodes(binding *catalogApprovalBinding) []models.SObject {
	if binding == nil {
		return nil
	}
	nodes := make([]models.SObject, 0, len(binding.catalog.Definition.Steps))
	for index, step := range binding.catalog.Definition.Steps {
		// A step's authored name is unique within its definition. Keep the
		// durable identity independent of display labels and ordering so an
		// immutable Catalog revision that reorders unchanged steps does not
		// orphan already-pending ProcessInstance rows.
		anchor := models.SObject{
			constants.FieldID: catalogsnapshot.ApprovalNodeStorageID(binding.catalog.DefinitionID, step.Name),
		}
		nodes = append(nodes, synthesizeProcessNode(binding.liveID, anchor, step, index+1))
	}
	return nodes
}

func (a *catalogApprovalAuthority) bindLiveDefinition(
	ctx context.Context,
	live models.SObject,
) (*catalogApprovalBinding, error) {
	storageID := strings.TrimSpace(approvalAuthorityString(live[constants.FieldID]))
	objectAPIName := strings.TrimSpace(approvalAuthorityString(live["TableEnumOrId"]))
	developerName := strings.TrimSpace(approvalAuthorityString(live["DeveloperName"]))
	if storageID == "" || objectAPIName == "" || developerName == "" {
		return nil, errors.New("runtime Approval ProcessDefinition identity anchor is incomplete")
	}
	if cached := a.bindings[storageID]; cached != nil {
		return cached, nil
	}
	definition, ok := a.view.ApprovalDefinitionForObject(objectAPIName, developerName)
	if !ok || definition.ObjectAPIName != objectAPIName || definition.DeveloperName != developerName {
		return nil, fmt.Errorf(
			"runtime Approval ProcessDefinition %q is absent from the request Catalog revision",
			storageID,
		)
	}
	binding := &catalogApprovalBinding{catalog: definition, liveID: storageID}
	nodes, err := a.loadAndValidateNodes(ctx, binding)
	if err != nil {
		return nil, err
	}
	binding.nodes = nodes
	a.bindings[storageID] = binding
	return binding, nil
}

func (a *catalogApprovalAuthority) loadAndValidateNodes(
	ctx context.Context,
	binding *catalogApprovalBinding,
) ([]models.SObject, error) {
	result, err := a.anchors.ListNodesByDefinitionID(ctx, binding.liveID)
	if err != nil {
		return nil, fmt.Errorf("runtime Approval read ProcessNode identity anchors: %w", err)
	}
	if len(result) != len(binding.catalog.Definition.Steps) {
		return nil, fmt.Errorf(
			"runtime Approval definition %q node identity topology is missing or stale",
			binding.catalog.DefinitionID,
		)
	}
	liveByOrder := make(map[int]models.SObject, len(result))
	seenIDs := make(map[string]struct{}, len(result))
	for _, anchor := range result {
		row := nodeAnchorRecord(anchor)
		id := strings.TrimSpace(approvalAuthorityString(row[constants.FieldID]))
		order := approvalAuthorityInt(row["StepOrder"])
		if id == "" || order <= 0 || order > len(binding.catalog.Definition.Steps) {
			return nil, errors.New("runtime Approval ProcessNode identity anchor is incomplete")
		}
		if _, duplicate := seenIDs[id]; duplicate {
			return nil, errors.New("runtime Approval ProcessNode identity anchor is duplicated")
		}
		if _, duplicate := liveByOrder[order]; duplicate {
			return nil, errors.New("runtime Approval ProcessNode order is duplicated")
		}
		seenIDs[id] = struct{}{}
		liveByOrder[order] = row
	}
	nodes := make([]models.SObject, 0, len(binding.catalog.Definition.Steps))
	for index, step := range binding.catalog.Definition.Steps {
		order := index + 1
		live := liveByOrder[order]
		if live == nil || approvalAuthorityString(live["DeveloperName"]) != step.Name {
			return nil, fmt.Errorf(
				"runtime Approval definition %q node topology is stale at step %d",
				binding.catalog.DefinitionID, order,
			)
		}
		nodes = append(nodes, synthesizeProcessNode(binding.liveID, live, step, order))
	}
	return nodes, nil
}

func definitionAnchorRecord(anchor persistence.RuntimeApprovalDefinitionAnchor) models.SObject {
	return models.SObject{
		constants.FieldID: anchor.ID,
		"TableEnumOrId":   anchor.ObjectAPIName,
		"DeveloperName":   anchor.DeveloperName,
	}
}

func nodeAnchorRecord(anchor persistence.RuntimeApprovalNodeAnchor) models.SObject {
	return models.SObject{
		constants.FieldID:     anchor.ID,
		"ProcessDefinitionId": anchor.ProcessDefinitionID,
		"DeveloperName":       anchor.DeveloperName,
		"StepOrder":           anchor.StepOrder,
	}
}

func synthesizeProcessDefinition(binding *catalogApprovalBinding) models.SObject {
	definition := binding.catalog.Definition
	state := "Inactive"
	if definition.Active {
		state = "Active"
	}
	envelope, _ := json.Marshal(approvalir.DefinitionEnvelope{
		Schema:       approvalir.DefinitionEnvelopeSchemaV1,
		SourceDigest: binding.catalog.SourceDigest,
		Definition:   definition,
	})
	record := models.SObject{
		constants.FieldID:          binding.liveID,
		"Name":                     firstApprovalAuthorityNonEmpty(definition.Label, binding.catalog.DeveloperName),
		"DeveloperName":            binding.catalog.DeveloperName,
		"Description":              definition.Description,
		"State":                    state,
		"IsActive":                 definition.Active,
		"TableEnumOrId":            binding.catalog.ObjectAPIName,
		"EntryFilter":              encodeApprovalEntryCriteria(definition.EntryCriteria),
		"FinalApprovalRecordLock":  definition.FinalApprovalRecordLock,
		"FinalRejectionRecordLock": definition.FinalRejectionRecordLock,
		"AllowRecall":              definition.AllowRecall,
		"Metadata":                 string(envelope),
	}
	if definition.ProcessOrder != nil {
		record[constants.ColProcessDefinition_ProcessOrder] = *definition.ProcessOrder
	}
	return record
}

func synthesizeProcessNode(
	definitionID string,
	live models.SObject,
	step approvalir.Step,
	order int,
) models.SObject {
	record := models.SObject{
		constants.FieldID: strings.TrimSpace(approvalAuthorityString(live[constants.FieldID])),
		// Preserve the ProcessNode wire/storage identity established by the
		// legacy CRM lifecycle materializer: Name is the authored developer
		// name. The human label remains in the immutable Approval definition
		// and is projected by the dedicated approval display model.
		"Name":                step.Name,
		"DeveloperName":       step.Name,
		"Description":         step.Description,
		"ProcessDefinitionId": definitionID,
		"AllowComments":       step.AllowComments == nil || *step.AllowComments,
		"RequireComments":     step.RequireComments != nil && *step.RequireComments,
		"StepOrder":           order,
		"RejectBehavior":      approvalIRRejectBehaviorForAuthority(step.RejectBehavior),
	}
	if len(step.Approvers) == 0 {
		return record
	}
	first := step.Approvers[0]
	record["ApproverType"] = approvalIRApproverTypeForAuthority(first.Kind)
	switch first.Kind {
	case approvalir.ApproverKindRelatedUserField, approvalir.ApproverKindUserHierarchyField:
		record["ApproverRelatedField"] = first.Reference
	case approvalir.ApproverKindUser:
		values := make([]string, 0, len(step.Approvers))
		for _, approver := range step.Approvers {
			if approver.Kind == approvalir.ApproverKindUser {
				values = append(values, approver.Reference)
			}
		}
		record["ApproverUserId"] = strings.Join(values, ",")
	}
	return record
}

func synthesizeProcessActions(binding *catalogApprovalBinding) ([]models.SObject, error) {
	definition := binding.catalog.Definition
	type actionGroup struct {
		event   string
		nodeID  string
		actions []approvalir.Action
	}
	groups := []actionGroup{
		{event: constants.ApprovalActionEventSubmit, actions: definition.InitialSubmissionActions},
		{event: constants.ApprovalActionEventStepApproval, actions: definition.StepApprovalActions},
		{event: constants.ApprovalActionEventFinalApproval, actions: definition.FinalApprovalActions},
		{event: constants.ApprovalActionEventFinalRejection, actions: definition.FinalRejectionActions},
		{event: constants.ApprovalActionEventRecall, actions: definition.RecallActions},
		{event: constants.ApprovalActionEventReassign, actions: definition.ReassignActions},
	}
	for index, step := range definition.Steps {
		groups = append(groups,
			actionGroup{event: constants.ApprovalActionEventStepApproval, nodeID: approvalAuthorityString(binding.nodes[index][constants.FieldID]), actions: step.ApprovalActions},
			actionGroup{event: constants.ApprovalActionEventStepRejection, nodeID: approvalAuthorityString(binding.nodes[index][constants.FieldID]), actions: step.RejectionActions},
		)
	}
	result := make([]models.SObject, 0)
	for _, group := range groups {
		for index, action := range group.actions {
			sum := sha256.Sum256([]byte(binding.catalog.DefinitionID + "\x00" + group.event + "\x00" + group.nodeID + "\x00" + strconv.Itoa(index) + "\x00" + action.Name))
			record := models.SObject{
				constants.FieldID:                  "catalog-approval-action-" + hex.EncodeToString(sum[:16]),
				"Name":                             action.Name,
				"ProcessDefinitionId":              binding.liveID,
				"ProcessNodeId":                    group.nodeID,
				"ActionEvent":                      group.event,
				constants.FieldProcessAction_Order: index + 1,
			}
			switch action.Kind {
			case approvalir.ActionKindFieldUpdate:
				if action.FieldUpdate == nil {
					return nil, fmt.Errorf("runtime Approval field-update action %q has no payload", action.Name)
				}
				fieldValue, ok := approvalAuthorityFieldValue(action.FieldUpdate.Value)
				if !ok {
					return nil, fmt.Errorf("runtime Approval action %q has a field value unsupported by the bounded V1 adapter", action.Name)
				}
				record["ActionType"] = constants.ProcessActionTypeFieldUpdate
				record["FieldApiName"] = action.FieldUpdate.FieldAPIName
				record["FieldValue"] = fieldValue
			case approvalir.ActionKindFlow:
				if action.Flow == nil || strings.TrimSpace(action.Flow.APIName) == "" {
					return nil, fmt.Errorf("runtime Approval Flow action %q has no API name", action.Name)
				}
				// Catalog compilation has already proven this exact Flow version is
				// notification-only. Preserve it as the existing ProcessAction Flow
				// shape so ApprovalService runs it post-commit and best-effort; it
				// never becomes part of the bounded approval state transaction.
				record["ActionType"] = constants.ProcessActionTypeFlow
				record[constants.FieldProcessAction_APIName] = action.Flow.APIName
			default:
				return nil, fmt.Errorf("runtime Approval action %q requires unsupported external or asynchronous execution", action.Name)
			}
			result = append(result, record)
		}
	}
	return result, nil
}

func approvalAuthorityFieldValue(value approvalir.FieldUpdateValue) (string, bool) {
	if value.Null {
		return "", false
	}
	switch value.Kind {
	case approvalir.FieldUpdateValueKindSourceText:
		if value.SourceText != nil {
			return *value.SourceText, true
		}
	case approvalir.FieldUpdateValueKindString:
		if value.String != nil {
			return *value.String, true
		}
	case approvalir.FieldUpdateValueKindBoolean:
		if value.Boolean != nil {
			return strconv.FormatBool(*value.Boolean), true
		}
	case approvalir.FieldUpdateValueKindInteger:
		if value.Integer != nil {
			return strconv.FormatInt(*value.Integer, 10), true
		}
	case approvalir.FieldUpdateValueKindDecimal:
		if value.Decimal != nil {
			coefficient := value.Decimal.Coefficient
			scale := int(value.Decimal.Scale)
			negative := strings.HasPrefix(coefficient, "-")
			digits := strings.TrimPrefix(coefficient, "-")
			for len(digits) <= scale {
				digits = "0" + digits
			}
			if scale > 0 {
				digits = digits[:len(digits)-scale] + "." + digits[len(digits)-scale:]
			}
			if negative {
				digits = "-" + digits
			}
			return digits, true
		}
	case approvalir.FieldUpdateValueKindDate:
		if value.Date != nil {
			return *value.Date, true
		}
	case approvalir.FieldUpdateValueKindDateTime:
		if value.DateTime != nil {
			return *value.DateTime, true
		}
	case approvalir.FieldUpdateValueKindReference:
		if value.Reference != nil {
			return *value.Reference, true
		}
	}
	return "", false
}

func encodeApprovalEntryCriteria(criteria *approvalir.Criteria) string {
	if criteria == nil {
		return ""
	}
	value := struct {
		Formula       string `json:"formula,omitempty"`
		BooleanFilter string `json:"booleanFilter,omitempty"`
		CriteriaItems []struct {
			Field     string `json:"field"`
			Operation string `json:"operation"`
			Value     string `json:"value"`
		} `json:"criteriaItems,omitempty"`
	}{Formula: criteria.Formula, BooleanFilter: criteria.BooleanFilter}
	for _, item := range criteria.Items {
		value.CriteriaItems = append(value.CriteriaItems, struct {
			Field     string `json:"field"`
			Operation string `json:"operation"`
			Value     string `json:"value"`
		}{Field: item.Field, Operation: approvalIRCriteriaOperatorForAuthority(item.Operation), Value: item.Value})
	}
	encoded, _ := json.Marshal(value)
	return string(encoded)
}

func parseApprovalAuthorityFilter(input string) (map[string]string, error) {
	input = strings.TrimSpace(input)
	if input == "" {
		return map[string]string{}, nil
	}
	clauses := make([]string, 0, 2)
	start := 0
	inQuote := false
	for index := 0; index < len(input); index++ {
		if input[index] == '\'' {
			if inQuote && index+1 < len(input) && input[index+1] == '\'' {
				index++
				continue
			}
			inQuote = !inQuote
			continue
		}
		if !inQuote && index+1 < len(input) && input[index:index+2] == "&&" {
			clauses = append(clauses, strings.TrimSpace(input[start:index]))
			index++
			start = index + 1
		}
	}
	if inQuote {
		return nil, errors.New("unterminated quoted value")
	}
	clauses = append(clauses, strings.TrimSpace(input[start:]))
	result := make(map[string]string, len(clauses))
	for _, clause := range clauses {
		match := approvalAuthorityClausePattern.FindStringSubmatch(clause)
		if match == nil || strings.Contains(strings.ReplaceAll(match[2], "''", ""), "'") {
			return nil, fmt.Errorf("unsupported closed filter clause %q", clause)
		}
		if _, duplicate := result[match[1]]; duplicate {
			return nil, fmt.Errorf("duplicate filter field %q", match[1])
		}
		result[match[1]] = strings.ReplaceAll(match[2], "''", "'")
	}
	return result, nil
}

func finishApprovalAuthorityQuery(records []models.SObject, filter map[string]string, sortField, sortDir string, limit int) (*models.QueryResult, error) {
	matched := make([]models.SObject, 0, len(records))
	for _, record := range records {
		matches := true
		for field, expected := range filter {
			if approvalAuthorityString(record[field]) != expected {
				matches = false
				break
			}
		}
		if matches {
			matched = append(matched, record)
		}
	}
	if sortField != "" {
		sort.SliceStable(matched, func(i, j int) bool {
			left, leftPresent := matched[i][sortField]
			right, rightPresent := matched[j][sortField]
			// Catalog defines an omitted ProcessOrder as ordered after every
			// explicit value. Preserve that authority instead of treating the
			// missing map value as an empty string that sorts first.
			if sortField == constants.ColProcessDefinition_ProcessOrder && leftPresent != rightPresent {
				return leftPresent
			}
			comparison := approvalAuthorityCompare(left, right)
			if strings.EqualFold(sortDir, "DESC") {
				return comparison > 0
			}
			return comparison < 0
		})
	}
	total := len(matched)
	if limit > 0 && len(matched) > limit {
		matched = matched[:limit]
	}
	return &models.QueryResult{TotalSize: total, Done: len(matched) == total, Records: cloneApprovalAuthorityRecords(matched)}, nil
}

func cloneApprovalAuthorityRecords(records []models.SObject) []models.SObject {
	result := make([]models.SObject, 0, len(records))
	for _, record := range records {
		copy := make(models.SObject, len(record))
		for key, value := range record {
			copy[key] = value
		}
		result = append(result, copy)
	}
	return result
}

func approvalAuthorityCompare(left, right interface{}) int {
	leftInt, leftOK := approvalAuthorityNumber(left)
	rightInt, rightOK := approvalAuthorityNumber(right)
	if leftOK && rightOK {
		switch {
		case leftInt < rightInt:
			return -1
		case leftInt > rightInt:
			return 1
		default:
			return 0
		}
	}
	return strings.Compare(approvalAuthorityString(left), approvalAuthorityString(right))
}

func approvalAuthorityNumber(value interface{}) (int64, bool) {
	switch typed := value.(type) {
	case int:
		return int64(typed), true
	case int32:
		return int64(typed), true
	case int64:
		return typed, true
	case float64:
		return int64(typed), true
	case string:
		parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
		return parsed, err == nil
	default:
		return 0, false
	}
}

func approvalAuthorityInt(value interface{}) int {
	parsed, ok := approvalAuthorityNumber(value)
	if !ok {
		return 0
	}
	return int(parsed)
}

func approvalAuthorityString(value interface{}) string {
	switch typed := value.(type) {
	case nil:
		return ""
	case string:
		return typed
	case []byte:
		return string(typed)
	case bool:
		return strconv.FormatBool(typed)
	case int:
		return strconv.Itoa(typed)
	case int32:
		return strconv.FormatInt(int64(typed), 10)
	case int64:
		return strconv.FormatInt(typed, 10)
	case float64:
		return strconv.FormatInt(int64(typed), 10)
	default:
		return fmt.Sprint(typed)
	}
}

func firstApprovalAuthorityNonEmpty(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return value
		}
	}
	return ""
}

func approvalIRCriteriaOperatorForAuthority(operator approvalir.CriteriaOperator) string {
	switch operator {
	case approvalir.CriteriaOperatorGreaterOrEqual:
		return "greaterOrEqual"
	case approvalir.CriteriaOperatorLessOrEqual:
		return "lessOrEqual"
	case approvalir.CriteriaOperatorGreaterThan:
		return "greaterThan"
	case approvalir.CriteriaOperatorLessThan:
		return "lessThan"
	case approvalir.CriteriaOperatorNotEqual:
		return "notEqual"
	case approvalir.CriteriaOperatorNotContain:
		return "notContain"
	case approvalir.CriteriaOperatorStartsWith:
		return "startsWith"
	case approvalir.CriteriaOperatorContains:
		return "contains"
	case approvalir.CriteriaOperatorExcludes:
		return "excludes"
	case approvalir.CriteriaOperatorIncludes:
		return "includes"
	case approvalir.CriteriaOperatorEquals:
		return "equals"
	default:
		return string(operator)
	}
}

func approvalIRApproverTypeForAuthority(kind approvalir.ApproverKind) string {
	switch kind {
	case approvalir.ApproverKindRelatedUserField:
		return constants.ApprovalApproverTypeRelatedUserField
	case approvalir.ApproverKindUserHierarchyField:
		return constants.ApprovalApproverTypeUserHierarchyField
	case approvalir.ApproverKindPreviousApprover:
		return constants.ApprovalApproverTypePreviousApprover
	case approvalir.ApproverKindAdHoc:
		return constants.ApprovalApproverTypeAdhoc
	case approvalir.ApproverKindUser:
		return constants.ApprovalApproverTypeUser
	case approvalir.ApproverKindQueue:
		return constants.ApprovalApproverTypeQueue
	case approvalir.ApproverKindRole:
		return constants.ApprovalApproverTypeRole
	case approvalir.ApproverKindHierarchy:
		return constants.ApprovalApproverTypeHierarchy
	default:
		return string(kind)
	}
}

func approvalIRRejectBehaviorForAuthority(behavior approvalir.RejectBehavior) string {
	switch behavior {
	case approvalir.RejectBehaviorBackToPrevious:
		return constants.ApprovalRejectBehaviorBackToPrevious
	case approvalir.RejectBehaviorBackToSubmitter:
		return constants.ApprovalRejectBehaviorBackToSubmitter
	case approvalir.RejectBehaviorRejectRequest:
		return constants.ApprovalRejectBehaviorRejectRequest
	case approvalir.RejectBehaviorFinalRejection:
		return constants.ApprovalRejectBehaviorFinalRejection
	default:
		return string(behavior)
	}
}
