package metadata

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"maps"
	"strings"

	apperrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/jsondocument"
	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/kernel/pkg/schema"
	"github.com/nexuscrm/kernel/pkg/schemautil"
	"github.com/nexuscrm/metadata/catalogsnapshot"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	commonsemantic "github.com/pingcap-inc/nexuscrm-contract/pkg/runtime/commonv1"
	layoutauthoringv1 "github.com/pingcap-inc/nexuscrm-contract/pkg/runtime/layoutauthoringv1"
	layoutauthoringv2 "github.com/pingcap-inc/nexuscrm-contract/pkg/runtime/layoutauthoringv2"
)

// currentGenerationVersion is retained only for legacy auto_generated rows and
// tests that exercise old row states. Layout reads no longer regenerate rows.
// v4: add Activity tab to all standard object layouts (Ref #183)
// v5: historical standardObjectSections migration
// v6: exclude Password + security-internal fields (FailedLoginAttempts, LockedUntil, LastLoginDate) from layouts
// v7: historical related-list migration
// v8: historical layout field migration
// v9: exclude autonumber fields from editable layout sections (issue #1104)
const currentGenerationVersion = 9

func normalizeAssignmentRouteSource(routeSource *string) *string {
	if routeSource == nil {
		return nil
	}
	trimmed := strings.TrimSpace(*routeSource)
	if trimmed == "" {
		return nil
	}
	return &trimmed
}

// --- Layout Methods ---

// ResolveLayout resolves the best layout for an object given user context.
// Uses specificity-based assignment lookup over authored metadata only.
// Always resolves the single default layout — mode (View/Edit/Create) is a response-time
// concern handled by the service layer, not a resolution concern.
//
// This is intentionally a read path: it does not auto-generate layouts, create
// runtime_fallback assignments, update GenerationVersion, write history, or
// delete orphan assignments. Missing layout compatibility for describe-layout
// lives in ResolveLayoutForDescribe as a non-persisted structural projection.
func (ms *MetadataService) ResolveLayout(ctx context.Context, apiName string, roleAncestors []string, profileID string, recordTypeID string) *models.PageLayout {
	// Ensure cache is initialized for schema fallback (virtual objects)
	if err := ms.ensureCoreCacheInitialized(ctx); err != nil {
		log.Printf("⚠️ Failed to initialize core cache in ResolveLayout: %v", err)
	}

	ms.mu.RLock()
	defer ms.mu.RUnlock()

	// 1. Find best assignment via specificity-based resolution
	assignment, err := ms.repo.FindBestAssignment(ctx, apiName, roleAncestors, profileID, recordTypeID)
	if err != nil {
		log.Printf("⚠️ FindBestAssignment failed for %s: %v", apiName, err)
	}

	var layout *models.PageLayout

	if assignment != nil {
		layout, err = ms.repo.GetLayout(ctx, assignment.LayoutID)
		if err != nil {
			log.Printf("⚠️ GetLayout failed for assignment %s -> layout %s: %v", assignment.ID, assignment.LayoutID, err)
		}
		// EC-1: Assignment points to soft-deleted layout. Fall through to
		// existing-layout fallback without mutating repository state; orphan
		// cleanup belongs to delete/reconcile paths, not describe reads.
		if layout == nil {
			log.Printf("⚠️ Layout %s referenced by assignment %s is deleted/missing; ignoring orphaned assignment", assignment.LayoutID, assignment.ID)
			assignment = nil
		}
	}

	// 2. No assignment or orphaned assignment — prefer an existing persisted
	// authored/legacy layout. The fallback is read-only and never creates a
	// runtime assignment.
	if layout == nil {
		layouts, lErr := ms.repo.GetLayouts(ctx, apiName)
		if lErr != nil {
			log.Printf("⚠️ GetLayouts failed for %s: %v", apiName, lErr)
		} else if len(layouts) > 0 && layouts[0] != nil {
			layout = layouts[0]
		}
	}

	// Related lists are populated at bootstrap time by BackfillLayoutRelatedLists.
	// Runtime repair only fixes stale persisted entries (labels, fields, campaign hierarchy)
	// — it does NOT add new relationships.
	if layout != nil {
		ms.repairLayoutRelatedLists(ctx, layout)
		// Layer 3a (issue #1104 / PR #1118): read-time autonumber scrubber.
		// Strips autonumber fields from Sections[*].Fields on every served
		// layout. Covers curated layouts (Source="curated"; never enter the
		// regen branch) and post-install drift from ApplyAutoNumberFormatSeeds
		// (resolver set flips without GenerationVersion change).
		ms.stripAutonumberSectionFields(ctx, layout)
	}

	return layout
}

// ResolveCurrentPageLayouts resolves every requested RecordType from one
// transaction-consistent object workspace. Database work stays constant when
// object describe asks for many RecordTypes.
func (ms *MetadataService) ResolveCurrentPageLayouts(
	ctx context.Context,
	apiName string,
	roleAncestors []string,
	profileID string,
	recordTypeIDs []string,
) (map[string]*models.PageLayout, error) {
	if ms == nil || ms.repo == nil || ms.txManager == nil {
		return nil, catalogsnapshot.ErrIncompatible
	}
	reader, ok := ms.repo.(interface {
		layoutAuthoringAggregateReader
		ReadAutoNumberFieldNames(context.Context, string) (map[string]struct{}, error)
	})
	if !ok {
		return nil, catalogsnapshot.ErrIncompatible
	}
	result := make(map[string]*models.PageLayout, len(recordTypeIDs))
	err := ms.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
		aggregate, err := reader.ReadLayoutAuthoringAggregate(txCtx, apiName)
		if err != nil {
			return err
		}
		autoFields, err := reader.ReadAutoNumberFieldNames(txCtx, aggregate.ObjectAPIName)
		if err != nil {
			return err
		}
		layouts := make(map[string]*models.PageLayout, len(aggregate.Layouts))
		for _, layout := range aggregate.Layouts {
			if layout != nil && !layout.IsDeleted && strings.EqualFold(layout.ObjectAPIName, apiName) {
				layouts[strings.ToLower(layout.ID)] = layout
			}
		}
		roleDistance := make(map[string]int, len(roleAncestors))
		for index, roleID := range roleAncestors {
			key := strings.ToLower(roleID)
			if _, exists := roleDistance[key]; !exists {
				roleDistance[key] = index
			}
		}
		resolvedLayouts := make(map[string]struct{})
		for _, recordTypeID := range recordTypeIDs {
			assignment := bestCurrentPageLayoutAssignment(
				aggregate.Assignments, profileID, roleDistance, recordTypeID,
			)
			if assignment == nil {
				return catalogsnapshot.ErrPageLayoutNotFound
			}
			layout := layouts[strings.ToLower(assignment.LayoutID)]
			if layout == nil {
				return catalogsnapshot.ErrPageLayoutNotFound
			}
			if _, scrubbed := resolvedLayouts[strings.ToLower(layout.ID)]; !scrubbed {
				filterAutonumberSectionFields(layout, func(name string) bool {
					_, found := autoFields[strings.ToLower(name)]
					return found
				})
				resolvedLayouts[strings.ToLower(layout.ID)] = struct{}{}
			}
			result[recordTypeID] = layout
		}
		return nil
	})
	return result, err
}

func bestCurrentPageLayoutAssignment(
	assignments []*models.LayoutAssignment,
	profileID string,
	roleDistance map[string]int,
	recordTypeID string,
) *models.LayoutAssignment {
	var selected *models.LayoutAssignment
	selectedRoleDistance := 0
	for _, candidate := range assignments {
		if candidate == nil ||
			candidate.ProfileID != nil && !strings.EqualFold(*candidate.ProfileID, profileID) ||
			candidate.RecordTypeID != nil && !strings.EqualFold(*candidate.RecordTypeID, recordTypeID) {
			continue
		}
		candidateRoleDistance := 0
		if candidate.RoleID != nil {
			var found bool
			candidateRoleDistance, found = roleDistance[strings.ToLower(*candidate.RoleID)]
			if !found {
				continue
			}
		}
		if selected == nil || betterCurrentPageLayoutAssignment(
			candidate, candidateRoleDistance, selected, selectedRoleDistance,
		) {
			selected, selectedRoleDistance = candidate, candidateRoleDistance
		}
	}
	return selected
}

func betterCurrentPageLayoutAssignment(
	candidate *models.LayoutAssignment,
	candidateRoleDistance int,
	selected *models.LayoutAssignment,
	selectedRoleDistance int,
) bool {
	if candidate.Specificity != selected.Specificity {
		return candidate.Specificity > selected.Specificity
	}
	if (candidate.RecordTypeID != nil) != (selected.RecordTypeID != nil) {
		return candidate.RecordTypeID != nil
	}
	if (candidate.ProfileID != nil) != (selected.ProfileID != nil) {
		return candidate.ProfileID != nil
	}
	if (candidate.RoleID != nil) != (selected.RoleID != nil) {
		return candidate.RoleID != nil
	}
	if candidate.RoleID != nil && candidateRoleDistance != selectedRoleDistance {
		return candidateRoleDistance < selectedRoleDistance
	}
	if !candidate.CreatedDate.Equal(selected.CreatedDate) {
		return candidate.CreatedDate.Before(selected.CreatedDate)
	}
	return candidate.ID < selected.ID
}

// ResolveLayoutForDescribe is the single describe-layout read path. It returns
// authored metadata when available; otherwise it returns a non-persisted,
// recordType-uniform structural projection so v63 describe-layout clients keep
// receiving a dereferenceable 200 response while L1 packs author real layouts.
func (ms *MetadataService) ResolveLayoutForDescribe(ctx context.Context, apiName string, roleAncestors []string, profileID string, recordTypeID string) *models.PageLayout {
	if layout := ms.ResolveLayout(ctx, apiName, roleAncestors, profileID, recordTypeID); layout != nil {
		return layout
	}
	schema := ms.resolveSchema(ctx, apiName)
	if schema == nil {
		return nil
	}
	return ms.BuildStructuralLayoutProjection(ctx, schema)
}

// resolveSchema finds the object schema from cache or DB.
func (ms *MetadataService) resolveSchema(ctx context.Context, apiName string) *models.ObjectMetadata {
	snap := ms.loadCoreCache()
	if snap != nil {
		if s, ok := snap.schemaMap[strings.ToLower(apiName)]; ok {
			return s
		}
	}
	s, err := ms.repo.GetSchemaByAPIName(ctx, apiName)
	if err == nil && s != nil {
		return s
	}
	return nil
}

