/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.jackrabbit.oak.commons;

import java.util.HashSet;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
 * Utility methods to parse a path.
 * <p>
 * Each method validates the input, except if the system property
 * {packageName}.SKIP_VALIDATION is set, in which case only minimal validation
 * takes place within this function, so when the parameter is an illegal path,
 * the the result of this method is undefined.
 */
public final class PathUtils {

    public static final String ROOT_PATH = "/";
    public static final String ROOT_NAME = "";

    private static final Pattern SNS_PATTERN =
            Pattern.compile("(.+)\\[[1-9][0-9]*\\]$");

    private PathUtils() {
        // utility class
    }

    /**
     * Whether the path is the root path ("/").
     *
     * @param path the path
     * @return whether this is the root
     */
    public static boolean denotesRoot(String path) {
        assert isValid(path) : "Invalid path ["+path+"]";

        return denotesRootPath(path);
    }

    private static boolean denotesRootPath(String path) {
        return ROOT_PATH.equals(path);
    }

    /**
     * @param element The path segment to check for being the current element
     * @return {@code true} if the specified element equals "."; {@code false} otherwise.
     */
    public static boolean denotesCurrent(String element) {
        return ".".equals(element);
    }

    /**
     * @param element The path segment to check for being the parent element
     * @return {@code true} if the specified element equals ".."; {@code false} otherwise.
     */
    public static boolean denotesParent(String element) {
        return "..".equals(element);
    }

    /**
     * Whether the path is absolute (starts with a slash) or not.
     *
     * @param path the path
     * @return true if it starts with a slash
     */
    public static boolean isAbsolute(String path) {
        assert isValid(path) : "Invalid path ["+path+"]";

        return isAbsolutePath(path);
    }

    private static boolean isAbsolutePath(String path) {
        return !path.isEmpty() && path.charAt(0) == '/';
    }

    /**
     * Get the parent of a path. The parent of the root path ("/") is the root
     * path.
     *
     * @param path the path
     * @return the parent path
     */
    @NotNull
    public static String getParentPath(String path) {
        return getAncestorPath(path, 1);
    }

    /**
     * Get the nth ancestor of a path. The 1st ancestor is the parent path,
     * 2nd ancestor the grandparent path, and so on...
     * <p>
     * If {@code nth <= 0}, the path argument is returned as is.
     *
     * @param path the path
     * @param nth  indicates the ancestor level for which the path should be
     *             calculated.
     * @return the ancestor path
     */
    @NotNull
    public static String getAncestorPath(String path, int nth) {
        assert isValid(path) : "Invalid path ["+path+"]";

        if (path.isEmpty() || denotesRootPath(path)
                || nth <= 0) {
            return path;
        }

        int end = path.length() - 1;
        int pos = -1;
        while (nth-- > 0) {
            pos = path.lastIndexOf('/', end);
            if (pos > 0) {
                end = pos - 1;
            } else if (pos == 0) {
                return ROOT_PATH;
            } else {
                return "";
            }
        }

        return path.substring(0, pos);
    }

    /**
     * Get the last element of the (absolute or relative) path. The name of the
     * root node ("/") and the name of the empty path ("") is the empty path.
     *
     * @param path the complete path
     * @return the last element
     */
    @NotNull
    public static String getName(String path) {
        assert isValid(path) : "Invalid path ["+path+"]";

        if (path.isEmpty() || denotesRootPath(path)) {
            return ROOT_NAME;
        }
        int end = path.length() - 1;
        int pos = path.lastIndexOf('/', end);
        if (pos != -1) {
            return path.substring(pos + 1, end + 1);
        }
        return path;
    }

    /**
     * Returns the given name without the possible SNS index suffix. If the
     * name does not contain an SNS index, then it is returned as-is.
     *
     * @param name name with a possible SNS index suffix
     * @return name without the SNS index suffix
     */
    @NotNull
    public static String dropIndexFromName(@NotNull String name) {
        if (!name.endsWith("]")) {
            return name;
        }
        Matcher matcher = SNS_PATTERN.matcher(name);
        if (matcher.matches()) {
            return matcher.group(1);
        }
        return name;
    }

    /**
     * Calculate the number of elements in the path. The root path has zero
     * elements.
     *
     * @param path the path
     * @return the number of elements
     */
    public static int getDepth(String path) {
        assert isValid(path) : "Invalid path ["+path+"]";

        if (path.isEmpty()) {
            return 0;
        }
        int count = 1, i = 0;
        if (isAbsolutePath(path)) {
            if (denotesRootPath(path)) {
                return 0;
            }
            i++;
        }
        while (true) {
            i = path.indexOf('/', i) + 1;
            if (i == 0) {
                return count;
            }
            count++;
        }
    }

