/**
 * This file is part of muCommander, http://www.mucommander.com
 *
 * muCommander is free software; you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * muCommander 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 Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */


package com.mucommander.commons.file.util;

import com.mucommander.commons.file.AbstractFile;

import java.util.Comparator;
import java.util.regex.Matcher;
import java.util.regex.Pattern;


/**
 * FileComparator compares {@link AbstractFile} instances using a comparison criterion order (ascending or descending).
 * Directories can either be mixed with regular files (compared just as regular files), or always precede regular files.

 * <p>FileComparator extends Comparator and thus can be used wherever a Comparator is accepted. In particular, it
 * can be used with <code>java.util.Arrays</code> sort methods to easily sort an array of files.
 *
 * <p>The following criteria are available:
 * <ul>
 * <li>{@link #NAME_CRITERION}: compares filenames returned by {@link AbstractFile#getName()}
 * <li>{@link #SIZE_CRITERION}: compares file sizes returned by {@link AbstractFile#getSize()}. Note: size for
 * directories is always considered as 0, even if {@link AbstractFile#getSize()} returns something else. 
 * <li>{@link #DATE_CRITERION}: compares file dates returned by {@link AbstractFile#getDate()}
 * <li>{@link #EXTENSION_CRITERION}: compares file extensions returned by {@link AbstractFile#getExtension()}
 * <li>{@link #PERMISSIONS_CRITERION}: compares file permissions returned by {@link AbstractFile#getPermissions()}
 * </ul>
 *
 * @author Maxence Bernard
 */
public class FileComparator implements Comparator<AbstractFile> {

    /** Comparison criterion */
    private int criterion;
    /** Ascending or descending order ? */
    private boolean ascending;
    /** Specifies whether directories should precede files or be handled as regular files */
    private boolean directoriesFirst;

    /** Criterion for filename comparison. */
    public final static int NAME_CRITERION = 0;
    /** Criterion for file size comparison. */
    public final static int SIZE_CRITERION = 1;
    /** Criterion for file date comparison. */
    public final static int DATE_CRITERION = 2;
    /** Criterion for file extension comparison. */
    public final static int EXTENSION_CRITERION = 3;
    /** Criterion for file permissions comparison. */
    public final static int PERMISSIONS_CRITERION = 4;
    /** Criterion for owner comparison. */
    public final static int OWNER_CRITERION = 5;
    /** Criterion for group comparison. */
    public final static int GROUP_CRITERION = 6;

    /** Matches filenames that contain a number, like "01 - Do the Joy.mp3" */
    private final static Pattern FILENAME_WITH_NUMBER_PATTERN = Pattern.compile("\\d+");


    /**
     * Creates a new FileComparator using the specified comparison criterion, order (ascending or descending) and
     * directory handling rule.
     *
     * @param criterion comparison criterion, see constant fields
     * @param ascending if true, ascending order will be used, descending order otherwise
     * @param directoriesFirst specifies whether directories should precede files or be handled as regular files
     */
    public FileComparator(int criterion, boolean ascending, boolean directoriesFirst) {
        this.criterion = criterion;
        this.ascending = ascending;
        this.directoriesFirst = directoriesFirst;
    }


    /**
     * Returns a <code>value</code> for the given character. Using this function in a comparator will separator
     * symbols for digits and letters and put in the following order:
     * <ul>
     *   <li>symbols first</li>
     *   <li>digits second</li>
     *   <li>letters third</li>
     * </ul>
     *
     * <p>This character order was suggested in ticket #282.</p> 
     *
     * @param c character for which to return a value
     * @return a <code>value</code> for the given character
     */
    private int getCharacterValue(int c) {
        // Note: max char value is 65535
        if(Character.isLetter(c))
            c += 131070;    // yields a value higher than any other symbol or digit
        else if(Character.isDigit(c))
            c += 65535;     // yields a value higher than any other symbol

        // else we have a symbol
        return c;
    }

    /**
     * Removes leading zeros ('0') from the given string (if it contains any), and returns the trimmed string.
     *
     * @param s the string from which to remove leading zeros
     * @return a string without leading zeros
     */
    private String removeLeadingZeros(String s) {
        int len = s.length();
        int i=0;
        while(i<len && s.charAt(i)=='0')
            i++;

        if(i>0)
            return s.substring(i, len);

        return s;
    }

