/*
 * [BackupToZip.java]
 *
 * Summary: Backup a set of files to a Zip file for backup to DVD or USB drive.
 *
 * Copyright: (c) 2009-2017 Roedy Green, Canadian Mind Products, http://mindprod.com
 *
 * Licence: This software may be copied and used freely for any purpose but military.
 *          http://mindprod.com/contact/nonmil.html
 *
 * Requires: JDK 1.6+
 *
 * Created with: JetBrains IntelliJ IDEA IDE http://www.jetbrains.com/idea/
 *
 * Version History:
 *  1.0 2009-05-12 initial version
 *  1.1 2009-05-18 Update the last modified date of the archive if changed. TrueZip does not do it automatically.
 *  1.2 2010-10-06 convert to TrueZip 6.1.8
 *  1.3 2011-01-30 use UTF-8 filename encoding to allow awkward characters in file names.
 *  1.4 2011-03-26 add online HTML manual.
 */
package com.mindprod.backuptozip;

import com.mindprod.commandline.CommandLine;
import com.mindprod.common11.Misc;
import com.mindprod.fastcat.FastCat;
import com.mindprod.filter.AllDirectoriesFilter;
import com.mindprod.filter.AvoidJunkFilter;
import de.schlichtherle.io.ArchiveDetector;
import de.schlichtherle.io.DefaultArchiveDetector;
import de.schlichtherle.io.File;
import de.schlichtherle.io.FileOutputStream;
import de.schlichtherle.util.zip.ZipOutputStream;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;

import static java.lang.System.err;
import static java.lang.System.out;

/**
 * Backup a set of files to a Zip file for backup to DVD or USB drive.
 * <p/>
 * On repeated use, updates the zip file rather than recreating it from scratch.
 * Unlike other zip utilities, any deleted files are also deleted from the zip.
 * Requires TrueZip jar installed in ext dirs.  Available from http://repo1.maven.org/maven2/de/schlichtherle/truezip/
 * Note that File, FileOutputStream and ZipOutputStream work like their Sun equivalents
 * but they are de.schlichtherle enhancements.
 *
 * @author Roedy Green, Canadian Mind Products
 * @version 1.4 2011-03-26 add online HTML manual.
 * @since 2009-05-12
 */
