package automation

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"os"
	"reflect"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/nexuscrm/kernel/pkg/recordoverlay"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

const (
	approvalActionOutboxTable = "_System_ApprovalActionOutbox"

	ApprovalActionReplayStatusPending    = "pending"
	ApprovalActionReplayStatusProcessing = "processing"
	ApprovalActionReplayStatusProcessed  = "processed"
	ApprovalActionReplayStatusFailed     = "failed"
)

// ApprovalActionReplayFieldUpdate is the concrete FieldUpdate instruction
// captured at approval lifecycle commit time.
type ApprovalActionReplayFieldUpdate struct {
	ActionID           string                       `json:"actionId,omitempty"`
	ActionName         string                       `json:"actionName,omitempty"`
	FieldAPIName       string                       `json:"fieldApiName"`
	FieldValue         string                       `json:"fieldValue"`
	UpdateObjectName   string                       `json:"updateObjectName"`
	UpdateRecordID     string                       `json:"updateRecordId"`
	UpdateFieldAPIName string                       `json:"updateFieldApiName"`
	PreparedUpdate     *models.PreparedRecordUpdate `json:"-"`
}

// ApprovalActionReplayPayload is the durable payload for approval lifecycle
// action replay. Versioned so future lifecycle events can extend it without
// guessing which wire shape a stored row uses.
type ApprovalActionReplayPayload struct {
	Version             int                               `json:"version"`
	LifecycleEvent      string                            `json:"lifecycleEvent"`
	ProcessInstanceID   string                            `json:"processInstanceId"`
	ProcessDefinitionID string                            `json:"processDefinitionId"`
	ObjectAPIName       string                            `json:"objectApiName"`
	TargetObjectID      string                            `json:"targetObjectId"`
	User                *models.UserSession               `json:"user,omitempty"`
	Actions             []ApprovalActionReplayFieldUpdate `json:"actions"`
}

// ApprovalActionReplayRow is the worker's view of one claimed replay row.
type ApprovalActionReplayRow struct {
	ID             int64
	LifecycleEvent string
	Payload        []byte
	Attempts       int
}

// ApprovalActionReplayCounts is an operator-facing status projection.
type ApprovalActionReplayCounts struct {
	Pending    int64
	Processing int64
	Processed  int64
	Failed     int64
}

// ApprovalActionReplayStore is the persistence boundary for durable approval
// lifecycle action replay.
type ApprovalActionReplayStore interface {
	EnqueueRecallFieldUpdates(ctx context.Context, payload ApprovalActionReplayPayload) (int64, error)
	ClaimDue(ctx context.Context, holderID string, batchSize int, lease time.Duration, now time.Time) ([]ApprovalActionReplayRow, error)
	MarkProcessed(ctx context.Context, id int64, holderID string, now time.Time) error
	ScheduleRetry(ctx context.Context, id int64, holderID string, attempts int, nextAttemptAt time.Time, lastErr string, now time.Time) error
	MarkFailed(ctx context.Context, id int64, holderID string, attempts int, lastErr string, now time.Time) error
	Counts(ctx context.Context) (ApprovalActionReplayCounts, error)
}

type replayTxExtractor interface {
	ExtractTx(ctx context.Context) interface{}
}

type replayTxRunner interface {
	WithTransaction(ctx context.Context, fn func(context.Context) error) error
	WithPessimisticTransaction(ctx context.Context, fn func(context.Context) error) error
}

type replayExecutor interface {
	ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
}

// SQLApprovalActionReplayStore stores approval lifecycle action replay rows in
// _System_ApprovalActionOutbox. It uses the caller's transaction when one is
// present on the context, which is what makes Recall enqueue atomic with the
// recall state transition.
type SQLApprovalActionReplayStore struct {
	db          *sql.DB
	txExtractor replayTxExtractor
}

func NewSQLApprovalActionReplayStore(db *sql.DB, txExtractor replayTxExtractor) *SQLApprovalActionReplayStore {
	return &SQLApprovalActionReplayStore{db: db, txExtractor: txExtractor}
}

func (s *SQLApprovalActionReplayStore) executor(ctx context.Context) replayExecutor {
	if s != nil && s.txExtractor != nil {
		if tx, ok := s.txExtractor.ExtractTx(ctx).(*sql.Tx); ok && tx != nil {
			return tx
		}
	}
	if s == nil {
		return nil
	}
	return s.db
}

