/*
 * RHQ Management Platform
 * Copyright (C) 2005-2014 Red Hat, Inc.
 * All rights reserved.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation version 2 of the License.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
 */

package org.rhq.enterprise.client;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.script.ScriptEngine;
import javax.script.ScriptException;

import jline.ArgumentCompletor;
import jline.Completor;
import jline.ConsoleReader;
import jline.MultiCompletor;
import jline.SimpleCompletor;
import mazz.i18n.Msg;
import gnu.getopt.Getopt;
import gnu.getopt.LongOpt;

import org.rhq.bindings.ScriptEngineFactory;
import org.rhq.bindings.util.PackageFinder;
import org.rhq.core.domain.auth.Subject;
import org.rhq.enterprise.client.commands.ClientCommand;
import org.rhq.enterprise.client.commands.ScriptCommand;
import org.rhq.enterprise.client.script.CommandLineParseException;
import org.rhq.enterprise.client.utility.CLIMetadataProvider;
import org.rhq.enterprise.client.utility.CodeCompletionCompletorWrapper;
import org.rhq.enterprise.client.utility.DummyCodeCompletion;
import org.rhq.enterprise.clientapi.RemoteClient;
import org.rhq.scripting.CodeCompletion;
import org.rhq.scripting.ScriptEngineInitializer;

/**
 * @author Greg Hinkle
 * @author Simeon Pinder
 */
public class ClientMain {

    // I18N messaging
    private static final Msg MSG = ClientI18NFactory.getMsg();

    // Stored command map. Key to instance that handles that command.
    private static Map<String, ClientCommand> commands = new HashMap<String, ClientCommand>();

    public static final int DEFAULT_CONSOLE_WIDTH = 80;

    // JLine console reader
    private ConsoleReader consoleReader;

    // for feedback to user.
    private PrintWriter outputWriter;

    // Local storage of credentials for this session/client
    private String transport = null;
    private String host = null;
    private int port;
    private String user;
    private String pass;
    private String language;
    private ArrayList<String> notes = new ArrayList<String>();

    private RemoteClient remoteClient;

    // The subject that will be used to carry out all requested actions
    private Subject subject;

    private CodeCompletion codeCompletion;

    private boolean interactiveMode = true;

    private Recorder recorder = new NoOpRecorder();

    private ScriptEngine engine;
    private ScriptEngineInitializer scriptEngineInitializer;

    private class StartupConfiguration {
        public boolean askForPassword;
        public boolean displayUsage;
        public List<String> commandsToExec;
        public boolean invalidArgs;
        public boolean showVersionAndExit;
        public boolean showDetailedVersion;

        public void process() throws Exception {
            if (invalidArgs) {
                displayUsage();
                throw new IllegalArgumentException(MSG.getMsg(ClientI18NResourceKeys.BAD_ARGS));
            }

            if (displayUsage) {
                displayUsage();
            }

            if (askForPassword) {
                setPass(getConsoleReader().readLine("password: ", (char) 0));
            }

            if (isInteractiveMode()) {
                String version = showDetailedVersion ? Version.getProductNameAndVersionBuildInfo() : Version
                    .getProductNameAndVersion();
                outputWriter.println(version);
                if (showVersionAndExit) {
                    // If -v was the only option specified, exit after printing the version.
                    System.exit(0);
                }
            }

            if (getUser() != null && getPass() != null) {
                ClientCommand loginCmd = getCommands().get("login");
                List<String> argsList = new ArrayList<String>(6); // 6 args at most
                argsList.add("login");
                argsList.add(getUser());
                argsList.add(getPass());
                String host = getHost();
                int port = getPort();
                String transport = getTransport();
                if (host != null) {
                    argsList.add(host);
                    if (port != 0) {
                        argsList.add(String.valueOf(port));
                        if (transport != null) {
                            argsList.add(transport);
                        }
                    }
                }
                loginCmd.execute(ClientMain.this, argsList.toArray(new String[argsList.size()]));
                if (!loggedIn()) {
                    if (isInteractiveMode()) {
                        return;
                    } else {
                        System.exit(1);
                    }
                }
            }

            if (commandsToExec != null && !commandsToExec.isEmpty()) {
                getCommands().get("exec").execute(ClientMain.this,
                    commandsToExec.toArray(new String[commandsToExec.size()]));
            }
        }
    }

