package metadata

import (
	"context"
	"encoding/json"
	"fmt"
	"math/big"
	"reflect"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"

	"github.com/rs/zerolog/log"

	apperrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/formula"
	"github.com/nexuscrm/kernel/pkg/schemautil"
	"github.com/nexuscrm/kernel/pkg/validator"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/fieldtypes"
	"github.com/nexuscrm/shared/pkg/formulastorage"
	"github.com/nexuscrm/shared/pkg/models"
)

const (
	activeComputedProjectionInfoThreshold  = time.Second
	activeComputedProjectionWarnThreshold  = 5 * time.Second
	activeComputedProjectionFieldThreshold = time.Second
	activeComputedProjectionTopFieldLimit  = 5
)

var (
	isChangedValidationPattern       = regexp.MustCompile(`(?i)\bISCHANGED\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)`)
	isBlankPriorValuePattern         = regexp.MustCompile(`(?i)\bISBLANK\s*\(\s*PRIORVALUE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)\s*\)`)
	isNullPriorValuePattern          = regexp.MustCompile(`(?i)\bISNULL\s*\(\s*PRIORVALUE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)\s*\)`)
	priorValueEqualsNullPattern      = regexp.MustCompile(`(?i)\bPRIORVALUE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)\s*(?:==|=)\s*(?:nil|null)\b`)
	priorValueNotEqualsNullPattern   = regexp.MustCompile(`(?i)\bPRIORVALUE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)\s*(?:!=|<>)\s*(?:nil|null)\b`)
	nullEqualsPriorValuePattern      = regexp.MustCompile(`(?i)\b(?:nil|null)\s*(?:==|=)\s*PRIORVALUE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)`)
	nullNotEqualsPriorValuePattern   = regexp.MustCompile(`(?i)\b(?:nil|null)\s*(?:!=|<>)\s*PRIORVALUE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)`)
	isBlankValidationPattern         = regexp.MustCompile(`(?i)\bISBLANK\s*\(\s*(?:record\.)?([A-Za-z_][A-Za-z0-9_]*)\s*\)`)
	isNullValidationPattern          = regexp.MustCompile(`(?i)\bISNULL\s*\(\s*(?:record\.)?([A-Za-z_][A-Za-z0-9_]*)\s*\)`)
	fieldEqualsNullValidationPattern = regexp.MustCompile(`(?i)\b(?:record\.)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:==|=)\s*(?:nil|null)\b`)
	fieldNotEqualsNullPattern        = regexp.MustCompile(`(?i)\b(?:record\.)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:!=|<>)\s*(?:nil|null)\b`)
	nullEqualsFieldValidationPattern = regexp.MustCompile(`(?i)\b(?:nil|null)\s*(?:==|=)\s*(?:record\.)?([A-Za-z_][A-Za-z0-9_]*)\b`)
	nullNotEqualsFieldPattern        = regexp.MustCompile(`(?i)\b(?:nil|null)\s*(?:!=|<>)\s*(?:record\.)?([A-Za-z_][A-Za-z0-9_]*)\b`)
	validationRelationPattern        = regexp.MustCompile(`\b([A-Za-z_][A-Za-z0-9_]*)\s*\.`)
	validationRelationOperandPattern = regexp.MustCompile(`^\$?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+$`)
)

// AutoNumberFieldResolver reports the field API names on `objectName`
// whose `_System_Field.auto_number_format` is non-empty. The engine
// renders these inside the insert transaction, so the validator must
// not reject them as required-but-missing on the inbound payload.
// Optional; validator treats nil resolver as "no autonumber fields".
type AutoNumberFieldResolver func(objectName string) map[string]struct{}

// FormulaRelationResolver resolves a relationship segment referenced from a
// validation formula (for example RecordType.DeveloperName or Owner.Name).
// Returning nil preserves the old "relationship missing" behaviour so the
// formula engine can decide whether to skip or fail closed.
type FormulaRelationResolver func(ctx context.Context, objectAPIName string, record models.SObject, relationName string) (models.SObject, error)

// RecordSetBindingResolver evaluates a metadata-owned record-set binding and
// returns the boolean injected into the validation-rule formula context.
type RecordSetBindingResolver func(ctx context.Context, schema *models.ObjectMetadata, record models.SObject, rule *models.ValidationRule, binding models.ValidationRuleRecordSetBinding, userContext map[string]interface{}) (bool, error)

// ValidationProjectionProvider returns the precomputed set of active computed
// field API names that must be projected for validation rules on a given object.
// When available, the validation service uses this narrow set instead of
// evaluating all active computed fields on the object.
//
// ValidationProjectionFieldsOrCompute additionally computes the narrow set on a
// cold-cache miss instead of reporting "absent", so the validation runtime does
// not degrade to evaluating every active computed field (a query-backed projection
// storm) in the window after a refresh-cache clears the projection cache.
type ValidationProjectionProvider interface {
	ValidationProjectionFields(objectName string) ([]string, bool)
	ValidationProjectionFieldsOrCompute(ctx context.Context, objectName string) ([]string, bool)
}

// ActiveFormulaResolver projects a single formula-backed active computed field
// through the authoritative formula-view/compiler path for the supplied
// in-flight record image.
type ActiveFormulaResolver func(ctx context.Context, schema *models.ObjectMetadata, record models.SObject, field *models.FieldMetadata) (interface{}, error)

// ValidationService handles record validation logic
type ValidationService struct {
	formula            *formula.Engine
	formulaResolve     FormulaEngineResolver
	validator          *validator.Registry
	collectAllRules    bool
	autoNumberResolve  AutoNumberFieldResolver
	relationResolve    FormulaRelationResolver
	activeFormula      ActiveFormulaResolver
	activeProjection   ActiveProjectionResolver
	recordSetResolve   RecordSetBindingResolver
	projectionProvider ValidationProjectionProvider
}

// FormulaEngineResolver selects the executable formula vocabulary for one
// validation request. Independent Runtime uses it to bind evaluation to the
// immutable Catalog view already admitted into the request context; legacy
// compositions keep using the static engine passed to NewValidationService.
type FormulaEngineResolver func(context.Context) (*formula.Engine, error)

type activeComputedProjectionFieldTiming struct {
	Name    string
	Kind    string
	Pass    int
	Elapsed time.Duration
}

type stateTransitionConfig struct {
	Initial     []string            `json:"initial"`
	Transitions map[string][]string `json:"transitions"`
}

// NewValidationService creates a new ValidationService
func NewValidationService(formula *formula.Engine) *ValidationService {
	return &ValidationService{
		formula:         formula,
		validator:       validator.GetRegistry(),
		collectAllRules: true,
	}
}

// SetFormulaEngineResolver installs request-bound formula authority. A resolver
// error fails the save closed; it never falls back to the process engine.
func (vs *ValidationService) SetFormulaEngineResolver(resolve FormulaEngineResolver) {
	vs.formulaResolve = resolve
}

func (vs *ValidationService) resolveFormulaEngine(ctx context.Context) (*formula.Engine, error) {
	if vs == nil {
		return nil, fmt.Errorf("validation service is unavailable")
	}
	if vs.formulaResolve != nil {
		engine, err := vs.formulaResolve(ctx)
		if err != nil {
			return nil, err
		}
		if engine == nil {
			return nil, fmt.Errorf("request-bound formula engine is unavailable")
		}
		return engine, nil
	}
	if vs.formula == nil {
		return nil, fmt.Errorf("formula engine is unavailable")
	}
	return vs.formula, nil
}

// SetCollectAllValidationRules controls aggregation across static field checks,
// custom rules, and the records save pipeline. New services collect all
// client-visible validation failures by default; isolated compatibility callers
// may explicitly opt out without changing production runtime behavior.
func (vs *ValidationService) SetCollectAllValidationRules(enabled bool) {
	vs.collectAllRules = enabled
}

// CollectAllValidationErrorsEnabled reports whether callers should continue
// through independent validation stages after a client-visible rejection.
func (vs *ValidationService) CollectAllValidationErrorsEnabled() bool {
	return vs != nil && vs.collectAllRules
}

// canCollectClientValidationError is the fail-closed gate for continuing past
// a validation stage. Collect-all may defer only errors whose complete unwrap
// tree is client-visible; authority, configuration, IO, and mixed error trees
// remain terminal.
func (vs *ValidationService) canCollectClientValidationError(err error) bool {
	if err == nil || !vs.CollectAllValidationErrorsEnabled() {
		return false
	}
	_, ok := apperrors.AsClientValidationErrors(err)
	return ok
}

// SetAutoNumberResolver wires the autonumber field resolver. Called by
// the service-manager initializer once the autonumber MetadataSource is
// available. Safe to call multiple times (e.g. after metadata refresh).
func (vs *ValidationService) SetAutoNumberResolver(r AutoNumberFieldResolver) {
	vs.autoNumberResolve = r
}

// SetFormulaRelationResolver wires the relationship resolver used by
// validation formulas when they reference dot-path roots such as
// RecordType.DeveloperName or Owner.Name.
func (vs *ValidationService) SetFormulaRelationResolver(r FormulaRelationResolver) {
	vs.relationResolve = r
}

// SetActiveFormulaResolver wires the compiler/query-backed resolver used by
// validation's active computed-field overlay for formula-backed fields.
func (vs *ValidationService) SetActiveFormulaResolver(r ActiveFormulaResolver) {
	vs.activeFormula = r
}

// SetActiveProjectionResolver wires the lookup resolver used by validation's
// active computed-field overlay for projection-backed fields.
func (vs *ValidationService) SetActiveProjectionResolver(r ActiveProjectionResolver) {
	vs.activeProjection = r
}

// SetRecordSetBindingResolver wires the resolver used by validation-rule
// record-set bindings.
func (vs *ValidationService) SetRecordSetBindingResolver(r RecordSetBindingResolver) {
	vs.recordSetResolve = r
}

