package com.atlassian.stash.internal.bootstrap;

import com.atlassian.bitbucket.permission.Permission;
import com.atlassian.bitbucket.server.ApplicationMode;
import com.atlassian.bitbucket.util.PageUtils;
import com.atlassian.bitbucket.util.UncheckedOperation;
import com.atlassian.crowd.directory.InternalDirectory;
import com.atlassian.crowd.embedded.api.Directory;
import com.atlassian.crowd.embedded.api.DirectoryType;
import com.atlassian.crowd.embedded.api.OperationType;
import com.atlassian.crowd.embedded.api.PasswordCredential;
import com.atlassian.crowd.exception.*;
import com.atlassian.crowd.manager.application.ApplicationManager;
import com.atlassian.crowd.manager.directory.DirectoryManager;
import com.atlassian.crowd.model.application.Application;
import com.atlassian.crowd.model.application.ApplicationImpl;
import com.atlassian.crowd.model.application.ApplicationType;
import com.atlassian.crowd.model.directory.DirectoryImpl;
import com.atlassian.crowd.model.group.Group;
import com.atlassian.crowd.model.group.GroupTemplate;
import com.atlassian.crowd.model.user.UserTemplate;
import com.atlassian.crowd.password.factory.PasswordEncoderFactory;
import com.atlassian.stash.internal.ApplicationConstants;
import com.atlassian.stash.internal.config.ConfigurationService;
import com.atlassian.stash.internal.config.RemoveSetupConfigurationRequest;
import com.atlassian.stash.internal.license.DualLicense;
import com.atlassian.stash.internal.license.InternalLicenseService;
import com.atlassian.stash.internal.license.LicenseHelper;
import com.atlassian.stash.internal.profiles.Production;
import com.atlassian.stash.internal.server.ApplicationProperty;
import com.atlassian.stash.internal.server.ApplicationPropertyDao;
import com.atlassian.stash.internal.server.InternalApplicationPropertiesService;
import com.atlassian.stash.internal.user.GlobalPermissionDao;
import com.atlassian.stash.internal.user.InternalGlobalPermission;
import com.atlassian.stash.internal.user.InternalNormalUser;
import com.atlassian.stash.internal.user.StashUserDao;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Sets;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

/**
 * Loads the application properties for the automated setup process and updates the database
 * accordingly. For more information see https://extranet.atlassian.com/display/BSERV/Automated+Application+Setup
 * <p>
 * Note the linear, fail fast behavior of the setup process. This makes it relatively easy to re-run the process and
 * try everything again without worrying about potentially complex inter-dependencies.
 *
 * @since 3.8
 */
@Production
@Component("bootstrapOperation")
public class BootstrapOperation implements UncheckedOperation<Void> {
    /**
     * These two properties are used by the mirroring plugin to configure upstream. Must be in sync with the mirror
     * plugin.
     */
    @VisibleForTesting
    static final String KEY_MIRROR_UPSTREAM = "plugin.mirroring.upstream.url";
    @VisibleForTesting
    static final String KEY_MIRROR_TYPE = "plugin.mirroring.upstream.type";
    @VisibleForTesting
    static final String VALUE_CLOUD_MIRROR = "cloud";
    @VisibleForTesting
    static final String VALUE_SERVER_MIRROR = "server";

    //An expired (2001-01-01) commercial license for 1 user registered to Atlassian Pty Ltd
    static final String LICENSE_MIRROR =
            "AAABIg0ODAoPeNp9kE1rwzAMhu/9FYadU5KMtKNgWD9y6Javtd3uaqquhsQJkh2Wf7+QuIfuMDAYp\n" +
                    "Pd9JL1PaaPFm9UiiESwWEX+KnoRm+NJhH4QzdgA3+ZxB5UFoxotr1AxuvIOuSTVjvX4p1WEF1GpE\n" +
                    "jWjuDYk7PCfe1EroobYmbaEI2kHBmXo+77nB8O7dxttoDQZ1ChTVd4AK3E09lKDdooUlDaoQZc4z\n" +
                    "uzvoOABNEBUh9KQxUd0PAAqWfMEfQVTAbMCPS+b2imT6YZT3+K4yDZP0/iw3a+T2RGpQ9rv5OY9z\n" +
                    "L3nZfbhhUlx8L4Wy7VzF0BGI41OV8rpG7TiKcH1faIoTC8Sc3GizNZnpPz6yUgsg8dd/ju1sDQEx\n" +
                    "fg30V9DOaGBMC0CFHdPkXYEvhWZq+KUramWa12MMSkVAhUAgFLcsMhCFa1Ziuzj/fdVQzr5SNI=X\n" +
                    "02em";

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

