package automation

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"reflect"
	"sort"
	"strings"
	"testing"

	"github.com/nexuscrm/kernel/pkg/formula"
	"github.com/nexuscrm/kernel/pkg/logging"
	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/nexuscrm/shared/pkg/queryspec"
	"github.com/rs/zerolog"
)

// Coverage for #3945: approval entry/step criteria are an admin-authored
// control and evaluate against a field-capped SystemContext read, so a
// gating field FLS- or sharing-hidden from the submitter/actor routes
// identically to an admin's view of the record. The harness below models the
// two security views explicitly: QueryWithFilter serves the USER-visible
// projection (and rejects elevated reads — user-facing loads must never
// widen), QueryWithSpec serves the SYSTEM row (and rejects non-elevated
// reads), projected to exactly the requested SelectFields so the
// least-privilege cap is load-bearing in every test, not decorative.

type recordedCriteriaSpec struct {
	object       string
	selectFields []string
}

type systemContextQueryStub struct {
	ports.QueryService
	// userViews is what the acting user can see (FLS/sharing applied):
	// object -> record. A missing object means the user cannot see the row.
	userViews map[string]models.SObject
	// systemRows is ground truth: object -> record.
	systemRows map[string]models.SObject
	// processDefs is served for ProcessDefinition list reads.
	processDefs []models.SObject

	// failReads makes the SYSTEM read of the given object fail with the
	// given error (simulating a DB/query failure on a criteria read).
	failReads map[string]error

	specCalls        []recordedCriteriaSpec
	userFilterCalls  []string
	elevationFailure error
}

func cloneTestSObject(in models.SObject) models.SObject {
	out := models.SObject{}
	for k, v := range in {
		out[k] = v
	}
	return out
}

func (s *systemContextQueryStub) QueryWithFilter(ctx context.Context, objectName, _ string, _ *models.UserSession, _ string, _ string, _ int) (*models.QueryResult, error) {
	if constants.GetSecurityContext(ctx) >= constants.SystemContext {
		// User-facing loads (submit target, process definitions) must never
		// arrive elevated: the #3945 elevation is scoped to the criteria
		// evaluator's own reads, all of which go through QueryWithSpec.
		s.elevationFailure = fmt.Errorf("user-context read of %s arrived with SystemContext", objectName)
		return nil, s.elevationFailure
	}
	s.userFilterCalls = append(s.userFilterCalls, objectName)
	if objectName == constants.TableProcessDefinition {
		return &models.QueryResult{TotalSize: len(s.processDefs), Records: s.processDefs}, nil
	}
	if objectName == constants.TableProcessInstance {
		return &models.QueryResult{TotalSize: 0}, nil
	}
	record, ok := s.userViews[objectName]
	if !ok {
		return &models.QueryResult{TotalSize: 0}, nil
	}
	return &models.QueryResult{TotalSize: 1, Records: []models.SObject{cloneTestSObject(record)}}, nil
}

func (s *systemContextQueryStub) QueryWithSpec(ctx context.Context, spec queryspec.QuerySpec, _ *models.UserSession) (*models.QueryResult, error) {
	if constants.GetSecurityContext(ctx) < constants.SystemContext {
		s.elevationFailure = fmt.Errorf("criteria read of %s arrived without SystemContext", spec.ObjectAPIName)
		return nil, s.elevationFailure
	}
	s.specCalls = append(s.specCalls, recordedCriteriaSpec{
		object:       spec.ObjectAPIName,
		selectFields: append([]string(nil), spec.SelectFields...),
	})
	if failErr, failing := s.failReads[spec.ObjectAPIName]; failing {
		return nil, failErr
	}
	record, ok := s.systemRows[spec.ObjectAPIName]
	if !ok {
		return &models.QueryResult{TotalSize: 0}, nil
	}
	// Serve ONLY the requested fields: the cap must be what makes hidden
	// values reachable, and nothing beyond it.
	out := models.SObject{}
	for _, field := range spec.SelectFields {
		if value, present := record[field]; present {
			out[field] = value
		}
	}
	if id, present := record[constants.FieldID]; present {
		out[constants.FieldID] = id
	}
	return &models.QueryResult{TotalSize: 1, Records: []models.SObject{out}}, nil
}

func systemContextTestSchema() SchemaLookup {
	amount := &models.FieldMetadata{APIName: "Amount", Type: models.FieldType(constants.FieldTypeDouble)}
	widgetFK := &models.FieldMetadata{APIName: "WidgetId", Type: models.FieldType(constants.FieldTypeReference)}
	region := &models.FieldMetadata{APIName: "Region", Type: models.FieldType(constants.FieldTypeString)}
	tier := &models.FieldMetadata{APIName: "Tier", Type: models.FieldType(constants.FieldTypeString)}
	return criteriaSchemaStub{schemas: map[string]*models.ObjectMetadata{
		"Widget": {
			APIName: "Widget",
			Fields:  []models.FieldMetadata{*amount, *region, *tier},
			FieldsMap: map[string]*models.FieldMetadata{
				"amount": amount, "region": region, "tier": tier,
			},
		},
		"Gadget": {
			APIName:   "Gadget",
			Fields:    []models.FieldMetadata{*widgetFK},
			FieldsMap: map[string]*models.FieldMetadata{"widgetid": widgetFK},
		},
	}}
}

