package formula

import (
	"encoding/json"
	"errors"
	"fmt"
	"regexp"
	"strings"

	"github.com/nexuscrm/backend/pkg/auth"
	"github.com/nexuscrm/backend/pkg/expression"
)

// ErrRelationFetch is returned (wrapped via %w) by Evaluate when the caller-
// supplied Context.Fetcher returns a non-nil error for any relation referenced
// by the expression. Callers that need to distinguish a transient relation-
// fetch failure (metadata-cache miss / DB blip) from a genuine evaluation
// failure (parse / function-call error) MUST gate on errors.Is(err,
// formula.ErrRelationFetch). The semantic difference is load-bearing for
// callers like records.computeFormulaFields, which must NOT corrupt a stored
// formula value with nil on a transient fetch error — see issue #2057.
var ErrRelationFetch = errors.New("formula: relation fetcher failed")

// Pre-compiled regexes used in normalizeFormulaExpression (P1-B: avoid recompiling per eval).
var (
	blockCommentPattern  = regexp.MustCompile(`(?s)/\*.*?\*/`)
	typedRelationPattern = regexp.MustCompile(`:([A-Za-z_][A-Za-z0-9_]*)\.`)
	dollarVarPattern     = regexp.MustCompile(`\$([A-Za-z_])`)
	isChangedPattern     = regexp.MustCompile(`ISCHANGED\(\s*(?:__Record\.)?([A-Za-z_][A-Za-z0-9_]*)\s*\)`)
	priorValuePattern    = regexp.MustCompile(`PRIORVALUE\(\s*(?:__Record\.)?([A-Za-z_][A-Za-z0-9_]*)\s*\)`)
	// Capture dotted identifier paths (e.g. `Quote.Contains_Other__c`) so the
	// whole path is wrapped in TRUTHY(...). A bare-identifier capture would
	// leave the dotted prefix stranded — `Quote.Field == true` became
	// `Quote.TRUTHY(Field)` instead of `TRUTHY(Quote.Field)`.
	eqTruePattern     = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*==\s*true\b`)
	eqFalsePattern    = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*==\s*false\b`)
	funcEqTruePattern = regexp.MustCompile(`\)\s*==\s*true\b`)
	isNewPattern      = regexp.MustCompile(`ISNEW\(\s*\)`)
	isClonePattern    = regexp.MustCompile(`ISCLONE\(\s*\)`)
	// Rewrites TODAY()+30 → DATE_ADD(TODAY(), 30), TODAY()-7 → DATE_ADD(TODAY(), -7)
	todayArithPattern = regexp.MustCompile(`(TODAY|NOW)\(\s*\)\s*([+-])\s*(\d+)`)
	// Strips Salesforce merge-field braces: {!$Record.Foo} → $Record.Foo
	// Must run BEFORE dollarVarPattern so the $ rewrite sees a clean identifier.
	mergeFieldPattern = regexp.MustCompile(`\{!([^}]+)\}`)

	// Matches __Record.<Ident>. — the shape of a cross-object relationship
	// reference after normalizeFormulaExpression has rewritten `$Record` to
	// `__Record`. The trailing dot ensures we only capture identifiers that
	// are actually used as a relationship step (there must be at least one
	// field access after), not bare field references like __Record.Name.
	recordRelationPattern = regexp.MustCompile(`__Record\.([A-Za-z_][A-Za-z0-9_]*)\.`)

	// bareRelationPattern matches a bare identifier used as a parent-record
	// reference prefix (e.g. "Account" in TEXT(Account.Region__c),
	// "RecordType" in RecordType.Name, "Account__r" in Account__r.Name) —
	// the Salesforce implicit-record-context shorthand where a lookup
	// relationship name is used without $Record.
	// Group 1: boundary char (non-identifier, non-dot, non-quote) or string
	// start. Group 2: the relationship identifier (underscore allowed so
	// custom __r names like Account__r are matched). Group 3: first char of
	// the dotted segment (alpha/underscore excludes bare function calls).
	// Identifiers starting with "__" are filtered by extractBareRelations.
	bareRelationPattern = regexp.MustCompile(`(^|[^._A-Za-z0-9"'])([A-Za-z][A-Za-z0-9_]*)\.([A-Za-z_])`)

	keywordPatterns = map[*regexp.Regexp]string{
		regexp.MustCompile(`(?i)\bnull\b`):  "nil",
		regexp.MustCompile(`(?i)\btrue\b`):  "true",
		regexp.MustCompile(`(?i)\bfalse\b`): "false",
	}

	// Infix boolean operator rewrites. Salesforce Flow metadata + our filter
	// builder (executor_helpers.buildFilterFromConditionsImpl) emit uppercase
	// `AND` / `OR` as infix boolean operators. expr-lang's parser only
	// understands lowercase `and` / `or` (or `&&` / `||`), so we rewrite.
	// Go's RE2 doesn't support lookaheads, so we capture the following
	// non-paren character and reinject it via a substitution template.
	// Regex issue #1124.
	andInfixPattern = regexp.MustCompile(`(?i)\bAND\b(\s*)([^(\s])`)
	orInfixPattern  = regexp.MustCompile(`(?i)\bOR\b(\s*)([^(\s])`)
	// Infix `AND(` / `OR(` — the Salesforce Flow importer emits forms like
	// `X AND(Y OR Z)` where `AND` is infix and `(` is the start of a
	// subexpression. Disambiguate from the call form `AND(a, b, c)` by
	// requiring a preceding expression-terminator character (identifier-char,
	// digit, closing paren/bracket, quote). Call form at the start of the
	// expression or after `(`, `,`, `&&`, `||`, `!` leaves `AND(` / `OR(`
	// untouched for the walker's variadic handling. Issue #1124.
	andInfixParenPattern = regexp.MustCompile(`([)\w'"\]])(\s*)\bAND\s*\(`)
	orInfixParenPattern  = regexp.MustCompile(`([)\w'"\]])(\s*)\bOR\s*\(`)
	// `IS NULL` / `IS NOT NULL` from SF-compat filter-builder output. After
	// keywordPatterns runs `NULL` has already been lowercased to `nil`.
	// Rewrite to expr-lang's native null comparison so the SQL walker can
	// emit `IS NULL` / `IS NOT NULL`.
	isNotNullPattern = regexp.MustCompile(`(?i)\bIS\s+NOT\s+nil\b`)
	isNullPattern    = regexp.MustCompile(`(?i)\bIS\s+nil\b`)

	// Salesforce-compat: `<identPath> = ""` / `<identPath> <> ""` on a text
	// field is TRUE for NULL values (Salesforce has no NULL/"" distinction
	// for text). expr-lang evaluates `nil == ""` as false, so the naive
	// rewrite resolves blank-check branches incorrectly and the formula
	// silently returns nil — which executeRecordUpdate then persists as a
	// NULL column. Rewrite the pattern to ISBLANK, which handles nil and
	// empty string uniformly. Issue #1680. Pattern matches identifiers and
	// dotted paths (`__Record.Account.Field__c`); complex LHS expressions
	// like function calls fall through to literal expr-lang semantics.
	emptyStringEqPattern = regexp.MustCompile(`([A-Za-z_][\w.]*)\s*==\s*""`)
	emptyStringNePattern = regexp.MustCompile(`([A-Za-z_][\w.]*)\s*!=\s*""`)

	functionPatterns map[string]*regexp.Regexp
)

