/* A program to copy directory trees
 *
 * Copyright (C) 2015 Sidney Marshall (swm@cs.rit.edu)
 *
 * 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/>.
 */

import java.io.FileOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.Comparator;

/**
 * This class copies files and directories from one directory to
 * another. It can check for differences either by file date or by a
 * byte by byte file compare.
 *
 * Without the -m option it uses byte by byte file compare and does
 * not remove target files or directories that are not in the source
 * directory.
 *
 * With the -m option it uses file dates to determine differences and
 * does remove files target files that do not exist in the source
 * directory. It also sets the dates in the target directory to match
 * the dates in the source directioy.
 */
class Copyfiles {
  //static byte xbuf[] = new byte[1048576]; // not thread safe
  //static byte ybuf[] = new byte[1048576]; // not thread safe

  /**
   * Does a byte for byte comparison of two files. Returns false on
   * any error.
   *
   * @param x first file to compare
   * @param y second file to compare
   * @return true if the files are exactly the same
   */
  static boolean equalFile(File x, File y) {
    if(x.length() != y.length()) return false;
    FileInputStream strmx = null;
    FileInputStream strmy = null;
    byte xbuf[] = new byte[1048576];
    byte ybuf[] = new byte[1048576];
    try {
      strmx = new FileInputStream(x);
      strmy = new FileInputStream(y);
      int xlen;
      int ylen;
      Arrays.fill(xbuf, (byte)0);
      Arrays.fill(ybuf, (byte)0);
      while(true) {
        xlen = strmx.read(xbuf);
        ylen = strmy.read(ybuf);
        if(xlen != ylen) return false;
        if(xlen < 0) return true;
        if(!Arrays.equals(xbuf, ybuf)) return false;
      }
    } catch(IOException e) {
      return false;
    } finally {
      if(strmx != null) try { strmx.close();} catch(IOException e) {}
      if(strmy != null) try { strmy.close();} catch(IOException e) {}
    }
  }

  /**
   * Compare modification times and return true if less than 2 seconds
   * different. This is to compensate for various precisions in time
   * stamps between opreating systems.
   *
   * @param x first file to compare
   * @param y second file to compare
   * @return true if the modification times are within 2 seconds of each other
   */
  static boolean equalDate(File x, File y) {
    return Math.abs(x.lastModified() - y.lastModified()) <= 2000;
  }

  /**
   * Copies a file creating or replacing the destination file as
   * necessary.
   *
   * @param from source file of copy
   * @param to destination file of copy
   * @return true if no errors
   */
  static boolean copyFile(File from, File to) {
    boolean status = true;
    FileInputStream in = null;
    FileOutputStream out = null;
    byte xbuf[] = new byte[1048576];
    try {
      in = new FileInputStream(from);
      out = new FileOutputStream(to);
      int len;
      while((len = in.read(xbuf)) > 0) {
        out.write(xbuf, 0, len);
      }
    } catch(IOException e) {
      status = false;
    } finally {
      if(in != null) {
        try {
          in.close();
        } catch(IOException e) { status = false; }
      }
      if(out != null) {
        try {
          out.close();
        } catch(IOException e) { status = false; }
      }
    }
    return status;
  }

  /**
   * Return a relative path relative to the given absolute path. If
   * the prefix is not a prefix of the absolute path just return the
   * unmodified absolute path.
   *
   * @param prefix the prefix to be stripped off of the path
   * @param absolute the path to be stripped
   * @return the stripped path
   */
  static String relativePath(String prefix, String absolute) {
    int index = absolute.indexOf(prefix);
    if(index == 0 && prefix.length() != absolute.length()) {
      String suffix = absolute.substring(prefix.length());
      if(suffix.charAt(0) == File.separatorChar) {
        suffix = suffix.substring(1);
      }
      return suffix;
    }
    return absolute;
  }

  /**
   * A comparator on File comparing base names.
   */
  static Comparator<File> fileComp = new Comparator<File>() {
      public int compare(File f1, File f2) {
        return f1.getName().compareTo(f2.getName());
      }
    };

  String fromBase;
  String toBase;