func (s *SQLApprovalActionReplayStore) EnqueueRecallFieldUpdates(ctx context.Context, payload ApprovalActionReplayPayload) (int64, error) {
	if s == nil || s.db == nil {
		return 0, errors.New("approval action replay store is not configured")
	}
	payload.Version = 1
	payload.LifecycleEvent = constants.ApprovalActionEventRecall
	payload.Actions = cloneReplayFieldUpdates(payload.Actions)
	payload.User = cloneReplayUser(payload.User)
	body, err := json.Marshal(payload)
	if err != nil {
		return 0, fmt.Errorf("marshal approval action replay payload: %w", err)
	}
	now := time.Now().UTC()
	res, err := s.executor(ctx).ExecContext(ctx,
		`INSERT INTO _System_ApprovalActionOutbox
			(LifecycleEvent, ProcessInstanceId, TargetObjectId, Payload, Status, Attempts, NextAttemptAt, CreatedDate, LastModifiedDate)
		 VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`,
		constants.ApprovalActionEventRecall,
		payload.ProcessInstanceID,
		payload.TargetObjectID,
		body,
		ApprovalActionReplayStatusPending,
		now,
		now,
		now,
	)
	if err != nil {
		return 0, fmt.Errorf("enqueue approval action replay row: %w", err)
	}
	id, err := res.LastInsertId()
	if err != nil {
		return 0, fmt.Errorf("read approval action replay id: %w", err)
	}
	return id, nil
}

func (s *SQLApprovalActionReplayStore) ClaimDue(ctx context.Context, holderID string, batchSize int, lease time.Duration, now time.Time) ([]ApprovalActionReplayRow, error) {
	if s == nil || s.db == nil {
		return nil, errors.New("approval action replay store is not configured")
	}
	if batchSize <= 0 {
		batchSize = 100
	}
	if lease <= 0 {
		lease = 30 * time.Second
	}
	now = now.UTC()
	rows, err := s.db.QueryContext(ctx,
		`SELECT Id, LifecycleEvent, Payload, Attempts
		   FROM _System_ApprovalActionOutbox
		  WHERE (Status = ? AND NextAttemptAt <= ?)
		     OR (Status = ? AND LeaseExpiresAt IS NOT NULL AND LeaseExpiresAt < ?)
		  ORDER BY CreatedDate ASC
		  LIMIT ?`,
		ApprovalActionReplayStatusPending,
		now,
		ApprovalActionReplayStatusProcessing,
		now,
		batchSize,
	)
	if err != nil {
		return nil, fmt.Errorf("query due approval action replay rows: %w", err)
	}
	defer rows.Close()

	type candidate struct {
		row ApprovalActionReplayRow
	}
	var candidates []candidate
	for rows.Next() {
		var c candidate
		if err := rows.Scan(&c.row.ID, &c.row.LifecycleEvent, &c.row.Payload, &c.row.Attempts); err != nil {
			return nil, fmt.Errorf("scan approval action replay row: %w", err)
		}
		candidates = append(candidates, c)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate approval action replay rows: %w", err)
	}

	leaseExpires := now.Add(lease)
	claimed := make([]ApprovalActionReplayRow, 0, len(candidates))
	for _, c := range candidates {
		res, err := s.db.ExecContext(ctx,
			`UPDATE _System_ApprovalActionOutbox
			    SET Status = ?, LeaseHolderId = ?, LeaseExpiresAt = ?, LastModifiedDate = ?
			  WHERE Id = ?
			    AND ((Status = ? AND NextAttemptAt <= ?)
			      OR (Status = ? AND LeaseExpiresAt IS NOT NULL AND LeaseExpiresAt < ?))`,
			ApprovalActionReplayStatusProcessing,
			holderID,
			leaseExpires,
			now,
			c.row.ID,
			ApprovalActionReplayStatusPending,
			now,
			ApprovalActionReplayStatusProcessing,
			now,
		)
		if err != nil {
			return claimed, fmt.Errorf("claim approval action replay row %d: %w", c.row.ID, err)
		}
		n, err := res.RowsAffected()
		if err != nil {
			return claimed, fmt.Errorf("claim approval action replay row %d rows affected: %w", c.row.ID, err)
		}
		if n == 1 {
			claimed = append(claimed, c.row)
		}
	}
	return claimed, nil
}

func (s *SQLApprovalActionReplayStore) MarkProcessed(ctx context.Context, id int64, holderID string, now time.Time) error {
	return s.updateLeasedRow(ctx, id, holderID, map[string]interface{}{
		"Status":           ApprovalActionReplayStatusProcessed,
		"ProcessedAt":      now.UTC(),
		"LeaseHolderId":    nil,
		"LeaseExpiresAt":   nil,
		"LastModifiedDate": now.UTC(),
	})
}

