package com.atlassian.stash.internal.hook.repository;

import com.atlassian.bitbucket.hook.repository.*;
import com.atlassian.event.api.EventPublisher;
import com.atlassian.plugin.ModuleDescriptor;
import com.atlassian.plugin.Plugin;
import com.atlassian.plugin.PluginAccessor;
import com.atlassian.plugin.spring.AvailableToPlugins;
import com.atlassian.bitbucket.avatar.CacheableAvatarSupplier;
import com.atlassian.bitbucket.avatar.DelegatingCacheableAvatarSupplier;
import com.atlassian.bitbucket.avatar.SimpleAvatarSupplier;
import com.atlassian.bitbucket.event.hook.RepositoryHookDisabledEvent;
import com.atlassian.bitbucket.event.hook.RepositoryHookEnabledEvent;
import com.atlassian.bitbucket.event.hook.RepositoryHookSettingsChangedEvent;
import com.atlassian.bitbucket.validation.FormValidationException;
import com.atlassian.bitbucket.IllegalEntityStateException;
import com.atlassian.bitbucket.NoSuchEntityException;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.stash.internal.AbstractService;
import com.atlassian.stash.internal.InternalConverter;
import com.atlassian.stash.internal.annotation.Unsecured;
import com.atlassian.stash.internal.plugin.RepositoryHookModuleDescriptor;
import com.atlassian.stash.internal.plugin.ValidatorModuleDescriptor;
import com.atlassian.stash.internal.repository.InternalRepository;
import com.atlassian.stash.internal.repository.InternalRepositoryHook;
import com.atlassian.stash.internal.repository.RepositoryHookDao;
import com.atlassian.stash.internal.setting.InternalSharedLob;
import com.atlassian.stash.internal.setting.MapSettingsBuilder;
import com.atlassian.stash.internal.setting.SettingsHelper;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.bitbucket.setting.RepositorySettingsValidator;
import com.atlassian.bitbucket.setting.Settings;
import com.atlassian.bitbucket.setting.SettingsBuilder;
import com.atlassian.bitbucket.setting.SettingsValidationErrors;
import com.atlassian.bitbucket.util.Page;
import com.atlassian.bitbucket.util.PageRequest;
import com.atlassian.bitbucket.util.PageUtils;
import com.atlassian.bitbucket.validation.FormErrors;
import com.google.common.base.Predicate;
import com.google.common.collect.*;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.validation.AbstractBindingResult;
import org.springframework.validation.Errors;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.net.FileNameMap;
import java.net.URL;
import java.util.*;

import static com.atlassian.stash.internal.InternalConverter.convertToInternalRepository;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.collect.Iterables.filter;
import static com.google.common.collect.Lists.newArrayList;

/**
 * @since 2.2
 */
@AvailableToPlugins(RepositoryHookService.class)
@Service
public class DefaultRepositoryHookService extends AbstractService implements InternalRepositoryHookService {

    /**
     * The number of settings to load in a single batch
     */
    static final int PAGE_SETTINGS_LIMIT = 20;
    private static final Logger log = LoggerFactory.getLogger(DefaultRepositoryHookService.class);
    private static final String HOOK_DEFAULT_ICON = "/hooks/icon-default-hook.png";

    private static final Comparator<SimpleRepositoryHookDetails> HOOK_NAME_COMPARATOR = new Comparator<SimpleRepositoryHookDetails>() {
        @Override
        public int compare(SimpleRepositoryHookDetails details1, SimpleRepositoryHookDetails details2) {
            return details1.getName().compareTo(details2.getName());
        }
    };

    private final EventPublisher eventPublisher;
    private final FileNameMap fileNameMap;
    private final RepositoryHookDao hookDao;
    private final I18nService i18nService;
    private final PluginAccessor pluginAccessor;
    private final SettingsHelper settingsHelper;
    private final TransactionTemplate transactionTemplate;

