package com.atlassian.bitbucket.internal.crowd.sso;

import com.atlassian.cache.CacheFactory;
import com.atlassian.cache.Supplier;
import com.atlassian.crowd.embedded.api.*;
import com.atlassian.crowd.event.directory.DirectoryCreatedEvent;
import com.atlassian.crowd.event.directory.DirectoryDeletedEvent;
import com.atlassian.crowd.event.directory.DirectoryUpdatedEvent;
import com.atlassian.crowd.exception.ApplicationPermissionException;
import com.atlassian.crowd.exception.InvalidAuthenticationException;
import com.atlassian.crowd.exception.OperationFailedException;
import com.atlassian.crowd.integration.http.CrowdHttpAuthenticator;
import com.atlassian.crowd.integration.http.CrowdHttpAuthenticatorImpl;
import com.atlassian.crowd.integration.http.util.CrowdHttpTokenHelper;
import com.atlassian.crowd.integration.http.util.CrowdHttpTokenHelperImpl;
import com.atlassian.crowd.integration.http.util.CrowdHttpValidationFactorExtractorImpl;
import com.atlassian.crowd.integration.rest.service.factory.RestCrowdClientFactory;
import com.atlassian.crowd.model.authentication.CookieConfiguration;
import com.atlassian.crowd.service.client.ClientProperties;
import com.atlassian.crowd.service.client.ClientPropertiesImpl;
import com.atlassian.crowd.service.client.CrowdClient;
import com.atlassian.event.api.EventListener;
import com.atlassian.fugue.Option;
import com.atlassian.bitbucket.event.server.BaseUrlChangedEvent;
import com.atlassian.bitbucket.ServerException;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.bitbucket.nav.NavBuilder;
import com.atlassian.bitbucket.crowd.sso.CrowdSsoService;
import com.atlassian.bitbucket.server.ApplicationPropertiesService;
import com.atlassian.util.concurrent.LazyReference;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.DisposableBean;

import javax.annotation.Nonnull;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.TimeUnit;

import static com.atlassian.crowd.integration.Constants.*;
import static com.atlassian.fugue.Option.none;
import static com.atlassian.fugue.Option.some;

public class DefaultCrowdSsoService implements CrowdSsoService, DisposableBean {

    static final String PROP_PREFIX = "plugin.auth-crowd.sso.";
    static final String PROP_ENABLED = PROP_PREFIX + "enabled";
    static final String PROP_CONFIG_ERROR_WAIT = PROP_PREFIX + "config.error.wait";
    static final String PROP_CONFIG_TTL = PROP_PREFIX + "config.ttl";
    static final SsoConfiguration DISABLED_CONFIGURATION = new SsoConfiguration(null, null, null, null);

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

    private final CrowdService crowdService;
    private final CrowdDirectoryService directoryService;
    private final I18nService i18nService;
    private final NavBuilder navBuilder;
    private final RestCrowdClientFactory restClientFactory;

    private final SsoConfigurationCache configCache;
    private final boolean enabled;
    private final LazyReference<Properties> ssoProperties;

    public DefaultCrowdSsoService(CacheFactory cacheFactory, CrowdService crowdService,
                                  CrowdDirectoryService directoryService, I18nService i18nService,
                                  NavBuilder navBuilder, final ApplicationPropertiesService propertiesService,
                                  RestCrowdClientFactory restCrowdClientFactory) {
        this.crowdService = crowdService;
        this.directoryService = directoryService;
        this.i18nService = i18nService;
        this.navBuilder = navBuilder;
        this.restClientFactory = restCrowdClientFactory;

        configCache = new SsoConfigurationCache(cacheFactory, new SsoConfigurationSupplier(),
                propertiesService.getPluginProperty(PROP_CONFIG_TTL, 15), TimeUnit.MINUTES,
                propertiesService.getPluginProperty(PROP_CONFIG_ERROR_WAIT, 1), TimeUnit.SECONDS);
        enabled = Boolean.parseBoolean(propertiesService.getPluginProperty(PROP_ENABLED));
        ssoProperties = new LazyReference<Properties>() {
            @Override
            protected Properties create() throws Exception {
                return loadClientConfigurationProperties(propertiesService);
            }
        };
    }

