/*
 * 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
 *
 *     http://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.brooklyn.util.core.internal.ssh;

import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertTrue;
import static org.testng.Assert.fail;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Calendar;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.apache.brooklyn.util.collections.MutableMap;
import org.apache.brooklyn.util.exceptions.Exceptions;
import org.apache.brooklyn.util.os.Os;
import org.apache.brooklyn.util.text.Identifiers;
import org.apache.brooklyn.util.text.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testng.Assert;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

import com.google.common.base.Charsets;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.Files;

/**
 * Test the operation of the {@link SshTool} utility class; to be extended to test concrete implementations.
 * <p>
 * The test <strong>assumes that</strong> you have configured keys for accessing localhost with the following keys:
 * <ul>
 * <li>passwordless ~/.ssh/id_rsa</li>
 * <li>~/.ssh/id_rsa_with_passphrase with password mypassphrase </li>
 * </ul>
 *
 * <br>
 * Paths for the keys above can override the from Java System Properties.
 * Here are the system properties you can override:
 *
 * <ul>
 * <li>brooklyn.sshPrivateKeyWithPassphrase by default it is ~/.ssh/id_rsa_with_passphrase<br>
 * <li>brooklyn.sshPrivateKeyPassphrase     by default it is mypassphrase<br>
 * <li>brooklyn.sshDefaultPrivateKeyFile    by default it is ~/.ssh/id_rsa<br>
 * </ul>
 *
 * Note that {@link #testSshKeyWithNoKeyDefaultsToIdrsa} requires a passwordless ~/.ssh/id_rsa
 * </p>
 */
public abstract class SshToolAbstractIntegrationTest extends ShellToolAbstractTest {

    private static final Logger log = LoggerFactory.getLogger(SshToolAbstractIntegrationTest.class);
    
    // FIXME need tests which take properties set in entities and brooklyn.properties;
    // but not in this class because it is lower level than entities, Aled would argue.

    // TODO No tests for retry logic and exception handing yet

    public static final String SSH_KEY_WITH_PASSPHRASE = System.getProperty("brooklyn.sshPrivateKeyWithPassphrase", "~/.ssh/id_rsa_with_passphrase");
    public static final String SSH_PASSPHRASE = System.getProperty("brooklyn.sshPrivateKeyPassphrase", "mypassphrase");
    public static final String SSH_DEFAULT_KEYFILE = System.getProperty("brooklyn.sshDefaultPrivateKeyFile", "~/.ssh/id_rsa");

    protected String remoteFilePath;

    @Override
    protected SshTool tool() { return (SshTool)tool; }
    
    @Override
    protected abstract SshTool newUnregisteredTool(Map<String,?> flags);

    @Override
    protected SshTool newTool() {
        return newTool(ImmutableMap.of("host", "localhost", "privateKeyFile", SSH_DEFAULT_KEYFILE));
    }
    
    @Override
    protected SshTool newTool(Map<String,?> flags) {
        return (SshTool) super.newTool(flags);
    }
    

    @Override
    @BeforeMethod(alwaysRun=true)
    public void setUp() throws Exception {
        super.setUp();
        remoteFilePath = "/tmp/ssh-test-remote-"+Identifiers.makeRandomId(8);
        filesCreated.add(remoteFilePath);
    }
    
    protected void assertRemoteFileContents(String remotePath, String expectedContents) {
        String catout = execCommands("cat "+remotePath);
        assertEquals(catout, expectedContents);
    }
    
    /**
     * @param remotePath
     * @param expectedPermissions Of the form, for example, "-rw-r--r--"
     */
    protected void assertRemoteFilePermissions(String remotePath, String expectedPermissions) {
        String lsout = execCommands("ls -l "+remotePath);
        assertTrue(lsout.contains(expectedPermissions), lsout);
    }
    