// SetValidationProjectionProvider wires the precomputed projection provider
// that narrows active field evaluation to only those referenced by validation
// rules (directly or transitively through ExtraDeps).
func (vs *ValidationService) SetValidationProjectionProvider(p ValidationProjectionProvider) {
	vs.projectionProvider = p
}

// ValidateRecordStaticConstraints validates the schema-static subset on a full
// record image without evaluating custom ValidationRule formulas. This is for
// operation contracts that need insert-style full-image shape validation but
// must not inherit create-specific rule semantics.
func (vs *ValidationService) ValidateRecordStaticConstraints(record models.SObject, schema *models.ObjectMetadata) error {
	return vs.validateRecordStaticConstraints(record, schema, nil, nil, true, true)
}

// ValidateRecordReactivationConstraints validates the schema/metadata-owned
// full-image constraints required before a soft-deleted row re-enters the live
// runtime. It intentionally does not evaluate custom ValidationRule formulas or
// run insert side effects such as default stamping, but it does enforce
// validators whose truth can drift while the row is deleted.
func (vs *ValidationService) ValidateRecordReactivationConstraints(record models.SObject, schema *models.ObjectMetadata) error {
	if err := vs.validateRecordStaticConstraints(record, schema, nil, nil, true, true); err != nil {
		return err
	}
	return vs.validateRecordReactivationStateTransitions(record, schema)
}

// ValidateRecordReactivationConstraintsWithStoredRecord keeps the stored image
// separate from the final image after before-restore canonicalizers. Only an
// unchanged historical static-picklist value is grandfathered.
func (vs *ValidationService) ValidateRecordReactivationConstraintsWithStoredRecord(record models.SObject, storedRecord models.SObject, schema *models.ObjectMetadata) error {
	// Reactivation restores the exact stored row; it does not author a new
	// picklist value. A value retired while the row was deleted remains valid
	// historical data. All other drift-sensitive full-image constraints still
	// run, including required/type/length/regex and lifecycle-state membership.
	if err := vs.validateRecordStaticConstraints(record, schema, nil, &storedRecord, true, true); err != nil {
		return err
	}
	return vs.validateRecordReactivationStateTransitions(record, schema)
}

func (vs *ValidationService) validateRecordStaticConstraints(
	record models.SObject,
	schema *models.ObjectMetadata,
	oldRecord *models.SObject,
	grandfatheredStaticPicklistRecord *models.SObject,
	enforceDefaultedOnCreateRequired bool,
	skipStateTransition bool,
) error {
	// Autonumber v2: fields with `_System_Field.auto_number_format`
	// set are rendered by the engine inside the insert transaction.
	// Skip them from required-field validation; the persistence layer
	// fills the value before any constraint that would care.
	var autoNumberFields map[string]struct{}
	if vs.autoNumberResolve != nil && schema != nil {
		autoNumberFields = vs.autoNumberResolve(schema.APIName)
	}

	staticFailures := make([]error, 0)
	collectStaticFailure := func(err error) error {
		if err == nil {
			return nil
		}
		if !vs.canCollectClientValidationError(err) {
			return err
		}
		staticFailures = append(staticFailures, err)
		return nil
	}

	// 1. Static Schema Constraints
fieldLoop:
	for _, field := range schema.Fields {
		// Skip system fields for required checks (unless input provided, but typically server-set)
		if field.IsSystem {
			continue
		}

		val, exists := record[field.APIName]

		// On update, skip validation entirely for fields whose value didn't change.
		// This prevents legacy NULL values in unchanged fields from blocking
		// unrelated PATCH operations (e.g., PATCH {IsActive: false} failing
		// because FirstName is NULL in migrated data).
		if oldRecord != nil {
			oldVal, oldExists := (*oldRecord)[field.APIName]
			if oldExists && valuesEqualForValidation(val, oldVal) {
				continue
			}
		}

		// Required fields: enforced on INSERT (oldRecord == nil) and on UPDATE
		// only for fields that were actually changed (unchanged fields skipped above).
		// Runtime-scoped picklist requiredness depends on its controlling-field
		// value and is owned by the records runtime-scoped validator. Rechecking
		// it here would both reject controller-absent values unconditionally and
		// duplicate the same failure when the save pipeline collects all errors.
		if field.Required && !models.IsRuntimeScopedPicklist(field) {
			// Create-path server defaults satisfy the caller contract even when
			// the stored column remains non-nullable. REST create-path validation,
			// shared records validation, and describe must agree on this rule.
			if oldRecord == nil && field.DefaultedOnCreate && !enforceDefaultedOnCreateRequired {
				continue
			}
			// Server-computed types are populated post-validation (autonumber
			// by generateAutoNumbers, formula/rollupSummary by the compute
			// layer). Rejecting them here produces false negatives — the
			// persistence layer would have filled the value itself.
			switch field.Type {
			case constants.FieldTypeAutonumber,
				constants.FieldTypeFormula,
				constants.FieldTypeRollupSummary:
				// skip required-check; value is server-assigned
			default:
				// Autonumber v2: field.Type may be `string` (e.g.
				// Quote.Name) while `auto_number_format` is set —
				// engine renders it inside the insert tx.
				if _, ok := autoNumberFields[field.APIName]; ok {
					// skip; value will be server-rendered
				} else if !exists || val == nil || val == "" {
					if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "is required")); err != nil {
						return err
					}
					continue fieldLoop
				}
			}
		}

		if val == nil {
			continue
		}

		// Type Validation & Constraints - Metadata-driven approach
		fieldType := string(field.Type)

		// Check if this field type has a validation pattern defined in metadata
		pattern, message := fieldtypes.GetValidationPattern(fieldType)
		if pattern != "" {
			if strVal, ok := val.(string); ok {
				if strVal != "" {
					matched, err := regexp.MatchString(pattern, strVal)
					if err == nil && !matched {
						if message == "" {
							message = "invalid format"
						}
						if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, message)); err != nil {
							return err
						}
						continue fieldLoop
					}
				}
			} else {
				if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "expected string for "+fieldType)); err != nil {
					return err
				}
				continue fieldLoop
			}
		}

		// Type-specific validations that can't be expressed as regex
		switch fieldType {
		case string(constants.FieldTypeBoolean):
			if _, ok := val.(bool); !ok {
				// Also allow string "true"/"false" or "0"/"1"
				if strVal, ok := val.(string); ok {
					if _, err := strconv.ParseBool(strVal); err != nil {
						if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "expected boolean")); err != nil {
							return err
						}
						continue fieldLoop
					}
				} else if floatVal, ok := val.(float64); ok {
					// JSON unmarshals numeric 0/1 as float64
					if floatVal != 0 && floatVal != 1 {
						if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "expected boolean (0/1)")); err != nil {
							return err
						}
						continue fieldLoop
					}
				} else if intVal, ok := val.(int); ok {
					// Allow 0 or 1
					if intVal != 0 && intVal != 1 {
						if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "expected boolean (0/1)")); err != nil {
							return err
						}
						continue fieldLoop
					}
				} else if int64Val, ok := val.(int64); ok {
					// Allow 0 or 1
					if int64Val != 0 && int64Val != 1 {
						if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "expected boolean (0/1)")); err != nil {
							return err
						}
						continue fieldLoop
					}
				} else {
					if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "expected boolean")); err != nil {
						return err
					}
					continue fieldLoop
				}
			}
		case string(constants.FieldTypeDouble), string(constants.FieldTypeCurrency), string(constants.FieldTypePercent):
			// Numeric checks below handles values, but here we ensure type compatibility
			switch v := val.(type) {
			case float64, int, int64:
				// OK
			case schemautil.ExactDecimal:
				if _, _, ok := v.CoefficientAndScale(); !ok {
					if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "expected numeric value")); err != nil {
						return err
					}
					continue fieldLoop
				}
			case string:
				if _, err := strconv.ParseFloat(v, 64); err != nil {
					if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "expected numeric value")); err != nil {
						return err
					}
					continue fieldLoop
				}
			default:
				if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "expected numeric value")); err != nil {
					return err
				}
				continue fieldLoop
			}
		}
		grandfatheredStaticPicklistValue := false
		if grandfatheredStaticPicklistRecord != nil {
			storedValue, stored := (*grandfatheredStaticPicklistRecord)[field.APIName]
			grandfatheredStaticPicklistValue = stored && valuesEqualForValidation(val, storedValue)
		}
		if !grandfatheredStaticPicklistValue {
			if err := validateStaticPicklistOption(field, val); err != nil {
				if err := collectStaticFailure(err); err != nil {
					return err
				}
				continue fieldLoop
			}
		}

		// Length checks use Unicode code points, matching the formula runtime's
		// string-length contract. Go's len(string) reports UTF-8 bytes instead.
		if strVal, ok := val.(string); ok {
			if field.MinLength != nil || field.MaxLength != nil {
				strLen := utf8.RuneCountInString(strVal)
				if field.MinLength != nil && strLen < *field.MinLength {
					if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "is too short")); err != nil {
						return err
					}
					continue fieldLoop
				}
				if field.MaxLength != nil && strLen > *field.MaxLength {
					if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "is too long")); err != nil {
						return err
					}
					continue fieldLoop
				}
			}

			// Regex Check
			if field.Regex != nil && *field.Regex != "" {
				matched, err := regexp.MatchString(*field.Regex, strVal)
				if err == nil && !matched {
					msg := "invalid format"
					if field.RegexMessage != nil {
						msg = *field.RegexMessage
					}
					if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, msg)); err != nil {
						return err
					}
					continue fieldLoop
				}
			}
		}

		// Numeric Checks
		if field.MinValue != nil || field.MaxValue != nil {
			// Try to interpret as number
			var numVal float64
			var exactVal *big.Rat
			isNum := false

			switch v := val.(type) {
			case float64:
				numVal = v
				isNum = true
			case int:
				numVal = float64(v)
				isNum = true
			case int64:
				numVal = float64(v)
				isNum = true
			case schemautil.ExactDecimal:
				exactVal, isNum = new(big.Rat).SetString(v.String())
			case string:
				if f, err := strconv.ParseFloat(v, 64); err == nil {
					numVal = f
					isNum = true
				}
			}

			if isNum {
				if field.MinValue != nil && numericValueBelowBound(numVal, exactVal, *field.MinValue) {
					if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "value is too small")); err != nil {
						return err
					}
					continue fieldLoop
				}
				if field.MaxValue != nil && numericValueAboveBound(numVal, exactVal, *field.MaxValue) {
					if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, "value is too large")); err != nil {
						return err
					}
					continue fieldLoop
				}
			}
		}

		// Pluggable Validator Check
		// Uses the validator registry for extensible validation
		if field.Validator != nil && *field.Validator != "" {
			if *field.Validator == "state_transition" {
				if skipStateTransition {
					continue
				}
				if err := vs.validateStateTransitionField(field, val, oldRecord); err != nil {
					if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, err.Error())); err != nil {
						return err
					}
				}
				continue fieldLoop
			}
			if models.IsRuntimeScopedPicklist(field) {
				continue
			}
			if strings.EqualFold(strings.TrimSpace(*field.Validator), "runtime_scoped_lookup") {
				continue
			}
			validatorName := *field.Validator
			var config map[string]interface{}

			// Parse validator config if provided
			if field.ValidatorConfig != nil && *field.ValidatorConfig != "" {
				if err := json.Unmarshal([]byte(*field.ValidatorConfig), &config); err != nil {
					// Invalid config, skip validator
					config = nil
				}
			}

			if err := vs.validator.Validate(validatorName, val, config); err != nil {
				if err := collectStaticFailure(apperrors.NewValidationError(field.APIName, err.Error())); err != nil {
					return err
				}
				continue fieldLoop
			}
		}
	}

	return apperrors.NewClientValidationErrors(staticFailures...)
}

