/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache 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://www.apache.org/licenses/LICENSE-2.0
 *
 * 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.apache.brooklyn.util.core.file;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Collections;
import java.util.Map;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.zip.ZipOutputStream;

import org.apache.brooklyn.util.core.file.ArchiveUtils.ArchiveType;
import org.apache.brooklyn.util.exceptions.Exceptions;
import org.apache.brooklyn.util.os.Os;
import org.apache.brooklyn.util.text.Strings;

import com.google.common.annotations.Beta;
import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedHashMultimap;
import com.google.common.collect.Multimap;
import com.google.common.io.Files;

/**
 * Build a Zip or Jar archive.
 * <p>
 * Supports creating temporary archives that will be deleted on exit, if no name is
 * specified. The created file must be a Java archive type, with the extension {@code .zip},
 * {@code .jar}, {@code .war} or {@code .ear}.
 * <p>
 * Example:
 * <pre> File zip = ArchiveBuilder.archive("data/archive.zip")
 *         .addAt(new File("./pom.xml"), "")
 *         .addDirContentsAt(new File("./src"), "src/")
 *         .addAt(new File("/tmp/Extra.java"), "src/main/java/")
 *         .addDirContentsAt(new File("/tmp/overlay/"), "")
 *         .create();
 * </pre>
 * <p>
 */
@Beta
public class ArchiveBuilder {

    /**
     * Create an {@link ArchiveBuilder} for an archive with the given name.
     */
    public static ArchiveBuilder archive(String archive) {
        return new ArchiveBuilder(archive);
    }

    /**
     * Create an {@link ArchiveBuilder} for a {@link ArchiveType#ZIP Zip} format archive.
     */
    public static ArchiveBuilder zip() {
        return new ArchiveBuilder(ArchiveType.ZIP);
    }

    /**
     * Create an {@link ArchiveBuilder} for a {@link ArchiveType#JAR Jar} format archive.
     */
    public static ArchiveBuilder jar() {
        return new ArchiveBuilder(ArchiveType.JAR);
    }

    // TODO would be nice to support TAR and TGZ
    // e.g. using commons-compress
    // TarArchiveOutputStream out = new TarArchiveOutputStream(new GZIPOutputStream(bytes));
    // but I think the way entries are done is slightly different so we'd need a bit of refactoring
    
    private final ArchiveType type;
    private File archive;
    private Manifest manifest;
    private Multimap<String, File> entries = LinkedHashMultimap.create();

    private ArchiveBuilder() {
        this(ArchiveType.ZIP);
    }

    private ArchiveBuilder(String filename) {
        this(ArchiveType.of(filename));

        named(filename);
    }

    private ArchiveBuilder(ArchiveType type) {
        checkNotNull(type);
        checkArgument(ArchiveType.ZIP_ARCHIVES.contains(type));

        this.type = type;
        this.manifest = new Manifest();
    }

    /**
     * Set the location of the generated archive file.
     */
    public ArchiveBuilder named(String name) {
        checkNotNull(name);
        String ext = Files.getFileExtension(name);
        if (ext.isEmpty()) {
            name = name + "." + type.toString();
        } else if (type != ArchiveType.of(name)) {
            throw new IllegalArgumentException(String.format("Extension for '%s' did not match archive type of %s", ext, type));
        }
        this.archive = new File(Os.tidyPath(name));
        return this;
    }

    /**
     * @see #named(String)
     */
    public ArchiveBuilder named(File file) {
        checkNotNull(file);
        return named(file.getPath());
    }

    /**
     * Add a manifest entry with the given {@code key} and {@code value}.
     */
    public ArchiveBuilder manifest(Object key, Object value) {
        checkNotNull(key, "key");
        checkNotNull(value, "value");
        manifest.getMainAttributes().put(key, value);
        return this;
    }

    /**
     * Add the file located at the {@code filePath} to the archive, 
     * with some complicated base-name strategies.
     *
     * @deprecated since 0.7.0 use one of the other add methods which makes the strategy explicit */ @Deprecated
    public ArchiveBuilder add(String filePath) {
        checkNotNull(filePath, "filePath");
        return add(new File(Os.tidyPath(filePath)));
    }

    /**
     * Add the {@code file} to the archive.
     * <p>
     * If the file path is absolute, or points to a file above the current directory,
     * the file is added to the archive as a top-level entry, using the file name only.
     * For relative {@code filePath}s below the current directory, the file is added
     * using the path given and is assumed to be located relative to the current
     * working directory.
     * <p>
     * No checks for file existence are made at this stage.
     *
     * @see #entry(String, File)
     * @deprecated since 0.7.0 use one of the other add methods which makes the strategy explicit */ @Deprecated
    public ArchiveBuilder add(File file) {
        checkNotNull(file, "file");
        String filePath = Os.tidyPath(file.getPath());
        if (file.isAbsolute() || filePath.startsWith("../")) {
            return entry(Os.mergePaths(".", file.getName()), file);
        } else {
            return entry(Os.mergePaths(".", filePath), file);
        }
    }

