package com.atlassian.bitbucket.mesh.git;

import com.atlassian.bitbucket.mesh.git.ref.Ref;
import com.atlassian.bitbucket.mesh.util.ShaUtils;
import com.google.common.collect.ImmutableList;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.util.List;
import java.util.regex.Pattern;

import static com.google.common.base.Preconditions.checkArgument;
import static java.util.Objects.requireNonNull;
import static org.apache.commons.lang3.StringUtils.splitPreserveAllTokens;

public final class GitUtils {

    /**
     * The null tree, referenced by empty commits and when performing a {@code git diff} or {@code git diff-tree}
     * to see all changes.
     */
    public static final String NULL_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
    public static final int TYPE_NULL = 0;

    /**
     * A constant for the {@code int} value of the first three characters in a mode which indicates the
     * entry is a directory.
     */
    public static final int TYPE_DIRECTORY = 40; //"040" -> 40
    /**
     * A constant for the {@code int} value of the first three characters in a mode which indicates the
     * entry is a file.
     */
    public static final int TYPE_FILE = 100;
    //These are special modes as outlined in the Git index format. More details are available here:
    //https://www.kernel.org/pub/software/scm/git/docs/technical/index-format.txt
    public static final int TYPE_SYMBOLIC_LINK = 120;
    public static final int TYPE_SUBMODULE_LINK = 160;

    private static final List<Charset> CHARSETS = ImmutableList.of(StandardCharsets.UTF_8, StandardCharsets.ISO_8859_1);
    /**
     * Defines the length at which lines are wrapped. Wrapped lines have a newline appended, so the length used is
     * 79. The intended target for wrapped messages is the console, so the lines are wrapped short to make them more
     * easily readable in that environment.
     */
    private static final int LINE_WRAP_LENGTH = 79;
    private static final char REPLACEMENT_CHAR = '\uFFFD';
    private static final Pattern UNESCAPE_PATTERN = Pattern.compile("\".*\"");

    private GitUtils() {
        throw new UnsupportedOperationException("Static utility class - not for instantiation");
    }

    /**
     * Determines whether the entry is a directory from the provided mode. If the entry {@link #TYPE_DIRECTORY
     * is a directory}, {@code true} will be returned.
     *
     * @param value the mode
     * @return {@code true} for {@link #TYPE_DIRECTORY directories}; otherwise, {@code false}
     */
    public static boolean isDirectory(@Nonnull String value) {
        return getType(value) == TYPE_DIRECTORY;
    }

    /**
     * Extracts the user-level permission bit from the provided mode, after checking the file's type. If the file
     * {@link #TYPE_NULL does not exist}, {@code null} will be returned. If the file {@link #TYPE_SYMBOLIC_LINK is
     * a symbolic link}, {@code true} will be returned. Otherwise, for all other file types, the user's permission
     * bit is checked. If it is odd, then the file is executable.
     * <p>
     * The mode provided is expected to be a git-style mode, which is 6 digits long. The first three digits are the
     * file's type, and the second three are the user, group and world permissions (Unix-style) for the file. Each
     * permission digit is the sum of:
     * <ul>
     *     <li>{@code 4}: read</li>
     *     <li>{@code 2}: write</li>
     *     <li>{@code 1}: execute</li>
     * </ul>
     * So the only way any given digit will be odd is if that grouping has execute permissions.
     *
     * @param value the file mode
     * @return {@code null} for {@link #TYPE_NULL nonexistent files}, {@code true} for {@link #TYPE_SYMBOLIC_LINK
     *         symbolic links} and {@code true} or {@code false}, based on the user-level permission, otherwise
     */
    public static Boolean isExecutable(@Nonnull String value) {
        int type = getType(value);
        if (type == TYPE_NULL) {
            //For files that don't exist, there is no executable-ness to report
            return null;
        }
        if (type == TYPE_SYMBOLIC_LINK) {
            //For symbolic links, displaying them on the filesystem (with ls -l) always shows them as executable,
            //because the _real_ executable-ness of the file can't be known until the link is dereferenced. We'll
            //emulate that behaviour here
            return true;
        }
        int mode = Integer.parseInt(value.substring(3));

        //Look at the user's permission and, if it's odd then the file is executable
        return ((mode / 100) % 2) == 1;
    }

    /**
     * Determines whether the entry is a file from the provided mode. If the entry {@link #TYPE_FILE is a file},
     * {@code true} will be returned.
     *
     * @param value the mode
     * @return {@code true} for {@link #TYPE_FILE files}; otherwise, {@code false}
     */
    public static boolean isFile(@Nonnull String value) {
        return getType(value) == TYPE_FILE;
    }

    /**
     * Determines whether the entry is a submodule from the provided mode. If the entry {@link #TYPE_SUBMODULE_LINK
     * is a submodule}, {@code true} will be returned.
     *
     * @param value the file mode
     * @return {@code true} for {@link #TYPE_SUBMODULE_LINK submodules}; otherwise, {@code false}
     */
    public static boolean isSubmodule(@Nonnull String value) {
        return getType(value) == TYPE_SUBMODULE_LINK;
    }