// WriteLayoutHistory appends a version snapshot to the layout history table (public wrapper).
func (ms *MetadataService) WriteLayoutHistory(ctx context.Context, layoutID string, reason string, changedByID *string) {
	ms.writeHistory(ctx, layoutID, reason, changedByID)
}

type layoutAuthoringListFieldsWriter interface {
	SetLayoutAuthoringListFields(context.Context, string, []string) error
}

type currentObjectListFieldsReader interface {
	ReadCurrentObjectListFields(context.Context, []string) (map[string][]string, error)
}

// ReadCurrentObjectListFields returns the database-owned ordered presentation
// for ordinary page reads. It bypasses the immutable Catalog snapshot and does
// not initialize or mutate a process-local metadata cache.
func (ms *MetadataService) ReadCurrentObjectListFields(
	ctx context.Context,
	objectAPIName []string,
) (map[string][]string, error) {
	if ms == nil || ms.repo == nil {
		return nil, catalogsnapshot.ErrIncompatible
	}
	reader, ok := ms.repo.(currentObjectListFieldsReader)
	if !ok {
		return nil, catalogsnapshot.ErrIncompatible
	}
	return reader.ReadCurrentObjectListFields(ctx, objectAPIName)
}

type layoutAuthoringAggregateLocker interface {
	LockLayoutAuthoringAggregate(context.Context, string) (*models.LayoutAuthoringAggregate, error)
}

type layoutAuthoringAggregateReader interface {
	ReadLayoutAuthoringAggregate(context.Context, string) (*models.LayoutAuthoringAggregate, error)
}

type layoutAuthoringSchemaReader interface {
	GetSchemaByAPIName(context.Context, string) (*models.ObjectMetadata, error)
}

type layoutAuthoringRecordTypeLocker interface {
	LockLayoutAuthoringRecordTypes(context.Context, []string) ([]*models.RecordType, error)
}

type layoutAuthoringSchemaReferenceLocker interface {
	LockLayoutAuthoringSchemaReferences(context.Context, []schema.IncarnationReference) error
}

type layoutAuthoringHistoryLocker interface {
	LockLayoutHistoryConfig(context.Context, string, int) (string, error)
}

type layoutAuthoringCreator interface {
	CreateLayoutForAdministration(context.Context, *models.PageLayout) error
}

type layoutAuthoringUpdater interface {
	SaveLayoutForAdministration(context.Context, *models.PageLayout) error
}

type layoutAuthoringDeleter interface {
	DeleteLayoutForAdministration(context.Context, string) error
}

type layoutAuthoringAssignmentDeleter interface {
	DeleteAssignmentForAdministration(context.Context, string) error
}

// CheckLayoutAuthoringAdministrationReady proves that this service owns the
// repository-backed write path and the transaction owner required by the
// synchronous Layout service. A request-bound RuntimeMetadataService is a serving
// reader and must never be accepted as an authoring owner.
func (ms *MetadataService) CheckLayoutAuthoringAdministrationReady() error {
	if ms == nil || ms.repo == nil || ms.txManager == nil || ms.UsesRequestBoundRuntimeView() {
		return errors.New("Layout authoring administration requires a repository-backed transactional metadata owner")
	}
	capabilities := []struct {
		name      string
		available bool
	}{
		{name: "aggregate lock", available: implementsLayoutAuthoringCapability[layoutAuthoringAggregateLocker](ms.repo)},
		{name: "schema incarnation locks", available: implementsLayoutAuthoringCapability[layoutAuthoringSchemaReferenceLocker](ms.repo)},
		{name: "RecordType lifecycle locks", available: implementsLayoutAuthoringCapability[layoutAuthoringRecordTypeLocker](ms.repo)},
		{name: "history lock", available: implementsLayoutAuthoringCapability[layoutAuthoringHistoryLocker](ms.repo)},
		{name: "strict create", available: implementsLayoutAuthoringCapability[layoutAuthoringCreator](ms.repo)},
		{name: "strict update", available: implementsLayoutAuthoringCapability[layoutAuthoringUpdater](ms.repo)},
		{name: "strict delete", available: implementsLayoutAuthoringCapability[layoutAuthoringDeleter](ms.repo)},
		{name: "strict assignment delete", available: implementsLayoutAuthoringCapability[layoutAuthoringAssignmentDeleter](ms.repo)},
		{name: "ordered ListFields", available: implementsLayoutAuthoringCapability[layoutAuthoringListFieldsWriter](ms.repo)},
	}
	for _, capability := range capabilities {
		if !capability.available {
			return fmt.Errorf("Layout authoring administration %s capability is unavailable", capability.name)
		}
	}
	return nil
}

func implementsLayoutAuthoringCapability[T any](value any) bool {
	_, ok := value.(T)
	return ok
}

// LayoutAuthoringVersionPreconditions names only versions already required by
// the accepted operation. Nil maps/pointers mean that version family is not a
// precondition for that operation.
type LayoutAuthoringVersionPreconditions struct {
	WorkspaceVersion           *string
	LayoutResourceVersions     map[string]string
	LayoutAggregateVersions    map[string]string
	AssignmentResourceVersions map[string]string
}

// LayoutAuthoringAggregateSnapshot is the complete transaction-current Core
// state plus the database-owned versions derived from exactly that state. A
// mutation uses this post-lock snapshot as its sole transition input;
// it must not re-read through presentation repositories before applying the
// mutation.
type LayoutAuthoringAggregateSnapshot struct {
	ObjectAPIName        string
	OrderedListFields    []string
	RequiredCreateFields []string
	Layouts              []*models.PageLayout
	Assignments          []*models.LayoutAssignment
	Versions             catalogsnapshot.LayoutAuthoringVersions
}

// ReadLayoutAuthoringAggregate returns one transaction-consistent current
// workspace for administration reads. It has no Catalog lookup or projection
// fallback; WorkspaceVersion is the database-owned Layout revision.
func (ms *MetadataService) ReadLayoutAuthoringAggregate(
	ctx context.Context,
	objectAPIName string,
) (LayoutAuthoringAggregateSnapshot, error) {
	if ms == nil || ms.txManager == nil {
		return LayoutAuthoringAggregateSnapshot{}, apperrors.NewInternalError(
			"Layout authoring read requires a transaction owner", nil,
		)
	}
	reader, ok := ms.repo.(layoutAuthoringAggregateReader)
	if !ok {
		return LayoutAuthoringAggregateSnapshot{}, apperrors.NewInternalError(
			"Layout authoring aggregate reader is unavailable", nil,
		)
	}
	schemaReader, ok := ms.repo.(layoutAuthoringSchemaReader)
	if !ok {
		return LayoutAuthoringAggregateSnapshot{}, apperrors.NewInternalError(
			"Layout authoring schema reader is unavailable", nil,
		)
	}
	var result LayoutAuthoringAggregateSnapshot
	err := ms.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
		aggregate, err := reader.ReadLayoutAuthoringAggregate(txCtx, objectAPIName)
		if err != nil {
			return err
		}
		object, err := schemaReader.GetSchemaByAPIName(txCtx, aggregate.ObjectAPIName)
		if err != nil {
			return err
		}
		if object == nil {
			return apperrors.NewNotFoundError("Object Metadata", aggregate.ObjectAPIName)
		}
		versions, err := catalogsnapshot.ComputeLayoutAuthoringVersions(
			aggregate.ObjectAPIName, aggregate.OrderedListFields, aggregate.Layouts, aggregate.Assignments,
		)
		if err != nil {
			return err
		}
		result = LayoutAuthoringAggregateSnapshot{
			ObjectAPIName:        aggregate.ObjectAPIName,
			OrderedListFields:    append([]string(nil), aggregate.OrderedListFields...),
			RequiredCreateFields: schemautil.RequiredCreateInputFields(object),
			Layouts:              aggregate.Layouts, Assignments: aggregate.Assignments, Versions: versions,
		}
		return nil
	})
	return result, err
}

// LockLayoutAuthoringAggregate locks and returns the complete object-scoped
// aggregate, derives its database workspace revision, and rejects stale
// preconditions. Returning the facts with their versions prevents a caller
// from validating one state and computing its post-image from another read.
func (ms *MetadataService) LockLayoutAuthoringAggregate(
	ctx context.Context,
	objectAPIName string,
	expected LayoutAuthoringVersionPreconditions,
) (LayoutAuthoringAggregateSnapshot, error) {
	if ms.txManager == nil || !ms.txManager.IsTransactionActive(ctx) || !ms.txManager.IsPessimisticTx(ctx) {
		return LayoutAuthoringAggregateSnapshot{}, apperrors.NewInternalError(
			"Layout authoring version checks require the owning pessimistic transaction", nil,
		)
	}
	aggregate, err := ms.lockStoredLayoutAuthoringAggregate(ctx, objectAPIName)
	if err != nil {
		return LayoutAuthoringAggregateSnapshot{}, err
	}
	actual, err := validateLayoutAuthoringVersionPreconditions(aggregate, expected)
	if err != nil {
		return LayoutAuthoringAggregateSnapshot{}, err
	}
	return LayoutAuthoringAggregateSnapshot{
		ObjectAPIName: aggregate.ObjectAPIName, OrderedListFields: aggregate.OrderedListFields,
		Layouts: aggregate.Layouts, Assignments: aggregate.Assignments, Versions: actual,
	}, nil
}

func validateLayoutAuthoringVersionPreconditions(
	aggregate *models.LayoutAuthoringAggregate,
	expected LayoutAuthoringVersionPreconditions,
) (catalogsnapshot.LayoutAuthoringVersions, error) {
	actual, err := catalogsnapshot.ComputeLayoutAuthoringVersions(
		aggregate.ObjectAPIName, aggregate.OrderedListFields, aggregate.Layouts, aggregate.Assignments,
	)
	if err != nil {
		return catalogsnapshot.LayoutAuthoringVersions{}, err
	}
	if expected.WorkspaceVersion != nil && *expected.WorkspaceVersion != actual.WorkspaceVersion {
		return catalogsnapshot.LayoutAuthoringVersions{}, apperrors.NewConflictErrorWithReason(
			"LayoutWorkspace", commonsemantic.ConflictRevisionMismatch, "The layout configuration has changed. Reload the latest layout before saving.",
		)
	}
	if err := requireLayoutAuthoringVersions(
		"PageLayout", "resourceVersion", expected.LayoutResourceVersions, actual.LayoutResourceVersions,
	); err != nil {
		return catalogsnapshot.LayoutAuthoringVersions{}, err
	}
	if err := requireLayoutAuthoringVersions(
		"PageLayout", "aggregateVersion", expected.LayoutAggregateVersions, actual.LayoutAggregateVersions,
	); err != nil {
		return catalogsnapshot.LayoutAuthoringVersions{}, err
	}
	if err := requireLayoutAuthoringVersions(
		"LayoutAssignment", "resourceVersion", expected.AssignmentResourceVersions, actual.AssignmentResourceVersions,
	); err != nil {
		return catalogsnapshot.LayoutAuthoringVersions{}, err
	}
	return actual, nil
}

