/*
 * 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.cassandra.io.util;

import java.io.BufferedWriter;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.nio.channels.Channels;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
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.text.DecimalFormat;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import com.google.common.base.Preconditions;
import com.google.common.util.concurrent.RateLimiter;
import com.sun.nio.file.ExtendedOpenOption;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.apache.cassandra.io.FSError;
import org.apache.cassandra.io.FSWriteError;
import org.apache.cassandra.utils.JVMStabilityInspector;
import org.apache.cassandra.utils.SyncUtil;

import static org.apache.cassandra.config.CassandraRelevantProperties.JAVA_IO_TMPDIR;
import static org.apache.cassandra.utils.Throwables.maybeFail;

public final class FileUtils
{
    public static final Charset CHARSET = StandardCharsets.UTF_8;

    private static final Logger logger = LoggerFactory.getLogger(FileUtils.class);

    public static final long ONE_KIB = 1024;
    public static final long ONE_MIB = 1024 * ONE_KIB;
    public static final long ONE_GIB = 1024 * ONE_MIB;
    public static final long ONE_TIB = 1024 * ONE_GIB;

    private static final DecimalFormat df = new DecimalFormat("#.##");

    private static final File tempDir = new File(JAVA_IO_TMPDIR.getString());
    private static final AtomicLong tempFileNum = new AtomicLong();

    public static File getTempDir()
    {
        return tempDir;
    }

    /**
     * Pretty much like {@link java.io.File#createTempFile(String, String, java.io.File)}, but with
     * the guarantee that the "random" part of the generated file name between
     * {@code prefix} and {@code suffix} is a positive, increasing {@code long} value.
     */
    public static File createTempFile(String prefix, String suffix, File directory)
    {
        // Do not use java.io.File.createTempFile(), because some tests rely on the
        // behavior that the "random" part in the temp file name is a positive 'long'.
        // However, at least since Java 9 the code to generate the "random" part
        // uses an _unsigned_ random long generated like this:
        // Long.toUnsignedString(new java.util.Random.nextLong())

        while (true)
        {
            // The contract of File.createTempFile() says, that it must not return
            // the same file name again. We do that here in a very simple way,
            // that probably doesn't cover all edge cases. Just rely on system
            // wall clock and return strictly increasing values from that.
            long num = tempFileNum.getAndIncrement();

            // We have a positive long here, which is safe to use for example
            // for CommitLogTest.
            String fileName = prefix + num + suffix;
            File candidate = new File(directory, fileName);
            if (candidate.createFileIfNotExists())
                return candidate;
        }
    }

    public static File createTempFile(String prefix, String suffix)
    {
        return createTempFile(prefix, suffix, tempDir);
    }

    public static File createDeletableTempFile(String prefix, String suffix)
    {
        File f = createTempFile(prefix, suffix, getTempDir());
        f.deleteOnExit();
        return f;
    }

    public static void createHardLink(String from, String to)
    {
        createHardLink(new File(from), new File(to));
    }

    public static void createHardLink(File from, File to)
    {
        if (to.exists())
            throw new DuplicateHardlinkException("Tried to create duplicate hard link from " + from + " to " + to);
        if (!from.exists())
            throw new RuntimeException("Tried to hard link to file that does not exist " + from);

        try
        {
            Files.createLink(to.toPath(), from.toPath());
        }
        catch (IOException e)
        {
            throw new FSWriteError(e, to);
        }
    }

    public static void createHardLinkWithConfirm(String from, String to)
    {
        createHardLinkWithConfirm(new File(from), new File(to));
    }

    public static void createHardLinkWithConfirm(File from, File to)
    {
        try
        {
            createHardLink(from, to);
        }
        catch (FSWriteError ex)
        {
            throw ex;
        }
        catch (DuplicateHardlinkException ex)
        {
            throw new RuntimeException(ex.getMessage());
        }
        catch (Throwable t)
        {
            throw new RuntimeException(String.format("Unable to hardlink from %s to %s", from, to), t);
        }
    }

    public static void createHardLinkWithoutConfirm(String from, String to)
    {
        createHardLinkWithoutConfirm(new File(from), new File(to));
    }

    public static void createHardLinkWithoutConfirm(File from, File to)
    {
        try
        {
            createHardLink(from, to);
        }
        catch (FSWriteError fse)
        {
            if (logger.isTraceEnabled())
                logger.trace("Could not hardlink file {} to {}", from, to, fse);
        }
    }

    public static void copyWithOutConfirm(String from, String to)
    {
        copyWithOutConfirm(new File(from), new File(to));
    }

    public static void copyWithOutConfirm(File from, File to)
    {
        try
        {
            Files.copy(from.toPath(), to.toPath());
        }
        catch (IOException e)
        {
            if (logger.isTraceEnabled())
                logger.trace("Could not copy file {} to {}", from, to, e);
        }
    }

    public static void copyWithConfirm(String from, String to)
    {
        copyWithConfirm(new File(from), new File(to));
    }

    public static void copyWithConfirm(File from, File to)
    {
        assert from.exists();
        if (logger.isTraceEnabled())
            logger.trace("Copying {} to {}", from.path(), to.path());

        try
        {
            Files.copy(from.toPath(), to.toPath());
        }
        catch (IOException e)
        {
            throw new FSWriteError(e, "Could not copy file" + from + " to " + to);
        }
    }

    public static void truncate(String path, long size)
    {
        File file = new File(path);
        try (FileChannel channel = file.newReadWriteChannel())
        {
            channel.truncate(size);
        }
        catch (IOException e)
        {
            throw PathUtils.propagateUnchecked(e, file.toPath(), true);
        }
    }

    public static void closeQuietly(Closeable c)
    {
        try
        {
            if (c != null)
                c.close();
        }
        catch (Exception e)
        {
            logger.warn("Failed closing {}", c, e);
        }
    }

    public static void closeQuietly(AutoCloseable c)
    {
        try
        {
            if (c != null)
                c.close();
        }
        catch (Exception e)
        {
            logger.warn("Failed closing {}", c, e);
        }
    }

    public static void close(Closeable... cs) throws IOException
    {
        close(Arrays.asList(cs));
    }

    public static void close(Iterable<? extends Closeable> cs) throws IOException
    {
        Throwable e = null;
        for (Closeable c : cs)
        {
            try
            {
                if (c != null)
                    c.close();
            }
            catch (Throwable ex)
            {
                if (e == null) e = ex;
                else e.addSuppressed(ex);
                logger.warn("Failed closing stream {}", c, ex);
            }
        }
        maybeFail(e, IOException.class);
    }

    public static void closeQuietly(Iterable<? extends AutoCloseable> cs)
    {
        for (AutoCloseable c : cs)
        {
            try
            {
                if (c != null)
                    c.close();
            }
            catch (Exception ex)
            {
                logger.warn("Failed closing {}", c, ex);
            }
        }
    }

    public static String getCanonicalPath(String filename)
    {
        return new File(filename).canonicalPath();
    }

    public static String getCanonicalPath(File file)
    {
        return file.canonicalPath();
    }

    /** Return true if file is contained in folder */
    public static boolean isContained(File folder, File file)
    {
        return folder.isAncestorOf(file);
    }

    public static long parseFileSize(String value)
    {
        long result;
        if (!value.matches("\\d+(\\.\\d+)? (GiB|KiB|MiB|TiB|bytes)"))
        {
            throw new IllegalArgumentException(
                String.format("value %s is not a valid human-readable file size", value));
        }
        if (value.endsWith(" TiB"))
        {
            result = Math.round(Double.valueOf(value.replace(" TiB", "")) * ONE_TIB);
            return result;
        }
        else if (value.endsWith(" GiB"))
        {
            result = Math.round(Double.valueOf(value.replace(" GiB", "")) * ONE_GIB);
            return result;
        }
        else if (value.endsWith(" KiB"))
        {
            result = Math.round(Double.valueOf(value.replace(" KiB", "")) * ONE_KIB);
            return result;
        }
        else if (value.endsWith(" MiB"))
        {
            result = Math.round(Double.valueOf(value.replace(" MiB", "")) * ONE_MIB);
            return result;
        }
        else if (value.endsWith(" bytes"))
        {
            result = Math.round(Double.valueOf(value.replace(" bytes", "")));
            return result;
        }
        else
        {
            throw new IllegalStateException(String.format("FileUtils.parseFileSize() reached an illegal state parsing %s", value));
        }
    }

    public static String stringifyFileSize(long bytes, boolean humanReadable)
    {
        if (humanReadable)
            return stringifyFileSize(bytes);
        else
            return Long.toString(bytes);
    }

    public static String stringifyFileSize(double value)
    {
        double d;
        if (value >= ONE_TIB)
        {
            d = value / ONE_TIB;
            String val = df.format(d);
            return val + " TiB";
        }
        else if (value >= ONE_GIB)
        {
            d = value / ONE_GIB;
            String val = df.format(d);
            return val + " GiB";
        }
        else if (value >= ONE_MIB)
        {
            d = value / ONE_MIB;
            String val = df.format(d);
            return val + " MiB";
        }
        else if (value >= ONE_KIB)
        {
            d = value / ONE_KIB;
            String val = df.format(d);
            return val + " KiB";
        }
        else
        {
            String val = df.format(value);
            return val + " bytes";
        }
    }

    /**
     * handleFSErrorAndPropagate will invoke the disk failure policy error handler,
     * which may or may not stop the daemon or transports. However, if we don't exit,
     * we still want to propagate the exception to the caller in case they have custom
     * exception handling
     *
     * @param e A filesystem error
     */
    public static void handleFSErrorAndPropagate(FSError e)
    {
        JVMStabilityInspector.inspectThrowable(e);
        throw e;
    }

    /**
     * Get the size of a directory in bytes
     * @param folder The directory for which we need size.
     * @return The size of the directory
     */
    public static long folderSize(File folder)
    {
        if (!folder.exists())
            return 0;

        final long [] sizeArr = {0L};
        try
        {
            Files.walkFileTree(folder.toPath(), new SimpleFileVisitor<Path>()
            {
                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
                {
                    sizeArr[0] += attrs.size();
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult visitFileFailed(Path path, IOException e) throws IOException
                {
                    if (e instanceof NoSuchFileException)
                        return FileVisitResult.CONTINUE;
                    else
                        throw e;
                }
            });
        }
        catch (IOException e)
        {
            logger.error("Error while getting {} folder size. {}", folder, e.getMessage());
        }
        return sizeArr[0];
    }

    public static void append(File file, String ... lines)
    {
        if (file.exists())
            write(file, Arrays.asList(lines), StandardOpenOption.APPEND);
        else
            write(file, Arrays.asList(lines), StandardOpenOption.CREATE);
    }

    public static void appendAndSync(File file, String ... lines)
    {
        if (file.exists())
            write(file, Arrays.asList(lines), StandardOpenOption.APPEND, StandardOpenOption.SYNC);
        else
            write(file, Arrays.asList(lines), StandardOpenOption.CREATE, StandardOpenOption.SYNC);
    }

    public static void replace(File file, String ... lines)
    {
        write(file, Arrays.asList(lines), StandardOpenOption.TRUNCATE_EXISTING);
    }

    /**
     * Write lines to a file adding a newline to the end of each supplied line using the provided open options.
     *
     * If open option sync or dsync is provided this will not open the file with sync or dsync since it might end up syncing
     * many times for a lot of lines. Instead it will write all the lines and sync once at the end. Since the file is
     * never returned there is not much difference from the perspective of the caller.
     * @param file
     * @param lines
     * @param options
     */
    public static void write(File file, List<String> lines, StandardOpenOption ... options)
    {
        Set<StandardOpenOption> optionsSet = EnumSet.noneOf(StandardOpenOption.class);
        for (StandardOpenOption option : options)
            optionsSet.add(option);

        //Emulate the old FileSystemProvider.newOutputStream behavior for open options.
        if (optionsSet.isEmpty())
        {
            optionsSet.add(StandardOpenOption.CREATE);
            optionsSet.add(StandardOpenOption.TRUNCATE_EXISTING);
        }
        boolean sync = optionsSet.remove(StandardOpenOption.SYNC);
        boolean dsync = optionsSet.remove(StandardOpenOption.DSYNC);
        optionsSet.add(StandardOpenOption.WRITE);

        Path filePath = file.toPath();
        try (FileChannel fc = filePath.getFileSystem().provider().newFileChannel(filePath, optionsSet);
             BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(Channels.newOutputStream(fc), CHARSET.newEncoder())))
        {
            for (CharSequence line: lines) {
                writer.append(line);
                writer.newLine();
            }

            if (sync)
            {
                SyncUtil.force(fc, true);
            }
            else if (dsync)
            {
                SyncUtil.force(fc, false);
            }
        }
        catch (ClosedChannelException cce)
        {
            throw new RuntimeException(cce);
        }
        catch (IOException ex)
        {
            throw new FSWriteError(ex, file);
        }
    }

    public static List<String> readLines(File file)
    {
        try
        {
            return Files.readAllLines(file.toPath(), CHARSET);
        }
        catch (IOException ex)
        {
            if (ex instanceof NoSuchFileException)
                return Collections.emptyList();

            throw new RuntimeException(ex);
        }
    }

    /** @deprecated See CASSANDRA-16926 */
    @Deprecated(since = "4.1")
    public static void createDirectory(String directory)
    {
        createDirectory(new File(directory));
    }

    /** @deprecated See CASSANDRA-16926 */
    @Deprecated(since = "4.1")
    public static void createDirectory(File directory)
    {
        PathUtils.createDirectoriesIfNotExists(directory.toPath());
    }

    /** @deprecated See CASSANDRA-16926 */
    @Deprecated(since = "4.1")
    public static boolean delete(String file)
    {
        return new File(file).tryDelete();
    }

    /** @deprecated See CASSANDRA-16926 */
    @Deprecated(since = "4.1")
    public static void delete(File... files)
    {
        for (File file : files)
            file.tryDelete();
    }

    /**
     * Deletes all files and subdirectories under "dir".
     * @param dir Directory to be deleted
     * @throws FSWriteError if any part of the tree cannot be deleted
     *
     * @deprecated See CASSANDRA-16926
     */
    @Deprecated(since = "4.1")
    public static void deleteRecursiveWithThrottle(File dir, RateLimiter rateLimiter)
    {
        dir.deleteRecursive(rateLimiter);
    }

    /**
     * Deletes all files and subdirectories under "dir".
     * @param dir Directory to be deleted
     * @throws FSWriteError if any part of the tree cannot be deleted
     *
     * @deprecated See CASSANDRA-16926
     */
    @Deprecated(since = "4.1")
    public static void deleteRecursive(File dir)
    {
        dir.deleteRecursive();
    }

    /**
     * Schedules deletion of all file and subdirectories under "dir" on JVM shutdown.
     * @param dir Directory to be deleted
     *
     * @deprecated See CASSANDRA-16926
     */
    @Deprecated(since = "4.1")
    public static void deleteRecursiveOnExit(File dir)
    {
        dir.deleteRecursiveOnExit();
    }

    /** @deprecated See CASSANDRA-16926 */
    @Deprecated(since = "4.1")
    public static boolean isSubDirectory(File parent, File child)
    {
        return parent.isAncestorOf(child);
    }

    /** @deprecated See CASSANDRA-16926 */
    @Deprecated(since = "4.1")
    public static Throwable deleteWithConfirm(File file, Throwable accumulate)
    {
        return file.delete(accumulate, null);
    }

    /** @deprecated See CASSANDRA-16926 */
    @Deprecated(since = "4.1")
    public static Throwable deleteWithConfirm(File file, Throwable accumulate, RateLimiter rateLimiter)
    {
        return file.delete(accumulate, rateLimiter);
    }

    /** @deprecated See CASSANDRA-16926 */
    @Deprecated(since = "4.1")
    public static void deleteWithConfirm(String file)
    {
        deleteWithConfirm(new File(file));
    }

    /** @deprecated See CASSANDRA-16926 */
    @Deprecated(since = "4.1")
    public static void deleteWithConfirm(File file)
    {
        file.delete();
    }

    /** @deprecated See CASSANDRA-16926 */
    @Deprecated(since = "4.1")
    public static void renameWithOutConfirm(String from, String to)
    {
        new File(from).tryMove(new File(to));
    }

    /** @deprecated See CASSANDRA-16926 */
    @Deprecated(since = "4.1")
    public static void renameWithConfirm(String from, String to)
    {
        renameWithConfirm(new File(from), new File(to));
    }

    /** @deprecated See CASSANDRA-16926 */
    @Deprecated(since = "4.1")
    public static void renameWithConfirm(File from, File to)
    {
        from.move(to);
    }

    /**
     * Private constructor as the class contains only static methods.
     */
    private FileUtils()
    {
    }

    /**
     * Moves the contents of a directory to another directory.
     * <p>Once a file has been copied to the target directory it will be deleted from the source directory.
     * If a file already exists in the target directory a warning will be logged and the file will not
     * be deleted.</p>
     *
     * @param source the directory containing the files to move
     * @param target the directory where the files must be moved
     */
    public static void moveRecursively(Path source, Path target) throws IOException
    {
        logger.info("Moving {} to {}" , source, target);

        if (Files.isDirectory(source))
        {
            Files.createDirectories(target);

            for (File f : new File(source).tryList())
            {
                String fileName = f.name();
                moveRecursively(source.resolve(fileName), target.resolve(fileName));
            }

            deleteDirectoryIfEmpty(source);
        }
        else
        {
            if (Files.exists(target))
            {
                logger.warn("Cannot move the file {} to {} as the target file already exists." , source, target);
            }
            else
            {
                Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES);
                Files.delete(source);
            }
        }
    }

    /**
     * Deletes the specified directory if it is empty
     *
     * @param path the path to the directory
     */
    public static void deleteDirectoryIfEmpty(Path path) throws IOException
    {
        Preconditions.checkArgument(Files.isDirectory(path), String.format("%s is not a directory", path));

        try
        {
            logger.info("Deleting directory {}", path);
            Files.delete(path);
        }
        catch (DirectoryNotEmptyException e)
        {
            try (Stream<Path> paths = Files.list(path))
            {
                String content = paths.map(p -> p.getFileName().toString()).collect(Collectors.joining(", "));

                logger.warn("Cannot delete the directory {} as it is not empty. (Content: {})", path, content);
            }
        }
    }

    public static boolean isDirectIOSupported(File file)
    {
        File testFile = null;
        try
        {
            File dir = file.isDirectory() ? file : file.parent();
            testFile = createTempFile("direct-io-test", ".tmp", dir);

            // Direct IO requires knowing the block size for buffer alignment
            if (blockSize(testFile) <= 0)
                return false;

            try (FileChannel channel = FileChannel.open(testFile.toPath(),
                                                        StandardOpenOption.READ,
                                                        ExtendedOpenOption.DIRECT))
            {
                return true;
            }
        }
        catch (Exception e)
        {
            return false;
        }
        finally
        {
            if (testFile != null)
                testFile.tryDelete();
        }
    }

    public static int getFileBlockSize(File file)
    {
        try
        {
            return blockSize(file);
        }
        catch (IOException e)
        {
            throw new RuntimeException("Failed to get file block size in " + file, e);
        }
    }

    private static final ConcurrentHashMap<String, Integer> blockSizeByDirectory = new ConcurrentHashMap<>();

    public static int getBlockSize(File directory)
    {
        return blockSizeByDirectory.computeIfAbsent(directory.absolutePath(), ignored -> probeBlockSize(directory));
    }

    private static int probeBlockSize(File directory)
    {
        File f = FileUtils.createTempFile("block-size-test", ".tmp", directory);
        try
        {
            return blockSize(f);
        }
        catch (IOException e)
        {
            throw new RuntimeException("Failed to get file block size in " + directory, e);
        }
        finally
        {
            f.tryDelete();
        }
    }

    private static int blockSize(File file) throws IOException
    {
        long bs = Files.getFileStore(file.toPath()).getBlockSize();
        assert bs >= 0 && bs <= Integer.MAX_VALUE;
        return (int) bs;
    }

    public static class DuplicateHardlinkException extends RuntimeException
    {
        public DuplicateHardlinkException(String message)
        {
            super(message);
        }
    }
}