package com.atlassian.stash.internal.hibernate;

import com.atlassian.crowd.embedded.api.CrowdService;
import com.atlassian.crowd.embedded.api.User;
import com.atlassian.stash.internal.user.InternalApplicationUser;
import com.atlassian.stash.internal.user.InternalNormalUser;
import org.hibernate.ObjectNotFoundException;
import org.hibernate.engine.spi.EntityKey;
import org.hibernate.event.internal.DefaultLoadEventListener;
import org.hibernate.event.spi.LoadEvent;
import org.hibernate.event.spi.PostLoadEvent;
import org.hibernate.event.spi.PostLoadEventListener;
import org.hibernate.persister.entity.EntityPersister;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;

/**
 * Extends Hibernate's {@code DefaultLoadEventListener} to initialise the backing Crowd {@code User} whenever an
 * {@link InternalApplicationUser} is loaded. This ensures the user's display name and e-mail address are always available
 * throughout the codebase without relying on redundant, easy-to-overlook initialisation code throughout the system.
 * <p>
 * Note: This class is both a {@code LoadEventListener} and a {@code PostLoadEventListener}. This is because Hibernate,
 * unhappily, does not invoke {@code LoadEventListener} instances for entities it loads from criteria queries. It does
 * invoke {@code PostLoadEventListener} instances, though, so we catch {@link InternalApplicationUser}s returned from queries
 * that way instead.
 * <p>
 * Warning: This class cannot have any direct dependencies that involve Hibernate. For example, it cannot depend on
 * the {@code CrowdService} directly, because this class must be instantiated <i>before</i> the {@code SessionFactory}
 * can be constructed. Hence, any dependency that involves Hibernate will cause a circular dependency.
 */
public class CrowdAwareLoadListener
        extends DefaultLoadEventListener
        implements ApplicationContextAware, PostLoadEventListener {

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

    private final String crowdServiceBeanName;
    private final Object lock;

    private ApplicationContext applicationContext;
    private volatile CrowdService crowdService;

    public CrowdAwareLoadListener(String crowdServiceBeanName) {
        this.crowdServiceBeanName = crowdServiceBeanName;

        lock = new Object();
    }

    /**
     * If the {@code event} is for the post-load of an {@link InternalApplicationUser}, loads the backing Crowd {@code User}
     * if it has not already been set.
     * <p>
     * This catches paths where an {@link InternalApplicationUser} is loaded by:
     * <ul>
     *     <li>{@code Criteria} API queries</li>
     * </ul>
     *
     * @param event the event containing the entity which has been loaded
     */
    @Override
    public void onPostLoad(PostLoadEvent event) {
        setCrowdUserIfNecessary(event.getEntity());
    }

    /**
     * Receives the {@code ApplicationContext} in which the bean for this listener is being instantiated.
     * <p>
     * <b>Warning:</b> This listener's {@link #onPostLoad(PostLoadEvent) post-load} behaviours may be triggered
     * <i>before</i> the {@code ApplicationContext} is refreshed. That means this class cannot simply implement
     * {@code ApplicationListener&lt;ContextRefreshedEvent&gt;} and key off of that. Instead, the best it can do
     * is to defer trying to load the {@code CrowdService} bean from the context it receives here until the first
     * {@link #load(LoadEvent, EntityPersister, EntityKey, LoadType) load} or {@link #onPostLoad(PostLoadEvent)
     * post-load} happens.
     *
     * @param applicationContext the application context
     */
    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    /**
     * Invokes the overridden superclass behaviour and then, for {@link InternalApplicationUser}s, loads the backing Crowd
     * {@code User} if it has not already been set.
     * <p>
     * This catches paths where an {@link InternalApplicationUser} is loaded by:
     * <ul>
     *     <li>{@code Session.get}</li>
     *     <li>{@code Session.load}</li>
     *     <li>Associations, such as the relationship between an {@code InternalRepositoryAccess} and its user</li>
     *     <li>HQL queries (rumour has it; may not be true)</li>
     * </ul>
     *
     * @param event     ignored
     * @param persister ignored
     * @param keyToLoad ignored
     * @param options   ignored
     * @return the loaded instance
     */
    @Override
    protected Object load(LoadEvent event, EntityPersister persister, EntityKey keyToLoad, LoadType options) {
        Object loaded = super.load(event, persister, keyToLoad, options);
        setCrowdUserIfNecessary(loaded);

        return loaded;
    }

    /**
     * Loads the {@code CrowdService} bean using the name that was provided when the listener was constructed, if it
     * has not already been loaded, and then uses it to retrieve the backing Crowd {@code User} associated with the
     * specified username.
     *
     * @param username the username to load the user for
     */
    private User getCrowdUser(String username) {
        //We need this check because {@link InternalNormalUser#getUsername()} can return null, when a collection of
        //{@link InternalApplicationUser} is initialised, {@see InternalPullRequestReviewersUpdatedActivity}
        if (username == null) {
            return null;
        }

        if (crowdService == null) {
            synchronized (lock) {
                if (crowdService == null) {
                    crowdService = applicationContext.getBean(crowdServiceBeanName, CrowdService.class);
                }
            }
        }

        try {
            return crowdService.getUser(username);
        } catch (ObjectNotFoundException e) {
            // A very unlikely race condition but it can happen. In which case we should handle it
            // gracefully and assume it means the user doesn't exist.
            log.debug("Loading the associated crowd user with name '{}' failed", username, e);
            return null;
        }
    }

    /**
     * If the provided entity is a {@link InternalApplicationUser}, {@link #getCrowdUser(String) retrieves} and sets the
     * backing Crowd {@code User} if it has not already {@link InternalNormalUser#isCrowdBacked() been set}.
     *
     * @param loaded the loaded (or post-loaded) object
     */
    private void setCrowdUserIfNecessary(Object loaded) {
        if (loaded instanceof InternalNormalUser) {
            InternalNormalUser user = (InternalNormalUser) loaded;
            if (!user.isCrowdBacked()) {
                user.setBackingCrowdUser(getCrowdUser(user.getUsername()));
            }
        }
    }
}
