package formula

import (
	"strings"
	"testing"

	"github.com/expr-lang/expr/ast"

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

func TestValidateSyntaxWithSchemaRejectsExpressionsRuntimeCannotEvaluate(t *testing.T) {
	engine := NewEngine()
	schema := parityTestSchema()
	record := map[string]interface{}{
		"Name":      "Acme",
		"Amount":    42.5,
		"IsActive":  true,
		"IsClosed":  false,
		"CloseDate": "2026-05-09",
		"AccountId": "001xx000003DGSWAA4",
		"Parent__c": "a01xx000000Parent",
		"Parent__r": map[string]interface{}{"Name": "Parent"},
	}

	tests := []struct {
		name                 string
		expr                 string
		runtimeMayMisresolve bool
	}{
		{name: "bare unknown field", expr: `MissingField`},
		{name: "unknown field inside binary", expr: `Amount + MissingField`},
		{name: "unknown field inside conditional", expr: `IF(Amount > MissingField, "large", "small")`},
		{name: "field casing must match runtime record keys", expr: `LEFT(name, 3)`},
		{name: "terminal relationship root is not runtime-bound", expr: `LEFT(Account, 3)`},
		{name: "schema-known operator type error", expr: `LEFT(Amount + Name, 3)`},
		{name: "UPPER rejects non-string argument", expr: `UPPER(Amount)`},
		{name: "LOWER rejects non-string argument", expr: `LOWER(Amount)`},
		{name: "LEN rejects non-string argument", expr: `LEN(Amount)`},
		{name: "CONTAINS rejects non-string haystack", expr: `CONTAINS(Amount, "2")`},
		{name: "CONTAINS rejects non-string needle", expr: `CONTAINS(Name, Amount)`},
		{name: "BEGINS rejects non-string value", expr: `BEGINS(Amount, "2")`},
		{name: "FIND rejects non-string search", expr: `FIND(Amount, Name)`},
		{name: "FIND rejects non-number start", expr: `FIND("m", Name, Name)`},
		{name: "SUBSTITUTE rejects non-string text", expr: `SUBSTITUTE(Amount, "m", "x")`},
		{name: "SUBSTITUTE rejects non-number occurrence", expr: `SUBSTITUTE(Name, "m", "x", Name)`},
		{name: "ROUND rejects non-number value", expr: `ROUND(Name, 2)`},
		{name: "ROUND rejects non-number precision", expr: `ROUND(Amount, Name)`},
		{name: "ROUND rejects null value", expr: `ROUND(null, 0)`},
		{name: "ROUND rejects null precision", expr: `ROUND(Amount, null)`},
		{name: "CEILING rejects non-number value", expr: `CEILING(Name)`},
		{name: "CEILING rejects null value", expr: `CEILING(null)`},
		{name: "ABS rejects non-number value", expr: `ABS(Name)`},
		{name: "ABS rejects null value", expr: `ABS(null)`},
		{name: "MOD rejects non-number left value", expr: `MOD(Name, 2)`},
		{name: "MOD rejects non-number right value", expr: `MOD(Amount, Name)`},
		{name: "MOD rejects null left value", expr: `MOD(null, 2)`},
		{name: "MOD rejects null right value", expr: `MOD(Amount, null)`},
		{name: "YEAR rejects non-date value", expr: `YEAR(Name)`},
		{name: "YEAR rejects null value", expr: `YEAR(null)`},
		{name: "MONTH rejects non-date value", expr: `MONTH(Name)`},
		{name: "MONTH rejects null value", expr: `MONTH(null)`},
		{name: "DAY rejects non-date value", expr: `DAY(Name)`},
		{name: "DAY rejects null value", expr: `DAY(null)`},
		{name: "__DATE_ADD_MONTHS__ rejects non-date value", expr: `__DATE_ADD_MONTHS__(Name, 1)`},
		{name: "__DATE_ADD_MONTHS__ rejects non-number month count", expr: `__DATE_ADD_MONTHS__(CloseDate, Name)`},
		{name: "__DATE_ADD_MONTHS__ rejects null date value", expr: `__DATE_ADD_MONTHS__(null, 1)`},
		{name: "__DATE_ADD_MONTHS__ rejects null month count", expr: `__DATE_ADD_MONTHS__(CloseDate, null)`},
		{name: "__DATE_ADD_MONTHS_TARGET_CLAMP__ rejects non-date value", expr: `__DATE_ADD_MONTHS_TARGET_CLAMP__(Name, 1)`},
		{name: "__DATE_ADD_MONTHS_TARGET_CLAMP__ rejects non-number month count", expr: `__DATE_ADD_MONTHS_TARGET_CLAMP__(CloseDate, Name)`},
		{name: "__DATE_DIFF_DAYS__ rejects non-date end", expr: `__DATE_DIFF_DAYS__(Name, CloseDate)`},
		{name: "__DATE_DIFF_DAYS__ rejects non-date start", expr: `__DATE_DIFF_DAYS__(CloseDate, Name)`},
		{name: "DATE_ADD rejects non-date value", expr: `DATE_ADD(Name, 1)`},
		{name: "DATE_ADD rejects non-number day count", expr: `DATE_ADD(CloseDate, Name)`},
		{name: "DATE_ADD rejects null date value", expr: `DATE_ADD(null, 1)`},
		{name: "DATE_ADD rejects null day count", expr: `DATE_ADD(CloseDate, null)`},
		{name: "YEAR rejects invalid date literal", expr: `YEAR("not-a-date")`},
		{name: "BCRYPT rejects non-string value", expr: `BCRYPT(Amount)`},
		{name: "BCRYPT rejects null value", expr: `BCRYPT(null)`},
		{name: "unary minus rejects null", expr: `-null`},
		{name: "ordered comparison rejects null left operand", expr: `null > Amount`},
		{name: "ordered comparison rejects null right operand", expr: `Amount > null`},
		{name: "ordered comparison rejects boolean fields", expr: `IsActive > IsClosed`},
		{name: "scalar string field cannot be used as dotted object", expr: `LEFT(Name.First, 3)`},
		{name: "scalar number field cannot be used as dotted object", expr: `LEFT(Amount.Foo, 3)`},
		{name: "__Record scalar field cannot be used as dotted object", expr: `LEFT($Record.Name.First, 3)`},
		{name: "reference id field cannot be used as dotted relationship", expr: `LEFT(AccountId.Name, 3)`},
		{name: "scalar string field cannot be indexed", expr: `LEFT(Name[0], 3)`},
		{name: "scalar number field cannot be indexed", expr: `Amount[0]`},
		{name: "__Record scalar field cannot be indexed", expr: `LEFT($Record.Name[0], 3)`},
		{name: "custom relationship root must have declared FK", expr: `LEFT(Missing__r.Name, 3)`},
		{name: "scalar number field cannot be sliced", expr: `Amount[0:1]`},
		{name: "scalar slice cannot feed typed function", expr: `LEFT(Amount[0:1], 1)`},
		{name: "dynamic record member access is not a static field path", expr: `$Record[Name]`, runtimeMayMisresolve: true},
		{name: "dynamic record member access cannot feed typed function", expr: `LEFT($Record[Name], 1)`, runtimeMayMisresolve: true},
		{name: "IF mixed branch types cannot feed string function", expr: `LEFT(IF(IsActive, Amount, Name), 1)`},
		{name: "IF mixed branch types cannot feed number function", expr: `ROUND(IF(IsActive, Name, Amount), 0)`},
		{name: "CASE mixed branch types cannot feed string function", expr: `LEFT(CASE(Name, "Acme", Amount, Name), 1)`},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			if err := engine.ValidateSyntaxWithSchema(tc.expr, schema); err == nil {
				t.Fatalf("ValidateSyntaxWithSchema(%q) error = nil; want rejection because runtime evaluation is not proven safe", tc.expr)
			}
			if tc.runtimeMayMisresolve {
				return
			}
			if _, err := engine.Evaluate(tc.expr, &Context{Record: record}); err == nil {
				t.Fatalf("Evaluate(%q) error = nil; test setup no longer demonstrates a verify/runtime parity mismatch", tc.expr)
			}
		})
	}
}

