package automation

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

	"github.com/nexuscrm/events"
	apperrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/flowactions"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// approvalSubmitter is the narrow seam the Flow executor needs from the
// approval engine. Mirrors the inverse-direction approvalFlowExecutor
// interface (approval_service.go): each side depends on a minimal interface,
// not the concrete service, because ApprovalService is constructed after
// FlowExecutor (service_manager_init.go wires it via SetApprovalSubmitter).
type approvalSubmitter interface {
	SubmitForApprovalAction(ctx context.Context, req SubmitRequest, user *models.UserSession) (map[string]interface{}, error)
	// HasPendingApprovalInstance backs the actionCall's idempotency pre-probe:
	// Submit's own already-pending check is unreachable for a pending record
	// under a non-superuser UserContext, because a pending record is by
	// definition approval-locked and the lock gate runs first. The probe takes
	// the full SubmitRequest (not a bare record id) because it re-runs Submit's
	// own target-resolution and submitter-authorization gates before touching
	// approval state — see HasPendingApprovalInstance.
	HasPendingApprovalInstance(ctx context.Context, req SubmitRequest, user *models.UserSession) (bool, error)
}

// Flow-facing input parameter names (case-insensitive, like all actionCall
// lookups; see normalizeFlowActionType). The SF wire alias "objectId" for the
// record id input is deliberately NOT accepted — it would be confusable with
// objectApiName.
const (
	flowSubmitForApprovalRecordIDParam          = "recordId"
	flowSubmitForApprovalObjectAPINameParam     = "objectApiName"
	flowSubmitForApprovalCommentsParam          = "comments"
	flowSubmitForApprovalSkipEntryCriteriaParam = "skipEntryCriteria"
)

// Flow-facing output names (keys of the outputs map consumed by
// assignActionCallOutputs). Unlike inputs, output names are matched
// EXACT-case by assignActionCallOutputs — flows must spell these camelCase
// names exactly in outputParameters.
const (
	flowSubmitForApprovalAlreadyPendingOutput    = "alreadyPending"
	flowSubmitForApprovalProcessInstanceIDOutput = "processInstanceId"
	flowSubmitForApprovalInstanceStatusOutput    = "instanceStatus"
)

// ApprovalFlowActionCallRegistrations returns actionCall handlers owned by the
// approval capability wiring.
func ApprovalFlowActionCallRegistrations() []FlowActionCallRegistration {
	return []FlowActionCallRegistration{
		FlowActionCallRegistration{
			ActionType:   flowactions.ActionTypeSubmitForApproval,
			HandlerRef:   "platform:automation/submit-for-approval",
			AtomicPolicy: FlowActionCallAtomicPolicyReject,
			AtomicRejectText: func(action *models.FlowActionCall) string {
				return fmt.Sprintf("flows executing atomically cannot contain %s action calls; approval submission performs approver-notification I/O that cannot be rolled back with the transaction — use a non-atomic after-save flow", action.ActionType)
			},
			PreviewPolicy: FlowActionCallPreviewPolicyReject,
			Handler: func(ctx context.Context, e *FlowExecutor, interview *models.FlowInterview, _ *models.FlowMetadata, action *models.FlowActionCall, user *models.UserSession) (string, error) {
				return e.executeSubmitForApprovalAction(ctx, interview, action, user)
			},
		},
	}
}

