package metadata

import (
	"context"
	"strings"
	"testing"

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

// Regression pins for #4161: validation-rule evaluation coerces blank numeric
// operands to a typed zero for comparison type-safety, which used to be done
// before the ISBLANK sentinels were built — so ISBLANK over a numeric field was
// always false and the vendor's required-field idiom was silently inert.
//
// The pins hold both directions of the blank/zero distinction. Reporting a
// blank numeric as blank is the fix; reporting an explicit 0 as blank would be
// an equal and opposite defect, since 0 is a value.

func blankNumericTestSchema() *models.ObjectMetadata {
	return &models.ObjectMetadata{
		APIName: "Account",
		Fields: []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
			{APIName: "Sic", Type: constants.FieldTypeString},
			{APIName: "AnnualRevenue", Type: constants.FieldTypeCurrency},
			{APIName: "Rate", Type: constants.FieldTypeDouble},
			{APIName: "Margin", Type: constants.FieldTypePercent},
			{APIName: "IsFlagged", Type: constants.FieldTypeBoolean},
		},
	}
}

func blankNumericRule(condition string) []*models.ValidationRule {
	return []*models.ValidationRule{{
		Name:         "RequireValue",
		Active:       true,
		Condition:    condition,
		ErrorMessage: "value is required",
	}}
}

// validateBlankNumericRecord reports whether the rule blocked the save.
func validateBlankNumericRecord(t *testing.T, condition string, record models.SObject, prior *models.SObject) bool {
	t.Helper()
	vs := NewValidationService(formula.NewEngine())
	err := vs.ValidateRecord(
		context.Background(),
		record,
		blankNumericTestSchema(),
		blankNumericRule(condition),
		prior,
		nil,
	)
	if err == nil {
		return false
	}
	if !strings.Contains(err.Error(), "value is required") {
		t.Fatalf("ValidateRecord() error = %v, want the rule's own message (an evaluation failure is not a block)", err)
	}
	return true
}

func TestValidateRecordISBLANKOverNumericObservesBlankness(t *testing.T) {
	tests := []struct {
		name      string
		condition string
		record    models.SObject
		wantBlock bool
	}{
		// The dead idiom this issue is about: a rule that blocks the save when
		// a currency field is blank, for both ways a field can arrive blank.
		{
			name:      "currency_absent_from_payload_blocks",
			condition: "ISBLANK(AnnualRevenue)",
			record:    models.SObject{"Name": "probe", "Sic": "1234"},
			wantBlock: true,
		},
		{
			name:      "currency_explicit_null_blocks",
			condition: "ISBLANK(AnnualRevenue)",
			record:    models.SObject{"Name": "probe", "Sic": "1234", "AnnualRevenue": nil},
			wantBlock: true,
		},
		// The opposite direction: 0 is a value, not a blank. A fix that reported
		// every zero-reading numeric as blank would pass the pins above and
		// break every rule of this shape on real data.
		{
			name:      "currency_explicit_zero_passes",
			condition: "ISBLANK(AnnualRevenue)",
			record:    models.SObject{"Name": "probe", "AnnualRevenue": 0.0},
			wantBlock: false,
		},
		{
			name:      "currency_value_passes",
			condition: "ISBLANK(AnnualRevenue)",
			record:    models.SObject{"Name": "probe", "AnnualRevenue": 100.0},
			wantBlock: false,
		},
		// Same for the other two coerced numeric types.
		{
			name:      "double_blank_blocks",
			condition: "ISBLANK(Rate)",
			record:    models.SObject{"Name": "probe"},
			wantBlock: true,
		},
		{
			name:      "double_zero_passes",
			condition: "ISBLANK(Rate)",
			record:    models.SObject{"Name": "probe", "Rate": 0.0},
			wantBlock: false,
		},
		{
			name:      "percent_blank_blocks",
			condition: "ISBLANK(Margin)",
			record:    models.SObject{"Name": "probe"},
			wantBlock: true,
		},
		{
			name:      "percent_zero_passes",
			condition: "ISBLANK(Margin)",
			record:    models.SObject{"Name": "probe", "Margin": 0.0},
			wantBlock: false,
		},
		// ISNULL and the `= null` comparison lower to the same sentinel.
		{
			name:      "isnull_over_blank_numeric_blocks",
			condition: "ISNULL(AnnualRevenue)",
			record:    models.SObject{"Name": "probe"},
			wantBlock: true,
		},
		{
			name:      "field_equals_null_over_blank_numeric_blocks",
			condition: "AnnualRevenue = null",
			record:    models.SObject{"Name": "probe"},
			wantBlock: true,
		},
		{
			name:      "field_equals_null_over_zero_passes",
			condition: "AnnualRevenue = null",
			record:    models.SObject{"Name": "probe", "AnnualRevenue": 0.0},
			wantBlock: false,
		},
		// The NOT(ISBLANK(...)) guard shape, which rules use to skip a branch
		// rather than to require a value: it must now actually skip.
		{
			name:      "not_isblank_guard_skips_blank_numeric",
			condition: "AND(NOT(ISBLANK(AnnualRevenue)), AnnualRevenue < 10)",
			record:    models.SObject{"Name": "probe"},
			wantBlock: false,
		},
		{
			name:      "not_isblank_guard_admits_present_numeric",
			condition: "AND(NOT(ISBLANK(AnnualRevenue)), AnnualRevenue < 10)",
			record:    models.SObject{"Name": "probe", "AnnualRevenue": 5.0},
			wantBlock: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := validateBlankNumericRecord(t, tt.condition, tt.record, nil); got != tt.wantBlock {
				t.Fatalf("rule %q blocked = %v, want %v", tt.condition, got, tt.wantBlock)
			}
		})
	}
}

