package automation

// multipicklist_canonical_test.go — coverage for canonicalizeMultiPicklist,
// the single-entry-point boundary function for multi-picklist value shape
// handling on the field-aware comparison path (issue #902).
//
// schemautil.CanonicalMultiPicklistItems is the workhorse delegate; this
// suite pins the wrapper's field-type assertion and validates that every
// RHS shape the crossfire challenger flagged round-trips correctly.

import (
	"testing"

	"github.com/nexuscrm/kernel/pkg/schemautil"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

func multiPicklistField(apiName string) *models.FieldMetadata {
	return &models.FieldMetadata{
		APIName: apiName,
		Type:    models.FieldType(constants.FieldTypeMultiPicklist),
	}
}

func multiPicklistFieldWithOptions(apiName string, values ...string) *models.FieldMetadata {
	field := multiPicklistField(apiName)
	field.Options = make(models.PicklistOptions, 0, len(values))
	for _, value := range values {
		field.Options = append(field.Options, models.PicklistOption{Value: value, Label: value})
	}
	return field
}

func textField(apiName string) *models.FieldMetadata {
	return &models.FieldMetadata{
		APIName: apiName,
		Type:    models.FieldType(constants.FieldTypeString),
	}
}

func sortedEqual(t *testing.T, got []string, want []string) {
	t.Helper()
	if len(got) != len(want) {
		t.Fatalf("token count mismatch: got %v (n=%d), want %v (n=%d)", got, len(got), want, len(want))
	}
	gotSet := map[string]int{}
	for _, v := range got {
		gotSet[v]++
	}
	for _, v := range want {
		if gotSet[v] == 0 {
			t.Fatalf("expected token %q in result %v", v, got)
		}
		gotSet[v]--
	}
}

// TestCanonicalizeMultiPicklist_BareString_SemicolonSplit covers the
// admin-authored RHS shape: a `;`-joined literal from a flow definition.
func TestCanonicalizeMultiPicklist_BareString_SemicolonSplit(t *testing.T) {
	got, err := canonicalizeMultiPicklist("A; B; C", multiPicklistField("Subject"))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	sortedEqual(t, got, []string{"A", "B", "C"})
}

// TestCanonicalizeMultiPicklist_BareString_JSONDeclaredLiteral covers
// the #902 ambiguity: a bare string may be an admin-authored option
// value whose bytes happen to look like a JSON storage envelope. When
// metadata declares that exact option, it must stay literal.
func TestCanonicalizeMultiPicklist_BareString_JSONDeclaredLiteral(t *testing.T) {
	got, err := canonicalizeMultiPicklist(`["A"]`, multiPicklistFieldWithOptions("Subject", `["A"]`, "A"))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	sortedEqual(t, got, []string{`["A"]`})
}

// TestCanonicalizeMultiPicklist_BareString_JSONStorageWhenSelectionsDeclared
// keeps the storage-compatibility side of #902: if the JSON array's
// selections are declared options and the JSON-looking wrapper itself is
// not a declared option, a bare string remains compatible with the
// historical storage envelope shape.
func TestCanonicalizeMultiPicklist_BareString_JSONStorageWhenSelectionsDeclared(t *testing.T) {
	got, err := canonicalizeMultiPicklist(`["A","B"]`, multiPicklistFieldWithOptions("Subject", "A", "B"))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	sortedEqual(t, got, []string{"A", "B"})
}

// TestCanonicalizeMultiPicklist_BareString_JSONOptionlessLiteral is the
// fail-closed case: without declared selections, schema cannot prove a
// bare string is storage, so it remains the exact author-authored token.
func TestCanonicalizeMultiPicklist_BareString_JSONOptionlessLiteral(t *testing.T) {
	got, err := canonicalizeMultiPicklist(`["A"]`, multiPicklistField("Subject"))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	sortedEqual(t, got, []string{`["A"]`})
}

// TestCanonicalizeMultiPicklist_BytesJSON pins the crossfire R3 BLOCKER
// case: a DB-driver round-trip `[]byte` carrying a JSON array. Before
// the canonicalizer-at-boundary refactor, the designer's proposed
// includesRHSItems would have recursed into the bare-string branch and
// treated this as the literal token `["A"]`. Now it delegates to
// schemautil.CanonicalMultiPicklistItems converts []byte → string → JSON
// decode → ["A"] token.
func TestCanonicalizeMultiPicklist_BytesJSON(t *testing.T) {
	got, err := canonicalizeMultiPicklist([]byte(`["A","B"]`), multiPicklistField("Subject"))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	sortedEqual(t, got, []string{"A", "B"})
}

// TestCanonicalizeMultiPicklist_BytesSemicolon covers the other []byte
// shape: semicolon-joined payload (no JSON prefix).
func TestCanonicalizeMultiPicklist_BytesSemicolon(t *testing.T) {
	got, err := canonicalizeMultiPicklist([]byte("A; B"), multiPicklistField("Subject"))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	sortedEqual(t, got, []string{"A", "B"})
}

// TestCanonicalizeMultiPicklist_Nil — nil input is a legitimate empty
// set (not an error). The comparator short-circuits on empty.
func TestCanonicalizeMultiPicklist_Nil(t *testing.T) {
	got, err := canonicalizeMultiPicklist(nil, multiPicklistField("Subject"))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(got) != 0 {
		t.Fatalf("expected empty result, got %v", got)
	}
}

// TestCanonicalizeMultiPicklist_NonMultipicklistField_Errors — the
// field-type assertion. Caller passed a Text field through the
// multi-picklist canonicalizer; that's a programming bug. The error path
// lets the caller log + fall back to legacy includes() rather than
// silently producing nonsense tokens.
func TestCanonicalizeMultiPicklist_NonMultipicklistField_Errors(t *testing.T) {
	_, err := canonicalizeMultiPicklist("A; B", textField("Subject"))
	if err == nil {
		t.Fatal("expected error when field is non-multi-picklist; got nil")
	}
}

// TestCanonicalizeMultiPicklist_NilField_NoAssertion — when caller has
// no metadata to thread (e.g., generic decision condition with no
// trigger-object lookup), nil field bypasses the type assertion.
func TestCanonicalizeMultiPicklist_NilField_NoAssertion(t *testing.T) {
	got, err := canonicalizeMultiPicklist("A; B", nil)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	sortedEqual(t, got, []string{"A", "B"})
}

// TestCanonicalizeMultiPicklist_StringSlice — direct []string shape.
func TestCanonicalizeMultiPicklist_StringSlice(t *testing.T) {
	got, err := canonicalizeMultiPicklist([]string{"A", "B"}, multiPicklistField("Subject"))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	sortedEqual(t, got, []string{"A", "B"})
}

// TestCanonicalizeMultiPicklist_InterfaceSlice — []interface{} shape
// produced by inline flow-input wiring or unmarshalled JSON storage.
func TestCanonicalizeMultiPicklist_InterfaceSlice(t *testing.T) {
	got, err := canonicalizeMultiPicklist([]interface{}{"A", "B"}, multiPicklistField("Subject"))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	sortedEqual(t, got, []string{"A", "B"})
}

// TestCanonicalizeMultiPicklist_NormalizedSentinel covers the
// persistence-layer NormalizedMultiPicklistJSON shape: extracted by the
// records pipeline before flow evaluation.
func TestCanonicalizeMultiPicklist_NormalizedSentinel(t *testing.T) {
	got, err := canonicalizeMultiPicklist(schemautil.NormalizedMultiPicklistJSON(`["A","B"]`), multiPicklistField("Subject"))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	sortedEqual(t, got, []string{"A", "B"})
}

func TestCanonicalizeMultiPicklist_NormalizedSentinel_DeclaredJSONLiteral(t *testing.T) {
	got, err := canonicalizeMultiPicklist(
		schemautil.NormalizedMultiPicklistJSON(`["[\"A\"]"]`),
		multiPicklistFieldWithOptions("Subject", `["A"]`, "A"),
	)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	sortedEqual(t, got, []string{`["A"]`})
}

// =====================================================================
// includesForField / excludesForField
// =====================================================================

// TestIncludesForField_MultipicklistJSON_BytesRHS pins the R3 BLOCKER
// scenario end-to-end: LHS is the admin-authored field value (a `;`-
// joined string with token "A"), RHS is a []byte carrying a JSON array
// containing "A". The set-equality semantic requires this to return true
// — both sides resolve to the canonical {"A"} token set. The crossfire
// designer's proposed includesRHSItems would have produced false here
// (treating []byte("[\"A\"]") as the literal token `["A"]` and missing
// the membership). The canonicalizer reuses the schemautil canonical items,
// which JSON-decodes the []byte correctly.
func TestIncludesForField_MultipicklistJSON_BytesRHS(t *testing.T) {
	field := multiPicklistField("Subject")
	matched, err := includesForField(field, "A; B", []byte(`["A"]`))
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !matched {
		t.Fatal(`includesForField("A; B", []byte("[\"A\"]")) → want true (token A in both), got false`)
	}
}

func TestIncludesForField_DeclaredJSONLookingOptionMatchesExactLiteral(t *testing.T) {
	field := multiPicklistFieldWithOptions("Subject", `["Others"]`, "Others")

	matched, err := includesForField(
		field,
		schemautil.NormalizedMultiPicklistJSON(`["[\"Others\"]"]`),
		`["Others"]`,
	)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !matched {
		t.Fatal(`declared option ["Others"] must match exact RHS literal ["Others"]`)
	}

	matched, err = includesForField(
		field,
		schemautil.NormalizedMultiPicklistJSON(`["[\"Others\"]"]`),
		"Others",
	)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if matched {
		t.Fatal(`declared option ["Others"] must not match plain RHS literal Others`)
	}
}

func TestIncludesForField_AuthoredJSONLookingRHSDoesNotDecodeToDeclaredSelection(t *testing.T) {
	field := multiPicklistFieldWithOptions("Subject", `["Others"]`, "Others")

	matched, err := includesForField(
		field,
		schemautil.NormalizedMultiPicklistJSON(`["Others"]`),
		`["Others"]`,
	)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if matched {
		t.Fatal(`authored RHS ["Others"] must stay a literal and not decode to selection Others`)
	}

	matched, err = includesForField(
		field,
		schemautil.NormalizedMultiPicklistJSON(`["Others"]`),
		"Others",
	)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !matched {
		t.Fatal(`storage selection Others must match authored RHS Others`)
	}
}

func TestLegacyIncludes_RHSPreservesAuthoredStringAndStorageShapes(t *testing.T) {
	tests := []struct {
		name string
		lhs  interface{}
		rhs  interface{}
		want bool
	}{
		{
			name: "string RHS matches JSON-looking option exactly",
			lhs:  schemautil.NormalizedMultiPicklistJSON(`["[\"Others\"]"]`),
			rhs:  `["Others"]`,
			want: true,
		},
		{
			name: "string RHS does not decode to storage selection",
			lhs:  schemautil.NormalizedMultiPicklistJSON(`["Others"]`),
			rhs:  `["Others"]`,
			want: false,
		},
		{
			name: "byte RHS remains storage envelope",
			lhs:  "A; B",
			rhs:  []byte(`["A"]`),
			want: true,
		},
		{
			name: "string slice RHS matches when every token is selected",
			lhs:  "A; B",
			rhs:  []string{"A", "B"},
			want: true,
		},
		{
			name: "string slice RHS matches when any token is selected",
			lhs:  "A; B",
			rhs:  []string{"X", "B"},
			want: true,
		},
		{
			name: "interface slice RHS matches when every token is selected",
			lhs:  "A; B",
			rhs:  []interface{}{"A", "B"},
			want: true,
		},
		{
			name: "interface slice RHS matches when any token is selected",
			lhs:  "A; B",
			rhs:  []interface{}{"X", "B"},
			want: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := includes(tt.lhs, tt.rhs); got != tt.want {
				t.Fatalf("includes(%#v, %#v) = %v, want %v", tt.lhs, tt.rhs, got, tt.want)
			}
			if got := excludes(tt.lhs, tt.rhs); got != !tt.want {
				t.Fatalf("excludes(%#v, %#v) = %v, want %v", tt.lhs, tt.rhs, got, !tt.want)
			}
		})
	}
}

