package spec

import "fmt"

// defaultSpecs is the single source of truth for the runtime function catalog:
// argument-count bounds (VS1, #1464), per-argument types and return types
// (VS2, #1466). Every consumer — the parse-time install/verify gate
// (pkg/formula.runtimeFunctionRegistry), the live evaluators
// (pkg/expression.Engine builtins, pkg/formula.Engine extensions), and the
// schema-aware return-type inferrer (pkg/formula.inferCallType) — derives its
// guard from this one declaration via DefaultRegistry / CheckArity / Lookup.
// There is exactly one place the call shape is declared; everything else reads
// it.
//
// IANTHEREAL P1 (PR #1550): before this catalog existed, each function body in
// pkg/expression/engine.go and pkg/formula/engine.go owned its own literal
// `len(params) != N` / `len(args) != N` check and the FunctionSpec registry
// merely *copied* those literals. That left arity as a copied fact (one fact,
// many declarations) with the authority direction reversed. The catalog inverts
// it: FunctionSpec -> ValidateSyntax/schema/runtime is now the only direction,
// so a single edit here updates the gate and every evaluator at once. VS2
// folds the return-type + per-argument-type surface (formerly the inferCallType
// name-switch) into the same declaration.
//
// Scope boundary (VS2 = frozen-runtime-only): VS2 cuts the FROZEN return-type
// reader (pkg/formula.inferCallType in schema_validation.go) over to this
// catalog. It deliberately does NOT cut over the ACTIVE DSL compiler's local
// return-type classifiers (classifyBlankOperand / isDateReturningFunction /
// isNumericReturningFunction / isBooleanReturningFunction /
// isStringReturningFunction in
// backend/internal/features/analytics/formula_dsl_compiler.go). That live
// SQL-generation type-inference path is migrated as a whole in VS3, which owns
// the DSL compiler's arity + return-type cutover together — exactly as VS1
// (#1550) cut frozen arity here and left the DSL arity checks to VS3. The
// catalog therefore remains the sole return-type authority for the frozen path
// only; the DSL compiler keeps its own table until VS3 routes it through
// DefaultRegistry. No code outside the frozen path re-declares catalog return
// types in this PR.
//
// Bounds notes:
//   - OR / AND are variadic with MinArgs 1 — zero-arg AND()/OR() is almost
//     certainly an authoring mistake even though the loop body short-circuits to
//     a default.
//   - IMAGE is IMAGE(image_url, alternate_text [, height [, width]]) = 2-4 args;
//     alternate_text is required, height/width optional
//     (Salesforce customize_functions_image).
//   - CASE carries an even-arity Predicate on top of its >=4 lower bound.
//
// Argument-type notes (preserved exactly from the legacy inferCallType switch):
//   - String arguments to the text functions accept nil (Nullable) because the
//     runtime coerces nil to "" — LEFT/RIGHT/MID/LEN/UPPER/LOWER/BEGINS/
//     CONTAINS/FIND/SUBSTITUTE/LPAD-arg0/RPAD-arg0/IMAGE.
//   - Numeric arguments are strict (reject nil): the runtime errors on a nil
//     numeric operand — ROUND/CEILING/ABS/MOD/DATE/LEFT-arg1/etc.
//   - LPAD/RPAD pad-string (arg 2) and BCRYPT (arg 0) are strict strings.
//   - DateLikeLiteral arguments (YEAR/MONTH/DAY/__DATE_ADD_MONTHS__/
//     __DATE_ADD_MONTHS_TARGET_CLAMP__/__DATE_DIFF_DAYS__/DATE_ADD/DATEVALUE
//     arg 0) accept a date- OR datetime-typed expression or a raw ISO-date
//     string literal (Salesforce calendar functions accept both Date and
//     DateTime inputs).
//   - Return types distinguish Date from DateTime per Salesforce semantics:
//     TODAY/DATE/DATEVALUE/__DATE_ADD_MONTHS__/__DATE_ADD_MONTHS_TARGET_CLAMP__/
//     DATE_ADD return Date; NOW/DATETIMEVALUE
//     return DateTime. The two are distinct assignment targets in the schema
//     validator but both widen to string (TEXT) and are mutually order-comparable
//     only within the same type.
//   - TEXT, DATETIMEVALUE, and the boolean predicates intentionally declare no
//     Args: their runtime evaluators decide whether a supplied scalar can be
//     converted without widening the formula type. DATETIMEFROMEPOCHMILLIS
//     keeps a narrow number-or-epoch-string contract.
//
// Adding a new runtime function: register its evaluator AND add its exact-case
// name, arity bounds, return type, and argument types here. Missing entries
// fail closed at parse time because the gate consults DefaultRegistry directly.
func defaultSpecs() []FunctionSpec {
	str := ArgSpec{Type: TypeString}                  // strict string (rejects nil)
	num := ArgSpec{Type: TypeNumber}                  // strict number (rejects nil)
	boolean := ArgSpec{Type: TypeBoolean}             // strict boolean
	nstr := ArgSpec{Type: TypeString, Nullable: true} // string-or-nil
	dateLike := ArgSpec{DateLikeLiteral: true}        // date expr or ISO-date literal
	epochMillis := ArgSpec{EpochMillis: true}         // number, nil, or strict epoch string

	fixed := func(t ValueType) ReturnSpec { return ReturnSpec{Kind: ReturnFixed, Fixed: t} }
	merge := func(idx ...int) ReturnSpec { return ReturnSpec{Kind: ReturnMergeArgs, MergeArgs: idx} }
	caseReturn := ReturnSpec{Kind: ReturnMergeEvenArgsPlusLast}

	return []FunctionSpec{
		// pkg/expression/engine.go builtins.
		{Name: "TODAY", MinArgs: 0, MaxArgs: 0, Return: fixed(TypeDate)},
		{Name: "NOW", MinArgs: 0, MaxArgs: 0, Return: fixed(TypeDatetime)}, // Salesforce: NOW() returns DateTime, not Date
		{Name: "LEN", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeNumber), Args: []ArgSpec{nstr}},
		{Name: "UPPER", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeString), Args: []ArgSpec{nstr}},
		{Name: "LOWER", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeString), Args: []ArgSpec{nstr}},
		{Name: "ROUND", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeNumber), Args: []ArgSpec{num, num}},
		{Name: "IF", MinArgs: 3, MaxArgs: 3, Return: merge(1, 2), Args: []ArgSpec{boolean, {Nullable: true}, {Nullable: true}}},
		{Name: "OR", MinArgs: 1, MaxArgs: Unbounded, Return: fixed(TypeBoolean)},  // variadic; >=1 arg per Salesforce contract
		{Name: "AND", MinArgs: 1, MaxArgs: Unbounded, Return: fixed(TypeBoolean)}, // variadic; >=1 arg per Salesforce contract
		{Name: "NOT", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeBoolean)},
		{Name: "TRUTHY", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeBoolean)},
		{Name: "BEGINS", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeBoolean), Args: []ArgSpec{nstr, nstr}},
		{Name: "FIND", MinArgs: 2, MaxArgs: 3, Return: fixed(TypeNumber), Args: []ArgSpec{nstr, nstr, num}},
		{Name: "SUBSTITUTE", MinArgs: 3, MaxArgs: 4, Return: fixed(TypeString), Args: []ArgSpec{nstr, nstr, nstr, num}},
		{Name: "__DATE_ADD_MONTHS__", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeDate), Args: []ArgSpec{dateLike, num}},
		{Name: "__DATE_ADD_MONTHS_TARGET_CLAMP__", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeDate), Args: []ArgSpec{dateLike, num}},
		{Name: "__DATE_DIFF_DAYS__", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeNumber), Args: []ArgSpec{dateLike, dateLike}},
		{Name: "DATEVALUE", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeDate), Args: []ArgSpec{dateLike}},
		{Name: "DATETIMEVALUE", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeDatetime)}, // Salesforce: returns DateTime; accepts any arg, no schema-level restriction
		{Name: "DATETIMEFROMEPOCHMILLIS", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeDatetime), Args: []ArgSpec{epochMillis}},
		{Name: "DATE_ADD", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeDate), Args: []ArgSpec{dateLike, num}},
		{Name: "ISBLANK", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeBoolean)},
		{Name: "ISNULL", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeBoolean)},
		{Name: "TEXT", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeString)},
		{Name: "CASE", MinArgs: 4, MaxArgs: Unbounded, Predicate: RequireEvenArity("CASE"), Return: caseReturn}, // runtime: len < 4 || len%2 != 0
		{Name: "LEFT", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeString), Args: []ArgSpec{nstr, num}},
		{Name: "RIGHT", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeString), Args: []ArgSpec{nstr, num}},
		{Name: "MID", MinArgs: 3, MaxArgs: 3, Return: fixed(TypeString), Args: []ArgSpec{nstr, num, num}},
		{Name: "CONTAINS", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeBoolean), Args: []ArgSpec{nstr, nstr}},
		{Name: "YEAR", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeNumber), Args: []ArgSpec{dateLike}},
		{Name: "MONTH", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeNumber), Args: []ArgSpec{dateLike}},
		{Name: "DAY", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeNumber), Args: []ArgSpec{dateLike}},
		{Name: "CEILING", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeNumber), Args: []ArgSpec{num}},
		{Name: "ABS", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeNumber), Args: []ArgSpec{num}},
		{Name: "MOD", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeNumber), Args: []ArgSpec{num, num}},
		{Name: "NULLVALUE", MinArgs: 2, MaxArgs: 2, Return: merge(0, 1), Args: []ArgSpec{{Nullable: true}, {Nullable: true}}},
		{Name: "BLANKVALUE", MinArgs: 2, MaxArgs: 2, Return: merge(0, 1), Args: []ArgSpec{{Nullable: true}, {Nullable: true}}},

		// Salesforce standard functions added in issue #429.
		{Name: "DATE", MinArgs: 3, MaxArgs: 3, Return: fixed(TypeDate), Args: []ArgSpec{num, num, num}},
		{Name: "LPAD", MinArgs: 3, MaxArgs: 3, Return: fixed(TypeString), Args: []ArgSpec{nstr, num, str}},
		{Name: "RPAD", MinArgs: 3, MaxArgs: 3, Return: fixed(TypeString), Args: []ArgSpec{nstr, num, str}},
		// IMAGE(image_url, alternate_text [, height [, width]]) → passthrough
		// returning the URL. alternate_text is required per Salesforce; height
		// and width are optional. The legacy switch type-checked only arg 0

		// pkg/formula/engine.go registerBuiltinFunctions() extensions.
		{Name: "BCRYPT", MinArgs: 1, MaxArgs: 1, Return: fixed(TypeString), Args: []ArgSpec{str}},
		{Name: "ISNULLX", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeBoolean)},
		{Name: "ISBLANKX", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeBoolean)},
		{Name: "ISCHANGEDX", MinArgs: 5, MaxArgs: 5, Return: fixed(TypeBoolean)},
		// __NCRM_FIELDUNCHANGED__(fieldName, cur, prior, mpFields) — eval-time ISCHANGED
		// helper synthesized by the normalizer's isChangedPattern lowering. Returns true
		// when the field is unchanged: set-compare for multi-picklist, Sprintf compare
		// otherwise. Negated in the lowering so callers see ISCHANGED semantics.
		{Name: "__NCRM_FIELDUNCHANGED__", MinArgs: 4, MaxArgs: 4, Return: fixed(TypeBoolean)},
		{Name: "SAFELT", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeBoolean)},
		{Name: "SAFEGT", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeBoolean)},
		{Name: "SAFELTE", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeBoolean)},
		{Name: "SAFEGTE", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeBoolean)},
		{Name: "STRICTSAFELT", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeBoolean)},
		{Name: "STRICTSAFEGT", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeBoolean)},
		{Name: "STRICTSAFELTE", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeBoolean)},
		{Name: "STRICTSAFEGTE", MinArgs: 2, MaxArgs: 2, Return: fixed(TypeBoolean)},
	}
}

