/**
 * 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.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.PrintWriter;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.NavigableMap;
import java.util.Stack;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;


/**
 * ZipSnap is a simple command-line directory incremental backup tool.
 */
public class ZipSnap
{
    /*********************
    * GENERAL CONSTANTS *
    *********************/

    /** constant: program title */
    static final String programTitle =
            "ZipSnap 2.1   Copyright 2007 Zach Scrivena   2007-08-26";

    /** constant: program version */
    static final double version = 2.1;

    /** constant: size in bytes of read/write buffer (1 Mb) */
    static final int bufferSize = 1048576;

    /**
    * constant: minimum file size in bytes for compression (256 bytes);
    * files smaller than this limit will be stored without compression
    */
    static final int minSizeForCompression = 256;

    /** constant: current time */
    static final Date currentTime = new Date();

    /**********************
    * GENERAL PARAMETERS *
    **********************/

    /** parameter: archive name (as specified by the command-line argument) */
    private static String archive = null;

    /** parameter: archive directory (absolute and canonical path) */
    private static File archiveDir = null;

    /** parameter: archive directory absolute and canonical pathname (ends with a trailing separator) */
    private static String archiveDirName = null;

    /** parameter: current directory (absolute and canonical path) */
    static File currentDir = null;

    /** parameter: current directory absolute and canonical pathname (ends with a trailing separator) */
    private static String currentDirName = null;

    /** parameter: simulate only (do not actually add/restore snapshot); by default, simulation mode is not used */
    static boolean simulateOnly = false;

    /*********************************
    * COMMAND AND OPTION PARAMETERS *
    *********************************/

    /** ZipSnap commands to be executed (ADD, RESTORE, INFO) */
    private static enum Command
    {
        ADD, RESTORE, INFO;
    }

    /** parameter: ZipSnap command to be executed (ADD, RESTORE, INFO) */
    private static Command command;

    /** parameter: use file CRC-32 checksum in file matching; by default, the checksum is not used */
    static boolean useCrc = false;

    /**
    * parameter: time-tolerance in milliseconds to be used in file matching;
    * by default, a 0-millisecond time-tolerance is used
    */
    private static long timeTolerance = 0;

    /**
    * parameter: Add/extract all files/directories when adding/restoring
    * a snapshot without performing file matching first. More explicitly, when
    * adding a snapshot, add all files/directories, not just those different
    * from previously archived files/directories; when restoring a snapshot,
    * extract all files/directories, not just those different from existing
    * files/directories. By default, only files/directories that fail file
    * matching are added/extracted.
    */
    private static boolean all = false;

    /** parameter: snapshot index to be restored */
    private static int snapshotIndex = -1;

    /**
    * parameter: force addition of snapshot, even if it is identical
    * to the last snapshot; by default, an identical snapshot is not added
    */
    private static boolean forceAdd = false;

    /**
    * parameter: default action on overwriting existing files;
    * by default, the user is prompted on whether to overwrite existing files
    */
    static char defaultActionOnOverwrite = '\0';

    /**
    * parameter: default action on deleting existing files;
    * by default, the user is prompted on whether to delete existing files
    */
    static char defaultActionOnDelete = '\0';

    /** archive volume types (ZIP, JAR, LIST) */
    private static enum VolumeType
    {
        ZIP, JAR, LIST;
    }

    /** parameter: archive volume type (ZIP, JAR, LIST); by default, the ZIP volume type is used */
    private static VolumeType volumeType = VolumeType.ZIP;

    /** parameter: compression level (0 = min, 9 = max); default compression level is 9 */
    static int compressionLevel = 9;

    /** parameter: search paths for volume files when restoring a snapshot */
    static final List<File> searchPaths = new ArrayList<File>();

    /** filter types (GLOB, REGEX) */
    private static enum FilterType
    {
        GLOB, REGEX;
    }

    /** parameter: filter type (GLOB, REGEX) */
    private static FilterType filterType;

    /** parameter: filter string for matching filenames */
    private static String filterString = null;

    /**
    * parameter: match filter string against full (relative) pathnames;
    * by default, only the name of the file/directory is used for matching
    */
    static boolean filterFullPathname = false;

    /** parameter: filter pattern for matching filenames */
    static Pattern filterPattern = null;


