/*
 * 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.ignite.internal.commandline;

import java.io.File;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Scanner;
import java.util.UUID;
import java.util.logging.FileHandler;
import java.util.logging.Formatter;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import java.util.logging.StreamHandler;
import java.util.stream.Collectors;
import org.apache.ignite.IgniteCheckedException;
import org.apache.ignite.internal.client.GridClientAuthenticationException;
import org.apache.ignite.internal.client.GridClientClosedException;
import org.apache.ignite.internal.client.GridClientConfiguration;
import org.apache.ignite.internal.client.GridClientDisconnectedException;
import org.apache.ignite.internal.client.GridClientHandshakeException;
import org.apache.ignite.internal.client.GridServerUnreachableException;
import org.apache.ignite.internal.client.impl.connection.GridClientConnectionResetException;
import org.apache.ignite.internal.client.ssl.GridSslBasicContextFactory;
import org.apache.ignite.internal.util.typedef.F;
import org.apache.ignite.internal.util.typedef.X;
import org.apache.ignite.internal.util.typedef.internal.U;
import org.apache.ignite.logger.java.JavaLoggerFileHandler;
import org.apache.ignite.logger.java.JavaLoggerFormatter;
import org.apache.ignite.plugin.security.SecurityCredentials;
import org.apache.ignite.plugin.security.SecurityCredentialsBasicProvider;
import org.apache.ignite.plugin.security.SecurityCredentialsProvider;
import org.apache.ignite.ssl.SslContextFactory;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import static java.lang.System.lineSeparator;
import static java.util.Objects.nonNull;
import static org.apache.ignite.internal.IgniteVersionUtils.ACK_VER_STR;
import static org.apache.ignite.internal.IgniteVersionUtils.COPYRIGHT;
import static org.apache.ignite.internal.commandline.CommandLogger.DOUBLE_INDENT;
import static org.apache.ignite.internal.commandline.CommandLogger.INDENT;
import static org.apache.ignite.internal.commandline.CommandLogger.errorMessage;
import static org.apache.ignite.internal.commandline.CommandLogger.optional;
import static org.apache.ignite.internal.commandline.CommonArgParser.CMD_AUTO_CONFIRMATION;
import static org.apache.ignite.internal.commandline.CommonArgParser.CMD_VERBOSE;
import static org.apache.ignite.internal.commandline.CommonArgParser.getCommonOptions;
import static org.apache.ignite.internal.commandline.TaskExecutor.DFLT_HOST;
import static org.apache.ignite.internal.commandline.TaskExecutor.DFLT_PORT;
import static org.apache.ignite.ssl.SslContextFactory.DFLT_SSL_PROTOCOL;

/**
 * Class that execute several commands passed via command line.
 */
public class CommandHandler {
    /** */
    static final String CMD_HELP = "--help";

    /** */
    public static final String CONFIRM_MSG = "y";

    /** */
    static final String DELIM = "--------------------------------------------------------------------------------";

    /** */
    public static final int EXIT_CODE_OK = 0;

    /** */
    public static final int EXIT_CODE_INVALID_ARGUMENTS = 1;

    /** */
    public static final int EXIT_CODE_CONNECTION_FAILED = 2;

    /** */
    public static final int ERR_AUTHENTICATION_FAILED = 3;

    /** */
    public static final int EXIT_CODE_UNEXPECTED_ERROR = 4;

    /** */
    private static final long DFLT_PING_INTERVAL = 5000L;

    /** */
    private static final long DFLT_PING_TIMEOUT = 30_000L;

    /** */
    private final Scanner in = new Scanner(System.in);

    /** Utility name. */
    public static final String UTILITY_NAME = "control.(sh|bat)";

    /** */
    public static final String NULL = "null";

    /** JULs logger. */
    private final Logger logger;

    /** Session. */
    protected final String ses = U.id8(UUID.randomUUID());

    /** Console instance. Public access needs for tests. */
    public GridConsole console = GridConsoleAdapter.getInstance();

    /** */
    private Object lastOperationRes;

    /**
     * @param args Arguments to parse and apply.
     */
    public static void main(String[] args) {
        CommandHandler hnd = new CommandHandler();

        System.exit(hnd.execute(Arrays.asList(args)));
    }