func (s *SQLApprovalActionReplayStore) ScheduleRetry(ctx context.Context, id int64, holderID string, attempts int, nextAttemptAt time.Time, lastErr string, now time.Time) error {
	return s.updateLeasedRow(ctx, id, holderID, map[string]interface{}{
		"Status":           ApprovalActionReplayStatusPending,
		"Attempts":         attempts,
		"NextAttemptAt":    nextAttemptAt.UTC(),
		"LastError":        truncateReplayError(lastErr),
		"LeaseHolderId":    nil,
		"LeaseExpiresAt":   nil,
		"LastModifiedDate": now.UTC(),
	})
}

func (s *SQLApprovalActionReplayStore) MarkFailed(ctx context.Context, id int64, holderID string, attempts int, lastErr string, now time.Time) error {
	return s.updateLeasedRow(ctx, id, holderID, map[string]interface{}{
		"Status":           ApprovalActionReplayStatusFailed,
		"Attempts":         attempts,
		"LastError":        truncateReplayError(lastErr),
		"FailedAt":         now.UTC(),
		"AlertedAt":        now.UTC(),
		"LeaseHolderId":    nil,
		"LeaseExpiresAt":   nil,
		"LastModifiedDate": now.UTC(),
	})
}

func (s *SQLApprovalActionReplayStore) updateLeasedRow(ctx context.Context, id int64, holderID string, updates map[string]interface{}) error {
	if s == nil || s.db == nil {
		return errors.New("approval action replay store is not configured")
	}
	exec := s.executor(ctx)
	if exec == nil {
		return errors.New("approval action replay executor is not configured")
	}
	ordered := make([]string, 0, len(updates))
	for key := range updates {
		ordered = append(ordered, key)
	}
	// Keep generated SQL deterministic for tests and logs.
	sortStrings(ordered)
	setParts := make([]string, 0, len(ordered))
	args := make([]interface{}, 0, len(ordered)+3)
	for _, key := range ordered {
		setParts = append(setParts, "`"+key+"` = ?")
		args = append(args, updates[key])
	}
	args = append(args, id, ApprovalActionReplayStatusProcessing, holderID)
	res, err := exec.ExecContext(ctx,
		fmt.Sprintf("UPDATE `%s` SET %s WHERE `Id` = ? AND `Status` = ? AND `LeaseHolderId` = ?",
			approvalActionOutboxTable, strings.Join(setParts, ", ")),
		args...,
	)
	if err != nil {
		return err
	}
	n, err := res.RowsAffected()
	if err != nil {
		return err
	}
	if n == 0 {
		return fmt.Errorf("approval action replay row %d is not leased by %s", id, holderID)
	}
	return nil
}

func (s *SQLApprovalActionReplayStore) Counts(ctx context.Context) (ApprovalActionReplayCounts, error) {
	if s == nil || s.db == nil {
		return ApprovalActionReplayCounts{}, errors.New("approval action replay store is not configured")
	}
	rows, err := s.db.QueryContext(ctx, "SELECT `Status`, COUNT(*) FROM `_System_ApprovalActionOutbox` GROUP BY `Status`")
	if err != nil {
		return ApprovalActionReplayCounts{}, err
	}
	defer rows.Close()
	var counts ApprovalActionReplayCounts
	for rows.Next() {
		var status string
		var count int64
		if err := rows.Scan(&status, &count); err != nil {
			continue
		}
		switch status {
		case ApprovalActionReplayStatusPending:
			counts.Pending = count
		case ApprovalActionReplayStatusProcessing:
			counts.Processing = count
		case ApprovalActionReplayStatusProcessed:
			counts.Processed = count
		case ApprovalActionReplayStatusFailed:
			counts.Failed = count
		}
	}
	return counts, rows.Err()
}

// ApprovalActionReplayConfig controls the approval replay worker.
type ApprovalActionReplayConfig struct {
	BatchSize      int
	PollInterval   time.Duration
	LeaseDuration  time.Duration
	MaxAttempts    int
	InitialBackoff time.Duration
	MaxBackoff     time.Duration
	HolderID       string
}

