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

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

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
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.types.FileSet;
import org.apache.tools.ant.types.Resource;
import org.apache.tools.ant.types.ResourceCollection;
import org.apache.tools.ant.types.resources.FileProvider;
import org.apache.tools.ant.types.resources.FileResource;
import org.apache.tools.ant.util.ResourceUtils;

import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;

/**
 * Ant task for sending files to remote machine over ssh/scp.
 *
 * @since Ant 1.6
 */
public class Scp extends SSHBase {

    private static final String[] FROM_ATTRS = {
        "file", "localfile", "remotefile" };

    private static final String[] TO_ATTRS = {
        "todir", "localtodir", "remotetodir", "localtofile", "remotetofile" };

    private String fromUri;
    private String toUri;
    private boolean preserveLastModified = false;
    private boolean compressed = false;
    private List<ResourceCollection> rcs = null;
    private boolean isFromRemote, isToRemote;
    private boolean isSftp = false;
    private Integer fileMode, dirMode;

    /**
     * Sets the file to be transferred.  This can either be a remote
     * file or a local file.  Remote files take the form:
     * <p>
     * <i>user:password@host:/directory/path/file.example</i>
     * </p>
     * Files to transfer can also include a wildcard to include all
     * files in a remote directory.  For example:
     * <p>
     * <i>user:password@host:/directory/path/*</i>
     * </p>
     *
     * @param aFromUri a string representing the file to transfer.
     */
    public void setFile(final String aFromUri) {
        setFromUri(aFromUri);
        this.isFromRemote = isRemoteUri(this.fromUri);
    }

    /**
     * Sets the location where files will be transferred to.
     * This can either be a remote directory or a local directory.
     * Remote directories take the form of:
     * <p>
     * <i>user:password@host:/directory/path/</i>
     * </p>
     * This parameter is required.

     * @param aToUri a string representing the target of the copy.
     */
    public void setTodir(final String aToUri) {
        setToUri(aToUri);
        this.isToRemote = isRemoteUri(this.toUri);
    }

    /**
     * Similar to {@link #setFile setFile} but explicitly states that
     * the file is a local file.  This is the only way to specify a
     * local file with a @ character.
     * @param aFromUri a string representing the source of the copy.
     * @since Ant 1.6.2
     */
    public void setLocalFile(final String aFromUri) {
        setFromUri(aFromUri);
        this.isFromRemote = false;
    }

    /**
     * Similar to {@link #setFile setFile} but explicitly states that
     * the file is a remote file.
     * @param aFromUri a string representing the source of the copy.
     * @since Ant 1.6.2
     */
    public void setRemoteFile(final String aFromUri) {
        validateRemoteUri("remoteFile", aFromUri);
        setFromUri(aFromUri);
        this.isFromRemote = true;
     }

    /**
     * Sets flag to determine if compression should
     * be used for the copy.
     * @param compressed boolean
     * @since Ant 1.9.8
     */
    public void setCompressed(boolean compressed) {
        this.compressed = compressed;
    }

    /**
     * Similar to {@link #setTodir setTodir} but explicitly states
     * that the directory is a local.  This is the only way to specify
     * a local directory with a @ character.
     * @param aToUri a string representing the target of the copy.
     * @since Ant 1.6.2
     */
    public void setLocalTodir(final String aToUri) {
        setToUri(aToUri);
        this.isToRemote = false;
    }

    /**
     * Sets flag to determine if file timestamp
     * is to be preserved during copy.
     * @param yesOrNo boolean
     * @since Ant 1.8.0
     */
    public void setPreservelastmodified(final boolean yesOrNo) {
        this.preserveLastModified = yesOrNo;
    }

    /**
     * Similar to {@link #setTodir setTodir} but explicitly states
     * that the directory is a remote.
     * @param aToUri a string representing the target of the copy.
     * @since Ant 1.6.2
     */
    public void setRemoteTodir(final String aToUri) {
        validateRemoteUri("remoteToDir", aToUri);
        setToUri(aToUri);
        this.isToRemote = true;
    }

