/*
 * Copyright (c) 2012-2018 Red Hat, Inc.
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *   Red Hat, Inc. - initial API and implementation
 */
package org.eclipse.che.ide.resource;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Strings.nullToEmpty;

import com.google.common.base.Objects;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

/**
 * Client side implementation for the resource path.
 *
 * <p>A path is an ordered collection of string segments, separated by a standard separator
 * character, "/". A path may also have a leading and/or a trailing separator.
 *
 * <p>Note that paths are value objects; all operations on paths return a new path; the path that is
 * operated on is unscathed.
 *
 * <p>This class is not intended to be extended by clients.
 *
 * @author Vlad Zhukovskyi
 * @since 4.0.0-RC7
 */
public final class Path {

  /** Path separator character constant "/" used in paths. */
  public static char SEPARATOR = '/';

  /** Device separator character constant ":" used in paths. */
  public static char DEVICE_SEPARATOR = ':';

  /** masks for separator values */
  private static final int HAS_LEADING = 1;

  private static final int IS_UNC = 2;
  private static final int HAS_TRAILING = 4;

  private static final int ALL_SEPARATORS = HAS_LEADING | IS_UNC | HAS_TRAILING;

  /** Constant empty string value. */
  private static final String EMPTY_STRING = "";

  /** Constant value indicating no segments */
  private static final String[] NO_SEGMENTS = new String[0];

  /** Constant value containing the empty path with no device. */
  public static final Path EMPTY = new Path(EMPTY_STRING);

  /** Mask for all bits that are involved in the hash code */
  private static final int HASH_MASK = ~HAS_TRAILING;

  /** Constant root path string (<code>"/"</code>). */
  private static final String ROOT_STRING = "/";

  /** Constant value containing the root path with no device. */
  public static final Path ROOT = new Path(ROOT_STRING);

  /** The device id string. May be null if there is no device. */
  private String device = null;

  /** The path segments */
  private String[] segments;

  /** flags indicating separators (has leading, is UNC, has trailing) */
  private int separators;

  /**
   * Constructs a new path from the given string path. The string path must represent a valid file
   * system path on the local file system. The path is canonicalized and double slashes are removed
   * except at the beginning. (to handle UNC paths). All forward slashes ('/') are treated as
   * segment delimiters, and any segment and device delimiters for the local file system are also
   * respected.
   *
   * @param pathString the portable string path
   * @since 4.0.0-RC5
   */
  public static Path valueOf(String pathString) {
    return new Path(pathString);
  }

  /* (Intentionally not included in javadoc)
   * Private constructor.
   */
  private Path() {
    // not allowed
  }

  /**
   * Constructs a new path from the given string path. The string path must represent a valid file
   * system path on the local file system. The path is canonicalized and double slashes are removed
   * except at the beginning. (to handle UNC paths). All forward slashes ('/') are treated as
   * segment delimiters, and any segment and device delimiters for the local file system are also
   * respected (such as colon (':') and backslash ('\') on some file systems).
   *
   * @param fullPath the string path
   * @see #isValidPath(String)
   * @since 4.0.0-RC5
   */
  public Path(String fullPath) {
    initialize(null, fullPath);
  }

  /**
   * Constructs a new path from the given device id and string path. The given string path must be
   * valid. The path is canonicalized and double slashes are removed except at the beginning (to
   * handle UNC paths). All forward slashes ('/') are treated as segment delimiters, and any segment
   * delimiters for the local file system are also respected (such as backslash ('\') on some file
   * systems).
   *
   * @param device the device id
   * @param path the string path
   * @see #isValidPath(String)
   * @see #setDevice(String)
   * @since 4.0.0-RC5
   */
  public Path(String device, String path) {
    initialize(device, path);
  }

  /* (Intentionally not included in javadoc)
   * Private constructor.
   */
  private Path(String device, String[] segments, int _separators) {
    // no segment validations are done for performance reasons
    this.segments = segments;
    this.device = device;
    // hash code is cached in all but the bottom three bits of the separators field
    this.separators = (computeHashCode() << 3) | (_separators & ALL_SEPARATORS);
  }

  /**
   * Returns a new path which is the same as this path but with the given file extension added. If
   * this path is empty, root or has a trailing separator, this path is returned. If this path
   * already has an extension, the existing extension is left and the given extension simply
   * appended. Clients wishing to replace the current extension should first remove the extension
   * and then add the desired one.
   *
   * <p>The file extension portion is defined as the string following the last period (".")
   * character in the last segment. The given extension should not include a leading ".".
   *
   * @param extension the file extension to append
   * @return the new path
   * @see #getFileExtension()
   * @since 4.0.0-RC5
   */
  public Path addFileExtension(String extension) {
    if (isRoot() || isEmpty() || hasTrailingSeparator()) return this;
    int len = segments.length;
    String[] newSegments = new String[len];
    System.arraycopy(segments, 0, newSegments, 0, len - 1);
    newSegments[len - 1] = segments[len - 1] + '.' + extension;
    return new Path(device, newSegments, separators);
  }