func ApprovalActionReplayConfigFromEnv(holderID string) ApprovalActionReplayConfig {
	cfg := ApprovalActionReplayConfig{
		BatchSize:      envInt("APPROVAL_ACTION_REPLAY_BATCH_SIZE", 100, 1, 1000),
		PollInterval:   time.Duration(envInt("APPROVAL_ACTION_REPLAY_POLL_MS", 500, 50, 60000)) * time.Millisecond,
		LeaseDuration:  time.Duration(envInt("APPROVAL_ACTION_REPLAY_LEASE_MS", 30000, 1000, 600000)) * time.Millisecond,
		MaxAttempts:    envInt("APPROVAL_ACTION_REPLAY_MAX_ATTEMPTS", 5, 1, 100),
		InitialBackoff: time.Duration(envInt("APPROVAL_ACTION_REPLAY_INITIAL_BACKOFF_MS", 1000, 0, 3600000)) * time.Millisecond,
		MaxBackoff:     time.Duration(envInt("APPROVAL_ACTION_REPLAY_MAX_BACKOFF_MS", 30000, 0, 86400000)) * time.Millisecond,
		HolderID:       holderID,
	}
	return cfg.withDefaults()
}

func (c ApprovalActionReplayConfig) withDefaults() ApprovalActionReplayConfig {
	if c.BatchSize <= 0 {
		c.BatchSize = 100
	}
	if c.PollInterval <= 0 {
		c.PollInterval = 500 * time.Millisecond
	}
	if c.LeaseDuration <= 0 {
		c.LeaseDuration = 30 * time.Second
	}
	if c.MaxAttempts <= 0 {
		c.MaxAttempts = 5
	}
	if c.InitialBackoff <= 0 {
		c.InitialBackoff = time.Second
	}
	if c.MaxBackoff <= 0 {
		c.MaxBackoff = 30 * time.Second
	}
	if c.MaxBackoff > 0 && c.InitialBackoff > c.MaxBackoff {
		c.InitialBackoff = c.MaxBackoff
	}
	if c.HolderID == "" {
		c.HolderID = "approval-action-replay"
	}
	return c
}

func (c ApprovalActionReplayConfig) backoffForAttempt(attempts int) time.Duration {
	c = c.withDefaults()
	if attempts <= 0 || c.InitialBackoff <= 0 {
		return 0
	}
	backoff := c.InitialBackoff
	for i := 1; i < attempts; i++ {
		backoff *= 2
		if c.MaxBackoff > 0 && backoff >= c.MaxBackoff {
			return c.MaxBackoff
		}
	}
	if c.MaxBackoff > 0 && backoff > c.MaxBackoff {
		return c.MaxBackoff
	}
	return backoff
}

func envInt(name string, fallback, min, max int) int {
	raw := strings.TrimSpace(os.Getenv(name))
	if raw == "" {
		return fallback
	}
	n, err := strconv.Atoi(raw)
	if err != nil {
		log.Printf("approval action replay: invalid %s=%q, using %d: %v", name, raw, fallback, err)
		return fallback
	}
	if n < min {
		return min
	}
	if n > max {
		return max
	}
	return n
}

// ApprovalActionReplayWorker drains _System_ApprovalActionOutbox.
type ApprovalActionReplayWorker struct {
	store    ApprovalActionReplayStore
	txRunner replayTxRunner
	service  *ApprovalService
	cfg      ApprovalActionReplayConfig
	clock    func() time.Time

	mu      sync.Mutex
	stopCh  chan struct{}
	wg      sync.WaitGroup
	running bool
}

func NewApprovalActionReplayWorker(store ApprovalActionReplayStore, txRunner replayTxRunner, service *ApprovalService, cfg ApprovalActionReplayConfig) *ApprovalActionReplayWorker {
	return &ApprovalActionReplayWorker{
		store:    store,
		txRunner: txRunner,
		service:  service,
		cfg:      cfg.withDefaults(),
		clock:    time.Now,
		stopCh:   make(chan struct{}),
	}
}

func (w *ApprovalActionReplayWorker) Start() {
	w.mu.Lock()
	if w.running {
		w.mu.Unlock()
		return
	}
	w.running = true
	w.stopCh = make(chan struct{})
	w.mu.Unlock()

	w.wg.Add(1)
	go w.runLoop()
}

func (w *ApprovalActionReplayWorker) Stop() {
	w.mu.Lock()
	if !w.running {
		w.mu.Unlock()
		return
	}
	w.running = false
	close(w.stopCh)
	w.mu.Unlock()

	w.wg.Wait()
}