func TestValidateSyntaxWithSchemaAcceptsExpressionsRuntimeCanEvaluate(t *testing.T) {
	engine := NewEngine()
	schema := parityTestSchema()
	record := map[string]interface{}{
		"Name":      "Acme",
		"Amount":    42.5,
		"IsActive":  true,
		"CloseDate": "2026-05-09",
		"AccountId": "001xx000003DGSWAA4",
		"Parent__c": "a01xx000000Parent",
		"Parent__r": map[string]interface{}{"Name": "Parent"},
	}

	tests := []string{
		`Name`,
		`LEFT(Name, 3)`,
		`LEFT(null, 3)`,
		`RIGHT(null, 3)`,
		`MID(null, 1, 2)`,
		`Name & " " & Name`,
		`Amount + 1`,
		`IF(IsActive, LEFT(Name, 3), "off")`,
		`IF(IsActive, null, Name)`,
		`LEFT(AccountId, 3)`,
		`UPPER(Name)`,
		`UPPER(null)`,
		`LOWER(Name)`,
		`LOWER(null)`,
		`LEN(Name)`,
		`LEN(null)`,
		`CONTAINS(Name, "m")`,
		`CONTAINS(null, "m")`,
		`BEGINS(Name, "A")`,
		`BEGINS(null, "A")`,
		`FIND("m", Name)`,
		`FIND(null, Name)`,
		`FIND("m", Name, 1)`,
		`SUBSTITUTE(Name, "m", "x")`,
		`SUBSTITUTE(null, "m", "x")`,
		`SUBSTITUTE(Name, "m", "x", 1)`,
		`ROUND(Amount, 0)`,
		`CEILING(Amount)`,
		`ABS(Amount)`,
		`MOD(Amount, 2)`,
		`YEAR(CloseDate)`,
		`MONTH(CloseDate)`,
		`DAY(CloseDate)`,
		`__DATE_ADD_MONTHS__(CloseDate, 1)`,
		`__DATE_ADD_MONTHS_TARGET_CLAMP__(CloseDate, 1)`,
		`__DATE_DIFF_DAYS__(CloseDate, "2026-05-01")`,
		`DATE_ADD(CloseDate, 1)`,
		`YEAR("2026-05-09")`,
		`DATE_ADD("2026-05-09", 1)`,
		`BCRYPT(Name)`,
	}

	for _, expr := range tests {
		t.Run(expr, func(t *testing.T) {
			if err := engine.ValidateSyntaxWithSchema(expr, schema); err != nil {
				t.Fatalf("ValidateSyntaxWithSchema(%q) error = %v; want nil", expr, err)
			}
			if _, err := engine.Evaluate(expr, &Context{Record: record}); err != nil {
				t.Fatalf("Evaluate(%q) error = %v; want nil", expr, err)
			}
		})
	}
}

