package formula

import (
	"errors"
	"math/rand"
	"strings"
	"testing"

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

// TestPropertyVerify is the property-based fuzz harness for the install/
// verify gate. The invariant under test (Round-11):
//
//	∀ randomly generated expression E:
//	  if Engine.ValidateSyntax(E) == nil
//	  then Engine.Evaluate(E, realCtx) MUST NOT panic.
//
// In other words: any input that PASSES the static gate MUST run the
// runtime evaluator panic-free against real (typed) bindings. A failure
// of this invariant is a P0 regression — the install gate would let a
// panicking formula through and records.computeFormulaFields would then
// panic at record-write time.
//
// Round-11 simplification: the previous invariant also required
// DryEvaluate(E) == nil because DryEvaluate ran the runtime evaluator
// against stub bindings. That gate was retracted (IANTHEREAL P1, PR #268,
// 2026-05-07T09:50Z) — see ValidateSyntax godoc in engine.go for the full
// rationale. The static walker now subsumes the panic-prevention work via
// (1) callee-shape rejection (no method-call form can ever reach the
// runtime), (2) function-name allowlist (every callee must be a registered
// function), and (3) arity-bounds enforcement (every call lies inside its
// declared [Min, Max]). Together those three guarantees are sufficient to
// make the post-static-gate runtime evaluator panic-free under realistic
// typed bindings — which is what this property test pins.
//
// The generator deliberately mixes shapes that historical bugs have
// exploited: identifier-call (allowlist gate), member-call (R9 walker
// gate), nested calls (recursion correctness), member chains, indexing,
// binary/ternary mixing, literals, boolean infix.
//
// Sample size: 1024 random expressions per run. Tunable via -short to drop
// to 200 for fast CI lanes; the default is sized to keep the whole test
// under ~5 seconds on a developer laptop.
func TestPropertyVerify(t *testing.T) {
	engine := NewEngine()
	rng := rand.New(rand.NewSource(20260507)) // deterministic seed

	sampleSize := 1024
	if testing.Short() {
		sampleSize = 200
	}

	// realCtx mirrors the shape Engine.Evaluate sees in production: a
	// record map populated with the typical Salesforce-shape fields and
	// nested relations. Stub names match what generateExpression draws
	// from so the runtime resolution path matches the static one.
	realCtx := &Context{
		Record: map[string]interface{}{
			"Amount":      100.0,
			"Description": "x",
			"Phone":       "555",
			"Email":       "a@b.com",
			"IsActive":    true,
			"Account": map[string]interface{}{
				"Name":     "Acme",
				"Region":   "NA",
				"IsActive": true,
			},
			"Owner": map[string]interface{}{
				"Email": "o@b.com",
			},
		},
	}

	for i := 0; i < sampleSize; i++ {
		expr := generateExpression(rng, 0)
		if strings.TrimSpace(expr) == "" {
			continue
		}

		if err := engine.ValidateSyntax(expr); err != nil {
			continue // not a candidate — static gate already blocks it
		}

		// The static gate passed. The invariant says Evaluate must not
		// panic against real bindings.
		if err := runEvaluateCatchingPanic(engine, expr, realCtx); err != nil {
			if errors.Is(err, errEvalPanic) {
				t.Fatalf("PROPERTY VIOLATION: expression %q passed ValidateSyntax but panicked under Engine.Evaluate (real bindings): %v", expr, err)
			}
			// Clean error returns are fine — the invariant is panic-free
			// runtime, not error-free runtime. Real-data type mismatches,
			// unknown identifiers, etc. are out of scope.
		}
	}
}

var errEvalPanic = errors.New("evaluate panic")

// runEvaluateCatchingPanic invokes Engine.Evaluate and converts a panic
// into a sentinel error so the caller can distinguish it from a clean
// error return.
func runEvaluateCatchingPanic(engine *Engine, expression string, ctx *Context) (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = errors.Join(errEvalPanic, errors.New(toString(r)))
		}
	}()
	_, runErr := engine.Evaluate(expression, ctx)
	return runErr
}

func toString(v interface{}) string {
	switch x := v.(type) {
	case string:
		return x
	case error:
		return x.Error()
	default:
		return "non-string panic"
	}
}