func (w *ApprovalActionReplayWorker) runLoop() {
	defer w.wg.Done()
	ticker := time.NewTicker(w.cfg.PollInterval)
	defer ticker.Stop()
	log.Printf("approval action replay worker started (interval=%s holder=%s)", w.cfg.PollInterval, w.cfg.HolderID)
	for {
		select {
		case <-w.stopCh:
			log.Printf("approval action replay worker stopped")
			return
		default:
		}
		workerCtx, cancel := approvalActionReplayWorkerContext(w.stopCh)
		_, err := w.processOnceForWorker(workerCtx)
		cancel()
		if err != nil {
			log.Printf("approval action replay worker error: %v", err)
		}
		select {
		case <-w.stopCh:
			log.Printf("approval action replay worker stopped")
			return
		case <-ticker.C:
		}
	}
}

func (w *ApprovalActionReplayWorker) processOnceForWorker(ctx context.Context) (processed int, err error) {
	defer func() {
		if recovered := recover(); recovered != nil {
			err = fmt.Errorf("approval action replay worker panic: %v", recovered)
		}
	}()
	return w.ProcessOnce(ctx)
}

func approvalActionReplayWorkerContext(stopCh <-chan struct{}) (context.Context, context.CancelFunc) {
	ctx, cancel := context.WithCancel(context.Background())
	if stopCh != nil {
		go func() {
			select {
			case <-stopCh:
				cancel()
			case <-ctx.Done():
			}
		}()
	}
	return ctx, cancel
}

// ProcessOnce claims and processes one batch. Tests use this as a deterministic
// "restart" drain point.
func (w *ApprovalActionReplayWorker) ProcessOnce(ctx context.Context) (int, error) {
	if w == nil || w.store == nil || w.service == nil || w.txRunner == nil {
		return 0, errors.New("approval action replay worker is not configured")
	}
	rows, err := w.store.ClaimDue(ctx, w.cfg.HolderID, w.cfg.BatchSize, w.cfg.LeaseDuration, w.now())
	if err != nil {
		return 0, err
	}
	for i := range rows {
		w.processRow(ctx, rows[i])
	}
	return len(rows), nil
}

func (w *ApprovalActionReplayWorker) processRow(ctx context.Context, row ApprovalActionReplayRow) {
	defer func() {
		if recovered := recover(); recovered != nil {
			w.markFailure(ctx, row, fmt.Errorf("approval action replay row panic: %v", recovered), false)
		}
	}()

	var payload ApprovalActionReplayPayload
	if err := json.Unmarshal(row.Payload, &payload); err != nil {
		w.markFailure(ctx, row, fmt.Errorf("invalid payload: %w", err), true)
		return
	}
	err := w.txRunner.WithPessimisticTransaction(ctx, func(txCtx context.Context) error {
		if err := w.service.executeApprovalActionReplay(txCtx, payload); err != nil {
			return err
		}
		if err := w.store.MarkProcessed(txCtx, row.ID, w.cfg.HolderID, w.now()); err != nil {
			return fmt.Errorf("mark approval action replay processed: %w", err)
		}
		return nil
	})
	if err != nil {
		w.markFailure(ctx, row, err, false)
		return
	}
	recordApprovalActionReplayAttempt(payload.LifecycleEvent, "success")
}

func (w *ApprovalActionReplayWorker) markFailure(ctx context.Context, row ApprovalActionReplayRow, err error, terminal bool) {
	now := w.now()
	attempts := row.Attempts + 1
	msg := truncateReplayError(err.Error())
	lifecycle := row.LifecycleEvent
	if terminal || attempts >= w.cfg.MaxAttempts {
		if markErr := w.store.MarkFailed(ctx, row.ID, w.cfg.HolderID, attempts, msg, now); markErr != nil {
			log.Printf("approval action replay: failed to mark row %d failed: %v", row.ID, markErr)
		}
		recordApprovalActionReplayAttempt(lifecycle, "failed")
		recordApprovalActionReplayExhausted(lifecycle)
		log.Printf("OPERATOR_ALERT approval action replay exhausted row=%d lifecycle=%s attempts=%d error=%s", row.ID, lifecycle, attempts, msg)
		return
	}
	nextAttemptAt := now.Add(w.cfg.backoffForAttempt(attempts))
	if retryErr := w.store.ScheduleRetry(ctx, row.ID, w.cfg.HolderID, attempts, nextAttemptAt, msg, now); retryErr != nil {
		log.Printf("approval action replay: failed to schedule retry for row %d: %v", row.ID, retryErr)
	}
	recordApprovalActionReplayAttempt(lifecycle, "retry")
}