func TestValidateSyntaxWithSchemaAcceptsRelationshipWhenTargetSchemaAvailable(t *testing.T) {
	engine := NewEngine()
	schema := parityTestSchema()
	parentSchema := &models.ObjectMetadata{
		APIName: "Parent__c",
		Fields: []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
		},
	}
	record := map[string]interface{}{
		"Parent__c": "a01xx000000Parent",
		"Parent__r": map[string]interface{}{"Name": "Parent"},
	}

	expr := `LEFT(Parent__r.Name, 3)`
	if err := engine.validateSyntaxWithSchema(expr, schema, map[string]*models.ObjectMetadata{"Parent__c": parentSchema}, nil); err != nil {
		t.Fatalf("validateSyntaxWithSchema(%q) error = %v; want nil", expr, err)
	}
	if _, err := engine.Evaluate(expr, &Context{Record: record}); err != nil {
		t.Fatalf("Evaluate(%q) error = %v; want nil", expr, err)
	}
}

func TestSchemaAwareValidationFailsClosedForUnprovenAnySites(t *testing.T) {
	engine := NewEngine()
	schema := parityTestSchema()
	schema.Fields = append(schema.Fields, models.FieldMetadata{
		APIName: "Payload",
		Type:    constants.FieldTypeJson,
	})

	for _, expr := range []string{`ROUND([1], 0)`, `LEFT([1], 1)`, `[1] > Amount`} {
		t.Run(expr, func(t *testing.T) {
			if err := engine.ValidateSyntaxWithSchema(expr, schema); err == nil {
				t.Fatalf("ValidateSyntaxWithSchema(%q) error = nil; want unproven any rejection", expr)
			}
		})
	}

	if err := engine.ValidateSyntaxWithSchema(`TEXT(Payload)`, schema); err == nil {
		t.Fatal("ValidateSyntaxWithSchema(TEXT(Payload)) error = nil; want unsupported field type rejection")
	}

	ctx := newSchemaValidationContext(schema, nil)
	if _, err := inferSchemaFormulaType(&ast.BinaryNode{
		Operator: "matches",
		Left:     &ast.IdentifierNode{Value: "Name"},
		Right:    &ast.StringNode{Value: "A"},
	}, ctx, schema); err == nil {
		t.Fatal("inferSchemaFormulaType(matches) error = nil; want unsupported binary operator rejection")
	}

	if _, err := inferCallType(&ast.CallNode{
		Callee:    &ast.IdentifierNode{Value: "NOT_REGISTERED"},
		Arguments: []ast.Node{&ast.IdentifierNode{Value: "Name"}},
	}, ctx, schema); err == nil {
		t.Fatal("inferCallType(NOT_REGISTERED) error = nil; want unregistered function rejection")
	}
}

func TestValidateSyntaxWithSchemaKeepsNilSchemaLegacyTolerance(t *testing.T) {
	engine := NewEngine()
	if err := engine.ValidateSyntaxWithSchema(`Amount + MissingField`, nil); err != nil {
		t.Fatalf("ValidateSyntaxWithSchema nil schema error = %v; want legacy syntax-only tolerance", err)
	}
}

