package formula

import (
	"sort"
	"testing"

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

// TestFunctionPatternsDerivedFromCatalog is the VS4 drift guard for the
// frozen-path name-normalizer. Before VS4, normalizeFormulaExpression built its
// functionPatterns map from a hardcoded literal list of function names — a
// second, hand-maintained declaration of the function set that could silently
// drift from the spec catalog (the single source of truth). VS4 derives the set
// from spec.DefaultRegistry().Names() with two explicit, reasoned deltas:
// runtimeOnlyNoNormalizeFuncs (catalog functions never authored in raw input)
// and normalizeOnlySpecialForms (non-catalog Salesforce context predicates).
//
// This test re-derives the expected normalize set from the catalog + the two
// delta sets and asserts it matches the compiled functionPatterns exactly, so
// any future catalog edit that should change normalization is forced through the
// catalog (and the deltas) rather than a forgotten parallel list.
func TestFunctionPatternsDerivedFromCatalog(t *testing.T) {
	expected := make(map[string]bool)
	for _, name := range spec.DefaultRegistry().Names() {
		if runtimeOnlyNoNormalizeFuncs[name] {
			continue
		}
		expected[name] = true
	}
	for _, name := range normalizeOnlySpecialForms {
		expected[name] = true
	}

	actual := make(map[string]bool, len(functionPatterns))
	for name := range functionPatterns {
		actual[name] = true
	}

	for name := range expected {
		if !actual[name] {
			t.Errorf("functionPatterns is missing %q (catalog-derived name lost normalization)", name)
		}
	}
	for name := range actual {
		if !expected[name] {
			t.Errorf("functionPatterns contains %q with no catalog-derived justification (orphan normalization entry)", name)
		}
	}
}

func TestNewEngineReusesDefaultFunctionPatterns(t *testing.T) {
	engine := NewEngine()

	if len(engine.functionPatterns) != len(functionPatterns) {
		t.Fatalf("default engine functionPatterns len = %d; want shared default len %d", len(engine.functionPatterns), len(functionPatterns))
	}
	for name, pattern := range functionPatterns {
		if engine.functionPatterns[name] != pattern {
			t.Fatalf("default engine function pattern %q was recompiled; want package-global pattern reused", name)
		}
	}
}

func TestBuildFunctionPatternsEscapesInjectedFunctionNames(t *testing.T) {
	registry := spec.NewRegistry()
	if err := registry.Register(spec.FunctionSpec{
		Name:    "A+B",
		MinArgs: 0,
		MaxArgs: 0,
		Return:  spec.ReturnSpec{Kind: spec.ReturnFixed, Fixed: spec.TypeNumber},
	}); err != nil {
		t.Fatalf("Register A+B: %v", err)
	}

	patterns := buildFunctionPatterns(registry)
	if got := normalizeFormulaExpressionWithPatterns("AAAB()", patterns); got != "AAAB()" {
		t.Fatalf("normalize with metacharacter function pattern = %q; want unrelated identifier unchanged", got)
	}
	if got := normalizeFormulaExpressionWithPatterns("a+b()", patterns); got != "A+B()" {
		t.Fatalf("normalize with escaped metacharacter function pattern = %q; want A+B()", got)
	}
}

// TestFunctionSetReconciliationRecord is the VS4 reconciliation record (issue
// #1471 scope item 3). It asserts every function name that participates in the
// frozen-path normalize/runtime surface is accounted for in exactly one bucket,
// so no name can silently drift into a second, unclassified declaration:
//
//	(a) shared / normalized — a catalog function that IS case-normalized for raw
//	    Salesforce input (the catalog minus the runtime-only exclusions);
//	(b) runtime-only — a catalog function deliberately NOT normalized because the
//	    normalizer (or upstream) emits it already upper-case and a user never
//	    authors it in raw input (runtimeOnlyNoNormalizeFuncs);
//	(c) special-form — a non-catalog Salesforce context predicate the normalizer
//	    case-canonicalizes only so a dedicated rewrite regex can rewrite it to a
//	    sentinel variable (normalizeOnlySpecialForms).
//
// The accounting invariants:
//   - runtimeOnlyNoNormalizeFuncs ⊆ catalog (a pure exclusion — it must never
//     invent a function name that isn't in the catalog);
//   - normalizeOnlySpecialForms ∩ catalog = ∅ (special forms are NOT catalog
//     functions; if one ever became a real function it must move to the catalog);
//   - every catalog function is in exactly one of {normalized, runtime-only}.
func TestFunctionSetReconciliationRecord(t *testing.T) {
	catalog := make(map[string]bool)
	for _, name := range spec.DefaultRegistry().Names() {
		catalog[name] = true
	}

	// (b) runtime-only must be a strict subset of the catalog.
	for name := range runtimeOnlyNoNormalizeFuncs {
		if !catalog[name] {
			t.Errorf("runtimeOnlyNoNormalizeFuncs lists %q which is NOT a catalog function; the exclusion set must only remove catalog names, never declare new ones", name)
		}
	}

	// (c) special forms must be disjoint from the catalog.
	for _, name := range normalizeOnlySpecialForms {
		if catalog[name] {
			t.Errorf("normalizeOnlySpecialForms lists %q which IS a catalog function; a real function must be normalized via the catalog path, not the special-form list", name)
		}
	}

	// Every catalog function lands in exactly one of {normalized, runtime-only}.
	var unclassified []string
	for name := range catalog {
		normalized := !runtimeOnlyNoNormalizeFuncs[name]
		runtimeOnly := runtimeOnlyNoNormalizeFuncs[name]
		if normalized == runtimeOnly {
			// Both true or both false is impossible by construction, but assert
			// the invariant explicitly so a future refactor can't break it.
			unclassified = append(unclassified, name)
		}
	}
	sort.Strings(unclassified)
	if len(unclassified) > 0 {
		t.Errorf("catalog functions not classified into exactly one bucket: %v", unclassified)
	}
}