    /**
     * Returns an {@code Iterable} for the path elements. The root path ("/") and the
     * empty path ("") have zero elements.
     *
     * @param path the path
     * @return an Iterable for the path elements
     */
    @NotNull
    public static Iterable<String> elements(final String path) {
        assert isValid(path) : "Invalid path ["+path+"]";

        return new Iterable<String>() {
            @Override
            public Iterator<String> iterator() {
                return new Iterator<String>() {
                    int pos = isAbsolute(path) ? 1 : 0;
                    String next;

                    @Override
                    public boolean hasNext() {
                        if (next == null) {
                            if (pos >= path.length()) {
                                return false;
                            }
                            int i = path.indexOf('/', pos);
                            if (i < 0) {
                                next = path.substring(pos);
                                pos = path.length();
                            } else {
                                next = path.substring(pos, i);
                                pos = i + 1;
                            }
                        }
                        return true;
                    }

                    @Override
                    public String next() {
                        if (hasNext()) {
                            String next = this.next;
                            this.next = null;
                            return next;
                        }
                        throw new NoSuchElementException();
                    }

                    @Override
                    public void remove() {
                        throw new UnsupportedOperationException("remove");
                    }
                };
            }
        };
    }

    /**
     * Concatenate path elements.
     *
     * @param parentPath    the parent path
     * @param relativePaths the relative path elements to add
     * @return the concatenated path
     */
    @NotNull
    public static String concat(String parentPath, String... relativePaths) {
        assert isValid(parentPath) : "Invalid parent path ["+parentPath+"]";
        int parentLen = parentPath.length();
        int size = relativePaths.length;
        StringBuilder buff = new StringBuilder(parentLen + size * 5);
        buff.append(parentPath);
        boolean needSlash = parentLen > 0 && !denotesRootPath(parentPath);
        for (String s : relativePaths) {
            assert isValid(s);
            if (isAbsolutePath(s)) {
                throw new IllegalArgumentException("Cannot append absolute path " + s);
            }
            if (!s.isEmpty()) {
                if (needSlash) {
                    buff.append('/');
                }
                buff.append(s);
                needSlash = true;
            }
        }
        return buff.toString();
    }

    /**
     * Concatenate path elements.
     *
     * @param parentPath the parent path
     * @param subPath    the subPath path to add
     * @return the concatenated path
     */
    @NotNull
    public static String concat(String parentPath, String subPath) {
        assert isValid(parentPath) : "Invalid parent path ["+parentPath+"]";
        assert isValid(subPath) : "Invalid sub path ["+subPath+"]";
        // special cases
        if (parentPath.isEmpty()) {
            return subPath;
        } else if (subPath.isEmpty()) {
            return parentPath;
        } else if (isAbsolutePath(subPath)) {
            throw new IllegalArgumentException("Cannot append absolute path " + subPath);
        }
        StringBuilder buff = new StringBuilder(parentPath.length() + subPath.length() + 1);
        buff.append(parentPath);
        if (!denotesRootPath(parentPath)) {
            buff.append('/');
        }
        buff.append(subPath);
        return buff.toString();
    }

    /**
     * Relative path concatenation.
     *
     * @param relativePaths relative paths
     * @return the concatenated path or {@code null} if the resulting path is empty.
     */
    @Nullable
    public static String concatRelativePaths(String... relativePaths) {
        StringBuilder result = new StringBuilder();
        for (String path : relativePaths) {
            if (path != null && !path.isEmpty()) {
                int i0 = 0;
                int i1 = path.length();
                while (i0 < i1 && path.charAt(i0) == '/') {
                    i0++;
                }
                while (i1 > i0 && path.charAt(i1-1) == '/') {
                    i1--;
                }
                if (i1 > i0) {
                    if (result.length() > 0) {
                        result.append('/');
                    }
                    result.append(path.substring(i0, i1));
                }
            }
        }
        return result.length() == 0 ? null : result.toString();
    }

    /**
     * Check if a path is a (direct or indirect) ancestor of another path.
     *
     * @param ancestor the ancestor path
     * @param path     the potential offspring path
     * @return true if the path is an offspring of the ancestor
     */
    public static boolean isAncestor(String ancestor, String path) {
        assert isValid(ancestor) : "Invalid parent path ["+ancestor+"]";
        assert isValid(path) : "Invalid path ["+path+"]";
        if (ancestor.isEmpty() || path.isEmpty()) {
            return false;
        }
        if (denotesRoot(ancestor)) {
            if (denotesRoot(path)) {
                return false;
            }
        } else {
            ancestor += "/";
        }
        return path.startsWith(ancestor);
    }

