/**
 * 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.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;


/**
 * Represent a ZipSnap catalog.
 */
public class Catalog
{
    /** snapshots in this catalog */
    final List<SnapshotUnit> snapshots;

    /** files/directories in this catalog (to be sorted by their name field) */
    final List<FileUnit> files;

    /**
    * Regex pattern for ZIPSNAP_VERSION line:
    * ZIPSNAP_VERSION:version
    *                    1     (capture group index)
    */
    private static final Pattern patternZipSnapVersion = Pattern.compile(
            Pattern.quote("v:") + "([0-9]+\\.[0-9]+)");

    /**
    * Regex pattern for NUMBER_OF_SNAPSHOTS line:
    * NUMBER_OF_SNAPSHOTS:number
    *                       1     (capture group index)
    */
    private static final Pattern patternNumberOfSnapshots = Pattern.compile(
            Pattern.quote("ns:") + "([0-9]+)");

    /**
    * Regex pattern for NUMBER_OF_FILES line:
    * NUMBER_OF_FILES:number
    *                   1     (capture group index)
    */
    private static final Pattern patternNumberOfFiles = Pattern.compile(
            Pattern.quote("nf:") + "([0-9]+)");

    /**
    * Regex pattern for SNAPSHOT line (before version 2.0):
    * SNAPSHOT:number:time
    *            1     2    (capture group index)
    */
    private static final Pattern patternSnapshot1 = Pattern.compile(
            Pattern.quote("s:") + "([0-9]+)" +
            Pattern.quote(":") + "([0-9\\-]+)");

    /**
    * Regex pattern for SNAPSHOT line:
    * SNAPSHOT:number;time
    *            1     2    (capture group index)
    */
    private static final Pattern patternSnapshot = Pattern.compile(
            Pattern.quote("s:") + "([0-9]+)" +
            Pattern.quote(";") + "([0-9\\-]+)");

    /**
    * Regex pattern for FILE line (before version 2.0):
    * FILE:number:[name],size,time(in sec),checksum,snapshots
    *         1     2     3       4           5        6     (capture group index)
    */
    private static final Pattern patternFile1 = Pattern.compile(
            Pattern.quote("f:") + "([0-9]+)" +
            Pattern.quote(":[") + "(.*)" +
            Pattern.quote("],") + "([0-9]+)" +
            Pattern.quote(",") + "([0-9]+)" +
            Pattern.quote(",") + "([0-9]+)" +
            Pattern.quote(",") + "([0-9\\,]+)");

    /**
    * Regex pattern for FILE line:
    * FILE:name;size;time;checksum;snapshots
    *       1    2    3      4        5      (capture group index)
    */
    private static final Pattern patternFile = Pattern.compile(
            Pattern.quote("f:") + "(.*)" +
            Pattern.quote(";") + "([0-9]+)" +
            Pattern.quote(";") + "([0-9]+)" +
            Pattern.quote(";") + "([0-9a-fA-F]+)" +
            Pattern.quote(";") + "([0-9\\,]+)");


    /**
    * Constructor for initializing an empty catalog.
    */
    Catalog()
    {
        this.snapshots = new ArrayList<SnapshotUnit>();
        this.files = new ArrayList<FileUnit>();
    }