// TestCASESAFEIDAndDATEVerifierRuntimeParity is the parity gate for the two new
// functions added in issue #429. It verifies the full verifier → runtime chain so
// that a future verifier widening that the runtime doesn't honour (or vice versa)
// fails immediately rather than surfacing at record-write time.
func TestCASESAFEIDAndDATEVerifierRuntimeParity(t *testing.T) {
	engine := NewEngine()
	if err := engine.RegisterFunctionBundle(casesafeIDTestBundle()); err != nil {
		t.Fatalf("RegisterFunctionBundle CASESAFEID: %v", err)
	}
	schema := parityTestSchema()

	t.Run("CASESAFEID/verifier_accepts_reference_field", func(t *testing.T) {
		if err := engine.ValidateSyntaxWithSchema(`CASESAFEID(AccountId)`, schema); err != nil {
			t.Fatalf("ValidateSyntaxWithSchema CASESAFEID(AccountId) = %v; want nil", err)
		}
	})
	t.Run("CASESAFEID/verifier_accepts_string_field", func(t *testing.T) {
		if err := engine.ValidateSyntaxWithSchema(`CASESAFEID(Name)`, schema); err != nil {
			t.Fatalf("ValidateSyntaxWithSchema CASESAFEID(Name) = %v; want nil", err)
		}
	})
	t.Run("CASESAFEID/runtime_15char_appends_checksum", func(t *testing.T) {
		record := map[string]interface{}{"AccountId": "001xx000003DGSW"}
		got, err := engine.Evaluate(`CASESAFEID(AccountId)`, &Context{Record: record})
		if err != nil {
			t.Fatalf("Evaluate CASESAFEID 15-char: %v", err)
		}
		if got != "001xx000003DGSWAA4" {
			t.Fatalf("CASESAFEID(15-char) = %q; want %q", got, "001xx000003DGSWAA4")
		}
	})
	t.Run("CASESAFEID/runtime_18char_passthrough", func(t *testing.T) {
		record := map[string]interface{}{"AccountId": "001xx000003DGSWAA4"}
		got, err := engine.Evaluate(`CASESAFEID(AccountId)`, &Context{Record: record})
		if err != nil {
			t.Fatalf("Evaluate CASESAFEID 18-char: %v", err)
		}
		if got != "001xx000003DGSWAA4" {
			t.Fatalf("CASESAFEID(18-char) = %q; want %q", got, "001xx000003DGSWAA4")
		}
	})
	t.Run("CASESAFEID/runtime_invalid_length_passes_through", func(t *testing.T) {
		record := map[string]interface{}{"AccountId": "tooshort"}
		got, err := engine.Evaluate(`CASESAFEID(AccountId)`, &Context{Record: record})
		if err != nil {
			t.Fatalf("Evaluate CASESAFEID(8-char) error = %v; want nil (passthrough for non-15/18 length)", err)
		}
		if got != "tooshort" {
			t.Fatalf("CASESAFEID(8-char) = %v; want %q (passthrough)", got, "tooshort")
		}
	})
	t.Run("CASESAFEID/runtime_nil_returns_nil", func(t *testing.T) {
		record := map[string]interface{}{"AccountId": nil}
		got, err := engine.Evaluate(`CASESAFEID(AccountId)`, &Context{Record: record})
		if err != nil {
			t.Fatalf("Evaluate CASESAFEID(nil): %v; want (nil, nil) per Salesforce semantics", err)
		}
		if got != nil {
			t.Fatalf("CASESAFEID(nil) = %v; want nil", got)
		}
	})
	t.Run("CASESAFEID/parity_verifier_nil_means_runtime_nil", func(t *testing.T) {
		expr := `CASESAFEID(AccountId)`
		if err := engine.ValidateSyntaxWithSchema(expr, schema); err != nil {
			t.Fatalf("ValidateSyntaxWithSchema unexpectedly rejected %q: %v", expr, err)
		}
		record := map[string]interface{}{"AccountId": "001xx000003DGSWAA4"}
		if _, err := engine.Evaluate(expr, &Context{Record: record}); err != nil {
			t.Fatalf("Evaluate(%q) error = %v; verifier passed but runtime failed — parity violation", expr, err)
		}
	})

	t.Run("DATE/verifier_accepts_number_literals", func(t *testing.T) {
		if err := engine.ValidateSyntaxWithSchema(`DATE(2026, 5, 10)`, schema); err != nil {
			t.Fatalf("ValidateSyntaxWithSchema DATE(2026,5,10) = %v; want nil", err)
		}
	})
	t.Run("DATE/verifier_accepts_number_fields", func(t *testing.T) {
		if err := engine.ValidateSyntaxWithSchema(`DATE(Amount, Amount, Amount)`, schema); err != nil {
			t.Fatalf("ValidateSyntaxWithSchema DATE(Amount,Amount,Amount) = %v; want nil", err)
		}
	})
	t.Run("DATE/verifier_rejects_string_year", func(t *testing.T) {
		if err := engine.ValidateSyntaxWithSchema(`DATE(Name, 5, 10)`, schema); err == nil {
			t.Fatal("ValidateSyntaxWithSchema DATE(Name,5,10) = nil; want rejection of non-number year")
		}
	})
	t.Run("DATE/verifier_rejects_string_month", func(t *testing.T) {
		if err := engine.ValidateSyntaxWithSchema(`DATE(2026, Name, 10)`, schema); err == nil {
			t.Fatal("ValidateSyntaxWithSchema DATE(2026,Name,10) = nil; want rejection of non-number month")
		}
	})
	t.Run("DATE/verifier_rejects_string_day", func(t *testing.T) {
		if err := engine.ValidateSyntaxWithSchema(`DATE(2026, 5, Name)`, schema); err == nil {
			t.Fatal("ValidateSyntaxWithSchema DATE(2026,5,Name) = nil; want rejection of non-number day")
		}
	})
	t.Run("DATE/runtime_valid_date", func(t *testing.T) {
		got, err := engine.Evaluate(`DATE(2026, 5, 10)`, &Context{Record: map[string]interface{}{}})
		if err != nil {
			t.Fatalf("Evaluate DATE(2026,5,10): %v", err)
		}
		if got != "2026-05-10" {
			t.Fatalf("DATE(2026,5,10) = %q; want %q", got, "2026-05-10")
		}
	})
	t.Run("DATE/runtime_zero_padded_month_and_day", func(t *testing.T) {
		got, err := engine.Evaluate(`DATE(2026, 1, 2)`, &Context{Record: map[string]interface{}{}})
		if err != nil {
			t.Fatalf("Evaluate DATE(2026,1,2): %v", err)
		}
		if got != "2026-01-02" {
			t.Fatalf("DATE(2026,1,2) = %q; want %q", got, "2026-01-02")
		}
	})
	t.Run("DATE/runtime_invalid_date_returns_error", func(t *testing.T) {
		if _, err := engine.Evaluate(`DATE(2026, 2, 30)`, &Context{Record: map[string]interface{}{}}); err == nil {
			t.Fatal("Evaluate DATE(2026,2,30) error = nil; want error for invalid date Feb 30")
		}
	})
	t.Run("DATE/runtime_invalid_month_returns_error", func(t *testing.T) {
		if _, err := engine.Evaluate(`DATE(2026, 13, 1)`, &Context{Record: map[string]interface{}{}}); err == nil {
			t.Fatal("Evaluate DATE(2026,13,1) error = nil; want error for invalid month 13")
		}
	})
	t.Run("DATE/runtime_nil_year_returns_nil", func(t *testing.T) {
		got, err := engine.Evaluate(`DATE(Amount, 5, 10)`, &Context{Record: map[string]interface{}{"Amount": nil}})
		if err != nil {
			t.Fatalf("Evaluate DATE(nil,5,10): %v; want (nil, nil) per Salesforce semantics", err)
		}
		if got != nil {
			t.Fatalf("DATE(nil,5,10) = %v; want nil", got)
		}
	})
	t.Run("DATE/parity_verifier_nil_means_runtime_nil", func(t *testing.T) {
		expr := `DATE(2026, 5, 10)`
		if err := engine.ValidateSyntaxWithSchema(expr, schema); err != nil {
			t.Fatalf("ValidateSyntaxWithSchema unexpectedly rejected %q: %v", expr, err)
		}
		if _, err := engine.Evaluate(expr, &Context{Record: map[string]interface{}{}}); err != nil {
			t.Fatalf("Evaluate(%q) error = %v; verifier passed but runtime failed — parity violation", expr, err)
		}
	})
	t.Run("DATE/runtime_lower_bound_year_valid", func(t *testing.T) {
		got, err := engine.Evaluate(`DATE(1700, 1, 1)`, &Context{Record: map[string]interface{}{}})
		if err != nil {
			t.Fatalf("Evaluate DATE(1700,1,1): %v; want (\"1700-01-01\", nil) — Salesforce lower bound is valid", err)
		}
		if got != "1700-01-01" {
			t.Fatalf("DATE(1700,1,1) = %q; want %q", got, "1700-01-01")
		}
	})
	t.Run("DATE/runtime_upper_bound_year_valid", func(t *testing.T) {
		got, err := engine.Evaluate(`DATE(4000, 12, 31)`, &Context{Record: map[string]interface{}{}})
		if err != nil {
			t.Fatalf("Evaluate DATE(4000,12,31): %v; want (\"4000-12-31\", nil) — Salesforce upper bound is valid", err)
		}
		if got != "4000-12-31" {
			t.Fatalf("DATE(4000,12,31) = %q; want %q", got, "4000-12-31")
		}
	})
	t.Run("DATE/runtime_year_below_lower_bound_returns_error", func(t *testing.T) {
		if _, err := engine.Evaluate(`DATE(1699, 12, 31)`, &Context{Record: map[string]interface{}{}}); err == nil {
			t.Fatal("Evaluate DATE(1699,12,31) error = nil; want error — year 1699 is below Salesforce lower bound 1700")
		}
	})
	t.Run("DATE/runtime_year_above_upper_bound_returns_error", func(t *testing.T) {
		if _, err := engine.Evaluate(`DATE(10000, 1, 1)`, &Context{Record: map[string]interface{}{}}); err == nil {
			t.Fatal("Evaluate DATE(10000,1,1) error = nil; want error — year 10000 is above Salesforce upper bound 4000")
		}
	})
}