func requireLayoutAuthoringVersions(resourceType, field string, expected, actual map[string]string) error {
	for id, version := range expected {
		if strings.TrimSpace(version) == "" {
			return apperrors.NewValidationError(field, "expected version is required")
		}
		actualVersion, ok := actual[id]
		if !ok {
			return apperrors.NewNotFoundError(resourceType, id)
		}
		if version != actualVersion {
			return apperrors.NewConflictErrorWithReason(resourceType, commonsemantic.ConflictRevisionMismatch, "The layout configuration has changed. Reload the latest layout before saving.")
		}
	}
	return nil
}

// CreateLayoutForAdministration rejects an existing target identity instead
// of letting the legacy save/upsert route reinterpret create as update.
func (ms *MetadataService) CreateLayoutForAdministration(
	ctx context.Context,
	layout *models.PageLayout,
	listFields *[]string,
	reason string,
	changedByID *string,
) error {
	return ms.saveLayoutForAdministration(ctx, layout, "", listFields, reason, changedByID, true)
}

// SaveLayoutForAdministration validates the database-owned aggregate version,
// then persists the Layout document, its strict history snapshot, and an
// optional ordered ListFields change under the same pessimistic aggregate
// lock. When Runtime already owns that transaction, the configured
// TransactionManager reuses it; this method never opens a nested independent
// commit.
func (ms *MetadataService) SaveLayoutForAdministration(
	ctx context.Context,
	layout *models.PageLayout,
	expectedAggregateVersion string,
	listFields *[]string,
	reason string,
	changedByID *string,
) error {
	if layout == nil {
		return apperrors.NewValidationError("layout", "page layout is required")
	}
	if strings.TrimSpace(expectedAggregateVersion) == "" {
		return apperrors.NewValidationError("expectedAggregateVersion", "Layout authoring update requires an expected aggregate version")
	}
	return ms.saveLayoutForAdministration(ctx, layout, expectedAggregateVersion, listFields, reason, changedByID, false)
}

// SaveLayoutListFieldsForAdministration changes only the object-level ordered
// list presentation. Layout content, history, and ownership stay untouched
// when an editor save contains no Layout document change.
func (ms *MetadataService) SaveLayoutListFieldsForAdministration(
	ctx context.Context,
	objectAPIName string,
	fields []string,
) error {
	return ms.runLayoutAdministrationTransaction(ctx, func(txCtx context.Context) error {
		ms.mu.Lock()
		defer ms.mu.Unlock()
		writer, ok := ms.repo.(layoutAuthoringListFieldsWriter)
		if !ok {
			return apperrors.NewInternalError("Layout authoring ListFields writer is unavailable", nil)
		}
		ordered := append([]string(nil), fields...)
		if ordered == nil {
			ordered = []string{}
		}
		return writer.SetLayoutAuthoringListFields(txCtx, objectAPIName, ordered)
	})
}

func (ms *MetadataService) saveLayoutForAdministration(
	ctx context.Context,
	layout *models.PageLayout,
	expectedAggregateVersion string,
	listFields *[]string,
	reason string,
	changedByID *string,
	requireCreate bool,
) error {
	if layout == nil {
		return apperrors.NewValidationError("layout", "page layout is required")
	}
	return ms.runLayoutAdministrationTransaction(ctx, func(txCtx context.Context) error {
		ms.mu.Lock()
		defer ms.mu.Unlock()
		aggregate, err := ms.lockStoredLayoutAuthoringAggregate(txCtx, layout.ObjectAPIName)
		if err != nil {
			return err
		}
		layout.ObjectAPIName = aggregate.ObjectAPIName
		if requireCreate {
			creator, ok := ms.repo.(layoutAuthoringCreator)
			if !ok {
				return apperrors.NewInternalError("strict Layout authoring create route is unavailable", nil)
			}
			if err := creator.CreateLayoutForAdministration(txCtx, layout); err != nil {
				return err
			}
		} else {
			if _, err := validateLayoutAuthoringVersionPreconditions(aggregate, LayoutAuthoringVersionPreconditions{
				LayoutAggregateVersions: map[string]string{layout.ID: expectedAggregateVersion},
			}); err != nil {
				return err
			}
			updater, ok := ms.repo.(layoutAuthoringUpdater)
			if !ok {
				return apperrors.NewInternalError("strict Layout authoring update route is unavailable", nil)
			}
			if err := updater.SaveLayoutForAdministration(txCtx, layout); err != nil {
				return err
			}
		}
		if err := ms.writeHistoryStrict(txCtx, layout.ID, reason, changedByID); err != nil {
			return err
		}
		if listFields == nil {
			return nil
		}
		writer, ok := ms.repo.(layoutAuthoringListFieldsWriter)
		if !ok {
			return apperrors.NewInternalError("Layout authoring ListFields writer is unavailable", nil)
		}
		ordered := append([]string(nil), (*listFields)...)
		if ordered == nil {
			ordered = []string{}
		}
		return writer.SetLayoutAuthoringListFields(txCtx, aggregate.ObjectAPIName, ordered)
	})
}

// RestoreLayoutForAdministration restores the complete authored document and
// appends the restore history row, attributed to the authenticated actor, in
// the same transaction.
func (ms *MetadataService) RestoreLayoutForAdministration(
	ctx context.Context,
	layoutID string,
	versionNumber int,
	changedByID string,
) error {
	changedByID = strings.TrimSpace(changedByID)
	if changedByID == "" {
		return apperrors.NewInternalError("Layout administration restore requires an authenticated actor", nil)
	}
	return ms.runLayoutAdministrationTransaction(ctx, func(txCtx context.Context) error {
		ms.mu.Lock()
		defer ms.mu.Unlock()
		discovered, err := ms.readStoredLayout(txCtx, layoutID)
		if err != nil {
			return err
		}
		if discovered == nil {
			return apperrors.NewNotFoundError("Layout", layoutID)
		}
		aggregate, err := ms.lockStoredLayoutAuthoringAggregate(txCtx, discovered.ObjectAPIName)
		if err != nil {
			return err
		}
		current := layoutFromAuthoringAggregate(aggregate, layoutID)
		if current == nil {
			return apperrors.NewNotFoundError("Layout", layoutID)
		}
		if err := ms.restoreLayoutForAdministration(txCtx, current, versionNumber); err != nil {
			return err
		}
		return ms.writeHistoryStrict(txCtx, layoutID, fmt.Sprintf("restored_from_v%d", versionNumber), &changedByID)
	})
}

// DeleteLayoutForAdministration owns the complete delete invariant: default
// and specifically assigned Layouts are rejected, while wildcard cleanup and
// the tombstone share the caller's transaction.
func (ms *MetadataService) DeleteLayoutForAdministration(ctx context.Context, layoutID string) error {
	return ms.runLayoutAdministrationTransaction(ctx, func(txCtx context.Context) error {
		ms.mu.Lock()
		defer ms.mu.Unlock()
		discovered, err := ms.readStoredLayout(txCtx, layoutID)
		if err != nil {
			return err
		}
		if discovered == nil {
			return apperrors.NewNotFoundError("Layout", layoutID)
		}
		aggregate, err := ms.lockStoredLayoutAuthoringAggregate(txCtx, discovered.ObjectAPIName)
		if err != nil {
			return err
		}
		layout := layoutFromAuthoringAggregate(aggregate, layoutID)
		if layout == nil {
			return apperrors.NewNotFoundError("Layout", layoutID)
		}
		if layout.IsDefault {
			return apperrors.NewConflictErrorWithReason("PageLayout", commonsemantic.ConflictDefaultRequired, "Choose another default layout before deleting this layout.")
		}
		for _, assignment := range aggregate.Assignments {
			if assignment == nil || !strings.EqualFold(assignment.LayoutID, layout.ID) {
				continue
			}
			if assignment.RoleID == nil && assignment.ProfileID == nil && assignment.RecordTypeID == nil {
				return apperrors.NewConflictErrorWithReason("PageLayout", commonsemantic.ConflictDefaultRequired, "Choose another default layout before deleting this layout.")
			}
			return apperrors.NewConflictErrorWithReason("PageLayout", commonsemantic.ConflictReferenced, "This layout is still assigned. Remove its assignments before deleting it.")
		}
		deleter, ok := ms.repo.(layoutAuthoringDeleter)
		if !ok {
			return apperrors.NewInternalError("strict Layout administration delete route is unavailable", nil)
		}
		if err := deleter.DeleteLayoutForAdministration(txCtx, layout.ID); err != nil {
			return err
		}
		if err := ms.repo.DeleteAssignmentsByLayoutID(txCtx, layout.ID); err != nil {
			return fmt.Errorf("failed to clean up assignments for layout %s: %w", layout.ID, err)
		}
		return nil
	})
}

// DeleteAssignmentForAdministration removes one specific admin-authored
// routing override. Persistence owns the transaction-current wildcard and
// ownership guards; the generic legacy delete route is intentionally not used.
func (ms *MetadataService) DeleteAssignmentForAdministration(ctx context.Context, assignmentID string) error {
	return ms.runLayoutAdministrationTransaction(ctx, func(txCtx context.Context) error {
		ms.mu.Lock()
		defer ms.mu.Unlock()
		deleter, ok := ms.repo.(layoutAuthoringAssignmentDeleter)
		if !ok {
			return apperrors.NewInternalError("strict Layout assignment delete route is unavailable", nil)
		}
		return deleter.DeleteAssignmentForAdministration(txCtx, assignmentID)
	})
}