// executeSubmitForApprovalAction submits the target record for approval as the
// flow's running user. Idempotency contract: a record that already has a
// Pending ProcessInstance is a no-op success (alreadyPending=true), decided by
// an explicit pre-probe BEFORE calling Submit. The pre-probe is load-bearing,
// not an optimization: Submit's own already-pending check sits BEHIND its
// entry-criteria and approval-lock gates, and a pending record is by
// definition approval-locked — so on re-fire the lock gate would fault first
// and ErrRecordAlreadyPending would never surface for a non-superuser running
// user. The probe itself is gated: it re-runs Submit's target-resolution and
// submitter-authorization path under the demoted user ctx and skips only the
// lock gate (see HasPendingApprovalInstance), so alreadyPending can never
// disclose approval state the running user could not learn interactively.
// Every other Submit error is a flow element fault.
//
// Notes for flow authors:
//   - An all-whitespace objectApiName input trims to empty and selects
//     Submit's self-resolution of the record's type — not a mismatch fault.
//   - After-save flow faults are logged and swallowed by the trigger
//     listener; without a FaultConnector a faulted submit is lost for the
//     event (no retry). Flows shipped on this action SHOULD declare a
//     FaultConnector that records the failure somewhere operator-visible.
//   - The running user's RLS/FLS applies to the eligibility read: a
//     cross-object submit (recordId bound to a lookup) faults when the
//     triggering user cannot read the target record.
//   - An instantly auto-approved submit leaves no pending row, but re-fires
//     within the same after-save causal chain are still no-ops: Submit
//     records every (chain, record, process) fact in
//     _System_ApprovalSubmitLineage inside its own transaction and the probe
//     reads it back — durable across any echo topology, gone on rollback.
//     A genuinely new chain (e.g. a user editing the record later) may
//     submit again — identical exposure to an interactive resubmit.
//   - DEDUP BOUNDARY (deliberate, SF-v63-parity): lineage dedup governs
//     PLATFORM-initiated re-execution only — outbox echo re-dispatch of one
//     causal chain, where the author never asked to run twice. Repetition
//     the author WRITES — two sequential SubmitForApproval elements, loops,
//     subflow re-entry within one non-trigger execution — follows interactive
//     approval semantics instead: a pending instance blocks resubmission (probe /
//     ErrRecordAlreadyPending), a COMPLETED one does not, so explicit double
//     submits of an auto-approved record create two instances exactly like
//     an interactive user submitting twice. Pinned by
//     TestFlowSubmitForApproval_ExplicitDoubleSubmitMatchesInteractiveSemantics.
func (e *FlowExecutor) executeSubmitForApprovalAction(ctx context.Context, interview *models.FlowInterview, action *models.FlowActionCall, user *models.UserSession) (string, error) {
	fault := func(err error) (string, error) {
		if action.FaultConnector != nil {
			return e.routeFault(ctx, interview, action.FaultConnector.TargetReference, err)
		}
		return "", err
	}
	if e.approvalSubmitter == nil {
		return fault(apperrors.NewInternalError("SubmitForApproval action requires an approval submitter; the composition root did not call SetApprovalSubmitter", nil))
	}
	req, err := e.buildSubmitForApprovalRequest(ctx, interview, action, user)
	if err != nil {
		return fault(err)
	}

	// Submit and the pending pre-probe both evaluate their user-facing gates
	// under the CALLER ctx before their own SystemContext escalation (a
	// trusted caller ctx silently bypasses those gates). Record-triggered
	// dispatch has already elevated ctx to SystemContext, so demote to
	// UserContext for both calls: flow-driven submission must gate exactly
	// like the same user submitting through the interactive path.
	submitCtx := constants.WithSecurityContext(ctx, constants.UserContext, "SubmitForApproval actionCall: evaluate submit gates as the running user")

	// Idempotency pre-probe. Fail-closed: if pending-ness cannot be proven the
	// element faults rather than risking a double submit ("never
	// double-submit", not "eventually submits" — see fault note above).
	alreadyPending, err := e.approvalSubmitter.HasPendingApprovalInstance(submitCtx, req, user)
	if err != nil {
		return fault(err)
	}

	var result map[string]interface{}
	if !alreadyPending {
		// Same-chain dedup is owned end-to-end by ApprovalService.Submit: it
		// pre-mints the chain id its FieldUpdate echoes will inherit and
		// records the (chain, record, process) fact inside the submit
		// transaction (unique-keyed, rollback-safe). The handler has no mark
		// bookkeeping — the probe above reads the committed facts.
		result, err = e.approvalSubmitter.SubmitForApprovalAction(submitCtx, req, user)
		// ErrRecordAlreadyPending here covers two idempotent races: a pending
		// instance landing between probe and Submit on tx-free dispatch
		// paths, and the lineage unique key collapsing two same-chain
		// dispatches racing the same (record, process) submit.
		if err != nil && errors.Is(err, ErrRecordAlreadyPending) {
			alreadyPending = true
		} else if err != nil {
			return fault(err)
		}
	}

	// Always assign all three outputs. Emitting empty strings (rather than
	// omitting keys) on the alreadyPending path matters: assignActionCallOutputs
	// only writes declared outputs whose keys are present, so an omitted key
	// would leave a flow variable holding a STALE value from a prior loop
	// iteration.
	outputs := map[string]interface{}{
		flowSubmitForApprovalAlreadyPendingOutput:    alreadyPending,
		flowSubmitForApprovalProcessInstanceIDOutput: "",
		flowSubmitForApprovalInstanceStatusOutput:    "",
	}
	if !alreadyPending {
		instanceID, _ := result["ProcessInstanceId"].(string)
		instanceStatus, _ := result["InstanceStatus"].(string)
		outputs[flowSubmitForApprovalProcessInstanceIDOutput] = instanceID
		outputs[flowSubmitForApprovalInstanceStatusOutput] = instanceStatus
	}
	assignActionCallOutputs(interview, action, outputs)

	if action.Connector != nil {
		return action.Connector.TargetReference, nil
	}
	return "", nil
}