    /**
    * Constructor for intializing a catalog with entries
    * read from the catalog of an existing ZipSnap archive.
    *
    * @param catalogFile
    *     File object representing the catalog to be read
    */
    Catalog(
            final File catalogFile)
    {
        /* initialize empty catalog */
        this.snapshots = new ArrayList<SnapshotUnit>();
        this.files = new ArrayList<FileUnit>();

        final String catalogFileName = catalogFile.getName();

        /* check if the catalog file already exists */
        if (!catalogFile.exists())
            ErrorWarningHandler.reportErrorAndExit("Catalog \"" + catalogFileName + "\" does not exist.");

        BufferedReader br = null;
        ZipFile zf = null;
        JarFile jf = null;

        try
        {
            if (catalogFileName.endsWith(".txt"))
            {
                /* read a plain text file */
                br = new BufferedReader(new FileReader(catalogFile));
            }
            else if (catalogFileName.endsWith(".txt.zip"))
            {
                /* read a compressed text file inside a ZIP volume */
                zf = new ZipFile(catalogFile);
                br = new BufferedReader(new InputStreamReader(
                        zf.getInputStream(zf.getEntry(
                        catalogFileName.substring(0, catalogFileName.lastIndexOf("."))))));
            }
            else if (catalogFileName.endsWith(".txt.jar"))
            {
                /* read a compressed text file inside a JAR volume */
                jf = new JarFile(catalogFile);
                br = new BufferedReader(new InputStreamReader(
                        jf.getInputStream(jf.getEntry(
                        catalogFileName.substring(0, catalogFileName.lastIndexOf("."))))));
            }
            else
            {
                ErrorWarningHandler.reportErrorAndExit("(INTERNAL) Catalog \"" + catalogFileName +
                        "\" has an invalid file extension.");
            }
        }
        catch (Exception e)
        {
            ErrorWarningHandler.reportErrorAndExit("Unable to open catalog \"" + catalogFileName +
                    "\":\n" + ErrorWarningHandler.getExceptionMessage(e));
        }

        /* number of snapshots */
        int numSnapshots = 0;

        /* number of files */
        int numFiles = 0;

        /* ZipSnap program version number */
        double versionNum = 0;

        /* read and parse each line of the catalog */
        ParseNextLine:
        for (int lineNum = 1; ; lineNum++)
        {
            String lineString = null;

            try
            {
                if (!br.ready())
                    break ParseNextLine;

                lineString = br.readLine().trim();
            }
            catch (Exception e)
            {
                ErrorWarningHandler.reportErrorAndExit("Unable to read line " + lineNum + " of catalog \"" +
                        catalogFileName + "\":\n" + ErrorWarningHandler.getExceptionMessage(e));
            }

            /* skip this line if empty or if it begins with '#' */
            if (lineString.isEmpty() || lineString.startsWith("#"))
                continue ParseNextLine;

            String s = lineString;

            try
            {
                if (s.startsWith("v:"))
                {
                    /* ZIPSNAP_VERSION:version                        */
                    /*                    1     (capture group index) */
                    final Matcher m = Catalog.patternZipSnapVersion.matcher(s);

                    if (!m.matches())
                        throw new Exception("Malformed ZIPSNAP_VERSION line.");

                    versionNum = Double.parseDouble(m.group(1));

                    if (versionNum > ZipSnap.version)
                        ErrorWarningHandler.reportWarning("Catalog \"" + catalogFileName +
                                "\" was created by a newer version of ZipSnap, and may not be read correctly.");
                }
                else if (s.startsWith("ns:"))
                {
                    /* NUMBER_OF_SNAPSHOTS:number                        */
                    /*                       1     (capture group index) */
                    final Matcher m = Catalog.patternNumberOfSnapshots.matcher(s);

                    if (!m.matches())
                        throw new Exception("Malformed NUMBER_OF_SNAPSHOTS line.");

                    numSnapshots = Integer.parseInt(m.group(1));
                }
                else if (s.startsWith("nf:"))
                {
                    /* NUMBER_OF_FILES:number                        */
                    /*                   1     (capture group index) */
                    final Matcher m = Catalog.patternNumberOfFiles.matcher(s);

                    if (!m.matches())
                        throw new Exception("Malformed NUMBER_OF_FILES line.");

                    numFiles = Integer.parseInt(m.group(1));
                }
                else if (s.startsWith("s:"))
                {
                    if (versionNum < 2.0)
                    {
                        /* SNAPSHOT:number:time                        */
                        /*            1     2    (capture group index) */
                        final Matcher m = Catalog.patternSnapshot1.matcher(s);

                        if (!m.matches())
                            throw new Exception("Malformed SNAPSHOT line.");

                        final int number = Integer.parseInt(m.group(1));

                        if (number != (this.snapshots.size() + 1))
                        {
                            ErrorWarningHandler.reportWarning("Malformed snapshot entry found on line " + lineNum +
                                    " of catalog \"" + catalogFileName + "\":\n\"" + lineString + "\"\n" +
                                    "Expected snapshot number " + (this.snapshots.size() + 1) +
                                    ", but found snapshot number " + number + ".\n" +
                                    "Snapshot entries must be listed in ascending order of snapshot number.\n" +
                                    "This snapshot entry will be ignored.");
                            continue ParseNextLine;
                        }

                        /* add snapshot to catalog */
                        this.snapshots.add(new SnapshotUnit(m.group(2)));
                    }
                    else
                    {
                        /* SNAPSHOT:number;time                        */
                        /*            1     2    (capture group index) */
                        final Matcher m = Catalog.patternSnapshot.matcher(s);

                        if (!m.matches())
                            throw new Exception("Malformed SNAPSHOT line.");

                        final int number = Integer.parseInt(m.group(1));

                        if (number != (this.snapshots.size() + 1))
                        {
                            ErrorWarningHandler.reportWarning("Malformed snapshot entry found on line " + lineNum +
                                    " of catalog \"" + catalogFileName + "\":\n\"" + lineString + "\"\n" +
                                    "Expected snapshot number " + (this.snapshots.size() + 1) +
                                    ", but found snapshot number " + number + ".\n" +
                                    "Snapshot entries must be listed in ascending order of snapshot number.\n" +
                                    "This snapshot entry will be ignored.");
                            continue ParseNextLine;
                        }

                        /* add snapshot to catalog */
                        this.snapshots.add(new SnapshotUnit(m.group(2)));
                    }
                }
                else if (s.startsWith("f:"))
                {
                    if (versionNum < 2.0)
                    {
                        /* FILE:number:[name],size,time(in sec),checksum,snapshots                      */
                        /*         1     2     3       4           5        6     (capture group index) */
                        final Matcher m = Catalog.patternFile1.matcher(s);

                        if (!m.matches())
                            throw new Exception("Malformed FILE line.");

                        final int number = Integer.parseInt(m.group(1));

                        if (number != (this.files.size() + 1))
                        {
                            ErrorWarningHandler.reportWarning("Malformed file entry found on line " + lineNum +
                                    " of catalog \"" + catalogFileName + "\":\n\"" + lineString + "\"\n" +
                                    "Expected file number " + (this.files.size() + 1) +
                                    ", but found file number " + number + ".\n" +
                                    "File entries must be listed in ascending order of file number.\n" +
                                    "This file entry will be ignored.");
                            continue ParseNextLine;
                        }

                        /* neutral and relative pathname of file/directory */
                        final String name = m.group(2);

                        /* native and relative pathname of file/directory */
                        final String nativeName = FileIO.neutralToNative(name);

                        /* File object representing this file/directory (native and absolute pathname) */
                        final File file = new File(ZipSnap.currentDir, nativeName);

                        /* true if this FileUnit represents a directory; false otherwise */
                        final boolean isDirectory = name.endsWith(FileIO.neutralSeparatorChar + "");

                        final FileUnit u = new FileUnit(nativeName, file, isDirectory);
                        u.name = name;
                        u.size = Long.parseLong(m.group(3));
                        u.time = Long.parseLong(m.group(4)) * 1000; /* convert "seconds" to "milliseconds" */
                        u.setCrc(Long.parseLong(m.group(5)));

                        /* snapshots that contain this file/directory */
                        final List<Integer> snapshots = new ArrayList<Integer>();
                        final String[] tokens = m.group(6).split("\\,");

                        for (String t : tokens)
                            snapshots.add(Integer.parseInt(t) - 1);  /* convert "number" to "index" */

                        u.snapshots.addAll(snapshots);

                        /* add file/directory to catalog */
                        this.files.add(u);
                    }
                    else
                    {
                        /* FILE:name;size;time;checksum;snapshots                       */
                        /*       1    2    3      4        5      (capture group index) */
                        final Matcher m = Catalog.patternFile.matcher(s);

                        if (!m.matches())
                            throw new Exception("Malformed FILE line.");

                        /* neutral and relative pathname of file/directory */
                        final String name = catalogToNeutral(m.group(1));

                        if (name == null)
                            throw new Exception("Malformed file/directory name \"" + m.group(1) + "\".");

                        /* native and relative pathname of file/directory */
                        final String nativeName = FileIO.neutralToNative(name);

                        /* File object representing this file/directory (native and absolute pathname) */
                        final File file = new File(ZipSnap.currentDir, nativeName);

                        /* true if this FileUnit represents a directory; false otherwise */
                        final boolean isDirectory = name.endsWith(FileIO.neutralSeparatorChar + "");

                        final FileUnit u = new FileUnit(nativeName, file, isDirectory);
                        u.name = name;
                        u.size = Long.parseLong(m.group(2));
                        u.time = Long.parseLong(m.group(3));
                        u.setCrc(Long.parseLong(m.group(4), 16));

                        /* snapshots that contain this file/directory */
                        final List<Integer> snapshots = new ArrayList<Integer>();
                        final String[] tokens = m.group(5).split("\\,");

                        for (String t : tokens)
                            snapshots.add(Integer.parseInt(t) - 1);  /* convert "number" to "index" */

                        u.snapshots.addAll(snapshots);

                        /* add file/directory to catalog */
                        this.files.add(u);
                    }
                }
                else
                {
                    throw new Exception("Unexpected line encountered.");
                }
            }
            catch (Exception e)
            {
                ErrorWarningHandler.reportWarning("Unable to parse line " + lineNum +
                        " of catalog \"" + catalogFileName + "\":\n\"" + lineString + "\"\n" +
                        ErrorWarningHandler.getExceptionMessage(e) + "\nThis line will be ignored.");
                continue ParseNextLine;
            }
        }

        /* close catalog reader */
        try
        {
            if (catalogFileName.endsWith(".txt"))
            {
                br.close();
            }
            else if (catalogFileName.endsWith(".txt.zip"))
            {
                br.close();
                zf.close();
            }
            else if (catalogFileName.endsWith(".txt.jar"))
            {
                br.close();
                jf.close();
            }
        }
        catch (Exception e)
        {
            ErrorWarningHandler.reportWarning("Unable to close catalog \"" + catalogFileName +
                                "\"\n(file may not be read successfully):\n" +
                                ErrorWarningHandler.getExceptionMessage(e));
        }

        /* check the counts */
        if (numSnapshots != this.snapshots.size())
            ErrorWarningHandler.reportWarning("Inconsistent number of snapshots found in catalog \"" + catalogFileName + "\":\n" +
                    "Expected " + numSnapshots + " snapshots, but parsed " + this.snapshots.size() + ".");

        if (numFiles != this.files.size())
            ErrorWarningHandler.reportWarning("Inconsistent number of files found in catalog \"" + catalogFileName + "\":\n" +
                    "Expected " + numFiles + " files, but parsed " + this.files.size() + ".");

        /* sort files/directories in catalog */
        this.sort();
    }