func newSystemContextTestService(query ports.QueryService) *ApprovalService {
	return NewApprovalService(nil, query, nil, nil, nil, nil, systemContextTestSchema(), logging.Default)
}

func requireNoElevationFailure(t *testing.T, query *systemContextQueryStub) {
	t.Helper()
	if query.elevationFailure != nil {
		t.Fatalf("security-context routing violated: %v", query.elevationFailure)
	}
}

func TestRuntimeApprovalLockedCriteriaTargetOverridesOlderSystemSnapshot(t *testing.T) {
	query := &systemContextQueryStub{
		systemRows: map[string]models.SObject{
			"Widget": {constants.FieldID: "rec-1", "Region": "Stale"},
		},
	}
	svc := newSystemContextTestService(query)
	ctx := withRuntimeApprovalLockedCriteriaTarget(
		t.Context(), "Widget", "rec-1",
		models.SObject{constants.FieldID: "rec-1", "Region": "Current"},
	)
	matched, err := svc.matchEntryCriteriaAgainstRecord(
		ctx, "Region equals Current", "Widget", "rec-1",
		models.SObject{constants.FieldID: "rec-1", "Region": "Stale"}, formulaProjectionTestUser(),
	)
	if err != nil {
		t.Fatal(err)
	}
	if !matched || len(query.specCalls) != 0 {
		t.Fatalf("matched=%v system snapshot reads=%#v, want locked current-row criteria basis", matched, query.specCalls)
	}
}

type runtimeSubmitRevalidationPersistence struct {
	PersistenceProvider
	locked models.SObject
}

func (p *runtimeSubmitRevalidationPersistence) GetLock(context.Context, string, string) (models.SObject, error) {
	return cloneAutomationSObject(p.locked), nil
}

func TestRuntimeApprovalSubmitRevalidationRejectsStaleEligibleSnapshot(t *testing.T) {
	definition := models.SObject{
		constants.FieldID: "pd-1", "TableEnumOrId": "Widget", "State": "Active",
		"EntryFilter": "Region equals Current", "Metadata": "{}",
	}
	query := &systemContextQueryStub{
		processDefs: []models.SObject{definition},
		// Model the transaction snapshot from before the target-row lock.
		systemRows: map[string]models.SObject{
			"Widget": {constants.FieldID: "rec-1", "Region": "Current"},
		},
	}
	persistence := &runtimeSubmitRevalidationPersistence{
		locked: models.SObject{constants.FieldID: "rec-1", "Region": "Changed"},
	}
	svc := NewApprovalService(
		persistence, query, nil, nil, nil, nil, systemContextTestSchema(), logging.Default,
	)
	err := svc.revalidateRuntimeSubmitPlan(t.Context(), approvalSubmitRuntimeRequest{
		processDefinitionID: "pd-1", objectName: "Widget", targetRecordID: "rec-1",
		runtimeEligibility: &SubmitEligibility{
			TargetRecordID: "rec-1", ObjectAPIName: "Widget", ProcessDefinition: definition,
			ProcessDefinitionID: "pd-1", TargetRecord: models.SObject{constants.FieldID: "rec-1"},
			runtimeRevalidate: true,
		},
	}, formulaProjectionTestUser())
	if !errors.Is(err, ErrEntryCriteriaNotMet) {
		t.Fatalf("revalidation error = %v, want ErrEntryCriteriaNotMet from locked current row", err)
	}
	if len(query.specCalls) != 0 {
		t.Fatalf("revalidation read older system snapshot: %#v", query.specCalls)
	}
}

// Two-sided FLS case (#3945 test plan): a submitter WITHOUT read access on
// the gating field must route exactly like an admin — above threshold the
// criteria match, below threshold they do not. Pre-fix, the hidden field was
// absent from the submitter-context load, compared nil, and both sides
// collapsed to "no match".
func TestEntryCriteriaItemsEvaluateFLSHiddenGatingFieldTwoSided(t *testing.T) {
	entry := `{"criteriaItems": [{"field": "Widget.Amount", "operation": "greaterOrEqual", "value": "90"}]}`
	for _, tc := range []struct {
		name   string
		amount float64
		want   bool
	}{
		{name: "above threshold matches", amount: 95, want: true},
		{name: "below threshold does not match", amount: 30, want: false},
	} {
		t.Run(tc.name, func(t *testing.T) {
			query := &systemContextQueryStub{
				systemRows: map[string]models.SObject{
					"Widget": {constants.FieldID: "rec-1", "Amount": tc.amount},
				},
			}
			svc := newSystemContextTestService(query)

			// The submitter-visible record: FLS hides Amount entirely.
			submitterRecord := models.SObject{constants.FieldID: "rec-1"}
			matched, err := svc.matchEntryCriteriaAgainstRecord(context.Background(), entry, "Widget", "rec-1", submitterRecord, formulaProjectionTestUser())
			if err != nil {
				t.Fatalf("matchEntryCriteriaAgainstRecord() error = %v", err)
			}
			requireNoElevationFailure(t, query)
			if matched != tc.want {
				t.Fatalf("matched = %v, want %v (criteria must evaluate the system value %v, not the FLS-dropped nil)", matched, tc.want, tc.amount)
			}
			// Least-privilege cap (decision 1): the system read selects
			// exactly the authored field set plus Id — never the wholesale
			// record.
			if len(query.specCalls) != 1 {
				t.Fatalf("system reads = %d, want exactly 1", len(query.specCalls))
			}
			wantFields := []string{constants.FieldID, "Amount"}
			if !reflect.DeepEqual(query.specCalls[0].selectFields, wantFields) {
				t.Fatalf("system read SelectFields = %v, want %v (capped to authored criteria fields)", query.specCalls[0].selectFields, wantFields)
			}
			// Leakage fence (decision 4): the caller's record — the map that
			// escapes as SubmitEligibility.TargetRecord — must not absorb the
			// system-read value.
			if _, leaked := submitterRecord["Amount"]; leaked {
				t.Fatalf("submitter-visible record absorbed the FLS-hidden value: %#v", submitterRecord)
			}
		})
	}
}

