/**
 * ZipSnap 2.1
 * Copyright 2007 Zach Scrivena
 * 2007-08-26
 * zachscrivena@gmail.com
 * http://zipsnap.sourceforge.net/
 *
 * ZipSnap is a simple command-line incremental backup tool for directories.
 *
 * TERMS AND CONDITIONS:
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package zipsnap;

import java.io.File;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.zip.CRC32;


/**
 * Simple class for performing common file input/output operations.
 */
public class FileIO
{
    /** buffer size (1 Mb) */
    private static final int bufferSize = 1048576;

    /** neutral separator char */
    public static final char neutralSeparatorChar = '/';


    /**
    * Rename a given file or directory.
    *
    * @param isDir
    *     True if renaming a directory, false otherwise
    * @param sourceFile
    *     Source file/directory to be renamed
    * @param targetFile
    *     Target file/directory to which the source file/directory is to be renamed
    * @return
    *     Result of the rename operation
    */
    private static FileIOResult renameFileDir(
            final boolean isDir,
            final File sourceFile,
            final File targetFile)
    {
        /* check if source file/directory exists */
        if (!sourceFile.exists())
            return new FileIOResult(false,
                    "Source " + ((isDir) ? "directory" : "file") + " does not exist.");

        /* check if source is a file/directory as specified */
        if (isDir != sourceFile.isDirectory())
            return new FileIOResult(false,
                    "Source is not a " + ((isDir) ? "directory." : "file."));

        /* check if target file is a distinct existing file/directory */
        if (targetFile.exists() && !targetFile.equals(sourceFile))
            return new FileIOResult(false,
                    "Target is an existing " + ((targetFile.isDirectory()) ? "directory." : "file."));

        boolean success = false;
        String exceptionMessage = null;

        try
        {
            success = sourceFile.renameTo(targetFile);
        }
        catch (Exception e)
        {
            exceptionMessage = ErrorWarningHandler.getExceptionMessage(e);
        }

        if (success)
        {
            return new FileIOResult(true, null);
        }
        else
        {
            if (exceptionMessage == null)
            {
                return new FileIOResult(false,
                        ((isDir) ? "Directory" : "File") + " rename was unsuccessful.");
            }
            else
            {
                return new FileIOResult(false,
                        "Java exception encountered during " +
                        ((isDir) ? "directory" : "file") + " rename:\n" + exceptionMessage);
            }
        }
    }


    /**
    * Rename a given file.
    *
    * @param sourceFile
    *     Source file to be renamed
    * @param targetFile
    *     Target file to which the source file is to be renamed
    * @return
    *     Result of the rename operation
    */
    public static FileIOResult renameFile(
            final File sourceFile,
            final File targetFile)
    {
        return renameFileDir(false, sourceFile, targetFile);
    }


    /**
    * Rename a given directory.
    *
    * @param sourceDir
    *     Source directory to be renamed
    * @param targetDir
    *     Target directory to which the source directory is to be renamed
    * @return
    *     Result of the rename operation
    */
    public static FileIOResult renameDir(
            final File sourceDir,
            final File targetDir)
    {
        return renameFileDir(true, sourceDir, targetDir);
    }


