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

import java.lang.reflect.InvocationTargetException;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

import org.apache.causeway.applib.events.domain.AbstractDomainEvent;
import org.apache.causeway.applib.events.domain.ActionDomainEvent;
import org.apache.causeway.applib.services.bookmark.Bookmark;
import org.apache.causeway.applib.services.iactn.ActionInvocation;
import org.apache.causeway.applib.services.queryresultscache.QueryResultsCache;
import org.apache.causeway.commons.collections.Can;
import org.apache.causeway.commons.internal.assertions._Assert;
import org.apache.causeway.commons.internal.collections._Arrays;
import org.apache.causeway.commons.internal.exceptions._Exceptions;
import org.apache.causeway.commons.internal.reflection._MethodFacades.MethodFacade;
import org.apache.causeway.core.metamodel.commons.CanonicalInvoker;
import org.apache.causeway.core.metamodel.consent.InteractionInitiatedBy;
import org.apache.causeway.core.metamodel.context.HasMetaModelContext;
import org.apache.causeway.core.metamodel.context.MetaModelContext;
import org.apache.causeway.core.metamodel.facetapi.FacetHolder;
import org.apache.causeway.core.metamodel.facets.DomainEventHelper;
import org.apache.causeway.core.metamodel.facets.actions.action.invocation.ActionInvocationFacetAbstract;
import org.apache.causeway.core.metamodel.facets.actions.semantics.ActionSemanticsFacet;
import org.apache.causeway.core.metamodel.interactions.InteractionHead;
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.services.ixn.InteractionDtoFactory;
import org.apache.causeway.core.metamodel.spec.feature.ObjectAction;
import org.apache.causeway.core.metamodel.spec.feature.ObjectActionParameter;

import static org.apache.causeway.commons.internal.base._Casts.uncheckedCast;

import lombok.Getter;
import org.jspecify.annotations.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;

