/*
 *  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
 *
 *      https://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.
 *
 */

/*
 * Since the initial version of this file was developed on the clock on
 * an NSF grant I should say the following boilerplate:
 *
 * This material is based upon work supported by the National Science
 * Foundation under Grant No. EIA-0196404. Any opinions, findings, and
 * conclusions or recommendations expressed in this material are those
 * of the author and do not necessarily reflect the views of the
 * National Science Foundation.
 */

package org.apache.tools.ant.taskdefs.optional.unix;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.stream.Stream;

import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.DirectoryScanner;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.dispatch.DispatchTask;
import org.apache.tools.ant.dispatch.DispatchUtils;
import org.apache.tools.ant.taskdefs.LogOutputStream;
import org.apache.tools.ant.types.FileSet;

/**
 * Creates, Deletes, Records and Restores Symlinks.
 *
 * <p>This task performs several related operations. In the most trivial
 * and default usage, it creates a link specified in the link attribute to
 * a resource specified in the resource attribute. The second usage of this
 * task is to traverse a directory structure specified by a fileset,
 * and write a properties file in each included directory describing the
 * links found in that directory. The third usage is to traverse a
 * directory structure specified by a fileset, looking for properties files
 * (also specified as included in the fileset) and recreate the links
 * that have been previously recorded for each directory. Finally, it can be
 * used to remove a symlink without deleting the associated resource.</p>
 *
 * <p>Usage examples:</p>
 *
 * <p>Make a link named &quot;foo&quot; to a resource named
 * &quot;bar.foo&quot; in subdir:</p>
 * <pre>
 * &lt;symlink link=&quot;${dir.top}/foo&quot; resource=&quot;${dir.top}/subdir/bar.foo&quot;/&gt;
 * </pre>
 *
 * <p>Record all links in subdir and its descendants in files named
 * &quot;dir.links&quot;:</p>
 * <pre>
 * &lt;symlink action=&quot;record&quot; linkfilename=&quot;dir.links&quot;&gt;
 *    &lt;fileset dir=&quot;${dir.top}&quot; includes=&quot;subdir&#47;**&quot; /&gt;
 * &lt;/symlink&gt;
 * </pre>
 *
 * <p>Recreate the links recorded in the previous example:</p>
 * <pre>
 * &lt;symlink action=&quot;recreate&quot;&gt;
 *    &lt;fileset dir=&quot;${dir.top}&quot; includes=&quot;subdir&#47;**&#47;dir.links&quot; /&gt;
 * &lt;/symlink&gt;
 * </pre>
 *
 * <p>Delete a link named &quot;foo&quot; to a resource named
 * &quot;bar.foo&quot; in subdir:</p>
 * <pre>
 * &lt;symlink action=&quot;delete&quot; link=&quot;${dir.top}/foo&quot;/&gt;
 * </pre>
 *
 * <p><strong>Note:</strong> Starting Ant version 1.10.2, this task relies on the symbolic link support
 * introduced in Java 7 through the {@link Files} APIs.
 */
public class Symlink extends DispatchTask {

    private String resource;
    private String link;
    private List<FileSet> fileSets = new ArrayList<>();
    private String linkFileName;
    private boolean overwrite;
    private boolean failonerror;
    private boolean executing = false;

    /**
     * Initialize the task.
     * @throws BuildException on error.
     */
    @Override
    public void init() throws BuildException {
        super.init();
        setDefaults();
    }

    /**
     * The standard method for executing any task.
     * @throws BuildException on error.
     */
    @Override
    public synchronized void execute() throws BuildException {
        if (executing) {
            throw new BuildException(
                "Infinite recursion detected in Symlink.execute()");
        }
        try {
            executing = true;
            DispatchUtils.execute(this);
        } finally {
            executing = false;
        }
    }

    /**
     * Create a symlink.
     * @throws BuildException on error.
     * @since Ant 1.7
     */
    public void single() throws BuildException {
        try {
            if (resource == null) {
                handleError("Must define the resource to symlink to!");
                return;
            }
            if (link == null) {
                handleError("Must define the link name for symlink!");
                return;
            }
            doLink(resource, link);
        } finally {
            setDefaults();
        }
    }

    /**
     * Delete a symlink.
     * @throws BuildException on error.
     * @since Ant 1.7
     */
    public void delete() throws BuildException {
        try {
            if (link == null) {
                handleError("Must define the link name for symlink!");
                return;
            }
            final Path linkPath = Paths.get(link);
            if (!Files.isSymbolicLink(linkPath)) {
                log("Skipping deletion of " + linkPath + " since it's not a symlink", Project.MSG_VERBOSE);
                // just ignore and silently return (this is consistent
                // with the current, 1.9.x versions, of Ant)
                return;

            }
            log("Removing symlink: " + link);
            deleteSymLink(linkPath);
        } catch (IOException ioe) {
            handleError(ioe.getMessage());
        } finally {
            setDefaults();
        }
    }

