/*
 * 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.util.ArrayList;
import java.util.List;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

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.TrustEveryoneUserInfo;
import org.apache.commons.vfs2.test.AbstractProviderTestConfig;
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.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>
 */
abstract class AbstractSftpProviderTestCase extends AbstractProviderTestConfig {

    /**
     * The underlying file system
     */
    protected 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";

    protected 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
     */
    protected 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(final boolean isExecChannelClosed) throws IOException {
        if (Server != null) {
            return;
        }
        SocketPort = FreeSocketPortUtil.findFreeLocalPort();
        // Use %40 for @ in a URL
        ConnectionUri = String.format("sftp://%s@localhost:%d", DEFAULT_USER, SocketPort);
        // 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<>(1);
        list.add(new NamedFactory<Command>() {

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

            @Override
            public Command create() {
                return new MySftpSubsystem();
            }
        });
        Server.setSubsystemFactories(list);
        Server.setPasswordAuthenticator((username, password, session) -> username != null && username.equals(password));
        Server.setPublickeyAuthenticator((username, key, session) -> 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(isExecChannelClosed)));
        // 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 class SftpProviderTestSuite extends ProviderTestSuite {
        private final boolean isExecChannelClosed;

        public SftpProviderTestSuite(final AbstractSftpProviderTestCase providerConfig) throws Exception {
            super(providerConfig);
            this.isExecChannelClosed = providerConfig.isExecChannelClosed();
        }

        @Override
        protected void setUp() throws Exception {
            if (getSystemTestUriOverride() == null) {
                setUpClass(isExecChannelClosed);
            }
            super.setUp();
        }

        @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);
            }
            tearDownClass();
            super.tearDown();
        }

    }

    protected abstract boolean isExecChannelClosed();

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

    /**
     * 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());
        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+)");
        private final boolean isExecChannelClosed;

        public TestCommandFactory(final boolean isExecChannelClosed) {
            this.isExecChannelClosed = isExecChannelClosed;
        }

        @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")) {
                        if (isExecChannelClosed) {
                            throw new IOException();
                        } else {
                            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((Runnable) () -> {
            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 ex1) {
                // Nothing to do, this occurs when the connection
                // is closed on the remote side
            } catch (final IOException ex2) {
                if (!ex2.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<>();
        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);
                }
            }
        }

    }
}