    /**
     * Relativize a path wrt. a parent path such that
     * {@code relativize(parentPath, concat(parentPath, path)) == paths}
     * holds.
     *
     * @param parentPath parent pth
     * @param path       path to relativize
     * @return relativized path
     */
    @NotNull
    public static String relativize(String parentPath, String path) {
        assert isValid(parentPath) : "Invalid parent path ["+parentPath+"]";
        assert isValid(path) : "Invalid path ["+path+"]";

        if (parentPath.equals(path)) {
            return "";
        }

        String prefix = denotesRootPath(parentPath)
                ? parentPath
                : parentPath + '/';

        if (path.startsWith(prefix)) {
            return path.substring(prefix.length());
        }
        throw new IllegalArgumentException("Cannot relativize " + path + " wrt. " + parentPath);
    }

    /**
     * Get the index of the next slash.
     *
     * @param path  the path
     * @param index the starting index
     * @return the index of the next slash (possibly the starting index), or -1
     *         if not found
     */
    public static int getNextSlash(String path, int index) {
        assert isValid(path) : "Invalid path ["+path+"]";

        return path.indexOf('/', index);
    }

    /**
     * Check if the path is valid, and throw an IllegalArgumentException if not.
     * A valid path is absolute (starts with a '/') or relative (doesn't start
     * with '/'), and contains none or more elements. A path may not end with
     * '/', except for the root path. Elements itself must be at least one
     * character long.
     *
     * @param path the path
     */
    public static void validate(String path) {
        if (path.isEmpty() || denotesRootPath(path)) {
            return;
        } else if (path.charAt(path.length() - 1) == '/') {
            throw new IllegalArgumentException("Path may not end with '/': " + path);
        }
        char last = 0;
        for (int index = 0, len = path.length(); index < len; index++) {
            char c = path.charAt(index);
            if (c == '/') {
                if (last == '/') {
                    throw new IllegalArgumentException("Path may not contains '//': " + path);
                }
            }
            last = c;
        }
    }

    /**
     * Check if the path is valid. A valid path is absolute (starts with a '/')
     * or relative (doesn't start with '/'), and contains none or more elements.
     * A path may not end with '/', except for the root path. Elements itself must
     * be at least one character long.
     *
     * @param path the path
     * @return {@code true} iff the path is valid.
     */
    public static boolean isValid(String path) {
        if (path.isEmpty() || denotesRootPath(path)) {
            return true;
        } else if (path.charAt(path.length() - 1) == '/') {
            return false;
        }
        char last = 0;
        for (int index = 0, len = path.length(); index < len; index++) {
            char c = path.charAt(index);
            if (c == '/') {
                if (last == '/') {
                    return false;
                }
            }
            last = c;
        }
        return true;
    }

    /**
     * Unify path inclusions and exclusions.
     * <ul>
     * <li>A path in {@code includePaths} is only retained if {@code includePaths} contains
     * none of its ancestors and {@code excludePaths} contains neither of its ancestors nor
     * that path itself.</li>
     * <li>A path in {@code excludePaths} is only retained if {@code includePaths} contains
     * an ancestor of that path.</li>
     * </ul>
     *
     * When a set of paths is <em>filtered wrt.</em> {@code includePaths} and {@code excludePaths}
     * by first excluding all paths that have an ancestor or are contained in {@code excludePaths}
     * and then including all paths that have an ancestor or are contained in {@code includePaths}
     * then the result is the same regardless whether the {@code includePaths} and
     * {@code excludePaths} sets have been run through this method or not.
     *
     * @param includePaths set of paths to be included
     * @param excludedPaths set of paths to be excluded
     */
    public static void unifyInExcludes(Set<String> includePaths, Set<String> excludedPaths) {
        Set<String> retain = new HashSet<>();
        Set<String> includesRemoved = new HashSet<>();
        for (String include : includePaths) {
            for (String exclude : excludedPaths) {
                if (exclude.equals(include) || isAncestor(exclude, include)) {
                    includesRemoved.add(include);
                } else if (isAncestor(include, exclude)) {
                    retain.add(exclude);
                }
            }

            //Remove redundant includes /a, /a/b -> /a
            for (String include2 : includePaths) {
                if (isAncestor(include, include2)) {
                    includesRemoved.add(include2);
                }
            }
        }
        includePaths.removeAll(includesRemoved);
        excludedPaths.retainAll(retain);
    }


}