// generateExpression produces a random expression. depth bounds recursion;
// shapes are picked by weight to favour realistic frozen-formula content
// over deeply-pathological forms.
func generateExpression(rng *rand.Rand, depth int) string {
	if depth >= 4 {
		return generateLeaf(rng)
	}
	switch rng.Intn(12) {
	case 0, 1:
		return generateLeaf(rng)
	case 2:
		return generateRuntimeFunctionCall(rng, depth+1)
	case 3:
		// Identifier-call (NOT allowlisted) — should be rejected by gate 1.
		args := generateArgList(rng, depth+1, rng.Intn(3)+1)
		return "NO_SUCH_FN_" + randomLetters(rng, 3) + "(" + args + ")"
	case 4:
		// Member-access (legal)
		return pickRelation(rng) + "." + pickField(rng)
	case 5:
		// Member-call (illegal — gate 2 reject)
		return pickRelation(rng) + "." + pickField(rng) + "()"
	case 6:
		// Binary
		return generateExpression(rng, depth+1) + " " + pickBinaryOp(rng) + " " + generateExpression(rng, depth+1)
	case 7:
		// Ternary
		return generateExpression(rng, depth+1) + " ? " + generateExpression(rng, depth+1) + " : " + generateExpression(rng, depth+1)
	case 8:
		// Indexing
		return pickRelation(rng) + "[" + generateLeaf(rng) + "]"
	case 9:
		return generateRuntimeFunctionCall(rng, depth+1)
	case 10:
		// Salesforce $Record path
		return "$Record." + pickRelation(rng) + "." + pickField(rng)
	default:
		// Boolean infix (Salesforce AND/OR)
		return generateExpression(rng, depth+1) + " AND " + generateExpression(rng, depth+1)
	}
}

func generateLeaf(rng *rand.Rand) string {
	switch rng.Intn(7) {
	case 0:
		return "1"
	case 1:
		return "true"
	case 2:
		return "false"
	case 3:
		return `"x"`
	case 4:
		return pickIdentifier(rng)
	case 5:
		return pickRelation(rng) + "." + pickField(rng)
	default:
		return "0"
	}
}

func generateArgList(rng *rand.Rand, depth, n int) string {
	if n <= 0 {
		return ""
	}
	parts := make([]string, n)
	for i := 0; i < n; i++ {
		parts[i] = generateExpression(rng, depth)
	}
	return strings.Join(parts, ", ")
}

var propertyFunctionPool = []string{
	"TODAY", "NOW", "IF", "AND", "OR", "NOT", "ISBLANK", "TEXT",
	"LEN", "FIND", "SUBSTITUTE", "CASE", "LEFT", "RIGHT",
	"MID", "CONTAINS", "ABS", "MOD", "NULLVALUE", "DATE_ADD",
}

func generateRuntimeFunctionCall(rng *rand.Rand, depth int) string {
	fn := propertyFunctionPool[rng.Intn(len(propertyFunctionPool))]
	rule, _ := runtimeFunctionRegistry.Lookup(fn)
	argc := randomValidArgCount(rng, rule)
	return fn + "(" + generateArgList(rng, depth, argc) + ")"
}

func randomValidArgCount(rng *rand.Rand, rule spec.FunctionSpec) int {
	if rule.MinArgs == rule.MaxArgs {
		return rule.MinArgs
	}
	if rule.MaxArgs == -1 {
		upper := rule.MinArgs + 4
		candidates := make([]int, 0, upper-rule.MinArgs+1)
		for n := rule.MinArgs; n <= upper; n++ {
			if rule.Predicate != nil {
				if err := rule.Predicate(n); err != nil {
					continue
				}
			}
			candidates = append(candidates, n)
		}
		return candidates[rng.Intn(len(candidates))]
	}
	candidates := make([]int, 0, rule.MaxArgs-rule.MinArgs+1)
	for n := rule.MinArgs; n <= rule.MaxArgs; n++ {
		if rule.Predicate != nil {
			if err := rule.Predicate(n); err != nil {
				continue
			}
		}
		candidates = append(candidates, n)
	}
	return candidates[rng.Intn(len(candidates))]
}

var identifierPool = []string{
	"Amount", "Description", "Phone", "Email", "IsActive", "Stage", "Status",
}

func pickIdentifier(rng *rand.Rand) string {
	return identifierPool[rng.Intn(len(identifierPool))]
}

var relationPool = []string{"Account", "Owner", "Contact", "Opportunity"}

func pickRelation(rng *rand.Rand) string {
	return relationPool[rng.Intn(len(relationPool))]
}

var fieldPool = []string{"Name", "Email", "IsActive", "Region", "Phone"}

func pickField(rng *rand.Rand) string {
	return fieldPool[rng.Intn(len(fieldPool))]
}

var binaryOps = []string{"==", "!=", "<", ">", "<=", ">=", "+", "-", "*", "&&", "||"}

func pickBinaryOp(rng *rand.Rand) string {
	return binaryOps[rng.Intn(len(binaryOps))]
}

func randomLetters(rng *rand.Rand, n int) string {
	const alpha = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
	b := make([]byte, n)
	for i := 0; i < n; i++ {
		b[i] = alpha[rng.Intn(len(alpha))]
	}
	return string(b)
}