    /**
    * Set last-modified time of a given file or directory.
    *
    * @param isDir
    *     True if setting last-modified time of a directory, false otherwise
    * @param file
    *     File/directory of which last-modified time is to be set
    * @param time
    *     New last-modified time, measured in milliseconds since the epoch
    *     (00:00:00 GMT, January 1, 1970)
    * @return
    *     Result of the set last-modified time operation
    */
    private static FileIOResult setFileDirTime(
            final boolean isDir,
            final File file,
            final long time)
    {
        /* check if file/directory exists */
        if (!file.exists())
            return new FileIOResult(false,
                    ((isDir) ? "Directory" : "File") + " does not exist.");

        /* check if file/directory is a file/directory as specified */
        if (isDir != file.isDirectory())
            return new FileIOResult(false,
                    "Specified " + ((isDir) ? "directory" : "file") +
                    " is not a " + ((isDir) ? "directory." : "file."));

        boolean success = false;
        String exceptionMessage = null;

        try
        {
            success = file.setLastModified(time);
        }
        catch (Exception e)
        {
            exceptionMessage = ErrorWarningHandler.getExceptionMessage(e);
        }

        if (success)
        {
            return new FileIOResult(true, null);
        }
        else
        {
            if (exceptionMessage == null)
            {
                return new FileIOResult(false,
                        "Setting of last-modified time of " +
                        ((isDir) ? "directory" : "file") + " was unsuccessful.");
            }
            else
            {
                return new FileIOResult(false,
                        "Java exception encountered during " + "setting of last-modified time of " +
                        ((isDir) ? "directory:\n" : "file:\n") + exceptionMessage);
            }
        }
    }


    /**
    * Set last-modified time of a given file.
    *
    * @param file
    *     File of which last-modified time is to be set
    * @param time
    *     New last-modified time, measured in milliseconds since the epoch
    *     (00:00:00 GMT, January 1, 1970)
    * @return
    *     Result of the set last-modified time operation
    */
    public static FileIOResult setFileTime(
            final File file,
            final long time)
    {
        return setFileDirTime(false, file, time);
    }


    /**
    * Set last-modified time of a given directory.
    *
    * @param dir
    *     Directory of which last-modified time is to be set
    * @param time
    *     New last-modified time, measured in milliseconds since the epoch
    *     (00:00:00 GMT, January 1, 1970)
    * @return
    *     Result of the set last-modified time operation
    */
    public static FileIOResult setDirTime(
            final File dir,
            final long time)
    {
        return setFileDirTime(true, dir, time);
    }


    /**
    * Create directory.
    *
    * @param dir
    *     Directory to be created
    * @return
    *     Result of the directory creation operation
    */
    public static FileIOResult createDir(
            final File dir)
    {
        boolean success = false;
        String exceptionMessage = null;

        try
        {
            success = dir.mkdirs();
        }
        catch (Exception e)
        {
            exceptionMessage = ErrorWarningHandler.getExceptionMessage(e);
        }

        if (success)
        {
            return new FileIOResult(true, null);
        }
        else
        {
            if (exceptionMessage == null)
            {
                return new FileIOResult(false,
                        "Directory creation was unsuccessful.");
            }
            else
            {
                return new FileIOResult(false,
                        "Java exception encountered during directory creation:\n" + exceptionMessage);
            }
        }
    }


    /**
    * Get directory contents.
    *
    * @param dir
    *     Directory of which to get contents
    * @param files
    *     ArrayList of files to which files in the specified directory
    *     will be added
    * @param dirs
    *     ArrayList of directories to which directories in the specified
    *     directory will be added
    * @return
    *     Result of the directory operation
    */
    public static FileIOResult getDirContents(
            final File dir,
            final List<File> files,
            final List<File> dirs)
    {
        /* check if specified file/directory exists */
        if (!dir.exists())
            return new FileIOResult(false,
                    "Directory does not exist.");

        /* check if specified directory is a directory */
        if (!dir.isDirectory())
            return new FileIOResult(false,
                    "Specified directory is not a directory.");

        /* get list of files and directories in the specified directory */
        final File listFiles[] = dir.listFiles();

        if (listFiles == null)
        {
            return new FileIOResult(false,
                    "Failed to list contents of directory.");
        }
        else
        {
            for (File f : listFiles)
            {
                if (f.isDirectory())
                {
                    dirs.add(f);
                }
                else
                {
                    files.add(f);
                }
            }

            /* sort lists */
            Collections.sort(files);
            Collections.sort(dirs);

            return new FileIOResult(true, null);
        }
    }


