package com.atlassian.stash.internal.event;

import com.atlassian.bitbucket.auth.AuthenticationContext;
import com.atlassian.bitbucket.event.ApplicationEvent;
import com.atlassian.bitbucket.event.annotation.TransactionAware;
import com.atlassian.crowd.event.directory.DirectoryUpdatedEvent;
import com.atlassian.crowd.event.group.GroupDeletedEvent;
import com.atlassian.crowd.event.group.GroupMembershipCreatedEvent;
import com.atlassian.crowd.event.group.GroupMembershipDeletedEvent;
import com.atlassian.crowd.event.user.UserDeletedEvent;
import com.atlassian.crowd.event.user.UserUpdatedEvent;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.stash.internal.spring.TransactionSynchronizer;
import com.google.common.collect.ImmutableMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
import org.springframework.util.ReflectionUtils;

import javax.annotation.Nonnull;
import java.lang.reflect.Field;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;

import static com.google.common.base.Preconditions.checkNotNull;

/**
 * Implementation of {@link com.atlassian.event.api.EventPublisher EventPublisher} that extends the default
 * atlassian-events implementation in a number of areas:
 * <ul>
 *     <li>sets the {@link ApplicationEvent#user} field with the current user when event is published</li>
 *     <li>
 *         adds support for {@link TransactionAware} annotations on events and ensures that event publishing is
 *         postponed until the configured transaction phase is reached.
 *      </li>
 *      <li>tracks the total number of events that have been published for monitoring of event processing</li>
 * </ul>
 */
public class TransactionAwareEventPublisher implements EventPublisher {

    private static final Map<Class, TransactionAware.When> TRANSACTION_AWARE_OVERRIDES =
            ImmutableMap.<Class, TransactionAware.When>builder()
                    .put(DirectoryUpdatedEvent.class, TransactionAware.When.AFTER_COMMIT)
                    .put(GroupDeletedEvent.class, TransactionAware.When.AFTER_COMMIT)
                    .put(GroupMembershipCreatedEvent.class, TransactionAware.When.AFTER_COMMIT)
                    .put(GroupMembershipDeletedEvent.class, TransactionAware.When.AFTER_COMMIT)
                    .put(UserDeletedEvent.class, TransactionAware.When.AFTER_COMMIT)
                    .put(UserUpdatedEvent.class, TransactionAware.When.AFTER_COMMIT)
                    .build();

    private static final Logger log = LoggerFactory.getLogger(TransactionAwareEventPublisher.class);

    private final AuthenticationContext authenticationContext;
    private final EventPublisher delegate;
    private final AtomicLong eventCount;
    private final TransactionSynchronizer synchronizer;
    private final Field userField;

    public TransactionAwareEventPublisher(AuthenticationContext authenticationContext, EventPublisher delegate,
                                          TransactionSynchronizer synchronizer) {
        this.authenticationContext = authenticationContext;
        this.delegate = delegate;
        this.synchronizer = synchronizer;

        eventCount = new AtomicLong();
        userField = getField("user");
    }

    public long getPublishedEventCount() {
        return eventCount.get();
    }

    /**
     * Publishes the event after first setting standard fields, such as the user triggering the event.
     * <p>
     * If a transaction is active, transaction synchronisation is available and the provided {@code event} is
     * {@link TransactionAware}, event publishing will be deferred until the requested
     * {@link TransactionAware.When publish point}.
     *
     * @param event the event to publish
     */
    @Override
    public void publish(@Nonnull Object event) {
        setFields(checkNotNull(event, "event"));

        //If this event is transaction-aware (typically meaning it includes data that is somehow related to a
        //transaction, such as Hibernate entities) and we're in a transaction, don't actually raise the event
        //until the desired publishing point (after commit, or after completion)
        TransactionAware.When publishWhen = getTransactionConfiguration(event.getClass());
        if (publishWhen != null && publishWhen != TransactionAware.When.IMMEDIATE) {
            if (synchronizer.register(createPublisher(publishWhen, event))) {
                log.debug("Deferring publishing for {} until {}", event.getClass().getSimpleName(), publishWhen);
                return;
            }
        }

        internalPublish(event);
    }

    @Override
    public void register(Object listener) {
        delegate.register(listener);
    }

    @Override
    public void unregister(Object listener) {
        delegate.unregister(listener);
    }

    @Override
    public void unregisterAll() {
        delegate.unregisterAll();
    }

    protected void setFields(Object event) {
        if (event instanceof ApplicationEvent) {
            if (authenticationContext != null) {
                ReflectionUtils.setField(userField, event, authenticationContext.getCurrentUser());
            }
        }
    }

    protected static Field getField(String fieldName) {
        Field field = ReflectionUtils.findField(ApplicationEvent.class, fieldName);
        ReflectionUtils.makeAccessible(field);

        return field;
    }

    private static TransactionAware.When getTransactionConfiguration(Class eventClass) {
        TransactionAware.When publishWhen = TRANSACTION_AWARE_OVERRIDES.get(eventClass);
        if (publishWhen == null) {
            TransactionAware annotation = AnnotationUtils.findAnnotation(eventClass, TransactionAware.class);
            if (annotation != null) {
                publishWhen = annotation.value();
            }
        }
        return publishWhen;
    }

    private TransactionSynchronization createPublisher(TransactionAware.When publishWhen, Object event) {
        switch (publishWhen) {
            case AFTER_COMMIT:
                return new AfterCommitPublisher(event);
            case AFTER_COMPLETION:
                return new AfterCompletionPublisher(event);
        }
        throw new IllegalArgumentException(publishWhen + " is not a known publishing point");
    }

    private void internalPublish(Object event) {
        eventCount.incrementAndGet();
        delegate.publish(event);
    }

    /**
     * Publishes the event provided during construction after the transaction commits.
     * <p>
     * <b>Warning</b>: If the transaction does not commit, most likely because it failed and was rolled back, the
     * event will <i>never</i> be published. It is assumed that this is the desired behaviour based on the event
     * was annotated.
     */
    private class AfterCommitPublisher extends TransactionSynchronizationAdapter {

        private final Object event;

        public AfterCommitPublisher(Object event) {
            this.event = event;
        }

        @Override
        public void afterCompletion(int status) {
            if (status == TransactionSynchronization.STATUS_COMMITTED) {
                log.debug("Publishing {} after commit", event.getClass().getSimpleName());
                internalPublish(event);
            } else {
                log.trace("Discarding {}; the transaction was not committed (Status: {})",
                        event.getClass().getSimpleName(), status);
            }
        }
    }

    /**
     * Publishes the event provided during construction after the transaction completes.
     * <p>
     * Unlike the {@link AfterCommitPublisher}, this publisher will <i>always</i> publish the event, whether the
     * transaction commits or is rolled back (or ends with some other, unknown status).
     */
    private class AfterCompletionPublisher extends TransactionSynchronizationAdapter {

        private final Object event;

        public AfterCompletionPublisher(Object event) {
            this.event = event;
        }

        @Override
        public void afterCompletion(int status) {
            log.debug("Publishing {} after completion (Status: {})", event.getClass().getSimpleName(), status);
            internalPublish(event);
        }
    }
}