    private static void validateRemoteUri(final String type, final String aToUri) {
        if (!isRemoteUri(aToUri)) {
            throw new BuildException(
                "%s '%s' is invalid. The 'remoteToDir' attribute must have syntax like the following: user:password@host:/path - the :password part is optional",
                type, aToUri);
        }
    }

    /**
     * Changes the file name to the given name while receiving it,
     * only useful if receiving a single file.
     * @param aToUri a string representing the target of the copy.
     * @since Ant 1.6.2
     */
    public void setLocalTofile(final String aToUri) {
        setToUri(aToUri);
        this.isToRemote = false;
    }

    /**
     * Changes the file name to the given name while sending it,
     * only useful if sending a single file.
     * @param aToUri a string representing the target of the copy.
     * @since Ant 1.6.2
     */
    public void setRemoteTofile(final String aToUri) {
        validateRemoteUri("remoteToFile", aToUri);
        setToUri(aToUri);
        this.isToRemote = true;
    }

    /**
     * Setting this to true to use sftp protocol.
     *
     * @param yesOrNo if true sftp protocol will be used.
     */
    public void setSftp(final boolean yesOrNo) {
        isSftp = yesOrNo;
    }

    /**
     * Set the file mode, defaults to "644".
     * @param fileMode String
     * @since Ant 1.9.5
     */
    public void setFileMode(String fileMode) {
        this.fileMode = Integer.parseInt(fileMode, 8);
    }

    /**
     * Set the dir mode, defaults to "755".
     * @param dirMode String
     * @since Ant 1.9.5
     */
    public void setDirMode(String dirMode) {
        this.dirMode = Integer.parseInt(dirMode, 8);
    }

    /**
     * Adds a FileSet transfer to remote host.  NOTE: Either
     * addFileSet() or setFile() are required.  But, not both.
     *
     * @param set FileSet to send to remote host.
     */
    public void addFileset(FileSet set) {
        add(set);
    }

    /**
     * Adds a ResourceCollection of local files to transfer to remote host.
     * @param res ResourceCollection to send to remote host.
     * @since Ant 1.9.7
     */
    public void add(ResourceCollection res) {
        if (rcs == null) {
            rcs = new LinkedList<>();
        }
        rcs.add(res);
    }

    /**
     * Initialize this task.
     * @throws BuildException on error
     */
    @Override
    public void init() throws BuildException {
        super.init();
        this.toUri = null;
        this.fromUri = null;
        this.rcs = null;
    }

    /**
     * Execute this task.
     * @throws BuildException on error
     */
    @Override
    public void execute() throws BuildException {
        if (toUri == null) {
            throw exactlyOne(TO_ATTRS);
        }
        if (fromUri == null && rcs == null) {
            throw exactlyOne(FROM_ATTRS, "one or more nested filesets");
        }
        try {
            if (isFromRemote && !isToRemote) {
                download(fromUri, toUri);
            } else if (!isFromRemote && isToRemote) {
                if (rcs != null) {
                    upload(rcs, toUri);
                } else {
                    upload(fromUri, toUri);
                }
            } else if (isFromRemote && isToRemote) { //NOSONAR
                throw new BuildException(
                    "Copying from a remote server to a remote server is not supported.");
            } else {
                throw new BuildException(
                    "'todir' and 'file' attributes must have syntax like the following: user:password@host:/path");
            }
        } catch (final Exception e) {
            if (getFailonerror()) {
                if (e instanceof BuildException) {
                    final BuildException be = (BuildException) e;
                    if (be.getLocation() == null) {
                        be.setLocation(getLocation());
                    }
                    throw be;
                }
                throw new BuildException(e);
            }
            log("Caught exception: " + e.getMessage(), Project.MSG_ERR);
        }
    }