    @Override
    public void destroy() throws Exception {
        configCache.destroy();
    }

    @Override
    public CrowdHttpAuthenticator getAuthenticator() {
        return getConfiguration().getAuthenticator();
    }

    /**
     * @return {@code true} if Crowd SSO integration is enabled and a Crowd Directory with a valid SSO configuration
     *         is registered in embedded Crowd.
     */
    public boolean isAvailable() {
        return getConfiguration().isValid();
    }

    @Override
    public boolean isManagedBySso(@Nonnull String username) {
        SsoConfiguration conf = getConfiguration();
        if (conf.isValid()) {
            User user = crowdService.getUser(username);

            return user != null && conf.getSsoDirectory().getId() == user.getDirectoryId();
        }

        return false;
    }

    @Override
    public boolean isSsoCandidate(HttpServletRequest request) {
        SsoConfiguration conf = getConfiguration();
        return conf.isValid() &&
                (conf.getCookieDomain().isEmpty() || request.getServerName().endsWith(conf.getCookieDomain()));
    }

    @EventListener
    public void onBaseUrlChanged(BaseUrlChangedEvent event) {
        ssoProperties.get().put(PROPERTIES_FILE_APPLICATION_LOGIN_URL, navBuilder.login().buildConfigured());
        reset();
    }

    @EventListener
    public void onDirectoryCreated(DirectoryCreatedEvent event) {
        reset();
    }

    @EventListener
    public void onDirectoryDeleted(DirectoryDeletedEvent event) {
        reset();
    }

    @EventListener
    public void onDirectoryUpdatedEvent(DirectoryUpdatedEvent event) {
        reset();
    }

    private Properties createCookieProperties(CookieConfiguration cookieConfiguration) {
        Properties properties = new Properties();
        properties.put(PROPERTIES_FILE_COOKIE_TOKENKEY, cookieConfiguration.getName());
        if (StringUtils.isNotBlank(cookieConfiguration.getDomain())) {
            properties.put(PROPERTIES_FILE_COOKIE_DOMAIN, StringUtils.trimToEmpty(cookieConfiguration.getDomain()));
        }

        return properties;
    }

    private ClientProperties createCrowdClientProperties(Directory directory, Properties customProperties) {
        Properties properties = new Properties();
        Map<String, String> dirAttributes = directory.getAttributes();

        properties.putAll(ssoProperties.get());
        properties.put(PROPERTIES_FILE_APPLICATION_NAME, dirAttributes.get("application.name"));
        properties.put(PROPERTIES_FILE_APPLICATION_PASSWORD, dirAttributes.get("application.password"));
        properties.put(PROPERTIES_FILE_BASE_URL, dirAttributes.get("crowd.server.url"));

        if (customProperties != null) {
            properties.putAll(customProperties);
        }

        return ClientPropertiesImpl.newInstanceFromProperties(properties);
    }

    private CookieConfiguration fetchCookieConfiguration(CrowdClient crowdClient, String crowdBaseUrl) {
        try {
            CookieConfiguration config = crowdClient.getCookieConfiguration();
            if (StringUtils.isNotBlank(config.getName())) {
                return config;
            } else {
                log.debug("Cookie configuration from Crowd at {} did not have a name. This may indicate an unexpected " +
                        "response from the Crowd server. Is the Crowd URL valid?", crowdBaseUrl);
            }
        } catch (OperationFailedException | InvalidAuthenticationException | ApplicationPermissionException e) {
            throw newConfigFetchFailedException(e);
        }

        return null;
    }

    SsoConfiguration getConfiguration() {
        if (!enabled) {
            return DISABLED_CONFIGURATION;
        }

        return configCache.get().getOrElse(DISABLED_CONFIGURATION);
    }