    /**
    * Write catalog to a file.
    *
    * @param catalogFile
    *     File object representing the catalog to be written
    */
    void writeToFile(
            final File catalogFile)
    {
        final String catalogFileName = catalogFile.getName();

        if (ZipSnap.simulateOnly)
        {
            System.out.print("\n\nSimulating creation of new catalog \"" + catalogFileName + "\"...");
            return;
        }

        System.out.print("\n\nCreating new catalog \"" + catalogFileName + "\"...");

        final File parentDir = catalogFile.getParentFile();

        /* create parent directories if necessary */
        if (!parentDir.exists())
            parentDir.mkdirs();

        /* check that the parent directory of the catalog exists */
        if (!parentDir.isDirectory())
            ErrorWarningHandler.reportErrorAndExit( "Unable to create catalog \"" + catalogFileName + "\":\nThe parent directory \"" +
                    parentDir.getPath() + "\" does not exist, and cannot be created.");

        /* check if the catalog file already exists */
        if (catalogFile.exists())
            ErrorWarningHandler.reportErrorAndExit( "Unable to create catalog \"" + catalogFileName + "\":\nA " +
                    (catalogFile.isDirectory() ? "directory" : "file") + " of the same name already exists.");

        PrintWriter pw = null;

        /* open the catalog writer */
        try
        {
            if (catalogFileName.endsWith(".txt"))
            {
                /* write a plain text file */
                pw = new PrintWriter(new BufferedWriter(new FileWriter(catalogFile)));
            }
            else if (catalogFileName.endsWith(".txt.zip"))
            {
                /* write a compressed text file to a ZIP volume */
                final ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(catalogFile));
                zos.setLevel(9); /* use maximum compression */
                zos.putNextEntry(new ZipEntry(
                        catalogFileName.substring(0, catalogFileName.lastIndexOf("."))));
                pw = new PrintWriter(new BufferedWriter(new OutputStreamWriter(zos)));
            }
            else if (catalogFileName.endsWith(".txt.jar"))
            {
                /* write a compressed text file to a JAR volume */
                final JarOutputStream jos = new JarOutputStream(new FileOutputStream(catalogFile));
                jos.setLevel(9); /* use maximum compression */
                jos.putNextEntry(new ZipEntry(
                        catalogFileName.substring(0, catalogFileName.lastIndexOf("."))));
                pw = new PrintWriter(new BufferedWriter(new OutputStreamWriter(jos)));
            }
            else
            {
                ErrorWarningHandler.reportErrorAndExit("(INTERNAL) Catalog \"" + catalogFileName +
                        "\" has an invalid file extension.");
            }
        }
        catch (Exception e)
        {
            ErrorWarningHandler.reportErrorAndExit("Unable to create new catalog \"" + catalogFileName + "\":\n" +
                    ErrorWarningHandler.getExceptionMessage(e));
        }

