/*
 * 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.commons.vfs2.provider.sftp.test;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.security.PublicKey;
import java.util.ArrayList;
import java.util.List;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import junit.extensions.TestSetup;
import junit.framework.Test;
import junit.framework.TestSuite;

import org.apache.commons.AbstractVfsTestCase;
import org.apache.commons.vfs2.FileObject;
import org.apache.commons.vfs2.FileSystemManager;
import org.apache.commons.vfs2.FileSystemOptions;
import org.apache.commons.vfs2.impl.DefaultFileSystemManager;
import org.apache.commons.vfs2.provider.sftp.SftpFileProvider;
import org.apache.commons.vfs2.provider.sftp.SftpFileSystem;
import org.apache.commons.vfs2.provider.sftp.SftpFileSystemConfigBuilder;
import org.apache.commons.vfs2.provider.sftp.SftpStreamProxy;
import org.apache.commons.vfs2.provider.sftp.TrustEveryoneUserInfo;
import org.apache.commons.vfs2.test.AbstractProviderTestConfig;
import org.apache.commons.vfs2.test.PermissionsTests;
import org.apache.commons.vfs2.test.ProviderReadTests;
import org.apache.commons.vfs2.test.ProviderTestConfig;
import org.apache.commons.vfs2.test.ProviderTestSuite;
import org.apache.commons.vfs2.util.FreeSocketPortUtil;
import org.apache.ftpserver.ftplet.FtpException;
import org.apache.sshd.SshServer;
import org.apache.sshd.common.NamedFactory;
import org.apache.sshd.common.Session;
import org.apache.sshd.common.SshException;
import org.apache.sshd.common.session.AbstractSession;
import org.apache.sshd.common.util.Buffer;
import org.apache.sshd.common.util.SecurityUtils;
import org.apache.sshd.server.Command;
import org.apache.sshd.server.Environment;
import org.apache.sshd.server.ExitCallback;
import org.apache.sshd.server.FileSystemFactory;
import org.apache.sshd.server.FileSystemView;
import org.apache.sshd.server.ForwardingFilter;
import org.apache.sshd.server.PasswordAuthenticator;
import org.apache.sshd.server.PublickeyAuthenticator;
import org.apache.sshd.server.SshFile;
import org.apache.sshd.server.auth.UserAuthNone;
import org.apache.sshd.server.command.ScpCommandFactory;
import org.apache.sshd.server.filesystem.NativeSshFile;
import org.apache.sshd.server.keyprovider.PEMGeneratorHostKeyProvider;
import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
import org.apache.sshd.server.session.ServerSession;
import org.apache.sshd.server.sftp.SftpSubsystem;

import com.jcraft.jsch.SftpATTRS;
import com.jcraft.jsch.TestIdentityRepositoryFactory;

/**
 * Tests cases for the SFTP provider.
 * <p>
 * Starts and stops an embedded Apache SSHd (MINA) server.
 * </p>
 */
public class SftpProviderTestCase extends AbstractProviderTestConfig
{
    /**
     * The underlying filesystem
     */
    private SftpFileSystem filesystem;

    /**
     * Implements FileSystemFactory because SSHd does not know about users and home directories.
     */
    static final class TestFileSystemFactory implements FileSystemFactory
    {
        /**
         * Accepts only the known test user.
         */
        @Override
        public FileSystemView createFileSystemView(final Session session) throws IOException
        {
            final String userName = session.getUsername();
            if (!DEFAULT_USER.equals(userName))
            {
                return null;
            }
            return new TestFileSystemView(AbstractVfsTestCase.getTestDirectory(), userName);
        }
    }

    /**
     * Implements FileSystemView because SSHd does not know about users and home directories.
     */
    static final class TestFileSystemView implements FileSystemView
    {
        private final String homeDirStr;

        private final String userName;

        // private boolean caseInsensitive;

        public TestFileSystemView(final String homeDirStr, final String userName)
        {
            this.homeDirStr = new File(homeDirStr).getAbsolutePath();
            this.userName = userName;
        }

        @Override
        public SshFile getFile(final SshFile baseDir, final String file)
        {
            return this.getFile(baseDir.getAbsolutePath(), file);
        }

        @Override
        public SshFile getFile(final String file)
        {
            return this.getFile(homeDirStr, file);
        }

