package formula

import (
	"math"
	"testing"

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

// blankAsZeroTestSchema declares one field of every class the blank-as-zero
// rule must discriminate: coerced numerics (double/currency/percent/int, plus
// a numeric-returning rollup) and excluded non-numerics (boolean, date,
// string).
func blankAsZeroTestSchema() *models.ObjectMetadata {
	doubleType := models.FieldType(constants.FieldTypeDouble)
	return &models.ObjectMetadata{
		Fields: []models.FieldMetadata{
			{APIName: "Amount", Type: models.FieldType(constants.FieldTypeDouble)},
			{APIName: "Price", Type: models.FieldType(constants.FieldTypeCurrency)},
			{APIName: "Rate", Type: models.FieldType(constants.FieldTypePercent)},
			{APIName: "Count", Type: models.FieldType(constants.FieldTypeInt)},
			{
				APIName:      "TotalRollup",
				Type:         constants.FieldTypeRollupSummary,
				ReturnType:   &doubleType,
				RollupConfig: &models.RollupConfig{ResultType: &doubleType},
			},
			{APIName: "Enabled", Type: models.FieldType(constants.FieldTypeBoolean)},
			{APIName: "Due", Type: models.FieldType(constants.FieldTypeDate)},
			{APIName: "Label", Type: models.FieldType(constants.FieldTypeString)},
		},
	}
}

// TestEvaluateTreatBlankNumericAsZeroArithmetic is the #4152 core case: with
// the flag set, arithmetic over a blank numeric operand computes as if the
// operand were 0 instead of poisoning the whole expression.
func TestEvaluateTreatBlankNumericAsZeroArithmetic(t *testing.T) {
	engine := NewEngine()
	ctx := &Context{
		Record:                  map[string]interface{}{"Amount": float64(10), "Price": nil},
		Schema:                  blankAsZeroTestSchema(),
		TreatBlankNumericAsZero: true,
	}

	got, err := engine.Evaluate(`Amount + Price`, ctx)
	if err != nil {
		t.Fatalf("Evaluate returned error: %v", err)
	}
	if number, ok := got.(float64); !ok || number != 10 {
		t.Fatalf("Evaluate returned %v (%T), want 10 (blank Price reads as 0)", got, got)
	}
}

// TestEvaluateWithoutFlagKeepsNilPropagation pins the scoping guarantee: the
// same blank-operand expression WITHOUT the flag keeps today's behavior, so
// validation-rule / flow / criteria callers (which never set the flag) are
// unaffected by #4152.
func TestEvaluateWithoutFlagKeepsNilPropagation(t *testing.T) {
	engine := NewEngine()
	ctx := &Context{
		Record: map[string]interface{}{"Amount": float64(10), "Price": nil},
		Schema: blankAsZeroTestSchema(),
	}

	got, err := engine.Evaluate(`Amount + Price`, ctx)
	if err == nil && got != nil {
		t.Fatalf("Evaluate without flag returned %v (%T), want nil result or error (nil propagation)", got, got)
	}
}

// TestEvaluateTreatBlankNumericAsZeroCoverage sweeps each schema class: every
// blank-as-zero numeric type coerces (including absent-from-payload fields and
// numeric-returning rollups), and boolean/date/string keep nil.
func TestEvaluateTreatBlankNumericAsZeroCoverage(t *testing.T) {
	engine := NewEngine()

	cases := []struct {
		name string
		expr string
		want interface{}
	}{
		// Present-but-nil numeric operands of each type read as 0.
		{"double", `Amount + 1`, float64(1)},
		{"currency", `Price + 1`, float64(1)},
		{"int", `Count + 1`, float64(1)},
		// Percent 0 is identical in fraction and storage domains.
		{"percent", `Rate + 1`, float64(1)},
		// Rollups resolve through EffectiveFieldType to their numeric result.
		{"rollup", `TotalRollup + 1`, float64(1)},
		// A blank numeric is indistinguishable from 0 to ISBLANK — the
		// vendor-documented consequence of BlankAsZero.
		{"isblank numeric", `ISBLANK(Amount)`, false},
		// BLANKVALUE never takes its default over a coerced numeric.
		{"blankvalue numeric", `BLANKVALUE(Amount, 5)`, float64(0)},
		// Excluded classes: blank boolean / date / string still read blank.
		{"isblank boolean", `ISBLANK(Enabled)`, true},
		{"isblank date", `ISBLANK(Due)`, true},
		{"isblank string", `ISBLANK(Label)`, true},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			ctx := &Context{
				Record: map[string]interface{}{
					"Amount": nil, "Price": nil, "Rate": nil, "Count": nil,
					"TotalRollup": nil, "Enabled": nil, "Due": nil, "Label": nil,
				},
				Schema:                  blankAsZeroTestSchema(),
				TreatBlankNumericAsZero: true,
			}
			got, err := engine.Evaluate(tc.expr, ctx)
			if err != nil {
				t.Fatalf("Evaluate(%q) returned error: %v", tc.expr, err)
			}
			if wantF, ok := tc.want.(float64); ok {
				gotF, okF := got.(float64)
				if !okF || math.Abs(gotF-wantF) > 1e-12 {
					t.Fatalf("Evaluate(%q) = %v (%T), want %v", tc.expr, got, got, tc.want)
				}
				return
			}
			if got != tc.want {
				t.Fatalf("Evaluate(%q) = %v (%T), want %v", tc.expr, got, got, tc.want)
			}
		})
	}
}