    /**
    * Main entry point for the ZipSnap program.
    *
    * @param args
    *     Command-line argument strings
    */
    public static void main(
            final String[] args)
    {
        /* print program title */
        System.out.print("\n" + ZipSnap.programTitle + "\n");

        /* create error and warning handler */
        ErrorWarningHandler.setPauseOnWarning(true); /* pause on warning by default */

        try
        {
            /* process command-line arguments */
            processArguments(args);

            /* construct Java regex Pattern, if necessary */
            if (ZipSnap.filterString != null)
            {
                if (ZipSnap.filterType == ZipSnap.FilterType.GLOB)
                {
                    ZipSnap.filterPattern = getGlobFilterPattern();
                }
                else if (ZipSnap.filterType == ZipSnap.FilterType.REGEX)
                {
                    ZipSnap.filterPattern = getRegexFilterPattern();
                }
            }

            /* execute requested command */
            if (ZipSnap.command == ZipSnap.Command.ADD)
            {
                addSnapshotToArchive();
            }
            else if (ZipSnap.command == ZipSnap.Command.RESTORE)
            {
                restoreSnapshotFromArchive();
            }
            else if (ZipSnap.command == ZipSnap.command.INFO)
            {
                displayArchiveInfo();
            }

            System.out.print("\n\nZipSnap is done!");

            final int numWarnings = ErrorWarningHandler.getNumWarnings();

            if (numWarnings > 0)
                System.out.print("\n(" + numWarnings + " " + ((numWarnings == 1) ? "warning" : "warnings") + " encountered)");

            System.out.print("\n\n");
        }
        catch (Exception e)
        {
            ErrorWarningHandler.reportErrorAndExit("An unexpected error has occurred:\n" +
                    ErrorWarningHandler.getExceptionMessage(e));
        }

        System.exit(0);
    }


