package formula

import (
	"fmt"
	"reflect"
	"strings"
	"testing"

	"github.com/nexuscrm/kernel/pkg/utils"
)

// TestToSQLByteParityGolden is a CHARACTERIZATION golden that byte-pins the
// CURRENT output of the kernel formula ToSQL emitter (engine.go ToSQL /
// ToSQLWithMapper / ToSQLWithOptions and the pkg/expression SQL walker it
// delegates to). It exists as the oracle for L0-F: a later carve that moves the
// by-name INCLUDES SQL branch (engine.go:441 -> lowerINCLUDESExpressionSQL ->
// lowerINCLUDESMultiTokenSQL) into a registered SQL-lowering hook MUST reproduce
// every row here byte-for-byte, and PR-A/PR-B must prove no collateral drift in
// the base-function catalog lowering.
//
// DISCIPLINE: this pins behavior as it is TODAY, warts included. Where the
// current output looks surprising it is flagged with an "ODDITY:" comment and
// captured as-is, NOT normalized. Do not "fix" a row to make it prettier — a
// characterization change is a real behavior change and belongs in the carve PR
// with its own justification, not here.
//
// Reachability notes (see report):
//   - The compiled-arg (non-string-literal) INCLUDES RHS branch
//     (lowerINCLUDESExpressionSQL:467) IS reachable from ToSQL: an integer
//     literal RHS, an identifier RHS, and a no-semicolon string RHS all take it.
//   - The internal arity guard `len(ctx.Args) != 2` at
//     lowerINCLUDESExpressionSQL:457 is UNREACHABLE from ToSQL: the executable
//     registry runs definition.Spec().Validate(argc) first
//     (engine.go:438), so a wrong-arity INCLUDES fails with the spec message
//     "INCLUDES expects 2 args, got N" before the by-name branch is entered.
//     Both are pinned as error rows below.

// toSQLGoldenRow is one characterization row: an expression, the options it is
// compiled under, and the exact SQL + args (or error) the emitter produces today.
type toSQLGoldenRow struct {
	name string
	expr string
	// mapper, when non-nil, is passed as SQLOptions.Mapper. A deterministic
	// identity-with-backticks mapper keeps the emitted SQL stable and readable.
	mapper func(string) string
	// allowMemberPaths mirrors SQLOptions.AllowMemberPaths for the relationship
	// member-path row.
	allowMemberPaths bool
	// useMapperEntrypoint routes the row through ToSQLWithMapper instead of
	// ToSQLWithOptions, pinning that entrypoint too.
	useMapperEntrypoint bool

	wantSQL  string
	wantArgs []interface{}

	// wantErrContains, when non-empty, asserts an error whose message contains
	// this substring and asserts the row produced no SQL. Used for both INCLUDES
	// error branches and the eval-only catalog functions that have no ToSQL
	// lowering.
	wantErrContains string
}

// bt is the deterministic identifier mapper used by every row: it wraps the
// identifier in backticks. No map iteration, so output is stable.
func bt(id string) string { return "`" + id + "`" }

// jc is the single-token multi-picklist membership predicate the emitter
// produces via utils.MultiPicklistJSONContainsSQL(field, value, true). Building
// the golden expectation from the same helper keeps the row readable while still
// byte-pinning the concrete SQL (the helper output is asserted verbatim by the
// utils package's own tests; the literal form is reproduced in the report).
func jc(fieldSQL, valueSQL string) string {
	return utils.MultiPicklistJSONContainsSQL(fieldSQL, valueSQL, true)
}

// jcAll wraps the all-token AND form exactly as
// utils.MultiPicklistJSONContainsAllSQL does (single predicate is returned
// unwrapped; >=2 predicates are AND-joined inside parens).
func jcAll(fieldSQL string, tokenSQLs ...string) string {
	return utils.MultiPicklistJSONContainsAllSQL(fieldSQL, tokenSQLs)
}

