package automation

import (
	"context"
	"fmt"

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

// buildFormulaFetcher returns a closure satisfying formula.Context.Fetcher
// that lazy-loads a named relationship against the live Flow runtime
// services. Returns nil when the executor has no query service wired (in
// which case the formula engine falls back to inline-only behaviour, matching
// pre-fix semantics for tests that don't need cross-object resolution).
//
// Routes the actual relation-name → FK column → fetch sequence through the
// shared formula.FetchRelatedRecord helper so this path agrees with
// records.buildRecordFetcher byte-for-byte on the 3-value contract per
// CLAUDE.md R10. The per-interview rCache is layered AROUND the call so
// repeated formula references to the same parent within one interview hit
// the cache (preserving the pre-existing N+1-suppression semantics).
//
// PR #2057 IANTHEREAL blocker 4: the closure now reads the full ObjectMetadata
// via the metadata service so Tier 1 (RelationshipName match) is reachable
// here. Pre-fix, automation passed nil schema to ResolveRelationFK and the
// helper silently fell through to Tier 2/3/4, while records used the schema
// and got Tier 1. An admin who renamed a relationship via RelationshipName
// would see records and automation resolve the same fact differently — the
// canonical R10 split-path defect. Threading schema through cuts the path.
//
// schema may be nil for test executors without a metadata service, or for
// transient cache misses. Tier 2/3/4 (with the polymorphic gate at Tier 4)
// remain the fallback in that case; behavior is identical to pre-fix when
// schema is unavailable.
func (e *FlowExecutor) buildFormulaFetcher(
	ctx context.Context,
	interview *models.FlowInterview,
	user *models.UserSession,
	triggerObjectType string,
) func(record map[string]interface{}, relationName string) (map[string]interface{}, error) {
	if e == nil || e.query == nil || interview == nil {
		return nil
	}
	return func(record map[string]interface{}, relationName string) (map[string]interface{}, error) {
		// For multi-hop chains the intermediate record is a related record
		// that carries its own object-type metadata — use that to identify the
		// correct schema. Fall back to rootObjectType (trigger-record type)
		// only when the intermediate record has no object-type stamp.
		objectType := models.RecordObjectType(record)
		if objectType == "" {
			objectType = rootObjectType(flowRuntimeContextFromCtx(ctx), "$Record", record)
		}
		// Entry-gate path: frc is not yet seeded, so rootObjectType returns "".
		// $Record__Old / $Record__Prior records do not carry an object-type stamp
		// (only the current $Record does). Fall back to the declared trigger object
		// type so cross-object relationship paths on the prior record resolve.
		if objectType == "" {
			objectType = triggerObjectType
		}

		// Read the full schema once per fetch so Tier 1 (RelationshipName)
		// resolution becomes reachable. nil-tolerant: tests without a
		// metadata service drop straight back to Tier 2/3/4 behavior.
		schema := e.lookupObjectSchema(ctx, objectType)

		// Resolve FK upfront so the cache key has both a target and an FK
		// value to discriminate on. Tier 1 fires from RelationshipName;
		// Tier 4 (<segment>Id) keeps automation's polymorphic gate so an
		// FK with multiple ReferenceTo targets is not silently traversed.
		fkColumn := e.resolveRelationshipFKWithSchema(ctx, schema, objectType, relationName)
		if fkColumn == "" {
			return nil, nil // unknown relationship — silent skip per existing contract.
		}

		// Cache lookup mirrors loadRelatedRecord's keying:
		// "{relatedType}:{fkID}". Resolve target eagerly via the same
		// adapter so the cache and helper agree on the type. We do this
		// inline (not by routing through FetchRelatedRecord) so cache hits
		// avoid even constructing the deps wrapper.
		fkVal, ok := record[fkColumn]
		if !ok || fkVal == nil {
			return nil, nil // FK absent / cleared — definitive null.
		}
		fkID := fmt.Sprintf("%v", fkVal)
		if fkID == "" {
			return nil, nil
		}

		deps := &automationFormulaFetchDeps{e: e, ctx: ctx, user: user, currentObjectType: objectType}
		// Resolve target via the SAME helper FetchRelatedRecord uses so the
		// cache key matches the shared helper's notion of target — including
		// the polymorphic <FK>_type discriminator. Picking refTo[0] would
		// cache/negative-cache polymorphic Task.WhoId/WhatId rows under the
		// first declared ReferenceTo entry while the helper resolves to the
		// discriminator-selected target, producing a stale cache hit on a
		// later evaluation (R10 split that codex Stage-2 round-9 flagged).
		refTo := deps.LookupReferenceTo(objectType, fkColumn)
		relatedType, targetResolved := formula.ResolveRelationTarget(record, fkColumn, refTo)
		if !targetResolved {
			// Indeterminate target (no ReferenceTo, polymorphic without
			// discriminator, or discriminator outside ReferenceTo). Skip
			// cache and let the shared helper surface the indeterminate via
			// resolved=false → flow.r10:WRN + sentinel error, matching the
			// no-cache behaviour records/ already has for this case.
			related, target, resolved := formula.FetchRelatedRecord(ctx, deps, schema, objectType, record, relationName)
			if !resolved {
				if e.logger != nil {
					e.logger.Warn(fmt.Sprintf(
						"flow.r10: formula fetcher skipping `%s` — FetchRelatedRecord returned indeterminate (target=%q, metadata-miss or query error); formula result will be nil",
						relationName, target,
					))
				}
				return nil, fmt.Errorf("flow.r10: %s relationship indeterminate (metadata-miss or transient error)", relationName)
			}
			// Helper returned definitive (related, target) — but without a
			// cacheable target we cannot key it; return without caching.
			return related, nil
		}
		cacheKey := relatedType + ":" + fkID
		cache := flowRuntimeContextFromCtx(ctx).RCache()
		if cache != nil {
			if cached, ok := cache[cacheKey]; ok {
				if cached == nil {
					// Negative-cache hit — established as definitive null in
					// a prior call within this interview.
					return nil, nil
				}
				return map[string]interface{}(cached), nil
			}
		}

		// Cache miss → call the shared helper. Schema threaded through so
		// Tier 1 RelationshipName matches uniformly with the records path.
		related, target, resolved := formula.FetchRelatedRecord(ctx, deps, schema, objectType, record, relationName)
		if !resolved {
			// R10: indeterminate (metadata-miss / transient error). Return a
			// sentinel error so the formula evaluator surfaces the miss
			// rather than treating it as a definitive null parent.
			if e.logger != nil {
				e.logger.Warn(fmt.Sprintf(
					"flow.r10: formula fetcher skipping `%s` — FetchRelatedRecord returned indeterminate (target=%q, metadata-miss or query error); formula result will be nil",
					relationName, target,
				))
			}
			return nil, fmt.Errorf("flow.r10: %s relationship indeterminate (metadata-miss or transient error)", relationName)
		}
		if related == nil {
			// Definitive null (FK cleared or orphan). Negative-cache so a
			// repeat lookup within the same interview short-circuits.
			if cache != nil {
				cache[cacheKey] = nil
			}
			return nil, nil
		}
		// Stamp neutral object-type metadata so downstream reuse can recover the type
		// without another metadata roundtrip — same as loadRelatedRecord.
		relSObj := models.SObject(related)
		models.StampObjectType(relSObj, target)
		if cache != nil {
			cache[cacheKey] = relSObj
		}
		return related, nil
	}
}

// automationFormulaFetchDeps adapts FlowExecutor's per-field metadata helper
// and QueryService to formula.RelationFetchDeps. Keeps the executor's RLS-
// aware QueryService scoped to the interview user — the access-control
// boundary records'/automation's adapters split on per CLAUDE.md R10.
type automationFormulaFetchDeps struct {
	e                 *FlowExecutor
	ctx               context.Context
	user              *models.UserSession
	currentObjectType string
}

func (d *automationFormulaFetchDeps) LookupReferenceTo(_ string, fkColumn string) []string {
	if d.e != nil && d.currentObjectType != "" {
		if fm := d.e.lookupFieldMeta(d.ctx, d.currentObjectType, fkColumn); fm != nil && len(fm.ReferenceTo) > 0 {
			return fm.ReferenceTo
		}
	}
	return nil
}

func (d *automationFormulaFetchDeps) FetchByID(ctx context.Context, target, id string) (map[string]interface{}, error) {
	// Missing query service is INDETERMINATE, not orphan. Returning (nil, nil)
	// here would let formula.FetchRelatedRecord classify it as definitive null
	// and the formula engine would compute against a null parent — silently
	// wrong vs. loadRelatedRecord which correctly returns resolved=false on
	// e.query == nil. Round-4 codex Pass A HIGH #2.
	if d.e == nil || d.e.query == nil {
		return nil, fmt.Errorf("automation.formulaFetch: query service unavailable for %s/%s", target, id)
	}
	result, err := d.e.query.QueryWithFilter(ctx, target, "Id = '"+escapeSQLValue(id)+"'", d.user, "", "", 1)
	if err != nil {
		return nil, err
	}
	if result == nil || len(result.Records) == 0 {
		return nil, nil
	}
	projected := map[string]interface{}(result.Records[0])
	if overlay, ok := recordoverlay.Lookup(ctx, target, id); ok {
		return overlayProjectedAutomationRecord(projected, overlay), nil
	}
	return projected, nil
}

func overlayProjectedAutomationRecord(projected map[string]interface{}, overlay models.SObject) map[string]interface{} {
	if len(projected) == 0 {
		return projected
	}
	merged := make(map[string]interface{}, len(projected))
	for key, value := range projected {
		if overlayValue, ok := overlay[key]; ok {
			merged[key] = overlayValue
			continue
		}
		merged[key] = value
	}
	return merged
}

// buildFlowFormulaContext constructs a formula.Context from an interview's
// ContextData. Shared by on-demand Flow Formula resources and filter formulas
// on the same mapping rules.
//
// Runtime-internal state (rCache, objectType, loopDepth) is no longer stored
// in interview.ContextData — it lives on FlowRuntimeContext typed fields.
// interview.ContextData therefore contains only user-visible flow variables,
// and no sentinel-key filtering is needed here.
//
// user is the UserSession associated with the executing flow. When non-nil it
// is stored as the typed formula.Context.UserSession field AND flattened into
// formula.Context.User (map) via UserSession.ToMap() so formula expressions
// like $User.Id resolve through the standard map-lookup path. When nil both
// fields remain nil — callers must not assume a non-nil user is always present.
func buildFlowFormulaContext(contextData map[string]interface{}, fetcher func(record map[string]interface{}, relationName string) (map[string]interface{}, error), user *models.UserSession) *formula.Context {
	fCtx := &formula.Context{
		Variables: make(map[string]interface{}, len(contextData)),
		Fetcher:   fetcher,
	}
	if record, ok := contextData["$Record"].(models.SObject); ok {
		fCtx.Record = record
	} else if record, ok := contextData["$Record"].(map[string]interface{}); ok {
		fCtx.Record = record
	}
	// Support both $Record__Old and $Record__Prior as aliases for the same data
	if prior, ok := contextData["$Record__Old"].(models.SObject); ok {
		fCtx.Prior = prior
	} else if prior, ok := contextData["$Record__Prior"].(models.SObject); ok {
		fCtx.Prior = prior
	} else if prior, ok := contextData["$Record__Old"].(map[string]interface{}); ok {
		fCtx.Prior = prior
	} else if prior, ok := contextData["$Record__Prior"].(map[string]interface{}); ok {
		fCtx.Prior = prior
	}
	for k, v := range contextData {
		switch k {
		case "$Record", "$Record__Old", "$Record__Prior":
			continue
		}
		fCtx.Variables[k] = v
	}
	// normalizeFormulaExpression rewrites $Record → __Record ($ is invalid in
	// expr-lang identifiers). Inject Record/Prior under both the formula
	// engine's native keys AND the normalized keys so expressions like
	// $Record.AccountId (→ __Record.AccountId) resolve correctly.
	if fCtx.Record != nil {
		fCtx.Variables["__Record"] = fCtx.Record
	}
	if fCtx.Prior != nil {
		fCtx.Variables["__Record__Old"] = fCtx.Prior
		// Also expose under __Record__Prior for bidirectional compatibility
		fCtx.Variables["__Record__Prior"] = fCtx.Prior
	} else {
		// No prior row (create / delete / scheduled): seed null-shaped roots so
		// $Record__Old.<field> / $Record__Prior.<field> evaluate to nil
		// (comparisons → false, ISBLANK → true) instead of raising
		// "unknown name __Record__Old" and fail-closing the entire gate/formula.
		//
		// fCtx.Prior is deliberately left nil: the _has_prior_ / _is_new_ sentinels
		// (ISCHANGED / ISNEW, engine.go:540/563) key off ctx.Prior == nil, so only
		// the member-access surface is seeded here, NOT the sentinel surface —
		// ISCHANGED stays false-on-create and ISNEW stays true-on-create (PR #1217).
		//
		// This makes the prior globals event-consistent (always present, null when
		// there is no prior row), so install-time validation can accept them on every
		// trigger without predicting the event. Matches Salesforce: prior is null on
		// create.
		emptyPrior := map[string]interface{}{}
		fCtx.Variables["__Record__Old"] = emptyPrior
		fCtx.Variables["__Record__Prior"] = emptyPrior
	}
	// Populate user context for FlowFormula evaluation via the shared
	// formula.BuildFormulaUserContext — the SINGLE read path for Salesforce-
	// modeled formula globals (__User, __Profile, __UserRole). The same helper
	// is consumed by records/persistence_service.go:buildUserContext, so a
	// single save transaction sees identical merge-field values in validation
	// rules and flow formulas. See pkg/formula/user_context.go and CLAUDE.md
	// §"Salesforce Boundary".
	//
	// fCtx.User is intentionally NOT set here: the formula engine flattens
	// ctx.User into the top-level expression environment, which would overwrite
	// same-named record fields (Id, Name, Email …) with the running user's
	// values. The normalized __User/__Profile/__UserRole keys in Variables are
	// the correct surface — normalizeFormulaExpression rewrites $User →
	// __User, $Profile → __Profile, $UserRole → __UserRole.
	if user != nil {
		fCtx.UserSession = user
		for k, v := range formula.BuildFormulaUserContext(user) {
			fCtx.Variables[k] = v
		}
	}
	return fCtx
}

// injectFormulaPermissions hydrates Variables["__Permission"] for a formula
// evaluation. It mirrors validation_service.go:withFormulaPermissionContext:
//   - Super-user capability holder: calls InjectSuperUserPermissions to set
//     every $Permission.X key referenced in expr to true.
//   - Normal user with a wired PermissionProvider: loads the effective system
//     and custom permissions from the provider (GetEffectiveSystemPermissions +
//     GetEffectiveCustomPermissions), merges them into a single map[string]bool,
//     and stores it under Variables["__Permission"].  The CLAUDE.md
//     "Formula context SSoT" rule is respected: $User/$Profile/$UserRole are
//     already populated from the JWT-bound UserSession by buildFlowFormulaContext;
//     only the permission map is fetched from the provider, and only when no DB
//     lookup is needed for $User/$Profile/$UserRole values.
//   - No provider: leaves Variables["__Permission"] as whatever buildFlowFormulaContext
//     set (empty map from the zero-value context), matching pre-fix semantics for
//     test executors that don't exercise $Permission formulas.
//
// expr is the raw formula expression string (used only by InjectSuperUserPermissions
// to extract permission key names without hardcoding L1 names in platform code).
func (e *FlowExecutor) injectFormulaPermissions(ctx context.Context, variables map[string]interface{}, expr string, user *models.UserSession) {
	if user == nil {
		return
	}
	// System-automation runs (scheduled flows, record-triggered system context) use
	// newSystemAutomationUser — ID == constants.SystemUserID, no ProfileID — and bypass
	// CRUD/FLS/RLS at the persistence layer via SystemContext, so they effectively hold
	// every permission. Without this they would hydrate an empty map below (no
	// ProfileID), making $Permission.X always false and silently blocking the
	// scheduled/system gates this PR advertises as supported. Mirror the
	// persistence-layer all-access for $Permission here.
	if user.ID == constants.SystemUserID {
		formula.InjectSuperUserPermissions(variables, expr, true)
		return
	}
	// Normal user: hydrate __Permission from the real permission service.
	// Fail-open when the service is unavailable: __Permission stays as an empty
	// map (set below), so $Permission.X resolves to false — the safe default.
	if _, alreadySet := variables["__Permission"].(map[string]bool); !alreadySet {
		variables["__Permission"] = map[string]bool{}
	}
	if e.permissions == nil {
		return
	}
	sysPerm, err := e.permissions.GetEffectiveSystemPermissions(ctx, user.ID, user.ProfileID)
	if err != nil {
		if e.logger != nil {
			e.logger.Warn(fmt.Sprintf("flow: $Permission: GetEffectiveSystemPermissions failed, using empty map: %s", err.Error()))
		}
		sysPerm = map[string]bool{}
	}
	if constants.HasSuperUserCapability(sysPerm) {
		formula.InjectSuperUserPermissions(variables, expr, true)
		return
	}
	custPerm, err := e.permissions.GetEffectiveCustomPermissions(ctx, user.ID, user.ProfileID)
	if err != nil {
		if e.logger != nil {
			e.logger.Warn(fmt.Sprintf("flow: $Permission: GetEffectiveCustomPermissions failed, using empty map: %s", err.Error()))
		}
		custPerm = map[string]bool{}
	}
	merged := make(map[string]bool, len(sysPerm)+len(custPerm))
	for k, v := range sysPerm {
		merged[k] = v
	}
	for k, v := range custPerm {
		merged[k] = v
	}
	variables["__Permission"] = merged
}

// evaluateFilterFormulaGate evaluates a record-triggered flow's start.filterFormula
// entry gate. FAIL-CLOSED: any eval error or non-bool result returns false (the flow
// does not fire). An entry gate that cannot be decided must not silently let the flow
// run — that silent always-run is exactly bug #2047. Deliberate stricter-than-design-time
// delta: Salesforce validates flow formulas at save time; NexusCRM evaluates at runtime
// and fails closed on a broken gate, logged at warn so a broken formula is visible.
//
// triggerType is flowMeta.Start.TriggerType — "RecordBeforeSave", "RecordAfterSave",
// "Scheduled", etc. It is used to guard the ISCHANGED-on-create rewrite: only
// record-triggered create events (TriggerType is RecordBeforeSave or RecordAfterSave,
// AND no prior record) should get ISCHANGED rewritten to true. Scheduled scans also
// have fCtx.Prior==nil but must NOT be treated as create events.
func (e *FlowExecutor) evaluateFilterFormulaGate(ctx context.Context, objectName string, triggerType string, expr string, context_ map[string]interface{}, user *models.UserSession) bool {
	engine, err := e.flowFormulaEngine(ctx)
	if err != nil {
		if e.logger != nil {
			e.logger.Warn(fmt.Sprintf("flow: resolve filter formula authority failed: %s", err.Error()))
		}
		return false
	}
	// Resolve trigger object type BEFORE building the fetcher so the fetcher
	// closure can fall back to it when a record (e.g. $Record__Old) carries no
	// object-type stamp and frc is not yet seeded. The same resolution logic that
	// sets fCtx.Schema is reused here — from the declared objectName, then frc
	// (empty at entry-gate time but safe to query), then the $Record stamp.
	schemaObjectType := objectName
	if schemaObjectType == "" {
		schemaObjectType = flowRuntimeContextFromCtx(ctx).ObjectType()
	}
	if schemaObjectType == "" {
		if rec, ok := context_["$Record"].(models.SObject); ok {
			schemaObjectType = models.RecordObjectType(rec)
		}
	}
	// buildFormulaFetcher only uses `interview` as a non-nil guard; its closure
	// reads the relation cache from ctx (RCache), not the interview. A transient
	// carrier enables cross-object (__r) resolution in entry-gate formulas without
	// threading the full interview through checkEntryConditionsCtx.
	// Pass schemaObjectType so $Record__Old relationship paths can resolve even
	// though frc is not seeded at entry-gate time (HIGH-1 fix, #2047 round-4).
	fetcher := e.buildFormulaFetcher(ctx, &models.FlowInterview{}, user, schemaObjectType)
	fCtx := buildFlowFormulaContext(context_, fetcher, user)
	fCtx.Variables[constants.FormulaContextSystemContext] = constants.FormulaSystemContextFromContext(ctx)
	if schemaObjectType != "" {
		fCtx.Schema = e.lookupObjectSchema(ctx, schemaObjectType)
	}
	// Hydrate __Permission for all users: super-user path sets every referenced
	// $Permission.X to true; normal-user path loads real perms from the
	// PermissionProvider (mirrors validation_service.go:withFormulaPermissionContext).
	e.injectFormulaPermissions(ctx, fCtx.Variables, expr, user)
	// filterFormula uses the pure formula contract (PR #1217 ruling: formula
	// ISCHANGED returns false on create, unlike the structured-filter "IsChanged"
	// operator which returns true when oldRecord==nil). No rewrite of ISCHANGED
	// for the formula path — the engine's native false-on-no-prior is correct here.
	//
	// Scheduled / non-record-trigger entry gate: the engine would compute
	// _is_new_=true from Prior==nil and ISNEW()→true would over-fire every
	// scanned row. Override via Context.ForceNotNew so the engine sets
	// _is_new_=false regardless of Prior. This is comment-injection-proof: the
	// regex approach (`ISNEW/*x*/()` survives regex, survives to engine, fires)
	// is replaced by a context override applied AFTER expression normalization.
	// ISCHANGED is already false on this path (no prior → _has_prior_ false);
	// ISCLONE is always false; PRIORVALUE resolves to nil. (Gate A round-9/10.)
	if !triggerprojection.IsRecordTriggerType(triggerType) {
		fCtx.ForceNotNew = true
	}
	result, err := engine.Evaluate(expr, fCtx)
	if err != nil {
		if e.logger != nil {
			e.logger.Warn(fmt.Sprintf("flow: start.filterFormula evaluation failed, blocking entry (fail-closed): %s", err.Error()))
		}
		return false
	}
	if b, ok := result.(bool); ok {
		return b
	}
	if e.logger != nil {
		e.logger.Warn(fmt.Sprintf("flow: start.filterFormula returned non-bool %T, blocking entry (fail-closed)", result))
	}
	return false
}
