/* Copyright (C) 2005-2011 Fabio Riccardi */

package com.lightcrafts.utils.file;

import java.io.*;
import java.util.Collection;
import java.util.ArrayList;
import java.util.Scanner;
import java.util.NoSuchElementException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.nio.channels.FileChannel;

import com.lightcrafts.platform.Platform;

/**
 * A <code>FileUtil</code> is a set of utility functions for files.
 *
 * @author Paul J. Lucas [paul@lightcrafts.com]
 */
public final class FileUtil {

    ////////// public /////////////////////////////////////////////////////////

    /**
     * Checks whether the given directory contains at least one {@link File}
     * that would be accepted by the given {@link FileFilter}.
     *
     * @param dir The directory to check.
     * @param filter The {@link FileFilter} to use, or <code>null</code>.
     * @return Returns <code>true</code> only if the directory contains at
     * at least one {@link File} that has been accepted by the given
     * {@link FileFilter}.
     */
    public static boolean containsAtLeastOne( File dir, FileFilter filter ) {
        dir = Platform.getPlatform().isSpecialFile( dir );
        final File[] allFiles = dir.listFiles();
        if ( allFiles != null && allFiles.length > 0 ) {
            if ( filter == null )
                return true;
            for ( File file : allFiles ) {
                final File file2 = Platform.getPlatform().isSpecialFile( file );
                if ( filter.accept( file2 ) )
                    return true;
            }
        }
        return false;
    }

    /**
     * Copy the contents of a {@link File}.
     *
     * @param source The {@link File} to copy from.
     * @param target The {@link File} to copy to.
     * @throws IOException if anything goes wrong.
     */
    public static void copyFile( File source, File target ) throws IOException {
        final FileChannel sourceChannel =
            new FileInputStream( source ).getChannel();
        final FileChannel targetChannel =
            new FileOutputStream( target ).getChannel();
        try {
            sourceChannel.transferTo( 0, sourceChannel.size(), targetChannel );
        }
        finally {
            try {
                targetChannel.close();
            }
            finally {
                sourceChannel.close();
            }
        }
    }

    /**
     * Delete a file.  If the file is actually a directory, delete the files it
     * contains as well.  If the directory contains subdirectories, they are
     * also deleted only if they are either empty or <code>recursive</code> is
     * <code>true</code>.
     *
     * @param dir The {@link File} to delete.
     * @param filter The {@link FileFilter} to use, or <code>null</code>.
     * @param recursive If <code>true</code>, also delete all encountered
     * subdirectories and their contents.
     * @return Returns <code>false</code> only if at least one delete attempt
     * fails.
     */
    public static boolean delete( File dir, FileFilter filter,
                                  boolean recursive ) {
        if ( dir.isDirectory() && !(dir instanceof SmartFolder) && recursive &&
             !delete( listFiles( dir, filter, recursive ), filter, recursive ) )
            return false;
        return dir.delete();
    }

    /**
     * Delete an array of {@link File}s.  For files that are actually
     * directories (and not {@link SmartFolder}s), they are also deleted only
     * if they are either empty or <code>recursive</code> is <code>true</code>.
     *
     * @param files The {@link File}s to delete.
     * @param filter The {@link FileFilter} to use; may be <code>null</code>.
     * @param recursive If <code>true</code>, also delete all enountered
     * subdirectories and their contents.
     * @return Returns <code>true</code> only if all the files were deleted
     * successfully and there were no I/O errors.
     */
    public static boolean delete( File[] files, FileFilter filter,
                                  boolean recursive ) {
        boolean result = true;
        if ( files != null )
            for ( File file : files ) {
                if ( file.isDirectory() && !(file instanceof SmartFolder) &&
                     recursive ) {
                    final File[] subFiles = listFiles( file, filter, true );
                    if ( subFiles == null || subFiles.length > 0 &&
                         !delete( subFiles, filter, true ) )
                        result = false;
                }
                if ( !file.delete() )
                    result = false;
            }
        return result;
    }

    /**
     * Decodes a filename that was encoded by {@link #encodeFilename(String)}.
     *
     * @param name The filename to be decoded.
     * @return Returns the decoded filename.
     * @see #encodeFilename(String)
     */
    public static String decodeFilename( String name ) {
        int from = 0;
        while ( true ) {
            final int i = name.indexOf( '%', from );
            if ( i >= 0 && i < name.length() - 2 ) {
                try {
                    final String hex = name.substring( i + 1, i + 3 );
                    final int ascii = Integer.parseInt( hex, 16 );
                    final String to = String.valueOf( (char)ascii );
                    name = name.substring( 0, i ) + to + name.substring( i+3 );
                }
                catch ( NumberFormatException e ) {
                    // ignore
                }
                from = i + 1;
            } else
                return name;
        }
    }

