package automation

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"

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

// errCriteriaRecordUnavailable marks evaluation failures where the criteria
// COMPARISON BASIS could not be established — record-side formula projection
// or cross-object lookup failure — as opposed to criteria-CONTENT errors
// (unparseable expressions). Submit resolution fails closed on the former
// (the record's true values are unknown, selecting or rejecting a process
// would be a guess) while keeping the long-standing lenient posture for the
// latter (an admin-authored broken expression must not hard-block the only
// active process; see the preserved single-process allow-on-eval-error
// submit test).
var errCriteriaRecordUnavailable = errors.New("approval criteria record unavailable")

func (s *ApprovalService) matchEntryCriteriaAgainstRecord(ctx context.Context, entryFilter, objectName, recordID string, record models.SObject, user *models.UserSession) (bool, error) {
	trimmed := strings.TrimSpace(entryFilter)
	if trimmed == "" {
		return true, nil
	}

	var criteria []criterionItem
	var booleanFilter string

	if strings.HasPrefix(trimmed, "{") {
		// JSON format — parse with encoding/json
		parsed, formula, parsedBooleanFilter, err := parseJSONEntryCriteria(trimmed)
		if err != nil {
			return false, apperrors.NewInternalError("invalid approval entry criteria format", err)
		}

		// If it's a formula, evaluate it
		if formula != "" {
			criteriaRecord, found, sysErr := s.criteriaEvaluationRecord(ctx, objectName, recordID, record, criteriaRootFieldSet(objectName, nil, formula), user)
			if sysErr != nil {
				return false, fmt.Errorf("%w: entry criteria system read: %w", errCriteriaRecordUnavailable, sysErr)
			}
			if !found {
				return false, fmt.Errorf("%w: entry criteria target %s/%s absent under system read", errCriteriaRecordUnavailable, objectName, recordID)
			}
			projected, projErr := s.ensureCriteriaFormulaFieldsProjected(ctx, objectName, recordID, criteriaRecord, nil, formula, user)
			if projErr != nil {
				return false, fmt.Errorf("%w: entry criteria formula projection: %w", errCriteriaRecordUnavailable, projErr)
			}
			matched, evalErr := s.evaluateFormulaAgainstRecord(ctx, formula, objectName, projected, user)
			if evalErr != nil {
				// Criteria-CONTENT error class (existing leniency). evalErr is
				// already value-safe (raw engine text is withheld at the
				// evaluation seam — it can embed operands from the
				// system-read basis), so logging it is fence-clean; the
				// client-facing error still carries no cause at all
				// (leakage fence, #3945).
				s.logger.Error("Failed to evaluate approval entry criteria formula", evalErr)
				return false, apperrors.NewInternalError("failed to evaluate approval entry criteria formula", nil)
			}
			return matched, nil
		}

		criteria = parsed
		booleanFilter = parsedBooleanFilter
	} else {
		// Simple text format: "Field op Value AND Field op Value"
		parsed, err := parseSimpleEntryCriteria(trimmed)
		if err != nil {
			return false, apperrors.NewInternalError("invalid approval entry criteria", err)
		}
		criteria = parsed
	}

	if len(criteria) == 0 {
		return true, nil
	}

	projectionItems := criteria
	if trimmedFilter := strings.TrimSpace(booleanFilter); trimmedFilter != "" {
		projectionItems = booleanFilterReferencedItems(trimmedFilter, criteria)
	}
	criteriaRecord, found, sysErr := s.criteriaEvaluationRecord(ctx, objectName, recordID, record, criteriaRootFieldSet(objectName, projectionItems, ""), user)
	if sysErr != nil {
		return false, fmt.Errorf("%w: entry criteria system read: %w", errCriteriaRecordUnavailable, sysErr)
	}
	if !found {
		return false, fmt.Errorf("%w: entry criteria target %s/%s absent under system read", errCriteriaRecordUnavailable, objectName, recordID)
	}
	record, projErr := s.ensureCriteriaFormulaFieldsProjected(ctx, objectName, recordID, criteriaRecord, projectionItems, "", user)
	if projErr != nil {
		return false, fmt.Errorf("%w: entry criteria formula projection: %w", errCriteriaRecordUnavailable, projErr)
	}

	if strings.TrimSpace(booleanFilter) != "" {
		matched, evalErr := s.evaluateBooleanFilter(ctx, booleanFilter, criteria, objectName, record, user)
		if evalErr != nil {
			return false, apperrors.NewInternalError("failed to evaluate approval entry criteria boolean filter", evalErr)
		}
		return matched, nil
	}
	matched, evalErr := s.evaluateCriteriaItems(ctx, criteria, objectName, record, user)
	if evalErr != nil {
		return false, apperrors.NewInternalError("failed to evaluate approval entry criteria", evalErr)
	}
	return matched, nil
}

// loadSubmitTargetRecord is deliberately a USER-context read: it is the
// submitter-visibility gate (an unreadable target must fault like any other
// read) and its result escapes as SubmitEligibility.TargetRecord, a
// user-visible projection. Criteria evaluation does NOT use it as its basis —
// criteriaEvaluationRecord overlays a field-capped SystemContext read on a
// clone (#3945) so the escaping record never carries FLS-hidden values.
func (s *ApprovalService) loadSubmitTargetRecord(ctx context.Context, objectName, recordID string, user *models.UserSession) (models.SObject, error) {
	record, err := s.querySingleByFilter(ctx, objectName, fmt.Sprintf("Id == '%s'", escapeApprovalFilterValue(recordID)), user)
	if err != nil {
		return nil, apperrors.NewInternalError("failed to load submit target record", err)
	}
	if record == nil {
		return nil, apperrors.NewNotFoundError(objectName, recordID)
	}
	return record, nil
}