        protected SshFile getFile(final String dir, final String file)
        {
            final String home = removePrefix(NativeSshFile.normalizeSeparateChar(homeDirStr));
            String userFileName = removePrefix(NativeSshFile.normalizeSeparateChar(file));
            final File sshFile = userFileName.startsWith(home) ? new File(userFileName) : new File(home, userFileName);
            userFileName = removePrefix(NativeSshFile.normalizeSeparateChar(sshFile.getAbsolutePath()));
            return new TestNativeSshFile(userFileName, sshFile, userName);
        }

        private String removePrefix(final String s)
        {
            final int index = s.indexOf('/');
            if (index < 1)
            {
                return s;
            }
            return s.substring(index);
        }
    }

    /**
     * Extends NativeSshFile because its constructor is protected and I do not want to create a whole NativeSshFile implementation for
     * testing.
     */
    static class TestNativeSshFile extends NativeSshFile
    {
        TestNativeSshFile(final String fileName, final File file, final String userName)
        {
            super(fileName, file, userName);
        }
    }

    private static int SocketPort;

    private static final String DEFAULT_USER = "testtest";

    // private static final String DEFAULT_PWD = "testtest";

    private static String ConnectionUri;

    private static SshServer Server;

    private static final String TEST_URI = "test.sftp.uri";

    /** True if we are testing the SFTP stream proxy */
    private final boolean streamProxyMode;

    private static String getSystemTestUriOverride()
    {
        return System.getProperty(TEST_URI);
    }

    /**
     * Creates and starts an embedded Apache SSHd Server (MINA).
     *
     * @throws FtpException
     * @throws IOException
     */
    private static void setUpClass() throws FtpException, IOException, InterruptedException
    {
        SocketPort = FreeSocketPortUtil.findFreeLocalPort();
        // Use %40 for @ in a URL
        ConnectionUri = String.format("sftp://%s@localhost:%d", DEFAULT_USER, SocketPort);

        if (Server != null)
        {
            return;
        }
        // System.setProperty("vfs.sftp.sshdir", getTestDirectory() + "/../vfs.sftp.sshdir");
        final String tmpDir = System.getProperty("java.io.tmpdir");
        Server = SshServer.setUpDefaultServer();
        Server.setPort(SocketPort);
        if (SecurityUtils.isBouncyCastleRegistered())
        {
            // A temporary file will hold the key
            final File keyFile = File.createTempFile("key", ".pem", new File(tmpDir));
            keyFile.deleteOnExit();
            // It has to be deleted in order to be generated
            keyFile.delete();

            final PEMGeneratorHostKeyProvider keyProvider = new PEMGeneratorHostKeyProvider(keyFile.getAbsolutePath());
            Server.setKeyPairProvider(keyProvider);
        } else
        {
            Server.setKeyPairProvider(new SimpleGeneratorHostKeyProvider(tmpDir + "/key.ser"));
        }
        final List<NamedFactory<Command>> list = new ArrayList<NamedFactory<Command>>(1);
        list.add(new NamedFactory<Command>()
        {

            @Override
            public String getName()
            {
                return "sftp";
            }

            @Override
            public Command create()
            {
                return new MySftpSubsystem();
            }
        });
        Server.setSubsystemFactories(list);
        Server.setPasswordAuthenticator(new PasswordAuthenticator()
        {
            @Override
            public boolean authenticate(final String username, final String password, final ServerSession session)
            {
                return username != null && username.equals(password);
            }
        });
        Server.setPublickeyAuthenticator(new PublickeyAuthenticator()
        {
            @Override
            public boolean authenticate(final String username, final PublicKey key, final ServerSession session)
            {
                // File f = new File("/Users/" + username + "/.ssh/authorized_keys");
                return true;
            }
        });
        Server.setForwardingFilter(new ForwardingFilter()
        {
            @Override
            public boolean canConnect(final InetSocketAddress address, final ServerSession session)
            {
                return true;
            }

            @Override
            public boolean canForwardAgent(final ServerSession session)
            {
                return true;
            }

            @Override
            public boolean canForwardX11(final ServerSession session)
            {
                return true;
            }

            @Override
            public boolean canListen(final InetSocketAddress address, final ServerSession session)
            {
                return true;
            }
        });
        // Allows the execution of commands
        Server.setCommandFactory(new ScpCommandFactory(new TestCommandFactory()));
        // HACK Start
        // How do we really do simple user to directory matching?
        Server.setFileSystemFactory(new TestFileSystemFactory());
        // HACK End
        Server.start();
        // HACK Start
        // How do we really do simple security?
        // Do this after we start the server to simplify this set up code.
        Server.getUserAuthFactories().add(new UserAuthNone.Factory());
        // HACK End
    }