    /**
    * Delete a file or directory.
    *
    * @param isDir
    *     True if deleting a directory, false otherwise
    * @param file
    *     File/directory to be deleted
    * @return
    *     Result of the file/directory delete operation
    */
    private static FileIOResult deleteFileDir(
            final boolean isDir,
            final File file)
    {
        /* check if specified file/directory exists */
        if (!file.exists())
            return new FileIOResult(false,
                    ((isDir) ? "Directory" : "File") + " does not exist.");

        /* check if file/directory is a file/directory as specified */
        if (isDir != file.isDirectory())
            return new FileIOResult(false,
                    "Specified " + ((isDir) ? "directory" : "file") +
                    " is not a " + ((isDir) ? "directory." : "file."));

        boolean success = false;
        String exceptionMessage = null;

        try
        {
            success = file.delete();
        }
        catch (Exception e)
        {
            exceptionMessage = ErrorWarningHandler.getExceptionMessage(e);
        }

        if (success)
        {
            return new FileIOResult(true, null);
        }
        else
        {
            if (exceptionMessage == null)
            {
                return new FileIOResult(false,
                        "Deleting of " + ((isDir) ? "directory" : "file") + " was unsuccessful.");
            }
            else
            {
                return new FileIOResult(false,
                        "Java exception encountered during deleting of " +
                        ((isDir) ? "directory:\n" : "file:\n") + exceptionMessage);
            }
        }
    }


    /**
    * Delete a file.
    *
    * @param file
    *     File to be deleted
    * @return
    *     Result of the file delete operation
    */
    public static FileIOResult deleteFile(
            final File file)
    {
        return deleteFileDir(false, file);
    }


    /**
    * Delete a directory.
    *
    * @param dir
    *     Directory to be deleted
    * @return
    *     Result of the directory delete operation
    */
    public static FileIOResult deleteDir(
            final File dir)
    {
        return deleteFileDir(true, dir);
    }


    /**
    * Delete a directory and all its contents (subdirectories and
    * files) recursively.
    *
    * @param dir
    *     Directory to be deleted, along with all its contents
    * @return
    *     Result of the directory tree delete operation
    */
    public static FileIOResult deleteDirTree(
            final File dir)
    {
        final List<File> files = new ArrayList<File>();
        final List<File> dirs = new ArrayList<File>();

        /* get contents of the specified directory */
        final FileIOResult getDirContentsResult = getDirContents(dir, files, dirs);

        if (!getDirContentsResult.success)
            return new FileIOResult(false, getDirContentsResult.errorMessage);

        /* error messages, if any */
        final StringBuilder errorMessages = new StringBuilder();

        /* delete files */
        for (File f : files)
        {
            final FileIOResult deleteFileResult = deleteFile(f);

            if (!deleteFileResult.success)
                errorMessages.append("\nFailed to delete file \"" + f.getPath() +
                        "\":\n" + deleteFileResult.errorMessage);
        }

        /* delete directories recursively */
        for (File d : dirs)
        {
            final FileIOResult deleteDirTreeResult = deleteDirTree(d);

            if (!deleteDirTreeResult.success)
                errorMessages.append("\nFailed to delete directory \"" + d.getPath() +
                        "\":\n" + deleteDirTreeResult.errorMessage);
        }

        /* delete this (specified) directory itself */
        final FileIOResult deleteDirResult = deleteDir(dir);

        if (!deleteDirResult.success)
            errorMessages.append("\nFailed to delete directory \"" + dir.getPath() +
                        "\":\n" + deleteDirResult.errorMessage);

        /* result of directory tree delete operation */
        return new FileIOResult(deleteDirResult.success, errorMessages.toString());
    }


