package formula

import (
	"errors"
	"fmt"
	"slices"
	"strings"

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

	"github.com/nexuscrm/kernel/pkg/formula/spec"
)

// runtimeFunctionRegistry is the spec.DefaultRegistry — the single source of
// truth for the runtime function arity catalog. ValidateSyntax's parse-time gate
// reads it (via Lookup/Names) and the live evaluators derive their own arity
// guards from the same catalog through spec.CheckArity. The catalog itself lives
// in pkg/formula/spec (catalog.go) so both pkg/expression (the base evaluator)
// and pkg/formula (the extension evaluator + this gate) read one declaration
// without an import cycle.
//
// The bounds are enforced statically by walkFormulaASTInto at install/verify
// time, so under-arity calls like `IF(Amount)` (which compile cleanly under
// expr-lang's untyped `expr.Function` registration and only fail at runtime
// when the body returns `IF requires 3 arguments (...)`) are rejected before
// they ever reach the runtime evaluator.
//
// IANTHEREAL P1 (PR #268, 2026-05-07T09:50Z): the previous gate used
// DryEvaluate against synthetic stubs to surface arity errors at runtime.
// That approach was strictly worse than ValidateSyntax because (1) stubs
// could not reproduce real typed-record semantics — `Amount > 0`,
// `Amount + Total`, `FieldA & FieldB` were all rejected as type errors
// against stub maps even though they evaluate cleanly at record-write time,
// and (2) stub coverage required hard-coding application vocabulary
// (ParentId / RelatedId / LookupId) into pkg/formula, violating the
// L0/L1 layering contract. Static arity enforcement is gate-equivalent for
// arity defects and has neither pathology.
//
// IANTHEREAL P1 (PR #1550): the catalog used to be declared here as a copy of
// the literals inside each evaluator body. That made arity a copied fact. It is
// now declared once in spec/catalog.go and every evaluator body derives its
// guard from it via spec.CheckArity — FunctionSpec -> ValidateSyntax/schema/
// runtime is the only direction.
//
// Adding a new runtime function: register it with the evaluator AND add its
// exact-case name plus arity bounds to spec/catalog.go. ValidateSyntax consults
// this registry directly, so missing entries fail closed at parse time.
var runtimeFunctionRegistry = spec.DefaultRegistry()

// validateRuntimeFunctionCalls walks the parsed AST of a normalized formula
// expression and rejects:
//
//  1. Any *ast.CallNode whose Callee is *ast.IdentifierNode but whose name is
//     not in the runtime function registry. Identifier-callee lookups are
//     evaluated against the same normalized runtime surface that Evaluate
//     compiles. normalizeFormulaExpression upper-cases the common Salesforce-
//     flavored names (`if` → `IF`, `today` → `TODAY`, ...) before this walker
//     runs, so those standard spellings validate exactly as the runtime sees
//     them. pkg/formula extensions that are NOT in the normalize list
//     (BCRYPT, SAFELT, STRICTSAFELT, etc.) still must be typed in their
//     registered case to clear the gate.
//
//  2. Any *ast.CallNode whose Callee is NOT an *ast.IdentifierNode — i.e.,
//     method-call shapes like `Parent__r.Name(...)` or `Foo.BAR(Amount)`. The
//     runtime evaluator (pkg/expression Engine.Run via expr-lang vm) does not
//     register methods on user data; attempting to dispatch a method on a
//     reflected user value triggers reflect.Value.Call panics
//     ("reflect: call of reflect.Value.Call on string Value"). Frozen-formula
//     install/verify therefore must reject any call whose callee is not the
//     identifier shape. IANTHEREAL Round-9 P1 (PR #268, 2026-05-07T07:51:23Z).
//
//  3. Any AST node type the walker does not explicitly handle. The walker is
//     exhaustive over expr-lang v1.17.6 ast nodes (NilNode, IdentifierNode,
//     IntegerNode, FloatNode, BoolNode, StringNode, ConstantNode, UnaryNode,
//     BinaryNode, ChainNode, MemberNode, SliceNode, CallNode, BuiltinNode,
//     PredicateNode, PointerNode, ConditionalNode, VariableDeclaratorNode,
//     SequenceNode, ArrayNode, MapNode, PairNode). The default arm returns
//     an error rather than silently passing — adding a new ast node type
//     without explicit handling becomes a verify failure, not a silent bypass.
//     This is the structural fail-closed-by-default contract: previous
//     walker behaviour treated absence-of-recorded-violation as proof of
//     pack health, so any AST shape the walker did not handle defaulted to
//     "pack is healthy" (fail-open).
//
// Returns nil when every node has been positively cleared.
func (e *Engine) validateRuntimeFunctionCalls(normalizedExpr string) error {
	if e == nil || e.functionRegistry == nil {
		return validateRuntimeFunctionCalls(normalizedExpr)
	}
	return validateRuntimeFunctionCallsWithRegistry(normalizedExpr, e.functionRegistry)
}