    private void loadProperty(ApplicationPropertiesService propertiesService, Properties properties, String key) {
        Object value = propertiesService.getPluginProperty(PROP_PREFIX + key);
        if (value != null) {
            properties.put(key, value);
        }
    }

    private Properties loadClientConfigurationProperties(ApplicationPropertiesService propertiesService) {
        Properties result = new Properties();

        loadProperty(propertiesService, result, PROPERTIES_FILE_COOKIE_DOMAIN);
        loadProperty(propertiesService, result, PROPERTIES_FILE_COOKIE_TOKENKEY);
        loadProperty(propertiesService, result, PROPERTIES_FILE_HTTP_MAX_CONNECTIONS);
        loadProperty(propertiesService, result, PROPERTIES_FILE_HTTP_PROXY_HOST);
        loadProperty(propertiesService, result, PROPERTIES_FILE_HTTP_PROXY_PORT);
        loadProperty(propertiesService, result, PROPERTIES_FILE_HTTP_PROXY_USERNAME);
        loadProperty(propertiesService, result, PROPERTIES_FILE_HTTP_PROXY_PASSWORD);
        loadProperty(propertiesService, result, PROPERTIES_FILE_HTTP_TIMEOUT);
        loadProperty(propertiesService, result, PROPERTIES_FILE_SESSIONKEY_LASTVALIDATION);
        loadProperty(propertiesService, result, PROPERTIES_FILE_SESSIONKEY_TOKENKEY);
        loadProperty(propertiesService, result, PROPERTIES_FILE_SESSIONKEY_VALIDATIONINTERVAL);
        loadProperty(propertiesService, result, PROPERTIES_FILE_SOCKET_TIMEOUT);

        result.put(PROPERTIES_FILE_APPLICATION_LOGIN_URL, navBuilder.login().buildConfigured());

        return result;
    }

    private ServerException newConfigFetchFailedException(Exception e) {
        throw new ServerException(i18nService.createKeyedMessage("bitbucket.auth.crowd.sso.configuration.fetch.failed", e.getMessage()), e);
    }

    private void reset() {
        configCache.reset();
    }

    private class SsoConfigurationSupplier implements Supplier<Option<SsoConfiguration>> {
        @Override
        public Option<SsoConfiguration> get() {
            for (Directory directory : directoryService.findAllDirectories()) {
                if (directory.isActive() && directory.getType() == DirectoryType.CROWD) {
                    // found a Crowd directory - try to use it for SSO
                    ClientProperties clientProperties = createCrowdClientProperties(directory, null);
                    CrowdClient crowdClient = restClientFactory.newInstance(clientProperties);

                    CookieConfiguration cookieConfiguration = fetchCookieConfiguration(crowdClient,
                            clientProperties.getBaseURL());
                    if (cookieConfiguration == null) {
                        // not a Crowd SSO directory after all
                        continue;
                    }

                    // re-create the client properties, now WITH cookie settings!
                    clientProperties = createCrowdClientProperties(directory, createCookieProperties(cookieConfiguration));

                    CrowdHttpTokenHelper tokenHelper =
                            CrowdHttpTokenHelperImpl.getInstance(CrowdHttpValidationFactorExtractorImpl.getInstance());
                    CrowdHttpAuthenticator authenticator = new CrowdHttpAuthenticatorImpl(crowdClient, clientProperties, tokenHelper);

                    SsoConfiguration config = new SsoConfiguration(authenticator, cookieConfiguration.getDomain(), directory, clientProperties);
                    if (!config.isValid()) {
                        log.info("Disabling Crowd SSO integration because the remote Crowd SSO configuration is invalid.");

                        throw new ServerException(i18nService.createKeyedMessage("bitbucket.auth.crowd.sso.invalid.configuration", directory.getName()));
                    }

                    log.debug("Crowd SSO integration with directory {} has been (re)configured and enabled", directory.getName());
                    return some(config);
                }
            }

            return none();
        }
    }

}