func init() {
	functions := []string{
		"if", "or", "and", "not", "today", "now", "text", "ispickval", "includes", "isblank", "isnull",
		"ischanged", "priorvalue", "isnew", "isclone", "contains",
		"find", "begins", "left", "right", "mid", "substitute", "len", "lower", "upper",
		"round", "ceiling", "abs", "mod", "case", "addmonths", "datevalue", "hyperlink",
		"nullvalue", "year", "month", "day",
	}
	functionPatterns = make(map[string]*regexp.Regexp, len(functions))
	for _, fn := range functions {
		functionPatterns[fn] = regexp.MustCompile(`(?i)\b` + fn + `\s*\(`)
	}
}

// Context holds the evaluation context for formulas
type Context struct {
	Record    map[string]interface{}                                                                   `json:"record"`
	Prior     map[string]interface{}                                                                   `json:"prior"`
	User      map[string]interface{}                                                                   `json:"user"`
	Env       map[string]interface{}                                                                   `json:"env"`
	Variables map[string]interface{}                                                                   `json:"variables"` // Flow Variables
	Fields    map[string]interface{}                                                                   `json:"-"`         // Catch-all for unmapped fields
	Extra     map[string]json.RawMessage                                                               `json:"-"`         // For custom unmarshaling
	Fetcher   func(record map[string]interface{}, relationName string) (map[string]interface{}, error) `json:"-"`
	IsVisible func(fieldName string) bool                                                              `json:"-"` // Optional FLS check
}