func TestApprovalEntryCriteriaMatchesPublishedNAServiceQuote(t *testing.T) {
	entry := `{"booleanFilter":"1 AND 2 AND (3 OR 4) AND 5 AND (6 OR 7)","criteriaItems":[{"field":"Opportunity.Region__c","operation":"equals","value":"NA"},{"field":"Quote.Product_Family__c","operation":"notContain","value":"Cloud"},{"field":"Quote.Rebate_Fee_Rate__c","operation":"equals","value":""},{"field":"Quote.Rebate_Fee_Rate__c","operation":"equals","value":"0"},{"field":"Quote.Referral_Fee_Rate__c","operation":"lessOrEqual","value":"5"},{"field":"Quote.Partner_Margin__c","operation":"equals","value":""},{"field":"Quote.Partner_Margin__c","operation":"equals","value":"0"}]}`
	query := &systemContextQueryStub{
		systemRows: map[string]models.SObject{
			"Quote": {
				constants.FieldID: "quote-1", "OpportunityId": "opportunity-1",
				"Product_Family__c":  "TiDB Enterprise Subscription",
				"Rebate_Fee_Rate__c": nil, "Referral_Fee_Rate__c": 0,
				"Partner_Margin__c": nil,
			},
			"Opportunity": {constants.FieldID: "opportunity-1", "Region__c": "NA"},
		},
	}
	svc := newSystemContextTestService(query)
	matched, err := svc.matchEntryCriteriaAgainstRecord(
		t.Context(), entry, "Quote", "quote-1",
		models.SObject{constants.FieldID: "quote-1"}, formulaProjectionTestUser(),
	)
	if err != nil {
		t.Fatal(err)
	}
	if !matched {
		t.Fatalf("published NA service criteria did not match production-shaped Quote; reads=%#v", query.specCalls)
	}
	requireNoElevationFailure(t, query)
}

// Two-sided step gate with a non-elevated incoming ctx and an actor who
// cannot see the record at all: step routing must follow the authored
// criteria against the system row, deterministically, with no actor-context
// read of the target object involved. (Production step callers today arrive
// wholesale-elevated — this pins that the basis is the evaluator's OWN
// capped system read, independent of what the caller's ctx happens to
// grant.)
func TestStepCriteriaGateEvaluatesActorHiddenRecordTwoSided(t *testing.T) {
	for _, tc := range []struct {
		name   string
		amount float64
		want   bool
	}{
		{name: "above threshold gate engages", amount: 92, want: true},
		{name: "below threshold gate skips", amount: 30, want: false},
	} {
		t.Run(tc.name, func(t *testing.T) {
			query := &systemContextQueryStub{
				// userViews deliberately empty: the actor cannot read the row.
				systemRows: map[string]models.SObject{
					"Widget": {constants.FieldID: "rec-1", "Amount": tc.amount},
				},
			}
			svc := newSystemContextTestService(query)

			def, node := stepCriteriaDefNode(`{
				"approvalStep": [{
					"name": "Margin_Gate",
					"stepCriteria": {"criteriaItems": [
						{"field": "Widget.Amount", "operation": "greaterOrEqual", "value": "90"}
					]},
					"ifCriteriaNotMet": "ApproveRecord"
				}]
			}`)

			matched, err := svc.stepCriteriaMatches(context.Background(), def, node, "Widget", "rec-1", formulaProjectionTestUser())
			if err != nil {
				t.Fatalf("stepCriteriaMatches() error = %v", err)
			}
			requireNoElevationFailure(t, query)
			if matched != tc.want {
				t.Fatalf("stepCriteriaMatches() = %v, want %v for system value %v", matched, tc.want, tc.amount)
			}
			for _, obj := range query.userFilterCalls {
				if obj == "Widget" {
					t.Fatal("step criteria performed an actor-context read of the target record; the evaluation basis must be the system read alone")
				}
			}
		})
	}
}