    protected final DirectoryManager directoryManager;
    protected final InternalApplicationPropertiesService propertiesService;
    protected final LicenseHelper licenseHelper;
    protected final Properties applicationProperties;
    protected final InternalLicenseService licenseService;
    protected final ApplicationMode applicationMode;

    private final ApplicationManager appManager;
    private final ConfigurationService configurationService;
    private final GlobalPermissionDao globalPermissionDao;
    private final ApplicationPropertyDao applicationPropertyDao;
    private final StashUserDao userDao;

    @Autowired
    public BootstrapOperation(DirectoryManager directoryManager, InternalApplicationPropertiesService propertiesService,
                              StashUserDao userDao, Properties applicationProperties, ApplicationManager appManager,
                              LicenseHelper licenseHelper, ConfigurationService configurationService,
                              GlobalPermissionDao globalPermissionDao, ApplicationPropertyDao applicationPropertyDao,
                              InternalLicenseService licenseService) {
        this.directoryManager = directoryManager;
        this.propertiesService = propertiesService;
        this.userDao = userDao;
        this.applicationProperties = applicationProperties;
        this.appManager = appManager;
        this.licenseHelper = licenseHelper;
        this.configurationService = configurationService;
        this.globalPermissionDao = globalPermissionDao;
        this.applicationPropertyDao = applicationPropertyDao;
        this.licenseService = licenseService;
        this.applicationMode = propertiesService.getMode();
    }

    /**
     * A template method for both {@link BootstrapOperation} and {@link DevBootstrapOperation} that defines the
     * broad progression and flow of setup common to both. If you change this method be sure it also makes sense
     * for {@link DevBootstrapOperation}'s overridden methods.
     *
     * @see Bootstrapper#start()
     */
    @Override
    @Transactional
    public Void perform() {
        Application application = createApplication();
        Directory directory = createDirectory(application);

        addDefaultGroup(directory);

        RemoveSetupConfigurationRequest.Builder setupConfigurationBuilder = new RemoveSetupConfigurationRequest.Builder();

        boolean isSetup = setupLicense(setupConfigurationBuilder);
        if (isSetup) {
            isSetup = setupUsers(setupConfigurationBuilder, directory);
        }

        isSetup &= setupBaseUrl(setupConfigurationBuilder);
        isSetup &= setupDisplayName(setupConfigurationBuilder);
        if (applicationMode == ApplicationMode.MIRROR) {
            String type = StringUtils.stripToNull(applicationProperties.getProperty(KEY_MIRROR_TYPE));
            if (VALUE_SERVER_MIRROR.equalsIgnoreCase(type)) {
                //If this user has not configured an upstreamURL then continue on to the setup wizard.
                isSetup &= StringUtils.isNotBlank(applicationProperties.getProperty(KEY_MIRROR_UPSTREAM));
            } else {
                isSetup &= VALUE_CLOUD_MIRROR.equalsIgnoreCase(type);
            }
        }

        if (isSetup) {
            propertiesService.setSetup(true);
            log.info("Application setup completed successfully");
        }

        if (setupConfigurationBuilder.hasPropertiesToRemove()) {
            configurationService.removeSetupProperties(setupConfigurationBuilder.build());
        }

        return null;
    }