// ensureSubmitTargetRecordVisible is the projection-free existence/visibility
// check for callers that discard the record (idempotency probes). Callers that
// evaluate criteria must use loadSubmitTargetRecord instead so formula fields
// are projected (#3913).
func (s *ApprovalService) ensureSubmitTargetRecordVisible(ctx context.Context, objectName, recordID string, user *models.UserSession) error {
	record, err := s.querySingleByFilter(ctx, objectName, fmt.Sprintf("Id == '%s'", escapeApprovalFilterValue(recordID)), user)
	if err != nil {
		return apperrors.NewInternalError("failed to load submit target record", err)
	}
	if record == nil {
		return apperrors.NewNotFoundError(objectName, recordID)
	}
	return nil
}

// booleanFilterReferencedItems returns the subset of items the boolean
// filter statically references. It mirrors evaluateBooleanFilterExpression's
// exact grammar via booleanFilterStaticIndices — NOT a substring scan, so a
// malformed filter like "foo1" is invalid as a whole (its evaluation fails as
// a criteria-CONTENT error) and projects nothing, instead of having its
// digit substring drag a doomed evaluation into the record-unavailable
// fail-closed class (#3913 review rounds 6-7).
func booleanFilterReferencedItems(filter string, items []criterionItem) []criterionItem {
	indices, ok := booleanFilterStaticIndices(filter, len(items))
	if !ok {
		return nil
	}
	referenced := make([]criterionItem, 0, len(indices))
	seen := make(map[int]struct{}, len(indices))
	for _, num := range indices {
		if _, dup := seen[num]; dup {
			continue
		}
		seen[num] = struct{}{}
		referenced = append(referenced, items[num-1])
	}
	return referenced
}

// booleanFilterStaticIndices is the pure-parse mirror of
// evaluateBooleanFilterExpression: same outer-parenthesis stripping, same
// top-level " OR "/" AND " splitting, same integer-terminal rule — but it
// only COLLECTS the reachable 1-based item indices instead of evaluating.
// ok=false means some terminal is not an integer, i.e. the evaluator will
// fail this filter as a criteria-content error, so nothing can evaluate and
// nothing needs projecting. An out-of-range integer terminal evaluates to
// false without touching any item, so it contributes no index but keeps the
// filter valid — exactly the evaluator's behavior.
func booleanFilterStaticIndices(expr string, itemCount int) ([]int, bool) {
	expr = strings.TrimSpace(expr)
	for len(expr) > 2 && expr[0] == '(' && expr[len(expr)-1] == ')' {
		balance := 0
		encloses := true
		for i := 0; i < len(expr); i++ {
			if expr[i] == '(' {
				balance++
			} else if expr[i] == ')' {
				balance--
				if balance == 0 && i < len(expr)-1 {
					encloses = false
					break
				}
			}
		}
		if !encloses || balance != 0 {
			break
		}
		expr = strings.TrimSpace(expr[1 : len(expr)-1])
	}
	for _, opSymbol := range []string{" OR ", " AND "} {
		balance := 0
		for i := 0; i < len(expr)-len(opSymbol)+1; i++ {
			if balance == 0 && strings.EqualFold(expr[i:i+len(opSymbol)], opSymbol) {
				left, leftOK := booleanFilterStaticIndices(expr[:i], itemCount)
				if !leftOK {
					return nil, false
				}
				right, rightOK := booleanFilterStaticIndices(expr[i+len(opSymbol):], itemCount)
				if !rightOK {
					return nil, false
				}
				return append(left, right...), true
			}
			if expr[i] == '(' {
				balance++
			} else if expr[i] == ')' {
				balance--
			}
		}
	}
	if num, err := strconv.Atoi(strings.TrimSpace(expr)); err == nil {
		if num >= 1 && num <= itemCount {
			return []int{num}, true
		}
		return nil, true
	}
	return nil, false
}

// ensureCriteriaFormulaFieldsProjected projects active-formula fields onto
// record only when the criteria about to be evaluated actually REFERENCE an
// active-formula field that is absent or nil in the loaded record: criteria ITEMS
// are matched by field name; a criteria FORMULA is statically analyzed for
// its current-object field references (relationship traversals resolve on
// their own hop; a formula that names only physical fields projects
// nothing). SkipEntryCriteria / blank-criteria / physical-field paths never
// touch the projection port, so an unrelated formula-view failure cannot
// reject or reroute an approval that does not depend on formula values
// (#3913 review rounds 2-3). Re-invocations against an already projected
// record no-op, so per-definition loops project at most once.
func (s *ApprovalService) ensureCriteriaFormulaFieldsProjected(ctx context.Context, objectName, recordID string, record models.SObject, items []criterionItem, formulaExpr string, user *models.UserSession) (models.SObject, error) {
	if record == nil || s.schema == nil {
		return record, nil
	}
	schema := s.schema.GetSchema(ctx, objectName)
	if schema == nil {
		return record, nil
	}
	// Cross-object hops project their own parent record in
	// resolveCrossObjectValue; only same-object references matter here. The
	// derivation is shared with the system-read cap (criteriaReferencedFields)
	// so the projection-need check and the cap cannot drift apart.
	referenced, _ := criteriaReferencedFields(objectName, items, formulaExpr)
	need := false
	for _, name := range referenced {
		field := models.FindField(schema, name)
		if field == nil || !formulastorage.IsActiveComputedField(field) {
			continue
		}
		if permissions.FieldReadBlockedByMetadata(field) {
			// Security-blocked (secret-class) fields are excluded from every
			// select, SystemContext included, so the view row can never carry
			// them — requesting one from the strict projector would misread
			// the guaranteed absence as view drift and fail closed. A
			// criterion on a blocked field keeps the documented degraded
			// posture instead: nil-compare + WARN, same as the physical-field
			// path (#3945 round 2).
			continue
		}
		// A field-capped read can preserve the active formula's key while the
		// physical/base snapshot has no computed value.  Presence alone is not
		// proof that formula projection ran: treating a present nil as complete
		// turns a real zero/false result into an empty comparison operand.
		if value, present := record[field.APIName]; !present || value == nil {
			need = true
			break
		}
	}
	if !need {
		return record, nil
	}
	return s.overlayActiveFormulaProjection(ctx, objectName, recordID, record, referenced, user)
}

