/*
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * Licensed 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 com.facebook.buck.io.file;

import com.facebook.buck.core.filesystems.AbsPath;
import com.facebook.buck.core.util.log.Logger;
import com.facebook.buck.io.windowsfs.WindowsFS;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.CharMatcher;
import com.google.common.base.Charsets;
import com.google.common.base.Functions;
import com.google.common.base.Objects;
import com.google.common.io.ByteStreams;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.FileSystems;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.nio.file.attribute.PosixFilePermission;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public final class MostFiles {

  private static final Logger LOG = Logger.get(MostFiles.class);

  // Extended attribute bits for directories and symlinks; see:
  // http://unix.stackexchange.com/questions/14705/the-zip-formats-external-file-attribute
  @SuppressWarnings("PMD.AvoidUsingOctalValues")
  public static final long S_IFDIR = 0040000;

  @SuppressWarnings("PMD.AvoidUsingOctalValues")
  public static final long S_IFREG = 0100000;

  @SuppressWarnings("PMD.AvoidUsingOctalValues")
  public static final long S_IFLNK = 0120000;

  public enum DeleteRecursivelyOptions {
    IGNORE_NO_SUCH_FILE_EXCEPTION,
    DELETE_CONTENTS_ONLY,
  }

  // Unix has two illegal characters - '/', and '\0'.  Windows has ten, which includes those two.
  // The full list can be found at https://msdn.microsoft.com/en-us/library/aa365247
  private static final String ILLEGAL_FILE_NAME_CHARACTERS = "<>:\"/\\|?*\0";

  private static final WindowsFS winFS = new WindowsFS();

  private static class FileAccessedEntry {
    public final File file;
    public final FileTime lastAccessTime;

    public File getFile() {
      return file;
    }

    public FileTime getLastAccessTime() {
      return lastAccessTime;
    }

    private FileAccessedEntry(File file, FileTime lastAccessTime) {
      this.file = file;
      this.lastAccessTime = lastAccessTime;
    }
  }

  /** Sorts by the lastAccessTime in descending order (more recently accessed files are first). */
  private static final Comparator<FileAccessedEntry> SORT_BY_LAST_ACCESSED_TIME_DESC =
      (a, b) -> b.getLastAccessTime().compareTo(a.getLastAccessTime());

  /** Utility class: do not instantiate. */
  private MostFiles() {}

  public static void deleteRecursivelyIfExists(Path path) throws IOException {
    deleteRecursivelyWithOptions(
        path, EnumSet.of(DeleteRecursivelyOptions.IGNORE_NO_SUCH_FILE_EXCEPTION));
  }

  /** Recursively copies all files under {@code fromPath} to {@code toPath}. */
  public static void copyRecursively(Path fromPath, Path toPath) throws IOException {
    copyRecursively(fromPath, toPath, Functions.identity());
  }

  /**
   * Recursively copies all files under {@code fromPath} to {@code toPath}. The {@code transform}
   * will be applied after the destination path for a file has been relativized. This will remove
   * any existing files in the toPath if there is a conflict.
   *
   * @param fromPath item to copy
   * @param toPath destination of copy
   * @param transform renaming function to apply when copying. If this function returns null, then
   *     the file is not copied.
   */
  public static void copyRecursively(Path fromPath, Path toPath, Function<Path, Path> transform)
      throws IOException {
    copyRecursively(fromPath, toPath, transform, input -> true);
  }

  public static void copyRecursively(
      Path fromPath, Path toPath, Function<Path, Path> transform, Function<Path, Boolean> filter)
      throws IOException {
    // Adapted from http://codingjunkie.net/java-7-copy-move/.
    SimpleFileVisitor<Path> copyDirVisitor =
        new SimpleFileVisitor<Path>() {

          @Override
          public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
              throws IOException {
            Path targetPath = toPath.resolve(fromPath.relativize(dir));
            if (!Files.exists(targetPath)) {
              Files.createDirectory(targetPath);
            }
            return FileVisitResult.CONTINUE;
          }

          @Override
          public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
              throws IOException {
            if (!filter.apply(file)) {
              return FileVisitResult.CONTINUE;
            }
            Path destPath = toPath.resolve(fromPath.relativize(file));
            Path transformedDestPath = transform.apply(destPath);
            if (transformedDestPath != null) {
              if (Files.isSymbolicLink(file)) {
                Files.deleteIfExists(transformedDestPath);
                MorePaths.createSymLink(winFS, transformedDestPath, Files.readSymbolicLink(file));
              } else {
                Files.copy(file, transformedDestPath, StandardCopyOption.REPLACE_EXISTING);
              }
            }
            return FileVisitResult.CONTINUE;
          }
        };
    Files.walkFileTree(fromPath, copyDirVisitor);
  }

  public static void deleteRecursively(Path path) throws IOException {
    deleteRecursivelyWithOptions(path, EnumSet.noneOf(DeleteRecursivelyOptions.class));
  }

  public static void deleteRecursivelyWithOptions(
      Path path, EnumSet<DeleteRecursivelyOptions> options) throws IOException {
    try {
      if (!Files.isDirectory(path)) {
        if (options.contains(DeleteRecursivelyOptions.IGNORE_NO_SUCH_FILE_EXCEPTION)) {
          if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
            return;
          }
        }
        if (options.contains(DeleteRecursivelyOptions.DELETE_CONTENTS_ONLY)) {
          throw new IOException(String.format("Can't delete contents of regular file %s.", path));
        }
        Files.delete(path);
      } else {
        // Adapted from http://codingjunkie.net/java-7-copy-move/.
        SimpleFileVisitor<Path> deleteDirVisitor =
            new SimpleFileVisitor<Path>() {

              @Override
              public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
                  throws IOException {
                try {
                  Files.delete(file);
                } catch (IOException e) {
                  LOG.warn("%s, could not delete file", e);
                }
                return FileVisitResult.CONTINUE;
              }

              @Override
              public FileVisitResult postVisitDirectory(Path dir, IOException e)
                  throws IOException {
                if (e == null) {
                  // Allow leaving the top-level directory in place (e.g. for deleting the contents
                  // of
                  // the trash dir but not the trash dir itself)
                  if (!(options.contains(DeleteRecursivelyOptions.DELETE_CONTENTS_ONLY)
                      && dir.equals(path))) {
                    try {
                      Files.delete(dir);
                    } catch (DirectoryNotEmptyException notEmpty) {
                      try (Stream<Path> paths = Files.list(dir)) {
                        LOG.warn(
                            String.format(
                                "Could not delete non-empty directory %s. Contents:\n%s",
                                dir, paths.map(Path::toString).collect(Collectors.joining("\n"))));
                      }
                    } catch (IOException ioException) {
                      LOG.warn("%s, could not delete directory", ioException);
                    }
                  }
                  return FileVisitResult.CONTINUE;
                } else {
                  throw e;
                }
              }
            };
        Files.walkFileTree(path, deleteDirVisitor);
      }
    } catch (NoSuchFileException e) {
      if (!options.contains(DeleteRecursivelyOptions.IGNORE_NO_SUCH_FILE_EXCEPTION)) {
        throw e;
      }
    }
  }

  /** Writes the specified lines to the specified file, encoded as UTF-8. */
  public static void writeLinesToFile(Iterable<String> lines, Path file) throws IOException {
    try (BufferedWriter writer = Files.newBufferedWriter(file, Charsets.UTF_8)) {
      for (String line : lines) {
        writer.write(line);
        writer.newLine();
      }
    }
  }

  /** Writes the specified lines to the specified file, encoded as UTF-8. */
  public static void writeLinesToFile(Iterable<String> lines, AbsPath file) throws IOException {
    writeLinesToFile(lines, file.getPath());
  }

  /** Log a simplistic diff between lines and the contents of file. */
  @VisibleForTesting
  static List<String> diffFileContents(Iterable<String> lines, File file) throws IOException {
    List<String> diffLines = new ArrayList<>();
    Iterator<String> iter = lines.iterator();
    try (BufferedReader reader = Files.newBufferedReader(file.toPath(), Charsets.UTF_8)) {
      while (iter.hasNext()) {
        String lineA = reader.readLine();
        String lineB = iter.next();
        if (!Objects.equal(lineA, lineB)) {
          diffLines.add(String.format("| %s | %s |", lineA == null ? "" : lineA, lineB));
        }
      }

      String lineA;
      while ((lineA = reader.readLine()) != null) {
        diffLines.add(String.format("| %s |  |", lineA));
      }
    }
    return diffLines;
  }

  /**
   * Does an in-place sort of the specified {@code files} array. Most recently accessed files will
   * be at the front of the array when sorted.
   */
  public static void sortFilesByAccessTime(File[] files) {
    FileAccessedEntry[] fileAccessedEntries = new FileAccessedEntry[files.length];
    for (int i = 0; i < files.length; ++i) {
      FileTime lastAccess;
      try {
        lastAccess =
            Files.readAttributes(files[i].toPath(), BasicFileAttributes.class).lastAccessTime();
      } catch (IOException e) {
        lastAccess = FileTime.fromMillis(files[i].lastModified());
      }
      fileAccessedEntries[i] = new FileAccessedEntry(files[i], lastAccess);
    }
    Arrays.sort(fileAccessedEntries, SORT_BY_LAST_ACCESSED_TIME_DESC);

    for (int i = 0; i < files.length; i++) {
      files[i] = fileAccessedEntries[i].getFile();
    }
  }

  /**
   * Tries to make the specified file executable. For file systems that do support the POSIX-style
   * permissions, the executable permission is set for each category of users that already has the
   * read permission.
   *
   * <p>If the file system does not support the executable permission or the operation fails, a
   * {@code java.io.IOException} is thrown.
   */
  public static void makeExecutable(Path file) throws IOException {
    if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) {
      Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(file);

      if (permissions.contains(PosixFilePermission.OWNER_READ)) {
        permissions.add(PosixFilePermission.OWNER_EXECUTE);
      }
      if (permissions.contains(PosixFilePermission.GROUP_READ)) {
        permissions.add(PosixFilePermission.GROUP_EXECUTE);
      }
      if (permissions.contains(PosixFilePermission.OTHERS_READ)) {
        permissions.add(PosixFilePermission.OTHERS_EXECUTE);
      }

      Files.setPosixFilePermissions(file, permissions);
    } else {
      if (!file.toFile().setExecutable(/* executable */ true, /* ownerOnly */ true)) {
        throw new IOException("The file could not be made executable");
      }
    }
  }

  /**
   * Given a file name, replace any illegal characters from it.
   *
   * @param name The file name to sanitize
   * @return a properly sanitized filename
   */
  public static String sanitize(String name) {
    return CharMatcher.anyOf(ILLEGAL_FILE_NAME_CHARACTERS).replaceFrom(name, "_");
  }

  /**
   * Concatenates the contents of one or more files.
   *
   * @param dest The path to which the concatenated files' contents are written.
   * @param pathsToConcatenate The paths whose contents are concatenated to {@code dest}.
   * @return {@code true} if any data was concatenated to {@code dest}, {@code false} otherwise.
   */
  public static boolean concatenateFiles(Path dest, Iterable<Path> pathsToConcatenate)
      throws IOException {
    // Concatenate all the logs to a temp file, then atomically rename it to the
    // passed-in concatenatedPath if any log data was collected.
    String tempFilename = "." + dest.getFileName() + ".tmp." + UUID.randomUUID();
    Path tempPath = dest.resolveSibling(tempFilename);

    try {
      long bytesCollected = 0;
      try (OutputStream os =
          Files.newOutputStream(
              tempPath,
              StandardOpenOption.CREATE,
              StandardOpenOption.TRUNCATE_EXISTING,
              StandardOpenOption.WRITE)) {
        for (Path path : pathsToConcatenate) {
          try (InputStream is = Files.newInputStream(path)) {
            bytesCollected += ByteStreams.copy(is, os);
          } catch (NoSuchFileException e) {
            continue;
          }
        }
      }
      if (bytesCollected > 0) {
        Files.move(
            tempPath, dest, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
        return true;
      } else {
        return false;
      }
    } finally {
      Files.deleteIfExists(tempPath);
    }
  }
}