    protected boolean setupLicense(RemoveSetupConfigurationRequest.Builder builder) {
        ApplicationProperty currentLicenseProperty = applicationPropertyDao.getById(ApplicationProperty.Key.LICENSE);
        String currentLicense = currentLicenseProperty == null ? null : currentLicenseProperty.getValue();
        String setupLicense = applicationProperties.getProperty(ApplicationConstants.SETUP_LICENSE);

        if (StringUtils.isNotBlank(currentLicense)) {
            if (StringUtils.isNotBlank(setupLicense)) {
                log.warn("Found {} property in {}, but ignored it because it's already set up",
                        ApplicationConstants.SETUP_LICENSE, ApplicationConstants.CONFIG_FILE);
            }

            return true;
        }

        if (applicationMode == ApplicationMode.MIRROR) {
            if (StringUtils.isNotBlank(setupLicense)) {
                log.warn("Found {} property in {}, but ignored it because this is a mirror",
                        ApplicationConstants.SETUP_LICENSE, ApplicationConstants.CONFIG_FILE);
            }

            // Set an expired commercial license for 1 user to shut other components up about a missing license
            licenseService.setUnvalidated(LICENSE_MIRROR);

            return true;
        }


        if (StringUtils.isNotBlank(setupLicense)) {
            log.info("Found {} property in {}", ApplicationConstants.SETUP_LICENSE,
                    ApplicationConstants.CONFIG_FILE);

            DualLicense dualLicense;
            try {
                // licenseHelper.decode() really ensures the license is valid
                dualLicense = licenseHelper.decode(setupLicense);
            } catch (IllegalArgumentException iae) {
                dualLicense = null;
            }

            if (dualLicense == null) {
                log.error("Property {} in {} is not valid", ApplicationConstants.SETUP_LICENSE,
                        ApplicationConstants.CONFIG_FILE);
                return false;
            }

            licenseHelper.set(setupLicense);

            builder.removeLicense();
            return true;
        }

        return false;
    }

    protected boolean setupBaseUrl(RemoveSetupConfigurationRequest.Builder builder) {
        URI baseUrl = propertiesService.getBaseUrl();
        String baseUrlString = applicationProperties.getProperty(ApplicationConstants.SETUP_BASE_URL);

        if (baseUrl != null) {
            if (StringUtils.isNotBlank(baseUrlString)) {
                log.warn("Found {} property in {}, but ignored it because it's already set up",
                        ApplicationConstants.SETUP_BASE_URL, ApplicationConstants.CONFIG_FILE);
            }

            return true;
        }

        if (StringUtils.isNotBlank(baseUrlString)) {
            log.info("Found {} property in {}", ApplicationConstants.SETUP_BASE_URL,
                    ApplicationConstants.CONFIG_FILE);

            try {
                propertiesService.setBaseURL(new URI(baseUrlString));
            } catch (URISyntaxException e) {
                log.error("Property {} in {} is not valid: {}", ApplicationConstants.SETUP_BASE_URL,
                        ApplicationConstants.CONFIG_FILE, e.getMessage());
                return false;
            }

            builder.removeBaseUrl();
            return true;
        }
        return false;
    }