    static private class BaseProviderTestSuite extends ProviderTestSuite {

        public BaseProviderTestSuite(final ProviderTestConfig providerConfig) throws Exception
        {
            super(providerConfig);
        }

        @Override
        protected void tearDown() throws Exception
        {
            // Close all active sessions
            // Note that it should be done by super.tearDown()
            // while closing
            for (final AbstractSession session : Server.getActiveSessions()) {
                session.close(true);
            }
            super.tearDown();
        }

    }

    /**
     * Creates the test suite for the ftp file system.
     */
    public static Test suite() throws Exception
    {
        // The test suite to be returned
        final TestSuite suite = new TestSuite();

        // --- Standard VFS test suite
        final SftpProviderTestCase standardTestCase = new SftpProviderTestCase(false);
        final ProviderTestSuite sftpSuite = new BaseProviderTestSuite(standardTestCase);

        // VFS-405: set/get permissions
        sftpSuite.addTests(PermissionsTests.class);

        suite.addTest(sftpSuite);


        // --- VFS-440: stream proxy test suite
        // We override the addBaseTests method so that only
        // one test is run (we just test that the input/output are correctly forwarded, and
        // hence if the reading test succeeds/fails the other will also succeed/fail)
        final SftpProviderTestCase streamProxyTestCase = new SftpProviderTestCase(true);
        final ProviderTestSuite sftpStreamSuite = new BaseProviderTestSuite(streamProxyTestCase)
        {
            @Override
            protected void addBaseTests() throws Exception
            {
                // Just tries to read
                addTests(ProviderReadTests.class);
            }
        };
        suite.addTest(sftpStreamSuite);


        // Decorate the test suite to set up the Sftp server
        final TestSetup setup = new TestSetup(suite)
        {
            @Override
            protected void setUp() throws Exception
            {
                if (getSystemTestUriOverride() == null)
                {
                    setUpClass();
                }
                super.setUp();
            }

            @Override
            protected void tearDown() throws Exception
            {
                // Close SFTP server if needed
                tearDownClass();
                super.tearDown();
            }
        };


        return setup;
    }

    /**
     * Stops the embedded Apache SSHd Server (MINA).
     *
     * @throws InterruptedException
     */
    private static void tearDownClass() throws InterruptedException
    {
        if (Server != null)
        {
            Server.stop();
        }
    }

    public SftpProviderTestCase(final boolean streamProxyMode) throws IOException
    {
        this.streamProxyMode = streamProxyMode;
    }

    /**
     * Returns the base folder for tests.
     */
    @Override
    public FileObject getBaseTestFolder(final FileSystemManager manager) throws Exception
    {
        String uri = getSystemTestUriOverride();
        if (uri == null)
        {
            uri = ConnectionUri;
        }

        final FileSystemOptions fileSystemOptions = new FileSystemOptions();
        final SftpFileSystemConfigBuilder builder = SftpFileSystemConfigBuilder.getInstance();
        builder.setStrictHostKeyChecking(fileSystemOptions, "no");
        builder.setUserInfo(fileSystemOptions, new TrustEveryoneUserInfo());
        builder.setIdentityRepositoryFactory(fileSystemOptions, new TestIdentityRepositoryFactory());

        if (streamProxyMode)
        {
            final FileSystemOptions proxyOptions = (FileSystemOptions) fileSystemOptions.clone();

            final URI parsedURI = new URI(uri);
            final String userInfo = parsedURI.getUserInfo();
            final String[] userFields = userInfo.split(":", 2);

            builder.setProxyType(fileSystemOptions, SftpFileSystemConfigBuilder.PROXY_STREAM);
            builder.setProxyUser(fileSystemOptions, userFields[0]);
            if (userFields.length > 1)
            {
                builder.setProxyPassword(fileSystemOptions, userFields[1]);
            }
            builder.setProxyHost(fileSystemOptions, parsedURI.getHost());
            builder.setProxyPort(fileSystemOptions, parsedURI.getPort());
            builder.setProxyCommand(fileSystemOptions, SftpStreamProxy.NETCAT_COMMAND);
            builder.setProxyOptions(fileSystemOptions, proxyOptions);
            builder.setProxyPassword(fileSystemOptions, parsedURI.getAuthority());

            // Set up the new URI
            uri = String.format("sftp://%s@localhost:%d", userInfo, parsedURI.getPort());
        }

        final FileObject fileObject = manager.resolveFile(uri, fileSystemOptions);
        this.filesystem = (SftpFileSystem) fileObject.getFileSystem();
        return fileObject;
    }

