package packs

import (
	"context"
	"encoding/json"
	"errors"
	"strings"
	"testing"

	sqlmock "github.com/DATA-DOG/go-sqlmock"
	"github.com/nexuscrm/metadata"
	"github.com/nexuscrm/shared/pkg/models"
)

// mockLayoutRow returns a sqlmock row for a page layout with the given JSON, source, and isCustomized.
func mockLayoutRow(id, configJSON, source string, isCustomized bool) *sqlmock.Rows {
	return sqlmock.NewRows([]string{"Id", "Config", "Source", "IsCustomized", "IsDefault", "OwnerPack"}).
		AddRow(id, configJSON, source, isCustomized, true, "")
}

func marshalLayout(t *testing.T, layout models.PageLayout) string {
	t.Helper()
	b, err := json.Marshal(layout)
	if err != nil {
		t.Fatalf("json.Marshal layout: %v", err)
	}
	return string(b)
}

// ─── Axis 1: content drift ───────────────────────────────────────────────────

// TestAxis1_ContentDriftCaught verifies that differences in Sections / LayoutName
// are detected as drift even when RelatedLists match.
func TestAxis1_ContentDriftCaught(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := models.PageLayout{
		ID:            "layout-campaign-curated",
		ObjectAPIName: "Campaign",
		LayoutName:    "Campaign Layout",
		Sections: []models.PageSection{
			{ID: "s1", Label: "Details", Fields: []string{"Name", "Status"}},
		},
	}
	// Live has a different LayoutName (content drift).
	live := models.PageLayout{
		ID:            "layout-campaign-curated",
		ObjectAPIName: "Campaign",
		LayoutName:    "Campaign Layout DRIFTED",
		Sections: []models.PageSection{
			{ID: "s1", Label: "Details", Fields: []string{"Name", "Status"}},
		},
	}

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	err = verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, nil, "Campaign")
	if err == nil {
		t.Fatal("expected drift error, got nil")
	}
	if !IsCuratedPageLayoutConfigDrift(err) {
		t.Fatalf("expected config drift error, got: %v", err)
	}
}

// TestAxis1_RelatedListsDivergenceIgnored_LiveHasExtras confirms that when the
// live layout has extra (seed-augmented) RelatedLists that the payload did NOT
// declare, axis 1 does NOT report drift (RelatedLists are stripped before compare).
func TestAxis1_RelatedListsDivergenceIgnored_LiveHasExtras(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := models.PageLayout{
		ID:            "layout-campaign-curated",
		ObjectAPIName: "Campaign",
		LayoutName:    "Campaign Layout",
		Sections: []models.PageSection{
			{ID: "s1", Label: "Details", Fields: []string{"Name"}},
		},
		RelatedLists: nil, // payload authored none
	}
	// Live has seed-augmented related lists.
	live := models.PageLayout{
		ID:            "layout-campaign-curated",
		ObjectAPIName: "Campaign",
		LayoutName:    "Campaign Layout",
		Sections: []models.PageSection{
			{ID: "s1", Label: "Details", Fields: []string{"Name"}},
		},
		RelatedLists: []models.RelatedListConfig{
			{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
		},
	}

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, nil, "Campaign"); err != nil {
		t.Errorf("expected no drift, got: %v", err)
	}
}

// TestAxis1_RelatedListsDivergenceIgnored_DifferentFieldOrder confirms that when
// the live layout's seed-augmented RelatedLists[i].Fields differ in order from
// the expected (nil) RelatedLists, axis 1 still passes (not a content-axis concern).
func TestAxis1_RelatedListsDivergenceIgnored_DifferentFieldOrder(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := models.PageLayout{
		ID:            "layout-campaign-curated",
		ObjectAPIName: "Campaign",
		LayoutName:    "Campaign Layout",
		Sections: []models.PageSection{
			{ID: "s1", Label: "Details", Fields: []string{"Name"}},
		},
		// payload authored no RelatedLists
	}
	live := models.PageLayout{
		ID:            "layout-campaign-curated",
		ObjectAPIName: "Campaign",
		LayoutName:    "Campaign Layout",
		Sections: []models.PageSection{
			{ID: "s1", Label: "Details", Fields: []string{"Name"}},
		},
		RelatedLists: []models.RelatedListConfig{
			// seed-augmented; different field order from what apply might have stored
			{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Status", "Name"}},
		},
	}

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, nil, "Campaign"); err != nil {
		t.Errorf("expected no drift on seed-augmented field order, got: %v", err)
	}
}

// ─── Axis 2: payload-authored RelatedLists ──────────────────────────────────

// TestAxis2_NoOpWhenPayloadEmpty confirms axis 2 is a no-op when payload relatedLists is empty.
// Mirrors Campaign / Case / Contact / User real-world cases.
func TestAxis2_NoOpWhenPayloadEmpty(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := models.PageLayout{
		ID:            "layout-campaign-curated",
		ObjectAPIName: "Campaign",
		LayoutName:    "Campaign Layout",
		Sections: []models.PageSection{
			{ID: "s1", Label: "Details", Fields: []string{"Name"}},
		},
	}
	live := models.PageLayout{
		ID:            "layout-campaign-curated",
		ObjectAPIName: "Campaign",
		LayoutName:    "Campaign Layout",
		Sections: []models.PageSection{
			{ID: "s1", Label: "Details", Fields: []string{"Name"}},
		},
		// live has seed-augmented entries; should not matter since axis 2 is no-op
		RelatedLists: []models.RelatedListConfig{
			{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId"},
		},
	}

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	// pass empty slice explicitly (nil treated the same)
	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, []models.RelatedListConfig{}, nil, "Campaign"); err != nil {
		t.Errorf("expected no drift on empty payload RelatedLists, got: %v", err)
	}
}