func validateRuntimeFunctionCalls(normalizedExpr string) error {
	return validateRuntimeFunctionCallsWithRegistry(normalizedExpr, runtimeFunctionRegistry)
}

func validateRuntimeFunctionCallsWithRegistry(normalizedExpr string, registry *spec.Registry) error {
	tree, err := parser.Parse(normalizedExpr)
	if err != nil {
		// ValidateSyntax compiles the same normalized form first, so a parse
		// failure here is still a fail-closed validation error rather than
		// "no calls to inspect".
		return err
	}

	finding, err := walkFormulaASTWithRegistry(tree.Node, registry)
	if err != nil {
		return err
	}
	if finding == nil {
		return nil
	}
	return finding.toError()
}

// formulaWalkFinding accumulates the de-duplicated, order-preserved list of
// unsupported identifier-callee function names AND the first non-identifier
// callee shape encountered AND the list of arity-mismatch findings AND any
// session-bound formula global roots whose runtime context is not injected
// by computeFormulaFields. The four categories are reported as a single
// user-facing error so verify-pack fails on the first batch rather than
// fixing one and re-running.
type formulaWalkFinding struct {
	registry             *spec.Registry
	unsupportedFunctions []unsupportedFunction
	seenFunctions        map[string]struct{}
	rejectedCallShape    string // first non-identifier call shape (Go type name)
	arityMismatches      []arityMismatch
	unsupportedGlobals   []string // session global roots not available in frozen formula eval
	seenGlobals          map[string]struct{}
}

type unsupportedFunction struct {
	name       string
	candidates []string
}

// arityMismatch records a CallNode whose argument count is outside the
// declared [MinArgs, MaxArgs] bounds for its callee. Reported per-call (not
// de-duplicated by name) so an operator sees every offending call site.
type arityMismatch struct {
	name    string
	rule    spec.FunctionSpec
	gotArgs int
	reason  error
}

func (f *formulaWalkFinding) addUnsupportedFunction(name string) {
	// CASE-SENSITIVE lookup: expr-lang resolves function names exactly as
	// registered at run time, so the parse-time gate must query the same
	// exact-case catalog. Using a case-folded lookup would be fail-open.
	registry := f.functionRegistry()
	if _, ok := registry.Lookup(name); ok {
		return
	}
	if f.seenFunctions == nil {
		f.seenFunctions = make(map[string]struct{})
	}
	// De-dup on the verbatim name so error messages preserve the user-typed
	// form (helps operators see exactly which case variant was rejected).
	if _, dup := f.seenFunctions[name]; dup {
		return
	}
	f.seenFunctions[name] = struct{}{}
	f.unsupportedFunctions = append(f.unsupportedFunctions, unsupportedFunction{
		name:       name,
		candidates: suggestRuntimeFunctionCandidatesFromRegistry(registry, name, 3),
	})
}

func (f *formulaWalkFinding) recordRejectedCallShape(callee ast.Node) {
	if f.rejectedCallShape != "" {
		return // first occurrence wins for stable error messages
	}
	f.rejectedCallShape = fmt.Sprintf("%T", callee)
}

// recordArityIfRegistered checks the call's argument count against the
// declared signature for `name` and records a mismatch if the count falls
// outside [MinArgs, MaxArgs]. Names not registered in the runtime function
// registry are silently skipped — addUnsupportedFunction has already recorded
// them as unsupported, and reporting an arity mismatch on top of "not
// implemented" would be redundant noise.
//
// Bounds violations record an arityMismatch with no reason (the rendered
// bounds describe the failure); a Predicate violation records the predicate
// error as the reason so the operator sees the specific rule (e.g. CASE's
// even-argument requirement). This preserves the prior ArityRule check order
// (bounds first, predicate last) exactly while routing the catalog lookup
// through the spec registry.
func (f *formulaWalkFinding) recordArityIfRegistered(name string, gotArgs int) {
	rule, ok := f.functionRegistry().Lookup(name)
	if !ok {
		return
	}
	if gotArgs < rule.MinArgs {
		f.arityMismatches = append(f.arityMismatches, arityMismatch{name: name, rule: rule, gotArgs: gotArgs})
		return
	}
	if rule.MaxArgs != spec.Unbounded && gotArgs > rule.MaxArgs {
		f.arityMismatches = append(f.arityMismatches, arityMismatch{name: name, rule: rule, gotArgs: gotArgs})
		return
	}
	if rule.Predicate != nil {
		if err := rule.Predicate(gotArgs); err != nil {
			f.arityMismatches = append(f.arityMismatches, arityMismatch{name: name, rule: rule, gotArgs: gotArgs, reason: err})
		}
	}
}

