/**
 * Sync 2.1
 * Copyright 2007 Zach Scrivena
 * 2007-12-09
 * zachscrivena@gmail.com
 * http://syncdir.sourceforge.net/
 *
 * Sync performs one-way directory or file synchronization.
 *
 * 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 sync;

import java.io.Console;
import java.io.File;
import java.io.PrintWriter;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.Deque;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Scanner;
import java.util.TreeMap;
import java.util.regex.PatternSyntaxException;


/**
 * Sync performs one-way directory or file synchronization.
 */
public class Sync
{
    /******************************************
    * CONSTANTS AND MISCELLANEOUS PARAMETERS *
    ******************************************/

    /** constant: program title */
    private static final String PROGRAM_TITLE =
            "Sync 2.1   Copyright 2007 Zach Scrivena   2007-12-09";

    /** constant: time format string (yyyy-MM-dd HH:mm:ss.SSS) */
    static final String TIME_FORMAT_STRING = "%1$tF %1$tT.%1$tL";

    /** parameter: true if this is a Windows OS, false otherwise */
    private static boolean isWindowsOperatingSystem = false;

    /******************************
    * SYNCHRONIZATION PARAMETERS *
    ******************************/

    /** parameter: simulate only; do not modify target (default = false) */
    private static boolean simulateOnly = false;

    /** parameter: ignore warnings; do not pause (default = false) */
    private static boolean ignoreWarnings = false;

    /** parameter: canonical full pathname of log file (default = null) */
    private static String logName = null;

    /** parameter: log file PrintWriter (default = null) */
    static PrintWriter log = null;

    /** parameter: standard output */
    static PrintWriter stdout = null;

    /** parameter: standard error */
    static PrintWriter stderr = null;

    /** parameter: do not recurse into subdirectories (default = false) */
    private static boolean noRecurse = false;

    /** enum type: synchronization mode (DIRECTORY, FILE) */
    private static enum SyncMode
    {
        DIRECTORY,
        FILE;
    }

    /** parameter: synchronization mode (DIRECTORY, FILE) */
    private static SyncMode syncMode;

    /** parameter: match file/directory name (default = true) */
    private static boolean matchName = true;

    /** parameter: match file/directory size (always true) */
    private static final boolean matchSize = true;

    /** parameter: match file/directory last-modified time (default = true) */
    private static boolean matchTime = true;

    /** parameter: match file/directory CRC-32 checksum (default = true) */
    private static boolean matchCrc = true;

    /** parameter: time-tolerance in milliseconds for file-matching (default = 0) */
    private static long matchTimeTolerance = 0L;

    /** parameter: string representation of match attributes, e.g. "(name,size,time,CRC)" */
    private static String matchNstcString;

    /** partial FileUnit comparator for file-matching */
    private static FileUnitComparator matchFileUnitComparator;

    /** partial FileUnit comparator for searching (should be a "truncated" version of Sync.matchFileUnitComparator) */
    private static FileUnitComparator searchFileUnitComparator;

    /** name-only FileUnit comparator */
    private static FileUnitComparator nameOnlyFileUnitComparator;

    /** parameter: canonical full pathname of source file/directory (ends with a separator for a directory) */
    private static String sourceName;

    /** parameter: canonical full pathname of target file/directory (ends with a separator for a directory) */
    private static String targetName;

    /** parameter: source file/directory (canonical full pathname) */
    private static File source;

    /** parameter: target file/directory (canonical full pathname) */
    private static File target;

    /** parameter: default action on renaming matched files (default = '\0') */
    private static char defaultActionOnRenameMatched = '\0';

    /** parameter: default action on synchronizing time of matched files (default = '\0') */
    private static char defaultActionOnTimeSyncMatched = '\0';

    /** parameter: default action on deleting unmatched target files/directories (default = '\0') */
    private static char defaultActionOnDeleteUnmatched = '\0';