    /**
     * @return prepared JULs logger.
     */
    private Logger setupJavaLogger() {
        Logger result = initLogger(CommandHandler.class.getName() + "Log");

        // Adding logging to file.
        try {
            String absPathPattern = new File(JavaLoggerFileHandler.logDirectory(U.defaultWorkDirectory()), "control-utility-%g.log").getAbsolutePath();

            FileHandler fileHandler = new FileHandler(absPathPattern, 5 * 1024 * 1024, 5);

            fileHandler.setFormatter(new JavaLoggerFormatter());

            result.addHandler(fileHandler);
        }
        catch (Exception e) {
            System.out.println("Failed to configure logging to file");
        }

        // Adding logging to console.
        result.addHandler(setupStreamHandler());

        return result;
    }

    /**
     * @return StreamHandler with empty formatting
     */
    public static StreamHandler setupStreamHandler() {
        return new StreamHandler(System.out, new Formatter() {
            @Override public String format(LogRecord record) {
                return record.getMessage() + "\n";
            }
        });
    }

    /**
     * Initialises JULs logger with basic settings
     * @param loggerName logger name. If {@code null} anonymous logger is returned.
     * @return logger
     */
    public static Logger initLogger(@Nullable String loggerName) {
        Logger result;

        if (loggerName == null)
            result = Logger.getAnonymousLogger();
        else
            result = Logger.getLogger(loggerName);

        result.setLevel(Level.INFO);
        result.setUseParentHandlers(false);

        return result;
    }

    /**
     *
     */
    public CommandHandler() {
        logger = setupJavaLogger();
    }

    /**
     * @param logger Logger to use.
     */
    public CommandHandler(Logger logger) {
        this.logger = logger;
    }

    /**
     * Parse and execute command.
     *
     * @param rawArgs Arguments to parse and execute.
     * @return Exit code.
     */
    public int execute(List<String> rawArgs) {
        LocalDateTime startTime = LocalDateTime.now();

        Thread.currentThread().setName("session=" + ses);

        logger.info("Control utility [ver. " + ACK_VER_STR + "]");
        logger.info(COPYRIGHT);
        logger.info("User: " + System.getProperty("user.name"));
        logger.info("Time: " + startTime);

        String commandName = "";

        Throwable err = null;
        boolean verbose = false;

        try {
            if (F.isEmpty(rawArgs) || (rawArgs.size() == 1 && CMD_HELP.equalsIgnoreCase(rawArgs.get(0)))) {
                printHelp();

                return EXIT_CODE_OK;
            }

            verbose = F.exist(rawArgs, CMD_VERBOSE::equalsIgnoreCase);

            ConnectionAndSslParameters args = new CommonArgParser(logger).parseAndValidate(rawArgs.iterator());

            Command command = args.command();
            commandName = command.name();

            GridClientConfiguration clientCfg = getClientConfiguration(args);

            int tryConnectMaxCount = 3;

            boolean suppliedAuth = !F.isEmpty(args.userName()) && !F.isEmpty(args.password());

            boolean credentialsRequested = false;

            while (true) {
                try {
                    if (!args.autoConfirmation()) {
                        command.prepareConfirmation(clientCfg);

                        if (!confirm(command.confirmationPrompt())) {
                            logger.info("Operation cancelled.");

                            return EXIT_CODE_OK;
                        }
                    }

                    logger.info("Command [" + commandName + "] started");
                    logger.info("Arguments: " + String.join(" ", rawArgs));
                    logger.info(DELIM);

                    lastOperationRes = command.execute(clientCfg, logger);

                    break;
                }
                catch (Throwable e) {
                    if (!isAuthError(e))
                        throw e;

                    if (suppliedAuth)
                        throw new GridClientAuthenticationException("Wrong credentials.");

                    if (tryConnectMaxCount == 0) {
                        throw new GridClientAuthenticationException("Maximum number of " +
                            "retries exceeded");
                    }

                    logger.info(credentialsRequested ?
                        "Authentication error, please try again." :
                        "This cluster requires authentication.");

                    if (credentialsRequested)
                        tryConnectMaxCount--;

                    String user = retrieveUserName(args, clientCfg);

                    String pwd = new String(requestPasswordFromConsole("password: "));

                    clientCfg = getClientConfiguration(user, pwd, args);

                    credentialsRequested = true;
                }
            }

            logger.info("Command [" + commandName + "] finished with code: " + EXIT_CODE_OK);

            return EXIT_CODE_OK;
        }
        catch (IllegalArgumentException e) {
            logger.severe("Check arguments. " + errorMessage(e));
            logger.info("Command [" + commandName + "] finished with code: " + EXIT_CODE_INVALID_ARGUMENTS);

            if (verbose)
                err = e;

            return EXIT_CODE_INVALID_ARGUMENTS;
        }
        catch (Throwable e) {
            if (isAuthError(e)) {
                logger.severe("Authentication error. " + errorMessage(e));
                logger.info("Command [" + commandName + "] finished with code: " + ERR_AUTHENTICATION_FAILED);

                if (verbose)
                    err = e;

                return ERR_AUTHENTICATION_FAILED;
            }

            if (isConnectionError(e)) {
                IgniteCheckedException cause = X.cause(e, IgniteCheckedException.class);

                if (isConnectionClosedSilentlyException(e))
                    logger.severe("Connection to cluster failed. Please check firewall settings and " +
                        "client and server are using the same SSL configuration.");
                else {
                    if (isSSLMisconfigurationError(cause))
                        e = cause;

                    logger.severe("Connection to cluster failed. " + errorMessage(e));

                }

                logger.info("Command [" + commandName + "] finished with code: " + EXIT_CODE_CONNECTION_FAILED);

                if (verbose)
                    err = e;

                return EXIT_CODE_CONNECTION_FAILED;
            }

            if (X.hasCause(e, IllegalArgumentException.class)) {
                IllegalArgumentException iae = X.cause(e, IllegalArgumentException.class);

                logger.severe("Check arguments. " + errorMessage(iae));
                logger.info("Command [" + commandName + "] finished with code: " + EXIT_CODE_INVALID_ARGUMENTS);

                if (verbose)
                    err = e;

                return EXIT_CODE_INVALID_ARGUMENTS;
            }

            logger.severe(errorMessage(e));
            logger.info("Command [" + commandName + "] finished with code: " + EXIT_CODE_UNEXPECTED_ERROR);

            err = e;

            return EXIT_CODE_UNEXPECTED_ERROR;
        }
        finally {
            LocalDateTime endTime = LocalDateTime.now();

            Duration diff = Duration.between(startTime, endTime);

            if (nonNull(err))
                logger.info("Error stack trace:" + System.lineSeparator() + X.getFullStackTrace(err));

            logger.info("Control utility has completed execution at: " + endTime);
            logger.info("Execution time: " + diff.toMillis() + " ms");

            Arrays.stream(logger.getHandlers())
                  .filter(handler -> handler instanceof FileHandler)
                  .forEach(Handler::close);
        }
    }