  /**
   * Returns a path with the same segments as this path but with a trailing separator added. This
   * path must have at least one segment.
   *
   * <p>If this path already has a trailing separator, this path is returned.
   *
   * @return the new path
   * @see #hasTrailingSeparator()
   * @see #removeTrailingSeparator()
   * @since 4.0.0-RC5
   */
  public Path addTrailingSeparator() {
    if (hasTrailingSeparator() || isRoot()) {
      return this;
    }
    if (isEmpty()) {
      return new Path(device, segments, HAS_LEADING);
    }
    return new Path(device, segments, separators | HAS_TRAILING);
  }

  /**
   * Returns a path with the same segments as this path but with a leading separator added.
   *
   * <p>If this path already has a leading separator, this path is returned.
   *
   * @return the new path
   * @see #hasTrailingSeparator()
   */
  public Path addLeadingSeparator() {
    if (hasLeadingSeparator() || isRoot()) {
      return this;
    }
    if (isEmpty()) {
      return new Path(device, segments, HAS_LEADING);
    }
    return new Path(device, segments, separators | HAS_LEADING);
  }

  /**
   * Returns the canonicalized path obtained from the concatenation of the given path's segments to
   * the end of this path. If the given path has a trailing separator, the result will have a
   * trailing separator. The device id of this path is preserved (the one of the given path is
   * ignored). Duplicate slashes are removed from the path except at the beginning where the path is
   * considered to be UNC.
   *
   * @param path the path to concatenate
   * @return the new path
   * @since 4.0.0-RC5
   */
  public Path append(Path path) {
    // optimize some easy cases
    if (path == null || path.segmentCount() == 0) return this;
    // these call chains look expensive, but in most cases they are no-ops
    if (this.isEmpty()) return path.setDevice(device).makeRelative().makeUNC(isUNC());
    if (this.isRoot()) return path.setDevice(device).makeAbsolute().makeUNC(isUNC());

    // concatenate the two segment arrays
    int myLen = segments.length;
    int tailLen = path.segmentCount();
    String[] newSegments = new String[myLen + tailLen];
    System.arraycopy(segments, 0, newSegments, 0, myLen);
    for (int i = 0; i < tailLen; i++) {
      newSegments[myLen + i] = path.segment(i);
    }
    // use my leading separators and the tail's trailing separator
    Path result =
        new Path(
            device,
            newSegments,
            (separators & (HAS_LEADING | IS_UNC))
                | (path.hasTrailingSeparator() ? HAS_TRAILING : 0));
    String tailFirstSegment = newSegments[myLen];
    if (tailFirstSegment.equals("..") || tailFirstSegment.equals(".")) {
      result.canonicalize();
    }
    return result;
  }

  /**
   * Returns the canonicalized path obtained from the concatenation of the given string path to the
   * end of this path. The given string path must be a valid path. If it has a trailing separator,
   * the result will have a trailing separator. The device id of this path is preserved (the one of
   * the given string is ignored). Duplicate slashes are removed from the path except at the
   * beginning where the path is considered to be UNC.
   *
   * @param path the string path to concatenate
   * @return the new path
   * @see #isValidPath(String)
   * @since 4.0.0-RC5
   */
  public Path append(String path) {
    // optimize addition of a single segment
    if (path.indexOf(SEPARATOR) == -1
        && path.indexOf("\\") == -1
        && path.indexOf(DEVICE_SEPARATOR) == -1) {
      int tailLength = path.length();
      if (tailLength < 3) {
        // some special cases
        if (tailLength == 0 || ".".equals(path)) {
          return this;
        }
        if ("..".equals(path)) return removeLastSegments(1);
      }
      // just add the segment
      int myLen = segments.length;
      String[] newSegments = new String[myLen + 1];
      System.arraycopy(segments, 0, newSegments, 0, myLen);
      newSegments[myLen] = path;
      return new Path(device, newSegments, separators & ~HAS_TRAILING);
    }
    // go with easy implementation
    return append(new Path(path));
  }

  /**
   * Destructively converts this path to its canonical form.
   *
   * <p>In its canonical form, a path does not have any "." segments, and parent references ("..")
   * are collapsed where possible.
   *
   * @return true if the path was modified, and false otherwise
   * @since 4.0.0-RC5
   */
  private boolean canonicalize() {
    // look for segments that need canonicalizing
    for (int i = 0, max = segments.length; i < max; i++) {
      String segment = segments[i];
      if (segment.charAt(0) == '.' && (segment.equals("..") || segment.equals("."))) {
        // path needs to be canonicalized
        collapseParentReferences();
        // paths of length 0 have no trailing separator
        if (segments.length == 0) separators &= (HAS_LEADING | IS_UNC);
        // recompute hash because canonicalize affects hash
        separators = (separators & ALL_SEPARATORS) | (computeHashCode() << 3);
        return true;
      }
    }
    return false;
  }