// overlayActiveFormulaProjection merges active-formula field values onto a
// record loaded through the internal query path, which does not project
// query-time formula fields. Entry/step criteria and criteria formulas
// authored on such fields would otherwise compare nil and never match —
// combined with the Salesforce default ifCriteriaNotMet=ApproveRecord that
// silently auto-approves past formula-gated steps (#3913).
//
// Projection goes through the same canonical seam the flow runtime uses
// (PersistenceProvider.RecomputeActiveFormulaProjection, issue #1362): one
// projection mechanism, no parallel formula-evaluation path. The call runs
// under SystemContext (#3945): criteria are an admin-authored control, so
// the projection must not silently skip FLS-hidden formula fields the way
// the user-context best-effort mode does — the seam's strict mode fails
// closed on an absent row or absent field instead, which is correct here
// because a referenced value the engine cannot establish is a
// comparison-basis failure. Per that seam's contract a projection error is
// returned to the caller, which keeps each call site's existing
// load-failure semantics. Schemas without active-formula fields return the
// record unchanged.
//
// The merge is capped to the referenced field names (the system authority
// extends exactly to what the criteria author wrote, no wider) and lands on
// a CLONE — the input record is never mutated, so a caller-held snapshot
// that reaches a user-facing surface cannot absorb system-read values.
func (s *ApprovalService) overlayActiveFormulaProjection(ctx context.Context, objectName, recordID string, record models.SObject, referencedFields []string, user *models.UserSession) (models.SObject, error) {
	if record == nil || s.persistence == nil || s.schema == nil {
		return record, nil
	}
	schema := s.schema.GetSchema(ctx, objectName)
	if schema == nil || !formulastorage.HasActiveComputedFields(schema) {
		return record, nil
	}
	// Canonical API names of the referenced fields: passed to the projection
	// seam so the strict variant selects AND fail-close-validates only the
	// fields the criteria author wrote (an unrelated active field's view
	// drift must not fail this scoped read), and reused below as the
	// evaluator-side merge cap — an independent second layer of the same
	// invariant.
	allowed := make(map[string]struct{}, len(referencedFields))
	scoped := make([]string, 0, len(referencedFields))
	for _, name := range referencedFields {
		field := models.FindField(schema, name)
		if field == nil || permissions.FieldReadBlockedByMetadata(field) {
			// Blocked fields never reach the view row (see the need check in
			// ensureCriteriaFormulaFieldsProjected); keeping them out of the
			// request keeps the strict validation honest.
			continue
		}
		if _, dup := allowed[field.APIName]; dup {
			continue
		}
		allowed[field.APIName] = struct{}{}
		scoped = append(scoped, field.APIName)
	}
	sysCtx := constants.WithSecurityContext(ctx, constants.SystemContext,
		"ApprovalService: criteria evaluation strict active-formula projection of authored criteria fields (#3945)")
	snapshot, err := s.persistence.RecomputeActiveFormulaProjection(sysCtx, user, objectName, recordID, scoped)
	if err != nil {
		return nil, apperrors.NewInternalError(
			fmt.Sprintf("failed to project active formula fields for %s/%s approval criteria", objectName, recordID), err)
	}
	if snapshot == nil {
		// The seam returns (nil, nil) for an unwired projector or an absent
		// view row. We only reach here because an evaluated criterion needs
		// an absent active-formula value, so a nil snapshot IS a
		// comparison-basis failure — treating it as success would fall back
		// to the silent nil compare this fix exists to close (#3913 review).
		return nil, apperrors.NewInternalError(
			fmt.Sprintf("active formula projection unavailable for %s/%s approval criteria", objectName, recordID), nil)
	}
	out := cloneAutomationSObject(record)
	for field, value := range snapshot {
		if _, ok := allowed[field]; !ok {
			continue
		}
		out[field] = value
	}
	cacheApprovalCriteriaRootProjection(ctx, objectName, recordID, out, scoped)
	return out, nil
}

// evaluateCriteriaItems runs an AND-match of the given criteria against a
// preloaded record. Handles Salesforce's RecordType ID↔Name canonicalisation
// so callers can express "RecordType equals X" against the stored foreign-key
// column. Empty criteria is treated as match. `objectName` is the SObject the
// record was loaded from; it lets same-object and cross-object field
// references (e.g. "Opportunity.Region__c" on a Quote) be resolved correctly.
func (s *ApprovalService) evaluateCriteriaItems(ctx context.Context, criteria []criterionItem, objectName string, record models.SObject, user *models.UserSession) (bool, error) {
	for _, group := range groupCriteriaForImplicitOr(criteria) {
		groupMatched := false
		for _, c := range group {
			matched, err := s.matchesCriterion(ctx, c, objectName, record, user)
			if err != nil {
				// An evaluation error (e.g. a cross-object formula projection
				// failure) must reach the caller's error posture — converting
				// it into a plain non-match lets ifCriteriaNotMet's
				// ApproveRecord default silently auto-approve (#3913 review).
				return false, err
			}
			if matched {
				groupMatched = true
				break
			}
		}
		if !groupMatched {
			return false, nil
		}
	}

	return true, nil
}

