package be.iminds.ilabt.jfed.lowlevel.api.test;

import be.iminds.ilabt.jfed.log.Logger;
import be.iminds.ilabt.jfed.lowlevel.GeniUserProvider;
import be.iminds.ilabt.jfed.lowlevel.JFedException;
import be.iminds.ilabt.jfed.lowlevel.TestbedInfoSource;
import be.iminds.ilabt.jfed.lowlevel.api_wrapper.impl.AutomaticAggregateManagerWrapper;
import be.iminds.ilabt.jfed.lowlevel.connection.JFedConnection;
import be.iminds.ilabt.jfed.lowlevel.connection.JFedConnectionProvider;
import be.iminds.ilabt.jfed.preferences.JFedPreferences;
import be.iminds.ilabt.jfed.rspec.model.*;
import be.iminds.ilabt.jfed.rspec.rspec_source.RequestRspecSource;
import be.iminds.ilabt.jfed.rspec.rspec_source.RspecSource;
import be.iminds.ilabt.jfed.testing.base.ApiTest;
import be.iminds.ilabt.jfed.util.*;
import net.schmizz.sshj.SSHClient;
import net.schmizz.sshj.connection.channel.direct.Session;
import net.schmizz.sshj.transport.verification.PromiscuousVerifier;
import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile;
import org.slf4j.LoggerFactory;

import javax.inject.Inject;
import java.io.*;
import java.net.URL;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;

/**
 * TestMultiNodeLogin
 * <p>
 * TestMultiNodeLogin will test node login for AMv2 or AMv3 (automatic and configurable).
 * It can test a specific nodes, and it can test any nodes.
 * More then 1 node will be tested, and links between the nodes will be created.
 * The nodelogin test will try to ping between the nodes over these links.
 * <p>
 * NOTE: This does NOT support stitching: all nodes and links have to be on the same AM
 */
public class TestMultiNodeLogin extends TestNodeLogin {
    private static final org.slf4j.Logger LOG = LoggerFactory.getLogger(TestMultiNodeLogin.class);

    @Inject
    public TestMultiNodeLogin(Logger logger, TargetAuthority testedAuthority,
                              GeniUserProvider geniUserProvider, JFedConnectionProvider connectionProvider,
                              TestbedInfoSource testbedInfoSource, JFedPreferences jFedPreferences,
                              AutomaticAggregateManagerWrapper.AutomaticAggregateManagerWrapperFactory automaticAggregateManagerWrapperFactory) {
        super(logger, testedAuthority, geniUserProvider, connectionProvider, testbedInfoSource, jFedPreferences, automaticAggregateManagerWrapperFactory);

    }

    public String getTestDescription() {
        return "This tests login to a multiple node on the same AM. AMv2 or AMv3 can be used. " +
                "Links between the nodes will be tested using ping." +
                "The sliver will be deleted afterwards.";
    }

    @Override
    public List<String> getOptConfKeys() {
        List<String> res = new ArrayList<>(super.getOptConfKeys());

        res.add("node_count"); //number of nodes to test. Must be >=2

        res.remove("fixed_node_urn");
//        res.add("fixed_node_urns");  //currently not yet supported: modify getRequestRspec() to support this.

        res.add("enable_ansible_test");   //default false
        res.add("enable_login_test");     //default true
        res.add("ansible_dir");           //dir to save ansible files in and execute ansible script in
        res.add("ansible_playbook_exe");           //default: "ansible-playbook" but on some systems might need to be "/usr/bin/python2 /usr/bin/ansible"
        res.add("ansible_cookbook");
        res.add("ansible_success_filter");
        res.add("ansible_warning_filter");
        res.add("ansible_failure_filter");
        res.add("ansible_success_regex");
        res.add("ansible_warning_regex");
        res.add("ansible_failure_regex");

        return res;
    }

    private Boolean nodeLoginTestEnabled;
    private Boolean ansibleTestEnabled;
    private String ansibleDirName;
    private boolean ansibleDebug;
    private String ansiblePlaybookExeName;
    private String ansibleCookbookName;
    private String ansibleSuccessWord;
    private String ansibleWarningWord;
    private String ansibleFailureWord;
    private String ansibleSuccessRegex;
    private String ansibleWarningRegex;
    private String ansibleFailureRegex;