func (e *FlowExecutor) buildSubmitForApprovalRequest(ctx context.Context, interview *models.FlowInterview, action *models.FlowActionCall, user *models.UserSession) (SubmitRequest, error) {
	req := SubmitRequest{}
	for _, param := range action.InputParameters {
		name := strings.TrimSpace(param.Name)
		if name == "" {
			continue
		}
		resolved, err := e.deepResolveFlowResource(ctx, interview, user, param.Value)
		if err != nil {
			return SubmitRequest{}, err
		}
		value := unwrapCanonicalSendEmailTypedValue(resolved)
		switch strings.ToLower(name) {
		case strings.ToLower(flowSubmitForApprovalRecordIDParam):
			s, err := coerceSubmitForApprovalString(name, value)
			if err != nil {
				return SubmitRequest{}, err
			}
			req.RecordID = s
		case strings.ToLower(flowSubmitForApprovalObjectAPINameParam):
			s, err := coerceSubmitForApprovalString(name, value)
			if err != nil {
				return SubmitRequest{}, err
			}
			req.ObjectAPIName = s
		case strings.ToLower(flowSubmitForApprovalCommentsParam):
			s, err := coerceSubmitForApprovalString(name, value)
			if err != nil {
				return SubmitRequest{}, err
			}
			req.Comments = s
		case strings.ToLower(flowSubmitForApprovalSkipEntryCriteriaParam):
			b, err := coerceSubmitForApprovalBool(name, value)
			if err != nil {
				return SubmitRequest{}, err
			}
			req.SkipEntryCriteria = b
		default:
			return SubmitRequest{}, apperrors.NewValidationError(name, "SubmitForApproval supports only recordId, objectApiName, comments, and skipEntryCriteria inputs")
		}
	}
	if strings.TrimSpace(req.RecordID) == "" {
		return SubmitRequest{}, apperrors.NewValidationError(flowSubmitForApprovalRecordIDParam, "recordId is required for SubmitForApproval")
	}
	// When the author omits objectApiName AND the submit targets the flow's
	// current $Record, default the object to that record's own type so Submit
	// resolves through the object's own read path — reliable for a just-created
	// record — instead of the best-effort reverse scan that can miss it on
	// multi-node TiDB (NX#3478). The record-trigger listener stamps $Record's
	// type, so this is authoritative. We deliberately do NOT default a
	// cross-object submit (recordId bound to a lookup pointing at a different
	// record): the documented contract is that a blank objectApiName selects
	// Submit's self-resolution, so synthesizing a (wrong) hint there would fault
	// a supported path.
	if strings.TrimSpace(req.ObjectAPIName) == "" {
		req.ObjectAPIName = currentRecordSubmitObjectAPIName(interview, req.RecordID)
	}
	return req, nil
}