// UnmarshalJSON implements custom JSON unmarshaling to capture extra fields
func (c *Context) UnmarshalJSON(data []byte) error {
	var temp map[string]interface{}
	if err := json.Unmarshal(data, &temp); err != nil {
		return err
	}

	if c.Fields == nil {
		c.Fields = make(map[string]interface{})
	}

	for key, value := range temp {
		switch key {
		case "record":
			if recordMap, ok := value.(map[string]interface{}); ok {
				c.Record = recordMap
			}
		case "prior":
			if priorMap, ok := value.(map[string]interface{}); ok {
				c.Prior = priorMap
			}
		case "user":
			if userMap, ok := value.(map[string]interface{}); ok {
				c.User = userMap
			}
		case "env":
			if envMap, ok := value.(map[string]interface{}); ok {
				c.Env = envMap
			}
		case "variables":
			if varMap, ok := value.(map[string]interface{}); ok {
				c.Variables = varMap
			}
		default:
			c.Fields[key] = value
		}
	}
	return nil
}

// Engine is the formula evaluation engine backed by expression engine
type Engine struct {
	exprEngine *expression.Engine
}

// NewEngine creates a new formula engine
func NewEngine() *Engine {
	engine := &Engine{
		exprEngine: expression.NewEngine(),
	}
	engine.registerBuiltinFunctions()
	return engine
}

// ToSQL converts an expression string to a SQL WHERE clause and arguments
func ToSQL(expr string) (string, []interface{}, error) {
	return expression.ToSQL(normalizeFormulaExpression(expr))
}

// ToSQLWithMapper converts an expression string to a SQL WHERE clause with identifier mapping
func ToSQLWithMapper(expr string, mapper func(string) string) (string, []interface{}, error) {
	return expression.ToSQLWithMapper(normalizeFormulaExpression(expr), mapper)
}

// Validate validates a formula expression syntax
func (e *Engine) Validate(expression string, env map[string]interface{}) error {
	return e.exprEngine.Validate(normalizeFormulaExpression(expression), env)
}