    private File ansibleDir;
    private File ansibleCookbook;

    //small helper
    private String makeNullIfEmpty(String s) {
        if (s == null) return null;
        if (s.trim().isEmpty()) return null;
        return s;
    }

    @Override
    public void setUp() {
        super.setUp();

        nodeLoginTestEnabled = TextUtil.objectToBoolean(getTestConfig().getProperty("enable_login_test"));
        ansibleTestEnabled = TextUtil.objectToBoolean(getTestConfig().getProperty("enable_ansible_test"));

        ansibleDirName = getTestConfig().getProperty("ansible_dir");
        ansiblePlaybookExeName = getTestConfig().getProperty("ansible_playbook_exe");
        ansibleCookbookName = getTestConfig().getProperty("ansible_cookbook");
        ansibleSuccessWord = makeNullIfEmpty(getTestConfig().getProperty("ansible_success_filter"));
        ansibleWarningWord = makeNullIfEmpty(getTestConfig().getProperty("ansible_warning_filter"));
        ansibleFailureWord = makeNullIfEmpty(getTestConfig().getProperty("ansible_failure_filter"));
        ansibleSuccessRegex = makeNullIfEmpty(getTestConfig().getProperty("ansible_success_regex"));
        ansibleWarningRegex = makeNullIfEmpty(getTestConfig().getProperty("ansible_warning_regex"));
        ansibleFailureRegex = makeNullIfEmpty(getTestConfig().getProperty("ansible_failure_regex"));
        ansibleDebug = TextUtil.objectToBoolean(getTestConfig().getProperty("ansible_debug")) == Boolean.TRUE;

        if (ansibleTestEnabled != null && ansibleTestEnabled) {
            if (ansiblePlaybookExeName == null || ansiblePlaybookExeName.trim().isEmpty())
                ansiblePlaybookExeName = "ansible-playbook";

//            assertNonEmptyString(ansibleDirName, "ansible_dir option must be specified");
            assertNonEmptyString(ansibleCookbookName, "ansible_cookbook option must be specified");

            if (ansibleSuccessWord == null && ansibleSuccessRegex == null) {
                errorFatal("Either ansible_success_filter or ansible_success_regex option must be specified");
            }

            if (ansibleDirName == null) {
                try {
                    ansibleDir = Files.createTempDirectory("ansibleTest").toFile();
                    ansibleDirName = ansibleDir.getAbsolutePath();
                } catch (IOException e) {
                    errorFatal("Could not create temporary dir for ansible files.", e);
                }
            } else {
                ansibleDir = new File(ansibleDirName);
            }

            //quick hack to support some URLs. Replace with something more robust when needed
            if (ansibleCookbookName.startsWith("http://") || ansibleCookbookName.startsWith("https://")) {
                try {
                    URL url = new URL(ansibleCookbookName);
                    String cookbookContent;
                    try (InputStream is = url.openStream()) {
                        cookbookContent = IOUtils.streamToString(is, "UTF-8");
                        if (cookbookContent == null || cookbookContent.trim().isEmpty()) {
                            errorFatal("Empty cookbook read from URL \"" + ansibleCookbookName + "\"");
                        }

                        String head = TextUtil.abbreviate(cookbookContent, 200);
                        note("Downloaded Cookbook from \"" + ansibleCookbookName + "\". Cookbook size: " + cookbookContent.length() + " characters.\nHead: " + head);
                    }

                    ansibleCookbook = Files.createTempFile("cookbook", ".yml").toFile();
                    IOUtils.stringToFile(ansibleCookbook, cookbookContent);
                } catch (Exception e) {
                    errorFatal("Error reading cookbook from \"" + ansibleCookbookName + "\"", e);
                }
            } else {
                ansibleCookbook = new File(ansibleCookbookName);
            }

            if (!(ansibleDir.exists() && ansibleDir.isDirectory())) {
                File parentDir = ansibleDir.getParentFile();
                if ((parentDir != null && !ansibleDir.exists()) && parentDir.exists() && parentDir.isDirectory()) {
                    //noinspection ResultOfMethodCallIgnored
                    ansibleDir.mkdir();
                    if (!(ansibleDir.exists() && ansibleDir.isDirectory()))
                        errorFatal("The ansible directory \"" + ansibleDirName + "\" does not exist and could be created.");
                } else
                    errorFatal("The ansible directory \"" + ansibleDirName + "\" does not exist and will not be created because the parent dir doesn't exist either.");
            }
            if (!(ansibleCookbook.exists() && ansibleCookbook.isFile() && ansibleCookbook.canRead())) {
                errorFatal("The ansible cookbook file \"" + ansibleCookbookName + "\" does not exist (or is not readable)");
            }
        }
    }