// Decision 2: system authority must cover sharing/RLS on cross-object hops,
// not only FLS on the root. The parent row is entirely invisible to the
// submitter; the authored criterion still evaluates its true value, and the
// hop read stays capped to the one authored field.
func TestEntryCriteriaCrossObjectSharingHiddenParentEvaluates(t *testing.T) {
	entry := `{"criteriaItems": [{"field": "Widget.Region", "operation": "equals", "value": "NA"}]}`
	query := &systemContextQueryStub{
		systemRows: map[string]models.SObject{
			"Gadget": {constants.FieldID: "child-1", "WidgetId": "parent-1"},
			"Widget": {constants.FieldID: "parent-1", "Region": "NA"},
		},
	}
	svc := newSystemContextTestService(query)

	submitterRecord := models.SObject{constants.FieldID: "child-1", "WidgetId": "parent-1"}
	matched, err := svc.matchEntryCriteriaAgainstRecord(context.Background(), entry, "Gadget", "child-1", submitterRecord, formulaProjectionTestUser())
	if err != nil {
		t.Fatalf("matchEntryCriteriaAgainstRecord() error = %v", err)
	}
	requireNoElevationFailure(t, query)
	if !matched {
		t.Fatal("matched = false, want true: sharing-hidden parent must still evaluate the authored criterion")
	}
	var parentFields [][]string
	for _, call := range query.specCalls {
		if call.object == "Widget" {
			parentFields = append(parentFields, call.selectFields)
		}
	}
	if len(parentFields) != 1 || !reflect.DeepEqual(parentFields[0], []string{constants.FieldID, "Region"}) {
		t.Fatalf("parent hop reads = %v, want exactly one capped to [Id Region]", parentFields)
	}
}

// A dangling FK must stay a null compare (Salesforce parity), not become a
// fail-closed error: migrated data contains FKs whose target row is gone,
// and system authority removes the visibility excuse without inventing a
// new failure mode.
func TestEntryCriteriaCrossObjectDanglingFKComparesAsNull(t *testing.T) {
	entry := `{"criteriaItems": [{"field": "Widget.Region", "operation": "equals", "value": "NA"}]}`
	query := &systemContextQueryStub{
		systemRows: map[string]models.SObject{
			"Gadget": {constants.FieldID: "child-1", "WidgetId": "parent-gone"},
			// no Widget row: the FK dangles
		},
	}
	svc := newSystemContextTestService(query)

	record := models.SObject{constants.FieldID: "child-1", "WidgetId": "parent-gone"}
	matched, err := svc.matchEntryCriteriaAgainstRecord(context.Background(), entry, "Gadget", "child-1", record, formulaProjectionTestUser())
	if err != nil {
		t.Fatalf("matchEntryCriteriaAgainstRecord() error = %v, want nil (dangling FK is a null compare, not a failure)", err)
	}
	if matched {
		t.Fatal("matched = true, want false for a dangling FK")
	}
}

// The per-evaluation parent cache accumulates capped reads: a second
// criterion on the SAME parent but a DIFFERENT field must trigger a second
// capped fetch and evaluate correctly — key-presence on the cached row is
// not the "already fetched" signal (a NULL column and a never-fetched column
// look identical there).
func TestEntryCriteriaCrossObjectCappedCacheAccumulatesFields(t *testing.T) {
	entry := `{"criteriaItems": [
		{"field": "Widget.Region", "operation": "equals", "value": "NA"},
		{"field": "Widget.Tier", "operation": "equals", "value": "Gold"}
	]}`
	query := &systemContextQueryStub{
		systemRows: map[string]models.SObject{
			"Gadget": {constants.FieldID: "child-1", "WidgetId": "parent-1"},
			"Widget": {constants.FieldID: "parent-1", "Region": "NA", "Tier": "Gold"},
		},
	}
	svc := newSystemContextTestService(query)

	ctx := withApprovalCrossObjectLookupCache(context.Background())
	record := models.SObject{constants.FieldID: "child-1", "WidgetId": "parent-1"}
	matched, err := svc.matchEntryCriteriaAgainstRecord(ctx, entry, "Gadget", "child-1", record, formulaProjectionTestUser())
	if err != nil {
		t.Fatalf("matchEntryCriteriaAgainstRecord() error = %v", err)
	}
	if !matched {
		t.Fatal("matched = false, want true: the cached capped parent row must grow the second authored field")
	}
	var parentReads []string
	for _, call := range query.specCalls {
		if call.object == "Widget" {
			parentReads = append(parentReads, call.selectFields[len(call.selectFields)-1])
		}
	}
	sort.Strings(parentReads)
	if !reflect.DeepEqual(parentReads, []string{"Region", "Tier"}) {
		t.Fatalf("parent capped reads = %v, want one per authored field [Region Tier]", parentReads)
	}
}

// End-to-end leakage fence (decision 4): submit resolution routes on the
// hidden gating field, while the user-facing SubmitEligibility.TargetRecord
// keeps the submitter's FLS view — the discriminating pair the proposal
// requires (routing correct AND snapshot clean).
func TestResolveSubmitEligibilityRoutesOnHiddenFieldWithoutLeakingIt(t *testing.T) {
	entry := `{"criteriaItems": [{"field": "Widget.Amount", "operation": "greaterOrEqual", "value": "90"}]}`
	query := &systemContextQueryStub{
		userViews: map[string]models.SObject{
			"Widget": {constants.FieldID: "rec-1"}, // FLS hides Amount
		},
		systemRows: map[string]models.SObject{
			"Widget": {constants.FieldID: "rec-1", "Amount": 95.0},
		},
		processDefs: []models.SObject{{
			constants.FieldID: "pd-gate",
			"TableEnumOrId":   "Widget",
			"State":           "Active",
			"EntryFilter":     entry,
		}},
	}
	persistence := &formulaProjectionPersistenceStub{}
	svc := NewApprovalService(persistence, query, nil, nil, nil, nil, systemContextTestSchema(), logging.Default)

	eligibility, err := svc.ResolveSubmitEligibility(context.Background(),
		SubmitRequest{ObjectAPIName: "Widget", RecordID: "rec-1"}, formulaProjectionTestUser())
	if err != nil {
		t.Fatalf("ResolveSubmitEligibility() error = %v", err)
	}
	requireNoElevationFailure(t, query)
	if eligibility.ProcessDefinitionID != "pd-gate" {
		t.Fatalf("ProcessDefinitionID = %q, want pd-gate (routing must see the hidden 95 >= 90)", eligibility.ProcessDefinitionID)
	}
	if _, leaked := eligibility.TargetRecord["Amount"]; leaked {
		t.Fatalf("SubmitEligibility.TargetRecord carries the FLS-hidden field: %#v", eligibility.TargetRecord)
	}
}

