package analytics

import (
	"sort"
	"strings"
	"testing"

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

// TestActiveFormulaArityParity asserts the active-formula DSL compiler enforces
// the SAME function arity contract as the frozen-formula evaluators, because
// both now read it from the single source of truth in pkg/formula/spec (VS3,
// issue #1467). Before this cutover the compiler carried its own inline arity
// branches (TODAY/NOW ignored extra args; IMAGE/ROUND/DATE accepted a
// 1-arg form), so the same shared function behaved differently in frozen vs
// active mode — a parallel source of truth that CLAUDE.md registry discipline
// forbids.
//
// The check happens in Compile (compileFuncCall -> spec.CheckArity), so each
// case Parses a syntactically valid call and asserts whether Compile accepts or
// rejects it on arity grounds.
func TestActiveFormulaArityParity(t *testing.T) {
	tests := []struct {
		name       string
		formula    string
		wantReject bool
	}{
		// TODAY / NOW are 0-arg; extra args used to be silently ignored.
		{name: "TODAY zero args accepted", formula: `TODAY()`, wantReject: false},
		{name: "NOW zero args accepted", formula: `NOW()`, wantReject: false},
		{name: "TODAY with extra arg rejected", formula: `TODAY(Subject)`, wantReject: true},
		{name: "NOW with extra arg rejected", formula: `NOW(Subject)`, wantReject: true},

		// ROUND is exactly 2 args; the 1-arg form used to be accepted.
		{name: "ROUND two args accepted", formula: `ROUND(Amount, 2)`, wantReject: false},
		{name: "ROUND one arg rejected below minimum", formula: `ROUND(Amount)`, wantReject: true},

		// DATE is exactly 3 args; the 1-arg form used to be accepted.
		{name: "DATE three args accepted", formula: `DATE(2024, 1, 31)`, wantReject: false},
		{name: "DATE one arg rejected", formula: `DATE(CreatedDate)`, wantReject: true},

		// MOD / DATE_ADD were previously routed through the generic passthrough
		// (which accepts any argument count); now they have explicit cases that
		// enforce the catalog arity of exactly 2.
		{name: "MOD two args accepted", formula: `MOD(Amount, 3)`, wantReject: false},
		{name: "MOD one arg rejected", formula: `MOD(Amount)`, wantReject: true},
		{name: "MOD three args rejected", formula: `MOD(Amount, 3, 1)`, wantReject: true},
		{name: "DATE_ADD two args accepted", formula: `DATE_ADD(CloseDate, 7)`, wantReject: false},
		{name: "DATE_ADD one arg rejected", formula: `DATE_ADD(CloseDate)`, wantReject: true},
		{name: "DATE_ADD three args rejected", formula: `DATE_ADD(CloseDate, 7, 1)`, wantReject: true},

		// A representative fixed-arity shared function: LEN is exactly 1.
		{name: "LEN one arg accepted", formula: `LEN(Subject)`, wantReject: false},
		{name: "LEN two args rejected above maximum", formula: `LEN(Subject, Subject)`, wantReject: true},

		// FIND is 2-3; both bounds exercised.
		{name: "FIND two args accepted", formula: `FIND("x", Subject)`, wantReject: false},
		{name: "FIND three args accepted", formula: `FIND("x", Subject, 2)`, wantReject: false},
		{name: "FIND one arg rejected below minimum", formula: `FIND("x")`, wantReject: true},
		{name: "FIND four args rejected above maximum", formula: `FIND("x", Subject, 2, 3)`, wantReject: true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ast, err := Parse(tt.formula)
			if err != nil {
				t.Fatalf("Parse(%q) error = %v", tt.formula, err)
			}

			sql, err := Compile(ast, &CompileContext{
				TableAlias:    "t",
				QualifyFields: true,
			})

			if tt.wantReject {
				if err == nil {
					t.Fatalf("Compile(%q) accepted an invalid arity, want rejection; sql=%q", tt.formula, sql)
				}
				return
			}
			if err != nil {
				t.Fatalf("Compile(%q) rejected a valid arity: %v", tt.formula, err)
			}
			if strings.TrimSpace(sql) == "" {
				t.Fatalf("Compile(%q) produced empty SQL", tt.formula)
			}
		})
	}
}