  /**
   * Make two directories equal by recursively copying files with
   * differing content. The file dates are not copied.
   *
   * @param from the source directory for the copy
   * @param to the destination directory for the copy
   */
  Copyfiles(File from, File to) {
    fromBase = from.getAbsolutePath();
    toBase = to.getAbsolutePath();
    copyDirectories(from, to);
  }

  /**
   * Mirror two directories - both arguments must be directories and
   * the destination directory must exist. Dates are not preserved.
   *
   * flag -t update timestamps
   * flag -d delete if not in source directory
   *
   * Currently updates timestamps and deletes if not in source
   * directory as there are no flags yet.
   *
   * 4 cases file, directory, unknown, missing
   *
   * @param fromDir the source directory for the copy
   * @param toDir the destination directory for the copy
   */
  void copyDirectories(File fromDir, File toDir) {
    File[] fromFiles = fromDir.listFiles();
    Arrays.sort(fromFiles, fileComp); // sort by getName()
    File[] toFiles = toDir.listFiles();
    Arrays.sort(toFiles, fileComp); // sort by getName()
    int i = 0, j = 0;
    int flen = fromFiles.length, tlen = toFiles.length;
    while(i < flen || j < tlen) {
      int c = i == flen ? 1 : j == tlen ? -1
        : fromFiles[i].getName().compareTo(toFiles[j].getName());
      if(c < 0) {
        // directory entry only in fromDir
        String fromPath = fromFiles[i].getAbsolutePath();
        File fromFile = fromFiles[i].getAbsoluteFile();
        String name = relativePath(fromBase, fromPath);
        File toFile = new File(toDir, fromFiles[i].getName());
        // create empty file/directory and continue
        if(fromFiles[i].isDirectory()) {
          System.out.println("Creating directory: " + name);
          toFile.mkdir();
          // update time stamp ///////////////////////
          copyDirectories(fromFiles[i], toFile);
        } else if(fromFiles[i].isFile()) {
          // print message and copy
          System.out.println("Creating: " + name);
          if(!copyFile(fromFile, toFile)) {
            System.out.println("Copying " + name + " failed.");
          }
          // update timestamp
          if(!toFile.setLastModified(fromFiles[i].lastModified())) {
            System.out.println("Setting last modified on " + toFile + " failed.");
          }
        } else {
          System.out.println("Unknown file type: " + name);
        }
        i++;
      } else if(c > 0) {
        File toFile = new File(toDir, toFiles[j].getName());
        String toPath = toFile.getAbsolutePath();
        String name = relativePath(toBase, toPath);
        // directory entry only in toDir - print error and continue
        System.out.println("Only in destination directory: " + name);
        // remove directory if flag set /////////////////////
        j++;
      } else {
        File fromFile = new File(fromDir, fromFiles[i].getName());
        File toFile = new File(toDir, toFiles[j].getName());
        String toPath = toFile.getAbsolutePath();
        String name = relativePath(toBase, toPath);
        if(fromFile.isFile() && toFile.isFile()) {
          // both files
          // if both dates and contents differ
          //   copy and set date
          // }
          // x.length() != y.length()
          if(!equalDate(fromFile, toFile) && !equalFile(fromFile, toFile)) {
            // print message and copy
            System.out.println("Updating: " + name);
            if(!copyFile(fromFile, toFile)) {
              System.out.println("Copying " + name + " failed.");
            }
            // update timestamp ///////////////////////////
            if(!toFile.setLastModified(fromFile.lastModified())) {
              System.out.println("Setting last modified on " + toFile + " failed.");
            }
          }
        } else if(fromFile.isDirectory() && toFile.isDirectory()) {
          // both directories
          copyDirectories(fromFile, toFile);
        } else {
          System.out.println("Incompatable file types: " + name);
          // if flag remove and copy ///////////////////////////
        }
        i++;
        j++;
      }
    }
  }