    /**
     * Prepares the file system manager.
     */
    @Override
    public void prepare(final DefaultFileSystemManager manager) throws Exception
    {
        manager.addProvider("sftp", new SftpFileProvider());
    }


    /**
     * The command factory for the SSH server:
     * Handles these commands
     * <p>
     * <li><code>id -u</code> (permissions test)</li>
     * <li><code>id -G</code> (permission tests)</li>
     * <li><code>nc -q 0 localhost port</code> (Stream proxy tests)</li>
     * </p>
     */
    private static class TestCommandFactory extends ScpCommandFactory
    {

        public static final Pattern NETCAT_COMMAND = Pattern.compile("nc -q 0 localhost (\\d+)");

        @Override
        public Command createCommand(final String command)
        {
            return new Command()
            {
                public ExitCallback callback = null;
                public OutputStream out = null;
                public OutputStream err = null;
                public InputStream in = null;

                @Override
                public void setInputStream(final InputStream in)
                {
                    this.in = in;
                }

                @Override
                public void setOutputStream(final OutputStream out)
                {
                    this.out = out;
                }

                @Override
                public void setErrorStream(final OutputStream err)
                {
                    this.err = err;
                }

                @Override
                public void setExitCallback(final ExitCallback callback)
                {
                    this.callback = callback;

                }

                @Override
                public void start(final Environment env) throws IOException
                {
                    int code = 0;
                    if (command.equals("id -G") || command.equals("id -u"))
                    {
                        new PrintStream(out).println(0);
                    } else if (NETCAT_COMMAND.matcher(command).matches())
                    {
                        final Matcher matcher = NETCAT_COMMAND.matcher(command);
                        matcher.matches();
                        final int port = Integer.parseInt(matcher.group(1));

                        final Socket socket = new Socket((String) null, port);

                        if (out != null)
                        {
                            connect("from nc", socket.getInputStream(), out, null);
                        }

                        if (in != null)
                        {
                            connect("to nc", in, socket.getOutputStream(), callback);
                        }

                        return;

                    } else
                    {
                        if (err != null)
                        {
                            new PrintStream(err).format("Unknown command %s%n", command);
                        }
                        code = -1;
                    }

                    if (out != null)
                    {
                        out.flush();
                    }
                    if (err != null)
                    {
                        err.flush();
                    }
                    callback.onExit(code);
                }

                @Override
                public void destroy()
                {
                }
            };
        }
    }

    /**
     * Creates a pipe thread that connects an input to an output
     * @param name The name of the thread (for debugging purposes)
     * @param in The input stream
     * @param out The output stream
     * @param callback An object whose method {@linkplain ExitCallback#onExit(int)} will be
     *                 called when the pipe is broken. The integer argument is 0 if everything
     *                 went well.
     */
    private static void connect(final String name, final InputStream in, final OutputStream out, final ExitCallback callback)
    {
        final Thread thread = new Thread(new Runnable()
        {
            @Override
            public void run()
            {
                int code = 0;
                try
                {
                    final byte buffer[] = new byte[1024];
                    int len;
                    while ((len = in.read(buffer, 0, buffer.length)) != -1)
                    {
                        out.write(buffer, 0, len);
                        out.flush();
                    }
                }
                catch (final SshException ex)
                {
                    // Nothing to do, this occurs when the connection
                    // is closed on the remote side
                }
                catch (final IOException ex)
                {
                    if (!ex.getMessage().equals("Pipe closed"))
                    {
                        code = -1;
                    }
                }
                if (callback != null)
                {
                    callback.onExit(code);
                }
            }
        }, name);
        thread.setDaemon(true);
        thread.start();
    }


    private static class SftpAttrs
    {
        int flags = 0;
        private int uid;
        long size = 0;
        private int gid;
        private int atime;
        private int permissions;
        private int mtime;
        private String[] extended;