// LockLayoutAuthoringRecordTypes returns exact transaction-current selector
// facts. The repository shares its object-root lock protocol with RecordType
// lifecycle writers; callers must first lock the Layout aggregate for the
// owning object.
func (ms *MetadataService) LockLayoutAuthoringRecordTypes(
	ctx context.Context,
	recordTypeIDs []string,
) ([]*models.RecordType, error) {
	if ms.txManager == nil || !ms.txManager.IsTransactionActive(ctx) || !ms.txManager.IsPessimisticTx(ctx) {
		return nil, apperrors.NewInternalError(
			"Layout authoring RecordType checks require the owning pessimistic transaction", nil,
		)
	}
	locker, ok := ms.repo.(layoutAuthoringRecordTypeLocker)
	if !ok {
		return nil, apperrors.NewInternalError("Layout authoring RecordType lock authority is unavailable", nil)
	}
	return locker.LockLayoutAuthoringRecordTypes(ctx, recordTypeIDs)
}

// LockLayoutAuthoringSchemaReferences closes the transaction-current identity
// fence after current Runtime schema has resolved every reference.
func (ms *MetadataService) LockLayoutAuthoringSchemaReferences(
	ctx context.Context,
	references []schema.IncarnationReference,
) error {
	if ms.txManager == nil || !ms.txManager.IsTransactionActive(ctx) || !ms.txManager.IsPessimisticTx(ctx) {
		return apperrors.NewInternalError(
			"Layout authoring schema checks require the owning pessimistic transaction", nil,
		)
	}
	locker, ok := ms.repo.(layoutAuthoringSchemaReferenceLocker)
	if !ok {
		return apperrors.NewInternalError("Layout authoring schema incarnation lock authority is unavailable", nil)
	}
	return locker.LockLayoutAuthoringSchemaReferences(ctx, references)
}

// ValidateAndLockLayoutAuthoringReferences resolves authored names from the
// transaction-current schema and locks the exact incarnations before commit.
// It deliberately has no Catalog read or publication side effect.
func (ms *MetadataService) ValidateAndLockLayoutAuthoringReferences(
	ctx context.Context,
	document layoutauthoringv1.LayoutDocument,
	listFields []string,
	validateListFields bool,
	historyProjections ...layoutauthoringv2.HistoryRelatedListProjection,
) error {
	if ms.txManager == nil || !ms.txManager.IsTransactionActive(ctx) || !ms.txManager.IsPessimisticTx(ctx) {
		return apperrors.NewInternalError(
			"Layout schema checks require the owning pessimistic transaction", nil,
		)
	}
	objects, err := ms.loadLayoutAuthoringReferencedSchemas(ctx, document)
	if err != nil {
		return err
	}
	references, err := catalogsnapshot.ResolveLayoutAuthoringReferencesFromObjects(
		objects, document, listFields, validateListFields, historyProjections...,
	)
	if err != nil {
		return apperrors.NewValidationError("layout", err.Error())
	}
	return ms.LockLayoutAuthoringSchemaReferences(ctx, references)
}

// loadLayoutAuthoringReferencedSchemas reads only objects reachable from this
// Layout document. Validation therefore scales with the edited Layout instead
// of every object and field in the tenant.
func (ms *MetadataService) loadLayoutAuthoringReferencedSchemas(
	ctx context.Context,
	document layoutauthoringv1.LayoutDocument,
) ([]*models.ObjectMetadata, error) {
	reader, ok := ms.repo.(layoutAuthoringSchemaReader)
	if !ok {
		return nil, apperrors.NewInternalError("Layout authoring schema reader is unavailable", nil)
	}
	loaded := make(map[string]*models.ObjectMetadata)
	load := func(apiName string) (*models.ObjectMetadata, error) {
		key := strings.ToLower(strings.TrimSpace(apiName))
		if object, exists := loaded[key]; exists {
			return object, nil
		}
		object, err := reader.GetSchemaByAPIName(ctx, apiName)
		if err != nil {
			return nil, err
		}
		if object != nil {
			loaded[key] = object
		}
		return object, nil
	}
	root, err := load(document.ObjectID)
	if err != nil {
		return nil, err
	}
	if root != nil {
		paths := append([]string(nil), document.CompactFields...)
		for _, section := range document.Sections {
			paths = append(paths, section.Fields...)
		}
		for _, path := range paths {
			if err := loadLayoutFieldPathObjects(path, root, load); err != nil {
				return nil, err
			}
		}
	}
	for _, relatedList := range document.RelatedLists {
		if _, err := load(relatedList.ObjectID); err != nil {
			return nil, err
		}
	}
	for _, placement := range []layoutauthoringv1.ActionPlacement{document.HeaderActions, document.QuickActions} {
		for _, action := range placement.Actions {
			if action.TargetObjectID != nil {
				if _, err := load(*action.TargetObjectID); err != nil {
					return nil, err
				}
			}
		}
	}
	for _, state := range document.RecordStates {
		for _, link := range state.Links {
			if link.ObjectID != "" {
				if _, err := load(link.ObjectID); err != nil {
					return nil, err
				}
			}
		}
	}
	objects := make([]*models.ObjectMetadata, 0, len(loaded))
	for _, object := range loaded {
		objects = append(objects, object)
	}
	return objects, nil
}

func loadLayoutFieldPathObjects(
	path string,
	root *models.ObjectMetadata,
	load func(string) (*models.ObjectMetadata, error),
) error {
	frontier := []*models.ObjectMetadata{root}
	segments := strings.Split(path, ".")
	for index, segment := range segments {
		if index == len(segments)-1 {
			break
		}
		next := make(map[string]*models.ObjectMetadata)
		for _, object := range frontier {
			field := models.FindField(object, segment)
			if field == nil {
				continue
			}
			for _, targetName := range field.ReferenceTo {
				target, err := load(targetName)
				if err != nil {
					return err
				}
				if target != nil {
					next[strings.ToLower(target.APIName)] = target
				}
			}
		}
		frontier = frontier[:0]
		for _, object := range next {
			frontier = append(frontier, object)
		}
	}
	return nil
}

func (ms *MetadataService) lockStoredLayoutAuthoringAggregate(ctx context.Context, objectAPIName string) (*models.LayoutAuthoringAggregate, error) {
	locker, ok := ms.repo.(layoutAuthoringAggregateLocker)
	if !ok {
		return nil, apperrors.NewInternalError("Layout authoring aggregate lock is unavailable", nil)
	}
	return locker.LockLayoutAuthoringAggregate(ctx, objectAPIName)
}

func layoutFromAuthoringAggregate(aggregate *models.LayoutAuthoringAggregate, layoutID string) *models.PageLayout {
	if aggregate == nil {
		return nil
	}
	for _, layout := range aggregate.Layouts {
		if layout != nil && strings.EqualFold(layout.ID, layoutID) {
			return layout
		}
	}
	return nil
}

func (ms *MetadataService) runLayoutAdministrationTransaction(ctx context.Context, operation func(context.Context) error) error {
	if ms.txManager == nil {
		return apperrors.NewInternalError("Layout administration transaction manager is unavailable", nil)
	}
	deferredInvalidation := false
	if err := ms.txManager.WithPessimisticTransaction(ctx, func(txCtx context.Context) error {
		if err := operation(txCtx); err != nil {
			return err
		}
		deferredInvalidation = ports.RegisterAfterCommitHook(txCtx, ms.InvalidateCache)
		return nil
	}); err != nil {
		return err
	}
	if !deferredInvalidation {
		ms.InvalidateCache()
	}
	return nil
}

// RestoreLayout restores a layout to a previous version from history.
func (ms *MetadataService) RestoreLayout(ctx context.Context, layoutID string, versionNumber int) error {
	if err := ms.restoreLayoutContent(ctx, layoutID, versionNumber); err != nil {
		return err
	}
	ms.writeHistory(ctx, layoutID, fmt.Sprintf("restored_from_v%d", versionNumber), nil)
	return nil
}

func (ms *MetadataService) restoreLayoutContent(ctx context.Context, layoutID string, versionNumber int) error {
	// Get the historical config snapshot
	configJSON, err := ms.repo.GetLayoutHistoryConfig(ctx, layoutID, versionNumber)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return apperrors.NewNotFoundError("LayoutHistory", fmt.Sprintf("%s version %d", layoutID, versionNumber))
		}
		return fmt.Errorf("failed to get history version %d for layout %s: %w", versionNumber, layoutID, err)
	}

	// Get current layout to preserve metadata fields. This value is written
	// back below, so it must be the stored layout, not a presentation read.
	current, err := ms.readStoredLayout(ctx, layoutID)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return apperrors.NewNotFoundError("Layout", layoutID)
		}
		return fmt.Errorf("failed to get layout %s: %w", layoutID, err)
	}
	if current == nil {
		return apperrors.NewNotFoundError("Layout", layoutID)
	}
	return ms.applyRestoredLayout(ctx, current, configJSON, false)
}

func (ms *MetadataService) restoreLayoutForAdministration(
	ctx context.Context,
	current *models.PageLayout,
	versionNumber int,
) error {
	locker, ok := ms.repo.(layoutAuthoringHistoryLocker)
	if !ok {
		return apperrors.NewInternalError("Layout authoring history lock is unavailable", nil)
	}
	configJSON, err := locker.LockLayoutHistoryConfig(ctx, current.ID, versionNumber)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return apperrors.NewNotFoundError("LayoutHistory", fmt.Sprintf("%s version %d", current.ID, versionNumber))
		}
		return fmt.Errorf("failed to lock history version %d for layout %s: %w", versionNumber, current.ID, err)
	}
	return ms.applyRestoredLayout(ctx, current, configJSON, true)
}

