/*
 * Licensed to The Apereo Foundation under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 *
 * The Apereo Foundation licenses this file to you under the Educational
 * Community License, Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License. You may obtain a copy of the License
 * at:
 *
 *   http://opensource.org/licenses/ecl2.txt
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
 * License for the specific language governing permissions and limitations under
 * the License.
 *
 */

package org.opencastproject.util;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.Vector;
import java.util.zip.Deflater;

import de.schlichtherle.io.ArchiveDetector;
import de.schlichtherle.io.ArchiveException;
import de.schlichtherle.io.ArchiveWarningException;
import de.schlichtherle.io.DefaultArchiveDetector;
import de.schlichtherle.io.File;
import de.schlichtherle.io.archive.zip.ZipDriver;

/*
 * WARNING:
 * Some archivers, such as file-roller in Ubuntu, seem not to be able to uncompress zip archives containg files with special characters.
 * The pure zip standard uses the CP437 encoding which CAN'T represent special characters, but applications have implemented their own methods
 * to overcome this inconvenience. TrueZip also. However, it seems file-roller (and probably others) doesn't "understand" how to restore the
 * original filenames and shows strange (and un-readable) characters in the unzipped filenames
 * However, the inverse process (unzipping zip archives made with file-roller and containing files with special characters) seems to work fine
 *
 * N.B. By "special characters" I mean those not present in the original ASCII specification, such as accents, special letters, etc
 *
 * ruben.perez
 */

/**
 * Provides static methods for compressing and extracting zip files using zip64 extensions when necessary.
 */
public final class ZipUtil {

  private static final Logger logger = LoggerFactory.getLogger(ZipUtil.class);

  public static final int BEST_SPEED = Deflater.BEST_SPEED;
  public static final int BEST_COMPRESSION = Deflater.BEST_COMPRESSION;
  public static final int DEFAULT_COMPRESSION = Deflater.DEFAULT_COMPRESSION;
  public static final int NO_COMPRESSION = Deflater.NO_COMPRESSION;

  /** Disable construction of this utility class */
  private ZipUtil() {
  }

  /**
   * Utility class to ease the process of umounting a zip file
   *
   * @param zipFile
   *          The file to umount
   * @throws IOException
   *           If some problem occurs on unmounting
   */
  private static void umount(File zipFile) throws IOException {
    try {
      File.umount(zipFile);
    } catch (ArchiveWarningException awe) {
      logger.warn("Umounting {} threw the following warning: {}", zipFile.getCanonicalPath(), awe.getMessage());
    } catch (ArchiveException ae) {
      logger.error("Unable to umount zip file: {}", zipFile.getCanonicalPath());
      throw new IOException("Unable to umount zip file: " + zipFile.getCanonicalPath(), ae);
    }
  }

  /***********************************************************************************/
  /* SERVICE CLASSES - The two following classes are the ones actually doing the job */
  /***********************************************************************************/

