package rest

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"

	"github.com/nexuscrm/backend/internal/bootstrap"
	"github.com/nexuscrm/backend/internal/features/automation"
	"github.com/nexuscrm/backend/pkg/apierr"
	"github.com/nexuscrm/backend/pkg/formula"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// dispatcherPermissions is the narrow view of PermissionService the
// dispatcher needs. Declared as an interface so unit tests can stub
// without standing up a real PermissionService + DB.
type dispatcherPermissions interface {
	HasPermission(ctx context.Context, user *models.UserSession, permissionAPIName string) bool
	CheckObjectPermissionWithUser(ctx context.Context, objectAPIName string, operation string, user *models.UserSession) bool
	CheckFieldVisibilityWithUser(ctx context.Context, objectAPIName string, fieldAPIName string, user *models.UserSession) bool
}

type actionSchemaMetadataProvider interface {
	GetSchema(ctx context.Context, apiName string) *models.ObjectMetadata
}

// actionInputDefaultTarget is the metadata scope used by
// _System_ActionFieldDefault rows that fill action inputs before
// _System_ActionSchema validation. Object-scoped rows keep flowing to the
// executor's recordCreate default resolver.
const actionInputDefaultTarget = "$Input"

// StandardActionDispatcher owns POST /services/data/v63.0/actions/standard/:name.
// It looks up a row in _System_StandardAction for :name, enforces declared
// permissions, and invokes the registered Flow via the executor. Names
// without a row fall through to the legacy StandardActionsHandler, so
// each action can migrate from a Go method to a registry row in its own
// PR without breaking the URL shape.
type StandardActionDispatcher struct {
	db          *sql.DB
	executor    *automation.FlowExecutor
	permissions dispatcherPermissions
	legacy      *StandardActionsHandler
	formulas    *formula.Engine
	metadata    actionSchemaMetadataProvider
	// executorHook is a test seam that swaps the FlowExecutor.ExecuteFlow
	// call for an in-memory func so unit tests can observe the inputs the
	// dispatcher passes to the flow layer. Production code leaves this nil.
	executorHook func(ctx context.Context, flowName string, inputs map[string]interface{}, user *models.UserSession) (*models.FlowExecutionResult, error)
	// recordCreatePreviewHook is the preview-side equivalent of executorHook.
	// It lets unit tests assert dispatcher behavior without standing up a real
	// FlowExecutor. Production leaves this nil.
	recordCreatePreviewHook func(ctx context.Context, flowName string, inputs map[string]interface{}, targets []automation.FlowRecordCreateFieldPreviewTarget, user *models.UserSession) (*automation.FlowRecordCreateFieldsPreviewResult, error)
}

// NewStandardActionDispatcher constructs the dispatcher. Any argument may be
// nil in tests that exercise isolated paths (e.g. a test that only hits
// the legacy fallthrough does not need the executor). Accepts the
// dispatcherPermissions interface rather than the concrete PermissionService
// so unit tests can pass a stub at construction time — *permissions.PermissionService
// already satisfies the interface, so the production wiring is unchanged.
func NewStandardActionDispatcher(
	db *sql.DB,
	executor *automation.FlowExecutor,
	perms dispatcherPermissions,
	legacy *StandardActionsHandler,
	metadata ...actionSchemaMetadataProvider,
) *StandardActionDispatcher {
	var meta actionSchemaMetadataProvider
	if len(metadata) > 0 {
		meta = metadata[0]
	}
	return &StandardActionDispatcher{
		db:          db,
		executor:    executor,
		permissions: perms,
		legacy:      legacy,
		formulas:    formula.NewEngine(),
		metadata:    meta,
	}
}

// registeredStandardAction is the subset of _System_StandardAction the
// dispatcher consumes. Schema/guard/default rows join in once those
// features land (M3+); keeping the shape minimal avoids premature
// query cost on every request.
type registeredStandardAction struct {
	ID                  string
	Name                string
	FlowDeveloperName   string
	RequiredPermissions []string
}

// StandardActionListEntry is the shape returned by GET /actions/standard.
//
// The `ID` field is the _System_StandardAction row's primary key, scanned
// into the entry so that follow-up queries (e.g. loading the action's
// input guards) use the authoritative registry identifier instead of
// re-deriving one from the Name via a bootstrap naming convention.
// Exposing this Id to the frontend is intentionally avoided (`json:"-"`):
// it's an infrastructure SSoT for server-side joins, not a client contract.
//
// Each entry represents one active _System_StandardAction row filtered to
// the subset the caller is actually allowed to invoke (every declared
// RequiredPermission passes the caller's permission check). This is the
// metadata-driven replacement for hardcoded frontend button visibility —
// the UI lists active entries, renders buttons only for names + target
// objects in the response, and an admin toggles visibility by setting
// IsActive=0 on the row (zero code changes).
type StandardActionListEntry struct {
	// ID is the _System_StandardAction.Id column, NOT exposed to frontend.
	// Used by annotateInputGuards to look up guards by the registry's
	// authoritative key — not a derived sa_<name> naming convention.
	ID                string   `json:"-"`
	Name              string   `json:"name"`
	Label             string   `json:"label"`
	Description       string   `json:"description,omitempty"`
	Category          string   `json:"category,omitempty"`
	FlowDeveloperName string   `json:"flowDeveloperName"`
	IsAtomic          bool     `json:"isAtomic"`
	RequiredPerms     []string `json:"requiredPermissions"`
	// TargetObjects lists the SObject API names this action applies to.
	// Frontend filters actions by membership (`targetObjects.includes(objectApiName)`)
	// to decide which buttons to render on each object page. An EMPTY
	// array means the action has no object-level placement — it is not
	// rendered on any page. Admins must list every applicable SObject
	// explicitly; silence = hidden. A future "truly global" placement
	// should use a dedicated field (e.g. IsGlobal), not overload empty
	// TargetObjects with a second semantic. See StandardActionDef's
	// companion comment in bootstrap/standard_actions.go.
	TargetObjects []string `json:"targetObjects"`
	// InputGuards exposes per-input authorization verdicts already evaluated
	// server-side against the current caller. The frontend consumes these
	// to hide input UI elements the caller can't use — no frontend-side
	// permission lookup required, no discovery-after-submit 403. Empty
	// array means the action has no input-level guards.
	InputGuards []InputGuardSummary `json:"inputGuards"`
	// Inputs/Outputs expose the action contract from _System_ActionSchema.
	// The dispatcher still validates request shape from the same rows at
	// invocation time; discovery returns them so bespoke UIs can render only
	// metadata-declared fields instead of carrying a parallel hardcoded
	// contract.
	Inputs   []ActionSchemaSummary `json:"inputs"`
	Outputs  []ActionSchemaSummary `json:"outputs"`
	Endpoint string                `json:"endpoint"`
}