    /**
     * Compare the specified strings, following the contract of {@link Comparator#compare(Object, Object)}.
     *
     * @param s1 first string to compare
     * @param s2 second string to compare.
     * @param ignoreCase <code>true</code> to perform a case-insensitive string comparison, <code>false</code> to take
     * the case into account.
     * @param nullProtection <code>true</code> if any of s1 or s2 can be <code>null</code>, <code>false</code>
     * if strings cannot be <code>null</code>.
     * @return a negative integer, zero, or a positive integer as the first argument is less than, equal to, or greater
     * than the second.
     */
    private int compareStrings(String s1, String s2, boolean ignoreCase, boolean nullProtection) {
        // Protect against null values, only if requested
        if(nullProtection) {
            if(s1==null && s2!=null)	    // s1 is null, s2 isn't
                return -1;
            else if(s1!=null && s2==null)	// s2 is null, s1 isn't
                return 1;
            // At this point, either both strings are null, or none of them are
            else {
                if (s1==null)		        // Both strings are null
                    return 0;
                // else: Both strings are not null, go on with the comparison
            }
        }

        // Special treatment for strings that contain a number, so they are ordered by the number's value, e.g.:
        // 1 < 1a < 2 < 10, like Mac OS X Finder and Windows Explorer do.
        //
        // This special order applies only if both strings contain a number and have the same prefix. Otherwise, the general order applies.
        Matcher m1 = FILENAME_WITH_NUMBER_PATTERN.matcher(s1);
        if(m1.find()) {
            Matcher m2 = FILENAME_WITH_NUMBER_PATTERN.matcher(s2);
            if(m2.find()) {
                // So we got two filenames that both contain a number, check if they have the same prefix
                int start1 = m1.start();
                int start2 = m2.start();

                // Note: compare prefixes only if start indexes match, faster that way
                if(start1==start2 && (start1==0 || s1.substring(0, start1).equals(s2.substring(0, start2)))) {
                    String g1 = removeLeadingZeros(m1.group());
                    String g2 = removeLeadingZeros(m2.group());

                    int g1Len = g1.length();
                    int g2Len = g2.length();

                    if(g1Len!=g2Len)
                        return g1Len - g2Len;

                    int c1, c2;
                    for (int i=0; i<g1Len && i<g2Len; i++) {
                        c1 = g1.charAt(i);
                        c2 = g2.charAt(i);
                        if(c1 != c2)
                            return c1 - c2;
                    }
                }
            }
        }

        int n1 = s1.length();
        int n2 = s2.length();

        for (int i=0; i<n1 && i<n2; i++) {
            int c1 = s1.charAt(i);
            int c2 = s2.charAt(i);

            if(ignoreCase) {
                if (c1 != c2) {
                    c1 = Character.toUpperCase(c1);
                    c2 = Character.toUpperCase(c2);
                    if (c1 != c2) {
                        // Quote from String#regionsMatches:
                        // "Unfortunately, conversion to uppercase does not work properly for the Georgian alphabet, which
                        // has strange rules about case conversion. So we need to make one last check before exiting."
                        c1 = Character.toLowerCase(c1);
                        c2 = Character.toLowerCase(c2);

                        if (c1 != c2)
                            return getCharacterValue(c1) -  getCharacterValue(c2);
                    }
                }
            }
            else if (c1 != c2) {
                return getCharacterValue(c1) -  getCharacterValue(c2);
            }
        }

        return n1 - n2;
    }


    ///////////////////////////////
    // Comparator implementation //
    ///////////////////////////////
    
    public int compare(AbstractFile f1, AbstractFile f2) {
        long diff;

        boolean is1Directory = f1.isDirectory();
        boolean is2Directory = f2.isDirectory();

        if(directoriesFirst) {
            if(is1Directory && !is2Directory)
                return -1;	// ascending has no effect on the result (a directory is always first) so let's return
            else if(is2Directory && !is1Directory)
                return 1;	// ascending has no effect on the result (a directory is always first) so let's return

            // At this point, either both files are directories or none of them are
        }

        if (criterion == SIZE_CRITERION)  {
            // Consider that directories have a size of 0
            long fileSize1 = is1Directory?0:f1.getSize();
            long fileSize2 = is2Directory?0:f2.getSize();

            // Returns file1 size - file2 size, file size of -1 (unavailable) is considered as enormous (max long value)
            diff = (fileSize1==-1?Long.MAX_VALUE:fileSize1)-(fileSize2==-1?Long.MAX_VALUE:fileSize2);
        }
        else if (criterion == DATE_CRITERION) {
            diff = f1.getDate()-f2.getDate();
        }
        else if (criterion == PERMISSIONS_CRITERION) {
            diff = f1.getPermissions().getIntValue() - f2.getPermissions().getIntValue();
        }
        else if (criterion == EXTENSION_CRITERION) {
            diff = compareStrings(f1.getExtension(), f2.getExtension(), true, true);
        }
        else if (criterion == OWNER_CRITERION) {
            diff = compareStrings(f1.getOwner(), f2.getOwner(), true, true);
        }
        else if (criterion == GROUP_CRITERION) {
            diff = compareStrings(f1.getGroup(), f2.getGroup(), true, true);
        }
        else {      // criterion == NAME_CRITERION
            diff = compareStrings(f1.getName(), f2.getName(), true, false);

            if(diff==0) {
                // This should never happen unless the current filesystem allows a directory to have
                // several files with different case variations of the same name.
                // AFAIK, no OS/filesystem allows this, but just to be safe.

                // Case-sensitive name comparison
                diff = compareStrings(f1.getName(), f2.getName(), false, false);
            }
        }

        if(criterion!=NAME_CRITERION && diff==0)	// If both files have the same criterion's value, compare names
            diff = compareStrings(f1.getName(), f2.getName(), true, false);

        // Cast long value to int, without overflowing the int if the long value exceeds the min or max int value
        int intValue;
        
        if(diff>Integer.MAX_VALUE)
            intValue = Integer.MAX_VALUE;   // 2147483647
        else if(diff<Integer.MIN_VALUE+1)   // Need that +1 so that the int is not overflowed if ascending order is enabled (i.e. int is negated)
            intValue = Integer.MIN_VALUE+1; // 2147483647
        else
            intValue = (int)diff;

        return ascending?intValue:-intValue; // Note: ascending is used more often, more efficient to negate for descending
    }


    /**
     * Returns true only if the given object is a FileComparator using the same criterion and ascending/descending order.
     */
    public boolean equals(Object o) {
        if(! (o instanceof FileComparator))
            return false;

        FileComparator fc = (FileComparator)o;
        return criterion ==fc.criterion && ascending==fc.ascending;
    }
}