func (s *ApprovalService) matchesCriterion(ctx context.Context, c criterionItem, objectName string, record models.SObject, user *models.UserSession) (bool, error) {
	rawField := strings.TrimSpace(c.field)

	// Cross-object reference handling. A dotted field whose prefix is NOT the
	// current object (e.g. "Opportunity.Region__c" on a Quote record) is
	// resolved by following the standard Salesforce FK convention
	// "<Parent>Id" on the record and loading the related row. Same-object
	// prefixes ("Quote.Autoapproval__c" on a Quote) fall through to the local
	// lookup below. RecordType cross-object refs are uncommon in SF and are
	// intentionally not handled here — they would require a metadata describe.
	if idx := strings.Index(rawField, "."); idx >= 0 {
		prefix := rawField[:idx]
		rest := rawField[idx+1:]
		if !strings.EqualFold(prefix, objectName) {
			value, _, err := s.resolveCrossObjectValue(ctx, prefix, rest, record, user)
			if err != nil {
				// Propagated, not swallowed: a lookup/projection failure is
				// not a non-match (#3913 review). Callers apply their own
				// posture — step criteria treat errors as match (the gate
				// engages), entry criteria fail the submit.
				return false, fmt.Errorf("%w: cross-object criterion %q: %w", errCriteriaRecordUnavailable, rawField, err)
			}
			return s.evaluateCriterionForField(ctx, prefix, rest, value, c.operation, c.value), nil
		}
	}

	normalizedField := normalizeCriterionFieldName(rawField)
	// Salesforce standard: entry criteria for record types are typically expressed
	// against the RecordType Name (e.g., 'RecordTypeId equals Partner' or
	// 'Contract.RecordType equals "Master Services Agreement"'), but records
	// store only the RecordType FK ID under the column 'RecordTypeId'.
	//
	// We canonicalize both forms here so the loop below can compare against
	// either an ID or a Name. See issue #532.
	isRecordTypeField := normalizedField == constants.FieldSysLayoutAssignment_RecordTypeID || normalizedField == "RecordType"
	recordIDForField := normalizedField
	if isRecordTypeField {
		recordIDForField = constants.FieldSysLayoutAssignment_RecordTypeID
	}
	rawRecordValue, fieldPresent := record[recordIDForField]
	if !fieldPresent {
		// A field entirely absent from the loaded record is indistinguishable
		// from a legitimate nil non-match below — this is how the missing
		// formula projection silently auto-approved past formula-gated steps
		// before #3913. Warn so the shape is diagnosable if it recurs: a
		// typo'd/miscased criteria field, a projection class not yet covered,
		// or a security-blocked field (secret/password-class values stay
		// excluded from every select, SystemContext included). FLS-hidden
		// fields no longer reach this branch: the evaluation basis carries a
		// field-capped SystemContext read of the authored criteria fields
		// (#3945).
		s.logger.Warn(fmt.Sprintf("Criterion field %q absent from loaded %s record; comparing as nil", recordIDForField, objectName))
	}
	recordValue := formatRecordValue(rawRecordValue)
	if s.evaluateCriterionForField(ctx, objectName, normalizedField, rawRecordValue, c.operation, c.value) {
		return true, nil
	}

	if isRecordTypeField && recordValue != "" {
		rtName, lookupErr := s.resolveRecordTypeName(ctx, recordValue, user)
		if lookupErr != nil {
			// The RecordTypeId under resolution is the record's field VALUE
			// and may come from the system-read basis — like every other
			// hidden-value-capable datum it stays out of logs (leakage
			// fence, #3945); object + criterion field name carry the
			// diagnostic weight.
			s.logger.Warn(fmt.Sprintf("Failed to resolve RecordType Name for %s entry criteria (record-type id withheld), treating as no match: %s", objectName, lookupErr.Error()))
			return false, nil
		}
		if rtName != "" && evaluateCriterion(rtName, c.operation, c.value) {
			return true, nil
		}
	}

	return false, nil
}

func (s *ApprovalService) evaluateCriterionForField(ctx context.Context, objectName, fieldName string, recordValue interface{}, operation, criterionValue string) bool {
	field := s.criterionFieldMeta(ctx, objectName, fieldName)
	if field != nil && constants.NormalizeFieldType(string(field.Type)) == constants.FieldTypeMultiPicklist {
		switch operation {
		case "includes":
			matched, err := includesForField(field, recordValue, approvalMultiPicklistCriterionLiteral(criterionValue))
			if err == nil {
				return matched
			}
			s.logger.Warn(fmt.Sprintf("Approval criterion includesForField failed for %s.%s, falling back to scalar comparison: %s", objectName, fieldName, err.Error()))
		case "excludes":
			matched, err := excludesForField(field, recordValue, approvalMultiPicklistCriterionLiteral(criterionValue))
			if err == nil {
				return matched
			}
			s.logger.Warn(fmt.Sprintf("Approval criterion excludesForField failed for %s.%s, falling back to scalar comparison: %s", objectName, fieldName, err.Error()))
		}
	}
	return evaluateCriterion(formatRecordValue(recordValue), operation, criterionValue)
}