    /**
     * Restore symlinks.
     * @throws BuildException on error.
     * @since Ant 1.7
     */
    public void recreate() throws BuildException {
        try {
            if (fileSets.isEmpty()) {
                handleError(
                        "File set identifying link file(s) required for action recreate");
                return;
            }
            final Properties links = loadLinks(fileSets);
            for (final String lnk : links.stringPropertyNames()) {
                final String res = links.getProperty(lnk);
                try {
                    if (Files.isSymbolicLink(Paths.get(lnk)) &&
                            new File(lnk).getCanonicalPath().equals(new File(res).getCanonicalPath())) {
                        // it's already a symlink and the symlink target is the same
                        // as the target noted in the properties file. So there's no
                        // need to recreate it
                        log("not recreating " + lnk + " as it points to the correct target already",
                            Project.MSG_DEBUG);
                        continue;
                    }
                } catch (IOException e) {
                    final String errMessage = "Failed to check if path " + lnk + " is a symbolic link, linking to " + res;
                    if (failonerror) {
                        throw new BuildException(errMessage, e);
                    }
                    // log and continue
                    log(errMessage, Project.MSG_INFO);
                    continue;
                }
                // create the link
                this.doLink(res, lnk);
            }
        } finally {
            setDefaults();
        }
    }

    /**
     * Record symlinks.
     * @throws BuildException on error.
     * @since Ant 1.7
     */
    public void record() throws BuildException {
        try {
            if (fileSets.isEmpty()) {
                handleError("Fileset identifying links to record required");
                return;
            }
            if (linkFileName == null) {
                handleError("Name of file to record links in required");
                return;
            }
            // create a map to group them by parent directory:
            Map<File, List<File>> byDir = new HashMap<>();

            // get an Iterator of file objects representing links (canonical):
            findLinks(fileSets).forEach(lnk -> byDir
                .computeIfAbsent(lnk.getParentFile(), k -> new ArrayList<>())
                .add(lnk));

            // write a Properties file in each directory:
            byDir.forEach((dir, linksInDir) -> {
                Properties linksToStore = new Properties();

                // fill up a Properties object with link and resource names:
                for (File lnk : linksInDir) {
                    try {
                        linksToStore.put(lnk.getName(), lnk.getCanonicalPath());
                    } catch (IOException ioe) {
                        handleError("Couldn't get canonical name of parent link");
                    }
                }
                writePropertyFile(linksToStore, dir);
            });
        } finally {
            setDefaults();
        }
    }

    /**
     * Return all variables to their default state for the next invocation.
     * @since Ant 1.7
     */
    private void setDefaults() {
        resource = null;
        link = null;
        linkFileName = null;
        failonerror = true;   // default behavior is to fail on an error
        overwrite = false;    // default behavior is to not overwrite
        setAction("single");      // default behavior is make a single link
        fileSets.clear();
    }

    /**
     * Set overwrite mode. If set to false (default)
     * the task will not overwrite existing links, and may stop the build
     * if a link already exists depending on the setting of failonerror.
     *
     * @param owrite If true overwrite existing links.
     */
    public void setOverwrite(boolean owrite) {
        this.overwrite = owrite;
    }

    /**
     * Set failonerror mode. If set to true (default) the entire build fails
     * upon error; otherwise the error is logged and the build will continue.
     *
     * @param foe    If true throw BuildException on error, else log it.
     */
    public void setFailOnError(boolean foe) {
        this.failonerror = foe;
    }

    /**
     * Set the action to be performed.  May be &quot;single&quot;,
     * &quot;delete&quot;, &quot;recreate&quot; or &quot;record&quot;.
     *
     * @param action    The action to perform.
     */
    @Override
    public void setAction(String action) {
        super.setAction(action);
    }

    /**
     * Set the name of the link. Used when action = &quot;single&quot;.
     *
     * @param lnk     The name for the link.
     */
    public void setLink(String lnk) {
        this.link = lnk;
    }

    /**
     * Set the name of the resource to which a link should be created.
     * Used when action = &quot;single&quot;.
     *
     * @param src      The resource to be linked.
     */
    public void setResource(String src) {
        this.resource = src;
    }