func TestHYPERLINKBundleArityParity(t *testing.T) {
	engine := NewEngine(WithoutRuntimeFunctionBundles())
	bundle, err := ParseFunctionBundlePayload([]byte(`{
		"functions": [
			{
				"name": "HYPERLINK",
				"minArgs": 2,
				"maxArgs": 3,
				"return": { "kind": "fixed", "type": "string" },
				"evaluator": {
					"kind": "formula_expr_v1",
					"params": ["url", "friendlyName"],
					"body": "friendlyName"
				}
			}
		]
	}`))
	if err != nil {
		t.Fatalf("ParseFunctionBundlePayload HYPERLINK: %v", err)
	}
	if err := engine.RegisterFunctionBundle(bundle); err != nil {
		t.Fatalf("RegisterFunctionBundle HYPERLINK: %v", err)
	}
	schema := parityTestSchema()
	record := map[string]interface{}{}

	accepted := []string{
		`HYPERLINK("https://example.com", "label")`,
		`HYPERLINK("https://example.com", "label", "_blank")`,
	}
	for _, expr := range accepted {
		t.Run("accepts/"+expr, func(t *testing.T) {
			if err := engine.ValidateSyntaxWithSchema(expr, schema); err != nil {
				t.Fatalf("ValidateSyntaxWithSchema(%q) error = %v; want nil (within bundle arity bounds)", expr, err)
			}
			got, err := engine.Evaluate(expr, &Context{Record: record})
			if err != nil {
				t.Fatalf("Evaluate(%q) error = %v; want nil", expr, err)
			}
			if got != "label" {
				t.Fatalf("Evaluate(%q) = %v, want label", expr, got)
			}
		})
	}

	rejected := []struct {
		name string
		expr string
	}{
		{"HYPERLINK below min (1 arg)", `HYPERLINK("https://example.com")`},
		{"HYPERLINK above max (4 args)", `HYPERLINK("https://example.com", "label", "_blank", "extra")`},
	}
	for _, tc := range rejected {
		t.Run("rejects/"+tc.name, func(t *testing.T) {
			if err := engine.ValidateSyntaxWithSchema(tc.expr, schema); err == nil {
				t.Fatalf("ValidateSyntaxWithSchema(%q) error = nil; want rejection outside bundle arity bounds", tc.expr)
			}
			if _, err := engine.Evaluate(tc.expr, &Context{Record: record}); err == nil {
				t.Fatalf("Evaluate(%q) error = nil; want rejection outside bundle arity bounds", tc.expr)
			}
		})
	}
}