    protected void assertRemoteFileLastModifiedIsNow(String remotePath) {
        // Check default last-modified time is `now`.
        // Be lenient in assertion, in case unlucky that clock ticked over to next hour/minute as test was running.
        // TODO Code could be greatly improved, but low priority!
        // Output format:
        //   -rw-r--r--  1   aled  wheel  18  Apr 24  15:03 /tmp/ssh-test-remote-CvFN9zQA
        //   [0]         [1] [2]   [3]    [4] [5] [6] [7]   [8]
        
        String lsout = execCommands("ls -l "+remotePath);
        
        String[] lsparts = lsout.split("\\s+");
        int day = Integer.parseInt(lsparts[6]);
        int hour = Integer.parseInt(lsparts[7].split(":")[0]);
        int minute = Integer.parseInt(lsparts[7].split(":")[1]);
        
        Calendar expected = Calendar.getInstance();
        int expectedDay = expected.get(Calendar.DAY_OF_MONTH);
        int expectedHour = expected.get(Calendar.HOUR_OF_DAY);
        int expectedMinute = expected.get(Calendar.MINUTE);
        
        assertEquals(day, expectedDay, "ls="+lsout+"; lsparts="+Arrays.toString(lsparts)+"; expected="+expected+"; expectedDay="+expectedDay+"; day="+day+"; zone="+expected.getTimeZone());
        assertTrue(Math.abs(hour - expectedHour) <= 1, "ls="+lsout+"; lsparts="+Arrays.toString(lsparts)+"; expected="+expected+"; expectedHour="+expectedHour+"; hour="+hour+"; zone="+expected.getTimeZone());
        assertTrue(Math.abs(minute - expectedMinute) <= 1, "ls="+lsout+"; lsparts="+Arrays.toString(lsparts)+"; expected="+expected+"; expectedMinute="+expectedMinute+"; minute="+minute+"; zone="+expected.getTimeZone());
    }

    @Test(groups = {"Integration"})
    public void testCopyToServerFromBytes() throws Exception {
        String contents = "echo hello world!\n";
        byte[] contentBytes = contents.getBytes();
        tool().copyToServer(MutableMap.<String,Object>of(), contentBytes, remoteFilePath);

        assertRemoteFileContents(remoteFilePath, contents);
        assertRemoteFilePermissions(remoteFilePath, "-rw-r--r--");
        
        // TODO would like to also assert lastModified time, but on jenkins the jvm locale
        // and the OS locale are different (i.e. different timezones) so the file time-stamp 
        // is several hours out.
        //assertRemoteFileLastModifiedIsNow(remoteFilePath);
    }

    @Test(groups = {"Integration"})
    public void testCopyToServerFromInputStream() throws Exception {
        String contents = "echo hello world!\n";
        ByteArrayInputStream contentsStream = new ByteArrayInputStream(contents.getBytes());
        tool().copyToServer(MutableMap.<String,Object>of(), contentsStream, remoteFilePath);

        assertRemoteFileContents(remoteFilePath, contents);
    }

    @Test(groups = {"Integration"})
    public void testCopyToServerWithPermissions() throws Exception {
        tool().copyToServer(ImmutableMap.of("permissions","0754"), "echo hello world!\n".getBytes(), remoteFilePath);

        assertRemoteFilePermissions(remoteFilePath, "-rwxr-xr--");
    }
    
    @Test(groups = {"Integration"})
    public void testCopyToServerWithLastModifiedDate() throws Exception {
        long lastModificationTime = 1234567;
        tool().copyToServer(ImmutableMap.of("lastModificationDate", lastModificationTime), "echo hello world!\n".getBytes(), remoteFilePath);

        String lsout = execCommands("ls -l "+remoteFilePath);//+" | awk '{print \$6 \" \" \$7 \" \" \$8}'"])
        //execCommands([ "ls -l "+remoteFilePath+" | awk '{print \$6 \" \" \$7 \" \" \$8}'"])
        //varies depending on timezone
        assertTrue(lsout.contains("Jan 15  1970") || lsout.contains("Jan 14  1970") || lsout.contains("Jan 16  1970"), lsout);
        //assertLastModified(lsout, lastModifiedDate)
    }
    