// The strict-projection direction (decision 3): the active-formula
// projection for criteria evaluation must arrive at the persistence seam
// under SystemContext, which routes RecomputeActiveFormulaProjection to the
// fail-closed strict mode instead of the user-context best-effort mode.
type projectionCtxCapturePersistenceStub struct {
	PersistenceProvider
	level    constants.SecurityContext
	called   bool
	fields   []string
	snapshot models.SObject
}

func (s *projectionCtxCapturePersistenceStub) RecomputeActiveFormulaProjection(ctx context.Context, _ *models.UserSession, _, _ string, fields []string) (models.SObject, error) {
	s.called = true
	s.level = constants.GetSecurityContext(ctx)
	s.fields = append([]string(nil), fields...)
	return s.snapshot, nil
}

func TestCriteriaFormulaProjectionRunsUnderSystemContext(t *testing.T) {
	persistence := &projectionCtxCapturePersistenceStub{snapshot: models.SObject{"Margin_Rate__c": 92.0}}
	query := &formulaProjectionQueryStub{record: models.SObject{constants.FieldID: "rec-1"}}
	svc := NewApprovalService(persistence, query, nil, nil, nil, nil, formulaProjectionSchema(), logging.Default)

	entry := `{"criteriaItems": [{"field": "Widget.Margin_Rate__c", "operation": "greaterOrEqual", "value": "90"}]}`
	matched, err := svc.matchEntryCriteriaAgainstRecord(context.Background(), entry, "Widget", "rec-1", models.SObject{constants.FieldID: "rec-1"}, formulaProjectionTestUser())
	if err != nil {
		t.Fatalf("matchEntryCriteriaAgainstRecord() error = %v", err)
	}
	if !matched {
		t.Fatal("matched = false, want true for projected 92 >= 90")
	}
	if !persistence.called {
		t.Fatal("RecomputeActiveFormulaProjection was never called")
	}
	if persistence.level < constants.SystemContext {
		t.Fatalf("projection SecurityContext = %v, want SystemContext (strict fail-closed mode)", persistence.level)
	}
	// The projection seam receives the referenced subset, so the strict
	// variant selects and validates ONLY the authored fields — an unrelated
	// active field's view drift cannot fail the scoped read.
	if !reflect.DeepEqual(persistence.fields, []string{"Margin_Rate__c"}) {
		t.Fatalf("projection fields = %v, want [Margin_Rate__c] (scoped to authored criteria fields)", persistence.fields)
	}
}

// A step-criteria formula written in the engine's `record.<Field>` namespace
// must contribute its field to the capped basis: with the basis narrowed to
// the derived set, a collector gap here would nil the reference and silently
// flip the gate (the exact auto-approve class #3945 closes). Two-sided.
func TestStepCriteriaFormulaRecordNamespaceFieldInBasisTwoSided(t *testing.T) {
	for _, tc := range []struct {
		name   string
		amount float64
		want   bool
	}{
		{name: "above threshold gate engages", amount: 92, want: true},
		{name: "below threshold gate skips", amount: 30, want: false},
	} {
		t.Run(tc.name, func(t *testing.T) {
			query := &systemContextQueryStub{
				systemRows: map[string]models.SObject{
					"Widget": {constants.FieldID: "rec-1", "Amount": tc.amount},
				},
			}
			svc := newSystemContextTestService(query)
			svc.formulaEngine = formula.NewEngine()

			def, node := stepCriteriaDefNode(`{
				"approvalStep": [{
					"name": "Margin_Gate",
					"stepCriteria": {"formula": "record.Amount >= 90"},
					"ifCriteriaNotMet": "ApproveRecord"
				}]
			}`)

			matched, err := svc.stepCriteriaMatches(context.Background(), def, node, "Widget", "rec-1", formulaProjectionTestUser())
			if err != nil {
				t.Fatalf("stepCriteriaMatches() error = %v", err)
			}
			requireNoElevationFailure(t, query)
			if matched != tc.want {
				t.Fatalf("stepCriteriaMatches() = %v, want %v: record.Amount must be part of the capped basis (value %v)", matched, tc.want, tc.amount)
			}
			if len(query.specCalls) != 1 || !reflect.DeepEqual(query.specCalls[0].selectFields, []string{constants.FieldID, "Amount"}) {
				t.Fatalf("system reads = %+v, want one capped to [Id Amount]", query.specCalls)
			}
		})
	}
}