func (ms *MetadataService) applyRestoredLayout(
	ctx context.Context,
	current *models.PageLayout,
	configJSON string,
	strict bool,
) error {
	var restoredLayout models.PageLayout
	if err := jsondocument.DecodeUseNumber([]byte(configJSON), &restoredLayout); err != nil {
		return fmt.Errorf("failed to unmarshal history config: %w", err)
	}

	// Restore the complete authored document. Identity, object scope, ownership,
	// routing state and the current compare-and-swap timestamp remain owned by
	// the live row. The strict authoring route restores Name because it
	// is an authored document field; the legacy route keeps its historical
	// immutable-name behavior.
	restoredLayout.ID = current.ID
	restoredLayout.ObjectAPIName = current.ObjectAPIName
	if !strict {
		restoredLayout.LayoutName = current.LayoutName
	}
	restoredLayout.IsDefault = current.IsDefault
	restoredLayout.IsDeleted = current.IsDeleted
	restoredLayout.Source = current.Source
	restoredLayout.IsCustomized = current.IsCustomized
	restoredLayout.CreatedDate = current.CreatedDate
	restoredLayout.LastModifiedDate = current.LastModifiedDate
	current = &restoredLayout

	var saveErr error
	if strict {
		updater, ok := ms.repo.(layoutAuthoringUpdater)
		if !ok {
			return apperrors.NewInternalError("strict Layout authoring update route is unavailable", nil)
		}
		saveErr = updater.SaveLayoutForAdministration(ctx, current)
	} else {
		saveErr = ms.repo.SaveLayout(ctx, current)
	}
	if saveErr != nil {
		return fmt.Errorf("failed to save restored layout: %w", saveErr)
	}

	return nil
}

// writeHistory appends a version snapshot to the layout history table.
// Version numbering is atomic (INSERT...SELECT MAX()+1 in the repo layer).
func (ms *MetadataService) writeHistory(ctx context.Context, layoutID string, reason string, changedByID *string) {
	if err := ms.writeHistoryStrict(ctx, layoutID, reason, changedByID); err != nil {
		log.Printf("⚠️ Failed to write layout history for %s: %v", layoutID, err)
	}
}

func (ms *MetadataService) writeHistoryStrict(ctx context.Context, layoutID string, reason string, changedByID *string) error {
	// Get current layout config for snapshot. A history entry must record what
	// is stored, so that restoring it reproduces the row rather than a
	// presentation of it.
	layout, err := ms.readStoredLayout(ctx, layoutID)
	if err != nil || layout == nil {
		if err != nil {
			return err
		}
		return apperrors.NewNotFoundError("Layout", layoutID)
	}
	configJSON, err := json.Marshal(layout)
	if err != nil {
		return err
	}

	entry := &models.PageLayoutHistoryEntry{
		LayoutID:     layoutID,
		Config:       string(configJSON),
		ChangedByID:  changedByID,
		ChangeReason: reason,
		// VersionNumber computed atomically by repo (COALESCE(MAX(VersionNumber),0)+1)
	}
	if err := ms.repo.WriteLayoutHistory(ctx, entry); err != nil {
		return err
	}

	// Retention pruning: keep only last 20 versions
	const maxHistoryVersions = 20
	maxVer, err := ms.repo.GetLayoutHistoryMaxVersion(ctx, layoutID)
	if err != nil {
		return err
	}
	if maxVer > maxHistoryVersions {
		pruneBelow := maxVer - maxHistoryVersions
		if err := ms.repo.PruneLayoutHistory(ctx, layoutID, pruneBelow); err != nil {
			return err
		}
	}
	return nil
}

// repairLayoutRelatedLists fixes stale persisted related list entries at serve time
// (e.g. campaign hierarchy with empty fields, missing labels or secondaryLabel).
// It does NOT discover or append new relationships — layout membership is controlled
// exclusively by bootstrap backfill and admin edits.
func (ms *MetadataService) repairLayoutRelatedLists(ctx context.Context, layout *models.PageLayout) {
	results, err := ms.repo.GetRelatedListConfigs(ctx, layout.ObjectAPIName)
	if err != nil {
		log.Printf("⚠️ repairLayoutRelatedLists(%s): %v", layout.ObjectAPIName, err)
		return
	}

	snap := ms.loadCoreCache()
	discovered := make(map[string]ports.RelatedListConfig, len(results))
	for _, res := range results {
		if IsSystemRelatedObject(res.ChildObjectAPI) {
			continue
		}
		discovered[relatedListKey(res.ChildObjectAPI, res.LookupFieldAPI)] = res
	}
	for i := range layout.RelatedLists {
		rl := &layout.RelatedLists[i]
		key := relatedListKey(rl.ObjectAPIName, rl.LookupField)
		cfg, hasCfg := discovered[key]
		label, columns := ms.resolveRelatedListPresentation(snap, layout.ObjectAPIName, rl.ObjectAPIName, rl.LookupField, rl.Label, hasCfg, cfg)
		if rl.Label == "" && label != "" {
			rl.Label = label
		}
		if len(rl.Fields) == 0 && len(columns) > 0 {
			rl.Fields = columns
		}
		if resolution := ResolveVirtualRelatedList(rl.ObjectAPIName, rl.LookupField); resolution != nil {
			if replacement := ms.repairVirtualRelatedListFields(*rl, resolution); len(replacement) > 0 {
				rl.Fields = replacement
			}
		}
		if rl.SecondaryLabel == "" {
			// Calculate secondary label from relationship metadata, even if no specific repo config exists
			secondaryLabel := ""
			if hasCfg && cfg.LookupFieldLabel != "" {
				secondaryLabel = cfg.LookupFieldLabel
			} else if hasCfg && cfg.LookupFieldAPI != "" {
				secondaryLabel = cfg.LookupFieldAPI
			} else {
				// Fall back to lookup field from the layout config itself
				secondaryLabel = rl.LookupField
			}
			if secondaryLabel != "" {
				rl.SecondaryLabel = "via " + secondaryLabel
			}
		}
	}
	layout.RelatedLists = ms.ensureRequiredSyntheticRelatedLists(layout.ObjectAPIName, layout.RelatedLists)
}

func (ms *MetadataService) repairVirtualRelatedListFields(
	rl models.RelatedListConfig,
	resolution *RelatedListResolution,
) []string {
	if resolution == nil || resolution.Query.ObjectApiName == "" {
		return nil
	}
	if ms.relatedListFieldsBelongToObject(rl.Fields, resolution.Query.ObjectApiName) {
		return nil
	}
	return ms.defaultRelatedListFields(resolution.Query.ObjectApiName)
}

func (ms *MetadataService) ensureRequiredSyntheticRelatedLists(objectAPIName string, existing []models.RelatedListConfig) []models.RelatedListConfig {
	result := dedupeRelatedLists(existing)
	required := runtimeRuleSet(objectAPIName)
	if len(required) == 0 {
		return result
	}

	seen := make(map[string]bool, len(result))
	for _, rl := range result {
		seen[relatedListDisplayIdentity(rl.ObjectAPIName, rl.LookupField)] = true
	}

	for _, rule := range required {
		key := relatedListDisplayIdentity(rule.ChildObjectAPI, rule.LookupField)
		if seen[key] {
			continue
		}
		candidate := candidateFromRule(rule)
		result = append(result, models.RelatedListConfig{
			ID:             candidate.ID,
			Label:          candidate.Label,
			SecondaryLabel: candidate.SecondaryLabel,
			ObjectAPIName:  candidate.ObjectAPIName,
			LookupField:    candidate.LookupField,
			Fields:         append([]string(nil), candidate.Fields...),
		})
	}

	SortByCuratedOrder(objectAPIName, result)
	return result
}

func dedupeRelatedLists(existing []models.RelatedListConfig) []models.RelatedListConfig {
	if len(existing) < 2 {
		return append([]models.RelatedListConfig(nil), existing...)
	}

	result := make([]models.RelatedListConfig, 0, len(existing))
	seen := make(map[string]bool, len(existing))
	for _, rl := range existing {
		key := relatedListKey(rl.ObjectAPIName, rl.LookupField)
		if seen[key] {
			continue
		}
		seen[key] = true
		result = append(result, rl)
	}
	return result
}

func relatedListKey(childAPI string, lookupField string) string {
	return strings.ToLower(childAPI) + "|" + strings.ToLower(lookupField)
}

func (ms *MetadataService) resolveRelatedListPresentation(
	snap *metadataSnapshot,
	parentAPI string,
	childAPI string,
	lookupField string,
	fallbackLabel string,
	hasRepoCfg bool,
	repoCfg ports.RelatedListConfig,
) (string, []string) {
	label := fallbackLabel
	if hasRepoCfg && repoCfg.ChildPluralLabel != "" {
		label = repoCfg.ChildPluralLabel
	}
	if label == "" {
		label = childAPI
	}

	fields := ms.defaultRelatedListFields(childAPI)
	if hasRepoCfg {
		if customFields := parseRelatedListFields(repoCfg.RelatedListFields); len(customFields) > 0 {
			fields = customFields
		}
	}

	return label, fields
}

func parseRelatedListFields(raw *string) []string {
	if raw == nil || *raw == "" {
		return nil
	}

	var fields []string
	if err := json.Unmarshal([]byte(*raw), &fields); err != nil || len(fields) == 0 {
		return nil
	}
	return fields
}

func parseConfiguredRelatedListFields(raw *string) []string {
	if raw == nil {
		return nil
	}

	trimmed := strings.TrimSpace(*raw)
	if trimmed == "" {
		return nil
	}

	if strings.HasPrefix(trimmed, "[") {
		var parsed []string
		if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil {
			return normalizeRelatedListFields(parsed)
		}
	}

	parts := strings.FieldsFunc(trimmed, func(r rune) bool {
		return r == ',' || r == '\n' || r == '\r' || r == '\t'
	})
	return normalizeRelatedListFields(parts)
}

func normalizeRelatedListFields(fields []string) []string {
	if len(fields) == 0 {
		return nil
	}

	normalized := make([]string, 0, len(fields))
	seen := make(map[string]bool, len(fields))
	for _, field := range fields {
		trimmed := strings.TrimSpace(strings.Trim(field, `"'`))
		if trimmed == "" {
			continue
		}
		if seen[trimmed] {
			continue
		}
		seen[trimmed] = true
		normalized = append(normalized, trimmed)
	}
	if len(normalized) == 0 {
		return nil
	}
	return normalized
}

func (ms *MetadataService) defaultRelatedListFields(childObjectAPI string) []string {
	nameField := constants.FieldName
	snap := ms.loadCoreCache()
	if snap != nil {
		if s, ok := snap.schemaMap[strings.ToLower(childObjectAPI)]; ok {
			schemaName := make(map[string]string, len(s.Fields))
			for _, f := range s.Fields {
				schemaName[strings.ToLower(f.APIName)] = f.APIName
				if f.IsNameField {
					nameField = f.APIName
				}
			}

			if len(s.ListFields) > 0 {
				filtered := make([]string, 0, len(s.ListFields))
				for _, fieldName := range s.ListFields {
					if canonical, exists := schemaName[strings.ToLower(fieldName)]; exists {
						filtered = append(filtered, canonical)
					}
				}
				if len(filtered) > 0 {
					return filtered
				}
			}

		}
	}
	return []string{nameField, constants.FieldCreatedDate}
}