// Evaluate evaluates a formula expression with the given context
func (e *Engine) Evaluate(expression string, ctx *Context) (interface{}, error) {
	// Flatten context for generic expression engine
	env := make(map[string]interface{})

	// 0. Cross-object lazy fetch. When the caller supplies ctx.Fetcher, scan the
	// normalized expression for relation references and populate the related
	// records via the Fetcher before flattening. Two ref forms are handled:
	//   a) __Record.X.Y — Salesforce $Record. shorthand (e.g. $Record.Account.Name)
	//   b) bare X.Y    — Salesforce implicit-record-context shorthand (e.g.
	//                    Account.Region__c on Opportunity, RecordType.Name).
	// Both go through the same fetcher and augmentRecordWithRelations so a
	// relation already populated by form (a) is not re-fetched by form (b).
	// Issue #2092 — bare X.Y was previously not augmented, causing "unknown
	// name Account" errors for valid cross-object formula fields.
	record := ctx.Record
	if ctx.Fetcher != nil && record != nil {
		normalizedExpr := normalizeFormulaExpression(expression)
		if relations := extractRecordRelations(normalizedExpr); len(relations) > 0 {
			augmented, err := augmentRecordWithRelations(record, relations, ctx.Fetcher)
			if err != nil {
				// Transient fetcher failure (metadata-cache miss / DB blip).
				// Surface as ErrRelationFetch so consumers can distinguish
				// from a genuine evaluation failure and avoid corrupting a
				// stored formula value with nil on a fleeting error. Issue
				// #2057.
				return nil, fmt.Errorf("%w: %v", ErrRelationFetch, err)
			}
			record = augmented
		}
		// Step 0b: also augment bare X.Y relation refs. augmentRecordWithRelations
		// is idempotent: relations already in `record` (from step 0a above or
		// supplied inline by the caller) are silently skipped.
		if bareRels := extractBareRelations(normalizedExpr); len(bareRels) > 0 {
			augmented, err := augmentRecordWithRelations(record, bareRels, ctx.Fetcher)
			if err != nil {
				return nil, fmt.Errorf("%w: %v", ErrRelationFetch, err)
			}
			record = augmented
		}
	}

	// 1. Add top-level objects
	if record != nil {
		// Apply FLS filtering if IsVisible is provided
		var recordMap map[string]interface{}
		if ctx.IsVisible != nil {
			recordMap = make(map[string]interface{})
			for k, v := range record {
				if ctx.IsVisible(k) {
					recordMap[k] = v
				}
			}
		} else {
			recordMap = record
		}

		env["record"] = recordMap
		// Also flatten record fields into top level for convenience (e.g. Amount instead of record.Amount)
		// This matches previous behavior where plain identifiers looked in record
		for k, v := range recordMap {
			env[k] = v
		}
	}
	if ctx.Prior != nil {
		env["prior"] = ctx.Prior
	}
	if ctx.User != nil {
		env["user"] = ctx.User
		// Flatten ONLY normalized global pseudo-keys (e.g. __Profile, __User,
		// __UserRole — produced by normalizeFormulaExpression rewriting $Profile
		// → __Profile) into top-level scope so Salesforce merge fields like
		// $Profile.Name resolve via member access.
		//
		// Flat aliases inside ctx.User (Id, Name, Email, ProfileId — kept there
		// for ActionService's env["user"].Email access path) are intentionally
		// NOT promoted: previously they would shadow record fields with the same
		// name, so a validation rule using bare `Name` read the running user's
		// Name instead of the record's Name (#2260 IANTHEREAL P1 blocker).
		for k, v := range ctx.User {
			if strings.HasPrefix(k, "__") {
				env[k] = v
			}
		}
	}
	if ctx.Env != nil {
		env["env"] = ctx.Env
	}
	if ctx.Variables != nil {
		// Inject variables into top-level scope (simplest for access)
		// Or should they be namespaced? Usually Flow Variables are just identifiers.
		// Let's namespace them under 'var' AND put at top level if no conflict?
		// For safety, let's put them in 'var' namespace AND top level, prioritizing Record fields.
		env["var"] = ctx.Variables
		for k, v := range ctx.Variables {
			// Only inject if not already present (Record fields take precedence or vice-versa?
			// Usually Variables override Record fields in Flow Context.
			env[k] = v
		}
	}

	// 1b. Ensure env["__Record"] references the (possibly augmented) record so
	// any cross-object relations lazy-loaded above are visible through the
	// normalized `__Record.<Relation>.<Field>` access path. Overrides the
	// original Record that buildFlowFormulaContext routed via Variables.
	if record != nil {
		env["__Record"] = record
	}

	// 2. Add extra fields (overrides)
	if ctx.Fields != nil {
		for k, v := range ctx.Fields {
			// Apply FLS to extra fields too if they are considered fields
			if ctx.IsVisible != nil && !ctx.IsVisible(k) {
				continue
			}
			env[k] = v
		}
	}

	// 3. Inject prior values with _prior_ prefix for ISCHANGED / PRIORVALUE support.
	if ctx.Prior != nil {
		env["_has_prior_"] = true
		for k, v := range ctx.Prior {
			env["_prior_"+k] = v
		}
	} else {
		// On insert: inject nil _prior_ values so PRIORVALUE resolves to nil
		// and ISCHANGED returns false (guarded by _has_prior_ sentinel).
		env["_has_prior_"] = false
		if ctx.Record != nil {
			for k := range ctx.Record {
				env["_prior_"+k] = nil
			}
		}
	}

	// 4. Inject ISNEW / ISCLONE sentinels.
	// ISNEW: true when Prior is nil (record is being created).
	// ISCLONE: always false for now (clone operation not yet implemented).
	env["_is_new_"] = ctx.Prior == nil
	env["_is_clone_"] = false

	// 5. Evaluate. Apply the Salesforce empty-string semantics rewrite
	// post-normalize, scoped to the Evaluate path (see helper godoc).
	return e.exprEngine.Evaluate(applySalesforceEmptyStringSemantics(normalizeFormulaExpression(expression)), env)
}

