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

import com.atlassian.bitbucket.home.AbstractHomeUpdateHandler;
import com.atlassian.bitbucket.home.HomeUpdate;
import com.atlassian.bitbucket.i18n.I18nService;
import com.atlassian.stash.internal.scm.git.InternalGitConstants;
import com.atlassian.bitbucket.scm.git.GitException;
import com.atlassian.bitbucket.scm.git.GitScmConfig;
import com.atlassian.bitbucket.util.FileUtils;
import com.google.common.base.Charsets;
import com.google.common.collect.Lists;
import com.google.common.io.Files;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.*;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Rewrites fork alternates when the home directory is updated.
 * <p>
 * Since alternates must use absolute paths in order to be processed recursively, they are sensitive to changes in the
 * home directory location. This handler attempts to update them whenever the home directory is moved. During updating,
 * if any repository fails no further repositories are updated and any successfully-updated repositories are reverted
 * (to allow the home directory to be moved back to its old location).
 * <p>
 * If reverting an update fails, <i>a system administrator will have to manually fix the alternates</i>. This is likely
 * to result in a support request, so this handler outputs as much logging information as possible to facilitate that.
 *
 * @since 2.4
 */
public class GitAlternatesHomeUpdateHandler extends AbstractHomeUpdateHandler {

    /**
     * Extracts the repository ID from an alternate path. the leading {@code .+} strips away the home directory path,
     * which is the part that is being updated by this handler. Slashes in the pattern are matched regardless of their
     * direction, to facilitate handling when the OS is changed in addition to the home directory location.
     */
    public static final Pattern PATTERN_PATH = Pattern.compile(".+[/\\\\]data[/\\\\]repositories[/\\\\](\\d+)[/\\\\]objects");

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

    private final GitScmConfig config;
    private final I18nService i18nService;
    private final List<File> updated;

    public GitAlternatesHomeUpdateHandler(GitScmConfig config, I18nService i18nService) {
        this.config = config;
        this.i18nService = i18nService;

        updated = Lists.newArrayList();
    }

    /**
     * Finds any forks, which are identified by the presence of an {@code objects/info/alternates} file, and attempts
     * to rewrite their alternates from the old home directory to the new one.
     *
     * @param update provides the old and new home directories
     */
    @Override
    public void apply(HomeUpdate update) {
        File repositoriesDir = config.getRepositoriesDir();
        File[] forkDirs = repositoriesDir.listFiles(new FileFilter() {

            @Override
            public boolean accept(File file) {
                //If the file represents a directory, check for the presence of an alternates file. This handles two
                //checks in one shot: 1) Is it a git repository? and 2) Does it need to be updated?
                return file.isDirectory() && FileUtils.construct(file, InternalGitConstants.SUBPATH_ALTERNATES).isFile();
            }
        });

        if (forkDirs == null || forkDirs.length == 0) {
            //Happy path: There are no forks, so moving the home directory doesn't matter (for the git plugin at least)
            log.debug("No forks exist; no alternates need to be updated after home directory move");
        } else {
            log.warn("Updating alternates for {} fork(s) after home directory move", forkDirs.length);
            try {
                //Updating will fail fast, stopping at the first repository for which alternates cannot be updated,
                //to reduce the work of rolling back the update
                updateForks(forkDirs, update.getOldDir(), update.getNewDir(), Mode.UPDATE);
            } catch (GitException e) {
                //rollback(HomeUpdate) will not be called by the system, so if the update fails roll back any
                //repositories which were updated successfully
                rollback(update);

                throw e;
            }
        }
    }

    /**
     * Reverts alternates if the update was successfully {@link #apply(HomeUpdate) applied} but a subsequent handler
     * failed.
     *
     * @param update provides the old and new home directories
     */
    @Override
    public void rollback(HomeUpdate update) {
        if (updated.isEmpty()) {
            log.debug("No alternates were updated; no rollback is necessary");
        } else {
            log.warn("Attempting to revert alternates for {} fork(s) after failed update", updated.size());
            try {
                File[] forkDirs = updated.toArray(new File[updated.size()]);

                //Revert will attempt to process all of the listed forks, even if one or more fail.
                //
                //Note: The directories are _intentionally_ reversed here, as the goal is to rewrite any reference to
                //      the new location back to the old location
                updateForks(forkDirs, update.getNewDir(), update.getOldDir(), Mode.REVERT);
            } catch (GitException e) {
                log.error("Previously updated alternates could not be reverted", e);
            }
        }
    }