func TestEvaluateCondition_LegacyIncludes_RHSPreservesAuthoredAndResolvedShapes(t *testing.T) {
	executor := &FlowExecutor{}

	tests := []struct {
		name    string
		subject interface{}
		rhs     interface{}
		context map[string]interface{}
		want    bool
	}{
		{
			name:    "typed string literal matches JSON-looking option exactly",
			subject: schemautil.NormalizedMultiPicklistJSON(`["[\"Others\"]"]`),
			rhs:     map[string]interface{}{"stringValue": `["Others"]`},
			want:    true,
		},
		{
			name:    "typed string literal does not decode to storage selection",
			subject: schemautil.NormalizedMultiPicklistJSON(`["Others"]`),
			rhs:     map[string]interface{}{"stringValue": `["Others"]`},
			want:    false,
		},
		{
			name:    "resolved string does not decode to storage selection",
			subject: schemautil.NormalizedMultiPicklistJSON(`["Others"]`),
			rhs:     map[string]interface{}{"elementReference": "wanted"},
			context: map[string]interface{}{"wanted": `["Others"]`},
			want:    false,
		},
		{
			name:    "resolved semicolon string remains tokenized",
			subject: "A; B",
			rhs:     map[string]interface{}{"elementReference": "wanted"},
			context: map[string]interface{}{"wanted": "A;B"},
			want:    true,
		},
		{
			name:    "resolved bytes remain storage envelope",
			subject: "A; B",
			rhs:     map[string]interface{}{"elementReference": "wanted"},
			context: map[string]interface{}{"wanted": []byte(`["A"]`)},
			want:    true,
		},
		{
			name:    "resolved string slice matches when every token is selected",
			subject: "A; B",
			rhs:     map[string]interface{}{"elementReference": "wanted"},
			context: map[string]interface{}{"wanted": []string{"A", "B"}},
			want:    true,
		},
		{
			name:    "resolved string slice matches when any token is selected",
			subject: "A; B",
			rhs:     map[string]interface{}{"elementReference": "wanted"},
			context: map[string]interface{}{"wanted": []string{"X", "B"}},
			want:    true,
		},
		{
			name:    "resolved interface slice matches when every token is selected",
			subject: "A; B",
			rhs:     map[string]interface{}{"elementReference": "wanted"},
			context: map[string]interface{}{"wanted": []interface{}{"A", "B"}},
			want:    true,
		},
		{
			name:    "resolved interface slice matches when any token is selected",
			subject: "A; B",
			rhs:     map[string]interface{}{"elementReference": "wanted"},
			context: map[string]interface{}{"wanted": []interface{}{"X", "B"}},
			want:    true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			context := map[string]interface{}{"subject": tt.subject}
			for key, value := range tt.context {
				context[key] = value
			}
			got := executor.evaluateCondition(context, &models.FlowCondition{
				LeftValueReference: "subject",
				Operator:           "Includes",
				RightValue:         tt.rhs,
			})
			if got != tt.want {
				t.Fatalf("evaluateCondition Includes = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestEvaluateCondition_LegacyBareStringIncludesExcludesPreservesTokenizedAnyNone(t *testing.T) {
	executor := &FlowExecutor{}
	context := map[string]interface{}{"subject": "A"}

	if got := executor.evaluateCondition(context, &models.FlowCondition{
		LeftValueReference: "subject",
		Operator:           "Includes",
		RightValue:         "A;B",
	}); !got {
		t.Fatal(`legacy bare-string Includes should match when any RHS token is selected`)
	}

	if got := executor.evaluateCondition(context, &models.FlowCondition{
		LeftValueReference: "subject",
		Operator:           "Excludes",
		RightValue:         "A;B",
	}); got {
		t.Fatal(`legacy bare-string Excludes should fail when any RHS token is selected`)
	}

	if got := executor.evaluateCondition(context, &models.FlowCondition{
		LeftValueReference: "subject",
		Operator:           "Excludes",
		RightValue:         "B;C",
	}); !got {
		t.Fatal(`legacy bare-string Excludes should match when no RHS token is selected`)
	}
}

func TestEvaluateCondition_ExcludesPreservesNoneOfContract(t *testing.T) {
	executor := &FlowExecutor{}
	context := map[string]interface{}{"subject": "A"}

	tests := []struct {
		name string
		rhs  interface{}
		want bool
	}{
		{
			name: "typed semicolon literal rejects partial overlap",
			rhs:  map[string]interface{}{"stringValue": "A;B"},
			want: false,
		},
		{
			name: "typed semicolon literal matches no overlap",
			rhs:  map[string]interface{}{"stringValue": "B;C"},
			want: true,
		},
		{
			name: "structured string slice rejects partial overlap",
			rhs:  []string{"A", "B"},
			want: false,
		},
		{
			name: "structured string slice matches no overlap",
			rhs:  []string{"B", "C"},
			want: true,
		},
		{
			name: "structured interface slice rejects partial overlap",
			rhs:  []interface{}{"A", "B"},
			want: false,
		},
		{
			name: "structured interface slice matches no overlap",
			rhs:  []interface{}{"B", "C"},
			want: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := executor.evaluateCondition(context, &models.FlowCondition{
				LeftValueReference: "subject",
				Operator:           "Excludes",
				RightValue:         tt.rhs,
			})
			if got != tt.want {
				t.Fatalf("evaluateCondition Excludes = %v, want %v", got, tt.want)
			}
		})
	}
}

// TestIncludesForField_NoOverlap — the any-token contract fails when no RHS
// token appears in LHS. Sanity check the comparator works in the
// non-match direction with multiple shapes mixed.
func TestIncludesForField_NoOverlap(t *testing.T) {
	field := multiPicklistField("Subject")
	matched, err := includesForField(field, []string{"A", "B"}, "X; Y")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if matched {
		t.Fatal(`includesForField({"A","B"}, "X; Y") → want false (no overlap), got true`)
	}
}

// TestIncludesForField_EmptyLHS_NoMatch — empty LHS short-circuits to
// false regardless of RHS shape (no set membership possible).
func TestIncludesForField_EmptyLHS_NoMatch(t *testing.T) {
	field := multiPicklistField("Subject")
	matched, err := includesForField(field, nil, "A")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if matched {
		t.Fatal("empty LHS must short-circuit to false")
	}
}

// TestExcludesForField_DeMorgan — excludes is true iff no RHS token is in LHS;
// pin the De Morgan equivalence to includesForField.
func TestExcludesForField_DeMorgan(t *testing.T) {
	field := multiPicklistField("Subject")

	// Every RHS token selected -> excludes false.
	matched, err := excludesForField(field, "A; B", "B; A")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if matched {
		t.Fatal(`excludesForField("A;B", "B;A") -> want false (every token selected), got true`)
	}

	// One missing RHS token but one present token -> excludes false.
	matched, err = excludesForField(field, "A; B", "B; C")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if matched {
		t.Fatal(`excludesForField("A;B", "B;C") -> want false (B present), got true`)
	}

	// No overlap -> excludes true.
	matched, err = excludesForField(field, "A; B", "X; Y")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !matched {
		t.Fatal(`excludesForField("A;B", "X;Y") -> want true (no overlap), got false`)
	}
}

// TestIncludesForField_NonMultipicklistField_Errors — caller-bug
// propagation. The canonicalizer rejects the field type, the comparator
// surfaces the error, and the call-site (evaluateConditionCtx etc.) can
// log + fall back to legacy includes() without breaking the flow.
func TestIncludesForField_NonMultipicklistField_Errors(t *testing.T) {
	_, err := includesForField(textField("Subject"), "A", "A")
	if err == nil {
		t.Fatal("expected error when field is non-multi-picklist; got nil")
	}
}
