package persistence

import (
	"database/sql"
	"encoding/json"
	"strings"
	"testing"

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

func TestBatchFieldMetadataUpsertCoversValidatorMetadata(t *testing.T) {
	updatable := stringSet(batchFieldMetadataUpdatableFields())
	guarded := stringSet(batchFieldMetadataGuardedFields())

	for _, field := range []string{
		constants.FieldSysField_Validator,
		constants.FieldSysField_ValidatorConfig,
		constants.FieldSysField_ControllingField,
		constants.FieldSysField_WritableReferenceTo,
		constants.FieldSysField_WriteProtectionPolicy,
		constants.FieldSysField_ReadProtectionPolicy,
	} {
		if !updatable[field] {
			t.Fatalf("batch updatable fields missing %s", field)
		}
		if !guarded[field] {
			t.Fatalf("batch guarded fields missing %s", field)
		}
	}
}

func TestReadProtectionPolicyPersistencePath(t *testing.T) {
	col := domainschema.ColumnDefinition{
		Name:                 "SecretValue__c",
		Label:                "Secret Value",
		Type:                 "VARCHAR(255)",
		LogicalType:          string(constants.FieldTypeString),
		ReadProtectionPolicy: constants.FieldReadProtectionBlocked,
	}
	repo := &SchemaRepository{}
	field := repo.buildFieldMetadataFromColumn(col)
	if field.ReadProtectionPolicy != constants.FieldReadProtectionBlocked {
		t.Fatalf("buildFieldMetadataFromColumn: ReadProtectionPolicy = %q, want %q", field.ReadProtectionPolicy, constants.FieldReadProtectionBlocked)
	}
	values, err := repo.prepareFieldDBMap(field, "obj_test", "fld_test_secret")
	if err != nil {
		t.Fatalf("prepareFieldDBMap: %v", err)
	}
	got, ok := values[constants.FieldSysField_ReadProtectionPolicy]
	if !ok {
		t.Fatalf("prepareFieldDBMap missing key %s", constants.FieldSysField_ReadProtectionPolicy)
	}
	ns, ok := got.(sql.NullString)
	if !ok {
		t.Fatalf("ReadProtectionPolicy value type = %T, want sql.NullString", got)
	}
	if !ns.Valid || ns.String != constants.FieldReadProtectionBlocked {
		t.Fatalf("ReadProtectionPolicy value = %+v, want valid %q", ns, constants.FieldReadProtectionBlocked)
	}
	mapped := repo.mapFieldToColumnDef(*field)
	if mapped.ReadProtectionPolicy != constants.FieldReadProtectionBlocked {
		t.Fatalf("mapFieldToColumnDef: ReadProtectionPolicy = %q, want %q", mapped.ReadProtectionPolicy, constants.FieldReadProtectionBlocked)
	}
}

func TestWriteProtectionPolicyPersistencePath(t *testing.T) {
	for _, policy := range []string{
		constants.FieldWriteProtectionTrustedOnly,
		constants.FieldWriteProtectionImmutableAfterCreate,
	} {
		t.Run(policy, func(t *testing.T) {
			col := domainschema.ColumnDefinition{
				Name:                  "ProtectedLookup__c",
				Label:                 "Protected Lookup",
				Type:                  "VARCHAR(255)",
				LogicalType:           string(constants.FieldTypeReference),
				ReferenceTo:           []string{"User"},
				WritableReferenceTo:   []string{"User"},
				WriteProtectionPolicy: policy,
			}
			repo := &SchemaRepository{}
			field := repo.buildFieldMetadataFromColumn(col)
			if field.WriteProtectionPolicy != policy {
				t.Fatalf("buildFieldMetadataFromColumn: WriteProtectionPolicy = %q, want %q", field.WriteProtectionPolicy, policy)
			}
			if got := strings.Join(field.WritableReferenceTo, ","); got != "User" {
				t.Fatalf("buildFieldMetadataFromColumn: WritableReferenceTo = %v, want [User]", field.WritableReferenceTo)
			}
			values, err := repo.prepareFieldDBMap(field, "obj_test", "fld_test_protected")
			if err != nil {
				t.Fatalf("prepareFieldDBMap: %v", err)
			}
			got, ok := values[constants.FieldSysField_WriteProtectionPolicy]
			if !ok {
				t.Fatalf("prepareFieldDBMap missing key %s", constants.FieldSysField_WriteProtectionPolicy)
			}
			ns, ok := got.(sql.NullString)
			if !ok {
				t.Fatalf("WriteProtectionPolicy value type = %T, want sql.NullString", got)
			}
			if !ns.Valid || ns.String != policy {
				t.Fatalf("WriteProtectionPolicy value = %+v, want valid %q", ns, policy)
			}
			gotWritable, ok := values[constants.FieldSysField_WritableReferenceTo]
			if !ok {
				t.Fatalf("prepareFieldDBMap missing key %s", constants.FieldSysField_WritableReferenceTo)
			}
			writableNS, ok := gotWritable.(sql.NullString)
			if !ok {
				t.Fatalf("WritableReferenceTo value type = %T, want sql.NullString", gotWritable)
			}
			if !writableNS.Valid || writableNS.String != `["User"]` {
				t.Fatalf("WritableReferenceTo value = %+v, want [\"User\"]", writableNS)
			}
			mapped := repo.mapFieldToColumnDef(*field)
			if mapped.WriteProtectionPolicy != policy {
				t.Fatalf("mapFieldToColumnDef: WriteProtectionPolicy = %q, want %q", mapped.WriteProtectionPolicy, policy)
			}
			if got := strings.Join(mapped.WritableReferenceTo, ","); got != "User" {
				t.Fatalf("mapFieldToColumnDef: WritableReferenceTo = %v, want [User]", mapped.WritableReferenceTo)
			}
		})
	}
}

func TestStrictCreateFieldsPreserveAuthoredRequiredWithNullablePhysicalColumn(t *testing.T) {
	repo := &SchemaRepository{}
	objectMeta := &models.ObjectMetadata{
		ID:      "obj_case",
		APIName: "Case",
		Fields: []models.FieldMetadata{{
			APIName:      "Resolution__c",
			PhysicalName: "Resolution__c",
			Label:        "Resolution",
			Type:         constants.FieldTypeString,
			Required:     true,
		}},
	}
	def := domainschema.TableDefinition{
		TableName: "Case",
		Columns: []domainschema.ColumnDefinition{{
			Name:        "Resolution__c",
			Type:        "VARCHAR(255)",
			LogicalType: string(constants.FieldTypeString),
			Nullable:    true,
		}},
	}

	fields := repo.prepareStrictCreateFieldsForBatch(def, objectMeta)
	if len(fields) != 1 {
		t.Fatalf("fields len = %d, want 1", len(fields))
	}
	fc := fields[0]

	if !fc.ColumnDefinition.Nullable {
		t.Fatalf("ColumnDefinition.Nullable = false, want true")
	}
	if fc.OwnerKind != constants.FieldOwnerKindAdmin {
		t.Fatalf("OwnerKind = %q, want %q", fc.OwnerKind, constants.FieldOwnerKindAdmin)
	}
	if fc.Field == nil {
		t.Fatal("Field = nil, want authored field metadata")
	}
	if !fc.Field.Required {
		t.Fatalf("Field.Required = false, want true")
	}
}

func TestStrictCreateFieldContextFallsBackToColumnDerivedMetadataWhenAuthoredFieldMissing(t *testing.T) {
	repo := &SchemaRepository{}
	objectMeta := &models.ObjectMetadata{
		ID:      "obj_case",
		APIName: "Case",
	}
	col := domainschema.ColumnDefinition{
		Name:        "Discovered__c",
		Type:        "VARCHAR(255)",
		LogicalType: string(constants.FieldTypeString),
		Nullable:    true,
	}

	fc := repo.prepareStrictCreateFieldForBatch("Case", col, objectMeta)

	if fc.Field != nil {
		t.Fatalf("Field = %#v, want nil so BatchSaveFieldMetadata uses column-derived fallback", fc.Field)
	}
	if fc.ObjectID != "obj_case" {
		t.Fatalf("ObjectID = %q, want obj_case", fc.ObjectID)
	}
}

func TestPicklistOptionExtensionsPersistencePath(t *testing.T) {
	repo := &SchemaRepository{}
	field := &models.FieldMetadata{
		APIName:    "Priority",
		Label:      "Priority",
		Type:       constants.FieldTypePicklist,
		Createable: true,
		Updateable: true,
		Options: models.PicklistOptions{
			{Value: "High", Label: "High", Extensions: map[string]json.RawMessage{
				"score": json.RawMessage("3"),
				"style": json.RawMessage(`{"color":"red"}`),
			}},
		},
	}

	values, err := repo.prepareFieldDBMap(field, "obj_issue", "fld_priority")
	if err != nil {
		t.Fatalf("prepareFieldDBMap: %v", err)
	}
	got, ok := values[constants.FieldSysField_Options]
	if !ok {
		t.Fatalf("prepareFieldDBMap missing key %s", constants.FieldSysField_Options)
	}
	optionsJSON, ok := got.(string)
	if !ok {
		t.Fatalf("Options value type = %T, want string", got)
	}

	var roundtrip models.PicklistOptions
	if err := json.Unmarshal([]byte(optionsJSON), &roundtrip); err != nil {
		t.Fatalf("unmarshal Options: %v", err)
	}
	if len(roundtrip) != 1 {
		t.Fatalf("roundtrip options = %d, want 1", len(roundtrip))
	}
	if got := string(roundtrip[0].Extensions["score"]); got != "3" {
		t.Fatalf("score extension = %q, want 3", got)
	}
	if got := string(roundtrip[0].Extensions["style"]); got != `{"color":"red"}` {
		t.Fatalf("style extension = %q, want object payload", got)
	}
}

func TestObjectMetadataDefaultChildRowsPersistencePath(t *testing.T) {
	repo := &SchemaRepository{}
	values, err := repo.prepareObjectDBMap(&models.ObjectMetadata{
		APIName:                    "Campaign",
		Label:                      "Campaign",
		PluralLabel:                "Campaigns",
		SharingModel:               constants.SharingModelPublicRead,
		DefaultChildRowsCustomized: true,
		DefaultChildRows: []models.DefaultChildRow{{
			TargetObject:      "CampaignMemberStatus",
			TargetLookupField: "CampaignId",
			FieldValues:       models.SObject{"Label": "Sent", "SortOrder": 1},
		}},
	})
	if err != nil {
		t.Fatalf("prepareObjectDBMap: %v", err)
	}
	got, ok := values[constants.FieldSysObject_DefaultChildRows]
	if !ok {
		t.Fatalf("prepareObjectDBMap missing key %s", constants.FieldSysObject_DefaultChildRows)
	}
	ns, ok := got.(sql.NullString)
	if !ok {
		t.Fatalf("DefaultChildRows value type = %T, want sql.NullString", got)
	}
	if !ns.Valid || !strings.Contains(ns.String, `"targetObject":"CampaignMemberStatus"`) {
		t.Fatalf("DefaultChildRows value = %+v, want encoded JSON payload", ns)
	}
	customized, ok := values[constants.FieldSysObject_DefaultChildRowsCustomized]
	if !ok {
		t.Fatalf("prepareObjectDBMap missing key %s", constants.FieldSysObject_DefaultChildRowsCustomized)
	}
	customizedBool, ok := customized.(bool)
	if !ok {
		t.Fatalf("DefaultChildRowsCustomized value type = %T, want bool", customized)
	}
	if !customizedBool {
		t.Fatal("DefaultChildRowsCustomized = false, want true")
	}
}

func TestObjectMetadataChildCloneRulesPersistencePath(t *testing.T) {
	repo := &SchemaRepository{}
	values, err := repo.prepareObjectDBMap(&models.ObjectMetadata{
		APIName:                   "Contract",
		Label:                     "Contract",
		PluralLabel:               "Contracts",
		SharingModel:              constants.SharingModelPublicRead,
		ChildCloneRulesCustomized: true,
		ChildCloneRules: []models.ChildCloneRule{{
			ParentLookupField: "AccountId",
			SourceObject:      "Asset",
			SourceLookupField: "AccountId",
			TargetObject:      "Asset",
			TargetLookupField: "ContractId",
			FieldMappings:     map[string]string{"Name": "Name"},
		}},
	})
	if err != nil {
		t.Fatalf("prepareObjectDBMap: %v", err)
	}
	got, ok := values[constants.FieldSysObject_ChildCloneRules]
	if !ok {
		t.Fatalf("prepareObjectDBMap missing key %s", constants.FieldSysObject_ChildCloneRules)
	}
	ns, ok := got.(sql.NullString)
	if !ok {
		t.Fatalf("ChildCloneRules value type = %T, want sql.NullString", got)
	}
	if !ns.Valid || !strings.Contains(ns.String, `"parentLookupField":"AccountId"`) {
		t.Fatalf("ChildCloneRules value = %+v, want encoded JSON payload", ns)
	}
	customized, ok := values[constants.FieldSysObject_ChildCloneRulesCustomized]
	if !ok {
		t.Fatalf("prepareObjectDBMap missing key %s", constants.FieldSysObject_ChildCloneRulesCustomized)
	}
	customizedBool, ok := customized.(bool)
	if !ok {
		t.Fatalf("ChildCloneRulesCustomized value type = %T, want bool", customized)
	}
	if !customizedBool {
		t.Fatal("ChildCloneRulesCustomized = false, want true")
	}
}

func TestObjectMetadataExclusiveSiblingFieldsPersistencePath(t *testing.T) {
	repo := &SchemaRepository{}
	values, err := repo.prepareObjectDBMap(&models.ObjectMetadata{
		APIName:                          "CampaignMemberStatus",
		Label:                            "Campaign Member Status",
		PluralLabel:                      "Campaign Member Statuses",
		SharingModel:                     constants.SharingModelPublicRead,
		ExclusiveSiblingFieldsCustomized: true,
		ExclusiveSiblingFields: []models.ExclusiveSiblingField{{
			FieldAPIName:          "IsDefault",
			PartitionFieldAPIName: "CampaignId",
		}},
	})
	if err != nil {
		t.Fatalf("prepareObjectDBMap: %v", err)
	}
	got, ok := values[constants.FieldSysObject_ExclusiveSiblingFields]
	if !ok {
		t.Fatalf("prepareObjectDBMap missing key %s", constants.FieldSysObject_ExclusiveSiblingFields)
	}
	ns, ok := got.(sql.NullString)
	if !ok {
		t.Fatalf("ExclusiveSiblingFields value type = %T, want sql.NullString", got)
	}
	if !ns.Valid || !strings.Contains(ns.String, `"fieldApiName":"IsDefault"`) {
		t.Fatalf("ExclusiveSiblingFields value = %+v, want encoded JSON payload", ns)
	}
	customized, ok := values[constants.FieldSysObject_ExclusiveSiblingFieldsCustomized]
	if !ok {
		t.Fatalf("prepareObjectDBMap missing key %s", constants.FieldSysObject_ExclusiveSiblingFieldsCustomized)
	}
	customizedBool, ok := customized.(bool)
	if !ok {
		t.Fatalf("ExclusiveSiblingFieldsCustomized value type = %T, want bool", customized)
	}
	if !customizedBool {
		t.Fatal("ExclusiveSiblingFieldsCustomized = false, want true")
	}
}

func TestObjectMetadataCustomSettingPersistencePath(t *testing.T) {
	repo := &SchemaRepository{}
	values, err := repo.prepareObjectDBMap(&models.ObjectMetadata{
		APIName:       "Countries_Region__c",
		Label:         "Countries Region",
		PluralLabel:   "Countries Regions",
		IsCustom:      true,
		CustomSetting: true,
		SharingModel:  constants.SharingModelPublicRead,
	})
	if err != nil {
		t.Fatalf("prepareObjectDBMap: %v", err)
	}
	got, ok := values[constants.FieldSysObject_CustomSetting]
	if !ok {
		t.Fatalf("prepareObjectDBMap missing key %s", constants.FieldSysObject_CustomSetting)
	}
	customSetting, ok := got.(bool)
	if !ok {
		t.Fatalf("CustomSetting value type = %T, want bool", got)
	}
	if !customSetting {
		t.Fatal("CustomSetting = false, want true")
	}
}

func TestObjectBatchCustomizedGuardFieldsExcludesDefaultChildRows(t *testing.T) {
	guarded := objectBatchCustomizedGuardFields()
	for _, field := range []string{
		constants.FieldSysObject_DefaultChildRows,
		constants.FieldSysObject_ChildCloneRules,
		constants.FieldSysObject_ExclusiveSiblingFields,
	} {
		if _, ok := guarded[field]; ok {
			t.Fatalf("objectBatchCustomizedGuardFields unexpectedly includes %s", field)
		}
	}
}

func TestBatchSaveObjectMetadataWithoutChildCloneRulesSkipsDedicatedOverlayFields(t *testing.T) {
	fields := batchObjectMetadataUpdatableFields(
		constants.FieldSysObject_ChildCloneRules,
		constants.FieldSysObject_ChildCloneRulesCustomized,
		constants.FieldSysObject_ExclusiveSiblingFields,
		constants.FieldSysObject_ExclusiveSiblingFieldsCustomized,
	)
	for _, skipped := range []string{
		constants.FieldSysObject_ChildCloneRules,
		constants.FieldSysObject_ChildCloneRulesCustomized,
		constants.FieldSysObject_ExclusiveSiblingFields,
		constants.FieldSysObject_ExclusiveSiblingFieldsCustomized,
	} {
		for _, field := range fields {
			if field == skipped {
				t.Fatalf("batchObjectMetadataUpdatableFields returned skipped field %s", skipped)
			}
		}
	}
}

func TestBatchSaveObjectMetadataPreservesCustomizedDefaultChildRowsByDefault(t *testing.T) {
	updates := map[string]interface{}{}
	(&SchemaRepository{}).configureObjectBatchUpdatesWithCustomizationPolicy(updates, []string{
		constants.FieldSysObject_DefaultChildRows,
		constants.FieldSysObject_DefaultChildRowsCustomized,
	}, true)

	want := map[string]string{
		constants.FieldSysObject_DefaultChildRows: "IF(`" +
			constants.FieldSysObject_DefaultChildRowsCustomized + "` = 0 OR `" +
			constants.FieldSysObject_DefaultChildRowsCustomized + "` IS NULL, VALUES(`" +
			constants.FieldSysObject_DefaultChildRows + "`), `" +
			constants.FieldSysObject_DefaultChildRows + "`)",
		constants.FieldSysObject_DefaultChildRowsCustomized: "IF(`" +
			constants.FieldSysObject_DefaultChildRowsCustomized + "` = 0 OR `" +
			constants.FieldSysObject_DefaultChildRowsCustomized + "` IS NULL, VALUES(`" +
			constants.FieldSysObject_DefaultChildRowsCustomized + "`), `" +
			constants.FieldSysObject_DefaultChildRowsCustomized + "`)",
	}
	for field, expected := range want {
		raw, ok := updates[field]
		if !ok {
			t.Fatalf("updates missing %s", field)
		}
		rawSQL, ok := raw.(query.Raw)
		if !ok {
			t.Fatalf("update %s = %T, want query.Raw", field, raw)
		}
		if string(rawSQL) != expected {
			t.Fatalf("update %s = %q, want %q", field, string(rawSQL), expected)
		}
	}
}

func TestBatchSaveObjectMetadataPreservesCustomizedChildCloneRulesByDefault(t *testing.T) {
	updates := map[string]interface{}{}
	(&SchemaRepository{}).configureObjectBatchUpdatesWithCustomizationPolicy(updates, []string{
		constants.FieldSysObject_ChildCloneRules,
		constants.FieldSysObject_ChildCloneRulesCustomized,
	}, true)

	want := map[string]string{
		constants.FieldSysObject_ChildCloneRules: "IF(`" +
			constants.FieldSysObject_ChildCloneRulesCustomized + "` = 0 OR `" +
			constants.FieldSysObject_ChildCloneRulesCustomized + "` IS NULL, VALUES(`" +
			constants.FieldSysObject_ChildCloneRules + "`), `" +
			constants.FieldSysObject_ChildCloneRules + "`)",
		constants.FieldSysObject_ChildCloneRulesCustomized: "IF(`" +
			constants.FieldSysObject_ChildCloneRulesCustomized + "` = 0 OR `" +
			constants.FieldSysObject_ChildCloneRulesCustomized + "` IS NULL, VALUES(`" +
			constants.FieldSysObject_ChildCloneRulesCustomized + "`), `" +
			constants.FieldSysObject_ChildCloneRulesCustomized + "`)",
	}
	for field, expected := range want {
		raw, ok := updates[field]
		if !ok {
			t.Fatalf("updates missing %s", field)
		}
		rawSQL, ok := raw.(query.Raw)
		if !ok {
			t.Fatalf("update %s = %T, want query.Raw", field, raw)
		}
		if string(rawSQL) != expected {
			t.Fatalf("update %s = %q, want %q", field, string(rawSQL), expected)
		}
	}
}

func TestBatchSaveObjectMetadataPreservesCustomizedExclusiveSiblingFieldsByDefault(t *testing.T) {
	updates := map[string]interface{}{}
	(&SchemaRepository{}).configureObjectBatchUpdatesWithCustomizationPolicy(updates, []string{
		constants.FieldSysObject_ExclusiveSiblingFields,
		constants.FieldSysObject_ExclusiveSiblingFieldsCustomized,
	}, true)

	want := map[string]string{
		constants.FieldSysObject_ExclusiveSiblingFields: "IF(`" +
			constants.FieldSysObject_ExclusiveSiblingFieldsCustomized + "` = 0 OR `" +
			constants.FieldSysObject_ExclusiveSiblingFieldsCustomized + "` IS NULL, VALUES(`" +
			constants.FieldSysObject_ExclusiveSiblingFields + "`), `" +
			constants.FieldSysObject_ExclusiveSiblingFields + "`)",
		constants.FieldSysObject_ExclusiveSiblingFieldsCustomized: "IF(`" +
			constants.FieldSysObject_ExclusiveSiblingFieldsCustomized + "` = 0 OR `" +
			constants.FieldSysObject_ExclusiveSiblingFieldsCustomized + "` IS NULL, VALUES(`" +
			constants.FieldSysObject_ExclusiveSiblingFieldsCustomized + "`), `" +
			constants.FieldSysObject_ExclusiveSiblingFieldsCustomized + "`)",
	}
	for field, expected := range want {
		raw, ok := updates[field]
		if !ok {
			t.Fatalf("updates missing %s", field)
		}
		rawSQL, ok := raw.(query.Raw)
		if !ok {
			t.Fatalf("update %s = %T, want query.Raw", field, raw)
		}
		if string(rawSQL) != expected {
			t.Fatalf("update %s = %q, want %q", field, string(rawSQL), expected)
		}
	}
}

func TestBatchObjectMetadataUpdatableFieldsCanSkipChildCloneRules(t *testing.T) {
	fields := batchObjectMetadataUpdatableFields(
		constants.FieldSysObject_ChildCloneRules,
		constants.FieldSysObject_ChildCloneRulesCustomized,
	)
	set := stringSet(fields)
	for _, field := range []string{
		constants.FieldSysObject_ChildCloneRules,
		constants.FieldSysObject_ChildCloneRulesCustomized,
	} {
		if set[field] {
			t.Fatalf("batchObjectMetadataUpdatableFields contains %s, want skipped", field)
		}
	}

	updates := map[string]interface{}{}
	(&SchemaRepository{}).configureObjectBatchUpdatesWithCustomizationPolicy(updates, fields, true)
	for _, field := range []string{
		constants.FieldSysObject_ChildCloneRules,
		constants.FieldSysObject_ChildCloneRulesCustomized,
	} {
		if _, ok := updates[field]; ok {
			t.Fatalf("updates contains %s, want omitted from duplicate updates", field)
		}
	}
}

func TestConfigureObjectBatchUpdatesDoesNotGuardDefaultChildRowsWhenPreservationDisabled(t *testing.T) {
	updates := map[string]interface{}{}
	(&SchemaRepository{}).configureObjectBatchUpdatesWithCustomizationPolicy(updates, []string{
		constants.FieldSysObject_DefaultChildRows,
		constants.FieldSysObject_DefaultChildRowsCustomized,
		constants.FieldSysObject_ChildCloneRules,
		constants.FieldSysObject_ChildCloneRulesCustomized,
	}, false)

	for _, field := range []string{
		constants.FieldSysObject_DefaultChildRows,
		constants.FieldSysObject_DefaultChildRowsCustomized,
		constants.FieldSysObject_ChildCloneRules,
		constants.FieldSysObject_ChildCloneRulesCustomized,
	} {
		raw, ok := updates[field]
		if !ok {
			t.Fatalf("updates missing %s", field)
		}
		rawSQL, ok := raw.(query.Raw)
		if !ok {
			t.Fatalf("update %s = %T, want query.Raw", field, raw)
		}
		want := "VALUES(`" + field + "`)"
		if string(rawSQL) != want {
			t.Fatalf("update %s = %q, want %q", field, string(rawSQL), want)
		}
	}
}

func TestConfigureObjectBatchUpdatesPreservationDisabledOmitsDefaultChildRowsCustomizationGuard(t *testing.T) {
	updates := map[string]interface{}{}
	(&SchemaRepository{}).configureObjectBatchUpdatesWithCustomizationPolicy(updates, []string{
		constants.FieldSysObject_DefaultChildRows,
		constants.FieldSysObject_DefaultChildRowsCustomized,
	}, false)

	for _, field := range []string{
		constants.FieldSysObject_DefaultChildRows,
		constants.FieldSysObject_DefaultChildRowsCustomized,
	} {
		raw, ok := updates[field]
		if !ok {
			t.Fatalf("updates missing %s", field)
		}
		rawSQL, ok := raw.(query.Raw)
		if !ok {
			t.Fatalf("update %s = %T, want query.Raw", field, raw)
		}
		if strings.Contains(string(rawSQL), "IF(`"+constants.FieldSysObject_DefaultChildRowsCustomized+"`") {
			t.Fatalf("update %s = %q, want no DefaultChildRowsCustomized guard when preservation disabled", field, string(rawSQL))
		}
	}
}

func TestPrepareFieldDBMapPersistsExplicitOwner(t *testing.T) {
	ownerPack := "jira-like-sample"
	values, err := (&SchemaRepository{}).prepareFieldDBMap(&models.FieldMetadata{
		APIName:   "Subject",
		Label:     "Subject",
		Type:      constants.FieldTypeString,
		OwnerKind: constants.FieldOwnerKindPack,
		OwnerPack: &ownerPack,
	}, "obj_issue", "fld_issue_subject")
	if err != nil {
		t.Fatalf("prepareFieldDBMap: %v", err)
	}
	if got := values[constants.FieldSysField_OwnerKind]; got != constants.FieldOwnerKindPack {
		t.Fatalf("OwnerKind = %v, want %s", got, constants.FieldOwnerKindPack)
	}
	if got := values[constants.FieldSysField_OwnerPack]; got == nil {
		t.Fatal("OwnerPack = nil, want pack name")
	}
}

func TestApplyBatchFieldOwnershipUsesFieldContext(t *testing.T) {
	field := &models.FieldMetadata{
		APIName: "Subject",
		Label:   "Subject",
		Type:    constants.FieldTypeString,
	}
	applyBatchFieldOwnership(field, domainschema.FieldWithContext{
		OwnerKind: constants.FieldOwnerKindPack,
		OwnerPack: "jira-like-sample",
	})
	if field.OwnerKind != constants.FieldOwnerKindPack {
		t.Fatalf("OwnerKind = %q, want pack", field.OwnerKind)
	}
	if field.OwnerPack == nil || *field.OwnerPack != "jira-like-sample" {
		t.Fatalf("OwnerPack = %#v, want jira-like-sample", field.OwnerPack)
	}
}

func TestBatchFieldOwnershipUpdatesOnlyAdoptOwnerlessRuntimeRows(t *testing.T) {
	updates := map[string]interface{}{}
	(&SchemaRepository{}).configureBatchFieldOwnershipUpdates(updates)
	for _, field := range []string{constants.FieldSysField_OwnerKind, constants.FieldSysField_OwnerPack} {
		raw, ok := updates[field]
		if !ok {
			t.Fatalf("ownership updates missing %s", field)
		}
		rawSQL, ok := raw.(query.Raw)
		if !ok {
			t.Fatalf("ownership update %s = %T, want query.Raw", field, raw)
		}
		if !strings.Contains(strings.ToLower(string(rawSQL)), constants.FieldOwnerKindRuntime) {
			t.Fatalf("ownership update %s = %v, want runtime adoption guard", field, raw)
		}
	}
}

// TestChildRelationshipNamePersistencePath covers the L1-DSL → L0-substrate
// chain for the ChildRelationshipName column. Catches regressions in:
//
//   - schema_field_metadata_builder.go: ColumnDefinition.ChildRelationshipName
//     must propagate to FieldMetadata.ChildRelationshipName
//   - schema_helpers.go: prepareFieldDBMap must emit the value under
//     FieldSysField_ChildRelationshipName for the upsert path
//   - schema_metadata_persist.go: batch field-metadata updatable + guarded
//     lists must include the column so pack re-apply persists changes
//
// Issue #153 cutover.
func TestChildRelationshipNamePersistencePath(t *testing.T) {
	const want = "Contacts"

	col := domainschema.ColumnDefinition{
		Name:                  "AccountId",
		Type:                  "VARCHAR(255)",
		LogicalType:           string(constants.FieldTypeReference),
		ReferenceTo:           []string{"Account"},
		RelationshipName:      "Account",
		ChildRelationshipName: want,
	}
	repo := &SchemaRepository{}
	field := repo.buildFieldMetadataFromColumn(col)
	if field.ChildRelationshipName == nil || *field.ChildRelationshipName != want {
		t.Fatalf("buildFieldMetadataFromColumn: ChildRelationshipName = %v, want %q", field.ChildRelationshipName, want)
	}

	values, err := repo.prepareFieldDBMap(field, "obj_test", "fld_test_accountid")
	if err != nil {
		t.Fatalf("prepareFieldDBMap: %v", err)
	}
	got, ok := values[constants.FieldSysField_ChildRelationshipName]
	if !ok {
		t.Fatalf("prepareFieldDBMap missing key %s", constants.FieldSysField_ChildRelationshipName)
	}
	ns, ok := got.(sql.NullString)
	if !ok {
		t.Fatalf("ChildRelationshipName value type = %T, want sql.NullString", got)
	}
	if !ns.Valid || ns.String != want {
		t.Fatalf("ChildRelationshipName value = %+v, want valid %q", ns, want)
	}

	updatable := stringSet(batchFieldMetadataUpdatableFields())
	guarded := stringSet(batchFieldMetadataGuardedFields())
	if !updatable[constants.FieldSysField_ChildRelationshipName] {
		t.Fatalf("batch updatable fields missing %s", constants.FieldSysField_ChildRelationshipName)
	}
	if !guarded[constants.FieldSysField_ChildRelationshipName] {
		t.Fatalf("batch guarded fields missing %s", constants.FieldSysField_ChildRelationshipName)
	}
}

func TestValidatorConfigPersistencePath(t *testing.T) {
	const (
		validatorName = "state_transition"
		validatorCfg  = `{"initial":["Draft"],"transitions":{"Draft":["Activated"]}}`
	)

	col := domainschema.ColumnDefinition{
		Name:            "Status",
		Type:            "VARCHAR(255)",
		LogicalType:     string(constants.FieldTypePicklist),
		Validator:       validatorName,
		ValidatorConfig: validatorCfg,
	}
	repo := &SchemaRepository{}
	field := repo.buildFieldMetadataFromColumn(col)
	if field.Validator == nil || *field.Validator != validatorName {
		t.Fatalf("buildFieldMetadataFromColumn: Validator = %#v, want %q", field.Validator, validatorName)
	}
	if field.ValidatorConfig == nil || *field.ValidatorConfig != validatorCfg {
		t.Fatalf("buildFieldMetadataFromColumn: ValidatorConfig = %#v, want %q", field.ValidatorConfig, validatorCfg)
	}

	values, err := repo.prepareFieldDBMap(field, "obj_test", "fld_test_status")
	if err != nil {
		t.Fatalf("prepareFieldDBMap: %v", err)
	}
	got, ok := values[constants.FieldSysField_ValidatorConfig]
	if !ok {
		t.Fatalf("prepareFieldDBMap missing key %s", constants.FieldSysField_ValidatorConfig)
	}
	ns, ok := got.(sql.NullString)
	if !ok {
		t.Fatalf("ValidatorConfig value type = %T, want sql.NullString", got)
	}
	if !ns.Valid || ns.String != validatorCfg {
		t.Fatalf("ValidatorConfig value = %+v, want valid %q", ns, validatorCfg)
	}
}

func TestPrepareFieldForBatchMarksGeneratedPolymorphicDiscriminatorReadOnly(t *testing.T) {
	repo := &SchemaRepository{}
	typeCol := domainschema.PolymorphicDiscriminatorColumn(domainschema.ColumnDefinition{
		Name:          "ActorId",
		Label:         "Actor",
		Type:          "VARCHAR(255)",
		LogicalType:   string(constants.FieldTypeReference),
		ReferenceTo:   []string{"User", "Group"},
		IsPolymorphic: true,
	})

	fc := repo.PrepareFieldForBatch("ProcessInstanceWorkitem", typeCol)

	if !fc.CapabilitiesDeclared {
		t.Fatal("CapabilitiesDeclared = false, want true for generated discriminator")
	}
	if fc.Field == nil {
		t.Fatal("Field = nil, want generated discriminator metadata")
	}
	if fc.Field.GeneratedForPolymorphicReference != "ActorId" {
		t.Fatalf("GeneratedForPolymorphicReference = %q, want ActorId", fc.Field.GeneratedForPolymorphicReference)
	}
	if fc.Field.Createable || fc.Field.Updateable {
		t.Fatalf("Createable/Updateable = %v/%v, want false/false", fc.Field.Createable, fc.Field.Updateable)
	}
}

func TestMapFieldToColumnDefPreservesSingleTargetPolymorphicFlag(t *testing.T) {
	repo := &SchemaRepository{}
	col := repo.mapFieldToColumnDef(models.FieldMetadata{
		APIName:       "UserOrGroupId",
		Label:         "User Or Group",
		Type:          constants.FieldTypeReference,
		ReferenceTo:   []string{"User"},
		IsPolymorphic: true,
	})

	if !col.IsPolymorphic {
		t.Fatal("ColumnDefinition.IsPolymorphic = false, want true for single-target transitional polymorphic reference")
	}
	field := repo.buildFieldMetadataFromColumn(col)
	if !field.IsPolymorphic {
		t.Fatal("FieldMetadata.IsPolymorphic = false after column round-trip, want true")
	}
}

// TestChildRelationshipNameJSONRoundTrip_PersistenceSide covers the
// wire-shape contract for childRelationshipName on ColumnDefinition and
// FieldMetadata — both must round-trip JSON without losing the field.
// Pack-side FieldSpec round-trip lives in
// controlplane/packs/child_relationship_validation_test.go (split to avoid
// the packs → persistence import cycle).
func TestChildRelationshipNameJSONRoundTrip_PersistenceSide(t *testing.T) {
	var col domainschema.ColumnDefinition
	if err := json.Unmarshal([]byte(`{"name":"AccountId","type":"VARCHAR(255)","childRelationshipName":"Contacts"}`), &col); err != nil {
		t.Fatalf("ColumnDefinition.Unmarshal: %v", err)
	}
	if col.ChildRelationshipName != "Contacts" {
		t.Fatalf("ColumnDefinition.ChildRelationshipName = %q, want Contacts", col.ChildRelationshipName)
	}

	var fm models.FieldMetadata
	if err := json.Unmarshal([]byte(`{"name":"AccountId","label":"AccountId","type":"reference","childRelationshipName":"Contacts"}`), &fm); err != nil {
		t.Fatalf("FieldMetadata.Unmarshal: %v", err)
	}
	if fm.ChildRelationshipName == nil || *fm.ChildRelationshipName != "Contacts" {
		t.Fatalf("FieldMetadata.ChildRelationshipName = %v, want Contacts", fm.ChildRelationshipName)
	}
}

func stringSet(values []string) map[string]bool {
	set := make(map[string]bool, len(values))
	for _, value := range values {
		set[value] = true
	}
	return set
}

func minimalObjectMetadataForAccessPolicySidecarTest(apiName string) *models.ObjectMetadata {
	return &models.ObjectMetadata{
		APIName:      apiName,
		Label:        apiName,
		PluralLabel:  apiName + "s",
		SharingModel: constants.SharingModelPublicRead,
	}
}

// TestLockRulePersistencePath verifies that a non-nil LockRule round-trips
// through prepareObjectDBMap as a valid sql.NullString with the original value.
func TestLockRulePersistencePath(t *testing.T) {
	rule := "Status == 'Active'"
	repo := &SchemaRepository{}
	values, err := repo.prepareObjectDBMap(&models.ObjectMetadata{
		APIName:      "Item__c",
		Label:        "Item",
		PluralLabel:  "Items",
		SharingModel: constants.SharingModelPrivate,
		LockRule:     &rule,
	})
	if err != nil {
		t.Fatalf("prepareObjectDBMap: %v", err)
	}
	got, ok := values[constants.FieldSysObject_LockRule]
	if !ok {
		t.Fatalf("prepareObjectDBMap missing key %s", constants.FieldSysObject_LockRule)
	}
	ns, ok := got.(sql.NullString)
	if !ok {
		t.Fatalf("LockRule value type = %T, want sql.NullString", got)
	}
	if !ns.Valid || ns.String != rule {
		t.Fatalf("LockRule value = %+v, want valid %q", ns, rule)
	}
}

// TestLockRuleNilPersistencePath verifies that a nil LockRule produces an
// invalid (NULL) sql.NullString — preserving the NULL round-trip contract.
func TestLockRuleNilPersistencePath(t *testing.T) {
	repo := &SchemaRepository{}
	values, err := repo.prepareObjectDBMap(&models.ObjectMetadata{
		APIName:      "Item__c",
		Label:        "Item",
		PluralLabel:  "Items",
		SharingModel: constants.SharingModelPrivate,
		LockRule:     nil,
	})
	if err != nil {
		t.Fatalf("prepareObjectDBMap: %v", err)
	}
	got, ok := values[constants.FieldSysObject_LockRule]
	if !ok {
		t.Fatalf("prepareObjectDBMap missing key %s", constants.FieldSysObject_LockRule)
	}
	ns, ok := got.(sql.NullString)
	if !ok {
		t.Fatalf("LockRule nil value type = %T, want sql.NullString", got)
	}
	if ns.Valid {
		t.Fatalf("LockRule nil value = %+v, want invalid (NULL)", ns)
	}
}

// TestLockRuleInBatchUpdatableFields verifies that LockRule is present in the
// batch-upsert updatable fields so pack re-apply propagates it.
func TestLockRuleInBatchUpdatableFields(t *testing.T) {
	repo := &SchemaRepository{}
	updatableFields := []string{
		constants.FieldSysObject_ListFields, constants.FieldSysObject_ListFieldsCustomized,
		constants.FieldSysObject_DefaultChildRows, constants.FieldSysObject_DefaultChildRowsCustomized,
		constants.FieldSysObject_PathField, constants.FieldSysObject_LockRule,
	}
	updates := make(map[string]interface{})
	repo.configureObjectBatchUpdatesWithCustomizationPolicy(updates, updatableFields, false)
	if _, ok := updates[constants.FieldSysObject_LockRule]; !ok {
		t.Fatalf("batch updates map missing %s", constants.FieldSysObject_LockRule)
	}
}

func TestBatchSaveObjectMetadataPreservesCustomizedListFieldsByDefault(t *testing.T) {
	updates := map[string]interface{}{}
	(&SchemaRepository{}).configureObjectBatchUpdatesWithCustomizationPolicy(updates, []string{
		constants.FieldSysObject_ListFields,
		constants.FieldSysObject_ListFieldsCustomized,
	}, true)

	want := map[string]string{
		constants.FieldSysObject_ListFields: "IF(`" +
			constants.FieldSysObject_ListFieldsCustomized + "` = 0 OR `" +
			constants.FieldSysObject_ListFieldsCustomized + "` IS NULL, VALUES(`" +
			constants.FieldSysObject_ListFields + "`), `" +
			constants.FieldSysObject_ListFields + "`)",
		constants.FieldSysObject_ListFieldsCustomized: "IF(`" +
			constants.FieldSysObject_ListFieldsCustomized + "` = 0 OR `" +
			constants.FieldSysObject_ListFieldsCustomized + "` IS NULL, VALUES(`" +
			constants.FieldSysObject_ListFieldsCustomized + "`), `" +
			constants.FieldSysObject_ListFieldsCustomized + "`)",
	}
	for field, wantExpr := range want {
		got, ok := updates[field]
		if !ok {
			t.Fatalf("updates missing key %s", field)
		}
		rawSQL, ok := got.(query.Raw)
		if !ok {
			t.Fatalf("updates[%s] = %T, want query.Raw", field, got)
		}
		if string(rawSQL) != wantExpr {
			t.Fatalf("updates[%s] = %q, want %q", field, string(rawSQL), wantExpr)
		}
	}
}

func TestBatchSaveObjectMetadataReassertsListFieldsWhenPreservationDisabled(t *testing.T) {
	updates := map[string]interface{}{}
	(&SchemaRepository{}).configureObjectBatchUpdatesWithCustomizationPolicy(updates, []string{
		constants.FieldSysObject_ListFields,
		constants.FieldSysObject_ListFieldsCustomized,
	}, false)

	for field, wantExpr := range map[string]string{
		constants.FieldSysObject_ListFields:           "VALUES(`" + constants.FieldSysObject_ListFields + "`)",
		constants.FieldSysObject_ListFieldsCustomized: "VALUES(`" + constants.FieldSysObject_ListFieldsCustomized + "`)",
	} {
		got, ok := updates[field]
		if !ok {
			t.Fatalf("updates missing key %s", field)
		}
		rawSQL, ok := got.(query.Raw)
		if !ok {
			t.Fatalf("updates[%s] = %T, want query.Raw", field, got)
		}
		if string(rawSQL) != wantExpr {
			t.Fatalf("updates[%s] = %q, want %q", field, string(rawSQL), wantExpr)
		}
	}
}

func TestListFieldsPersistencePreservesPresence(t *testing.T) {
	repo := &SchemaRepository{}
	tests := []struct {
		name       string
		listFields []string
		wantValid  bool
		want       string
	}{
		{name: "omitted", listFields: nil, wantValid: false},
		{name: "explicit_empty", listFields: []string{}, wantValid: true, want: `[]`},
		{name: "non_empty", listFields: []string{"Name", "Status__c"}, wantValid: true, want: `["Name","Status__c"]`},
	}

	for _, tt := range tests {
		t.Run(tt.name+"/map", func(t *testing.T) {
			values, err := repo.prepareObjectDBMap(&models.ObjectMetadata{
				APIName:      "Item__c",
				Label:        "Item",
				PluralLabel:  "Items",
				SharingModel: constants.SharingModelPrivate,
				ListFields:   tt.listFields,
			})
			if err != nil {
				t.Fatalf("prepareObjectDBMap: %v", err)
			}
			assertListFieldsNullString(t, values[constants.FieldSysObject_ListFields], tt.wantValid, tt.want)
		})

		t.Run(tt.name+"/values", func(t *testing.T) {
			values, err := repo.prepareObjectDBValues(&models.ObjectMetadata{
				APIName:      "Item__c",
				Label:        "Item",
				PluralLabel:  "Items",
				SharingModel: constants.SharingModelPrivate,
				ListFields:   tt.listFields,
			})
			if err != nil {
				t.Fatalf("prepareObjectDBValues: %v", err)
			}
			const listFieldsInsertIndex = 15
			assertListFieldsNullString(t, values[listFieldsInsertIndex], tt.wantValid, tt.want)
			const listFieldsCustomizedInsertIndex = 16
			if got := values[listFieldsCustomizedInsertIndex]; got != false {
				t.Fatalf("ListFieldsCustomized insert value = %v, want false", got)
			}
		})
	}
}

func TestObjectInsertQueryValueArityIncludesListFieldsCustomized(t *testing.T) {
	repo := &SchemaRepository{}
	values, err := repo.prepareObjectDBValues(&models.ObjectMetadata{
		ID:           "obj_item",
		APIName:      "Item__c",
		Label:        "Item",
		PluralLabel:  "Items",
		SharingModel: constants.SharingModelPrivate,
		ListFields:   []string{},
	})
	if err != nil {
		t.Fatalf("prepareObjectDBValues: %v", err)
	}

	for name, query := range map[string]string{
		"upsert": repo.getObjectInsertQuery(),
		"strict": repo.getObjectStrictInsertQuery(),
	} {
		if got, want := strings.Count(query, "?"), len(values); got != want {
			t.Fatalf("%s object insert placeholders = %d, want %d", name, got, want)
		}
		if !strings.Contains(query, constants.FieldSysObject_ListFieldsCustomized) {
			t.Fatalf("%s object insert query missing %s: %s", name, constants.FieldSysObject_ListFieldsCustomized, query)
		}
	}
}

func assertListFieldsNullString(t *testing.T, got interface{}, wantValid bool, want string) {
	t.Helper()
	ns, ok := got.(sql.NullString)
	if !ok {
		t.Fatalf("ListFields value type = %T, want sql.NullString", got)
	}
	if ns.Valid != wantValid || ns.String != want {
		t.Fatalf("ListFields value = %+v, want valid=%v string=%q", ns, wantValid, want)
	}
}

func TestReadSourcePersistencePath(t *testing.T) {
	repo := &SchemaRepository{}
	values, err := repo.prepareObjectDBMap(&models.ObjectMetadata{
		APIName:      "RecordType",
		Label:        "Record Type",
		PluralLabel:  "Record Types",
		SharingModel: constants.SharingModelPrivate,
		ReadSource: &models.ObjectReadSourceConfig{
			Kind:          "physical_table",
			PhysicalTable: "_System_RecordType",
		},
	})
	if err != nil {
		t.Fatalf("prepareObjectDBMap: %v", err)
	}
	got, ok := values[constants.FieldSysObject_ReadSource]
	if !ok {
		t.Fatalf("prepareObjectDBMap missing key %s", constants.FieldSysObject_ReadSource)
	}
	ns, ok := got.(sql.NullString)
	if !ok {
		t.Fatalf("ReadSource value type = %T, want sql.NullString", got)
	}
	if !ns.Valid || !strings.Contains(ns.String, `"physicalTable":"_System_RecordType"`) {
		t.Fatalf("ReadSource value = %+v, want encoded JSON payload", ns)
	}
}

func TestReadSourceNilPersistencePath(t *testing.T) {
	repo := &SchemaRepository{}
	values, err := repo.prepareObjectDBMap(&models.ObjectMetadata{
		APIName:      "RecordType",
		Label:        "Record Type",
		PluralLabel:  "Record Types",
		SharingModel: constants.SharingModelPrivate,
	})
	if err != nil {
		t.Fatalf("prepareObjectDBMap: %v", err)
	}
	got, ok := values[constants.FieldSysObject_ReadSource]
	if !ok {
		t.Fatalf("prepareObjectDBMap missing key %s", constants.FieldSysObject_ReadSource)
	}
	ns, ok := got.(sql.NullString)
	if !ok {
		t.Fatalf("ReadSource nil value type = %T, want sql.NullString", got)
	}
	if ns.Valid {
		t.Fatalf("ReadSource nil value = %+v, want invalid (NULL)", ns)
	}
}

func TestReadSourcePersistenceRejectsMalformedDeclaration(t *testing.T) {
	repo := &SchemaRepository{}
	_, err := repo.prepareObjectDBMap(&models.ObjectMetadata{
		APIName:      "RecordType",
		Label:        "Record Type",
		PluralLabel:  "Record Types",
		SharingModel: constants.SharingModelPrivate,
		ReadSource: &models.ObjectReadSourceConfig{
			Kind:          "view",
			PhysicalTable: "_System_RecordType",
		},
	})
	if err == nil {
		t.Fatal("prepareObjectDBMap() = nil err, want invalid readSource failure")
	}
}

func TestReadSourceInsertValuesRejectMalformedDeclaration(t *testing.T) {
	repo := &SchemaRepository{}
	_, err := repo.prepareObjectDBValues(&models.ObjectMetadata{
		APIName:      "RecordType",
		Label:        "Record Type",
		PluralLabel:  "Record Types",
		SharingModel: constants.SharingModelPrivate,
		ReadSource: &models.ObjectReadSourceConfig{
			Kind:          "view",
			PhysicalTable: "_System_RecordType",
		},
	})
	if err == nil {
		t.Fatal("prepareObjectDBValues() = nil err, want invalid readSource failure")
	}
}

func TestReadSourceInBatchUpdatableFields(t *testing.T) {
	if !stringSet(batchObjectMetadataUpdatableFields())[constants.FieldSysObject_ReadSource] {
		t.Fatalf("batchObjectMetadataUpdatableFields missing %s", constants.FieldSysObject_ReadSource)
	}
}

func TestObjectBatchCustomizedGuardFieldsIncludesReadSource(t *testing.T) {
	guarded := objectBatchCustomizedGuardFields()
	if _, ok := guarded[constants.FieldSysObject_ReadSource]; !ok {
		t.Fatalf("objectBatchCustomizedGuardFields missing %s", constants.FieldSysObject_ReadSource)
	}
}

// TestObjectBatchCustomizedGuardFieldsExcludesLockRule verifies that LockRule
// is NOT in the row-scoped IsCustomized guard set. It must use the column-scoped
// LockRuleCustomized bit instead, so an unrelated admin edit (label, icon, etc.)
// does not suppress pack re-apply of the LockRule fact.
func TestObjectBatchCustomizedGuardFieldsExcludesLockRule(t *testing.T) {
	guarded := objectBatchCustomizedGuardFields()
	if _, ok := guarded[constants.FieldSysObject_LockRule]; ok {
		t.Fatalf("objectBatchCustomizedGuardFields unexpectedly includes %s (must use column-scoped LockRuleCustomized bit instead)", constants.FieldSysObject_LockRule)
	}
}

// TestBatchSaveObjectMetadataPreservesCustomizedLockRuleByDefault verifies
// that configureObjectBatchUpdatesWithCustomizationPolicy with preserveCustomized=true
// wraps LockRule behind the column-scoped LockRuleCustomized bit (not IsCustomized).
func TestBatchSaveObjectMetadataPreservesCustomizedLockRuleByDefault(t *testing.T) {
	updates := map[string]interface{}{}
	(&SchemaRepository{}).configureObjectBatchUpdatesWithCustomizationPolicy(updates, []string{
		constants.FieldSysObject_LockRule,
		constants.FieldSysObject_LockRuleCustomized,
	}, true)

	want := map[string]string{
		constants.FieldSysObject_LockRule: "IF(`" +
			constants.FieldSysObject_LockRuleCustomized + "` = 0 OR `" +
			constants.FieldSysObject_LockRuleCustomized + "` IS NULL, VALUES(`" +
			constants.FieldSysObject_LockRule + "`), `" +
			constants.FieldSysObject_LockRule + "`)",
		constants.FieldSysObject_LockRuleCustomized: "IF(`" +
			constants.FieldSysObject_LockRuleCustomized + "` = 0 OR `" +
			constants.FieldSysObject_LockRuleCustomized + "` IS NULL, VALUES(`" +
			constants.FieldSysObject_LockRuleCustomized + "`), `" +
			constants.FieldSysObject_LockRuleCustomized + "`)",
	}
	for field, wantExpr := range want {
		got, ok := updates[field]
		if !ok {
			t.Fatalf("updates missing key %s", field)
		}
		rawSQL, ok := got.(query.Raw)
		if !ok {
			t.Fatalf("updates[%s] = %T, want query.Raw", field, got)
		}
		if string(rawSQL) != wantExpr {
			t.Fatalf("updates[%s] = %q, want %q", field, string(rawSQL), wantExpr)
		}
	}
}

func TestNormalizeMultiPicklistStorageBackfill(t *testing.T) {
	tests := []struct {
		name  string
		input interface{}
		want  interface{}
	}{
		// Legacy JSON string storage shapes
		{name: "legacy semicolon JSON string", input: `"A;B"`, want: `["A","B"]`},
		{name: "legacy single token JSON string", input: `"Hot"`, want: `["Hot"]`},
		{name: "legacy whitespace trimmed", input: `" A ; B "`, want: `["A","B"]`},
		// Canonical JSON array storage shapes — idempotent
		{name: "canonical JSON array pass-through", input: `["Hot","Cold"]`, want: `["Hot","Cold"]`},
		{name: "canonical single element pass-through", input: `["Hot"]`, want: `["Hot"]`},
		{name: "canonical empty array", input: `[]`, want: `[]`},
		// Edge cases
		{name: "legacy duplicate tokens", input: `"A;A;B"`, want: `["A","B"]`},
		{name: "legacy empty token skipped", input: `"A;;B"`, want: `["A","B"]`},
		{name: "nil pass-through", input: nil, want: nil},
		{name: "empty string returns nil", input: "", want: nil},
		{name: "[]byte legacy semicolon", input: []byte(`"A;B"`), want: `["A","B"]`},
		{name: "[]byte canonical pass-through", input: []byte(`["A","B"]`), want: `["A","B"]`},
		{name: "invalid JSON pass-through", input: `not json`, want: `not json`},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := schemautil.NormalizeMultiPicklistStorageBackfill(tt.input)
			if tt.want == nil {
				if got != nil {
					t.Errorf("got %v, want nil", got)
				}
				return
			}
			wantStr, ok := tt.want.(string)
			if !ok {
				t.Fatalf("want is not string: %T", tt.want)
			}
			gotStr, ok := got.(string)
			if !ok {
				t.Fatalf("got is not string: %T %v", got, got)
			}
			if gotStr != wantStr {
				t.Errorf("got %q, want %q", gotStr, wantStr)
			}
		})
	}
}