    /**
     * Determines whether the entry is a submodule from the provided mode. If the entry {@link #TYPE_SYMBOLIC_LINK
     * is a symbolic link}, {@code true} will be returned.
     *
     * @param value the file mode
     * @return {@code true} for {@link #TYPE_SYMBOLIC_LINK symbolic links}; otherwise, {@code false}
     */
    public static boolean isSymbolicLink(@Nonnull String value) {
        return getType(value) == TYPE_SYMBOLIC_LINK;
    }

    public static boolean isHash(String value) {
        return ShaUtils.isHash(value);
    }

    /**
     * Determines whether the hash on the {@code left} {@link Ref ref} matches the hash for {@code right} ref. The
     * {@code right} value may be {@code null} to simplify using this method in conjunction with
     * {@link com.atlassian.bitbucket.mesh.git.ref.RefManager#resolveRefs resolved refs}.
     *
     * @param left  the ref to compare the resolved branch to
     * @param right the resolved branch corresponding to the provided ref
     * @return {@code true} if the {@code right} ref is not {@code null} and its hash matches the {@code left} ref's
     */
    public static boolean refsMatch(@Nonnull Ref left, @Nullable Ref right) {
        return right != null && requireNonNull(left, "left").getLatestCommit().equals(right.getLatestCommit());
    }

    public static String unescape(String s) {
        if (s == null) {
            return null;
        }

        int start = 0;
        int end = s.length();
        if (s.matches("\".*\"")) {
            start++;
            end--;
        }

        byte[] bytes = new byte[end - start];
        int index = 0;
        for (int i = start; i < end; ++i) {
            int c = s.charAt(i);
            if (c == '\\') {
                i++;
                if (i < end) {
                    c = s.charAt(i);
                    switch (c) {
                        case 't':
                            c = '\t';
                            break;
                        case 'n':
                            c = '\n';
                            break;
                        case '"':
                            c = '"';
                            break;
                        case '\\':
                            c = '\\';
                            break;
                        default:
                            if (Character.isDigit(c)) {
                                // octal
                                c = c - '0';
                                i++;
                                if (i < end) {
                                    c = c * 8 + s.charAt(i) - '0';
                                    i++;
                                    if (i < end) {
                                        c = c * 8 + s.charAt(i) - '0';
                                    }
                                }
                            }
                            break;
                    }
                }
            }
            bytes[index++] = (byte) c;
        }

        for (Charset charset : CHARSETS) {
            String normalized = Normalizer.normalize(new String(bytes, 0, index, charset), Normalizer.Form.NFC);

            //This check won't always work. It can give us false positives, but it is better than nothing.
            //Ideally we should allow the user to set the encoding of a repository
            if (normalized.indexOf(REPLACEMENT_CHAR) == -1) {
                return normalized;
            }
        }

        return new String(bytes);
    }

    public static String unescapeQuoted(String s) {
        if (s != null && UNESCAPE_PATTERN.matcher(s).matches()) {
            return unescape(s);
        }
        return s;
    }

    /**
     * Wraps the provided {@code line} at {@link #LINE_WRAP_LENGTH 79} characters.
     * <p>
     * The approach taken here is fairly naive, but it should work in the general case. The line is split on whitespace
     * and reassembled word by word. Each time appending a word would exceed 79 characters, the line is wrapped. We use
     * 79 instead of the default terminal width of 80 in case of trailing new lines.
     *
     * @param line       the line to perform wrapping on
     * @return a line with wrapping applied such that no individual line is longer than the specified length
     */
    public static String wordWrap(String line) {
        //Edge cases: Line is already short enough and line contains no spaces, just bail out
        if (line.length() < LINE_WRAP_LENGTH || !line.contains(" ")) {
            return line;
        }

        //Preserve tokens when two separators are adjacent. This way we can detect double-newlines.
        String[] words = splitPreserveAllTokens(line);
        StringBuilder builder = new StringBuilder(line.length() + 100);

        int currentLength = 0;
        for (String word : words) {
            if (word.isEmpty()) {
                //Empty token means two instances of whitespace in a row. This should only happen with newlines.
                builder.append("\n\n");
                currentLength = 0;

                continue;
            }

            if ((currentLength + word.length() + 1) > LINE_WRAP_LENGTH) {
                //If appending this word to the line would exceed the length, wrap it
                builder.append("\n");
                currentLength = 0;
            } else if (currentLength > 0) {
                //If we're some depth into the line, append a space before the word
                builder.append(" ");
                ++currentLength;
            }
            builder.append(word);
            currentLength += word.length();
        }

        return builder.toString();
    }

    private static int getType(String value) {
        requireNonNull(value, "value");
        checkArgument(value.length() == 6, "A git-style mode, 6 digits long, is required");

        //First three digits are the file type. We need special handling for symbolic links
        return Integer.parseInt(value.substring(0, 3));
    }
}