func numericValueBelowBound(value float64, exact *big.Rat, bound float64) bool {
	if exact == nil {
		return value < bound
	}
	boundRat, ok := new(big.Rat).SetString(strconv.FormatFloat(bound, 'g', -1, 64))
	if !ok {
		return false
	}
	return exact.Cmp(boundRat) < 0
}

func numericValueAboveBound(value float64, exact *big.Rat, bound float64) bool {
	if exact == nil {
		return value > bound
	}
	boundRat, ok := new(big.Rat).SetString(strconv.FormatFloat(bound, 'g', -1, 64))
	if !ok {
		return false
	}
	return exact.Cmp(boundRat) > 0
}

// ValidateRecord performs comprehensive validation on a record.
func (vs *ValidationService) ValidateRecord(
	ctx context.Context,
	record models.SObject,
	schema *models.ObjectMetadata,
	rules []*models.ValidationRule,
	oldRecord *models.SObject,
	userContext map[string]interface{},
) error {
	if ctx == nil {
		ctx = context.Background()
	}
	userContext = withFormulaPermissionContext(userContext)

	staticErr := vs.validateRecordStaticConstraints(record, schema, oldRecord, nil, false, false)
	// Rule evaluation can resolve formula authority and issue metadata/record-set
	// reads. Never cross that boundary after a terminal static failure; only a
	// completely classified client-validation tree may be combined with rules.
	if staticErr != nil && !vs.canCollectClientValidationError(staticErr) {
		return staticErr
	}

	// 2. Custom Validation Rules
	if len(rules) > 0 {
		// A single validation invocation may evaluate several rules that share the
		// same record-set candidate query. Keep successful candidate reads local to
		// this invocation; a later validation pass must observe its own transaction
		// snapshot and therefore always starts with an empty cache.
		ctx = withValidationRecordSetBindingQueryCache(ctx)
		formulaEngine, err := vs.resolveFormulaEngine(ctx)
		if err != nil {
			return apperrors.NewInternalError("Validation runtime failed to bind formula authority", err)
		}
		// Build formula-specific record as a COPY of the original record.
		// All coercion and field population happens on the copy, NOT on the
		// original record, to prevent mutation from leaking to the persistence
		// layer (which writes the same map after validation).
		formulaRecord := make(map[string]interface{}, len(schema.Fields))
		for k, v := range record {
			formulaRecord[k] = v
		}
		rawFormulaRecord := cloneValidationFormulaMap(formulaRecord)
		erasedBlanks := prepareValidationFormulaRecord(models.SObject(formulaRecord), schema)
		activeValidationFields := vs.validationActiveComputedFields(ctx, schema)
		if err := vs.overlayActiveValidationComputedFields(ctx, schema, formulaRecord, rawFormulaRecord, activeValidationFields, "current"); err != nil {
			return apperrors.NewInternalError("Validation runtime failed to project active computed fields", err)
		}

		// SUPPORT 'null' keyword:
		if _, exists := formulaRecord["null"]; !exists {
			formulaRecord["null"] = nil
		}

		// Build prior record with the same coercion for correct ISCHANGED comparisons
		var priorRecord map[string]interface{}
		var priorErasedBlanks map[string]struct{}
		if oldRecord != nil {
			priorRecord = make(map[string]interface{}, len(schema.Fields))
			for k, v := range *oldRecord {
				priorRecord[k] = v
			}
			rawPriorRecord := cloneValidationFormulaMap(priorRecord)
			priorErasedBlanks = prepareValidationFormulaRecord(models.SObject(priorRecord), schema)
			if err := vs.overlayActiveValidationComputedFields(ctx, schema, priorRecord, rawPriorRecord, activeValidationFields, "prior"); err != nil {
				return apperrors.NewInternalError("Validation runtime failed to project active computed prior fields", err)
			}
		}

		// On create there is no prior record, and the formula engine seeds each
		// _prior_<field> from the CURRENT record (the vendor's PRIORVALUE returns
		// the current value on insert). The prior sentinels have to mirror that
		// same source, or ISBLANK(PRIORVALUE(<numeric>)) would read the coerced
		// zero and disagree with ISBLANK(<numeric>) on the same field in the same
		// save. Strings never showed the disagreement because their blank
		// survives the coercion as nil.
		priorSentinelRecord, priorSentinelBlanks := formulaRecord, erasedBlanks
		if priorRecord != nil {
			priorSentinelRecord, priorSentinelBlanks = priorRecord, priorErasedBlanks
		}

		formulaFields := buildFormulaNullSentinels(models.SObject(formulaRecord), schema, "__null_", erasedBlanks)
		for k, v := range buildFormulaNullSentinels(models.SObject(priorSentinelRecord), schema, "__prior_null_", priorSentinelBlanks) {
			formulaFields[k] = v
		}
		superUser := hasSuperUserFormulaCapability(userContext)
		validationFailures := make([]apperrors.ValidationRuleFailure, 0)
		evaluationRules := rules
		if vs.collectAllRules {
			evaluationRules = validationRulesByID(rules)
		}
		for _, rule := range evaluationRules {
			if !rule.Active {
				continue
			}
			// For super-user capability holders: inject any $Permission.X keys
			// referenced in this rule's condition into __Permission so the
			// formula engine resolves them as true. This avoids hardcoding L1
			// permission names in L0 platform code.
			// Uses the shared formula.InjectSuperUserPermissions helper (Issue #609).
			formula.InjectSuperUserPermissions(userContext, rule.Condition, superUser)
			ruleRecord := cloneValidationFormulaMap(formulaRecord)
			if result, independent := vs.validationRuleOutcomeIndependentOfRecordSetBindings(
				ctx, formulaEngine, schema, ruleRecord, priorRecord, rule, userContext, formulaFields,
			); independent {
				if result {
					validationFailures = append(validationFailures, validationRuleFailure(rule))
					if !vs.collectAllRules {
						return apperrors.NewValidationRuleFailures(validationFailures)
					}
				}
				continue
			}
			bindingFields, err := vs.resolveRecordSetBindingFields(ctx, schema, ruleRecord, rule, userContext)
			if err != nil {
				return apperrors.NewInternalError(fmt.Sprintf("Validation rule '%s' failed to evaluate", rule.Name), err)
			}
			ruleFields := make(map[string]interface{}, len(formulaFields)+len(bindingFields))
			for name, value := range bindingFields {
				ruleFields[name] = value
			}
			// Sentinels are applied last so they own the `__null_` /
			// `__prior_null_` namespace outright. Binding names are admin-authored
			// and may begin with an underscore, so one could otherwise shadow a
			// sentinel — harmless while every numeric sentinel was false, but
			// load-bearing now that the ISBLANK family reads them for numerics.
			for name, value := range formulaFields {
				ruleFields[name] = value
			}
			formulaCtx := &formula.Context{
				Record: ruleRecord,
				Prior:  priorRecord,
				User:   userContext,
				Fields: ruleFields,
				Schema: schema,
			}
			if schema != nil && schema.APIName != "" && vs.relationResolve != nil {
				formulaCtx.Fetcher = func(record map[string]interface{}, relationName string) (map[string]interface{}, error) {
					if validationFormulaGlobalRoot(relationName) {
						return nil, nil
					}
					if !validationKnownFormulaRoot(schema, relationName) {
						return nil, fmt.Errorf("unknown validation relation %q", relationName)
					}
					related, err := vs.relationResolve(ctx, schema.APIName, models.SObject(record), relationName)
					if related == nil || err != nil {
						return nil, err
					}
					related = applyValidationRuleRelationCompatibility(rule, relationName, related)
					return map[string]interface{}(related), nil
				}
			}
			if schema != nil && schema.APIName != "" && vs.relationResolve != nil {
				for _, relationName := range extractValidationRelationRoots(rule.Condition) {
					if _, exists := ruleRecord[relationName]; exists {
						continue
					}
					if !validationKnownFormulaRoot(schema, relationName) {
						return apperrors.NewInternalError(
							fmt.Sprintf("Validation rule '%s' failed to evaluate", rule.Name),
							fmt.Errorf("unknown validation relation %q", relationName),
						)
					}
					related, err := vs.relationResolve(ctx, schema.APIName, ruleRecord, relationName)
					if err != nil {
						return apperrors.NewInternalError(fmt.Sprintf("Validation rule '%s' failed to evaluate", rule.Name), err)
					}
					if related == nil {
						related = models.SObject{}
					} else {
						related = applyValidationRuleRelationCompatibility(rule, relationName, related)
					}
					ruleRecord[relationName] = related
				}
			}
			result, err := formulaEngine.Evaluate(rewriteValidationFormulaExpression(rule.Condition, schema), formulaCtx)
			if err != nil {
				errMsg := err.Error()
				// Skip rules referencing known-unsupported formula functions or
				// imported custom-field artifacts. Relationship names are resolved
				// from metadata and must not be maintained as an object-specific
				// platform allowlist.
				// expr-lang produces "unknown name <IDENT>" for missing identifiers.
				// We only skip if the identifier is a known Salesforce import artifact;
				// genuinely malformed rules (typos, random garbage) fail closed.
				if ident := extractUnknownIdentifier(errMsg); ident != "" &&
					!schemaHasField(schema, ident) &&
					vs.isSalesforceImportArtifactWithEngine(ident, formulaEngine) {
					log.Warn().Str("rule", rule.Name).Str("object", schema.APIName).
						Str("identifier", ident).
						Msg("Skipping validation rule: known-unsupported function or import artifact")
					continue
				}
				// Fail Closed for all other evaluation errors: stop to prevent invalid data.
				return apperrors.NewInternalError(fmt.Sprintf("Validation rule '%s' failed to evaluate", rule.Name), err)
			}

			if isTrue, ok := result.(bool); ok && isTrue {
				validationFailures = append(validationFailures, validationRuleFailure(rule))
				if !vs.collectAllRules {
					return apperrors.NewValidationRuleFailures(validationFailures)
				}
			}
		}
		return apperrors.NewClientValidationErrors(
			staticErr,
			apperrors.NewValidationRuleFailures(validationFailures),
		)
	}

	return staticErr
}