// TestAxis2_MissingPayloadAuthoredEntry confirms drift when a payload-authored
// entry is missing from the live layout.
func TestAxis2_MissingPayloadAuthoredEntry(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	payloadRL := []models.RelatedListConfig{
		{ID: "Contact-AccountId", Label: "Contacts", ObjectAPIName: "Contact", LookupField: "AccountId", Fields: []string{"Name", "Email"}},
	}
	expected := models.PageLayout{
		ID:            "layout-account-curated",
		ObjectAPIName: "Account",
		LayoutName:    "Account Layout",
		Sections: []models.PageSection{
			{ID: "s1", Label: "Details", Fields: []string{"Name"}},
		},
		RelatedLists: payloadRL,
	}
	// Live dropped the payload-authored entry.
	live := models.PageLayout{
		ID:            "layout-account-curated",
		ObjectAPIName: "Account",
		LayoutName:    "Account Layout",
		Sections: []models.PageSection{
			{ID: "s1", Label: "Details", Fields: []string{"Name"}},
		},
		RelatedLists: nil, // dropped
	}

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-account-curated", marshalLayout(t, live), "curated", false))

	err = verifyCuratedPageLayoutConfig(context.Background(), db, expected, payloadRL, nil, "Account")
	if err == nil {
		t.Fatal("expected drift error, got nil")
	}
	if !IsCuratedPageLayoutConfigDrift(err) {
		t.Fatalf("expected config drift error, got: %v", err)
	}
}

