/*
 *  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.spec.impl;

import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.jspecify.annotations.Nullable;
import org.springframework.util.ClassUtils;

import org.apache.causeway.applib.Identifier;
import org.apache.causeway.applib.annotation.Domain;
import org.apache.causeway.applib.annotation.DomainService;
import org.apache.causeway.applib.annotation.Introspection.IntrospectionPolicy;
import org.apache.causeway.applib.fa.FontAwesomeLayers;
import org.apache.causeway.applib.id.LogicalType;
import org.apache.causeway.applib.services.metamodel.BeanSort;
import org.apache.causeway.commons.collections.Can;
import org.apache.causeway.commons.collections.ImmutableEnumSet;
import org.apache.causeway.commons.internal.assertions._Assert;
import org.apache.causeway.commons.internal.base._Casts;
import org.apache.causeway.commons.internal.base._Lazy;
import org.apache.causeway.commons.internal.base._NullSafe;
import org.apache.causeway.commons.internal.base._Oneshot;
import org.apache.causeway.commons.internal.base._Strings;
import org.apache.causeway.commons.internal.collections._Lists;
import org.apache.causeway.commons.internal.collections._Maps;
import org.apache.causeway.commons.internal.collections._Multimaps;
import org.apache.causeway.commons.internal.collections._Multimaps.ListMultimap;
import org.apache.causeway.commons.internal.collections._Sets;
import org.apache.causeway.commons.internal.collections._Streams;
import org.apache.causeway.commons.internal.exceptions._Exceptions;
import org.apache.causeway.commons.internal.reflection._ClassCache;
import org.apache.causeway.commons.internal.reflection._GenericResolver.ResolvedMethod;
import org.apache.causeway.commons.internal.reflection._MethodFacades.MethodFacade;
import org.apache.causeway.commons.internal.reflection._Reflect;
import org.apache.causeway.core.config.beans.CausewayBeanMetaData;
import org.apache.causeway.core.config.beans.CausewayBeanTypeRegistry;
import org.apache.causeway.core.metamodel.consent.Consent;
import org.apache.causeway.core.metamodel.consent.InteractionInitiatedBy;
import org.apache.causeway.core.metamodel.consent.InteractionResult;
import org.apache.causeway.core.metamodel.context.MetaModelContext;
import org.apache.causeway.core.metamodel.facetapi.Facet;
import org.apache.causeway.core.metamodel.facetapi.FacetHolder;
import org.apache.causeway.core.metamodel.facetapi.FeatureType;
import org.apache.causeway.core.metamodel.facets.FacetedMethod;
import org.apache.causeway.core.metamodel.facets.ImperativeFacet;
import org.apache.causeway.core.metamodel.facets.actcoll.typeof.TypeOfFacet;
import org.apache.causeway.core.metamodel.facets.all.described.ObjectDescribedFacet;
import org.apache.causeway.core.metamodel.facets.all.help.HelpFacet;
import org.apache.causeway.core.metamodel.facets.all.hide.HiddenFacet;
import org.apache.causeway.core.metamodel.facets.all.named.MemberNamedFacet;
import org.apache.causeway.core.metamodel.facets.all.named.MemberNamedFacetForStaticMemberName;
import org.apache.causeway.core.metamodel.facets.all.named.ObjectNamedFacet;
import org.apache.causeway.core.metamodel.facets.members.cssclass.CssClassFacet;
import org.apache.causeway.core.metamodel.facets.members.iconfa.FaFacet;
import org.apache.causeway.core.metamodel.facets.members.iconfa.FaLayersProvider;
import org.apache.causeway.core.metamodel.facets.object.entity.EntityFacet;
import org.apache.causeway.core.metamodel.facets.object.icon.IconFacet;
import org.apache.causeway.core.metamodel.facets.object.icon.ObjectIcon;
import org.apache.causeway.core.metamodel.facets.object.immutable.ImmutableFacet;
import org.apache.causeway.core.metamodel.facets.object.introspection.IntrospectionPolicyFacet;
import org.apache.causeway.core.metamodel.facets.object.logicaltype.AliasedFacet;
import org.apache.causeway.core.metamodel.facets.object.mixin.MixinFacet;
import org.apache.causeway.core.metamodel.facets.object.mixin.MixinFacet.Contributing;
import org.apache.causeway.core.metamodel.facets.object.mixin.MixinFacetAbstract;
import org.apache.causeway.core.metamodel.facets.object.navparent.NavigableParentFacet;
import org.apache.causeway.core.metamodel.facets.object.parented.ParentedCollectionFacet;
import org.apache.causeway.core.metamodel.facets.object.title.TitleFacet;
import org.apache.causeway.core.metamodel.facets.object.title.TitleRenderRequest;
import org.apache.causeway.core.metamodel.facets.object.value.ValueFacet;
import org.apache.causeway.core.metamodel.facets.object.viewmodel.ViewModelFacet;
import org.apache.causeway.core.metamodel.interactions.InteractionContext;
import org.apache.causeway.core.metamodel.interactions.InteractionUtils;
import org.apache.causeway.core.metamodel.interactions.acc.ObjectTitleContext;
import org.apache.causeway.core.metamodel.interactions.val.ObjectValidityContext;
import org.apache.causeway.core.metamodel.object.ManagedObject;
import org.apache.causeway.core.metamodel.object.ManagedObjects;
import org.apache.causeway.core.metamodel.services.classsubstitutor.ClassSubstitutorRegistry;
import org.apache.causeway.core.metamodel.spec.ActionScope;
import org.apache.causeway.core.metamodel.spec.ObjectSpecification;
import org.apache.causeway.core.metamodel.spec.feature.MixedIn;
import org.apache.causeway.core.metamodel.spec.feature.ObjectAction;
import org.apache.causeway.core.metamodel.spec.feature.ObjectAssociation;
import org.apache.causeway.core.metamodel.spec.feature.ObjectMember;
import org.apache.causeway.core.metamodel.spi.EntityTitleSubscriber;
import org.apache.causeway.core.metamodel.util.Facets;

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

import lombok.Getter;
import org.jspecify.annotations.NonNull;
import lombok.extern.log4j.Log4j2;

@Log4j2
final class ObjectSpecificationDefault
implements ObjectMemberContainer, ObjectSpecificationMutable, HasSpecificationLoaderInternal {

    // -- CONSTRUCTION

    /**
     * Lazily built by {@link #getMember(Method)}.
     */
    private Map<ResolvedMethod, ObjectMember> membersByMethod = null;

    private final FacetedMethodsBuilder facetedMethodsBuilder;
    private final ClassSubstitutorRegistry classSubstitutorRegistry;

    @Getter(onMethod_={@Override})
    private final IntrospectionPolicy introspectionPolicy;

    public ObjectSpecificationDefault(
            final @NonNull CausewayBeanMetaData typeMeta,
            final @NonNull MetaModelContext mmc,
            final @NonNull FacetProcessor facetProcessor,
            final @NonNull PostProcessor postProcessor,
            final @NonNull ClassSubstitutorRegistry classSubstitutorRegistry) {

        this.correspondingClass = typeMeta.getCorrespondingClass();
        this.logicalType = typeMeta.logicalType();
        this.fullName = correspondingClass.getName();
        this.shortName = typeMeta.logicalType().logicalSimpleName();
        this.beanSort = typeMeta.beanSort();

        this.facetHolder = FacetHolder.simple(
            facetProcessor.getMetaModelContext(),
            Identifier.classIdentifier(logicalType));

        this.facetProcessor = facetProcessor;
        this.postProcessor = postProcessor;

        this.isVetoedForInjection = switch (typeMeta.managedBy()) {
            case NONE, CAUSEWAY, PERSISTENCE -> true;
            case UNSPECIFIED, SPRING  -> false;
        };
        this.classSubstitutorRegistry = classSubstitutorRegistry;

        // must install EncapsulationFacet (if any) and MemberAnnotationPolicyFacet (if any)
        facetProcessor.processObjectType(typeMeta.getCorrespondingClass(), this);

        // naturally supports attribute inheritance from the type's hierarchy
        this.introspectionPolicy = this.lookupFacet(IntrospectionPolicyFacet.class)
                .map(introspectionPolicyFacet->introspectionPolicyFacet.getIntrospectionPolicy())
                .orElseGet(()->mmc.getConfiguration().getCore().getMetaModel().getIntrospector().getPolicy());

        this.facetedMethodsBuilder =
                new FacetedMethodsBuilder(this, facetProcessor, classSubstitutorRegistry);
    }

    // -- CONTRACT

    @Override
    public int hashCode() {
        return correspondingClass.hashCode();
    }
    @Override
    public boolean equals(final Object o) {
        return (o instanceof ObjectSpecification other)
            ? Objects.equals(this.correspondingClass, other.getCorrespondingClass())
            : false;
    }
    @Override
    public String toString() {
        return "ObjSpec[class=%s, sort=%s, super=%s]"
            .formatted(getFullIdentifier(), getBeanSort().name(), superclass() == null
                ? "Object"
                : superclass().getFullIdentifier());
    }

    protected void introspectTypeHierarchy() {

        facetedMethodsBuilder.introspectClass();

        // name
        addNamedFacetIfRequired();

        // go no further if a value
        if(this.isValue()) {
            if (log.isDebugEnabled()) {
                log.debug("skipping type hierarchy introspection for value type {}", getFullIdentifier());
            }
            return;
        }

        // superclass
        final Class<?> superclass = getCorrespondingClass().getSuperclass();
        loadSpecOfSuperclass(superclass);

        // walk superinterfaces

        //
        // REVIEW: the processing here isn't quite the same as with
        // superclasses, in that with superclasses the superclass adds this type as its
        // subclass, whereas here this type defines itself as the subtype.
        //
        // it'd be nice to push the responsibility for adding subclasses to
        // the interface type... needs some tests around it, though, before
        // making that refactoring.
        //
        final Class<?>[] interfaceTypes = getCorrespondingClass().getInterfaces();
        final List<ObjectSpecification> interfaceSpecList = _Lists.newArrayList();
        for (var interfaceType : interfaceTypes) {
            var interfaceSubstitute = classSubstitutorRegistry.getSubstitution(interfaceType);
            if (interfaceSubstitute.isReplace()) {
                var interfaceSpec = specLoaderInternal().loadSpecification(interfaceSubstitute.replacement());
                interfaceSpecList.add(interfaceSpec);
            }
        }

        updateAsSubclassTo(interfaceSpecList);
        updateInterfaces(interfaceSpecList);
    }

    private void introspectMembers() {

        // yet this logic does not skip UNKNONW
        if(this.getBeanSort().isCollection()
                || this.getBeanSort().isVetoed()
                || this.isValue()) {
            if (log.isDebugEnabled()) {
                log.debug("skipping full introspection for {} type {}", this.getBeanSort(), getFullIdentifier());
            }
            return;
        }

        // create associations and actions
        replaceAssociations(createAssociations());
        replaceActions(createActions());

        postProcessor.postProcess(this);
        invalidateCachedFacets();
    }

    private void addNamedFacetIfRequired() {
        if (getFacet(MemberNamedFacet.class) == null) {
            addFacet(new MemberNamedFacetForStaticMemberName(
                    _Strings.asNaturalName.apply(getShortIdentifier()),
                    this));
        }
    }

    // -- create associations and actions
    private Stream<ObjectAssociation> createAssociations() {
        return facetedMethodsBuilder.getAssociationFacetedMethods()
                .stream()
                .map(this::createAssociation)
                .filter(_NullSafe::isPresent);
    }

    private ObjectAssociation createAssociation(final FacetedMethod facetMethod) {
        if (facetMethod.featureType().isCollection()) {
            return OneToManyAssociationDefault.forMethod(facetMethod);
        } else if (facetMethod.featureType().isProperty()) {
            return OneToOneAssociationDefault.forMethod(facetMethod);
        } else {
            return null;
        }
    }

    private Stream<ObjectAction> createActions() {
        return facetedMethodsBuilder.getActionFacetedMethods()
                .stream()
                .map(this::createAction)
                .filter(_NullSafe::isPresent);
    }

    private ObjectAction createAction(final FacetedMethod facetedMethod) {
        if (facetedMethod.featureType().isAction()) {
            /* Assuming, that facetedMethod was already populated with ContributingFacet,
             * we copy the mixin-sort information from the FacetedMethod to the MixinFacet
             * that is held by the mixin's type spec. */
            mixinFacet()
            .flatMap(mixinFacet->_Casts.castTo(MixinFacetAbstract.class, mixinFacet))
            .ifPresent(mixinFacetAbstract->
                mixinFacetAbstract.initMixinSortFrom(facetedMethod));

            return this.isMixin()
                    ? ObjectActionDefault.forMixinMain(facetedMethod)
                    : ObjectActionDefault.forMethod(facetedMethod);
        } else {
            return null;
        }
    }

    // -- getObjectAction

    @Override
    public Optional<ObjectAction> getDeclaredAction(
            final @Nullable String id,
            final ImmutableEnumSet<ActionScope> actionScopes,
            final MixedIn mixedIn) {

        introspectUpTo(IntrospectionState.FULLY_INTROSPECTED);

        return _Strings.isEmpty(id)
            ? Optional.empty()
            : streamDeclaredActions(actionScopes, mixedIn)
                .filter(action->
                    id.equals(action.getFeatureIdentifier().getMemberNameAndParameterClassNamesIdentityString())
                            || id.equals(action.getFeatureIdentifier().memberLogicalName())
                )
                .findFirst();
    }

    @Override
    public Optional<? extends ObjectMember> getMember(final ResolvedMethod method) {
        introspectUpTo(IntrospectionState.FULLY_INTROSPECTED);

        if (membersByMethod == null) {
            this.membersByMethod = catalogueMembers();
        }

        var member = membersByMethod.get(method);
        return Optional.ofNullable(member);
    }

    private Map<ResolvedMethod, ObjectMember> catalogueMembers() {
        var membersByMethod = _Maps.<ResolvedMethod, ObjectMember>newHashMap();
        cataloguePropertiesAndCollections(membersByMethod::put);
        catalogueActions(membersByMethod::put);
        return membersByMethod;
    }

    private void cataloguePropertiesAndCollections(final BiConsumer<ResolvedMethod, ObjectMember> onMember) {
        streamDeclaredAssociations(MixedIn.EXCLUDED)
        .forEach(field->
            field.streamFacets(ImperativeFacet.class)
                .map(ImperativeFacet::getMethods)
                .flatMap(Can::stream)
                .map(MethodFacade::asMethodElseFail) // expected regular
                .peek(method->_Reflect.guardAgainstSynthetic(method.method())) // expected non-synthetic
                .forEach(imperativeFacetMethod->onMember.accept(imperativeFacetMethod, field)));
    }

    private void catalogueActions(final BiConsumer<ResolvedMethod, ObjectMember> onMember) {
        streamDeclaredActions(MixedIn.INCLUDED)
        .forEach(userAction->
            userAction.streamFacets(ImperativeFacet.class)
                .map(ImperativeFacet::getMethods)
                .flatMap(Can::stream)
                .map(MethodFacade::asMethodForIntrospection)
                .peek(method->_Reflect.guardAgainstSynthetic(method.method())) // expected non-synthetic
                .forEach(imperativeFacetMethod->
                    onMember.accept(imperativeFacetMethod, userAction)));
    }

    // -- ELEMENT SPECIFICATION

    private final _Lazy<Optional<ObjectSpecification>> elementSpecification =
            _Lazy.threadSafe(()->lookupFacet(TypeOfFacet.class)
                    .map(typeOfFacet -> typeOfFacet.elementSpec()));

    @Override
    public Optional<ObjectSpecification> getElementSpecification() {
        return elementSpecification.get();
    }

    // -- TABLE COLUMN RENDERING

    @Override
    public final Stream<ObjectAssociation> streamAssociationsForColumnRendering(
            final Identifier memberIdentifier,
            final ManagedObject parentObject) {
        return new _MembersAsColumns(getMetaModelContext())
            .streamAssociationsForColumnRendering(this, memberIdentifier, parentObject);
    }

    @Override
    public Stream<ObjectAction> streamActionsForColumnRendering(
            final Identifier memberIdentifier) {
        return new _MembersAsColumns(getMetaModelContext())
                .streamActionsForColumnRendering(this, memberIdentifier);
    }

    // -- DETERMINE INJECTABILITY

    private boolean isVetoedForInjection;

    private _Lazy<Boolean> isInjectableLazy = _Lazy.threadSafe(()->
        !isVetoedForInjection
                && !getBeanSort().isAbstract()
                && !getBeanSort().isValue()
                && !getBeanSort().isEntity()
                && !getBeanSort().isViewModel()
                && !getBeanSort().isMixin()
                && (getBeanSort().isManagedBeanAny()
                        || getServiceRegistry()
                                .lookupRegisteredBeanById(logicalType())
                                .isPresent())
                );

    @Override
    public boolean isInjectable() {
        return isInjectableLazy.get();
    }

    private _Lazy<Boolean> isDomainServiceLazy = _Lazy.threadSafe(()->
        _ClassCache.getInstance().head(getCorrespondingClass()).hasAnnotation(DomainService.class));

    @Override
    public boolean isDomainService() {
        return isDomainServiceLazy.get();
    }

    //-----------------------------------------------------------------------------------------------------------------
    // MERGED FROM FORMER ObjectSpecificationAbstract
    //-----------------------------------------------------------------------------------------------------------------

    /**
     * @implNote thread-safe
     */
    private static class Subclasses {

        // List performs better compared to a Set, when the number of elements is low
        private Can<ObjectSpecification> classes = Can.empty();

        public void addSubclass(final ObjectSpecification subclass) {
            synchronized(classes) {
                classes = classes.addUnique(subclass);
            }
        }

        public boolean hasSubclasses() {
            synchronized(classes) {
                return classes.isNotEmpty();
            }
        }

        public Can<ObjectSpecification> snapshot() {
            synchronized(classes) {
                return classes;
            }
        }
    }

    // -- FIELDS

    private final PostProcessor postProcessor;
    private final FacetProcessor facetProcessor;

    @Getter private final BeanSort beanSort;

    // -- ASSOCIATIONS

    private final List<ObjectAssociation> associations = _Lists.newArrayList();

    // defensive immutable lazy copy of associations
    private final _Lazy<Can<ObjectAssociation>> unmodifiableAssociations =
            _Lazy.threadSafe(()->Can.ofCollection(associations));

    // -- ACTIONS

    private final List<ObjectAction> objectActions = _Lists.newArrayList();

    /** not API, used for validation */
    @Getter private final Set<ResolvedMethod> potentialOrphans = _Sets.newHashSet();

    // defensive immutable lazy copy of objectActions
    private final _Lazy<Can<ObjectAction>> unmodifiableActions =
            _Lazy.threadSafe(()->Can.ofCollection(objectActions));

    // partitions and caches objectActions by type; updated in sortCacheAndUpdateActions()
    private final ListMultimap<ActionScope, ObjectAction> objectActionsByType =
            _Multimaps.newConcurrentListMultimap();

    // -- INTERFACES

    private final List<ObjectSpecification> interfaces = _Lists.newArrayList();

    // defensive immutable lazy copy of interfaces
    private final _Lazy<Can<ObjectSpecification>> unmodifiableInterfaces =
            _Lazy.threadSafe(()->Can.ofCollection(interfaces));

    private final Subclasses directSubclasses = new Subclasses();
    // built lazily
    private Subclasses transitiveSubclasses;

    private final Class<?> correspondingClass;
    private final String fullName;
    private final String shortName;

    private final LogicalType logicalType;

    private ObjectSpecification superclassSpec;

    private ValueFacet<?> valueFacet;
    private EntityFacet entityFacet;
    private ViewModelFacet viewmodelFacet;
    private MixinFacet mixinFacet;
    private TitleFacet titleFacet;
    private IconFacet iconFacet;
    private NavigableParentFacet navigableParentFacet;
    private AliasedFacet aliasedFacet;
    private CssClassFacet cssClassFacet;

    private IntrospectionState introspectionState = IntrospectionState.NOT_INTROSPECTED;

    @Getter(onMethod_ = {@Override}) private FacetHolder facetHolder;



    // -- Stuff immediately derivable from class
    @Override
    public final FeatureType getFeatureType() {
        return FeatureType.OBJECT;
    }

    @Override
    public final LogicalType logicalType() {
        return logicalType;
    }

    @Override
    public final Class<?> getCorrespondingClass() {
        return correspondingClass;
    }

    @Override
    public final String getShortIdentifier() {
        return shortName;
    }

    /**
     * The {@link Class#getName() (full) name} of the
     * {@link #getCorrespondingClass() class}.
     */
    @Override
    public final String getFullIdentifier() {
        return fullName;
    }

    @Override
    public void introspectUpTo(final IntrospectionState upTo) {

        if(!isLessThan(upTo)) {
            return; // optimization
        }

        if(log.isDebugEnabled()) {
            log.debug("introspectingUpTo: {}, {}", getFullIdentifier(), upTo);
        }

        boolean revalidate = false;

        switch (introspectionState) {
        case NOT_INTROSPECTED:
            if(isLessThan(upTo)) {
                introspectType();
            }
            if(isLessThan(upTo)) {
                introspectFully();
                revalidate = true;
            }
            // set to avoid infinite loops
            break;

        case TYPE_BEING_INTROSPECTED:
            // nothing to do (interim state during introspectType)
            break;

        case TYPE_INTROSPECTED:
            if(isLessThan(upTo)) {
                introspectFully();
                revalidate = true;
            }
            break;

        case MEMBERS_BEING_INTROSPECTED:
            // nothing to do (interim state during introspectully)
            break;

        case FULLY_INTROSPECTED:
            // nothing to do ... all done
            break;

        default:
            throw _Exceptions.unexpectedCodeReach();
        }

        if(revalidate) {
            getSpecificationLoader().validateLater(this);
        }
    }

    private void introspectType() {

        // set to avoid infinite loops
        this.introspectionState = IntrospectionState.TYPE_BEING_INTROSPECTED;
        introspectTypeHierarchy();
        invalidateCachedFacets();
        this.introspectionState = IntrospectionState.TYPE_INTROSPECTED;
    }

    private void introspectFully() {

        // set to avoid infinite loops
        this.introspectionState = IntrospectionState.MEMBERS_BEING_INTROSPECTED;
        introspectMembers();
        this.introspectionState = IntrospectionState.FULLY_INTROSPECTED;

        // make sure we've loaded the facets from layout.xml also.
        Facets.gridPreload(this, null);
    }

    boolean isLessThan(final IntrospectionState upTo) {
        return this.introspectionState.compareTo(upTo) < 0;
    }

    protected void loadSpecOfSuperclass(final Class<?> superclass) {
        if (superclass == null) {
            return;
        }
        superclassSpec = specLoaderInternal().loadSpecification(superclass);
        if (superclassSpec != null) {
            if (log.isDebugEnabled()) {
                log.debug("  Superclass {}", superclass.getName());
            }
            updateAsSubclassTo(superclassSpec);
        }
    }

    protected void updateInterfaces(final List<ObjectSpecification> interfaces) {
        synchronized(unmodifiableInterfaces) {
            this.interfaces.clear();
            this.interfaces.addAll(interfaces);
            unmodifiableInterfaces.clear();
        }
    }

    private void updateAsSubclassTo(final ObjectSpecification supertypeSpec) {
        // API
        var introspectableSpec = (ObjectSpecificationDefault) supertypeSpec;
        introspectableSpec.updateSubclasses(this);
    }

    protected void updateAsSubclassTo(final List<ObjectSpecification> supertypeSpecs) {
        for (final ObjectSpecification supertypeSpec : supertypeSpecs) {
            updateAsSubclassTo(supertypeSpec);
        }
    }

    private void updateSubclasses(final ObjectSpecification subclass) {
        this.directSubclasses.addSubclass(subclass);
    }

    protected final void replaceAssociations(final Stream<ObjectAssociation> associations) {
        var orderedAssociations = _MemberSortingUtils.sortAssociationsIntoList(associations);
        synchronized (unmodifiableAssociations) {
            this.associations.clear();
            this.associations.addAll(orderedAssociations);
            unmodifiableAssociations.clear(); // invalidate
        }
    }

    protected final void replaceActions(final Stream<ObjectAction> objectActions) {
        var orderedActions = _MemberSortingUtils.sortActionsIntoList(objectActions);
        synchronized (unmodifiableActions){
            this.objectActions.clear();
            this.objectActions.addAll(orderedActions);
            unmodifiableActions.clear(); // invalidate

            // rebuild objectActionsByType multi-map
            for (var actionType : ActionScope.values()) {
                var objectActionForType = objectActionsByType.getOrElseNew(actionType);
                objectActionForType.clear();
                orderedActions.stream()
                .filter(ObjectAction.Predicates.ofActionType(actionType))
                .forEach(objectActionForType::add);
            }
        }
    }

    void invalidateCachedFacets() {
        this.valueFacet = getFacet(ValueFacet.class);
        this.titleFacet = lookupNonFallbackFacet(TitleFacet.class).orElse(null);
        this.iconFacet = getFacet(IconFacet.class);
        this.navigableParentFacet = getFacet(NavigableParentFacet.class);
        this.cssClassFacet = getFacet(CssClassFacet.class);
        this.aliasedFacet = getFacet(AliasedFacet.class);
    }

    @Override
    public final Optional<ValueFacet<?>> valueFacet() {
        if(valueFacet == null
                && getBeanSort().isValue()) {
            invalidateCachedFacets();
        }
        return Optional.ofNullable(valueFacet);
    }

    @Override
    public final Optional<MixinFacet> mixinFacet() {
        // deliberately don't memoize lookup misses, because could be too early
        if(mixinFacet==null) {
            mixinFacet = getFacet(MixinFacet.class);
        }
        return Optional.ofNullable(mixinFacet);
    }

    @Override
    public final Optional<EntityFacet> entityFacet() {
        // deliberately don't memoize lookup misses, because could be too early
        if(entityFacet==null) {
            entityFacet = getFacet(EntityFacet.class);
        }
        return Optional.ofNullable(entityFacet);
    }

    @Override
    public final Optional<ViewModelFacet> viewmodelFacet() {
        // deliberately don't memoize lookup misses, because could be too early
        if(viewmodelFacet==null) {
            viewmodelFacet = getFacet(ViewModelFacet.class);
        }
        return Optional.ofNullable(viewmodelFacet);
    }

    @Override
    public String getTitle(final TitleRenderRequest titleRenderRequest) {
        if (titleFacet != null) {
            var titleString = titleFacet.title(titleRenderRequest);
            if (!_Strings.isEmpty(titleString)) {
                notifySubscribersIfEntity(titleRenderRequest, titleString);
                return titleString;
            }
        }
        var prefix = this.isInjectable()
                ? ""
                : "Untitled ";
        return prefix + getSingularName();
    }

    private void notifySubscribersIfEntity(
            final TitleRenderRequest titleRenderRequest,
            final String titleString) {
        if (!isEntity()) {
            return;
        }
        var managedObject = titleRenderRequest.object();
        managedObject.getBookmark().ifPresent(bookmark -> {
            getTitleSubscribers().stream().forEach(x -> x.entityTitleIs(bookmark, titleString));
        });
    }

    @Override
    public String getIconName(final ManagedObject domainObject) {
        if(ManagedObjects.isSpecified(domainObject)) {
            _Assert.assertEquals(domainObject.objSpec(), this);
        }
        return iconFacet != null
                ? iconFacet.iconName(domainObject)
                : null;
    }

    @Override
    public ObjectIcon getIcon(final ManagedObject domainObject) {
        var iconNameModifier = getIconName(domainObject);
        return getObjectIconService().getObjectIcon(this, iconNameModifier);
    }

    @Override
    public Object getNavigableParent(final Object object) {
        return navigableParentFacet != null
                ? navigableParentFacet.navigableParent(object)
                : null;
    }

    @Override
    public String getCssClass(final ManagedObject reference) {
        return cssClassFacet != null
                ? cssClassFacet.cssClass(reference)
                : null;
    }

    @Override
    public Optional<FontAwesomeLayers> getFaLayers(final ManagedObject reference){
        return lookupFacet(FaFacet.class)
                .map(FaFacet::getSpecialization)
                .map(either->either.fold(
                        faStaticFacet->(FaLayersProvider)faStaticFacet,
                        faImperativeFacet->faImperativeFacet.getFaLayersProvider(reference)))
                .map(FaLayersProvider::getLayers);
    }

    @Override
    public Can<LogicalType> getAliases() {
        return aliasedFacet != null
                ? aliasedFacet.getAliases()
                : Can.empty();
    }

    // -- HIERARCHICAL

    @Override
    public boolean isOfType(final ObjectSpecification other) {

        var thisClass = this.getCorrespondingClass();
        var otherClass = other.getCorrespondingClass();

        return thisClass == otherClass
                || otherClass.isAssignableFrom(thisClass);
    }

    @Override
    public boolean isOfTypeResolvePrimitive(final ObjectSpecification other) {

        var thisClass = ClassUtils.resolvePrimitiveIfNecessary(this.getCorrespondingClass());
        var otherClass = ClassUtils.resolvePrimitiveIfNecessary(other.getCorrespondingClass());

        return thisClass == otherClass
                || otherClass.isAssignableFrom(thisClass);
    }

    // -- NAME, DESCRIPTION, PERSISTABILITY

    @Override
    public String getSingularName() {
        return lookupFacet(ObjectNamedFacet.class)
            .flatMap(textFacet->textFacet.translated())
            // unexpected code reach, however keep for JUnit testing
            .orElseGet(()->String.format(
                    "(%s has neither title- nor object-named-facet)",
                    getFullIdentifier()));
    }

    /**
     * The translated description according to any available {@link ObjectDescribedFacet},
     * else empty string (<tt>""</tt>).
     */
    @Override
    public String getDescription() {
        return lookupFacet(ObjectDescribedFacet.class)
                .map(ObjectDescribedFacet::translated)
                .orElse("");
    }

    /*
     * help is typically a reference (eg a URL) and so should not default to a
     * textual value if not set up
     */
    @Override
    public String getHelp() {
        var helpFacet = getFacet(HelpFacet.class);
        return helpFacet == null ? null : helpFacet.value();
    }

    @Override
    public final Optional<Contributing> contributing() {
        return mixinFacet()
                .map(MixinFacet::contributing);
    }

    // -- FACET HANDLING

    @Override
    public <Q extends Facet> Q getFacet(final Class<Q> facetType) {

        synchronized(unmodifiableInterfaces) {

            // lookup facet holder's facet
            var facets1 = _NullSafe.streamNullable(facetHolder.getFacet(facetType));

            // lookup all interfaces
            var facets2 = _NullSafe.stream(interfaces())
                    .filter(_NullSafe::isPresent) // just in case
                    .map(interfaceSpec->interfaceSpec.getFacet(facetType));

            // search up the inheritance hierarchy
            var facets3 = _NullSafe.streamNullable(superclass())
                    .map(superSpec->superSpec.getFacet(facetType));

            var facetsCombined = _Streams.concat(facets1, facets2, facets3);

            var notANoopFacetFilter = new NotANoopFacetFilter<Q>();

            return facetsCombined
                    .filter(notANoopFacetFilter)
                    .findFirst()
                    .orElse(notANoopFacetFilter.noopFacet);

        }
    }

    @Domain.Exclude
    private static class NotANoopFacetFilter<Q extends Facet> implements Predicate<Q> {
        Q noopFacet;

        @Override
        public boolean test(final Q facet) {
            if(facet==null) {
                return false;
            }
            if(!facet.getPrecedence().isFallback()) {
                return true;
            }
            if(noopFacet == null) {
                noopFacet = facet;
            }
            return false;
        }
    }

    @Override
    public ObjectTitleContext createTitleInteractionContext(
            final ManagedObject targetObjectAdapter,
            final InteractionInitiatedBy interactionMethod) {

        return new ObjectTitleContext(targetObjectAdapter, getFeatureIdentifier(),
                targetObjectAdapter.getTitle(),
                interactionMethod);
    }

    // -- SUPERCLASS, INTERFACES, SUBCLASSES, IS-ABSTRACT

    @Override
    public ObjectSpecification superclass() {
        return superclassSpec;
    }

    @Override
    public Can<ObjectSpecification> interfaces() {
        return unmodifiableInterfaces.get();
    }

    @Override
    public Can<ObjectSpecification> subclasses(final Depth depth) {
        if (depth == Depth.DIRECT) {
            return directSubclasses.snapshot();
        }

        // depth == Depth.TRANSITIVE)
        if (transitiveSubclasses == null) {
            transitiveSubclasses = transitiveSubclasses();
        }

        return transitiveSubclasses.snapshot();
    }

    private synchronized Subclasses transitiveSubclasses() {
        final Subclasses appendTo = new Subclasses();
        appendSubclasses(this, appendTo);
        transitiveSubclasses = appendTo;
        return transitiveSubclasses;
    }

    private void appendSubclasses(
            final ObjectSpecification objectSpecification,
            final Subclasses appendTo) {

        var directSubclasses = objectSpecification.subclasses(Depth.DIRECT);
        for (ObjectSpecification subclass : directSubclasses) {
            appendTo.addSubclass(subclass);
            appendSubclasses(subclass, appendTo);
        }

    }

    @Override
    public boolean hasSubclasses() {
        return directSubclasses.hasSubclasses();
    }

    // -- ASSOCIATIONS

    @Override
    public Stream<ObjectAssociation> streamDeclaredAssociations(final MixedIn mixedIn) {
        introspectUpTo(IntrospectionState.FULLY_INTROSPECTED);

        mixedInAssociationAdder.trigger(this::createMixedInAssociationsAndResort); // only if not already

        synchronized(unmodifiableAssociations) {
            return stream(unmodifiableAssociations.get())
                    .filter(mixedIn.toFilter());
        }
    }

    @Override
    public Optional<? extends ObjectMember> getMember(final String memberId) {
        introspectUpTo(IntrospectionState.FULLY_INTROSPECTED);

        if(_Strings.isEmpty(memberId)) {
            return Optional.empty();
        }

        var objectAction = getAction(memberId);
        if(objectAction.isPresent()) {
            return objectAction;
        }
        var association = getAssociation(memberId);
        if(association.isPresent()) {
            return association;
        }
        return Optional.empty();
    }

    @Override
    public Optional<ObjectAssociation> getDeclaredAssociation(final String id, final MixedIn mixedIn) {
        introspectUpTo(IntrospectionState.FULLY_INTROSPECTED);

        if(_Strings.isEmpty(id)) {
            return Optional.empty();
        }

        return streamDeclaredAssociations(mixedIn)
                .filter(objectAssociation->objectAssociation.getId().equals(id))
                .findFirst();
    }

    @Override
    public Stream<ObjectAction> streamRuntimeActions(final MixedIn mixedIn) {
        var actionScopes = ActionScope.forEnvironment(getMetaModelContext().getSystemEnvironment());
        return streamActions(actionScopes, mixedIn);
    }

    @Override
    public Stream<ObjectAction> streamDeclaredActions(
            final ImmutableEnumSet<ActionScope> actionScopes,
            final MixedIn mixedIn) {
        introspectUpTo(IntrospectionState.FULLY_INTROSPECTED);

        mixedInActionAdder.trigger(this::createMixedInActionsAndResort);

        return actionScopes.stream()
                .flatMap(actionScope->stream(objectActionsByType.get(actionScope)))
                .filter(mixedIn.toFilter());
    }

    // -- mixin associations (properties and collections)
    /**
     * Creates all mixed in properties and collections for this spec.
     */
    private Stream<ObjectAssociation> createMixedInAssociations() {
        if (isInjectable() || isValue()) {
            return Stream.empty();
        }
        return getCausewayBeanTypeRegistry().streamMixinTypes()
                .flatMap(this::createMixedInAssociation);
    }

    private Stream<ObjectAssociation> createMixedInAssociation(final Class<?> mixinType) {

        var mixinSpec = specLoaderInternal().loadSpecification(mixinType,
                IntrospectionState.FULLY_INTROSPECTED);
        if (mixinSpec == null
                || mixinSpec == this) {
            return Stream.empty();
        }
        var mixinFacet = mixinSpec.mixinFacet().orElse(null);
        if(mixinFacet == null) {
            // this shouldn't happen; to be covered by meta-model validation later
            return Stream.empty();
        }
        if(!mixinFacet.isMixinFor(getCorrespondingClass())) {
            return Stream.empty();
        }
        var mixinMethodName = mixinFacet.getMainMethodName();

        return mixinSpec.streamActions(ActionScope.ANY, MixedIn.EXCLUDED)
        .filter(_SpecPredicates::isMixedInAssociation)
        .map(ObjectActionDefault.class::cast)
        .map(_MixedInMemberFactory.mixedInAssociation(this, mixinSpec, mixinMethodName))
        .peek(facetProcessor::processMemberOrder);
    }

    // -- mixin actions
    /**
     * Creates all mixed in actions for this spec.
     */
    private Stream<ObjectActionMixedIn> createMixedInActions() {
        return getCausewayBeanTypeRegistry().streamMixinTypes()
            .flatMap(this::createMixedInAction);
    }

    private Stream<ObjectActionMixedIn> createMixedInAction(final Class<?> mixinType) {

        var mixinSpec = specLoaderInternal().loadSpecification(mixinType,
                IntrospectionState.FULLY_INTROSPECTED);
        if (mixinSpec == null
                || mixinSpec == this) {
            return Stream.empty();
        }
        var mixinFacet = mixinSpec.mixinFacet().orElse(null);
        if(mixinFacet == null) {
            // this shouldn't happen; to be covered by meta-model validation later
            return Stream.empty();
        }
        if(!mixinFacet.isMixinFor(getCorrespondingClass())) {
            return Stream.empty();
        }
        // don't mixin Object_ mixins to domain services
        if(getBeanSort().isManagedBeanContributing()
                && mixinFacet.isMixinFor(java.lang.Object.class)) {
            return Stream.empty();
        }

        var mixinMethodName = mixinFacet.getMainMethodName();

        return mixinSpec.streamActions(ActionScope.ANY, MixedIn.EXCLUDED)
        // value types only support constructor mixins
        .filter(this::whenIsValueThenIsAlsoConstructorMixin)
        .filter(_SpecPredicates::isMixedInAction)
        .map(ObjectActionDefault.class::cast)
        .map(_MixedInMemberFactory.mixedInAction(this, mixinSpec, mixinMethodName))
        .peek(facetProcessor::processMemberOrder);
    }

    /**
     * Whether the mixin's main method returns an instance of type equal to the mixee's type.
     * <p>
     * Introduced to support constructor mixins for value-types and
     * also to support associated <i>Actions</i> for <i>Action Parameters</i>.
     */
    private boolean whenIsValueThenIsAlsoConstructorMixin(final ObjectAction act) {
        return getBeanSort().isValue()
                ? Objects.equals(this, act.getReturnType())
                : true;
    }

    // -- VALIDITY

    @Override
    public Consent isValid(
            final ManagedObject targetAdapter,
            final InteractionInitiatedBy interactionInitiatedBy) {

        return isValidResult(targetAdapter, interactionInitiatedBy).createConsent();
    }

    @Override
    public InteractionResult isValidResult(
            final ManagedObject targetAdapter,
            final InteractionInitiatedBy interactionInitiatedBy) {
        var validityContext =
                createValidityInteractionContext(
                        targetAdapter, interactionInitiatedBy);
        return InteractionUtils.isValidResult(this, validityContext);
    }

    /**
     * Create an {@link InteractionContext} representing an attempt to save the
     * object.
     */
    @Override
    public ObjectValidityContext createValidityInteractionContext(
            final ManagedObject targetAdapter, final InteractionInitiatedBy interactionInitiatedBy) {
        return new ObjectValidityContext(targetAdapter, getFeatureIdentifier(), interactionInitiatedBy);
    }

    // -- convenience isXxx (looked up from facets)
    @Override
    public boolean isImmutable() {
        return containsFacet(ImmutableFacet.class);
    }

    @Override
    public boolean isHidden() {
        return containsFacet(HiddenFacet.class);
    }

    @Override
    public boolean isParented() {
        return containsFacet(ParentedCollectionFacet.class);
    }

    // -- MIXIN ADDER ONESHOTs

    private final _Oneshot mixedInActionAdder = new _Oneshot();
    private final _Oneshot mixedInAssociationAdder = new _Oneshot();

    /**
     * one-shot: must be no-op, if already created
     */
    private void createMixedInActionsAndResort() {
        var include = isEntityOrViewModelOrAbstract()
                || getBeanSort().isManagedBeanContributing()
                // in support of composite value-type constructor mixins
                || getBeanSort().isValue();
        if(!include) {
            return;
        }
        var mixedInActions = createMixedInActions()
                .collect(Collectors.toList());
        if(mixedInActions.isEmpty()) {
           return; // nothing to do (this spec has no mixed-in actions, regular actions have already been added)
        }

        var regularActions = _Lists.newArrayList(objectActions); // defensive copy

        // note: we are doing this before any member sorting
        _MemberIdClashReporting.flagAnyMemberIdClashes(this, regularActions, mixedInActions);

        replaceActions(Stream.concat(
                regularActions.stream(),
                mixedInActions.stream()));
    }

    /**
     * one-shot: must be no-op, if already created
     */
    private void createMixedInAssociationsAndResort() {
        if(!isEntityOrViewModelOrAbstract()) {
            return;
        }
        var mixedInAssociations = createMixedInAssociations()
                .collect(Collectors.toList());
        if(mixedInAssociations.isEmpty()) {
           return; // nothing to do (this spec has no mixed-in associations, regular associations have already been added)
        }

        var regularAssociations = _Lists.newArrayList(associations); // defensive copy

        // note: we are doing this before any member sorting
        _MemberIdClashReporting.flagAnyMemberIdClashes(this, regularAssociations, mixedInAssociations);

        replaceAssociations(Stream.concat(
                regularAssociations.stream(),
                mixedInAssociations.stream()));
    }

    @Getter(lazy = true)
    private final CausewayBeanTypeRegistry causewayBeanTypeRegistry =
        getServiceRegistry()
                .lookupServiceElseFail(CausewayBeanTypeRegistry.class);

    @Getter(lazy = true)
    private final Can<EntityTitleSubscriber> titleSubscribers =
        getServiceRegistry().select(EntityTitleSubscriber.class);

}
