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

import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.util.Version;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import io.atlassian.fugue.Option;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.SystemUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.Iterator;
import java.util.List;
import java.util.regex.Matcher;

import static com.atlassian.bitbucket.mesh.git.builder.version.VersionStdioHandler.PATTERN_VERSION;
import static java.nio.charset.StandardCharsets.UTF_8;

public class DefaultGitBinaryHelper implements GitBinaryHelper {

    /**
     * Extra locations to check for the Git executable (if it is not in the {@code $PATH}), on Unix-like operating
     * systems such as Linux and MacOS.
     */
    static final List<String> DEFAULT_LOCATIONS = ImmutableList.of(
            "/usr/bin", "/bin", "/usr/sbin", "/sbin", // standard paths (should already be in the PATH env)
            "/usr/local/bin",     // Homebrew
            "/opt/local/bin",     // MacPort
            "/usr/local/git/bin", // git-osx-installer
            "/opt/bin", SystemUtils.USER_HOME + "/bin");

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

    private Version unsupportedFromVersion;

    @Nonnull
    @Override
    public GitBinary find(String configuredLocation) {
        if (StringUtils.isNotBlank(configuredLocation)) {
            File executable = new File(configuredLocation);
            if (isExecutable(executable)) {
                log.trace("Checking the configured git executable: {}", configuredLocation);
                Option<Version> option = versionOf(executable);
                if (option.isEmpty()) {
                    log.warn("The configured git executable, {}, is not valid", configuredLocation);
                } else if (MINIMUM_VERSION.compareTo(option.get()) > 0) {
                    log.warn("The configured git executable, {}, is version {}; {} is the minimum required version",
                            configuredLocation, option.get(), MINIMUM_VERSION);
                } else if (unsupportedFromVersion != null && unsupportedFromVersion.compareTo(option.get()) <= 0) {
                    log.warn("The configured git executable, {}, is version {}, which is unsupported because git " +
                                    "{} and higher (may) contain changes which break core functionality",
                            configuredLocation, option.get(), unsupportedFromVersion);
                } else {
                    log.debug("Using explicitly configured git executable at {}", configuredLocation);

                    return foundBinary(new SimpleGitBinary(executable.getAbsolutePath(), option.get()));
                }
            } else {
                log.warn("The configured git executable, {}, does not exist or is not executable", configuredLocation);
            }
        }

        return findBinary(findIn(getSearchPaths()));
    }

    public void setUnsupportedFromVersion(Version value) {
        unsupportedFromVersion = value;
    }

    @Nonnull
    @VisibleForTesting
    protected Iterable<String> getSearchPaths() {
        String path = System.getenv("PATH");

        Iterable<String> paths;
        if (path == null) {
            log.debug("PATH was not set");
            paths = null;
        } else {
            paths = new LoggingIterable("PATH", ImmutableList.copyOf(path.split(File.pathSeparator)));
        }

        Iterable<String> defaults = new LoggingIterable("default locations", DEFAULT_LOCATIONS);
        return paths == null ? defaults : Iterables.concat(paths, defaults);
    }