// The string path is what proved the rule machinery fires at all while the
// numeric path was dead. It must stay exactly as it was.
func TestValidateRecordISBLANKOverStringIsUnchanged(t *testing.T) {
	tests := []struct {
		name      string
		record    models.SObject
		wantBlock bool
	}{
		{name: "absent_blocks", record: models.SObject{"Name": "probe"}, wantBlock: true},
		{name: "explicit_null_blocks", record: models.SObject{"Name": "probe", "Sic": nil}, wantBlock: true},
		{name: "empty_string_blocks", record: models.SObject{"Name": "probe", "Sic": ""}, wantBlock: true},
		{name: "value_passes", record: models.SObject{"Name": "probe", "Sic": "1234"}, wantBlock: false},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := validateBlankNumericRecord(t, "ISBLANK(Sic)", tt.record, nil); got != tt.wantBlock {
				t.Fatalf("ISBLANK(Sic) blocked = %v, want %v", got, tt.wantBlock)
			}
		})
	}
}

// Checkboxes are out of scope: the nil-to-false coercion matches the vendor
// (their checkboxes are never blank), so this fix deliberately does not report
// a coerced boolean as an erased blank.
//
// The two cases below disagree with each other, and that asymmetry predates
// this change — an absent checkbox is seeded nil and reads blank, while an
// explicitly-null one is coerced to false and does not. Both values were
// captured from pristine main and are unchanged by this fix. They are pinned
// here to hold that invariance, not to endorse the asymmetry: settling which
// reading is right needs a vendor answer for ISBLANK over a checkbox, which
// #4161 does not have and does not claim. Whoever settles it should expect to
// change these expectations, not work around them.
func TestValidateRecordISBLANKOverBooleanIsUnchanged(t *testing.T) {
	for _, tt := range []struct {
		name      string
		record    models.SObject
		wantBlock bool
	}{
		{name: "absent_reads_blank", record: models.SObject{"Name": "probe"}, wantBlock: true},
		{name: "explicit_null_reads_false", record: models.SObject{"Name": "probe", "IsFlagged": nil}, wantBlock: false},
		{name: "explicit_false_reads_false", record: models.SObject{"Name": "probe", "IsFlagged": false}, wantBlock: false},
		{name: "explicit_true_reads_false", record: models.SObject{"Name": "probe", "IsFlagged": true}, wantBlock: false},
	} {
		t.Run(tt.name, func(t *testing.T) {
			if got := validateBlankNumericRecord(t, "ISBLANK(IsFlagged)", tt.record, nil); got != tt.wantBlock {
				t.Fatalf("ISBLANK(<checkbox>) blocked = %v, want %v (pre-existing behavior, must not shift)", got, tt.wantBlock)
			}
		})
	}
}