func (f *formulaWalkFinding) functionRegistry() *spec.Registry {
	if f != nil && f.registry != nil {
		return f.registry
	}
	return runtimeFunctionRegistry
}

// addUnsupportedGlobal records a normalized global root (e.g. __User,
// __Permission, __Organization, __Setup, __Label) that the frozen formula
// field evaluator does not inject. Only `__Record` is injected by
// Engine.Evaluate when the runtime context is the bare Record+Fetcher shape
// computeFormulaFields builds; every other `__X` therefore fails-closed at
// verify time. De-duplicated on the normalised name so each root is
// reported once.
func (f *formulaWalkFinding) addUnsupportedGlobal(name string) {
	if f.seenGlobals == nil {
		f.seenGlobals = make(map[string]struct{})
	}
	if _, dup := f.seenGlobals[name]; dup {
		return
	}
	f.seenGlobals[name] = struct{}{}
	f.unsupportedGlobals = append(f.unsupportedGlobals, name)
}

func (f *formulaWalkFinding) isEmpty() bool {
	return f == nil || (len(f.unsupportedFunctions) == 0 && f.rejectedCallShape == "" && len(f.arityMismatches) == 0 && len(f.unsupportedGlobals) == 0)
}

func (f *formulaWalkFinding) toError() error {
	parts := make([]string, 0, 3)
	if len(f.unsupportedFunctions) > 0 {
		descs := make([]string, 0, len(f.unsupportedFunctions))
		for _, fn := range f.unsupportedFunctions {
			desc := fn.name
			if len(fn.candidates) > 0 {
				desc += fmt.Sprintf(" (did you mean: %s)", strings.Join(fn.candidates, ", "))
			}
			descs = append(descs, desc)
		}
		parts = append(parts, fmt.Sprintf(
			"formula references function(s) not implemented by the runtime evaluator: %s",
			strings.Join(descs, ", "),
		))
	}
	if f.rejectedCallShape != "" {
		parts = append(parts, fmt.Sprintf(
			"formula contains method-call form (callee %s) that the runtime evaluator cannot resolve to a registered function",
			f.rejectedCallShape,
		))
	}
	if len(f.arityMismatches) > 0 {
		descs := make([]string, 0, len(f.arityMismatches))
		for _, m := range f.arityMismatches {
			desc := fmt.Sprintf("%s expects %s, got %d",
				m.name, formatArityRule(m.rule), m.gotArgs)
			if m.reason != nil {
				desc += fmt.Sprintf(" (%v)", m.reason)
			}
			descs = append(descs, desc)
		}
		parts = append(parts, fmt.Sprintf(
			"formula has arity-mismatched call(s): %s",
			strings.Join(descs, "; "),
		))
	}
	if len(f.unsupportedGlobals) > 0 {
		parts = append(parts, fmt.Sprintf(
			"formula references global root(s) not injected by the frozen formula field evaluation context (only __Record is injected): %s",
			strings.Join(f.unsupportedGlobals, ", "),
		))
	}
	if len(parts) == 0 {
		return nil
	}
	return errors.New(strings.Join(parts, "; "))
}

func formatArityRule(rule spec.FunctionSpec) string {
	return rule.BoundsDescription()
}

func suggestRuntimeFunctionCandidatesFromRegistry(registry *spec.Registry, name string, limit int) []string {
	if limit <= 0 || registry == nil || registry.Len() == 0 {
		return nil
	}
	type candidate struct {
		name     string
		distance int
	}
	normalized := strings.ToUpper(strings.TrimSpace(name))
	registeredNames := registry.Names()
	candidates := make([]candidate, 0, len(registeredNames))
	for _, fn := range registeredNames {
		candidates = append(candidates, candidate{
			name:     fn,
			distance: levenshteinDistance(normalized, fn),
		})
	}
	slices.SortFunc(candidates, func(a, b candidate) int {
		if a.distance != b.distance {
			return a.distance - b.distance
		}
		return strings.Compare(a.name, b.name)
	})
	if limit > len(candidates) {
		limit = len(candidates)
	}
	suggestions := make([]string, 0, limit)
	for _, candidate := range candidates[:limit] {
		suggestions = append(suggestions, candidate.name)
	}
	return suggestions
}