    private void download(final String fromSshUri, final String toPath)
        throws JSchException, IOException {
        final String file = parseUri(fromSshUri);

        Session session = null;
        try {
            session = openSession();
            ScpFromMessage message = null;
            if (!isSftp) {
                message =
                    new ScpFromMessage(getVerbose(), session, file,
                                       getProject().resolveFile(toPath),
                                       fromSshUri.endsWith("*"),
                                       preserveLastModified,
                                       compressed);
            } else {
                message =
                    new ScpFromMessageBySftp(getVerbose(), session, file,
                                             getProject().resolveFile(toPath),
                                             fromSshUri.endsWith("*"),
                                             preserveLastModified);
            }
            log("Receiving file: " + file);
            message.setLogListener(this);
            message.execute();
        } finally {
            if (session != null) {
                session.disconnect();
            }
        }
    }

    private void upload(final List<ResourceCollection> rcs, final String toSshUri)
        throws IOException, JSchException {
        final String file = parseUri(toSshUri);

        Session session = null;
        try {
            final List<Directory> list = new ArrayList<>(rcs.size());
            for (ResourceCollection rc : rcs) {
                if (rc instanceof FileSet && rc.isFilesystemOnly()) {
                    FileSet fs = (FileSet) rc;
                    final Directory d = createDirectory(fs);
                    if (d != null) {
                        list.add(d);
                    }
                } else {
                    List<Directory> ds = createDirectoryCollection(rc);
                    if (ds != null) {
                        list.addAll(ds);
                    }
                }
            }
            if (!list.isEmpty()) {
                session = openSession();
                ScpToMessage message;
                if (!isSftp) {
                    message = new ScpToMessage(getVerbose(), compressed, session,
                                               list, file, preserveLastModified);
                } else {
                    message = new ScpToMessageBySftp(getVerbose(), session,
                                                     list, file, preserveLastModified);
                }
                message.setLogListener(this);
                if (fileMode != null) {
                    message.setFileMode(fileMode);
                }
                if (dirMode != null) {
                    message.setDirMode(dirMode);
                }
                message.execute();
            }
        } finally {
            if (session != null) {
                session.disconnect();
            }
        }
    }

    private void upload(final String fromPath, final String toSshUri)
        throws IOException, JSchException {
        final String file = parseUri(toSshUri);

        Session session = null;
        try {
            session = openSession();
            ScpToMessage message = null;
            if (!isSftp) {
                message =
                    new ScpToMessage(getVerbose(), compressed, session,
                                     getProject().resolveFile(fromPath), file,
                                     preserveLastModified);
            } else {
                message =
                    new ScpToMessageBySftp(getVerbose(), session,
                                           getProject().resolveFile(fromPath),
                                           file, preserveLastModified);
            }
            message.setLogListener(this);
            if (fileMode != null) {
                message.setFileMode(fileMode);
            }
            if (dirMode != null) {
                message.setDirMode(dirMode);
            }
            message.execute();
        } finally {
            if (session != null) {
                session.disconnect();
            }
        }
    }