// currentRecordSubmitObjectAPIName returns the object type to default onto an
// omitted objectApiName, but ONLY when the submit targets the flow's current
// $Record — i.e. recordId resolves to $Record.Id — using that record's own
// stamped type. For a cross-object / lookup-bound submit (recordId != $Record.Id)
// it returns "" so Submit self-resolves the target and the documented contract
// is preserved.
func currentRecordSubmitObjectAPIName(interview *models.FlowInterview, recordID string) string {
	if interview == nil {
		return ""
	}
	record := coerceFlowRecord(interview.ContextData["$Record"])
	if record == nil {
		return ""
	}
	recordID = strings.TrimSpace(recordID)
	currentID, _ := record[constants.FieldID].(string)
	if recordID == "" || !strings.EqualFold(strings.TrimSpace(currentID), recordID) {
		return ""
	}
	return strings.TrimSpace(models.RecordObjectType(record))
}

// HasPendingApprovalInstance reports whether the submit target is already
// handled — a Pending ProcessInstance exists, OR the current AfterSave causal
// chain already submitted it (which covers instantly auto-approved submits
// that leave no Pending row for their own echo dispatch to see). It backs the
// SubmitForApproval actionCall's idempotency contract and runs behind the
// SAME disclosure gates Submit owns, skipping ONLY the approval-lock gate —
// the lock is what makes a pending record's ErrRecordAlreadyPending
// unreachable interactively, and that locked-pending state is precisely what
// this probe exists to detect:
//
//  1. For an objectless opaque-ID collision, existing lineage or pending
//     ProcessDefinition identity may supply the object hint. This preserves
//     idempotency when that already-used process is no longer active; no
//     pending fact is disclosed before the visibility gate.
//  2. resolveApprovalSubmitTarget under the caller ctx: canonical target
//     resolution, objectApiName-hint mismatch faults — Submit's own first
//     gate (shared code, not a parallel read path).
//  3. loadSubmitTargetRecord under the caller ctx: the user-visible target
//     read — an unreadable target faults exactly like interactive Submit
//     instead of leaking pending-ness.
//  4. The chain-dedup check against _System_ApprovalSubmitLineage, scoped to
//     process identity: a (record, process) this causal chain already
//     submitted (committed fact, written inside that submit's transaction) is
//     a no-op regardless of instance status, but a DIFFERENT process made
//     newly eligible by the first submit's FieldUpdates falls through to a
//     fresh submit. Being a DB read, the fact is visible to every dispatch
//     of the chain — sibling echo rows included — which no per-row
//     context/envelope snapshot could guarantee.
//  5. The ProcessInstance probe under SystemContext (ProcessInstance is an
//     approval system table; pending-ness must not depend on the running
//     user's RLS). On a hit, submitter authorization is enforced against the
//     pending instance's OWN ProcessDefinition (canUserSubmitForProcess,
//     exactly like Submit) before pending-ness is disclosed.
//
// Deliberately NOT consulted: active-process selection / entry criteria.
// Whether the record is *currently eligible for a fresh submit* is Submit's
// question; the probe's question is whether a submit already happened. Submit
// FieldUpdates routinely move records out of their own entry criteria (e.g. a
// stamped status field), and re-running eligibility here would turn the
// idempotent re-fire path into a fault (Codex review, PR #3450 round 2).
//
// Mirrors Submit's internal pending check with one intentional difference: a
// probe query error is RETURNED (fail-closed) instead of falling through, so
// the caller faults rather than double-submits.
func (s *ApprovalService) HasPendingApprovalInstance(ctx context.Context, req SubmitRequest, user *models.UserSession) (bool, error) {
	return s.hasPendingApprovalInstance(ctx, req, user, true)
}

