package expression

import (
	"strings"

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

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

// innerMemberMarker is a first-pass AST visitor that records which MemberNode instances
// are "inner" nodes in a chained access (i.e., some outer MemberNode has them as its Node).
// This is used by sfFormulaPatcher to distinguish outermost chains from intermediate hops.
type innerMemberMarker struct {
	inner map[*ast.MemberNode]bool
}

func newInnerMemberMarker() *innerMemberMarker {
	return &innerMemberMarker{inner: make(map[*ast.MemberNode]bool)}
}

func (m *innerMemberMarker) Visit(node *ast.Node) {
	n, ok := (*node).(*ast.MemberNode)
	if !ok {
		return
	}
	if inner, ok := n.Node.(*ast.MemberNode); ok {
		m.inner[inner] = true
	}
}

// sfFormulaPatcher applies two Salesforce-semantic fixes to the parsed AST before compilation:
//
// Fix A — lazy IF: rewrites IF(cond, a, b) → (cond ? a : b).
// expr-lang evaluates function arguments eagerly; the native ternary is lazy.
// This matches SF semantics where only the chosen branch is executed.
//
// Fix B — null propagation: for any MemberNode chain of depth ≥ 2, marks non-root
// hops as Optional and wraps the outermost in a ChainNode so that a nil intermediate
// value short-circuits the chain and returns nil instead of "cannot fetch X from <nil>".
// This matches SF semantics where field traversal through a null relationship returns null.
type sfFormulaPatcher struct {
	marker         *innerMemberMarker
	processed      map[*ast.MemberNode]bool
	functionLookup *spec.Registry
}

func newSFFormulaPatcher(marker *innerMemberMarker, registry *spec.Registry) *sfFormulaPatcher {
	return &sfFormulaPatcher{
		marker:         marker,
		processed:      make(map[*ast.MemberNode]bool),
		functionLookup: registry,
	}
}

func (p *sfFormulaPatcher) Visit(node *ast.Node) {
	switch n := (*node).(type) {
	case *ast.CallNode:
		p.visitCall(node, n)
	case *ast.MemberNode:
		p.visitMember(node, n)
	case *ast.BinaryNode:
		p.visitBinary(node, n)
	}
}

// visitCall applies call-level formula rewrites: lazy IF and schema-aware
// INCLUDES multipicklist field routing.
func (p *sfFormulaPatcher) visitCall(node *ast.Node, n *ast.CallNode) {
	ident, ok := n.Callee.(*ast.IdentifierNode)
	if !ok {
		return
	}
	switch ident.Value {
	case "IF":
		if !p.hasFunction("IF") {
			return
		}
		if len(n.Arguments) != 3 {
			return
		}
		ast.Patch(node, &ast.ConditionalNode{
			Cond: n.Arguments[0],
			Exp1: n.Arguments[1],
			Exp2: n.Arguments[2],
		})
	case "AND":
		if !p.hasFunction("AND") || !p.hasFunction("TRUTHY") {
			return
		}
		if len(n.Arguments) == 0 {
			return
		}
		ast.Patch(node, buildLazyAndNode(n.Arguments))
	case "OR":
		if !p.hasFunction("OR") || !p.hasFunction("TRUTHY") {
			return
		}
		if len(n.Arguments) == 0 {
			return
		}
		ast.Patch(node, buildLazyOrNode(n.Arguments))
	case "INCLUDES":
		if !p.hasFunction("INCLUDES") {
			return
		}
		if len(n.Arguments) != 2 {
			return
		}
		if rhs, ok := n.Arguments[1].(*ast.StringNode); ok && strings.Contains(rhs.Value, ";") {
			ident.Value = "__NCRM_MPINCLUDESALL__"
		}
		path, ok := multiPicklistArgumentPath(n.Arguments[0])
		if !ok || path == "" {
			return
		}
		n.Arguments[0] = &ast.CallNode{
			Callee: &ast.IdentifierNode{Value: "__NCRM_MPFIELD__"},
			Arguments: []ast.Node{
				n.Arguments[0],
				&ast.StringNode{Value: path},
				&ast.IdentifierNode{Value: MultiPicklistFieldEnvKey},
			},
		}
	}
}

func buildLazyAndNode(args []ast.Node) ast.Node {
	if len(args) == 1 {
		return truthyCall(args[0])
	}
	return &ast.ConditionalNode{
		Cond: truthyCall(args[0]),
		Exp1: buildLazyAndNode(args[1:]),
		Exp2: &ast.BoolNode{Value: false},
	}
}

func buildLazyOrNode(args []ast.Node) ast.Node {
	if len(args) == 1 {
		return truthyCall(args[0])
	}
	return &ast.ConditionalNode{
		Cond: truthyCall(args[0]),
		Exp1: &ast.BoolNode{Value: true},
		Exp2: buildLazyOrNode(args[1:]),
	}
}

func truthyCall(arg ast.Node) ast.Node {
	return &ast.CallNode{
		Callee: &ast.IdentifierNode{Value: "TRUTHY"},
		Arguments: []ast.Node{
			arg,
		},
	}
}

func (p *sfFormulaPatcher) hasFunction(name string) bool {
	if p == nil || p.functionLookup == nil {
		return false
	}
	_, ok := p.functionLookup.Lookup(name)
	return ok
}

func multiPicklistArgumentPath(node ast.Node) (string, bool) {
	switch n := node.(type) {
	case *ast.IdentifierNode:
		return n.Value, true
	case *ast.ChainNode:
		return multiPicklistArgumentPath(n.Node)
	case *ast.MemberNode:
		base, ok := multiPicklistArgumentPath(n.Node)
		if !ok || base == "" {
			return "", false
		}
		prop, ok := memberNodeProperty(n.Property)
		if !ok || prop == "" {
			return "", false
		}
		return base + "." + prop, true
	default:
		return "", false
	}
}

func memberNodeProperty(node ast.Node) (string, bool) {
	switch n := node.(type) {
	case *ast.StringNode:
		return n.Value, true
	case *ast.IdentifierNode:
		return n.Value, true
	default:
		return "", false
	}
}

// visitBinary applies Fix C: rewrite `+` BinaryNode to __SFPLUS__(left, right).
//
// Salesforce text concatenation (`&`) is rewritten to `+` by replaceFormulaOperators
// before the expression engine sees the formula. This makes it impossible to
// distinguish "text +" from "numeric +" at the character level. Fix C intercepts
// ALL `+` binary nodes here and routes them through __SFPLUS__, which uses runtime
// type inspection to decide whether to null-coerce (text) or null-propagate (numeric).
// Issue #468.
func (p *sfFormulaPatcher) visitBinary(node *ast.Node, n *ast.BinaryNode) {
	if n.Operator != "+" {
		return
	}
	ast.Patch(node, &ast.CallNode{
		Callee:    &ast.IdentifierNode{Value: "__SFPLUS__"},
		Arguments: []ast.Node{n.Left, n.Right},
	})
}

// visitMember applies Fix B: null-propagating optional chaining for all
// MemberNode depths.
//
// Depth ≥ 2 (e.g. Record.RecordType.DeveloperName): inner hops get Optional;
// the outermost is wrapped in ChainNode for JumpIfNil guards.
//
// Depth 1 (e.g. Account.Name, RecordType.DeveloperName as SF bare shorthand):
// same treatment — a nil root (definitive-null relation installed by
// augmentRecordWithRelations) propagates to nil rather than erroring with
// "type nil has no field X". Issue #2181 r2.
func (p *sfFormulaPatcher) visitMember(node *ast.Node, n *ast.MemberNode) {
	if p.processed[n] {
		return
	}
	p.processed[n] = true

	n.Optional = true

	if !p.marker.inner[n] {
		// Outermost in the chain: wrap in ChainNode so the compiler emits JumpIfNil guards.
		ast.Patch(node, &ast.ChainNode{Node: n})
	}
}

// sqlNullDivisionPatcher rewrites every binary `/` into a call to
// __NCRM_SQLDIV__, the SQL-parity division used by formula-FIELD evaluation
// under blank-as-zero (#4152): a zero divisor or a non-finite quotient yields
// nil, exactly as the SQL projection renders the corresponding
// COALESCE(...)/... division as NULL. Compiled only via
// EvaluateWithSQLNullDivision; every other evaluation keeps the native float
// operator (and its IEEE +Inf/NaN) untouched.
type sqlNullDivisionPatcher struct{}

func (p *sqlNullDivisionPatcher) Visit(node *ast.Node) {
	n, ok := (*node).(*ast.BinaryNode)
	if !ok || n.Operator != "/" {
		return
	}
	ast.Patch(node, &ast.CallNode{
		Callee:    &ast.IdentifierNode{Value: "__NCRM_SQLDIV__"},
		Arguments: []ast.Node{n.Left, n.Right},
	})
}