func levenshteinDistance(a, b string) int {
	if a == b {
		return 0
	}
	if len(a) == 0 {
		return len(b)
	}
	if len(b) == 0 {
		return len(a)
	}
	prev := make([]int, len(b)+1)
	for j := range prev {
		prev[j] = j
	}
	for i := 1; i <= len(a); i++ {
		curr := make([]int, len(b)+1)
		curr[0] = i
		for j := 1; j <= len(b); j++ {
			cost := 0
			if a[i-1] != b[j-1] {
				cost = 1
			}
			curr[j] = min3(
				prev[j]+1,
				curr[j-1]+1,
				prev[j-1]+cost,
			)
		}
		prev = curr
	}
	return prev[len(b)]
}

func min3(a, b, c int) int {
	if a <= b && a <= c {
		return a
	}
	if b <= c {
		return b
	}
	return c
}

func walkFormulaASTWithRegistry(n ast.Node, registry *spec.Registry) (*formulaWalkFinding, error) {
	finding := &formulaWalkFinding{registry: registry}
	if err := walkFormulaASTInto(n, finding); err != nil {
		return nil, err
	}
	if finding.isEmpty() {
		return nil, nil
	}
	return finding, nil
}

func walkFormulaASTInto(n ast.Node, finding *formulaWalkFinding) error {
	if n == nil {
		return nil
	}
	switch v := n.(type) {
	// --- leaf nodes (no children) ---
	case *ast.NilNode:
		return nil
	case *ast.IdentifierNode:
		// Allowlist polarity: the frozen-formula runtime context built by
		// computeFormulaFields (formula_compute.go:198) carries only Record +
		// Fetcher; Engine.Evaluate then injects `__Record` and nothing else.
		// Any other normalized `$X → __X` root ($User, $Permission,
		// $Organization, $Setup, $Label, $Api, $Resource, $Site, …) is
		// guaranteed to be absent from the eval env and therefore stored as
		// NULL by the runtime's non-fetch error path
		// (formula_compute.go:226). A denylist of "known-bad globals" leaves
		// every unknown `__X` silently passing — this is the verify hole
		// IANTHEREAL flagged across PR #308 R2 ($Permission), R3
		// ($Organization/$Setup/$Label). The fix-closed contract requires the
		// inverse: only `__Record` passes; everything else fails. Bare
		// identifiers (no `__` prefix) like `Profile` / `Account` remain
		// valid — they are record-relationship roots resolved via Fetcher.
		//
		// __ncrm_multipicklist_fields is an internal lowering sentinel injected
		// by normalizeFormulaExpression when ISCHANGED is lowered to
		// __NCRM_FIELDUNCHANGED__(..., __ncrm_multipicklist_fields).  It appears
		// in the post-normalize expression as a variable reference, not a $-global,
		// and is injected into the eval env by Engine.Evaluate — skip it here so
		// ValidateSyntax on a legitimate ISCHANGED formula does not fail.
		// Author-written occurrences are rejected before normalization by
		// rejectInternalLoweringArtifacts.
		if strings.HasPrefix(v.Value, "__") && v.Value != "__Record" && v.Value != "__ncrm_multipicklist_fields" {
			finding.addUnsupportedGlobal(v.Value)
		}
		return nil
	case *ast.IntegerNode:
		return nil
	case *ast.FloatNode:
		return nil
	case *ast.BoolNode:
		return nil
	case *ast.StringNode:
		return nil
	case *ast.ConstantNode:
		return nil
	case *ast.PointerNode:
		return nil

	// --- composite nodes (recurse into children) ---
	case *ast.UnaryNode:
		return walkFormulaASTInto(v.Node, finding)
	case *ast.BinaryNode:
		if err := walkFormulaASTInto(v.Left, finding); err != nil {
			return err
		}
		return walkFormulaASTInto(v.Right, finding)
	case *ast.ChainNode:
		return walkFormulaASTInto(v.Node, finding)
	case *ast.MemberNode:
		// MemberNode itself (field-access form `obj.field`) is allowed —
		// the runtime evaluator resolves it via map lookup. The method-call
		// case is rejected only when MemberNode appears as a CallNode.Callee
		// (handled below).
		if err := walkFormulaASTInto(v.Node, finding); err != nil {
			return err
		}
		return walkFormulaASTInto(v.Property, finding)
	case *ast.SliceNode:
		if err := walkFormulaASTInto(v.Node, finding); err != nil {
			return err
		}
		if err := walkFormulaASTInto(v.From, finding); err != nil {
			return err
		}
		return walkFormulaASTInto(v.To, finding)
	case *ast.CallNode:
		// Identifier callee → check against runtime allowlist AND declared
		// arity bounds. Anything else (MemberNode, ChainNode, CallNode, etc.)
		// → rejected as a method-call shape the runtime cannot resolve
		// (IANTHEREAL R9). The arity check is the static replacement for the
		// previous DryEvaluate-against-stubs gate (IANTHEREAL Round-11 P1):
		// expr-lang's untyped `expr.Function` registration carries no
		// signature, so under-arity calls like `IF(Amount)` compile cleanly
		// and only fail at runtime when the body returns
		// "IF requires 3 arguments (...)". Catching them statically here
		// removes the need for stub-bound dry-evaluation, which was both too
		// strict (rejected `Amount > 0` against typed-record runtime
		// semantics) and required hard-coding application vocabulary
		// (ParentId / RelatedId / LookupId) in L0 to seed stub maps.
		if id, ok := v.Callee.(*ast.IdentifierNode); ok {
			finding.addUnsupportedFunction(id.Value)
			finding.recordArityIfRegistered(id.Value, len(v.Arguments))
			// Do NOT recurse into IdentifierNode callees — they are function
			// names, not variable roots, and are fully handled above. Recursing
			// would incorrectly trigger the session-global check on names like
			// "User" in a hypothetical User() call.
		} else {
			finding.recordRejectedCallShape(v.Callee)
			// Recurse into complex callees (e.g. foo()(args) where the callee
			// is itself a CallNode) so nested unsupported calls surface.
			if err := walkFormulaASTInto(v.Callee, finding); err != nil {
				return err
			}
		}
		for i := range v.Arguments {
			if err := walkFormulaASTInto(v.Arguments[i], finding); err != nil {
				return err
			}
		}
		return nil
	case *ast.BuiltinNode:
		// expr-lang builtins (len, all, any, filter, map, ...) are not
		// registered by pkg/formula's runtime, so even if the parser
		// classifies a call as BuiltinNode it must surface as unsupported.
		// Use the Name field directly because BuiltinNode does not carry an
		// IdentifierNode callee.
		finding.addUnsupportedFunction(v.Name)
		for i := range v.Arguments {
			if err := walkFormulaASTInto(v.Arguments[i], finding); err != nil {
				return err
			}
		}
		return nil
	case *ast.PredicateNode:
		return walkFormulaASTInto(v.Node, finding)
	case *ast.ConditionalNode:
		if err := walkFormulaASTInto(v.Cond, finding); err != nil {
			return err
		}
		if err := walkFormulaASTInto(v.Exp1, finding); err != nil {
			return err
		}
		return walkFormulaASTInto(v.Exp2, finding)
	case *ast.VariableDeclaratorNode:
		if err := walkFormulaASTInto(v.Value, finding); err != nil {
			return err
		}
		return walkFormulaASTInto(v.Expr, finding)
	case *ast.SequenceNode:
		for i := range v.Nodes {
			if err := walkFormulaASTInto(v.Nodes[i], finding); err != nil {
				return err
			}
		}
		return nil
	case *ast.ArrayNode:
		for i := range v.Nodes {
			if err := walkFormulaASTInto(v.Nodes[i], finding); err != nil {
				return err
			}
		}
		return nil
	case *ast.MapNode:
		for i := range v.Pairs {
			if err := walkFormulaASTInto(v.Pairs[i], finding); err != nil {
				return err
			}
		}
		return nil
	case *ast.PairNode:
		if err := walkFormulaASTInto(v.Key, finding); err != nil {
			return err
		}
		return walkFormulaASTInto(v.Value, finding)

	default:
		// Any ast node type not explicitly enumerated is treated as a
		// verify failure (fail-closed-by-default). Adding a new node type
		// to expr-lang without updating this switch will fail every frozen
		// formula that triggers the new shape, which is the desired
		// behaviour: forces an explicit accept/reject decision.
		return fmt.Errorf("formula validator: unsupported ast node type %T (fail-closed-by-default; add explicit handling in walkFormulaASTInto)", n)
	}
}