func TestVerifyCuratedPageLayoutFailsWhenCanonicalDefaultRowIDIsMissing(t *testing.T) {
	db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := models.PageLayout{
		ID:            "curated_Campaign_default",
		ObjectAPIName: "Campaign",
		LayoutName:    "Campaign Layout",
		IsDefault:     true,
	}
	managedSeed := models.LayoutAssignmentRouteSourceManagedSeed

	mock.ExpectQuery(`SELECT COUNT\(\*\)\s+FROM _System_PageLayout`).
		WithArgs(expected.ObjectAPIName).
		WillReturnRows(sqlmock.NewRows([]string{"COUNT(*)"}).AddRow(1))
	mock.ExpectQuery(`SELECT COUNT\(\*\)\s+FROM _System_LayoutAssignment`).
		WithArgs(expected.ObjectAPIName).
		WillReturnRows(sqlmock.NewRows([]string{"COUNT(*)"}).AddRow(1))
	mock.ExpectQuery(`SELECT COUNT\(\*\)\s+FROM _System_LayoutAssignment`).
		WithArgs(expected.ObjectAPIName, models.LayoutAssignmentRouteSourceAdminAuthored).
		WillReturnRows(sqlmock.NewRows([]string{"COUNT(*)"}).AddRow(0))
	mock.ExpectQuery("SELECT la\\.\\`Id\\`, la\\.\\`LayoutId\\`").
		WithArgs(expected.ObjectAPIName).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "LayoutId", "Source", "IsCustomized", "TargetPresent", "RouteSource"}).
			AddRow("la-campaign-wildcard", "legacy-campaign-layout", "curated", 0, 1, managedSeed))
	mock.ExpectQuery(`FROM .` + "_System_PageLayout" + `. WHERE`).
		WithArgs(expected.ID).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Config", "Source", "IsCustomized", "IsDefault", "OwnerPack"}))
	mock.ExpectQuery(`FROM .` + "_System_PageLayout" + ` `).
		WithArgs(expected.ObjectAPIName).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Config", "Source", "IsCustomized", "IsDefault", "OwnerPack"}))
	mock.ExpectQuery(`SELECT COUNT\(\*\) FROM .` + "_System_PageLayout" + ` `).
		WithArgs(expected.ObjectAPIName).
		WillReturnRows(sqlmock.NewRows([]string{"COUNT(*)"}).AddRow(0))

	err = verifyCuratedPageLayout(db, expected, "salesforce-crm-standard", "Campaign")
	if err == nil {
		t.Fatal("verifyCuratedPageLayout() error = nil, want fail-closed non-canonical routing drift")
	}
	var routingErr *curatedPageLayoutRoutingError
	if !errors.As(err, &routingErr) {
		t.Fatalf("verifyCuratedPageLayout() error = %v, want curatedPageLayoutRoutingError", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestVerifyCuratedPageLayoutConfigFailsWhenCanonicalDefaultRowIDIsMissing(t *testing.T) {
	db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := models.PageLayout{
		ID:            "curated_Campaign_default",
		ObjectAPIName: "Campaign",
		LayoutName:    "Campaign Layout",
		IsDefault:     true,
		Sections: []models.PageSection{
			{ID: "s1", Label: "Details", Fields: []string{"Name"}},
		},
	}
	payloadRelatedLists := []models.RelatedListConfig{
		{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
	}
	expected.RelatedLists = payloadRelatedLists
	mock.ExpectQuery(`FROM .` + "_System_PageLayout" + `. WHERE`).
		WithArgs(expected.ID).
		WillReturnRows(sqlmock.NewRows([]string{"Id", "Config", "Source", "IsCustomized", "IsDefault", "OwnerPack"}))

	err = verifyCuratedPageLayoutConfigForPack(
		context.Background(),
		db,
		expected,
		payloadRelatedLists,
		nil,
		"salesforce-crm-standard",
		"Campaign",
	)
	if err == nil {
		t.Fatal("verifyCuratedPageLayoutConfigForPack() error = nil, want missing canonical curated row")
	}
	var missingErr *curatedPageLayoutMissingError
	if !errors.As(err, &missingErr) {
		t.Fatalf("verifyCuratedPageLayoutConfigForPack() error = %v, want curatedPageLayoutMissingError", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

// TestAxis2_DriftedPayloadAuthoredEntry confirms drift when a payload-authored
// entry is present in live but its content differs (Label / SecondaryLabel /
// Fields content / Fields order).
func TestAxis2_DriftedPayloadAuthoredEntry(t *testing.T) {
	tests := []struct {
		name    string
		payload models.RelatedListConfig
		live    models.RelatedListConfig
	}{
		{
			name:    "label_drifted",
			payload: models.RelatedListConfig{ObjectAPIName: "Contact", LookupField: "AccountId", Label: "Contacts"},
			live:    models.RelatedListConfig{ObjectAPIName: "Contact", LookupField: "AccountId", Label: "All Contacts"},
		},
		{
			name:    "secondary_label_drifted",
			payload: models.RelatedListConfig{ObjectAPIName: "Contact", LookupField: "AccountId", Label: "Contacts", SecondaryLabel: "Primary"},
			live:    models.RelatedListConfig{ObjectAPIName: "Contact", LookupField: "AccountId", Label: "Contacts", SecondaryLabel: "Secondary"},
		},
		{
			name:    "fields_content_drifted",
			payload: models.RelatedListConfig{ObjectAPIName: "Contact", LookupField: "AccountId", Fields: []string{"Name", "Email"}},
			live:    models.RelatedListConfig{ObjectAPIName: "Contact", LookupField: "AccountId", Fields: []string{"Name", "Phone"}},
		},
		{
			name:    "fields_order_drifted",
			payload: models.RelatedListConfig{ObjectAPIName: "Contact", LookupField: "AccountId", Fields: []string{"Name", "Email"}},
			live:    models.RelatedListConfig{ObjectAPIName: "Contact", LookupField: "AccountId", Fields: []string{"Email", "Name"}},
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			db, mock, err := sqlmock.New()
			if err != nil {
				t.Fatalf("sqlmock: %v", err)
			}
			defer db.Close()

			baseLayout := models.PageLayout{
				ID:            "layout-account-curated",
				ObjectAPIName: "Account",
				LayoutName:    "Account Layout",
				Sections:      []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
			}
			payloadRL := []models.RelatedListConfig{tc.payload}
			expectedLayout := baseLayout
			expectedLayout.RelatedLists = payloadRL

			liveLayout := baseLayout
			liveLayout.RelatedLists = []models.RelatedListConfig{tc.live}

			mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-account-curated", marshalLayout(t, liveLayout), "curated", false))

			err = verifyCuratedPageLayoutConfig(context.Background(), db, expectedLayout, payloadRL, nil, "Account")
			if err == nil {
				t.Fatal("expected drift error, got nil")
			}
			if !IsCuratedPageLayoutConfigDrift(err) {
				t.Fatalf("expected config drift error, got: %v", err)
			}
		})
	}
}

// TestAxis2_MatchesByIdentityTuple confirms that axis 2 matches by
// (ObjectAPIName, LookupField) even when ID differs in case or format.
func TestAxis2_MatchesByIdentityTuple(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	payloadRL := []models.RelatedListConfig{
		// payload uses canonical casing
		{ID: "Contact-AccountId", Label: "Contacts", ObjectAPIName: "Contact", LookupField: "AccountId", Fields: []string{"Name"}},
	}
	expected := models.PageLayout{
		ID:            "layout-account-curated",
		ObjectAPIName: "Account",
		LayoutName:    "Account Layout",
		Sections:      []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
		RelatedLists:  payloadRL,
	}
	// Live uses different ID case / format but same ObjectAPIName+LookupField.
	live := models.PageLayout{
		ID:            "layout-account-curated",
		ObjectAPIName: "Account",
		LayoutName:    "Account Layout",
		Sections:      []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
		RelatedLists: []models.RelatedListConfig{
			{ID: "contact-accountid", Label: "Contacts", ObjectAPIName: "contact", LookupField: "accountId", Fields: []string{"Name"}},
		},
	}

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-account-curated", marshalLayout(t, live), "curated", false))

	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, payloadRL, nil, "Account"); err != nil {
		t.Errorf("expected no drift (identity matched case-insensitively), got: %v", err)
	}
}

// TestAxis2_IgnoresSeedAugmentedLiveOnlyEntries confirms that live-only
// seed-augmented entries that don't match any payload identity are ignored.
func TestAxis2_IgnoresSeedAugmentedLiveOnlyEntries(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	// Payload authored one entry.
	payloadRL := []models.RelatedListConfig{
		{ID: "Contact-AccountId", Label: "Contacts", ObjectAPIName: "Contact", LookupField: "AccountId", Fields: []string{"Name"}},
	}
	expected := models.PageLayout{
		ID:            "layout-account-curated",
		ObjectAPIName: "Account",
		LayoutName:    "Account Layout",
		Sections:      []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
		RelatedLists:  payloadRL,
	}
	// Live has the payload-authored entry PLUS a seed-augmented extra.
	live := models.PageLayout{
		ID:            "layout-account-curated",
		ObjectAPIName: "Account",
		LayoutName:    "Account Layout",
		Sections:      []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
		RelatedLists: []models.RelatedListConfig{
			{ID: "Contact-AccountId", Label: "Contacts", ObjectAPIName: "Contact", LookupField: "AccountId", Fields: []string{"Name"}},
			// seed-augmented extra — not declared by payload
			{ID: "Opportunity-AccountId", Label: "Opportunities", ObjectAPIName: "Opportunity", LookupField: "AccountId", Fields: []string{"Name"}},
		},
	}

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-account-curated", marshalLayout(t, live), "curated", false))

	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, payloadRL, nil, "Account"); err != nil {
		t.Errorf("expected no drift (seed-augmented extras ignored), got: %v", err)
	}
}

// TestAxis2_IgnoresTenantOverlayAddedEntries mirrors TestAxis2_IgnoresSeedAugmentedLiveOnlyEntries
// but frames the live-only extras as tenant-overlay additions.
func TestAxis2_IgnoresTenantOverlayAddedEntries(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	payloadRL := []models.RelatedListConfig{
		{ID: "Contact-AccountId", Label: "Contacts", ObjectAPIName: "Contact", LookupField: "AccountId", Fields: []string{"Name"}},
	}
	expected := models.PageLayout{
		ID:            "layout-account-curated",
		ObjectAPIName: "Account",
		LayoutName:    "Account Layout",
		Sections:      []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
		RelatedLists:  payloadRL,
	}
	live := models.PageLayout{
		ID:            "layout-account-curated",
		ObjectAPIName: "Account",
		LayoutName:    "Account Layout",
		Sections:      []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
		RelatedLists: []models.RelatedListConfig{
			{ID: "Contact-AccountId", Label: "Contacts", ObjectAPIName: "Contact", LookupField: "AccountId", Fields: []string{"Name"}},
			// tenant-overlay-added extra
			{ID: "CustomObj-AccountId", Label: "Custom Objects", ObjectAPIName: "CustomObj", LookupField: "AccountId", Fields: []string{"Name"}},
		},
	}

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-account-curated", marshalLayout(t, live), "curated", false))

	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, payloadRL, nil, "Account"); err != nil {
		t.Errorf("expected no drift (tenant-overlay extras ignored), got: %v", err)
	}
}

// TestAxis2_NormalizesFieldsNilVsEmpty confirms that Fields nil vs []string{}
// does not produce false drift.
func TestAxis2_NormalizesFieldsNilVsEmpty(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	// Payload has Fields: nil.
	payloadRL := []models.RelatedListConfig{
		{ID: "Contact-AccountId", Label: "Contacts", ObjectAPIName: "Contact", LookupField: "AccountId", Fields: nil},
	}
	expected := models.PageLayout{
		ID:            "layout-account-curated",
		ObjectAPIName: "Account",
		LayoutName:    "Account Layout",
		Sections:      []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
		RelatedLists:  payloadRL,
	}
	// Live has Fields: []string{} (empty slice, not nil).
	live := models.PageLayout{
		ID:            "layout-account-curated",
		ObjectAPIName: "Account",
		LayoutName:    "Account Layout",
		Sections:      []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
		RelatedLists: []models.RelatedListConfig{
			{ID: "Contact-AccountId", Label: "Contacts", ObjectAPIName: "Contact", LookupField: "AccountId", Fields: []string{}},
		},
	}

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-account-curated", marshalLayout(t, live), "curated", false))

	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, payloadRL, nil, "Account"); err != nil {
		t.Errorf("expected no drift (Fields nil vs [] normalized), got: %v", err)
	}
}

// TestAxis2_NormalizesPrefillMappingsNilVsEmpty confirms that PrefillMappings
// nil vs empty map does not produce false drift.
func TestAxis2_NormalizesPrefillMappingsNilVsEmpty(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	// Payload has PrefillMappings: nil.
	payloadRL := []models.RelatedListConfig{
		{ID: "Contact-AccountId", Label: "Contacts", ObjectAPIName: "Contact", LookupField: "AccountId", PrefillMappings: nil},
	}
	expected := models.PageLayout{
		ID:            "layout-account-curated",
		ObjectAPIName: "Account",
		LayoutName:    "Account Layout",
		Sections:      []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
		RelatedLists:  payloadRL,
	}
	// Live has PrefillMappings: map[string]string{} (empty map, not nil).
	live := models.PageLayout{
		ID:            "layout-account-curated",
		ObjectAPIName: "Account",
		LayoutName:    "Account Layout",
		Sections:      []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
		RelatedLists: []models.RelatedListConfig{
			{ID: "Contact-AccountId", Label: "Contacts", ObjectAPIName: "Contact", LookupField: "AccountId", PrefillMappings: map[string]string{}},
		},
	}

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-account-curated", marshalLayout(t, live), "curated", false))

	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, payloadRL, nil, "Account"); err != nil {
		t.Errorf("expected no drift (PrefillMappings nil vs {} normalized), got: %v", err)
	}
}