func (ms *MetadataService) relatedListFieldsBelongToObject(fieldNames []string, objectAPI string) bool {
	if len(fieldNames) == 0 {
		return false
	}
	snap := ms.loadCoreCache()
	if snap == nil {
		return false
	}
	schema, ok := snap.schemaMap[strings.ToLower(objectAPI)]
	if !ok || schema == nil {
		return false
	}
	availableFields := make(map[string]struct{}, len(schema.Fields))
	for _, field := range schema.Fields {
		availableFields[strings.ToLower(field.APIName)] = struct{}{}
	}
	for _, fieldName := range fieldNames {
		if _, exists := availableFields[strings.ToLower(fieldName)]; !exists {
			return false
		}
	}
	return true
}

func (ms *MetadataService) resolveRelatedListFields(childObjectAPI string, configured *string) []string {
	if parsed := parseConfiguredRelatedListFields(configured); len(parsed) > 0 {
		return parsed
	}
	return ms.defaultRelatedListFields(childObjectAPI)
}

// IsSystemRelatedObject returns true for system/internal objects that should not
// appear in default page layout related lists (matching Salesforce behavior).
func IsSystemRelatedObject(apiName string) bool {
	return constants.IsSystemRelatedObjectAPIName(apiName)
}

// RelatedListCandidate is a structured candidate for the layout editor.
type RelatedListCandidate struct {
	ID             string   `json:"id"`
	ObjectAPIName  string   `json:"objectApiName"`
	LookupField    string   `json:"lookupField"`
	Label          string   `json:"label"`
	SecondaryLabel string   `json:"secondaryLabel"`
	Fields         []string `json:"fields"`
	FieldLabel     string   `json:"fieldLabel"`
	IsSystem       bool     `json:"isSystem"`
	IsRecommended  bool     `json:"isRecommended"`
	Bucket         string   `json:"bucket"`
}

// GetAllLayouts returns all layouts for an object (no fallback generation)
func (ms *MetadataService) GetAllLayouts(ctx context.Context, objectAPIName string) ([]*models.PageLayout, error) {
	ms.mu.RLock()
	defer ms.mu.RUnlock()
	return ms.repo.GetLayouts(ctx, objectAPIName)
}

// GetLayoutByID returns a single layout by ID.
//
// Like ResolveLayout, this read path is reachable from UI layout presentation
// without going through generation/regen, so curated and drifted layouts would
// otherwise reach the UI with autonumber fields in editable sections. Layer 3b
// (issue #1104 / PR #1118) strips them on every read.
func (ms *MetadataService) GetLayoutByID(ctx context.Context, layoutID string) (*models.PageLayout, error) {
	ms.mu.RLock()
	defer ms.mu.RUnlock()
	layout, err := ms.repo.GetLayout(ctx, layoutID)
	if err != nil || layout == nil {
		return layout, err
	}
	ms.stripAutonumberSectionFields(ctx, layout)
	return layout, nil
}

// GetLayoutForAuthoring returns the stored layout as persisted, for surfaces
// that will write it back.
//
// This is deliberately NOT GetLayoutByID. That method is a presentation read:
// it strips autonumber fields from Sections[*].Fields on the way out (Layer 3b,
// issue #1104 / PR #1118) so they never render as editable form inputs, and it
// reports no signal that it did. An authoring surface that reads through it
// sees a field list the stored row does not have, and because SaveLayout
// overwrites Config verbatim, writes that shorter list back — permanently
// deleting the field from the layout.
//
// The two reads therefore own different facts, and both are needed:
//
//	GetLayoutByID          — the layout as it should be presented
//	GetLayoutForAuthoring  — the layout as it is stored
//
// The scrubber's own contract comment assumes "no other reader observes the
// pre-scrub shape"; this method is that other reader, so it must not scrub.
// Render paths are unaffected — ResolveLayout and GetLayoutByID each scrub
// independently, and the create/edit form excludes autonumber on its own.
//
// The returned layout is not fully verbatim in one respect that the repository
// owns and cannot be opted out of: IsDefault is re-derived from the live
// wildcard LayoutAssignment, which is its only source of truth. Callers that
// round-trip this value must not echo IsDefault back into a write.
func (ms *MetadataService) GetLayoutForAuthoring(ctx context.Context, layoutID string) (*models.PageLayout, error) {
	ms.mu.RLock()
	defer ms.mu.RUnlock()
	return ms.readStoredLayout(ctx, layoutID)
}

// readStoredLayout is the single implementation of "read ONE layout by id as it
// is stored". Every caller that does so and then persists the result goes
// through it — the authoring read above, RestoreLayout, and the history
// snapshot — so a future read-time transform cannot be added to one of them and
// silently make that caller write the transformed shape back.
//
// It does not cover the read-modify-write paths that fetch by OBJECT rather
// than by id: field admission and relationship field admission both go through
// repo.GetLayouts. Those are correct today because that repository method
// applies no read-time transform either, but they are a separate sibling case
// this helper cannot reach.
//
// It takes no lock; callers own their own locking.
func (ms *MetadataService) readStoredLayout(ctx context.Context, layoutID string) (*models.PageLayout, error) {
	return ms.repo.GetLayout(ctx, layoutID)
}

// SaveLayout saves or updates a page layout
func (ms *MetadataService) SaveLayout(ctx context.Context, layout *models.PageLayout) error {
	ms.mu.Lock()
	defer ms.mu.Unlock()

	return ms.repo.SaveLayout(ctx, layout)
}

// SetDefaultLayout sets a layout as the default for its object.
// actorID is the UI user who authored the wildcard routing choice.
func (ms *MetadataService) SetDefaultLayout(ctx context.Context, objectAPIName string, layoutID string, actorID *string) error {
	ms.mu.Lock()
	defer ms.mu.Unlock()
	return ms.repo.SetDefaultLayout(ctx, objectAPIName, layoutID, actorID)
}

// DeleteLayout soft-deletes a layout and cleans up any LayoutAssignment rows pointing to it.
// Without this cleanup, FindBestAssignment would return the deleted layout's assignment,
// causing repeated misses before the resolver falls back to authored metadata or
// a describe-only structural projection.
func (ms *MetadataService) DeleteLayout(ctx context.Context, layoutID string) error {
	ms.mu.Lock()
	defer ms.mu.Unlock()

	if err := ms.repo.DeleteAssignmentsByLayoutID(ctx, layoutID); err != nil {
		return fmt.Errorf("failed to clean up assignments for layout %s: %w", layoutID, err)
	}
	return ms.repo.DeleteLayout(ctx, layoutID)
}

// AssignLayoutToProfile assigns a layout to a profile
func (ms *MetadataService) AssignLayoutToProfile(ctx context.Context, profileID, objectAPIName, layoutID string) error {
	ms.mu.Lock()
	defer ms.mu.Unlock()

	if obj, err := ms.repo.GetSchemaByAPIName(ctx, objectAPIName); err == nil && obj != nil {
		objectAPIName = obj.APIName
	}

	return ms.repo.AssignLayoutToProfile(ctx, profileID, objectAPIName, layoutID)
}

// --- Layout Assignment CRUD (new specificity-based system) ---

// GetAssignmentsForObject returns all assignments for an object.
func (ms *MetadataService) GetAssignmentsForObject(ctx context.Context, objectAPIName string) ([]*models.LayoutAssignment, error) {
	return ms.repo.GetAssignmentsForObject(ctx, objectAPIName)
}

// CreateAssignment creates a new layout assignment.
func (ms *MetadataService) CreateAssignment(ctx context.Context, assignment *models.LayoutAssignment) error {
	assignment.ComputeSpecificity()
	assignment.RouteSource = normalizeAssignmentRouteSource(assignment.RouteSource)
	if assignment.RouteSource == nil {
		return fmt.Errorf("routeSource is required")
	}
	return ms.repo.CreateAssignment(ctx, assignment)
}

// DeleteAssignment removes a layout assignment by ID.
func (ms *MetadataService) DeleteAssignment(ctx context.Context, id string) error {
	return ms.repo.DeleteAssignment(ctx, id)
}

// GetAllApprovalLayouts returns all approval layouts for an object.
func (ms *MetadataService) GetAllApprovalLayouts(ctx context.Context, objectAPIName string) ([]*models.ApprovalPageLayout, error) {
	return ms.repo.GetApprovalLayouts(ctx, objectAPIName)
}

// GetApprovalLayoutByID retrieves a specific approval layout by ID.
func (ms *MetadataService) GetApprovalLayoutByID(ctx context.Context, layoutID string) (*models.ApprovalPageLayout, error) {
	return ms.repo.GetApprovalLayout(ctx, layoutID)
}

// SaveApprovalLayout persists an approval layout.
func (ms *MetadataService) SaveApprovalLayout(ctx context.Context, layout *models.ApprovalPageLayout) error {
	ms.mu.Lock()
	defer ms.mu.Unlock()

	return ms.repo.SaveApprovalLayout(ctx, layout)
}

// SetDefaultApprovalLayout marks an approval layout as the default for its object.
func (ms *MetadataService) SetDefaultApprovalLayout(ctx context.Context, objectAPIName string, layoutID string) error {
	ms.mu.Lock()
	defer ms.mu.Unlock()

	return ms.repo.SetDefaultApprovalLayout(ctx, objectAPIName, layoutID)
}

// DeleteApprovalLayout removes an approval layout by ID.
func (ms *MetadataService) DeleteApprovalLayout(ctx context.Context, layoutID string) error {
	ms.mu.Lock()
	defer ms.mu.Unlock()

	// Note: We don't delete assignments for approval layouts as they may not use the assignment system
	return ms.repo.DeleteApprovalLayout(ctx, layoutID)
}

// addFieldToLayout adds a new field to ALL layouts for the object (not just the first).
// Follows Salesforce's "fields must never disappear" principle.
// NOTE: Assumes ms.mu is already locked
func (ms *MetadataService) addFieldToLayout(ctx context.Context, objectAPIName string, field *models.FieldMetadata) error {
	_, err := ms.addFieldToLayoutWithRollback(ctx, objectAPIName, field)
	return err
}