  /**
   * Mirror two directories - both arguments must be directories and
   * the destination directory must exist. Dates are preserved.
   * Equality is checked by comparing dates on files.
   *
   * 4 cases file, directory, unknown, missing
   *
   * @param fromDir the source directory for the copy
   * @param toDir the destination directory for the copy
   */
  static void mirrorDirectories(File fromDir, File toDir) {
    File[] fromFiles = fromDir.listFiles();
    Arrays.sort(fromFiles, fileComp); // sort by getName()
    File[] toFiles = toDir.listFiles();
    Arrays.sort(toFiles, fileComp); // sort by getName()
    int i = 0, j = 0;
    int flen = fromFiles.length, tlen = toFiles.length;
    while(i < flen || j < tlen) {
      int c = i == flen ? 1 : j == tlen ? -1
        : fromFiles[i].getName().compareTo(toFiles[j].getName());
      if(c < 0) {
        // directory entry only in fromDir
        File toFile = new File(toDir, fromFiles[i].getName());
        // create empty file/directory and continue
        if(fromFiles[i].isDirectory()) {
          System.out.println("Creating directory: " + toFile);
          toFile.mkdir();
          // update time stamp ///////////////////////
          mirrorDirectories(fromFiles[i], toFile);
        } else if(fromFiles[i].isFile()) {
          // print message and copy
          System.out.println("Creating: " + toFile);
          if(!copyFile(fromFiles[i], toFile)) {
            System.out.println("Copying " + toFile + " failed.");
          }
          if(!toFile.setLastModified(fromFiles[i].lastModified())) {              
            System.out.println("Setting last modified on " + toFile + " failed.");
          }
        } else {
          System.out.println("Unknown file type: " + fromFiles[i]);
        }
        i++;
      } else if(c > 0) {
        // directory entry only in toDir - print error and continue
        System.out.println("Only in destination directory: " + toFiles[j]);
        if(!delete(toFiles[j])) {
          System.out.println("Deleting " + toFiles[j] + " failed.");
        }
        // remove directory if flag set /////////////////////
        j++;
      } else {
        if(fromFiles[i].isFile() && toFiles[j].isFile()) {
          // both files
          if(fromFiles[i].lastModified() != toFiles[j].lastModified()) {
            // print message and copy
            System.out.println("Updating: " + toFiles[j]);
            if(!copyFile(fromFiles[i], toFiles[j])) {
              System.out.println("Copying " + toFiles[j] + " failed.");
            }
            // update timestamp ///////////////////////////
            try {
              if(!toFiles[j].setLastModified(fromFiles[i].lastModified())) {
                System.out.println("Setting last modified on " + toFiles[j] + " failed.");
              }
            }
            catch(Exception e) {
              System.out.println("Setting last modified on " + toFiles[j] + " failed.");
              System.out.println("Date was: " + fromFiles[i].lastModified());
              e.printStackTrace();
            }
          }
        } else if(fromFiles[i].isDirectory() && toFiles[j].isDirectory()) {
          // both directories
          mirrorDirectories(fromFiles[i], toFiles[j]);
        } else {
          System.out.println("Incompatable file types: " + fromFiles[i]);
          ///////////// should delete and copy
        }
        i++;
        j++;
      }
    }
  }

  /**
   * This method deletes the specified directory. If given a
   * directory it recursively deletes its contents and then the
   * directory. Returns true if successful, otherwise false.
   *
   * @param file the file or directory to be deleted
   * @return true if file is a directory and there are no errors
   */
  static boolean delete(File file) {
    if(file.isDirectory()) {
      boolean status = true;
      File[] files = file.listFiles();
      for(int i = 0; i < files.length; i++) {
        status &= delete(files[i]);
      }
      status &= file.delete();
      return status;
    } else if(file.isFile()) return file.delete();
    else return false;
  }

  /**
   * Copies or mirrors a directory tree.
   *
   * java [-m] sourceDirectory destinationDirectory
   *
   * Without the -m flag does a byte for byte compare and does not
   * delete files in the destination directory.
   *
   * With the -m flag only checks file dates for differences and
   * deletes destination files and directories that do not appear in
   * the source directory hierarchy.
   *
   * Note: file syntax is c:/ etc.
   *
   * @param args the command line arguments.
   */
  public static void main(String...args) {
    if(args.length == 3 && args[0].equals("-m")) {
      mirrorDirectories(new File(args[1]), new File(args[2]));
    } else {
      File from = new File(args[0]);
      File to = new File(args[1]);
      Copyfiles update = new Copyfiles(from, to);
      //update.mirrorDirectories(from, to);
    }
  }
} // class CopyFiles