// InputGuardSummary carries one _System_ActionInputGuard row enriched with
// the server-computed guard verdict for the current caller. Permission guards
// evaluate through HasPermission; object guards evaluate through the generic
// object CRUD helper. The Condition is exposed for debug/UX;
// CallerHasPermission is the field the frontend actually gates on.
type InputGuardSummary struct {
	GatedInput          string `json:"gatedInput"`
	RequiredPermission  string `json:"requiredPermission,omitempty"`
	RequiredObject      string `json:"requiredObject,omitempty"`
	RequiredOperation   string `json:"requiredOperation,omitempty"`
	Condition           string `json:"condition,omitempty"`
	CallerHasPermission bool   `json:"callerHasPermission"`
}

// ActionSchemaSummary is the frontend-facing projection of one
// _System_ActionSchema row. Field names match the Salesforce-ish
// InvocableAction contract used by frontend/src/infrastructure/api/actions.ts.
type ActionSchemaSummary struct {
	Name                      string `json:"name"`
	Type                      string `json:"type"`
	Required                  bool   `json:"required,omitempty"`
	ReferenceTo               string `json:"referenceTo,omitempty"`
	Label                     string `json:"label,omitempty"`
	UISection                 string `json:"uiSection,omitempty"`
	UIMode                    string `json:"uiMode,omitempty"`
	TargetObject              string `json:"targetObject,omitempty"`
	TargetFieldAPIName        string `json:"targetFieldAPIName,omitempty"`
	RequiredPicklistTag       string `json:"requiredPicklistTag,omitempty"`
	MappingSourceObject       string `json:"mappingSourceObject,omitempty"`
	MappingActionType         string `json:"mappingActionType,omitempty"`
	PrefillSourceFieldAPIName string `json:"prefillSourceFieldAPIName,omitempty"`
	TargetRequired            bool   `json:"targetRequired,omitempty"`
	TargetDefaulted           bool   `json:"targetDefaulted,omitempty"`
	// Defaulted is true when an active unconditional $Input-scoped
	// _System_ActionFieldDefault row exists for this input. Conditional
	// defaults are intentionally not advertised as guaranteed because dispatch
	// can skip them for a request payload whose condition is false. defaultValue
	// is included only when the dispatcher can evaluate an unconditional default
	// without a request payload; dynamic defaults remain server-owned and are
	// resolved at dispatch time.
	Defaulted    bool        `json:"defaulted,omitempty"`
	DefaultValue interface{} `json:"defaultValue,omitempty"`
}

type standardActionRecordCreatePreviewRequest struct {
	Inputs             []map[string]interface{}                  `json:"inputs"`
	RecordCreateFields []standardActionRecordCreatePreviewTarget `json:"recordCreateFields"`
}

type standardActionRecordCreatePreviewTarget struct {
	ObjectAPIName string `json:"objectApiName"`
	FieldAPIName  string `json:"fieldApiName"`
}

type actionInputDefaultSummary struct {
	defaulted       bool
	hasDefaultValue bool
	defaultValue    interface{}
}

// List is the gin handler for GET /actions/standard. Returns active standard
// actions the caller is permitted to invoke. Callers that fail a permission
// check for a given action simply don't see it in the list — NOT a 403, so
// the frontend can render the navigation menu without exposing privileged
// actions to users who can't use them.
//
// For each candidate action, input-independent whole-action guards (blank
// GatedInput) are evaluated with the same permission helpers Dispatch uses;
// failing actions are hidden before the frontend can render an invokable
// button. Input-dependent and per-input guards are resolved against the
// caller's permissions at request time, so the frontend gets a pre-decided
// answer per input, including for custom permissions that aren't exposed by
// /permissions/effective. No hardcoded permission names or special-case hooks
// on the frontend.
//
// The response shape is SF-shaped plural JSON `{ actions: [...] }` matching
// Salesforce's own describe-lists convention.
func (d *StandardActionDispatcher) List(c *gin.Context) {
	user := MustGetUser(c)
	ctx := c.Request.Context()

	entries, err := d.listActiveActions(ctx)
	if err != nil {
		c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(
			apierr.SFCodeInternalError, err.Error()))
		return
	}

	allowed := make([]StandardActionListEntry, 0, len(entries))
	for _, e := range entries {
		if !d.callerHasAllPerms(ctx, user, e.RequiredPerms) {
			continue
		}
		guards, err := d.loadGuards(e.ID)
		if err != nil {
			c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(
				apierr.SFCodeInternalError,
				fmt.Sprintf("failed to resolve action guards for %q: %s", e.Name, err.Error())))
			return
		}
		visible, err := d.callerPassesActionVisibilityGuards(ctx, user, guards)
		if err != nil {
			c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(
				apierr.SFCodeInternalError,
				fmt.Sprintf("failed to evaluate action guards for %q: %s", e.Name, err.Error())))
			return
		}
		if !visible {
			continue
		}
		if err := d.annotateActionSchema(ctx, &e); err != nil {
			c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(
				apierr.SFCodeInternalError,
				fmt.Sprintf("failed to resolve action schema for %q: %s", e.Name, err.Error())))
			return
		}
		// Resolve per-input guard verdicts for this caller. Failing guards
		// do NOT remove the action from the list; they annotate which
		// input UI elements the frontend should hide.
		if err := d.annotateInputGuardsFromRows(ctx, user, &e, guards); err != nil {
			c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(
				apierr.SFCodeInternalError,
				fmt.Sprintf("failed to resolve input guards for %q: %s", e.Name, err.Error())))
			return
		}
		allowed = append(allowed, e)
	}

	c.JSON(http.StatusOK, gin.H{"actions": allowed})
}

// annotateInputGuards loads every active InputGuard for the action and
// evaluates `callerHasPermission` against d.permissions for each. Mutates
// entry.InputGuards in place. Empty slice (not nil) when no guards exist
// so the JSON round-trips to `"inputGuards": []` rather than `null`.
//
// The lookup keys on entry.ID — the actual Id column scanned from the
// registry — NOT on a re-derivation from Name. An admin-inserted or
// migration-imported action row with a non-sa_<name> Id would otherwise
// dispatch correctly (lookupAction queries by Name) while its input
// guards silently disappear from the discovery response. Registry Id is
// the only SSoT; discovery consults it directly.
func (d *StandardActionDispatcher) annotateInputGuards(ctx context.Context, user *models.UserSession, entry *StandardActionListEntry) error {
	entry.InputGuards = []InputGuardSummary{}
	if d.db == nil || entry.ID == "" {
		return nil
	}
	guards, err := bootstrap.GetActionInputGuards(d.db, entry.ID)
	if err != nil {
		return err
	}
	return d.annotateInputGuardsFromRows(ctx, user, entry, guards)
}