type fieldLayoutAdmissionSaver interface {
	SaveLayoutForFieldAdmission(ctx context.Context, original, updated *models.PageLayout) (func(context.Context, *models.PageLayout) error, error)
}

func (ms *MetadataService) addFieldToLayoutWithRollback(ctx context.Context, objectAPIName string, field *models.FieldMetadata) (func() error, error) {
	// Lock held by caller

	// Skip autonumber fields — server-managed, not form-editable.
	if isAutoLayoutSectionExcluded(field, ms.autoNumberFieldsForObject(objectAPIName)) {
		return nil, nil
	}

	layouts, err := ms.repo.GetLayouts(ctx, objectAPIName)
	if err != nil {
		return nil, apperrors.NewInternalError("failed to query layouts", err)
	}
	if len(layouts) == 0 {
		return nil, nil
	}

	type layoutAdmissionSnapshot struct {
		current  *models.PageLayout
		original *models.PageLayout
		restore  func() error
	}
	var snapshots []layoutAdmissionSnapshot
	rollbackSnapshots := func() error {
		var errs []error
		for i := len(snapshots) - 1; i >= 0; i-- {
			snapshot := snapshots[i]
			restore := snapshot.restore
			if restore == nil {
				errs = append(errs, fmt.Errorf("%s: field layout admission rollback restore missing", snapshot.original.ID))
				continue
			}
			if err := restore(); err != nil {
				errs = append(errs, fmt.Errorf("%s: %w", snapshot.original.ID, err))
				continue
			}
			copyPageLayoutForFieldAdmission(snapshot.current, snapshot.original)
		}
		if len(errs) > 0 {
			return fmt.Errorf("restore field layout admission: %w", errors.Join(errs...))
		}
		return nil
	}

	for _, layout := range layouts {
		if len(layout.Sections) == 0 {
			continue
		}

		// Check if field already exists in any section
		fieldExists := false
		for _, section := range layout.Sections {
			for _, f := range section.Fields {
				if f == field.APIName {
					fieldExists = true
					break
				}
			}
			if fieldExists {
				break
			}
		}

		if !fieldExists {
			admissionSaver, ok := ms.repo.(fieldLayoutAdmissionSaver)
			if !ok {
				return nil, apperrors.NewInternalError("field layout admission requires a conflict-safe layout repository", nil)
			}
			original := clonePageLayoutForFieldAdmission(layout)
			updated := clonePageLayoutForFieldAdmission(layout)

			// Add to the last non-system section (or first section as fallback)
			targetIdx := 0
			for i := len(updated.Sections) - 1; i >= 0; i-- {
				label := strings.ToLower(updated.Sections[i].Label)
				if label != "system information" && label != "system" {
					targetIdx = i
					break
				}
			}
			updated.Sections[targetIdx].Fields = append(updated.Sections[targetIdx].Fields, field.APIName)

			restore, err := ms.admitComponentWrite(ctx, componentAdmissionRequest{
				kind:         componentAdmissionKindPageLayout,
				componentID:  layout.ID,
				errorContext: fmt.Sprintf("failed to add field %s to layout %s", field.APIName, layout.ID),
				admit: func(ctx context.Context) (componentAdmissionRestore, error) {
					capturedRestore, err := admissionSaver.SaveLayoutForFieldAdmission(ctx, original, updated)
					if err != nil {
						return nil, err
					}
					if capturedRestore == nil {
						return nil, nil
					}
					return func() error {
						return capturedRestore(ctx, updated)
					}, nil
				},
			})
			if err != nil {
				if rollbackErr := rollbackSnapshots(); rollbackErr != nil {
					log.Printf("⚠️ Failed to roll back partial layout admission for %s.%s: %v", objectAPIName, field.APIName, rollbackErr)
					return nil, fieldCreateFailureWithLayoutRollbackFailures(err, rollbackErr)
				}
				return nil, err
			}
			copyPageLayoutForFieldAdmission(layout, updated)
			snapshots = append(snapshots, layoutAdmissionSnapshot{
				current:  layout,
				original: original,
				restore:  restore,
			})
		}
	}

	if len(snapshots) == 0 {
		return nil, nil
	}
	return rollbackSnapshots, nil
}

func (ms *MetadataService) addRelationshipFieldToParentLayoutsWithRollback(ctx context.Context, child *models.ObjectMetadata, field *models.FieldMetadata) (func() error, error) {
	// Lock held by caller.
	if child == nil || field == nil || field.IsSystem || IsSystemRelatedObject(child.APIName) {
		return nil, nil
	}

	sem := models.ResolveFieldRelationshipSemantics(field, models.LifecycleAuthoredWrite)
	if !sem.IsRelationship || sem.IsPolymorphic || len(sem.ReferenceTargets) == 0 {
		return nil, nil
	}

	targets := uniqueRelationshipTargets(sem.ReferenceTargets)
	if len(targets) == 0 {
		return nil, nil
	}

	relatedList := ms.relatedListConfigForRelationshipField(child, field)
	var admissionSaver fieldLayoutAdmissionSaver
	hasAdmissionSaver := false
	type layoutAdmissionSnapshot struct {
		current  *models.PageLayout
		original *models.PageLayout
		restore  func() error
	}
	var snapshots []layoutAdmissionSnapshot
	rollbackSnapshots := func() error {
		var errs []error
		for i := len(snapshots) - 1; i >= 0; i-- {
			snapshot := snapshots[i]
			restore := snapshot.restore
			if restore == nil {
				errs = append(errs, fmt.Errorf("%s: relationship layout admission rollback restore missing", snapshot.original.ID))
				continue
			}
			if err := restore(); err != nil {
				errs = append(errs, fmt.Errorf("%s: %w", snapshot.original.ID, err))
				continue
			}
			copyPageLayoutForFieldAdmission(snapshot.current, snapshot.original)
		}
		if len(errs) > 0 {
			return fmt.Errorf("restore relationship layout admission: %w", errors.Join(errs...))
		}
		return nil
	}

	for _, parentAPI := range targets {
		layouts, err := ms.repo.GetLayouts(ctx, parentAPI)
		if err != nil {
			return nil, apperrors.NewInternalError("failed to query parent layouts for relationship field admission", err)
		}

		for _, layout := range layouts {
			if layout == nil || relatedListConfigExists(layout.RelatedLists, child.APIName, field.APIName) {
				continue
			}
			if !hasAdmissionSaver {
				var ok bool
				admissionSaver, ok = ms.repo.(fieldLayoutAdmissionSaver)
				if !ok {
					return nil, apperrors.NewInternalError("relationship layout admission requires a conflict-safe layout repository", nil)
				}
				hasAdmissionSaver = true
			}

			original := clonePageLayoutForFieldAdmission(layout)
			updated := clonePageLayoutForFieldAdmission(layout)
			updated.RelatedLists = append(updated.RelatedLists, cloneRelatedListConfig(relatedList))
			SortByCuratedOrder(parentAPI, updated.RelatedLists)

			restore, err := ms.admitComponentWrite(ctx, componentAdmissionRequest{
				kind:         componentAdmissionKindPageLayout,
				componentID:  layout.ID,
				errorContext: fmt.Sprintf("failed to add related list %s.%s to parent layout %s", child.APIName, field.APIName, layout.ID),
				admit: func(ctx context.Context) (componentAdmissionRestore, error) {
					capturedRestore, err := admissionSaver.SaveLayoutForFieldAdmission(ctx, original, updated)
					if err != nil {
						return nil, err
					}
					if capturedRestore == nil {
						return nil, nil
					}
					return func() error {
						return capturedRestore(ctx, updated)
					}, nil
				},
			})
			if err != nil {
				if rollbackErr := rollbackSnapshots(); rollbackErr != nil {
					log.Printf("⚠️ Failed to roll back partial relationship layout admission for %s.%s: %v", child.APIName, field.APIName, rollbackErr)
					return nil, fieldCreateFailureWithLayoutRollbackFailures(err, rollbackErr)
				}
				return nil, err
			}
			copyPageLayoutForFieldAdmission(layout, updated)
			snapshots = append(snapshots, layoutAdmissionSnapshot{
				current:  layout,
				original: original,
				restore:  restore,
			})
		}
	}

	if len(snapshots) == 0 {
		return nil, nil
	}
	return rollbackSnapshots, nil
}

func uniqueRelationshipTargets(targets []string) []string {
	unique := make([]string, 0, len(targets))
	seen := make(map[string]struct{}, len(targets))
	for _, target := range targets {
		trimmed := strings.TrimSpace(target)
		if trimmed == "" {
			continue
		}
		key := strings.ToLower(trimmed)
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}
		unique = append(unique, trimmed)
	}
	return unique
}

func relatedListConfigExists(existing []models.RelatedListConfig, childAPI string, lookupField string) bool {
	key := relatedListKey(childAPI, lookupField)
	for _, rl := range existing {
		if relatedListKey(rl.ObjectAPIName, rl.LookupField) == key {
			return true
		}
	}
	return false
}

func (ms *MetadataService) relatedListConfigForRelationshipField(child *models.ObjectMetadata, field *models.FieldMetadata) models.RelatedListConfig {
	label := strings.TrimSpace(child.PluralLabel)
	if label == "" {
		label = strings.TrimSpace(child.Label)
	}
	if label == "" {
		label = child.APIName
	}

	fieldLabel := strings.TrimSpace(field.Label)
	if fieldLabel == "" {
		fieldLabel = field.APIName
	}

	return models.RelatedListConfig{
		ID:             fmt.Sprintf("%s-%s", child.APIName, field.APIName),
		Label:          label,
		SecondaryLabel: "via " + fieldLabel,
		ObjectAPIName:  child.APIName,
		LookupField:    field.APIName,
		Fields:         defaultRelatedListFieldsForObject(child),
	}
}

func defaultRelatedListFieldsForObject(object *models.ObjectMetadata) []string {
	if object == nil {
		return []string{constants.FieldName, constants.FieldCreatedDate}
	}

	fieldByLower := make(map[string]string, len(object.Fields))
	nameField := constants.FieldName
	for _, field := range object.Fields {
		fieldByLower[strings.ToLower(field.APIName)] = field.APIName
		if field.IsNameField {
			nameField = field.APIName
		}
	}

	if len(object.ListFields) > 0 {
		filtered := make([]string, 0, len(object.ListFields))
		for _, fieldName := range object.ListFields {
			if canonical, ok := fieldByLower[strings.ToLower(fieldName)]; ok {
				filtered = append(filtered, canonical)
			}
		}
		if len(filtered) > 0 {
			return filtered
		}
	}

	return []string{nameField, constants.FieldCreatedDate}
}

