package com.atlassian.stash.internal.scm.git;

import com.atlassian.bitbucket.repository.*;
import com.atlassian.bitbucket.scm.git.GitRefPattern;
import com.atlassian.bitbucket.scm.git.GitUtils;
import com.atlassian.bitbucket.util.IoUtils;
import com.google.common.collect.ImmutableList;
import com.google.common.io.CharSource;
import com.google.common.io.Files;
import com.google.common.io.LineProcessor;
import org.springframework.core.io.ClassPathResource;

import javax.annotation.Nonnull;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collection;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class GitHookUtils {

    public static final String PATH_HOOKS = "hooks";
    public static final String POST_RECEIVE = "post-receive";
    public static final String PRE_RECEIVE = "pre-receive";
    public static final String SCRIPTS_HOOKS = "/scripts/hooks/";

    private static final int GROUP_FROM_HASH = 1;
    private static final int GROUP_REF_ID = 3;
    private static final int GROUP_TO_HASH = 2;
    private static final Pattern PATTERN_REF_CHANGE = Pattern.compile("([a-f0-9]{40}) ([a-f0-9]{40}) (.*)");

    private GitHookUtils() {
    }

    /**
     * Retrieves the path to the directory where the specified hook's callback scripts should be stored.
     *
     * @param hooksDir the path to the repository's {@code hooks} directory
     * @param hookName the name of the hook
     * @return the path to the callback directory for the specified hook
     * @since 3.10
     */
    @Nonnull
    public static File getCallbackDir(@Nonnull File hooksDir, @Nonnull String hookName) {
        return new File(hooksDir, hookName + ".d");
    }

    /**
     * Retrieves the path to the specified repository's {@code hooks} directory.
     *
     * @param repositoryDir the path to the repository's directory
     * @return the path to the repository's {@code hooks} directory
     * @since 3.10
     */
    @Nonnull
    public static File getHooksDir(@Nonnull File repositoryDir) {
        return new File(repositoryDir, PATH_HOOKS);
    }

    /**
     * Creates (or overwrites) the callback and coordinator hooks with the latest version. Existing hooks may need
     * to be overwritten if they are changed during an upgrade. {@link #prepareHookScripts(File)} should have been
     * called to prepare the script layout, prior to installing them.
     * <p>
     * Note: Git requires its hooks to have Unix-style LF line endings. Windows-style CRLF endings will cause the
     * scripts to fail, which will, in turn, prevent pushes. Scripts are installed using a straight binary copy to
     * ensure platform-specific line endings are not applied. This is paired with using the Assembly plugin during
     * the build to ensure the resource included in the jar file always has LF line endings, even when it is built
     * on Windows.
     *
     * @param repositoryDir the repository in which hooks should be installed
     * @throws IOException if any scripts cannot be written, or cannot be set executable
     */
    public static void installHookScripts(File repositoryDir) throws IOException {
        File hooksDir = getHooksDir(repositoryDir);

        installHookScript(hooksDir, PRE_RECEIVE);
        installHookScript(hooksDir, POST_RECEIVE);
    }

    /**
     * Parses {@link RefChange RefChanges} from the provided {@code CharSequence}.
     *
     * @param input hook input, provided by Git when the script was invoked, containing change details
     * @return a collection of {@link RefChange RefChanges} which may be empty but never {@code null}
     */
    public static Collection<RefChange> parseRefChanges(@Nonnull CharSequence input) {
        try {
            return CharSource.wrap(input).readLines(new RefChangeLineProcessor());
        } catch (IOException e) {
            // This should never happen when reading from a CharSequence
            throw new IllegalStateException(e);
        }
    }

    /**
     * Creates the directory structure for hooks, moving any existing hooks into a subdirectory, but doesn't actually
     * install the hooks. {@link #installHookScripts(File)} should be called after this method to write the hooks.
     *
     * @param repositoryDir the repository in which the script layout should be prepared
     * @throws IOException if any directories in the script layout cannot be created
     */
    public static void prepareHookScripts(File repositoryDir) throws IOException {
        File hooksDir = getHooksDir(repositoryDir);

        setupHook(hooksDir, PRE_RECEIVE);
        setupHook(hooksDir, POST_RECEIVE);
    }

    private static RefChangeType getType(String fromHash, String toHash) {
        if (GitUtils.NULL_SHA1.equals(fromHash)) {
            return RefChangeType.ADD;
        }
        if (GitUtils.NULL_SHA1.equals(toHash)) {
            return RefChangeType.DELETE;
        }
        return RefChangeType.UPDATE;
    }

    private static void installHookScript(File hooksDir, String hookName) throws IOException {
        // install the callback to stash
        installHookScriptFromClasspath(SCRIPTS_HOOKS + hookName + "-callback",
                new File(getCallbackDir(hooksDir, hookName), "20_stash_callback"));

        // install the hook-delegation script that iterates over each of the scripts in the
        // subdir and executes them one at a time.
        installHookScriptFromClasspath(SCRIPTS_HOOKS + hookName + "-coordinator",
                new File(hooksDir, hookName));
    }

    private static void installHookScriptFromClasspath(String resourceName, File destinationFile) throws IOException {
        try (InputStream inputStream = new ClassPathResource(resourceName, GitHookUtils.class).getInputStream()) {
            IoUtils.copy(inputStream, destinationFile);
        }

        if (!destinationFile.setExecutable(true)) {
            throw new IOException(destinationFile.getAbsolutePath() + " could not be set executable.");
        }
    }

    private static void setupHook(File hooksDir, String hookName) throws IOException {
        File callbackDir = getCallbackDir(hooksDir, hookName);
        if (callbackDir.exists()) {
            // assume the hook has already been set up
            return;
        }

        if (!callbackDir.mkdirs()) {
            throw new IOException("Error setting up hook " + hookName + "; could not create hook subdirectory");
        }

        // move any pre-existing hook into the subdirectory
        File existingHook = new File(hooksDir, hookName);
        if (existingHook.exists()) {
            // there is a hook already installed, move it into the .d subdirectory
            Files.move(existingHook, new File(callbackDir, "10_custom"));
        }
    }

    private static class RefChangeLineProcessor implements LineProcessor<Collection<RefChange>> {

        private final ImmutableList.Builder<RefChange> builder = ImmutableList.builder();

        @Override
        public boolean processLine(@Nonnull String line) throws IOException {
            Matcher matcher = PATTERN_REF_CHANGE.matcher(line);
            if (matcher.matches()) {
                String fromHash = matcher.group(GROUP_FROM_HASH);
                String toHash = matcher.group(GROUP_TO_HASH);

                String refId = matcher.group(GROUP_REF_ID);
                boolean isTag = refId.startsWith("refs/tags/");
                MinimalRef ref = new SimpleMinimalRef.Builder()
                        .id(refId)
                        .displayId(isTag ? GitRefPattern.TAGS.unqualify(refId) : GitRefPattern.HEADS.unqualify(refId))
                        .type(isTag ? StandardRefType.TAG : StandardRefType.BRANCH)
                        .build();

                builder.add(new SimpleRefChange.Builder()
                        .ref(ref)
                        .fromHash(fromHash)
                        .toHash(toHash)
                        .type(getType(fromHash, toHash))
                        .build());
            }

            return true;
        }

        @Override
        public Collection<RefChange> getResult() {
            return builder.build();
        }
    }
}