func validationRuleFailure(rule *models.ValidationRule) apperrors.ValidationRuleFailure {
	field := ""
	if rule.ErrorDisplayField != nil {
		field = strings.TrimSpace(*rule.ErrorDisplayField)
	}
	return apperrors.ValidationRuleFailure{
		RuleName: rule.Name,
		Message:  rule.ErrorMessage,
		Field:    field,
	}
}

func validationRulesByID(rules []*models.ValidationRule) []*models.ValidationRule {
	ordered := append([]*models.ValidationRule(nil), rules...)
	sort.SliceStable(ordered, func(i, j int) bool {
		if ordered[i] == nil {
			return false
		}
		if ordered[j] == nil {
			return true
		}
		left, right := strings.ToLower(ordered[i].ID), strings.ToLower(ordered[j].ID)
		if left == right {
			return ordered[i].ID < ordered[j].ID
		}
		return left < right
	})
	return ordered
}

func validateStaticPicklistOption(field models.FieldMetadata, val interface{}) error {
	if constants.NormalizeFieldType(string(field.Type)) != constants.FieldTypePicklist || val == nil || len(field.Options) == 0 || models.IsRuntimeScopedPicklist(field) {
		return nil
	}
	strVal, ok := val.(string)
	if !ok || strVal == "" {
		return nil
	}
	if isDeclaredSinglePicklistOption(field.Options, strVal) {
		return nil
	}
	strVal = strings.TrimSpace(strVal)
	if strVal == "" {
		return nil
	}
	for _, opt := range field.Options {
		if opt.Value == strVal {
			return nil
		}
	}
	return apperrors.NewValidationError(field.APIName, fmt.Sprintf("value %q is not an active picklist option", strVal))
}

func isDeclaredSinglePicklistOption(options models.PicklistOptions, value string) bool {
	for _, opt := range options {
		if opt.Value == value {
			return true
		}
	}
	return false
}

// overlayActiveValidationComputedFields evaluates active formula-backed fields
// against the in-flight record image so validation rules can read the same
// values Salesforce exposes during save-time validation.
func (vs *ValidationService) overlayActiveValidationComputedFields(
	ctx context.Context,
	schema *models.ObjectMetadata,
	record map[string]interface{},
	rawRecord map[string]interface{},
	activeFields []*models.FieldMetadata,
	projectionPhase string,
) error {
	if vs == nil || vs.formula == nil || schema == nil || len(schema.Fields) == 0 || len(record) == 0 {
		return nil
	}
	if len(activeFields) == 0 {
		return nil
	}

	if ctx == nil {
		ctx = context.Background()
	}

	start := time.Now()
	zlog := log.Logger
	debugFields := zlog.Debug().Enabled()
	fieldTimings := make([]activeComputedProjectionFieldTiming, 0, len(activeFields))

	maxPasses := len(activeFields) + 1
	for pass := 0; pass < maxPasses; pass++ {
		changed := false
		for _, field := range activeFields {
			fieldStart := time.Now()
			value, err := vs.evaluateValidationActiveComputedField(ctx, schema, field, record, rawRecord)
			fieldElapsed := time.Since(fieldStart)
			timing := activeComputedProjectionFieldTiming{
				Name:    activeComputedProjectionFieldName(field),
				Kind:    activeComputedProjectionFieldKind(field),
				Pass:    pass + 1,
				Elapsed: fieldElapsed,
			}
			fieldTimings = append(fieldTimings, timing)
			if debugFields {
				zlog.Debug().
					Str("request_id", validationRequestIDFromCtx(ctx)).
					Str("object", schema.APIName).
					Str("phase", projectionPhase).
					Str("field", timing.Name).
					Str("field_kind", timing.Kind).
					Int("pass", timing.Pass).
					Int64("elapsed_ms", fieldElapsed.Milliseconds()).
					Msg("validation active computed projection field")
			}
			if err != nil {
				zlog.Error().
					Err(err).
					Str("request_id", validationRequestIDFromCtx(ctx)).
					Str("object", schema.APIName).
					Str("phase", projectionPhase).
					Str("field", timing.Name).
					Str("field_kind", timing.Kind).
					Int("pass", timing.Pass).
					Int("field_count", len(activeFields)).
					Int64("elapsed_ms", fieldElapsed.Milliseconds()).
					Msg("validation active computed projection field failed")
				return err
			}
			if !reflect.DeepEqual(record[field.APIName], value) {
				record[field.APIName] = value
				changed = true
			}
		}
		if !changed {
			vs.logActiveComputedProjectionSummary(ctx, schema.APIName, projectionPhase, len(activeFields), pass+1, time.Since(start), fieldTimings)
			return nil
		}
	}

	totalElapsed := time.Since(start)
	vs.logActiveComputedProjectionSummary(ctx, schema.APIName, projectionPhase, len(activeFields), maxPasses, totalElapsed, fieldTimings)
	return fmt.Errorf(
		"active computed field projection for %s did not converge after %d passes",
		schema.APIName,
		maxPasses,
	)
}

func (vs *ValidationService) evaluateValidationActiveComputedField(
	ctx context.Context,
	schema *models.ObjectMetadata,
	field *models.FieldMetadata,
	record map[string]interface{},
	rawRecord map[string]interface{},
) (interface{}, error) {
	if field == nil {
		return nil, nil
	}
	if field.Formula != nil && strings.TrimSpace(*field.Formula) != "" {
		if vs.activeFormula == nil {
			return nil, fmt.Errorf("active formula field %s.%s has no resolver configured", schema.APIName, field.APIName)
		}
		value, err := vs.activeFormula(ctx, schema, validationProjectionInputRecord(schema, rawRecord, record), field)
		if err != nil {
			return nil, fmt.Errorf("active computed field %s.%s: %w", schema.APIName, field.APIName, err)
		}
		return value, nil
	}
	if field.ProjectionConfig != nil {
		if vs.activeProjection == nil {
			return nil, fmt.Errorf("active projection field %s.%s has no resolver configured", schema.APIName, field.APIName)
		}
		value, err := vs.activeProjection(ctx, schema, validationProjectionInputRecord(schema, rawRecord, record), field)
		if err != nil {
			return nil, fmt.Errorf("active projection field %s.%s: %w", schema.APIName, field.APIName, err)
		}
		return value, nil
	}
	return nil, nil
}

func (vs *ValidationService) logActiveComputedProjectionSummary(
	ctx context.Context,
	objectName string,
	projectionPhase string,
	fieldCount int,
	passCount int,
	elapsed time.Duration,
	fieldTimings []activeComputedProjectionFieldTiming,
) {
	maxFieldElapsed := maxActiveComputedProjectionFieldElapsed(fieldTimings)
	if elapsed < activeComputedProjectionInfoThreshold && maxFieldElapsed < activeComputedProjectionFieldThreshold {
		return
	}
	isWarn := elapsed >= activeComputedProjectionWarnThreshold || maxFieldElapsed >= activeComputedProjectionFieldThreshold
	if !isWarn {
		if v, _ := ctx.Value(constants.ContextKeyPerfTiming).(bool); !v {
			return
		}
	}
	zlog := log.Logger
	event := zlog.Debug()
	if isWarn {
		event = zlog.Warn()
	}
	event.
		Str("event", "validation_timing").
		Str("request_id", validationRequestIDFromCtx(ctx)).
		Str("object", objectName).
		Str("stage", "activeComputedProjectionSummary").
		Str("phase", projectionPhase).
		Int("field_count", fieldCount).
		Int("pass_count", passCount).
		Int64("elapsed_ms", elapsed.Milliseconds()).
		Strs("top_slow_fields", activeComputedProjectionTopFields(fieldTimings, activeComputedProjectionTopFieldLimit)).
		Msg("validation active computed projection slow")
}