func toSQLGoldenRows() []toSQLGoldenRow {
	return []toSQLGoldenRow{
		// ------------------------------------------------------------------
		// INCLUDES — the carve target. Exhaustive over both RHS forms and the
		// documented edge cases. All resolve INCLUDES through the executable
		// runtime bundle (registered in the test body), exactly as production
		// does.
		// ------------------------------------------------------------------

		// String-literal RHS, NO semicolon -> single-literal COMPILED-ARG path
		// (lowerINCLUDESExpressionSQL:467). RHS becomes a bound "?" arg.
		{
			name:     "INCLUDES/single_token_string_literal",
			expr:     `INCLUDES(Regions__c, "North")`,
			mapper:   bt,
			wantSQL:  jc("`Regions__c`", "?"),
			wantArgs: []interface{}{"North"},
		},

		// String-literal RHS WITH semicolons (>=2 tokens) -> MULTI-TOKEN path
		// (lowerINCLUDESMultiTokenSQL). AND-joined, one "?" per token, tokens
		// trimmed+deduped by engineDedupeTrimmed.
		{
			name:     "INCLUDES/multi_token_three",
			expr:     `INCLUDES(Regions__c, "North;East;West")`,
			mapper:   bt,
			wantSQL:  jcAll("`Regions__c`", "?", "?", "?"),
			wantArgs: []interface{}{"North", "East", "West"},
		},
		{
			name:     "INCLUDES/multi_token_two",
			expr:     `INCLUDES(Regions__c, "North;East")`,
			mapper:   bt,
			wantSQL:  jcAll("`Regions__c`", "?", "?"),
			wantArgs: []interface{}{"North", "East"},
		},

		// Compiled-arg RHS: bare identifier (dynamic column). No bound args; the
		// RHS column SQL is substituted directly into JSON_QUOTE(...).
		{
			name:     "INCLUDES/dynamic_identifier_rhs",
			expr:     `INCLUDES(Regions__c, WantedTag__c)`,
			mapper:   bt,
			wantSQL:  jc("`Regions__c`", "`WantedTag__c`"),
			wantArgs: []interface{}{},
		},

		// Compiled-arg RHS: integer literal. ODDITY: INCLUDES is declared as
		// string/string in the bundle spec, but ToSQL does not type-check the
		// compiled RHS, so a numeric literal flows straight through as an integer
		// bound arg. Captured as-is.
		{
			name:     "INCLUDES/integer_literal_rhs_compiled_arg",
			expr:     `INCLUDES(Regions__c, 42)`,
			mapper:   bt,
			wantSQL:  jc("`Regions__c`", "?"),
			wantArgs: []interface{}{42},
		},

		// String-literal RHS with NO semicolon but surrounding whitespace ->
		// single-literal compiled-arg path. ODDITY: whitespace is NOT trimmed on
		// this path (contrast the multi-token path, which trims each token).
		{
			name:     "INCLUDES/single_literal_whitespace_not_trimmed",
			expr:     `INCLUDES(Regions__c, "  North  ")`,
			mapper:   bt,
			wantSQL:  jc("`Regions__c`", "?"),
			wantArgs: []interface{}{"  North  "},
		},

		// String-literal RHS that is empty -> no semicolon -> single-literal
		// compiled-arg path with an empty-string bound arg.
		{
			name:     "INCLUDES/empty_string_rhs_compiled_arg",
			expr:     `INCLUDES(Regions__c, "")`,
			mapper:   bt,
			wantSQL:  jc("`Regions__c`", "?"),
			wantArgs: []interface{}{""},
		},

		// RHS is exactly ";" -> HAS a semicolon so it takes the multi-token
		// path, but splitting+trimming+dedup yields ZERO tokens ->
		// MultiPicklistJSONContainsAllSQL(field, nil) == "0 = 1". ODDITY:
		// a semicolon-only RHS compiles to a constant-false predicate with no
		// args. Captured as-is.
		{
			name:     "INCLUDES/semicolon_only_rhs_is_constant_false",
			expr:     `INCLUDES(Regions__c, ";")`,
			mapper:   bt,
			wantSQL:  "0 = 1",
			wantArgs: []interface{}{},
		},

		// RHS "North;;East" -> empty middle token dropped by dedupeTrimmed -> 2
		// tokens.
		{
			name:     "INCLUDES/multi_token_empty_middle_dropped",
			expr:     `INCLUDES(Regions__c, "North;;East")`,
			mapper:   bt,
			wantSQL:  jcAll("`Regions__c`", "?", "?"),
			wantArgs: []interface{}{"North", "East"},
		},

		// RHS "North; East ;West" -> each token trimmed -> 3 clean tokens.
		{
			name:     "INCLUDES/multi_token_trimmed",
			expr:     `INCLUDES(Regions__c, "North; East ;West")`,
			mapper:   bt,
			wantSQL:  jcAll("`Regions__c`", "?", "?", "?"),
			wantArgs: []interface{}{"North", "East", "West"},
		},

		// RHS "North;North" -> HAS a semicolon so multi-token path, but dedup
		// collapses to ONE token. MultiPicklistJSONContainsAllSQL returns the
		// single predicate UNWRAPPED (no parens), so this is byte-identical to
		// the single-token literal form above. Captured to pin the dedup+unwrap.
		{
			name:     "INCLUDES/multi_token_deduped_to_single_unwrapped",
			expr:     `INCLUDES(Regions__c, "North;North")`,
			mapper:   bt,
			wantSQL:  jc("`Regions__c`", "?"),
			wantArgs: []interface{}{"North"},
		},

		// Field arg is ALSO a string literal with a semicolon. ODDITY: the field
		// fragment is compiled once, and lowerINCLUDESMultiTokenSQL re-appends
		// field.Args for EVERY RHS token, so the field literal ("North;East")
		// appears as a bound arg once per token: args = [field, tok0, field,
		// tok1]. Also the field's own semicolon does NOT trigger token-splitting
		// (only the RHS is split). Pins the field-args duplication.
		{
			name:    "INCLUDES/field_literal_args_duplicated_per_token",
			expr:    `INCLUDES("North;East", "North;East")`,
			mapper:  bt,
			wantSQL: jcAll("?", "?", "?"),
			wantArgs: []interface{}{
				"North;East", "North",
				"North;East", "East",
			},
		},

		// Relationship member path RHS via AllowMemberPaths + mapper. Pins the
		// ToSQLWithOptions member-path plumbing on the INCLUDES branch.
		{
			name:             "INCLUDES/member_path_field",
			expr:             `INCLUDES(Account.Regions__c, "North")`,
			allowMemberPaths: true,
			mapper: func(id string) string {
				return "`j_account`.`Regions__c`"
			},
			wantSQL:  jc("`j_account`.`Regions__c`", "?"),
			wantArgs: []interface{}{"North"},
		},

		// INCLUDES error branch: wrong arity (1). Pins that the SPEC arity gate
		// (engine.go:438), not the internal len!=2 guard, is what fires.
		{
			name:            "INCLUDES/error_arity_one",
			expr:            `INCLUDES(Regions__c)`,
			mapper:          bt,
			wantErrContains: "INCLUDES expects 2 args, got 1",
		},
		// INCLUDES error branch: wrong arity (3).
		{
			name:            "INCLUDES/error_arity_three",
			expr:            `INCLUDES(Regions__c, "A", "B")`,
			mapper:          bt,
			wantErrContains: "INCLUDES expects 2 args, got 3",
		},

		// One INCLUDES row through the ToSQLWithMapper entrypoint (not
		// ToSQLWithOptions) to pin that path end-to-end.
		{
			name:                "INCLUDES/via_ToSQLWithMapper_entrypoint",
			expr:                `INCLUDES(Regions__c, "North")`,
			mapper:              bt,
			useMapperEntrypoint: true,
			wantSQL:             jc("`Regions__c`", "?"),
			wantArgs:            []interface{}{"North"},
		},

		// ------------------------------------------------------------------
		// Base catalog breadth. One representative row per base-function SQL
		// switch case in the pkg/expression walker, plus operator / null /
		// concatenation forms. These MUST NOT drift when the INCLUDES branch is
		// carved out — that is the point of pinning them here.
		// ------------------------------------------------------------------

		{name: "base/UPPER", expr: `UPPER(Name)`, mapper: bt, wantSQL: "UPPER(`Name`)", wantArgs: []interface{}{}},
		{name: "base/LOWER", expr: `LOWER(Name)`, mapper: bt, wantSQL: "LOWER(`Name`)", wantArgs: []interface{}{}},
		{name: "base/LEN_to_CHAR_LENGTH", expr: `LEN(Name)`, mapper: bt, wantSQL: "CHAR_LENGTH(`Name`)", wantArgs: []interface{}{}},
		{name: "base/IF", expr: `IF(Amount > 0, 1, 2)`, mapper: bt, wantSQL: "IF((`Amount` > ?), ?, ?)", wantArgs: []interface{}{0, 1, 2}},
		{name: "base/TODAY_to_CURDATE", expr: `TODAY()`, mapper: bt, wantSQL: "CURDATE()", wantArgs: []interface{}{}},
		{name: "base/NOW", expr: `NOW()`, mapper: bt, wantSQL: "NOW()", wantArgs: []interface{}{}},
		{name: "base/DATE_ADD", expr: `DATE_ADD(CreatedDate, 5)`, mapper: bt, wantSQL: "DATE(DATE_ADD(`CreatedDate`, INTERVAL ? DAY))", wantArgs: []interface{}{5}},
		{
			name:   "base/DATE_ADD_MONTHS_sf_clamp",
			expr:   `__DATE_ADD_MONTHS__(CreatedDate, 3)`,
			mapper: bt,
			wantSQL: "DATE(CASE WHEN DAY(`CreatedDate`) = DAY(LAST_DAY(`CreatedDate`)) " +
				"THEN LAST_DAY(DATE_ADD(`CreatedDate`, INTERVAL ? MONTH)) " +
				"ELSE DATE_ADD(`CreatedDate`, INTERVAL ? MONTH) END)",
			wantArgs: []interface{}{3, 3},
		},
		{name: "base/DATE_ADD_MONTHS_target_clamp", expr: `__DATE_ADD_MONTHS_TARGET_CLAMP__(CreatedDate, 3)`, mapper: bt, wantSQL: "DATE(DATE_ADD(`CreatedDate`, INTERVAL ? MONTH))", wantArgs: []interface{}{3}},
		{name: "base/DATE_DIFF_DAYS", expr: `__DATE_DIFF_DAYS__(CloseDate, CreatedDate)`, mapper: bt, wantSQL: "DATEDIFF(`CloseDate`, `CreatedDate`)", wantArgs: []interface{}{}},
		{name: "base/CONTAINS_fn_to_LIKE", expr: `CONTAINS(Name, "foo")`, mapper: bt, wantSQL: "`Name` LIKE ?", wantArgs: []interface{}{"%foo%"}},
		{name: "base/STARTS_WITH_fn_to_LIKE", expr: `STARTS_WITH(Name, "foo")`, mapper: bt, wantSQL: "`Name` LIKE ?", wantArgs: []interface{}{"foo%"}},
		{name: "base/ENDS_WITH_fn_to_LIKE", expr: `ENDS_WITH(Name, "foo")`, mapper: bt, wantSQL: "`Name` LIKE ?", wantArgs: []interface{}{"%foo"}},
		{name: "base/TRUTHY", expr: `TRUTHY(IsActive)`, mapper: bt, wantSQL: "(`IsActive` != 0)", wantArgs: []interface{}{}},
		{name: "base/NOT_fn", expr: `NOT(IsActive)`, mapper: bt, wantSQL: "NOT(`IsActive`)", wantArgs: []interface{}{}},
		{name: "base/AND_variadic", expr: `AND(A, B)`, mapper: bt, wantSQL: "(`A` AND `B`)", wantArgs: []interface{}{}},
		{name: "base/OR_variadic", expr: `OR(A, B)`, mapper: bt, wantSQL: "(`A` OR `B`)", wantArgs: []interface{}{}},
		{name: "base/ISBLANK", expr: `ISBLANK(Name)`, mapper: bt, wantSQL: "(`Name` IS NULL OR CAST(`Name` AS CHAR) = '')", wantArgs: []interface{}{}},
		{
			name:     "base/FLOWISBLANK",
			expr:     `FLOWISBLANK(Name)`,
			mapper:   bt,
			wantSQL:  "(`Name` IS NULL OR REPLACE(REPLACE(REPLACE(REPLACE(CAST(`Name` AS CHAR), CHAR(9), ''), CHAR(10), ''), CHAR(13), ''), ' ', '') = '')",
			wantArgs: []interface{}{},
		},
		{name: "base/ISNULL_fn", expr: `ISNULL(Name)`, mapper: bt, wantSQL: "(`Name` IS NULL)", wantArgs: []interface{}{}},
		{name: "base/NULLVALUE", expr: `NULLVALUE(Name, "x")`, mapper: bt, wantSQL: "COALESCE(NULLIF(CAST(`Name` AS CHAR), ''), ?)", wantArgs: []interface{}{"x"}},
		{name: "base/CASE", expr: `CASE(Stage, "a", 1, "b", 2, 9)`, mapper: bt, wantSQL: "CASE `Stage` WHEN ? THEN ? WHEN ? THEN ? ELSE ? END", wantArgs: []interface{}{"a", 1, "b", 2, 9}},
		{name: "base/YEAR", expr: `YEAR(CreatedDate)`, mapper: bt, wantSQL: "YEAR(`CreatedDate`)", wantArgs: []interface{}{}},
		{name: "base/MONTH", expr: `MONTH(CreatedDate)`, mapper: bt, wantSQL: "MONTH(`CreatedDate`)", wantArgs: []interface{}{}},
		{name: "base/DAY", expr: `DAY(CreatedDate)`, mapper: bt, wantSQL: "DAY(`CreatedDate`)", wantArgs: []interface{}{}},
		{
			name:    "base/LPAD",
			expr:    `LPAD(Name, 5, "*")`,
			mapper:  bt,
			wantSQL: "IF(? IS NULL OR ? IS NULL OR ? = '', NULL, IF(? <= 0, '', LPAD(COALESCE(`Name`, ''), ?, ?)))",
			// ODDITY: LPAD's guards re-emit args 1 and 2 multiple times, so the
			// literals 5 and "*" appear several times in wantArgs.
			wantArgs: []interface{}{5, "*", "*", 5, 5, "*"},
		},
		{
			name:     "base/RPAD",
			expr:     `RPAD(Name, 5, "*")`,
			mapper:   bt,
			wantSQL:  "IF(? IS NULL OR ? IS NULL OR ? = '', NULL, IF(? <= 0, '', RPAD(COALESCE(`Name`, ''), ?, ?)))",
			wantArgs: []interface{}{5, "*", "*", 5, 5, "*"},
		},

		// Operator / null / literal forms handled directly by the walker.
		{name: "op/eq_null_is_null", expr: `Name == null`, mapper: bt, wantSQL: "(`Name` IS NULL)", wantArgs: []interface{}{}},
		{name: "op/neq_null_is_not_null", expr: `Name != null`, mapper: bt, wantSQL: "(`Name` IS NOT NULL)", wantArgs: []interface{}{}},
		{name: "op/and_lt_gt", expr: `Amount > 0 && Amount < 100`, mapper: bt, wantSQL: "((`Amount` > ?) AND (`Amount` < ?))", wantArgs: []interface{}{0, 100}},
		{name: "op/or_eq", expr: `Name == "x" || Name == "y"`, mapper: bt, wantSQL: "((`Name` = ?) OR (`Name` = ?))", wantArgs: []interface{}{"x", "y"}},
		{name: "op/in_array", expr: `Name in ["a", "b"]`, mapper: bt, wantSQL: "(`Name` IN (?, ?))", wantArgs: []interface{}{"a", "b"}},
		{name: "op/contains_operator_to_like", expr: `Name contains "foo"`, mapper: bt, wantSQL: "(`Name` LIKE ?)", wantArgs: []interface{}{"%foo%"}},
		{name: "op/startsWith_operator_to_like", expr: `Name startsWith "foo"`, mapper: bt, wantSQL: "(`Name` LIKE ?)", wantArgs: []interface{}{"foo%"}},
		{name: "op/endsWith_operator_to_like", expr: `Name endsWith "foo"`, mapper: bt, wantSQL: "(`Name` LIKE ?)", wantArgs: []interface{}{"%foo"}},
		{name: "op/matches_to_regexp", expr: `Name matches "^[A-Z]+$"`, mapper: bt, wantSQL: "(`Name` REGEXP ?)", wantArgs: []interface{}{"^[A-Z]+$"}},
		{name: "op/arithmetic_plus", expr: `Amount + 1`, mapper: bt, wantSQL: "(`Amount` + ?)", wantArgs: []interface{}{1}},
		// ODDITY: the "&" Salesforce concatenation operator is normalized to "+"
		// and emitted as a numeric-plus, so string concatenation compiles to
		// "(`Name` + ?)". Captured as-is.
		{name: "op/ampersand_concat_becomes_plus", expr: `Name & "x"`, mapper: bt, wantSQL: "(`Name` + ?)", wantArgs: []interface{}{"x"}},

		// nil mapper: identifiers pass through unmapped (no backticks).
		{name: "mapper/nil_mapper_passthrough", expr: `UPPER(Name)`, mapper: nil, wantSQL: "UPPER(Name)", wantArgs: []interface{}{}},

		// ------------------------------------------------------------------
		// Eval-only catalog functions: declared in spec.defaultSpecs but with NO
		// ToSQL lowering in the walker switch. They pass the arity gate and then
		// hit the walker's default "unsupported function" arm. Pinned as error
		// rows so a future carve that accidentally teaches one of them a SQL form
		// (or drops the error) is caught. See report for the full list.
		// ------------------------------------------------------------------
		{name: "evalonly/ROUND", expr: `ROUND(Amount, 2)`, mapper: bt, wantErrContains: "unsupported function: ROUND"},
		{name: "evalonly/ABS", expr: `ABS(Amount)`, mapper: bt, wantErrContains: "unsupported function: ABS"},
		{name: "evalonly/CEILING", expr: `CEILING(Amount)`, mapper: bt, wantErrContains: "unsupported function: CEILING"},
		{name: "evalonly/MOD", expr: `MOD(Amount, 2)`, mapper: bt, wantErrContains: "unsupported function: MOD"},
		{name: "evalonly/TEXT", expr: `TEXT(Amount)`, mapper: bt, wantErrContains: "unsupported function: TEXT"},
		{name: "evalonly/LEFT", expr: `LEFT(Name, 2)`, mapper: bt, wantErrContains: "unsupported function: LEFT"},
		{name: "evalonly/RIGHT", expr: `RIGHT(Name, 2)`, mapper: bt, wantErrContains: "unsupported function: RIGHT"},
		{name: "evalonly/MID", expr: `MID(Name, 2, 3)`, mapper: bt, wantErrContains: "unsupported function: MID"},
		{name: "evalonly/DATE", expr: `DATE(2026, 1, 1)`, mapper: bt, wantErrContains: "unsupported function: DATE"},
		{name: "evalonly/DATEVALUE", expr: `DATEVALUE(CreatedDate)`, mapper: bt, wantErrContains: "unsupported function: DATEVALUE"},
		{name: "evalonly/DATETIMEVALUE", expr: `DATETIMEVALUE(CreatedDate)`, mapper: bt, wantErrContains: "unsupported function: DATETIMEVALUE"},
		{name: "evalonly/DATETIMEFROMEPOCHMILLIS", expr: `DATETIMEFROMEPOCHMILLIS(OccurredAt)`, mapper: bt, wantErrContains: "unsupported function: DATETIMEFROMEPOCHMILLIS"},
		{name: "evalonly/SUBSTITUTE", expr: `SUBSTITUTE(Name, "a", "b")`, mapper: bt, wantErrContains: "unsupported function: SUBSTITUTE"},
		{name: "evalonly/BEGINS", expr: `BEGINS(Name, "foo")`, mapper: bt, wantErrContains: "unsupported function: BEGINS"},
		{name: "evalonly/FIND", expr: `FIND("x", Name)`, mapper: bt, wantErrContains: "unsupported function: FIND"},
		{name: "evalonly/BLANKVALUE", expr: `BLANKVALUE(Name, "x")`, mapper: bt, wantErrContains: "unsupported function: BLANKVALUE"},
	}
}