func (d *StandardActionDispatcher) annotateInputGuardsFromRows(ctx context.Context, user *models.UserSession, entry *StandardActionListEntry, guards []bootstrap.ActionInputGuardRow) error {
	entry.InputGuards = []InputGuardSummary{}
	for _, g := range guards {
		summary := InputGuardSummary{
			GatedInput:         g.GatedInput,
			RequiredPermission: g.RequiredPermission,
			RequiredObject:     g.RequiredObject,
			RequiredOperation:  g.RequiredOperation,
			Condition:          g.Condition,
		}
		allowed, err := d.callerPassesGuard(ctx, user, g)
		if err != nil {
			return err
		}
		summary.CallerHasPermission = allowed
		entry.InputGuards = append(entry.InputGuards, summary)
	}
	return nil
}

func (d *StandardActionDispatcher) callerPassesActionVisibilityGuards(ctx context.Context, user *models.UserSession, guards []bootstrap.ActionInputGuardRow) (bool, error) {
	if len(guards) == 0 {
		return true, nil
	}
	engine := d.formulas
	if engine == nil {
		engine = formula.NewEngine()
	}
	fctx := &formula.Context{Variables: map[string]interface{}{"Input": map[string]interface{}{}}}
	for _, guard := range guards {
		if strings.TrimSpace(guard.GatedInput) != "" {
			continue
		}
		if guardConditionDependsOnInput(guard.Condition) {
			continue
		}
		raw, err := engine.Evaluate(guard.Condition, fctx)
		if err != nil {
			return false, fmt.Errorf("input guard condition %q is invalid: %w", guard.Condition, err)
		}
		if !automation.IsFormulaTruthy(raw) {
			continue
		}
		allowed, err := d.callerPassesGuard(ctx, user, guard)
		if err != nil {
			return false, err
		}
		if !allowed {
			return false, nil
		}
	}
	return true, nil
}

func guardConditionDependsOnInput(condition string) bool {
	return strings.Contains(condition, "Input.") || strings.Contains(condition, "$Input.")
}

// annotateActionSchema loads active input/output descriptors for discovery.
// Dispatch uses the same _System_ActionSchema rows for request validation,
// so this keeps the read-side contract and write-side enforcement on one
// metadata source of truth.
func (d *StandardActionDispatcher) annotateActionSchema(ctx context.Context, entry *StandardActionListEntry) error {
	entry.Inputs = []ActionSchemaSummary{}
	entry.Outputs = []ActionSchemaSummary{}
	if d.db == nil || entry.ID == "" {
		return nil
	}
	rows, err := bootstrap.GetActionSchema(d.db, entry.ID)
	if err != nil {
		return err
	}
	inputDefaults, err := d.actionInputDefaultSummaries(entry.ID)
	if err != nil {
		return err
	}
	for _, row := range rows {
		summary := ActionSchemaSummary{
			Name:                row.APIName,
			Type:                row.DataType,
			Required:            row.IsRequired,
			ReferenceTo:         row.ReferenceTo,
			Label:               row.Label,
			UISection:           row.UISection,
			UIMode:              row.UIMode,
			TargetObject:        row.TargetObject,
			TargetFieldAPIName:  row.TargetFieldAPIName,
			RequiredPicklistTag: row.RequiredPicklistTag,
			MappingSourceObject: row.MappingSourceObject,
			MappingActionType:   row.MappingActionType,
		}
		d.annotateTargetFieldMetadata(ctx, &summary)
		if sourceField, err := d.prefillSourceField(row); err != nil {
			return err
		} else if sourceField != "" {
			summary.PrefillSourceFieldAPIName = sourceField
		}
		if row.Direction == "output" {
			entry.Outputs = append(entry.Outputs, summary)
			continue
		}
		if def, ok := inputDefaults[row.APIName]; ok {
			summary.Defaulted = def.defaulted
			if def.hasDefaultValue {
				summary.DefaultValue = def.defaultValue
			}
		}
		entry.Inputs = append(entry.Inputs, summary)
	}
	return nil
}

func (d *StandardActionDispatcher) annotateTargetFieldMetadata(ctx context.Context, summary *ActionSchemaSummary) {
	if d.metadata == nil || summary.TargetObject == "" || summary.TargetFieldAPIName == "" {
		if summary.Label == "" {
			summary.Label = summary.Name
		}
		return
	}
	schema := d.metadata.GetSchema(ctx, summary.TargetObject)
	field := models.FindField(schema, summary.TargetFieldAPIName)
	if field == nil {
		if summary.Label == "" {
			summary.Label = summary.Name
		}
		return
	}
	if summary.Label == "" && field.Label != "" {
		summary.Label = field.Label
	}
	summary.TargetRequired = field.Required
	if field.DefaultValue != nil && strings.TrimSpace(*field.DefaultValue) != "" {
		summary.TargetDefaulted = true
	}
	if summary.Label == "" {
		summary.Label = summary.Name
	}
}

func (d *StandardActionDispatcher) prefillSourceField(row bootstrap.ActionSchemaRow) (string, error) {
	if d.db == nil ||
		row.MappingSourceObject == "" ||
		row.TargetObject == "" ||
		row.TargetFieldAPIName == "" ||
		row.MappingActionType == "" {
		return "", nil
	}
	return bootstrap.GetFieldMappingSourceForTarget(
		d.db,
		row.MappingSourceObject,
		row.TargetObject,
		row.MappingActionType,
		row.TargetFieldAPIName,
	)
}

func (d *StandardActionDispatcher) actionInputDefaultSummaries(actionID string) (map[string]actionInputDefaultSummary, error) {
	rows, err := d.loadFieldDefaults(actionID)
	if err != nil {
		return nil, err
	}
	if len(rows) == 0 {
		return nil, nil
	}
	engine := d.formulas
	if engine == nil {
		engine = formula.NewEngine()
	}
	fctx := &formula.Context{Variables: map[string]interface{}{"Input": map[string]interface{}{}}}
	out := make(map[string]actionInputDefaultSummary)
	for _, row := range rows {
		if row.TargetObject != actionInputDefaultTarget {
			continue
		}
		if row.Condition != "" {
			continue
		}
		summary := out[row.FieldAPIName]
		summary.defaulted = true
		if !summary.hasDefaultValue {
			if value, evalErr := engine.Evaluate(row.ValueFormula, fctx); evalErr == nil {
				summary.defaultValue = value
				summary.hasDefaultValue = true
			}
		}
		out[row.FieldAPIName] = summary
	}
	return out, nil
}