func (s *ApprovalService) criterionFieldMeta(ctx context.Context, objectName, fieldName string) *models.FieldMetadata {
	if s == nil || s.schema == nil {
		return nil
	}
	objectName = strings.TrimSpace(objectName)
	fieldName = strings.TrimSpace(fieldName)
	if objectName == "" || fieldName == "" {
		return nil
	}
	if idx := strings.Index(fieldName, "."); idx >= 0 {
		prefix := strings.TrimSpace(fieldName[:idx])
		rest := strings.TrimSpace(fieldName[idx+1:])
		if prefix == "" || rest == "" {
			return nil
		}
		if !strings.EqualFold(prefix, objectName) {
			return s.criterionFieldMeta(ctx, prefix, rest)
		}
		fieldName = rest
	}
	schema := s.schema.GetSchema(ctx, objectName)
	if schema == nil {
		return nil
	}
	return models.FindField(schema, fieldName)
}

func approvalMultiPicklistCriterionLiteral(value string) interface{} {
	if !strings.Contains(value, ",") {
		return value
	}
	parts := strings.Split(value, ",")
	items := make([]string, 0, len(parts))
	for _, part := range parts {
		for _, item := range strings.Split(part, ";") {
			item = strings.TrimSpace(item)
			if item != "" {
				items = append(items, item)
			}
		}
	}
	return items
}

// formatRecordValue renders a record field value for criterion comparison.
// Returns "" for nil/missing keys so `equals ""` can match blank fields instead
// of comparing against the literal "<nil>".
func formatRecordValue(v interface{}) string {
	if v == nil {
		return ""
	}
	return fmt.Sprintf("%v", v)
}

func groupCriteriaForImplicitOr(criteria []criterionItem) [][]criterionItem {
	groups := make([][]criterionItem, 0, len(criteria))
	groupIndexByKey := make(map[string]int)

	for _, criterion := range criteria {
		if key, ok := implicitOrGroupKey(criterion); ok {
			if idx, exists := groupIndexByKey[key]; exists {
				groups[idx] = append(groups[idx], criterion)
				continue
			}
			groupIndexByKey[key] = len(groups)
		}
		groups = append(groups, []criterionItem{criterion})
	}

	return groups
}

func implicitOrGroupKey(c criterionItem) (string, bool) {
	switch strings.ToLower(strings.TrimSpace(c.operation)) {
	case "equals", "includes":
		return normalizeCriterionFieldName(c.field) + "::" + strings.ToLower(strings.TrimSpace(c.operation)), true
	default:
		return "", false
	}
}

func normalizeCriterionFieldName(fieldName string) string {
	fieldName = strings.TrimSpace(fieldName)
	if idx := strings.Index(fieldName, "."); idx >= 0 {
		fieldName = fieldName[idx+1:]
	}
	return fieldName
}

// evaluateBooleanFilter evaluates a boolean filter expression like "1 OR 2",
// "1 AND 2", or "(1 OR 2) AND 3" against the provided criteria and record.
// `objectName` is propagated so individual terms can resolve cross-object field
// references.
func (s *ApprovalService) evaluateBooleanFilter(ctx context.Context, expr string, criteria []criterionItem, objectName string, record models.SObject, user *models.UserSession) (bool, error) {
	if len(criteria) == 0 {
		return false, fmt.Errorf("no criteria items to evaluate against")
	}

	result, err := s.evaluateBooleanFilterExpression(ctx, expr, criteria, objectName, record, user)
	if err != nil {
		return false, err
	}
	return result, nil
}

func (s *ApprovalService) evaluateBooleanFilterExpression(ctx context.Context, expr string, criteria []criterionItem, objectName string, record models.SObject, user *models.UserSession) (bool, error) {
	// Remove outer parentheses only if they enclose the whole expression.
	// Naively stripping whenever expr starts with '(' and ends with ')' is wrong
	// for shapes like "(3 OR 5) AND 4 AND (6 OR 7)".
	expr = strings.TrimSpace(expr)
	for len(expr) > 2 && expr[0] == '(' && expr[len(expr)-1] == ')' {
		balance := 0
		encloses := true
		for i := 0; i < len(expr); i++ {
			if expr[i] == '(' {
				balance++
			} else if expr[i] == ')' {
				balance--
				if balance == 0 && i < len(expr)-1 {
					encloses = false
					break
				}
			}
		}
		if !encloses || balance != 0 {
			break
		}
		expr = strings.TrimSpace(expr[1 : len(expr)-1])
	}

	ops := []string{" OR ", " AND "}
	for _, opSymbol := range ops {
		balance := 0
		for i := 0; i < len(expr)-len(opSymbol)+1; i++ {
			if balance == 0 && i+len(opSymbol) <= len(expr) && strings.EqualFold(expr[i:i+len(opSymbol)], opSymbol) {
				left := expr[:i]
				right := expr[i+len(opSymbol):]

				leftResult, leftErr := s.evaluateBooleanFilterExpression(ctx, strings.TrimSpace(left), criteria, objectName, record, user)
				if leftErr != nil {
					return false, leftErr
				}

				rightResult, rightErr := s.evaluateBooleanFilterExpression(ctx, strings.TrimSpace(right), criteria, objectName, record, user)
				if rightErr != nil {
					return false, rightErr
				}

				if opSymbol == " OR " {
					return leftResult || rightResult, nil
				} else if opSymbol == " AND " {
					return leftResult && rightResult, nil
				}
			}

			if expr[i] == '(' {
				balance++
			} else if expr[i] == ')' {
				balance--
			}
		}
	}

	term := strings.TrimSpace(expr)
	if _, err := strconv.Atoi(term); err == nil {
		num, _ := strconv.Atoi(term)
		if num < 1 || num > len(criteria) {
			return false, nil
		}
		return s.evaluateCriteriaItems(ctx, []criterionItem{criteria[num-1]}, objectName, record, user)
	}

	if len(term) >= 2 && term[0] == '(' && term[len(term)-1] == ')' {
		return s.evaluateBooleanFilterExpression(ctx, term[1:len(term)-1], criteria, objectName, record, user)
	}

	return false, fmt.Errorf("unable to parse boolean expression term: %s", term)
}

