/*
 *  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.isis.core.runtime.services.publish;

import java.sql.Timestamp;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import javax.enterprise.context.RequestScoped;

import com.google.common.base.Function;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;

import org.apache.isis.applib.Identifier;
import org.apache.isis.applib.annotation.DomainService;
import org.apache.isis.applib.annotation.NatureOfService;
import org.apache.isis.applib.annotation.Programmatic;
import org.apache.isis.applib.annotation.PublishedAction;
import org.apache.isis.applib.annotation.PublishedObject;
import org.apache.isis.applib.annotation.PublishedObject.ChangeKind;
import org.apache.isis.applib.services.bookmark.Bookmark;
import org.apache.isis.applib.services.clock.ClockService;
import org.apache.isis.applib.services.command.Command;
import org.apache.isis.applib.services.command.CommandContext;
import org.apache.isis.applib.services.iactn.Interaction;
import org.apache.isis.applib.services.iactn.InteractionContext;
import org.apache.isis.applib.services.metrics.MetricsService;
import org.apache.isis.applib.services.publish.EventMetadata;
import org.apache.isis.applib.services.publish.EventPayload;
import org.apache.isis.applib.services.publish.EventType;
import org.apache.isis.applib.services.publish.ObjectStringifier;
import org.apache.isis.applib.services.publish.PublishedObjects;
import org.apache.isis.applib.services.publish.PublisherService;
import org.apache.isis.applib.services.publish.PublishingService;
import org.apache.isis.applib.services.user.UserService;
import org.apache.isis.core.metamodel.adapter.ObjectAdapter;
import org.apache.isis.core.metamodel.adapter.oid.Oid;
import org.apache.isis.core.metamodel.adapter.oid.OidMarshaller;
import org.apache.isis.core.metamodel.adapter.oid.RootOid;
import org.apache.isis.core.metamodel.facetapi.IdentifiedHolder;
import org.apache.isis.core.metamodel.facets.FacetedMethod;
import org.apache.isis.core.metamodel.facets.FacetedMethodParameter;
import org.apache.isis.core.metamodel.facets.actions.action.invocation.CommandUtil;
import org.apache.isis.core.metamodel.facets.actions.publish.PublishedActionFacet;
import org.apache.isis.core.metamodel.facets.object.encodeable.EncodableFacet;
import org.apache.isis.core.metamodel.facets.object.publishedobject.PublishedObjectFacet;
import org.apache.isis.core.metamodel.services.ixn.InteractionDtoServiceInternal;
import org.apache.isis.core.metamodel.services.publishing.PublishingServiceInternal;
import org.apache.isis.core.metamodel.spec.feature.ObjectAction;
import org.apache.isis.core.runtime.services.changes.ChangedObjectsServiceInternal;
import org.apache.isis.core.runtime.system.persistence.PersistenceSession;
import org.apache.isis.core.runtime.system.session.IsisSessionFactory;

/**
 * Wrapper around {@link PublishingService}.  Is a no-op if there is no injected service.
 */
@DomainService(
        nature = NatureOfService.DOMAIN,
        menuOrder = "" + Integer.MAX_VALUE
)
@RequestScoped
public class PublishingServiceInternalDefault implements PublishingServiceInternal {

    private final static OidMarshaller OID_MARSHALLER = OidMarshaller.INSTANCE;

    //region > static helper functions
    private Function<ObjectAdapter, ObjectAdapter> notDestroyedElseEmpty() {
        return new Function<ObjectAdapter, ObjectAdapter>() {
            public ObjectAdapter apply(ObjectAdapter adapter) {
                if (adapter == null) {
                    return null;
                }
                if (!adapter.isDestroyed()) {
                    return adapter;
                }
                // objectstores such as JDO prevent the underlying pojo from being touched once it has been deleted.
                // we therefore replace that pojo with an 'empty' one.

                Object replacementObject = getPersistenceSession()
                        .instantiateAndInjectServices(adapter.getSpecification());
                getPersistenceSession().remapRecreatedPojo(adapter, replacementObject);
                return adapter;
            }
        };
    }
    //endregion