func activeComputedProjectionTopFields(fieldTimings []activeComputedProjectionFieldTiming, limit int) []string {
	if len(fieldTimings) == 0 || limit <= 0 {
		return nil
	}
	copied := append([]activeComputedProjectionFieldTiming(nil), fieldTimings...)
	sort.SliceStable(copied, func(i, j int) bool {
		return copied[i].Elapsed > copied[j].Elapsed
	})
	if len(copied) > limit {
		copied = copied[:limit]
	}
	out := make([]string, 0, len(copied))
	for _, timing := range copied {
		out = append(out, fmt.Sprintf("%s:%dms", timing.Name, timing.Elapsed.Milliseconds()))
	}
	return out
}

func maxActiveComputedProjectionFieldElapsed(fieldTimings []activeComputedProjectionFieldTiming) time.Duration {
	var max time.Duration
	for _, timing := range fieldTimings {
		if timing.Elapsed > max {
			max = timing.Elapsed
		}
	}
	return max
}

func activeComputedProjectionFieldName(field *models.FieldMetadata) string {
	if field == nil {
		return ""
	}
	return field.APIName
}

func activeComputedProjectionFieldKind(field *models.FieldMetadata) string {
	if field == nil {
		return ""
	}
	if field.Formula != nil && strings.TrimSpace(*field.Formula) != "" {
		return "formula"
	}
	if field.ProjectionConfig != nil {
		return "projection"
	}
	return "unknown"
}

func validationRequestIDFromCtx(ctx context.Context) string {
	if ctx == nil {
		return ""
	}
	if id, ok := ctx.Value(constants.ContextKeyRequestID).(string); ok {
		return id
	}
	return ""
}

// validationActiveComputedFields returns the set of active computed fields that
// need to be projected for validation. The projection provider supplies a narrow
// closure (only the fields validation rules reference, directly or transitively),
// computing it on a cold-cache miss rather than reporting "absent". An authoritative
// closure — including a genuinely empty one for rules that touch only plain fields —
// is honored exactly. Only when no provider is wired (or it cannot answer) does this
// fall back to evaluating all active computed fields on the schema.
func (vs *ValidationService) validationActiveComputedFields(ctx context.Context, schema *models.ObjectMetadata) []*models.FieldMetadata {
	if schema == nil {
		return nil
	}
	if vs.projectionProvider != nil {
		if closureFields, ok := vs.projectionProvider.ValidationProjectionFieldsOrCompute(ctx, schema.APIName); ok {
			if len(closureFields) == 0 {
				return nil
			}
			closureSet := make(map[string]bool, len(closureFields))
			for _, name := range closureFields {
				closureSet[strings.ToLower(name)] = true
			}
			result := make([]*models.FieldMetadata, 0, len(closureFields))
			for i := range schema.Fields {
				if closureSet[strings.ToLower(schema.Fields[i].APIName)] && formulastorage.IsActiveComputedField(&schema.Fields[i]) {
					result = append(result, &schema.Fields[i])
				}
			}
			return result
		}
	}
	return formulastorage.ActiveComputedFieldPointers(schema)
}

func cloneValidationFormulaMap(in map[string]interface{}) map[string]interface{} {
	if in == nil {
		return nil
	}
	out := make(map[string]interface{}, len(in))
	for k, v := range in {
		out[k] = v
	}
	return out
}

func applyValidationRuleRelationCompatibility(rule *models.ValidationRule, relationName string, related models.SObject) models.SObject {
	if related == nil || !strings.EqualFold(strings.TrimSpace(relationName), constants.ObjectRecordType) {
		return related
	}
	if !validationRuleUsesLegacyRecordTypeDeveloperName(rule) {
		return related
	}
	name, _ := related[constants.FieldName].(string)
	if strings.TrimSpace(name) == "" {
		return related
	}
	related["DeveloperName"] = name
	return related
}

func validationRuleUsesLegacyRecordTypeDeveloperName(rule *models.ValidationRule) bool {
	if rule == nil {
		return false
	}
	if rule.IsCustomized {
		return true
	}
	if rule.OwnerPack != nil && strings.TrimSpace(*rule.OwnerPack) != "" {
		return false
	}
	return true
}

func (vs *ValidationService) validateStateTransitionField(field models.FieldMetadata, val interface{}, oldRecord *models.SObject) error {
	newState, ok := val.(string)
	if !ok || newState == "" {
		return nil
	}
	cfg, err := parseStateTransitionConfig(field.ValidatorConfig)
	if err != nil {
		return err
	}

	if oldRecord == nil {
		if len(cfg.Initial) == 0 || stateListContains(cfg.Initial, newState) {
			return nil
		}
		return fmt.Errorf("invalid initial state transition to %q", newState)
	}

	oldVal, _ := (*oldRecord)[field.APIName]
	oldState, _ := oldVal.(string)
	if oldState == newState {
		return nil
	}
	if oldState == "" {
		return fmt.Errorf("invalid state transition from blank prior state to %q", newState)
	}

	allowed, ok := cfg.Transitions[oldState]
	if !ok || !stateListContains(allowed, newState) {
		return fmt.Errorf("invalid state transition from %q to %q", oldState, newState)
	}
	return nil
}

func (vs *ValidationService) validateRecordReactivationStateTransitions(record models.SObject, schema *models.ObjectMetadata) error {
	if schema == nil {
		return nil
	}
	for _, field := range schema.Fields {
		if field.Validator == nil || *field.Validator != "state_transition" {
			continue
		}
		state, ok := record[field.APIName].(string)
		if !ok || state == "" {
			continue
		}
		cfg, err := parseStateTransitionConfig(field.ValidatorConfig)
		if err != nil {
			return apperrors.NewValidationError(field.APIName, err.Error())
		}
		if !stateTransitionGraphContainsState(cfg, state) {
			return apperrors.NewValidationError(field.APIName, fmt.Sprintf("state %q is not allowed by the current state transition graph", state))
		}
	}
	return nil
}

func stateTransitionGraphContainsState(cfg stateTransitionConfig, want string) bool {
	if _, ok := cfg.Transitions[want]; ok {
		return true
	}
	for _, targets := range cfg.Transitions {
		if stateListContains(targets, want) {
			return true
		}
	}
	return false
}

func parseStateTransitionConfig(raw *string) (stateTransitionConfig, error) {
	if raw == nil || strings.TrimSpace(*raw) == "" {
		return stateTransitionConfig{}, fmt.Errorf("state transition validator is missing configuration")
	}

	var cfg stateTransitionConfig
	if err := json.Unmarshal([]byte(*raw), &cfg); err != nil {
		return stateTransitionConfig{}, fmt.Errorf("state transition validator has invalid configuration")
	}
	if len(cfg.Initial) == 0 {
		return stateTransitionConfig{}, fmt.Errorf("state transition validator requires at least one initial state")
	}
	if len(cfg.Transitions) == 0 {
		return stateTransitionConfig{}, fmt.Errorf("state transition validator requires a transition graph")
	}
	for _, state := range cfg.Initial {
		if strings.TrimSpace(state) == "" {
			return stateTransitionConfig{}, fmt.Errorf("state transition validator contains a blank initial state")
		}
		if _, ok := cfg.Transitions[state]; !ok {
			return stateTransitionConfig{}, fmt.Errorf("state transition validator initial state %q is missing from the transition graph", state)
		}
	}
	for fromState, toStates := range cfg.Transitions {
		if strings.TrimSpace(fromState) == "" {
			return stateTransitionConfig{}, fmt.Errorf("state transition validator contains a blank source state")
		}
		for _, toState := range toStates {
			if strings.TrimSpace(toState) == "" {
				return stateTransitionConfig{}, fmt.Errorf("state transition validator contains a blank target state for %q", fromState)
			}
		}
	}
	return cfg, nil
}

func stateListContains(states []string, want string) bool {
	for _, state := range states {
		if state == want {
			return true
		}
	}
	return false
}

func withFormulaPermissionContext(userContext map[string]interface{}) map[string]interface{} {
	if userContext == nil {
		userContext = map[string]interface{}{}
	}
	if _, ok := userContext[constants.FormulaContextSystemContext]; !ok {
		userContext[constants.FormulaContextSystemContext] = constants.FormulaSystemContextFromContext(nil)
	}
	if _, ok := userContext["__SystemPermissions"]; !ok {
		if perms, ok := userContext[constants.ContextKeySystemPermissions]; ok {
			userContext["__SystemPermissions"] = perms
		} else {
			userContext["__SystemPermissions"] = map[string]bool{}
		}
	}
	if _, ok := userContext["__CustomPermissions"]; !ok {
		if perms, ok := userContext[constants.ContextKeyCustomPermissions]; ok {
			userContext["__CustomPermissions"] = perms
		} else {
			userContext["__CustomPermissions"] = map[string]bool{}
		}
	}
	if _, ok := userContext["__Permission"]; !ok {
		if hasSuperUserFormulaCapability(userContext) {
			// Set __Permission to an empty map. ValidateRecord fills in each
			// $Permission.X key referenced by the rule being evaluated via
			// formula.InjectSuperUserPermissions — no permission name is hardcoded
			// in L0 platform code. Issue #609.
			userContext["__Permission"] = map[string]bool{}
		} else {
			userContext["__Permission"] = mergedFormulaPermissions(
				userContext["__SystemPermissions"],
				userContext["__CustomPermissions"],
			)
		}
	}
	return userContext
}

func hasSuperUserFormulaCapability(userContext map[string]interface{}) bool {
	if userContext == nil {
		return false
	}
	return constants.HasSuperUserCapability(mergedFormulaPermissions(userContext["__SystemPermissions"]))
}