    /**
     * Add a file located at the {@code fileSubPath}, relative to the {@code baseDir} on the local system,
     * as {@code fileSubPath} in the archive. For most archives directories are supported.
     * <p>
     * Uses the {@code fileSubPath} as the name of the file in the archive. Note that the
     * file is found by concatenating the two path components using {@link Os#mergePaths(String...)},
     * thus {@code fileSubPath} should not be absolute or point to a location above the current directory.
     * <p>
     * For a simpler addition mechanism, use {@link #addAt(File, String)}.
     * <p>
     * For complete control over file locations and names in the archive.
     * use {@link #entry(String, String)} directly or {@link #entries(Map)} for complete
     *
     * @see #entry(String, String)
     */
    public ArchiveBuilder addFromLocalBaseDir(File baseDir, String fileSubPath) {
        checkNotNull(baseDir, "baseDir");
        checkNotNull(fileSubPath, "filePath");
        return entry(fileSubPath, Os.mergePaths(baseDir.getPath(), fileSubPath));
    }
    /** @deprecated since 0.7.0 use {@link #addFromLocalBaseDir(File, String)}, or
     * one of the other add methods if adding relative to baseDir was not intended */ @Deprecated
    public ArchiveBuilder addFromLocalBaseDir(String baseDir, String fileSubPath) {
        return addFromLocalBaseDir(new File(baseDir), fileSubPath);
    }
    /** @deprecated since 0.7.0 use {@link #addFromLocalBaseDir(File, String)}, or
     * one of the other add methods if adding relative to baseDir was not intended */ @Deprecated
    public ArchiveBuilder add(String baseDir, String fileSubPath) {
        return addFromLocalBaseDir(baseDir, fileSubPath);
    }
     
    /** 
     * Adds the given file or directory to the archive, preserving its name but putting under the given directory in the archive (may be <code>""</code> or <code>"./"</code>).
     * See also {@link #addDirContentsAt(File, String)} and {@link #addFromLocalBaseDir(File, String)}. */
    public ArchiveBuilder addAt(File file, String archiveParentDir) {
        checkNotNull(archiveParentDir, "archiveParentDir");
        checkNotNull(file, "file");
        return entry(Os.mergePaths(archiveParentDir, file.getName()), file);
    }
    
    /** 
     * Adds the given file or directory to the root of the archive, preserving its name.
     * To add the contents of a directory without the dir name, use {@link #addDirContentsAtRoot(File)}.
     * See also {@link #addAt(File, String)}. */
    public ArchiveBuilder addAtRoot(File file) {
        return addAt(file, "");
    }

    /**
     * Add the contents of the directory named {@code dirName} to the archive.
     *
     * @see #addDir(File)
     * @deprecated since 0.7.0 use {@link #addDirContentsAt(File, String) */ @Deprecated
    public ArchiveBuilder addDir(String dirName) {
        checkNotNull(dirName, "dirName");
        return addDir(new File(Os.tidyPath(dirName)));
    }

    /**
     * Add the contents of the directory {@code dir} to the archive.
     * The directory's name is not included; use {@link #addAt(File, String)} with <code>""</code> as the second argument if you want that behavior. 
     *
     * @see #entry(String, File)
     */
    public ArchiveBuilder addDirContentsAt(File dir, String archiveParentDir) {
        checkNotNull(dir, "dir");
        if (!dir.isDirectory()) throw new IllegalArgumentException(dir+" is not a directory; cannot add contents to archive");
        return entry(archiveParentDir, dir);
    }
    /** See {@link #addDirContentsAt(File, String)} and {@link #addAtRoot(File)}. */
    public ArchiveBuilder addDirContentsAtRoot(File dir) {
        return addDirContentsAt(dir, "");
    }

    /**
     * As {@link #addDirContentsAt(File, String)}, 
     * using {@literal .} as the parent directory name for the contents.
     * 
     * @deprecated since 0.7.0 use {@link #addDirContentsAt(File, String)
     * to clarify API, argument types, and be explicit about where it should be installed,
     * because JARs seem to require <code>""<code> whereas ZIPs might want <code>"./"</code>. */ @Deprecated
    public ArchiveBuilder addDir(File dir) {
        return addDirContentsAt(dir, ".");
    }

    /**
     * Add the collection of {@code files} to the archive.
     *
     * @see #add(String)
     * @deprecated since 0.7.0 use one of the other add methods if keeping this file's path was not intended */ @Deprecated
    public ArchiveBuilder add(Iterable<String> files) {
        checkNotNull(files, "files");
        for (String filePath : files) {
            add(filePath);
        }
        return this;
    }

    /**
     * Add the collection of {@code files}, relative to the {@code baseDir}, to
     * the archive.
     *
     * @see #add(String, String)
     * @deprecated since 0.7.0 use one of the other add methods if keeping this file's path was not intended */ @Deprecated
    public ArchiveBuilder add(String baseDir, Iterable<String> files) {
        checkNotNull(baseDir, "baseDir");
        checkNotNull(files, "files");
        for (String filePath : files) {
            add(baseDir, filePath);
        }
        return this;
    }