// ─── Admin-owned bypass ───────────────────────────────────────────────────────

// TestAdminOwnedBypassPreserved confirms that IsCustomized=true returns nil
// regardless of content drift or RelatedLists drift.
func TestAdminOwnedBypassPreserved(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := models.PageLayout{
		ID:            "layout-campaign-curated",
		ObjectAPIName: "Campaign",
		LayoutName:    "Campaign Layout",
		Sections: []models.PageSection{
			{ID: "s1", Label: "Details", Fields: []string{"Name"}},
		},
	}
	// Live is completely different — but IsCustomized=true so bypass applies.
	live := models.PageLayout{
		ID:            "layout-campaign-curated",
		ObjectAPIName: "Campaign",
		LayoutName:    "ADMIN CUSTOMIZED — completely different",
		Sections: []models.PageSection{
			{ID: "s1", Label: "Other", Fields: []string{"OtherField"}},
		},
		RelatedLists: []models.RelatedListConfig{
			{ObjectAPIName: "Custom", LookupField: "SomeId", Label: "Custom RL"},
		},
	}

	// IsCustomized = true → bypass must return nil.
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", true))

	payloadRL := []models.RelatedListConfig{
		{ObjectAPIName: "Contact", LookupField: "CampaignId", Label: "Contacts"},
	}
	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, payloadRL, nil, "Campaign"); err != nil {
		t.Errorf("expected nil (admin-owned bypass), got: %v", err)
	}
}