func mergedFormulaPermissions(values ...interface{}) map[string]bool {
	out := map[string]bool{}
	for _, value := range values {
		switch perms := value.(type) {
		case map[string]bool:
			for k, v := range perms {
				out[k] = v
			}
		case map[string]interface{}:
			for k, v := range perms {
				if b, ok := v.(bool); ok {
					out[k] = b
				}
			}
		}
	}
	return out
}

// ValidateObjectMetadata enforces naming conventions
func (vs *ValidationService) ValidateObjectMetadata(obj *models.ObjectMetadata) error {
	if obj == nil {
		return apperrors.NewValidationError(constants.FieldAPIName, "object metadata is required")
	}
	if err := validateCatalogObjectAPIName(obj.APIName); err != nil {
		return apperrors.NewValidationError(constants.FieldAPIName, err.Error())
	}
	return nil
}

// ValidateFieldMetadata enforces naming conventions and, for multi-picklist
// fields, rejects option values containing ';'. Salesforce API v63.0 uses ';'
// as the multi-picklist wire separator; storing an option value that contains
// that separator cannot round-trip through the FromStorageRecord / wire-format
// join path and, under a record-triggered flow that writes the field, has been
// observed to re-open the self-update loop fixed in issue #1683 / PR #1684.
// The guard belongs at the authoring boundary — once the option is persisted,
// records can be created and the loop can start. Closes #1686.
//
// This is the CREATE-strict entry point: the non-formula-Type + non-empty-
// Formula shape is rejected unconditionally. UpdateField callers that are
// revalidating an already-persisted field (whose offending shape may have been
// admitted by the pack apply path before this guard existed) must use
// ValidateFieldMetadataForUpdate, which exempts metadata-only edits to the
// pre-existing legacy shape. See the Codex round-6 BLOCK on PR #964.
func (vs *ValidationService) ValidateFieldMetadata(field *models.FieldMetadata) error {
	return vs.validateFieldMetadata(field, nil)
}

// ValidateFieldMetadataForUpdate runs the same checks as ValidateFieldMetadata
// but, given the field's prior on-disk shape, MAY exempt metadata-only edits
// on a pre-existing legacy PACK-OWNED non-formula-Type + non-empty-Formula
// field (e.g. pack-installed CampaignMember.Title,
// OpportunityLineItem.TotalPrice). The public formula/type contract itself
// still comes from ValidateFormulaExpressionDeclaredType; this update path only
// waives that exact shared-helper rejection when an explicit migration-state
// contract (`OwnerKind=pack` plus non-empty `OwnerPack`) is present and the
// patch is presentation-only. See
// legacyComputedShapePresentationOnlyAllowedFields and
// isLegacyComputedShapeUnchanged below for the protected surface and the
// Codex round-8 motivation (R6's quadruple-equality model leaked
// ReferenceTo / Options / Required / ValidationFormula etc.).
//
// Pass existing=nil to get create-strict behavior (equivalent to
// ValidateFieldMetadata). See the Codex round-6/7/8 BLOCKs on PR #964 for the
// full motivation — UpdateField's post-merge revalidation was hitting
// installed pack fields and blocking otherwise-safe metadata-only PATCHes,
// but the original carve-out admitted structural mutations that the metadata-
// API has no provisioning to back.
func (vs *ValidationService) ValidateFieldMetadataForUpdate(updated, existing *models.FieldMetadata) error {
	return vs.validateFieldMetadata(updated, existing)
}