    /**
     * Create users from config file if available
     */
    protected boolean setupUsers(RemoveSetupConfigurationRequest.Builder builder, Directory directory) {
        try {
            Set<String> missingAdminProps = findMissingAdminProps(applicationProperties);
            boolean someAdminPropsMissing = missingAdminProps.size() > 0;
            boolean someAdminPropsSupplied = missingAdminProps.size() < ApplicationConstants.SETUP_USER_PROPERTIES.size();

            if (applicationMode == ApplicationMode.MIRROR) {
                if (someAdminPropsSupplied) {
                    log.warn("Found sysadmin user properties in {}, but ignored them because this is a mirror and cannot manage local users",
                            ApplicationConstants.CONFIG_FILE);
                }

                return true;
            }

            if (usersExist()) {
                if (someAdminPropsSupplied) {
                    log.warn("Found sysadmin user properties in {}, but ignored them because user(s) already exist",
                            ApplicationConstants.CONFIG_FILE);
                }

                return true;
            }

            if (someAdminPropsMissing) {
                if (someAdminPropsSupplied) {
                    // Only log if some (not all) of the properties are missing
                    log.error("Aborting the creation of the initial sysadmin user: not all the needed properties are"
                                    + " available in {}. Missing keys: {}", ApplicationConstants.CONFIG_FILE,
                            missingAdminProps.toString());
                }

                return false;
            }

            log.info("Creating initial sysadmin user...");
            createUser(directory,
                    applicationProperties.getProperty(ApplicationConstants.SETUP_USER_NAME),
                    applicationProperties.getProperty(ApplicationConstants.SETUP_USER_PASSWORD),
                    applicationProperties.getProperty(ApplicationConstants.SETUP_USER_DISPLAY_NAME),
                    applicationProperties.getProperty(ApplicationConstants.SETUP_USER_EMAIL_ADDRESS),
                    true, true);
            log.info("Created the initial sysadmin, user name: {}", applicationProperties.getProperty(ApplicationConstants.SETUP_USER_NAME));

            builder.removeSysAdmin();

            return true;
        } catch (Exception e) {
            throw Throwables.propagate(e);
        }
    }

    protected void createUser(Directory directory, String username, String password, String displayName, String emailAddress,
                              boolean addToDefaultGroup, boolean isSysAdmin) throws Exception {
        // we MUST use DAOs to create users, set up permissions and add to groups. If we use the various services
        // that do the same job we run the risk of entering methods with authorisation advice, loading the permission
        // graph for the user into the perm graph cache and not having it flushed meaning an admin user may not have admin perms until
        // a restart (or until an event fired to rebuild the cache). We may also inadvertently trigger events for which
        // the application is not yet set up to handle

        UserTemplate userTemplate = new UserTemplate(username, username, username, displayName);
        userTemplate.setDirectoryId(directory.getId());
        userTemplate.setEmailAddress(emailAddress);
        userTemplate.setActive(true);

        directoryManager.addUser(directory.getId(), userTemplate, new PasswordCredential(password));
        if (addToDefaultGroup) {
            directoryManager.addUserToGroup(directory.getId(), username, ApplicationConstants.STASH_USERS);
        }

        if (isSysAdmin) {
            InternalNormalUser user = userDao.findByName(username);
            globalPermissionDao.create((new InternalGlobalPermission.Builder()
                    .permission(Permission.SYS_ADMIN)
                    .user(user)
                    .build()));
        }
    }

    protected boolean usersExist() {
        return userDao.findAll(PageUtils.newRequest(0, 1)).getSize() > 0;
    }

    private Group addDefaultGroup(Directory directory) {
        Long directoryId = directory.getId();
        Group group;
        try {
            group = directoryManager.findGroupByName(directoryId, ApplicationConstants.STASH_USERS);
        } catch (GroupNotFoundException gnfe) {
            try {
                log.debug("Add group");
                group = directoryManager.addGroup(directoryId, new GroupTemplate(ApplicationConstants.STASH_USERS, directoryId));
                globalPermissionDao.create(new InternalGlobalPermission.Builder()
                        .group(ApplicationConstants.STASH_USERS)
                        .permission(Permission.LICENSED_USER)
                        .build());
                log.info("Group added");
            } catch (CrowdException | PermissionException e) {
                throw Throwables.propagate(e);
            }
        } catch (OperationFailedException | DirectoryNotFoundException e) {
            throw Throwables.propagate(e);
        }
        return group;
    }