    /**
    * Copy a file.
    *
    * @param sourceFile
    *     Source file
    * @param targetFile
    *     Target file
    * @return
    *     Result of file copy operation
    */
    public static FileIOResult copyFile(
            final File sourceFile,
            final File targetFile)
    {
        /* buffered input stream for reading */
        BufferedInputStream bis = null;

        /* buffered output stream for writing */
        BufferedOutputStream bos = null;

        try
        {
            /* error messages, if any */
            final StringBuilder errorMessages = new StringBuilder();

            try
            {
                bis = new BufferedInputStream(new FileInputStream(sourceFile));
            }
            catch (Exception e)
            {
                errorMessages.append("\nUnable to open source file for reading:\n" +
                        ErrorWarningHandler.getExceptionMessage(e));

                return new FileIOResult(false, errorMessages.toString());
            }

            /* parent directory of the target file */
            final File targetParentDir = targetFile.getParentFile();

            /* create parent directory of target file, if necessary */
            if (!targetParentDir.exists())
                createDir(targetParentDir);

            if (!targetParentDir.isDirectory())
            {
                errorMessages.append("\nUnable to create parent directory of target file.");

                return new FileIOResult(false, errorMessages.toString());
            }

            try
            {
                bos = new BufferedOutputStream(new FileOutputStream(targetFile));
            }
            catch (Exception e)
            {
                errorMessages.append("\nUnable to open target file for writing:\n" +
                        ErrorWarningHandler.getExceptionMessage(e));

                return new FileIOResult(false, errorMessages.toString());
            }

            /* byte buffer */
            final byte byteBuffer[] = new byte[FileIO.bufferSize];

            try
            {
                /* copy bytes from the source file to the target file */
                while (true)
                {
                    final int byteCount = bis.read(byteBuffer, 0, FileIO.bufferSize);

                    if (byteCount == -1)
                        break; /* reached EOF */

                    bos.write(byteBuffer, 0, byteCount);
                }
            }
            catch (Exception e)
            {
                errorMessages.append("\nUnable to copy data from source file to target file:\n" +
                        ErrorWarningHandler.getExceptionMessage(e));

                return new FileIOResult(false, errorMessages.toString());
            }

            try
            {
                bis.close();
                bis = null;
            }
            catch (Exception e)
            {
                errorMessages.append("\nUnable to close source file after reading:\n");
                errorMessages.append(ErrorWarningHandler.getExceptionMessage(e));
            }

            try
            {
                bos.close();
                bos = null;
            }
            catch (Exception e)
            {
                errorMessages.append("\nUnable to close target file after writing:\n");
                errorMessages.append(ErrorWarningHandler.getExceptionMessage(e));
            }

            final FileIOResult setFileTimeResult = setFileTime(targetFile, sourceFile.lastModified());

            if (!setFileTimeResult.success)
            {
                errorMessages.append("\nUnable to set last-modified time of target file:\n");
                errorMessages.append(setFileTimeResult.errorMessage);
            }

            /* result of file copy operation */
            return new FileIOResult(setFileTimeResult.success, errorMessages.toString());
        }
        finally
        {
            /* close buffered input stream for reading */
            if (bis != null)
            {
                try
                {
                    bis.close();
                }
                catch (Exception e)
                {
                    /* ignore */
                }
            }

            /* close buffered output stream for writing */
            if (bos != null)
            {
                try
                {
                    bos.close();
                }
                catch (Exception e)
                {
                    /* ignore */
                }
            }
        }
    }