    @Test(groups = {"Integration"})
    public void testCopyFileToServerWithPermissions() throws Exception {
        String contents = "echo hello world!\n";
        Files.write(contents, new File(localFilePath), Charsets.UTF_8);
        tool().copyToServer(ImmutableMap.of("permissions", "0754"), new File(localFilePath), remoteFilePath);

        assertRemoteFileContents(remoteFilePath, contents);

        String lsout = execCommands("ls -l "+remoteFilePath);
        assertTrue(lsout.contains("-rwxr-xr--"), lsout);
    }

    @Test(groups = {"Integration"})
    public void testCopyFromServer() throws Exception {
        String contentsWithoutLineBreak = "echo hello world!";
        String contents = contentsWithoutLineBreak+"\n";
        tool().copyToServer(MutableMap.<String,Object>of(), contents.getBytes(), remoteFilePath);
        
        tool().copyFromServer(MutableMap.<String,Object>of(), remoteFilePath, new File(localFilePath));

        List<String> actual = Files.readLines(new File(localFilePath), Charsets.UTF_8);
        assertEquals(actual, ImmutableList.of(contentsWithoutLineBreak));
    }
    
    // TODO No config options in sshj or scp for auto-creating the parent directories
    @Test(enabled=false, groups = {"Integration"})
    public void testCopyFileToNonExistantDir() throws Exception {
        String contents = "echo hello world!\n";
        String remoteFileDirPath = "/tmp/ssh-test-remote-dir-"+Identifiers.makeRandomId(8);
        String remoteFileInDirPath = remoteFileDirPath + File.separator + "ssh-test-remote-"+Identifiers.makeRandomId(8);
        filesCreated.add(remoteFileInDirPath);
        filesCreated.add(remoteFileDirPath);
        
        tool().copyToServer(MutableMap.<String,Object>of(), contents.getBytes(), remoteFileInDirPath);

        assertRemoteFileContents(remoteFileInDirPath, contents);
    }
    

    @Test(groups = {"Integration"})
    public void testAllocatePty() {
        final ShellTool localtool = newTool(MutableMap.of("host", "localhost", SshTool.PROP_ALLOCATE_PTY.getName(), true));
        connect(localtool);
        Map<String,Object> props = new LinkedHashMap<String, Object>();
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        ByteArrayOutputStream err = new ByteArrayOutputStream();
        props.put("out", out);
        props.put("err", err);
        int exitcode = localtool.execScript(props, Arrays.asList("echo hello err > /dev/stderr"), null);
        assertTrue(out.toString().contains("hello err"), "no hello in output: "+out+" (err is '"+err+"')");
        assertFalse(err.toString().contains("hello err"), "hello found in stderr: "+err);
        assertEquals(0, exitcode);
    }

    // Requires setting up an extra ssh key, with a passphrase, and adding it to ~/.ssh/authorized_keys
    @Test(groups = {"Integration"})
    public void testSshKeyWithPassphrase() throws Exception {
        final SshTool localtool = newTool(ImmutableMap.<String,Object>builder()
                .put(SshTool.PROP_HOST.getName(), "localhost")
                .put(SshTool.PROP_PRIVATE_KEY_FILE.getName(), SSH_KEY_WITH_PASSPHRASE)
                .put(SshTool.PROP_PRIVATE_KEY_PASSPHRASE.getName(), SSH_PASSPHRASE)
                .build());
        localtool.connect();
        
        assertEquals(tool.execScript(MutableMap.<String,Object>of(), ImmutableList.of("date")), 0);

        // Also needs the negative test to prove that we're really using an ssh-key with a passphrase
        try {
            final SshTool localtool2 = newTool(ImmutableMap.<String,Object>builder()
                    .put(SshTool.PROP_HOST.getName(), "localhost")
                    .put(SshTool.PROP_PRIVATE_KEY_FILE.getName(), SSH_KEY_WITH_PASSPHRASE)
                    .build());
            localtool2.connect();
            fail();
        } catch (Exception e) {
            SshException se = Exceptions.getFirstThrowableOfType(e, SshException.class);
            if (se == null) throw e;
        }
    }