    @Autowired
    public DefaultRepositoryHookService(EventPublisher eventPublisher, FileNameMap fileNameMap,
                                        RepositoryHookDao hookDao, I18nService i18nService,
                                        PluginAccessor pluginAccessor, SettingsHelper settingsHelper,
                                        PlatformTransactionManager transactionManager) {
        this.eventPublisher = eventPublisher;
        this.fileNameMap = fileNameMap;
        this.hookDao = hookDao;
        this.i18nService = i18nService;
        this.pluginAccessor = pluginAccessor;
        this.settingsHelper = settingsHelper;

        transactionTemplate = new TransactionTemplate(transactionManager);
        transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_NOT_SUPPORTED);
    }

    @Nonnull
    @Override
    @Unsecured("No private data is exposed by this method")
    @Transactional(propagation = Propagation.SUPPORTS)
    public SettingsBuilder createSettingsBuilder() {
        return new MapSettingsBuilder();
    }

    @Nonnull
    @Override
    @Transactional
    @PreAuthorize("hasRepositoryPermission(#repository, 'REPO_ADMIN')")
    public RepositoryHook disable(@Nonnull Repository repository, @Nonnull String hookKey) {
        InternalRepository internalRepository = InternalConverter.convertToInternalRepository(repository);

        RepositoryHook repositoryHook = setEnabled(internalRepository, hookKey, null, false);

        eventPublisher.publish(new RepositoryHookDisabledEvent(this, internalRepository, hookKey));

        return repositoryHook;
    }

    @Nonnull
    @Override
    @Transactional
    @PreAuthorize("hasRepositoryPermission(#repository, 'REPO_ADMIN')")
    public RepositoryHook enable(@Nonnull Repository repository, @Nonnull String hookKey) {
        return enableAndPublishEvent(convertToInternalRepository(repository), hookKey, null);
    }

    @Nonnull
    @Override
    @Transactional
    @PreAuthorize("hasRepositoryPermission(#repository, 'REPO_ADMIN')")
    public RepositoryHook enable(@Nonnull Repository repository, @Nonnull String hookKey, @Nonnull Settings settings)
            throws FormValidationException {
        checkNotNull(settings, "settings");

        validateSettings(repository, hookKey, settings);

        RepositoryHook repositoryHook =
                enableAndPublishEvent(convertToInternalRepository(repository), hookKey, settings);

        eventPublisher.publish(new RepositoryHookSettingsChangedEvent(this, repository, hookKey, settings));

        return repositoryHook;
    }

    @Nonnull
    @Override
    @PreAuthorize("hasRepositoryPermission(#repository, 'REPO_ADMIN')")
    public Page<RepositoryHook> findAll(@Nonnull Repository repository, @Nonnull PageRequest pageRequest) {
        checkNotNull(repository, "repository");
        checkNotNull(pageRequest, "pageRequest");

        return getRepositoryHookPage(repository, pageRequest, getAllOrdered());
    }

    @Nonnull
    @Override
    @PreAuthorize("hasRepositoryPermission(#repository, 'REPO_ADMIN')")
    public Page<RepositoryHook> findByType(@Nonnull final Repository repository, @Nonnull RepositoryHookType type,
                                           @Nonnull PageRequest pageRequest) {
        checkNotNull(repository, "repository");
        checkNotNull(type, "type");
        checkNotNull(pageRequest, "pageRequest");

        return getRepositoryHookPage(repository, pageRequest, getOrderedByType(type));
    }

    @Nonnull
    @Override
    @PreAuthorize("hasGlobalPermission('LICENSED_USER')")
    public CacheableAvatarSupplier getAvatar(@Nonnull String hookKey) {
        SimpleRepositoryHookDetails details = getHookDetailsOrFail(hookKey);
        if (details.getIconPath() == null) {
            return getDefaultAvatar();
        }

        try {
            Plugin plugin = pluginAccessor.getEnabledPluginModule(hookKey).getPlugin();
            URL resource = plugin.getResource(details.getIconPath());
            if (resource != null) {
                return getCacheableSupplier(new UrlResource(resource));
            }
        } catch (IOException e) {
            if (log.isDebugEnabled()) {
                log.warn("Error loading icon for {}", hookKey, e);
            } else {
                log.warn("Error loading icon for {}: {}", hookKey, e.getMessage());
            }
        }

        return getDefaultAvatar();
    }

    @Override
    @PreAuthorize("hasRepositoryPermission(#repository, 'REPO_ADMIN')")
    public RepositoryHook getByKey(@Nonnull Repository repository, @Nonnull String hookKey) {
        ModuleDescriptor<?> moduleDescriptor = pluginAccessor.getEnabledPluginModule(hookKey);
        if (!(moduleDescriptor instanceof RepositoryHookModuleDescriptor)) {
            return null;
        }

        SimpleRepositoryHookDetails descriptor =
                SimpleRepositoryHookDetails.FROM_MODULE_DESCRIPTOR.apply((RepositoryHookModuleDescriptor) moduleDescriptor);
        InternalRepositoryHook hook = hookDao.findByKey(convertToInternalRepository(repository), hookKey);
        return new SimpleRepositoryHook(descriptor, hook != null && hook.isEnabled(), hook != null && hook.hasSettings());
    }

    @Override
    @PreAuthorize("hasRepositoryPermission(#repository, 'REPO_ADMIN')")
    public Settings getSettings(@Nonnull Repository repository, @Nonnull String hookKey) {
        InternalRepositoryHook hook = hookDao.findByKey(convertToInternalRepository(repository), hookKey);

        return hook == null || hook.getSettings() == null ? null : settingsHelper.deserialize(hook.getSettings().getData());
    }

    @Nonnull
    @Override
    @Transactional
    @PreAuthorize("hasRepositoryPermission(#repository, 'REPO_ADMIN')")
    public Settings setSettings(@Nonnull Repository repository, @Nonnull String hookKey, @Nonnull Settings settings)
            throws FormValidationException {
        checkNotNull(settings, "settings");

        validateSettings(repository, hookKey, settings);

        InternalRepositoryHook repositoryHook =
                hookDao.findByKey(convertToInternalRepository(repository), hookKey);

        if (repositoryHook == null) {
            createHook(hookKey, repository, false, settings);
        } else {
            updateHook(repositoryHook, repositoryHook.isEnabled(), settings);
        }

        eventPublisher.publish(new RepositoryHookSettingsChangedEvent(this, repository, hookKey, settings));
        return settings;
    }

    @Override
    @Unsecured("This method is internal-only, so it needs no permission checks")
    public <T extends RepositoryHookImplementor> boolean visitEnabledHooks(@Nonnull Repository repository,
                                                                           @Nonnull Class<T> type,
                                                                           @Nonnull final RepositoryHookSettingsCallback<T> callback) {
        // Argument validation
        checkNotNull(repository, "repository");
        checkNotNull(type, "type");
        checkNotNull(callback, "callback");

        // Grab the matching module descriptors
        final Map<String, RepositoryHookModuleDescriptor> modulesByHookKey = buildDescriptorIndex(type);

        if (!modulesByHookKey.isEmpty()) {
            // We're only worried about the size of too many settings in memory
            // Partition the Modules Map to load the hooks in batches
            for (List<String> keys : Iterables.partition(modulesByHookKey.keySet(), PAGE_SETTINGS_LIMIT)) {
                // Find enabled hooks
                List<InternalRepositoryHook> enabledHooks = hookDao.findEnabledByKey(convertToInternalRepository(repository), keys);
                if (!transactionTemplate.execute(new RepositoryHookPagedTransactionCallback(callback, enabledHooks, modulesByHookKey))) {
                    return false;
                }
            }
        }

        return true;
    }

    private static <T> Predicate<RepositoryHookModuleDescriptor> isModuleOfType(final Class<T> type) {
        return new Predicate<RepositoryHookModuleDescriptor>() {

            @Override
            public boolean apply(RepositoryHookModuleDescriptor input) {
                return type.isAssignableFrom(input.getModuleClass());
            }
        };
    }

    private static Predicate<RepositoryHookModuleDescriptor> isModuleOfType(final RepositoryHookType type) {
        return new Predicate<RepositoryHookModuleDescriptor>() {

            @Override
            public boolean apply(RepositoryHookModuleDescriptor descriptor) {
                return type.equals(descriptor.getType());
            }
        };
    }

    private <T extends RepositoryHookImplementor> Map<String, RepositoryHookModuleDescriptor> buildDescriptorIndex(Class<T> type) {
        return Maps.uniqueIndex(filter(getDescriptors(), isModuleOfType(type)), ModuleDescriptor::getCompleteKey);
    }

    private void createHook(String hookKey, Repository repository, boolean enabled,
                            Settings settings) {
        InternalRepositoryHook.Builder hookBuilder = new InternalRepositoryHook.Builder()
                .hookKey(hookKey)
                .repository(convertToInternalRepository(repository))
                .enabled(enabled);

        if (settings != null) {
            hookBuilder.settings(settingsHelper.serialize(settings));
        }

        hookDao.create(hookBuilder.build());
    }

    private RepositoryHook enableAndPublishEvent(InternalRepository internalRepository, String hookKey,
                                                 Settings settings) {
        RepositoryHook repositoryHook = setEnabled(internalRepository, hookKey, settings, true);

        eventPublisher.publish(new RepositoryHookEnabledEvent(this, internalRepository, hookKey));

        return repositoryHook;
    }

    private List<SimpleRepositoryHookDetails> getAllOrdered() {
        return getOrdered(Iterables.transform(getDescriptors(), SimpleRepositoryHookDetails.FROM_MODULE_DESCRIPTOR));
    }

    private Iterable<SimpleRepositoryHookDetails> getByType(RepositoryHookType type) {
        return Iterables.transform(filter(getDescriptors(), isModuleOfType(type)), SimpleRepositoryHookDetails.FROM_MODULE_DESCRIPTOR);
    }

    private CacheableAvatarSupplier getCacheableSupplier(Resource resource) throws IOException {
        return new DelegatingCacheableAvatarSupplier(new SimpleAvatarSupplier(
                fileNameMap.getContentTypeFor(resource.getFilename()),
                resource.getInputStream()
        ), resource.lastModified());
    }

    private CacheableAvatarSupplier getDefaultAvatar() {
        try {
            return getCacheableSupplier(new UrlResource(getClass().getResource(HOOK_DEFAULT_ICON)));
        } catch (IOException e) {
            // Should never happen and we need to know
            throw new RuntimeException(e);
        }
    }

    private Iterable<RepositoryHookModuleDescriptor> getDescriptors() {
        return pluginAccessor.getEnabledModuleDescriptorsByClass(RepositoryHookModuleDescriptor.class);
    }

    private SimpleRepositoryHookDetails getHookDetailsOrFail(String hookKey) {
        return SimpleRepositoryHookDetails.FROM_MODULE_DESCRIPTOR.apply(getModuleDescriptorOrFail(hookKey));
    }

    private Set<String> getHookKeys(List<SimpleRepositoryHookDetails> hookDetails) {
        return Maps.uniqueIndex(hookDetails, RepositoryHookDetails::getKey).keySet();
    }

    private Map<String, InternalRepositoryHook> getHooksForRepository(InternalRepository repository, Set<String> hookKeys) {
        return Maps.uniqueIndex(hookDao.findEnabledByKey(repository, hookKeys), InternalRepositoryHook::getHookKey);
    }

    private RepositoryHookModuleDescriptor getModuleDescriptorOrFail(String hookKey) {
        ModuleDescriptor<?> moduleDescriptor = pluginAccessor.getEnabledPluginModule(hookKey);
        if (!(moduleDescriptor instanceof RepositoryHookModuleDescriptor)) {
            throw new NoSuchEntityException(
                    i18nService.createKeyedMessage("bitbucket.service.repository.hook.nosuchhook", hookKey));
        }
        return (RepositoryHookModuleDescriptor) moduleDescriptor;
    }

    private List<SimpleRepositoryHookDetails> getOrdered(Iterable<SimpleRepositoryHookDetails> hooks) {
        List<SimpleRepositoryHookDetails> hooksAsList = newArrayList(hooks);
        Collections.sort(hooksAsList, HOOK_NAME_COMPARATOR);
        return hooksAsList;
    }

    private List<SimpleRepositoryHookDetails> getOrderedByType(RepositoryHookType type) {
        return getOrdered(getByType(type));
    }

    private Page<RepositoryHook> getRepositoryHookPage(Repository repository, PageRequest pageRequest,
                                                       List<SimpleRepositoryHookDetails> hookDetails) {
        if (pageRequest.getStart() > hookDetails.size() || hookDetails.isEmpty()) {
            return PageUtils.createEmptyPage(pageRequest);
        }

        final Map<String, InternalRepositoryHook> hooksByKey =
                getHooksForRepository(convertToInternalRepository(repository), getHookKeys(hookDetails));

        Page<SimpleRepositoryHookDetails> hookPage =
                PageUtils.createPage(hookDetails.subList(pageRequest.getStart(), hookDetails.size()), pageRequest);

        return hookPage.transform(details -> {
            InternalRepositoryHook repositoryHook = hooksByKey.get(details.getKey());

            boolean isEnabled = false, hasSettings = false;
            if (repositoryHook != null) {
                isEnabled = repositoryHook.isEnabled();
                hasSettings = repositoryHook.hasSettings();
            }

            return new SimpleRepositoryHook(details, isEnabled, hasSettings);
        });
    }

    private boolean requiresSettings(SimpleRepositoryHookDetails hookDetails) {
        return StringUtils.isNotBlank(hookDetails.getConfigFormKey());
    }

    private RepositoryHook setEnabled(InternalRepository internalRepository, String hookKey,
                                      Settings settings, boolean enabling) {
        checkNotNull(internalRepository, "repository");
        checkNotNull(hookKey, "hookKey");

        SimpleRepositoryHookDetails hookDetails = getHookDetailsOrFail(hookKey);

        InternalRepositoryHook hook = hookDao.findByKey(internalRepository, hookKey);
        boolean hasSettings = hook != null && hook.getSettings() != null || settings != null;

        if (enabling && requiresSettings(hookDetails) && !hasSettings) {
            //don't let hooks be enabled when they require settings and have none stored
            throw new IllegalEntityStateException(
                    i18nService.createKeyedMessage("bitbucket.service.hook.notconfigured", hookKey));
        }

        if (hook == null) {
            createHook(hookKey, internalRepository, enabling, settings);
        } else if (shouldUpdateHook(hook, settings, enabling)) {
            updateHook(hook, enabling, settings);
        }

        return new SimpleRepositoryHook(hookDetails, enabling, hasSettings);
    }

    private boolean shouldUpdateHook(InternalRepositoryHook hook, Settings settings, boolean enabling) {
        return hook.isEnabled() != enabling || settings != null;
    }

    private void updateHook(InternalRepositoryHook hook, boolean enabled, Settings settings) {
        InternalRepositoryHook.Builder hookBuilder = new InternalRepositoryHook.Builder(hook)
                .enabled(enabled);

        if (settings != null) {
            hookBuilder.settings(settingsHelper.serialize(settings));
        }

        hookDao.update(hookBuilder.build());
    }

    private void validateSettings(Repository repository, String hookKey, Settings settings)
            throws FormValidationException {
        ModuleDescriptor<?> pluginModule = pluginAccessor.getEnabledPluginModule(hookKey);

        checkArgument(ValidatorModuleDescriptor.class.isInstance(pluginModule),
                "Module '" + hookKey + "' does not implement " + ValidatorModuleDescriptor.class.getName());

        RepositorySettingsValidator validator = ((ValidatorModuleDescriptor) pluginModule).getValidator();
        if (validator != null) {
            SimpleSettingsValidationErrors validationErrors = new SimpleSettingsValidationErrors(new SettingsErrors(settings));
            validator.validate(settings, validationErrors, repository);
            if (!validationErrors.isEmpty()) {
                throw new FormValidationException(i18nService.createKeyedMessage("bitbucket.repository.setting.validation.error"),
                        validationErrors);
            }
        }
    }

    private static class SettingsErrors extends AbstractBindingResult {

        private final Settings settings;

        public SettingsErrors(Settings settings) {
            super("__OBJECT__"); // This is not used
            this.settings = settings;
        }

        @Override
        public Object getTarget() {
            return settings;
        }

        @Override
        protected Object getActualFieldValue(String field) {
            // Calling getString() will throw ClassCastException on non-strings
            return settings.asMap().get(field);
        }
    }

    private static class SimpleSettingsValidationErrors implements SettingsValidationErrors, FormErrors {

        private final Errors errors;

        private SimpleSettingsValidationErrors(Errors errors) {
            this.errors = errors;
        }

        @Override
        public void addFieldError(String fieldName, String errorMessage) {
            errors.rejectValue(fieldName, errorMessage, errorMessage);
        }

        @Override
        public void addFormError(String errorMessage) {
            errors.reject(errorMessage, errorMessage);
        }

        @Nonnull
        @Override
        public Collection<String> getErrors(@Nonnull String field) {
            return Collections2.transform(errors.getFieldErrors(field), input -> input.getDefaultMessage());
        }

        @Nonnull
        @Override
        public Map<String, Collection<String>> getFieldErrors() {
            Multimap<String, String> errorMap = HashMultimap.create();
            // We only store fields at the moment
            for (FieldError message : errors.getFieldErrors()) {
                errorMap.put(message.getField(), message.getDefaultMessage());
            }
            return errorMap.asMap();
        }

        @Nonnull
        @Override
        public Collection<String> getFormErrors() {
            return Collections2.transform(errors.getGlobalErrors(), input -> input.getDefaultMessage());
        }

        @Override
        public boolean isEmpty() {
            return !errors.hasErrors();
        }
    }

    private class RepositoryHookPagedTransactionCallback implements TransactionCallback<Boolean> {
        private final List<InternalRepositoryHook> repositoryHooks;
        private final Map<String, RepositoryHookModuleDescriptor> enabledHooks;
        private RepositoryHookSettingsCallback callback;

        private RepositoryHookPagedTransactionCallback(@Nonnull RepositoryHookSettingsCallback callback,
                                                       @Nonnull List<InternalRepositoryHook> repositoryHooks,
                                                       @Nonnull Map<String, RepositoryHookModuleDescriptor> enabledHooks) {

            checkNotNull(callback, "callback");
            checkNotNull(repositoryHooks, "repositoryHooks");
            checkNotNull(enabledHooks, "enabledHooks");

            this.callback = callback;
            this.repositoryHooks = repositoryHooks;
            this.enabledHooks = enabledHooks;
        }

        @Override
        public Boolean doInTransaction(TransactionStatus txStatus) {
            for (InternalRepositoryHook repositoryHook : repositoryHooks) {
                String hookKey = repositoryHook.getHookKey();

                Settings settings = firstNonNull(toSettings(repositoryHook.getSettings()), EmptyHookSettings.INSTANCE);
                if (!callback.visit(hookKey, enabledHooks.get(hookKey).getModule(), settings)) {
                    // Break early
                    return false;
                }
            }

            return true;
        }

        private Settings toSettings(InternalSharedLob settingsLob) {
            return settingsLob == null ? null : settingsHelper.deserialize(settingsLob.getData());
        }
    }
}