// hasPendingApprovalInstance is the shared submit-admission probe. Flow callers
// include causal-chain lineage so an instantly completed submit is idempotent;
// ordinary and Runtime submits consult only durable Pending instances.
func (s *ApprovalService) hasPendingApprovalInstance(ctx context.Context, req SubmitRequest, user *models.UserSession, includeLineage bool) (bool, error) {
	targetRecordID := strings.TrimSpace(req.RecordID)
	chainID := ""
	if includeLineage {
		chainID = events.AfterSaveChainIDFromContext(ctx)
	}
	sysCtx := constants.WithSecurityContext(ctx, constants.SystemContext, "SubmitForApproval actionCall: idempotency pending-probe")
	resolutionReq := req
	var submitted []string
	submittedLoaded := false
	var result *models.QueryResult
	pendingLoaded := false

	if targetRecordID != "" && strings.TrimSpace(req.ObjectAPIName) == "" {
		_, candidateErr := s.approvalSubmitTargetCandidates(ctx, targetRecordID)
		if candidateErr != nil {
			return false, candidateErr
		}
		if chainID != "" {
			if s.submitLineage == nil {
				return false, apperrors.NewInternalError("approval submit lineage store is not wired", nil)
			}
			var lineageErr error
			submitted, lineageErr = s.submitLineage.SubmittedProcessIDs(ctx, chainID, targetRecordID)
			if lineageErr != nil {
				return false, apperrors.NewInternalError("failed to read approval submit lineage", lineageErr)
			}
			submittedLoaded = true
			if len(submitted) > 0 {
				objectName, err := s.approvalSubmitProcessObject(sysCtx, submitted, user)
				if err != nil {
					return false, err
				}
				resolutionReq.ObjectAPIName = objectName
			}
		}
		if strings.TrimSpace(resolutionReq.ObjectAPIName) == "" {
			filter := fmt.Sprintf("TargetObjectId == '%s' && Status == '%s'", escapeApprovalFilterValue(targetRecordID), constants.ApprovalStatusPending)
			var err error
			result, err = s.query.QueryWithFilter(sysCtx, constants.TableProcessInstance, filter, user, "", "", 200)
			if err != nil {
				return false, apperrors.NewInternalError("failed to probe pending approval instances for SubmitForApproval", err)
			}
			pendingLoaded = true
			if result != nil && result.TotalSize > 0 && len(result.Records) > 0 {
				pendingDefID, _ := result.Records[0][constants.ColProcessInstance_ProcessDefinitionID].(string)
				objectName, err := s.approvalSubmitProcessObject(sysCtx, []string{pendingDefID}, user)
				if err != nil {
					return false, err
				}
				resolutionReq.ObjectAPIName = objectName
			}
		}
	}

	targetRecordID, objectName, err := s.resolveApprovalSubmitTarget(ctx, resolutionReq, user)
	if err != nil {
		return false, err
	}
	// Existence/visibility gate only — the record is discarded and no
	// criteria are evaluated on it, so skip the active-formula projection
	// overlay (#3913): a projection fault (view rebuild in flight, drift)
	// must not break the idempotent re-fire path, and criteria evaluation
	// inside Submit/planSubmitProcessID does its own projected load.
	if err := s.ensureSubmitTargetRecordVisible(ctx, objectName, targetRecordID, user); err != nil {
		return false, err
	}
	if chainID != "" {
		if s.submitLineage == nil {
			// Fail closed: without the lineage read there is no proof this
			// chain has not already submitted — fault, never double-submit.
			return false, apperrors.NewInternalError("approval submit lineage store is not wired", nil)
		}
		if !submittedLoaded {
			var lineageErr error
			submitted, lineageErr = s.submitLineage.SubmittedProcessIDs(ctx, chainID, targetRecordID)
			if lineageErr != nil {
				return false, apperrors.NewInternalError("failed to read approval submit lineage", lineageErr)
			}
		}
		if len(submitted) > 0 {
			// Chain-dedup is scoped to PROCESS identity: suppress only when a
			// fresh submit would recreate an approval this chain already ran.
			// planSubmitProcessID re-uses Submit's own eligibility resolution
			// best-effort — a resolution failure (e.g. submit-time
			// FieldUpdates drifted the record out of every entry criteria)
			// means no NEW process is provably eligible, so suppression is
			// the correct conservative answer, not a fault (this is also what
			// keeps the round-2 drift semantics: drifted re-fires no-op
			// instead of faulting).
			planned := s.planSubmitProcessID(ctx, req, user)
			if planned == "" || slices.Contains(submitted, planned) {
				return true, nil
			}
			// A process this chain has NOT submitted is now eligible (e.g.
			// the first submit's FieldUpdates moved the record into a
			// different process's entry criteria). Fall through: the pending
			// probe and Submit own the rest.
		}
	}
	if !pendingLoaded {
		filter := fmt.Sprintf("TargetObjectId == '%s' && Status == '%s'", escapeApprovalFilterValue(targetRecordID), constants.ApprovalStatusPending)
		result, err = s.query.QueryWithFilter(sysCtx, constants.TableProcessInstance, filter, user, "", "", 200)
		if err != nil {
			return false, apperrors.NewInternalError("failed to probe pending approval instances for SubmitForApproval", err)
		}
	}
	processDef, pendingDefID, err := s.pendingInstanceProcessDefinitionForObject(sysCtx, result, objectName, user)
	if err != nil {
		return false, wrapSubmitTargetError(err, targetRecordID)
	}
	if processDef == nil {
		return false, nil
	}
	canSubmit, err := s.canUserSubmitForProcess(sysCtx, processDef, objectName, targetRecordID, user)
	if err != nil {
		s.logger.Error("Failed to evaluate allowed submitters for "+pendingDefID, err)
		return false, wrapSubmitTargetError(apperrors.NewInternalError("failed to evaluate approval submitter permissions", err), targetRecordID)
	}
	if !canSubmit {
		return false, wrapSubmitTargetError(ErrSubmitPermissionDenied, targetRecordID)
	}
	return true, nil
}