    public static void main(String[] args) {
        initCommands();
        try {
            new ClientMain().run(args);
        } catch (Exception e) {
            e.printStackTrace();
            System.exit(1);
        }
    }

    private void run(String[] args) throws Exception {
        // capture startup arguments and setup the properties
        //from them
        StartupConfiguration config = processArguments(args);

        //initialize the CLI
        initialize();

        //process the arguments now that we are initialized
        config.process();

        if (isInteractiveMode()) {
            // begin client access loop
            inputLoop();
        }
    }

    private static void initCommands() {
        for (Class<ClientCommand> commandClass : ClientCommand.COMMANDS) {
            ClientCommand command;
            try {
                command = commandClass.newInstance();
                commands.put(command.getPromptCommandString(), command);
            } catch (InstantiationException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
        }
    }

    private void initScriptCommand() {
        ScriptCommand sc = (ScriptCommand) commands.get("exec");
        sc.initClient(this);
    }

    private void initCodeCompletion() {
        this.codeCompletion.setScriptContext(getScriptEngine().getContext());
        this.codeCompletion.setMetadataProvider(new CLIMetadataProvider());
    }

    public ClientMain() {
        // initialize the printwriter to system.out for console conversations
        outputWriter = new PrintWriter(System.out, true);
    }

    private void initialize() throws IOException {
        // this.inputReader = new BufferedReader(new
        // InputStreamReader(System.in));

        // initialize the printwriter to system.out for console conversations
        this.outputWriter = new PrintWriter(System.out, true);

        //ScriptCommand is super special because it handles executing all the code for us
        initScriptCommand();

        if (isInteractiveMode()) {
            // Initialize JLine console elements.
            consoleReader = new jline.ConsoleReader();

            // Setup the command line completers for listed actions for the user before login
            // completes initial commands available
            Completor commandCompletor = new SimpleCompletor(commands.keySet().toArray(new String[commands.size()]));
            // completes help arguments (basically, help <command>)
            Completor helpCompletor = new ArgumentCompletor(new Completor[] { new SimpleCompletor("help"),
                new SimpleCompletor(commands.keySet().toArray(new String[commands.size()])) });

            this.codeCompletion = ScriptEngineFactory.getCodeCompletion(getLanguage());
            if (codeCompletion == null) {
                //the language module for this language doesn't support code completion
                //let's provide a dummy one.
                codeCompletion = new DummyCodeCompletion();
            }

            initCodeCompletion();

            consoleReader.addCompletor(new MultiCompletor(new Completor[] {
                new CodeCompletionCompletorWrapper(codeCompletion, outputWriter, consoleReader), helpCompletor,
                commandCompletor }));

            // enable pagination
            consoleReader.setUsePagination(true);
        }
    }

    public String getUserInput(String prompt) {

        String input_string = "";

        while ((input_string != null) && (input_string.trim().length() == 0)) {
            if (prompt == null) {
                if (!loggedIn()) {
                    prompt = "unconnected$ ";
                } else {
                    // Modify the prompt to display host:port(logged-in-user)
                    String loggedInUser = "";
                    if ((getSubject() != null) && (getSubject().getName() != null)) {
                        loggedInUser = getSubject().getName();
                    }
                    if (loggedInUser.trim().length() > 0) {
                        prompt = loggedInUser + "@" + host + ":" + port + "$ ";
                    } else {
                        prompt = host + ":" + port + "$ ";
                    }
                }
            }

            try {
                outputWriter.flush();
                input_string = consoleReader.readLine(prompt);
            } catch (Exception e) {
                input_string = null;
            }
        }

        return input_string;
    }

    public ConsoleReader getConsoleReader() {
        return consoleReader;
    }

    /**
     * Indicates whether the 'Subject', used for all authenticated actions, is currently logged in.
     *
     * @return flag indicating status of realtime check.
     */
    public boolean loggedIn() {
        return subject != null && remoteClient != null && remoteClient.isLoggedIn();
    }

    /**
     * This enters in an infinite loop. Because this never returns, the current thread never dies and hence the agent
     * stays up and running. The user can enter agent commands at the prompt - the commands are sent to the agent as if
     * the user is a remote client.
     */
    private void inputLoop() {
        // we need to start a new thread and run our loop in it; otherwise, our
        // shutdown hook doesn't work
        Thread inputLoopThread = new Thread(new LoopRunnable());
        inputLoopThread.setName("RHQ Client Prompt Input Thread");
        inputLoopThread.setDaemon(false);
        inputLoopThread.start();
    }

    public boolean executePromptCommand(String[] args) throws Exception {
        String cmd = args[0];
        if (commands.containsKey(cmd)) {
            ClientCommand command = commands.get(cmd);

            if (shouldDisplayHelp(args)) {
                outputWriter.println("Usage: " + command.getSyntax());
                outputWriter.println(command.getDetailedHelp());
                return true;
            }

            try {
                boolean response = command.execute(this, args);
                processNotes(outputWriter);
                outputWriter.println("");
                return response;
            } catch (CommandLineParseException e) {
                outputWriter.println(command.getPromptCommandString() + ": " + e.getMessage());
                outputWriter.println("Usage: " + command.getSyntax());
            } catch (ArrayIndexOutOfBoundsException e) {
                outputWriter.println(command.getPromptCommandString()
                    + ": An incorrect number of arguments was specified.");
                outputWriter.println("Usage: " + command.getSyntax());
            }
        } else {
            boolean result = commands.get("exec").execute(this, args);
            if (loggedIn()) {
                this.codeCompletion.setScriptContext(getScriptEngine().getContext());
            }

            return result;
        }
        return true;
    }

    private boolean shouldDisplayHelp(String[] args) {
        return args.length >= 2 && (args[1].equals("-h") || args[1].equals("--help"));
    }

    /**
     * Meant to display small note/helpful ui messages to the user as feedback from the previous command.
     *
     * @param outputWriter2
     *            reference to printWriter.
     */
    private void processNotes(PrintWriter outputWriter2) {
        if ((outputWriter2 != null) && (notes.size() > 0)) {
            for (String line : notes) {
                outputWriter2.println("-> " + line);
            }
            notes.clear();
        }
    }

    /**
     * Given a command line, this will parse each argument and return the argument array.
     *
     * @param cmdLine
     *            the command line
     * @return the array of command line arguments
     */
    public String[] parseCommandLine(String cmdLine) {
        if (cmdLine == null) {
            return new String[] { "" };
        }

        ByteArrayInputStream in = new ByteArrayInputStream(cmdLine.getBytes());
        StreamTokenizer strtok = new StreamTokenizer(new InputStreamReader(in));
        List<String> args = new ArrayList<String>();
        boolean keep_going = true;

        boolean isScriptFileCommand = false;
        boolean isNamedArgs = false;

        // we don't want to parse numbers and we want ' to be a normal word
        // character
        strtok.ordinaryChars('0', '9');
        strtok.ordinaryChar('.');
        strtok.ordinaryChar('-');
        strtok.ordinaryChar('\'');
        strtok.wordChars(33, 127);

        // parse the command line
        while (keep_going) {
            int nextToken;

            try {
                // if we are executing a script file and have reached the arguments, we
                // want to reset the tokenizer's syntax so that handle single and double
                // quotes correctly.
                if (isScriptFileCommand && args.size() > 2 && args.get(args.size() - 2).equals("-f")) {
                    strtok.resetSyntax();
                    strtok.ordinaryChars('0', '9');
                    strtok.ordinaryChar('.');
                    strtok.ordinaryChar('-');
                    strtok.quoteChar('\'');
                    strtok.quoteChar('"');
                    strtok.wordChars(33, 33);
                    strtok.wordChars(35, 38);
                    strtok.wordChars(40, 127);
                }

                nextToken = strtok.nextToken();

            } catch (IOException e) {
                nextToken = StreamTokenizer.TT_EOF;
            }

            if (nextToken == java.io.StreamTokenizer.TT_WORD) {
                if (args.size() > 0 && strtok.sval.equals("-f")) {
                    isScriptFileCommand = true;
                }
                args.add(strtok.sval);
                if (strtok.sval.equals("--args-style=named")) {
                    isNamedArgs = true;
                }
            } else if (nextToken == '\"' || nextToken == '\'') {
                args.add(strtok.sval);
            } else if ((nextToken == java.io.StreamTokenizer.TT_EOF) || (nextToken == java.io.StreamTokenizer.TT_EOL)) {
                keep_going = false;
            }
        }

        if (isNamedArgs) {
            List<String> newArgs = new ArrayList<String>();
            int namedArgsIndex = args.indexOf("--args-style=named");

            for (int i = 0; i <= namedArgsIndex; ++i) {
                newArgs.add(args.get(i));
            }

            String namedArg = null;
            for (int i = namedArgsIndex + 1; i < args.size(); ++i) {
                if (namedArg == null && args.get(i).endsWith("=")) {
                    namedArg = args.get(i);
                } else if (namedArg != null) {
                    newArgs.add(args.get(i - 1) + args.get(i));
                    namedArg = null;
                } else {
                    newArgs.add(args.get(i));
                }
            }

            return newArgs.toArray(new String[newArgs.size()]);
        }

        return args.toArray(new String[args.size()]);
    }

    private void displayUsage() {
        outputWriter
            .println("rhq-cli.sh [-h] [-u user] [-p pass] [-P] [-s host] [-t port] [-v] [-f file]|[-c command]");
    }

    StartupConfiguration processArguments(String[] args) throws IllegalArgumentException, IOException {
        StartupConfiguration config = new StartupConfiguration();

        String sopts = "-:hu:p:Ps:t:r:c:f:v";
        LongOpt[] lopts = { new LongOpt("help", LongOpt.NO_ARGUMENT, null, 'h'),
            new LongOpt("user", LongOpt.REQUIRED_ARGUMENT, null, 'u'),
            new LongOpt("password", LongOpt.REQUIRED_ARGUMENT, null, 'p'),
            new LongOpt("prompt", LongOpt.OPTIONAL_ARGUMENT, null, 'P'),
            new LongOpt("host", LongOpt.REQUIRED_ARGUMENT, null, 's'),
            new LongOpt("port", LongOpt.REQUIRED_ARGUMENT, null, 't'),
            new LongOpt("transport", LongOpt.REQUIRED_ARGUMENT, null, 'r'),
            new LongOpt("command", LongOpt.REQUIRED_ARGUMENT, null, 'c'),
            new LongOpt("file", LongOpt.NO_ARGUMENT, null, 'f'),
            new LongOpt("version", LongOpt.NO_ARGUMENT, null, 'v'),
            new LongOpt("language", LongOpt.REQUIRED_ARGUMENT, null, 'l'),
            new LongOpt("args-style", LongOpt.REQUIRED_ARGUMENT, null, -2) };

        Getopt getopt = new Getopt("Cli", args, sopts, lopts, false);
        int code;

        List<String> execCmdLine = new ArrayList<String>();
        execCmdLine.add("exec");

        while ((code = getopt.getopt()) != -1) {
            switch (code) {
            case ':':
            case '?': {
                config.invalidArgs = true;
                break;
            }

            case 1: {
                // this catches non-option arguments which can be passed when running a script in non-interactive mode
                // with -f or running a single command in non-interactive mode with -c.
                execCmdLine.add(getopt.getOptarg());
                break;
            }

            case 'h': {
                config.displayUsage = true;
                break;
            }

            case 'u': {
                this.user = getopt.getOptarg();
                break;
            }
            case 'p': {
                this.pass = getopt.getOptarg();
                break;
            }
            case 'P': {
                config.askForPassword = true;
                break;
            }
            case 'c': {
                interactiveMode = false;
                execCmdLine.add(getopt.getOptarg());
                break;
            }
            case 'f': {
                interactiveMode = false;
                execCmdLine.add("-f");
                execCmdLine.add(getopt.getOptarg());
                break;
            }
            case -2: {
                execCmdLine.add("--args-style=" + getopt.getOptarg());
                break;
            }
            case 's': {
                setHost(getopt.getOptarg());
                break;
            }
            case 'r': {
                setTransport(getopt.getOptarg());
                break;
            }
            case 't': {
                String portArg = getopt.getOptarg();
                try {
                    setPort(Integer.parseInt(portArg));
                } catch (Exception e) {
                    outputWriter.println("Invalid port [" + portArg + "]");
                    System.exit(1);
                }
                break;
            }
            case 'v': {
                config.showDetailedVersion = true;
                if (args.length == 1) {
                    config.showVersionAndExit = true;
                }
                break;
            }
            case 'l':
                this.language = getopt.getOptarg();
                break;
            }
        }

        if (!interactiveMode) {
            config.commandsToExec = execCmdLine;
        }

        return config;
    }

    public RemoteClient getRemoteClient() {
        return remoteClient;
    }

    public void setRemoteClient(RemoteClient remoteClient) {
        this.remoteClient = remoteClient;

        initScriptCommand();
        if (isInteractiveMode()) {
            initCodeCompletion();
        }
    }

    public Subject getSubject() {
        return subject;
    }

    public void setSubject(Subject subject) {
        this.subject = subject;
    }

    public String getTransport() {
        return transport;
    }

    public void setTransport(String transport) {
        this.transport = transport;
    }

    public String getHost() {
        return host;
    }

    public void setHost(String host) {
        this.host = host;
    }

    public int getPort() {
        return port;
    }

    public void setPort(int port) {
        this.port = port;
    }

    public String getUser() {
        return user;
    }

    public void setUser(String user) {
        this.user = user;
    }

    public String getPass() {
        return pass;
    }

    public void setPass(String pass) {
        this.pass = pass;
    }

    public PrintWriter getPrintWriter() {
        return outputWriter;
    }

    public void setPrintWriter(PrintWriter writer) {
        this.outputWriter = writer;
    }

    public String getLanguage() {
        return this.language == null ? "javascript" : this.language;
    }

    public int getConsoleWidth() {
        //the console reader might be null when this method is asked for the output
        //width in non-interactive mode where we don't attach to stdin.
        return this.consoleReader == null ? DEFAULT_CONSOLE_WIDTH : this.consoleReader.getTermwidth();
    }

    public ScriptEngine getScriptEngine() {
        if (engine == null) {
            try {
                engine = ScriptEngineFactory.getScriptEngine(getLanguage(),
                    new PackageFinder(Arrays.asList(getLibDir())), null);

                if (engine == null) {
                    throw new IllegalStateException("The scripting language '" + getLanguage()
                        + "' could not be loaded.");
                }
                scriptEngineInitializer = ScriptEngineFactory.getInitializer(getLanguage());
            } catch (ScriptException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        return engine;
    }

    public String getUsefulErrorMessage(ScriptException e) {
        return scriptEngineInitializer.extractUserFriendlyErrorMessage(e);
    }

    public Map<String, ClientCommand> getCommands() {
        return commands;
    }

    /**
     * This method allows ClientCommands to insert a small note to be displayed after the command has been executed. A
     * note can be an indication of a problem that was handled or a note about some option that should be changed.
     *
     * These notes are meant to be terse, and pasted/purged at the end of every command execution.
     *
     * @param note the note to be displayed, e.g. "There were errors retrieving some data from the server objects. See
     *             System Admin."
     */
    public void addMenuNote(String note) {
        if ((note != null) && (note.trim().length() > 0)) {
            notes.add(note);
        }
    }

    public boolean isInteractiveMode() {
        return interactiveMode;
    }

    public Recorder getRecorder() {
        return recorder;
    }

    public void setRecorder(Recorder recorder) {
        this.recorder = recorder;
    }

    private static File getLibDir() {
        String cwd = System.getProperty("user.dir");
        return new File(cwd, "lib");
    }

    private class LoopRunnable implements Runnable {
        @Override
        public void run() {
            while (true) {
                String cmd;
                cmd = getUserInput(null);

                try {
                    recorder.record(cmd);
                } catch (IOException e) {
                    e.printStackTrace();
                }

                try {
                    // parse the command into separate arguments and execute
                    String[] cmd_args = parseCommandLine(cmd);
                    boolean can_continue = executePromptCommand(cmd_args);

                    // break the input loop if the prompt command told us to exit
                    // if we are not in daemon mode, this really will end up killing the agent
                    if (!can_continue) {
                        break;
                    }
                } catch (Throwable t) {
                    // outputWriter.println(ThrowableUtil.getAllMessages(t));
                    t.printStackTrace(outputWriter);
                    // LOG.debug(t,
                    // AgentI18NResourceKeys.COMMAND_FAILURE_STACK_TRACE);
                }
            }
        }
    }
}