// Comparison semantics are deliberately out of scope (issue #4161 Class 2): the
// record image the rule expression evaluates against is byte-identical to
// before, so a blank numeric still compares as 0. This pin exists so that the
// day someone changes that, it is a decision and not a side effect.
func TestValidateRecordBlankNumericStillComparesAsZero(t *testing.T) {
	if !validateBlankNumericRecord(t, "AnnualRevenue >= 0", models.SObject{"Name": "probe"}, nil) {
		t.Fatal("blank numeric no longer compares as 0; comparison semantics are out of scope for the ISBLANK fix")
	}
	if validateBlankNumericRecord(t, "AnnualRevenue >= 0.4", models.SObject{"Name": "probe"}, nil) {
		t.Fatal("blank numeric no longer compares as 0; comparison semantics are out of scope for the ISBLANK fix")
	}
}

// ISCHANGED consumes the current and prior sentinels, so restoring them makes
// blank transitions over numerics visible. The 0 -> blank case is the one that
// was invisible: both sides read 0.
func TestValidateRecordISCHANGEDOverNumericSeesBlankTransitions(t *testing.T) {
	tests := []struct {
		name      string
		prior     models.SObject
		record    models.SObject
		wantBlock bool
	}{
		{
			name:      "zero_to_blank_is_a_change",
			prior:     models.SObject{"Name": "probe", "AnnualRevenue": 0.0},
			record:    models.SObject{"Name": "probe", "AnnualRevenue": nil},
			wantBlock: true,
		},
		{
			name:      "blank_to_zero_is_a_change",
			prior:     models.SObject{"Name": "probe", "AnnualRevenue": nil},
			record:    models.SObject{"Name": "probe", "AnnualRevenue": 0.0},
			wantBlock: true,
		},
		{
			name:      "blank_to_value_is_a_change",
			prior:     models.SObject{"Name": "probe", "AnnualRevenue": nil},
			record:    models.SObject{"Name": "probe", "AnnualRevenue": 100.0},
			wantBlock: true,
		},
		{
			name:      "blank_to_blank_is_not_a_change",
			prior:     models.SObject{"Name": "probe", "AnnualRevenue": nil},
			record:    models.SObject{"Name": "probe", "AnnualRevenue": nil},
			wantBlock: false,
		},
		{
			name:      "value_to_same_value_is_not_a_change",
			prior:     models.SObject{"Name": "probe", "AnnualRevenue": 100.0},
			record:    models.SObject{"Name": "probe", "AnnualRevenue": 100.0},
			wantBlock: false,
		},
		{
			name:      "value_to_other_value_is_a_change",
			prior:     models.SObject{"Name": "probe", "AnnualRevenue": 100.0},
			record:    models.SObject{"Name": "probe", "AnnualRevenue": 200.0},
			wantBlock: true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			prior := tt.prior
			if got := validateBlankNumericRecord(t, "ISCHANGED(AnnualRevenue)", tt.record, &prior); got != tt.wantBlock {
				t.Fatalf("ISCHANGED(AnnualRevenue) blocked = %v, want %v", got, tt.wantBlock)
			}
		})
	}
}

// The prior-value sentinel gets the same treatment as the current one.
func TestValidateRecordISBLANKOverPriorNumericObservesBlankness(t *testing.T) {
	tests := []struct {
		name      string
		prior     models.SObject
		wantBlock bool
	}{
		{name: "prior_blank_blocks", prior: models.SObject{"Name": "probe"}, wantBlock: true},
		{name: "prior_explicit_null_blocks", prior: models.SObject{"Name": "probe", "AnnualRevenue": nil}, wantBlock: true},
		{name: "prior_zero_passes", prior: models.SObject{"Name": "probe", "AnnualRevenue": 0.0}, wantBlock: false},
		{name: "prior_value_passes", prior: models.SObject{"Name": "probe", "AnnualRevenue": 100.0}, wantBlock: false},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			prior := tt.prior
			record := models.SObject{"Name": "probe", "AnnualRevenue": 50.0}
			if got := validateBlankNumericRecord(t, "ISBLANK(PRIORVALUE(AnnualRevenue))", record, &prior); got != tt.wantBlock {
				t.Fatalf("ISBLANK(PRIORVALUE(AnnualRevenue)) blocked = %v, want %v", got, tt.wantBlock)
			}
		})
	}
}

