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

import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Predicate;
import java.util.stream.Collectors;

import org.jspecify.annotations.NonNull;

import org.apache.causeway.applib.Identifier;
import org.apache.causeway.applib.annotation.Action;
import org.apache.causeway.applib.annotation.ActionLayout;
import org.apache.causeway.applib.annotation.SemanticsOf;
import org.apache.causeway.applib.annotation.Where;
import org.apache.causeway.applib.exceptions.RecoverableException;
import org.apache.causeway.applib.services.bookmark.Bookmark;
import org.apache.causeway.applib.services.command.Command;
import org.apache.causeway.commons.collections.Can;
import org.apache.causeway.commons.collections.CanVector;
import org.apache.causeway.commons.functional.IndexedFunction;
import org.apache.causeway.commons.internal.assertions._Assert;
import org.apache.causeway.commons.internal.base._Lazy;
import org.apache.causeway.commons.internal.exceptions._Exceptions;
import org.apache.causeway.core.metamodel.commons.UtilStr;
import org.apache.causeway.core.metamodel.consent.Consent;
import org.apache.causeway.core.metamodel.consent.InteractionInitiatedBy;
import org.apache.causeway.core.metamodel.consent.InteractionResultSet;
import org.apache.causeway.core.metamodel.facetapi.FacetHolder;
import org.apache.causeway.core.metamodel.facetapi.FeatureType;
import org.apache.causeway.core.metamodel.facets.FacetedMethod;
import org.apache.causeway.core.metamodel.facets.actions.action.invocation.ActionInvocationFacet;
import org.apache.causeway.core.metamodel.facets.actions.prototype.PrototypeFacet;
import org.apache.causeway.core.metamodel.facets.actions.semantics.ActionSemanticsFacet;
import org.apache.causeway.core.metamodel.facets.param.choices.ActionParameterChoicesFacet;
import org.apache.causeway.core.metamodel.interactions.InteractionHead;
import org.apache.causeway.core.metamodel.interactions.InteractionUtils;
import org.apache.causeway.core.metamodel.interactions.use.ActionUsabilityContext;
import org.apache.causeway.core.metamodel.interactions.use.UsabilityContext;
import org.apache.causeway.core.metamodel.interactions.val.ActionValidityContext;
import org.apache.causeway.core.metamodel.interactions.vis.ActionVisibilityContext;
import org.apache.causeway.core.metamodel.interactions.vis.VisibilityContext;
import org.apache.causeway.core.metamodel.object.ManagedObject;
import org.apache.causeway.core.metamodel.spec.ActionScope;
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.ObjectActionParameter;
import org.apache.causeway.core.metamodel.util.Facets;
import org.apache.causeway.schema.cmd.v2.CommandDto;

import lombok.Getter;
import lombok.extern.slf4j.Slf4j;

