/**
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License. See License.txt in the project root for
 * license information.
 */

package com.microsoft.azure.management.samples;

import com.jcraft.jsch.Channel;
import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.ChannelShell;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.KeyPair;
import com.jcraft.jsch.Session;
import expect4j.Closure;
import expect4j.Expect4j;
import expect4j.ExpectState;
import expect4j.matches.Match;
import expect4j.matches.RegExpMatch;
import org.apache.oro.text.regex.MalformedPatternException;

import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Hashtable;
import java.util.List;

/**
 * Utility class to run commands on Linux VM via SSH.
 */
public final class SSHShell {
    private final Session session;
    private final ChannelShell channel;
    private final Expect4j expect;
    private final StringBuilder shellBuffer = new StringBuilder();
    private List<Match> linuxPromptMatches =  new ArrayList<>();

    /**
     * Creates SSHShell.
     *
     * @param host the host name
     * @param port the ssh port
     * @param userName the ssh user name
     * @param password the ssh password
     * @return the shell
     * @throws JSchException
     * @throws IOException
     */
    private SSHShell(String host, int port, String userName, String password)
            throws JSchException, IOException {
        Closure expectClosure = getExpectClosure();
        for (String linuxPromptPattern : new String[]{"\\>", "#", "~#", "~\\$"}) {
            try {
                Match match = new RegExpMatch(linuxPromptPattern, expectClosure);
                linuxPromptMatches.add(match);
            } catch (MalformedPatternException malformedEx) {
                throw new RuntimeException(malformedEx);
            }
        }
        JSch jsch = new JSch();
        this.session = jsch.getSession(userName, host, port);
        session.setPassword(password);
        Hashtable<String, String> config = new Hashtable<>();
        config.put("StrictHostKeyChecking", "no");
        session.setConfig(config);
        session.connect(60000);
        this.channel = (ChannelShell) session.openChannel("shell");
        this.expect = new Expect4j(channel.getInputStream(), channel.getOutputStream());
        channel.connect();
    }

    /**
     * Creates SSHShell.
     *
     * @param host the host name
     * @param port the ssh port
     * @param userName the ssh user name
     * @param sshPrivateKey the ssh password
     * @return the shell
     * @throws JSchException
     * @throws IOException
     */
    private SSHShell(String host, int port, String userName, byte[] sshPrivateKey)
        throws JSchException, IOException {
        Closure expectClosure = getExpectClosure();
        for (String linuxPromptPattern : new String[]{"\\>", "#", "~#", "~\\$"}) {
            try {
                Match match = new RegExpMatch(linuxPromptPattern, expectClosure);
                linuxPromptMatches.add(match);
            } catch (MalformedPatternException malformedEx) {
                throw new RuntimeException(malformedEx);
            }
        }
        JSch jsch = new JSch();
        jsch.setKnownHosts(System.getProperty("user.home") + "/.ssh/known_hosts");
        jsch.addIdentity(host, sshPrivateKey, (byte[]) null, (byte[]) null);
        this.session = jsch.getSession(userName, host, port);
        this.session.setConfig("StrictHostKeyChecking", "no");
        this.session.setConfig("PreferredAuthentications", "publickey,keyboard-interactive,password");
        session.connect(60000);
        this.channel = (ChannelShell) session.openChannel("shell");
        this.expect = new Expect4j(channel.getInputStream(), channel.getOutputStream());
        channel.connect();
    }

    /**
     * Opens a SSH shell.
     *
     * @param host the host name
     * @param port the ssh port
     * @param userName the ssh user name
     * @param password the ssh password
     * @return the shell
     * @throws JSchException exception thrown
     * @throws IOException IO exception thrown
     */
    public static SSHShell open(String host, int port, String userName, String password)
            throws JSchException, IOException {
        return new SSHShell(host, port, userName, password);
    }

    /**
     * Opens a SSH shell.
     *
     * @param host the host name
     * @param port the ssh port
     * @param userName the ssh user name
     * @param sshPrivateKey the ssh private key
     * @return the shell
     * @throws JSchException exception thrown
     * @throws IOException IO exception thrown
     */
    public static SSHShell open(String host, int port, String userName, byte[] sshPrivateKey)
        throws JSchException, IOException {
        return new SSHShell(host, port, userName, sshPrivateKey);
    }

    /**
     * Runs a given list of commands in the shell.
     *
     * @param commands the commands
     * @return the result
     * @throws Exception exception thrown
     */
    public String runCommands(List<String> commands) throws Exception {
        String output = null;
        try {
            for (String command : commands) {
                expect.expect(this.linuxPromptMatches);
                expect.send(command);
                expect.send("\r");
                expect.expect(this.linuxPromptMatches);
            }
            output = shellBuffer.toString();
        } finally {
            shellBuffer.setLength(0);
        }
        return output;
    }