func (w *ApprovalActionReplayWorker) now() time.Time {
	if w.clock != nil {
		return w.clock().UTC()
	}
	return time.Now().UTC()
}

func (s *ApprovalService) executeApprovalActionReplay(ctx context.Context, payload ApprovalActionReplayPayload) error {
	if strings.TrimSpace(payload.LifecycleEvent) != constants.ApprovalActionEventRecall {
		return fmt.Errorf("unsupported approval action replay lifecycle %q", payload.LifecycleEvent)
	}
	if strings.TrimSpace(payload.ObjectAPIName) == "" || strings.TrimSpace(payload.TargetObjectID) == "" {
		return fmt.Errorf("approval action replay missing object or target id")
	}
	if len(payload.Actions) == 0 {
		return nil
	}
	user := cloneReplayUser(payload.User)
	if user == nil || strings.TrimSpace(user.ID) == "" {
		user = newSystemAutomationUser()
	}
	replayCtx := constants.WithSecurityContext(ctx, constants.SystemContext, "ApprovalService: replay approval lifecycle FieldUpdate")
	return s.executeReplayFieldUpdateMutations(replayCtx, payload.Actions, user)
}

func (s *ApprovalService) executeReplayFieldUpdateMutations(ctx context.Context, actions []ApprovalActionReplayFieldUpdate, user *models.UserSession) error {
	for _, action := range actions {
		actionName := action.ActionName
		fieldAPIName := strings.TrimSpace(action.FieldAPIName)
		fieldValue := action.FieldValue
		updateObjectName := strings.TrimSpace(action.UpdateObjectName)
		updateRecordID := strings.TrimSpace(action.UpdateRecordID)
		updateFieldAPIName := strings.TrimSpace(action.UpdateFieldAPIName)
		if updateObjectName == "" || updateRecordID == "" || updateFieldAPIName == "" {
			reason := fmt.Sprintf("missing frozen replay target (object=%q recordId=%q field=%q originalFieldApiName=%q)", updateObjectName, updateRecordID, updateFieldAPIName, fieldAPIName)
			return &ApprovalActionError{ActionName: actionName, ActionType: constants.ProcessActionTypeFieldUpdate, Reason: reason}
		}
		current, getErr := s.readApprovalSystemFields(ctx, updateObjectName, updateRecordID, []string{updateFieldAPIName})
		if getErr != nil {
			return &ApprovalActionError{ActionName: actionName, ActionType: constants.ProcessActionTypeFieldUpdate, TargetObject: updateObjectName, TargetID: updateRecordID, Field: updateFieldAPIName, Reason: getErr.Error()}
		}
		if replayFieldValueAlreadyApplied(current[updateFieldAPIName], fieldValue) {
			continue
		}
		updates := models.SObject{updateFieldAPIName: fieldValue}
		if updateErr := s.persistence.Update(ctx, updateObjectName, updateRecordID, updates, user); updateErr != nil {
			updateErr = flowRecordMutationValidation(updateErr)
			return &ApprovalActionError{
				ActionName:   actionName,
				ActionType:   constants.ProcessActionTypeFieldUpdate,
				TargetObject: updateObjectName,
				TargetID:     updateRecordID,
				Field:        updateFieldAPIName,
				Reason:       updateErr.Error(),
				Cause:        updateErr,
			}
		}
	}
	return nil
}

func (s *ApprovalService) replayFieldUpdatesFromActions(ctx context.Context, actions []models.SObject, objectAPIName, targetObjectID string) ([]ApprovalActionReplayFieldUpdate, error) {
	fieldUpdates := make([]ApprovalActionReplayFieldUpdate, 0)
	for _, action := range actions {
		actionType, _ := action["ActionType"].(string)
		if actionType != constants.ProcessActionTypeFieldUpdate {
			continue
		}
		id, _ := action[constants.FieldID].(string)
		name, _ := action["Name"].(string)
		fieldAPIName, _ := action["FieldApiName"].(string)
		fieldValue, _ := action["FieldValue"].(string)
		if strings.TrimSpace(fieldAPIName) == "" || strings.TrimSpace(objectAPIName) == "" {
			return nil, &ApprovalActionError{
				ActionName: name,
				ActionType: constants.ProcessActionTypeFieldUpdate,
				Reason:     fmt.Sprintf("missing fieldApiName or objectApiName (fieldApiName=%q objectApiName=%q)", fieldAPIName, objectAPIName),
			}
		}
		updateObjectName, updateRecordID, updateFieldAPIName, resolveErr := s.resolveFieldUpdateTarget(ctx, objectAPIName, targetObjectID, fieldAPIName)
		if resolveErr != nil {
			return nil, &ApprovalActionError{ActionName: name, ActionType: constants.ProcessActionTypeFieldUpdate, Field: fieldAPIName, Reason: resolveErr.Error()}
		}
		fieldUpdates = append(fieldUpdates, ApprovalActionReplayFieldUpdate{
			ActionID:           id,
			ActionName:         name,
			FieldAPIName:       fieldAPIName,
			FieldValue:         fieldValue,
			UpdateObjectName:   updateObjectName,
			UpdateRecordID:     updateRecordID,
			UpdateFieldAPIName: updateFieldAPIName,
		})
	}
	return fieldUpdates, nil
}