// RegisterFunction delegates to expression engine
func (e *Engine) RegisterFunction(name string, fn func(params ...interface{}) (interface{}, error)) {
	e.exprEngine.RegisterFunction(name, fn)
}

// registerBuiltinFunctions registers formula-specific functions
func (e *Engine) registerBuiltinFunctions() {
	// BCRYPT for password hashing
	e.RegisterFunction("BCRYPT", func(args ...interface{}) (interface{}, error) {
		if len(args) != 1 {
			return nil, fmt.Errorf("BCRYPT requires 1 argument")
		}
		password, ok := args[0].(string)
		if !ok {
			return nil, fmt.Errorf("BCRYPT argument must be string")
		}
		// Hash the password using auth package
		return auth.HashPassword(password)
	})
	e.RegisterFunction("ISNULLX", func(args ...interface{}) (interface{}, error) {
		if len(args) != 2 {
			return nil, fmt.Errorf("ISNULLX requires 2 arguments")
		}
		wasNull, ok := args[1].(bool)
		if !ok {
			wasNull = false
		}
		return wasNull || args[0] == nil, nil
	})
	e.RegisterFunction("ISBLANKX", func(args ...interface{}) (interface{}, error) {
		if len(args) != 2 {
			return nil, fmt.Errorf("ISBLANKX requires 2 arguments")
		}
		wasNull, ok := args[1].(bool)
		if !ok {
			wasNull = false
		}
		if wasNull || args[0] == nil {
			return true, nil
		}
		if s, ok := args[0].(string); ok {
			return s == "", nil
		}
		return false, nil
	})
	e.RegisterFunction("ISCHANGEDX", func(args ...interface{}) (interface{}, error) {
		if len(args) != 5 {
			return nil, fmt.Errorf("ISCHANGEDX requires 5 arguments")
		}
		hasPrior, ok := args[0].(bool)
		if !ok || !hasPrior {
			return false, nil
		}
		currentWasNull, _ := args[3].(bool)
		priorWasNull, _ := args[4].(bool)
		if currentWasNull != priorWasNull {
			return true, nil
		}
		if currentWasNull && priorWasNull {
			return false, nil
		}
		return fmt.Sprintf("%v", args[1]) != fmt.Sprintf("%v", args[2]), nil
	})
}