    private Directory createDirectory(Application application) {
        Directory directory;
        try {
            directory = directoryManager.findDirectoryByName(ApplicationConstants.INTERNAL_DIRECTORY_NAME);
        } catch (DirectoryNotFoundException e) {
            log.debug("Create directory");
            DirectoryImpl newDirectory = createDirectory(ApplicationConstants.INTERNAL_DIRECTORY_NAME, true,
                    ApplicationConstants.INTERNAL_DIRECTORY_NAME, DirectoryType.INTERNAL,
                    InternalDirectory.class, OperationType.values(),
                    ImmutableMap.of(InternalDirectory.ATTRIBUTE_USER_ENCRYPTION_METHOD,
                            PasswordEncoderFactory.ATLASSIAN_SECURITY_ENCODER));
            log.info("Directory created");
            try {
                directory = directoryManager.addDirectory(newDirectory);
            } catch (DirectoryInstantiationException die) {
                throw Throwables.propagate(die);
            }
        }

        try {
            appManager.addDirectoryMapping(application, directory, true, OperationType.values());
        } catch (CrowdException e) {
            throw Throwables.propagate(e);
        }
        return directory;
    }

    private Application createApplication() {
        try {
            return appManager.findByName(ApplicationConstants.CROWD_APPLICATION_NAME);
        } catch (ApplicationNotFoundException e) {
            log.debug("Create application");
            ApplicationImpl newApplication = createApplication(ApplicationConstants.CROWD_APPLICATION_NAME,
                    ApplicationType.CROWD, PasswordCredential.NONE);
            newApplication.setMembershipAggregationEnabled(true);
            log.info("Application created");
            try {
                return appManager.add(newApplication);
            } catch (InvalidCredentialException | ApplicationAlreadyExistsException exception) {
                throw Throwables.propagate(exception);
            }
        }
    }

    private boolean setupDisplayName(RemoveSetupConfigurationRequest.Builder setupConfigurationBuilder) {
        String displayName = propertiesService.getDisplayName();
        String displayNameConfig = applicationProperties.getProperty(ApplicationConstants.SETUP_DISPLAY_NAME);

        if (StringUtils.isBlank(displayName)) {
            if (StringUtils.isBlank(displayNameConfig)) {
                if (applicationMode == ApplicationMode.MIRROR) {
                    //Don't set any default displayName in mirroring because we want the user to enter one
                    //as it cannot be changed once the mirror is registered.
                    return false;
                } else {
                    propertiesService.setDisplayName(ApplicationConstants.PRODUCT_NAME);
                }
            } else {
                log.info("Found {} property in {}", ApplicationConstants.SETUP_DISPLAY_NAME,
                        ApplicationConstants.CONFIG_FILE);
                propertiesService.setDisplayName(displayNameConfig);
                setupConfigurationBuilder.removeDisplayName();
            }
        } else {
            if (StringUtils.isNotBlank(displayNameConfig)) {
                log.warn("Found {} property in {}, but ignored it because it's already set up",
                        ApplicationConstants.SETUP_DISPLAY_NAME, ApplicationConstants.CONFIG_FILE);
            }
        }

        return true;
    }

    /**
     * @return a set with the missing properties.
     */
    private Set<String> findMissingAdminProps(Properties properties) {
        Set<String> missingProperties = Sets.newHashSet();

        for (String propertyName : ApplicationConstants.SETUP_USER_PROPERTIES) {
            checkAndAdd(properties, propertyName, missingProperties);
        }

        return missingProperties;
    }

    private void checkAndAdd(Properties properties, String property, Set<String> missingProperties) {
        if (StringUtils.isBlank(properties.getProperty(property))) {
            missingProperties.add(property);
        }
    }

    private DirectoryImpl createDirectory(String name, boolean isActive, String description, DirectoryType type,
                                          Class implementationClass, OperationType[] operations,
                                          Map<String, String> attributes) {
        DirectoryImpl directory = new DirectoryImpl();
        directory.setName(name);
        directory.setActive(isActive);
        directory.setDescription(description);
        directory.setType(type);
        directory.setImplementationClass(implementationClass.getName());
        for (OperationType operationType : operations) {
            directory.addAllowedOperation(operationType);
        }

        for (Map.Entry<String, String> entry : attributes.entrySet()) {
            directory.setAttribute(entry.getKey(), entry.getValue());
        }

        return directory;
    }

    private ApplicationImpl createApplication(String name, ApplicationType type, PasswordCredential credential) {
        return ApplicationImpl.newInstanceWithCredential(name, type, credential);
    }
}