// stepCriteriaMatches reports whether a ProcessNode's step criteria from the
// ProcessDefinition metadata match the target record. The evaluation basis is
// a field-capped SystemContext read of the authored criteria fields (#3945):
// step routing is an admin-authored control and must not depend on the acting
// user's FLS or sharing. Both production callers (Submit's initial-step
// selection and Approve/Reject's transition) already ran under a
// wholesale-elevated ctx, so for them this narrows the basis from the full
// system row to the authored field set (least-privilege) and makes the
// basis explicit and caller-independent rather than fixing a live
// mis-route; the live #3945 mis-route was on the entry-criteria path, which
// runs pre-elevation. Records absent under the system read (deleted) and
// evaluation errors are treated as a match to preserve legacy runtime
// behavior. Definition decoder errors are returned so invalid product
// metadata cannot bypass authored step routing through a legacy ProcessNode
// projection.
func (s *ApprovalService) stepCriteriaMatches(ctx context.Context, def models.SObject, node models.SObject, objectName, recordID string, user *models.UserSession) (bool, error) {
	metadata, metadataErr := s.approvalMetadataForDefinition(def)
	if metadataErr != nil {
		return false, metadataErr
	}
	step := metadataStepForProcessNode(metadata, node)
	if step == nil {
		// The treat-as-match fallback preserves legacy runtime behavior for
		// definitions without authored metadata, but it has also silently
		// absorbed regressions where authored criteria stopped being FOUND
		// (NX#3913 masking, NX#3997). Keep the behavior, lose the silence.
		s.logger.Warn(fmt.Sprintf("stepCriteria: %s; treating as match (legacy behavior)", describeMetadataStepGap(def, node, metadata)))
		return true, nil
	}
	if step.StepCriteria == nil {
		return true, nil
	}
	sc := step.StepCriteria

	if sc.Formula != "" {
		result, err := s.evaluateFormula(ctx, sc.Formula, objectName, recordID, user)
		if err != nil {
			s.logger.Warn(fmt.Sprintf("stepCriteria: formula evaluation failed for %s/%s, treating as match: %s", objectName, recordID, err.Error()))
			return true, nil
		}
		return result, nil
	}

	if len(sc.CriteriaItems) == 0 {
		return true, nil
	}
	items := make([]criterionItem, 0, len(sc.CriteriaItems))
	for _, it := range sc.CriteriaItems {
		items = append(items, criterionItem{field: it.Field, operation: it.Operation, value: it.Value})
	}

	projectionItems := items
	if trimmedFilter := strings.TrimSpace(sc.BooleanFilter); trimmedFilter != "" {
		projectionItems = booleanFilterReferencedItems(trimmedFilter, items)
	}
	base, found, loadErr := s.criteriaEvaluationRecord(ctx, objectName, recordID, nil, criteriaRootFieldSet(objectName, projectionItems, ""), user)
	if loadErr != nil || !found {
		if loadErr == nil {
			loadErr = errors.New("record absent under system read")
		}
		s.logger.Warn(fmt.Sprintf("stepCriteria: cannot load %s/%s, treating as match: %v", objectName, recordID, loadErr))
		return true, nil
	}
	criteriaRecord, projErr := s.ensureCriteriaFormulaFieldsProjected(ctx, objectName, recordID, base, projectionItems, "", user)
	if projErr != nil {
		s.logger.Warn(fmt.Sprintf("stepCriteria: cannot project formula fields for %s/%s, treating as match: %v", objectName, recordID, projErr))
		return true, nil
	}
	if strings.TrimSpace(sc.BooleanFilter) == "" {
		matched, evalErr := s.evaluateCriteriaItems(ctx, items, objectName, criteriaRecord, user)
		if evalErr != nil {
			s.logger.Warn(fmt.Sprintf("stepCriteria: evaluation error for %s/%s, treating as match: %v", objectName, recordID, evalErr))
			return true, nil
		}
		return matched, nil
	}

	match, evalErr := s.evaluateBooleanFilter(ctx, strings.TrimSpace(sc.BooleanFilter), items, objectName, criteriaRecord, user)
	if evalErr != nil {
		s.logger.Warn(fmt.Sprintf("stepCriteria: error evaluating booleanFilter %q, treating as match: %v", sc.BooleanFilter, evalErr))
		return true, nil
	}
	return match, nil
}