    @VisibleForTesting
    protected Option<Version> versionOf(File executable) {
        String path = executable.getAbsolutePath();

        try {
            Process process = new ProcessBuilder(path, "version").start();

            try (InputStream inputStream = process.getInputStream();
                 BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, UTF_8))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    Matcher matcher = PATTERN_VERSION.matcher(line);
                    if (matcher.matches()) {
                        return Option.some(new Version(matcher.group(1)));
                    }
                }
            }
        } catch (Exception e) {
            log.warn("Could not execute " + path, e);
        }

        return Option.none();
    }

    private static GitBinary foundBinary(GitBinary binary) {
        Version version = binary.getVersion();
        if (NEXT_MINIMUM_VERSION.compareTo(version) > 0) {
            log.warn("Using {} ({}). The next major version will require Git {} or higher.",
                    binary.getPath(), version, NEXT_MINIMUM_VERSION);
        } else {
            log.info("Using {} ({})", binary.getPath(), version);
        }

        return binary;
    }

    private static boolean isExecutable(File executable) {
        return executable.isFile() && executable.canExecute();
    }

    private static boolean isWorldWritable(File executable) throws IOException {
        try {
            Path path = executable.toPath();
            if (MoreFiles.isPosixSupported(path)) {
                return Files.getPosixFilePermissions(path).contains(PosixFilePermission.OTHERS_WRITE);
            }
            log.warn("Could not check permissions for {}; assuming that the binary is safe to use", executable);
        } catch (FileNotFoundException | NoSuchFileException ignored) {
            // $PATH/%PATH% contains a directory that doesn't exist; move on
        }
        return false;
    }

    private Option<File> executableFor(String path) {
        File directory = new File(path);
        File executable = new File(directory, "git");

        if (isExecutable(executable)) {
            try {
                if (isWorldWritable(executable)) {
                    log.warn("{} will be ignored because it is world-writable and could be exploited.", executable);
                    return Option.none();
                }
            } catch (IOException e) {
                log.warn("Could not check permissions for {}; will not check version", executable, e);
                return Option.none();
            }
            return Option.some(executable);
        }
        return Option.none();
    }

    @Nonnull
    private GitBinary findBinary(GitBinary binary) {
        if (binary == null) {
            throw new IllegalStateException("Git was not found on the PATH. Install Git " +
                    MINIMUM_VERSION + "+, and restart.");
        }

        Version version = binary.getVersion();
        if (MINIMUM_VERSION.compareTo(version) > 0) {
            throw new IllegalStateException("You are using Git " + version + ". The minimum supported version is " +
                    MINIMUM_VERSION + ". Upgrade to Git " + MINIMUM_VERSION + "+ and restart.");
        }
        if (unsupportedFromVersion != null && unsupportedFromVersion.compareTo(version) <= 0) {
            throw new IllegalStateException("You are using Git " + version + ", which is not supported. Downgrade " +
                    "Git to a version between " + getVersionMajorMinorString(MINIMUM_VERSION) + " and up to but not " +
                    "including " + getVersionMajorMinorString(unsupportedFromVersion) + " then restart.");
        }
        return foundBinary(binary);
    }

    private GitBinary findIn(Iterable<String> paths) {
        GitBinary found = null;
        boolean foundIsSupported = false;

        for (String path : paths) {
            log.trace("Looking for Git executable in {}", path);
            for (File executable : executableFor(path)) {
                log.trace("Checking version for {}", executable);
                for (Version version : versionOf(executable)) {
                    GitBinary binary = new SimpleGitBinary(executable.getAbsolutePath(), version);
                    boolean supported = isSupported(version);
                    log.debug("Found git version {} at {}", version, executable);

                    if (supported && version.compareTo(NEXT_MINIMUM_VERSION) > -1) {
                        //If this binary has a version above the _next_ minimum, excluding restricted versions and
                        //under the optional unsupportedFromVersion, there's no need to check more paths for a higher
                        //version. The check is based on the NEXT_MINIMUM_VERSION rather than MINIMUM_VERSION so that,
                        //if the system has multiple Git installations, say one at 1.7.9.5 and one at 2.1.4, if 1.7.9.5
                        //is found first the search will continue and still find 2.1.4. Since the system will display a
                        //warning banner if the version will not be compatible with the next major release, continuing
                        //the search to potentially find a compatible version leads to be a better user experience.
                        return binary;
                    }

                    if (found == null) {
                        //If this is the first binary we've found, even if it's below the minimum version, it's
                        //automatically the "best" option.
                        found = binary;
                        foundIsSupported = supported;
                    } else if (foundIsSupported == supported) {
                        if (version.compareTo(found.getVersion()) > 0) {
                            //This version is newer than the "best" found so far, and it is also (un)supported. Use it
                            //as the "better" option.
                            //If we don't find a version higher than NEXT_MINIMUM_VERSION, but the "best" option we
                            //find is higher than MINIMUM_VERSION, either(GitBinary) will still accept it and the
                            //system will not be locked out. Otherwise, the "best" version we found will be reported
                            //in the logs with instructions to upgrade or downgrade to a supported version.
                            found = binary;
                        }
                    } else if (supported) {
                        //The current "best" was not supported, but this candidate is.
                        found = binary;
                        foundIsSupported = true;
                    }
                }
            }
        }

        return found;
    }

    /**
     * By default {@link Version#toString()} will return the version back as major.minor.patch, and we do not always
     * want that; especially in our logs for determining what is the minimum and maximum version we support as we do not
     * discriminate against the patch versions.
     *
     * @param version the version to get the major and minor numerical values from
     * @return the version string representation with just the major and minor numerical values
     */
    private String getVersionMajorMinorString(Version version) {
        return version.getMajor() + "." + version.getMinor();
    }

    private boolean isSupported(Version version) {
        return MINIMUM_VERSION.compareTo(version) <= 0 &&
                (unsupportedFromVersion == null || unsupportedFromVersion.compareTo(version) > 0);
    }

    /**
     * For debugging purposes, outputs a type qualifier before providing an {@code Iterator}, indicating the type of
     * paths being iterated over.
     * <p>
     * This is useful for seeing when the {@code PATH} environment variable, or default locations, are being iterated
     * over searching for the {@code git} executable.
     */
    private static class LoggingIterable implements Iterable<String> {

        private final Iterable<String> delegate;
        private final String type;

        private LoggingIterable(String type, Iterable<String> delegate) {
            this.delegate = delegate;
            this.type = type;
        }

        @Nonnull
        @Override
        public Iterator<String> iterator() {
            log.debug("Searching {} for git executable", type);

            return delegate.iterator();
        }
    }
}