// defaultRegistry is the package-level SSoT registry, built once from
// defaultSpecs. DefaultRegistry returns it for read-only catalog consumers and
// CheckArity uses it for the runtime arity guard.
var defaultRegistry = buildDefaultRegistry()

func buildDefaultRegistry() *Registry {
	r := NewRegistry()
	for _, s := range defaultSpecs() {
		if err := r.Register(s); err != nil {
			panic(fmt.Sprintf("spec: building default function registry: %v", err))
		}
	}
	return r
}

// DefaultRegistry returns the single source-of-truth runtime function registry.
// Catalog consumers (the parse-time gate, candidate suggestions, schema
// classification) read through it so there is no second arity declaration.
func DefaultRegistry() *Registry {
	return defaultRegistry
}

// CheckArity validates that argc satisfies the declared arity contract for the
// runtime function `name`, deriving the bounds (and any Predicate) from the
// DefaultRegistry SSoT. It is the runtime evaluator's arity guard: each function
// body calls CheckArity(<its own name>, len(args)) instead of writing a literal
// length check, so the registry is the only place arity is declared.
//
// An unregistered name is a programming error (a function body that was never
// added to defaultSpecs); CheckArity returns an explicit error rather than
// silently accepting any argument count.
func CheckArity(name string, argc int) error {
	return defaultRegistry.CheckArity(name, argc)
}

// RequireEvenArity returns a Predicate that rejects an odd argument count. It is
// the escape hatch for CASE's value/result-pair contract, which plain bounds
// cannot express.
func RequireEvenArity(fn string) func(int) error {
	return func(actual int) error {
		if actual%2 != 0 {
			return fmt.Errorf("%s requires an even number of arguments", fn)
		}
		return nil
	}
}