// evaluateFormula evaluates a formula against a record and returns true if the
// formula evaluates to true.
func (s *ApprovalService) evaluateFormula(ctx context.Context, formulaStr, objectName, recordID string, user *models.UserSession) (bool, error) {
	if s.formulaEngine == nil {
		s.logger.Warn("Formula evaluation skipped: formula engine not available, treating as match")
		return true, nil
	}

	base, found, loadErr := s.criteriaEvaluationRecord(ctx, objectName, recordID, nil, criteriaRootFieldSet(objectName, nil, formulaStr), user)
	if loadErr != nil || !found {
		if loadErr == nil {
			loadErr = errors.New("record absent under system read")
		}
		s.logger.Error("Failed to retrieve record for formula evaluation", loadErr)
		return false, fmt.Errorf("could not load record %s/%s for formula evaluation: %w", objectName, recordID, loadErr)
	}
	criteriaRecord, projErr := s.ensureCriteriaFormulaFieldsProjected(ctx, objectName, recordID, base, nil, formulaStr, user)
	if projErr != nil {
		s.logger.Error("Failed to project formula fields for formula evaluation", projErr)
		return false, fmt.Errorf("could not project formula fields for %s/%s formula evaluation: %w", objectName, recordID, projErr)
	}
	return s.evaluateFormulaAgainstRecord(ctx, formulaStr, objectName, criteriaRecord, user)
}

func (s *ApprovalService) evaluateFormulaAgainstRecord(ctx context.Context, formulaStr string, objectName string, record models.SObject, user *models.UserSession) (bool, error) {
	// A dynamic record member access (record[Key]) reads the field named by
	// Key's runtime value: the field-capped basis (#3945) cannot cover it
	// statically, and evaluating anyway would compare a silently under-read
	// nil — the exact gate-flip class this fix closes. The shape is outside
	// the Salesforce formula grammar (SF formulas cannot express dynamic
	// field access) and frozen schema validation already rejects it for
	// authored formula FIELDS; criteria metadata bypasses program checks, so
	// it is refused here as a criteria-CONTENT error (entry keeps the
	// long-standing content leniency; step treats it as match, engaging the
	// gate). Plain parse errors are NOT pre-checked: the engine fails those
	// identically on its own, keeping one error source.
	if _, refsErr := formula.CollectCurrentObjectFieldReferences(formulaStr); errors.Is(refsErr, formula.ErrDynamicRecordFieldAccess) {
		return false, fmt.Errorf("approval criteria formula uses dynamic record field access, which cannot be field-capped and is outside the supported criteria grammar: %w", refsErr)
	}
	userVars := formula.BuildFormulaUserContext(user)
	hasSuperUserCapability := s.hasSuperUserCapability(ctx, user)
	formulaContext := &formula.Context{
		Record: record,
		User: map[string]interface{}{
			"IsSystemAdmin": hasSuperUserCapability,
		},
		Variables: userVars,
		Env: map[string]interface{}{
			"Now":   time.Now().Format(time.RFC3339),
			"Today": time.Now().Truncate(24 * time.Hour).Format("2006-01-02"),
		},
	}
	if s.schema != nil && objectName != "" {
		formulaContext.Schema = s.schema.GetSchema(ctx, objectName)
	}

	formula.InjectSuperUserPermissions(formulaContext.Variables, formulaStr, hasSuperUserCapability)

	result, err := s.formulaEngine.Evaluate(formulaStr, formulaContext)
	if err != nil {
		// Engine error text can embed operand VALUES (e.g.
		// formulaEpochMillisFloatArg echoes a malformed argument back with
		// %v); with the evaluation basis carrying system-read values, raw
		// engine detail must reach neither callers nor logs (leakage fence,
		// #3945). The authored expression is admin metadata and carries the
		// diagnostic weight; the engine text is withheld from the wrap
		// entirely rather than filtered.
		return false, fmt.Errorf("formula evaluation error for approval criteria expression %q on %s (engine detail withheld: it may embed field values)", formulaStr, objectName)
	}

	boolResult, ok := result.(bool)
	if !ok {
		switch v := result.(type) {
		case string:
			boolResult = v != ""
		case int, int32, int64:
			boolResult = fmt.Sprintf("%v", v) != "0"
		case float32, float64:
			boolResult = fmt.Sprintf("%v", v) != "0"
		default:
			boolResult = result != nil
		}
	}

	return boolResult, nil
}

// resolveRecordTypeName looks up the Name of a RecordType by its ID.
// Returns ("", nil) if the RecordType is not found. The lookup goes through
// the same field-capped SystemContext criteria read as the record itself
// (#3945): the RecordType Name-form criterion ("RecordType equals X") is
// part of criteria evaluation, so its outcome must not depend on the acting
// user's ability to read the RecordType row — and the widened authority
// stays capped to the one field the canonicalization needs.
func (s *ApprovalService) resolveRecordTypeName(ctx context.Context, recordTypeID string, user *models.UserSession) (string, error) {
	if recordTypeID == "" {
		return "", nil
	}
	row, found, err := s.approvalCriteriaSystemRead(ctx, constants.TableRecordType, recordTypeID, []string{constants.FieldSysRecordType_Name}, user)
	if err != nil {
		// The inner read error embeds the record-type id (the record's
		// RecordTypeId field VALUE, possibly system-read); withhold it like
		// every other hidden-value-capable datum (leakage fence, #3945).
		return "", errors.New("RecordType Name lookup failed (detail withheld: the record-type id is a field value)")
	}
	if !found {
		return "", nil
	}
	name, _ := row[constants.FieldSysRecordType_Name].(string)
	return name, nil
}

// jsonEntryCriteria represents the JSON entry criteria format from Salesforce sync.
type jsonEntryCriteria struct {
	Formula       string             `json:"formula"`
	BooleanFilter string             `json:"booleanFilter"`
	CriteriaItems []jsonCriteriaItem `json:"criteriaItems"`
}

type jsonCriteriaItem struct {
	Field     string `json:"field"`
	Operation string `json:"operation"`
	Value     string `json:"value"`
}