        private SftpAttrs(final Buffer buf)
        {
            int flags = 0;
            flags = buf.getInt();

            if ((flags & SftpATTRS.SSH_FILEXFER_ATTR_SIZE) != 0)
            {
                size = buf.getLong();
            }
            if ((flags & SftpATTRS.SSH_FILEXFER_ATTR_UIDGID) != 0)
            {
                uid = buf.getInt();
                gid = buf.getInt();
            }
            if ((flags & SftpATTRS.SSH_FILEXFER_ATTR_PERMISSIONS) != 0)
            {
                permissions = buf.getInt();
            }
            if ((flags & SftpATTRS.SSH_FILEXFER_ATTR_ACMODTIME) != 0)
            {
                atime = buf.getInt();
            }
            if ((flags & SftpATTRS.SSH_FILEXFER_ATTR_ACMODTIME) != 0)
            {
                mtime = buf.getInt();
            }

        }
    }

    private static class MySftpSubsystem extends SftpSubsystem
    {
        TreeMap<String, Integer> permissions = new TreeMap<String, Integer>();
        private int _version;

        @Override
        protected void process(final Buffer buffer) throws IOException
        {
            final int rpos = buffer.rpos();
            final int length = buffer.getInt();
            final int type = buffer.getByte();
            final int id = buffer.getInt();

            switch (type)
            {
                case SSH_FXP_SETSTAT:
                case SSH_FXP_FSETSTAT:
                {
                    // Get the path
                    final String path = buffer.getString();
                    // Get the permission
                    final SftpAttrs attrs = new SftpAttrs(buffer);
                    permissions.put(path, attrs.permissions);
//                    System.err.format("Setting [%s] permission to %o%n", path, attrs.permissions);
                    break;
                }

                case SSH_FXP_REMOVE:
                {
                    // Remove cached attributes
                    final String path = buffer.getString();
                    permissions.remove(path);
//                    System.err.format("Removing [%s] permission cache%n", path);
                    break;
                }

                case SSH_FXP_INIT:
                {
                    // Just grab the version here
                    this._version = id;
                    break;
                }
            }

            buffer.rpos(rpos);
            super.process(buffer);

        }

        @Override
        protected void writeAttrs(final Buffer buffer, final SshFile file, final int flags) throws IOException
        {
            if (!file.doesExist()) {
                throw new FileNotFoundException(file.getAbsolutePath());
            }


            int p = 0;

            final Integer cached = permissions.get(file.getAbsolutePath());
            if (cached != null)
            {
                // Use cached permissions
//                System.err.format("Using cached [%s] permission of %o%n", file.getAbsolutePath(), cached);
                p |= cached;
            } else
            {
                // Use permissions from Java file
                if (file.isReadable())
                {
                    p |= S_IRUSR;
                }
                if (file.isWritable())
                {
                    p |= S_IWUSR;
                }
                if (file.isExecutable())
                {
                    p |= S_IXUSR;
                }
            }

            if (_version >= 4)
            {
                final long size = file.getSize();
//                String username = session.getUsername();
                final long lastModif = file.getLastModified();
                if (file.isFile())
                {
                    buffer.putInt(SSH_FILEXFER_ATTR_PERMISSIONS);
                    buffer.putByte((byte) SSH_FILEXFER_TYPE_REGULAR);
                    buffer.putInt(p);
                } else if (file.isDirectory()) {
                    buffer.putInt(SSH_FILEXFER_ATTR_PERMISSIONS);
                    buffer.putByte((byte) SSH_FILEXFER_TYPE_DIRECTORY);
                    buffer.putInt(p);
                } else {
                    buffer.putInt(0);
                    buffer.putByte((byte) SSH_FILEXFER_TYPE_UNKNOWN);
                }
            } else {
                if (file.isFile()) {
                    p |= 0100000;
                }
                if (file.isDirectory()) {
                    p |= 0040000;
                }


                if (file.isFile()) {
                    buffer.putInt(SSH_FILEXFER_ATTR_SIZE| SSH_FILEXFER_ATTR_PERMISSIONS | SSH_FILEXFER_ATTR_ACMODTIME);
                    buffer.putLong(file.getSize());
                    buffer.putInt(p);
                    buffer.putInt(file.getLastModified()/1000);
                    buffer.putInt(file.getLastModified()/1000);
                } else if (file.isDirectory()) {
                    buffer.putInt(SSH_FILEXFER_ATTR_PERMISSIONS | SSH_FILEXFER_ATTR_ACMODTIME);
                    buffer.putInt(p);
                    buffer.putInt(file.getLastModified()/1000);
                    buffer.putInt(file.getLastModified()/1000);
                } else {
                    buffer.putInt(0);
                }
            }
        }

    }
}