@RequiredArgsConstructor
//@Log4j2
public final class ActionExecutor
implements
    HasMetaModelContext,
    InteractionInternal.MemberExecutor<ActionInvocation> {

    // -- FACTORIES

    public static ActionExecutor forAction(
            final @NonNull FacetHolder facetHolder,
            final @NonNull InteractionInitiatedBy interactionInitiatedBy,
            final @NonNull InteractionHead head,
            final @NonNull Can<ManagedObject> argumentAdapters,
            final @NonNull ObjectAction owningAction,
            final @NonNull ActionInvocationFacetAbstract actionInvocationFacetAbstract) {

        _Assert.assertEquals(owningAction.getParameterCount(), argumentAdapters.size(),
                "action's parameter count and provided argument count must match");

        // guard against malformed argumentAdapters
        argumentAdapters.forEach(arg->{if(!ManagedObjects.isSpecified(arg)) {
            throw _Exceptions.illegalArgument("arguments must be specified for action %s", owningAction);
        }});

        var method = actionInvocationFacetAbstract.getMethods().getFirstElseFail();

        return new ActionExecutor(
                owningAction.getMetaModelContext(), facetHolder,
                interactionInitiatedBy, owningAction, method, head, argumentAdapters, actionInvocationFacetAbstract);
    }

    // -- CONSTRUCTION

    @Getter(onMethod_={@Override})
    private final @NonNull MetaModelContext metaModelContext;

    @Getter
    private final @NonNull FacetHolder facetHolder;
    @Getter
    private final @NonNull InteractionInitiatedBy interactionInitiatedBy;
    @Getter
    private final @NonNull ObjectAction owningAction;
    @Getter
    private final @NonNull MethodFacade method;
    @Getter
    private final @NonNull InteractionHead head;
    @Getter
    private final @NonNull Can<ManagedObject> arguments;

    private final ActionInvocationFacetAbstract actionInvocationFacetAbstract;

    @Getter(lazy=true)
    private final InteractionDtoFactory interactionDtoServiceInternal =
        getServiceRegistry().lookupServiceElseFail(InteractionDtoFactory.class);

    @Getter(lazy=true)
    private final DomainEventHelper domainEventHelper =
        DomainEventHelper.ofServiceRegistry(getServiceRegistry());

    @Getter(lazy=true)
    private final QueryResultsCache queryResultsCache =
        getServiceRegistry().lookupServiceElseFail(QueryResultsCache.class);

    private boolean isPostable() {
        // when mixed-in prop/coll always returns false
        return actionInvocationFacetAbstract.isPostable();
    }

    @SneakyThrows
    @Override
    public Object execute(final ActionInvocation currentExecution) {

        // update the current execution with the DTO (memento)
        //
        // but ... no point in attempting this if no bookmark is yet available.
        // this logic is for symmetry with PropertyModifier, which has a scenario where this might occur.
        //
        var ownerAdapter = head.owner();
        Optional<Bookmark> ownerBookmarkIfAny = ManagedObjects.bookmark(ownerAdapter);
        var ownerHasBookmark = ownerBookmarkIfAny.isPresent();
        if (ownerHasBookmark) {
            var invocationDto =
                    getInteractionDtoServiceInternal().asActionInvocationDto(owningAction, head, arguments);
            currentExecution.setDto(invocationDto);
        }

        if(!isPostable()) {
            // don't emit domain events
            return executeWithoutEvents(arguments);
        }

        // ... post the executing event
        final ActionDomainEvent<?> event = getDomainEventHelper().postEventForAction(
                AbstractDomainEvent.Phase.EXECUTING,
                getEventType(),
                owningAction, owningAction,
                head, arguments,
                null);

        final Can<ManagedObject> argsForInvocation;
        if (event != null) {
            // the event handlers may have updated the argument themselves
            argsForInvocation = updateArguments(
                    owningAction.getParameters(),
                    arguments,
                    event.getArguments());
        } else {
            argsForInvocation = arguments;
        }

        // set event onto the execution
        currentExecution.setEvent(event);

        // invoke method
        var resultPojo = executeWithoutEvents(argsForInvocation);

        if (event != null) {
            // ... post the executed event
            getDomainEventHelper().postEventForAction(
                    AbstractDomainEvent.Phase.EXECUTED,
                    event,
                    owningAction, owningAction, head, argsForInvocation,
                    resultPojo);

            // probably superfluous, but does no harm...
            Object actualReturnValue = event.getReturnValue();  // usually the same as resultPojo
            getServiceInjector().injectServicesInto(actualReturnValue);

            return actualReturnValue;
        } else {
            return resultPojo;
        }
    }

    @SneakyThrows
    private Object executeWithoutEvents(final Can<ManagedObject> arguments) {
        // invoke method
        var resultPojo = invokeMethodElseFromCache(method, head, arguments);
        return getServiceInjector().injectServicesInto(resultPojo);
    }

    // -- HELPER

    private final Class<? extends ActionDomainEvent<?>> getEventType() {
        //TODO[CAUSEWAY-3409] when mixed-in prop/coll we need to ask the prop/coll facet instead
        return uncheckedCast(actionInvocationFacetAbstract.getEventType());
    }

    private Object invokeMethodElseFromCache(
            final MethodFacade method,
            final InteractionHead head,
            final Can<ManagedObject> arguments)
                    throws IllegalAccessException, InvocationTargetException {

        final Object[] executionParameters = MmUnwrapUtils.multipleAsArray(arguments);
        final Object targetPojo = Objects.requireNonNull(
                MmUnwrapUtils.single(head.target()),
                ()->"Could not extract pojo, that this invocation is targeted at.");

        final ActionSemanticsFacet semanticsFacet = getFacetHolder().getFacet(ActionSemanticsFacet.class);
        final boolean cacheable = semanticsFacet != null && semanticsFacet.value().isSafeAndRequestCacheable();
        if(cacheable) {
            final QueryResultsCache queryResultsCache = getQueryResultsCache();
            final Object[] targetPojoPlusExecutionParameters = _Arrays.combineWithExplicitType(Object.class, executionParameters, targetPojo);
            return queryResultsCache.execute(
                    ()->CanonicalInvoker.invoke(method, targetPojo, executionParameters),
                    targetPojo.getClass(), method.getName(), targetPojoPlusExecutionParameters);

        } else {
            return CanonicalInvoker.invoke(method, targetPojo, executionParameters);
        }
    }

    private static Can<ManagedObject> updateArguments(
            final @NonNull Can<ObjectActionParameter> params,
            final @NonNull Can<ManagedObject> argumentAdapters,
            final @NonNull List<Object> newArgumentPojos) {

        // zip in the newArgumentPojos from right
        // element wise: update adapter if new-argument pojo differs from original adapter pojo
        return params.zipMap(newArgumentPojos, (param, newPojo)->{
            final int paramIndex = param.getParameterIndex();
            var originalAdapter = argumentAdapters.getElseFail(paramIndex);
            var originalPojo = originalAdapter.getPojo(); // the original
            return Objects.equals(originalPojo, newPojo)
                    ? originalAdapter
                    : ManagedObject.adaptParameter(param, newPojo);
        });
    }

}