// validateFieldMetadata is the shared implementation. existing==nil ⇒ create-
// strict. existing!=nil ⇒ update path that may exempt legacy-shape metadata-
// only edits (see ValidateFieldMetadataForUpdate).
func (vs *ValidationService) validateFieldMetadata(field, existing *models.FieldMetadata) error {
	if !isValidIdentifier(field.APIName) {
		return apperrors.NewValidationError(constants.FieldAPIName, fmt.Sprintf("Invalid API name '%s': must be alphanumeric (PascalCase/snake_case)", field.APIName))
	}
	writePolicy := strings.TrimSpace(field.WriteProtectionPolicy)
	existingWritePolicy := ""
	if existing != nil {
		existingWritePolicy = strings.TrimSpace(existing.WriteProtectionPolicy)
	}
	if writePolicy != existingWritePolicy &&
		(writePolicy == constants.FieldWriteProtectionAuthorityOnly ||
			existingWritePolicy == constants.FieldWriteProtectionAuthorityOnly) {
		return apperrors.NewValidationError(
			"writeProtectionPolicy",
			"authority_only is owned by the embedded system-table catalog and cannot transition through generic metadata authoring",
		)
	}
	switch writePolicy {
	case "":
	case constants.FieldWriteProtectionTrustedOnly:
	case constants.FieldWriteProtectionAuthorityOnly:
		if existing == nil || existingWritePolicy != constants.FieldWriteProtectionAuthorityOnly {
			return apperrors.NewValidationError(
				"writeProtectionPolicy",
				"authority_only is owned by the embedded system-table catalog and cannot be introduced through generic metadata authoring",
			)
		}
	case constants.FieldWriteProtectionImmutableAfterCreate:
	default:
		return apperrors.NewValidationError("writeProtectionPolicy", fmt.Sprintf("unsupported write protection policy %q", field.WriteProtectionPolicy))
	}
	switch strings.TrimSpace(field.ReadProtectionPolicy) {
	case "":
	case constants.FieldReadProtectionBlocked:
	default:
		return apperrors.NewValidationError("readProtectionPolicy", fmt.Sprintf("unsupported read protection policy %q", field.ReadProtectionPolicy))
	}
	if len(field.WritableReferenceTo) > 0 {
		if field.Type != constants.FieldTypeReference {
			return apperrors.NewValidationError("writableReferenceTo", "writableReferenceTo is only valid for reference fields")
		}
		for _, writableTarget := range field.WritableReferenceTo {
			target := strings.TrimSpace(writableTarget)
			if target == "" {
				return apperrors.NewValidationError("writableReferenceTo", "writableReferenceTo cannot contain blank targets")
			}
			if !containsStringFold(field.ReferenceTo, target) {
				return apperrors.NewValidationError("writableReferenceTo", fmt.Sprintf("target %q is not declared in referenceTo", target))
			}
		}
	}
	if field.RollupConfig != nil && constants.NormalizeFieldType(string(field.Type)) != constants.FieldTypeRollupSummary {
		return apperrors.NewValidationError("rollupConfig", "rollupConfig requires field type rollupsummary")
	}
	if err := validateSiblingMaxMetadata(field); err != nil {
		return err
	}
	if field.Type == constants.FieldTypeFormula {
		if field.Formula == nil || strings.TrimSpace(*field.Formula) == "" {
			return apperrors.NewValidationError("formula", "Formula fields require a formula expression")
		}
		if field.ReturnType == nil || strings.TrimSpace(string(*field.ReturnType)) == "" {
			return apperrors.NewValidationError("return_type", "Formula fields require a valid return_type")
		}
	}
	if constants.NormalizeFieldType(string(field.Type)) == constants.FieldTypeMultiPicklist {
		for _, opt := range field.Options {
			if strings.Contains(opt.Value, ";") {
				return apperrors.NewValidationError(
					"options",
					fmt.Sprintf("multi-picklist option value %q cannot contain ';' — it is the Salesforce wire-format separator; use a comma or dash instead", opt.Value),
				)
			}
		}
	}
	if field.Validator != nil && *field.Validator == "state_transition" {
		if _, err := parseStateTransitionConfig(field.ValidatorConfig); err != nil {
			return apperrors.NewValidationError(constants.FieldSysField_ValidatorConfig, err.Error())
		}
	}
	if field.Validator != nil && strings.EqualFold(strings.TrimSpace(*field.Validator), "runtime_scoped_lookup") {
		normalizedType := constants.NormalizeFieldType(string(field.Type))
		if normalizedType != constants.FieldTypeReference && normalizedType != constants.FieldTypeMasterDetail {
			return apperrors.NewValidationError(constants.FieldType, "runtime_scoped_lookup is only valid on reference fields")
		}
		cfg, err := models.ParseLookupScopeConfig(field.ValidatorConfig)
		if err != nil {
			return apperrors.NewValidationError(constants.FieldSysField_ValidatorConfig, err.Error())
		}
		for _, mapping := range cfg.FilterMappings {
			if strings.EqualFold(strings.TrimSpace(mapping.RecordField), strings.TrimSpace(mapping.SourceField)) {
				return apperrors.NewValidationError(constants.FieldSysField_ValidatorConfig, "runtime_scoped_lookup currently requires parent-derived mappings; same-record direct mappings are not supported")
			}
		}
	}
	// Canonical-formulaMode gate (Codex round-4 BLOCK on #964): fires for any
	// field that declares a non-empty FormulaMode, regardless of Type and
	// regardless of whether a Formula expression is present. Previously this
	// check lived only inside the non-formula + formula-set branch below, so
	// a formula-typed field could persist FormulaMode "FROZEN" or " frozen "
	// through ValidateFieldMetadata, and save-time compute (formula_compute
	// .go:175/285, persistence_bulk_insert.go:624) — which matches "frozen"
	// exactly — would silently skip it, leaving a physically-created formula
	// column permanently uncomputed. Match the canonical form raw at the
	// authoring boundary so every downstream reader's exact-string compare
	// agrees with what was admitted here.
	if field.FormulaMode != nil {
		mode := *field.FormulaMode
		if mode != "" && mode != "active" && mode != "frozen" {
			return apperrors.NewValidationError(
				field.APIName,
				fmt.Sprintf(
					"field %q has formulaMode %q which is not a canonical value; only \"\", \"active\", or \"frozen\" are accepted (exact lowercase, no surrounding whitespace) because downstream readers (source_pack_ops.go:1186, schema_pack_owned_field.go:597, records/formula_compute.go:175/285, persistence_bulk_insert.go:624) match the canonical form raw",
					field.APIName, mode,
				),
			)
		}
	}
	if field.FormulaStorageMode != nil {
		storageMode := *field.FormulaStorageMode
		if storageMode != "" &&
			storageMode != formulastorage.ModeViewOnly &&
			storageMode != formulastorage.ModeWriteMaterialized {
			return apperrors.NewValidationError(
				field.APIName,
				fmt.Sprintf(
					"field %q has formulaStorageMode %q which is not a canonical value; only \"\", %q, or %q are accepted (exact lowercase, no surrounding whitespace)",
					field.APIName,
					storageMode,
					formulastorage.ModeViewOnly,
					formulastorage.ModeWriteMaterialized,
				),
			)
		}
	}
	// Update-path formula storage gate: PATCH may only edit Formula /
	// FormulaMode / FormulaStorageMode / ReturnType when BOTH the existing row and the merged
	// candidate are active formula-storage fields. Frozen formula storage and
	// legacy nil/blank FormulaMode storage still require backing-column
	// provisioning that UpdateField does not perform, so the validator owns the
	// fail-closed decision before any metadata save or DDL is attempted.
	if existing != nil && isFormulaStorageMutation(field, existing) {
		existingActiveFormulaStorage := existing != nil && existing.Type == constants.FieldTypeFormula && formulastorage.IsActiveComputedField(existing)
		updatedActiveFormulaStorage := field != nil && field.Type == constants.FieldTypeFormula && formulastorage.IsActiveComputedField(field)
		if !existingActiveFormulaStorage || !updatedActiveFormulaStorage {
			return apperrors.NewValidationError(
				field.APIName,
				"editing formula expression, mode, storage mode, or return type for frozen/legacy formula fields is not supported via this path",
			)
		}
	}
	// Codex round-9 BLOCK on #964: structural Formula-mutation pre-check for
	// pre-existing legacy PACK-OWNED active-scalar computed fields
	// (Type != formula, FormulaMode == "active", Formula non-empty —
	// e.g. pack-installed CampaignMember.Title,
	// OpportunityLineItem.TotalPrice). The round-8 reflection-based
	// whitelist below ALREADY blocks Formula mutations when the post-merge
	// state still carries the legacy non-formula-Type + non-empty-Formula
	// shape, because the shape check at the top of the
	// `if field.Formula != nil && ... != "" && field.Type != Formula`
	// clause gates entry to the whitelist call. The hole is the
	// empty-post-merge case: a PATCH that CLEARS Formula on a legacy
	// active-scalar leaves updated.Formula nil/blank, the structural guard
	// short-circuits before the whitelist runs, and the update silently
	// persists metadata that no longer qualifies as active-computed — no
	// DDL or formula-view rebuild, no rollback hook for the runtime view
	// drift, no FormulaViewService substrate teardown. Fail closed on any
	// Formula mutation against that explicitly pack-owned legacy migration
	// shape until UpdateField can provision and rollback the same substrate
	// the pack apply path wires up. This pre-check fires before the
	// non-formula-Type structural guard so the rejection runs regardless of
	// post-merge Formula state.
	formulaTypeErr := ValidateFormulaExpressionDeclaredType("", field.APIName, field.Type, field.Formula)
	if existing != nil && hasLegacyActiveScalarMigrationContract(existing) {
		if !formulaExpressionsEqual(field.Formula, existing.Formula) {
			updatedExpr := ""
			if field.Formula != nil {
				updatedExpr = *field.Formula
			}
			existingExpr := ""
			if existing.Formula != nil {
				existingExpr = *existing.Formula
			}
			return apperrors.NewValidationError(
				field.APIName,
				fmt.Sprintf(
					"field %q is a legacy active-scalar computed field (type %q, formulaMode \"active\", formula %q); mutating the formula expression to %q via UpdateField is rejected because the metadata API has no provisioning to rebuild the FormulaViewService substrate or roll back on failure (the asymmetry called out in pack_field_shapes.go round-4 / validation_service.go round-5 BLOCKs on PR #964 still applies — until the same end-to-end substrate the pack apply path wires up is reachable from UpdateField, any Formula mutation on this shape is rejected, including clears that would silently demote the field to a plain physical column)",
					field.APIName, field.Type, existingExpr, updatedExpr,
				),
			)
		}
		// The legacy carve-out is not a shape-only exemption. Once the existing
		// row is known to be a pack-owned legacy active-scalar computed field,
		// only presentation-only edits may pass. Any structural drift must fail
		// closed here, even when the post-merge field no longer trips the shared
		// formula/type guard.
		if isLegacyComputedShapeUnchanged(field, existing) {
			if formulaTypeErr != nil {
				return nil
			}
		} else {
			return apperrors.NewValidationError(
				field.APIName,
				fmt.Sprintf(
					"field %q is a legacy pack-owned active-scalar computed field; only presentation-only metadata edits (label, help text) are allowed via UpdateField until the migration substrate is retired",
					field.APIName,
				),
			)
		}
	}
	if formulaTypeErr != nil {
		// Type-coherence guard for #958. Strict rejection of non-formula Type
		// + non-empty Formula at the metadata-API boundary, regardless of
		// FormulaMode (blank, "active", or "frozen" all reject here).
		//
		// Asymmetry vs the pack apply path (Codex round-4 BLOCK on #964):
		// pack_field_shapes.go intentionally PERMITS scalar+active computed
		// fields because the pack install pipeline has the end-to-end
		// provisioning to back them — FormulaViewService projects them via
		// v_<object> (formula_view_service.go:1080-1083, isActiveComputedField
		// at :1115), and the install flow wires the view + dependency graph
		// before declaring success. The metadata API does NOT have that
		// provisioning: CreateField/batch DDL only skip physical-column
		// creation for Type==formula, and the synchronous rebuild/rollback
		// path also only keys off Type==formula, so a metadata-API create of
		// a scalar+active computed field would return success while the
		// physical column, view dependency, and rollback hooks all key on
		// the wrong condition — until the async listener catches up,
		// FormulaViewService.ResolveTableName routes reads to v_<object> and
		// fails closed when the view is missing or stale. That turns a
		// pack-only transitional substrate into a broken public metadata-API
		// contract. Until CreateField/batch create/update and the
		// synchronous rebuild/rollback checks adopt the isActiveComputedField
		// contract end-to-end, the metadata API must fail closed.
		//
		// Canonical-formulaMode filtering already happened above, so the only
		// FormulaMode values that reach here are "", "active", or "frozen" —
		// all three reject for non-formula Type.
		return formulaTypeErr
	}
	if existing == nil {
		if err := ValidateFormulaAuthoringContract("", field.APIName, field.Type, field.Formula); err != nil {
			return err
		}
	} else if isFormulaAuthoringMutation(field, existing) {
		if err := ValidateFormulaAuthoringContract("", field.APIName, field.Type, field.Formula); err != nil {
			return err
		}
	}
	if existing != nil && isLegacyActiveScalarComputedShape(existing) && !hasLegacyActiveScalarMigrationContract(existing) {
		if !reflect.DeepEqual(*field, *existing) {
			return apperrors.NewValidationError(
				field.APIName,
				fmt.Sprintf(
					"field %q has a legacy active-scalar computed shape without the pack-ownership migration contract; UpdateField rejects all mutations on this shape",
					field.APIName,
				),
			)
		}
	}
	return nil
}

func validateSiblingMaxMetadata(field *models.FieldMetadata) error {
	if field == nil || field.SiblingMaxParentField == nil {
		return nil
	}
	parentField := strings.TrimSpace(*field.SiblingMaxParentField)
	if parentField == "" {
		return nil
	}
	if !isValidIdentifier(parentField) {
		return apperrors.NewValidationError("siblingMaxParentField", fmt.Sprintf("Invalid API name '%s': must be alphanumeric (PascalCase/snake_case)", parentField))
	}
	if strings.EqualFold(parentField, field.APIName) {
		return apperrors.NewValidationError("siblingMaxParentField", "siblingMaxParentField cannot point to the target field itself")
	}
	if field.DefaultValue != nil {
		return apperrors.NewValidationError("siblingMaxParentField", "siblingMaxParentField cannot be combined with defaultValue")
	}
	if !field.Createable || !field.Updateable {
		return apperrors.NewValidationError("siblingMaxParentField", "siblingMaxParentField requires a createable and updateable numeric target field")
	}
	if field.Formula != nil && strings.TrimSpace(*field.Formula) != "" {
		return apperrors.NewValidationError("siblingMaxParentField", "siblingMaxParentField requires a writable numeric target field")
	}
	if field.RollupConfig != nil ||
		constants.NormalizeFieldType(string(field.Type)) == constants.FieldTypeFormula ||
		constants.NormalizeFieldType(string(field.Type)) == constants.FieldTypeRollupSummary ||
		constants.NormalizeFieldType(string(field.Type)) == constants.FieldTypeAutonumber {
		return apperrors.NewValidationError("siblingMaxParentField", "siblingMaxParentField requires a writable numeric target field")
	}
	if !siblingMaxMetadataTargetTypeAllowed(field) {
		return apperrors.NewValidationError("siblingMaxParentField", fmt.Sprintf("siblingMaxParentField requires an integer-compatible numeric target field, got %q", field.Type))
	}
	return nil
}

func siblingMaxMetadataTargetTypeAllowed(field *models.FieldMetadata) bool {
	if field == nil {
		return false
	}
	switch constants.NormalizeFieldType(string(field.Type)) {
	case "integer", "int":
		return true
	default:
		return false
	}
}