        pw.println("# " + ZipSnap.programTitle);
        pw.println("# COMMENT: Lines beginning with a '#' character are ignored by ZipSnap");

        /* ZipSnap version */
        pw.println();
        pw.println("# ZipSnap version");
        pw.println("v:" + ZipSnap.version);

        /* Snapshots */
        pw.println();
        pw.println("# SNAPSHOT:number;time");

        for (int i = 0; i < this.snapshots.size(); i++)
            pw.println("s:" + (i + 1) + ";" + this.snapshots.get(i).time);

        pw.println("# Number of snapshots");
        pw.println("ns:" + this.snapshots.size());

        /* Files */
        pw.println();
        pw.println("# FILE:name;size;time;checksum;snapshots");

        for (FileUnit u : this.files)
        {
            pw.print("f:" +
                    neutralToCatalog(u.name) + ";" +
                    u.size + ";" +
                    u.time + ";" +
                    Long.toHexString(u.getCrc()).toUpperCase(Locale.ENGLISH) + ";");

            for (int i = 0; i < u.snapshots.size() - 1; i++)
                pw.print((u.snapshots.get(i) + 1) + ","); /* convert "index" to "number" */

            pw.println(u.snapshots.get(u.snapshots.size() - 1) + 1); /* convert "index" to "number" */
        }

