/*
 * Copyright (c) 2013, 2016, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.
 *
 * This code 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
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */

package toolbox;

import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOError;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Set;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.tools.FileObject;
import javax.tools.JavaFileManager;
import javax.tools.JavaFileObject;
import static toolbox.ToolBox.currDir;

/**
 * A task to configure and run the jar file utility.
 */
public class JarTask extends AbstractTask<JarTask> {
    private Path jar;
    private Manifest manifest;
    private String classpath;
    private String mainClass;
    private Path baseDir;
    private List<Path> paths;
    private Set<FileObject> fileObjects;

    /**
     * Creates a task to write jar files, using API mode.
     * @param toolBox the {@code ToolBox} to use
     */
    public JarTask(ToolBox toolBox) {
        super(toolBox, Task.Mode.API);
        paths = Collections.emptyList();
        fileObjects = new LinkedHashSet<>();
    }

    /**
     * Creates a JarTask for use with a given jar file.
     * @param toolBox the {@code ToolBox} to use
     * @param path the file
     */
    public JarTask(ToolBox toolBox, String path) {
        this(toolBox);
        jar = Paths.get(path);
    }

    /**
     * Creates a JarTask for use with a given jar file.
     * @param toolBox the {@code ToolBox} to use
     * @param path the file
     */
    public JarTask(ToolBox toolBox, Path path) {
        this(toolBox);
        jar = path;
    }

    /**
     * Sets a manifest for the jar file.
     * @param manifest the manifest
     * @return this task object
     */
    public JarTask manifest(Manifest manifest) {
        this.manifest = manifest;
        return this;
    }

    /**
     * Sets a manifest for the jar file.
     * @param manifest a string containing the contents of the manifest
     * @return this task object
     * @throws IOException if there is a problem creating the manifest
     */
    public JarTask manifest(String manifest) throws IOException {
        this.manifest = new Manifest(new ByteArrayInputStream(manifest.getBytes()));
        return this;
    }

    /**
     * Sets the classpath to be written to the {@code Class-Path}
     * entry in the manifest.
     * @param classpath the classpath
     * @return this task object
     */
    public JarTask classpath(String classpath) {
        this.classpath = classpath;
        return this;
    }

    /**
     * Sets the class to be written to the {@code Main-Class}
     * entry in the manifest..
     * @param mainClass the name of the main class
     * @return this task object
     */
    public JarTask mainClass(String mainClass) {
        this.mainClass = mainClass;
        return this;
    }

    /**
     * Sets the base directory for files to be written into the jar file.
     * @param baseDir the base directory
     * @return this task object
     */
    public JarTask baseDir(String baseDir) {
        this.baseDir = Paths.get(baseDir);
        return this;
    }

    /**
     * Sets the base directory for files to be written into the jar file.
     * @param baseDir the base directory
     * @return this task object
     */
    public JarTask baseDir(Path baseDir) {
        this.baseDir = baseDir;
        return this;
    }

    /**
     * Sets the files to be written into the jar file.
     * @param files the files
     * @return this task object
     */
    public JarTask files(String... files) {
        this.paths = Stream.of(files)
                .map(file -> Paths.get(file))
                .collect(Collectors.toList());
        return this;
    }

    /**
     * Adds a set of file objects to be written into the jar file, by copying them
     * from a Location in a JavaFileManager.
     * The file objects to be written are specified by a series of paths;
     * each path can be in one of the following forms:
     * <ul>
     * <li>The name of a class. For example, java.lang.Object.
     * In this case, the corresponding .class file will be written to the jar file.
     * <li>the name of a package followed by {@code .*}. For example, {@code java.lang.*}.
     * In this case, all the class files in the specified package will be written to
     * the jar file.
     * <li>the name of a package followed by {@code .**}. For example, {@code java.lang.**}.
     * In this case, all the class files in the specified package, and any subpackages
     * will be written to the jar file.
     * </ul>
     *
     * @param fm the file manager in which to find the file objects
     * @param l  the location in which to find the file objects
     * @param paths the paths specifying the file objects to be copied
     * @return this task object
     * @throws IOException if errors occur while determining the set of file objects
     */
    public JarTask files(JavaFileManager fm, JavaFileManager.Location l, String... paths)
            throws IOException {
        for (String p : paths) {
            if (p.endsWith(".**"))
                addPackage(fm, l, p.substring(0, p.length() - 3), true);
            else if (p.endsWith(".*"))
                addPackage(fm, l, p.substring(0, p.length() - 2), false);
            else
                addFile(fm, l, p);
        }
        return this;
    }

    private void addPackage(JavaFileManager fm, JavaFileManager.Location l, String pkg, boolean recurse)
            throws IOException {
        for (JavaFileObject fo : fm.list(l, pkg, EnumSet.allOf(JavaFileObject.Kind.class), recurse)) {
            fileObjects.add(fo);
        }
    }

    private void addFile(JavaFileManager fm, JavaFileManager.Location l, String path) throws IOException {
        JavaFileObject fo = fm.getJavaFileForInput(l, path, JavaFileObject.Kind.CLASS);
        fileObjects.add(fo);
    }

    /**
     * Provides limited jar command-like functionality.
     * The supported commands are:
     * <ul>
     * <li> jar cf jarfile -C dir files...
     * <li> jar cfm jarfile manifestfile -C dir files...
     * </ul>
     * Any values specified by other configuration methods will be ignored.
     * @param args arguments in the style of those for the jar command
     * @return a Result object containing the results of running the task
     */
    public Task.Result run(String... args) {
        if (args.length < 2)
            throw new IllegalArgumentException();

        ListIterator<String> iter = Arrays.asList(args).listIterator();
        String first = iter.next();
        switch (first) {
            case "cf":
                jar = Paths.get(iter.next());
                break;
            case "cfm":
                jar = Paths.get(iter.next());
                try (InputStream in = Files.newInputStream(Paths.get(iter.next()))) {
                    manifest = new Manifest(in);
                } catch (IOException e) {
                    throw new IOError(e);
                }
                break;
        }

        if (iter.hasNext()) {
            if (iter.next().equals("-C"))
                baseDir = Paths.get(iter.next());
            else
                iter.previous();
        }

        paths = new ArrayList<>();
        while (iter.hasNext())
            paths.add(Paths.get(iter.next()));

        return run();
    }