func clonePageLayoutForFieldAdmission(layout *models.PageLayout) *models.PageLayout {
	if layout == nil {
		return nil
	}
	clone := *layout
	clone.CompactLayout = append([]string(nil), layout.CompactLayout...)
	clone.Tabs = append([]string(nil), layout.Tabs...)
	clone.Sections = append([]models.PageSection(nil), layout.Sections...)
	// The section struct copy above aliases every map a section carries, and
	// this clone backs a rollback snapshot, so an aliased map lets a later
	// mutation of the clone rewrite the snapshot meant to restore it. Fields,
	// FieldLabels and FieldBehaviors are copied here. ComponentConfig is
	// deliberately left aliased: it is map[string]interface{} whose values are
	// arbitrary nested data, so a shallow copy would look like a fix without
	// being one. That aliasing predates FieldBehaviors and is unchanged by it.
	for i := range clone.Sections {
		clone.Sections[i].Fields = append([]string(nil), layout.Sections[i].Fields...)
		clone.Sections[i].FieldLabels = maps.Clone(layout.Sections[i].FieldLabels)
		clone.Sections[i].FieldBehaviors = cloneFieldBehaviors(layout.Sections[i].FieldBehaviors)
	}
	clone.RelatedLists = cloneRelatedListConfigs(layout.RelatedLists)
	clone.RecordPanels = cloneRecordPanelConfigs(layout.RecordPanels)
	clone.HeaderActions = append([]models.ActionConfig(nil), layout.HeaderActions...)
	clone.QuickActions = append([]models.ActionConfig(nil), layout.QuickActions...)
	clone.RecordStates = append([]models.RecordStateRule(nil), layout.RecordStates...)
	clone.PrimaryHeaderActions = append([]string(nil), layout.PrimaryHeaderActions...)
	return &clone
}

// cloneRecordPanelConfigs keeps rollback snapshots independent from later
// mutations of renderer-owned placement configuration. Config is opaque JSON,
// so every object and array node must be copied rather than only the top-level
// map.
func cloneRecordPanelConfigs(in []models.RecordPanelConfig) []models.RecordPanelConfig {
	if in == nil {
		return nil
	}
	out := make([]models.RecordPanelConfig, len(in))
	for i := range in {
		out[i] = in[i]
		out[i].Config = cloneRecordPanelConfigMap(in[i].Config)
	}
	return out
}

func cloneRecordPanelConfigMap(in map[string]interface{}) map[string]interface{} {
	if in == nil {
		return nil
	}
	out := make(map[string]interface{}, len(in))
	for key, value := range in {
		out[key] = cloneRecordPanelConfigValue(value)
	}
	return out
}

func cloneRecordPanelConfigValue(in interface{}) interface{} {
	switch value := in.(type) {
	case map[string]interface{}:
		return cloneRecordPanelConfigMap(value)
	case []interface{}:
		out := make([]interface{}, len(value))
		for i := range value {
			out[i] = cloneRecordPanelConfigValue(value[i])
		}
		return out
	default:
		return value
	}
}

// cloneFieldBehaviors deep-copies the nested behavior map. maps.Clone is
// shallow, so the inner per-mode maps need their own copy; without it the
// clone and its source would share them.
//
// maps.Clone is nil-in/nil-out at both levels, and the two levels need that
// for different reasons:
//
//   - Inner maps are values inside a map, so they carry no struct tag and no
//     omitempty. A nil inner map encodes as null, an allocated empty one as
//     {} — different bytes. The field-admission rollback snapshot is fenced by
//     a raw string comparison of marshalled config
//     (repo_layout_field_admission.go), so allocating here would make a
//     layout's guard disagree with its stored row and reject admissions on a
//     layout nobody modified.
//   - The outer map does carry omitempty, which suppresses the key for nil and
//     empty alike, so preserving nil there is structural fidelity to the
//     source rather than a fence requirement.
func cloneFieldBehaviors(in map[string]map[string]string) map[string]map[string]string {
	out := maps.Clone(in)
	for field, modes := range out {
		out[field] = maps.Clone(modes)
	}
	return out
}

func cloneRelatedListConfigs(in []models.RelatedListConfig) []models.RelatedListConfig {
	if len(in) == 0 {
		return nil
	}
	out := make([]models.RelatedListConfig, len(in))
	for i := range in {
		out[i] = cloneRelatedListConfig(in[i])
	}
	return out
}

func cloneRelatedListConfig(in models.RelatedListConfig) models.RelatedListConfig {
	out := in
	out.Fields = append([]string(nil), in.Fields...)
	if in.PrefillMappings != nil {
		out.PrefillMappings = make(map[string]string, len(in.PrefillMappings))
		for k, v := range in.PrefillMappings {
			out.PrefillMappings[k] = v
		}
	}
	return out
}

func copyPageLayoutForFieldAdmission(dst *models.PageLayout, src *models.PageLayout) {
	if dst == nil || src == nil {
		return
	}
	*dst = *clonePageLayoutForFieldAdmission(src)
}

// isLayoutInvisibleField returns true for fields that should NEVER appear in any
// page layout. This is a narrow list — OwnerId, CreatedById, etc. are NOT invisible
// (they appear in Salesforce layouts in "Information" and "System Information" sections).
func isLayoutInvisibleField(apiName string) bool {
	switch apiName {
	case constants.FieldID, constants.FieldIsDeleted, constants.FieldSystemModstamp,
		// Password is never shown in page layouts (Salesforce uses a separate
		// "Reset Password" action); security-internal fields likewise hidden.
		constants.FieldPassword, "FailedLoginAttempts", "LockedUntil", "LastLoginDate":
		return true
	}
	return false
}

// isAutoLayoutSectionExcluded returns true for fields that should NOT appear
// in any editable page-layout section. Autonumber fields are server-managed
// and do not belong in form-editable sections. Unlike isLayoutInvisibleField,
// these fields MAY still appear in CompactLayout for identification.
//
// Detects both v1 (Type == FieldTypeAutonumber) and v2 (Type may be string
// but _System_Field.auto_number_format is set, resolved via autoNumberSet).
func isAutoLayoutSectionExcluded(f *models.FieldMetadata, autoNumberSet map[string]struct{}) bool {
	if f.Type == constants.FieldTypeAutonumber {
		return true
	}
	if len(autoNumberSet) > 0 {
		if _, ok := autoNumberSet[f.APIName]; ok {
			return true
		}
	}
	return false
}

// stripAutonumberSectionFields filters autonumber fields out of
// Sections[*].Fields on the supplied layout. This is a read-time scrubber
// (Layer 3 of the autonumber-in-layout fix; see issue #1104 / PR #1118):
// regardless of who produced the persisted row (engine `auto_generated`,
// curated/source-backed pack layouts, or admin edits via SaveLayout), the
// presentation layer must not surface autonumber fields as editable.
//
// Predicate matches isAutoLayoutSectionExcluded exactly:
//   - v1 detection: f.Type == FieldTypeAutonumber (per-field schema lookup)
//   - v2 detection: f.APIName ∈ resolver set
//     (resolver indexes _System_Field.auto_number_format non-empty)
//
// The schema lookup uses the per-object resolveSchema (cache-first), so a
// v1 autonumber field with no auto_number_format seed is still filtered.
//
// CONTRACT: callers pass a layout that is NOT aliased with any cache or
// long-lived state. The two production call sites (ResolveLayout and
// GetLayoutByID) both consume freshly-allocated structs from
// MetadataRepository.GetLayouts / GetLayout, which scan brand-new
// *PageLayout values per call (see
// infrastructure/persistence/repo_layout.go). MetadataService holds no
// PageLayout cache. UI presentation caches are assembled outside this metadata
// service from fresh layout reads. Therefore mutating Sections[i].Fields in place
// is safe in production — no other reader observes the pre-scrub shape.
//
// A fresh backing array (make([]string, 0, len(orig))) is allocated only
// when the section actually changes; this avoids the in-place
// filter-with-slice[:0] aliasing trick during a single iteration. It does
// NOT claim to provide immutability for shared-pointer test mocks.
func (ms *MetadataService) stripAutonumberSectionFields(ctx context.Context, layout *models.PageLayout) {
	if layout == nil {
		return
	}
	autoSet := ms.autoNumberFieldsForObject(layout.ObjectAPIName)
	// Field-type lookup for v1 detection (FieldTypeAutonumber without
	// resolver-set membership). Lazy: only resolved when we need it.
	var fieldTypeByAPIName map[string]models.FieldType
	getFieldType := func() map[string]models.FieldType {
		if fieldTypeByAPIName != nil {
			return fieldTypeByAPIName
		}
		fieldTypeByAPIName = map[string]models.FieldType{}
		schema := ms.resolveSchema(ctx, layout.ObjectAPIName)
		if schema == nil {
			return fieldTypeByAPIName
		}
		for i := range schema.Fields {
			fieldTypeByAPIName[schema.Fields[i].APIName] = schema.Fields[i].Type
		}
		return fieldTypeByAPIName
	}
	isAutoFieldName := func(name string) bool {
		if len(autoSet) > 0 {
			if _, ok := autoSet[name]; ok {
				return true
			}
		}
		if t, ok := getFieldType()[name]; ok && t == constants.FieldTypeAutonumber {
			return true
		}
		return false
	}
	// Cheap early-exit: if there are no candidate autonumber fields known
	// via resolver OR schema, do nothing. Probe schema lazily.
	if len(autoSet) == 0 {
		ft := getFieldType()
		if len(ft) == 0 {
			return
		}
		hasAuto := false
		for _, t := range ft {
			if t == constants.FieldTypeAutonumber {
				hasAuto = true
				break
			}
		}
		if !hasAuto {
			return
		}
	}
	filterAutonumberSectionFields(layout, isAutoFieldName)
}

func filterAutonumberSectionFields(layout *models.PageLayout, isAutoFieldName func(string) bool) {
	for i := range layout.Sections {
		orig := layout.Sections[i].Fields
		filtered := make([]string, 0, len(orig))
		changed := false
		for _, f := range orig {
			if isAutoFieldName(f) {
				changed = true
				continue
			}
			filtered = append(filtered, f)
		}
		if changed {
			layout.Sections[i].Fields = filtered
		}
	}
}
