/*
 *  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.util.interaction;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.UnaryOperator;
import java.util.stream.Stream;

import jakarta.inject.Inject;

import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.function.ThrowingSupplier;

import static org.junit.jupiter.api.Assertions.assertEquals;
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.stereotype.Service;

import org.apache.causeway.applib.Identifier;
import org.apache.causeway.applib.annotation.Where;
import org.apache.causeway.applib.exceptions.unrecoverable.DomainModelException;
import org.apache.causeway.applib.id.LogicalType;
import org.apache.causeway.applib.services.command.Command;
import org.apache.causeway.applib.services.factory.FactoryService;
import org.apache.causeway.applib.services.iactnlayer.InteractionService;
import org.apache.causeway.commons.collections.Can;
import org.apache.causeway.commons.internal.base._Casts;
import org.apache.causeway.commons.internal.base._Strings;
import org.apache.causeway.commons.internal.exceptions._Exceptions;
import org.apache.causeway.core.config.CausewayConfiguration;
import org.apache.causeway.core.config.environment.CausewaySystemEnvironment;
import org.apache.causeway.core.config.progmodel.ProgrammingModelConstants;
import org.apache.causeway.core.metamodel.context.HasMetaModelContext;
import org.apache.causeway.core.metamodel.context.MetaModelContext;
import org.apache.causeway.core.metamodel.facetapi.Facet;
import org.apache.causeway.core.metamodel.facets.members.cssclass.CssClassFacet;
import org.apache.causeway.core.metamodel.facets.object.icon.IconFacet;
import org.apache.causeway.core.metamodel.facets.object.layout.LayoutPrefixFacet;
import org.apache.causeway.core.metamodel.facets.object.value.ValueFacet;
import org.apache.causeway.core.metamodel.interactions.managed.ActionInteraction;
import org.apache.causeway.core.metamodel.interactions.managed.CollectionInteraction;
import org.apache.causeway.core.metamodel.interactions.managed.ManagedAction;
import org.apache.causeway.core.metamodel.interactions.managed.ManagedCollection;
import org.apache.causeway.core.metamodel.interactions.managed.ManagedMember;
import org.apache.causeway.core.metamodel.interactions.managed.ManagedProperty;
import org.apache.causeway.core.metamodel.interactions.managed.ManagedValue;
import org.apache.causeway.core.metamodel.interactions.managed.ParameterNegotiationModel;
import org.apache.causeway.core.metamodel.interactions.managed.PropertyInteraction;
import org.apache.causeway.core.metamodel.interactions.managed.PropertyNegotiationModel;
import org.apache.causeway.core.metamodel.object.ManagedObject;
import org.apache.causeway.core.metamodel.object.ManagedObjects;
import org.apache.causeway.core.metamodel.object.MmUnwrapUtils;
import org.apache.causeway.core.metamodel.spec.ObjectSpecification;
import org.apache.causeway.core.metamodel.spec.feature.ObjectAction;
import org.apache.causeway.core.metamodel.spec.feature.ObjectMember;
import org.apache.causeway.core.metamodel.spec.feature.OneToManyAssociation;
import org.apache.causeway.core.metamodel.spec.feature.OneToOneAssociation;
import org.apache.causeway.core.metamodel.specloader.SpecificationLoader;
import org.apache.causeway.core.metamodel.tabular.DataTableInteractive;
import org.apache.causeway.testdomain.util.CollectionAssertions;
import org.apache.causeway.testing.integtestsupport.applib.validate.DomainModelValidator;

import lombok.Getter;
import lombok.SneakyThrows;

@Service
public class DomainObjectTesterFactory implements HasMetaModelContext {

    public <T> ObjectTester<T> objectTester(
            final T domainObject) {
        var tester = getServiceInjector().injectServicesInto(
                new ObjectTester<T>(domainObject));
        return tester;
    }

    public <T> ActionTester<T> actionTester(
            final T domainObject,
            final String actionName,
            final Where where) {
        var tester = getServiceInjector().injectServicesInto(
                new ActionTester<T>(domainObject, actionName, where));
        tester.init();
        return tester;
    }

    public <T> ActionTester<T> actionTesterForSpecificInteraction(
            final @NonNull T domainObject,
            final @NonNull ActionInteraction actionInteraction) {
        var managedAction = actionInteraction.getManagedActionElseFail();
        assertEquals(domainObject.getClass(),
                managedAction.getOwner().objSpec().getCorrespondingClass());
        var actionTester = getServiceInjector().injectServicesInto(
                new ActionTester<>(domainObject, actionInteraction, managedAction));
        actionTester.init();
        return actionTester;
    }

    public <T> PropertyTester<T> propertyTester(
            final T domainObject,
            final String propertyName,
            final Where where) {
        var tester = getServiceInjector().injectServicesInto(
                new PropertyTester<T>(domainObject, propertyName, where));
        tester.init();
        return tester;
    }

    public <T> CollectionTester<T> collectionTester(
            final T domainObject,
            final String collectionName,
            final Where where) {
        var tester = getServiceInjector().injectServicesInto(
                new CollectionTester<T>(domainObject, collectionName, where));
        tester.init();
        return tester;
    }

    // -- SHORTCUTS

    public <T> ObjectTester<T> objectTester(
            final Class<T> domainObjectType) {
        return objectTester(domainObject(domainObjectType));
    }

    public <T> ActionTester<T> actionTester(
            final Class<T> domainObjectType,
            final String actionName,
            final Where where) {
        return actionTester(domainObject(domainObjectType), actionName, where);
    }
    public <T> ActionTester<T> actionTester(
            final T domainObject,
            final String actionName) {
        return actionTester(domainObject, actionName, Where.ANYWHERE);
    }
    public <T> ActionTester<T> actionTester(
            final Class<T> domainObjectType,
            final String actionName) {
        return actionTester(domainObject(domainObjectType), actionName);
    }
    public <T> ActionTester<T> actionTesterForSpecificInteraction(
            final Class<T> domainObjectType,
            final ActionInteraction actionInteraction) {
        return actionTesterForSpecificInteraction(domainObject(domainObjectType), actionInteraction);
    }

    public <T> PropertyTester<T> propertyTester(
            final Class<T> domainObjectType,
            final String propertyName,
            final Where where) {
        return propertyTester(domainObject(domainObjectType), propertyName, where);
    }
    public <T> PropertyTester<T> propertyTester(
            final Class<T> domainObjectType,
            final String propertyName) {
        return propertyTester(domainObject(domainObjectType), propertyName);
    }
    public <T> PropertyTester<T> propertyTester(
            final T domainObject,
            final String propertyName) {
        return propertyTester(domainObject, propertyName, Where.ANYWHERE);
    }

    public <T> CollectionTester<T> collectionTester(
            final Class<T> domainObjectType,
            final String collectionName,
            final Where where) {
        return collectionTester(domainObject(domainObjectType), collectionName, where);
    }
    public <T> CollectionTester<T> collectionTester(
            final Class<T> domainObjectType,
            final String collectionName) {
        return collectionTester(domainObject(domainObjectType), collectionName);
    }
    public <T> CollectionTester<T> collectionTester(
            final T domainObject,
            final String collectionName) {
        return collectionTester(domainObject, collectionName, Where.ANYWHERE);
    }

    // -- HELPER

    @Inject protected FactoryService factoryService;

    private <T> T domainObject(final @NonNull Class<T> domainObjectType) {
        return factoryService.viewModel(domainObjectType);
    }

    // -- OBJECT TESTER

    public static class ObjectTester<T>
    extends Tester<T> {

        protected ObjectTester(final @NonNull T domainObject) {
            super(domainObject);
        }

        public void assertTitle(final @Nullable String expectedResult) {
            assertEquals(expectedResult,
                    super.objectSpecification.getTitleService().titleOf(vm.getPojo()));
            assertEquals(expectedResult,
                    vm.getTitle());
        }

        public void assertIcon(final @Nullable String expectedResult) {
            assertEquals(expectedResult,
                    super.objectSpecification.getTitleService().iconNameOf(vm.getPojo()));
            assertEquals(expectedResult,
                    super.objectSpecification.lookupFacet(IconFacet.class)
                    .map(iconFacet->iconFacet.iconName(vm))
                    .orElse(null));
        }

        public void assertCssClass(final @Nullable String expectedResult) {
            assertEquals(expectedResult,
                    super.objectSpecification.lookupFacet(CssClassFacet.class)
                    .map(cssClassFacet->cssClassFacet.cssClass(vm))
                    .orElse(null));
        }

        public void assertLayout(final @Nullable String expectedResult) {
            assertEquals(expectedResult,
                    super.objectSpecification.lookupFacet(LayoutPrefixFacet.class)
                    .map(layoutPrefixFacet->layoutPrefixFacet.layoutPrefix(vm))
                    .orElse(null));
        }

        public void assertValidationFailureOnMember(
                final ProgrammingModelConstants.MessageTemplate violation,
                final String memberName) {

            var validateDomainModel =
                    new DomainModelValidator(specificationLoader, configuration, causewaySystemEnvironment);

            assertThrows(DomainModelException.class, validateDomainModel::throwIfInvalid);
            validateDomainModel.assertAnyFailuresContaining(
                    Identifier.classIdentifier(LogicalType.fqcn(getDomainObjectType())),
                    violation
                        .builder()
                        .addVariable("type", getDomainObjectType().getName())
                        .addVariable("member", memberName)
                        .buildMessage());
        }

    }

    // -- ACTION TESTER

    public static class ActionTester<T>
    extends MemberTester<T> {

        private ActionInteraction actionInteraction;
        private Optional<ActionInteraction> actionInteraction() {
            return Optional.ofNullable(actionInteraction);
        }

        private final @NonNull ThrowingSupplier<ParameterNegotiationModel> parameterNegotiationStarter;
        private List<Command> capturedCommands = new ArrayList<>();

        private ActionTester(
                final @NonNull T domainObject,
                final @NonNull ActionInteraction actionInteraction,
                final @NonNull ManagedAction managedAction) {
            super(domainObject,
                    managedAction.getId(),
                    "actionName",
                    managedAction.getWhere());
            this.actionInteraction = actionInteraction;
            this.parameterNegotiationStarter = ()->
                actionInteraction
                .startParameterNegotiation()
                .orElseThrow(()->_Exceptions
                        .illegalAccess("action not visible or usable: %s",
                                managedAction.getAction().getFeatureIdentifier()));
        }

        private ActionTester(
                final @NonNull T domainObject,
                final @NonNull String actionName,
                final @NonNull Where where) {
            super(domainObject, actionName, "actionName", where);
            this.parameterNegotiationStarter = null;
        }

        @Nullable
        public <X> X getActionOwnerAs(final Class<X> type) {
            return _Casts.uncheckedCast(getActionOwner().map(ManagedObject::getPojo).orElse(null));
        }

        public ManagedObject getActionOwnerElseFail() {
            return getActionOwner().orElseThrow();
        }

        public Optional<ManagedObject> getActionOwner() {
            return getManagedAction().map(ManagedAction::getOwner);
        }

        public Optional<ManagedAction> getManagedAction() {
            return actionInteraction().flatMap(ActionInteraction::getManagedAction);
        }

        public ManagedAction getManagedActionElseFail() {
            return getManagedAction().orElseThrow();
        }

        @Override
        public Optional<ObjectAction> getMetaModel() {
            return getManagedAction()
            .map(ManagedAction::getMetaModel)
            .map(ObjectAction.class::cast);
        }

        public ObjectAction getActionMetaModelElseFail() {
            return getMetaModel().orElseThrow();
        }

        @Override
        public Optional<ObjectSpecification> getElementType() {
            return getMetaModel()
                    .map(ObjectAction::getReturnType);
        }

        @Override
        protected Optional<ManagedAction> startInteractionOn(final ManagedObject viewModel) {
            if(parameterNegotiationStarter==null) {
                this.actionInteraction = ActionInteraction
                        .start(viewModel, getMemberName(), Where.NOT_SPECIFIED);
            }
            assertNotNull(actionInteraction);
            return getManagedAction();
        }

        public void assertInvocationResult(
                final @Nullable Object expectedResult,
                @SuppressWarnings("rawtypes") final @Nullable UnaryOperator ...pojoDefaultArgReplacers) {

            assertExists(true);

            var pojoReplacers = Can.ofArray(pojoDefaultArgReplacers);

            interactionService.runAnonymous(()->{

                var pendingArgs = startParameterNegotiation(true);

                pendingArgs.getParamModels()
                        .forEach(param->{
                            pojoReplacers
                                .get(param.paramIndex())
                                .ifPresent(replacer->updatePojo(param, replacer));
                        });

                //pendingArgs.validateParameterSetForParameters();

                var resultOrVeto = actionInteraction.invokeWith(pendingArgs);
                assertTrue(resultOrVeto.isSuccess()); // assert action did not throw

                var actionResultAsPojo = resultOrVeto.getSuccessElseFail().getPojo();
                assertEquals(expectedResult, actionResultAsPojo);

                captureCommand();
            });

        }

        public Object invokeWithPojos(final List<Object> pojoArgList) {

            assertExists(true);

            var pojoVector = Can.ofCollection(pojoArgList);

            return interactionService.callAnonymous(()->{

                var pendingArgs = startParameterNegotiation(true);

                pendingArgs.getParamModels()
                        .forEach(param->{
                            pojoVector
                                .get(param.paramIndex())
                                .ifPresent(pojo->updatePojo(param, __->pojo));
                        });

                //pendingArgs.validateParameterSetForParameters();

                var resultOrVeto = actionInteraction.invokeWith(pendingArgs);
                assertTrue(resultOrVeto.isSuccess()); // assert action did not throw

                captureCommand();

                return resultOrVeto.getSuccessElseFail().getPojo();
            });
        }

        /**
         * circumvents rule checking
         */
        public void assertInvocationResultNoRules(
                final @Nullable Object expectedResult,
                @SuppressWarnings("rawtypes") final @Nullable UnaryOperator ...pojoDefaultArgReplacers) {

            assertExists(true);

            var pojoReplacers = Can.ofArray(pojoDefaultArgReplacers);
            var managedAction = this.getManagedActionElseFail();

            interactionService.runAnonymous(()->{

                var pendingArgs = startParameterNegotiation(false); // no rule checking

                pendingArgs.getParamModels()
                .forEach(param->{
                    pojoReplacers
                        .get(param.paramIndex())
                        .ifPresent(replacer->updatePojo(param, replacer));
                });

                // spawns its own transactional boundary, or reuses an existing one if available
                var either = managedAction.invoke(pendingArgs.getParamValues());

                assertTrue(either.isSuccess()); // assert action did not throw

                var actionResultAsPojo = either.getSuccessElseFail().getPojo();

                assertEquals(expectedResult, actionResultAsPojo);

            });

        }

        @SuppressWarnings("unchecked")
        public void assertParameterValues(
                final boolean checkRules,
                @SuppressWarnings("rawtypes") final Consumer ...pojoDefaultArgTests) {

            assertExists(true);

            var pojoTests = Can.ofArray(pojoDefaultArgTests);

            interactionService.runAnonymous(()->{

                var pendingArgs = startParameterNegotiation(checkRules);

                pendingArgs.getParamModels()
                .forEach(param->{
                    pojoTests
                        .get(param.paramIndex())
                        .ifPresent(pojoTest->
                            pojoTest.accept(
                                    MmUnwrapUtils.single(param.getValue().getValue())
                                    ));
                });

                captureCommand();

            });

        }

        @SuppressWarnings("unchecked")
        public <X> void assertParameterChoices(
                final boolean checkRules,
                final Class<X> elementType,
                final Consumer<Iterable<X>> ...pojoArgChoiceTests) {

            assertExists(true);

            var pojoTests = Can.ofArray(pojoArgChoiceTests);

            interactionService.runAnonymous(()->{

                startParameterNegotiation(checkRules).getParamModels()
                .forEach(param->{
                    pojoTests
                        .get(param.paramIndex())
                        .ifPresent(pojoTest->
                            pojoTest.accept(
                                    (List<X>) choicesFor(param)
                                    ));
                });

                captureCommand();

            });

        }

        private static List<Object> choicesFor(final ManagedValue param) {
            return MmUnwrapUtils.multipleAsList(param.getChoices().getValue());
        }

        @SuppressWarnings("unchecked")
        public void assertParameterVisibility(
                final boolean checkRules,
                final Consumer<Boolean> ...argVisibleChecks) {

            assertParameterModel(checkRules,
                // when
                pendingArgs->{

                },
                // then
                pendingArgs->{

                    var visibilityTests = Can.ofArray(argVisibleChecks);

                    pendingArgs.getParamModels()
                    .forEach(param->{

                        var consent = pendingArgs.getVisibilityConsent(param.paramIndex());

                        visibilityTests
                            .get(param.paramIndex())
                            .ifPresent(visibilityTest->
                                visibilityTest.accept(consent.isAllowed()));
                    });

                });
        }

        public void assertParameterUsability(
                final boolean checkRules,
                @SuppressWarnings("unchecked") final Consumer<String> ...argUsableChecks) {

            assertParameterModel(checkRules,
                // when
                pendingArgs->{

                },
                // then
                pendingArgs->{

                    var usabilityTests = Can.ofArray(argUsableChecks);

                    pendingArgs.getParamModels()
                    .forEach(param->{

                        var consent = pendingArgs.getUsabilityConsent(param.paramIndex());

                        usabilityTests
                            .get(param.paramIndex())
                            .ifPresent(usabilityTest->
                                usabilityTest.accept(consent.getReasonAsString().orElse(null)));
                    });

                });

        }

        @SuppressWarnings("unchecked")
        public void assertValidationMessage(
                final String expectedMessage,
                final boolean checkRules,
                @SuppressWarnings("rawtypes") final UnaryOperator ...pojoDefaultArgMapper) {

            assertParameterModel(checkRules,
                // when
                pendingArgs->{

                    var pojoArgMappers = Can.ofArray(pojoDefaultArgMapper);

                    pendingArgs.getParamModels()
                    .forEach(param->{

                        var objManager = param.getMetaModel().getObjectManager();

                        pojoArgMappers
                            .get(param.paramIndex())
                            .ifPresent(argMapper->
                                param.getValue().setValue(
                                    objManager
                                    .adapt(
                                        argMapper
                                        .apply(MmUnwrapUtils.single(param.getValue().getValue())))));
                    });

                },
                // then
                pendingArgs->{

                    assertEquals(expectedMessage, pendingArgs.getObservableActionValidation().getValue());

                });
        }

        public void assertParameterModel(
                final boolean checkRules,
                final Consumer<ParameterNegotiationModel> when,
                final Consumer<ParameterNegotiationModel> then) {

            assertExists(true);

            interactionService.runAnonymous(()->{
                var pendingArgs = startParameterNegotiation(checkRules);
                when.accept(pendingArgs);
                captureCommand();
                pendingArgs.activateValidationFeedback();
                then.accept(pendingArgs);
            });

        }

        public Can<Command> getCapturedCommands() {
            return Can.ofCollection(capturedCommands);
        }

        /**
         * Use on non scalar results.
         */
        public DataTableTester tableTester(
                @SuppressWarnings("rawtypes") final @Nullable UnaryOperator ...pojoDefaultArgReplacers) {

            assertExists(true);

            var pojoReplacers = Can.ofArray(pojoDefaultArgReplacers);
            var managedAction = this.getManagedActionElseFail();

            return interactionService.callAnonymous(()->{

                var pendingArgs = startParameterNegotiation(true);

                pendingArgs.getParamModels()
                        .forEach(param->{
                            pojoReplacers
                                .get(param.paramIndex())
                                .ifPresent(replacer->updatePojo(param, replacer));
                        });

                //pendingArgs.validateParameterSetForParameters();

                var resultOrVeto = actionInteraction.invokeWith(pendingArgs);
                assertTrue(resultOrVeto.isSuccess()); // assert action did not throw

                var actionResult = resultOrVeto.getSuccessElseFail();

                var table = DataTableInteractive
                        .forAction(managedAction, actionResult);

                return DataTableTester.of(table);

            });

        }

        // -- HELPER

        @SuppressWarnings("unchecked")
        static void updatePojo(final ManagedValue managedValue, @SuppressWarnings("rawtypes") final UnaryOperator replacer) {
            managedValue.update(v->ManagedObject.adaptSingular(
                    v.objSpec(),
                    replacer.apply(v.getPojo())));
        }

        @SneakyThrows
        private ParameterNegotiationModel startParameterNegotiation(final boolean checkRules) {

            if(parameterNegotiationStarter!=null) {
                return parameterNegotiationStarter.get();
            }

            if(actionInteraction==null) {
                fail("action-interaction not initialized on action-tester");
            }

            if(checkRules) {
                actionInteraction
                    .checkVisibility()
                    .checkUsability();
            }

            return actionInteraction
                    .startParameterNegotiation().orElseThrow(()->_Exceptions
                            .illegalAccess("action not visible or usable: %s",
                                    getManagedAction()
                                    .map(ManagedAction::getAction)
                                    .map(ObjectAction::getFeatureIdentifier)
                                    .map(Identifier::toString)
                                    .orElse("no such action")));
        }

        private void captureCommand() {
            capturedCommands.add(
                    interactionService.currentInteraction().get().getCommand());
        }
    }

    // -- PROPERTY TESTER

    public static class PropertyTester<T>
    extends MemberTester<T> {

        @Getter
        private Optional<ManagedProperty> managedPropertyIfAny;

        private PropertyTester(
                final @NonNull T domainObject,
                final @NonNull String propertyName,
                final @NonNull Where where) {
            super(domainObject, propertyName, "property", where);
        }

        @Override
        public Optional<OneToOneAssociation> getMetaModel() {
            return managedPropertyIfAny
            .map(ManagedProperty::getMetaModel)
            .map(OneToOneAssociation.class::cast);
        }

        public OneToOneAssociation getPropertyMetaModelElseFail() {
            return getMetaModel().orElseThrow();
        }

        @Override
        protected Optional<ObjectSpecification> getElementType() {
            return getMetaModel()
                    .map(OneToOneAssociation::getElementType);
        }

        @Override
        protected Optional<ManagedProperty> startInteractionOn(final ManagedObject viewModel) {
            return this.managedPropertyIfAny = PropertyInteraction
                    .start(viewModel, getMemberName(), where)
                    .getManagedProperty();
        }

        /**
         * circumvents rule checking
         */
        public void assertValue(final Object expectedPropertyValue) {

            assertExists(true);

            managedPropertyIfAny
            .ifPresent(managedProperty->{
                interactionService.runAnonymous(()->{
                    assertEquals(expectedPropertyValue, managedProperty.getPropertyValue().getPojo());
                });
            });
        }

        /**
         * circumvents rule checking
         */
        public void assertValueUpdate(final Object proposedNewPropertyValue) {

            assertExists(true);

            managedPropertyIfAny
            .ifPresent(managedProperty->{
                interactionService.runAnonymous(()->{

                    var newPropertyValue = managedProperty.getMetaModel().getMetaModelContext()
                          .getObjectManager().adapt(proposedNewPropertyValue);

                    managedProperty.modifyProperty(newPropertyValue);
                    assertEquals(proposedNewPropertyValue, managedProperty.getPropertyValue().getPojo());
                });
            });

        }

        public void assertValueUpdateUsingNegotiation(final Object proposedNewPropertyValue) {

            assertExists(true);

            managedPropertyIfAny
            .ifPresent(managedProperty->{
                interactionService.runAnonymous(()->{

                    var propNeg = managedProperty.startNegotiation();
                    var initialValue = managedProperty.getPropertyValue();

                    assertEquals(initialValue, propNeg.getValue().getValue());

                    var newPropertyValue = managedProperty.getMetaModel().getMetaModelContext()
                            .getObjectManager().adapt(proposedNewPropertyValue);
                    propNeg.getValue().setValue(newPropertyValue);

                    // yet just pending
                    assertEquals(initialValue, managedProperty.getPropertyValue());
                    assertEquals(newPropertyValue, propNeg.getValue().getValue());

                    propNeg.submit();

                    // after submission
                    assertEquals(newPropertyValue, managedProperty.getPropertyValue());
                    assertEquals(newPropertyValue, propNeg.getValue().getValue());

                });
            });

        }

        public void assertValueNegotiation(
                final Consumer<PropertyNegotiationModel> when,
                final Consumer<PropertyNegotiationModel> then) {

            assertExists(true);

            managedPropertyIfAny
            .ifPresent(managedProperty->{
                interactionService.runAnonymous(()->{
                    var propNeg = managedProperty.startNegotiation();
                    when.accept(propNeg);
                    propNeg.activateValidationFeedback();
                    propNeg.submit();
                    then.accept(propNeg);
                });
            });
        }

        /**
         * Supported by all properties that reflect a value type.
         * Uses value-semantics under the hood to do the conversion.
         */
        public void assertValueUpdateUsingNegotiationTextual(
                final String parsableProposedValue) {

            assertExists(true);

            managedPropertyIfAny
            .ifPresent(managedProperty->{
                interactionService.runAnonymous(()->{

                    var propNeg = managedProperty.startNegotiation();
                    var initialValue = managedProperty.getPropertyValue();

                    assertEquals(initialValue, propNeg.getValue().getValue());

                    propNeg.getValueAsParsableText().setValue(parsableProposedValue);

                    // yet just pending
                    assertEquals(initialValue, managedProperty.getPropertyValue());
                    assertEquals(parsableProposedValue, propNeg.getValueAsParsableText().getValue());

                    propNeg.submit();

                    // after submission
                    assertEquals(parsableProposedValue, asParsebleText());
                    assertEquals(parsableProposedValue, propNeg.getValueAsParsableText().getValue());

                });
            });
        }

        /**
         * Supported by all properties that reflect a value type.
         * Uses value-semantics under the hood to do the conversion.
         */
        public void assertValueUpdateUsingNegotiationTextual(
                final String parsableProposedValue,
                final @NonNull String expectedValidationMessage) {

            assertExists(true);

            managedPropertyIfAny
            .ifPresent(managedProperty->{
                interactionService.runAnonymous(()->{

                    var propNeg = managedProperty.startNegotiation();
                    var initialValue = managedProperty.getPropertyValue();

                    if(ManagedObjects.isNullOrUnspecifiedOrEmpty(initialValue)) {
                        assertTrue(ManagedObjects.isNullOrUnspecifiedOrEmpty(propNeg.getValue().getValue()));
                    } else {
                        assertEquals(initialValue, propNeg.getValue().getValue());
                    }

                    propNeg.getValueAsParsableText().setValue(parsableProposedValue);

                    // yet just pending
                    assertEquals(initialValue, managedProperty.getPropertyValue());
                    assertEquals(parsableProposedValue, propNeg.getValueAsParsableText().getValue());

                    // check expected validation message
                    propNeg.activateValidationFeedback();
                    assertEquals(expectedValidationMessage, propNeg.getValidationMessage().getValue());
                });
            });
        }

        @SuppressWarnings("unchecked")
        public String asParsebleText() {
            assertExists(true);
            var valueFacet = getFacetOnElementTypeElseFail(ValueFacet.class);
            var prop = this.getMetaModel().get();

            var context = valueFacet
                    .createValueSemanticsContext(prop);

            return valueFacet.selectParserForAttributeOrElseFallback(prop)
                    .parseableTextRepresentation(context,
                            MmUnwrapUtils.single(managedPropertyIfAny.get().getPropertyValue()));
        }

    }

    // -- COLLECTION TESTER

    public static class CollectionTester<T>
    extends MemberTester<T> {

        @Getter
        private Optional<ManagedCollection> managedCollectionIfAny;

        private CollectionTester(
                final @NonNull T domainObject,
                final @NonNull String collectionName,
                final @NonNull Where where) {
            super(domainObject, collectionName, "collection", where);
        }

        @Override
        public Optional<OneToManyAssociation> getMetaModel() {
            return managedCollectionIfAny
            .map(ManagedCollection::getMetaModel)
            .map(OneToManyAssociation.class::cast);
        }

        public OneToManyAssociation getCollectionMetaModelElseFail() {
            return getMetaModel().orElseThrow();
        }

        @Override
        protected Optional<ObjectSpecification> getElementType() {
            return getMetaModel()
                    .map(OneToManyAssociation::getElementType);
        }

        @Override
        protected Optional<ManagedCollection> startInteractionOn(final ManagedObject viewModel) {
            return this.managedCollectionIfAny = CollectionInteraction
                    .start(viewModel, getMemberName(), Where.NOT_SPECIFIED)
                    .getManagedCollection();
        }

        /**
         * circumvents rule checking
         */
        public Stream<ManagedObject> streamCollectionElements() {

            assertExists(true);

            return managedCollectionIfAny
            .map(managedCollection->
                interactionService.callAnonymous(managedCollection::streamElements))
            .orElseGet(Stream::empty);
        }

        /**
         * circumvents rule checking
         */
        public void assertCollectionElements(final Iterable<?> expectedCollectionElements) {

            assertExists(true);

            managedCollectionIfAny
            .ifPresent(managedCollection->{
                interactionService.runAnonymous(()->{
                    CollectionAssertions
                    .assertComponentWiseEquals(expectedCollectionElements, managedCollection.getCollectionValue().getPojo());
                });
            });
        }

        public DataTableTester tableTester() {
            return DataTableTester.of(getManagedCollectionIfAny()
                    .orElseThrow()
                    .createDataTableModel());
        }

    }

    // -- COMMON ABSTRACT MEMBER TESTER

    private static abstract class MemberTester<T>
    extends Tester<T>{

        @Getter private final String memberName;
        private final String memberSort;
        protected final Where where;

        private Optional<? extends ManagedMember> managedMemberIfAny;

        protected MemberTester(
                final @NonNull T domainObject,
                final @NonNull String memberName,
                final @NonNull String memberSort,
                final @NonNull Where where) {
            super(domainObject);
            this.memberName = memberName;
            this.memberSort = memberSort;
            this.where = where;
        }

        protected final MemberTester<T> init() {
            this.managedMemberIfAny = startInteractionOn(vm);
            return this;
        }

        protected abstract Optional<? extends ObjectMember> getMetaModel();

        protected abstract Optional<ObjectSpecification> getElementType();

        public ObjectSpecification getElementTypeElseFail() {
            return getElementType().orElseThrow();
        }

        protected abstract Optional<? extends ManagedMember> startInteractionOn(ManagedObject viewModel);

        public <F extends Facet> F getFacetOnMemberElseFail(final Class<F> facetType) {
            return getMetaModel()
            .map(m->m.getFacet(facetType))
            .orElseThrow(()->_Exceptions.noSuchElement(
                    String.format("%s has no %s", memberName, facetType.getSimpleName())
                    ));
        }

        public <F extends Facet> F getFacetOnElementTypeElseFail(final Class<F> facetType) {
            return getElementType()
            .map(m->m.getFacet(facetType))
            .orElseThrow(()->_Exceptions.noSuchElement(
                    String.format("'%s: %s' has no %s on its element type",
                            memberName,
                            getObjectSpecification().getCorrespondingClass().getSimpleName(),
                            facetType.getSimpleName())
                    ));
        }

        public final void assertExists(final boolean isExpectedToExist) {

            if(isExpectedToExist
                    && managedMemberIfAny.isEmpty()) {
                fail(String.format("%s %s does not exist", memberSort, memberName));
            }

            if(!isExpectedToExist
                    && managedMemberIfAny.isPresent()) {
                fail(String.format("%s %s does exist", memberSort, memberName));
            }
        }

        public void assertMemberId(final String expectedMemberId) {
            assertEquals(expectedMemberId,
                    getMetaModel()
                    .orElseThrow(_Exceptions::noSuchElement).getId());
        }

        public final void assertFriendlyName(final String expectedFriendlyName) {
            assertTrue(managedMemberIfAny.isPresent());
            assertEquals(expectedFriendlyName,
                    managedMemberIfAny.get()
                    .getFriendlyName());
        }

        public final void assertDescription(final String expectedDescription) {
            assertTrue(managedMemberIfAny.isPresent());
            assertEquals(_Strings.nullToEmpty(expectedDescription),
                    managedMemberIfAny.get()
                    .getDescription().orElse(""));
        }

        private final void assertVisibility(final boolean isExpectedVisible) {
            assertVisibilityIsVetoedWith(isExpectedVisible ? null : "Hidden");
        }

        public final void assertVisibilityIsVetoed() {
            assertVisibility(false);
        }

        public final void assertVisibilityIsNotVetoed() {
            assertVisibility(true);
        }

        private final void assertVisibilityIsVetoedWith(final @Nullable String expectedVetoReason) {

            final boolean isExpectedVisible = expectedVetoReason == null;

            if(isExpectedVisible) {
                assertExists(true);
            }

            managedMemberIfAny
            .ifPresent(managedCollection->{
                interactionService.runAnonymous(()->{

                    final String actualVetoResaon = managedCollection
                        .checkVisibility()
                        .flatMap(veto->veto.getReasonAsString())
                        .orElse(null);

                    assertEquals(expectedVetoReason, actualVetoResaon);
                });
            });
        }

        public final void assertUsabilityIsNotVetoed() {
            assertUsabilityIsVetoedWith(null);
        }

        public final void assertUsabilityIsVetoedWithAll(final Can<String> expectedVetoReasons) {

            assertExists(true);

            managedMemberIfAny
            .ifPresent(managedMember->{
                interactionService.runAnonymous(()->{
                    final String actualVetoReason = managedMember
                            .checkUsability()
                            .flatMap(veto->veto.getReasonAsString())
                            .orElse(null);

                    if(!expectedVetoReasons.isEmpty()
                            && actualVetoReason==null) {
                        fail("usability not vetoed, while expecting all of: " + expectedVetoReasons.toList());
                    }

                    expectedVetoReasons
                    .forEach(expectedVetoReason->{
                        assertTrue(actualVetoReason.contains(expectedVetoReason),
                               ()->String.format("usability veto %s is not containing %s",
                                       actualVetoReason, expectedVetoReason));
                    });
                });
            });
        }

        public final void assertUsabilityIsVetoedWith(final @Nullable String expectedVetoReason) {

            final boolean isExpectedUsable = expectedVetoReason == null;

            if(isExpectedUsable) {
                assertExists(true);
            }

            managedMemberIfAny
            .ifPresent(managedMember->{
                interactionService.runAnonymous(()->{
                    final String actualVetoReason = managedMember
                            .checkUsability()
                            .flatMap(veto->veto.getReasonAsString())
                            .orElse(null);

                        assertEquals(expectedVetoReason, actualVetoReason);
                });
            });
        }

        public final void assertIsExplicitlyAnnotated(final boolean isExpectedExplicitlyAnnotated) {
            if(isExpectedExplicitlyAnnotated
                    && !getMetaModel().get().isExplicitlyAnnotated()) {
                fail(String.format("%s %s is not explicitly annotated", memberSort, memberName));
            }

            if(!isExpectedExplicitlyAnnotated
                    && getMetaModel().get().isExplicitlyAnnotated()) {
                fail(String.format("%s %s is explicitly annotated", memberSort, memberName));
            }
        }

    }

    private static abstract class Tester<T> {

        @Inject protected CausewayConfiguration configuration;
        @Inject protected CausewaySystemEnvironment causewaySystemEnvironment;
        @Inject protected SpecificationLoader specificationLoader;
        @Inject protected InteractionService interactionService;

        @Getter private final Class<T> domainObjectType;
        @Getter private final ObjectSpecification objectSpecification;
        protected final ManagedObject vm;

        protected Tester(
                final @NonNull T domainObject) {
            this.domainObjectType = _Casts.uncheckedCast(domainObject.getClass());
            this.vm = MetaModelContext.instanceElseFail().getObjectManager().adapt(domainObject);
            this.objectSpecification = vm.objSpec();
        }

    }

}