// TestActiveFormulaSubstituteInstanceUnsupported documents that the 4-arg
// SUBSTITUTE form is a valid arity per the shared catalog (3-4) but is not
// lowerable to TiDB REPLACE(), so the active compiler rejects it explicitly
// rather than silently dropping the instance-number argument. This keeps arity
// as a single catalog-owned fact while making the compiler's capability gap
// loud instead of silent.
func TestActiveFormulaSubstituteInstanceUnsupported(t *testing.T) {
	ast, err := Parse(`SUBSTITUTE(Subject, "a", "b", 2)`)
	if err != nil {
		t.Fatalf("Parse error = %v", err)
	}
	if _, err := Compile(ast, &CompileContext{TableAlias: "t", QualifyFields: true}); err == nil {
		t.Fatalf("Compile accepted 4-arg SUBSTITUTE, want explicit unsupported error")
	}

	// The 3-arg form remains supported.
	ast3, err := Parse(`SUBSTITUTE(Subject, "a", "b")`)
	if err != nil {
		t.Fatalf("Parse error = %v", err)
	}
	if _, err := Compile(ast3, &CompileContext{TableAlias: "t", QualifyFields: true}); err != nil {
		t.Fatalf("Compile rejected valid 3-arg SUBSTITUTE: %v", err)
	}
}

// TestActiveFormulaMODDATEADDLowering asserts the MOD / DATE_ADD passthrough
// residue is fixed: both are now compiled by explicit cases (not the generic
// passthrough), and DATE_ADD(date, days) lowers to the TiDB INTERVAL form
// instead of a raw DATE_ADD(date, days) that MySQL rejects.
func TestActiveFormulaMODDATEADDLowering(t *testing.T) {
	cases := []struct {
		name       string
		formula    string
		fieldTypes map[string]string
		want       string
	}{
		{
			name:    "MOD lowers to native MOD",
			formula: `MOD(Amount, 3)`,
			want:    "MOD(t.`Amount`, 3)",
		},
		{
			name:       "DATE_ADD keeps known Date hot path direct",
			formula:    `DATE_ADD(CloseDate, 7)`,
			fieldTypes: map[string]string{"CloseDate": "date"},
			want:       "DATE_ADD(t.`CloseDate`, INTERVAL (7) DAY)",
		},
		{
			name:       "DATE_ADD normalizes DateTime to catalog Date",
			formula:    `DATE_ADD(CreatedDate, 7)`,
			fieldTypes: map[string]string{"CreatedDate": "datetime"},
			want:       "DATE(DATE_ADD(t.`CreatedDate`, INTERVAL (7) DAY))",
		},
		{
			name:       "target-clamp month add keeps known Date hot path direct",
			formula:    `__DATE_ADD_MONTHS_TARGET_CLAMP__(CloseDate, 1)`,
			fieldTypes: map[string]string{"CloseDate": "date"},
			want:       "DATE_ADD(t.`CloseDate`, INTERVAL 1 MONTH)",
		},
		{
			name:       "target-clamp month add normalizes DateTime to catalog Date",
			formula:    `__DATE_ADD_MONTHS_TARGET_CLAMP__(CreatedDate, 1)`,
			fieldTypes: map[string]string{"CreatedDate": "datetime"},
			want:       "DATE(DATE_ADD(t.`CreatedDate`, INTERVAL 1 MONTH))",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			ast, err := Parse(tc.formula)
			if err != nil {
				t.Fatalf("Parse(%q) error = %v", tc.formula, err)
			}
			got, err := Compile(ast, &CompileContext{TableAlias: "t", QualifyFields: true, FieldTypes: tc.fieldTypes})
			if err != nil {
				t.Fatalf("Compile(%q) error = %v", tc.formula, err)
			}
			if got != tc.want {
				t.Fatalf("Compile(%q) = %q, want %q", tc.formula, got, tc.want)
			}
		})
	}
}

// TestActiveFormulaNoLocalCatalogRedeclaration is the drift guard for VS3: it
// asserts the active DSL compiler holds NO local table that re-declares a
// catalog function's arity or return type. Every shared (cataloged) function
// must be classified by reading spec.DefaultRegistry, so the local residual
// tables — which exist only for active-formula-only functions with no catalog
// entry — must stay disjoint from the catalog.
//
// If a future edit adds a cataloged name to any of these local maps, the
// compiler would carry a second declaration of that catalog fact (the
// half-cutover the gate rejects); this test fails loudly when that happens.
func TestActiveFormulaNoLocalCatalogRedeclaration(t *testing.T) {
	catalog := make(map[string]bool)
	for _, name := range spec.DefaultRegistry().Names() {
		catalog[name] = true
	}

	localTables := map[string][]string{
		"dslOnlyReturnBlankKind": keysOf(dslOnlyReturnBlankKind),
		"dslOnlyDateLikeFuncs":   keysOfBool(dslOnlyDateLikeFuncs),
		"dslOnlyNumericFuncs":    keysOfBool(dslOnlyNumericFuncs),
		"dslPassthroughFuncs":    keysOfBool(dslPassthroughFuncs),
	}
	for table, names := range localTables {
		sort.Strings(names)
		for _, name := range names {
			if catalog[name] {
				t.Errorf("local table %s re-declares catalog function %q; cataloged functions must be resolved from spec, not a local table", table, name)
			}
		}
	}
}