// A step-criteria formula using PRIORVALUE(Field) must keep Field in the
// capped basis: the lowering produces a `_prior_Field` identifier, and on
// the no-prior approval path the engine seeds `_prior_<field>` from the
// record's CURRENT values — so dropping the field from the read silently
// flips the gate (Codex round-3). Two-sided.
func TestStepCriteriaFormulaPriorValueFieldInBasisTwoSided(t *testing.T) {
	for _, tc := range []struct {
		name   string
		amount float64
		want   bool
	}{
		{name: "above threshold gate engages", amount: 92, want: true},
		{name: "below threshold gate skips", amount: 30, want: false},
	} {
		t.Run(tc.name, func(t *testing.T) {
			query := &systemContextQueryStub{
				systemRows: map[string]models.SObject{
					"Widget": {constants.FieldID: "rec-1", "Amount": tc.amount},
				},
			}
			svc := newSystemContextTestService(query)
			svc.formulaEngine = formula.NewEngine()

			def, node := stepCriteriaDefNode(`{
				"approvalStep": [{
					"name": "Margin_Gate",
					"stepCriteria": {"formula": "PRIORVALUE(Amount) >= 90"},
					"ifCriteriaNotMet": "ApproveRecord"
				}]
			}`)

			matched, err := svc.stepCriteriaMatches(context.Background(), def, node, "Widget", "rec-1", formulaProjectionTestUser())
			if err != nil {
				t.Fatalf("stepCriteriaMatches() error = %v", err)
			}
			requireNoElevationFailure(t, query)
			if matched != tc.want {
				t.Fatalf("stepCriteriaMatches() = %v, want %v: PRIORVALUE(Amount) must read the capped system value %v", matched, tc.want, tc.amount)
			}
			if len(query.specCalls) != 1 || !reflect.DeepEqual(query.specCalls[0].selectFields, []string{constants.FieldID, "Amount"}) {
				t.Fatalf("system reads = %+v, want one capped to [Id Amount]", query.specCalls)
			}
		})
	}
}

// A criteria formula using DYNAMIC record member access (record[Key]) reads
// the field named by Key's runtime value — the capped basis cannot cover it
// statically, and evaluating anyway would compare a silently under-read nil.
// The shape is outside the SF formula grammar and is refused as a
// criteria-CONTENT error: entry keeps the content-error posture (not
// record-unavailable), step treats it as match so the gate ENGAGES instead
// of silently skipping.
func TestCriteriaDynamicRecordAccessRefusedAsContentError(t *testing.T) {
	query := &systemContextQueryStub{
		systemRows: map[string]models.SObject{
			"Widget": {constants.FieldID: "rec-1", "Amount": 30.0},
		},
	}
	svc := newSystemContextTestService(query)
	svc.formulaEngine = formula.NewEngine()

	entry := `{"formula": "record[Amount] >= 90"}`
	_, err := svc.matchEntryCriteriaAgainstRecord(context.Background(), entry, "Widget", "rec-1", models.SObject{constants.FieldID: "rec-1"}, formulaProjectionTestUser())
	if err == nil {
		t.Fatal("matchEntryCriteriaAgainstRecord() error = nil, want content-class refusal for dynamic record access")
	}
	if errors.Is(err, errCriteriaRecordUnavailable) {
		t.Fatalf("error = %v, want criteria-CONTENT class, not record-unavailable", err)
	}

	def, node := stepCriteriaDefNode(`{
		"approvalStep": [{
			"name": "Margin_Gate",
			"stepCriteria": {"formula": "record[Amount] >= 90"},
			"ifCriteriaNotMet": "ApproveRecord"
		}]
	}`)
	matched, stepErr := svc.stepCriteriaMatches(context.Background(), def, node, "Widget", "rec-1", formulaProjectionTestUser())
	if stepErr != nil {
		t.Fatalf("stepCriteriaMatches() error = %v", stepErr)
	}
	if !matched {
		t.Fatal("stepCriteriaMatches() = false, want true: a refused dynamic-access formula must engage the gate, never silently skip it")
	}
}

// Engine runtime error text can echo an operand back (e.g.
// DATETIMEFROMEPOCHMILLIS rejecting a non-integral number with "got %v").
// With the evaluation basis carrying system-read values, that text is a
// disclosure channel through BOTH the error chain and the server log — the
// evaluation seam must withhold it (leakage fence, #3945 round-7).
func TestCriteriaFormulaEngineErrorTextNeverCarriesHiddenValues(t *testing.T) {
	const hiddenValue = "90.5407"
	var logBuf bytes.Buffer
	logger := logging.New(zerolog.New(&logBuf).WithContext(context.Background()))

	query := &systemContextQueryStub{
		systemRows: map[string]models.SObject{
			// Non-integral float: DATETIMEFROMEPOCHMILLIS rejects it with an
			// error text embedding the value.
			"Widget": {constants.FieldID: "rec-1", "Amount": 90.5407},
		},
	}
	svc := NewApprovalService(nil, query, nil, nil, nil, nil, systemContextTestSchema(), logger)
	svc.formulaEngine = formula.NewEngine()

	entry := `{"formula": "DATETIMEFROMEPOCHMILLIS(Amount) != null"}`
	_, err := svc.matchEntryCriteriaAgainstRecord(context.Background(), entry, "Widget", "rec-1", models.SObject{constants.FieldID: "rec-1"}, formulaProjectionTestUser())
	if err == nil {
		t.Fatal("matchEntryCriteriaAgainstRecord() error = nil, want engine failure surfaced as content error")
	}
	if strings.Contains(err.Error(), hiddenValue) {
		t.Fatalf("error chain carries the hidden operand value: %v", err)
	}
	if logged := logBuf.String(); strings.Contains(logged, hiddenValue) {
		t.Fatalf("server log carries the hidden operand value: %q", logged)
	}

	// Step posture unchanged: the value-safe error still degrades to
	// treat-as-match (gate engages), with a value-free log line.
	logBuf.Reset()
	def, node := stepCriteriaDefNode(`{
		"approvalStep": [{
			"name": "Margin_Gate",
			"stepCriteria": {"formula": "DATETIMEFROMEPOCHMILLIS(Amount) != null"},
			"ifCriteriaNotMet": "ApproveRecord"
		}]
	}`)
	matched, stepErr := svc.stepCriteriaMatches(context.Background(), def, node, "Widget", "rec-1", formulaProjectionTestUser())
	if stepErr != nil {
		t.Fatalf("stepCriteriaMatches() error = %v", stepErr)
	}
	if !matched {
		t.Fatal("stepCriteriaMatches() = false, want true (engine failure engages the gate)")
	}
	if logged := logBuf.String(); strings.Contains(logged, hiddenValue) {
		t.Fatalf("step-path log carries the hidden operand value: %q", logged)
	}
}