    //region > publishObjects
    @Override
    @Programmatic
    public void publishObjects() {

        if(suppress) {
            return;
        }

        // take a copy of enlisted adapters ... the JDO implementation of the PublishingService
        // creates further entities which would be enlisted; taking copy of the map avoids ConcurrentModificationException
        final Map<ObjectAdapter, ChangeKind> changeKindByEnlistedAdapter = Maps.newHashMap();
        changeKindByEnlistedAdapter.putAll(changedObjectsServiceInternal.getChangeKindByEnlistedAdapter());

        publishObjectsToPublishingService(changeKindByEnlistedAdapter);
        publishObjectsToPublisherServices(changeKindByEnlistedAdapter);
    }

    private void publishObjectsToPublishingService(final Map<ObjectAdapter, ChangeKind> changeKindByEnlistedAdapter) {

        if(publishingServiceIfAny == null) {
            return;
        }

        final String currentUser = userService.getUser().getName();
        final Timestamp timestamp = clockService.nowAsJavaSqlTimestamp();
        final ObjectStringifier stringifier = objectStringifier();

        for (final Map.Entry<ObjectAdapter, ChangeKind> adapterAndChange : changeKindByEnlistedAdapter.entrySet()) {
            final ObjectAdapter enlistedAdapter = adapterAndChange.getKey();
            final ChangeKind changeKind = adapterAndChange.getValue();

            publishObjectToPublishingService(
                    enlistedAdapter, changeKind, currentUser, timestamp, stringifier);
        }
    }

    private void publishObjectToPublishingService(
            final ObjectAdapter enlistedAdapter,
            final ChangeKind changeKind,
            final String currentUser,
            final Timestamp timestamp,
            final ObjectStringifier stringifier) {

        final PublishedObjectFacet publishedObjectFacet =
                enlistedAdapter.getSpecification().getFacet(PublishedObjectFacet.class);
        if(publishedObjectFacet == null) {
            return;
        }

        final PublishedObject.PayloadFactory payloadFactory = publishedObjectFacet.value();

        final RootOid enlistedAdapterOid = (RootOid) enlistedAdapter.getOid();
        final String enlistedAdapterClass = CommandUtil.targetClassNameFor(enlistedAdapter);
        final Bookmark enlistedTarget = enlistedAdapterOid.asBookmark();

        final EventMetadata metadata = newEventMetadata(
                currentUser, timestamp, changeKind, enlistedAdapterClass, enlistedTarget);

        final Object pojo = ObjectAdapter.Util.unwrap(undeletedElseEmpty(enlistedAdapter));
        final EventPayload payload = payloadFactory.payloadFor(pojo, changeKind);

        payload.withStringifier(stringifier);
        publishingServiceIfAny.publish(metadata, payload);
    }

    private void publishObjectsToPublisherServices(
            final Map<ObjectAdapter, ChangeKind> changeKindByEnlistedAdapter) {

        final Map<ObjectAdapter, ChangeKind> changeKindByPublishedAdapter =
                Maps.filterKeys(
                        changeKindByEnlistedAdapter,
                        PublishedObjectFacet.Predicates.isPublished());

        if(changeKindByPublishedAdapter.isEmpty()) {
            return;
        }

        final int numberLoaded = metricsService.numberObjectsLoaded();
        final int numberObjectPropertiesModified = changedObjectsServiceInternal.numberObjectPropertiesModified();
        final PublishedObjects publishedObjects = newPublishedObjects(numberLoaded, numberObjectPropertiesModified,
                changeKindByPublishedAdapter);

        for (PublisherService publisherService : publisherServices) {
            publisherService.publish(publishedObjects);
        }
    }

    private PublishedObjects newPublishedObjects(
            final int numberLoaded,
            final int numberObjectPropertiesModified,
            final Map<ObjectAdapter, ChangeKind> changeKindByPublishedAdapter) {

        final Command command = commandContext.getCommand();
        final UUID transactionUuid = command.getTransactionId();

        final String userName = userService.getUser().getName();
        final Timestamp timestamp = clockService.nowAsJavaSqlTimestamp();

        final Interaction interaction = interactionContext.getInteraction();

        final int nextEventSequence = interaction.next(Interaction.Sequence.INTERACTION.id());

        return new PublishedObjectsDefault(transactionUuid, nextEventSequence, userName, timestamp, numberLoaded, numberObjectPropertiesModified, changeKindByPublishedAdapter);
    }