    /**
    * Process command-line arguments.
    *
    * @param args
    *     Command-line argument strings
    */
    private static void processArguments(
            final String[] args)
    {
        /* error message */
        String err = null;

        CheckArguments:
        do
        {
            /* run one iteration */

            if (args.length == 0)
            {
                /* print usage help */
                printUsage();
                System.exit(0);
                break CheckArguments;
            }
            else if (args.length < 2)
            {
                err = "Insufficient arguments:\nA command and a ZipSnap archive directory must be specified.";
                break CheckArguments;
            }

            /* archive name is the last argument */
            ZipSnap.archive = args[args.length - 1];

            /* command is the first argument */
            final String command = args[0];

            if ("a".equals(command))
            {
                ZipSnap.command = ZipSnap.Command.ADD;
            }
            else if ("r".equals(command))
            {
                ZipSnap.command = ZipSnap.Command.RESTORE;
            }
            else if ("i".equals(command))
            {
                ZipSnap.command = ZipSnap.Command.INFO;
            }
            else
            {
                err = "Invalid command \"" + command +
                        "\" specified:\nCommand must be \"a\", \"r\", or \"i\".";
                break CheckArguments;
            }

            /* flags for volume type */
            int swZip = 0;
            int swJar = 0;
            int swList = 0;

            /* process command-specific switches */
            ProcessNextSwitch:
            for (int i = 1; i < args.length - 1; i++)
            {
                final String sw = args[i];


                /******************************************
                * (1) SWITCHES FOR ADD, RESTORE, OR INFO *
                ******************************************/
                if ((ZipSnap.command == ZipSnap.Command.ADD) ||
                        (ZipSnap.command == ZipSnap.Command.RESTORE) ||
                        (ZipSnap.command == ZipSnap.Command.INFO))
                {
                    if ("-i".equals(sw) || "--ignorewarnings".equals(sw))
                    {
                        /* ignore warnings */
                        ErrorWarningHandler.setPauseOnWarning(false);
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-f:") || sw.startsWith("--filter:"))
                    {
                        /* glob filter for file/directory name */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --filter parameter specified:\nGlob filter string must be nonempty.";
                            break CheckArguments;
                        }

                        ZipSnap.filterString = a;
                        ZipSnap.filterType = ZipSnap.FilterType.GLOB;
                        ZipSnap.filterFullPathname = false;
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-F:") || sw.startsWith("--FILTER:"))
                    {
                        /* glob filter for full (relative) file/directory pathname */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --FILTER parameter specified:\nGlob filter string must be nonempty.";
                            break CheckArguments;
                        }

                        ZipSnap.filterString = a;
                        ZipSnap.filterType = ZipSnap.FilterType.GLOB;
                        ZipSnap.filterFullPathname = true;
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-e:") || sw.startsWith("--filterregex:"))
                    {
                        /* regex filter for file/directory name */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --filterregex parameter specified:\nRegex filter string must be nonempty.";
                            break CheckArguments;
                        }

                        ZipSnap.filterString = a;
                        ZipSnap.filterType = ZipSnap.FilterType.REGEX;
                        ZipSnap.filterFullPathname = false;
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-E:") || sw.startsWith("--FILTERREGEX:"))
                    {
                        /* regex filter for full (relative) file/directory pathname */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --FILTERREGEX parameter specified:\nRegex filter string must be nonempty.";
                            break CheckArguments;
                        }

                        ZipSnap.filterString = a;
                        ZipSnap.filterType = ZipSnap.FilterType.REGEX;
                        ZipSnap.filterFullPathname = true;
                        continue ProcessNextSwitch;
                    }
                }


                /************************************
                * (2) SWITCHES FOR ADD, OR RESTORE *
                ************************************/
                if ((ZipSnap.command == ZipSnap.Command.ADD) ||
                        (ZipSnap.command == ZipSnap.Command.RESTORE))
                {
                    if ("-s".equals(sw) || "--simulate".equals(sw))
                    {
                        /* simulate only; do not actually add/restore snapshot */
                        ZipSnap.simulateOnly = true;
                        ErrorWarningHandler.setPauseOnWarning(false);
                        continue ProcessNextSwitch;
                    }
                    else if ("--all".equals(sw))
                    {
                        /* add/restore all files/directories without performing matching */
                        ZipSnap.all = true;
                        continue ProcessNextSwitch;
                    }
                    else if ("-c".equals(sw) || "--crc".equals(sw))
                    {
                        /* use file/directory CRC-32 checksum for matching */
                        ZipSnap.useCrc = true;
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-t:") || sw.startsWith("--time:"))
                    {
                        /* use specified time-tolerance (in milliseconds) for matching */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --time parameter specified:\nTime-tolerance (in milliseconds) must be a nonnegative integer.";
                            break CheckArguments;
                        }

                        try
                        {
                            ZipSnap.timeTolerance = Long.parseLong(a);
                        }
                        catch (Exception e)
                        {
                            ZipSnap.timeTolerance = -1;
                        }

                        if (ZipSnap.timeTolerance < 0)
                        {
                            err = "Invalid --time parameter \"" + a +
                                    "\" specified:\nTime-tolerance (in milliseconds) must be a nonnegative integer.";
                            break CheckArguments;
                        }

                        continue ProcessNextSwitch;
                    }
                    else if ("-l".equals(sw) || "--list".equals(sw))
                    {
                        /* create a list of files/directories to be added/extracted */
                        swList = 1;
                        continue ProcessNextSwitch;
                    }
                }


                /************************
                * (3) SWITCHES FOR ADD *
                ************************/
                if (ZipSnap.command == ZipSnap.Command.ADD)
                {
                    if ("--forceadd".equals(sw))
                    {
                        /* force addition of snapshot, even if identical to the last snapshot */
                        ZipSnap.forceAdd = true;
                        continue ProcessNextSwitch;
                    }
                    else if ("-z".equals(sw) || "--zip".equals(sw))
                    {
                        /* add files/directories to a ZIP volume */
                        swZip = 1;
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-z:") || sw.startsWith("--zip:"))
                    {
                        /* add files/directories to a ZIP volume with specified compression level */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --zip parameter specified:\nCompression level must be an integer 0-9.";
                            break CheckArguments;
                        }

                        try
                        {
                            ZipSnap.compressionLevel = Integer.parseInt(a);
                        }
                        catch (Exception e)
                        {
                            ZipSnap.compressionLevel = -1;
                        }

                        if ((ZipSnap.compressionLevel < 0) || (ZipSnap.compressionLevel > 9))
                        {
                            err = "Invalid --zip parameter \"" + a +
                                    "\" specified.\nCompression level must be an integer 0-9.";
                            break CheckArguments;
                        }

                        swZip = 1;
                        continue ProcessNextSwitch;
                    }
                    else if ("-j".equals(sw) || "--jar".equals(sw))
                    {
                        /* add files/directories to a JAR volume */
                        swJar = 1;
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-j:") || sw.startsWith("--jar:"))
                    {
                        /* add files/directories to a JAR volume with specified compression level */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --jar parameter specified:\nCompression level must be an integer 0-9.";
                            break CheckArguments;
                        }

                        try
                        {
                            ZipSnap.compressionLevel = Integer.parseInt(a);
                        }
                        catch (Exception e)
                        {
                            ZipSnap.compressionLevel = -1;
                        }

                        if ((ZipSnap.compressionLevel < 0) || (ZipSnap.compressionLevel > 9))
                        {
                            err = "Invalid --jar parameter \"" + a +
                                    "\" specified.\nCompression level must be an integer 0-9.";
                            break CheckArguments;
                        }

                        swJar = 1;
                        continue ProcessNextSwitch;
                    }
                }


                /****************************
                * (4) SWITCHES FOR RESTORE *
                ****************************/
                if (ZipSnap.command == ZipSnap.Command.RESTORE)
                {
                    if (sw.startsWith("--snapshot:"))
                    {
                        /* snapshot number to be restored */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --snapshot parameter specified:\nSnapshot number must be a positive integer.";
                            break CheckArguments;
                        }

                        try
                        {
                            ZipSnap.snapshotIndex = Integer.parseInt(a) - 1; /* convert "number" to "index" */
                        }
                        catch (Exception e)
                        {
                            ZipSnap.snapshotIndex = -1;
                        }

                        if (ZipSnap.snapshotIndex < 0)
                        {
                            err = "Invalid --snapshot parameter \"" + a +
                                    "\" specified:\nSnapshot number must be a positive integer.";
                            break CheckArguments;
                        }

                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-o:") || sw.startsWith("--overwrite:"))
                    {
                        /* overwrite existing files/directories? */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --overwrite parameter specified:\nOverwrite parameter must be \"y\" or \"n\".";
                            break CheckArguments;
                        }

                        if ("y".equals(a))
                        {
                            ZipSnap.defaultActionOnOverwrite = 'Y';
                        }
                        else if ("n".equals(a))
                        {
                            ZipSnap.defaultActionOnOverwrite = 'N';
                        }
                        else
                        {
                            err = "Invalid --overwrite parameter \"" + a +
                                    "\" specified:\nOverwrite parameter must be \"y\" or \"n\".";
                            break CheckArguments;
                        }

                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-d:") || sw.startsWith("--delete:"))
                    {
                        /* delete unmatched existing files/directories? */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --delete parameter specified:\nDelete parameter must be \"y\" or \"n\".";
                            break CheckArguments;
                        }

                        if ("y".equals(a))
                        {
                            ZipSnap.defaultActionOnDelete = 'Y';
                        }
                        else if ("n".equals(a))
                        {
                            ZipSnap.defaultActionOnDelete = 'N';
                        }
                        else
                        {
                            err = "Invalid --delete parameter \"" + a +
                                    "\" specified:\nDelete parameter must be \"y\" or \"n\".";
                            break CheckArguments;
                        }

                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-p:") || sw.startsWith("--path:"))
                    {
                        /* additional search path for volume files */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --path parameter specified:\nPath parameter must be an existing directory.";
                            break CheckArguments;
                        }

                        final File dir;

                        try
                        {
                            dir = (new File(a)).getCanonicalFile();
                        }
                        catch (Exception e)
                        {
                            err = "Invalid --path parameter specified:\nCannot get absolute path of directory \"" +
                                    a + "\":\n" + ErrorWarningHandler.getExceptionMessage(e);
                            break CheckArguments;
                        }

                        if (!dir.exists())
                        {
                            err = "Invalid --path parameter specified:\nDirectory \"" +
                                    a + "\" does not exist.";
                            break CheckArguments;
                        }

                        if (!dir.isDirectory())
                        {
                            err = "Invalid --path parameter specified:\n\"" +
                                    a + "\" is not a directory; could it be a file?";
                            break CheckArguments;
                        }

                        ZipSnap.searchPaths.add(dir);
                        continue ProcessNextSwitch;
                    }
                }


                /**********************
                * (5) INVALID SWITCH *
                **********************/
                /* MUST BE LAST BLOCK */
                err = "\"" + sw + "\" is not a valid switch for command \"" + command + "\".";
                break CheckArguments;
            }

            if (ZipSnap.all && (ZipSnap.timeTolerance > 0))
            {
                err = "Switches --all and --time cannot be used together.";
                break CheckArguments;
            }

            if (ZipSnap.all && ZipSnap.useCrc)
            {
                err = "Switches --all and --crc cannot be used together.";
                break CheckArguments;
            }

            if (ZipSnap.all && ZipSnap.forceAdd)
            {
                err = "Switches --all and --forceadd cannot be used together.";
                break CheckArguments;
            }

            if (swZip + swJar + swList > 1)
            {
                err = "Only one of the three switches --zip, --jar, and --list can be used.";
                break CheckArguments;
            }

            /* volume type */
            if (swZip > 0)
                ZipSnap.volumeType = ZipSnap.VolumeType.ZIP;

            if (swJar > 0)
                ZipSnap.volumeType = ZipSnap.VolumeType.JAR;

            if (swList > 0)
                ZipSnap.volumeType = ZipSnap.VolumeType.LIST;
        }
        while (false);

        /* invalid command-line arguments encountered */
        if (err != null)
            ErrorWarningHandler.reportErrorAndExit(e