  /** Destructively removes all occurrences of ".." segments from this path. */
  private void collapseParentReferences() {
    int segmentCount = segments.length;
    String[] stack = new String[segmentCount];
    int stackPointer = 0;
    for (String segment : segments) {
      if (segment.equals("..")) {
        if (stackPointer == 0) {
          // if the stack is empty we are going out of our scope
          // so we need to accumulate segments.  But only if the original
          // path is relative.  If it is absolute then we can't go any higher than
          // root so simply toss the .. references.
          if (!isAbsolute()) stack[stackPointer++] = segment; // stack push
        } else {
          // if the top is '..' then we are accumulating segments so don't pop
          if ("..".equals(stack[stackPointer - 1])) stack[stackPointer++] = "..";
          else stackPointer--;
          // stack pop
        }
        // collapse current references
      } else if (!segment.equals(".") || segmentCount == 1)
        stack[stackPointer++] = segment; // stack push
    }
    // if the number of segments hasn't changed, then no modification needed
    if (stackPointer == segmentCount) return;
    // build the new segment array backwards by popping the stack
    String[] newSegments = new String[stackPointer];
    System.arraycopy(stack, 0, newSegments, 0, stackPointer);
    this.segments = newSegments;
  }

  /**
   * Removes duplicate slashes from the given path, with the exception of leading double slash which
   * represents a UNC path.
   */
  private String collapseSlashes(String path) {
    int length = path.length();
    // if the path is only 0, 1 or 2 chars long then it could not possibly have illegal
    // duplicate slashes.
    if (length < 3) return path;
    // check for an occurrence of // in the path.  Start at index 1 to ensure we skip leading UNC //
    // If there are no // then there is nothing to collapse so just return.
    if (path.indexOf("//", 1) == -1) return path;
    // We found an occurrence of // in the path so do the slow collapse.
    char[] result = new char[path.length()];
    int count = 0;
    boolean hasPrevious = false;
    char[] characters = path.toCharArray();
    for (int index = 0; index < characters.length; index++) {
      char c = characters[index];
      if (c == SEPARATOR) {
        if (hasPrevious) {
          // skip double slashes, except for beginning of UNC.
          // note that a UNC path can't have a device.
          if (device == null && index == 1) {
            result[count] = c;
            count++;
          }
        } else {
          hasPrevious = true;
          result[count] = c;
          count++;
        }
      } else {
        hasPrevious = false;
        result[count] = c;
        count++;
      }
    }
    return new String(result, 0, count);
  }

  /* (Intentionally not included in javadoc)
   * Computes the hash code for this object.
   */
  private int computeHashCode() {
    int hash = device == null ? 17 : device.hashCode();
    int segmentCount = segments.length;
    for (int i = 0; i < segmentCount; i++) {
      // this function tends to given a fairly even distribution
      hash = hash * 37 + segments[i].hashCode();
    }

    return hash;
  }

  /* (Intentionally not included in javadoc)
   * Returns the size of the string that will be created by toString or toOSString.
   */
  private int computeLength() {
    int length = 0;
    if (device != null) length += device.length();
    if ((separators & HAS_LEADING) != 0) length++;
    if ((separators & IS_UNC) != 0) length++;
    // add the segment lengths
    int max = segments.length;
    if (max > 0) {
      for (String segment : segments) {
        length += segment.length();
      }
      // add the separator lengths
      length += max - 1;
    }
    if ((separators & HAS_TRAILING) != 0) length++;
    return length;
  }

  /* (Intentionally not included in javadoc)
   * Returns the number of segments in the given path
   */
  private int computeSegmentCount(String path) {
    int len = path.length();
    if (len == 0 || (len == 1 && path.charAt(0) == SEPARATOR)) {
      return 0;
    }
    int count = 1;
    int prev = -1;
    int i;
    while ((i = path.indexOf(SEPARATOR, prev + 1)) != -1) {
      if (i != prev + 1 && i != len) {
        ++count;
      }
      prev = i;
    }
    if (path.charAt(len - 1) == SEPARATOR) {
      --count;
    }
    return count;
  }