// TestActiveFormulaReturnTypeParityWithCatalog asserts that for every fixed-
// return cataloged function, the active compiler's blank-operand classification
// (funcReturnBlankKind) matches the catalog's declared return type — string-like
// for TypeString, NULL-only for date/datetime/number/boolean. This proves the
// active classifier reads the SAME return-type fact as the frozen path, with no
// silent divergence for any shared function.
func TestActiveFormulaReturnTypeParityWithCatalog(t *testing.T) {
	for _, name := range spec.DefaultRegistry().Names() {
		fnSpec, _ := spec.DefaultRegistry().Lookup(name)
		if fnSpec.Return.Kind != spec.ReturnFixed {
			// Merge-kind returns (IF/CASE/NULLVALUE/BLANKVALUE) are operand-driven
			// in the blank classifier; their return contract is exercised by the
			// frozen-side merge parity tests, not the fixed-return path here.
			continue
		}
		t.Run(name, func(t *testing.T) {
			got, ok := funcReturnBlankKind(name, spec.DefaultRegistry())
			if !ok {
				t.Fatalf("funcReturnBlankKind(%q) reported no classification for a fixed-return catalog function", name)
			}
			want := blankKindForValueType(fnSpec.Return.Fixed)
			if got != want {
				t.Fatalf("funcReturnBlankKind(%q) = %v, want %v (catalog return %s)", name, got, want, fnSpec.Return.Fixed)
			}
		})
	}
}

// TestActiveFormulaFunctionSetReconciliationRecord is the VS4 reconciliation
// record for the active DSL compiler (issue #1471 scope item 3). It asserts that
// every function name the active compiler classifies in a local residual table
// is a genuine DSL-only function with NO shared catalog entry — i.e. the local
// tables and the catalog partition the function space with no overlap and no
// unclassified drift. Combined with the engine-side
// TestFunctionSetReconciliationRecord, this proves the post-VS4 invariant: no
// compiler re-declares any catalog function fact, and every name in a local
// table is accounted for as DSL-only-by-construction.
//
// Buckets:
//
//	(a) shared      — a catalog function; its arity + return type are read from
//	    spec.DefaultRegistry (never from a local table).
//	(b) DSL-only     — an active-compiler-only SQL helper / passthrough with no
//	    catalog entry, classified by the local residual tables below.
//
// The invariant: the union of the local DSL-only tables is disjoint from the
// catalog (no name is both shared and DSL-only). This is the same disjointness
// TestActiveFormulaNoLocalCatalogRedeclaration enforces per-table; here it is
// stated as the explicit reconciliation accounting the issue calls for.
func TestActiveFormulaFunctionSetReconciliationRecord(t *testing.T) {
	catalog := make(map[string]bool)
	for _, name := range spec.DefaultRegistry().Names() {
		catalog[name] = true
	}

	// (b) Every name across the local DSL-only tables must be DSL-only — i.e.
	// absent from the catalog. A name present in both buckets is the
	// half-cutover drift the gate rejects.
	dslOnlyTables := map[string][]string{
		"dslOnlyReturnBlankKind": keysOf(dslOnlyReturnBlankKind),
		"dslOnlyDateLikeFuncs":   keysOfBool(dslOnlyDateLikeFuncs),
		"dslOnlyNumericFuncs":    keysOfBool(dslOnlyNumericFuncs),
		"dslPassthroughFuncs":    keysOfBool(dslPassthroughFuncs),
	}

	dslOnlyUnion := make(map[string]bool)
	for table, names := range dslOnlyTables {
		for _, name := range names {
			if catalog[name] {
				t.Errorf("local table %s classifies %q as DSL-only, but it IS a catalog function; shared functions must be resolved from spec, never a local table", table, name)
			}
			dslOnlyUnion[name] = true
		}
	}

	// Sanity: the partition is non-degenerate — there is at least one catalog
	// (shared) function and at least one DSL-only function, so the test is
	// actually exercising both buckets rather than vacuously passing.
	if len(catalog) == 0 {
		t.Fatal("catalog is empty; reconciliation record cannot be validated")
	}
	if len(dslOnlyUnion) == 0 {
		t.Fatal("no DSL-only functions found; reconciliation record cannot be validated")
	}
}

func keysOf(m map[string]blankOperandKind) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	return out
}

func keysOfBool(m map[string]bool) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	return out
}