public class BackupToZip
    {
    // ------------------------------ CONSTANTS ------------------------------

    private static final int FIRST_COPYRIGHT_YEAR = 2009;

    /**
     * undisplayed copyright notice
     */
    @SuppressWarnings({ "UnusedDeclaration" })
    private static final String EMBEDDED_COPYRIGHT =
            "Copyright: (c) 2009-2017 Roedy Green, Canadian Mind Products, http://mindprod.com";

    /**
     * when this version released to the public
     */
    private static final String RELEASE_DATE = "2011-03-26";

    /**
     * name of this application.
     */
    private static final String TITLE_STRING = "B a c k u p T o Z i p";

    /**
     * how to use the command line
     */
    private static final String USAGE = "BackupToZip needs a filename.zip then a space-separated list of " +
                                        "filenames/dirs, with optional -s -q -v switches";

    /**
     * latest version released to the public
     */
    private static final String VERSION_STRING = "1.4";

    /**
     * used to collect list of files already backed up
     */
    private static ArrayList<File> collector;

    // -------------------------- STATIC METHODS --------------------------

    /**
     * compare two lists, files to be backup up with ones in archive already and do necessary
     * additions, updates and deletes to the archive to bring it up to date.
     *
     * @param zipFile                 archive file we are updating
     * @param filesToBackup           array of files to back up
     * @param filesPreviouslyBackedUp array of files already in the archive
     */
    private static void compareToBackupWithPreviouslyBackedUp( File zipFile, final java.io.File[] filesToBackup,
                                                               final File[] filesPreviouslyBackedUp )
        {
        final int filesToBackupCount = filesToBackup.length;
        final int filesPreviouslyBackedUpCount = filesPreviouslyBackedUp.length;
        final int leadIgnore = Misc.getCanOrAbsPath( zipFile ).length() + 1;
        int i = 0, j = 0;
        int deletedCount = 0, addedCount = 0, updatedCount = 0, unchangedCount = 0;
        while ( i < filesToBackupCount && j < filesPreviouslyBackedUpCount )
            {
            java.io.File p = filesToBackup[ i ];   // primary
            final File s = filesPreviouslyBackedUp[ j ]; // secondary
            String pPath = Misc.getCanOrAbsPath( p );
            String sPath = Misc.getCanOrAbsPath( s ).substring( leadIgnore );
            // case-sensitive, works on canonical names.
            int diff = pPath.compareTo( sPath );
            if ( diff == 0 )
                {
                // we have a match in name.
                // If size and date match we don't have to do anything.
                // Zip format truncates timestamps to 2 second resolution. Could use new feature to work with more
                // accurate count.
                // Compare uncompressed lengths.
                if ( p.length() == s.length() && Math.abs( p.lastModified() - s.lastModified() ) <= 2000 )
                    {
                    // do nothing. This is the most common case.
                    unchangedCount++;
                    }
                else
                    {
                    // update the archive. This is the second most common case.
                    // copy one p element into the zip on top of element s.
                    if ( s.archiveCopyFrom( p ) )
                        {
                        out.println( "  update > " + pPath );
                        updatedCount++;
                        }
                    else
                        {
                        err.println( "failed to update " + pPath );
                        err.println( p.length() + " : " + s.length() + " : " + p.lastModified() + " : " + s
                                .lastModified() );
                        err.println( s.isArchive() + " : " + s.isDirectory() + " : " + s.isEntry() + " : " + s.isFile
                                () );
                        }
                    }
                i++;
                j++;
                }
            else if ( diff < 0 )
                {
                // unmatched primary
                // get it added to backup. Any Zip we add is treated atomically.
                final File zipEntry = new File( zipFile, pPath, ArchiveDetector.NULL );
                if ( zipEntry.archiveCopyFrom( p ) )
                    {
                    out.println( "  add > " + pPath );
                    addedCount++;
                    }
                else
                    {
                    err.println( "failed to add " + pPath );
                    }
                i++;
                }
            else
                {
                //  unmatched secondary.
                // get it removed from the archive
                if ( s.delete() )
                    {
                    out.println( "  delete > " + sPath );
                    deletedCount++;
                    }
                else
                    {
                    err.println( "failed to delete " + sPath );
                    }
                j++;
                }
            }// end while
        // process any remaining primary
        for (; i < filesToBackupCount; i++ )
            {
            // get them added to the backup
            java.io.File p = filesToBackup[ i ];
            String pPath = Misc.getCanOrAbsPath( p );
            // Any Zip we add is treated atomically.
            final File zipEntry = new File( zipFile, pPath, ArchiveDetector.NULL );
            if ( zipEntry.archiveCopyFrom( p ) )
                {
                out.println( "  add > " + pPath );
                addedCount++;
                }
            else
                {
                err.println( "failed to add " + pPath );
                }
            }
        // process any remaining secondary
        for (; j < filesPreviouslyBackedUpCount; j++ )
            {
            final File s = filesPreviouslyBackedUp[ j ];
            String sPath = Misc.getCanOrAbsPath( s ).substring( leadIgnore );
            if ( s.delete() )
                {
                out.println( "  delete > " + sPath );
                deletedCount++;
                }
            else
                {
                err.println( "failed to delete " + sPath );
                }
            }
        out.println( ">>>> "
                     + deletedCount
                     + " deleted, "
                     + addedCount
                     + " added, "
                     + updatedCount
                     + " updated, "
                     + unchangedCount
                     + " unchanged in "
                     + Misc.getCanOrAbsPath( zipFile ) );
        if ( updatedCount > 0 || addedCount > 0 || deletedCount > 0 )
            {
            // We have to do this manually because of a bug in TrueZip. It does not maintain lastModified automatically.
            // Unfortunately this has the side effect of flushing everything to disk. That is why we do it last thing.
            //noinspection ResultOfMethodCallIgnored
            out.println( "  flushing " + Misc.getCanOrAbsPath( zipFile ) );
            zipFile.setLastModified( System.currentTimeMillis() );
            }
        }

    /**
     * ensure zip file exists as archive
     *
     * @param zipFile zip file handle
     */
    private static void createZip( final File zipFile )
        {
        if ( !zipFile.exists() )
            {
            try
                {
                // encode filenames with awkward chars. Create a new empty zip.
                ZipOutputStream zos =
                        new ZipOutputStream( new FileOutputStream( zipFile ), "UTF-8" /* can't be Charset */ );
                zos.close();
                }
            catch ( IOException e )
                {
                throw new IllegalArgumentException( "Unable to create the zip archive. " + e.getMessage() );
                }
            if ( !zipFile.exists() )
                {
                throw new IllegalArgumentException( "Unable to create the zip archive." );
                }
            }
        if ( !( zipFile.isDirectory() && zipFile.isArchive() ) )
            {
            throw new IllegalArgumentException( "Zip file " + zipFile.toString() + " is not the correct format. " +
                                                "Delete it and try again." );
            }
        }

    /**
     * remove duplicates if any from the list
     *
     * @param filesToBackup sorted array of files
     *
     * @return array of files with dups removed
     */
    private static java.io.File[] dedup
    (
            final java.io.File[] filesToBackup )
        {
        int dups = 0;
        java.io.File prev = null;
        for ( int i = 0; i < filesToBackup.length; i++ )
            {
            if ( filesToBackup[ i ].equals( prev ) )
                {
                filesToBackup[ i ] = null;
                dups++;
                }
            else
                {
                prev = filesToBackup[ i ];
                }
            } // end for
        if ( dups == 0 )
            {
            return filesToBackup;
            }
        final java.io.File[] deduped = new java.io.File[ filesToBackup.length - dups ];
        int i = 0;
        for ( java.io.File f : filesToBackup )
            {
            if ( f != null )
                {
                deduped[ i++ ] = f;
                }
            }
        return deduped;
        }

    /**
     * Display exception error
     *
     * @param e    the error exception
     * @param args arguments from the command line
     */
    private static void displayError( final Exception e, String[] args )
        {
        err.println();
        if ( e != null )
            {
            err.println( e.getMessage() );
            }
        err.println( "Usage: java.exe -jar " + "BackupToZip" + ".jar  backup.zip -q someFile1.txt -s someDir" );
        err.println( "or:    " + "BackupToZip" + ".jar  backup.zip -q someFile1.txt -s someDir" );
        err.println( "Jet:   " + "BackupToZip" + ".exe  backup.zip -q someFile1.txt -s someDir" );
        err.println();
        if ( e != null )
            {
            err.println();
            e.printStackTrace( err );
            err.println();
            }
        err.println();
        err.println( "command line parameters:" );
        for ( String arg : args )
            {
            err.println( '[' + arg + ']' );
            }
        }

    /**
     * get list of files in the archive previously backed up
     *
     * @param zipFile       the zip archive
     * @param estimatedSize estimated number of files.
     *
     * @return array of files already backed up in the archive
     */
    private static File[] getSortListOfFilesPreviouslyBackedUp( final File zipFile, final int estimatedSize )
        {
        collector = new ArrayList<File>( estimatedSize );
        scanZip( zipFile );
        final File[] filesPreviouslyBackedUp = collector.toArray( new File[ collector.size() ] );
        Arrays.sort( filesPreviouslyBackedUp, new CaseSensitive() );  // by absolute file name, case-sensitive
        return filesPreviouslyBackedUp;
        }

    /**
     * @param args command line args with zip file nullified.
     *
     * @return Files that currently exist that both backed up and not yet backed up.
     */
    private static java.io.File[] getSortedListOfFilesToBackup( final String[] args )
        {
        // avoid junk files from being backed up
        final AvoidJunkFilter avoidJunkFilter = new AvoidJunkFilter();
        avoidJunkFilter.setExtensions( "aps",
                "bak",
                "csm",
                "dmp",
                "ilk",
                "log",
                "lst",
                "map",
                "ncb",
                "obj",
                "pch",
                "pdb",
                "temp",
                "tmp" );
        avoidJunkFilter.setFilenames( "temp", "temp1", "temp2" );
        avoidJunkFilter.setStartsWith( "delete_me" );
        final CommandLine commandLine = new CommandLine( args,
                new AllDirectoriesFilter(), /* not AllButSVNDirectoriesFilter, we back up SVN */
                avoidJunkFilter );
        if ( commandLine.size() == 0 )
            {
            throw new IllegalArgumentException( "No files found to process\n" + USAGE );
            }
        final java.io.File[] filesToBackup = new java.io.File[ commandLine.size() ];
        int k = 0;
        for ( java.io.File f : commandLine )
            {
            java.io.File clean;
            try
                {
                clean = f.getCanonicalFile();
                }
            catch ( IOException e )
                {
                clean = f.getAbsoluteFile();
                }
            filesToBackup[ k++ ] = clean;
            }
        Arrays.sort( filesToBackup, new CaseSensitive() ); // by absolute file name, case-sensitive
        return dedup( filesToBackup );
        }

    /**
     * recursively collect a list of all files in the archive already backed up .
     * Adds findings to collector.
     *
     * @param dir directory to scan, initially the entire zip as a virtual directory
     */
    private static void scanZip( File dir )
        {
        final File[] dirContents = ( File[] ) dir.listFiles();
        if ( dirContents != null )
            {
            for ( File f : dirContents )
                {
                if ( f.isFile() )
                    {
                    // file name will automatically be canonical since we only put canonical names into the archive.
                    // name includes drive letter, and directory of file originally backed up.
                    collector.add( f );
                    }
                else if ( f.isArchive() )
                    {
                    // we want to treat this archive as a whole as a virtual dir, but this embedded zip atomically
                    collector.add( new File( dir, f.getName(), ArchiveDetector.NULL ) );
                    }
                else
                    {
                    // ordinary dir inside archive
                    /* call recursively */
                    scanZip( f );
                    }
                } // end for
            }
        }

    // -------------------------- INNER CLASSES --------------------------

    /**
     * Sort files by absolute path.
     * <p/>
     * Defines an alternate sort order for java.io.File.
     */
    private static class CaseSensitive implements Comparator<java.io.File>
        {
        // -------------------------- PUBLIC INSTANCE  METHODS --------------------------

        /**
         * Sort files by absolute path.
         * Defines an alternate sort order for java.io.File.
         * Compare two java.io.File Objects.
         * Compares getAbsolutePath.
         * Informally, returns (a-b), or +ve if a is more positive than b.
         *
         * @param a first java.io.File to compare
         * @param b second java.io.File to compare
         *
         * @return +ve if a&gt;b, 0 if a==b, -ve if a&lt;b
         */
        public final int compare( java.io.File a, java.io.File b )
            {
            return Misc.getCanOrAbsPath( a ).compareTo( Misc.getCanOrAbsPath( b ) );
            }
        }

    // --------------------------- main() method ---------------------------

    /**
     * Extracts lines in files that contain a given string.
     *
     * @param args strings to search for, then a -, then names of files to process, no wildcards.
     *             strings are case-sensitive, not regexes.
     *             -all switch means all strings must match
     *             -where switch means display where each line found file/line #
     */
    public static void main( String[] args )
        {
        try
            {
            out.println( TITLE_STRING + "  " + VERSION_STRING + "  released: " + RELEASE_DATE );
            // configure TrueZip to treat zips as atomic files by default.
            File.setDefaultArchiveDetector( ArchiveDetector.NULL );
            // first parm is the zip. It we want to treat as a virtual directory tree.
            if ( args.length < 2 )
                {
                displayError( null, args );
                System.exit( 1 );
                }
            final ArchiveDetector zipArchiveDetector = new DefaultArchiveDetector( "zip" );
            // need driver name to include in ant script to build jar.
            // out.println( zipArchiveDetector.getArchiveDriver( "dummy.zip" ) );
            final File zipFile = new File( args[ 0 ], zipArchiveDetector );
            createZip( zipFile );
            // don't process zip as one of the dirs to backup
            args[ 0 ] = null;
            out.println( "  searching for changed/new/deleted files to backup to " + Misc.getCanOrAbsPath(
                    zipFile ) );
            // Get sorted list of files to back up.
            final java.io.File[] filesToBackup = getSortedListOfFilesToBackup( args );
            // Get sorted list of files already backup up.
            final File[] filesPreviouslyBackedUp = getSortListOfFilesPreviouslyBackedUp( zipFile,
                    filesToBackup.length + 100 );
            final FastCat sb = new FastCat( 6 );
            sb.append( "  " );
            sb.append( filesToBackup.length );
            sb.append( " files to back up, " );
            sb.append( filesPreviouslyBackedUp.length );
            sb.append( " files previously backed up to " );
            sb.append( Misc.getCanOrAbsPath( zipFile ) );
            out.println( sb.toString() );
            // compare sorted lists of filesToBack and collector
            // arrange additions, updates and deletions.
            compareToBackupWithPreviouslyBackedUp( zipFile, filesToBackup, filesPreviouslyBackedUp );
            // close zipFile virtual directory. Reorganise the file copying over unchanged elts and merging with adds
            // and updates.
            File.umount();
            out.println( "done" );
            }
        catch ( Exception e )
            {
            displayError( e, args );
            }
        }
    }