package com.atlassian.stash.internal.home;

import com.atlassian.event.api.EventListener;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.johnson.event.*;
import com.atlassian.plugin.PluginAccessor;
import com.atlassian.plugin.event.events.PluginFrameworkStartedEvent;
import com.atlassian.bitbucket.Product;
import com.atlassian.bitbucket.concurrent.LockService;
import com.atlassian.bitbucket.ServiceException;
import com.atlassian.bitbucket.help.HelpPathService;
import com.atlassian.bitbucket.home.HomeUpdate;
import com.atlassian.bitbucket.home.HomeUpdateHandler;
import com.atlassian.bitbucket.home.HomeUpdateHandlerModuleDescriptor;
import com.atlassian.bitbucket.home.SimpleHomeUpdate;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.stash.internal.server.InternalApplicationPropertiesService;
import com.google.common.base.Function;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import org.apache.commons.collections.CollectionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.locks.Lock;

/**
 * On each system startup, compares the current {@link InternalApplicationPropertiesService#getSharedHomeDir() home directory}
 * with the {@link InternalApplicationPropertiesService#getLastSharedHomeDir() last home directory}. If the two do not match,
 * all registered {@link HomeUpdateHandler}s are invoked to allow the system to be updated with the new home directory.
 * <p>
 * The system is locked by Johnson while handlers are being invoked. If any handler fails, to ensure the system is not
 * used while in an inconsistent state, the lock will not be released.
 *
 * @since 2.4
 */
@Component
@DependsOn("homeLockAcquirer")
public class HomeTracker {

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

    private final EventPublisher eventPublisher;
    private final HelpPathService helpPathService;
    private final I18nService i18nService;
    private final LockService lockService;
    private final InternalApplicationPropertiesService propertiesService;

    @Autowired
    public HomeTracker(EventPublisher eventPublisher, HelpPathService helpPathService,
                       I18nService i18nService, LockService lockService, InternalApplicationPropertiesService propertiesService) {

        this.eventPublisher = eventPublisher;
        this.helpPathService = helpPathService;
        this.i18nService = i18nService;
        this.lockService = lockService;
        this.propertiesService = propertiesService;
    }

    @EventListener
    public void onPluginFrameworkStarted(PluginFrameworkStartedEvent event) {
        Lock lock = lockService.getLock(HomeTracker.class.getName() + ".sharedHome");
        lock.lock();
        try {
            checkLastHomeDir(event);
        } finally {
            lock.unlock();
        }
    }

    private void checkLastHomeDir(PluginFrameworkStartedEvent event) {
        String sharedHomeDir = propertiesService.getSharedHomeDir().getAbsolutePath();

        String lastSharedHomeDir = propertiesService.getLastSharedHomeDir();
        if (lastSharedHomeDir == null) {
            //This is the first time the system has been started while tracking its home directory. Just update the
            //value, but don't raise any events. This allows an edge case where the system has been upgraded _and_
            //relocated in a single shot, but, since older versions of the system had no location-aware functionality,
            //it should not matter.
            log.info("Capturing benchmark shared home directory: {}", sharedHomeDir);
            propertiesService.setLastSharedHomeDir(sharedHomeDir);
        } else if (!lastSharedHomeDir.equals(sharedHomeDir)) {
            //The home directory has moved. Components may need to update themselves based on the change, so an SPI has
            //been provided to allow them to register for notification. Invoke any registered handlers and, if none of
            //them fail, update the last home directory in the database.
            log.warn("The shared home directory has been moved from {} to {}", lastSharedHomeDir, sharedHomeDir);
            if (invokeHandlers(event.getPluginAccessor(), sharedHomeDir, lastSharedHomeDir)) {
                log.info("Updating benchmark shared home directory: {}", sharedHomeDir);
                propertiesService.setLastSharedHomeDir(sharedHomeDir);
            } else {
                log.warn("Not updating benchmark shared home directory. An update handler failed. Subsequent restarts " +
                                "will attempt to run the handlers again, unless the home directory is moved back to {}",
                        lastSharedHomeDir
                );
            }
        }
    }