    @Override
    protected String getRequestRspec() throws IOException {
        final String nodeCountArg = getTestConfig().getProperty("node_count");
        int nodeCount = 2;
        if (nodeCountArg != null)
            nodeCount = Integer.parseInt(nodeCountArg);
        if (nodeCount < 2) {
            warn("Node count is configured as " + nodeCountArg + " which is too small. Changing to minimum value: 2");
            nodeCount = 2;
        }

        String rspecArg = commonAMTest.getFixedRspecOption();
        if (rspecArg == null) {
            String sliverType = CommonAMTest.getSliverTypeForSite(testedAuthority.getServerForRspecComponentManager());
            boolean exclusive = CommonAMTest.getExclusiveForSite(testedAuthority.getServerForRspecComponentManager());
            String cmUrn = testedAuthority.getServerForRspecComponentManager().getDefaultComponentManagerUrn();

            RspecFactory rspecFactory = RspecFactoryFactory.getRspecFactoryInstance(ModelRspecType.BASIC);
            ModelRspec newRspec = rspecFactory.createModelRspec("request");

            RspecLink link = rspecFactory.createLinkWithClientId(newRspec, "link0", "lan");
            newRspec.addLink(link);
            for (int i = 0; i < nodeCount; i++) {
                RspecNode node = rspecFactory.createNodeWithClientId(newRspec, "n" + i);
                node.setComponentManagerId(cmUrn);
                node.setSliverTypeName(sliverType);
                node.setExclusive(exclusive);

                newRspec.addNode(node);

                RspecInterface iface = rspecFactory.createInterface(node, link, "n" + i + ":if0");
                iface.getIpAddresses().add(new RspecInterface.IpAddress("192.168.1." + (i + 1), "255.255.255.0", "ipv4"));
            }

            assert newRspec.getNodes().size() == nodeCount;
            assert newRspec.getLinks().size() == 1;
            note("Generated RSpec with " + nodeCount + " nodes.");
            isGeniRspec = true;
            return newRspec.toGeni3Rspec();
        } else {
            isGeniRspec = commonAMTest.isGeniFixedRspec();
            return rspecArg;
        }
    }

    private List<String> pingTargets(ModelRspec modelRspec, RspecNode node) {
        Set<String> res = new HashSet<>();
        //add all directly connected IPs
        for (RspecLink link : node.getLinks())
            for (RspecInterface iface : link.getInterfaces())
                for (RspecInterface.IpAddress ip : iface.getIpAddresses())
                    res.add(ip.getAddress());

        //remove own IPs
        for (RspecInterface iface : node.getInterfaces())
            for (RspecInterface.IpAddress ip : iface.getIpAddresses())
                res.remove(ip.getAddress());

        return new ArrayList(res);
    }

