package com.atlassian.bitbucket.mesh.git.auth;

import com.atlassian.bitbucket.mesh.config.ProcessConfig;
import com.atlassian.bitbucket.mesh.credentials.Credentials;
import com.atlassian.bitbucket.mesh.credentials.CredentialsVisitor;
import com.atlassian.bitbucket.mesh.credentials.PrivateKeyCredentials;
import com.atlassian.bitbucket.mesh.credentials.UsernamePasswordCredentials;
import com.atlassian.bitbucket.mesh.git.builder.GitProcessBuilderSupport;
import com.atlassian.bitbucket.mesh.layout.StorageLayout;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;

import static com.atlassian.bitbucket.mesh.git.GitConstants.CONFIG_CREDENTIAL_USERNAME;
import static java.util.Objects.requireNonNull;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;

public class DefaultGitAuthenticationHelper implements GitAuthenticationHelper {

    /**
     * Overrides the core.askpass configuration value. This is the program invoked whenever Git needs to ask the user
     * for credentials.
     */
    private static final String ENV_ASKPASS = "GIT_ASKPASS";
    /**
     * This environment variable should be exported when using the {@code askPass} script to provide a password to the
     * git command, without the password showing up in logs or bash history.
     */
    private static final String ENV_ASKPASS_PASSWORD = "BITBUCKET_ASKPASS_PASSWORD";
    /**
     * Causes {@code git} to use an alternate script / executable when using SSH to communicate with remote systems.
     * This is typically used when SSH needs to be called with custom parameters, for instance when using a custom SSH
     * key.
     */
    private static final String ENV_SSH = "GIT_SSH";
    /**
     * Environment variable used by the SSH script that provides the absolute path to the real SSH executable.
     */
    private static final String ENV_SSH_EXEC = "BITBUCKET_SSH_EXEC";
    /**
     * Environment variable used by the SSH script that provides the absolute path to the SSH private key that should
     * be used.
     */
    private static final String ENV_SSH_KEY = "BITBUCKET_SSH_KEY";
    /**
     * Allows explicitly configuring the {@code ssh} variant in use, which controls which flags Git passes when using
     * SSH to access remote servers.
     * <p>
     * Starting in 2.16 Git no longer assumes the SSH command supports OpenSSH flags (such as {@code -4}, {@code -6}
     * and {@code -p}) when using the {@link #ENV_SSH GIT_SSH} environment variable. Instead, it defaults to a "simple"
     * variant which is <i>unable to use non-standard ports</i>. Configuring the variant to "ssh" explicitly using this
     * environment variable can override that limitation.
     */
    private static final String ENV_SSH_VARIANT = "GIT_SSH_VARIANT";

    private static final String GIT_ASK_PASS_SCRIPT = "ask-pass.pl";
    private static final String GIT_SSH_SCRIPT = "ssh-git.sh";

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

    private final Path askPassScript;
    private final ProcessConfig processConfig;
    private final Path sshScript;

    public DefaultGitAuthenticationHelper(ProcessConfig processConfig, StorageLayout storageLayout) {
        this.processConfig = processConfig;

        Path scriptsPath = storageLayout.getScriptsDir();
        askPassScript = scriptsPath.resolve(GIT_ASK_PASS_SCRIPT).toAbsolutePath();
        sshScript = scriptsPath.resolve(GIT_SSH_SCRIPT).toAbsolutePath();
    }

    @Nonnull
    @Override
    public <B extends GitProcessBuilderSupport<B>> B configure(@Nonnull B builder,
                                                               @Nullable String url,
                                                               @Nullable Credentials credentials) {
        requireNonNull(builder, "builder");

        // Always configure the GIT_ASKPASS script to prevent Git from ever prompting for a password
        configureAskPass(builder);

        // Always configure the GIT_SSH script to ensure we never prompt for things like updating known_hosts,
        // which can cause the process to hang.
        configureSsh(builder);

        if (credentials == null) {
            return builder;
        }

        return credentials.accept(new CredentialsConfigurer<>(builder, url));
    }

    private <B extends GitProcessBuilderSupport<B>> void configureAskPass(B builder) {
        if (Files.exists(askPassScript)) {
            builder.withEnvironment(ENV_ASKPASS, askPassScript.toString());
            // Disable any credential helpers that may be present in the system wide git configuration to ensure
            // our askPass script is called when a password is required.
            builder.withConfiguration("credential.helper", "");
        } else {
            log.warn("ASKPASS script {} does not exist. Git operations that require a password will fail",
                    askPassScript);
        }
    }

    private <B extends GitProcessBuilderSupport<B>> void configureSsh(B builder) {
        if (Files.exists(sshScript)) {
            builder.withEnvironment(ENV_SSH, sshScript.toString())
                    .withEnvironment(ENV_SSH_VARIANT, "ssh")
                    .withEnvironment(ENV_SSH_EXEC, processConfig.getSshBinary());
        } else {
            log.warn("SSH script {} does not exist. Git operations over SSH may fail", sshScript);
        }
    }

    private class CredentialsConfigurer<B extends GitProcessBuilderSupport<B>> implements CredentialsVisitor<B> {

        private final B builder;
        private final String url;

        private CredentialsConfigurer(B builder, String url) {
            this.builder = builder;
            this.url = url;
        }

        @Override
        public B visit(@Nonnull UsernamePasswordCredentials credentials) {
            configureDefaultUsername(credentials.getUsername());
            configurePassword(credentials.getPassword());
            return builder;
        }

        @Override
        public B visit(@Nonnull PrivateKeyCredentials credentials) {
            configureDefaultUsername(credentials.getUsername());
            credentials.getPassphrase()
                    .filter(StringUtils::isNotBlank)
                    .ifPresent(this::configurePassword);
            configureSsh(credentials.getPrivateKeyPath());
            return builder;
        }

        private void configureDefaultUsername(String username) {
            if (isBlank(username)) {
                // If no username was provided explicitly, try to extract it from the url. This way, we have it in case
                // the remote server redirects us to another URL, thereby dropping the user-info.
                try {
                    username = new URI(url).getUserInfo();
                } catch (URISyntaxException e) {
                    // ignored
                }
            }

            // Set the default username to the one supplied so that any redirects (which likely won't have userinfo
            // in the URL) aren't left prompting the user (who isn't there) for the username and thus failing auth when
            // in fact we could probably succeed
            if (StringUtils.isNotBlank(username)) {
                builder.withConfiguration(CONFIG_CREDENTIAL_USERNAME, username);
            }
        }

        private void configurePassword(@Nonnull String password) {
            requireNonNull(password, "password");
            if (Files.exists(askPassScript) && isNotBlank(password)) {
                log.trace("Configuring git to return a password via ASKPASS");
                builder.withEnvironment(ENV_ASKPASS_PASSWORD, password);
            }
        }

        private void configureSsh(@Nonnull Path sshPrivateKeyFile) {
            requireNonNull(sshPrivateKeyFile, "sshPrivateKeyFile");
            if (Files.exists(sshScript)) {
                log.trace("Configuring git to use SSH private key file {}", sshPrivateKeyFile.toAbsolutePath());
                builder.withEnvironment(ENV_SSH_KEY, sshPrivateKeyFile.toAbsolutePath().toString());
            }
        }
    }
}