// A numeric formula field's value is written into the record by the
// active-computed projection, which runs after the coercion. Sourcing its
// blankness from the pre-coercion image would throw the projected value away —
// and it would be wrong anyway: a numeric formula field carries the vendor's
// blank-as-zero rule by design (#4152 / #4160), so it never reads blank.
func TestValidateRecordISBLANKOverNumericFormulaFieldIgnoresPreCoercionBlank(t *testing.T) {
	mode := "active"
	schema := &models.ObjectMetadata{
		APIName: "Account",
		Fields: []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
			{APIName: "AnnualRevenue", Type: constants.FieldTypeCurrency},
			{
				APIName:     "Score__c",
				Type:        constants.FieldTypeFormula,
				Formula:     validationStringPtr("AnnualRevenue * 2"),
				FormulaMode: &mode,
				ReturnType:  validationFieldTypePtr(constants.FieldTypeDouble),
			},
		},
	}

	projected := 0
	vs := NewValidationService(formula.NewEngine())
	vs.SetActiveFormulaResolver(func(_ context.Context, _ *models.ObjectMetadata, _ models.SObject, field *models.FieldMetadata) (interface{}, error) {
		if field.APIName == "Score__c" {
			projected++
			return 42.0, nil
		}
		return nil, nil
	})

	// Score__c is absent from the payload — exactly the shape that would be
	// reported as an erased blank if formula fields were not excluded.
	err := vs.ValidateRecord(
		context.Background(),
		models.SObject{"Name": "probe", "AnnualRevenue": 21.0},
		schema,
		blankNumericRule("ISBLANK(Score__c)"),
		nil,
		nil,
	)
	if projected == 0 {
		t.Fatal("active formula resolver was never called; the test is not exercising the projection")
	}
	if err != nil {
		t.Fatalf("ValidateRecord() error = %v, want the projected value 42 to read as non-blank", err)
	}
}

// A relation-qualified operand takes a different path entirely: the ISBLANK
// lowering only fires for local schema fields, so `ISBLANK(<relation>.<field>)`
// reaches the engine's native ISBLANK and reads the fetched related record
// directly. Related records are prepared by formula.PrepareRecordForEvaluation,
// which does not zero blanks, so this shape was never broken — pinned because
// the guard form NOT(ISBLANK(<relation>.<price>)) is exactly what a cross-record
// rule uses to skip lines that have no reference value, and a future decision to
// share the local coercion with the related-record path would silently kill it.
func TestValidateRecordISBLANKOverRelatedNumericObservesBlankness(t *testing.T) {
	contractRel := "Contract"
	orderSchema := &models.ObjectMetadata{
		APIName: "Order",
		Fields: []models.FieldMetadata{
			{
				APIName:          "ContractId",
				Type:             constants.FieldTypeReference,
				ReferenceTo:      []string{"Contract"},
				RelationshipName: &contractRel,
			},
		},
	}
	contractSchema := &models.ObjectMetadata{
		APIName: "Contract",
		Fields: []models.FieldMetadata{
			{APIName: "ListPrice", Type: constants.FieldTypeCurrency},
		},
	}

	tests := []struct {
		name      string
		related   models.SObject
		wantBlock bool
	}{
		{
			name:      "related_numeric_blank_reads_blank",
			related:   models.SObject{constants.FieldID: "800000000000001AAA", "ListPrice": nil},
			wantBlock: true,
		},
		{
			name:      "related_numeric_absent_reads_blank",
			related:   models.SObject{constants.FieldID: "800000000000001AAA"},
			wantBlock: true,
		},
		{
			name:      "related_numeric_zero_reads_present",
			related:   models.SObject{constants.FieldID: "800000000000001AAA", "ListPrice": 0.0},
			wantBlock: false,
		},
		{
			name:      "related_numeric_value_reads_present",
			related:   models.SObject{constants.FieldID: "800000000000001AAA", "ListPrice": 100.0},
			wantBlock: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			vs := NewValidationService(formula.NewEngine())
			metadataSvc := newValidationTestMetadataService(t, orderSchema, contractSchema)
			vs.SetFormulaRelationResolver(NewValidationFormulaRelationResolver(
				metadataSvc,
				validationRecordRepoStub{record: tt.related},
			))

			err := vs.ValidateRecord(
				context.Background(),
				models.SObject{"ContractId": "800000000000001AAA"},
				orderSchema,
				[]*models.ValidationRule{{
					Name:         "RequireRelatedPrice",
					Active:       true,
					Condition:    "ISBLANK(Contract.ListPrice)",
					ErrorMessage: "value is required",
				}},
				nil,
				nil,
			)
			if got := err != nil; got != tt.wantBlock {
				t.Fatalf("ISBLANK(Contract.ListPrice) blocked = %v (err=%v), want %v", got, err, tt.wantBlock)
			}
		})
	}
}