  /**
   * Compresses source files into a zip archive
   *
   * @param sourceFiles
   *          A {@link java.io.File} array with the files to include in the root of the archive
   * @param destination
   *          A {@link java.io.File} descriptor to the location where the zip file should be created
   * @param recursive
   *          Indicate whether or not recursively zipping nested directories
   * @param level
   *          The zip algorithm compression level. Ranges between 0 (no compression) and 9 (max. compression)
   * @return A {@link java.io.File} descriptor of the zip archive file
   * @throws IOException
   *           If the zip file can not be created, or the input files names can not be correctly parsed
   */
  public static java.io.File zip(java.io.File[] sourceFiles, java.io.File destination, boolean recursive, int level)
          throws IOException {

    if (sourceFiles == null) {
      logger.error("The array with files to zip cannot be null");
      throw new IllegalArgumentException("The array with files to zip cannot be null");
    }

    if (sourceFiles.length <= 0) {
      logger.error("The array with files to zip cannot be empty");
      throw new IllegalArgumentException("The array with files to zip cannot be empty");
    }

    if (destination == null) {
      logger.error("The destination file cannot be null");
      throw new IllegalArgumentException("The destination file cannot be null");
    }

    if (destination.exists()) {
      logger.error("The destination file {} already exists", destination.getCanonicalPath());
      throw new IllegalArgumentException("The destination file already exists");
    }

    if (level < -1) {
      logger.warn("Compression level cannot be less than 0 (or -1 for default)");
      logger.warn("Reverting to default...");
      level = -1;
    } else if (level > 9) {
      logger.warn("Compression level cannot be greater than 9");
      logger.warn("Reverting to default...");
      level = -1;
    }

    // Limits the compression support to ZIP only and sets the compression level
    ZipDriver zd = new ZipDriver(level);
    ArchiveDetector ad = new DefaultArchiveDetector(ArchiveDetector.NULL, "zip", zd);
    File zipFile;
    try {
      zipFile = new File(destination.getCanonicalFile(), ad);
    } catch (IOException ioe) {
      logger.error("Unable to create the zip file: {}", destination.getAbsolutePath());
      throw new IOException("Unable to create the zip file: {}" + destination.getAbsolutePath(), ioe);
    }

    try {
      if (!zipFile.isArchive()) {
        logger.error("The destination file does not represent a valid zip archive (.zip extension is required)");
        zipFile.deleteAll();
        throw new IllegalArgumentException(
                "The destination file does not represent a valid zip archive (.zip extension is required)");
      }

      if (!zipFile.mkdirs())
        throw new IOException("Couldn't create the destination file");

      for (java.io.File f : sourceFiles) {

        if (f == null) {
          logger.error("Null inputfile in array");
          zipFile.deleteAll();
          throw new IllegalArgumentException("Null inputfile in array");
        }

        logger.debug("Attempting to zip file {}...", f.getAbsolutePath());

        // TrueZip manual says that (archiveC|copy)All(From|To) methods work with either directories or regular files
        // Therefore, one could do zipFile.archiveCopyAllFrom(f), where f is a regular file, and it would work. Well, it
        // DOESN'T
        // This is why we have to tell if a file is a regular file or a directory BEFORE copying it with the appropriate
        // method
        boolean success = false;
        if (f.exists()) {
          if (!f.isDirectory() || recursive) {
            success = new File(zipFile, f.getName()).copyAllFrom(f);
            if (success)
              logger.debug("File {} zipped successfuly", f.getAbsolutePath());
            else {
              logger.error("File {} not zipped", f.getAbsolutePath());
              zipFile.deleteAll();
              throw new IOException("Failed to zip one of the input files: " + f.getAbsolutePath());
            }
          }
        } else {
          logger.error("Input file {} doesn't exist", f.getAbsolutePath());
          zipFile.deleteAll();
          throw new FileNotFoundException("One of the input files does not exist: " + f.getAbsolutePath());
        }
      }
    } catch (IOException e) {
      throw e;
    } finally {
      umount(zipFile);
    }

    return destination;
  }

  /**
   * Extracts a zip file to a directory.
   *
   * @param zipFile
   *          A {@link String} with the path to the source zip archive
   * @param destination
   *          A {@link String} with the location where the zip archive will be extracted. If this destination directory
   *          does not exist, it will be created.
   * @throws IOException
   *           if the zip file cannot be read, the destination directory cannot be created or the extraction is not
   *           successful
   */
  public static void unzip(java.io.File zipFile, java.io.File destination) throws IOException {

    boolean success;

    if (zipFile == null) {
      logger.error("The zip file cannot be null");
      throw new IllegalArgumentException("The zip file must be set");
    }

    if (!zipFile.exists()) {
      logger.error("The zip file does not exist: {}", zipFile.getCanonicalPath());
      throw new FileNotFoundException("The zip file does not exist: " + zipFile.getCanonicalPath());
    }

    if (destination == null) {
      logger.error("The destination file cannot be null");
      throw new IllegalArgumentException("Destination file cannot be null");
    }

    // FIXME Commented out for 3rd party compatibility. See comment in the zip method above -ruben.perez
    // File f = new File(zipFile.getCanonicalFile(), new DefaultArchiveDetector(ArchiveDetector.NULL, "zip", new
    // ZipDriver("utf-8")));
    File f;
    try {
      f = new File(zipFile.getCanonicalFile());
    } catch (IOException ioe) {
      logger.error("Unable to create the zip file: {}", destination.getAbsolutePath());
      throw new IOException("Unable to create the zip file: {}" + destination.getAbsolutePath(), ioe);
    }

    try {
      if (f.isArchive() && f.isDirectory()) {
        if (destination.exists()) {
          if (!destination.isDirectory()) {
            logger.error("Destination file must be a directory");
            throw new IllegalArgumentException("Destination file must be a directory");
          }
        }

        try {
          destination.mkdirs();
        } catch (SecurityException e) {
          logger.error("Cannot create destination directory: {}", e.getMessage());
          throw new IOException("Cannot create destination directory", e);
        }

        success = f.copyAllTo(destination);

        if (success)
          logger.debug("File {} unzipped successfully", zipFile.getCanonicalPath());
        else {
          logger.warn("File {} was not correctly unzipped", zipFile.getCanonicalPath());
          throw new IOException("File " + zipFile.getCanonicalPath() + " was not correctly unzipped");
        }
      } else {
        logger.error("The input file is not a valid zip file");
        throw new IllegalArgumentException("The input file is not a valid zip file");
      }
    } catch (IOException e) {
      throw (e);
    } finally {
      umount(f);
    }

  }