    /**
     * Evaluates the provided {@code alternate} to determine if it is in the specified {@code path}. This considers
     * paths in a more filesystem-like way than simple {@code String.startsWith(String)} checks.
     *
     * @param alternate the alternate to evaluate
     * @param path      the path to compare the alternate with
     * @return {@code true} if the alternate represents a file beneath the specified path; otherwise, {@code false}
     */
    private static boolean isAlternateInPath(String alternate, String path) {
        int length = path.length();

        //The +20 here is not completely arbitrary. It's assumed that "path" is a home directory path, which means the
        //structure beneath it is "known" to the system. Specifically, there should be "data", "repositories" and
        //"objects" directories in the alternate, for example. That's 22 characters for their names, plus 4 slashes:
        //between home and "data", "data" and "repositories", "repositories" and ID, ID and "objects" for a total of
        //26. 20 is used to allow a little leeway; the expectation is that the regular expression will handle the rest
        if (alternate.length() >= (length + 20) && alternate.startsWith(path)) {
            //Pathological: Handle the case where the alternate is in a directory that starts with the same base as the
            //path but is not actually in the path. For example, "/a/bb/enterprise-repo/objects" _looks_ like it's in
            //"/a/b", to a simple substring, but actually isn't. To be sure, the next character after the prefix must
            //be a slash (direction-agnostic, for platform changes)
            char next = alternate.charAt(length);
            if (next == '/' || next == '\\') {
                return true;
            }
        }

        return false;
    }

    /**
     * Rewrites the alternate, replacing {@code oldPath} with {@code newPath}.
     * <p>
     * There are two possible approaches, here:
     * <ul>
     *     <li>Pattern-match to extract the repository ID and rewrite the path completely</li>
     *     <li>Use string substitution to replace the {@code oldPath} with {@code newPath}</li>
     * </ul>
     * Both approaches have their pitfalls. String substitution requires slash normalisation to be done as a separate
     * step, for example, to handle platform migrations. Pattern-matching relies on the layout of the home directory,
     * and runs the risk of false positives.
     * <p>
     * The approach taken here uses a mix of both strategies. Only alternates {@link #isAlternateInPath(String, String)
     * in} the {@code oldPath} are rewritten, and a regular expression is used to extract the repository ID and rebuild
     * the alternate completely to handle platform migrations.
     *
     * @param filePath the path to the alternates file being updated
     * @param line     the alternate to rewrite
     * @param oldPath  the old home directory path
     * @param newPath  the new home directory path
     * @return the alternate, which may have been rewritten
     */
    private String rewriteAlternate(String filePath, String line, String oldPath, String newPath) {
        if (isAlternateInPath(line, oldPath)) {
            Matcher matcher = PATTERN_PATH.matcher(line);
            if (matcher.matches()) {
                String original = line;

                //The newPath is the current home directory. Any slashes in that path will already be in the
                //OS-appropriate direction. Beneath the home directory, we resupply "data" and "repositories",
                //followed by the repository ID extracted from the existing alternate, followed by "objects"
                //(since alternates point directly to objects directories). This reassembles the path, using
                //the OS-appropriate separator, allowing both simple moves from one path to another as well as
                //more complicated moves between operating systems.
                String[] components = {newPath, "data", "repositories", matcher.group(1), "objects"};
                line = StringUtils.join(components, File.separator);

                log.debug("Rewrote alternate from [{}] to [{}] in {}", original, line, filePath);
            } else {
                log.warn("In {}, \"{}\" looks like it references the old home location ({}), but it does not match " +
                        "the expected pattern for an alternate. It will not be updated", filePath, line, oldPath);
            }
        } else {
            log.warn("In {}, \"{}\" appears to be an alternate to a location outside the home directory. " +
                    "Such alternates are not created or managed by the system and will not be updated",
                    filePath, line);
        }

        return line;
    }