// listActiveActions reads every active row from _System_StandardAction and
// returns them in SortOrder+Name order. Does NOT apply permission filtering
// — the caller (List) layers that on top so tests can exercise the SQL
// path independently.
func (d *StandardActionDispatcher) listActiveActions(ctx context.Context) ([]StandardActionListEntry, error) {
	if d.db == nil {
		return nil, nil
	}
	rows, err := d.db.QueryContext(ctx,
		`SELECT Id, Name, Label, Description, Category, FlowDeveloperName,
		        IsAtomic, RequiredPermissions, TargetObjects
		   FROM _System_StandardAction
		  WHERE IsActive = 1 AND IsDeleted = 0
		  ORDER BY SortOrder ASC, Name ASC`,
	)
	if err != nil {
		return nil, fmt.Errorf("list standard actions: %w", err)
	}
	defer rows.Close()

	var out []StandardActionListEntry
	for rows.Next() {
		var (
			id, name, label, flowName string
			description               sql.NullString
			category                  sql.NullString
			isAtomic                  bool
			reqPermsJSON              sql.NullString
			targetObjectsJSON         sql.NullString
		)
		if err := rows.Scan(&id, &name, &label, &description, &category, &flowName, &isAtomic, &reqPermsJSON, &targetObjectsJSON); err != nil {
			return nil, fmt.Errorf("scan standard action row: %w", err)
		}
		entry := StandardActionListEntry{
			ID:                id,
			Name:              name,
			Label:             label,
			Description:       description.String,
			Category:          category.String,
			FlowDeveloperName: flowName,
			IsAtomic:          isAtomic,
			TargetObjects:     []string{},
			InputGuards:       []InputGuardSummary{},
			Endpoint:          fmt.Sprintf("/services/data/v63.0/actions/standard/%s", name),
		}
		if reqPermsJSON.Valid && reqPermsJSON.String != "" {
			if err := json.Unmarshal([]byte(reqPermsJSON.String), &entry.RequiredPerms); err != nil {
				return nil, fmt.Errorf("parse RequiredPermissions for %q: %w", name, err)
			}
		}
		if targetObjectsJSON.Valid && targetObjectsJSON.String != "" {
			if err := json.Unmarshal([]byte(targetObjectsJSON.String), &entry.TargetObjects); err != nil {
				return nil, fmt.Errorf("parse TargetObjects for %q: %w", name, err)
			}
		}
		out = append(out, entry)
	}
	return out, rows.Err()
}

// callerHasAllPerms is a thin helper so both Dispatch and List share the
// same permission semantics (every declared permission must pass). A missing
// permissions service is treated as "no perms" — the fail-closed posture
// matches dispatchRegistry.
func (d *StandardActionDispatcher) callerHasAllPerms(ctx context.Context, user *models.UserSession, perms []string) bool {
	if len(perms) == 0 {
		// List deliberately hides rows with empty RequiredPermissions — same
		// fail-closed posture as dispatchRegistry. An admin who forgets to
		// wire permissions on a new action row will therefore also NOT see
		// it on any frontend (both write and read surfaces refuse).
		return false
	}
	if d.permissions == nil {
		return false
	}
	for _, p := range perms {
		if !d.permissions.HasPermission(ctx, user, p) {
			return false
		}
	}
	return true
}

// Dispatch is the gin handler. It is the single entry point for
// /actions/standard/:name; fallthrough to legacy code is an internal
// branch of this method.
func (d *StandardActionDispatcher) Dispatch(c *gin.Context) {
	name := c.Param("name")
	action, err := d.lookupAction(c.Request.Context(), name)
	if err != nil {
		// Registry-row corruption (e.g. malformed RequiredPermissions JSON)
		// is ALWAYS an internal error, not a user error. Respond 500 with
		// SF-shaped INTERNAL_ERROR so it's visible to ops and can't be
		// confused with a permission denial.
		c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(
			apierr.SFCodeInternalError, err.Error()))
		return
	}
	if action == nil {
		d.dispatchLegacy(c, name)
		return
	}
	d.dispatchRegistry(c, action)
}