// planSubmitProcessID best-effort resolves the ProcessDefinition a Submit for
// req would select right now, using the SAME eligibility path Submit runs
// (ResolveSubmitEligibility). Every error collapses to "" — the probe uses
// the result only to compare against recorded chain-lineage facts, never as
// an authorization or correctness gate.
func (s *ApprovalService) planSubmitProcessID(ctx context.Context, req SubmitRequest, user *models.UserSession) string {
	eligibility, err := s.ResolveSubmitEligibility(ctx, req, user)
	if err != nil || eligibility == nil {
		return ""
	}
	return eligibility.ProcessDefinitionID
}

func (s *ApprovalService) approvalSubmitProcessObject(ctx context.Context, processDefinitionIDs []string, user *models.UserSession) (string, error) {
	objectNames := map[string]struct{}{}
	for _, processDefID := range processDefinitionIDs {
		filter := fmt.Sprintf("Id == '%s'", escapeApprovalFilterValue(processDefID))
		result, err := s.query.QueryWithFilter(ctx, constants.TableProcessDefinition, filter, user, "", "", 1)
		if err != nil {
			return "", apperrors.NewInternalError("failed to load approval process definition for submit target resolution", err)
		}
		if result == nil || len(result.Records) == 0 {
			return "", apperrors.NewInternalError("approval process definition for submit target resolution not found", nil)
		}
		objectName, _ := result.Records[0]["TableEnumOrId"].(string)
		objectName = strings.TrimSpace(objectName)
		if objectName == "" {
			return "", apperrors.NewInternalError("approval process definition resolved an empty target object", nil)
		}
		objectNames[objectName] = struct{}{}
	}
	if len(objectNames) != 1 {
		return "", apperrors.NewInternalError("approval submit evidence resolves to multiple target objects", nil)
	}
	for objectName := range objectNames {
		return objectName, nil
	}
	return "", apperrors.NewInternalError("approval submit evidence resolved no target object", nil)
}