// A failing cross-object hop must not put the parent FK id into the error
// chain (and thence the server log): for a hidden reference field the ID is
// the field's VALUE, and the inner query error embeds it in filters too —
// both are withheld at the hop boundary while the record-unavailable
// classification is preserved (leakage fence, #3945 round-8).
func TestCriteriaCrossObjectHopFailureNeverCarriesHiddenFKValue(t *testing.T) {
	const hiddenFK = "parent-secret-0042"
	query := &systemContextQueryStub{
		systemRows: map[string]models.SObject{
			"Gadget": {constants.FieldID: "child-1", "WidgetId": hiddenFK},
		},
		failReads: map[string]error{
			"Widget": fmt.Errorf("query failed for filter Id == '%s'", hiddenFK),
		},
	}
	svc := newSystemContextTestService(query)

	entry := `{"criteriaItems": [{"field": "Widget.Region", "operation": "equals", "value": "NA"}]}`
	record := models.SObject{constants.FieldID: "child-1", "WidgetId": hiddenFK}
	_, err := svc.matchEntryCriteriaAgainstRecord(context.Background(), entry, "Gadget", "child-1", record, formulaProjectionTestUser())
	if err == nil {
		t.Fatal("matchEntryCriteriaAgainstRecord() error = nil, want hop failure surfaced")
	}
	if !errors.Is(err, errCriteriaRecordUnavailable) {
		t.Fatalf("error = %v, want errCriteriaRecordUnavailable class", err)
	}
	if strings.Contains(err.Error(), hiddenFK) {
		t.Fatalf("error chain carries the hidden FK value: %v", err)
	}
}

// criteriaRootFieldSet mirrors the evaluator's record reads exactly; pin the
// derivation table so the cap and the evaluator cannot drift apart silently.
func TestCriteriaRootFieldSetDerivation(t *testing.T) {
	for _, tc := range []struct {
		name    string
		object  string
		items   []criterionItem
		formula string
		want    []string
	}{
		{
			name:   "same-object prefixed item normalizes",
			object: "Widget",
			items:  []criterionItem{{field: "Widget.Amount"}},
			want:   []string{"Amount"},
		},
		{
			name:   "bare item",
			object: "Widget",
			items:  []criterionItem{{field: "Amount"}},
			want:   []string{"Amount"},
		},
		{
			name:   "cross-object item contributes only the FK",
			object: "Quote",
			items:  []criterionItem{{field: "Opportunity.Region__c"}},
			want:   []string{"OpportunityId"},
		},
		{
			name:   "RecordType forms canonicalize and dedupe",
			object: "Widget",
			items:  []criterionItem{{field: "RecordType"}, {field: "RecordTypeId"}},
			want:   []string{"RecordTypeId"},
		},
		{
			name:    "formula contributes collected current-object references",
			object:  "Widget",
			formula: "AND(Margin__c > 0.5, Amount > 100)",
			want:    []string{"Margin__c", "Amount"},
		},
		{
			name:    "unparseable formula contributes nothing",
			object:  "Widget",
			formula: "AND((",
			want:    []string{},
		},
		{
			name:    "items and formula merge with dedupe",
			object:  "Widget",
			items:   []criterionItem{{field: "Amount"}},
			formula: "Amount > 100",
			want:    []string{"Amount"},
		},
		{
			// The evaluator only reads flat keys: a same-object multi-hop
			// item ("Quote.Owner.Name" normalized to "Owner.Name") compared
			// nil before the fix and must keep doing so — a dotted name in
			// SelectFields would trip relationship-selection validation and
			// turn this criteria-CONTENT shape into a fail-closed outage.
			name:   "same-object multi-hop dotted name is dropped",
			object: "Quote",
			items:  []criterionItem{{field: "Quote.Owner.Name"}, {field: "Amount"}},
			want:   []string{"Amount"},
		},
		{
			name:   "cross-object FK derivation trims whitespace",
			object: "Quote",
			items:  []criterionItem{{field: "Opportunity .Region__c"}},
			want:   []string{"OpportunityId"},
		},
		{
			name:    "formula record namespace contributes the field",
			object:  "Widget",
			formula: `record.Amount >= 90`,
			want:    []string{"Amount"},
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			got := criteriaRootFieldSet(tc.object, tc.items, tc.formula)
			if len(got) == 0 && len(tc.want) == 0 {
				return
			}
			if !reflect.DeepEqual(got, tc.want) {
				t.Fatalf("criteriaRootFieldSet() = %v, want %v", got, tc.want)
			}
		})
	}
}