  /************************************************************************************* */
  /* "ALIASES" - For different types of input, but actually calling the previous methods */
  /***************************************************************************************/

  /**
   * Compresses source files into a zip archive
   *
   * @param sourceFiles
   *          A {@link String} array with the file names to be included in the root of the archive
   * @param destination
   *          A {@link String} with the path name of the resulting zip file
   * @param recursive
   *          Indicate whether or not recursively zipping nested directories
   * @param level
   *          The zip algorithm compression level. Ranges between 0 (no compression) and 9 (max. compression)
   * @return A {@link java.io.File} descriptor of the zip archive file
   * @throws IOException
   *           If the zip file can not be created, or the input files names can not be correctly parsed
   */
  public static java.io.File zip(String[] sourceFiles, String destination, boolean recursive, int level)
          throws IOException {

    if (sourceFiles == null) {
      logger.error("The input String array cannot be null");
      throw new IllegalArgumentException("The input String array cannot be null");
    }

    if (destination == null) {
      logger.error("Destination file cannot be null");
      throw new IllegalArgumentException("Destination file cannot be null");
    }

    if ("".equals(destination)) {
      logger.error("Destination file name must be set");
      throw new IllegalArgumentException("Destination file name must be set");
    }

    Vector<java.io.File> files = new Vector<java.io.File>();
    for (String name : sourceFiles) {
      if (name == null) {
        logger.error("One of the input file names is null");
        throw new IllegalArgumentException("One of the input file names is null");
      } else if ("".equals(name)) {
        logger.error("One of the input file names is blank");
        throw new IllegalArgumentException("One of the input file names is blank");
      }
      files.add(new java.io.File(name));
    }

    return zip(files.toArray(new java.io.File[files.size()]), new java.io.File(destination), recursive, level);

  }

  /**
   * Compresses source files into a zip archive
   *
   * @param sourceFiles
   *          A {@link String} array with the file names to be included in the root of the archive
   * @param destination
   *          A {@link java.io.File} with the path name of the resulting zip file
   * @param recursive
   *          Indicate whether or not recursively zipping nested directories
   * @param level
   *          The zip algorithm compression level. Ranges between 0 (no compression) and 9 (max. compression)
   * @return A {@link java.io.File} descriptor of the zip archive file
   * @throws IOException
   *           If the zip file can not be created, or the input files names can not be correctly parsed
   */
  public static java.io.File zip(String[] sourceFiles, java.io.File destination, boolean recursive, int level)
          throws IOException {

    if (sourceFiles == null) {
      logger.error("The input String array cannot be null");
      throw new IllegalArgumentException("The input String array cannot be null");
    }

    Vector<java.io.File> files = new Vector<java.io.File>();
    for (String name : sourceFiles) {
      if (name == null) {
        logger.error("One of the input file names is null");
        throw new IllegalArgumentException("One of the input file names is null");
      } else if ("".equals(name)) {
        logger.error("One of the input file names is blank");
        throw new IllegalArgumentException("One of the input file names is blank");
      }
      files.add(new java.io.File(name));
    }

    return zip(files.toArray(new java.io.File[files.size()]), destination, recursive, level);

  }