// PreviewRecordCreateFields is the read-only companion to Dispatch for
// standard-action UIs that need to show what a backing Flow would assign to a
// recordCreate field before submit. It reuses the same action registry,
// permission, input-default, schema, guard, internal-scope, and Flow
// value-resolution path as Dispatch; the Flow executor stops at the requested
// recordCreate target and performs no DML.
func (d *StandardActionDispatcher) PreviewRecordCreateFields(c *gin.Context) {
	name := c.Param("name")
	action, err := d.lookupAction(c.Request.Context(), name)
	if err != nil {
		c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(
			apierr.SFCodeInternalError, err.Error()))
		return
	}
	if action == nil {
		c.JSON(http.StatusNotFound, apierr.SFErrorResponse(apierr.SFCodeNotFound, fmt.Sprintf("standard action %q not found", name)))
		return
	}

	user := MustGetUser(c)
	ctx := c.Request.Context()
	if len(action.RequiredPermissions) == 0 {
		c.JSON(http.StatusForbidden, apierr.SFErrorResponse(
			apierr.SFCodeInsufficientPerms,
			fmt.Sprintf("standard action %q is misconfigured: _System_StandardAction row must declare at least one RequiredPermission", action.Name),
		))
		return
	}
	for _, perm := range action.RequiredPermissions {
		if d.permissions == nil || !d.permissions.HasPermission(ctx, user, perm) {
			c.JSON(http.StatusForbidden, apierr.SFErrorResponse(
				apierr.SFCodeInsufficientPerms,
				fmt.Sprintf("permission denied: %q is required", perm),
			))
			return
		}
	}

	var req standardActionRecordCreatePreviewRequest
	if !BindJSON(c, &req) {
		return
	}
	if len(req.Inputs) == 0 {
		c.JSON(http.StatusBadRequest, apierr.SFErrorResponse(apierr.SFCodeInvalidRequest, "inputs array is required"))
		return
	}
	if len(req.RecordCreateFields) == 0 {
		c.JSON(http.StatusBadRequest, apierr.SFErrorResponse(apierr.SFCodeInvalidRequest, "recordCreateFields array is required"))
		return
	}
	actionSchema, schemaErr := d.loadActionSchema(action.ID)
	if schemaErr != nil {
		c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(apierr.SFCodeUnknownException, schemaErr.Error()))
		return
	}
	previewOutputTargets := recordCreatePreviewTargetsFromActionSchema(actionSchema)
	targets := make([]automation.FlowRecordCreateFieldPreviewTarget, 0, len(req.RecordCreateFields))
	for _, target := range req.RecordCreateFields {
		objectAPIName := strings.TrimSpace(target.ObjectAPIName)
		fieldAPIName := strings.TrimSpace(target.FieldAPIName)
		if objectAPIName == "" || fieldAPIName == "" {
			c.JSON(http.StatusBadRequest, apierr.SFErrorResponse(
				apierr.SFCodeInvalidRequest,
				"recordCreateFields entries require objectApiName and fieldApiName",
			))
			return
		}
		requestedTarget := standardActionRecordCreatePreviewTarget{
			ObjectAPIName: objectAPIName,
			FieldAPIName:  fieldAPIName,
		}
		if _, ok := previewOutputTargets[requestedTarget]; !ok {
			c.JSON(http.StatusBadRequest, apierr.SFErrorResponse(
				apierr.SFCodeInvalidRequest,
				fmt.Sprintf("recordCreateFields target %s.%s is not declared as a preview output for standard action %q", objectAPIName, fieldAPIName, action.Name),
			))
			return
		}
		if d.permissions == nil || !d.permissions.CheckFieldVisibilityWithUser(ctx, objectAPIName, fieldAPIName, user) {
			c.JSON(http.StatusForbidden, apierr.SFErrorResponse(
				apierr.SFCodeInsufficientPerms,
				fmt.Sprintf("permission denied: field %s.%s is not readable", objectAPIName, fieldAPIName),
			))
			return
		}
		targets = append(targets, automation.FlowRecordCreateFieldPreviewTarget{
			ObjectAPIName: objectAPIName,
			FieldAPIName:  fieldAPIName,
		})
	}

	schema := filterActionSchemaRows(actionSchema, "input")
	fieldDefaults, fdErr := d.loadFieldDefaults(action.ID)
	if fdErr != nil {
		c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(apierr.SFCodeUnknownException, fdErr.Error()))
		return
	}
	recordDefaults := recordScopedActionDefaults(fieldDefaults)
	if len(recordDefaults) > 0 {
		ctx = automation.WithActionFieldDefaults(ctx, recordDefaults)
	}
	guards, guardErr := d.loadGuards(action.ID)
	if guardErr != nil {
		c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(apierr.SFCodeUnknownException, guardErr.Error()))
		return
	}

	executionCtx := ctx
	internalScopesLoaded := false
	actionExecutionCtx := func() (context.Context, error) {
		if internalScopesLoaded {
			return executionCtx, nil
		}
		internalScopesLoaded = true
		executionCtx = constants.WithStandardActionExecutionRootFlow(ctx, action.ID, action.FlowDeveloperName)
		internalScopes, scopeErr := d.loadInternalAccessScopes(action.ID)
		if scopeErr != nil {
			return nil, scopeErr
		}
		if len(internalScopes) > 0 {
			executionCtx = constants.WithStandardActionInternalAccessScopes(executionCtx, action.ID, internalScopes)
		}
		return executionCtx, nil
	}

	results := make([]models.FlowExecutionResult, 0, len(req.Inputs))
	for _, inputs := range req.Inputs {
		if defaultResult := d.applyInputDefaults(fieldDefaults, inputs, action.Name); defaultResult != nil {
			results = append(results, *defaultResult)
			continue
		}
		if schemaResult := d.validateInputShape(schema, inputs, action.Name); schemaResult != nil {
			results = append(results, *schemaResult)
			continue
		}
		if picklistResult := d.validateInputPicklistTags(ctx, schema, inputs, action.Name); picklistResult != nil {
			results = append(results, *picklistResult)
			continue
		}
		if guardResult := d.evaluateGuards(ctx, user, guards, inputs, action.Name); guardResult != nil {
			results = append(results, *guardResult)
			continue
		}
		preview := d.recordCreatePreviewHook
		if preview == nil {
			if d.executor == nil {
				results = append(results, models.FlowExecutionResult{
					ActionName: action.Name,
					IsSuccess:  false,
					Errors: []models.FlowExecutionError{{
						StatusCode: apierr.SFCodeUnknownException,
						Message:    "Flow executor unavailable",
					}},
				})
				continue
			}
			preview = d.executor.PreviewRecordCreateFields
		}
		execCtx, scopeErr := actionExecutionCtx()
		if scopeErr != nil {
			c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(apierr.SFCodeUnknownException, scopeErr.Error()))
			return
		}
		previewResult, err := preview(execCtx, action.FlowDeveloperName, inputs, targets, user)
		if err != nil {
			results = append(results, models.FlowExecutionResult{
				ActionName: action.Name,
				IsSuccess:  false,
				Errors: []models.FlowExecutionError{{
					StatusCode: "FLOW_PREVIEW_ERROR",
					Message:    err.Error(),
				}},
			})
			continue
		}
		results = append(results, models.FlowExecutionResult{
			ActionName: action.Name,
			IsSuccess:  true,
			OutputValues: map[string]interface{}{
				"recordCreates": previewResult.RecordCreates,
			},
		})
	}

	c.JSON(http.StatusOK, results)
}

func (d *StandardActionDispatcher) lookupAction(ctx context.Context, name string) (*registeredStandardAction, error) {
	if d.db == nil {
		return nil, nil
	}
	row := d.db.QueryRowContext(ctx,
		`SELECT Id, Name, FlowDeveloperName, RequiredPermissions
		   FROM _System_StandardAction
		  WHERE Name = ? AND IsActive = 1 AND IsDeleted = 0
		  LIMIT 1`,
		name,
	)
	var a registeredStandardAction
	var reqPermsJSON sql.NullString
	if err := row.Scan(&a.ID, &a.Name, &a.FlowDeveloperName, &reqPermsJSON); err != nil {
		if err == sql.ErrNoRows {
			return nil, nil
		}
		return nil, err
	}
	if reqPermsJSON.Valid && reqPermsJSON.String != "" {
		if err := json.Unmarshal([]byte(reqPermsJSON.String), &a.RequiredPermissions); err != nil {
			// A corrupt RequiredPermissions column must NEVER degrade to a
			// silent empty-perms state — with the fail-closed check in
			// dispatchRegistry that would surface as a 403 ("misconfigured")
			// and mask the real root cause (malformed JSON in the registry
			// row). Surface the parse error directly as a 500 so ops can
			// grep logs to the exact row.
			return nil, fmt.Errorf("standard action %q is misconfigured: RequiredPermissions is not valid JSON: %w",
				name, err)
		}
	}
	return &a, nil
}