  /** Computes the segment array for the given canonicalized path. */
  private String[] computeSegments(String path) {
    // performance sensitive --- avoid creating garbage
    int segmentCount = computeSegmentCount(path);
    if (segmentCount == 0) return NO_SEGMENTS;
    String[] newSegments = new String[segmentCount];
    int len = path.length();
    // check for initial slash
    int firstPosition = (path.charAt(0) == SEPARATOR) ? 1 : 0;
    // check for UNC
    if (firstPosition == 1 && len > 1 && (path.charAt(1) == SEPARATOR)) firstPosition = 2;
    int lastPosition = (path.charAt(len - 1) != SEPARATOR) ? len - 1 : len - 2;
    // for non-empty paths, the number of segments is
    // the number of slashes plus 1, ignoring any leading
    // and trailing slashes
    int next = firstPosition;
    for (int i = 0; i < segmentCount; i++) {
      int start = next;
      int end = path.indexOf(SEPARATOR, next);
      if (end == -1) {
        newSegments[i] = path.substring(start, lastPosition + 1);
      } else {
        newSegments[i] = path.substring(start, end);
      }
      next = end + 1;
    }
    return newSegments;
  }

  /**
   * Returns whether this path equals the given object.
   *
   * <p>Equality for paths is defined to be: same sequence of segments, same absolute/relative
   * status, and same device. Trailing separators are disregarded. Paths are not generally
   * considered equal to objects other than paths.
   *
   * @param obj the other object
   * @return <code>true</code> if the paths are equivalent, and <code>false</code> if they are not
   * @since 4.0.0-RC5
   */
  public boolean equals(Object obj) {
    if (this == obj) return true;
    if (!(obj instanceof Path)) return false;
    Path target = (Path) obj;
    // check leading separators and hash code
    if ((separators & HASH_MASK) != (target.separators & HASH_MASK)) return false;
    String[] targetSegments = target.segments;
    int i = segments.length;
    // check segment count
    if (i != targetSegments.length) return false;
    // check segments in reverse order - later segments more likely to differ
    while (--i >= 0) if (!segments[i].equals(targetSegments[i])) return false;
    // check device last (least likely to differ)
    return device == target.device || (device != null && device.equals(target.device));
  }

  /**
   * Returns the device id for this path, or <code>null</code> if this path has no device id. Note
   * that the result will end in ':'.
   *
   * @return the device id, or <code>null</code>
   * @see #setDevice(String)
   * @since 4.0.0-RC5
   */
  public String getDevice() {
    return device;
  }

  /**
   * Returns the file extension portion of this path, or <code>null</code> if there is none.
   *
   * <p>The file extension portion is defined as the string following the last period (".")
   * character in the last segment. If there is no period in the last segment, the path has no file
   * extension portion. If the last segment ends in a period, the file extension portion is the
   * empty string.
   *
   * @return the file extension or <code>null</code>
   * @see #addFileExtension(String)
   * @since 4.0.0-RC5
   */
  public String getFileExtension() {
    if (hasTrailingSeparator()) {
      return null;
    }
    String lastSegment = lastSegment();
    if (lastSegment == null) {
      return null;
    }
    int index = lastSegment.lastIndexOf('.');
    if (index == -1) {
      return null;
    }
    return lastSegment.substring(index + 1);
  }

  /* (Intentionally not included in javadoc)
   * Computes the hash code for this object.
   */
  public int hashCode() {
    return Objects.hashCode(segments);
  }

  /**
   * Returns whether this path has a trailing separator.
   *
   * <p>Note: In the root path ("/"), the separator is considered to be leading rather than
   * trailing.
   *
   * @return <code>true</code> if this path has a trailing separator, and <code>false</code>
   *     otherwise
   * @see #addTrailingSeparator()
   * @see #removeTrailingSeparator()
   * @since 4.0.0-RC5
   */
  public boolean hasTrailingSeparator() {
    return (separators & HAS_TRAILING) != 0;
  }

  /**
   * Returns whether this path has a leading separator.
   *
   * <p>Note: In the root path ("/"), the separator is considered to be leading rather than
   * trailing.
   *
   * @return <code>true</code> if this path has a leading separator, and <code>false</code>
   *     otherwise
   * @see #addLeadingSeparator()
   */
  public boolean hasLeadingSeparator() {
    return (separators & HAS_LEADING) != 0;
  }

  /*
   * Initialize the current path with the given string.
   */
  private Path initialize(String deviceString, String path) {
    checkNotNull(path);
    this.device = deviceString;

    path = collapseSlashes(path);
    int len = path.length();

    // compute the separators array
    if (len < 2) {
      if (len == 1 && path.charAt(0) == SEPARATOR) {
        this.separators = HAS_LEADING;
      } else {
        this.separators = 0;
      }
    } else {
      boolean hasLeading = path.charAt(0) == SEPARATOR;
      boolean isUNC = hasLeading && path.charAt(1) == SEPARATOR;
      // UNC path of length two has no trailing separator
      boolean hasTrailing = !(isUNC && len == 2) && path.charAt(len - 1) == SEPARATOR;
      separators = hasLeading ? HAS_LEADING : 0;
      if (isUNC) separators |= IS_UNC;
      if (hasTrailing) separators |= HAS_TRAILING;
    }
    // compute segments and ensure canonical form
    segments = computeSegments(path);
    if (!canonicalize()) {
      // compute hash now because canonicalize didn't need to do it
      separators = (separators & ALL_SEPARATORS) | (computeHashCode() << 3);
    }
    return this;
  }