    /**
     * Set the name of the file to which links will be written.
     * Used when action = &quot;record&quot;.
     *
     * @param lf      The name of the file to write links to.
     */
    public void setLinkfilename(String lf) {
        this.linkFileName = lf;
    }

    /**
     * Add a fileset to this task.
     *
     * @param set      The fileset to add.
     */
    public void addFileset(FileSet set) {
        fileSets.add(set);
    }

    /**
     * Delete a symlink (without deleting the associated resource).
     *
     * <p>This is a convenience method that simply invokes {@link #deleteSymlink(File)}</p>
     *
     * @param path A string containing the path of the symlink to delete.
     * @throws IOException If the deletion attempt fails
     *
     * @deprecated use {@link Files#delete(Path)} instead
     */
    @Deprecated
    public static void deleteSymlink(final String path)
            throws IOException {
        deleteSymlink(Paths.get(path).toFile());
    }

    /**
     * Delete a symlink (without deleting the associated resource).
     *
     * <p>This is a utility method that removes a symlink without removing
     * the resource that the symlink points to. If it is accidentally invoked
     * on a real file, the real file will not be harmed and instead this method
     * returns silently.</p>
     *
     * <p>Since Ant 1.10.2 this method relies on the {@link Files#isSymbolicLink(Path)}
     * and {@link Files#delete(Path)} to check and delete the symlink
     * </p>
     *
     * @param linkfil A <code>File</code> object of the symlink to delete. Cannot be null.
     * @throws IOException If the attempt to delete runs into exception
     *
     * @deprecated use {@link Files#delete(Path)} instead
     */
    @Deprecated
    public static void deleteSymlink(final File linkfil)
            throws IOException {
        if (!Files.isSymbolicLink(linkfil.toPath())) {
            return;
        }
        deleteSymLink(linkfil.toPath());
    }

    /**
     * Write a properties file. This method uses <code>Properties.store</code>
     * and thus may throw exceptions that occur while writing the file.
     *
     * @param properties     The properties object to be written.
     * @param dir            The directory for which we are writing the links.
     * @throws BuildException if the property file could not be written
     */
    private void writePropertyFile(Properties properties, File dir)
        throws BuildException {
        try (BufferedOutputStream bos = new BufferedOutputStream(
            Files.newOutputStream(new File(dir, linkFileName).toPath()))) {
            properties.store(bos, "Symlinks from " + dir);
        } catch (IOException ioe) {
            throw new BuildException(ioe, getLocation());
        }
    }

    /**
     * Handle errors based on the setting of failonerror.
     *
     * @param msg    The message to log, or include in the
     *                  <code>BuildException</code>.
     * @throws BuildException with the message if failonerror=true
     */
    private void handleError(String msg) {
        if (failonerror) {
            throw new BuildException(msg);
        }
        log(msg);
    }

    /**
     * Conduct the actual construction of a link.
     *
     * @param res The path of the resource we are linking to.
     * @param lnk The name of the link we wish to make.
     * @throws BuildException when things go wrong
     */
    private void doLink(String res, String lnk) throws BuildException {
        final Path link = Paths.get(lnk);
        final Path target = Paths.get(res);
        final boolean alreadyExists = Files.exists(link, LinkOption.NOFOLLOW_LINKS);
        if (!alreadyExists) {
            // if the path (at which the link is expected to be created) isn't already present
            // then we just go ahead and attempt to symlink
            try {
                log("creating symlink " + link + " -> " + target, Project.MSG_DEBUG);
                Files.createSymbolicLink(link, target);
            } catch (IOException e) {
                if (failonerror) {
                    throw new BuildException("Failed to create symlink " + lnk + " to target " + res, e);
                }
                log("Unable to create symlink " + lnk + " to target " + res, e, Project.MSG_INFO);
            }
            return;
        }
        // file already exists, see if we are allowed to overwrite
        if (!overwrite) {
            log("Skipping symlink creation, since file at " + lnk + " already exists and overwrite is set to false", Project.MSG_INFO);
            return;
        }
        // we have been asked to overwrite, so we now do the necessary steps

        // initiate a deletion of the existing file
        final boolean existingFileDeleted = link.toFile().delete();
        if (!existingFileDeleted) {
            handleError("Deletion of file at " + lnk + " failed, while trying to overwrite it with a symlink");
            return;
        }
        try {
            log("creating symlink " + link + " -> " + target + " after removing original",
                Project.MSG_DEBUG);
            Files.createSymbolicLink(link, target);
        } catch (IOException e) {
            if (failonerror) {
                throw new BuildException("Failed to create symlink " + lnk + " to target " + res, e);
            }
            log("Unable to create symlink " + lnk + " to target " + res, e, Project.MSG_INFO);
        }
    }