func normalizeFormulaExpression(expression string) string {
	expr := strings.TrimSpace(expression)
	if expr == "" {
		return expr
	}

	// Remove Salesforce-style block comments used in some imported formulas.
	// Uses (?s) flag so . matches \n for multiline block comments (P1-G).
	expr = blockCommentPattern.ReplaceAllString(expr, " ")

	expr = strings.ReplaceAll(expr, "\r\n", " ")
	expr = strings.ReplaceAll(expr, "\n", " ")
	expr = strings.ReplaceAll(expr, "\r", " ")
	expr = strings.Join(strings.Fields(expr), " ")

	// Extract string literals into placeholders so all subsequent regex rewrites
	// are guaranteed not to modify content inside quoted strings.
	var literals []string
	expr, literals = extractStringLiterals(expr)

	// Strip Salesforce merge-field braces: {!$Record.Foo} → $Record.Foo
	// Must run before $ and function normalization so inner content is clean.
	expr = mergeFieldPattern.ReplaceAllString(expr, "$1")

	// Salesforce polymorphic/typed relation syntax: Owner:User.Name -> Owner.Name
	expr = typedRelationPattern.ReplaceAllString(expr, ".")

	// Normalize Salesforce global variable prefix: $Profile.Name -> __Profile.Name
	// The $ character is not valid in expr-lang identifiers
	expr = dollarVarPattern.ReplaceAllString(expr, "__$1")

	// Normalize booleans/nulls
	for re, replacement := range keywordPatterns {
		expr = re.ReplaceAllString(expr, replacement)
	}

	// Normalize function names used in imported Salesforce formulas
	for fn, re := range functionPatterns {
		upper := strings.ToUpper(fn)
		expr = re.ReplaceAllStringFunc(expr, func(_ string) string {
			return upper + "("
		})
	}

	// Rewrite ISCHANGED(FieldName) — guarded by _has_prior_ so insert always returns false
	expr = isChangedPattern.ReplaceAllString(expr, "(_has_prior_ && $1 != _prior_$1)")

	// Rewrite PRIORVALUE(FieldName) to _prior_FieldName
	expr = priorValuePattern.ReplaceAllString(expr, "_prior_$1")

	// Rewrite ISNEW() and ISCLONE() to sentinel variables
	expr = isNewPattern.ReplaceAllString(expr, "_is_new_")
	expr = isClonePattern.ReplaceAllString(expr, "_is_clone_")

	// Rewrite TODAY()+N and TODAY()-N to DATE_ADD(TODAY(), N) / DATE_ADD(TODAY(), -N)
	// so date arithmetic works without changing TODAY()'s return type.
	// Also handles NOW()+N.
	expr = todayArithPattern.ReplaceAllString(expr, "DATE_ADD($1(), $2$3)")

	// replaceFormulaOperators is already string-safe (uses its own quote tracking)
	// but strings are already extracted, so it operates on placeholders only.
	expr = replaceFormulaOperators(expr)

	// Salesforce-compat AND/OR/IS NULL rewrites must run AFTER replaceFormulaOperators
	// so callers that go through Evaluate (validation rules, sharing-rule criteria)
	// see a consistent expression: `=` is already `==`, `<>` is already `!=`, etc.
	// Running them earlier produced malformed expressions when the upstream form
	// still contained Salesforce `=`. Issue #1124 / #1128 regression.
	//
	// Salesforce-compat: rewrite `IS NULL` / `IS NOT NULL` (already lowercased
	// to `IS nil` / `IS NOT nil` by keywordPatterns) to expr-lang null
	// comparisons so the SQL walker emits `IS NULL` / `IS NOT NULL`.
	// Order: NOT variant first so it doesn't get consumed by the shorter one.
	expr = isNotNullPattern.ReplaceAllString(expr, "!= nil")
	expr = isNullPattern.ReplaceAllString(expr, "== nil")

	// Salesforce-compat: disambiguate infix `AND(` / `OR(` (expression
	// followed by AND and a subexpression) from call-form `AND(a, b, ...)`
	// by looking at the preceding character. Infix form rewrites to `&& (`
	// / `|| (`; call form stays and is handled by the walker. Must run
	// before the plain infix rewrite so the `(` doesn't confuse it.
	expr = andInfixParenPattern.ReplaceAllString(expr, "$1$2&& (")
	expr = orInfixParenPattern.ReplaceAllString(expr, "$1$2|| (")

	// Salesforce-compat: rewrite uppercase infix `AND` / `OR` to `&&` / `||`.
	// Call-form `AND(` / `OR(` is left alone so walker can treat it as a
	// variadic boolean function (handled in visitCall). The regex uses a
	// character-class follow-up capture (RE2 has no lookaheads) that excludes
	// `(`, so only the infix form matches.
	expr = andInfixPattern.ReplaceAllString(expr, "&& $2")
	expr = orInfixPattern.ReplaceAllString(expr, "|| $2")

	// Rewrite boolean comparisons to handle type mismatches (DB int64 vs bool)
	// Must run AFTER replaceFormulaOperators which converts Salesforce "=" to "=="
	// 1. "field == true" → "TRUTHY(field)" (handles int64/string/bool)
	expr = eqTruePattern.ReplaceAllString(expr, "TRUTHY($1)")
	// 2. "field == false" → "NOT(TRUTHY(field))"
	expr = eqFalsePattern.ReplaceAllString(expr, "NOT(TRUTHY($1))")
	// 3. "FUNC(...) == true" → remove redundant "== true" (func already returns bool)
	expr = funcEqTruePattern.ReplaceAllString(expr, ")")

	// Restore string literals back into the expression
	expr = restoreStringLiterals(expr, literals)
	return expr
}