    @Override
    @Test(hardDepends = {"createSliver"}, softDepends = {"waitForSliverReady",
            "describeReadySliver"}, groups = {
            "nodelogin"})
    public void testNodeLogin() throws JFedException, IOException {
        assertNotNull(notEnoughResourcesDetected);
//        if (notEnoughResourcesDetected) skip("Test skipped because not enough free resources detected while tying to create the sliver(s)");
        if (notEnoughResourcesDetected) {
            warn("Test skipped because not enough free resources detected while tying to create the sliver(s)");
            return;
        }

        assert nodeLoginTester != null;

        commonAMTest.setupProxyForNodeLogin(getTestConfig(), nodeLoginTester, slice.urn);
        NodeLoginTester basicNodeLoginTester = nodeLoginTester;

        RspecSource rspecSource = new RequestRspecSource(requestRspec, ModelRspecType.BASIC);
        ModelRspec modelRspec = rspecSource.getModelRspec();

        if (nodeLoginTestEnabled == null || nodeLoginTestEnabled) {

            long now = System.currentTimeMillis();
            long start = now;
            long deadline = now + (20 * 60 * 1000);

            boolean allLoginOk = true;
            for (RspecNode node : modelRspec.getNodes()) {
                NodeLoginTester nodeLoginTester = basicNodeLoginTester.copy();
                boolean foundNodeLogin = false;
                if (!foundNodeLogin && manifestRspec2 != null)
                    foundNodeLogin =
                            nodeLoginTester.parseSshInfoFromGeni3ManifestRspec(manifestRspec2, null/*auth*/, node.getClientId());
                if (!foundNodeLogin && manifestRspec != null)
                    foundNodeLogin =
                            nodeLoginTester.parseSshInfoFromGeni3ManifestRspec(manifestRspec, null/*auth*/, node.getClientId());

                if (!foundNodeLogin || nodeLoginTester.getSshHostname() == null) {
                    errorNonFatal("No node / service / login in manifest RSpec for node \"" + node.getClientId() + "\", so SSH login cannot be tested.");
                    allLoginOk = false;
                    continue;
                }

                Boolean nodeloginUseExternalSsh = TextUtil.objectToBoolean(getTestConfig().getProperty("nodelogin_use_external_ssh"));
                if (nodeloginUseExternalSsh == null) nodeloginUseExternalSsh = false;
                if (nodeloginUseExternalSsh) {
                    //node login without ping, using external SSH command
                    nodeLoginTester.testNodeLogin(false/*warnIfNotYetReady*/, !nodeloginUseExternalSsh/*internalSsh*/); //only note, no warning
                } else {
                    //nodelogin with ping, using sshj java SSH library
                    boolean isAuthenticated;
                    String remainingMethods = "";

                    SSHClient ssh = new SSHClient();
                    ssh.addHostKeyVerifier(new PromiscuousVerifier()); //don't verify host keys
                    JFedConnection.SshProxyInfo sshProxy = nodeLoginTester.getSshProxy();
                    boolean forceSshProxyDns = false;
                    if (sshProxy != null) {
                        note("Using SSH proxy for SSH node login test. info: " + sshProxy);
                        ssh.setSocketFactory(SshProxySocketFactory.createWithForcedTarget(sshProxy, nodeLoginTester.getSshHostname(), nodeLoginTester.getSshPort()));
                        forceSshProxyDns = true;
                    }

                    do { //try to setup connection until deadline
                        try {
                            //TODO: add fix to sshj so this is cleaner
                            if (forceSshProxyDns)
                                ssh.connect("localhost", nodeLoginTester.getSshPort());
                            else
                                ssh.connect(nodeLoginTester.getSshHostname(), nodeLoginTester.getSshPort());

                            String privateKeyToPrint = new String(nodeLoginTester.getSshKeyHelper().getPEMAnyPrivateKey());
                            if (privateKeyToPrint.length() > 75)
                                privateKeyToPrint = privateKeyToPrint.substring(0, 75) + " ...";
                            note("Connected to target node. Authenticating as " + nodeLoginTester.getSshUsername() + " with PEM private key:\n" + privateKeyToPrint);

                            OpenSSHKeyFile key = new OpenSSHKeyFile();
                            key.init(new String(nodeLoginTester.getSshKeyHelper().getPEMRsaPrivateKey()), null);

                            ssh.authPublickey(nodeLoginTester.getSshUsername(), key);
                            isAuthenticated = true;
                        } catch (IOException e) {
                            note("Exception while setting up SSH connection: " + e.getMessage());
                            isAuthenticated = false;
                        }
                        if (!isAuthenticated) {
                            note("SSH connection not successful (should be available from the moment that status is 'ready').  Trying again in 30 seconds...");
                            try {
                                Thread.sleep(30000 /*ms*/);
                            } catch (InterruptedException e) { /* Ignore*/ }
                            now = System.currentTimeMillis();
                        }
                    } while (!isAuthenticated && now < deadline);

                    if (!isAuthenticated) {
                        errorNonFatal("Could not connect to node \"" + nodeLoginTester.getSshHostname() + "\" at port " + nodeLoginTester.getSshPort() + " " + remainingMethods);
                        allLoginOk = false;
                        continue;
                    }

                    note("SSH connection authenticated successfully, will open session.");
                    try {
                        List<String> pingTargets = pingTargets(modelRspec, node);
                        for (String targetIP : pingTargets) {
                            note("Trying to ping " + targetIP);
                     /*
                     Ping options on linux:
                            -c count
                                   Stop after sending count ECHO_REQUEST packets. With deadline option, ping waits for count ECHO_REPLY packets, until the timeout expires.
                            -n     Numeric output only.  No attempt will be made to lookup symbolic names for host addresses.
                            -w deadline
                                   Specify a timeout, in seconds, before ping exits regardless of how many packets have been sent or received. In this case ping does not stop after count packet are sent, it
                                   waits either for deadline expire or until count probes are answered or for some error notification from network.
                            -W timeout
                                   Time to wait for a response, in seconds. The option affects only timeout in absense of any responses, otherwise ping waits for two RTTs.
                     * */

                            Session session = ssh.startSession();
                            String command = "ping -c 5 -n -w 30 " + targetIP;
                            String result = "";
                            String err = "";
                            try {
                                Session.Command comm = session.exec(command);
                                comm.join();
                                BufferedReader sout = new BufferedReader(new InputStreamReader(comm.getInputStream()));
                                BufferedReader serr = new BufferedReader(new InputStreamReader(comm.getErrorStream()));

                                String line = sout.readLine();
                                while (line != null) {
                                    result += line + "\n";
                                    line = sout.readLine();
                                }
                                line = serr.readLine();
                                while (line != null) {
                                    err += line + "\n";
                                    line = serr.readLine();
                                }
                            } finally {
                                session.close();

                            }
                            note("\"" + command + "\" command on " + nodeLoginTester.getSshUsername() + "@" + nodeLoginTester.getSshHostname() + ":" + nodeLoginTester.getSshPort() +
                                    " result: \"" + result.trim() + "\". (stderr is: \"" + err + "\")");
                            setErrorsNotFatal();
                            assertTrue(result.length() > 5, "I executed \"ping\" on the remote host, and expected some reply. Instead I got: \"" + result.trim() + "\". (stderr is: \"" + err + "\")");

                            commonAMTest.checkPingResult(session, result);
                            setErrorsFatal();
                        }
                    } finally {
                        ssh.close();
                    }
                }
            }

            if (!allLoginOk) {
                errorNonFatal("one or more login tests failed");
            }
        }
        LOG.debug("finished or skipped nodelogin test");

        if (ansibleTestEnabled != null && ansibleTestEnabled) {
            LOG.debug("running ansible test");
            JFedConnection.SshProxyInfo sshProxy = nodeLoginTester.getSshProxy();

            assertTrue(sshProxy == null, "ansible does not support an SSH proxy, but proxy configured: "+sshProxy);

            LOG.debug("  starting ansible test");
            ansibleTest(modelRspec);
        }
    }