    /**
     * Analyses passed exception to find out whether it is related to SSL misconfiguration issues.
     *
     * (!) Implementation depends heavily on structure of exception stack trace
     * thus is very fragile to any changes in that structure.
     *
     * @param e Exception to analyze.
     *
     * @return {@code True} if exception may be related to SSL misconfiguration issues.
     */
    private boolean isSSLMisconfigurationError(Throwable e) {
        return e != null && e.getMessage() != null && e.getMessage().contains("SSL");
    }

    /**
     * Analyses passed exception to find out whether it is caused by server closing connection silently.
     * This happens when client tries to establish unprotected connection
     * to the cluster supporting only secured communications (e.g. when server is configured to use SSL certificates
     * and client is not).
     *
     * (!) Implementation depends heavily on structure of exception stack trace
     * thus is very fragile to any changes in that structure.
     *
     * @param e Exception to analyse.
     * @return {@code True} if exception may be related to the attempt to establish unprotected connection
     * to secured cluster.
     */
    private boolean isConnectionClosedSilentlyException(Throwable e) {
        if (!(e instanceof GridClientDisconnectedException))
            return false;

        Throwable cause = e.getCause();

        if (cause == null)
            return false;

        cause = cause.getCause();

        if (cause instanceof GridClientConnectionResetException &&
            cause.getMessage() != null &&
            cause.getMessage().contains("Failed to perform handshake")
        )
            return true;

        return false;
    }

    /**
     * Does one of three things:
     * <ul>
     *     <li>returns user name from connection parameters if it is there;</li>
     *     <li>returns user name from client configuration if it is there;</li>
     *     <li>requests user input and returns entered name.</li>
     * </ul>
     *
     * @param args Connection parameters.
     * @param clientCfg Client configuration.
     * @throws IgniteCheckedException If security credetials cannot be provided from client configuration.
     */
    private String retrieveUserName(
        ConnectionAndSslParameters args,
        GridClientConfiguration clientCfg
    ) throws IgniteCheckedException {
        if (!F.isEmpty(args.userName()))
            return args.userName();
        else if (clientCfg.getSecurityCredentialsProvider() == null)
            return requestDataFromConsole("user: ");
        else
            return (String)clientCfg.getSecurityCredentialsProvider().credentials().getLogin();
    }