// applySalesforceEmptyStringSemantics rewrites `<identPath> == ""` and
// `<identPath> != ""` to `ISBLANK(<identPath>)` and `!ISBLANK(<identPath>)`.
// Runs ONLY on the Evaluate path — not on ToSQL / Validate / relation
// extraction — because ValidationRule and formula-view SQL paths existed
// pre-fix and any semantic shift there would be a wider change than this
// PR's blast radius. Issue #1680.
func applySalesforceEmptyStringSemantics(expr string) string {
	expr = emptyStringEqPattern.ReplaceAllString(expr, "ISBLANK($1)")
	expr = emptyStringNePattern.ReplaceAllString(expr, "!ISBLANK($1)")
	return expr
}

func replaceFormulaOperators(expression string) string {
	var b strings.Builder
	inSingle := false
	inDouble := false

	for i := 0; i < len(expression); i++ {
		ch := expression[i]

		switch ch {
		case '\'':
			if !inDouble {
				inSingle = !inSingle
			}
			b.WriteByte(ch)
			continue
		case '"':
			if !inSingle {
				inDouble = !inDouble
			}
			b.WriteByte(ch)
			continue
		}

		if inSingle || inDouble {
			b.WriteByte(ch)
			continue
		}

		// Salesforce string concatenation
		if ch == '&' {
			if i+1 < len(expression) && expression[i+1] == '&' {
				b.WriteString("&&")
				i++
				continue
			}
			b.WriteByte('+')
			continue
		}

		// Salesforce inequality
		if ch == '<' && i+1 < len(expression) && expression[i+1] == '>' {
			b.WriteString("!=")
			i++
			continue
		}

		// Salesforce equality
		if ch == '=' {
			prev := byte(0)
			next := byte(0)
			if i > 0 {
				prev = expression[i-1]
			}
			if i+1 < len(expression) {
				next = expression[i+1]
			}
			if prev != '!' && prev != '<' && prev != '>' && prev != '=' && next != '=' {
				b.WriteString("==")
				continue
			}
		}

		b.WriteByte(ch)
	}

	return b.String()
}

// extractStringLiterals replaces all string literals (single- and double-quoted)
// with numbered placeholders and returns the modified expression plus the extracted strings.
// This allows regex-based rewrites to operate safely without modifying string contents.
func extractStringLiterals(expr string) (string, []string) {
	var b strings.Builder
	var literals []string
	i := 0
	for i < len(expr) {
		ch := expr[i]
		if ch == '"' || ch == '\'' {
			quote := ch
			var lit strings.Builder
			lit.WriteByte(ch)
			i++
			for i < len(expr) {
				c := expr[i]
				// Handle backslash-escaped quotes: \"  \'
				if c == '\\' && i+1 < len(expr) && expr[i+1] == quote {
					lit.WriteByte(c)
					lit.WriteByte(expr[i+1])
					i += 2
					continue
				}
				// Handle doubled-quote escape: ""  ''
				if c == quote && i+1 < len(expr) && expr[i+1] == quote {
					lit.WriteByte(c)
					lit.WriteByte(expr[i+1])
					i += 2
					continue
				}
				lit.WriteByte(c)
				i++
				if c == quote {
					break
				}
			}
			b.WriteString(fmt.Sprintf("__STR_%d__", len(literals)))
			literals = append(literals, lit.String())
		} else {
			b.WriteByte(ch)
			i++
		}
	}
	return b.String(), literals
}

// restoreStringLiterals replaces placeholders back with the original string literals.
func restoreStringLiterals(expr string, literals []string) string {
	for i, lit := range literals {
		expr = strings.Replace(expr, fmt.Sprintf("__STR_%d__", i), lit, 1)
	}
	return expr
}

// extractRecordRelations returns the distinct relationship-segment names
// referenced by a normalized expression as __Record.<Name>.<Field>… The
// result is order-preserved per first occurrence so callers that iterate
// for lazy fetching observe deterministic behaviour across evaluations.
//
// A relationship segment is any identifier following `__Record.` that is
// itself followed by another `.` — i.e. used as a step, not a terminal
// field access. Bare field refs like `__Record.Name` are deliberately
// excluded because they need no lazy fetch.
func extractRecordRelations(normalizedExpr string) []string {
	matches := recordRelationPattern.FindAllStringSubmatch(normalizedExpr, -1)
	if len(matches) == 0 {
		return nil
	}
	seen := make(map[string]struct{}, len(matches))
	out := make([]string, 0, len(matches))
	for _, m := range matches {
		name := m[1]
		if _, dup := seen[name]; dup {
			continue
		}
		seen[name] = struct{}{}
		out = append(out, name)
	}
	return out
}