// TestEvaluateTreatBlankNumericAsZeroAbsentField covers the create-with-partial-
// payload shape: a schema-declared numeric field absent from the record map is
// seeded nil by PrepareRecordForEvaluation and must then coerce to 0.
func TestEvaluateTreatBlankNumericAsZeroAbsentField(t *testing.T) {
	engine := NewEngine()
	ctx := &Context{
		Record:                  map[string]interface{}{"Amount": float64(3)},
		Schema:                  blankAsZeroTestSchema(),
		TreatBlankNumericAsZero: true,
	}

	got, err := engine.Evaluate(`Amount + Price + Count`, ctx)
	if err != nil {
		t.Fatalf("Evaluate returned error: %v", err)
	}
	if number, ok := got.(float64); !ok || number != 3 {
		t.Fatalf("Evaluate returned %v (%T), want 3 (absent numerics read as 0)", got, got)
	}
}

// TestEvaluateTreatBlankNumericAsZeroDoesNotMutateCaller mirrors the engine's
// existing no-mutation guarantee: the coercion happens on the evaluation clone,
// never on the caller-owned record.
func TestEvaluateTreatBlankNumericAsZeroDoesNotMutateCaller(t *testing.T) {
	engine := NewEngine()
	record := map[string]interface{}{"Amount": float64(10), "Price": nil}
	ctx := &Context{
		Record:                  record,
		Schema:                  blankAsZeroTestSchema(),
		TreatBlankNumericAsZero: true,
	}

	if _, err := engine.Evaluate(`Amount + Price`, ctx); err != nil {
		t.Fatalf("Evaluate returned error: %v", err)
	}
	if record["Price"] != nil {
		t.Fatalf("caller-owned record mutated: Price = %#v, want nil preserved", record["Price"])
	}
}

// TestZeroBlankNumericOperandsDirect unit-covers the helper itself, including
// the boolean-exclusion contract (a tinyint-backed blank boolean must not
// become false) and the untouched-when-absent contract (only keys present in
// the map are rewritten; PrepareRecordForEvaluation owns absent-key seeding).
// rawPresent=nil means every key is treated as caller-supplied.
func TestZeroBlankNumericOperandsDirect(t *testing.T) {
	schema := blankAsZeroTestSchema()
	record := map[string]interface{}{
		"Amount":  nil,
		"Enabled": nil,
		"Due":     nil,
	}

	ZeroBlankNumericOperands(record, schema, nil)

	if got, ok := record["Amount"].(float64); !ok || got != 0 {
		t.Fatalf("Amount = %#v, want float64(0)", record["Amount"])
	}
	if record["Enabled"] != nil {
		t.Fatalf("Enabled = %#v, want nil (booleans excluded from blank-as-zero)", record["Enabled"])
	}
	if record["Due"] != nil {
		t.Fatalf("Due = %#v, want nil (dates excluded from blank-as-zero)", record["Due"])
	}
	if _, present := record["Price"]; present {
		t.Fatalf("Price seeded by ZeroBlankNumericOperands; absent keys are PrepareRecordForEvaluation's job")
	}
}

// TestZeroBlankNumericOperandsSkipsUnloadedFormulaFields pins the
// fabrication guard: a FORMULA-BACKED numeric field that the caller never
// supplied (a virtual active formula, or a sibling frozen formula not yet
// computed) keeps nil even though its EffectiveFieldType is numeric — its
// nil means "not loaded", not "blank". A formula-backed field the caller DID
// supply as nil (a loaded frozen column that is genuinely blank) still
// coerces.
func TestZeroBlankNumericOperandsSkipsUnloadedFormulaFields(t *testing.T) {
	frozenExpr := "Amount * 2"
	doubleType := models.FieldType(constants.FieldTypeDouble)
	schema := &models.ObjectMetadata{
		Fields: []models.FieldMetadata{
			{APIName: "Amount", Type: models.FieldType(constants.FieldTypeDouble)},
			{APIName: "Virtual", Type: constants.FieldTypeFormula, Formula: &frozenExpr, ReturnType: &doubleType},
			{APIName: "LoadedFrozen", Type: constants.FieldTypeFormula, Formula: &frozenExpr, ReturnType: &doubleType},
		},
	}
	record := map[string]interface{}{
		"Amount":       nil,
		"Virtual":      nil, // seeded by preparation, NOT caller-supplied
		"LoadedFrozen": nil, // caller-supplied: loaded storage column, blank
	}
	rawPresent := map[string]struct{}{"Amount": {}, "LoadedFrozen": {}}

	ZeroBlankNumericOperands(record, schema, rawPresent)

	if got, ok := record["Amount"].(float64); !ok || got != 0 {
		t.Fatalf("Amount = %#v, want float64(0)", record["Amount"])
	}
	if record["Virtual"] != nil {
		t.Fatalf("Virtual = %#v, want nil (unloaded formula field must not fabricate 0)", record["Virtual"])
	}
	if got, ok := record["LoadedFrozen"].(float64); !ok || got != 0 {
		t.Fatalf("LoadedFrozen = %#v, want float64(0) (loaded blank frozen column coerces)", record["LoadedFrozen"])
	}
}