    /**
     * @param args Common arguments.
     * @return Thin client configuration to connect to cluster.
     * @throws IgniteCheckedException If error occur.
     */
    @NotNull private GridClientConfiguration getClientConfiguration(
        ConnectionAndSslParameters args
    ) throws IgniteCheckedException {
        return getClientConfiguration(args.userName(), args.password(), args);
    }

    /**
     * @param userName User name for authorization.
     * @param password Password for authorization.
     * @param args Common arguments.
     * @return Thin client configuration to connect to cluster.
     * @throws IgniteCheckedException If error occur.
     */
    @NotNull private GridClientConfiguration getClientConfiguration(
        String userName,
        String password,
        ConnectionAndSslParameters args
    ) throws IgniteCheckedException {
        GridClientConfiguration clientCfg = new GridClientConfiguration();

        clientCfg.setPingInterval(args.pingInterval());

        clientCfg.setPingTimeout(args.pingTimeout());

        clientCfg.setServers(Collections.singletonList(args.host() + ":" + args.port()));

        if (!F.isEmpty(userName))
            clientCfg.setSecurityCredentialsProvider(getSecurityCredentialsProvider(userName, password, clientCfg));

        if (!F.isEmpty(args.sslKeyStorePath()))
            clientCfg.setSslContextFactory(createSslSupportFactory(args));

        return clientCfg;
    }

    /**
     * @param userName User name for authorization.
     * @param password Password for authorization.
     * @param clientCfg Thin client configuration to connect to cluster.
     * @return Security credentials provider with usage of given user name and password.
     * @throws IgniteCheckedException If error occur.
     */
    @NotNull private SecurityCredentialsProvider getSecurityCredentialsProvider(
        String userName,
        String password,
        GridClientConfiguration clientCfg
    ) throws IgniteCheckedException {
        SecurityCredentialsProvider securityCredential = clientCfg.getSecurityCredentialsProvider();

        if (securityCredential == null)
            return new SecurityCredentialsBasicProvider(new SecurityCredentials(userName, password));

        final SecurityCredentials credential = securityCredential.credentials();
        credential.setLogin(userName);
        credential.setPassword(password);

        return securityCredential;
    }

    /**
     * @param args Commond args.
     * @return Ssl support factory.
     */
    @NotNull private GridSslBasicContextFactory createSslSupportFactory(ConnectionAndSslParameters args) {
        GridSslBasicContextFactory factory = new GridSslBasicContextFactory();

        List<String> sslProtocols = split(args.sslProtocol(), ",");

        String sslProtocol = F.isEmpty(sslProtocols) ? DFLT_SSL_PROTOCOL : sslProtocols.get(0);

        factory.setProtocol(sslProtocol);
        factory.setKeyAlgorithm(args.sslKeyAlgorithm());

        if (sslProtocols.size() > 1)
            factory.setProtocols(sslProtocols);

        factory.setCipherSuites(split(args.getSslCipherSuites(), ","));

        factory.setKeyStoreFilePath(args.sslKeyStorePath());

        if (args.sslKeyStorePassword() != null)
            factory.setKeyStorePassword(args.sslKeyStorePassword());
        else {
            char[] keyStorePwd = requestPasswordFromConsole("SSL keystore password: ");

            args.sslKeyStorePassword(keyStorePwd);
            factory.setKeyStorePassword(keyStorePwd);
        }

        factory.setKeyStoreType(args.sslKeyStoreType());

        if (F.isEmpty(args.sslTrustStorePath()))
            factory.setTrustManagers(GridSslBasicContextFactory.getDisabledTrustManager());
        else {
            factory.setTrustStoreFilePath(args.sslTrustStorePath());

            if (args.sslTrustStorePassword() != null)
                factory.setTrustStorePassword(args.sslTrustStorePassword());
            else {
                char[] trustStorePwd = requestPasswordFromConsole("SSL truststore password: ");

                args.sslTrustStorePassword(trustStorePwd);
                factory.setTrustStorePassword(trustStorePwd);
            }

            factory.setTrustStoreType(args.sslTrustStoreType());
        }

        return factory;
    }

    /**
     * Used for tests.
     *
     * @return Last operation result;
     */
    public <T> T getLastOperationResult() {
        return (T)lastOperationRes;
    }

    /**
     * Provides a prompt, then reads a single line of text from the console.
     *
     * @param prompt text
     * @return A string containing the line read from the console
     */
    private String readLine(String prompt) {
        System.out.print(prompt);

        return in.nextLine();
    }