func (s *ApprovalService) planOrderedFieldUpdatesFromActions(ctx context.Context, actions []models.SObject, objectAPIName, targetObjectID string, user *models.UserSession) ([]ApprovalActionReplayFieldUpdate, error) {
	fieldUpdates := make([]ApprovalActionReplayFieldUpdate, 0)
	state := newFieldUpdatePlanningState()
	for _, action := range actions {
		actionType, _ := action["ActionType"].(string)
		if actionType != constants.ProcessActionTypeFieldUpdate {
			continue
		}
		id, _ := action[constants.FieldID].(string)
		name, _ := action["Name"].(string)
		fieldAPIName, _ := action["FieldApiName"].(string)
		fieldValue, _ := action["FieldValue"].(string)
		if strings.TrimSpace(fieldAPIName) == "" || strings.TrimSpace(objectAPIName) == "" {
			return nil, &ApprovalActionError{
				ActionName: name,
				ActionType: constants.ProcessActionTypeFieldUpdate,
				Reason:     fmt.Sprintf("missing fieldApiName or objectApiName (fieldApiName=%q objectApiName=%q)", fieldAPIName, objectAPIName),
			}
		}
		updateObjectName, updateRecordID, updateFieldAPIName, resolveErr := s.resolveFieldUpdateTargetFromState(ctx, state, objectAPIName, targetObjectID, fieldAPIName)
		if resolveErr != nil {
			return nil, &ApprovalActionError{ActionName: name, ActionType: constants.ProcessActionTypeFieldUpdate, Field: fieldAPIName, Reason: resolveErr.Error()}
		}
		previewUpdates, previewBuildErr := s.buildFieldUpdatePreviewUpdates(ctx, state, updateObjectName, updateRecordID, updateFieldAPIName, fieldValue)
		if previewBuildErr != nil {
			return nil, &ApprovalActionError{ActionName: name, ActionType: constants.ProcessActionTypeFieldUpdate, TargetObject: updateObjectName, TargetID: updateRecordID, Field: updateFieldAPIName, Reason: previewBuildErr.Error()}
		}
		previewCtx := planningPreviewCtxWithShadow(ctx, state.current)
		preview, previewErr := s.persistence.PreviewBeforeUpdate(previewCtx, updateObjectName, updateRecordID, previewUpdates, nil, user)
		if previewErr != nil {
			previewErr = flowRecordMutationValidation(previewErr)
			return nil, &ApprovalActionError{
				ActionName: name, ActionType: constants.ProcessActionTypeFieldUpdate,
				TargetObject: updateObjectName, TargetID: updateRecordID, Field: updateFieldAPIName,
				Reason: previewErr.Error(),
				Cause:  previewErr, // ferry retryable lock-ordering errors to the submit boundary loop (NX#2175 Defect B)
			}
		}
		if preview != nil {
			key := updateObjectName + "/" + updateRecordID
			state.current[key] = mergePreparedPlanningShadow(state.current[key], preview)
		} else {
			record, getErr := s.loadFieldUpdatePlanningRecord(ctx, state, updateObjectName, updateRecordID, false)
			if getErr != nil {
				return nil, &ApprovalActionError{ActionName: name, ActionType: constants.ProcessActionTypeFieldUpdate, TargetObject: updateObjectName, TargetID: updateRecordID, Field: updateFieldAPIName, Reason: getErr.Error()}
			}
			record[updateFieldAPIName] = fieldValue
		}
		fieldUpdates = append(fieldUpdates, ApprovalActionReplayFieldUpdate{
			ActionID:           id,
			ActionName:         name,
			FieldAPIName:       fieldAPIName,
			FieldValue:         fieldValue,
			UpdateObjectName:   updateObjectName,
			UpdateRecordID:     updateRecordID,
			UpdateFieldAPIName: updateFieldAPIName,
			PreparedUpdate:     preview.Clone(),
		})
	}
	return fieldUpdates, nil
}