    /**
     * {@link #rewriteAlternate(String, String, String, String) Rewrites} each line in the specified alternates file to
     * replace any references to the old home location with the new location.
     *
     * @param alternates the alternates file to update
     * @param oldPath  the old home directory path
     * @param newPath  the new home directory path
     * @throws IOException if the alternates file cannot be read or written
     */
    private void updateAlternates(File alternates, String oldPath, String newPath) throws IOException {
        List<String> lines = Files.readLines(alternates, Charsets.UTF_8);

        try (Writer writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(alternates), Charsets.UTF_8))) {
            String filePath = alternates.getAbsolutePath();
            for (String line : lines) {
                writer.write(rewriteAlternate(filePath, line, oldPath, newPath));
                writer.write('\n'); //git requires alternates be written with UNIX line endings
            }
        }
    }

    /**
     * Iterates over each fork directory and {@link #updateAlternates(File, String, String) updates alternates}.
     * <p>
     * The {@link Mode modes} have slightly different behaviour:
     * <ul>
     *     <li>{@link Mode#UPDATE UPDATE} is <i>fail-fast</i>, to reduce the work of rolling back. The first repository
     *     which cannot be updated aborts processing for any remaining forks</li>
     *     <li>{@link Mode#REVERT REVERT} counts failures, and throws an exception is any rollbacks fail, but will
     *     attempt to process every fork even if one or more of them fail</li>
     * </ul>
     * Regardless of the mode, if one (or potentially more, when reverting) repository cannot be updated, an exception
     * will be thrown.
     *
     * @param forkDirs an array containing one or more fork directories in which alternates should be rewritten
     * @param oldPath  the old home directory path
     * @param newPath  the new home directory path
     * @param mode     the mode to use when applying the update
     * @throws GitException if one or more forks cannot be updated (regardless of {@link Mode mode})
     */
    private void updateForks(File[] forkDirs, String oldPath, String newPath, Mode mode) {
        //Buckle up; this is going to get terrible. For each fork, we need to update its alternate to point to a
        //repository under the new home directory path. If anything goes wrong, it's going to be painful.
        int failed = 0;
        for (File forkDir : forkDirs) {
            File alternates = FileUtils.construct(forkDir, InternalGitConstants.SUBPATH_ALTERNATES);
            if (!alternates.isFile()) {
                //Should be impossible, but... If the repository doesn't have an alternates file, move on. Don't
                //consider it a success or a failure
                continue;
            }

            try {
                updateAlternates(alternates, oldPath, newPath);

                log.info("Successfully {} alternates in {}", mode.getDone(), forkDir.getAbsolutePath());
                updated.add(forkDir);
            } catch (IOException e) {
                log.error("Failed to " + mode.getDoing() + " alternates in " + forkDir.getAbsolutePath(), e);

                if (mode.isFailFast()) {
                    //While updating, fail fast. The first repository for which an alternate cannot be updated
                    //aborts processing for any remaining repositories. This reduces the number of repositories
                    //for which alternates need to be reverted to try and undo the update
                    throw new GitException(i18nService.createKeyedMessage("bitbucket.git.home.updatefailed"));
                } else {
                    //While reverting, track how many repositories can't be reverted for a summary exception that
                    //is thrown at the end. The goal is to revert as many repositories as possible
                    ++failed;
                }
            }
        }

        if (failed > 0) {
            //At least one revert failed, which means there are some repositories with alternates pointing to the
            //old home directory and some with alternates pointing to the new one. Only an administrator is going
            //to be able to resolve this state
            throw new GitException(i18nService.createKeyedMessage("bitbucket.git.home.revertfailed", failed, forkDirs.length));
        }
        log.info("Successfully {} alternates for {} forks", mode.getDone(), forkDirs.length);
    }

    /**
     * Describes the possible modes for {@link GitAlternatesHomeUpdateHandler#updateForks(File[], String, String, Mode)
     * updating forks}.
     */
    private enum Mode {

        REVERT("revert", "reverted", false),
        UPDATE("update", "updated", true);

        private final String doing;
        private final String done;
        private final boolean failFast;

        Mode(String doing, String done, boolean failFast) {
            this.doing = doing;
            this.done = done;
            this.failFast = failFast;
        }

        public String getDoing() {
            return doing;
        }

        public String getDone() {
            return done;
        }

        public boolean isFailFast() {
            return failFast;
        }
    }
}
