package records

// formula_compute.go — write-materialized formula field evaluation hook for Insert/Update.
//
// Hook fires AFTER all user-supplied field values are merged into the row,
// BEFORE the SQL INSERT / UPDATE executes. Iterates ObjectMetadata.Fields
// for the target object and evaluates only formula-backed fields whose
// resolved storage is write-materialized.
//
// Scope: storage-resolved write-materialized formulas only. View-only active
// formulas are read-time projections served by formula views; frozen formulas
// and active write-materialized formulas are write-time engine snapshots
// stored in physical columns.
//
// Cache: bounded LRU keyed by (fieldID + "|" + formulaText). FieldMetadata
// has no LastModifiedDate field today, so we substitute the formula text:
// any admin edit changes the text → naturally invalidates the entry at
// next save. Cap at formulaCacheMaxSize entries; LRU eviction (list + map).
// Process-level singleton; no cross-request coordination needed.

import (
	"container/list"
	"context"
	"errors"
	"fmt"
	"math"
	"reflect"
	"strings"
	"sync"

	"github.com/nexuscrm/shared/pkg/formulastorage"

	zlog "github.com/rs/zerolog/log"

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

// formulaCacheMaxSize is the maximum number of compiled expressions the cache
// holds before LRU eviction. Keeps memory bounded without complex policy.
const formulaCacheMaxSize = 10_000

// formulaCacheKey combines the field ID and the (trimmed) formula text. Admin
// edits to the formula necessarily change the text → the new (id, text) pair
// is a different key → the stale entry is naturally bypassed (and evicted by
// LRU pressure). FieldMetadata has no LastModifiedDate field today, so this
// is the most reliable change-detection signal available without plumbing.
func formulaCacheKey(fieldID, formulaText string) string {
	return fieldID + "|" + formulaText
}

// formulaCache is a bounded LRU cache of pre-normalized formula expressions.
// We cache the raw expression string (post-normalised by formula.Engine.Evaluate
// internally) keyed by the admin-change-safe key. This avoids re-parsing on
// every save for the common no-edit case.
//
// Thread-safe: protected by mu.
type formulaCache struct {
	mu      sync.Mutex
	entries map[string]*list.Element
	order   *list.List
	maxSize int
}

type formulaCacheEntry struct {
	key   string
	value string // the formula expression string
}

func newFormulaCache(maxSize int) *formulaCache {
	return &formulaCache{
		entries: make(map[string]*list.Element, maxSize),
		order:   list.New(),
		maxSize: maxSize,
	}
}

// get returns (expression, true) if the key is in cache.
func (c *formulaCache) get(key string) (string, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if el, ok := c.entries[key]; ok {
		c.order.MoveToFront(el)
		return el.Value.(*formulaCacheEntry).value, true
	}
	return "", false
}

// set adds or updates the cache entry, evicting the LRU element when over cap.
func (c *formulaCache) set(key, expr string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if el, ok := c.entries[key]; ok {
		c.order.MoveToFront(el)
		el.Value.(*formulaCacheEntry).value = expr
		return
	}
	if c.order.Len() >= c.maxSize {
		back := c.order.Back()
		if back != nil {
			c.order.Remove(back)
			delete(c.entries, back.Value.(*formulaCacheEntry).key)
		}
	}
	el := c.order.PushFront(&formulaCacheEntry{key: key, value: expr})
	c.entries[key] = el
}

// processFormulaCache is the process-level singleton. Initialised once.
var processFormulaCache = newFormulaCache(formulaCacheMaxSize)

// computeFormulaFields evaluates all frozen-mode formula fields on the given
// row before it is written to the DB. Modifies row in place (formula fields
// are derived — any user-supplied value is overwritten). objectName is used
// only for log messages; schema.Fields is the source of truth.
//
// Parameters:
//   - ctx: request context (for future tracing / deadline propagation)
//   - schema: ObjectMetadata for the object being saved
//   - row: the merged record about to be INSERTed or UPDATEd
//   - repo: ports.RecordRepository used to lazy-load parent records for __r refs
//   - objectName: API name of the object (for log messages only)
//
// Returns the set of formula fields whose evaluation hit a transient
// relation-fetch error (formula.ErrRelationFetch). The UPDATE caller passes
// this set into injectComputedFormulaFields so the affected fields are
// excluded from effectiveUpdates — preserving the row's existing stored
// value and avoiding a false audit delta on a fleeting DB blip. INSERT
// callers may discard the returned set; on INSERT the field is left absent
// from `row` (no SQL write) which matches "preserve existing value" since
// there is no existing value yet. Issue #2057.
//
// Genuine evaluation failures (parse errors, function-call errors — anything
// not wrapped in formula.ErrRelationFetch) keep the legacy "store NULL +
// WRN" semantics. They are NOT included in the returned set.
func computeFormulaFields(
	ctx context.Context,
	schema *models.ObjectMetadata,
	row models.SObject,
	repo recordFetcher,
	meta schemaFetcher,
	objectName string,
) map[string]struct{} {
	return computeFormulaFieldsWithPrior(ctx, schema, row, nil, repo, meta, objectName)
}

// computeFormulaFieldsWithPrior is the update-aware form of
// computeFormulaFields. Frozen formulas are part of the save lifecycle, so
// ISNEW, ISCHANGED and PRIORVALUE must observe the same locked old row used by
// validation rules and before-save automation. Passing nil preserves create
// semantics: ISNEW is true, ISCHANGED is false and PRIORVALUE returns the
// current create value.
func computeFormulaFieldsWithPrior(
	ctx context.Context,
	schema *models.ObjectMetadata,
	row models.SObject,
	prior models.SObject,
	repo recordFetcher,
	meta schemaFetcher,
	objectName string,
) map[string]struct{} {
	if schema == nil || len(schema.Fields) == 0 || len(row) == 0 {
		return nil
	}

	var fetchErrFields map[string]struct{}
	formulaEngine := formula.NewEngine()

	for i := range schema.Fields {
		f := &schema.Fields[i]

		if f.Formula == nil || *f.Formula == "" {
			continue
		}
		storage, err := formulastorage.Resolve(formulastorage.SpecFromField(f))
		if err != nil {
			zlog.Warn().
				Str("object", objectName).
				Str("field", f.APIName).
				Str("error", err.Error()).
				Msg("records.formulaCompute: invalid formula storage metadata; using canonical fallback")
		}
		if !storage.IsWriteMaterialized() {
			continue
		}

		expr := *f.Formula

		// Cache look-up. Key on (fieldID, formulaText): admin edits to the
		// expression naturally change the key, so the new save uses a fresh
		// cache slot and the stale entry ages out via LRU.
		cacheKey := formulaCacheKey(f.ID, expr)

		// We cache expression strings (not compiled ASTs) because the formula
		// engine compiles internally and the cost of Evaluate is dominated by
		// relation fetch, not parsing.
		if cached, ok := processFormulaCache.get(cacheKey); ok {
			expr = cached
		} else {
			// Normalise once (trim whitespace) and cache.
			expr = strings.TrimSpace(expr)
			processFormulaCache.set(cacheKey, expr)
		}

		// Build formula.Context for this row. TreatBlankNumericAsZero applies
		// the vendor's BlankAsZero default (#4152) to formula-FIELD computation
		// only — this is the frozen/write-materialized formula path; the same
		// rule reaches active formulas through the SQL view compiler.
		fctx := &formula.Context{
			Record:                  map[string]interface{}(row),
			Prior:                   map[string]interface{}(prior),
			Schema:                  schema,
			Fetcher:                 buildRecordFetcher(ctx, schema, f, repo, meta),
			TreatBlankNumericAsZero: true,
		}

		result, err := formulaEngine.Evaluate(expr, fctx)
		if err != nil {
			if errors.Is(err, formula.ErrRelationFetch) {
				// Transient relation-fetch failure: do NOT corrupt the row.
				// Leave row[f.APIName] untouched (preserves any existing
				// stored value) and signal the field to the UPDATE caller
				// so injectComputedFormulaFields skips it for audit /
				// effectiveUpdates. Pre-#2057 this path silently wrote nil,
				// which both clobbered the formula column AND emitted a
				// false audit delta on every transient DB blip.
				if fetchErrFields == nil {
					fetchErrFields = make(map[string]struct{})
				}
				fetchErrFields[f.APIName] = struct{}{}
				zlog.Warn().
					Str("records.formulaCompute", "relation_fetch_error").
					Str("object", objectName).
					Str("field", f.APIName).
					Str("error_class", fmt.Sprintf("%T", err)).
					Str("error", err.Error()).
					Msg("records.formulaCompute: relation_fetch_error; preserving prior value")
				continue
			}
			// Genuine evaluation failure (parse error, function call error,
			// type mismatch, etc.) → store NULL, emit WRN. Same as pre-fix
			// SF frozen-mode semantics for an irrecoverable expression.
			row[f.APIName] = nil
			zlog.Warn().
				Str("records.formulaCompute", "eval_error").
				Str("object", objectName).
				Str("field", f.APIName).
				Str("error_class", fmt.Sprintf("%T", err)).
				Str("error", err.Error()).
				Msg("records.formulaCompute: evaluation failed; storing NULL")
			continue
		}

		// Non-finite guard: float division by zero does not error in the
		// expression engine — it yields +Inf/-Inf/NaN. Blank-as-zero (#4152)
		// widens the reachable zero-divisor population (a blank divisor now
		// reads 0; a stored 0 always did this), and a non-finite float must
		// never reach storage: it breaks JSON serialization of the row and
		// diverges from both the SQL path (x/0 renders NULL in a SELECT) and
		// the vendor (an error value, not a number). Store NULL instead.
		if f64, isFloat := result.(float64); isFloat && (math.IsNaN(f64) || math.IsInf(f64, 0)) {
			row[f.APIName] = nil
			continue
		}
		row[f.APIName] = restoreFormulaResultForStorage(f, result)
	}

	return fetchErrFields
}

func restoreFormulaResultForStorage(field *models.FieldMetadata, result interface{}) interface{} {
	if field == nil || field.ReturnType == nil || constants.NormalizeFieldType(string(*field.ReturnType)) != constants.FieldTypePercent {
		return result
	}
	switch v := result.(type) {
	case float64:
		return v * 100.0
	case int:
		return float64(v) * 100.0
	case int64:
		return float64(v) * 100.0
	default:
		return result
	}
}

// recordFetcher is the subset of ports.RecordRepository used by the formula
// compute hook. Defined here so tests can satisfy it without pulling in the
// full repository interface.
type recordFetcher interface {
	FindOne(ctx context.Context, tableName string, id string) (models.SObject, error)
}

type schemaFetcher interface {
	GetSchema(ctx context.Context, apiName string) *models.ObjectMetadata
}

// injectComputedFormulaFields copies the post-compute value of each frozen
// formula field from `merged` into `effectiveUpdates` whenever the value
// differs from `oldRecord`. Used by Update so computed formula values flow
// into both the SQL UPDATE and the audit-log emission upstream.
//
// We propagate only on a real diff (post-compute vs. oldRecord) so unchanged
// formula values do not pollute the audit history with no-op rows. Comparison
// uses fmt.Sprintf("%v") for a simple value-equality check across the mixed
// types the formula engine returns (string / float64 / bool / nil).
//
// `fetchErrFields` is the set of formula fields whose computeFormulaFields
// invocation failed with formula.ErrRelationFetch. Those fields are skipped
// here so a transient fetch failure neither writes to effectiveUpdates (no
// SQL UPDATE for the column) nor emits a false audit delta — the row's
// stored formula value is preserved untouched. Issue #2057. Pass nil when
// no fields hit fetch errors (the common case).
func injectComputedFormulaFields(
	schema *models.ObjectMetadata,
	merged models.SObject,
	oldRecord models.SObject,
	effectiveUpdates models.SObject,
	fetchErrFields map[string]struct{},
) {
	if schema == nil || len(schema.Fields) == 0 {
		return
	}
	for i := range schema.Fields {
		f := &schema.Fields[i]
		if f.Type != constants.FieldTypeFormula || f.Formula == nil || *f.Formula == "" {
			continue
		}
		storage, err := formulastorage.Resolve(formulastorage.SpecFromField(f))
		if err != nil {
			zlog.Warn().
				Str("object", schema.APIName).
				Str("field", f.APIName).
				Str("error", err.Error()).
				Msg("records.formulaCompute: invalid formula storage metadata; using canonical fallback")
		}
		if !storage.IsWriteMaterialized() {
			continue
		}
		if _, skip := fetchErrFields[f.APIName]; skip {
			// Transient relation-fetch failure on this field — preserve the
			// stored value, emit no audit delta, AND drop any first-pass entry
			// from effectiveUpdates so the SQL UPDATE / audit row do not carry
			// a stale pre-BeforeSave formula value forward (PR #2057 IANTHEREAL
			// blocker 3). Without the delete, a sequence
			//   pass1 success → effectiveUpdates[f]=preFlowValue
			//   BeforeSave repoints FK
			//   pass2 fetch error → continue (preserve)
			// would persist preFlowValue while the row's inputs reflect the
			// post-flow FK — silent data corruption.
			delete(effectiveUpdates, f.APIName)
			continue
		}
		newVal, present := merged[f.APIName]
		if !present {
			continue
		}
		oldVal := oldRecord[f.APIName]
		// Type-aware equality: fmt.Sprintf("%v") would collapse string "1.5"
		// and float64(1.5) into the same string repr, silently suppressing a
		// type-correction audit delta. Round-4 codex Pass A HIGH #1.
		if reflect.DeepEqual(newVal, oldVal) {
			continue
		}
		effectiveUpdates[f.APIName] = newVal
	}
}

// buildRecordFetcher returns a formula.Context.Fetcher that lazy-loads a
// parent record when the formula expression contains a __r cross-object ref.
//
// Delegates to the shared formula.FetchRelatedRecord helper so this path and
// automation.buildFormulaFetcher agree on the same 3-value contract for every
// input class (success / definitive null / indeterminate). CLAUDE.md R10:
// "multiple read paths is the defect even when answers currently agree" —
// the shared helper resolves the duplication called out in #2057 and #2067.
//
// Return contract preserved for the formula engine:
//
//   - (related, nil) — success: parent row loaded.
//   - (nil, nil)     — DEFINITIVE NULL: unknown relation name, FK absent /
//     cleared / empty, or FetchByID returned nil (orphan FK). Engine treats
//     as null parent per SF semantics.
//   - (nil, error)   — INDETERMINATE wrapped in formula.ErrRelationFetch via
//     %w: missing ReferenceTo metadata or transient repo.FindOne error. The
//     engine surfaces the wrapped error so computeFormulaFields preserves
//     the row's prior stored value rather than corrupting it with nil.
func buildRecordFetcher(
	ctx context.Context,
	schema *models.ObjectMetadata,
	_ *models.FieldMetadata,
	repo recordFetcher,
	meta schemaFetcher,
) func(record map[string]interface{}, relationName string) (map[string]interface{}, error) {
	if repo == nil {
		return nil
	}
	// Hop-schema registry. augmentRecordWithRelationChain hands the SAME map
	// this fetcher returned back as `record` for the next hop of a multi-hop
	// chain, so relation resolution beyond hop 1 must run against the FETCHED
	// object's schema, not the root's. Maps returned by this closure are
	// registered by identity; an unknown map (the root record, or a
	// caller-inlined relation map) resolves against the root schema. The
	// closure lives for one field evaluation on one goroutine.
	type hopEntry struct {
		rec    map[string]interface{} // pins the map so its identity is stable for the closure's lifetime
		schema *models.ObjectMetadata
	}
	var hops []hopEntry
	schemaForHop := func(record map[string]interface{}) *models.ObjectMetadata {
		if record == nil {
			return schema
		}
		id := reflect.ValueOf(record).Pointer()
		for i := range hops {
			if reflect.ValueOf(hops[i].rec).Pointer() == id {
				return hops[i].schema
			}
		}
		return schema
	}
	registerHop := func(record map[string]interface{}, s *models.ObjectMetadata) {
		if record != nil && s != nil {
			hops = append(hops, hopEntry{rec: record, schema: s})
		}
	}
	return func(record map[string]interface{}, relationName string) (map[string]interface{}, error) {
		hopSchema := schemaForHop(record)
		deps := &recordsRelationFetchDeps{schema: hopSchema, repo: repo}
		related, target, resolved := formula.FetchRelatedRecord(ctx, deps, hopSchema, schemaAPIName(hopSchema), record, relationName)
		if !resolved {
			// Indeterminate (metadata-cache miss / transient repo error).
			// Wrap formula.ErrRelationFetch via %w so the engine classifies
			// this as a fetch error and computeFormulaFields preserves the
			// existing stored value (issue #2057).
			return nil, fmt.Errorf("records.formulaFetch: indeterminate %q (target=%q): %w", relationName, target, formula.ErrRelationFetch)
		}
		relatedSchema := recordsFetchedRelationSchema(meta, target)
		// This fetcher only ever serves formula-FIELD computation, so the
		// blank-as-zero operand rule (#4152) applies to fetched parents
		// unconditionally — cross-object numeric operands must read the same
		// way as local ones. The raw key set is captured before preparation so
		// virtual (view-only) parent formula fields, which the storage read
		// never carries, keep nil instead of fabricating a 0.
		if related != nil {
			related = cloneFetchedRelationRecord(related)
			rawKeys := make(map[string]struct{}, len(related))
			for k := range related {
				rawKeys[k] = struct{}{}
			}
			formula.PrepareRecordForEvaluation(related, relatedSchema)
			formula.ZeroBlankNumericOperands(related, relatedSchema, rawKeys)
			registerHop(related, relatedSchema)
			return related, nil
		}
		// Definitive null parent. The vendor treats a traversal through an
		// empty lookup as blank field reads, so under BlankAsZero the parent's
		// numeric fields read 0 while its non-numeric fields stay blank —
		// measured on a live source org (a bare cross-object numeric formula
		// reads 0, never blank, on every parentless record) and matching the
		// SQL path, where the LEFT-JOIN miss makes COALESCE(join.col, 0) read
		// 0. Synthesize that shape instead of returning a null parent, which
		// would NULL-poison the whole expression on the Go path only.
		//
		// A blank FK resolves no target, so derive it from the relation's
		// declared ReferenceTo when it is unambiguous. Polymorphic relations
		// with no type hint keep the null-parent shape — fabricating one
		// arbitrary target's field skeleton could shadow another target's
		// same-named non-numeric field.
		if relatedSchema == nil {
			relatedSchema = recordsNullParentSchema(hopSchema, relationName, meta)
		}
		if relatedSchema != nil {
			skeleton := make(map[string]interface{})
			formula.PrepareRecordForEvaluation(skeleton, relatedSchema)
			formula.ZeroBlankNumericOperands(skeleton, relatedSchema, nil)
			registerHop(skeleton, relatedSchema)
			return skeleton, nil
		}
		return nil, nil
	}
}

// recordsNullParentSchema resolves the target schema for a definitive-null
// relation traversal, where no FK value exists to carry a target: the
// relation's declared ReferenceTo, accepted only when unambiguous.
func recordsNullParentSchema(schema *models.ObjectMetadata, relationName string, meta schemaFetcher) *models.ObjectMetadata {
	if schema == nil || meta == nil {
		return nil
	}
	fkColumn := formula.ResolveRelationFK(schema, relationName)
	if fkColumn == "" {
		return nil
	}
	for i := range schema.Fields {
		field := &schema.Fields[i]
		if field.APIName != fkColumn {
			continue
		}
		if len(field.ReferenceTo) == 1 {
			return recordsFetchedRelationSchema(meta, field.ReferenceTo[0])
		}
		return nil
	}
	return nil
}

// schemaAPIName returns schema.APIName or "" if schema is nil. Pulled into a
// helper so the buildRecordFetcher closure stays single-statement readable.
func schemaAPIName(schema *models.ObjectMetadata) string {
	if schema == nil {
		return ""
	}
	return schema.APIName
}

func recordsFetchedRelationSchema(meta schemaFetcher, targetObject string) *models.ObjectMetadata {
	if meta == nil || strings.TrimSpace(targetObject) == "" {
		return nil
	}
	return meta.GetSchema(context.Background(), targetObject)
}

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

// recordsRelationFetchDeps adapts recordFetcher to formula.RelationFetchDeps.
// Records' adapter reads ReferenceTo straight off the FK field on the
// in-memory schema (no metadata-service round-trip — the schema is already
// loaded by computeFormulaFields' caller) and delegates the actual fetch to
// repo.FindOne, which is the bypass-RLS read path used elsewhere by the
// records persistence layer.
type recordsRelationFetchDeps struct {
	schema *models.ObjectMetadata
	repo   recordFetcher
}

func (d *recordsRelationFetchDeps) LookupReferenceTo(_ string, fkColumn string) []string {
	if d.schema == nil {
		return nil
	}
	for i := range d.schema.Fields {
		if d.schema.Fields[i].APIName == fkColumn {
			return d.schema.Fields[i].ReferenceTo
		}
	}
	return nil
}

func (d *recordsRelationFetchDeps) FetchByID(ctx context.Context, target, id string) (map[string]interface{}, error) {
	// Preview-provenance short-circuit: under a no-DML insert preview
	// preparation, a __r relation FK on the row being previewed can point
	// at a synthetic preview-* ID minted by an earlier same-run
	// RecordCreate. The durable read path would 404, which the formula
	// engine surfaces as ErrRelationFetch and computeFormulaFields treats
	// as "preserve prior value" — yielding a silent computation gap on
	// the in-flight preview row. Resolve from the registry first; stray
	// preview-* literals (no matching registry entry) still fall through
	// to repo.FindOne, preserving the durable-existence gate.
	if constants.HasInsertPreviewPreparation(ctx) {
		if synthetic, ok := constants.PreviewSyntheticRecordForObject(ctx, target, id); ok {
			out := make(map[string]interface{}, len(synthetic.Fields))
			for k, v := range synthetic.Fields {
				out[k] = v
			}
			return out, nil
		}
	}
	var readGen uint64
	if c := recordReadCacheFromCtx(ctx); c != nil {
		if record, found, hit := c.get(target, id); hit {
			if !found {
				return nil, nil
			}
			return map[string]interface{}(record), nil
		}
		readGen = c.beginRead(target, id)
	}
	rec, err := d.repo.FindOne(ctx, target, id)
	if err != nil {
		return nil, err
	}
	if c := recordReadCacheFromCtx(ctx); c != nil {
		c.setIfFresh(target, id, rec, rec != nil, readGen)
	}
	if rec == nil {
		return nil, nil
	}
	return map[string]interface{}(rec), nil
}