  /**
   * Returns whether this path is an absolute path (ignoring any device id).
   *
   * <p>Absolute paths start with a path separator. A root path, like <code>/</code> or <code>C:/
   * </code>, is considered absolute. UNC paths are always absolute.
   *
   * @return <code>true</code> if this path is an absolute path, and <code>false</code> otherwise
   * @since 4.0.0-RC5
   */
  public boolean isAbsolute() {
    // it's absolute if it has a leading separator
    return (separators & HAS_LEADING) != 0;
  }

  /**
   * Returns whether this path has no segments and is not a root path.
   *
   * @return <code>true</code> if this path is empty, and <code>false</code> otherwise
   * @since 4.0.0-RC5
   */
  public boolean isEmpty() {
    // true if no segments and no leading prefix
    return segments.length == 0 && ((separators & ALL_SEPARATORS) != HAS_LEADING);
  }

  /**
   * Returns whether this path is a prefix of the given path. To be a prefix, this path's segments
   * must appear in the argument path in the same order, and their device ids must match.
   *
   * <p>An empty path is a prefix of all paths with the same device; a root path is a prefix of all
   * absolute paths with the same device.
   *
   * @param anotherPath the other path
   * @return <code>true</code> if this path is a prefix of the given path, and <code>false</code>
   *     otherwise
   * @since 4.0.0-RC5
   */
  public boolean isPrefixOf(Path anotherPath) {
    if (device == null) {
      if (anotherPath.getDevice() != null) {
        return false;
      }
    } else {
      if (!device.equalsIgnoreCase(anotherPath.getDevice())) {
        return false;
      }
    }
    if (isEmpty() || (isRoot() && anotherPath.isAbsolute())) {
      return true;
    }
    int len = segments.length;
    if (len > anotherPath.segmentCount()) {
      return false;
    }
    for (int i = 0; i < len; i++) {
      if (!segments[i].equals(anotherPath.segment(i))) return false;
    }
    return true;
  }

  /**
   * Returns whether this path is a root path.
   *
   * <p>The root path is the absolute non-UNC path with zero segments; e.g., <code>/</code> or
   * <code>C:/</code>. The separator is considered a leading separator, not a trailing one.
   *
   * @return <code>true</code> if this path is a root path, and <code>false</code> otherwise
   * @since 4.0.0-RC5
   */
  public boolean isRoot() {
    // must have no segments, a leading separator, and not be a UNC path.
    return this == ROOT || (segments.length == 0 && ((separators & ALL_SEPARATORS) == HAS_LEADING));
  }

  /**
   * Returns a boolean value indicating whether or not this path is considered to be in UNC form.
   * Return false if this path has a device set or if the first 2 characters of the path string are
   * not <code>Path.SEPARATOR</code>.
   *
   * @return boolean indicating if this path is UNC
   * @since 4.0.0-RC5
   */
  public boolean isUNC() {
    return device == null && (separators & IS_UNC) != 0;
  }

  /**
   * Returns whether the given string is syntactically correct as a path. The device id is the
   * prefix up to and including the device separator for the local file system; the path proper is
   * everything to the right of it, or the entire string if there is no device separator. When the
   * platform location is a file system with no meaningful device separator, the entire string is
   * treated as the path proper. The device id is not checked for validity; the path proper is
   * correct if each of the segments in its canonicalized form is valid.
   *
   * @param path the path to check
   * @return <code>true</code> if the given string is a valid path, and <code>false</code> otherwise
   * @see #isValidSegment(String)
   * @since 4.0.0-RC5
   */
  public static boolean isValidPath(String path) {
    Path test = new Path(path);
    for (int i = 0, max = test.segmentCount(); i < max; i++)
      if (!isValidSegment(test.segment(i))) return false;
    return true;
  }

  /**
   * Returns whether the given string is valid as a segment in a path. The rules for valid segments
   * are as follows:
   *
   * <ul>
   *   <li>the empty string is not valid
   *   <li>any string containing the slash character ('/') is not valid
   *   <li>any string containing segment or device separator characters on the local file system,
   *       such as the backslash ('\') and colon (':') on some file systems.
   * </ul>
   *
   * @param segment the path segment to check
   * @return <code>true</code> if the given path segment is valid, and <code>false</code> otherwise
   * @since 4.0.0-RC5
   */
  protected static boolean isValidSegment(String segment) {
    int size = segment.length();
    if (size == 0) return false;
    for (int i = 0; i < size; i++) {
      char c = segment.charAt(i);
      if (c == '/') return false;
    }
    return true;
  }