// legacyComputedShapePresentationOnlyAllowedFields is the AUTHORITATIVE
// whitelist of models.FieldMetadata struct-field names that an UpdateField
// PATCH is allowed to mutate on a pre-existing legacy non-formula-Type +
// non-empty-Formula field (e.g. pack-installed CampaignMember.Title,
// OpportunityLineItem.TotalPrice). Anything NOT listed here must be
// byte-identical between updated and existing for the legacy-shape exemption
// to fire; if any non-whitelisted field differs, the exemption is dropped and
// the create-strict rejection applies.
//
// The whitelist is intentionally narrow — display-only facets that have no
// physical-schema, validation, security, polymorphic-target, or pack-ownership
// consequence. Adding a field here REQUIRES an explicit threat-model review:
// the metadata API's create-path provisioning is keyed on Type==formula and
// does NOT run DDL / rebuild / rollback for any other PATCH facet on a legacy
// active-scalar computed field, so e.g. changing ReferenceTo here would let a
// pack-installed active computed reference field silently reinterpret its
// computed IDs against a different target object with no DDL/rebuild/rollback.
//
// Codex round-8 BLOCK on PR #964 enumerated the leak surface that the prior
// quadruple-equality model (Type/FormulaMode/Formula/ReturnType) admitted by
// omission — ReferenceTo, WritableReferenceTo, Options, Required, IsUnique,
// Validator*, ChildRelationshipName, MasterDetail*, DefaultValue*, MinValue,
// MaxValue, MaxLength, Precision, Scale, OwnerPack and others. The fix is to
// flip the model from "block a small protected quadruple" to "allow only a
// small presentation-only whitelist". Do NOT widen this set without naming
// the new threat model the addition is safe under.
var legacyComputedShapePresentationOnlyAllowedFields = map[string]struct{}{
	"Label":    {}, // display label (json:"label")
	"HelpText": {}, // inline help (json:"inlineHelpText"); pointer
}

// isLegacyActiveScalarComputedShape reports true for the raw legacy shape that
// can exist on already-installed rows regardless of ownership:
// Type!=formula, FormulaMode="active", and Formula non-empty.
func isLegacyActiveScalarComputedShape(existing *models.FieldMetadata) bool {
	if existing == nil {
		return false
	}
	if existing.Type == constants.FieldTypeFormula {
		return false
	}
	if existing.FormulaMode == nil || *existing.FormulaMode != "active" {
		return false
	}
	if existing.Formula == nil || strings.TrimSpace(*existing.Formula) == "" {
		return false
	}
	return true
}

// hasLegacyActiveScalarMigrationContract reports true when existing carries the
// explicit migration-state contract for the pack-installed legacy
// active-scalar computed shape: OwnerKind=pack, OwnerPack non-empty, plus the
// raw active-scalar legacy shape from isLegacyActiveScalarComputedShape (e.g.
// CampaignMember.Title with Type=string). This is the single fact that lets
// ValidateFieldMetadataForUpdate grandfather metadata-only edits while still
// delegating the public formula/type contract to
// ValidateFormulaExpressionDeclaredType. Used by the Codex round-9
// Formula-mutation pre-check and the presentation-only whitelist path.
//
// Mode comparison is exact-lowercase to match the canonical-formulaMode gate
// the same prompt admitted upstream — non-canonical spellings are filtered
// before they can reach here, so the equality check is byte-precise.
func hasLegacyActiveScalarMigrationContract(existing *models.FieldMetadata) bool {
	if !isLegacyActiveScalarComputedShape(existing) {
		return false
	}
	ownerPack := ""
	if existing.OwnerPack != nil {
		ownerPack = strings.TrimSpace(*existing.OwnerPack)
	}
	if !strings.EqualFold(strings.TrimSpace(existing.OwnerKind), constants.FieldOwnerKindPack) || ownerPack == "" {
		return false
	}
	return true
}

// formulaExpressionsEqual reports true when two *string formula expressions
// hold byte-identical content after TrimSpace normalization. nil and a
// pointer to "" or whitespace are treated as equal (both represent "no
// expression"); any actual content difference, including a clear (existing
// non-empty → updated nil/blank), is treated as a mutation.
func formulaExpressionsEqual(a, b *string) bool {
	deref := func(p *string) string {
		if p == nil {
			return ""
		}
		return strings.TrimSpace(*p)
	}
	return deref(a) == deref(b)
}

func isFormulaStorageMutation(updated, existing *models.FieldMetadata) bool {
	if updated == nil || existing == nil {
		return false
	}
	return !formulaExpressionsEqual(updated.Formula, existing.Formula) ||
		!reflect.DeepEqual(updated.FormulaMode, existing.FormulaMode) ||
		!reflect.DeepEqual(updated.FormulaStorageMode, existing.FormulaStorageMode) ||
		!reflect.DeepEqual(updated.ReturnType, existing.ReturnType)
}

func isFormulaAuthoringMutation(updated, existing *models.FieldMetadata) bool {
	if updated == nil || existing == nil {
		return false
	}
	return updated.Type != existing.Type || isFormulaStorageMutation(updated, existing)
}

// isLegacyComputedShapeUnchanged reports true when existing already carried
// the explicit pack-owned legacy active-scalar migration shape that the
// create-strict guard rejects AND every field of the updated
// *models.FieldMetadata struct that is NOT in
// legacyComputedShapePresentationOnlyAllowedFields is reflect.DeepEqual to
// its counterpart in existing.
//
// Approach (Codex round-8): walk the struct with reflection rather than
// enumerating a protected sub-tuple. This inverts the prior model so the
// exemption defaults to REJECT on any non-whitelisted divergence — Type,
// FormulaMode, Formula, ReturnType remain protected (none of them appear in
// the whitelist), and so do ReferenceTo / Options / Required / Validator* /
// MasterDetail* / DefaultValue* / MinValue / MaxValue / MaxLength /
// Precision / Scale / OwnerPack / ChildRelationshipName /
// WriteProtectionPolicy / ReadProtectionPolicy and every other current and
// future FieldMetadata field. The check is
// defense-in-depth: field_update.go's earlier guards already reject many of
// these mutations, but the carve-out must not silently widen the gate.
//
// existing==nil ⇒ create path, exemption does not apply.
func isLegacyComputedShapeUnchanged(updated, existing *models.FieldMetadata) bool {
	if updated == nil || existing == nil {
		return false
	}
	// Existing field must already carry the explicit migration-state shape
	// the carve-out protects. Shape-only matches do not qualify.
	if !hasLegacyActiveScalarMigrationContract(existing) {
		return false
	}
	updatedVal := reflect.ValueOf(updated).Elem()
	existingVal := reflect.ValueOf(existing).Elem()
	structType := updatedVal.Type()
	for i := 0; i < structType.NumField(); i++ {
		structField := structType.Field(i)
		if !structField.IsExported() {
			continue
		}
		if _, allowed := legacyComputedShapePresentationOnlyAllowedFields[structField.Name]; allowed {
			continue
		}
		if !reflect.DeepEqual(updatedVal.Field(i).Interface(), existingVal.Field(i).Interface()) {
			return false
		}
	}
	return true
}

func containsStringFold(values []string, target string) bool {
	for _, value := range values {
		if strings.EqualFold(strings.TrimSpace(value), target) {
			return true
		}
	}
	return false
}

func isValidIdentifier(s string) bool {
	validName := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
	return validName.MatchString(s)
}

func validationRelationDeclared(schema *models.ObjectMetadata, relationName string) bool {
	if schema == nil || strings.TrimSpace(relationName) == "" {
		return false
	}
	for i := range schema.Fields {
		field := &schema.Fields[i]
		if field.RelationshipName != nil && *field.RelationshipName == relationName {
			return true
		}
		if strings.HasSuffix(relationName, "__r") && field.APIName == strings.TrimSuffix(relationName, "__r")+"__c" {
			return true
		}
		if field.APIName == relationName+"Id" {
			return true
		}
	}
	return false
}

func validationKnownFormulaRoot(schema *models.ObjectMetadata, relationName string) bool {
	return validationFormulaGlobalRoot(relationName) || validationRelationDeclared(schema, relationName)
}

func validationFormulaGlobalRoot(relationName string) bool {
	return formula.IsSessionFormulaGlobalRoot(relationName)
}

func (vs *ValidationService) isSalesforceImportArtifact(ident string) bool {
	if vs != nil && vs.formula != nil {
		return isSalesforceImportArtifactWithFormulaGap(ident, vs.formula.IsKnownUnsupportedSalesforceImportFunction)
	}
	return isSalesforceImportArtifact(ident)
}

func (vs *ValidationService) isSalesforceImportArtifactWithEngine(ident string, engine *formula.Engine) bool {
	if engine != nil {
		return isSalesforceImportArtifactWithFormulaGap(ident, engine.IsKnownUnsupportedSalesforceImportFunction)
	}
	return vs.isSalesforceImportArtifact(ident)
}

// isSalesforceImportArtifact returns true if the identifier is a known
// Salesforce-specific construct not supported by NexusCRM.
func isSalesforceImportArtifact(ident string) bool {
	return isSalesforceImportArtifactWithFormulaGap(ident, formula.IsKnownUnsupportedSalesforceImportFunction)
}

func isSalesforceImportArtifactWithFormulaGap(ident string, isFormulaGap func(string) bool) bool {
	if isFormulaGap != nil && isFormulaGap(ident) {
		return true
	}
	// Standard Salesforce custom fields (e.g. Partner_Margin__c) that don't exist
	// in NexusCRM yet. These were imported with validation rules but the corresponding
	// fields haven't been created.
	if strings.HasSuffix(ident, "__c") {
		return true
	}
	// Salesforce namespace-prefixed fields (e.g. awsapn__APN_CRM_Id__c, Elvance__Elvance_Owner__c)
	if strings.Contains(ident, "__") && strings.Count(ident, "__") >= 2 {
		return true
	}
	return false
}