        pw.println("# Number of files/directories");
        pw.println("nf:" + this.files.size());

        pw.println();
        pw.println("# End-of-Catalog");

        /* close the catalog writer */
        pw.flush();
        pw.close();

        /* check for errors */
        if (pw.checkError())
            ErrorWarningHandler.reportWarning("An error was encountered while creating catalog \"" +
                    catalogFileName + "\" (file may not be written successfully).");

        /* set timestamp of catalog file */
        final FileIO.FileIOResult result = FileIO.setFileTime(catalogFile, ZipSnap.currentTime.getTime());

        if (!result.success)
            ErrorWarningHandler.reportWarning("Unable to set last-modified time of catalog \"" + catalogFileName + "\".");
    }


    /**
    * Sort the files/directories in the catalog by their name fields.
    */
    void sort()
    {
        Collections.sort(this.files);
    }


    /**
    * Convert a file name from neutral form to catalog form.
    *
    * @param path
    *     The file name in neutral form to be converted
    * @return
    *     The catalog form of the file name
    */
    private static String neutralToCatalog(
            final String path)
    {
        /* return value */
        final StringBuilder t = new StringBuilder();

        /* escape sequences '\n' and '\r' */
        for (int i = 0; i < path.length(); i++)
        {
            final char c = path.charAt(i);

            if (c == '\n')
            {
                t.append("\\n");
            }
            else if (c == '\r')
            {
                t.append("\\r");
            }
            else if (c == '\\')
            {
                t.append("\\\\");
            }
            else
            {
                t.append(c);
            }
        }

        return t.toString();
    }


    /**
    * Convert a file/directory name from catalog form to neutral form.
    *
    * @param path
    *     The file/directory name in catalog form to be converted
    * @return
    *     The neutral form of the file name; or null if an error is encountered
    */
    private static String catalogToNeutral(
            final String path)
    {
        /* return value */
        final StringBuilder t = new StringBuilder();

        /* escape sequences '\n' and '\r' */
        for (int i = 0; i < path.length(); i++)
        {
            final char c = path.charAt(i);

            if (c == '\\')
            {
                if (i + 1 < path.length())
                {
                    /* read next character */
                    i++;
                    final char d = path.charAt(i);

                    if (d == 'n')
                    {
                        t.append('\n');
                    }
                    else if (d == 'r')
                    {
                        t.append('\r');
                    }
                    else if (d == '\\')
                    {
                        t.append('\\');
                    }
                    else
                    {
                        /* invalid escape sequence */
                        return null;
                    }
                }
                else
                {
                    /* invalid escape sequence */
                    return null;
                }
            }
            else
            {
                t.append(c);
            }
        }

        return t.toString();
    }
}