    /** parameter: default action on overwriting existing target files (default = '\0') */
    static char defaultActionOnOverwrite = '\0';

    /** parameter: filter for source file/directory names (default = null) */
    private static FilterNode sourceFilter = null;

    /** parameter: filter for target file/directory names (default = null) */
    private static FilterNode targetFilter = null;

    /** parameter: filter relative pathnames instead of filenames (default = false) */
    private static boolean filterRelativePathname = false;

    /** parameter: use lower case names for filtering (default = false) */
    private static boolean filterLowerCase = false;

    /*********************
    * REPORT STATISTICS *
    *********************/

    /** statistic: number of warnings encountered */
    private static int reportNumWarnings = 0;


    /**
    * Main entry point for the Sync program.
    *
    * @param args
    *     Command-line argument strings
    */
    public static void main(
            final String[] args)
    {
        /* initialize standard output and error streams */
        final Console console = System.console();

        if (console == null)
        {
            Sync.stdout = new PrintWriter(System.out);
            Sync.stderr = new PrintWriter(System.err);
        }
        else
        {
            Sync.stdout = console.writer();
            Sync.stderr = console.writer();
        }

        /* display program title */
        SyncIO.printFlush("\n" + Sync.PROGRAM_TITLE);

        /* exit status code to be reported to the OS when exiting (default = 0) */
        int exitCode = 0;

        try
        {
            /* determine if this is a Windows OS */
            Sync.isWindowsOperatingSystem = System.getProperty("os.name").toUpperCase(Locale.ENGLISH).contains("WINDOWS") &&
                    (File.separatorChar == '\\');

            /* process command-line arguments and configure synchronization parameters */
            processArguments(args);

            SyncIO.printLog("\n" + Sync.PROGRAM_TITLE);

            /* perform synchronization */
            switch (Sync.syncMode)
            {
                case DIRECTORY:
                    syncDirectory();
                    break;

                case FILE:
                    syncFile();
                    break;
            }

            SyncIO.print("\n\nSync is done!\n\n");
        }
        catch (TerminatingException e)
        {
            /* terminating exception thrown; proceed to abort program */
            /* (this should be the only place where a TerminatingException is caught) */

            exitCode = e.getExitCode();

            if (exitCode != 0)
            {
                /* abnormal termination; SyncIO.print error message */
                SyncIO.printToErr("\n\nERROR: " + e.getMessage() + "\n");
                SyncIO.print("\nSync aborted.\n\n");
            }
        }
        catch (Exception e)
        {
            /* catch all other exceptions; proceed to abort program */
            SyncIO.printToErr("\n\nERROR: An unexpected error has occurred:\n" +
                    getExceptionMessage(e) + "\n");

            exitCode = 1;
            SyncIO.print("\nSync aborted.\n\n");
        }
        finally
        {
            /* perform clean-up before exiting */
            Sync.stdout.flush();
            Sync.stderr.flush();

            if (Sync.log != null)
            {
                Sync.log.flush();
                Sync.log.close();
                Sync.log = null;
            }
        }

        System.exit(exitCode);
    }