// dispatchLegacy handles names that do not yet have a _System_StandardAction
// registry row. Only changeOwner and createRecord remain; convertLead is
// handled through the registry path and is never routed here. If the
// registry row is deactivated the dispatcher returns 404, which is the
// correct admin-driven "action disabled" signal. This function disappears
// once changeOwner and createRecord migrate — registry miss then becomes
// an unconditional 404.
func (d *StandardActionDispatcher) dispatchLegacy(c *gin.Context, name string) {
	if d.legacy == nil {
		c.JSON(http.StatusNotFound, apierr.SFErrorResponse(apierr.SFCodeNotFound, fmt.Sprintf("standard action %q not found", name)))
		return
	}
	switch name {
	case "changeOwner":
		d.legacy.ChangeOwner(c)
	case "createRecord":
		d.legacy.CreateRecord(c)
	default:
		c.JSON(http.StatusNotFound, apierr.SFErrorResponse(apierr.SFCodeNotFound, fmt.Sprintf("standard action %q not found", name)))
	}
}

func (d *StandardActionDispatcher) dispatchRegistry(c *gin.Context, action *registeredStandardAction) {
	user := MustGetUser(c)
	ctx := c.Request.Context()

	// Fail-closed on misconfigured registry row. An action row without any
	// RequiredPermissions would otherwise be publicly invokable by any
	// authenticated caller — a silent privilege footgun for admins who
	// forget to wire permissions on a new action. Actions that genuinely
	// need no per-action check still rely on middleware-level PermApiEnabled,
	// and in that case the row must declare the sentinel
	// "ApiEnabled" permission (or a more specific one) explicitly.
	if len(action.RequiredPermissions) == 0 {
		c.JSON(http.StatusForbidden, apierr.SFErrorResponse(
			apierr.SFCodeInsufficientPerms,
			fmt.Sprintf("standard action %q is misconfigured: _System_StandardAction row must declare at least one RequiredPermission", action.Name),
		))
		return
	}

	for _, perm := range action.RequiredPermissions {
		if d.permissions == nil || !d.permissions.HasPermission(ctx, user, perm) {
			c.JSON(http.StatusForbidden, apierr.SFErrorResponse(
				apierr.SFCodeInsufficientPerms,
				fmt.Sprintf("permission denied: %q is required", perm),
			))
			return
		}
	}

	var req models.FlowExecutionRequest
	if !BindJSON(c, &req) {
		return
	}
	if len(req.Inputs) == 0 {
		c.JSON(http.StatusBadRequest, apierr.SFErrorResponse(apierr.SFCodeInvalidRequest, "inputs array is required"))
		return
	}

	schema, schemaErr := d.loadInputSchema(action.ID)
	if schemaErr != nil {
		c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(apierr.SFCodeUnknownException, schemaErr.Error()))
		return
	}

	fieldDefaults, fdErr := d.loadFieldDefaults(action.ID)
	if fdErr != nil {
		c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(apierr.SFCodeUnknownException, fdErr.Error()))
		return
	}

	// Stamp the pre-loaded object-scoped ActionFieldDefault rows on ctx so the flow
	// executor's recordCreate pipeline can apply stage-3 action defaults
	// at create time (not pre-flight). This is what replaces the old
	// `_defaults.*` interview-context injection — the flow JSON no longer
	// references `_defaults`, and the engine applies defaults uniformly
	// for any recordCreate inside the action scope (parent + subflows).
	recordDefaults := recordScopedActionDefaults(fieldDefaults)
	if len(recordDefaults) > 0 {
		ctx = automation.WithActionFieldDefaults(ctx, recordDefaults)
	}

	guards, guardErr := d.loadGuards(action.ID)
	if guardErr != nil {
		c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(apierr.SFCodeUnknownException, guardErr.Error()))
		return
	}
	executionCtx := ctx
	internalScopesLoaded := false
	actionExecutionCtx := func() (context.Context, error) {
		if internalScopesLoaded {
			return executionCtx, nil
		}
		internalScopesLoaded = true
		executionCtx = constants.WithStandardActionExecutionRootFlow(ctx, action.ID, action.FlowDeveloperName)
		internalScopes, scopeErr := d.loadInternalAccessScopes(action.ID)
		if scopeErr != nil {
			return nil, scopeErr
		}
		if len(internalScopes) > 0 {
			executionCtx = constants.WithStandardActionInternalAccessScopes(executionCtx, action.ID, internalScopes)
		}
		return executionCtx, nil
	}

	results := make([]models.FlowExecutionResult, 0, len(req.Inputs))
	for _, inputs := range req.Inputs {
		if defaultResult := d.applyInputDefaults(fieldDefaults, inputs, action.Name); defaultResult != nil {
			results = append(results, *defaultResult)
			continue
		}
		if schemaResult := d.validateInputShape(schema, inputs, action.Name); schemaResult != nil {
			results = append(results, *schemaResult)
			continue
		}
		if picklistResult := d.validateInputPicklistTags(ctx, schema, inputs, action.Name); picklistResult != nil {
			results = append(results, *picklistResult)
			continue
		}
		if guardResult := d.evaluateGuards(ctx, user, guards, inputs, action.Name); guardResult != nil {
			results = append(results, *guardResult)
			continue
		}
		// ActionFieldDefault rows are applied by the executor at each
		// recordCreate (stage 3 of the default pipeline), using the
		// ctx-stamped rows from WithActionFieldDefaults above. The old
		// pre-flight `_defaults.*` injection has been removed — the
		// flow JSON no longer references that convention.
		execute := d.executorHook
		if execute == nil {
			if d.executor == nil {
				results = append(results, models.FlowExecutionResult{
					ActionName: action.Name,
					IsSuccess:  false,
					Errors: []models.FlowExecutionError{{
						StatusCode: apierr.SFCodeUnknownException,
						Message:    "Flow executor unavailable",
					}},
				})
				continue
			}
			execute = d.executor.ExecuteFlow
		}
		execCtx, scopeErr := actionExecutionCtx()
		if scopeErr != nil {
			c.JSON(http.StatusInternalServerError, apierr.SFErrorResponse(apierr.SFCodeUnknownException, scopeErr.Error()))
			return
		}
		result, err := execute(execCtx, action.FlowDeveloperName, inputs, user)
		if err != nil {
			results = append(results, models.FlowExecutionResult{
				ActionName: action.Name,
				IsSuccess:  false,
				Errors: []models.FlowExecutionError{{
					StatusCode: "FLOW_EXECUTION_ERROR",
					Message:    err.Error(),
				}},
			})
			continue
		}
		if result == nil {
			results = append(results, models.FlowExecutionResult{
				ActionName: action.Name,
				IsSuccess:  false,
				Errors: []models.FlowExecutionError{{
					StatusCode: "UNKNOWN_EXCEPTION",
					Message:    "Flow execution returned no result",
				}},
			})
			continue
		}
		result.ActionName = action.Name
		results = append(results, *result)
	}

	c.JSON(http.StatusOK, results)
}

