package automation

import (
	"context"
	"fmt"
	"strings"

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

// rootObjectType resolves the object type of a root context variable so __r
// traversal issues DB lookups against the correct object.
//
// Rules (in order):
//  1. $Record / $Record__Old / $Record__Prior → use frc.ObjectType()
//     (the flow trigger object)
//  2. object-type metadata on the record itself
//  3. Fallback to frc.ObjectType() — safer than empty, but noted as imperfect
//     for non-$Record variables that lack object-type metadata.
func rootObjectType(frc *FlowRuntimeContext, rootName string, record interface{}) string {
	rootType := frc.ObjectType()
	if rootName == "$Record" || rootName == "$Record__Old" || rootName == "$Record__Prior" {
		return rootType
	}
	// Try neutral object-type metadata, with v63 attributes.type fallback.
	if typeName := models.RecordObjectType(record); typeName != "" {
		return typeName
	}
	return rootType
}

// resolveRelationshipFK maps a relationship segment to its FK column, layering
// a metadata-driven lookup on top of the metadata-free relationshipFKField.
//
// Resolution order:
//  1. Custom "__r" convention.
//  2. "<Relation>Id" convention against the current object's metadata: if
//     `currentObjectType` has a single-target Reference field named
//     `segment + "Id"`, return that field. This keeps the engine
//     admin-configurable — adding a new reference field via metadata
//     immediately makes it traversable without a code change.
//
// Returns "" when neither path resolves.
func (e *FlowExecutor) resolveRelationshipFK(ctx context.Context, currentObjectType, segment string) string {
	if segment == "" {
		return ""
	}
	if strings.HasSuffix(segment, "__r") {
		return strings.TrimSuffix(segment, "__r") + "__c"
	}
	if currentObjectType == "" {
		return ""
	}
	candidateFK := segment + "Id"
	// Gate on non-polymorphic targets only. loadRelatedRecord picks
	// ReferenceTo[0] unconditionally — widening the reachable set of
	// relationships to polymorphic ones (e.g. Task.WhoId → [Contact,Lead])
	// would let the resolver silently query the wrong object. Polymorphic
	// traversal is tracked separately; this fix keeps to single-target refs.
	if fm := e.lookupFieldMeta(ctx, currentObjectType, candidateFK); fm != nil && len(fm.ReferenceTo) == 1 {
		return candidateFK
	}
	return ""
}

// resolveRelationshipFKWithSchema augments resolveRelationshipFK with Tier 1
// (RelationshipName lookup on the full schema) so admin-renamed relationships
// resolve identically across records and automation formula evaluation.
//
// Background (PR #2057 IANTHEREAL blocker 4): records calls
// formula.ResolveRelationFK(schema, segment) which checks RelationshipName on
// every field. Automation historically skipped Tier 1 because the executor
// only had per-field metadata accessors, so an admin who renamed a
// relationship by setting RelationshipName='MyParent' on field 'CustomFK__c'
// would see records resolve "MyParent" → "CustomFK__c" while automation
// returned "" (definitive null). Same fact, two read paths — R10 violation.
//
// Fix: read the full schema via the metadata service once when the formula
// fetcher is constructed and pass it through here. Tier 1 fires when schema
// is available; Tier 2/3/4 (with the polymorphic gate) remain the fallback.
//
// schema may be nil (test executor without a metadata service, or a cache
// miss). When nil, behavior reduces to the original resolveRelationshipFK.
func (e *FlowExecutor) resolveRelationshipFKWithSchema(ctx context.Context, schema *models.ObjectMetadata, currentObjectType, segment string) string {
	if segment == "" {
		return ""
	}
	// Tier 1: RelationshipName match on the full schema (metadata-driven).
	if schema != nil {
		for i := range schema.Fields {
			f := &schema.Fields[i]
			if f.RelationshipName != nil && *f.RelationshipName == segment {
				return f.APIName
			}
		}
	}
	// Tier 2/3/4: existing convention-first resolver with polymorphism gate.
	return e.resolveRelationshipFK(ctx, currentObjectType, segment)
}

// implicitRelationshipProven reports whether relationshipName is a proven lookup
// relationship on objType — used exclusively by resolveAssignmentRHSValueWithPresence.
//
// Deliberately excludes the convention-only Tier 2 path (`__r → __c` suffix
// substitution without metadata proof). Without this exclusion, any unqualified
// `Missing__r.Field` name would trigger a recursive lookup because the suffix
// alone satisfies the convention guard, even when the object has no such field.
//
// Three proof tiers (all metadata-backed):
//   - Tier 1: full schema has a field whose RelationshipName equals name.
//   - Tier 3 (standard): name+"Id" is a single-reference FK field in per-field metadata.
//   - Tier 3b (custom __r): name ends in "__r" and the derived "__c" field exists in per-field metadata.
func (e *FlowExecutor) implicitRelationshipProven(ctx context.Context, schema *models.ObjectMetadata, objType, name string) bool {
	if schema != nil {
		for i := range schema.Fields {
			f := &schema.Fields[i]
			if f.RelationshipName != nil && *f.RelationshipName == name {
				return true
			}
		}
	}
	if fm := e.lookupFieldMeta(ctx, objType, name+"Id"); fm != nil && len(fm.ReferenceTo) == 1 {
		return true
	}
	if strings.HasSuffix(name, "__r") {
		if fm := e.lookupFieldMeta(ctx, objType, strings.TrimSuffix(name, "__r")+"__c"); fm != nil && len(fm.ReferenceTo) > 0 {
			return true
		}
	}
	return false
}

// resolveNestedReferenceCtx resolves a dot-notation reference with lazy-load
// support for `__r` cross-object segments (design GAP 6).
//
// Behavior:
//   - Non-`__r` segments behave exactly like resolveNestedReference.
//   - A `__r` segment triggers a one-shot DB lookup of the related record.
//   - Related records are cached per-interview on FlowRuntimeContext.RCache(),
//     keyed by "{objectType}:{recordId}", to prevent repeated DB hits within
//     a single interview.
//   - When the FK is absent or the related record is missing, this returns nil
//     (no panic) and logs a warning.
//   - When `__r` is resolved while inside a Loop (loopDepth > 0), a warning is
//     emitted to flag the N+1 risk called out in the design doc.
//
// Known limitation (design doc): the cache is populated once per reference per
// interview. If a flow action writes to the related record, subsequent reads
// in the same interview return stale data. Acceptable for current flows.
func (e *FlowExecutor) resolveNestedReferenceCtx(ctx context.Context, interview *models.FlowInterview, user *models.UserSession, ref string) interface{} {
	if interview == nil {
		return e.resolveNestedReference(nil, ref)
	}
	parts := strings.Split(ref, ".")
	if len(parts) == 0 {
		return nil
	}

	contextData := interview.ContextData
	rootName := parts[0]
	current, rootPresent := contextData[rootName]
	if !rootPresent {
		return nil
	}
	if current == nil {
		return nil
	}

	// Track the object type of the current record as we walk. The type of the
	// ROOT variable is determined per-variable: $Record / $Record__Old /
	// $Record__Prior map to the flow's trigger object (from
	// FlowRuntimeContext.ObjectType()); other
	// variables (e.g. results of RecordLookup) carry their own type via
	// neutral object-type metadata. Without that, we fall back to the
	// trigger object type — good enough for the common case, and safer than
	// silently using the wrong type.
	frc := flowRuntimeContextFromCtx(ctx)
	currentObjectType := rootObjectType(frc, rootName, current)

	for i := 1; i < len(parts); i++ {
		fieldName := parts[i]

		// Handle `__r` / standard cross-object traversal. Schema-aware
		// resolution per CLAUDE.md R10 (PR #2057 round-6 F1): same
		// admin-authored `RelationshipName` must resolve identically across
		// formula evaluation, nested-reference traversal, and validation
		// formulas — all four readers share the same SSoT FK derivation.
		schema := e.lookupObjectSchema(ctx, currentObjectType)
		if fkField := e.resolveRelationshipFKWithSchema(ctx, schema, currentObjectType, fieldName); fkField != "" {
			if inline := lookupField(current, fieldName); inline != nil {
				current = inline
				currentObjectType = refineObjectType(inline, currentObjectType)
				continue
			}
			related, nextType, resolved := e.loadRelatedRecord(ctx, interview, user, schema, current, currentObjectType, fieldName, fkField)
			if !resolved {
				// R10 (issue #2047): indeterminate result (metadata-miss or
				// transient error). Emit a WRN so the miss is observable,
				// then return nil — same value as before, but now the log
				// distinguishes this from a definitive null FK.
				// Chosen interpretation: treat as nil (preserve existing
				// return-nil semantics) so downstream conditions see nil
				// rather than stale data. This matches the write-path
				// callers' behavior (skip-write on unresolved).
				if e.logger != nil {
					e.logger.Warn(fmt.Sprintf(
						"flow.r10: resolveNestedReferenceCtx skipping `%s` — loadRelatedRecord returned indeterminate (metadata-miss or query error)",
						fieldName,
					))
				}
				return nil
			}
			if related == nil {
				return nil
			}
			current = related
			currentObjectType = nextType
			continue
		}

		switch obj := current.(type) {
		case models.SObject:
			current = obj[fieldName]
		case map[string]interface{}:
			current = obj[fieldName]
		default:
			return nil
		}

		if current == nil {
			return nil
		}

		// After stepping into a nested map/sObject, refresh currentObjectType
		// from the nested record's object-type metadata when available. Otherwise a
		// downstream `__r` hop would look up metadata on the PARENT object
		// rather than the current record's object.
		if newType := models.RecordObjectType(current); newType != "" {
			currentObjectType = newType
		}
	}

	return current
}

// resolveNestedReferenceCtxWithPresence is the leaf-existence-tracking
// sibling of resolveNestedReferenceCtx, used by ResolveFlowResource
// so RecordCreate stage 2 can distinguish a deep explicit-null
// (`$lead.SomeNullableField` where the leaf KEY exists with value nil —
// author/caller intent: "null this field") from a deep absent key
// (`$lead.MissingField` — let defaults fill).
//
// Presence is tracked at every hop via the two-value map read `v, ok := m[k]`:
//   - root variable missing from ContextData              → (nil, false)
//   - any intermediate key absent from its parent map     → (nil, false)
//   - any intermediate key present-but-nil (can't walk)   → (nil, false)
//   - __r relationship fails to load or resolves to nil   → (nil, false)
//     (preserves the existing resolveNestedReferenceCtx semantics for
//     cross-object hops; explicit-null on inline __r refs is an edge
//     case not exercised by shipped flows)
//   - leaf key present, value may be anything incl. nil   → (value, true)
//
// The function is otherwise identical in shape to resolveNestedReferenceCtx
// so future maintenance updates apply consistently to both.
func (e *FlowExecutor) resolveNestedReferenceCtxWithPresence(ctx context.Context, interview *models.FlowInterview, user *models.UserSession, ref string, firstNullHopIndex *int) (interface{}, bool) {
	if firstNullHopIndex != nil {
		*firstNullHopIndex = -1
	}
	if interview == nil {
		// No interview context — presence is undefined. Preserve the
		// existing nil-return behavior; mark absent so the caller skips
		// rather than writing a spurious nil.
		return e.resolveNestedReference(nil, ref), false
	}
	parts := strings.Split(ref, ".")
	if len(parts) == 0 {
		return nil, false
	}

	contextData := interview.ContextData
	rootName := parts[0]
	current, rootPresent := contextData[rootName]
	if !rootPresent {
		return nil, false
	}
	// Single-part "nested" ref is really just a root lookup — return
	// present-with-value (value may be nil). The caller checking exists=true
	// then honors nil as the author's intent.
	if len(parts) == 1 {
		return current, true
	}
	// Multi-part ref but root is nil — can't traverse; treat as absent.
	if current == nil {
		return nil, false
	}

	currentObjectType := rootObjectType(flowRuntimeContextFromCtx(ctx), rootName, current)

	for i := 1; i < len(parts); i++ {
		fieldName := parts[i]
		isLeaf := i == len(parts)-1
		// 0-based hop index: hop 0 = parts[1], hop 1 = parts[2], ...
		hopIndex := i - 1

		// Schema-aware resolution per CLAUDE.md R10 (PR #2057 round-6 F1):
		// see resolveNestedReferenceCtx for the full rationale. Both nested
		// reference paths must consult the same RelationshipName-aware
		// resolver as buildFormulaFetcher.
		schema := e.lookupObjectSchema(ctx, currentObjectType)
		if fkField := e.resolveRelationshipFKWithSchema(ctx, schema, currentObjectType, fieldName); fkField != "" {
			if inline := lookupField(current, fieldName); inline != nil {
				current = inline
				currentObjectType = refineObjectType(inline, currentObjectType)
				continue
			}
			related, nextType, resolved := e.loadRelatedRecord(ctx, interview, user, schema, current, currentObjectType, fieldName, fkField)
			if !resolved {
				// R10 (issue #2047): indeterminate result — presence=false.
				// Chosen interpretation: return (nil, false) so the
				// write-path caller (RecordCreate stage 2, executeAssignment)
				// skips the assignment rather than writing nil. This is
				// identical in value to the prior definitive-null path but
				// now correctly represents "we don't know" semantics.
				// Matches main #2017's intent (skip write to avoid target
				// corruption) but adds the observability log.
				if e.logger != nil {
					e.logger.Warn(fmt.Sprintf(
						"flow.r10: resolveNestedReferenceCtxWithPresence skipping `%s` — loadRelatedRecord returned indeterminate (metadata-miss or query error)",
						fieldName,
					))
				}
				return nil, false
			}
			if related == nil {
				// Definitive null: FK was cleared or parent was deleted.
				// Record which hop produced the null so the DML resolver
				// can distinguish root-FK-null from intermediate-FK-null.
				if firstNullHopIndex != nil && *firstNullHopIndex == -1 {
					*firstNullHopIndex = hopIndex
				}
				// Return (nil, true) so the caller writes nil per SF semantics
				// (main #2017 / #1817 F4 fix). The earlier !resolved branch
				// above handles indeterminate (metadata-miss / query error).
				return nil, true
			}
			current = related
			currentObjectType = nextType
			continue
		}

		// Leaf / intermediate map access, tracking key presence
		// explicitly. This is the whole point of this function: ok=true
		// even when value is nil is the "author set explicit null" signal
		// that must reach RecordCreate stage 2.
		var val interface{}
		var present bool
		switch obj := current.(type) {
		case models.SObject:
			val, present = obj[fieldName]
		case map[string]interface{}:
			val, present = obj[fieldName]
		default:
			return nil, false
		}

		if !present {
			return nil, false
		}

		if isLeaf {
			return val, true
		}

		// Intermediate hop must have a non-nil value we can descend into.
		if val == nil {
			// Track this hop as the first null we see.
			if firstNullHopIndex != nil && *firstNullHopIndex == -1 {
				*firstNullHopIndex = hopIndex
			}
			return nil, true
		}
		current = val
		if newType := models.RecordObjectType(current); newType != "" {
			currentObjectType = newType
		}
	}

	return current, true
}

// refineObjectType picks the best known object type after stepping through an
// inline relationship record. It prefers the nested record's own object-type
// metadata and falls back to the previous type when metadata is missing (better
// than silently emptying the string).
func refineObjectType(record interface{}, previous string) string {
	if typeName := models.RecordObjectType(record); typeName != "" {
		return typeName
	}
	return previous
}

// inferRootObjectType recovers the trigger object type from a $Record /
// $Record__Old / $Record__Prior carried in ContextData, using neutral
// object-type metadata.
// Used by executeFlowMetadata for AutoLaunchedFlow subflows that
// have no declared Start.Object but receive typed $Record inputs from the
// parent. Returns "" when neither input is present or carries object metadata.
func inferRootObjectType(contextData map[string]interface{}) string {
	for _, key := range []string{"$Record", "$Record__Old", "$Record__Prior"} {
		if rec, ok := contextData[key]; ok {
			if typeName := models.RecordObjectType(rec); typeName != "" {
				return typeName
			}
		}
	}
	return ""
}

// loadRelatedRecord performs the cross-object lookup for a single `__r` segment.
// Returns (relatedRecord, relatedObjectType, resolved) where resolved distinguishes:
//
//   - resolved=true, record=nil: definitive null per SF semantics (FK cleared or
//     orphan parent — the field is genuinely absent; downstream may treat as null).
//
//   - resolved=false, record=nil: indeterminate (metadata-miss or transient query
//     error — we don't know; downstream should SKIP rather than write nil, to
//     preserve the target's prior value).
//
//   - resolved=true, record!=nil: successful lookup.
//
// This distinction fixes the Codex P1 on PR #2017: the `if !resolved { break }`
// gate in executeAssignment was previously too broad — it skipped writes even for
// FK-null and orphan lookups, leaving ghost data. Now only metadata-miss and
// query errors are indeterminate; FK-null and orphan are definitive nulls.
//
// Read-path callers (resolveNestedReferenceCtx, resolveNestedReferenceCtxWithPresence,
// buildFormulaFetcher) apply the same distinction per R10 (issue #2047): no path
// silently conflates metadata-miss with explicit null.
func (e *FlowExecutor) loadRelatedRecord(
	ctx context.Context,
	interview *models.FlowInterview,
	user *models.UserSession,
	schema *models.ObjectMetadata,
	currentRecord interface{},
	currentObjectType, relationshipName, fkField string,
) (models.SObject, string, bool) {
	// CLAUDE.md R10: this wrapper layers per-interview concerns (rCache,
	// N+1 warn, object-type stamping, automation-specific WRN policy)
	// AROUND a single shared helper — formula.FetchRelatedRecord — so the
	// nested-reference read path and the formula read path agree byte-for-
	// byte on FK extraction, target resolution, and DB fetch. The polymorphic
	// single-ReferenceTo Tier-4 gate already lives upstream in
	// resolveRelationshipFK; by the time we get here, fkField has been
	// vetted, so we keep this wrapper focused on caching + logging.

	// 1. Extract the FK value from the current record.
	fkValue := lookupField(currentRecord, fkField)
	if fkValue == nil || fkValue == "" {
		// FK is null/empty — definitive null per SF semantics: the FK field is
		// genuinely absent or cleared. resolved=true: we KNOW there is no
		// parent. Mirror FetchRelatedRecord's definitive-null semantics so
		// downstream callers (read + write paths) treat both helpers the same.
		return nil, "", true
	}
	fkID := fmt.Sprintf("%v", fkValue)

	// 2. Resolve the related object type ONCE so the cache key and the
	// indeterminate-gate decision agree, and so the WRN messages can name the
	// resolved type. Routes through formula.ResolveRelationTarget so
	// polymorphic references (Task.WhoId → [Contact, Lead], Task.WhatId →
	// [Account, Opportunity, …]) honour the row's `<FK>_type` discriminator
	// — pre-#2057-round-8 this picked field.ReferenceTo[0] for any length
	// and the cache key + object-type stamp drifted away from the
	// shared helper's resolved target. The shared helper also gates
	// indeterminate cases (missing / empty / non-matching discriminator)
	// the same way as FetchRelatedRecord, so both reads agree byte-for-byte.
	recordMap := recordAsMap(currentRecord)
	relatedType := ""
	if currentObjectType != "" {
		if field := e.lookupFieldMeta(ctx, currentObjectType, fkField); field != nil {
			if t, ok := formula.ResolveRelationTarget(recordMap, fkField, field.ReferenceTo); ok {
				relatedType = t
			}
		}
	}
	// 3. Cache lookup keyed by {relatedType}:{fkID}. When relatedType is
	// empty (no metadata) we fall back to a type-less key so a previously
	// seeded entry can still hit. Done BEFORE the N+1 Loop warn so we don't
	// flood logs when the cache absorbs repeat lookups within a Loop body.
	cacheKey := relatedType + ":" + fkID
	frc := flowRuntimeContextFromCtx(ctx)
	cache := frc.RCache()
	if cache != nil {
		if cached, ok := cache[cacheKey]; ok {
			// Cache hit — resolved=true regardless of whether the cached value
			// is nil. A nil cache entry is the negative cache: a definitive
			// null (orphan or FK-cleared) established in a prior call within
			// the same interview.
			return cached, relatedType, true
		}
	}

	// 4. Warn when we would issue a DB query inside a Loop (cache MISS path).
	// Emitted BEFORE FetchRelatedRecord so the warning is visible even when
	// the helper subsequently classifies the result as indeterminate.
	if frc.LoopDepth() > 0 {
		if e.logger != nil {
			e.logger.Warn(fmt.Sprintf(
				"flow: `%s` resolved inside Loop (cache miss) — potential N+1 query (relationship=%s, fkField=%s, fkId=%s)",
				relationshipName, relationshipName, fkField, fkID,
			))
		}
	}

	// 5. Indeterminate gate: without a concrete type, or without a query
	// service, we cannot fetch. Bail out before constructing the deps adapter
	// so the WRN message preserves the pre-refactor wording. Same outcome as
	// FetchRelatedRecord would produce (LookupReferenceTo would return empty
	// → resolved=false), but the WRN is more specific because we know which
	// gate fired.
	if relatedType == "" || e.query == nil {
		if e.logger != nil {
			e.logger.Warn(fmt.Sprintf(
				"flow: cannot resolve `%s` — missing related object type (fkField=%s, fkId=%s)",
				relationshipName, fkField, fkID,
			))
		}
		return nil, "", false // indeterminate: metadata-miss
	}

	// 6. Delegate FK→fetch to the SHARED helper. The deps adapter is the
	// same shape buildFormulaFetcher uses, so both read paths agree on
	// LookupReferenceTo + FetchByID semantics (R10). recordMap was already
	// derived in step 2 for ResolveRelationTarget; reuse it.
	deps := &automationFormulaFetchDeps{e: e, ctx: ctx, user: user, currentObjectType: currentObjectType}
	// Schema is threaded through so FetchRelatedRecord's internal
	// ResolveRelationFK call hits Tier 1 (RelationshipName) consistently
	// with the pre-resolved fkField above. Pre-#2057-R6, this passed nil
	// and the helper silently fell through to T2/T3/T4 — which would
	// disagree with the caller for any admin-renamed relationship.
	related, fetchedType, resolved := formula.FetchRelatedRecord(ctx, deps, schema, currentObjectType, recordMap, relationshipName)
	// Pre-#2057-round-8 we discarded fetchedType and re-stamped attributes
	// with the locally pre-computed relatedType. For polymorphic FKs the two
	// could disagree (pre-compute picked refTo[0]; helper consulted the
	// discriminator), so the cache + stamp drifted from the actual fetched
	// row's SObject. Now both sides go through ResolveRelationTarget; if a
	// future divergence ever sneaks back in, prefer the helper's value as
	// the authoritative target since it owns the FetchByID call.
	if fetchedType != "" && fetchedType != relatedType {
		relatedType = fetchedType
		cacheKey = relatedType + ":" + fkID
	}

	if !resolved {
		// Helper hit a transient error during FetchByID. Emit the same WRN
		// shape as before so log greps stay stable.
		if e.logger != nil {
			e.logger.Warn(fmt.Sprintf(
				"flow: `%s` lookup failed (relatedType=%s, fkId=%s)",
				relationshipName, relatedType, fkID,
			))
		}
		return nil, "", false // indeterminate: transient query error
	}
	if related == nil {
		// Definitive null: orphan FK (FK present, parent row absent). Mirror
		// pre-refactor WRN + negative-cache so a repeat lookup short-circuits.
		if e.logger != nil {
			e.logger.Warn(fmt.Sprintf(
				"flow: `%s` related record not found (relatedType=%s, fkId=%s)",
				relationshipName, relatedType, fkID,
			))
		}
		if cache != nil {
			cache[cacheKey] = nil
		}
		return nil, "", true // definitive null: parent deleted / orphan
	}

	// 7. Stamp neutral object-type metadata so later reuse (assignment, subflow input,
	// further __r traversal from a nested position) can recover the type
	// without another metadata roundtrip. Cache the annotated record.
	record := models.SObject(related)
	models.StampObjectType(record, relatedType)
	if cache != nil {
		cache[cacheKey] = record
	}
	return record, relatedType, true
}

// lookupField reads a field value from either an SObject or a plain map, used
// when we need a specific column (the FK) from a record whose concrete type
// may vary at the call site.
func lookupField(record interface{}, field string) interface{} {
	switch obj := record.(type) {
	case models.SObject:
		return obj[field]
	case map[string]interface{}:
		return obj[field]
	default:
		return nil
	}
}

// recordAsMap exposes a record as a plain map[string]interface{} for callers
// that need to pass it into shared helpers (e.g. formula.ResolveRelationTarget,
// formula.FetchRelatedRecord). Returns nil when the concrete type is neither
// SObject nor map[string]interface{} — callers must treat nil as "no record"
// and not dereference.
func recordAsMap(record interface{}) map[string]interface{} {
	switch obj := record.(type) {
	case models.SObject:
		return map[string]interface{}(obj)
	case map[string]interface{}:
		return obj
	default:
		return nil
	}
}

// deriveRecordOldRef translates a "$Record.<path>" or "$Record__Prior.<path>" reference to its
// "$Record__Old.<path>" counterpart for IsChanged comparisons. Returns
// (priorRef, true) when the input is rooted at $Record or $Record__Prior, and ("", false)
// otherwise so callers can fall through to their unknown-ref handling.
//
// A bare "$Record" or "$Record__Prior" (no trailing dot) is intentionally not translated:
// Salesforce always emits IsChanged on a specific field, and a bare
// record reference has no field identity to compare.
//
// Also supports translating "$Record__Prior.<path>" back to "$Record__Old.<path>"
// and "$Record__Old.<path>" to itself (for consistency).
func deriveRecordOldRef(ref string) (string, bool) {
	const recordPrefix = "$Record."
	const priorPrefix = "$Record__Prior."
	const oldPrefix = "$Record__Old."

	if strings.HasPrefix(ref, recordPrefix) {
		return "$Record__Old." + ref[len(recordPrefix):], true
	} else if strings.HasPrefix(ref, priorPrefix) {
		return "$Record__Old." + ref[len(priorPrefix):], true
	} else if strings.HasPrefix(ref, oldPrefix) {
		// Already a $Record__Old reference, keep as is
		return ref, true
	}
	return "", false
}

// setNestedReference writes a value into a dot-notation target such as
// $Record.Account_Status__c. Missing intermediate maps are created on demand.
func (e *FlowExecutor) setNestedReference(context map[string]interface{}, ref string, value interface{}) bool {
	parts := strings.Split(ref, ".")
	if len(parts) == 0 {
		return false
	}
	if len(parts) == 1 {
		context[parts[0]] = value
		return true
	}

	current, exists := context[parts[0]]
	if !exists || current == nil {
		next := make(map[string]interface{})
		context[parts[0]] = next
		current = next
	}

	for i := 1; i < len(parts)-1; i++ {
		fieldName := parts[i]

		switch obj := current.(type) {
		case models.SObject:
			next := obj[fieldName]
			if next == nil {
				newMap := make(map[string]interface{})
				obj[fieldName] = newMap
				current = newMap
				continue
			}
			current = next
		case map[string]interface{}:
			next := obj[fieldName]
			if next == nil {
				newMap := make(map[string]interface{})
				obj[fieldName] = newMap
				current = newMap
				continue
			}
			current = next
		default:
			return false
		}
	}

	lastField := parts[len(parts)-1]
	switch obj := current.(type) {
	case models.SObject:
		obj[lastField] = value
		return true
	case map[string]interface{}:
		obj[lastField] = value
		return true
	default:
		return false
	}
}