    /**
    * Process command-line arguments and configure synchronization parameters.
    *
    * @param args
    *     Command-line argument strings
    */
    private static void processArguments(
            final String[] args)
    {
        final String howHelp = "\nTo display help, run Sync without any command-line arguments.";

        /* SyncIO.print usage documentation, if no arguments */
        if (args.length == 0)
        {
            printUsage();
            throw new TerminatingException(null, 0);
        }

        /* check if sufficient arguments */
        if (args.length < 2)
            throw new TerminatingException("Insufficient arguments:\nThe source and target directories/files must be specified." + howHelp);

        /* process source directory/file */
        Sync.source = new File(args[args.length - 2]);

        try
        {
            Sync.source = Sync.source.getCanonicalFile();
        }
        catch (Exception e)
        {
            throw new TerminatingException("Source \"" + Sync.source.getPath() + "\" is not a valid directory/file:\n" + getExceptionMessage(e) + howHelp);
        }

        /* process target directory/file */
        Sync.target = new File(args[args.length - 1]);

        try
        {
            Sync.target = Sync.target.getCanonicalFile();
        }
        catch (Exception e)
        {
            throw new TerminatingException("Target \"" + Sync.target.getPath() + "\" is not a valid directory/file:\n" + getExceptionMessage(e) + howHelp);
        }

        /* determine synchronization mode */
        if (Sync.source.isDirectory())
        {
            /* source is a directory; must check that target is NOT a file */
            if (Sync.target.exists() && !Sync.target.isDirectory())
                throw new TerminatingException("Target \"" + Sync.target.getPath() + "\" is a file.\nFor DIRECTORY synchronization, the target (if it exists) must also be a directory." + howHelp);

            /* DIRECTORY synchronization */
            Sync.syncMode = Sync.SyncMode.DIRECTORY;
            Sync.sourceName = SyncIO.trimTrailingSeparator(Sync.source.getPath()) + File.separatorChar;
            Sync.targetName = SyncIO.trimTrailingSeparator(Sync.target.getPath()) + File.separatorChar;
        }
        else if (source.exists())
        {
            /* source is a file; must check that target is NOT a directory */
            if (Sync.target.isDirectory())
                throw new TerminatingException("Target \"" + Sync.target.getPath() + "\" is a directory.\nFor FILE synchronization, the target (if it exists) must also be a file." + howHelp);

            /* FILE synchronization */
            Sync.syncMode = Sync.SyncMode.FILE;
            Sync.sourceName = SyncIO.trimTrailingSeparator(Sync.source.getPath());
            Sync.targetName = SyncIO.trimTrailingSeparator(Sync.target.getPath());
        }
        else
        {
            /* source does not exist */
            throw new TerminatingException("Source \"" + Sync.source.getPath() + "\" does not exist." + howHelp);
        }

        /* initialize filename filters */
        final List<String> includeSource = new ArrayList<String>();
        final List<String> excludeSource = new ArrayList<String>();
        final List<String> includeTarget = new ArrayList<String>();
        final List<String> excludeTarget = new ArrayList<String>();
        boolean regexFilter = false;

        /* process command-line switches */
        for (int i = 0, n = args.length - 2; i < n; i++)
        {
            final String sw = args[i];

            if ("--simulate".equals(sw) || "-s".equals(sw))
            {
                /* simulate only; do not modify target */
                Sync.simulateOnly = true;
                Sync.ignoreWarnings = true;
            }
            else if ("--ignorewarnings".equals(sw))
            {
                /* ignore warnings; do not pause  */
                Sync.ignoreWarnings = true;
            }
            else if ("--log".equals(sw) || "-l".equals(sw))
            {
                /* create log file "sync.yyyyMMdd-HHmmss.log" */
                if (Sync.logName != null)
                    throw new TerminatingException("Switch --log can be specified at most once." + howHelp);

                final String timestamp = String.format("%1$tY%1$tm%1$td-%1$tH%1$tM%1$tS", Calendar.getInstance(Locale.ENGLISH));

                File f = new File("sync." + timestamp + ".log");

                if (f.exists())
                {
                    /* find an unused file name */
                    for (long k = 0; k < Long.MAX_VALUE; k++)
                    {
                        f = new File("sync." + timestamp + "." + k + ".log");

                        if (f.exists())
                        {
                            f = null;
                        }
                        else
                        {
                            /* use this unused name */
                            break;
                        }
                    }

                    if (f == null)
                        throw new TerminatingException("Failed to create an unused filename for log file:\nRan out of suffixes n in \"sync." +
                                timestamp + ".n.log\"; try specifying a filename, e.g. --log:\"record.txt\"." + howHelp);
                }

                try
                {
                    Sync.logName = f.getCanonicalPath();
                }
                catch (Exception e)
                {
                    throw new TerminatingException("Failed to create log file \"" + f.getPath() + "\":\n" + getExceptionMessage(e) + howHelp);
                }
            }
            else if (sw.startsWith("--log:") || sw.startsWith("-l:"))
            {
                /* create log file with the specified name */
                if (Sync.logName != null)
                    throw new TerminatingException("Switch --log can be specified at most once." + howHelp);

                final String a = sw.substring(sw.indexOf(':') + 1);

                if (a.isEmpty())
                    throw new TerminatingException("Empty --log parameter:\nA log filename must be specified, e.g. --log:\"record.txt\"." + howHelp);

                File f = new File(a);

                if (f.exists())
                    throw new TerminatingException("Log file \"" + f.getPath() + "\" already exists:\nA nonexistent file must be specified." + howHelp);

                try
                {
                    Sync.logName = f.getCanonicalPath();
                }
                catch (Exception e)
                {
                    throw new TerminatingException("Failed to create log file \"" + f.getPath() + "\":\n" + getExceptionMessage(e) + howHelp);
                }
            }
            else if ("--norecurse".equals(sw) || "-r".equals(sw))
            {
                /* do not recurse into subdirectories */
                Sync.noRecurse = true;
            }
            else if ("--noname".equals(sw) || "-n".equals(sw))
            {
                /* do not use filename for file-matching */
                Sync.matchName = false;
            }
            else if ("--notime".equals(sw) || "-t".equals(sw))
            {
                /* do not use last-modified time for file-matching */
                Sync.matchTime = false;
            }
            else if ("--nocrc".equals(sw)|| "-c".equals(sw))
            {
                /* do not use CRC-32 checksum for file-matching */
                Sync.matchCrc = false;
            }
            else if (sw.startsWith("--time:"))
            {
                /* use specified time-tolerance (in milliseconds) for file-matching */
                final String a = sw.substring(sw.indexOf(':') + 1);

                if (a.isEmpty())
                    throw new TerminatingException("Empty --time parameter:\nTime-tolerance (in milliseconds) must be a nonnegative integer, e.g. --time:2000." + howHelp);

                try
                {
                    Sync.matchTimeTolerance = Long.parseLong(a);
                }
                catch (Exception e)
                {
                    Sync.matchTimeTolerance = -1L;
                }

                if (Sync.matchTimeTolerance < 0L)
                    throw new TerminatingException("Invalid --time parameter \"" +
                            a + "\":\nTime-tolerance (in milliseconds) must be a nonnegative integer, e.g. --time:2000." + howHelp);
            }
            else if (sw.startsWith("--rename:"))
            {
                /* rename matched target files? */
                final String a = sw.substring(sw.indexOf(':') + 1);

                if (a.isEmpty())
                    throw new TerminatingException("Empty --rename parameter:\nParameter must be \"y\" or \"n\", e.g. --rename:y." + howHelp);

                if ("y".equals(a))
                {
                    Sync.defaultActionOnRenameMatched = 'Y';
                }
                else if ("n".equals(a))
                {
                    Sync.defaultActionOnRenameMatched = 'N';
                }
                else
                {
                    throw new TerminatingException("Invalid --rename parameter \"" + a + "\":\nParameter must be \"y\" or \"n\", e.g. --rename:y." + howHelp);
                }
            }
            else if (sw.startsWith("--synctime:"))
            {
                /* synchronize time of matched target files? */
                final String a = sw.substring(sw.indexOf(':') + 1);

                if (a.isEmpty())
                    throw new TerminatingException("Empty --synctime parameter:\nParameter must be \"y\" or \"n\", e.g. --synctime:y." + howHelp);

                if ("y".equals(a))
                {
                    Sync.defaultActionOnTimeSyncMatched = 'Y';
                }
                else if ("n".equals(a))
                {
                    Sync.defaultActionOnTimeSyncMatched = 'N';
                }
                else
                {
                    throw new TerminatingException("Invalid --synctime parameter \"" + a + "\":\nParameter must be \"y\" or \"n\", e.g. --synctime:y." + howHelp);
                }
            }
            else if (sw.startsWith("--overwrite:"))
            {
                /* overwrite existing target files? */
                final String a = sw.substring(sw.indexOf(':') + 1);

                if (a.isEmpty())
                    throw new TerminatingException("Empty --overwrite parameter:\nParameter must be \"y\" or \"n\", e.g. --overwrite:y." + howHelp);

                if ("y".equals(a))
                {
                    Sync.defaultActionOnOverwrite = 'Y';
                }
                else if ("n".equals(a))
                {
                    Sync.defaultActionOnOverwrite = 'N';
                }
                else
                {
                    throw new TerminatingException("Invalid --overwrite parameter \"" + a + "\":\nParameter must be \"y\" or \"n\", e.g. --overwrite:y." + howHelp);
                }
            }
            else if (sw.startsWith("--delete:"))
            {
                /* delete unmatched target files/directories? */
                if (syncMode != syncMode.DIRECTORY)
                    throw new TerminatingException("Switch --delete can be used for only DIRECTORY synchronization." + howHelp);

                final String a = sw.substring(sw.indexOf(':') + 1);

                if (a.isEmpty())
                    throw new TerminatingException("Empty --delete parameter:\nParameter must be \"y\" or \"n\", e.g. --delete:y." + howHelp);

                if ("y".equals(a))
                {
                    Sync.defaultActionOnDeleteUnmatched = 'Y';
                }
                else if ("n".equals(a))
                {
                    Sync.defaultActionOnDeleteUnmatched = 'N';
                }
                else
                {
                    throw new TerminatingException("Invalid --delete parameter \"" + a + "\":\nParameter must be \"y\" or \"n\", e.g. --delete:y." + howHelp);
                }
            }
            else if ("--force".equals(sw))
            {
                /* equivalent to the combination: "--rename:y --synctime:y --overwrite:y --delete:y" */
                Sync.defaultActionOnRenameMatched = 'Y';
                Sync.defaultActionOnTimeSyncMatched = 'Y';
                Sync.defaultActionOnOverwrite = 'Y';
                Sync.defaultActionOnDeleteUnmatched = 'Y';
            }
            else if ("--path".equals(sw) || "-p".equals(sw))
            {
                /* filter relative pathnames instead of filenames (e.g. "work\report\jan.txt" instead of "jan.txt") */
                if (Sync.syncMode != Sync.syncMode.DIRECTORY)
                    throw new TerminatingException("Switch --path can be used for only DIRECTORY synchronization." + howHelp);

                Sync.filterRelativePathname = true;
            }
            else if ("--lower".equals(sw) || "-w".equals(sw))
            {
                /* use lower case names for filtering (e.g. "HelloWorld2007.JPG" ---> "helloworld2007.jpg") */
                if (Sync.syncMode != Sync.syncMode.DIRECTORY)
                    throw new TerminatingException("Switch --lower can be used for only DIRECTORY synchronization." + howHelp);

                Sync.filterLowerCase = true;
            }
            else if ("--regex".equals(sw))
            {
                /* use REGEX instead of GLOB filename filters */
                regexFilter = true;
            }
            else if (sw.startsWith("--include:") || sw.startsWith("-i:"))
            {
                /* include source and target files/directories with names matching specified GLOB/REGEX expression */
                if (Sync.syncMode != Sync.syncMode.DIRECTORY)
                    throw new TerminatingException("Switch --include can be used for only DIRECTORY synchronization." + howHelp);

                final String a = sw.substring(sw.indexOf(':') + 1);

                if (a.isEmpty())
                    throw new TerminatingException("Empty --include parameter:\nA GLOB (or REGEX) expression must be specified, e.g. --include:\"*.{mp3,jpg}\"." + howHelp);

                includeSource.add(a);
                includeTarget.add(a);
            }
            else if (sw.startsWith("--exclude:") || sw.startsWith("-x:"))
            {
                /* exclude source and target files/directories with names matching specified GLOB/REGEX expression */
                if (Sync.syncMode != Sync.syncMode.DIRECTORY)
                    throw new TerminatingException("Switch --exclude can be used for only DIRECTORY synchronization." + howHelp);

                final String a = sw.substring(sw.indexOf(':') + 1);

                if (a.isEmpty())
                    throw new TerminatingException("Empty --exclude parameter:\nA GLOB (or REGEX) expression must be specified, e.g. --exclude:\"*.{mp3,jpg}\"." + howHelp);

                excludeSource.add(a);
                excludeTarget.add(a);
            }
            else if (sw.startsWith("--includesource:") || sw.startsWith("-is:"))
            {
                /* include source files/directories with names matching specified GLOB/REGEX expression */
                if (Sync.syncMode != Sync.syncMode.DIRECTORY)
                    throw new TerminatingException("Switch --includesource can be used for only DIRECTORY synchronization." + howHelp);

                final String a = sw.substring(sw.indexOf(':') + 1);

                if (a.isEmpty())
                    throw new TerminatingException("Empty --includesource parameter:\nA GLOB (or REGEX) expression must be specified, e.g. --includesource:\"*.{mp3,jpg}\"." + howHelp);

                includeSource.add(a);
            }
            else if (sw.startsWith("--excludesource:") || sw.startsWith("-xs:"))
            {
                /* exclude source files/directories with names matching specified GLOB/REGEX expression */
                if (Sync.syncMode != Sync.syncMode.DIRECTORY)
                    throw new TerminatingException("Switch --excludesource can be used for only DIRECTORY synchronization." + howHelp);

                final String a = sw.substring(sw.indexOf(':') + 1);

                if (a.isEmpty())
                    throw new TerminatingException("Empty --excludesource parameter:\nA GLOB (or REGEX) expression must be specified, e.g. --excludesource:\"*.{mp3,jpg}\"." + howHelp);

                excludeSource.add(a);
            }
            else if (sw.startsWith("--includetarget:") || sw.startsWith("-it:"))
            {
                /* include target files/directories with names matching specified GLOB/REGEX expression */
                if (Sync.syncMode != Sync.syncMode.DIRECTORY)
                    throw new TerminatingException("Switch --includetarget can be used for only DIRECTORY synchronization." + howHelp);

                final String a = sw.substring(sw.indexOf(':') + 1);

                if (a.isEmpty())
                    throw new TerminatingException("Empty --includetarget parameter:\nA GLOB (or REGEX) expression must be specified, e.g. --includetarget:\"*.{mp3,jpg}\"." + howHelp);

                includeTarget.add(a);
            }
            else if (sw.startsWith("--excludetarget:") || sw.startsWith("-xt:"))
            {
                /* exclude target files/directories with names matching specified GLOB/REGEX expression */
                if (Sync.syncMode != Sync.syncMode.DIRECTORY)
                    throw new TerminatingException("Switch --excludetarget can be used for only DIRECTORY synchronization." + howHelp);

                final String a = sw.substring(sw.indexOf(':') + 1);

                if (a.isEmpty())
                    throw new TerminatingException("Empty --excludetarget parameter:\nA GLOB (or REGEX) expression must be specified, e.g. --excludetarget:\"*.{mp3,jpg}\"." + howHelp);

                excludeTarget.add(a);
            }
            else
            {
                /* invalid switch */
                throw new TerminatingException("\"" + sw + "\" is not a valid switch." + howHelp);
            }
        }

        /* process source filename filters, if any */
        if (includeSource.isEmpty())
        {
            if (excludeSource.isEmpty())
            {
                Sync.sourceFilter = null;
            }
            else
            {
                Sync.sourceFilter = new FilterNode(FilterNode.LogicType.NOR);

                for (String s : excludeSource)
                {
                    try
                    {
                        Sync.sourceFilter.addFilter