// ─── Axis 3: seed sentinel ────────────────────────────────────────────────────

// mockNameFieldRows returns empty sqlmock rows for the loadNameFieldMap query
// (no name-field overrides — defaultColumnsForObject falls back to "Name").
func mockNameFieldRows() *sqlmock.Rows {
	return sqlmock.NewRows([]string{"obj_ApiName", "f_ApiName"})
}

// emptyCandidateRows returns empty sqlmock Rows for getRelatedListCandidates
// (columns: f.ApiName, f.Label, o.ApiName, o.PluralLabel, rel.RelatedListLabel, rel.RelatedListFields).
func emptyCandidateRows() *sqlmock.Rows {
	return sqlmock.NewRows([]string{"f_ApiName", "f_Label", "o_ApiName", "o_PluralLabel", "rel_RelatedListLabel", "rel_RelatedListFields"})
}

// oneCandidateRows returns sqlmock Rows with a single candidate row.
// lookupField, lookupLabel, childObject, pluralLabel are the 4 non-nullable columns;
// relListLabel and relListFields are NULLable (pass nil).
func oneCandidateRows(lookupField, lookupLabel, childObject, pluralLabel string) *sqlmock.Rows {
	return sqlmock.NewRows([]string{"f_ApiName", "f_Label", "o_ApiName", "o_PluralLabel", "rel_RelatedListLabel", "rel_RelatedListFields"}).
		AddRow(lookupField, lookupLabel, childObject, pluralLabel, nil, nil)
}

// baseExpectedLayout returns a minimal pack layout for Campaign with no authored RelatedLists.
func baseExpectedLayoutCampaign() models.PageLayout {
	return models.PageLayout{
		ID:            "layout-campaign-curated",
		ObjectAPIName: "Campaign",
		LayoutName:    "Campaign Layout",
		Sections:      []models.PageSection{{ID: "s1", Label: "Details", Fields: []string{"Name"}}},
	}
}

// TestSeedSentinelLiveLostAllSeededEntriesBlocks verifies that axis 3 fires when
// the live layout has no RelatedLists but the DB yields at least one candidate
// classified as BucketRecommended by the pack's rules.
// This is the primary regression case: apply seeded entries, admin/overlay cleared them,
// verify now passes silently — axis 3 catches this.
func TestSeedSentinelLiveLostAllSeededEntriesBlocks(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	// Live matches content (axis 1 passes) but lost all RelatedLists.
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = nil

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))
	// Axis 3: loadNameFieldMap returns empty.
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockNameFieldRows())
	// Axis 3: getRelatedListCandidates returns one candidate.
	mock.ExpectQuery(`SELECT`).WillReturnRows(oneCandidateRows("CampaignId", "Campaign", "CampaignMember", "Campaign Members"))

	// Pass a rule that classifies CampaignMember/CampaignId as BucketRecommended
	// so resolveMetadataRelatedListCandidatesWithRules returns IsRecommended=true.
	rules := []metadata.RelatedListRule{
		{
			ParentObjectAPI: "Campaign",
			ChildObjectAPI:  "CampaignMember",
			LookupField:     "CampaignId",
			Bucket:          metadata.BucketRecommended,
		},
	}
	err = verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign")
	if err == nil {
		t.Fatal("expected drift error (axis 3 sentinel), got nil")
	}
	if !IsCuratedPageLayoutConfigDrift(err) {
		t.Fatalf("expected config drift error, got: %v", err)
	}
}

// ─── Axis 3 widened: per-entry Fields drift (issue #1102) ────────────────────