  /**
   * Returns the last segment of this path, or <code>null</code> if it does not have any segments.
   *
   * @return the last segment of this path, or <code>null</code>
   * @since 4.0.0-RC5
   */
  public String lastSegment() {
    int len = segments.length;
    return len == 0 ? null : segments[len - 1];
  }

  /**
   * Returns an absolute path with the segments and device id of this path. Absolute paths start
   * with a path separator. If this path is absolute, it is simply returned.
   *
   * @return the new path
   * @since 4.0.0-RC5
   */
  public Path makeAbsolute() {
    if (isAbsolute()) {
      return this;
    }
    Path result = new Path(device, segments, separators | HAS_LEADING);
    // may need canonicalizing if it has leading ".." or "." segments
    if (result.segmentCount() > 0) {
      String first = result.segment(0);
      assert first != null;
      if (first.equals("..") || first.equals(".")) {
        result.canonicalize();
      }
    }
    return result;
  }

  /**
   * Returns a relative path with the segments and device id of this path. Absolute paths start with
   * a path separator and relative paths do not. If this path is relative, it is simply returned.
   *
   * @return the new path
   * @since 4.0.0-RC5
   */
  public Path makeRelative() {
    if (!isAbsolute()) {
      return this;
    }
    return new Path(device, segments, separators & HAS_TRAILING);
  }

  /**
   * Returns a path equivalent to this path, but relative to the given base path if possible.
   *
   * <p>The path is only made relative if the base path if both paths have the same device and have
   * a non-zero length common prefix. If the paths have different devices, or no common prefix, then
   * this path is simply returned. If the path is successfully made relative, then appending the
   * returned path to the base will always produce a path equal to this path.
   *
   * @param base The base path to make this path relative to
   * @return A path relative to the base path, or this path if it could not be made relative to the
   *     given base
   * @since 4.0.0-RC5
   */
  public Path makeRelativeTo(Path base) {
    // can't make relative if devices are not equal
    if (device != base.getDevice()
        && (device == null || !device.equalsIgnoreCase(base.getDevice()))) return this;
    int commonLength = matchingFirstSegments(base);
    final int differenceLength = base.segmentCount() - commonLength;
    final int newSegmentLength = differenceLength + segmentCount() - commonLength;
    if (newSegmentLength == 0) return Path.EMPTY;
    String[] newSegments = new String[newSegmentLength];
    // add parent references for each segment different from the base
    Arrays.fill(newSegments, 0, differenceLength, ".."); // $NON-NLS-1$
    // append the segments of this path not in common with the base
    System.arraycopy(
        segments, commonLength, newSegments, differenceLength, newSegmentLength - differenceLength);
    return new Path(null, newSegments, separators & HAS_TRAILING);
  }

  /**
   * Return a new path which is the equivalent of this path converted to UNC form (if the given
   * boolean is true) or this path not as a UNC path (if the given boolean is false). If UNC, the
   * returned path will not have a device and the first 2 characters of the path string will be
   * <code>Path.SEPARATOR</code>. If not UNC, the first 2 characters of the returned path string
   * will not be <code>Path.SEPARATOR</code>.
   *
   * @param toUNC true if converting to UNC, false otherwise
   * @return the new path, either in UNC form or not depending on the boolean parameter
   * @since 4.0.0-RC5
   */
  public Path makeUNC(boolean toUNC) {
    // if we are already in the right form then just return
    if (!(toUNC ^ isUNC())) return this;

    int newSeparators = this.separators;
    if (toUNC) {
      newSeparators |= HAS_LEADING | IS_UNC;
    } else {
      // mask out the UNC bit
      newSeparators &= HAS_LEADING | HAS_TRAILING;
    }
    return new Path(toUNC ? null : device, segments, newSeparators);
  }

  /**
   * Returns a count of the number of segments which match in this path and the given path (device
   * ids are ignored), comparing in increasing segment number order.
   *
   * @param anotherPath the other path
   * @return the number of matching segments
   * @since 4.0.0-RC5
   */
  public int matchingFirstSegments(Path anotherPath) {
    checkNotNull(anotherPath);
    int anotherPathLen = anotherPath.segmentCount();
    int max = Math.min(segments.length, anotherPathLen);
    int count = 0;
    for (int i = 0; i < max; i++) {
      if (!segments[i].equals(anotherPath.segment(i))) {
        return count;
      }
      count++;
    }
    return count;
  }