    /**
     * {@inheritDoc}
     * @return the name "jar"
     */
    @Override
    public String name() {
        return "jar";
    }

    /**
     * Creates a jar file with the arguments as currently configured.
     * @return a Result object indicating the outcome of the compilation
     * and the content of any output written to stdout, stderr, or the
     * main stream by the compiler.
     * @throws TaskError if the outcome of the task is not as expected.
     */
    @Override
    public Task.Result run() {
        Manifest m = (manifest == null) ? new Manifest() : manifest;
        Attributes mainAttrs = m.getMainAttributes();
        if (mainClass != null)
            mainAttrs.put(Attributes.Name.MAIN_CLASS, mainClass);
        if (classpath != null)
            mainAttrs.put(Attributes.Name.CLASS_PATH, classpath);

        AbstractTask.StreamOutput sysOut = new AbstractTask.StreamOutput(System.out, System::setOut);
        AbstractTask.StreamOutput sysErr = new AbstractTask.StreamOutput(System.err, System::setErr);

        Map<Task.OutputKind, String> outputMap = new HashMap<>();

        try (OutputStream os = Files.newOutputStream(jar);
                JarOutputStream jos = openJar(os, m)) {
            writeFiles(jos);
            writeFileObjects(jos);
        } catch (IOException e) {
            error("Exception while opening " + jar, e);
        } finally {
            outputMap.put(Task.OutputKind.STDOUT, sysOut.close());
            outputMap.put(Task.OutputKind.STDERR, sysErr.close());
        }
        return checkExit(new Task.Result(toolBox, this, (errors == 0) ? 0 : 1, outputMap));
    }

    private JarOutputStream openJar(OutputStream os, Manifest m) throws IOException {
        if (m == null || m.getMainAttributes().isEmpty() && m.getEntries().isEmpty()) {
            return new JarOutputStream(os);
        } else {
            if (m.getMainAttributes().get(Attributes.Name.MANIFEST_VERSION) == null)
                m.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
            return new JarOutputStream(os, m);
        }
    }

    private void writeFiles(JarOutputStream jos) throws IOException {
            Path base = (baseDir == null) ? currDir : baseDir;
            for (Path path : paths) {
                Files.walkFileTree(base.resolve(path), new SimpleFileVisitor<Path>() {
                    @Override
                    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                        try {
                        String p = base.relativize(file)
                                .normalize()
                                .toString()
                                .replace(File.separatorChar, '/');
                        JarEntry e = new JarEntry(p);
                            jos.putNextEntry(e);
                        try {
                            jos.write(Files.readAllBytes(file));
                        } finally {
                            jos.closeEntry();
                        }
                            return FileVisitResult.CONTINUE;
                        } catch (IOException e) {
                        error("Exception while adding " + file + " to jar file", e);
                            return FileVisitResult.TERMINATE;
                        }
                    }
                });
            }
    }

    private void writeFileObjects(JarOutputStream jos) throws IOException {
        for (FileObject fo : fileObjects) {
            String p = guessPath(fo);
            JarEntry e = new JarEntry(p);
            jos.putNextEntry(e);
            try {
                byte[] buf = new byte[1024];
                try (BufferedInputStream in = new BufferedInputStream(fo.openInputStream())) {
                    int n;
                    while ((n = in.read(buf)) > 0)
                        jos.write(buf, 0, n);
                } catch (IOException ex) {
                    error("Exception while adding " + fo.getName() + " to jar file", ex);
                }
        } finally {
                jos.closeEntry();
        }
        }
    }

    /*
     * A jar: URL is of the form  jar:URL!/<entry>  where URL is a URL for the .jar file itself.
     * In Symbol files (i.e. ct.sym) the underlying entry is prefixed META-INF/sym/<base>.
     */
    private final Pattern jarEntry = Pattern.compile(".*!/(?:META-INF/sym/[^/]+/)?(.*)");

    /*
     * A jrt: URL is of the form  jrt:/modules/<module>/<package>/<file>
     */
    private final Pattern jrtEntry = Pattern.compile("/modules/([^/]+)/(.*)");

    /*
     * A file: URL is of the form  file:/path/to/{modules,patches}/<module>/<package>/<file>
     */
    private final Pattern fileEntry = Pattern.compile(".*/(?:modules|patches)/([^/]+)/(.*)");

    private String guessPath(FileObject fo) {
        URI u = fo.toUri();
        switch (u.getScheme()) {
            case "jar": {
                Matcher m = jarEntry.matcher(u.getSchemeSpecificPart());
                if (m.matches()) {
                    return m.group(1);
                }
                break;
            }
            case "jrt": {
                Matcher m = jrtEntry.matcher(u.getSchemeSpecificPart());
                if (m.matches()) {
                    return m.group(2);
                }
                break;
            }
            case "file": {
                Matcher m = fileEntry.matcher(u.getSchemeSpecificPart());
                if (m.matches()) {
                    return m.group(2);
                }
                break;
            }
        }
        throw new IllegalArgumentException(fo.getName() + "--" + fo.toUri());
    }

    private void error(String message, Throwable t) {
        toolBox.out.println("Error: " + message + ": " + t);
        errors++;
    }

    private int errors;
}