// The lowering itself is untouched by this fix. Pinning its output keeps a
// future edit to the sentinel plumbing from quietly changing the emitted
// expression, which is what the sentinels are keyed to.
func TestRewriteValidationFormulaExpressionLoweringIsUnchanged(t *testing.T) {
	schema := blankNumericTestSchema()
	for _, tt := range []struct{ condition, want string }{
		{"ISBLANK(AnnualRevenue)", "ISBLANKX(AnnualRevenue, __null_AnnualRevenue)"},
		{"ISNULL(AnnualRevenue)", "ISNULLX(AnnualRevenue, __null_AnnualRevenue)"},
		{"AnnualRevenue = null", "ISNULLX(AnnualRevenue, __null_AnnualRevenue)"},
		{"ISCHANGED(AnnualRevenue)", "ISCHANGEDX(_has_prior_, AnnualRevenue, _prior_AnnualRevenue, __null_AnnualRevenue, __prior_null_AnnualRevenue)"},
		{"ISBLANK(PRIORVALUE(AnnualRevenue))", "ISBLANKX(_prior_AnnualRevenue, __prior_null_AnnualRevenue)"},
		{"ISBLANK(Sic)", "ISBLANKX(Sic, __null_Sic)"},
	} {
		if got := rewriteValidationFormulaExpression(tt.condition, schema); got != tt.want {
			t.Fatalf("rewrite(%q) = %q, want %q", tt.condition, got, tt.want)
		}
	}
}

// buildFormulaNullSentinels is the seam the fix turns on; these assert its
// contract directly rather than through a rule evaluation.
func TestBuildFormulaNullSentinelsHonorsErasedBlanks(t *testing.T) {
	schema := blankNumericTestSchema()
	record := models.SObject{"Name": "probe", "AnnualRevenue": nil, "Rate": 7.0}

	erased := prepareValidationFormulaRecord(record, schema)

	if _, ok := erased["AnnualRevenue"]; !ok {
		t.Fatal("prepareValidationFormulaRecord did not report the nil currency as an erased blank")
	}
	if _, ok := erased["Margin"]; !ok {
		t.Fatal("prepareValidationFormulaRecord did not report the absent percent as an erased blank")
	}
	if _, ok := erased["Rate"]; ok {
		t.Fatal("prepareValidationFormulaRecord reported a present numeric as an erased blank")
	}
	if _, ok := erased["IsFlagged"]; ok {
		t.Fatal("prepareValidationFormulaRecord reported a checkbox as an erased blank")
	}
	if _, ok := erased["Sic"]; ok {
		t.Fatal("prepareValidationFormulaRecord reported a string as an erased blank; strings keep nil and report themselves")
	}
	if got := record["AnnualRevenue"]; got != 0.0 {
		t.Fatalf("record[AnnualRevenue] = %v, want the coercion to still produce 0.0 for comparison type-safety", got)
	}

	sentinels := buildFormulaNullSentinels(record, schema, "__null_", erased)
	for key, want := range map[string]bool{
		"__null_AnnualRevenue": true,
		"__null_Margin":        true,
		"__null_Rate":          false,
		// Absent checkbox: seeded nil, reports itself blank. Pre-existing; see
		// TestValidateRecordISBLANKOverBooleanIsUnchanged.
		"__null_IsFlagged": true,
		"__null_Sic":       true,
		"__null_Name":      false,
	} {
		if got := sentinels[key]; got != want {
			t.Fatalf("sentinel %s = %v, want %v", key, got, want)
		}
	}
}