@Slf4j
class ObjectActionDefault
extends ObjectMemberAbstract
implements ObjectAction, HasSpecificationLoaderInternal {
    private static final long serialVersionUID = 1L;

    public static ActionScope getType(final String typeStr) {
        final ActionScope type = ActionScope.valueOf(typeStr);
        if (type == null) {
            throw new IllegalArgumentException();
        }
        return type;
    }

    // -- FACTORIES

    public static ObjectActionDefault forMethod(
            final FacetedMethod facetedMethod) {
        return new ObjectActionDefault(facetedMethod.getFeatureIdentifier(), facetedMethod, false, false);
    }

    public static ObjectAction forMixinMain(final FacetedMethod facetedMethod) {
        return new ObjectActionDefault(facetedMethod.getFeatureIdentifier(), facetedMethod, true, false);
    }

    @Override
    public String asciiId() {
        return getMetaModelContext().getAsciiIdentifierService().asciiIdFor(getId());
    }

    // -- CONSTRUCTION

    /**
     * For convenience, during introspection of mixin types,
     * the mixin's main is wrapped with an {@link ObjectActionDefault},
     * to ease further processing.
     * <p>
     * However, such an {@link ObjectActionDefault} then is never exposed by the meta-model.
     */
    @Getter(onMethod_ = @Override)
    private final boolean declaredOnMixin;

    private final boolean testing;

    protected ObjectActionDefault(
            final Identifier identifier,
            final FacetedMethod facetedMethod,
            final boolean declaredOnMixin,
            final boolean testing) {
        super(identifier, facetedMethod, FeatureType.ACTION);

        this.declaredOnMixin = declaredOnMixin;
        this.testing = testing;
    }

    // -- DECLARING TYPE

    @Getter(onMethod_ = @Override, lazy=true)
    private final ObjectSpecification declaringType = loadDeclaringType();

    private ObjectSpecification loadDeclaringType() {
        var declaringType = getActionInvocationFacet()
                .map(ActionInvocationFacet::getDeclaringType);
        // JUnit support
        if(testing
                && declaringType.isEmpty()) {
            return specLoaderInternal().loadSpecification(getFacetedMethod().methodFacade().getDeclaringClass());
        }
        return declaringType.orElseThrow(()->_Exceptions
                .illegalState("missing ActionInvocationFacet on action %s", getFeatureIdentifier()));
    }

    @Override
    public SemanticsOf getSemantics() {
        return lookupFacet(ActionSemanticsFacet.class)
            .map(ActionSemanticsFacet::value)
            .orElse(SemanticsOf.NON_IDEMPOTENT);
    }

    // -- ELEMENT TYPE

    @Getter(onMethod_ = @Override, lazy=true)
    private final ObjectSpecification elementType = loadElementType();

    private ObjectSpecification loadElementType() {
        return Facets.elementSpec(getFacetedMethod())
                .orElseGet(()->{
                    var returnType = getReturnType();
                    if(!returnType.isSingular()) {
                        log.warn("plural action return type requires a TypeOfFacet: {}", getFeatureIdentifier());
                    }
                    return returnType;
                });
    }

    // -- RETURN TYPE

    @Getter(onMethod_ = @Override, lazy=true)
    private final ObjectSpecification returnType = loadReturnType();

    private ObjectSpecification loadReturnType() {
        var returType = getActionInvocationFacet()
                .map(ActionInvocationFacet::getReturnType);
        // JUnit support
        if(testing
                && returType.isEmpty()) {
            return specLoaderInternal().loadSpecification(getFacetedMethod().methodFacade().getReturnType());
        }
        return returType.orElseThrow(()->_Exceptions
                .illegalState("framework bug: missing ActionInvocationFacet on action %s", getFeatureIdentifier()));
    }

    /**
     * Returns true if the represented action returns something, else returns
     * false.
     */
    @Override
    public boolean hasReturn() {
        if(getReturnType() == null) {
            // this shouldn't happen; return Type always defined, even if represents void.class
            return false;
        }
        return !getReturnType().isVoidPrimitive();
    }

    // -- TYPE

    @Override
    public ActionScope getScope() {
        return getScope(this);
    }

    private static ActionScope getScope(final FacetHolder facetHolder) {
        return facetHolder.containsFacet(PrototypeFacet.class)
            ? ActionScope.PROTOTYPE
            : ActionScope.PRODUCTION;
    }

    @Override
    public InteractionHead interactionHead(final @NonNull ManagedObject actionOwner) {
        return InteractionHead.regular(actionOwner);
    }

    // -- Parameters

    private final _Lazy<Can<ObjectActionParameter>> parameters = _Lazy.threadSafe(this::determineParameters);

    @Override
    public int getParameterCount() {
        return getFacetedMethod().parameters().size();
    }

    @Override
    public Can<ObjectActionParameter> getParameters() {
        return parameters.get();
    }

    protected Can<ObjectActionParameter> determineParameters() {
        var specLoaderInternal = specLoaderInternal();

        return getFacetedMethod().parameters()
            .map(facetedParam->{
                final int paramIndex = facetedParam.paramIndex();
                var paramElementType = specLoaderInternal.loadSpecification(facetedParam.resolvedType().elementType()); // preload

                return facetedParam.featureType() == FeatureType.ACTION_PARAMETER_SINGULAR
                            ? new OneToOneActionParameterDefault(paramElementType, paramIndex, this)
                            : new OneToManyActionParameterDefault(paramElementType, paramIndex, this);
            });
    }

    @Override
    public Can<ObjectSpecification> getParameterTypes() {
        return getParameters().map(ObjectActionParameter::getElementType);
    }

    @Override
    public ObjectActionParameter getParameterById(final String paramId) {
        return getParameters().stream()
                .filter(param->Objects.equals(paramId, param.getId()))
                .findAny()
                .orElse(null);
    }

    @Override
    public ObjectActionParameter getParameterByName(final String paramName) {
        return getParameters().stream()
                .filter(param->Objects.equals(paramName, param.getCanonicalFriendlyName()))
                .findAny()
                .orElse(null);
    }

    @Override
    public Can<ObjectActionParameter> getParameters(final Predicate<ObjectActionParameter> filter) {
        return getParameters().filter(filter);
    }

    ObjectActionParameter getParameter(final int position) {
        var parameters = getParameters();
        if (position >= parameters.size()) {
            throw new IllegalArgumentException(
                    "getParameter(int): only " + parameters.size() + " parameters, position=" + position);
        }
        return parameters.getElseFail(position);
    }

    // -- VISIBLE

    @Override
    public VisibilityContext createVisibleInteractionContext(
            final ManagedObject target,
            final InteractionInitiatedBy interactionInitiatedBy,
            final Where where) {
        return new ActionVisibilityContext(
                headFor(target),
                this,
                getFeatureIdentifier(),
                interactionInitiatedBy,
                where,
                InteractionUtils.renderPolicy(target));
    }

    // -- USABLE

    @Override
    public UsabilityContext createUsableInteractionContext(
            final ManagedObject target,
            final InteractionInitiatedBy interactionInitiatedBy,
            final Where where) {
        return new ActionUsabilityContext(
                headFor(target),
                this,
                getFeatureIdentifier(),
                interactionInitiatedBy,
                where,
                InteractionUtils.renderPolicy(target));
    }

    // -- VALIDATE

    @Override
    public Consent isArgumentSetValid(
            final InteractionHead head,
            final Can<ManagedObject> proposedArguments,
            final InteractionInitiatedBy interactionInitiatedBy) {

        final InteractionResultSet resultSet = new InteractionResultSet();

        validateArgumentsIndividually(head, proposedArguments, interactionInitiatedBy, resultSet);
        if (resultSet.isAllowed()) {
            // only check the action's own validity if all the arguments are OK.
            validateArgumentSet(head, proposedArguments, interactionInitiatedBy, resultSet);
        }

        return resultSet.createConsent();
    }

    @Override
    public InteractionResultSet isArgumentSetValidForParameters(
            final InteractionHead head,
            final Can<ManagedObject> proposedArguments,
            final InteractionInitiatedBy interactionInitiatedBy) {

        final InteractionResultSet resultSet = new InteractionResultSet();

        validateArgumentsIndividually(head, proposedArguments, interactionInitiatedBy, resultSet);

        return resultSet;
    }

    private void validateArgumentsIndividually(
            final InteractionHead head,
            final Can<ManagedObject> proposedArguments,
            final InteractionInitiatedBy interactionInitiatedBy,
            final InteractionResultSet resultSet) {

        var actionParameters = getParameters();
        if (proposedArguments != null) {
            for (int i = 0; i < proposedArguments.size(); i++) {
                var validityContext = actionParameters.getElseFail(i)
                        .createProposedArgumentInteractionContext(
                                head, proposedArguments, i, interactionInitiatedBy);

                InteractionUtils.isValidResultSet(getParameter(i), validityContext, resultSet);
            }
        }
    }

    @Override
    public Consent isArgumentSetValidForAction(
            final InteractionHead head,
            final Can<ManagedObject> proposedArguments,
            final InteractionInitiatedBy interactionInitiatedBy) {

        final InteractionResultSet resultSet = new InteractionResultSet();
        validateArgumentSet(head, proposedArguments, interactionInitiatedBy, resultSet);

        return resultSet.createConsent();
    }

    protected void validateArgumentSet(
            final InteractionHead head,
            final Can<ManagedObject> proposedArguments,
            final InteractionInitiatedBy interactionInitiatedBy,
            final InteractionResultSet resultSet) {

        var validityContext = createActionInvocationInteractionContext(
                head, proposedArguments, interactionInitiatedBy);
        InteractionUtils.isValidResultSet(this, validityContext, resultSet);
    }

    ActionValidityContext createActionInvocationInteractionContext(
            final InteractionHead head,
            final Can<ManagedObject> proposedArguments,
            final InteractionInitiatedBy interactionInitiatedBy) {

        return new ActionValidityContext(
                head,
                this,
                getFeatureIdentifier(),
                proposedArguments,
                interactionInitiatedBy);
    }

    // -- EXECUTE

    @Override
    public ManagedObject executeWithRuleChecking(
            final InteractionHead head,
            final Can<ManagedObject> arguments,
            final InteractionInitiatedBy interactionInitiatedBy,
            final Where where) {

        var target = head.owner();

        // see it?
        final Consent visibility = isVisible(target, interactionInitiatedBy, where);
        if (visibility.isVetoed()) {
            throw new HiddenException();
        }

        // use it?
        final Consent usability = isUsable(target, interactionInitiatedBy, where);
        if(usability.isVetoed()) {
            throw new DisabledException(usability.getReasonAsString().orElse("no reason given"));
        }

        // do it?
        final Consent validity = isArgumentSetValid(head, arguments, interactionInitiatedBy);
        if(validity.isVetoed()) {
            throw new RecoverableException(validity.getReasonAsString().orElse("no reason given"));
        }

        return execute(head, arguments, interactionInitiatedBy);
    }

    /**
     * Sets up the {@link Command}, then delegates to {@link #executeInternal(InteractionHead, Can, InteractionInitiatedBy)}
     * to invoke the {@link ActionInvocationFacet invocation facet}.
     */
    @Override
    public ManagedObject execute(
            final InteractionHead head,
            final Can<ManagedObject> argumentAdapters,
            final InteractionInitiatedBy interactionInitiatedBy) {

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

        final ManagedObject owner = head.owner();

        if(!interactionInitiatedBy.isPassThrough()) {
            setupCommand(head, argumentAdapters);

            if(log.isInfoEnabled()) {
                Optional<Bookmark> bookmarkIfAny = owner.getBookmark();
                bookmarkIfAny.ifPresent(bookmark -> {   // should always be true
                    log.info("Executing: {}#{} {} {}",
                        getFeatureIdentifier().logicalTypeName(),
                        getFeatureIdentifier().memberLogicalName(),
                        UtilStr.entityAsStr(bookmark, getSpecificationLoader()),
                        argsFor(getParameters(), argumentAdapters));
                });
            }
        }

        return this.executeInternal(head, argumentAdapters, interactionInitiatedBy);
    }

    /**
     * private API, called by mixins
     */
    protected ManagedObject executeInternal(
            final InteractionHead head,
            final Can<ManagedObject> argumentAdapters,
            final InteractionInitiatedBy interactionInitiatedBy) {
        var actionInvocationFacet = getFacet(ActionInvocationFacet.class);
        return actionInvocationFacet
                .invoke(this, head, argumentAdapters, interactionInitiatedBy);
    }

    protected Optional<ActionInvocationFacet> getActionInvocationFacet() {
        return getFacetedMethod().lookupFacet(ActionInvocationFacet.class);
    }

    // -- choices

    @Override
    public CanVector<ManagedObject> getChoices(
            final ManagedObject target,
            final InteractionInitiatedBy interactionInitiatedBy) {

        final int parameterCount = getParameterCount();
        CanVector<ManagedObject> paramChoicesVector;

        var parameters = getParameters();

            // use the new choicesNXxx approach for each param in turn
            // (the reflector will have made sure both aren't installed).

        var emptyPendingArgs = Can.<ManagedObject>empty();
        paramChoicesVector = new CanVector<>(parameterCount);
        for (int i = 0; i < parameterCount; i++) {
            var param = parameters.getElseFail(i);
            var paramSpec = param.getElementType();
            var paramFacet = param.getFacet(ActionParameterChoicesFacet.class);

            if (paramFacet != null && !paramFacet.getPrecedence().isFallback()) {

                var visibleChoices = paramFacet.getChoices(
                        paramSpec,
                        actionInteractionHead(target),
                        emptyPendingArgs,
                        interactionInitiatedBy);
                ObjectActionParameterAbstract.checkChoicesOrAutoCompleteType(
                        specLoaderInternal(), visibleChoices, paramSpec);
                paramChoicesVector.set(i, visibleChoices);
            } else {
                paramChoicesVector.set(i, Can.empty());
            }
        }
        return paramChoicesVector;
    }

    @Override
    public boolean isPrototype() {
        return getScope().isPrototype();
    }

    @Getter(lazy=true, onMethod_ = {@Override})
    private final boolean explicitlyAnnotated = calculateIsExplicitlyAnnotated();

    /**
     * Internal API, called by the various implementations of
     * {@link ObjectAction} ({@link ObjectActionDefault default} and
     * {@link ObjectActionMixedIn mixed-in}.
     */
    public void setupCommand(
            final InteractionHead head,
            final Can<ManagedObject> argumentAdapters) {

        if(head.owner().objSpec().isValue()) {
            return; // do not record value type mixin actions
        }

        setupCommand(head,
                interactionId->commandDtoFor(interactionId, head, argumentAdapters));
    }

    @Override
    public ManagedObject realTargetAdapter(final ManagedObject targetAdapter) {
        return targetAdapter;
    }

    @Override
    public String toString() {
        final StringBuffer sb = new StringBuffer();
        sb.append("Action [");
        sb.append(super.toString());
        sb.append(",scope=");
        sb.append(getScope());
        sb.append(",returns=");
        sb.append(getReturnType());
        sb.append(",parameters={");
        for (int i = 0; i < getParameterCount(); i++) {
            if (i > 0) {
                sb.append(",");
            }
            sb.append(getParameters().getElseFail(i).getElementType().getShortIdentifier());
        }
        sb.append("}]");
        return sb.toString();
    }

    @Override
    public FacetHolder getFacetHolder() {
        return super.getFacetedMethod();
    }

    // -- HELPER

    protected String argsFor(final Can<ObjectActionParameter> parameters, final Can<ManagedObject> arguments) {
        if(parameters.size() != arguments.size()) {
            return "???"; // shouldn't happen
        }
        return parameters.stream().map(IndexedFunction.zeroBased((i, param) -> {
            var id = param.getId();
            var argStr = argStr(id, arguments, i);
            return id + "=" + argStr;
        })).collect(Collectors.joining(","));
    }

    private static String argStr(
            final String paramId,
            final Can<ManagedObject> arguments,
            final int i) {
        return UtilStr.namedArgStr(paramId, arguments.get(i));
    }

    private CommandDto commandDtoFor(
            final UUID interactionId,
            final InteractionHead head,
            final Can<ManagedObject> argumentAdapters) {

        return getCommandDtoFactory()
                .asCommandDto(interactionId, head, this, argumentAdapters);
    }

    private boolean calculateIsExplicitlyAnnotated() {
        var methodFacade = getFacetedMethod().methodFacade();
        return methodFacade.synthesize(Action.class).isPresent()
                || methodFacade.synthesize(ActionLayout.class).isPresent();
    }

}