    /**
     * Add the {@code file} to the archive with the path {@code entryPath}.
     *
     * @see #entry(String, File)
     */
    public ArchiveBuilder entry(String entryPath, String filePath) {
        checkNotNull(entryPath, "entryPath");
        checkNotNull(filePath, "filePath");
        return entry(entryPath, new File(filePath));
    }

    /**
     * Add the {@code file} to the archive with the path {@code entryPath}.
     */
    public ArchiveBuilder entry(String entryPath, File file) {
        checkNotNull(entryPath, "entryPath");
        checkNotNull(file, "file");
        this.entries.put(entryPath, file);
        return this;
    }

    /**
     * Add a {@link Map} of entries to the archive.
     * <p>
     * The keys should be the names of the file entries to be added to the archive and
     * the value should point to the actual {@link File} to be added.
     * <p>
     * This allows complete control over the directory structure of the eventual archive,
     * as the entry names do not need to bear any relationship to the name or location
     * of the files on the filesystem.
     */
    public ArchiveBuilder entries(Map<String, File> entries) {
        checkNotNull(entries, "entries");
        for (Map.Entry<String, File> entry: entries.entrySet())
            this.entries.put(entry.getKey(), entry.getValue());
        return this;
    }

    /**
     * Generates the archive and outputs it to the given stream, ignoring any file name.
     * <p>
     * This will add a manifest file if the type is a Jar archive.
     */
    public void stream(OutputStream output) {
        try {
            ZipOutputStream target;
            if (type == ArchiveType.ZIP) {
                target = new ZipOutputStream(output);
            } else {
                manifest(Attributes.Name.MANIFEST_VERSION, "1.0");
                target = new JarOutputStream(output, manifest);
            }
            for (String entry : entries.keySet()) {
                addToArchive(entry, entries.get(entry), target);
            }
            target.close();
        } catch (IOException ioe) {
            throw Exceptions.propagate(ioe);
        }
    }

    /**
     * Generates the archive, saving it with the given name.
     */
    public File create(String archiveFile) {
        return named(archiveFile).create();
    }

    /**
     * Generates the archive.
     * <p>
     * If no name has been specified, the archive will be created as a temporary file with
     * a unique name, that is deleted on exit. Otherwise, the given name will be used.
     */
    public File create() {
        if (archive == null) {
            File temp = Os.newTempFile("brooklyn-archive", type.toString());
            temp.deleteOnExit();
            named(temp);
        }
        try {
            OutputStream output = new FileOutputStream(archive);
            stream(output);
            output.close();
        } catch (IOException ioe) {
            throw Exceptions.propagate(ioe);
        }
        return archive;
    }

    /**
     * Recursively add files to the archive.
     * <p>
     * Code adapted from this <a href="http://stackoverflow.com/questions/1281229/how-to-use-jaroutputstream-to-create-a-jar-file">example</a>
     * <p>
     * <strong>Note</strong> {@link File} provides no support for symbolic links, and as such there is
     * no way to ensure that a symbolic link to a directory is not followed when traversing the
     * tree. In this case, iterables created by this traverser could contain files that are
     * outside of the given directory or even be infinite if there is a symbolic link loop.
     */
    private void addToArchive(String path, Iterable<File> sources, ZipOutputStream target) throws IOException {
        int size = Iterables.size(sources);
        if (size==0) return;
        boolean isDirectory;
        if (size>1) {
            // it must be directories if we are putting multiple things here 
            isDirectory = true;
        } else {
            isDirectory = Iterables.getOnlyElement(sources).isDirectory();
        }
        
        String name = path.replace("\\", "/");
        if (isDirectory) {
            name = Strings.removeAllFromEnd(name, "/");
            if (name.length()>0) {
                name += "/";
                JarEntry entry = new JarEntry(name);

                long lastModified=-1;
                for (File source: sources)
                    if (source.lastModified()>lastModified)
                        lastModified = source.lastModified();

                entry.setTime(lastModified);
                target.putNextEntry(entry);
                target.closeEntry();
            }

            for (File source: sources) {
                if (!source.isDirectory()) {
                    throw new IllegalStateException("Cannot add multiple items at a path in archive unless they are directories: "+sources+" at "+path+" is not valid.");
                }
                Iterable<File> children = Files.fileTreeTraverser().children(source);
                for (File child : children) {
                    addToArchive(Os.mergePaths(name, child.getName()), Collections.singleton(child), target);
                }
            }
            return;
        }

        File source = Iterables.getOnlyElement(sources);
        JarEntry entry = new JarEntry(name);
        entry.setTime(source.lastModified());
        target.putNextEntry(entry);
        Files.asByteSource(source).copyTo(target);
        target.closeEntry();
    }
}