    //endregion

    //region > publishAction

    @Programmatic
    public void publishAction(
            final Interaction.Execution execution,
            final ObjectAction objectAction,
            final IdentifiedHolder identifiedHolder,
            final ObjectAdapter targetAdapter,
            final List<ObjectAdapter> parameterAdapters,
            final ObjectAdapter resultAdapter) {

        if(suppress) {
            return;
        }
        publishActionToPublishingService(
                objectAction, identifiedHolder, targetAdapter, parameterAdapters, resultAdapter
        );

        publishToPublisherServices(execution);
    }

    private void publishActionToPublishingService(
            final ObjectAction objectAction,
            final IdentifiedHolder identifiedHolder,
            final ObjectAdapter targetAdapter,
            final List<ObjectAdapter> parameterAdapters,
            final ObjectAdapter resultAdapter) {
        if(publishingServiceIfAny == null) {
            return;
        }
        final String currentUser = userService.getUser().getName();
        final Timestamp timestamp = clockService.nowAsJavaSqlTimestamp();

        final PublishedActionFacet publishedActionFacet =
                identifiedHolder.getFacet(PublishedActionFacet.class);
        if(publishedActionFacet == null) {
            return;
        }

        final RootOid adapterOid = (RootOid) targetAdapter.getOid();
        final String oidStr = OID_MARSHALLER.marshal(adapterOid);
        final Identifier actionIdentifier = objectAction.getIdentifier();
        final String title = oidStr + ": " + actionIdentifier.toNameParmsIdentityString();

        final String actionTargetClass = CommandUtil.targetClassNameFor(targetAdapter);
        final String actionTargetAction = CommandUtil.targetMemberNameFor(objectAction);
        final Bookmark actionTarget = CommandUtil.bookmarkFor(targetAdapter);
        final String actionMemberIdentifier = CommandUtil.memberIdentifierFor(objectAction);

        final List<String> parameterNames;
        final List<Class<?>> parameterTypes;
        final Class<?> returnType;

        if(identifiedHolder instanceof FacetedMethod) {
            // should always be the case

            final FacetedMethod facetedMethod = (FacetedMethod) identifiedHolder;
            returnType = facetedMethod.getType();

            final List<FacetedMethodParameter> parameters = facetedMethod.getParameters();
            parameterNames = immutableList(Iterables.transform(parameters, FacetedMethodParameter.Functions.GET_NAME));
            parameterTypes = immutableList(Iterables.transform(parameters, FacetedMethodParameter.Functions.GET_TYPE));
        } else {
            parameterNames = null;
            parameterTypes = null;
            returnType = null;
        }

        final Interaction interaction = interactionContext.getInteraction();

        final int nextEventSequence = interaction.next(Interaction.Sequence.PUBLISHED_EVENT.id());
        final UUID transactionId = interaction.getTransactionId();
        final EventMetadata metadata = new EventMetadata(
                transactionId, nextEventSequence, EventType.ACTION_INVOCATION, currentUser, timestamp, title,
                actionTargetClass, actionTargetAction, actionTarget, actionMemberIdentifier, parameterNames,
                parameterTypes, returnType);

        final PublishedAction.PayloadFactory payloadFactory = publishedActionFacet.value();

        final ObjectStringifier stringifier = objectStringifier();

        final EventPayload payload = payloadFactory.payloadFor(
                identifiedHolder.getIdentifier(),
                ObjectAdapter.Util.unwrap(undeletedElseEmpty(targetAdapter)),
                ObjectAdapter.Util.unwrap(undeletedElseEmpty(parameterAdapters)),
                ObjectAdapter.Util.unwrap(undeletedElseEmpty(resultAdapter)));
        payload.withStringifier(stringifier);
        publishingServiceIfAny.publish(metadata, payload);
    }

    private static <T> List<T> immutableList(final Iterable<T> iterable) {
        return Collections.unmodifiableList(Lists.newArrayList(iterable));
    }