    private void ansibleTest(ModelRspec modelRspec) {
        try {
            writeAnsibleFiles(modelRspec);
            LOG.debug("   wrote ansible files to " + ansibleDir);
        } catch (IOException e) {
            errorFatal("Failed to write ansible files", e);
        }
        String output = callAnsible();
        LOG.debug("    got ansible output");

        if (output == null || output.trim().isEmpty())
            fatalError("Ansible did not return any output.");

        note("Ansible output:\n" + output + "\n", true/*formatAsCode*/);

        checkAnsibleOutput(
                output, this,
                ansibleSuccessWord, ansibleWarningWord, ansibleFailureWord,
                ansibleSuccessRegex, ansibleWarningRegex, ansibleFailureRegex);
    }

    static void checkAnsibleOutput(String output,
                                   ApiTest test,
                                   String ansibleSuccessWord, String ansibleWarningWord, String ansibleFailureWord,
                                   String ansibleSuccessRegex, String ansibleWarningRegex, String ansibleFailureRegex) {
        if (ansibleFailureWord != null && output.contains(ansibleFailureWord))
            test.fatalError("Error: Ansible output contained \"" + ansibleFailureWord + "\"");
        if (ansibleFailureRegex != null && Pattern.compile(ansibleFailureRegex).matcher(output).find())
            test.fatalError("Error: Ansible output matched regex \"" + ansibleFailureRegex + "\"");
        if (ansibleWarningWord != null && output.contains(ansibleWarningWord))
            test.warn("Warning: Ansible output contained \"" + ansibleWarningWord + "\"");
        else {
            if (ansibleWarningRegex != null && Pattern.compile(ansibleWarningRegex).matcher(output).find())
                test.warn("Warning: Ansible output matched regex \"" + ansibleWarningRegex + "\"");
            else {
                if (ansibleSuccessWord != null && output.contains(ansibleSuccessWord))
                    test.note("Success: Ansible output contained \"" + ansibleSuccessWord + "\"");
                else {
                    if (ansibleSuccessRegex != null && Pattern.compile(ansibleSuccessRegex).matcher(output).find())
                        test.note("Success: Ansible output matched regex \"" + ansibleSuccessRegex + "\"");
                    else {
                        if (ansibleSuccessWord != null) {
                            if (ansibleSuccessRegex != null)
                                test.fatalError("Error: Ansible output did not contain \"" + ansibleSuccessWord + "\" or match regex \"" + ansibleSuccessRegex + "\"");
                            else
                                test.fatalError("Error: Ansible output did not contain \"" + ansibleSuccessWord + "\"");
                        } else
                            test.fatalError("Error: Ansible output did not match regex \"" + ansibleSuccessRegex + "\"");
                    }
                }
            }
        }
    }