    /**
     * Find all the links in all supplied filesets.
     *
     * <p>This method is invoked when the action attribute is
     * &quot;record&quot;. This means that filesets are interpreted
     * as the directories in which links may be found.</p>
     *
     * @param fileSets The filesets specified by the user.
     * @return A Set of <code>File</code> objects containing the
     * links (with canonical parent directories).
     */
    private Set<File> findLinks(List<FileSet> fileSets) {
        final Set<File> result = new HashSet<>();
        for (FileSet fs : fileSets) {
            DirectoryScanner ds = fs.getDirectoryScanner(getProject());

            File dir = fs.getDir(getProject());

            Stream.of(ds.getIncludedFiles(), ds.getIncludedDirectories())
                    .flatMap(Stream::of).forEach(path -> {
                        try {
                            final File f = new File(dir, path);
                            final File pf = f.getParentFile();
                            final String name = f.getName();
                            // we use the canonical path of the parent dir in which the (potential)
                            // link resides
                            final File parentDirCanonicalizedFile = new File(pf.getCanonicalPath(), name);
                            if (Files.isSymbolicLink(parentDirCanonicalizedFile.toPath())) {
                                result.add(parentDirCanonicalizedFile);
                            }
                        } catch (IOException e) {
                            handleError("IOException: " + path + " omitted");
                        }
            });
        }
        return result;
    }

    /**
     * Load links from properties files included in one or more FileSets.
     *
     * <p>This method is only invoked when the action attribute is set to
     * &quot;recreate&quot;. The filesets passed in are assumed to specify the
     * names of the property files with the link information and the
     * subdirectories in which to look for them.</p>
     *
     * @param fileSets    The <code>FileSet</code>s for this task.
     * @return            The links to be made.
     */
    private Properties loadLinks(List<FileSet> fileSets) {
        Properties finalList = new Properties();
        // loop through the supplied file sets:
        for (FileSet fs : fileSets) {
            DirectoryScanner ds = new DirectoryScanner();
            fs.setupDirectoryScanner(ds, getProject());
            ds.setFollowSymlinks(false);
            ds.scan();
            File dir = fs.getDir(getProject());

            // load included files as properties files:
            for (String name : ds.getIncludedFiles()) {
                File inc = new File(dir, name);
                File pf = inc.getParentFile();
                Properties lnks = new Properties();
                try (InputStream is = new BufferedInputStream(
                    Files.newInputStream(inc.toPath()))) {
                    lnks.load(is);
                    pf = pf.getCanonicalFile();
                } catch (FileNotFoundException fnfe) {
                    handleError("Unable to find " + name + "; skipping it.");
                    continue;
                } catch (IOException ioe) {
                    handleError("Unable to open " + name
                        + " or its parent dir; skipping it.");
                    continue;
                }
                try {
                    lnks.store(new PrintStream(
                        new LogOutputStream(this, Project.MSG_INFO)),
                        "listing properties");
                } catch (IOException ex) {
                    log("failed to log unshortened properties");
                    lnks.list(new PrintStream(
                        new LogOutputStream(this, Project.MSG_INFO)));
                }
                // Write the contents to our master list of links
                // This method assumes that all links are defined in
                // terms of absolute paths, or paths relative to the
                // working directory:
                for (String key : lnks.stringPropertyNames()) {
                    finalList.put(new File(pf, key).getAbsolutePath(),
                        lnks.getProperty(key));
                }
            }
        }
        return finalList;
    }

    private static void deleteSymLink(final Path path) throws IOException {
        // Implementation note: We intentionally use java.io.File#delete() instead of
        // java.nio.file.Files#delete(Path) since it turns out that the latter doesn't
        // update/clear the "canonical file paths cache" maintained by the JRE FileSystemProvider.
        // Not clearing/updating that cache results in this deleted (and later recreated) symlink
        // to point to a wrong/outdated target for a few seconds (30 seconds is the time the JRE
        // maintains the cache entries for). All this is implementation detail of the JRE and
        // is a JRE bug https://mail.openjdk.java.net/pipermail/core-libs-dev/2017-December/050540.html,
        // but given that it affects our tests (SymlinkTest#testRecreate consistently fails
        // on MacOS/Unix) as well as the Symlink task, it makes sense to use this API instead of
        // the Files#delete(Path) API
        final boolean deleted = path.toFile().delete();
        if (!deleted) {
            throw new IOException("Could not delete symlink at " + path);
        }
    }
}