  /**
   * Returns a new path which is the same as this path but with the file extension removed. If this
   * path does not have an extension, this path is returned.
   *
   * <p>The file extension portion is defined as the string following the last period (".")
   * character in the last segment. If there is no period in the last segment, the path has no file
   * extension portion. If the last segment ends in a period, the file extension portion is the
   * empty string.
   *
   * @return the new path
   * @see #addFileExtension(String)
   * @since 4.0.0-RC5
   */
  public Path removeFileExtension() {
    String extension = getFileExtension();
    if (extension == null || extension.equals("")) {
      return this;
    }
    String lastSegment = lastSegment();
    int index = lastSegment.lastIndexOf(extension) - 1;
    return removeLastSegments(1).append(lastSegment.substring(0, index));
  }

  /**
   * Returns a copy of this path with the given number of segments removed from the beginning. The
   * device id is preserved. The number must be greater or equal zero. If the count is zero, this
   * path is returned. The resulting path will always be a relative path with respect to this path.
   * If the number equals or exceeds the number of segments in this path, an empty relative path is
   * returned.
   *
   * @param count the number of segments to remove
   * @return the new path
   * @since 4.0.0-RC5
   */
  public Path removeFirstSegments(int count) {
    if (count == 0) return this;
    if (count >= segments.length) {
      return new Path(device, NO_SEGMENTS, 0);
    }
    checkArgument(count > 0);
    int newSize = segments.length - count;
    String[] newSegments = new String[newSize];
    System.arraycopy(this.segments, count, newSegments, 0, newSize);

    // result is always a relative path
    return new Path(device, newSegments, separators & HAS_TRAILING);
  }

  /**
   * Returns a copy of this path with the given number of segments removed from the end. The device
   * id is preserved. The number must be greater or equal zero. If the count is zero, this path is
   * returned.
   *
   * <p>If this path has a trailing separator, it will still have a trailing separator after the
   * last segments are removed (assuming there are some segments left). If there is no trailing
   * separator, the result will not have a trailing separator. If the number equals or exceeds the
   * number of segments in this path, a path with no segments is returned.
   *
   * @param count the number of segments to remove
   * @return the new path
   * @since 4.0.0-RC5
   */
  public Path removeLastSegments(int count) {
    if (count == 0) return this;
    if (count >= segments.length) {
      // result will have no trailing separator
      return new Path(device, NO_SEGMENTS, separators & (HAS_LEADING | IS_UNC));
    }
    checkArgument(count > 0);
    int newSize = segments.length - count;
    String[] newSegments = new String[newSize];
    System.arraycopy(this.segments, 0, newSegments, 0, newSize);
    return new Path(device, newSegments, separators & (HAS_LEADING | IS_UNC));
  }

  /**
   * Returns a path with the same segments as this path but with a trailing separator removed. Does
   * nothing if this path does not have at least one segment. The device id is preserved.
   *
   * <p>If this path does not have a trailing separator, this path is returned.
   *
   * @return the new path
   * @see #addTrailingSeparator()
   * @see #hasTrailingSeparator()
   * @since 4.0.0-RC5
   */
  public Path removeTrailingSeparator() {
    if (!hasTrailingSeparator()) {
      return this;
    }
    return new Path(device, segments, separators & (HAS_LEADING | IS_UNC));
  }

  /**
   * Returns the specified segment of this path, or <code>null</code> if the path does not have such
   * a segment.
   *
   * @param index the 0-based segment index
   * @return the specified segment, or <code>null</code>
   * @since 4.0.0-RC5
   */
  public String segment(int index) {
    if (index >= segments.length) return null;
    return segments[index];
  }

  /**
   * Returns the number of segments in this path.
   *
   * <p>Note that both root and empty paths have 0 segments.
   *
   * @return the number of segments
   * @since 4.0.0-RC5
   */
  public int segmentCount() {
    return segments.length;
  }

  /**
   * Returns the segments in this path in order.
   *
   * @return an array of string segments
   * @since 4.0.0-RC5
   */
  public String[] segments() {
    String[] segmentCopy = new String[segments.length];
    System.arraycopy(segments, 0, segmentCopy, 0, segments.length);
    return segmentCopy;
  }

  /**
   * Returns a new path which is the same as this path but with the given device id. The device id
   * must end with a ":". A device independent path is obtained by passing <code>null</code>.
   *
   * <p>For example, "C:" and "Server/Volume:" are typical device ids.
   *
   * @param device the device id or <code>null</code>
   * @return a new path
   * @see #getDevice()
   * @since 4.0.0-RC5
   */
  public Path setDevice(String device) {
    if (device != null) {
      checkArgument(
          device.indexOf(Path.DEVICE_SEPARATOR) == (device.length() - 1),
          "Last character should be the device separator");
    }
    // return the receiver if the device is the same
    if (device == this.device || (device != null && device.equals(this.device))) return this;

    return new Path(device, segments, separators);
  }