// F3a — live Fields differ from rule.Fields (non-empty rule) → drift.
func TestSeedSentinelDetectsPackRuleFieldsDrift(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = []models.RelatedListConfig{
		{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
	}
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	rules := []metadata.RelatedListRule{
		{ParentObjectAPI: "Campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: []string{"Name", "Status"}},
	}
	err = verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign")
	if err == nil {
		t.Fatal("expected drift error, got nil")
	}
	if !IsCuratedPageLayoutConfigDrift(err) {
		t.Fatalf("expected config drift error, got: %v", err)
	}
}

// F3b — order-sensitive Fields compare: live [A,B], rule [B,A] → drift.
func TestSeedSentinelDetectsFieldsOrderDrift(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = []models.RelatedListConfig{
		{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"A", "B"}},
	}
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	rules := []metadata.RelatedListRule{
		{ParentObjectAPI: "Campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: []string{"B", "A"}},
	}
	err = verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign")
	if err == nil {
		t.Fatal("expected drift error (order), got nil")
	}
	if !IsCuratedPageLayoutConfigDrift(err) {
		t.Fatalf("expected config drift error, got: %v", err)
	}
}

// F3c — equal Fields → no drift.
func TestSeedSentinelPassesWhenFieldsMatchRule(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = []models.RelatedListConfig{
		{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
	}
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	rules := []metadata.RelatedListRule{
		{ParentObjectAPI: "Campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: []string{"Name"}},
	}
	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign"); err != nil {
		t.Errorf("expected no drift (Fields match), got: %v", err)
	}
}

// F3d — identity match is case-insensitive on parent, child, lookup.
func TestSeedSentinelFieldsCompareIdentityCaseInsensitive(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	live := baseExpectedLayoutCampaign()
	// Live uses lowercase child and mixed-case lookup.
	live.RelatedLists = []models.RelatedListConfig{
		{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "campaignmember", LookupField: "campaignId", Fields: []string{"Name"}},
	}
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	// Rule uses different casing and non-empty Fields differing from live.
	rules := []metadata.RelatedListRule{
		{ParentObjectAPI: "campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: []string{"Name", "Status"}},
	}
	err = verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign")
	if err == nil {
		t.Fatal("expected drift error (case-insensitive match, Fields differ), got nil")
	}
	if !IsCuratedPageLayoutConfigDrift(err) {
		t.Fatalf("expected config drift error, got: %v", err)
	}
}

// F3e — multiple live entries; only matched one with content drift fires.
func TestSeedSentinelFieldsDriftIgnoresEntriesWithoutMatchingRule(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = []models.RelatedListConfig{
		// This one matches a rule with differing Fields → drift.
		{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
		// This one has no matching rule → ignored.
		{ID: "Task-WhatId", Label: "Tasks", ObjectAPIName: "Task", LookupField: "WhatId", Fields: []string{"Subject"}},
	}
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	rules := []metadata.RelatedListRule{
		{ParentObjectAPI: "Campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: []string{"Name", "Status"}},
	}
	err = verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign")
	if err == nil {
		t.Fatal("expected drift error, got nil")
	}
	if !IsCuratedPageLayoutConfigDrift(err) {
		t.Fatalf("expected config drift error, got: %v", err)
	}
}

// F3f — no matching rule for any live entry → no drift.
func TestSeedSentinelNoMatchingRulesNoBlock(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = []models.RelatedListConfig{
		{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
	}
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	// No rules at all.
	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, nil, "Campaign"); err != nil {
		t.Errorf("expected no drift (no rules), got: %v", err)
	}
}

// F3g — non-Recommended Bucket rules don't trigger drift.
func TestSeedSentinelFieldsCompareIgnoresNonRecommendedBucket(t *testing.T) {
	buckets := []metadata.Bucket{metadata.BucketAdditional, metadata.BucketSystem}
	for _, bucket := range buckets {
		t.Run(string(bucket), func(t *testing.T) {
			db, mock, err := sqlmock.New()
			if err != nil {
				t.Fatalf("sqlmock: %v", err)
			}
			defer db.Close()

			expected := baseExpectedLayoutCampaign()
			live := baseExpectedLayoutCampaign()
			live.RelatedLists = []models.RelatedListConfig{
				{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
			}
			mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

			rules := []metadata.RelatedListRule{
				{ParentObjectAPI: "Campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: bucket, Fields: []string{"Name", "Status"}},
			}
			if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign"); err != nil {
				t.Errorf("expected no drift (Bucket=%s not Recommended), got: %v", bucket, err)
			}
		})
	}
}

// F3h-locked — rule with empty Fields does NOT trigger drift (issue-scope contract).
func TestSeedSentinelFieldsCompareIgnoresEmptyRuleFields(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = []models.RelatedListConfig{
		{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name", "Email", "Phone"}},
	}
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	// Rule is Recommended but Fields is nil — not authoring Fields, deferring to relationship default.
	rules := []metadata.RelatedListRule{
		{ParentObjectAPI: "Campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: nil},
	}
	// Issue body Goal #1: "non-empty pack-rule Fields" — empty rule must not trigger drift.
	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign"); err != nil {
		t.Errorf("expected no drift (empty rule Fields, scope-narrowed), got: %v", err)
	}
}

// F3i — identity match uses ToLower only (no TrimSpace); whitespace-padded rule does NOT match.
func TestSeedSentinelFieldsCompareDoesNotTrimWhitespace(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = []models.RelatedListConfig{
		{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
	}
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	// Rule has trailing whitespace in ParentObjectAPI — apply also uses ToLower-only, so this does NOT match.
	rules := []metadata.RelatedListRule{
		{ParentObjectAPI: "Campaign ", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: []string{"Name", "Status"}},
	}
	// No match → no drift (mirrors apply's non-determinism).
	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign"); err != nil {
		t.Errorf("expected no drift (whitespace-padded parent does not match), got: %v", err)
	}
}

// F3j — empty-string Bucket treated as Recommended (cross-package equivalent to normalizeRelatedListBucket("")).
func TestSeedSentinelFieldsCompareTreatsEmptyBucketAsRecommended(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = []models.RelatedListConfig{
		{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
	}
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	rules := []metadata.RelatedListRule{
		{ParentObjectAPI: "Campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: "", Fields: []string{"Name", "Status"}},
	}
	err = verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign")
	if err == nil {
		t.Fatal("expected drift error (empty bucket treated as Recommended), got nil")
	}
	if !IsCuratedPageLayoutConfigDrift(err) {
		t.Fatalf("expected config drift error, got: %v", err)
	}
}

// F4 — parent-filter: wrong-parent rule MUST NOT fire drift.
func TestSeedSentinelFieldsCompareFiltersByParent(t *testing.T) {
	t.Run("both parents in rules + R1 equal Fields → nil", func(t *testing.T) {
		db, mock, err := sqlmock.New()
		if err != nil {
			t.Fatalf("sqlmock: %v", err)
		}
		defer db.Close()

		expected := baseExpectedLayoutCampaign()
		live := baseExpectedLayoutCampaign()
		live.RelatedLists = []models.RelatedListConfig{
			{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
		}
		mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

		rules := []metadata.RelatedListRule{
			{ParentObjectAPI: "Campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: []string{"Name"}},
			{ParentObjectAPI: "Lead", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: []string{"Name", "Email"}},
		}
		if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign"); err != nil {
			t.Errorf("expected no drift (Campaign rule Fields match), got: %v", err)
		}
	})

	t.Run("both parents + R1 differing Fields → drift", func(t *testing.T) {
		db, mock, err := sqlmock.New()
		if err != nil {
			t.Fatalf("sqlmock: %v", err)
		}
		defer db.Close()

		expected := baseExpectedLayoutCampaign()
		live := baseExpectedLayoutCampaign()
		live.RelatedLists = []models.RelatedListConfig{
			{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
		}
		mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

		rules := []metadata.RelatedListRule{
			{ParentObjectAPI: "Campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: []string{"Name", "Status"}},
			{ParentObjectAPI: "Lead", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: []string{"Name"}},
		}
		err = verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign")
		if err == nil {
			t.Fatal("expected drift error, got nil")
		}
		if !IsCuratedPageLayoutConfigDrift(err) {
			t.Fatalf("expected config drift error, got: %v", err)
		}
	})

	t.Run("only wrong-parent rule present → nil", func(t *testing.T) {
		db, mock, err := sqlmock.New()
		if err != nil {
			t.Fatalf("sqlmock: %v", err)
		}
		defer db.Close()

		expected := baseExpectedLayoutCampaign()
		live := baseExpectedLayoutCampaign()
		live.RelatedLists = []models.RelatedListConfig{
			{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
		}
		mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

		// Only Lead has a rule; Campaign parent is absent.
		rules := []metadata.RelatedListRule{
			{ParentObjectAPI: "Lead", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: []string{"Name", "Status"}},
		}
		if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign"); err != nil {
			t.Errorf("expected no drift (wrong-parent rule ignored), got: %v", err)
		}
	})
}

// F6 — tenant-only rules invisible to axis 3 (empty pack rules slice).
func TestSeedSentinelTenantRuleNotPropagatedThroughAxis3(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = []models.RelatedListConfig{
		{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"Name"}},
	}
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	// Empty pack rules — tenant rules are not passed in this slice.
	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, nil, "Campaign"); err != nil {
		t.Errorf("expected no drift (no pack rules), got: %v", err)
	}
}

// F13 — rule.Fields empty or nil does NOT trigger false drift (round-2 gap-closing).
func TestSeedSentinelFieldsCompareEmptyRuleNoFalseDrift(t *testing.T) {
	t.Run("Fields is nil", func(t *testing.T) {
		db, mock, err := sqlmock.New()
		if err != nil {
			t.Fatalf("sqlmock: %v", err)
		}
		defer db.Close()

		expected := baseExpectedLayoutCampaign()
		live := baseExpectedLayoutCampaign()
		live.RelatedLists = []models.RelatedListConfig{
			{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"A", "B"}},
		}
		mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

		rules := []metadata.RelatedListRule{
			{ParentObjectAPI: "Campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: nil},
		}
		// Issue body Goal #1: "non-empty pack-rule Fields" — nil Fields is not authoring.
		if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign"); err != nil {
			t.Errorf("expected no drift (nil rule Fields), got: %v", err)
		}
	})

	t.Run("Fields is empty slice", func(t *testing.T) {
		db, mock, err := sqlmock.New()
		if err != nil {
			t.Fatalf("sqlmock: %v", err)
		}
		defer db.Close()

		expected := baseExpectedLayoutCampaign()
		live := baseExpectedLayoutCampaign()
		live.RelatedLists = []models.RelatedListConfig{
			{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Fields: []string{"A", "B"}},
		}
		mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

		rules := []metadata.RelatedListRule{
			{ParentObjectAPI: "Campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended, Fields: []string{}},
		}
		// Issue body Goal #1: "non-empty pack-rule Fields" — empty slice is not authoring.
		if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign"); err != nil {
			t.Errorf("expected no drift (empty rule Fields slice), got: %v", err)
		}
	})
}

// F12 — drift error message points at `apply` NOT `apply-packs`.
func TestCuratedPageLayoutConfigDriftErrorPointsAtApply(t *testing.T) {
	err := &curatedPageLayoutConfigDriftError{logName: "Campaign"}
	msg := err.Error()
	if !strings.Contains(msg, "go run ./cmd/setup apply") {
		t.Fatalf("expected error message to contain 'go run ./cmd/setup apply', got: %s", msg)
	}
	if strings.Contains(msg, "apply-packs") {
		t.Fatalf("expected error message NOT to contain 'apply-packs', got: %s", msg)
	}
}

// TestSeedSentinelLivePopulatedNoBlock verifies that axis 3 is not triggered
// when the live layout already has at least one RelatedList entry.
func TestSeedSentinelLivePopulatedNoBlock(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = []models.RelatedListConfig{
		{ID: "CampaignMember-CampaignId", Label: "Campaign Members", ObjectAPIName: "CampaignMember", LookupField: "CampaignId"},
	}

	// Only the LoadPageLayoutRow query; axis 3 short-circuits on len(live)>0.
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, nil, "Campaign"); err != nil {
		t.Errorf("expected no drift (live has entries, axis 3 skipped), got: %v", err)
	}
}

// TestSeedSentinelNoCandidatesNoBlock verifies that axis 3 does NOT block
// when the live layout is empty AND the DB yields no related-list candidates.
// "DB also empty → no smoke regression to flag."
func TestSeedSentinelNoCandidatesNoBlock(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = nil

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))
	// Axis 3: no name-field rows.
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockNameFieldRows())
	// Axis 3: no candidates from DB.
	mock.ExpectQuery(`SELECT`).WillReturnRows(emptyCandidateRows())

	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, nil, "Campaign"); err != nil {
		t.Errorf("expected no drift (no DB candidates → no sentinel block), got: %v", err)
	}
}

// TestSeedSentinelOnlyNonRecommendedCandidatesNoBlock verifies that axis 3 does
// NOT block when the DB has candidates but the rules filter all of them to
// IsRecommended=false (i.e., BucketExcluded or BucketOther).
func TestSeedSentinelOnlyNonRecommendedCandidatesNoBlock(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = nil

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockNameFieldRows())
	// One candidate from DB.
	mock.ExpectQuery(`SELECT`).WillReturnRows(oneCandidateRows("CampaignId", "Campaign", "CampaignMember", "Campaign Members"))

	// Pass a rule that sets the candidate to BucketSystem (IsRecommended=false).
	rules := []metadata.RelatedListRule{
		{
			ParentObjectAPI: "Campaign",
			ChildObjectAPI:  "CampaignMember",
			LookupField:     "CampaignId",
			Bucket:          metadata.BucketSystem,
		},
	}

	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign"); err != nil {
		t.Errorf("expected no drift (all candidates non-recommended), got: %v", err)
	}
}

// TestSeedSentinelInactiveWhenPayloadAuthored verifies that axis 3 is NOT reached
// when payloadAuthoredRelatedLists is non-empty. In that case, axis 2 handles
// RelatedLists validation (and fires first if live is empty).
func TestSeedSentinelInactiveWhenPayloadAuthored(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	payloadRL := []models.RelatedListConfig{
		{ObjectAPIName: "CampaignMember", LookupField: "CampaignId", Label: "Campaign Members"},
	}
	expected := baseExpectedLayoutCampaign()
	expected.RelatedLists = payloadRL

	// Live has no RelatedLists — axis 2 fires (missing payload entry).
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = nil

	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", false))

	// Axis 2 should detect the missing entry; axis 3 should NOT be reached
	// (no extra SQL expectations needed).
	rules := []metadata.RelatedListRule{
		{ParentObjectAPI: "Campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended},
	}
	err = verifyCuratedPageLayoutConfig(context.Background(), db, expected, payloadRL, rules, "Campaign")
	if err == nil {
		t.Fatal("expected drift error (axis 2 missing entry), got nil")
	}
	if !IsCuratedPageLayoutConfigDrift(err) {
		t.Fatalf("expected config drift error, got: %v", err)
	}
}

// TestSeedSentinelAdminOwnedBypass verifies that IsCustomized=true causes an
// early return before any axis fires (including axis 3).
func TestSeedSentinelAdminOwnedBypass(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock: %v", err)
	}
	defer db.Close()

	expected := baseExpectedLayoutCampaign()
	// Live is admin-owned, completely empty RelatedLists.
	live := baseExpectedLayoutCampaign()
	live.RelatedLists = nil

	// IsCustomized=true → admin-owned bypass engages; no axis runs.
	mock.ExpectQuery(`SELECT`).WillReturnRows(mockLayoutRow("layout-campaign-curated", marshalLayout(t, live), "curated", true))

	rules := []metadata.RelatedListRule{
		{ParentObjectAPI: "Campaign", ChildObjectAPI: "CampaignMember", LookupField: "CampaignId", Bucket: metadata.BucketRecommended},
	}
	if err := verifyCuratedPageLayoutConfig(context.Background(), db, expected, nil, rules, "Campaign"); err != nil {
		t.Errorf("expected nil (admin-owned bypass), got: %v", err)
	}
}