    @Test(groups = {"Integration"})
    public void testSshKeyWithNoKeyDefaultsToIdrsa() throws Exception {
        final SshTool localtool = newTool(ImmutableMap.<String,Object>builder()
                .put(SshTool.PROP_HOST.getName(), "localhost")
                .build());
        tools.add(localtool);
        localtool.connect();
        assertEquals(localtool.execScript(MutableMap.<String,Object>of(), ImmutableList.of("date")), 0);
    }

    @Test(groups = {"Integration"})
    public void testSshKeyWithPrivateKeyData() throws Exception {
        final SshTool localtool = newTool(ImmutableMap.<String,Object>builder()
                .put(SshTool.PROP_HOST.getName(), "localhost")
                .put(SshTool.PROP_PRIVATE_KEY_DATA.getName(), new String(Files.toByteArray(new File(Os.tidyPath(SSH_DEFAULT_KEYFILE))), StandardCharsets.UTF_8))
                .build());
        localtool.connect();

        assertEquals(localtool.execScript(MutableMap.<String,Object>of(), ImmutableList.of("date")), 0);

        // Also needs the negative test to prove that we're really using an ssh-key with a passphrase
        try {
            final SshTool localtool2 = newTool(ImmutableMap.<String,Object>builder()
                    .put(SshTool.PROP_HOST.getName(), "localhost")
                    .put(SshTool.PROP_PRIVATE_KEY_DATA.getName(), "invalid data")
                    .build());
            localtool2.connect();
            localtool2.execScript(MutableMap.<String,Object>of(), ImmutableList.of("date"));
            // Notice that executing a command may succeed for SshCliToolIntegrationTest.testSshKeyWithPrivateKeyData if you already have valid keys loaded in the ssh-agent
            fail();
        } catch (Exception e) {
            SshException se = Exceptions.getFirstThrowableOfType(e, SshException.class);
            if (se == null) throw e;
        }
    }

    @Test(groups = {"Integration"})
    public void testConnectWithInvalidUserThrowsException() throws Exception {
        final ShellTool localtool = newTool(ImmutableMap.of("user", "wronguser", "host", "localhost", "privateKeyFile", SSH_DEFAULT_KEYFILE));
        tools.add(localtool);
        try {
            connect(localtool);
            fail();
        } catch (SshException e) {
            if (!e.toString().contains("failed to connect")) throw e;
        }
    }

    @Test(groups = {"Integration"})
    public void testOutputAsExpected() throws Exception {
        final String CONTENTS = "hello world\n"
            + "bye bye\n";
        execCommands("cat > "+Os.mergePaths(Os.tmp(), "test1")+" << X\n"
            + CONTENTS
            + "X\n");
        String read = execCommands("echo START_FOO", "cat "+Os.mergePaths(Os.tmp(), "test1"), "echo END_FOO");
        log.debug("read back data written, as:\n"+read);
        String contents = Strings.getFragmentBetween(read, "START_FOO", "END_FOO");
        Assert.assertEquals(CONTENTS.trim(), contents.trim());
    }

    @Test(groups = {"Integration"})
    public void testScriptDirPropertiesIsRespected() {
        // For explanation of (some of) the magic behind this command, see http://stackoverflow.com/a/229606/68898
        final String command = "if [[ \"$0\" == \"/var/tmp/\"* ]]; then true; else false; fi";

        SshTool sshTool = newTool(ImmutableMap.<String, Object>builder()
                .put(SshTool.PROP_HOST.getName(), "localhost")
                .build());
        int rc = sshTool.execScript(ImmutableMap.<String, Object>builder()
                .put(SshTool.PROP_SCRIPT_DIR.getName(), "/var/tmp")
                .build(), ImmutableList.of(command));
        assertEquals(rc, 0);
    }
}