    /**
     * Encodes a filename such that each illeal character is replaced by a 3
     * character sequence composed of a '%' followed by the ASCII code for the
     * illegal character expressed in hexadecimal.  Additionally, the '%'
     * character, although not illegal, is also encoded.  This encoding scheme
     * is similar to URL encoding.
     * <p>
     * The set of illegal characters is the union of all the illegal characters
     * for Linux, Mac OS X, and Windows.
     *
     * @param name The filename to encode.
     * @return Returns the encoded filename.
     * @see #decodeFilename(String)
     */
    public static String encodeFilename( String name ) {
        //
        // Unfortunately, Java's String class doesn't have any equivalent of
        // the strpbrk(3) C standard library function.  :-(
        //
        for ( int i = 0; i < ILLEGAL_FILENAME_CHARS.length(); ++i ) {
            final char c = ILLEGAL_FILENAME_CHARS.charAt( i );
            if ( name.indexOf( c ) >= 0 ) {
                final String from = String.valueOf( c );
                //noinspection UnnecessaryBoxing
                final String to = String.format( "%%%02X", new Integer( c ) );
                name = name.replace( from, to );
            }
        }
        return name;
    }

    /**
     * Gets the last access time of the given file.
     *
     * @param file The {@link File} to get the last access time for.
     * @return Returns the number of milliseconds since epoch of the last
     * access time.
     * @throws IOException if the file doesn't exist or the access time could
     * not be obtained.
     */
    public static long getLastAccessTimeOf( File file ) throws IOException {
        return getLastAccessTime( file.getAbsolutePath() ) * 1000;
    }

    /**
     * Gets the extension (the part of the file's name after the
     * <code>'.'</code>) of the given file.
     *
     * @param file The {@link File} to get the extension of.
     * @return Returns the extension (without the <code>'.'</code>) or
     * <code>null</code> if the file has no extension.
     * @see #replaceExtensionOf(File,String)
     * @see #replaceExtensionOf(String,String)
     * @see #trimExtensionOf(File)
     * @see #trimExtensionOf(String)
     */
    public static String getExtensionOf( File file ) {
        final String fileName = file.getName();
        final int dot = fileName.lastIndexOf( '.' );
        if ( dot <= 0 || dot == fileName.length() - 1 )
            return null;
        return fileName.substring( dot + 1 );
    }

    /**
     * Gets a {@link File} in the file's directory that doesn't collide with
     * any existing files by appending or inserting a numbered suffix.
     * <p>
     * For example, if the file <code>/tmp/foo.jpg</code> exists, returns
     * <code>/tmp/foo-1.jpg</code>; if <code>/tmp/foo-1.jpg</code> exists,
     * returns <code>/tmp/foo-2.jpg</code>; and so on.
     *
     * @param file The {@link File} to start with.
     * @return Returns said file.  Note that if the given file doesn't exist,
     * returns that file.
     */
    public static File getNoncollidingFileFor( File file ) {
        while ( true ) {
            if ( !file.exists() )
                return file;
            final String name = file.getName();
            final Matcher m = NUMBERED_FILE_PATTERN.matcher( name );
            final String newName;
            if ( m.matches() ) {
                final int next = Integer.parseInt( m.group(1) ) + 1;
                newName =
                    name.substring( 0, m.start(1) ) + '-' + next +
                    name.substring( m.end(1) );
            } else {
                final int dot = name.lastIndexOf( '.' );
                newName =
                    name.substring( 0, dot ) + "-1" +
                    name.substring( dot );
            }
            file = new File( file.getParentFile(), newName );
        }
    }

    /**
     * Gets the platform's temporary directory.
     *
     * @return Returns said directory.
     * @throws IOException if anything goes wrong.
     */
    public static File getTempDir() throws IOException {
        File temp = null;
        try {
            temp = File.createTempFile( "LZTemp", null );
            return temp.getParentFile();
        }
        finally {
            if ( temp != null )
                temp.delete();
        }
    }

    /**
     * Inserts (if necessary) the given suffix into the filename just before
     * the extension.
     *
     * @param file The {@link File} whose name to insert the suffix into.
     * @param suffix The suffix to insert.
     * @return Returns the new filename.
     * @see #insertSuffix(String,String)
     */
    public static String insertSuffix( File file, String suffix ) {
        return insertSuffix( file.getAbsolutePath(), suffix );
    }