// The fix rests on one invariant: the fields the active-computed projection
// writes are excluded from the erased-blank set. This is the shape that proves
// it: a NUMERIC field carrying a ProjectionConfig is both coerced to zero (its
// declared type is in the coerced bucket) and overwritten by the projection
// afterwards. Without the carve-out, the coercion would report it as an erased
// blank and the stale sentinel would claim the projected value is blank.
//
// A formula-TYPED field cannot stand in for this: its declared type is `formula`,
// which is not in the coerced bucket, so it never reaches the carve-out at all.
func TestValidateRecordISBLANKOverProjectedNumericSeesProjectedValue(t *testing.T) {
	schema := &models.ObjectMetadata{
		APIName: "Account",
		Fields: []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
			{
				APIName:          "Projected__c",
				Type:             constants.FieldTypeCurrency,
				ProjectionConfig: &models.FieldProjectionConfig{},
			},
		},
	}

	field := &schema.Fields[1]
	if !formulastorage.IsActiveComputedField(field) {
		t.Fatal("fixture is not active-computed; it would not exercise the carve-out")
	}
	probe := models.SObject{"Projected__c": nil}
	if handled, _ := zeroBlankNumericForValidation(probe, field); !handled {
		t.Fatal("fixture is not in the coerced numeric bucket; it would not exercise the carve-out")
	}

	projected := 0
	vs := NewValidationService(formula.NewEngine())
	vs.SetActiveProjectionResolver(func(_ context.Context, _ *models.ObjectMetadata, _ models.SObject, f *models.FieldMetadata) (interface{}, error) {
		if f.APIName == "Projected__c" {
			projected++
			return 42.0, nil
		}
		return nil, nil
	})

	// Absent from the payload: the coercion zeroes it before the projection runs.
	err := vs.ValidateRecord(
		context.Background(),
		models.SObject{"Name": "probe"},
		schema,
		blankNumericRule("ISBLANK(Projected__c)"),
		nil,
		nil,
	)
	if projected == 0 {
		t.Fatal("projection resolver was never called; the test is not exercising the overlay")
	}
	if err != nil {
		t.Fatalf("ValidateRecord() error = %v, want the projected value 42 to read as non-blank", err)
	}
}

// The carve-out is scoped to what the projection actually overwrites, so a
// numeric that is merely derived-looking but never projected still reports its
// own blankness. These two shapes are physical columns that can genuinely be
// NULL, and nothing restores a value for them after the coercion.
func TestValidateRecordISBLANKOverUnprojectedDerivedNumericsObservesBlankness(t *testing.T) {
	for _, tt := range []struct {
		name  string
		field models.FieldMetadata
	}{
		{"compound_numeric", models.FieldMetadata{APIName: "Derived__c", Type: constants.FieldTypeDouble, IsCompound: true}},
		{"write_materialized_formula_numeric", models.FieldMetadata{
			APIName:            "Derived__c",
			Type:               constants.FieldTypeCurrency,
			Formula:            validationStringPtr("1 + 1"),
			FormulaStorageMode: validationStringPtr("write_materialized"),
		}},
	} {
		t.Run(tt.name, func(t *testing.T) {
			field := tt.field
			if formulastorage.IsActiveComputedField(&field) {
				t.Fatal("fixture IS projected; it belongs in the carve-out test instead")
			}
			schema := &models.ObjectMetadata{
				APIName: "Account",
				Fields:  []models.FieldMetadata{{APIName: "Name", Type: constants.FieldTypeString}, field},
			}
			vs := NewValidationService(formula.NewEngine())
			err := vs.ValidateRecord(context.Background(), models.SObject{"Name": "probe", "Derived__c": nil},
				schema, blankNumericRule("ISBLANK(Derived__c)"), nil, nil)
			if err == nil {
				t.Fatal("ISBLANK over an unprojected blank numeric did not fire; its blankness is unobservable")
			}
		})
	}
}