// loadGuards reads active _System_ActionInputGuard rows for the action. A
// nil db yields no guards — mirrors the other nil-tolerant paths so tests
// that don't care about guards don't need to stub them.
func (d *StandardActionDispatcher) loadGuards(actionID string) ([]bootstrap.ActionInputGuardRow, error) {
	if d.db == nil {
		return nil, nil
	}
	return bootstrap.GetActionInputGuards(d.db, actionID)
}

func (d *StandardActionDispatcher) loadInternalAccessScopes(actionID string) ([]constants.StandardActionInternalAccessScope, error) {
	if d.db == nil {
		return nil, nil
	}
	rows, err := bootstrap.GetStandardActionInternalAccessScopes(d.db, actionID)
	if err != nil {
		return nil, err
	}
	out := make([]constants.StandardActionInternalAccessScope, 0, len(rows))
	for _, row := range rows {
		out = append(out, constants.StandardActionInternalAccessScope{
			StandardActionID: row.StandardActionID,
			ObjectAPIName:    row.ObjectAPIName,
			Operation:        row.Operation,
		})
	}
	return out, nil
}

func (d *StandardActionDispatcher) loadActionSchema(actionID string) ([]bootstrap.ActionSchemaRow, error) {
	if d.db == nil {
		return nil, nil
	}
	return bootstrap.GetActionSchema(d.db, actionID)
}

// loadInputSchema reads only input-direction rows from _System_ActionSchema.
// Output descriptors are enforced by preview/read-side hooks that need them.
func (d *StandardActionDispatcher) loadInputSchema(actionID string) ([]bootstrap.ActionSchemaRow, error) {
	all, err := d.loadActionSchema(actionID)
	if err != nil {
		return nil, err
	}
	return filterActionSchemaRows(all, "input"), nil
}

func filterActionSchemaRows(rows []bootstrap.ActionSchemaRow, direction string) []bootstrap.ActionSchemaRow {
	filtered := make([]bootstrap.ActionSchemaRow, 0, len(rows))
	for _, row := range rows {
		if row.Direction == direction {
			filtered = append(filtered, row)
		}
	}
	return filtered
}

func recordCreatePreviewTargetsFromActionSchema(rows []bootstrap.ActionSchemaRow) map[standardActionRecordCreatePreviewTarget]struct{} {
	targets := make(map[standardActionRecordCreatePreviewTarget]struct{})
	for _, row := range rows {
		if row.Direction != "output" || row.UIMode != "preview" {
			continue
		}
		objectAPIName := strings.TrimSpace(row.TargetObject)
		fieldAPIName := strings.TrimSpace(row.TargetFieldAPIName)
		if objectAPIName == "" || fieldAPIName == "" {
			continue
		}
		targets[standardActionRecordCreatePreviewTarget{
			ObjectAPIName: objectAPIName,
			FieldAPIName:  fieldAPIName,
		}] = struct{}{}
	}
	return targets
}

// validateInputShape enforces per-input rules from _System_ActionSchema:
//   - every required field must be present with a non-nil/non-empty value,
//   - unknown field names are rejected so typos surface at dispatch time
//     instead of silently landing in the flow as unused context.
//
// Returns nil when the input passes, or a per-input IsSuccess=false result
// otherwise. Schema with zero declared inputs permits anything (the action
// authors opted out of validation).
func (d *StandardActionDispatcher) validateInputShape(schema []bootstrap.ActionSchemaRow, inputs map[string]interface{}, actionName string) *models.FlowExecutionResult {
	if len(schema) == 0 {
		return nil
	}

	declared := make(map[string]bootstrap.ActionSchemaRow, len(schema))
	for _, row := range schema {
		declared[row.APIName] = row
	}

	for _, row := range schema {
		if !row.IsRequired {
			continue
		}
		val, present := inputs[row.APIName]
		if !present || isEmptyInputValue(val) {
			return &models.FlowExecutionResult{
				ActionName: actionName,
				IsSuccess:  false,
				Errors: []models.FlowExecutionError{{
					StatusCode: apierr.SFCodeRequiredFieldMissing,
					Message:    fmt.Sprintf("required field %q is missing", row.APIName),
				}},
			}
		}
	}

	for name := range inputs {
		if _, ok := declared[name]; !ok {
			return &models.FlowExecutionResult{
				ActionName: actionName,
				IsSuccess:  false,
				Errors: []models.FlowExecutionError{{
					StatusCode: apierr.SFCodeInvalidField,
					Message:    fmt.Sprintf("unknown input field %q", name),
				}},
			}
		}
	}

	return nil
}

func (d *StandardActionDispatcher) validateInputPicklistTags(
	ctx context.Context,
	schema []bootstrap.ActionSchemaRow,
	inputs map[string]interface{},
	actionName string,
) *models.FlowExecutionResult {
	for _, row := range schema {
		tag := strings.TrimSpace(row.RequiredPicklistTag)
		if tag == "" {
			continue
		}
		val, present := inputs[row.APIName]
		if !present || isEmptyInputValue(val) {
			continue
		}
		if d.metadata == nil {
			return actionInputError(actionName, apierr.SFCodeInvalidField,
				fmt.Sprintf("input %q requires target picklist metadata", row.APIName))
		}
		targetSchema := d.metadata.GetSchema(ctx, row.TargetObject)
		field := models.FindField(targetSchema, row.TargetFieldAPIName)
		if field == nil {
			return actionInputError(actionName, apierr.SFCodeInvalidField,
				fmt.Sprintf("input %q target field %s.%s is not available", row.APIName, row.TargetObject, row.TargetFieldAPIName))
		}
		if !automation.PicklistOptionHasTag(field, val, tag) {
			return actionInputError(actionName, apierr.SFCodeInvalidField,
				fmt.Sprintf("input %q value %q is not an active option tagged %q on %s.%s", row.APIName, fmt.Sprintf("%v", val), tag, row.TargetObject, row.TargetFieldAPIName))
		}
	}
	return nil
}

func actionInputError(actionName, statusCode, message string) *models.FlowExecutionResult {
	return &models.FlowExecutionResult{
		ActionName: actionName,
		IsSuccess:  false,
		Errors: []models.FlowExecutionError{{
			StatusCode: statusCode,
			Message:    message,
		}},
	}
}

