/*
 *  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.testdomain.domainmodel;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import jakarta.inject.Inject;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;

import org.apache.causeway.applib.annotation.Introspection.EncapsulationPolicy;
import org.apache.causeway.applib.annotation.Introspection.MemberAnnotationPolicy;
import org.apache.causeway.applib.id.LogicalType;
import org.apache.causeway.applib.services.jaxb.JaxbService;
import org.apache.causeway.applib.services.metamodel.BeanSort;
import org.apache.causeway.applib.services.metamodel.Config;
import org.apache.causeway.applib.services.metamodel.MetaModelService;
import org.apache.causeway.applib.services.registry.ServiceRegistry;
import org.apache.causeway.applib.services.title.TitleService;
import org.apache.causeway.commons.collections.Can;
import org.apache.causeway.commons.internal.base._Strings;
import org.apache.causeway.commons.internal.exceptions._Exceptions;
import org.apache.causeway.core.config.presets.CausewayPresets;
import org.apache.causeway.core.metamodel.context.MetaModelContext;
import org.apache.causeway.core.metamodel.facetapi.FacetHolder;
import org.apache.causeway.core.metamodel.facets.all.named.MemberNamedFacet;
import org.apache.causeway.core.metamodel.facets.members.publish.execution.ExecutionPublishingFacet;
import org.apache.causeway.core.metamodel.facets.object.icon.IconFacet;
import org.apache.causeway.core.metamodel.facets.object.introspection.IntrospectionPolicyFacet;
import org.apache.causeway.core.metamodel.facets.object.title.TitleFacet;
import org.apache.causeway.core.metamodel.facets.object.viewmodel.ViewModelFacet;
import org.apache.causeway.core.metamodel.facets.object.viewmodel.ViewModelFacetForJavaRecord;
import org.apache.causeway.core.metamodel.facets.objectvalue.mandatory.MandatoryFacet;
import org.apache.causeway.core.metamodel.facets.objectvalue.mandatory.MandatoryFacet.Semantics;
import org.apache.causeway.core.metamodel.facets.param.choices.ActionParameterChoicesFacet;
import org.apache.causeway.core.metamodel.facets.param.choices.ActionParameterChoicesFacetFromAction;
import org.apache.causeway.core.metamodel.facets.param.choices.ActionParameterChoicesFacetFromElementType;
import org.apache.causeway.core.metamodel.facets.param.choices.methodnum.ActionParameterChoicesFacetViaMethod;
import org.apache.causeway.core.metamodel.facets.param.defaults.ActionParameterDefaultsFacet;
import org.apache.causeway.core.metamodel.object.ManagedObject;
import org.apache.causeway.core.metamodel.postprocessors.param.ActionParameterDefaultsFacetFromAssociatedCollection;
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.specloader.SpecificationLoader;
import org.apache.causeway.core.metamodel.tabular.simple.DataTable;
import org.apache.causeway.schema.metamodel.v2.DomainClassDto;
import org.apache.causeway.testdomain.conf.Configuration_headless;
import org.apache.causeway.testdomain.model.good.Configuration_usingValidDomain;
import org.apache.causeway.testdomain.model.good.ElementTypeAbstract;
import org.apache.causeway.testdomain.model.good.ElementTypeConcrete;
import org.apache.causeway.testdomain.model.good.ElementTypeInterface;
import org.apache.causeway.testdomain.model.good.ProperActionParamterBoundingWhenUsingEnum;
import org.apache.causeway.testdomain.model.good.ProperChoicesWhenActionHasParamSupportingMethodTypeOfReference;
import org.apache.causeway.testdomain.model.good.ProperChoicesWhenActionHasParamSupportingMethodTypeOfString;
import org.apache.causeway.testdomain.model.good.ProperChoicesWhenChoicesFrom;
import org.apache.causeway.testdomain.model.good.ProperElementTypeVm;
import org.apache.causeway.testdomain.model.good.ProperFullyImpl;
import org.apache.causeway.testdomain.model.good.ProperGenericAbstract;
import org.apache.causeway.testdomain.model.good.ProperGenericInterface;
import org.apache.causeway.testdomain.model.good.ProperInterface2;
import org.apache.causeway.testdomain.model.good.ProperMemberInheritanceInterface;
import org.apache.causeway.testdomain.model.good.ProperMemberInheritanceUsingAbstract;
import org.apache.causeway.testdomain.model.good.ProperMemberInheritanceUsingInterface;
import org.apache.causeway.testdomain.model.good.ProperMemberSupport;
import org.apache.causeway.testdomain.model.good.ProperMemberSupportDiscovery;
import org.apache.causeway.testdomain.model.good.ProperMixinContribution;
import org.apache.causeway.testdomain.model.good.ProperMixinContribution_action1;
import org.apache.causeway.testdomain.model.good.ProperMixinContribution_action1a;
import org.apache.causeway.testdomain.model.good.ProperMixinContribution_action1b;
import org.apache.causeway.testdomain.model.good.ProperMixinContribution_action2;
import org.apache.causeway.testdomain.model.good.ProperMixinContribution_action3;
import org.apache.causeway.testdomain.model.good.ProperMixinContribution_action4;
import org.apache.causeway.testdomain.model.good.ProperMixinContribution_action5;
import org.apache.causeway.testdomain.model.good.ProperMixinContribution_action6;
import org.apache.causeway.testdomain.model.good.ProperObjectWithAlias;
import org.apache.causeway.testdomain.model.good.ProperRecordAsViewModelUsingAnnotations;
import org.apache.causeway.testdomain.model.good.ProperServiceWithAlias;
import org.apache.causeway.testdomain.model.good.ProperServiceWithMixin;
import org.apache.causeway.testdomain.model.good.ProperViewModelInferredFromNotBeingAnEntity;
import org.apache.causeway.testdomain.model.good.ViewModelWithAnnotationOptionalUsingPrivateSupport;
import org.apache.causeway.testdomain.model.good.ViewModelWithEncapsulatedMembers;
import org.apache.causeway.testdomain.util.interaction.DomainObjectTesterFactory;
import org.apache.causeway.testing.integtestsupport.applib.CausewayIntegrationTestAbstract;
import org.apache.causeway.testing.integtestsupport.applib.validate.DomainModelValidator;

import lombok.RequiredArgsConstructor;

@SpringBootTest(
        classes = {
                Configuration_headless.class,
                Configuration_usingValidDomain.class,

        },
        properties = {
                //"causeway.core.meta-model.introspector.policy=annotation_optional",
                "causeway.core.meta-model.introspector.mode=FULL",
                "causeway.applib.annotation.domain-object.editing=TRUE",
                "causeway.core.meta-model.validator.explicit-object-type=FALSE", // does not override any of the imports
                "logging.level.DependentArgUtils=DEBUG"
        })
@TestPropertySource({
    //CausewayPresets.DebugMetaModel,
    //CausewayPresets.DebugProgrammingModel,
    CausewayPresets.SilenceMetaModel,
    CausewayPresets.SilenceProgrammingModel
})
class DomainModelTest_usingGoodDomain extends CausewayIntegrationTestAbstract {

    @Inject private MetaModelService metaModelService;
    @Inject private JaxbService jaxbService;
    @Inject private ServiceRegistry serviceRegistry;
    @Inject private SpecificationLoader specificationLoader;
    @Inject private TitleService titleService;
    @Inject private DomainObjectTesterFactory testerFactory;

    void debug() {
        var config = Config.builder().build()
                .withNamespacePrefix("org.apache.causeway.testdomain.");

        System.out.println("=== listing MM");
        var metamodelDto = metaModelService.exportMetaModel(config);
        for (DomainClassDto domainClass : metamodelDto.getDomainClassDto()) {
            System.out.println("dc: " + domainClass.getId());
            var xmlString = jaxbService.toXml(domainClass);
            System.out.println(xmlString);
        }
        System.out.println("==============");
    }

    @Test
    void goodDomain_shouldPassValidation() {
        //debug();
        assertFalse(specificationLoader.snapshotSpecifications().isEmpty());

        var validateDomainModel = new DomainModelValidator(serviceRegistry);

        // guard against left overs from shared context
        var validationFailures = validateDomainModel.getFailures().stream()
                .filter(f->!f.origin().className().contains("bad"))
                .collect(Collectors.toSet());

        if(!validationFailures.isEmpty()) {

            fail(String.format("%d problems found:\n%s",
                    validationFailures.size(),
                    validationFailures.stream()
                    .map(validationFailure->validationFailure.message())
                    .collect(Collectors.joining("\n"))));
        }
    }

    @Test
    void reservedPrefixShouldBeAllowed_onExplicitAction() {

        var tester = testerFactory.actionTester(ProperMemberSupport.class, "hideMe");
        tester.assertExists(true);
        tester.assertMemberId("hideMe");
    }

    @Test
    void typeLevelAnnotations_shouldBeHonored_onMixins() {

        var holderSpec = specificationLoader.specForTypeElseFail(ProperMemberSupport.class);

        var mx_action = holderSpec.getActionElseFail("action1"); // when @Action at type level
        assertNotNull(mx_action);
        assertEquals("action1", mx_action.getId());
        assertEquals("foo", mx_action.getStaticFriendlyName().get());
        assertEquals("bar", mx_action.getStaticDescription().get());
        assertHasPublishedActionFacet(mx_action);

        var mx_action2 = holderSpec.getActionElseFail("action2"); // proper mixed-in action support
        assertNotNull(mx_action2);
        assertHasPublishedActionFacet(mx_action2);

        var mx_property = holderSpec.getAssociationElseFail("property1"); // when @Property at type level
        assertNotNull(mx_property);
        assertEquals("property1", mx_property.getId());
        assertEquals("foo", mx_property.getStaticFriendlyName().get());
        assertEquals("bar", mx_property.getStaticDescription().get());

        var mx_property2 = holderSpec.getAssociationElseFail("property2"); // when @Property at method level
        assertNotNull(mx_property2);
        assertEquals("property2", mx_property2.getId());
        assertEquals("foo", mx_property2.getStaticFriendlyName().get());
        assertEquals("bar", mx_property2.getStaticDescription().get());

        var mx_collection = holderSpec.getAssociationElseFail("collection1"); // when @Collection at type level
        assertNotNull(mx_collection);
        assertEquals("collection1", mx_collection.getId());
        assertEquals("foo", mx_collection.getStaticFriendlyName().get());
        assertEquals("bar", mx_collection.getStaticDescription().get());

        var mx_collection2 = holderSpec.getAssociationElseFail("collection2"); // when @Collection at method level
        assertNotNull(mx_collection2);
        assertEquals("collection2", mx_collection2.getId());
        assertEquals("foo", mx_collection2.getStaticFriendlyName().get());
        assertEquals("bar", mx_collection2.getStaticDescription().get());

    }

    @Test
    void memberLevelAnnotations_shouldResolveUnambiguous_onMixins() {

        var holderSpec = specificationLoader.specForTypeElseFail(ProperMemberSupport.class);

        var mx_openRestApi = holderSpec.getDeclaredAction("openRestApi"); // built-in mixin support
        assertNotNull(mx_openRestApi);

        assertThrows(Exception.class, ()->holderSpec.getAssociationElseFail("openRestApi")); // should not be picked up as a property

    }

    @Test
    void fullyAbstractObject_whenImplemented_shouldBeSupported() {
        var tester = testerFactory.objectTester(ProperFullyImpl.class);
        tester.assertTitle("title");
        tester.assertIcon("icon");
        tester.assertCssClass("css");
        tester.assertLayout("layout");
    }

    @ParameterizedTest
    @ValueSource(classes = {
            ProperGenericInterface.Impl.class,
            ProperGenericAbstract.Impl.class,
    })
    void genericBaseType_whenImplemented_shouldBeSupported(final Class<?> classUnderTest) {
        var propTester = testerFactory.propertyTester(classUnderTest, "value");
        propTester.assertExists(true);
        propTester.assertValue("aValue");

        var actTester = testerFactory.actionTester(classUnderTest, "sampleAction");
        actTester.assertExists(true);
        actTester.assertInvocationResult("aValue", arg0->"aValue");

        // assert that we properly pickup the @Nullable annotation on arg-0
        var argMeta = actTester.getActionMetaModelElseFail().getParameterByIndex(0);
        var argMandatoryFacet = argMeta.lookupFacet(MandatoryFacet.class)
            .orElseThrow(()->_Exceptions.unrecoverable("missing MandatoryFacet on action parameter"));
        assertEquals(Semantics.OPTIONAL, argMandatoryFacet.getSemantics());
        actTester.assertInvocationResult(null, arg0->null);
    }

    @ParameterizedTest
    @MethodSource("provideProperMemberInheritanceTypes")
    void titleAndIconName_shouldBeInheritable(final Class<?> type) throws Exception {

        var spec = specificationLoader.specForTypeElseFail(type);

        var titleFacet = spec.getFacet(TitleFacet.class);
        assertNotNull(titleFacet);

        var iconFacet = spec.getFacet(IconFacet.class);
        assertNotNull(iconFacet);

        if(!spec.isAbstract()) {
            var instance = type.getDeclaredConstructor().newInstance();
            assertEquals("inherited title", titleService.titleOf(instance));
            assertEquals("inherited icon", titleService.iconNameOf(instance));

            var domainObject = ManagedObject.adaptSingular(spec, instance);
            assertEquals("inherited title", domainObject.getTitle());
            assertEquals("inherited icon", iconFacet.iconName(domainObject));
        }
    }

    @ParameterizedTest
    @MethodSource("provideProperMemberInheritanceTypes")
    void metamodelContributingMembers_shouldBeInheritable(final Class<?> type) {

        var holderSpec = specificationLoader.specForTypeElseFail(type);

        var action = holderSpec.getActionElseFail("sampleAction");
        assertNotNull(action);
        assertEquals("sampleAction", action.getId());
        assertEquals("foo", action.getStaticFriendlyName().get());
        assertEquals("bar", action.getStaticDescription().get());

        var property = holderSpec.getAssociationElseFail("sampleProperty");
        assertNotNull(property);
        assertEquals("sampleProperty", property.getId());
        assertEquals("foo", property.getStaticFriendlyName().get());
        assertEquals("bar", property.getStaticDescription().get());

        var collection = holderSpec.getAssociationElseFail("sampleCollection");
        assertNotNull(collection);
        assertEquals("sampleCollection", collection.getId());
        assertEquals("foo", collection.getStaticFriendlyName().get());
        assertEquals("bar", collection.getStaticDescription().get());

    }

    @ParameterizedTest
    @MethodSource("provideProperMemberInheritanceTypes")
    void metamodelContributingActions_shouldBeUnique_whenOverridden(final Class<?> type) {

        if(type.isInterface()
                && type.getSuperclass()==null) {
            return; // not implemented for interface that don't extend from others
        }

        var holderSpec = specificationLoader.specForTypeElseFail(type);

        var super_action = holderSpec.getActionElseFail("sampleActionOverride");
        assertNotNull(super_action);
        assertEquals("sampleActionOverride", super_action.getId());
        assertEquals("foo", super_action.getStaticFriendlyName().get());
        assertEquals("bar", super_action.getStaticDescription().get());

        assertEquals(1L, holderSpec.streamAnyActions(MixedIn.EXCLUDED)
                .filter(prop->prop.getId().equals("sampleActionOverride"))
                .count());

    }

    @ParameterizedTest
    @MethodSource("provideProperMemberInheritanceTypes")
    void metamodelContributingProperties_shouldBeUnique_whenOverridden(final Class<?> type) {

        if(type.isInterface()
                && type.getSuperclass()==null) {
            return; // not implemented for interface that don't extend from others
        }

        var holderSpec = specificationLoader.specForTypeElseFail(type);

        var super_property = holderSpec.getAssociationElseFail("samplePropertyOverride");
        assertNotNull(super_property);
        assertEquals("samplePropertyOverride", super_property.getId());
        assertEquals("foo", super_property.getStaticFriendlyName().get());
        assertEquals("bar", super_property.getStaticDescription().get());

        assertEquals(1L, holderSpec.streamProperties(MixedIn.EXCLUDED)
                .filter(prop->prop.getId().equals("samplePropertyOverride"))
                .count());

    }

    @Test
    void elementTypes_shouldBeIntrospected_whenDiscoveredViaGenerics_usingNoWildcards() {

        // when using generic type (no wild-cards)

        var vmSpec = specificationLoader.specForTypeElseFail(ProperElementTypeVm.class);

        var concreteColl = vmSpec.getCollectionElseFail("concreteColl");
        var concreteCollSpec = concreteColl.getElementType();

        assertEquals(ElementTypeConcrete.class, concreteCollSpec.getCorrespondingClass());
        assertEquals(BeanSort.VIEW_MODEL, concreteCollSpec.getBeanSort());
        assertHasAction(concreteCollSpec, "abstractAction");
        assertHasAction(concreteCollSpec, "interfaceAction");
        assertHasProperty(concreteCollSpec, "abstractProp");
        assertHasProperty(concreteCollSpec, "interfaceProp");

        var interfaceColl = vmSpec.getCollectionElseFail("interfaceColl");
        var interfaceCollSpec = interfaceColl.getElementType();

        assertEquals(ElementTypeInterface.class, interfaceCollSpec.getCorrespondingClass());
        assertEquals(BeanSort.ABSTRACT, interfaceCollSpec.getBeanSort());
        assertHasAction(interfaceCollSpec, "interfaceAction");
        assertHasProperty(interfaceCollSpec, "interfaceProp");

        var abstractColl = vmSpec.getCollectionElseFail("abstractColl");
        var abstractCollSpec = abstractColl.getElementType();

        assertEquals(ElementTypeAbstract.class, abstractCollSpec.getCorrespondingClass());
        assertEquals(BeanSort.ABSTRACT, abstractCollSpec.getBeanSort());
        assertHasAction(abstractCollSpec, "abstractAction");
        assertHasProperty(abstractCollSpec, "abstractProp");
    }

    @Test
    void elementTypeInference_fromGenerics_usingNoWildcards() {

        // when using generic type (no wild-cards)

        var vmSpec = specificationLoader.specForTypeElseFail(ProperElementTypeVm.class);

        // scenario 1

        //abstract Set<ElementTypeInterface> getSetOfInterfaceType();
        //override SortedSet<ElementTypeInterface> getSetOfInterfaceType();

        var interfaceSet = vmSpec.getCollectionElseFail("setOfInterfaceType");
        var interfaceSetSpec = interfaceSet.getElementType();

        assertEquals(ElementTypeInterface.class, interfaceSetSpec.getCorrespondingClass());
        assertEquals(BeanSort.ABSTRACT, interfaceSetSpec.getBeanSort());

        //TODO scenario 2

        //abstract ImmutableCollection<ElementTypeInterface> getImmutableOfInterfaceType();
        //override Can<ElementTypeInterface> getImmutableOfInterfaceType();

        var interfaceIterLookup = vmSpec.getAssociationElseFail("immutableOfInterfaceType");
        assertTrue(interfaceIterLookup.isOneToManyAssociation(), "required to be a coll");

        var interfaceIter = vmSpec.getCollectionElseFail("immutableOfInterfaceType");
        var interfaceIterSpec = interfaceIter.getElementType();

        assertEquals(ElementTypeInterface.class, interfaceIterSpec.getCorrespondingClass());
        assertEquals(BeanSort.ABSTRACT, interfaceIterSpec.getBeanSort());

    }

    @Test
    void elementTypes_shouldBeIntrospected_whenDiscoveredViaGenerics_usingWildcards() {

        // when using generic type (w/ wild-cards)

        var vmSpec = specificationLoader.specForTypeElseFail(ProperElementTypeVm.class);

        var concreteColl = vmSpec.getCollectionElseFail("concreteColl2");
        var concreteCollSpec = concreteColl.getElementType();

        assertEquals(ElementTypeConcrete.class, concreteCollSpec.getCorrespondingClass());
        assertEquals(BeanSort.VIEW_MODEL, concreteCollSpec.getBeanSort());
        assertHasAction(concreteCollSpec, "abstractAction");
        assertHasAction(concreteCollSpec, "interfaceAction");
        assertHasProperty(concreteCollSpec, "abstractProp");
        assertHasProperty(concreteCollSpec, "interfaceProp");

        var interfaceColl = vmSpec.getCollectionElseFail("interfaceColl2");
        var interfaceCollSpec = interfaceColl.getElementType();

        assertEquals(ElementTypeInterface.class, interfaceCollSpec.getCorrespondingClass());
        assertEquals(BeanSort.ABSTRACT, interfaceCollSpec.getBeanSort());
        assertHasAction(interfaceCollSpec, "interfaceAction");
        assertHasProperty(interfaceCollSpec, "interfaceProp");

        var abstractColl = vmSpec.getCollectionElseFail("abstractColl2");
        var abstractCollSpec = abstractColl.getElementType();

        assertEquals(ElementTypeAbstract.class, abstractCollSpec.getCorrespondingClass());
        assertEquals(BeanSort.ABSTRACT, abstractCollSpec.getBeanSort());
        assertHasAction(abstractCollSpec, "abstractAction");
        assertHasProperty(abstractCollSpec, "abstractProp");

        //TODO scenario 1

        //abstract Set<? extends ElementTypeConcrete> getSetOfConcreteType();
        //override SortedSet<? extends ElementTypeConcrete> getSetOfConcreteType();

        //TODO scenario 2

        //abstract Iterable<? extends ElementTypeConcrete> getIterableOfConcreteType();
        //override Can<? extends ElementTypeConcrete> getIterableOfConcreteType();

    }

    @Test
    void domainObjects_ifNatureNotSpecified_shouldConsiderBeanTypeClassifier() {
        var vmSpec = specificationLoader.specForTypeElseFail(ProperViewModelInferredFromNotBeingAnEntity.class);

        assertEquals(BeanSort.VIEW_MODEL, vmSpec.getBeanSort());
        assertNotNull(vmSpec.getFacet(ViewModelFacet.class));
    }

    @Test
    void interfaces_shouldSupport_inheritedMembers() {

        var i2Spec = specificationLoader.specForTypeElseFail(ProperInterface2.class);

        assertEquals(BeanSort.ABSTRACT, i2Spec.getBeanSort());
        assertHasProperty(i2Spec, "a");
        assertHasProperty(i2Spec, "b");
        assertHasProperty(i2Spec, "c");
        assertHasProperty(i2Spec, "d");
        assertHasProperty(i2Spec, "e");
        assertHasProperty(i2Spec, "f");
    }

    @SuppressWarnings("unchecked")
    @Test
    void actionParamChoices_shouldBeImplicitlyBounded_whenEnum() {

        var spec = specificationLoader.specForTypeElseFail(ProperActionParamterBoundingWhenUsingEnum.class);

        var action = spec.getActionElseFail("sampleAction");
        var param0 = action.getParameters().getFirstElseFail();

        assertEquals(
                ActionParameterChoicesFacetFromElementType.class,
                param0.lookupFacet(ActionParameterChoicesFacet.class)
                    .map(Object::getClass)
                    .orElse(null));

        var act = testerFactory
                .actionTester(ProperActionParamterBoundingWhenUsingEnum.class, "sampleAction");

        var expectedParamChoices = Can.ofArray(
                ProperActionParamterBoundingWhenUsingEnum.SampleEnum.values());

        act.assertParameterChoices(true, ProperActionParamterBoundingWhenUsingEnum.SampleEnum.class,
                choices0->assertEquals(expectedParamChoices, Can.ofIterable(choices0), ()->"param 0 choices mismatch"));
    }

    @Test
    void actionParamChoices_shouldBeAllowed_toBeDerivedFromChoicesFrom() {

        var spec = specificationLoader.specForTypeElseFail(ProperChoicesWhenChoicesFrom.class);

        var action = spec.getActionElseFail("appendACharacterToCandidates");
        var param0 = action.getParameters().getFirstElseFail();

        assertEquals(
                ActionParameterChoicesFacetFromAction.class,
                param0.lookupFacet(ActionParameterChoicesFacet.class)
                    .map(Object::getClass)
                    .orElse(null));

        assertEquals(
                ActionParameterDefaultsFacetFromAssociatedCollection.class,
                param0.lookupFacet(ActionParameterDefaultsFacet.class)
                    .map(Object::getClass)
                    .orElse(null));
    }

    @Test
    void actionParamChoices_shouldBeAvailable_whenMixedInActionHasParamSupportingMethodTypeOfString() {

        var spec = specificationLoader.specForTypeElseFail(ProperChoicesWhenActionHasParamSupportingMethodTypeOfString.class);

        var action = spec.getActionElseFail("remove");
        var param0 = action.getParameters().getFirstElseFail();

        assertEquals(
                ActionParameterChoicesFacetViaMethod.class,
                param0.lookupFacet(ActionParameterChoicesFacet.class)
                    .map(Object::getClass)
                    .orElse(null));
    }

    @Test
    void actionParamChoices_shouldBeAvailable_whenMixedInActionHasParamSupportingMethodTypeOfReference() {

        var spec = specificationLoader
                .specForTypeElseFail(ProperChoicesWhenActionHasParamSupportingMethodTypeOfReference.class);

        var action = spec.getActionElseFail("remove");
        var param0 = action.getParameters().getFirstElseFail();

        assertEquals(
                ActionParameterChoicesFacetViaMethod.class,
                param0.lookupFacet(ActionParameterChoicesFacet.class)
                    .map(Object::getClass)
                    .orElse(null));
    }

    /**
     *  annotation provided 'choicesFrom' fallback, if no explicit choices member-support is given
     *  (that are params #1 and #3)
     */
    @SuppressWarnings("unchecked")
    @Test
    void actionParamChoices_shouldBeAvailable_whenMixedInActionHasChoicesFromAnnotationAttribute() {

        var actTester = testerFactory
                .actionTester(ProperMemberSupport.class, "action6");
        actTester.assertExists(true);

        // low-level MM inspection
        {
            var action = actTester.getActionMetaModelElseFail();
            action.getParameters()
            .forEach(param->{

               assertTrue(param.getAction().isMixedIn(), ()->String.format(
                       "param %d is expected to belong to a mixed-in action",
                       param.getParameterIndex()));

               var choicesFacet = param.getFacet(ActionParameterChoicesFacet.class);

               assertNotNull(choicesFacet, ()->String.format(
                       "param %d is expected to have an ActionParameterChoicesFacet",
                       param.getParameterIndex()));
            });
        }

        var mixee = actTester.getActionOwnerAs(ProperMemberSupport.class);
        mixee.setMyColl(List.of(
                "Hallo",
                "World"));

        var expectedParamChoices = Can.of(
                "Hallo",
                "World");

        // verify param choices from 'choicesFrom' action annotation attribute (param params #1 and #3)
        actTester.assertParameterChoices(true, String.class,
                choices0->assertEquals(expectedParamChoices, Can.ofIterable(choices0), ()->"param 0 choices mismatch"),
                choices1->assertEquals(expectedParamChoices, Can.ofIterable(choices1), ()->"param 1 choices mismatch"),
                choices2->assertEquals(expectedParamChoices, Can.ofIterable(choices2), ()->"param 2 choices mismatch"),
                choices3->assertEquals(expectedParamChoices, Can.ofIterable(choices3), ()->"param 3 choices mismatch"));
    }

    @ParameterizedTest
    @MethodSource("provideImperativelyNamed")
    void imperativelyNamedMembers(final String memberId, final String named, final String described) {

        var objectSpec = specificationLoader.specForTypeElseFail(ProperMemberSupport.class);
        var member = objectSpec.getMemberElseFail(memberId);
        var sampleObject = ManagedObject.adaptSingular(objectSpec, new ProperMemberSupport());

        assertEquals(named, member.getFriendlyName(()->sampleObject));
        assertEquals(described, member.getDescription(()->sampleObject).orElse(null));
    }

    @Test
    void mixinsOnDomainServices_shouldBeAllowed() {

        var objectSpec = specificationLoader.specForTypeElseFail(ProperServiceWithMixin.class);

        var mixinSpec = specificationLoader.specForTypeElseFail(ProperServiceWithMixin.Now.class);

        assertTrue(mixinSpec.isMixin());

        assertEquals(
                1L,
                objectSpec.streamRuntimeActions(MixedIn.INCLUDED)
                .filter(ObjectAction::isMixedIn)
                .peek(act->{
                    //System.out.println("act: " + act);
                    var memberNamedFacet = act.getFacet(MemberNamedFacet.class);
                    assertNotNull(memberNamedFacet);
                    assertTrue(memberNamedFacet.getSpecialization().isLeft());
                })
                .count());

    }

    @Test
    void aliasesOnDomainServices_shouldBeHonored() {

        var objectSpec = specificationLoader.specForTypeElseFail(ProperServiceWithAlias.class);
        assertTrue(objectSpec.isInjectable());
        assertTrue(objectSpec.getAction("now").isPresent());

        assertEquals(Can.of(
                "testdomain.v1.ProperServiceWithAlias",
                "testdomain.v2.ProperServiceWithAlias"),
                objectSpec.getAliases().map(LogicalType::logicalName));

        assertEquals(objectSpec,
                specificationLoader.specForLogicalTypeName("testdomain.v1.ProperServiceWithAlias")
                .orElse(null));
        assertEquals(objectSpec,
                specificationLoader.specForLogicalTypeName("testdomain.v2.ProperServiceWithAlias")
                .orElse(null));
    }

    @Test
    void aliasesOnDomainObjects_shouldBeHonored() {

        var objectSpec = specificationLoader.specForTypeElseFail(ProperObjectWithAlias.class);
        assertTrue(objectSpec.isViewModel());
        assertTrue(objectSpec.getAction("now").isPresent());

        assertEquals(Can.of(
                "testdomain.v1.ProperObjectWithAlias",
                "testdomain.v2.ProperObjectWithAlias"),
                objectSpec.getAliases().map(LogicalType::logicalName));

        assertEquals(objectSpec,
                specificationLoader.specForLogicalTypeName("testdomain.v1.ProperObjectWithAlias")
                .orElse(null));
        assertEquals(objectSpec,
                specificationLoader.specForLogicalTypeName("testdomain.v2.ProperObjectWithAlias")
                .orElse(null));
    }

    @Test
    void viewmodelWithEncapsulatedMembers() {

        // OBJECT

        var objectSpec = specificationLoader.specForTypeElseFail(ViewModelWithEncapsulatedMembers.class);

        var introspectionPolicyFacet = objectSpec.getFacet(IntrospectionPolicyFacet.class);
        assertNotNull(introspectionPolicyFacet);

        var introspectionPolicy = introspectionPolicyFacet.getIntrospectionPolicy();
        assertEquals(
                EncapsulationPolicy.ENCAPSULATED_MEMBERS_SUPPORTED,
                introspectionPolicy.getEncapsulationPolicy());
        assertEquals(
                MemberAnnotationPolicy.MEMBER_ANNOTATIONS_REQUIRED,
                introspectionPolicy.getMemberAnnotationPolicy());

        // PRIVATE ACTION

        var act = testerFactory
                .actionTester(ViewModelWithEncapsulatedMembers.class, "myAction");
        act.assertExists(true);
        act.assertIsExplicitlyAnnotated(true);
        act.assertVisibilityIsNotVetoed();
        act.assertUsabilityIsVetoedWith("action disabled for testing purposes");
        act.assertInvocationResultNoRules("Hallo World!");

        // -- PROPERTY WITH PRIVATE GETTER AND SETTER

        var prop = testerFactory
                .propertyTester(ViewModelWithEncapsulatedMembers.class, "propWithPrivateAccessors");
        prop.assertExists(true);
        prop.assertIsExplicitlyAnnotated(true);
        prop.assertVisibilityIsNotVetoed();
        prop.assertUsabilityIsVetoedWith("property disabled for testing purposes");
        prop.assertValue("Foo");
        prop.assertValueUpdate("Bar");

        // -- COLLECTION WITH PRIVATE GETTER AND SETTER

        var coll = testerFactory
                .collectionTester(ViewModelWithEncapsulatedMembers.class, "collWithPrivateAccessors");
        coll.assertExists(true);
        coll.assertIsExplicitlyAnnotated(true);
        coll.assertVisibilityIsNotVetoed();
        coll.assertUsabilityIsVetoedWith("collection disabled for testing purposes");
        coll.assertCollectionElements(List.of("Foo"));
    }

    @Test
    void viewmodelWithAnnotationOptional_usingPrivateSupport() {

        // OBJECT

        var objectSpec = specificationLoader.specForTypeElseFail(ViewModelWithAnnotationOptionalUsingPrivateSupport.class);

        var introspectionPolicyFacet = objectSpec.getFacet(IntrospectionPolicyFacet.class);
        assertNotNull(introspectionPolicyFacet);

        var introspectionPolicy = introspectionPolicyFacet.getIntrospectionPolicy();
        assertEquals(
                EncapsulationPolicy.ONLY_PUBLIC_MEMBERS_SUPPORTED,
                introspectionPolicy.getEncapsulationPolicy());
        assertEquals(
                MemberAnnotationPolicy.MEMBER_ANNOTATIONS_OPTIONAL,
                introspectionPolicy.getMemberAnnotationPolicy());

        // PRIVATE ACTION

        var act = testerFactory
                .actionTester(ViewModelWithAnnotationOptionalUsingPrivateSupport.class, "myAction");
        act.assertExists(true);
        act.assertIsExplicitlyAnnotated(true);
        act.assertVisibilityIsNotVetoed();
        act.assertUsabilityIsVetoedWithAll(
                Can.of("object disabled for testing purposes", "action disabled for testing purposes"));
        act.assertInvocationResultNoRules("Hallo World!");

        // -- PROPERTY WITH PRIVATE GETTER AND SETTER

        var prop = testerFactory
                .propertyTester(ViewModelWithAnnotationOptionalUsingPrivateSupport.class, "propWithPrivateAccessors");
        prop.assertExists(true);
        prop.assertIsExplicitlyAnnotated(true);
        prop.assertVisibilityIsNotVetoed();
        prop.assertUsabilityIsVetoedWithAll(
                Can.of("object disabled for testing purposes", "property disabled for testing purposes"));
        prop.assertValue("Foo");
        prop.assertValueUpdate("Bar");

        // -- COLLECTION WITH PRIVATE GETTER AND SETTER

        var coll = testerFactory
                .collectionTester(ViewModelWithAnnotationOptionalUsingPrivateSupport.class, "collWithPrivateAccessors");
        coll.assertExists(true);
        coll.assertIsExplicitlyAnnotated(true);
        coll.assertVisibilityIsNotVetoed();
        coll.assertUsabilityIsVetoedWithAll(
                Can.of("object disabled for testing purposes", "collection disabled for testing purposes"));
        coll.assertCollectionElements(List.of("Foo"));
    }

    @SuppressWarnings("unchecked")
    @ParameterizedTest
    @ValueSource(classes = {
            ProperMemberSupportDiscovery.WhenEncapsulationEnabled.class,
            ProperMemberSupportDiscovery.WhenAnnotationRequired.class,
            ProperMemberSupportDiscovery.WhenAnnotationOptional.class
            })
    void properMemberSupportDiscovery(final Class<?> classUnderTest) {

        var act = testerFactory
                .actionTester(classUnderTest, "placeOrder");
        var prop = testerFactory
                .propertyTester(classUnderTest, "email");
        var coll = testerFactory
                .collectionTester(classUnderTest, "orders");

        act.assertExists(true);

        // namedPlaceOrder(): String = "my name"
        act.assertFriendlyName("my name");

        // describedPlaceOrder(): String = "my description"
        act.assertDescription("my description");

        // hidePlaceOrder(): boolean = false
        act.assertVisibilityIsNotVetoed();

        // disablePlaceOrder(): String = "my disable reason"
        act.assertUsabilityIsVetoedWith("my disable reason");

        // default0PlaceOrder(): String = "my default-0"
        // default1PlaceOrder(): String = "my default-1"
        act.assertParameterValues(
                false, // skip rule checking
                arg0->assertEquals(
                        "my default-0",
                        arg0),
                arg1->assertEquals(
                        "my default-1",
                        arg1));

        act.assertValidationMessage("my validation",
                false, // skip rule checking
                arg0->"",
                arg1->"");

        // hide0PlaceOrder(x): boolean = true
        // hide1PlaceOrder(y): boolean = false
        act.assertParameterVisibility(
                false, // skip rule checking
                arg0Visible->assertFalse(arg0Visible),
                arg1Visible->assertTrue(arg1Visible));

        // disable0PlaceOrder(x): String = "my disable reason-0"
        // disable1PlaceOrder(z): String = "my disable reason-1"
        act.assertParameterUsability(
                false, // skip rule checking
                arg0Veto->assertEquals(
                        "my disable reason-0",
                        arg0Veto),
                arg1Veto->assertEquals(
                        "my disable reason-1",
                        arg1Veto));

        // choices0PlaceOrder(x): List.of("my choice")
        act.assertParameterModel(
                false, // skip rule checking
                pendingArgsWhen->{},
                pendingArgsThen->{

                    var actualChoices =
                            pendingArgsThen.getObservableParamChoices(0).getValue().map(ManagedObject::getPojo);
                    assertEquals(
                            Can.of("my choice"),
                            actualChoices);

                });

        // autoComplete1PlaceOrder(y, search): List.of("my search arg=" + search)
        act.assertParameterModel(
                false, // skip rule checking
                pendingArgsWhen->{
                    pendingArgsWhen.getBindableParamSearchArgument(1).setValue("hello");
                },
                pendingArgsThen->{

                    var actualChoices =
                            pendingArgsThen.getObservableParamChoices(1).getValue().map(ManagedObject::getPojo);
                    assertEquals(
                            Can.of("my search arg=hello"),
                            actualChoices);

                });

        // validate0PlaceOrder(String x): String = "my validation-0"
        // validate1PlaceOrder(String y): String = "my validation-1"
        // validatePlaceOrder(String x, final String y): String = "my validation"
        act.assertParameterModel(
                false, // skip rule checking
                pendingArgsWhen->{},
                pendingArgsThen->{
                    assertEquals(
                            "my validation-0",
                            pendingArgsThen.getObservableParamValidation(0).getValue());
                    assertEquals(
                            "my validation-1",
                            pendingArgsThen.getObservableParamValidation(1).getValue());
                    assertEquals(
                            "my validation",
                            pendingArgsThen.validateParameterSetForAction().getReasonAsString().orElse(null));
                });

        // namedEmail(): String = "my email"
        prop.assertFriendlyName("my email");

        // describedEmail: String = "my email described"
        prop.assertDescription("my email described");

        // hideEmail(): boolean = true
        prop.assertVisibilityIsVetoed();

        // disableEmail(): String = "my email disable"
        prop.assertUsabilityIsVetoedWith("my email disable");

        // defaultEmail(): String = "my default email"
        prop.assertValueNegotiation(
                propNeg->{
                    assertEquals(
                            "my default email",
                            propNeg.getValue().getValue().getPojo());
                },
                propNegPostCommit->{});

        // choicesEmail(): Collection<String> = List.of("my email choice")
        prop.assertValueNegotiation(
                propNeg->{
                    assertEquals(
                            Can.of("my email choice"),
                            propNeg.getChoices().getValue().map(ManagedObject::getPojo));
                },
                propNegPostCommit->{});

        // validateEmail(final String email): String = "my email validate"
        prop.assertValueNegotiation(
                propNeg->{},
                propNegPostCommit->{
                    assertEquals(
                            "my email validate",
                            propNegPostCommit.getValidationMessage().getValue());
                });

        // -- COLLECTION

        // namedOrders(): String = "my orders"
        coll.assertFriendlyName("my orders");

        // describedOrders: String = "my orders described"
        coll.assertDescription("my orders described");

        // hideOrders(): boolean = true
        coll.assertVisibilityIsVetoed();

        // disableOrders(): String = "my orders disabled"
        coll.assertUsabilityIsVetoedWith("my orders disabled");

    }

    @ParameterizedTest
    @ValueSource(classes = {
            ProperMixinContribution_action1.class,
            ProperMixinContribution_action1a.class, // relaxed inclusion policy for mixin main
            ProperMixinContribution_action1b.class, // relaxed inclusion policy for mixin main
            ProperMixinContribution_action2.class,
            ProperMixinContribution_action3.class,
            ProperMixinContribution_action4.class,
            ProperMixinContribution_action5.class,
            ProperMixinContribution_action6.class})
    void mixins_shouldBePickedUp_asTheRightContributingFeature(final Class<?> mixinClass) {

        final String actionName = _Strings.splitThenStream(mixinClass.getSimpleName(), "_")
                .reduce((a, b)->b)
                .orElseThrow();

        var vmSpec = specificationLoader.specForTypeElseFail(ProperMixinContribution.class);
        assertHasAction(vmSpec, "myAction"); // regular action (just a sanity check)
        assertHasAction(vmSpec, actionName); // contributed action
        assertMissesProperty(vmSpec, actionName); // verify don't contributes as property
    }

    // -- JAVA RECORD AS VIEWMODEL

    @RequiredArgsConstructor
    enum RecordScenario {
        /*
         * PLAIN(ProperRecordAsViewModel.class, Can.of( new
         * ProperRecordAsViewModel("Hello!", 3, true) )),
         */
        ANNOTATED(ProperRecordAsViewModelUsingAnnotations.class, Can.of(
                    new ProperRecordAsViewModelUsingAnnotations("Hello!", 3, true)
                ));
        final Class<?> recordClass;
        final Can<?> samples;
        final String classFriendlyName() {
            return _Strings.asNaturalName.apply(recordClass.getSimpleName());
        }
    }

    @ParameterizedTest
    @EnumSource(RecordScenario.class)
    void javaRecordAsViewModel(final RecordScenario scenario) {
        final Class<?> classUnderTest = scenario.recordClass;
        final Object sample = scenario.samples.getFirstElseFail();
        var viewModel = MetaModelContext.instanceElseFail().getObjectManager().adapt(sample);
        var elementType = viewModel.objSpec();
        var viewmodelFacet = elementType.getFacet(ViewModelFacet.class);

        assertEquals(BeanSort.VIEW_MODEL, elementType.getBeanSort());
        assertEquals(classUnderTest.getName(), elementType.getFeatureIdentifier().logicalTypeName());
        assertTrue(ViewModelFacetForJavaRecord.class.isInstance(viewmodelFacet),
                ()->"Record is expected to have a ViewModelFacetForJavaRecord");

        var bookmark = viewmodelFacet.serializeToBookmark(viewModel);
        var viewModelAfterRoundTrip = viewmodelFacet.instantiate(elementType, Optional.of(bookmark));
        assertEquals(viewModel.getPojo(), viewModelAfterRoundTrip.getPojo());

        var isExpectedExplicitlyAnnotated = scenario == RecordScenario.ANNOTATED;

        var additionalString = testerFactory
                .propertyTester(sample, "additionalString");
        additionalString.assertExists(true);
        additionalString.assertVisibilityIsNotVetoed();
        additionalString.assertUsabilityIsVetoedWith("Disabled, property has no setter.");
        additionalString.assertIsExplicitlyAnnotated(isExpectedExplicitlyAnnotated);
        additionalString.assertValue("add Hello!");

        var arbitraryString = testerFactory
                .propertyTester(sample, "arbitraryString");
        arbitraryString.assertExists(true);
        arbitraryString.assertVisibilityIsNotVetoed();
        arbitraryString.assertUsabilityIsVetoedWith("Disabled, property has no setter.");
        arbitraryString.assertIsExplicitlyAnnotated(isExpectedExplicitlyAnnotated);
        arbitraryString.assertValue("Hello!");

        var arbitraryInt = testerFactory
                .propertyTester(sample, "arbitraryInt");
        arbitraryInt.assertExists(true);
        arbitraryInt.assertVisibilityIsNotVetoed();
        arbitraryInt.assertUsabilityIsVetoedWith("Disabled, property has no setter.");
        arbitraryInt.assertIsExplicitlyAnnotated(isExpectedExplicitlyAnnotated);
        arbitraryInt.assertValue(3);

        var arbitraryBoolean = testerFactory
                .propertyTester(sample, "arbitraryBoolean");
        arbitraryBoolean.assertExists(true);
        arbitraryBoolean.assertVisibilityIsNotVetoed();
        arbitraryBoolean.assertUsabilityIsVetoedWith("Disabled, property has no setter.");
        arbitraryBoolean.assertIsExplicitlyAnnotated(isExpectedExplicitlyAnnotated);
        arbitraryBoolean.assertValue(true);
    }

    @ParameterizedTest
    @EnumSource(RecordScenario.class)
    void javaRecordAsDataRowModel(final RecordScenario scenario) {
        final Class<?> classUnderTest = scenario.recordClass;

        var dataTable = DataTable.forDomainType(classUnderTest)
            .withDataElementPojos(scenario.samples);

        assertEquals(scenario.classFriendlyName(), dataTable.tableFriendlyName());
        assertEquals(scenario.samples.size(), dataTable.dataRows().size());
        assertEquals(4, dataTable.dataColumns().size());

        assertEquals("""
                Additional String: add Hello!
                Arbitrary Boolean: true
                Arbitrary Int: 3
                Arbitrary String: Hello!
                """, tableToString(dataTable));
    }

    // -- META ANNOTAIONS

    @Test
    void propMeta_shouldHonorSpecification() {
        var propTester = testerFactory
                .propertyTester(ProperMemberSupport.class, "myPropWithMeta");
        propTester.assertExists(true);
        propTester.assertFriendlyName("Proper Name");
        propTester.assertValueUpdateUsingNegotiationTextual("just a string", "injection worked");
    }

    @Test
    void actionParamMeta_shouldHonorSpecification() {
        var actTester = testerFactory
                .actionTester(ProperMemberSupport.class, "myActionWithMetaOnParam");
        actTester.assertExists(true);
        //TODO[CAUSEWAY-3326] perhaps tester needs fixing?
        //actTester.assertValidationMessage("injection worked", true, p0->"just a string");

    }

    // -- HELPER

    private String tableToString(final DataTable dataTable) {
        var sb = new StringBuilder();
        dataTable.visit((column, cellValues) ->
            sb.append(String.format("%s: %s\n",
                    column.columnFriendlyName(),
                    "" + cellValues.getFirstElseFail().getPojo())));
        return sb.toString();
    }

    private void assertHasProperty(final ObjectSpecification spec, final String propertyId) {
        spec.getPropertyElseFail(propertyId);
    }

    private void assertMissesProperty(final ObjectSpecification spec, final String propertyId) {
        assertFalse(spec.getProperty(propertyId).isPresent(),
                ()->String.format("unexpected to find a property '%s'", propertyId));
    }

    private void assertHasAction(final ObjectSpecification spec, final String actionId) {
        spec.getActionElseFail(actionId);
    }

    private void assertHasPublishedActionFacet(final FacetHolder facetHolder) {
        var facet = facetHolder.getFacet(ExecutionPublishingFacet.class);
        assertNotNull(facet);
    }

    static Stream<Arguments> provideProperMemberInheritanceTypes() {
        return Stream.of(
                Arguments.of(ProperMemberInheritanceUsingAbstract.class),
                Arguments.of(ProperMemberInheritanceUsingInterface.class),
                Arguments.of(ProperMemberInheritanceInterface.class)
        );
    }

    static Stream<Arguments> provideImperativelyNamed() {
        return Stream.of(
                // regular on type
                Arguments.of("myAction", "named-imperative[MyAction]", "described-imperative[MyAction]"),
                Arguments.of("myProp", "named-imperative[MyProp]", "described-imperative[MyProp]"),
                Arguments.of("myColl", "named-imperative[MyColl]", "described-imperative[MyColl]"),
                // contributed by mixin(s)
                Arguments.of("action5", "named-imperative[action5]", "described-imperative[action5]"),
                Arguments.of("property3", "named-imperative[property3]", "described-imperative[property3]"),
                Arguments.of("collection3", "named-imperative[collection3]", "described-imperative[collection3]")
        );
    }

}