    private boolean invokeHandlers(PluginAccessor pluginAccessor, String newHomeDir, String oldHomeDir) {
        // Note: The list returned by getEnabledModuleDescriptiorsByClass is Immutable, so we copy it
        // to allow us to sort it later
        List<HomeUpdateHandlerModuleDescriptor> descriptors = new ArrayList<>(
                pluginAccessor.getEnabledModuleDescriptorsByClass(HomeUpdateHandlerModuleDescriptor.class));
        if (CollectionUtils.isEmpty(descriptors)) {
            //If no handlers are registered, don't bother locking the application (since the git plugin is bundled
            //and registers a handler, this should never happen)
            return true;
        }
        // Must duplicate list (copy list items, Immutable Lists cannot be sorted
        Collections.sort(descriptors);

        //Before invoking handlers, to ensure the system is not used while it may be in a transitional state, lock
        //access with Johnson. If all handlers complete successfully, this lock will be removed later
        String helpTitle = helpPathService.getPageTitle("bitbucket.help.home.directory");
        String helpUrl = helpPathService.getPageUrl("bitbucket.help.home.directory");
        String message = i18nService.getMessage("bitbucket.shared.home.updated", Product.NAME, helpTitle, helpUrl);
        Event lock = new Event(EventType.get("home-updated"), message, EventLevel.get(EventLevel.WARNING));
        eventPublisher.publish(new AddEvent(this, lock));

        HomeUpdate update = new SimpleHomeUpdate.Builder().newDir(newHomeDir).oldDir(oldHomeDir).build();

        log.info("Invoking {} handler(s) after shared home directory update", descriptors.size());
        List<HomeUpdateHandler> handlers = Lists.transform(descriptors, new Function<HomeUpdateHandlerModuleDescriptor, HomeUpdateHandler>() {

            @Override
            public HomeUpdateHandler apply(HomeUpdateHandlerModuleDescriptor descriptor) {
                return new DelegatingHomeUpdateHandler(descriptor);
            }
        });
        List<HomeUpdateHandler> successful = Lists.newArrayListWithCapacity(handlers.size());
        for (HomeUpdateHandler handler : handlers) {
            try {
                handler.apply(update);

                successful.add(handler);
            } catch (RuntimeException e) {
                //The exception is logged inside DelegatingHomeUpdateHandler. This catch block only applies handling
                String reason = e instanceof ServiceException ? e.getLocalizedMessage() : e.getMessage();

                lock.setDesc(i18nService.getMessage("bitbucket.shared.home.updatefailed", Product.NAME, reason, helpTitle, helpUrl));
                lock.setLevel(EventLevel.get(EventLevel.FATAL));

                Collections.reverse(successful); //Revert handlers in reverse order
                for (HomeUpdateHandler revert : successful) {
                    //Any exception thrown is handled inside DelegatingHomeUpdateHandler
                    revert.rollback(update);
                }

                return false;
            }
        }

        log.info("{} handler(s) have applied the home directory update successfully", handlers.size());
        eventPublisher.publish(new RemoveEvent(this, lock));

        return true;
    }

    /**
     * Wraps a {@link HomeUpdateHandler} and the {@link HomeUpdateHandlerModuleDescriptor descriptor} that created it
     * and applies logging and exception handling. The primary goal for the logging done by this class is to link the
     * handler with its module key, to hopefully facilitate support requests if anything goes wrong.
     */
    private static class DelegatingHomeUpdateHandler implements HomeUpdateHandler {

        private final HomeUpdateHandlerModuleDescriptor descriptor;
        private final HomeUpdateHandler handler;

        private DelegatingHomeUpdateHandler(HomeUpdateHandlerModuleDescriptor descriptor) {
            this.descriptor = descriptor;

            handler = descriptor.getModule();
        }

        @Override
        public void apply(HomeUpdate update) {
            log.debug("{}: Applying home update", descriptor.getCompleteKey());
            try {
                handler.apply(update);
            } catch (RuntimeException e) {
                log.error(descriptor.getCompleteKey() + ": Failed to apply home update", e);

                //Propagate the exception to the HomeTracker to allow rollback to be called on any handlers that
                //were applied successfully before this one failed
                throw e;
            }
        }

        @Override
        public void rollback(HomeUpdate update) {
            log.debug("{}: Rolling back successful home update", descriptor.getCompleteKey());
            try {
                handler.rollback(update);
            } catch (RuntimeException e) {
                log.error(descriptor.getCompleteKey() + ": Failed to rollback the home update after another " +
                        "handler failed. This may have left some home directory contents in an inconsistent " +
                        "state, with a mixture of references to the old and new locations.", e);

                //There is no reason to propagate this exception to the HomeTracker; the system can't do anything
                //about it, and the exception from apply that triggered rollback to be called will already prevent
                //the system from being unlocked
            }
        }
    }

}
