package formula

import (
	"fmt"
	"strconv"
	"strings"
	"time"

	"github.com/expr-lang/expr/ast"
	"github.com/expr-lang/expr/parser"

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

// ValidateLockRuleExpression validates expr for use as a LockRule at authoring
// time using a schema-aware structural strategy:
//
//  1. Syntax + function check: engine.ValidateSyntax rejects parse errors and
//     unknown function names (VLOOKUP, REGEX, and any other unimplemented
//     Salesforce function). This is the same gate the formula verifier uses
//     for frozen formulas.
//
//  2. Structural walk: walks the AST and rejects expressions that reference
//     session globals or relationship paths that the LockRule runtime gate
//     cannot execute:
//     — $Permission.* (normalizes to __Permission.*): Permission context is
//     never injected into the lock gate's formula context. An expression
//     referencing $Permission would always fail at runtime (fail-closed),
//     but the admin who authored it would have no authoring-time feedback.
//     Reject at save time with a clear message.
//     — Relationship traversal paths (identifiers ending in __r or
//     containing __r. in dotted paths, e.g. Parent__r.Status): the lock
//     gate's Evaluate call has no Fetcher; cross-object lookups always
//     return ErrRelationFetch. Reject at save time.
//     — Any other $ global root other than $User, $Profile, $UserRole
//     (e.g. $Setup, $Label, $CustomMetadata): not injected into the lock
//     gate context. Reject at save time.
//
//  3. Schema-aware type check: uses the object's field schema augmented with
//     synthetic pseudo-fields for the three supported session globals
//     (__User.*, __Profile.*, __UserRole.*) to prove the expression returns
//     a boolean and that every field reference exists on the object.
//     — $User.Id / $User.Name / $User.Email / $User.ProfileId / $User.UserRoleId
//     — $Profile.Id / $Profile.Name
//     — $UserRole.Id / $UserRole.Name
//     These pseudo-fields are derived from BuildFormulaUserContext's documented
//     output shape (user_context.go).
//
// objectSchema is the metadata for the object being saved. It must be
// non-nil for field-existence validation to run; a nil schema is rejected
// with a clear error.
//
// engine must be non-nil.
func ValidateLockRuleExpression(exprStr string, engine *Engine, objectSchema *models.ObjectMetadata) error {
	if strings.TrimSpace(exprStr) == "" {
		return nil
	}
	if engine == nil {
		return fmt.Errorf("LockRule validation requires a formula engine")
	}
	if objectSchema == nil {
		return fmt.Errorf("LockRule validation requires object schema to prove field existence")
	}

	// Reject internal lowering artifacts in raw author input before normalization.
	// __NCRM_FIELDUNCHANGED__ is a registered catalog function (ISCHANGED lowering),
	// so validateFunctionCallsForLockRule (unknown-function check only) would accept
	// an authored __NCRM_* helper. Mirror ValidateFilterFormulaExpression's guard.
	if err := rejectInternalLoweringArtifacts(exprStr); err != nil {
		return fmt.Errorf("LockRule expression: %w", err)
	}

	// Tier 1: syntax + registered-function check.
	// Rejects parse errors (unmatched parens, dangling operators) and unregistered
	// function names (VLOOKUP, REGEX, any non-platform function).
	// Uses validateFunctionCallsForLockRuleWithRegistry instead of engine.ValidateSyntax so
	// the three supported session globals ($User/$Profile/$UserRole, normalised to
	// __User/__Profile/__UserRole) are not rejected by the generic frozen-formula
	// global allowlist (which only permits __Record).
	normalized := engine.normalizeFormulaExpression(exprStr)
	if err := validateFunctionCallsForLockRuleWithRegistry(normalized, engine.functionRegistry); err != nil {
		return fmt.Errorf("LockRule expression: %w", err)
	}

	// Tier 2: structural AST walk — reject unsupported globals and __r traversal.
	// `normalized` is already set from the Tier 1 check above.
	tree, err := parser.Parse(normalized)
	if err != nil {
		return fmt.Errorf("LockRule expression: %w", err)
	}
	if err := validateLockRuleASTNodes(tree.Node); err != nil {
		return err
	}

	// Tier 3: schema-aware boolean type check.
	// Build an augmented schema that adds reference-type pseudo-fields for the
	// session globals the lock gate actually injects (__User, __Profile,
	// __UserRole via BuildFormulaUserContext), plus synthetic object schemas for
	// those globals so the schema walker can resolve __Profile.Name etc.
	// The augmented schema is a shallow copy of the object schema so the
	// original is not mutated.
	//
	// Uses engine.lockRuleSchemaTypeCheck instead of engine.validateSyntaxWithSchema
	// because validateSyntaxWithSchema calls engine.ValidateSyntax internally,
	// which rejects supported session globals (__User/__Profile/__UserRole) via
	// the generic frozen-formula allowlist. We already did the syntax+function
	// check in Tier 1 with the lock-rule-specific validator, so Tier 3 only needs
	// the AST type inference pass.
	augmented := lockRuleAugmentedSchema(objectSchema)
	sessionSchemas := lockRuleSessionGlobalObjects()
	boolType := models.FieldType(constants.FieldTypeBoolean)
	if err := engine.lockRuleSchemaTypeCheck(exprStr, augmented, sessionSchemas, boolType); err != nil {
		return fmt.Errorf("LockRule expression: %w", err)
	}
	return nil
}

// lockRuleSchemaTypeCheck is the Tier-3 schema-aware type check for
// ValidateLockRuleExpression. It is like validateSyntaxWithSchema but
// omits the ValidateSyntax call (which would reject __User/__Profile/__UserRole
// via the frozen-formula global allowlist). The caller has already done the
// syntax and function check in Tier 1 via validateFunctionCallsForLockRuleWithRegistry.
func (e *Engine) lockRuleSchemaTypeCheck(expression string, schema *models.ObjectMetadata, schemasByObjectAPIName map[string]*models.ObjectMetadata, expectedType models.FieldType) error {
	if schema == nil {
		return nil
	}
	normalized := e.normalizeFormulaExpression(expression)
	tree, err := parser.Parse(normalized)
	if err != nil {
		return err
	}
	ctx := newSchemaValidationContextWithRegistry(schema, schemasByObjectAPIName, e.functionRegistry)
	exprType, err := inferSchemaFormulaType(tree.Node, ctx, schema)
	if err != nil {
		return err
	}
	expected := schemaTypeFromFieldType(expectedType)
	if expected == schemaTypeUnknown {
		return fmt.Errorf("formula target field type %q is not supported by schema-aware frozen validation", expectedType)
	}
	if !schemaResultTypeCompatible(exprType, expected) {
		return fmt.Errorf("formula result type %s is incompatible with expected target type %s", exprType, expected)
	}
	return nil
}

// lockRuleSessionGlobalObjects returns synthetic object schemas for the three
// session globals that BuildFormulaUserContext injects into the lock gate's
// formula context. These are used by lockRuleSchemasByObjectAPIName to
// satisfy the schema walker when it resolves __Profile.Name etc.
//
// The synthetic object API names match the engine-normalised form (__ prefix
// after $X → __X rewrite) so the schema walker looks them up correctly.
func lockRuleSessionGlobalObjects() map[string]*models.ObjectMetadata {
	return map[string]*models.ObjectMetadata{
		// __User (normalised from $User)
		"__User": {
			APIName: "__User",
			Fields: []models.FieldMetadata{
				{APIName: "Id", Type: constants.FieldTypeString},
				{APIName: "ID", Type: constants.FieldTypeString},
				{APIName: "Name", Type: constants.FieldTypeString},
				{APIName: "Email", Type: constants.FieldTypeString},
				{APIName: "ProfileId", Type: constants.FieldTypeString},
				{APIName: "UserRoleId", Type: constants.FieldTypeString},
			},
		},
		// __Profile (normalised from $Profile)
		"__Profile": {
			APIName: "__Profile",
			Fields: []models.FieldMetadata{
				{APIName: "Id", Type: constants.FieldTypeString},
				{APIName: "Name", Type: constants.FieldTypeString},
			},
		},
		// __UserRole (normalised from $UserRole)
		"__UserRole": {
			APIName: "__UserRole",
			Fields: []models.FieldMetadata{
				{APIName: "Id", Type: constants.FieldTypeString},
				{APIName: "Name", Type: constants.FieldTypeString},
			},
		},
	}
}

// lockRuleAugmentedSchema returns a shallow copy of objectSchema that includes
// synthetic reference-type fields for the three session globals the lock gate
// supports. These synthetic fields let the schema walker resolve dotted paths
// like __Profile.Name through inferSchemaRelationshipPathType.
//
// The synthetic fields carry:
//   - APIName: "<GlobalId>Id" (e.g. "__ProfileId") — tier-3 FK candidate that
//     ResolveRelationFK will find when looking up the relationship name
//   - RelationshipName: "<Global>" (e.g. "__Profile") — tier-1 match in
//     ResolveRelationFK so the walker resolves "__Profile" → "__ProfileId" → field
//   - ReferenceTo: ["<Global>"] — points to the synthetic object schema returned
//     by lockRuleSessionGlobalObjects, which must be passed as schemasByObjectAPIName
//
// The relationship names use the engine-normalised form (__X, not $X) because
// normalizeFormulaExpression rewrites $Profile → __Profile before schema
// validation runs.
//
// IMPORTANT: all reference-type fields copied from base have their
// RelationshipName cleared. This prevents resolveSchemaRelationRoot from
// resolving ordinary relationship names (e.g. "Parent" → ParentId → target
// schema → Parent.Name accepted). The LockRule runtime gate has no Fetcher,
// so any ordinary cross-object traversal would fail at runtime. We block it
// here at save time instead.
func lockRuleAugmentedSchema(base *models.ObjectMetadata) *models.ObjectMetadata {
	userRel := "__User"
	profileRel := "__Profile"
	userRoleRel := "__UserRole"
	sessionFields := []models.FieldMetadata{
		{
			APIName:          "__UserId",
			RelationshipName: &userRel,
			Type:             constants.FieldTypeReference,
			ReferenceTo:      []string{"__User"},
		},
		{
			APIName:          "__ProfileId",
			RelationshipName: &profileRel,
			Type:             constants.FieldTypeReference,
			ReferenceTo:      []string{"__Profile"},
		},
		{
			APIName:          "__UserRoleId",
			RelationshipName: &userRoleRel,
			Type:             constants.FieldTypeReference,
			ReferenceTo:      []string{"__UserRole"},
		},
	}
	// Copy base fields, neutralising all reference-type fields so that
	// resolveSchemaRelationRoot cannot resolve ordinary relationship segments
	// (e.g. "Parent") through any tier of ResolveRelationFK:
	//   - Tier 1: RelationshipName cleared → no metadata-driven match.
	//   - Tier 3: ReferenceTo cleared AND type changed to string → the
	//     combined condition (len(ReferenceTo)>0 || type==reference) is false.
	//
	// Changing the type to string is correct for a direct FK access
	// (e.g. "OwnerId == 'x'"): Salesforce ID fields are strings, and
	// schemaTypeFromFieldType already maps reference to schemaTypeString.
	// A direct FK comparison therefore still type-checks correctly.
	mergedBase := make([]models.FieldMetadata, len(base.Fields))
	for i, f := range base.Fields {
		if constants.IsReferenceType(constants.NormalizeFieldType(string(f.Type))) {
			f.RelationshipName = nil
			f.ReferenceTo = nil
			f.Type = constants.FieldTypeString
		}
		mergedBase[i] = f
	}
	merged := make([]models.FieldMetadata, 0, len(mergedBase)+len(sessionFields))
	merged = append(merged, mergedBase...)
	merged = append(merged, sessionFields...)
	return &models.ObjectMetadata{
		ID:      base.ID,
		APIName: base.APIName,
		Label:   base.Label,
		Fields:  merged,
	}
}

// lockRuleAllowedGlobals is the set of normalised (__ prefix) session global
// roots that the LockRule runtime gate actually injects via BuildFormulaUserContext.
// These roots are allowed through Tier 1 (function call validation) so they are
// not rejected by the generic frozen-formula allowlist (which only allows __Record).
var lockRuleAllowedGlobals = map[string]struct{}{
	"__User":     {},
	"__Profile":  {},
	"__UserRole": {},
	"__Record":   {},
}

func validateFunctionCallsForLockRuleWithRegistry(normalizedExpr string, registry *spec.Registry) error {
	tree, err := parser.Parse(normalizedExpr)
	if err != nil {
		return err
	}
	finding := &formulaWalkFinding{registry: registry}
	if err := walkFormulaASTForLockRule(tree.Node, finding); err != nil {
		return err
	}
	// Filter out the supported session globals from the unsupported list —
	// the standard walkFormulaASTInto marks them as unsupported because the
	// frozen formula field context only injects __Record, but the lock rule
	// gate injects __User, __Profile, and __UserRole as well.
	filtered := finding.unsupportedGlobals[:0]
	for _, g := range finding.unsupportedGlobals {
		if _, ok := lockRuleAllowedGlobals[g]; !ok {
			filtered = append(filtered, g)
		}
	}
	finding.unsupportedGlobals = filtered
	if finding.isEmpty() {
		return nil
	}
	return finding.toError()
}

// walkFormulaASTForLockRule is identical to walkFormulaASTInto but records
// ALL __X identifiers as unsupported globals (including the lock-rule-supported
// ones) so validateFunctionCallsForLockRule can filter them afterwards. This
// avoids duplicating the full AST walk logic.
func walkFormulaASTForLockRule(n ast.Node, finding *formulaWalkFinding) error {
	return walkFormulaASTInto(n, finding)
}

// validateLockRuleASTNodes walks the normalised expression AST and rejects
// identifiers / paths that the LockRule runtime gate cannot execute:
//   - __Permission.* paths: Permission context is never injected.
//   - Unsupported session global roots other than __User, __Profile, __UserRole.
//   - Relationship traversal identifiers (bare roots ending in __r, e.g.
//     Parent__r used as the start of a dotted path). The lock gate's Evaluate
//     call has no Fetcher; cross-object lookups always fail.
func validateLockRuleASTNodes(n ast.Node) error {
	if n == nil {
		return nil
	}
	// Check identifier / dotted-path roots.
	if path := memberPath(n); len(path) > 0 {
		root := path[0]
		// Reject __Permission.* — unsupported global in lock gate context.
		if root == "__Permission" {
			return fmt.Errorf("LockRule expression references $Permission which is not available in the lock rule context; use a boolean record field set by a flow instead")
		}
		// Reject any other unsupported __ global (not __User, __Profile, __UserRole, __Record).
		if strings.HasPrefix(root, "__") &&
			root != "__User" &&
			root != "__Profile" &&
			root != "__UserRole" &&
			root != "__Record" {
			return fmt.Errorf("LockRule expression references unsupported session global %q (only $User, $Profile, $UserRole are available in the lock rule context)", strings.TrimPrefix(root, "__"))
		}
		// Reject relationship traversal: identifiers ending in __r used as path
		// roots signal cross-object lookups that the lock gate cannot perform.
		if strings.HasSuffix(root, "__r") {
			return fmt.Errorf("LockRule expression references relationship path %q which requires a cross-object fetch that is not available in the lock rule context", root)
		}
		// Also scan deeper path segments for __r traversal (e.g. Record__r inside a path).
		for i := 1; i < len(path); i++ {
			if strings.HasSuffix(path[i], "__r") {
				return fmt.Errorf("LockRule expression references relationship traversal at %q which is not available in the lock rule context", strings.Join(path[:i+1], "."))
			}
		}
		return nil // path-based node handled; children are path components not sub-nodes
	}

	// Recurse into sub-expressions.
	switch v := n.(type) {
	case *ast.UnaryNode:
		return validateLockRuleASTNodes(v.Node)
	case *ast.BinaryNode:
		if err := validateLockRuleASTNodes(v.Left); err != nil {
			return err
		}
		return validateLockRuleASTNodes(v.Right)
	case *ast.CallNode:
		if err := validateLockRuleASTNodes(v.Callee); err != nil {
			return err
		}
		for i := range v.Arguments {
			if err := validateLockRuleASTNodes(v.Arguments[i]); err != nil {
				return err
			}
		}
	case *ast.ConditionalNode:
		if err := validateLockRuleASTNodes(v.Cond); err != nil {
			return err
		}
		if err := validateLockRuleASTNodes(v.Exp1); err != nil {
			return err
		}
		return validateLockRuleASTNodes(v.Exp2)
	case *ast.ChainNode:
		return validateLockRuleASTNodes(v.Node)
	case *ast.BuiltinNode:
		for i := range v.Arguments {
			if err := validateLockRuleASTNodes(v.Arguments[i]); err != nil {
				return err
			}
		}
	case *ast.ArrayNode:
		for i := range v.Nodes {
			if err := validateLockRuleASTNodes(v.Nodes[i]); err != nil {
				return err
			}
		}
	case *ast.SequenceNode:
		for i := range v.Nodes {
			if err := validateLockRuleASTNodes(v.Nodes[i]); err != nil {
				return err
			}
		}
	// Leaf nodes (literals, nil, bool, identifier without dot) — no children to walk.
	case *ast.IdentifierNode, *ast.IntegerNode, *ast.FloatNode, *ast.BoolNode,
		*ast.StringNode, *ast.NilNode, *ast.ConstantNode, *ast.PointerNode:
		// Check bare identifiers that are unsupported globals.
		if id, ok := n.(*ast.IdentifierNode); ok {
			if id.Value == "__Permission" {
				return fmt.Errorf("LockRule expression references $Permission which is not available in the lock rule context")
			}
			if strings.HasPrefix(id.Value, "__") &&
				id.Value != "__User" &&
				id.Value != "__Profile" &&
				id.Value != "__UserRole" &&
				id.Value != "__Record" {
				return fmt.Errorf("LockRule expression references unsupported session global %q", strings.TrimPrefix(id.Value, "__"))
			}
			if strings.HasSuffix(id.Value, "__r") {
				return fmt.Errorf("LockRule expression references relationship identifier %q which requires a cross-object fetch not available in the lock rule context", id.Value)
			}
		}
	}
	return nil
}

type schemaFormulaType string

const (
	schemaTypeAny      schemaFormulaType = "any"
	schemaTypeUnknown  schemaFormulaType = "unknown"
	schemaTypeNil      schemaFormulaType = "nil"
	schemaTypeString   schemaFormulaType = "string"
	schemaTypeNumber   schemaFormulaType = "number"
	schemaTypeBoolean  schemaFormulaType = "boolean"
	schemaTypeDate     schemaFormulaType = "date"
	schemaTypeDatetime schemaFormulaType = "datetime"
	schemaTypeObject   schemaFormulaType = "object"
)

// ExpressionVariableType describes an authoring-scope variable that can be
// referenced by a formula expression outside the record schema itself.
type ExpressionVariableType struct {
	Type          spec.ValueType
	Collection    bool
	Dynamic       bool
	ObjectAPIName string
}

// ValidateSyntaxWithSchema extends ValidateSyntax with checks that are only
// sound when the caller has the current object's ObjectMetadata. Nil schema
// deliberately keeps the legacy syntax-only tolerance used by metadata-free
// callers.
func (e *Engine) ValidateSyntaxWithSchema(expression string, schema *models.ObjectMetadata) error {
	return e.validateSyntaxWithSchema(expression, schema, nil, nil)
}

// ValidateBooleanSyntaxWithSchema validates the expression syntax and additionally
// enforces that the expression returns a boolean value. Use this for authoring
// gates such as LockRule where a non-boolean result would cause
// runtime fail-closed behaviour (denying all edits/deletes).
//
// Only record-field-only expressions are accepted at authoring time. Session
// globals ($Profile, $User, $UserRole, $Permission) are rejected at save time
// with a clear error. This prevents the fail-closed runtime trap where
// $Permission.X passes save-time validation but fails at runtime (Permission
// context is never injected), and where $Profile.Name (string) passes the
// boolean type check but evaluates to string at runtime. Admins who need
// per-profile lock conditions should use a boolean record field that is stamped
// by a flow or trigger.
func (e *Engine) ValidateBooleanSyntaxWithSchema(expression string, schema *models.ObjectMetadata) error {
	boolType := models.FieldType(constants.FieldTypeBoolean)
	return e.validateSyntaxWithSchema(expression, schema, nil, &boolType)
}

func (e *Engine) validateSyntaxWithSchema(expression string, schema *models.ObjectMetadata, schemasByObjectAPIName map[string]*models.ObjectMetadata, expectedType *models.FieldType) error {
	if err := e.ValidateSyntax(expression); err != nil {
		return err
	}
	if schema == nil {
		return nil
	}
	normalized := e.normalizeFormulaExpression(expression)
	tree, err := parser.Parse(normalized)
	if err != nil {
		return err
	}
	ctx := newSchemaValidationContextWithRegistry(schema, schemasByObjectAPIName, e.functionRegistry)
	exprType, err := inferSchemaFormulaType(tree.Node, ctx, schema)
	if err != nil {
		return err
	}
	if expectedType != nil {
		expected := schemaTypeFromFieldType(*expectedType)
		if expected == schemaTypeUnknown {
			return fmt.Errorf("formula target field type %q is not supported by schema-aware frozen validation", *expectedType)
		}
		if !schemaResultTypeCompatible(exprType, expected) {
			return fmt.Errorf("formula result type %s is incompatible with expected target type %s", exprType, expected)
		}
	}
	return err
}

// InferFormulaExpressionTypeWithSchemas infers the result type of a Salesforce
// formula expression using the engine's function registry, the current record
// schema, optional cross-object schemas, and optional Flow/resource variables.
//
// The Flow formula caller supplies the same effective metadata snapshot used by
// pack verification, so exact schema misses and undeclared roots are authoring
// errors. Declared runtime roots may be marked Dynamic when their value can be
// populated later and re-read during Wait/resume formula evaluation.
func (e *Engine) InferFormulaExpressionTypeWithSchemas(expression string, schema *models.ObjectMetadata, schemasByObjectAPIName map[string]*models.ObjectMetadata, variables map[string]ExpressionVariableType) (spec.ValueType, error) {
	if strings.TrimSpace(expression) == "" {
		return spec.TypeNil, nil
	}
	if err := rejectInternalLoweringArtifacts(expression); err != nil {
		return spec.TypeUnknown, err
	}
	normalized := e.normalizeFormulaExpression(expression)
	if err := validateFunctionCallsForFlowFormulaWithRegistry(normalized, e.functionRegistry); err != nil {
		return spec.TypeUnknown, err
	}
	tree, err := parser.Parse(normalized)
	if err != nil {
		return spec.TypeUnknown, err
	}
	if err := validateFlowFormulaASTNodes(tree.Node); err != nil {
		return spec.TypeUnknown, err
	}

	effectiveSchema := schema
	if effectiveSchema == nil {
		effectiveSchema = &models.ObjectMetadata{APIName: "<object-independent>"}
	}
	augmented := flowFormulaAugmentedSchema(effectiveSchema)
	mergedSchemas := flowFormulaContextObjects()
	if len(schemasByObjectAPIName) > 0 {
		mergedSchemas = make(map[string]*models.ObjectMetadata, len(mergedSchemas)+len(schemasByObjectAPIName))
		for k, v := range flowFormulaContextObjects() {
			mergedSchemas[k] = v
		}
		for k, v := range schemasByObjectAPIName {
			if _, exists := mergedSchemas[k]; !exists {
				mergedSchemas[k] = v
			}
		}
	}
	ctx := newSchemaValidationContextWithRegistry(augmented, mergedSchemas, e.functionRegistry)
	ctx = ctx.withExpressionVariables(variables)
	ctx.allowPolymorphicRelationship = true

	exprType, err := inferSchemaFormulaType(tree.Node, ctx, augmented)
	if err != nil {
		return spec.TypeUnknown, err
	}
	return specValueTypeFromSchemaType(exprType), nil
}

func flowFormulaContextObjects() map[string]*models.ObjectMetadata {
	out := filterFormulaSessionGlobalObjects()
	out[constants.FormulaContextSystemContext] = &models.ObjectMetadata{
		APIName: constants.FormulaContextSystemContext,
		Fields: []models.FieldMetadata{
			{APIName: constants.FormulaSystemContextIsDerivedRecompute, Type: constants.FieldTypeBoolean},
			{APIName: constants.FormulaSystemContextConnectorSyncOrigin, Type: constants.FieldTypeString},
		},
	}
	return out
}

func flowFormulaAugmentedSchema(base *models.ObjectMetadata) *models.ObjectMetadata {
	augmented := filterFormulaAugmentedSchema(base)
	systemContextRel := constants.FormulaContextSystemContext
	systemContextField := models.FieldMetadata{
		APIName:          constants.FormulaContextSystemContext + "Id",
		RelationshipName: &systemContextRel,
		Type:             constants.FieldTypeReference,
		ReferenceTo:      []string{constants.FormulaContextSystemContext},
	}
	out := *augmented
	out.Fields = make([]models.FieldMetadata, 0, len(augmented.Fields)+1)
	out.Fields = append(out.Fields, augmented.Fields...)
	out.Fields = append(out.Fields, systemContextField)
	return &out
}

type schemaValidationContext struct {
	schemasByObjectAPIName       map[string]*models.ObjectMetadata
	functionRegistry             *spec.Registry
	variables                    map[string]schemaExpressionVariableType
	allowPolymorphicRelationship bool
}

type schemaExpressionVariableType struct {
	Type          schemaFormulaType
	Collection    bool
	Dynamic       bool
	ObjectAPIName string
}

// schemaCtxKey normalises an object API name for use as a map key inside
// schemaValidationContext. Object API names are case-insensitive (Salesforce
// treats "ParentAccount" and "parentaccount" as the same object), so we
// lowercase the trimmed name here and apply the same transform in both the
// store path (newSchemaValidationContext) and the lookup path (schemaForObject).
// Using a single helper prevents store/lookup from drifting.
func schemaCtxKey(s string) string {
	return strings.ToLower(strings.TrimSpace(s))
}

func newSchemaValidationContext(root *models.ObjectMetadata, schemasByObjectAPIName map[string]*models.ObjectMetadata) schemaValidationContext {
	return newSchemaValidationContextWithRegistry(root, schemasByObjectAPIName, runtimeFunctionRegistry)
}

func newSchemaValidationContextWithRegistry(root *models.ObjectMetadata, schemasByObjectAPIName map[string]*models.ObjectMetadata, registry *spec.Registry) schemaValidationContext {
	if registry == nil {
		registry = runtimeFunctionRegistry
	}
	out := make(map[string]*models.ObjectMetadata, len(schemasByObjectAPIName)+1)
	for name, schema := range schemasByObjectAPIName {
		key := schemaCtxKey(name)
		if key != "" && schema != nil {
			out[key] = schema
		}
	}
	if root != nil && schemaCtxKey(root.APIName) != "" {
		out[schemaCtxKey(root.APIName)] = root
	}
	return schemaValidationContext{
		schemasByObjectAPIName: out,
		functionRegistry:       registry,
	}
}

func (ctx schemaValidationContext) schemaForObject(apiName string) *models.ObjectMetadata {
	return ctx.schemasByObjectAPIName[schemaCtxKey(apiName)]
}

func (ctx schemaValidationContext) withExpressionVariables(variables map[string]ExpressionVariableType) schemaValidationContext {
	if len(variables) == 0 {
		return ctx
	}
	ctx.variables = make(map[string]schemaExpressionVariableType, len(variables))
	for name, typ := range variables {
		name = strings.TrimSpace(name)
		if name == "" {
			continue
		}
		ctx.variables[name] = schemaExpressionVariableType{
			Type:          schemaTypeFromSpecValueType(typ.Type),
			Collection:    typ.Collection,
			Dynamic:       typ.Dynamic,
			ObjectAPIName: strings.TrimSpace(typ.ObjectAPIName),
		}
	}
	return ctx
}

func (ctx schemaValidationContext) expressionVariable(name string) (schemaExpressionVariableType, bool) {
	if len(ctx.variables) == 0 {
		return schemaExpressionVariableType{}, false
	}
	typ, ok := ctx.variables[strings.TrimSpace(name)]
	return typ, ok
}

func inferSchemaFormulaType(n ast.Node, ctx schemaValidationContext, schema *models.ObjectMetadata) (schemaFormulaType, error) {
	if n == nil {
		return schemaTypeNil, nil
	}
	if path := memberPath(n); len(path) > 0 {
		if t, ok, err := inferExpressionVariablePathType(path, ctx); ok {
			return t, err
		}
		return inferSchemaPathType(path, ctx, schema)
	}

	switch v := n.(type) {
	case *ast.NilNode:
		return schemaTypeNil, nil
	case *ast.IdentifierNode:
		return inferIdentifierType(v.Value, schema)
	case *ast.IntegerNode, *ast.FloatNode:
		return schemaTypeNumber, nil
	case *ast.BoolNode:
		return schemaTypeBoolean, nil
	case *ast.StringNode:
		return schemaTypeString, nil
	case *ast.ConstantNode:
		return schemaTypeUnknown, fmt.Errorf("formula uses constant node that schema-aware validation cannot prove runtime-safe")
	case *ast.PointerNode:
		return schemaTypeUnknown, fmt.Errorf("formula uses pointer node that schema-aware validation cannot prove runtime-safe")
	case *ast.UnaryNode:
		operand, err := inferSchemaFormulaType(v.Node, ctx, schema)
		if err != nil {
			return schemaTypeUnknown, err
		}
		switch v.Operator {
		case "!", "not":
			if operand == schemaTypeAny {
				return schemaTypeBoolean, nil
			}
			if !schemaTypeCompatible(operand, schemaTypeBoolean) {
				return schemaTypeUnknown, fmt.Errorf("formula unary operator %q requires boolean operand, got %s", v.Operator, operand)
			}
			return schemaTypeBoolean, nil
		case "-":
			if operand == schemaTypeAny {
				return schemaTypeAny, nil
			}
			if !schemaTypeCompatible(operand, schemaTypeNumber) {
				return schemaTypeUnknown, fmt.Errorf("formula unary operator %q requires number operand, got %s", v.Operator, operand)
			}
			return schemaTypeNumber, nil
		default:
			return schemaTypeUnknown, fmt.Errorf("formula unary operator %q is not supported by schema-aware frozen validation", v.Operator)
		}
	case *ast.BinaryNode:
		return inferBinaryType(v, ctx, schema)
	case *ast.ChainNode:
		return inferSchemaFormulaType(v.Node, ctx, schema)
	case *ast.MemberNode:
		return schemaTypeUnknown, fmt.Errorf("formula uses indexed or dynamic member access that schema-aware frozen validation cannot prove runtime-safe")
	case *ast.SliceNode:
		return schemaTypeUnknown, fmt.Errorf("formula uses slice access that schema-aware frozen validation cannot prove runtime-safe")
	case *ast.CallNode:
		return inferCallType(v, ctx, schema)
	case *ast.BuiltinNode:
		return schemaTypeUnknown, fmt.Errorf("formula uses expr builtin that schema-aware validation cannot prove runtime-safe")
	case *ast.PredicateNode:
		return inferSchemaFormulaType(v.Node, ctx, schema)
	case *ast.ConditionalNode:
		cond, err := inferSchemaFormulaType(v.Cond, ctx, schema)
		if err != nil {
			return schemaTypeUnknown, err
		}
		if cond != schemaTypeAny && !schemaTypeCompatible(cond, schemaTypeBoolean) {
			return schemaTypeUnknown, fmt.Errorf("formula conditional requires boolean condition, got %s", cond)
		}
		t1, err := inferSchemaFormulaType(v.Exp1, ctx, schema)
		if err != nil {
			return schemaTypeUnknown, err
		}
		t2, err := inferSchemaFormulaType(v.Exp2, ctx, schema)
		if err != nil {
			return schemaTypeUnknown, err
		}
		return mergeSchemaTypes(t1, t2), nil
	case *ast.VariableDeclaratorNode:
		if _, err := inferSchemaFormulaType(v.Value, ctx, schema); err != nil {
			return schemaTypeUnknown, err
		}
		return inferSchemaFormulaType(v.Expr, ctx, schema)
	case *ast.SequenceNode:
		out := schemaTypeNil
		for i := range v.Nodes {
			next, err := inferSchemaFormulaType(v.Nodes[i], ctx, schema)
			if err != nil {
				return schemaTypeUnknown, err
			}
			out = next
		}
		return out, nil
	case *ast.ArrayNode:
		return schemaTypeUnknown, fmt.Errorf("formula uses array literal that schema-aware validation cannot prove runtime-safe")
	case *ast.MapNode:
		return schemaTypeUnknown, fmt.Errorf("formula uses map literal that schema-aware validation cannot prove runtime-safe")
	case *ast.PairNode:
		if _, err := inferSchemaFormulaType(v.Key, ctx, schema); err != nil {
			return schemaTypeUnknown, err
		}
		return inferSchemaFormulaType(v.Value, ctx, schema)
	default:
		return schemaTypeUnknown, fmt.Errorf("formula schema validator: unsupported ast node type %T", n)
	}
}

func inferIdentifierType(name string, schema *models.ObjectMetadata) (schemaFormulaType, error) {
	if name == "__Record" {
		return schemaTypeObject, nil
	}
	if field := findExactSchemaField(schema, name); field != nil {
		return schemaTypeFromSchemaField(field, name)
	}
	if _, ok := resolveSchemaRelationRoot(schema, name); ok {
		return schemaTypeUnknown, fmt.Errorf("formula references terminal relationship %q; runtime binds relationship fields only as dotted paths", name)
	}
	return schemaTypeUnknown, fmt.Errorf("formula references field %q that does not exist on %s with exact runtime casing", name, schema.APIName)
}

func inferExpressionVariablePathType(path []string, ctx schemaValidationContext) (schemaFormulaType, bool, error) {
	if len(path) == 0 {
		return schemaTypeUnknown, false, nil
	}
	variable, ok := ctx.expressionVariable(path[0])
	if !ok {
		return schemaTypeUnknown, false, nil
	}
	if variable.Collection {
		return schemaTypeUnknown, true, fmt.Errorf("formula references collection variable %q as a scalar value", path[0])
	}
	if len(path) == 1 {
		return variable.Type, true, nil
	}
	if variable.Type == schemaTypeAny {
		return schemaTypeAny, true, nil
	}
	if variable.Type != schemaTypeObject {
		return schemaTypeUnknown, true, fmt.Errorf("formula references path %q but variable %q is a %s scalar, not a relationship object", strings.Join(path, "."), path[0], variable.Type)
	}
	if strings.TrimSpace(variable.ObjectAPIName) == "" {
		return schemaTypeAny, true, nil
	}
	schema := ctx.schemaForObject(variable.ObjectAPIName)
	if schema == nil {
		return schemaTypeAny, true, nil
	}
	t, err := inferSchemaPathType(path[1:], ctx, schema)
	return t, true, err
}

func inferSchemaPathType(path []string, ctx schemaValidationContext, schema *models.ObjectMetadata) (schemaFormulaType, error) {
	if len(path) == 0 {
		return schemaTypeAny, nil
	}
	if t, ok, err := inferPriorStateSentinelPathType(path, schema); ok {
		return t, err
	}
	if path[0] == "__ncrm_multipicklist_fields" {
		return schemaTypeAny, nil
	}
	// $Permission is an open map: any $Permission.<name> resolves to a boolean
	// at runtime (whether the user holds that permission).  Field-name enumeration
	// is intentionally open — do not validate <name> against a static list.
	// Bare $Permission (len==1) is not a usable boolean value.
	// $Permission.X.Y (len>2) is a scalar-tail on a boolean — reject.
	if path[0] == "__Permission" {
		if len(path) == 1 {
			return schemaTypeUnknown, fmt.Errorf("bare $Permission is not a usable value; use $Permission.<name> to test a specific permission")
		}
		if len(path) == 2 {
			return schemaTypeBoolean, nil
		}
		return schemaTypeUnknown, fmt.Errorf("formula references path %q but $Permission.%s is a boolean scalar, not a relationship object", strings.Join(path, "."), path[1])
	}
	if path[0] == "__Record" {
		if len(path) == 1 {
			return schemaTypeObject, nil
		}
		field := findExactSchemaField(schema, path[1])
		if field != nil {
			if len(path) == 2 {
				return schemaTypeFromSchemaField(field, strings.Join(path, "."))
			}
			return schemaTypeUnknown, fmt.Errorf("formula references field path %q but %q is a scalar field, not a relationship object", strings.Join(path, "."), path[1])
		}
		if relationField, ok := resolveSchemaRelationRoot(schema, path[1]); ok {
			if len(path) == 2 {
				return schemaTypeUnknown, fmt.Errorf("formula references terminal relationship %q; runtime binds relationship fields only as dotted paths", path[1])
			}
			return inferSchemaRelationshipPathType(strings.Join(path, "."), relationField, path[2:], ctx)
		}
		return schemaTypeUnknown, fmt.Errorf("formula references field %q that does not exist on %s with exact runtime casing", path[1], schema.APIName)
	}
	field := findExactSchemaField(schema, path[0])
	if field != nil {
		if len(path) == 1 {
			return schemaTypeFromSchemaField(field, strings.Join(path, "."))
		}
		return schemaTypeUnknown, fmt.Errorf("formula references field path %q but %q is a scalar field, not a relationship object", strings.Join(path, "."), path[0])
	}
	if relationField, ok := resolveSchemaRelationRoot(schema, path[0]); ok {
		if len(path) == 1 {
			return schemaTypeUnknown, fmt.Errorf("formula references terminal relationship %q; runtime binds relationship fields only as dotted paths", path[0])
		}
		return inferSchemaRelationshipPathType(strings.Join(path, "."), relationField, path[1:], ctx)
	}
	return schemaTypeUnknown, fmt.Errorf("formula references field %q that does not exist on %s with exact runtime casing", path[0], schema.APIName)
}

func inferPriorStateSentinelPathType(path []string, schema *models.ObjectMetadata) (schemaFormulaType, bool, error) {
	if len(path) != 1 {
		return schemaTypeUnknown, false, nil
	}
	name := path[0]
	switch name {
	case "_has_prior_", "_is_new_", "_is_clone_":
		return schemaTypeBoolean, true, nil
	}
	if !strings.HasPrefix(name, "_prior_") {
		return schemaTypeUnknown, false, nil
	}
	fieldName := strings.TrimPrefix(name, "_prior_")
	field := findExactSchemaField(schema, fieldName)
	if field == nil {
		return schemaTypeUnknown, true, fmt.Errorf("formula references prior field %q that does not exist on %s with exact runtime casing", fieldName, schema.APIName)
	}
	t, err := schemaTypeFromSchemaField(field, fieldName)
	return t, true, err
}

func inferSchemaRelationshipPathType(fullPath string, relationField *models.FieldMetadata, remainingPath []string, ctx schemaValidationContext) (schemaFormulaType, error) {
	if relationField == nil {
		return schemaTypeUnknown, fmt.Errorf("formula relationship path %q has no resolved foreign-key field", fullPath)
	}
	if len(relationField.ReferenceTo) != 1 {
		if ctx.allowPolymorphicRelationship {
			return inferPolymorphicSchemaRelationshipPathType(fullPath, relationField, remainingPath, ctx)
		}
		return schemaTypeUnknown, fmt.Errorf("formula relationship path %q references polymorphic relationship %q that schema-aware frozen validation cannot prove runtime-safe", fullPath, relationField.APIName)
	}
	targetObject := strings.TrimSpace(relationField.ReferenceTo[0])
	targetSchema := ctx.schemaForObject(targetObject)
	if targetSchema == nil {
		return schemaTypeUnknown, fmt.Errorf("formula relationship path %q targets %q but no target schema is available for frozen validation", fullPath, targetObject)
	}
	return inferSchemaPathType(remainingPath, ctx, targetSchema)
}

func inferPolymorphicSchemaRelationshipPathType(fullPath string, relationField *models.FieldMetadata, remainingPath []string, ctx schemaValidationContext) (schemaFormulaType, error) {
	merged := schemaTypeNil
	for _, targetObject := range relationField.ReferenceTo {
		targetObject = strings.TrimSpace(targetObject)
		if targetObject == "" {
			continue
		}
		targetSchema := ctx.schemaForObject(targetObject)
		if targetSchema == nil {
			return schemaTypeAny, nil
		}
		t, err := inferSchemaPathType(remainingPath, ctx, targetSchema)
		if err != nil {
			return schemaTypeUnknown, err
		}
		merged = mergeSchemaTypes(merged, t)
		if merged == schemaTypeUnknown {
			return schemaTypeUnknown, fmt.Errorf("formula relationship path %q has incompatible polymorphic target field types", fullPath)
		}
	}
	if merged == schemaTypeNil {
		return schemaTypeAny, nil
	}
	return merged, nil
}

func resolveSchemaRelationRoot(schema *models.ObjectMetadata, segment string) (*models.FieldMetadata, bool) {
	fkColumn := ResolveRelationFK(schema, segment)
	if fkColumn == "" {
		return nil, false
	}
	field := findExactSchemaField(schema, fkColumn)
	if field == nil {
		return nil, false
	}
	fieldType := constants.NormalizeFieldType(string(field.Type))
	if !constants.IsReferenceType(fieldType) {
		return nil, false
	}
	if len(field.ReferenceTo) == 0 {
		return nil, false
	}
	return field, true
}

func inferBinaryType(v *ast.BinaryNode, ctx schemaValidationContext, schema *models.ObjectMetadata) (schemaFormulaType, error) {
	left, err := inferSchemaFormulaType(v.Left, ctx, schema)
	if err != nil {
		return schemaTypeUnknown, err
	}
	right, err := inferSchemaFormulaType(v.Right, ctx, schema)
	if err != nil {
		return schemaTypeUnknown, err
	}
	switch v.Operator {
	case "+":
		if left == schemaTypeAny || right == schemaTypeAny {
			return schemaTypeAny, nil
		}
		if left == schemaTypeNumber && right == schemaTypeNumber {
			return schemaTypeNumber, nil
		}
		if left == schemaTypeString && right == schemaTypeString {
			return schemaTypeString, nil
		}
		return schemaTypeUnknown, fmt.Errorf("formula operator %q requires both operands to be numbers or both operands to be strings, got %s and %s", v.Operator, left, right)
	case "-", "*", "/", "%":
		if left == schemaTypeAny || right == schemaTypeAny {
			return schemaTypeAny, nil
		}
		if !schemaTypeCompatible(left, schemaTypeNumber) || !schemaTypeCompatible(right, schemaTypeNumber) {
			return schemaTypeUnknown, fmt.Errorf("formula operator %q requires number operands, got %s and %s", v.Operator, left, right)
		}
		return schemaTypeNumber, nil
	case ">", ">=", "<", "<=":
		if left == schemaTypeAny || right == schemaTypeAny {
			return schemaTypeBoolean, nil
		}
		if !schemaComparable(left, right) {
			return schemaTypeUnknown, fmt.Errorf("formula operator %q has incompatible operands %s and %s", v.Operator, left, right)
		}
		return schemaTypeBoolean, nil
	case "==", "!=", "=", "<>":
		return schemaTypeBoolean, nil
	case "&&", "||", "and", "or":
		if left == schemaTypeAny || right == schemaTypeAny {
			return schemaTypeBoolean, nil
		}
		if !schemaTypeCompatible(left, schemaTypeBoolean) || !schemaTypeCompatible(right, schemaTypeBoolean) {
			return schemaTypeUnknown, fmt.Errorf("formula operator %q requires boolean operands, got %s and %s", v.Operator, left, right)
		}
		return schemaTypeBoolean, nil
	case "&":
		return schemaTypeString, nil
	default:
		return schemaTypeUnknown, fmt.Errorf("formula binary operator %q is not supported by schema-aware frozen validation", v.Operator)
	}
}

// inferCallType infers the schema-aware result type of a function call and
// validates its argument types. As of VS2 (issue #1466) the return type and
// per-argument type contracts are read from the spec catalog
// engine function registry instead of an inline name-switch: the catalog is
// the single source of truth for arity (VS1), argument types, and return types.
//
// The function name reaches here already upper-cased by the frozen-formula
// normalization pass, matching the catalog's exact-case keys.
func inferCallType(v *ast.CallNode, ctx schemaValidationContext, schema *models.ObjectMetadata) (schemaFormulaType, error) {
	id, ok := v.Callee.(*ast.IdentifierNode)
	if !ok {
		return schemaTypeAny, nil
	}

	// Infer every argument's type first. This intentionally runs even for
	// functions that do not type-check a given argument (e.g. TEXT), so that an
	// argument whose own sub-expression is unprovable — an unsupported field
	// type, a dynamic member access — still fails closed.
	args := make([]schemaFormulaType, 0, len(v.Arguments))
	for i := range v.Arguments {
		argType, err := inferSchemaFormulaType(v.Arguments[i], ctx, schema)
		if err != nil {
			return schemaTypeUnknown, err
		}
		args = append(args, argType)
	}

	registry := ctx.functionRegistry
	if registry == nil {
		registry = runtimeFunctionRegistry
	}
	fnSpec, found := registry.Lookup(id.Value)
	if !found {
		if _, registered := registry.Lookup(id.Value); registered {
			return schemaTypeUnknown, fmt.Errorf("formula function %s has no schema-aware type validation classification", id.Value)
		}
		return schemaTypeUnknown, fmt.Errorf("formula function %s is not registered for schema-aware frozen validation", id.Value)
	}

	if err := validateSchemaCallArgs(id.Value, fnSpec, v.Arguments, args); err != nil {
		return schemaTypeUnknown, err
	}
	return inferSchemaReturnType(id.Value, fnSpec.Return, args)
}

// validateSchemaCallArgs enforces each declared per-argument type contract from
// the spec. Arguments past the contract (a variadic tail with no VarArg) are
// left unvalidated, matching the legacy requireSchema* primitives which
// returned nil for an index beyond the supplied argument count.
func validateSchemaCallArgs(fn string, fnSpec spec.FunctionSpec, nodes []ast.Node, args []schemaFormulaType) error {
	for i := range args {
		argSpec, ok := fnSpec.ArgSpecAt(i)
		if !ok {
			continue
		}
		if err := validateSchemaArg(fn, i, argSpec, nodes[i], args[i]); err != nil {
			return err
		}
	}
	return nil
}

func validateSchemaArg(fn string, index int, argSpec spec.ArgSpec, node ast.Node, got schemaFormulaType) error {
	if got == schemaTypeAny {
		return nil
	}
	if argSpec.DateLikeLiteral {
		// Calendar functions (YEAR/MONTH/DAY/__DATE_ADD_MONTHS__/
		// __DATE_ADD_MONTHS_TARGET_CLAMP__/__DATE_DIFF_DAYS__/DATE_ADD/DATEVALUE)
		// accept both Date and DateTime inputs.
		if got == schemaTypeDate || got == schemaTypeDatetime {
			return nil
		}
		if stringLiteral, ok := node.(*ast.StringNode); ok && isSchemaDateLiteral(stringLiteral.Value) {
			return nil
		}
		return fmt.Errorf("%s argument %d requires %s, got %s", fn, index+1, schemaTypeDate, got)
	}
	if argSpec.EpochMillis {
		if got == schemaTypeNumber || got == schemaTypeNil {
			return nil
		}
		if got == schemaTypeString {
			if stringLiteral, ok := node.(*ast.StringNode); ok {
				if _, err := strconv.ParseInt(stringLiteral.Value, 10, 64); err != nil {
					return fmt.Errorf("%s argument %d requires a number or integral epoch-millisecond string, got invalid string literal", fn, index+1)
				}
			}
			return nil
		}
		return fmt.Errorf("%s argument %d requires a number or integral epoch-millisecond string, got %s", fn, index+1, got)
	}

	// A typeless ArgSpec (only Nullable set, e.g. IF/NULLVALUE branch args)
	// imposes no type constraint: the branch type flows through to the merge.
	if argSpec.Type == "" {
		return nil
	}

	want := schemaFormulaType(argSpec.Type)
	if schemaTypeCompatible(got, want) {
		return nil
	}
	if argSpec.Nullable {
		if got == schemaTypeNil {
			return nil
		}
		return fmt.Errorf("%s argument %d requires %s or nil, got %s", fn, index+1, want, got)
	}
	return fmt.Errorf("%s argument %d requires %s, got %s", fn, index+1, want, got)
}

// inferSchemaReturnType derives the call's result type from its ReturnSpec and
// the already-inferred argument types. Merge kinds reproduce the legacy
// switch's "incompatible branch types" rejection.
func inferSchemaReturnType(fn string, ret spec.ReturnSpec, args []schemaFormulaType) (schemaFormulaType, error) {
	switch ret.Kind {
	case spec.ReturnMergeArgs:
		// Defensive: arity is already validated upstream, but guard against a
		// short arg list so a merge index never panics (matches the legacy
		// len(args) < N -> schemaTypeAny fallthrough).
		for _, idx := range ret.MergeArgs {
			if idx >= len(args) {
				return schemaTypeAny, nil
			}
		}
		merged := schemaTypeNil
		for _, idx := range ret.MergeArgs {
			merged = mergeSchemaTypes(merged, args[idx])
		}
		if merged == schemaTypeUnknown {
			return schemaTypeUnknown, fmt.Errorf("%s arguments have incompatible types", fn)
		}
		return merged, nil
	case spec.ReturnMergeEvenArgsPlusLast:
		// CASE(switch, val1, res1, ..., else): merge the else branch with every
		// result branch (even indexes in [2, len-1)).
		if len(args) == 0 {
			return schemaTypeAny, nil
		}
		out := args[len(args)-1]
		for i := 2; i < len(args)-1; i += 2 {
			out = mergeSchemaTypes(out, args[i])
			if out == schemaTypeUnknown {
				return schemaTypeUnknown, fmt.Errorf("%s result branches have incompatible types", fn)
			}
		}
		return out, nil
	default: // spec.ReturnFixed
		return schemaFormulaType(ret.Fixed), nil
	}
}

func isSchemaDateLiteral(value string) bool {
	for _, layout := range []string{"2006-01-02", "2006-01-02 15:04:05", time.RFC3339, time.RFC3339Nano} {
		if _, err := time.Parse(layout, value); err == nil {
			return true
		}
	}
	return false
}

func schemaTypeCompatible(got, want schemaFormulaType) bool {
	// Date and DateTime both widen to string (e.g. TEXT, & concatenation).
	if want == schemaTypeString && (got == schemaTypeDate || got == schemaTypeDatetime) {
		return true
	}
	return got == want
}

func schemaResultTypeCompatible(got, want schemaFormulaType) bool {
	if want == schemaTypeAny {
		return true
	}
	// Date and DateTime both satisfy a string target via widening, but a Date
	// result does NOT satisfy a DateTime target (and vice versa) — Salesforce
	// treats them as distinct assignment targets.
	if want == schemaTypeString && (got == schemaTypeDate || got == schemaTypeDatetime) {
		return true
	}
	return got == schemaTypeNil || got == want
}

func schemaComparable(left, right schemaFormulaType) bool {
	if left != right {
		return false
	}
	switch left {
	case schemaTypeNumber, schemaTypeString, schemaTypeDate, schemaTypeDatetime:
		return true
	default:
		return false
	}
}

func mergeSchemaTypes(left, right schemaFormulaType) schemaFormulaType {
	if left == right {
		return left
	}
	if left == schemaTypeNil {
		return right
	}
	if right == schemaTypeNil {
		return left
	}
	if left == schemaTypeAny || right == schemaTypeAny {
		return schemaTypeAny
	}
	return schemaTypeUnknown
}

func memberPath(n ast.Node) []string {
	switch v := n.(type) {
	case *ast.IdentifierNode:
		return []string{v.Value}
	case *ast.MemberNode:
		base := memberPath(v.Node)
		if len(base) == 0 {
			return nil
		}
		prop, ok := memberPropertyName(v.Property)
		if !ok {
			return nil
		}
		return append(base, prop)
	case *ast.ChainNode:
		return memberPath(v.Node)
	default:
		return nil
	}
}

func memberPropertyName(n ast.Node) (string, bool) {
	switch v := n.(type) {
	case *ast.StringNode:
		return v.Value, true
	default:
		return "", false
	}
}

func findExactSchemaField(schema *models.ObjectMetadata, apiName string) *models.FieldMetadata {
	if schema == nil || apiName == "" {
		return nil
	}
	for i := range schema.Fields {
		if schema.Fields[i].APIName == apiName {
			return &schema.Fields[i]
		}
	}
	return nil
}

func schemaTypeFromField(field *models.FieldMetadata) schemaFormulaType {
	if field == nil {
		return schemaTypeUnknown
	}
	fieldType := field.Type
	if field.Type == constants.FieldTypeFormula && field.ReturnType != nil {
		fieldType = *field.ReturnType
	}
	return schemaTypeFromFieldType(fieldType)
}

func schemaTypeFromSchemaField(field *models.FieldMetadata, path string) (schemaFormulaType, error) {
	fieldType := schemaTypeFromField(field)
	if fieldType == schemaTypeUnknown {
		return schemaTypeUnknown, fmt.Errorf("formula references field %q with type %q that is not supported by schema-aware frozen validation", path, field.Type)
	}
	return fieldType, nil
}

func schemaTypeFromFieldType(fieldType constants.SchemaFieldType) schemaFormulaType {
	switch constants.NormalizeFieldType(string(fieldType)) {
	case constants.FieldTypeString,
		constants.FieldTypeEmail,
		constants.FieldTypePhone,
		constants.FieldTypeTextarea,
		constants.FieldTypeUrl,
		constants.FieldTypePicklist,
		constants.FieldTypeMultiPicklist,
		constants.FieldTypeEncryptedString,
		constants.FieldTypePassword,
		constants.FieldTypeAutonumber,
		constants.FieldTypeReference,
		constants.FieldTypeMasterDetail:
		return schemaTypeString
	case constants.FieldTypeDouble, constants.FieldTypeCurrency, constants.FieldTypePercent:
		return schemaTypeNumber
	case constants.FieldTypeBoolean:
		return schemaTypeBoolean
	case constants.FieldTypeDate:
		return schemaTypeDate
	case constants.FieldTypeDatetime:
		return schemaTypeDatetime
	default:
		return schemaTypeUnknown
	}
}

func schemaTypeFromSpecValueType(valueType spec.ValueType) schemaFormulaType {
	switch valueType {
	case spec.TypeString:
		return schemaTypeString
	case spec.TypeNumber:
		return schemaTypeNumber
	case spec.TypeBoolean:
		return schemaTypeBoolean
	case spec.TypeDate:
		return schemaTypeDate
	case spec.TypeDatetime:
		return schemaTypeDatetime
	case spec.TypeObject:
		return schemaTypeObject
	case spec.TypeAny:
		return schemaTypeAny
	case spec.TypeNil:
		return schemaTypeNil
	default:
		return schemaTypeUnknown
	}
}

func specValueTypeFromSchemaType(schemaType schemaFormulaType) spec.ValueType {
	switch schemaType {
	case schemaTypeString:
		return spec.TypeString
	case schemaTypeNumber:
		return spec.TypeNumber
	case schemaTypeBoolean:
		return spec.TypeBoolean
	case schemaTypeDate:
		return spec.TypeDate
	case schemaTypeDatetime:
		return spec.TypeDatetime
	case schemaTypeObject:
		return spec.TypeObject
	case schemaTypeAny:
		return spec.TypeAny
	case schemaTypeNil:
		return spec.TypeNil
	default:
		return spec.TypeUnknown
	}
}