// applyInputDefaults lets admins fill optional-or-derived action inputs from
// _System_ActionFieldDefault rows using the reserved $Input target. It runs
// before schema validation, so a required schema field can be satisfied by
// metadata rather than by frontend or handler code. Object-scoped rows are
// ignored here and continue to be applied by recordCreate.
func (d *StandardActionDispatcher) applyInputDefaults(
	rows []bootstrap.ActionFieldDefaultRow,
	inputs map[string]interface{},
	actionName string,
) *models.FlowExecutionResult {
	if len(rows) == 0 || inputs == nil {
		return nil
	}
	engine := d.formulas
	if engine == nil {
		engine = formula.NewEngine()
	}
	fctx := &formula.Context{Variables: map[string]interface{}{"Input": inputs}}
	claimed := make(map[string]struct{})
	for _, row := range rows {
		if row.TargetObject != actionInputDefaultTarget {
			continue
		}
		if _, already := claimed[row.FieldAPIName]; already {
			continue
		}
		if val, present := inputs[row.FieldAPIName]; present && !isEmptyInputValue(val) {
			claimed[row.FieldAPIName] = struct{}{}
			continue
		}
		if row.Condition != "" {
			raw, err := engine.Evaluate(row.Condition, fctx)
			if err != nil {
				return &models.FlowExecutionResult{
					ActionName: actionName,
					IsSuccess:  false,
					Errors: []models.FlowExecutionError{{
						StatusCode: apierr.SFCodeInvalidRequest,
						Message:    fmt.Sprintf("action input default condition %q for %s is invalid: %s", row.Condition, row.FieldAPIName, err.Error()),
					}},
				}
			}
			if !automation.IsFormulaTruthy(raw) {
				continue
			}
		}
		value, err := engine.Evaluate(row.ValueFormula, fctx)
		if err != nil {
			return &models.FlowExecutionResult{
				ActionName: actionName,
				IsSuccess:  false,
				Errors: []models.FlowExecutionError{{
					StatusCode: apierr.SFCodeInvalidRequest,
					Message:    fmt.Sprintf("action input default formula %q for %s is invalid: %s", row.ValueFormula, row.FieldAPIName, err.Error()),
				}},
			}
		}
		inputs[row.FieldAPIName] = value
		claimed[row.FieldAPIName] = struct{}{}
	}
	return nil
}

func recordScopedActionDefaults(rows []bootstrap.ActionFieldDefaultRow) []bootstrap.ActionFieldDefaultRow {
	if len(rows) == 0 {
		return nil
	}
	out := make([]bootstrap.ActionFieldDefaultRow, 0, len(rows))
	for _, row := range rows {
		if row.TargetObject == actionInputDefaultTarget {
			continue
		}
		out = append(out, row)
	}
	return out
}

func isEmptyInputValue(v interface{}) bool {
	switch typed := v.(type) {
	case nil:
		return true
	case string:
		return typed == ""
	default:
		return false
	}
}

// loadFieldDefaults reads active _System_ActionFieldDefault rows. Keeping
// db-nil tolerant matches the other loaders so unit tests that don't stub
// the registry don't need to know about this path.
func (d *StandardActionDispatcher) loadFieldDefaults(actionID string) ([]bootstrap.ActionFieldDefaultRow, error) {
	if d.db == nil {
		return nil, nil
	}
	return bootstrap.GetActionFieldDefaults(d.db, actionID)
}

// evaluateGuards returns a non-nil FlowExecutionResult when any guard row's
// Condition formula evaluates truthy for the given input AND the caller
// lacks the row's declared guard target. Nil means "all guards passed,
// proceed with ExecuteFlow". Per-input evaluation lets a single request mix
// inputs that trip different guards without failing the whole batch.
func (d *StandardActionDispatcher) evaluateGuards(
	ctx context.Context,
	user *models.UserSession,
	guards []bootstrap.ActionInputGuardRow,
	inputs map[string]interface{},
	actionName string,
) *models.FlowExecutionResult {
	if len(guards) == 0 {
		return nil
	}
	fctx := &formula.Context{Variables: map[string]interface{}{"Input": inputs}}
	for _, guard := range guards {
		raw, err := d.formulas.Evaluate(guard.Condition, fctx)
		if err != nil {
			return &models.FlowExecutionResult{
				ActionName: actionName,
				IsSuccess:  false,
				Errors: []models.FlowExecutionError{{
					StatusCode: apierr.SFCodeInvalidRequest,
					Message:    fmt.Sprintf("input guard condition %q is invalid: %s", guard.Condition, err.Error()),
				}},
			}
		}
		if !automation.IsFormulaTruthy(raw) {
			continue
		}
		allowed, err := d.callerPassesGuard(ctx, user, guard)
		if err != nil {
			return &models.FlowExecutionResult{
				ActionName: actionName,
				IsSuccess:  false,
				Errors: []models.FlowExecutionError{{
					StatusCode: apierr.SFCodeInvalidRequest,
					Message:    err.Error(),
				}},
			}
		}
		if !allowed {
			msg := guard.ErrorMessage
			if msg == "" {
				msg = guardDefaultErrorMessage(guard)
			}
			return &models.FlowExecutionResult{
				ActionName: actionName,
				IsSuccess:  false,
				Errors: []models.FlowExecutionError{{
					StatusCode: apierr.SFCodeInsufficientPerms,
					Message:    msg,
				}},
			}
		}
	}
	return nil
}

func (d *StandardActionDispatcher) callerPassesGuard(ctx context.Context, user *models.UserSession, guard bootstrap.ActionInputGuardRow) (bool, error) {
	hasPermission := guard.RequiredPermission != ""
	hasObject := guard.RequiredObject != "" || guard.RequiredOperation != ""
	if hasPermission == hasObject {
		return false, fmt.Errorf("input guard %q must declare exactly one target: requiredPermission or requiredObject+requiredOperation", guard.Condition)
	}
	if d.permissions == nil {
		return false, nil
	}
	if hasPermission {
		return d.permissions.HasPermission(ctx, user, guard.RequiredPermission), nil
	}
	if guard.RequiredObject == "" || guard.RequiredOperation == "" {
		return false, fmt.Errorf("input guard %q must declare both requiredObject and requiredOperation", guard.Condition)
	}
	return d.permissions.CheckObjectPermissionWithUser(ctx, guard.RequiredObject, guard.RequiredOperation, user), nil
}

func guardDefaultErrorMessage(guard bootstrap.ActionInputGuardRow) string {
	if guard.RequiredPermission != "" {
		return fmt.Sprintf("permission denied: %q is required", guard.RequiredPermission)
	}
	return fmt.Sprintf("permission denied: %s.%s is required", guard.RequiredObject, guard.RequiredOperation)
}