    /**
    * Compute the CRC-32 checksum of a file.
    *
    * @param file
    *     File for which to compute the CRC-32 checksum
    * @return
    *     Result of the CRC-32 checksum computation
    */
    public static ComputeFileCRC32Result computeFileCRC32(
            final File file)
    {
        /* checksum of directory is defined as 0 */
        if (file.isDirectory())
            return new ComputeFileCRC32Result(true, null, 0L);

        /* buffered input stream for reading */
        BufferedInputStream bis = null;

        try
        {
            /* error messages, if any */
            final StringBuilder errorMessages = new StringBuilder();

            try
            {
                bis = new BufferedInputStream(new FileInputStream(file));
            }
            catch (Exception e)
            {
                errorMessages.append("\nUnable to open file for reading:\n" +
                        ErrorWarningHandler.getExceptionMessage(e) +
                        "\nAssuming file CRC-32 checksum of 0.");

                return new ComputeFileCRC32Result(false, errorMessages.toString(), 0L);
            }

            /* byte buffer */
            final byte byteBuffer[] = new byte[FileIO.bufferSize];

            /* CRC-32 object to track checksum computation */
            final CRC32 crc32 = new CRC32();

            try
            {
                /* read bytes from the file, and track the checksum computation */
                while (true)
                {
                    final int byteCount = bis.read(byteBuffer, 0, FileIO.bufferSize);

                    if (byteCount == -1)
                        break; /* reached EOF */

                    crc32.update(byteBuffer, 0, byteCount);
                }
            }
            catch (Exception e)
            {
                errorMessages.append("\nUnable to read data from file to compute checksum:\n" +
                        ErrorWarningHandler.getExceptionMessage(e) +
                        "\nAssuming file CRC-32 checksum of 0.");

                return new ComputeFileCRC32Result(false, errorMessages.toString(), 0L);
            }

            try
            {
                bis.close();
                bis = null;
            }
            catch (Exception e)
            {
                errorMessages.append("\nUnable to close file after reading:\n");
                errorMessages.append(ErrorWarningHandler.getExceptionMessage(e));
            }

            /* successful computation of CRC-32 checksum */
            return new ComputeFileCRC32Result(true, errorMessages.toString(), crc32.getValue());
        }
        finally
        {
            /* close buffered input stream for reading */
            if (bis != null)
            {
                try
                {
                    bis.close();
                }
                catch (Exception e)
                {
                    /* ignore */
                }
            }
        }
    }


    /**
    * Convert a path string from native form to neutral form.
    * In neutral form, the separator is always FileIO.neutralSeparatorChar.
    *
    * @param path
    *     The native path string to be converted.
    * @return
    *     The neutral form of the path string.
    */
    public static String nativeToNeutral(
            final String path)
    {
        return path.replace(File.separatorChar, FileIO.neutralSeparatorChar);
    }


    /**
    * Convert a path string from neutral form to native form.
    * In neutral form, the separator is always FileIO.neutralSeparatorChar.
    *
    * @param path
    *     The neutral path string to be converted.
    * @return
    *     The native form of the path string.
    */
    public static String neutralToNative(
            final String path)
    {
        return path.replace(FileIO.neutralSeparatorChar, File.separatorChar);
    }


    /**
    * Removes a trailing separator, if any, in the specified path string.
    *
    * @param path
    *     Path string to be trimmed
    * @return
    *     Path string after removal of a trailing separator
    */
    public static String trimTrailingSeparator(
            final String path)
    {
        if (path.endsWith(File.separator))
            return path.substring(0, path.length() - 1);

        return path;
    }


    /**
    * Inner class to represent the result of a file IO operation.
    */
    public static class FileIOResult
    {
        /** file IO operation is successful if true; unsuccessful otherwise */
        public boolean success = false;

        /** error message, if any */
        public String errorMessage = null;

        /**
        * Constructor.
        *
        * @param success
        *     File IO operation is successful if true; unsuccessful otherwise
        * @param errorMessage
        *     Error message, if any
        */
        FileIOResult(
                final boolean success,
                final String errorMessage)
        {
            this.success = success;
            this.errorMessage = errorMessage;
        }
    }


    /**
    * Inner class to represent the result of a file CRC-32 computation.
    */
    public static class ComputeFileCRC32Result
    {
        /** file CRC-32 computation is successful if true; unsuccessful otherwise */
        public boolean success = false;

        /** error message, if any */
        public String errorMessage = null;

        /** file CRC-32 checksum value */
        public long checksum;

        /**
        * Constructor.
        *
        * @param success
        *     File CRC-32 computation is successful if true; unsuccessful otherwise
        * @param errorMessage
        *     Error message, if any
        * @param checksum
        *     File CRC-32 checksum value
        */
        ComputeFileCRC32Result(
                final boolean success,
                final String errorMessage,
                final long checksum)
        {
            this.success = success;
            this.errorMessage = errorMessage;
            this.checksum = checksum;
        }
    }
}