func TestIMAGEArityParity(t *testing.T) {
	engine := NewEngine()
	if err := engine.RegisterFunctionBundle(FunctionBundle{
		Functions: []FunctionDefinition{{
			Name:    "IMAGE",
			MinArgs: 2,
			MaxArgs: 4,
			Return:  FunctionReturnDefinition{Kind: "fixed", Type: spec.TypeString},
			Args:    []spec.ArgSpec{{Type: spec.TypeString, Nullable: true}},
			Evaluator: FunctionEvaluator{
				Kind:   FormulaExprV1EvaluatorKind,
				Params: []string{"arg0", "arg1"},
				Body:   "arg0",
			},
		}},
	}); err != nil {
		t.Fatalf("RegisterFunctionBundle IMAGE: %v", err)
	}
	schema := parityTestSchema()
	record := map[string]interface{}{}

	accepted := []string{
		// IMAGE(image_url, alternate_text [, height [, width]]) → 2-4 args.
		`IMAGE("https://example.com/logo.png", "alt")`,
		`IMAGE("https://example.com/logo.png", "alt", 80)`,
		`IMAGE("https://example.com/logo.png", "alt", 80, 80)`,
	}
	for _, expr := range accepted {
		t.Run("accepts/"+expr, func(t *testing.T) {
			if err := engine.ValidateSyntaxWithSchema(expr, schema); err != nil {
				t.Fatalf("ValidateSyntaxWithSchema(%q) error = %v; want nil (within Salesforce arity bounds)", expr, err)
			}
			if _, err := engine.Evaluate(expr, &Context{Record: record}); err != nil {
				t.Fatalf("Evaluate(%q) error = %v; want nil — verify accepted but runtime rejected (parity violation)", expr, err)
			}
		})
	}

	rejected := []struct {
		name string
		expr string
	}{
		{"IMAGE below min (1 arg)", `IMAGE("https://example.com/logo.png")`},
		{"IMAGE above max (5 args)", `IMAGE("https://example.com/logo.png", "alt", 80, 80, 80)`},
	}
	for _, tc := range rejected {
		t.Run("rejects/"+tc.name, func(t *testing.T) {
			if err := engine.ValidateSyntaxWithSchema(tc.expr, schema); err == nil {
				t.Fatalf("ValidateSyntaxWithSchema(%q) error = nil; want rejection — outside Salesforce arity bounds", tc.expr)
			}
			if _, err := engine.Evaluate(tc.expr, &Context{Record: record}); err == nil {
				t.Fatalf("Evaluate(%q) error = nil; want rejection — runtime must enforce the same arity bound as the gate", tc.expr)
			}
		})
	}
}