    /**
     * Executes a command on the remote host.
     *
     * @param command the command to be executed
     * @param getExitStatus return the exit status captured in the stdout
     * @param withErr capture the stderr as part of the output
     * @return the content of the remote output from executing the command
     * @throws Exception exception thrown
     */
    public String executeCommand(String command, Boolean getExitStatus, Boolean withErr) throws Exception {
        String result = "";
        String resultErr = "";

        Channel channel = this.session.openChannel("exec");
        ((ChannelExec) channel).setCommand(command);
        InputStream commandOutput = channel.getInputStream();
        InputStream commandErr = ((ChannelExec) channel).getErrStream();
        channel.connect();
        byte[] tmp  = new byte[4096];
        while (true) {
            while (commandOutput.available() > 0) {
                int i = commandOutput.read(tmp, 0, 4096);
                if (i < 0) {
                    break;
                }
                result += new String(tmp, 0, i);
            }
            while (commandErr.available() > 0) {
                int i = commandErr.read(tmp, 0, 4096);
                if (i < 0) {
                    break;
                }
                resultErr += new String(tmp, 0, i);
            }
            if (channel.isClosed()) {
                if (commandOutput.available() > 0) {
                    continue;
                }
                if (getExitStatus) {
                    result += "exit-status: " + channel.getExitStatus();
                    if (withErr) {
                        result += "\n With error:\n" + resultErr;
                    }
                }
                break;
            }
            try {
                Thread.sleep(100);
            } catch (Exception ee) { }
        }
        channel.disconnect();

        return result;
    }

    /**
     * Downloads the content of a file from the remote host as a String.
     *
     * @param fileName the name of the file for which the content will be downloaded
     * @param fromPath the path of the file for which the content will be downloaded
     * @param isUserHomeBased true if the path of the file is relative to the user's home directory
     * @return the content of the file
     * @throws Exception exception thrown
     */
    public String download(String fileName, String fromPath, boolean isUserHomeBased) throws Exception {
        ChannelSftp channel = (ChannelSftp) this.session.openChannel("sftp");
        channel.connect();
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        BufferedOutputStream buff = new BufferedOutputStream(outputStream);
        String absolutePath = isUserHomeBased ? channel.getHome() + "/" + fromPath : fromPath;
        channel.cd(absolutePath);
        channel.get(fileName, buff);

        channel.disconnect();

        return outputStream.toString();
    }

    /**
     * Creates a new file on the remote host using the input content.
     *
     * @param from the byte array content to be uploaded
     * @param fileName the name of the file for which the content will be saved into
     * @param toPath the path of the file for which the content will be saved into
     * @param isUserHomeBased true if the path of the file is relative to the user's home directory
     * @param filePerm file permissions to be set
     * @throws Exception exception thrown
     */
    public void upload(InputStream from, String fileName, String toPath, boolean isUserHomeBased, String filePerm) throws Exception {
        ChannelSftp channel = (ChannelSftp) this.session.openChannel("sftp");
        channel.connect();
        String absolutePath = isUserHomeBased ? channel.getHome() + "/" + toPath : toPath;

        String path = "";
        for (String dir : absolutePath.split("/")) {
            path = path + "/" + dir;
            try {
                channel.mkdir(path);
            } catch (Exception ee) {
            }
        }
        channel.cd(absolutePath);
        channel.put(from, fileName);
        if (filePerm != null) {
            channel.chmod(Integer.parseInt(filePerm), absolutePath + "/" + fileName);
        }

        channel.disconnect();
    }

    /**
     * Closes shell.
     */
    public void close() {
        if (expect != null) {
            expect.close();
        }
        if (channel != null) {
            channel.disconnect();
        }
        if (session != null) {
            session.disconnect();
        }
    }

    private Closure getExpectClosure() {
        return new Closure() {
            public void run(ExpectState expectState) throws Exception {
                String outputBuffer = expectState.getBuffer();
                System.out.println(outputBuffer);
                shellBuffer.append(outputBuffer);
                expectState.exp_continue();
            }
        };
    }

    /**
     * Automatically generate SSH keys.
     * @param passPhrase the byte array content to be uploaded
     * @param comment the name of the file for which the content will be saved into
     * @return SSH public and private key
     * @throws Exception exception thrown
     */
    public static SshPublicPrivateKey generateSSHKeys(String passPhrase, String comment) throws Exception {
        JSch jsch = new JSch();
        KeyPair keyPair = KeyPair.genKeyPair(jsch, KeyPair.RSA);
        ByteArrayOutputStream privateKeyBuff = new ByteArrayOutputStream(2048);
        ByteArrayOutputStream publicKeyBuff = new ByteArrayOutputStream(2048);

        keyPair.writePublicKey(publicKeyBuff, (comment != null) ? comment : "SSHCerts");

        if (passPhrase == null  || passPhrase.isEmpty()) {
            keyPair.writePrivateKey(privateKeyBuff);
        } else {
            keyPair.writePrivateKey(privateKeyBuff, passPhrase.getBytes());
        }

        return new SshPublicPrivateKey(privateKeyBuff.toString(), publicKeyBuff.toString());
    }

    /**
     * Internal class to retain the generate SSH keys.
     */
    public static class SshPublicPrivateKey {
        private String sshPublicKey;
        private String sshPrivateKey;

        /**
         * Constructor.
         * @param sshPrivateKey SSH private key
         * @param sshPublicKey SSH public key
         */
        public SshPublicPrivateKey(String sshPrivateKey, String sshPublicKey) {
            this.sshPrivateKey = sshPrivateKey;
            this.sshPublicKey = sshPublicKey;
        }

        /**
         * Get SSH public key.
         * @return public key
         */
        public String getSshPublicKey() {
            return sshPublicKey;
        }

        /**
         * Get SSH private key.
         * @return private key
         */
        public String getSshPrivateKey() {
            return sshPrivateKey;
        }

        /**
         * Set SSH public key.
         * @param sshPublicKey public key
         */
        public void setSshPublicKey(String sshPublicKey) {
            this.sshPublicKey = sshPublicKey;
        }

        /**
         * Set SSH private key.
         * @param sshPrivateKey private key
         */
        public void setSshPrivateKey(String sshPrivateKey) {
            this.sshPrivateKey = sshPrivateKey;
        }
    }
}