    /**
     * Inserts (if necessary) the given suffix into the filename just before
     * the extension.
     *
     * @param fileName The filename whose name to insert the suffix into.
     * @param suffix The suffix to insert.
     * @return Returns the new filename if the original file did not contain
     * the suffix, or the original filename if it already did.
     * @see #insertSuffix(File,String)
     */
    public static String insertSuffix( String fileName, String suffix ) {
        final int dot = fileName.lastIndexOf( '.' );
        if ( dot <= 0 || dot == fileName.length() - 1 )
            return null;
        final int suffixBegin = dot - suffix.length();
        if ( suffixBegin >= 0 ) {
            final String beforeDot = fileName.substring( suffixBegin, dot );
            if ( beforeDot.equals( suffix ) )
                return fileName;
        }
        return  fileName.substring( 0, dot ) + suffix +
                fileName.substring( dot );
    }

    /**
     * Checks whether the given {@link File} is a non-hidden, traversable
     * folder (including {@link SmartFolder}s).  This should be used instead of
     * {@link File#isDirectory()} for folders that are presented to the user.
     *
     * @param file The {@link File} to check.
     * @return Returns the rusult of {@link Platform#isSpecialFile(File)} only
     * if the given {@link File} is a non-hidden, traversable folder; otherwise
     * returns <code>null</code>.
     */
    public static File isFolder( File file ) {
        if ( file.isHidden() )
            return null;
        final Platform platform = Platform.getPlatform();
        file = platform.isSpecialFile( file );
        if ( file.isFile() )
            return null;
        if ( file instanceof SmartFolder ) {
            //
            // We must test for SmartFolders explicitly because they're not
            // considered "traversable" by Java.
            //
            return file;
        }
        return platform.getFileSystemView().isTraversable( file ) ? file : null;
    }

    /**
     * Gets an array of file in the given directory.
     * <p>
     * This method needs to be used rather than
     * {@link File#listFiles(FileFilter)} because, for some reason, the latter
     * method doesn't include things like "My Computer" under Windows.
     *
     * @param dir The directory to get the list of child files of.
     * @return Returns said array or <code>null</code> if there was an I/O
     * error.
     * @see #listFiles(File,FileFilter,boolean)
     */
    public static File[] listFiles( File dir ) {
        return listFiles( dir, null, false );
    }

    /**
     * Gets an array of file in the given directory.
     * <p>
     * This method needs to be used rather than
     * {@link File#listFiles(FileFilter)} because, for some reason, the latter
     * method doesn't include things like "My Computer" under Windows.
     *
     * @param dir The directory to get the list of child files of.
     * @param filter The {@link FileFilter} to use, or <code>null</code>.
     * @param includeDirs If <code>true</code>, include directories regardless
     * of the filter.  If the filter is <code>null</code>, this parameter is
     * ignored.
     * @return Returns said array or <code>null</code> if there was an I/O
     * error.
     * @see #listFiles(File)
     */
    public static File[] listFiles( File dir, FileFilter filter,
                                    boolean includeDirs ) {
        dir = Platform.getPlatform().isSpecialFile( dir );
        final File[] allFiles = dir.listFiles();
        if ( allFiles == null || allFiles.length == 0 || filter == null )
            return allFiles;
        final Collection<File> filteredFiles = new ArrayList<File>();
        for ( File file : allFiles ) {
            final File file2 = Platform.getPlatform().isSpecialFile( file );
            if ( file2.isDirectory() && includeDirs || filter.accept( file2 ) )
                filteredFiles.add( file );
        }
        return filteredFiles.toArray( new File[0] );
    }

    /**
     * Reads an entire file into a {@link String}.
     *
     * @param file The {@link File} to read.
     * @return Returns the entire contents of the file as a {@link String}.
     * @see #readEntireStream(InputStream)
     */
    public static String readEntireFile( File file ) throws IOException {
        final InputStream is = new FileInputStream( file );
        try {
            return readEntireStream( is );
        }
        finally {
            is.close();
        }
    }

    /**
     * Reads the entire contents of an {@link InputStream} into a
     * {@link String} using the UTF-8 encoding.
     *
     * @param in The {@link InputStream} to read.  It is not closed.
     * @return Returns the entire contents of the file.
     * @see #readEntireFile(File)
     */
    public static String readEntireStream( InputStream in ) throws IOException {
        final byte[] buf = new byte[ 1024 ];
        int bytesRead;
        final StringBuilder sb = new StringBuilder();
        while ( (bytesRead = in.read( buf )) > 0 )
            sb.append( new String( buf, 0, bytesRead, "UTF-8" ) );
        return sb.toString();
    }