// With nothing authored to read (a malformed boolean filter statically
// references no item), the evaluator must not touch the query port at all —
// the doomed evaluation stays a criteria-CONTENT error and an unrelated
// query-port outage cannot reclassify it (mirrors the #3946 rule).
func TestCriteriaEmptyFieldSetSkipsSystemRead(t *testing.T) {
	query := &systemContextQueryStub{}
	svc := newSystemContextTestService(query)

	entry := `{
		"criteriaItems": [{"field": "Widget.Amount", "operation": "greaterOrEqual", "value": "90"}],
		"booleanFilter": "not a valid criteria expression"
	}`
	_, err := svc.matchEntryCriteriaAgainstRecord(context.Background(), entry, "Widget", "rec-1", models.SObject{constants.FieldID: "rec-1"}, formulaProjectionTestUser())
	if err == nil {
		t.Fatal("matchEntryCriteriaAgainstRecord() error = nil, want content error from malformed filter")
	}
	if errors.Is(err, errCriteriaRecordUnavailable) {
		t.Fatalf("error = %v, want criteria-CONTENT class", err)
	}
	if len(query.specCalls) != 0 {
		t.Fatalf("system reads = %d, want 0 when nothing is authored to read", len(query.specCalls))
	}
}

// A criterion on a security-blocked (secret-class) ACTIVE FORMULA field must
// keep the same documented degraded posture as a blocked physical field:
// nil-compare + WARN. Blocked fields are excluded from every select,
// SystemContext included, so the view row can never carry them — the strict
// projector must not even be asked (its fail-closed missing-field check
// would misread the guaranteed absence as view drift and abort the submit).
func TestEntryCriteriaBlockedActiveFormulaFieldNilComparesWithoutProjection(t *testing.T) {
	active := "active"
	expr := "CanaryA / CanaryB"
	blockedField := &models.FieldMetadata{
		APIName:              "Secret_Margin__c",
		Type:                 models.FieldType(constants.FieldTypePercent),
		Formula:              &expr,
		FormulaMode:          &active,
		ReadProtectionPolicy: constants.FieldReadProtectionBlocked,
	}
	schema := criteriaSchemaStub{schemas: map[string]*models.ObjectMetadata{
		"Widget": {
			APIName:   "Widget",
			Fields:    []models.FieldMetadata{*blockedField},
			FieldsMap: map[string]*models.FieldMetadata{"secret_margin__c": blockedField},
		},
	}}
	// Projection port poisoned: any projection attempt fails the test via
	// the record-unavailable error class.
	persistence := &formulaProjectionPersistenceStub{err: context.DeadlineExceeded}
	// System row omits the blocked field, mirroring the query layer, which
	// drops blocked names from every select regardless of SecurityContext.
	query := &systemContextQueryStub{
		systemRows: map[string]models.SObject{
			"Widget": {constants.FieldID: "rec-1"},
		},
	}
	svc := NewApprovalService(persistence, query, nil, nil, nil, nil, schema, logging.Default)

	entry := `{"criteriaItems": [{"field": "Widget.Secret_Margin__c", "operation": "greaterOrEqual", "value": "90"}]}`
	matched, err := svc.matchEntryCriteriaAgainstRecord(context.Background(), entry, "Widget", "rec-1", models.SObject{constants.FieldID: "rec-1"}, formulaProjectionTestUser())
	if err != nil {
		t.Fatalf("matchEntryCriteriaAgainstRecord() error = %v, want nil: a blocked field degrades to nil-compare, never fail-closed", err)
	}
	if matched {
		t.Fatal("matched = true, want false (nil compare for a blocked field)")
	}
	if persistence.calls != 0 {
		t.Fatalf("RecomputeActiveFormulaProjection calls = %d, want 0 for a blocked active field", persistence.calls)
	}
}

// Root record vanishing between the submitter load and criteria evaluation
// fails closed on the entry path (record-unavailable class), matching the
// #3946 first-match-wins argument: an unevaluable candidate must abort, not
// be skipped.
func TestEntryCriteriaRootAbsentUnderSystemReadFailsClosed(t *testing.T) {
	entry := `{"criteriaItems": [{"field": "Widget.Amount", "operation": "greaterOrEqual", "value": "90"}]}`
	query := &systemContextQueryStub{
		systemRows: map[string]models.SObject{}, // deleted mid-evaluation
	}
	svc := newSystemContextTestService(query)

	_, err := svc.matchEntryCriteriaAgainstRecord(context.Background(), entry, "Widget", "rec-1", models.SObject{constants.FieldID: "rec-1"}, formulaProjectionTestUser())
	if err == nil {
		t.Fatal("matchEntryCriteriaAgainstRecord() error = nil, want fail-closed record-unavailable")
	}
	if !errors.Is(err, errCriteriaRecordUnavailable) {
		t.Fatalf("error = %v, want errCriteriaRecordUnavailable class", err)
	}
}