    /**
     * Requests interactive user confirmation if forthcoming operation is dangerous.
     *
     * @return {@code true} if operation confirmed (or not needed), {@code false} otherwise.
     */
    private boolean confirm(String str) {
        if (str == null)
            return true;

        String prompt = str + lineSeparator() + "Press '" + CONFIRM_MSG + "' to continue . . . ";

        return CONFIRM_MSG.equalsIgnoreCase(readLine(prompt));
    }

    /**
     * @param e Exception to check.
     * @return {@code true} if specified exception is {@link GridClientAuthenticationException}.
     */
    public static boolean isAuthError(Throwable e) {
        return X.hasCause(e, GridClientAuthenticationException.class);
    }

    /**
     * @param e Exception to check.
     * @return {@code true} if specified exception is a connection error.
     */
    private static boolean isConnectionError(Throwable e) {
        return e instanceof GridClientClosedException ||
            e instanceof GridClientConnectionResetException ||
            e instanceof GridClientDisconnectedException ||
            e instanceof GridClientHandshakeException ||
            e instanceof GridServerUnreachableException;
    }

    /**
     * Requests password from console with message.
     *
     * @param msg Message.
     * @return Password.
     */
    private char[] requestPasswordFromConsole(String msg) {
        if (console == null)
            throw new UnsupportedOperationException("Failed to securely read password (console is unavailable): " + msg);
        else
            return console.readPassword(msg);
    }

    /**
     * Requests user data from console with message.
     *
     * @param msg Message.
     * @return Input user data.
     */
    private String requestDataFromConsole(String msg) {
        if (console != null)
            return console.readLine(msg);
        else {
            Scanner scanner = new Scanner(System.in);

            logger.info(msg);

            return scanner.nextLine();
        }
    }

    /**
     * Split string into items.
     *
     * @param s String to process.
     * @param delim Delimiter.
     * @return List with items.
     */
    private static List<String> split(String s, String delim) {
        if (F.isEmpty(s))
            return Collections.emptyList();

        return Arrays.stream(s.split(delim))
            .map(String::trim)
            .filter(item -> !item.isEmpty())
            .collect(Collectors.toList());
    }

    /** */
    private void printHelp() {
        logger.info("Control utility script is used to execute admin commands on cluster or get common cluster info. " +
            "The command has the following syntax:");
        logger.info("");

        logger.info(INDENT + CommandLogger.join(" ", CommandLogger.join(" ", UTILITY_NAME, CommandLogger.join(" ", getCommonOptions())),
            optional("command"), "<command_parameters>"));
        logger.info("");
        logger.info("");

        logger.info("This utility can do the following commands:");

        Arrays.stream(CommandList.values()).forEach(c -> c.command().printUsage(logger));

        logger.info("");
        logger.info("By default commands affecting the cluster require interactive confirmation.");
        logger.info("Use " + CMD_AUTO_CONFIRMATION + " option to disable it.");
        logger.info("");

        logger.info("Default values:");
        logger.info(DOUBLE_INDENT + "HOST_OR_IP=" + DFLT_HOST);
        logger.info(DOUBLE_INDENT + "PORT=" + DFLT_PORT);
        logger.info(DOUBLE_INDENT + "PING_INTERVAL=" + DFLT_PING_INTERVAL);
        logger.info(DOUBLE_INDENT + "PING_TIMEOUT=" + DFLT_PING_TIMEOUT);
        logger.info(DOUBLE_INDENT + "SSL_PROTOCOL=" + SslContextFactory.DFLT_SSL_PROTOCOL);
        logger.info(DOUBLE_INDENT + "SSL_KEY_ALGORITHM=" + SslContextFactory.DFLT_KEY_ALGORITHM);
        logger.info(DOUBLE_INDENT + "KEYSTORE_TYPE=" + SslContextFactory.DFLT_STORE_TYPE);
        logger.info(DOUBLE_INDENT + "TRUSTSTORE_TYPE=" + SslContextFactory.DFLT_STORE_TYPE);

        logger.info("");

        logger.info("Exit codes:");
        logger.info(DOUBLE_INDENT + EXIT_CODE_OK + " - successful execution.");
        logger.info(DOUBLE_INDENT + EXIT_CODE_INVALID_ARGUMENTS + " - invalid arguments.");
        logger.info(DOUBLE_INDENT + EXIT_CODE_CONNECTION_FAILED + " - connection failed.");
        logger.info(DOUBLE_INDENT + ERR_AUTHENTICATION_FAILED + " - authentication failed.");
        logger.info(DOUBLE_INDENT + EXIT_CODE_UNEXPECTED_ERROR + " - unexpected error.");
    }
}