    private String parseUri(final String uri) {

        int indexOfAt = uri.indexOf('@');
        final int indexOfColon = uri.indexOf(':');

        if (indexOfColon > -1 && indexOfColon < indexOfAt) {
            // user:password@host:/path notation
            // everything upto the last @ before the last : is considered
            // password. (so if the path contains an @ and a : it will not work)
            int indexOfCurrentAt = indexOfAt;
            final int indexOfLastColon = uri.lastIndexOf(':');
            while (indexOfCurrentAt > -1 && indexOfCurrentAt < indexOfLastColon) {
                indexOfAt = indexOfCurrentAt;
                indexOfCurrentAt = uri.indexOf('@', indexOfCurrentAt + 1);
            }
            setUsername(uri.substring(0, indexOfColon));
            setPassword(uri.substring(indexOfColon + 1, indexOfAt));
        } else if (indexOfAt > -1) {
            // no password, will require keyfile
            setUsername(uri.substring(0, indexOfAt));
        } else {
            throw new BuildException("no username was given.  Can't authenticate.");
        }

        // For IPv6 addresses, the host part may contain more than one ":",
        // look for the last occurrence
        final int indexOfPath = uri.lastIndexOf(':');
        if (indexOfPath <= indexOfAt) {
            throw new BuildException("no remote path in %s", uri);
        }

        setHost(uri.substring(indexOfAt + 1, indexOfPath));
        
        loadSshConfig();
        
        if (getUserInfo().getPassword() == null
            && getUserInfo().getKeyfile() == null) {
            throw new BuildException(
                "neither password nor keyfile for user %s has been given.  Can't authenticate.",
                getUserInfo().getName());
        }
        
        String remotePath = uri.substring(indexOfPath + 1);
        if (remotePath.isEmpty()) {
            remotePath = ".";
        }
        return remotePath;
    }

    private static boolean isRemoteUri(final String uri) {
        return uri.contains("@");
    }

    private Directory createDirectory(final FileSet set) {
        final DirectoryScanner scanner = set.getDirectoryScanner(getProject());
        final String[] files = scanner.getIncludedFiles();
        if (files.length == 0) {
            // skip
            return null;
        }
        Directory root = new Directory(scanner.getBasedir());
        Stream.of(files).map(Directory::getPath).forEach(path -> {
            Directory current = root;
            File currentParent = scanner.getBasedir();
            for (String element : path) {
                final File file = new File(currentParent, element);
                if (file.isDirectory()) {
                    current.addDirectory(new Directory(file));
                    current = current.getChild(file);
                    currentParent = current.getDirectory();
                } else if (file.isFile()) {
                    current.addFile(file);
                }
            }
        });
        return root;
    }

    private List<Directory> createDirectoryCollection(final ResourceCollection rc) {
        // not a fileset or contains non-file resources
        if (!rc.isFilesystemOnly()) {
            throw new BuildException("Only FileSystem resources are supported.");
        }

        List<Directory> ds = new ArrayList<>();
        for (Resource r : rc) {
            if (!r.isExists()) {
                throw new BuildException("Could not find resource %s to scp.",
                        r.toLongString());
            }

            FileProvider fp = r.as(FileProvider.class);
            if (fp == null) {
                throw new BuildException("Resource %s is not a file.",
                        r.toLongString());
            }

            FileResource fr = ResourceUtils.asFileResource(fp);
            File baseDir = fr.getBaseDir();
            if (baseDir == null) {
                throw new BuildException("basedir for resource %s is undefined.",
                        r.toLongString());
            }

            // if the basedir is set, the name will be relative to that
            String name = r.getName();
            Directory root = new Directory(baseDir);
            Directory current = root;
            File currentParent = baseDir;
            for (String element : Directory.getPath(name)) {
                final File file = new File(currentParent, element);
                if (file.isDirectory()) {
                    current.addDirectory(new Directory(file));
                    current = current.getChild(file);
                    currentParent = current.getDirectory();
                } else if (file.isFile()) {
                    current.addFile(file);
                }
            }
            ds.add(root);
        }
        return ds;
    }

    private void setFromUri(final String fromUri) {
        if (this.fromUri != null) {
            throw exactlyOne(FROM_ATTRS);
        }
        this.fromUri = fromUri;
    }

    private void setToUri(final String toUri) {
        if (this.toUri != null) {
            throw exactlyOne(TO_ATTRS);
        }
        this.toUri = toUri;
    }

    private BuildException exactlyOne(final String[] attrs) {
        return exactlyOne(attrs, null);
    }

    private BuildException exactlyOne(final String[] attrs, final String alt) {
        return new BuildException("Exactly one of [%s]%s is required",
                String.join("|", attrs),
            alt == null ? "" : " or " + alt);
    }
}