    /**
     * Renames a {@link File}.  Unlike {@link File#renameTo(File)}, this method
     * throws {@link IOException} if the rename fails.
     *
     * @param from The {@link File} to rename.
     * @param to The {@link File} to rename to.
     * @throws IOException if the rename fails.
     */
    public static void renameFile( File from, File to ) throws IOException {
        try {
            //
            // Windows doesn't allow renaming a file to an existing file, so we
            // have to delete it first.
            //
            to.delete();

            if ( !from.renameTo( to ) )
                throw new IOException();
        }
        catch ( SecurityException e ) {
            final IOException ioe = new IOException();
            ioe.initCause( e );
            throw ioe;
        }
    }

    /**
     * Replaces the extension (the part of the file's name after the
     * <code>'.'</code>) with a new extension.
     *
     * @param file The {@link File} to replace the extension of.
     * @param newExtension The new extension (without the <code>'.'</code>).
     * @return Returns a new filename with the extension replaced or
     * <code>null</code> if the file has no extension.
     * @see #getExtensionOf(File)
     * @see #replaceExtensionOf(String,String)
     * @see #trimExtensionOf(File)
     */
    public static String replaceExtensionOf( File file, String newExtension ) {
        return replaceExtensionOf( file.getAbsolutePath(), newExtension );
    }

    /**
     * Replaces the extension (the part of the file's name after the
     * <code>'.'</code>) with a new extension.
     *
     * @param fileName The name of the file to replace the extension of.
     * @param newExtension The new extension (without the <code>'.'</code>).
     * @return Returns a new filename with the extension replaced or
     * <code>null</code> if the file has no extension.
     * @see #getExtensionOf(File)
     * @see #replaceExtensionOf(File,String)
     * @see #trimExtensionOf(File)
     */
    public static String replaceExtensionOf( String fileName,
                                             String newExtension ) {
        final int dot = fileName.lastIndexOf( '.' );
        if ( dot <= 0 || dot == fileName.length() - 1 )
            return null;
        return fileName.substring( 0, dot + 1 ) + newExtension;
    }

    /**
     * Resolves a {@link File} if it's an alias.
     *
     * @param file The {@link File} that may be an alias to resolve.
     * @return Returns a resolved {@link File}, or the original {@link File} if
     * it didn't refer to an alias or if there was an error.
     */
    public static File resolveAliasFile( File file ) {
        if ( file != null ) {
            final String resolvedPath =
                Platform.getPlatform().resolveAliasFile( file );
            if ( resolvedPath != null &&
                 !resolvedPath.equals( file.getAbsolutePath() ) )
                file = new File( resolvedPath );
        }
        return file;
    }

    /**
     * Touch (update the modification time) the given file.
     *
     * @param file The {@link File} to touch.
     */
    public static void touch( File file ) {
        file.setLastModified( System.currentTimeMillis() );
    }

    /**
     * Trim the extension (the part of the file's name after the
     * <code>'.'</code>).
     *
     * @param file The {@link File} to trim the extension from.
     * @return Returns a new filename with the extension removed or the
     * original filename if there was no extension.
     * @see #getExtensionOf(File)
     * @see #replaceExtensionOf(File,String)
     * @see #replaceExtensionOf(String,String)
     * @see #trimExtensionOf(String)
     */
    public static String trimExtensionOf( File file ) {
        return trimExtensionOf( file.getAbsolutePath() );
    }

    /**
     * Trim the extension (the part of the file's name after the
     * <code>'.'</code>).
     *
     * @param fileName The filename to trim the extension from.
     * @return Returns a new filename with the extension removed or the
     * original filename if there was no extension.
     * @see #getExtensionOf(File)
     * @see #replaceExtensionOf(File,String)
     * @see #replaceExtensionOf(String,String)
     * @see #trimExtensionOf(File)
     */
    public static String trimExtensionOf( String fileName ) {
        final int dot = fileName.lastIndexOf( '.' );
        return dot >= 1 ? fileName.substring( 0, dot ) : fileName;
    }

    ////////// private ////////////////////////////////////////////////////////

    /**
     * Gets the last access time of a file.
     *
     * @param fileName The full path to the file.
     * @return Returns the number of seconds since epoch of the last access
     * time.
     * @throws IOException if the file doesn't exist or the access time could
     * not be obtained.
     */
    private static native long getLastAccessTime( String fileName )
        throws IOException;

    /**
     * The set of characters that are illegal in filenames comprising Linux,
     * Mac OS X, and Windows.  Additionally, the '%' character, although not
     * illegal, is included first so it will be encoded first by
     * {@link #encodeFilename(String)}.
     */
    private static final String ILLEGAL_FILENAME_CHARS = "%\"*/:<>?\\|";

    private static final Pattern NUMBERED_FILE_PATTERN =
        Pattern.compile( "^.*-(\\d+)\\.[a-z]{3,4}$" );

    static {
        System.loadLibrary( "LCFileUtil" );
    }

}
/* vim:set et sw=4 ts=4: */
