package automation

import (
	"context"
	"fmt"
	"strings"

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

type runtimeApprovalLockedCriteriaTarget struct {
	objectName string
	recordID   string
	record     models.Record
}

type runtimeApprovalLockedCriteriaTargetKey struct{}

type approvalCriteriaRootReadCacheKey struct {
	objectName string
	recordID   string
}

type approvalCriteriaRootReadCacheResult struct {
	record        models.Record
	found         bool
	fetchedFields map[string]struct{}
}

type approvalCriteriaRootReadCache struct {
	results map[approvalCriteriaRootReadCacheKey]approvalCriteriaRootReadCacheResult
}

type approvalCriteriaRootReadCacheContextKey struct{}

// withApprovalCriteriaRootReadCache scopes root-record reads and active-formula
// projections to one eligibility resolution. Approval process selection is a
// first-match scan of one business record; rereading fields already established
// for an earlier candidate adds latency without adding authority or freshness.
// Transaction revalidation gets a fresh cache and still evaluates the row held
// by the transaction lock.
func withApprovalCriteriaRootReadCache(ctx context.Context) context.Context {
	if cache, _ := ctx.Value(approvalCriteriaRootReadCacheContextKey{}).(*approvalCriteriaRootReadCache); cache != nil {
		return ctx
	}
	return context.WithValue(ctx, approvalCriteriaRootReadCacheContextKey{}, &approvalCriteriaRootReadCache{
		results: make(map[approvalCriteriaRootReadCacheKey]approvalCriteriaRootReadCacheResult),
	})
}

func approvalCriteriaRootReadCacheFromContext(ctx context.Context) *approvalCriteriaRootReadCache {
	cache, _ := ctx.Value(approvalCriteriaRootReadCacheContextKey{}).(*approvalCriteriaRootReadCache)
	return cache
}

func withRuntimeApprovalLockedCriteriaTarget(ctx context.Context, objectName, recordID string, record models.Record) context.Context {
	return context.WithValue(ctx, runtimeApprovalLockedCriteriaTargetKey{}, runtimeApprovalLockedCriteriaTarget{
		objectName: objectName,
		recordID:   recordID,
		record:     cloneAutomationRecord(record),
	})
}

// Approval entry/step criteria are an admin-authored control: when the engine
// evaluates one, the acting principal is the rule author (by proxy), not the
// submitter. Criteria therefore execute in system context: evaluating them
// under the submitting user's FLS/sharing instead silently
// drops FLS-hidden gating fields from the comparison basis, and the
// ifCriteriaNotMet=ApproveRecord default turns that into a silent
// auto-approve (#3945).
//
// The reads below are the system-context seam for that evaluation. They are
// deliberately NOT a wholesale record load: system authority is capped to the
// exact field set the admin authored into the criteria (items, boolean-filter
// reachable items, statically collected formula references) — authoring a
// rule on a field is the authorization to read that field for evaluation,
// nothing wider. Security-blocked fields (secrets/password-class) stay
// excluded even here: the query layer drops them from any select regardless
// of SecurityContext.
//
// The resulting snapshot must never escape the criteria evaluator; see the
// leakage-fence tests in approval_criteria_system_context_test.go. The
// user-facing target record (SubmitEligibility.TargetRecord, notification
// loads) remains a submitter-context read.

// criteriaReferencedFields derives the fields the criteria evaluator reads,
// mirroring matchesCriterion / evaluateFormulaAgainstRecord. It is the SINGLE
// source of truth for both consumers of that fact — the field-capped system
// read (criteriaRootFieldSet) and the projection-need check
// (ensureCriteriaFormulaFieldsProjected); deriving them separately would let
// the cap and the projection drift apart and reintroduce the silent
// nil-compare class (#3913).
//
//   - rootFields: same-object item names, normalized, with the RecordType
//     criteria forms canonicalized to the stored FK column. A name still
//     containing a dot after normalization (e.g. "Owner.Name" from the
//     authored "Quote.Owner.Name" on a Quote) is dropped: the evaluator only
//     reads flat keys off the record — such a criterion compared nil before
//     this fix and must keep doing so, and a dotted name in SelectFields
//     would trip relationship-selection validation and turn that
//     criteria-CONTENT shape into a fail-closed outage. A criteria formula
//     contributes its statically collected current-object references
//     (single-segment by construction); a formula that fails static analysis
//     contributes nothing — it fails at evaluation as a criteria-CONTENT
//     error under the existing leniency, so nothing it references can matter.
//   - crossObjectFKs: for a cross-object item ("Parent.X"), the FK
//     "<Parent>Id" the hop resolution reads off the root record — the parent
//     row itself is read on its own hop (resolveCrossObjectValue).
func criteriaReferencedFields(objectName string, items []criterionItem, formulaExpr string) (rootFields, crossObjectFKs []string) {
	seen := make(map[string]struct{}, len(items)+4)
	addTo := func(dst *[]string, name string) {
		name = strings.TrimSpace(name)
		if name == "" {
			return
		}
		if _, dup := seen[name]; dup {
			return
		}
		seen[name] = struct{}{}
		*dst = append(*dst, name)
	}
	for _, item := range items {
		raw := strings.TrimSpace(item.field)
		if idx := strings.Index(raw, "."); idx >= 0 && !strings.EqualFold(raw[:idx], objectName) {
			addTo(&crossObjectFKs, strings.TrimSpace(raw[:idx])+"Id")
			continue
		}
		name := normalizeCriterionFieldName(raw)
		if strings.Contains(name, ".") {
			continue
		}
		if name == "RecordType" || name == constants.FieldSysLayoutAssignment_RecordTypeID {
			name = constants.FieldSysLayoutAssignment_RecordTypeID
		}
		addTo(&rootFields, name)
	}
	if strings.TrimSpace(formulaExpr) != "" {
		if refs, err := formula.CollectCurrentObjectFieldReferences(formulaExpr); err == nil {
			for _, ref := range refs {
				addTo(&rootFields, ref)
			}
		}
	}
	return rootFields, crossObjectFKs
}

// criteriaRootFieldSet is the system-read cap for the root record: everything
// the evaluator reads off it — same-object references plus the FKs that
// cross-object hops dereference.
func criteriaRootFieldSet(objectName string, items []criterionItem, formulaExpr string) []string {
	rootFields, crossObjectFKs := criteriaReferencedFields(objectName, items, formulaExpr)
	return append(rootFields, crossObjectFKs...)
}

// approvalCriteriaSystemRead loads recordID with system authority capped to
// the given criteria fields. found=false means the record does not exist (no
// FLS/sharing excuse remains under system context). Unknown or virtual names
// in fields are dropped by the query layer's select intersection; the
// caller-side overlay reads back only the names it asked for.
func (s *ApprovalService) approvalCriteriaSystemRead(ctx context.Context, objectName, recordID string, fields []string, user *models.UserSession) (models.Record, bool, error) {
	sysCtx := constants.WithSecurityContext(ctx, constants.SystemContext,
		"ApprovalService: criteria evaluation field-capped read of admin-authored criteria fields (#3945)")
	selectFields := make([]string, 0, len(fields)+1)
	selectFields = append(selectFields, constants.FieldID)
	selectFields = append(selectFields, fields...)
	res, err := s.query.QueryWithSpec(sysCtx, queryspec.QuerySpec{
		ObjectAPIName: objectName,
		SelectFields:  selectFields,
		Filter: queryspec.CriteriaPredicate([]queryspec.QueryCriterion{
			{Field: constants.FieldID, Op: "=", Val: recordID},
		}),
		Limit: 1,
	}, user)
	if err != nil {
		return nil, false, fmt.Errorf("criteria system read %s(%s): %w", objectName, recordID, err)
	}
	if res == nil || len(res.Records) == 0 {
		return nil, false, nil
	}
	return res.Records[0], true, nil
}

func (s *ApprovalService) approvalCriteriaRootRead(ctx context.Context, objectName, recordID string, fields []string, user *models.UserSession) (models.Record, bool, error) {
	cache := approvalCriteriaRootReadCacheFromContext(ctx)
	if cache == nil {
		return s.approvalCriteriaSystemRead(ctx, objectName, recordID, fields, user)
	}

	key := approvalCriteriaRootReadCacheKey{objectName: strings.ToLower(strings.TrimSpace(objectName)), recordID: recordID}
	cached, exists := cache.results[key]
	if exists && !cached.found {
		return nil, false, nil
	}
	if cached.record == nil {
		cached.record = models.Record{constants.FieldID: recordID}
	}
	if cached.fetchedFields == nil {
		cached.fetchedFields = make(map[string]struct{}, len(fields))
	}

	missing := make([]string, 0, len(fields))
	for _, field := range fields {
		field = strings.TrimSpace(field)
		if field == "" {
			continue
		}
		if _, ok := cached.fetchedFields[strings.ToLower(field)]; !ok {
			missing = append(missing, field)
		}
	}
	if len(missing) > 0 {
		row, found, err := s.approvalCriteriaSystemRead(ctx, objectName, recordID, missing, user)
		if err != nil {
			return nil, false, err
		}
		if !found {
			cache.results[key] = approvalCriteriaRootReadCacheResult{}
			return nil, false, nil
		}
		cached.found = true
		for _, field := range missing {
			cached.fetchedFields[strings.ToLower(field)] = struct{}{}
			if value, ok := row[field]; ok {
				cached.record[field] = value
			}
		}
	}
	cache.results[key] = cached
	return cloneAutomationRecord(cached.record), true, nil
}

func cacheApprovalCriteriaRootProjection(ctx context.Context, objectName, recordID string, snapshot models.Record, fields []string) {
	cache := approvalCriteriaRootReadCacheFromContext(ctx)
	if cache == nil || snapshot == nil {
		return
	}
	key := approvalCriteriaRootReadCacheKey{objectName: strings.ToLower(strings.TrimSpace(objectName)), recordID: recordID}
	cached, exists := cache.results[key]
	if !exists || !cached.found {
		return
	}
	for _, field := range fields {
		if value, ok := snapshot[field]; ok {
			cached.record[field] = value
		}
	}
	cache.results[key] = cached
}

// criteriaEvaluationRecord builds the snapshot criteria are evaluated
// against: a CLONE of base (never the caller's map — the snapshot absorbs
// system-read values and must not alias any record that reaches a
// user-facing surface) overlaid with the authored criteria fields from a
// field-capped system read. Only the requested field names are copied off
// the system row, so the snapshot never carries more hidden-field values
// than the criteria reference. found=false reports the root record absent
// under system visibility (deleted mid-evaluation); callers apply their own
// posture (entry: fail closed; step: legacy treat-as-match).
func (s *ApprovalService) criteriaEvaluationRecord(ctx context.Context, objectName, recordID string, base models.Record, fields []string, user *models.UserSession) (models.Record, bool, error) {
	if len(fields) == 0 {
		// Nothing authored to read (e.g. a malformed boolean filter that
		// statically references no item, or an unparseable formula): the
		// evaluation is going to fail as a criteria-CONTENT error on its
		// own, so don't touch the query port — mirrors the #3946 rule that
		// an unrelated port failure must not reclassify a doomed evaluation
		// into the record-unavailable fail-closed class.
		out := cloneAutomationRecord(base)
		if out == nil {
			out = models.Record{constants.FieldID: recordID}
		}
		return out, true, nil
	}
	if locked, ok := ctx.Value(runtimeApprovalLockedCriteriaTargetKey{}).(runtimeApprovalLockedCriteriaTarget); ok &&
		strings.EqualFold(locked.objectName, objectName) && locked.recordID == recordID && locked.record != nil {
		out := cloneAutomationRecord(base)
		if out == nil {
			out = models.Record{constants.FieldID: recordID}
		}
		var schema *models.ObjectMetadata
		if s.schema != nil {
			schema = s.schema.GetSchema(ctx, objectName)
		}
		for _, field := range fields {
			// The locked base-table row is the transaction's current source of
			// truth. Remove every authored field from the older pre-lock snapshot
			// before overlaying it: active formula fields are intentionally absent
			// from the base table and must remain absent here so the canonical
			// formula projector recomputes them below. Retaining an older nil/value
			// merely because the locked row omitted the virtual column would make
			// transaction revalidation evaluate a stale comparison basis.
			delete(out, field)
			if metadata := models.FindField(schema, field); metadata != nil && permissions.FieldReadBlockedByMetadata(metadata) {
				continue
			}
			if value, present := locked.record[field]; present {
				out[field] = value
			}
		}
		return out, true, nil
	}
	row, found, err := s.approvalCriteriaRootRead(ctx, objectName, recordID, fields, user)
	if err != nil {
		return nil, false, err
	}
	if !found {
		return nil, false, nil
	}
	out := cloneAutomationRecord(base)
	if out == nil {
		out = models.Record{}
	}
	if _, ok := out[constants.FieldID]; !ok {
		out[constants.FieldID] = recordID
	}
	for _, field := range fields {
		if value, ok := row[field]; ok {
			out[field] = value
		}
	}
	return out, true, nil
}