// pendingInstanceProcessDefinition loads the ProcessDefinition backing an
// existing pending instance so the probe can authorize disclosure against the
// process that actually holds the record — not against a fresh entry-criteria
// selection, which can legitimately diverge after submit-time FieldUpdates.
func (s *ApprovalService) pendingInstanceProcessDefinition(sysCtx context.Context, processDefID string, user *models.UserSession) (map[string]interface{}, error) {
	if strings.TrimSpace(processDefID) == "" {
		return nil, apperrors.NewInternalError("pending approval instance carries no ProcessDefinitionId", nil)
	}
	filter := fmt.Sprintf("Id == '%s'", escapeApprovalFilterValue(processDefID))
	result, err := s.query.QueryWithFilter(sysCtx, constants.TableProcessDefinition, filter, user, "", "", 1)
	if err != nil {
		return nil, apperrors.NewInternalError("failed to load process definition for pending approval instance", err)
	}
	if result == nil || len(result.Records) == 0 {
		return nil, apperrors.NewInternalError("process definition for pending approval instance not found", nil)
	}
	return result.Records[0], nil
}

// pendingInstanceProcessDefinitionForObject filters opaque-ID collisions by
// the backing ProcessDefinition's object before pending state is authorized or
// disclosed. The bounded ProcessInstance query must be complete so a foreign
// object's first row cannot hide a later pending instance for objectName.
func (s *ApprovalService) pendingInstanceProcessDefinitionForObject(sysCtx context.Context, result *models.QueryResult, objectName string, user *models.UserSession) (map[string]interface{}, string, error) {
	if result == nil || result.TotalSize == 0 {
		return nil, "", nil
	}
	if len(result.Records) == 0 || result.TotalSize > len(result.Records) {
		return nil, "", apperrors.NewInternalError("pending approval instance probe returned an incomplete result", nil)
	}
	for _, instance := range result.Records {
		pendingDefID, _ := instance[constants.ColProcessInstance_ProcessDefinitionID].(string)
		processDef, err := s.pendingInstanceProcessDefinition(sysCtx, pendingDefID, user)
		if err != nil {
			return nil, "", err
		}
		processObject, _ := processDef["TableEnumOrId"].(string)
		if strings.TrimSpace(processObject) == "" {
			return nil, "", apperrors.NewInternalError("process definition for pending approval instance has no target object", nil)
		}
		if strings.EqualFold(strings.TrimSpace(processObject), strings.TrimSpace(objectName)) {
			return processDef, strings.TrimSpace(pendingDefID), nil
		}
	}
	return nil, "", nil
}

func coerceSubmitForApprovalString(name string, value interface{}) (string, error) {
	switch v := value.(type) {
	case nil:
		return "", nil
	case string:
		return strings.TrimSpace(v), nil
	default:
		return "", apperrors.NewValidationError(name, fmt.Sprintf("%s must be a string", name))
	}
}

// coerceSubmitForApprovalBool deliberately avoids Flow-condition truthiness
// (isTruthy would turn any non-empty string, including "false", into true):
// bools pass through, strings go through strict strconv.ParseBool, anything
// else is a validation error.
func coerceSubmitForApprovalBool(name string, value interface{}) (bool, error) {
	switch v := value.(type) {
	case nil:
		return false, nil
	case bool:
		return v, nil
	case string:
		parsed, err := strconv.ParseBool(strings.TrimSpace(v))
		if err != nil {
			return false, apperrors.NewValidationError(name, fmt.Sprintf("%s must be a boolean (true/false)", name))
		}
		return parsed, nil
	default:
		return false, apperrors.NewValidationError(name, fmt.Sprintf("%s must be a boolean (true/false)", name))
	}
}