// TestZeroBlankNumericOperandsSkipsUnloadedProjectionAndCompoundFields extends
// the fabrication guard to the other computed-elsewhere shapes: a numeric
// field backed by a metadata-owned read projection (ProjectionConfig) or a
// compound derivation is not "blank" when absent from a write payload — it
// was simply never loaded.
func TestZeroBlankNumericOperandsSkipsUnloadedProjectionAndCompoundFields(t *testing.T) {
	schema := &models.ObjectMetadata{
		Fields: []models.FieldMetadata{
			{APIName: "Amount", Type: models.FieldType(constants.FieldTypeDouble)},
			{
				APIName:          "ProjectedRate",
				Type:             models.FieldType(constants.FieldTypeDouble),
				ProjectionConfig: &models.FieldProjectionConfig{Kind: "lookupValue"},
			},
			{APIName: "DerivedTotal", Type: models.FieldType(constants.FieldTypeDouble), IsCompound: true},
		},
	}
	record := map[string]interface{}{
		"Amount":        nil,
		"ProjectedRate": nil, // seeded by preparation, NOT caller-supplied
		"DerivedTotal":  nil, // seeded by preparation, NOT caller-supplied
	}
	rawPresent := map[string]struct{}{"Amount": {}}

	ZeroBlankNumericOperands(record, schema, rawPresent)

	if got, ok := record["Amount"].(float64); !ok || got != 0 {
		t.Fatalf("Amount = %#v, want float64(0)", record["Amount"])
	}
	if record["ProjectedRate"] != nil {
		t.Fatalf("ProjectedRate = %#v, want nil (unloaded projection field must not fabricate 0)", record["ProjectedRate"])
	}
	if record["DerivedTotal"] != nil {
		t.Fatalf("DerivedTotal = %#v, want nil (unloaded compound field must not fabricate 0)", record["DerivedTotal"])
	}
}

// TestEvaluateTreatBlankNumericAsZeroDivisionParity pins the SQL-parity
// division mode: a blank-coerced zero divisor must never leak IEEE +Inf/NaN
// into the evaluation — bare division yields nil (the SQL projection renders
// it NULL), and an enclosing IF must not see +Inf pick its then-branch.
func TestEvaluateTreatBlankNumericAsZeroDivisionParity(t *testing.T) {
	engine := NewEngine()

	newCtx := func() *Context {
		return &Context{
			Record:                  map[string]interface{}{"Amount": float64(10), "Price": nil},
			Schema:                  blankAsZeroTestSchema(),
			TreatBlankNumericAsZero: true,
		}
	}

	got, err := engine.Evaluate(`Amount / Price`, newCtx())
	if err == nil && got != nil {
		t.Fatalf("Amount / blank = %v (%T), want nil result or error — never a non-finite float", got, got)
	}
	if f, ok := got.(float64); ok && (math.IsInf(f, 0) || math.IsNaN(f)) {
		t.Fatalf("Amount / blank leaked non-finite %v", f)
	}

	// The Codex repro shape: +Inf > 100 must not pick the then-branch.
	got, err = engine.Evaluate(`IF(Amount / Price > 100, 1, 2)`, newCtx())
	if f, ok := got.(float64); ok && f == 1 {
		t.Fatalf("IF over division-by-blank picked the +Inf branch (got 1); err=%v", err)
	}
	if i, ok := got.(int); ok && i == 1 {
		t.Fatalf("IF over division-by-blank picked the +Inf branch (got 1); err=%v", err)
	}

	// Without the flag, the native operator is untouched: float division by a
	// literal zero still produces +Inf on this engine (pinned so the scoping
	// guarantee — no behavior change for unflagged callers — stays honest).
	got, err = engine.Evaluate(`Amount / 0.0`, &Context{
		Record: map[string]interface{}{"Amount": float64(10)},
		Schema: blankAsZeroTestSchema(),
	})
	if err != nil {
		t.Fatalf("unflagged division errored: %v", err)
	}
	if f, ok := got.(float64); !ok || !math.IsInf(f, 1) {
		t.Fatalf("unflagged Amount / 0.0 = %v (%T), want +Inf (native operator untouched)", got, got)
	}
}