  /**
   * Compresses source files into a zip archive
   *
   * @param sourceFiles
   *          A {@link java.io.File} array with the file names to be included in the root of the archive
   * @param destination
   *          A {@link String} with the path name of the resulting zip file
   * @param recursive
   *          Indicate whether or not recursively zipping nested directories
   * @param level
   *          The zip algorithm compression level. Ranges between 0 (no compression) and 9 (max. compression)
   * @return A {@link java.io.File} descriptor of the zip archive file
   * @throws IOException
   *           If the zip file can not be created, or the input files names can not be correctly parsed
   */
  public static java.io.File zip(java.io.File[] sourceFiles, String destination, boolean recursive, int level)
          throws IOException {

    if (destination == null) {
      logger.error("Destination file cannot be null");
      throw new IllegalArgumentException("Destination file cannot be null");
    }

    if ("".equals(destination)) {
      logger.error("Destination file name must be set");
      throw new IllegalArgumentException("Destination file name must be set");
    }

    return zip(sourceFiles, new java.io.File(destination), recursive, level);

  }

  /**
   * Compresses source files into a zip archive (no recursive)
   *
   * @param sourceFiles
   *          A {@link java.io.File} array with the file names to be included in the root of the archive
   * @param destination
   *          A {@link java.io.File} with the path name of the resulting zip file
   * @param level
   *          The zip algorithm compression level. Ranges between 0 (no compression) and 9 (max. compression)
   * @return A {@link java.io.File} descriptor of the zip archive file
   * @throws IOException
   *           If the zip file can not be created, or the input files names can not be correctly parsed
   */
  public static java.io.File zip(java.io.File[] sourceFiles, java.io.File destination, int level) throws IOException {
    return zip(sourceFiles, destination, false, level);
  }

  /**
   * Extracts a zip file to a directory.
   *
   * @param zipFile
   *          A {@link String} with the path to the source zip archive
   * @param destination
   *          A {@link String} with the location where the zip archive will be extracted. If this destination directory
   *          does not exist, it will be created.
   * @throws IOException
   *           if the zip file cannot be read, the destination directory cannot be created or the extraction is not
   *           successful
   */
  public static void unzip(String zipFile, String destination) throws IOException {

    if (zipFile == null) {
      logger.error("Input filename cannot be null");
      throw new IllegalArgumentException("Input filename cannot be null");
    }

    if ("".equals(zipFile)) {
      logger.error("Input filename cannot be empty");
      throw new IllegalArgumentException("Input filename cannot be empty");
    }

    if (destination == null) {
      logger.error("Output filename cannot be null");
      throw new IllegalArgumentException("Output filename cannot be null");
    }

    if ("".equals(destination)) {
      logger.error("Output filename cannot be empty");
      throw new IllegalArgumentException("Output filename cannot be empty");
    }

    unzip(new java.io.File(zipFile), new java.io.File(destination));

  }

  /**
   * Extracts a zip file to a directory.
   *
   * @param zipFile
   *          A {@link java.io.File} with the path to the source zip archive
   * @param destination
   *          A {@link String} with the location where the zip archive will be extracted.
   * @throws IOException
   *           if the zip file cannot be read, the destination directory cannot be created or the extraction is not
   *           successful
   */
  public static void unzip(java.io.File zipFile, String destination) throws IOException {

    if (destination == null) {
      logger.error("Output filename cannot be null");
      throw new IllegalArgumentException("Output filename cannot be null");
    }

    if ("".equals(destination)) {
      logger.error("Output filename cannot be empty");
      throw new IllegalArgumentException("Output filename cannot be empty");
    }

    unzip(zipFile, new java.io.File(destination));

  }

  /**
   * Extracts a zip file to a directory.
   *
   * @param zipFile
   *          A {@link String} with the path to the source zip archive
   * @param destination
   *          A {@link java.io.File} with the location where the zip archive will be extracted.
   * @throws IOException
   *           if the zip file cannot be read, the destination directory cannot be created or the extraction is not
   *           successful
   */
  public static void unzip(String zipFile, java.io.File destination) throws IOException {

    if (zipFile == null) {
      logger.error("Input filename cannot be null");
      throw new IllegalArgumentException("Input filename cannot be null");
    }

    if ("".equals(zipFile)) {
      logger.error("Input filename cannot be empty");
      throw new IllegalArgumentException("Input filename cannot be empty");
    }

    unzip(new java.io.File(zipFile), destination);

  }

}