// extractBareRelations returns relation names used as bare dotted prefixes in
// normalizedExpr — e.g. "Account" from TEXT(Account.Region__c), "RecordType"
// from RecordType.Name. These are the Salesforce implicit-record-context
// shorthand where a parent lookup relationship name is used directly without
// the $Record. qualifier. Issue #2092.
//
// Complements extractRecordRelations (which handles __Record.X.Y form after
// $Record. normalization). Both sets are consumed by Evaluate's step-0
// augmentation so bare X.Y paths and $Record.X.Y paths go through the same
// fetcher and the same augmentRecordWithRelations idempotency guard.
func extractBareRelations(normalizedExpr string) []string {
	// Strip string literals so quoted content (e.g. "See Account.Name") never
	// matches as a real relation reference. normalizeFormulaExpression restores
	// literals after its own rewrites, so the expression received here may still
	// contain raw quoted strings.
	stripped, _ := extractStringLiterals(normalizedExpr)
	matches := bareRelationPattern.FindAllStringSubmatch(stripped, -1)
	if len(matches) == 0 {
		return nil
	}
	// Reserved roots that Evaluate injects directly into env — they are never
	// external relations and must not be passed to the Fetcher. Matches the
	// exclusion list in validation_service.go:extractValidationRelationRoots.
	reservedRoots := map[string]struct{}{
		"record": {}, "prior": {}, "user": {}, "env": {}, "var": {},
	}
	seen := make(map[string]struct{}, len(matches))
	out := make([]string, 0, len(matches))
	for _, m := range matches {
		ident := m[2]
		if strings.HasPrefix(ident, "__") {
			continue // normalised global vars (__Record, __Profile, etc.)
		}
		if _, skip := reservedRoots[ident]; skip {
			continue
		}
		if _, dup := seen[ident]; dup {
			continue
		}
		seen[ident] = struct{}{}
		out = append(out, ident)
	}
	return out
}

// augmentRecordWithRelations invokes fetcher for each named relation that is
// not already populated inline on record, returning a new map with the
// fetched records installed under the relation name. The input record is
// never mutated. When no relations require fetching (all inline or already
// returned definitive-null) the original record is returned so the hot path
// stays allocation-free.
//
// Error contract:
//
//   - (nil, nil) from fetcher: definitive null (FK cleared / orphan / unknown
//     relation). Skip silently — formula evaluator treats as null parent.
//
//   - (_, err) from fetcher: indeterminate (transient repo error, metadata-
//     cache miss). Propagate the error so Evaluate can wrap it with
//     ErrRelationFetch and let consumers preserve old values rather than
//     corrupt them with nil. Issue #2057.
func augmentRecordWithRelations(
	record map[string]interface{},
	relations []string,
	fetcher func(record map[string]interface{}, relationName string) (map[string]interface{}, error),
) (map[string]interface{}, error) {
	var augmented map[string]interface{}
	for _, rel := range relations {
		if existing, ok := record[rel]; ok && existing != nil {
			continue // caller already supplied the related record inline
		}
		related, err := fetcher(record, rel)
		if err != nil {
			// Indeterminate: surface to caller. Do NOT swallow — pre-#2057
			// behavior corrupted stored formula values on transient blips.
			return nil, fmt.Errorf("relation %q: %w", rel, err)
		}
		if related == nil {
			// Definitive null: expose as an empty map so the expression engine
			// resolves the identifier (no "unknown name X") and any field access
			// returns nil via map key lookup rather than panicking. Installing nil
			// instead of an empty map would cause "type nil has no field X" in
			// the expr-lang VM because interface{}(nil) has no type to reflect on.
			// Issue #2181 r2.
			if augmented == nil {
				augmented = make(map[string]interface{}, len(record)+len(relations))
				for k, v := range record {
					augmented[k] = v
				}
			}
			augmented[rel] = map[string]interface{}{}
			continue
		}
		if augmented == nil {
			augmented = make(map[string]interface{}, len(record)+len(relations))
			for k, v := range record {
				augmented[k] = v
			}
		}
		augmented[rel] = related
	}
	if augmented != nil {
		return augmented, nil
	}
	return record, nil
}