func TestValidateSyntaxWithSchemaClassifiesEveryRegisteredRuntimeFunction(t *testing.T) {
	engine := NewEngine()
	schema := parityTestSchema()
	record := map[string]interface{}{
		"Name":      "Acme",
		"Amount":    42.5,
		"IsActive":  true,
		"CloseDate": "2026-05-09",
		"AccountId": "001xx000003DGSWAA4",
	}

	expressions := map[string]string{
		"TODAY":                            `TODAY()`,
		"NOW":                              `NOW()`,
		"LEN":                              `LEN(Name)`,
		"UPPER":                            `UPPER(Name)`,
		"LOWER":                            `LOWER(Name)`,
		"ROUND":                            `ROUND(Amount, 0)`,
		"IF":                               `IF(IsActive, Name, "off")`,
		"OR":                               `OR(IsActive, false)`,
		"AND":                              `AND(IsActive, true)`,
		"NOT":                              `NOT(IsActive)`,
		"TRUTHY":                           `TRUTHY(Name)`,
		"BEGINS":                           `BEGINS(Name, "A")`,
		"FIND":                             `FIND("m", Name)`,
		"SUBSTITUTE":                       `SUBSTITUTE(Name, "m", "x")`,
		"__DATE_ADD_MONTHS__":              `__DATE_ADD_MONTHS__(CloseDate, 1)`,
		"__DATE_ADD_MONTHS_TARGET_CLAMP__": `__DATE_ADD_MONTHS_TARGET_CLAMP__(CloseDate, 1)`,
		"__DATE_DIFF_DAYS__":               `__DATE_DIFF_DAYS__(CloseDate, "2026-05-01")`,
		"DATEVALUE":                        `DATEVALUE(CloseDate)`,
		"DATETIMEVALUE":                    `DATETIMEVALUE(CloseDate)`,
		"DATETIMEFROMEPOCHMILLIS":          `DATETIMEFROMEPOCHMILLIS(42)`,
		"DATE_ADD":                         `DATE_ADD(CloseDate, 1)`,
		"ISBLANK":                          `ISBLANK(Name)`,
		"ISNULL":                           `ISNULL(Name)`,
		"TEXT":                             `TEXT(Amount)`,
		"CASE":                             `CASE(Name, "Acme", "yes", "no")`,
		"LEFT":                             `LEFT(Name, 3)`,
		"RIGHT":                            `RIGHT(Name, 3)`,
		"MID":                              `MID(Name, 1, 2)`,
		"CONTAINS":                         `CONTAINS(Name, "m")`,
		"YEAR":                             `YEAR(CloseDate)`,
		"MONTH":                            `MONTH(CloseDate)`,
		"DAY":                              `DAY(CloseDate)`,
		"CEILING":                          `CEILING(Amount)`,
		"ABS":                              `ABS(Amount)`,
		"MOD":                              `MOD(Amount, 2)`,
		"NULLVALUE":                        `NULLVALUE(Name, "fallback")`,
		"BLANKVALUE":                       `BLANKVALUE(Name, "fallback")`,
		"BCRYPT":                           `BCRYPT(Name)`,
		"ISNULLX":                          `ISNULLX(Name, false)`,
		"ISBLANKX":                         `ISBLANKX(Name, false)`,
		"ISCHANGEDX":                       `ISCHANGEDX(true, Name, "Old", false, false)`,
		// __NCRM_FIELDUNCHANGED__ is intentionally absent from this author-facing
		// expression map: it is a runtime-internal lowering artifact emitted by
		// normalizeFormulaExpression (ISCHANGED lowering) and is rejected in
		// author-written formula text by rejectInternalLoweringArtifacts.
		// See TestValidateSyntax_RejectsInternalLoweringArtifacts.
		"SAFELT":        `SAFELT(Amount, 50)`,
		"SAFEGT":        `SAFEGT(Amount, 1)`,
		"SAFELTE":       `SAFELTE(Amount, 50)`,
		"SAFEGTE":       `SAFEGTE(Amount, 1)`,
		"STRICTSAFELT":  `STRICTSAFELT(Amount, 50)`,
		"STRICTSAFEGT":  `STRICTSAFEGT(Amount, 1)`,
		"STRICTSAFELTE": `STRICTSAFELTE(Amount, 50)`,
		"STRICTSAFEGTE": `STRICTSAFEGTE(Amount, 1)`,
		"DATE":          `DATE(2026, 5, 10)`,
		"LPAD":          `LPAD(Name, 8, "0")`,
		"RPAD":          `RPAD(Name, 8, "0")`,
	}
	// internalLoweringFunctions are registered in the runtime registry so the
	// post-normalize expression produced by normalizeFormulaExpression validates
	// and evaluates correctly, but they are not author-facing: authored formulas
	// that reference them directly are rejected by rejectInternalLoweringArtifacts.
	// They are excluded from the author-facing parity classification loop below.
	internalLoweringFunctions := map[string]bool{
		"__NCRM_FIELDUNCHANGED__": true,
	}
	for _, name := range runtimeFunctionRegistry.Names() {
		if internalLoweringFunctions[name] {
			continue // runtime-only; not a valid author-facing expression
		}
		expr, ok := expressions[name]
		if !ok {
			t.Fatalf("runtime function %s has no schema classification test expression", name)
		}
		t.Run(name, func(t *testing.T) {
			if err := engine.ValidateSyntaxWithSchema(expr, schema); err != nil {
				t.Fatalf("ValidateSyntaxWithSchema(%q) error = %v; want nil", expr, err)
			}
			if _, err := engine.Evaluate(expr, &Context{Record: record}); err != nil {
				t.Fatalf("Evaluate(%q) error = %v; want nil", expr, err)
			}
		})
	}
}

func TestValidateSyntaxWithSchemaErrorNamesCause(t *testing.T) {
	engine := NewEngine()
	err := engine.ValidateSyntaxWithSchema(`LEFT(Account, 3)`, parityTestSchema())
	if err == nil {
		t.Fatal("ValidateSyntaxWithSchema error = nil; want terminal relationship rejection")
	}
	if got := err.Error(); !strings.Contains(got, "terminal relationship") || !strings.Contains(got, "Account") {
		t.Fatalf("error = %q; want terminal relationship detail with identifier name", got)
	}
}