    private ObjectStringifier objectStringifier() {
        return new ObjectStringifier() {
                @Override
                public String toString(Object object) {
                    if(object == null) {
                        return null;
                    }
                    final ObjectAdapter adapter = isisSessionFactory.getCurrentSession()
                            .getPersistenceSession().adapterFor(object);
                    Oid oid = adapter.getOid();
                    return oid != null? oid.enString(): encodedValueOf(adapter);
                }
                private String encodedValueOf(ObjectAdapter adapter) {
                    EncodableFacet facet = adapter.getSpecification().getFacet(EncodableFacet.class);
                    return facet != null? facet.toEncodedString(adapter): adapter.toString();
                }
                @Override
                public String classNameOf(Object object) {
                    final ObjectAdapter adapter = getPersistenceSession().adapterFor(object);
                    final String className = adapter.getSpecification().getFullIdentifier();
                    return className;
                }
            };
    }

    private List<ObjectAdapter> undeletedElseEmpty(List<ObjectAdapter> parameters) {
        return Lists.newArrayList(Iterables.transform(parameters, notDestroyedElseEmpty()));
    }

    private ObjectAdapter undeletedElseEmpty(ObjectAdapter adapter) {
        return notDestroyedElseEmpty().apply(adapter);
    }

    private EventMetadata newEventMetadata(
            final String currentUser,
            final Timestamp timestamp,
            final ChangeKind changeKind,
            final String enlistedAdapterClass,
            final Bookmark enlistedTarget) {
        final EventType eventType = PublishingServiceInternalDefault.eventTypeFor(changeKind);

        final Interaction interaction = interactionContext.getInteraction();

        final int nextEventSequence = interaction.next(Interaction.Sequence.PUBLISHED_EVENT.id());
        final UUID transactionId = interaction.getTransactionId();
        return new EventMetadata(
                transactionId, nextEventSequence, eventType, currentUser, timestamp, enlistedTarget.toString(),
                enlistedAdapterClass, null, enlistedTarget, null, null, null, null);
    }

    private static EventType eventTypeFor(ChangeKind changeKind) {
        if(changeKind == ChangeKind.UPDATE) {
            return EventType.OBJECT_UPDATED;
        }
        if(changeKind == ChangeKind.CREATE) {
            return EventType.OBJECT_CREATED;
        }
        if(changeKind == ChangeKind.DELETE) {
            return EventType.OBJECT_DELETED;
        }
        throw new IllegalArgumentException("unknown ChangeKind '" + changeKind + "'");
    }
    //endregion

    //region > publishProperty

    @Override
    public void publishProperty(
            final Interaction.Execution execution) {

        if(suppress) {
            return;
        }

        publishToPublisherServices(execution);
    }


    //endregion

    //region > helper: publishToPublisherServices

    private void publishToPublisherServices(final Interaction.Execution<?,?> execution) {

        if(publisherServices == null || publisherServices.isEmpty()) {
            return;
        }

        for (final PublisherService publisherService : publisherServices) {
            publisherService.publish(execution);
        }
    }

    //endregion

    //region > suppress

    // this service is request scoped
    boolean suppress;

    @Programmatic
    @Override
    public <T> T withPublishingSuppressed(final Block<T> block) {
        try {
            suppress = true;
            return block.exec();
        } finally {
            suppress = false;
        }
    }

    //endregion

    //region > injected services
    @javax.inject.Inject
    private List<PublisherService> publisherServices;

    @javax.inject.Inject
    private PublishingService publishingServiceIfAny;

    @javax.inject.Inject
    private ChangedObjectsServiceInternal changedObjectsServiceInternal;

    @javax.inject.Inject
    private InteractionDtoServiceInternal interactionDtoServiceInternal;

    @javax.inject.Inject
    private CommandContext commandContext;

    @javax.inject.Inject
    private InteractionContext interactionContext;

    @javax.inject.Inject
    private ClockService clockService;

    @javax.inject.Inject
    private UserService userService;

    @javax.inject.Inject
    private MetricsService metricsService;

    @javax.inject.Inject
    private IsisSessionFactory isisSessionFactory;

    private PersistenceSession getPersistenceSession() {
        return isisSessionFactory.getCurrentSession().getPersistenceSession();
    }
    //endregion

}