  /**
   * Returns a string representation of this path, including its device id. The same separator, "/",
   * is used on all platforms.
   *
   * <p>Example result strings (without and with device id):
   *
   * <pre>
   * "/foo/bar.txt"
   * "bar.txt"
   * "/foo/"
   * "foo/"
   * ""
   * "/"
   * "C:/foo/bar.txt"
   * "C:bar.txt"
   * "C:/foo/"
   * "C:foo/"
   * "C:"
   * "C:/"
   * </pre>
   *
   * This string is suitable for passing to <code>Path(String)</code>.
   *
   * @return a string representation of this path
   * @since 4.0.0-RC5
   */
  public String toString() {
    int resultSize = computeLength();
    if (resultSize <= 0) return EMPTY_STRING;
    char[] result = new char[resultSize];
    int offset = 0;
    if (device != null) {
      int size = device.length();
      device.getChars(0, size, result, offset);
      offset += size;
    }
    if ((separators & HAS_LEADING) != 0) result[offset++] = SEPARATOR;
    if ((separators & IS_UNC) != 0) result[offset++] = SEPARATOR;
    int len = segments.length - 1;
    if (len >= 0) {
      // append all but the last segment, with separators
      for (int i = 0; i < len; i++) {
        int size = segments[i].length();
        segments[i].getChars(0, size, result, offset);
        offset += size;
        result[offset++] = SEPARATOR;
      }
      // append the last segment
      int size = segments[len].length();
      segments[len].getChars(0, size, result, offset);
      offset += size;
    }
    if ((separators & HAS_TRAILING) != 0) result[offset++] = SEPARATOR;
    return new String(result);
  }

  /**
   * Returns a copy of this path truncated after the given number of segments. The number must not
   * be negative. The device id is preserved.
   *
   * <p>If this path has a trailing separator, the result will too (assuming there are some segments
   * left). If there is no trailing separator, the result will not have a trailing separator.
   * Copying up to segment zero simply means making an copy with no path segments.
   *
   * @param count the segment number at which to truncate the path
   * @return the new path
   * @since 4.0.0-RC5
   */
  public Path uptoSegment(int count) {
    if (count == 0) return new Path(device, NO_SEGMENTS, separators & (HAS_LEADING | IS_UNC));
    if (count >= segments.length) return this;
    checkArgument(count > 0, "Invalid parameter to Path.uptoSegment");
    String[] newSegments = new String[count];
    System.arraycopy(segments, 0, newSegments, 0, count);
    return new Path(device, newSegments, separators);
  }

  /**
   * Returns a copy of this path with removed last segment.
   *
   * @return the new path
   * @since 4.4.0
   */
  public Path parent() {
    return segmentCount() == 1 ? Path.ROOT : this.removeLastSegments(1);
  }

  /**
   * Converts given input array of paths into the list.
   *
   * @param paths the input array of paths
   * @return the converted list
   * @since 4.4.0
   */
  public static List<String> toList(Path[] paths) {
    if (paths == null || paths.length == 0) {
      return Collections.emptyList();
    }

    List<String> list = new ArrayList<>(paths.length);
    for (Path path : paths) {
      list.add(path.toString());
    }

    return list;
  }

  /**
   * Calculated common path from the several paths given as array.
   *
   * <p>For example we have three paths:
   *
   * <ul>
   *   <li>{@code /a/b/c}
   *   <li>{@code /a/b/d}
   *   <li>{@code /a/b/d/e}
   * </ul>
   *
   * Common path will be {@code /a/b}
   *
   * @param paths paths array
   * @return common path of empty string if given array is empty
   * @throws NullPointerException in case if given {@code paths} array is null
   * @since 5.0.0
   */
  public static Path commonPath(Path... paths) {
    checkNotNull(paths);

    Path commonPath = Path.ROOT;

    if (paths.length == 0) {
      return EMPTY;
    }

    if (paths.length == 1) {
      return paths[0];
    }

    for (int i = 0; i < paths[0].segmentCount(); i++) {
      final String currentSegment = paths[0].segment(i);

      boolean segmentsMatched = true;

      for (int j = 1; j < paths.length && segmentsMatched; j++) {
        final Path comparedPath = paths[j];

        if (comparedPath.segmentCount() < i) {
          segmentsMatched = false;
          break;
        } else {
          segmentsMatched = nullToEmpty(comparedPath.segment(i)).equals(currentSegment);
        }
      }

      if (segmentsMatched) {
        commonPath = commonPath.append(currentSegment);
      } else {
        break;
      }
    }

    return commonPath;
  }
}