// parseJSONEntryCriteria parses JSON entry criteria into a formula, boolean
// filter, and criterion items.
func parseJSONEntryCriteria(raw string) ([]criterionItem, string, string, error) {
	var jc jsonEntryCriteria
	if err := json.Unmarshal([]byte(raw), &jc); err != nil {
		return nil, "", "", fmt.Errorf("invalid JSON: %w", err)
	}
	if jc.Formula != "" {
		return nil, jc.Formula, "", nil
	}
	if len(jc.CriteriaItems) == 0 {
		return nil, "", jc.BooleanFilter, nil
	}

	var result []criterionItem
	for _, item := range jc.CriteriaItems {
		result = append(result, criterionItem{
			field:     strings.TrimSpace(item.Field),
			operation: item.Operation,
			value:     item.Value,
		})
	}
	return result, "", jc.BooleanFilter, nil
}

type criterionItem struct {
	field     string
	operation string
	value     string
}

type approvalCriterionMultiMode int

const (
	approvalCriterionSingle approvalCriterionMultiMode = iota
	approvalCriterionAny
	approvalCriterionNone
)

type approvalCriterionOperatorSpec struct {
	name            string
	flowOperator    string
	multiMode       approvalCriterionMultiMode
	caseInsensitive bool
	negate          bool
}

var approvalCriterionOperators = []approvalCriterionOperatorSpec{
	{name: "greaterOrEqual", flowOperator: "GreaterThanOrEqualTo"},
	{name: "lessOrEqual", flowOperator: "LessThanOrEqualTo"},
	{name: "greaterThan", flowOperator: "GreaterThan"},
	{name: "lessThan", flowOperator: "LessThan"},
	{name: "notEqual", flowOperator: "EqualTo", multiMode: approvalCriterionNone, caseInsensitive: true},
	{name: "notContain", flowOperator: "Contains", caseInsensitive: true, negate: true},
	{name: "startsWith", flowOperator: "StartsWith", caseInsensitive: true},
	{name: "contains", flowOperator: "Contains", caseInsensitive: true},
	{name: "excludes", flowOperator: "EqualTo", multiMode: approvalCriterionNone, caseInsensitive: true},
	{name: "includes", flowOperator: "EqualTo", multiMode: approvalCriterionAny, caseInsensitive: true},
	{name: "equals", flowOperator: "EqualTo", multiMode: approvalCriterionAny, caseInsensitive: true},
}

var approvalCriterionOperatorByName = func() map[string]approvalCriterionOperatorSpec {
	byName := make(map[string]approvalCriterionOperatorSpec, len(approvalCriterionOperators))
	for _, op := range approvalCriterionOperators {
		byName[op.name] = op
	}
	return byName
}()

func approvalCriterionOperatorNamesForParsing() []string {
	names := make([]string, 0, len(approvalCriterionOperators))
	for _, op := range approvalCriterionOperators {
		names = append(names, op.name)
	}
	return names
}

// parseSimpleEntryCriteria parses "Field op Value AND Field op Value" format.
func parseSimpleEntryCriteria(input string) ([]criterionItem, error) {
	var result []criterionItem
	parts := strings.Split(input, " AND ")
	for _, part := range parts {
		part = strings.TrimSpace(part)
		if part == "" {
			continue
		}
		var matched bool
		for _, op := range approvalCriterionOperatorNamesForParsing() {
			idx := strings.Index(part, " "+op+" ")
			if idx >= 0 {
				field := strings.TrimSpace(part[:idx])
				value := strings.TrimSpace(part[idx+len(" "+op+" "):])
				result = append(result, criterionItem{field: field, operation: op, value: value})
				matched = true
				break
			}
		}
		if !matched {
			return nil, fmt.Errorf("unrecognized criterion: %s", part)
		}
	}
	return result, nil
}

// evaluateCriterion compares a record field value against a criterion.
// criterionValue may be comma-separated for multi-value matching (e.g., "NA,EMEA").
func evaluateCriterion(recordValue, operation, criterionValue string) bool {
	op, ok := approvalCriterionOperatorByName[operation]
	if !ok {
		return false
	}

	values, trimRecordValue := approvalCriterionValues(criterionValue, op.multiMode)
	if trimRecordValue {
		recordValue = strings.TrimSpace(recordValue)
	}

	switch op.multiMode {
	case approvalCriterionAny:
		for _, value := range values {
			if approvalCriterionMatchesValue(recordValue, value, op) {
				return true
			}
		}
		return false
	case approvalCriterionNone:
		for _, value := range values {
			if approvalCriterionMatchesValue(recordValue, value, op) {
				return false
			}
		}
		return true
	default:
		return approvalCriterionMatchesValue(recordValue, criterionValue, op)
	}
}

func approvalCriterionValues(raw string, mode approvalCriterionMultiMode) ([]string, bool) {
	if mode == approvalCriterionSingle || !strings.Contains(raw, ",") {
		return []string{raw}, false
	}
	parts := strings.Split(raw, ",")
	values := make([]string, 0, len(parts))
	for _, part := range parts {
		values = append(values, strings.TrimSpace(part))
	}
	return values, true
}

func approvalCriterionMatchesValue(recordValue, criterionValue string, op approvalCriterionOperatorSpec) bool {
	leftValue := recordValue
	rightValue := criterionValue
	if op.caseInsensitive {
		leftValue = strings.ToLower(leftValue)
		rightValue = strings.ToLower(rightValue)
	}

	matched, handled := evaluateFlowBinaryOperatorCore(op.flowOperator, leftValue, rightValue, flowBinaryOperatorOptions{}, nil)
	if !handled {
		return false
	}
	if op.negate {
		return !matched
	}
	return matched
}
