/*
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you under the Apache License, Version 2.0 (the
 *  "License"); you may not use this file except in compliance
 *  with the License.  You may obtain a copy of the License at
 *
 *        http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing,
 *  software distributed under the License is distributed on an
 *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 *  KIND, either express or implied.  See the License for the
 *  specific language governing permissions and limitations
 *  under the License.
 */
package org.apache.causeway.core.metamodel.facets.object.domainobject;

import java.lang.reflect.Modifier;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import jakarta.inject.Inject;
import jakarta.inject.Named;

import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;

import org.springframework.util.ClassUtils;

import org.apache.causeway.applib.Identifier;
import org.apache.causeway.applib.annotation.Action;
import org.apache.causeway.applib.annotation.Collection;
import org.apache.causeway.applib.annotation.DomainObject;
import org.apache.causeway.applib.annotation.Nature;
import org.apache.causeway.applib.annotation.Property;
import org.apache.causeway.applib.annotation.Value;
import org.apache.causeway.applib.events.lifecycle.ObjectCreatedEvent;
import org.apache.causeway.applib.events.lifecycle.ObjectLoadedEvent;
import org.apache.causeway.applib.events.lifecycle.ObjectPersistedEvent;
import org.apache.causeway.applib.events.lifecycle.ObjectPersistingEvent;
import org.apache.causeway.applib.events.lifecycle.ObjectRemovingEvent;
import org.apache.causeway.applib.events.lifecycle.ObjectUpdatedEvent;
import org.apache.causeway.applib.events.lifecycle.ObjectUpdatingEvent;
import org.apache.causeway.applib.id.LogicalType;
import org.apache.causeway.commons.collections.Can;
import org.apache.causeway.commons.internal.collections._Multimaps;
import org.apache.causeway.commons.internal.reflection._GenericResolver.ResolvedMethod;
import org.apache.causeway.core.config.progmodel.ProgrammingModelConstants;
import org.apache.causeway.core.metamodel.context.MetaModelContext;
import org.apache.causeway.core.metamodel.facetapi.FacetHolder;
import org.apache.causeway.core.metamodel.facetapi.FacetUtil;
import org.apache.causeway.core.metamodel.facetapi.FeatureType;
import org.apache.causeway.core.metamodel.facetapi.MetaModelRefiner;
import org.apache.causeway.core.metamodel.facets.FacetFactoryAbstract;
import org.apache.causeway.core.metamodel.facets.ObjectTypeFacetFactory;
import org.apache.causeway.core.metamodel.facets.object.callbacks.CreatedLifecycleEventFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.callbacks.LoadedLifecycleEventFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.callbacks.PersistedLifecycleEventFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.callbacks.PersistingLifecycleEventFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.callbacks.RemovingLifecycleEventFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.callbacks.UpdatedLifecycleEventFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.callbacks.UpdatingLifecycleEventFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.domainobject.autocomplete.AutoCompleteFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.domainobject.choices.ChoicesFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.domainobject.domainevents.ActionDomainEventDefaultFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.domainobject.domainevents.CollectionDomainEventDefaultFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.domainobject.domainevents.PropertyDomainEventDefaultFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.domainobject.editing.EditingEnabledFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.domainobject.editing.ImmutableFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.domainobject.entitychangepublishing.EntityChangePublishingFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.domainobject.introspection.IntrospectionPolicyFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.mixin.MixinFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.facets.object.viewmodel.ViewModelFacetForDomainObjectAnnotation;
import org.apache.causeway.core.metamodel.object.MmEventUtils;
import org.apache.causeway.core.metamodel.progmodel.ProgrammingModel;
import org.apache.causeway.core.metamodel.spec.ObjectSpecification;
import org.apache.causeway.core.metamodel.specloader.validator.MetaModelValidatorAbstract;
import org.apache.causeway.core.metamodel.specloader.validator.ValidationFailure;

import static org.apache.causeway.commons.internal.base._NullSafe.stream;

import lombok.extern.log4j.Log4j2;