    private void writeAnsibleFiles(ModelRspec modelRspec) throws IOException {
        //this uses the alternate manifests of AnsibleFileWriter to get all possible logins from both received manifests

        //safe to copy private key, as it is generated just for this test
        AnsibleFileWriter ansibleFileWriter =
                AnsibleFileWriter.createWithCopiedPrivateKey(new BasicStringRspec(requestRspec),
                        nodeLoginTester.getSshKeyHelper().getSshPrivateKey(),
                        nodeLoginTester.getSshKeyHelper().getSshPublicKey(),
                        getUser(),
                        null, nodeLoginTester.getPreferredUser());
        ansibleFileWriter.addAltBasicStringRspec(manifestRspec2);
        ansibleFileWriter.addAltBasicStringRspec(manifestRspec);

        ansibleFileWriter.writeFilesToDir(ansibleDir);
    }

    private String callAnsible() {
        ArrayList<String> command = new ArrayList<>();
        assert ansiblePlaybookExeName != null;
        command.add(ansiblePlaybookExeName);
        command.add(ansibleCookbook.getAbsolutePath());
        if (ansibleDebug)
            command.add("-vvvv");
        try {
            ProcessBuilder pb = new ProcessBuilder(command);
            pb.directory(ansibleDir);
            pb.redirectErrorStream(); //combine stderr and stout, so we can read both at once in one thread. Makes everything easy.

            Process p = pb.start();
            BufferedReader input = new BufferedReader(new InputStreamReader(p.getInputStream()));

            String output = "";
            String line;
            while ((line = input.readLine()) != null) {
                output += line + System.lineSeparator();
            }
            //note: input might close before terminal stops.
            input.close();

            try {
                p.waitFor();
            } catch (InterruptedException e) {
                //ignore
            }

            return output;
        } catch (Exception e) {
            fatalError("Error trying to call ansible with command: " + command + "", e);
            return null;
        }
    }
}