func TestValidateBooleanSyntaxWithSchemaRejectsNonBooleanExpressions(t *testing.T) {
	engine := NewEngine()
	schema := parityTestSchema()

	// Non-boolean field references and expressions must be rejected.
	nonBooleanExprs := []string{
		`Name`,
		`Amount`,
		`CloseDate`,
		`LEFT(Name, 3)`,
		`Amount + 1`,
	}
	for _, expr := range nonBooleanExprs {
		if err := engine.ValidateBooleanSyntaxWithSchema(expr, schema); err == nil {
			t.Errorf("ValidateBooleanSyntaxWithSchema(%q) error = nil; want non-boolean rejection", expr)
		}
	}
}

func TestValidateBooleanSyntaxWithSchemaAcceptsBooleanExpressions(t *testing.T) {
	engine := NewEngine()
	schema := parityTestSchema()

	// Boolean field references and expressions must be accepted.
	booleanExprs := []string{
		`IsActive`,
		`IsClosed`,
		`IsActive && IsClosed`,
		`IsActive || IsClosed`,
		`!IsActive`,
		`Amount > 100`,
		`Name == "Acme"`,
		`CONTAINS(Name, "Corp")`,
		`BEGINS(Name, "A")`,
		`ISBLANK(Name)`,
		`ISNULL(Name)`,
	}
	for _, expr := range booleanExprs {
		if err := engine.ValidateBooleanSyntaxWithSchema(expr, schema); err != nil {
			t.Errorf("ValidateBooleanSyntaxWithSchema(%q) error = %v; want nil", expr, err)
		}
	}
}

// TestValidateBooleanSyntaxWithSchemaRejectsSessionGlobals asserts that
// $Profile/$User/$UserRole dotted-path comparisons are REJECTED by the
// schema-aware boolean validator. LockRule is restricted to
// record-field-only boolean expressions at authoring time:
//
//   - $Permission.X passes ValidateSyntax but the runtime evaluator never
//     injects $Permission context → write gate fail-closes all edits.
//   - $Profile.Name (string) passes the boolean type check via schemaTypeSessionField
//     but evaluates to string at runtime → write gate errors and denies all mutations.
//
// Admins who need per-profile lock conditions should use a boolean record field
// stamped by a flow or trigger.
func TestValidateBooleanSyntaxWithSchemaRejectsSessionGlobals(t *testing.T) {
	engine := NewEngine()
	schema := parityTestSchema()

	exprs := []string{
		`$Profile.Name == "Sales"`,
		`$User.ProfileId == "someProfileId"`,
		`$UserRole.Name == "Manager"`,
		`$Profile.Name == "Admin" || IsActive`,
		`!ISBLANK($User.Email)`,
		`$Profile.Name == "Sales" && Amount > 100`,
		`$Permission.ExportReports`,
	}
	for _, expr := range exprs {
		if err := engine.ValidateBooleanSyntaxWithSchema(expr, schema); err == nil {
			t.Errorf("ValidateBooleanSyntaxWithSchema(%q) error = nil; want rejection (session globals are not record fields)", expr)
		}
	}
}

// TestValidateSyntaxWithSchemaRejectsSessionGlobals asserts that session globals
// are also rejected by the non-boolean schema-aware validator. ValidateSyntaxWithSchema
// delegates to ValidateSyntax which runs the frozen-formula AST walker — session-global
// roots are unsupported in computeFormulaFields and are rejected there as well.
func TestValidateSyntaxWithSchemaRejectsSessionGlobals(t *testing.T) {
	engine := NewEngine()
	schema := parityTestSchema()

	exprs := []string{
		`$User.Name`,
		`$Profile.Name`,
		`$UserRole.Name`,
		`LEFT($User.Name, 3)`,
		`IF($Profile.Name == "Admin", "admin", "user")`,
	}
	for _, expr := range exprs {
		if err := engine.ValidateSyntaxWithSchema(expr, schema); err == nil {
			t.Errorf("ValidateSyntaxWithSchema(%q) error = nil; want rejection (session globals unsupported in frozen-formula context)", expr)
		}
	}
}

func parityTestSchema() *models.ObjectMetadata {
	relationshipName := "Account"
	return &models.ObjectMetadata{
		APIName: "Invoice",
		Fields: []models.FieldMetadata{
			{APIName: "Name", Type: constants.FieldTypeString},
			{APIName: "Amount", Type: constants.FieldTypeCurrency},
			{APIName: "IsActive", Type: constants.FieldTypeBoolean},
			{APIName: "IsClosed", Type: constants.FieldTypeBoolean},
			{APIName: "CloseDate", Type: constants.FieldTypeDate},
			{
				APIName:          "AccountId",
				Type:             constants.FieldTypeReference,
				ReferenceTo:      []string{"Account"},
				RelationshipName: &relationshipName,
			},
			{
				APIName:     "Parent__c",
				Type:        constants.FieldTypeReference,
				ReferenceTo: []string{"Parent__c"},
			},
		},
	}
}