func planningPreviewCtxWithShadow(ctx context.Context, shadow map[string]models.SObject) context.Context {
	// Approval planning must execute the real before-save pipeline to discover
	// its final record image, but it is still a speculative no-DML pass. Carry
	// the existing Flow preview marker so triggered Flows cannot escape through
	// RecordCreate, cross-record DML, ActionCall, email, audit, or Wait paths.
	previewCtx := WithPreviewMode(recordoverlay.WithRecords(ctx, shadow))
	if len(shadow) == 0 {
		return previewCtx
	}
	previewCtx = constants.WithPreviewSyntheticRecordRegistry(previewCtx)
	for key, record := range shadow {
		parts := strings.SplitN(key, "/", 2)
		if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" || record == nil {
			continue
		}
		constants.RegisterPreviewSyntheticRecord(previewCtx, parts[1], parts[0], cloneAutomationSObject(record))
	}
	return previewCtx
}

func mergePreparedPlanningShadow(existing models.SObject, prepared *models.PreparedRecordUpdate) models.SObject {
	if prepared == nil {
		return cloneAutomationSObject(existing)
	}
	if existing == nil {
		if prepared.Record != nil {
			return cloneAutomationSObject(prepared.Record)
		}
		if prepared.EffectiveUpdates != nil {
			return cloneAutomationSObject(prepared.EffectiveUpdates)
		}
		return cloneAutomationSObject(prepared.InternalUpdates)
	}
	merged := cloneAutomationSObject(existing)
	for k, v := range prepared.EffectiveUpdates {
		merged[k] = v
	}
	for k, v := range prepared.InternalUpdates {
		merged[k] = v
	}
	if prepared.Record != nil {
		for k, v := range prepared.Record {
			merged[k] = v
		}
	}
	return merged
}

func (s *ApprovalService) buildFieldUpdatePreviewUpdates(ctx context.Context, state *fieldUpdatePlanningState, objectAPIName, recordID, fieldAPIName, fieldValue string) (models.SObject, error) {
	record, err := s.loadFieldUpdatePlanningRecord(ctx, state, objectAPIName, recordID, true)
	if err != nil {
		return nil, err
	}
	stored := state.base[objectAPIName+"/"+recordID]
	if stored == nil {
		return nil, fmt.Errorf("%s %s not found for ordered FieldUpdate planning", objectAPIName, recordID)
	}
	updates := models.SObject{}
	for key, value := range record {
		if key == constants.FieldID {
			continue
		}
		if storedValue, ok := stored[key]; !ok || !reflect.DeepEqual(storedValue, value) {
			updates[key] = value
		}
	}
	updates[fieldAPIName] = fieldValue
	return updates, nil
}

func cloneReplayFieldUpdates(in []ApprovalActionReplayFieldUpdate) []ApprovalActionReplayFieldUpdate {
	if len(in) == 0 {
		return nil
	}
	out := make([]ApprovalActionReplayFieldUpdate, len(in))
	copy(out, in)
	return out
}

func cloneReplayUser(in *models.UserSession) *models.UserSession {
	if in == nil {
		return nil
	}
	out := *in
	if in.Email != nil {
		email := *in.Email
		out.Email = &email
	}
	if in.RoleID != nil {
		roleID := *in.RoleID
		out.RoleID = &roleID
	}
	if in.RoleName != nil {
		roleName := *in.RoleName
		out.RoleName = &roleName
	}
	return &out
}

func replayFieldValueAlreadyApplied(current interface{}, want string) bool {
	if current == nil {
		return want == ""
	}
	switch v := current.(type) {
	case string:
		return v == want
	case []byte:
		return string(v) == want
	default:
		return fmt.Sprint(v) == want
	}
}

const replayErrorMaxLen = 1024

func truncateReplayError(msg string) string {
	if len(msg) <= replayErrorMaxLen {
		return msg
	}
	if replayErrorMaxLen <= 3 {
		return msg[:replayErrorMaxLen]
	}
	return msg[:replayErrorMaxLen-3] + "..."
}

func sortStrings(values []string) {
	for i := 1; i < len(values); i++ {
		v := values[i]
		j := i - 1
		for ; j >= 0 && values[j] > v; j-- {
			values[j+1] = values[j]
		}
		values[j+1] = v
	}
}