// On create the engine seeds every _prior_<field> from the current record, so
// PRIORVALUE returns the current value. The prior sentinels must be built from
// that same source or the two halves of one rule disagree about one field in one
// save. The string case is the control: it never disagreed, because a blank
// string survives the coercion as nil.
func TestValidateRecordPriorSentinelsMirrorCurrentOnCreate(t *testing.T) {
	for _, tt := range []struct {
		name      string
		condition string
		record    models.SObject
		wantBlock bool
	}{
		{"numeric_blank_current", "ISBLANK(AnnualRevenue)", models.SObject{"Name": "p"}, true},
		{"numeric_blank_prior", "ISBLANK(PRIORVALUE(AnnualRevenue))", models.SObject{"Name": "p"}, true},
		{"numeric_blank_prior_isnull", "ISNULL(PRIORVALUE(AnnualRevenue))", models.SObject{"Name": "p"}, true},
		{"numeric_blank_prior_eq_null", "PRIORVALUE(AnnualRevenue) = null", models.SObject{"Name": "p"}, true},
		{"numeric_present_prior", "ISBLANK(PRIORVALUE(AnnualRevenue))", models.SObject{"Name": "p", "AnnualRevenue": 100.0}, false},
		{"numeric_zero_prior", "ISBLANK(PRIORVALUE(AnnualRevenue))", models.SObject{"Name": "p", "AnnualRevenue": 0.0}, false},
		{"string_blank_prior", "ISBLANK(PRIORVALUE(Sic))", models.SObject{"Name": "p"}, true},
		{"string_present_prior", "ISBLANK(PRIORVALUE(Sic))", models.SObject{"Name": "p", "Sic": "1234"}, false},
	} {
		t.Run(tt.name, func(t *testing.T) {
			if got := validateBlankNumericRecord(t, tt.condition, tt.record, nil); got != tt.wantBlock {
				t.Fatalf("on create, %q blocked = %v, want %v", tt.condition, got, tt.wantBlock)
			}
		})
	}
}

// ISCHANGED must stay false on create no matter what the mirrored prior
// sentinels say — it is gated on _has_prior_, which the engine sets false.
func TestValidateRecordISCHANGEDIsNeverTrueOnCreate(t *testing.T) {
	for _, record := range []models.SObject{
		{"Name": "p"},
		{"Name": "p", "AnnualRevenue": 100.0},
		{"Name": "p", "AnnualRevenue": 0.0},
	} {
		if validateBlankNumericRecord(t, "ISCHANGED(AnnualRevenue)", record, nil) {
			t.Fatalf("ISCHANGED fired on create for %v; creation is not a change", record)
		}
	}
}

// A record-set binding must not be able to shadow a sentinel. Binding names are
// admin-authored and the identifier rule permits a leading underscore, so the
// sentinel namespace is asserted last when the two maps are merged.
func TestValidateRecordBindingCannotShadowNullSentinel(t *testing.T) {
	schema := blankNumericTestSchema()
	vs := NewValidationService(formula.NewEngine())
	vs.SetRecordSetBindingResolver(func(_ context.Context, _ *models.ObjectMetadata, _ models.SObject, _ *models.ValidationRule, _ models.ValidationRuleRecordSetBinding, _ map[string]interface{}) (bool, error) {
		// What a shadowing binding would inject: the opposite of the sentinel
		// the blank numeric has to produce.
		return false, nil
	})

	rule := &models.ValidationRule{
		Name:         "RequireValue",
		Active:       true,
		Condition:    "ISBLANK(AnnualRevenue)",
		ErrorMessage: "value is required",
		RecordSetBindings: []models.ValidationRuleRecordSetBinding{{
			Name:          "__null_AnnualRevenue",
			Mode:          "exists",
			ObjectAPIName: "Contact",
			Filters: []models.ValidationRuleRecordSetFilter{{
				Field:       "AccountId",
				Operator:    "equals",
				ValueSource: "literal",
				Value:       "irrelevant",
			}},
		}},
	}

	err := vs.ValidateRecord(context.Background(), models.SObject{"Name": "probe"}, schema,
		[]*models.ValidationRule{rule}, nil, nil)
	if err == nil || !strings.Contains(err.Error(), "value is required") {
		t.Fatalf("ValidateRecord() error = %v, want the blank-numeric rule to still fire; a binding shadowed its sentinel", err)
	}
}

// A nil erased set is the shape a caller passes for an absent prior record; it
// must behave exactly as the pre-fix sentinel build did.
func TestBuildFormulaNullSentinelsToleratesNilErasedSet(t *testing.T) {
	schema := blankNumericTestSchema()
	sentinels := buildFormulaNullSentinels(nil, schema, "__prior_null_", nil)
	for _, field := range schema.Fields {
		if got := sentinels["__prior_null_"+field.APIName]; got != false {
			t.Fatalf("sentinel for %s over a nil record = %v, want false", field.APIName, got)
		}
	}
}