@Log4j2
public class DomainObjectAnnotationFacetFactory
extends FacetFactoryAbstract
implements
    MetaModelRefiner,
    ObjectTypeFacetFactory {

    private final MetaModelValidatorForMixinTypes mixinTypeValidator =
            new MetaModelValidatorForMixinTypes("@DomainObject#nature=MIXIN");

    @Inject
    public DomainObjectAnnotationFacetFactory(
            final MetaModelContext mmc) {
        super(mmc, FeatureType.OBJECTS_ONLY);
    }

    @Override
    public void process(final ProcessObjectTypeContext processClassContext) {

        var domainObjectIfAny = processClassContext.synthesizeOnType(DomainObject.class);
        processAliased(domainObjectIfAny, processClassContext);
        processIntrospecion(domainObjectIfAny, processClassContext);

        // conflicting type semantics validation ...
        validateConflictingTypeSemantics(domainObjectIfAny, processClassContext);
    }

    /*
     * on the fly validation ...
     * yet only considers annotations and falls short on other means
     * (eg. ValueTypeRegistry, configuration, ...)
     * TODO instead properly validate by implementing a validator that looks into the facets that are created
     */
    private void validateConflictingTypeSemantics(
            final Optional<DomainObject> domainObjectIfAny,
            final ProcessObjectTypeContext processClassContext) {

        if(domainObjectIfAny.isEmpty()) {
            return;
        }

        var domainObject = domainObjectIfAny.get();

        var facetHolder = processClassContext.getFacetHolder();
        var cls = processClassContext.getCls();

        if(cls.isInterface()) {
            ValidationFailure.raiseFormatted(facetHolder,
                    "Cannot use @DomainObject on interface: %s", cls.getName());
        }

        if(processClassContext.synthesizeOnType(Value.class).isPresent()) {
            ValidationFailure.raiseFormatted(facetHolder,
                    "Cannot use @DomainObject and @Value on the same type: %s", cls.getName());
        }

        if(!domainObject.nature().isMixin()) {
            if(processClassContext.synthesizeOnType(Action.class).isPresent()) {
                ValidationFailure.raiseFormatted(facetHolder,
                        "Cannot use @DomainObject and @Action on the same type, "
                        + "unless nature is MIXIN: %s", cls.getName());
            }
            if(processClassContext.synthesizeOnType(Property.class).isPresent()) {
                ValidationFailure.raiseFormatted(facetHolder,
                        "Cannot use @DomainObject and @Property on the same type, "
                        + "unless nature is MIXIN: %s", cls.getName());
            }
            if(processClassContext.synthesizeOnType(Collection.class).isPresent()) {
                ValidationFailure.raiseFormatted(facetHolder,
                        "Cannot use @DomainObject and @Collection on the same type, "
                        + "unless nature is MIXIN: %s", cls.getName());
            }
        }

        if(domainObject.nature().isMixin()
                && processClassContext.synthesizeOnType(Named.class).isPresent()) {
            // just a console warning, not decided yet whether we should be strict and fail MM validation
            log.warn("Mixins don't need a logicalTypeName, as was declared with {}.", cls.getName());
        }
    }

    @Override
    public void process(final ProcessClassContext processClassContext) {

        var domainObjectIfAny = processClassContext.synthesizeOnType(DomainObject.class);

        processEntityChangePublishing(domainObjectIfAny, processClassContext);
        processAutoComplete(domainObjectIfAny, processClassContext);
        processBounded(domainObjectIfAny, processClassContext);
        processEditing(domainObjectIfAny, processClassContext);
        processNature(domainObjectIfAny, processClassContext);
        processLifecycleEvents(domainObjectIfAny, processClassContext);
        processDomainEvents(domainObjectIfAny, processClassContext);
    }

    void processEntityChangePublishing(
            final Optional<DomainObject> domainObjectIfAny,
            final ProcessClassContext processClassContext) {
        //var cls = processClassContext.getCls();
        var facetHolder = processClassContext.getFacetHolder();

        // check for @DomainObject(entityChangePublishing=....)
        var entityChangePublishing = domainObjectIfAny
                .map(DomainObject::entityChangePublishing);
        addFacetIfPresent(
                EntityChangePublishingFacetForDomainObjectAnnotation
                .create(entityChangePublishing, getConfiguration(), facetHolder));
    }

    // -- AUTO COMPLETE

    void processAutoComplete(
            final Optional<DomainObject> domainObjectIfAny,
            final ProcessClassContext processClassContext) {
        var cls = processClassContext.getCls();
        var facetHolder = processClassContext.getFacetHolder();

        // check from @DomainObject(autoCompleteRepository=...)
        processClassContext.synthesizeOnType(DomainObject.class)
                .map(AnnotHelper::new)
                .filter(a -> a.autoCompleteRepository != Object.class)
                .filter(a -> {
                    a.repositoryMethod = findRepositoryMethod(
                            facetHolder,
                            cls,
                            "@DomainObject",
                            a.autoCompleteRepository,
                            a.autoCompleteMethod);

                    return a.repositoryMethod != null;
                })
                .map(a -> new AutoCompleteFacetForDomainObjectAnnotation(
                        facetHolder, a.autoCompleteRepository, a.repositoryMethod))
                .ifPresent(super::addFacet);

    }

    private static final class AnnotHelper {
        AnnotHelper(final DomainObject domainObject) {
            this.autoCompleteRepository = domainObject.autoCompleteRepository();
            this.autoCompleteMethod = domainObject.autoCompleteMethod();
        }
        final Class<?> autoCompleteRepository;
        final String autoCompleteMethod;
        ResolvedMethod repositoryMethod;
    }

    private ResolvedMethod findRepositoryMethod(
            final FacetHolder facetHolder,
            final Class<?> cls,
            final String annotationName,
            final Class<?> repositoryClass,
            final String methodName) {

        var repoMethod = getClassCache()
            .streamPublicMethods(repositoryClass)
            .filter(method->method.name().equals(methodName))
            .filter(method->method.isSingleArg())
            .filter(method->method.paramType(0).equals(String.class))
            .findFirst()
            .orElse(null);

        if(repoMethod!=null) {
            return repoMethod;
        }

        ValidationFailure.raise(
                facetHolder.getSpecificationLoader(),
                Identifier.classIdentifier(LogicalType.fqcn(cls)),
                String.format(
                        "%s annotation on %s specifies method '%s' that does not exist in repository '%s'",
                        annotationName,
                        cls.getName(),
                        methodName,
                        repositoryClass.getName())
                );
        return null;
    }

    // -- BOUNDED

    // check from @DomainObject(bounded=...)
    void processBounded(
            final Optional<DomainObject> domainObjectIfAny,
            final ProcessClassContext processClassContext) {
        var facetHolder = processClassContext.getFacetHolder();
        FacetUtil.addFacetIfPresent(
                ChoicesFacetForDomainObjectAnnotation
                .create(domainObjectIfAny, facetHolder));
    }

    // check from @DomainObject(editing=...)
    void processEditing(
            final Optional<DomainObject> domainObjectIfAny,
            final ProcessClassContext processClassContext) {
        var facetHolder = processClassContext.getFacetHolder();

        FacetUtil.addFacetIfPresent(
                EditingEnabledFacetForDomainObjectAnnotation
                .create(domainObjectIfAny, facetHolder));

        FacetUtil.addFacetIfPresent(
                ImmutableFacetForDomainObjectAnnotation
                .create(domainObjectIfAny, getConfiguration(), facetHolder));
    }

    // check from @DomainObject(aliased=...)
    void processAliased(
            final Optional<DomainObject> domainObjectIfAny,
            final ProcessObjectTypeContext processClassContext) {

        var cls = processClassContext.getCls();
        var facetHolder = processClassContext.getFacetHolder();

        FacetUtil.addFacetIfPresent(
                AliasedFacetForDomainObjectAnnotation
                .create(domainObjectIfAny, cls, facetHolder));
    }

    // check from @DomainObject(introspection=...)
    void processIntrospecion(
            final Optional<DomainObject> domainObjectIfAny,
            final ProcessObjectTypeContext processClassContext) {

        var cls = processClassContext.getCls();
        var facetHolder = processClassContext.getFacetHolder();

        FacetUtil.addFacetIfPresent(
                IntrospectionPolicyFacetForDomainObjectAnnotation
                .create(domainObjectIfAny, cls, facetHolder));
    }

    void processNature(
            final Optional<DomainObject> domainObjectIfAny,
            final ProcessClassContext processClassContext) {
        var cls = processClassContext.getCls();
        var facetHolder = processClassContext.getFacetHolder();

        if(!domainObjectIfAny.isPresent()) {
            return;
        }

        // handle with least priority
        if(addFacetIfPresent(
                ViewModelFacetForDomainObjectAnnotation
                .create(
                        domainObjectIfAny,
                        facetHolder))
                .isPresent()) {
            return;
        }

        if(cls.isInterface()
                || Modifier.isAbstract(cls.getModifiers())) {

            // entirely ignore abstract types
            // there is no reason for these to be recognized as mixins,
            // as only concrete mixins will ever contribute to the domain
            return;
        }

        var mixinDomainObjectIfAny =
                domainObjectIfAny
                .filter(domainObject -> domainObject.nature() == Nature.MIXIN)
                .filter(domainObject -> mixinTypeValidator.ensureMixinType(facetHolder, cls));

        addFacetIfPresent(
                MixinFacetForDomainObjectAnnotation
                    .create(mixinDomainObjectIfAny, cls, facetHolder));

    }

    private void processLifecycleEvents(
            final Optional<DomainObject> domainObjectIfAny,
            final ProcessClassContext processClassContext) {

        if(!domainObjectIfAny.isPresent()) {
            return;
        }
        var facetHolder = processClassContext.getFacetHolder();

        processLifecycleEventCreated(domainObjectIfAny, facetHolder);
        processLifecycleEventLoaded(domainObjectIfAny, facetHolder);
        processLifecycleEventPersisted(domainObjectIfAny, facetHolder);
        processLifecycleEventPersisting(domainObjectIfAny, facetHolder);
        processLifecycleEventRemoving(domainObjectIfAny, facetHolder);
        processLifecycleEventUpdated(domainObjectIfAny, facetHolder);
        processLifecycleEventUpdating(domainObjectIfAny, facetHolder);
    }

    private void processDomainEvents(
            final Optional<DomainObject> domainObjectIfAny,
            final ProcessClassContext processClassContext) {
        if(!domainObjectIfAny.isPresent()) {
            return;
        }
        var facetHolder = processClassContext.getFacetHolder();

        processDomainEventAction(domainObjectIfAny, facetHolder);
        processDomainEventProperty(domainObjectIfAny, facetHolder);
        processDomainEventCollection(domainObjectIfAny, facetHolder);
    }

    private void processLifecycleEventCreated(
            final Optional<DomainObject> domainObjectIfAny,
            final FacetHolder holder) {

        domainObjectIfAny
        .map(DomainObject::createdLifecycleEvent)
        .filter(lifecycleEvent ->
        MmEventUtils.eventTypeIsPostable(
                lifecycleEvent,
                ObjectCreatedEvent.Noop.class,
                ObjectCreatedEvent.Default.class,
                getConfiguration().getApplib().getAnnotation().getDomainObject().getCreatedLifecycleEvent().isPostForDefault())
                )
        .map(lifecycleEvent -> new CreatedLifecycleEventFacetForDomainObjectAnnotation(
                holder, lifecycleEvent))
        .ifPresent(super::addFacet);
    }

    private void processLifecycleEventLoaded(
            final Optional<DomainObject> domainObjectIfAny,
            final FacetHolder holder) {

        domainObjectIfAny
        .map(DomainObject::loadedLifecycleEvent)
        .filter(lifecycleEvent ->
        MmEventUtils.eventTypeIsPostable(
                lifecycleEvent,
                ObjectLoadedEvent.Noop.class,
                ObjectLoadedEvent.Default.class,
                getConfiguration().getApplib().getAnnotation().getDomainObject().getLoadedLifecycleEvent().isPostForDefault())
                )
        .map(lifecycleEvent -> new LoadedLifecycleEventFacetForDomainObjectAnnotation(
                holder, lifecycleEvent))
        .ifPresent(super::addFacet);
    }

    private void processLifecycleEventPersisting(
            final Optional<DomainObject> domainObjectIfAny,
            final FacetHolder holder) {

        domainObjectIfAny
        .map(DomainObject::persistingLifecycleEvent)
        .filter(lifecycleEvent ->
        MmEventUtils.eventTypeIsPostable(
                lifecycleEvent,
                ObjectPersistingEvent.Noop.class,
                ObjectPersistingEvent.Default.class,
                getConfiguration().getApplib().getAnnotation().getDomainObject().getPersistingLifecycleEvent().isPostForDefault())
                )
        .map(lifecycleEvent -> new PersistingLifecycleEventFacetForDomainObjectAnnotation(
                holder, lifecycleEvent))
        .ifPresent(super::addFacet);
    }

    private void processLifecycleEventPersisted(
            final Optional<DomainObject> domainObjectIfAny,
            final FacetHolder holder) {

        domainObjectIfAny
        .map(DomainObject::persistedLifecycleEvent)
        .filter(lifecycleEvent ->
        MmEventUtils.eventTypeIsPostable(
                lifecycleEvent,
                ObjectPersistedEvent.Noop.class,
                ObjectPersistedEvent.Default.class,
                getConfiguration().getApplib().getAnnotation().getDomainObject().getPersistedLifecycleEvent().isPostForDefault())
                )
        .map(lifecycleEvent -> new PersistedLifecycleEventFacetForDomainObjectAnnotation(
                holder, lifecycleEvent))
        .ifPresent(super::addFacet);
    }

    private void processLifecycleEventRemoving(
            final Optional<DomainObject> domainObjectIfAny,
            final FacetHolder holder) {

        domainObjectIfAny
        .map(DomainObject::removingLifecycleEvent)
        .filter(lifecycleEvent ->
        MmEventUtils.eventTypeIsPostable(
                lifecycleEvent,
                ObjectRemovingEvent.Noop.class,
                ObjectRemovingEvent.Default.class,
                getConfiguration().getApplib().getAnnotation().getDomainObject().getRemovingLifecycleEvent().isPostForDefault())
                )
        .map(lifecycleEvent -> new RemovingLifecycleEventFacetForDomainObjectAnnotation(
                holder, lifecycleEvent))
        .ifPresent(super::addFacet);
    }

    private void processLifecycleEventUpdated(
            final Optional<DomainObject> domainObjectIfAny,
            final FacetHolder holder) {

        domainObjectIfAny
        .map(DomainObject::updatedLifecycleEvent)
        .filter(lifecycleEvent ->
        MmEventUtils.eventTypeIsPostable(
                lifecycleEvent,
                ObjectUpdatedEvent.Noop.class,
                ObjectUpdatedEvent.Default.class,
                getConfiguration().getApplib().getAnnotation().getDomainObject().getUpdatedLifecycleEvent().isPostForDefault())
                )
        .map(lifecycleEvent -> new UpdatedLifecycleEventFacetForDomainObjectAnnotation(
                holder, lifecycleEvent))
        .ifPresent(super::addFacet);
    }

    private void processLifecycleEventUpdating(
            final Optional<DomainObject> domainObjectIfAny,
            final FacetHolder holder) {

        domainObjectIfAny
        .map(DomainObject::updatingLifecycleEvent)
        .filter(lifecycleEvent ->
        MmEventUtils.eventTypeIsPostable(
                lifecycleEvent,
                ObjectUpdatingEvent.Noop.class,
                ObjectUpdatingEvent.Default.class,
                getConfiguration().getApplib().getAnnotation().getDomainObject().getUpdatingLifecycleEvent().isPostForDefault())
                )
        .map(lifecycleEvent -> new UpdatingLifecycleEventFacetForDomainObjectAnnotation(
                holder, lifecycleEvent))
        .ifPresent(super::addFacet);
    }

    private void processDomainEventAction(
            final Optional<DomainObject> domainObjectIfAny,
            final FacetHolder holder) {

        domainObjectIfAny
        .map(DomainObject::actionDomainEvent)
        .flatMap(domainEvent -> ActionDomainEventDefaultFacetForDomainObjectAnnotation
                .create(domainEvent, holder))
        .ifPresent(super::addFacet);

    }

    private void processDomainEventProperty(
            final Optional<DomainObject> domainObjectIfAny,
            final FacetHolder holder) {

        domainObjectIfAny
        .map(DomainObject::propertyDomainEvent)
        .flatMap(domainEvent -> PropertyDomainEventDefaultFacetForDomainObjectAnnotation
                .create(domainEvent, holder))
        .ifPresent(super::addFacet);
    }

    private void processDomainEventCollection(
            final Optional<DomainObject> domainObjectIfAny,
            final FacetHolder holder) {

        domainObjectIfAny
        .map(DomainObject::collectionDomainEvent)
        .flatMap(domainEvent -> CollectionDomainEventDefaultFacetForDomainObjectAnnotation
                .create(domainEvent, holder))
        .ifPresent(super::addFacet);
    }

    @Override
    public void refineProgrammingModel(final ProgrammingModel programmingModel) {
        addValidatorToEnsureUniqueLogicalTypeNames(programmingModel);
    }

    private void addValidatorToEnsureUniqueLogicalTypeNames(final ProgrammingModel programmingModel) {

        programmingModel
        .addValidator(
            new MetaModelValidatorAbstract(getMetaModelContext(), spec->!spec.isAbstract()){

                final _Multimaps.ListMultimap<String, ObjectSpecification> specsByLogicalTypeName =
                        _Multimaps.newConcurrentListMultimap();

                @Override
                public void validateObjectEnter(final ObjectSpecification objSpec) {

                    // @DomainObject(logicalTypeName=...) must be unique among non-abstract types
                    // Eg. having an ApplicationUser interface and a concrete ApplicationUser (JDO)
                    // that have the same @DomainObject(logicalTypeName=...) should be allowed.
                    // A hard constraint that applies, is that there cannot be multiple bookmark-able
                    // types that share the same @DomainObject(logicalTypeName=...).
                    // This must be guaranteed by MM validation.
                    // - see also LogicalTypeResolver.register(...)

                    specsByLogicalTypeName.putElement(objSpec.logicalTypeName(), objSpec);

                    // also adding aliases to the multi-map
                    objSpec.getAliases()
                    .forEach(alias->
                        specsByLogicalTypeName.putElement(alias.logicalName(), objSpec));
                }

                @Override
                public void validateExit() {

                    specsByLogicalTypeName.forEach((logicalTypeName, collidingSpecs)->{
                        if(isObjectTypeCollision(collidingSpecs)) {

                            // assuming, a check for proxies is only required when there is also a bean name collision
                            // where the plain class and the proxied class collide having the same logical-type-name
                            var proxies = proxiesIn(collidingSpecs);
                            if(proxies.isNotEmpty()) {

                                proxies.forEach(spec->{
                                    ValidationFailure.raiseFormatted(spec,
                                        ProgrammingModelConstants.MessageTemplate.PROXIED_SERVICE_BEAN_NOT_ALLOWED_TO_CONTRIBUTE
                                            .builder()
                                            .addVariable("logicalTypeName", spec.logicalTypeName())
                                            .addVariable("csv", asCsv(proxies.toList()))
                                            .buildMessage());
                                });

                            } else {

                                collidingSpecs.stream()
                                .filter(this::logicalTypeNameIsNotIncludedInAliased)
                                .forEach(spec->{
                                    ValidationFailure.raiseFormatted(spec,
                                        ProgrammingModelConstants.MessageTemplate.NON_UNIQUE_LOGICAL_TYPE_NAME_OR_ALIAS
                                            .builder()
                                            .addVariable("logicalTypeName", spec.logicalTypeName())
                                            .addVariable("csv", asCsv(collidingSpecs))
                                            .buildMessage());
                                });

                            }
                        }
                    });

                    // clean-up
                    specsByLogicalTypeName.clear();
                }

                private boolean isProxy(final @NonNull ObjectSpecification spec) {
                    var cls = spec.getCorrespondingClass();
                    return !ClassUtils.getUserClass(cls).equals(cls);
                }

                private Can<ObjectSpecification> proxiesIn(final @Nullable List<ObjectSpecification> specs) {
                    return stream(specs).filter(this::isProxy).collect(Can.toCan());
                }

                private boolean logicalTypeNameIsNotIncludedInAliased(final ObjectSpecification objectSpecification) {
                    if (getConfiguration().getCore().getMetaModel().getValidator().isAllowLogicalTypeNameAsAlias()) {
                        return objectSpecification.getAliases()
                                .map(LogicalType::logicalName).stream()
                                .noneMatch(name -> objectSpecification.logicalTypeName().equals(name));
                    }
                    return true;
                }

                private boolean isObjectTypeCollision(final List<ObjectSpecification> specs) {
                    return specs.size()>1;
                }

                private String asCsv(final List<ObjectSpecification> specList) {
                    return stream(specList)
                            .map(ObjectSpecification::getFullIdentifier)
                            .sorted()
                            .collect(Collectors.joining(", "));
                }

            });
    }

}