func TestToSQLByteParityGolden(t *testing.T) {
	// INCLUDES is a CRM L1 runtime bundle, not a default-catalog function, so it
	// must be registered into the process-global runtime registry for the
	// by-name ToSQL branch to be reached — mirroring production startup.
	if err := RegisterRuntimeFunctionBundles(nil); err != nil {
		t.Fatalf("clear runtime bundles: %v", err)
	}
	t.Cleanup(func() { _ = RegisterRuntimeFunctionBundles(nil) })
	if err := RegisterRuntimeFunctionBundles([]FunctionBundle{testIncludesFunctionBundle(t)}); err != nil {
		t.Fatalf("RegisterRuntimeFunctionBundles INCLUDES: %v", err)
	}

	for _, row := range toSQLGoldenRows() {
		row := row
		t.Run(row.name, func(t *testing.T) {
			var (
				gotSQL  string
				gotArgs []interface{}
				err     error
			)
			if row.useMapperEntrypoint {
				gotSQL, gotArgs, err = ToSQLWithMapper(row.expr, row.mapper)
			} else {
				gotSQL, gotArgs, err = ToSQLWithOptions(row.expr, SQLOptions{
					Mapper:           row.mapper,
					AllowMemberPaths: row.allowMemberPaths,
				})
			}

			if row.wantErrContains != "" {
				if err == nil {
					t.Fatalf("expr %q: expected error containing %q, got nil (sql=%q args=%#v)",
						row.expr, row.wantErrContains, gotSQL, gotArgs)
				}
				if !strings.Contains(err.Error(), row.wantErrContains) {
					t.Fatalf("expr %q: error = %q, want substring %q", row.expr, err.Error(), row.wantErrContains)
				}
				if gotSQL != "" {
					t.Fatalf("expr %q: expected empty SQL on error, got %q", row.expr, gotSQL)
				}
				return
			}

			if err != nil {
				t.Fatalf("expr %q: unexpected error: %v", row.expr, err)
			}
			if gotSQL != row.wantSQL {
				t.Fatalf("expr %q SQL mismatch:\n got: %s\nwant: %s", row.expr, gotSQL, row.wantSQL)
			}
			if !argsEqual(gotArgs, row.wantArgs) {
				t.Fatalf("expr %q args mismatch:\n got: %s\nwant: %s",
					row.expr, formatArgs(gotArgs), formatArgs(row.wantArgs))
			}
		})
	}
}

// argsEqual compares two []interface{} arg slices, treating nil and empty as
// equal (the emitter returns a non-nil empty slice for zero-arg SQL).
func argsEqual(got, want []interface{}) bool {
	if len(got) == 0 && len(want) == 0 {
		return true
	}
	return reflect.DeepEqual(got, want)
}

func formatArgs(args []interface{}) string {
	parts := make([]string, len(args))
	for i, a := range args {
		parts[i] = fmt.Sprintf("%#v", a)
	}
	return "[" + strings.Join(parts, ", ") + "]"
}
