/*
 * This is free software, licensed under the Gnu Public License (GPL) get a copy from <http://www.gnu.org/licenses/gpl.html> $Id:
 * HenPlus.java,v 1.77 2008-10-19 09:14:49 hzeller Exp $ author: Henner Zeller <H.Zeller@acm.org>
 */
package henplus;

import henplus.commands.*;
import henplus.commands.properties.PropertyCommand;
import henplus.commands.properties.SessionPropertyCommand;
import henplus.io.ConfigurationContainer;
import henplus.logging.Logger;
import henplus.util.StringUtil;

import java.io.BufferedReader;
import java.io.EOFException;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.util.Iterator;
import java.util.Map;

import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.PosixParser;
import org.gnu.readline.Readline;
import org.gnu.readline.ReadlineLibrary;

public final class HenPlus implements Interruptable {

    private static final String HISTORY_NAME = "history";
    private static final String HENPLUSDIR = ".henplus";
    private static final String PROMPT = "Hen*Plus> ";

    public static final byte LINE_EXECUTED = 1;
    public static final byte LINE_EMPTY = 2;
    public static final byte LINE_INCOMPLETE = 3;

    private static HenPlus instance = null; // singleton.

    private boolean _fromTerminal;
    private final SQLStatementSeparator _commandSeparator;
    private final StringBuilder _historyLine;

    private boolean _quiet;
    private ConfigurationContainer _historyConfig;

    private SetCommand _settingStore;
    private SessionManager _sessionManager;
    private CommandDispatcher _dispatcher;
    private PropertyRegistry _henplusProperties;
    private ListUserObjectsCommand _objectLister;
    private String _previousHistoryLine;
    private boolean _terminated;
    private String _prompt;
    private String _emptyPrompt;
    private File _configDir;
    private boolean _alreadyShutDown;
    private BufferedReader _fileReader;
    private OutputDevice _output;
    private OutputDevice _msg;

    private volatile boolean _interrupted;
    private boolean _verbose;

    /**
     * Only initialize fields so that the instance is up fast.
     */
    private HenPlus() throws IOException {
        _terminated = false;
        _alreadyShutDown = false;

        _commandSeparator = new SQLStatementSeparator();
        _historyLine = new StringBuilder();
        // read options .. like -q

    }

    /**
     * @param argv
     * @throws UnsupportedEncodingException
     */
    private void init(final String[] argv) throws UnsupportedEncodingException {
        String noReadlineMsg = null;
        try {
            Readline.load(ReadlineLibrary.GnuReadline);
        } catch (final UnsatisfiedLinkError ignoreMe) {
            noReadlineMsg = String.format("no readline found (%s). Using simple stdin.", ignoreMe.getMessage());
        }

        _fromTerminal = Readline.hasTerminal();
        _quiet |= !_fromTerminal; // not from terminal: always quiet.

        if (_fromTerminal) {
            setOutput(new TerminalOutputDevice(System.out), new TerminalOutputDevice(System.err));
        } else {
            setOutput(new PrintStreamOutputDevice(System.out), new PrintStreamOutputDevice(System.err));
        }

        initializeCommands(argv);
        readCommandLineOptions(argv);

        if (StringUtil.isEmpty(noReadlineMsg)) {
            Logger.info("using GNU readline (Brian Fox, Chet Ramey), Java wrapper by Bernhard Bablok");
        } else {
            Logger.info(noReadlineMsg);
        }

        _historyConfig = createConfigurationContainer(HISTORY_NAME);
        Readline.initReadline("HenPlus");
        _historyConfig.read(new ConfigurationContainer.ReadAction() {

            @Override
            public void readConfiguration(final InputStream in) throws Exception {
                HistoryWriter.readReadlineHistory(in);
            }
        });

        Readline.setWordBreakCharacters(" ,/()<>=\t\n"); // TODO..
        setDefaultPrompt();
    }

    public void initializeCommands(final String[] argv) {
        _henplusProperties = new PropertyRegistry();
        _henplusProperties.registerProperty("comments-remove", _commandSeparator.getRemoveCommentsProperty());

        _sessionManager = SessionManager.getInstance();

        // FIXME: to many cross dependencies of commands now. clean up.
        _settingStore = new SetCommand(this);
        _dispatcher = new CommandDispatcher(_settingStore);
        _objectLister = new ListUserObjectsCommand(this);
        _henplusProperties.registerProperty("echo-commands", new EchoCommandProperty(_dispatcher));

        _dispatcher.register(new HelpCommand());

        /*
         * this one prints as well the initial copyright header.
         */
        _dispatcher.register(new AboutCommand());

        _dispatcher.register(new ExitCommand());
        _dispatcher.register(new EchoCommand());
        final PluginCommand pluginCommand = new PluginCommand(this);
        _dispatcher.register(pluginCommand);
        _dispatcher.register(new DriverCommand(this));
        final AliasCommand aliasCommand = new AliasCommand(this);
        _dispatcher.register(aliasCommand);
        if (_fromTerminal) {
            _dispatcher.register(new KeyBindCommand(this));
        }

        final LoadCommand loadCommand = new LoadCommand();
        _dispatcher.register(loadCommand);

        _dispatcher.register(new ConnectCommand(this, _sessionManager));
        _dispatcher.register(new StatusCommand());

        _dispatcher.register(_objectLister);
        _dispatcher.register(new DescribeCommand(_objectLister));

        _dispatcher.register(new TreeCommand(_objectLister));

        _dispatcher.register(new SQLCommand(_objectLister, _henplusProperties));

        _dispatcher.register(new ImportCommand(_objectLister));
        // _dispatcher.register(new ExportCommand());
        _dispatcher.register(new DumpCommand(_objectLister, loadCommand));

        _dispatcher.register(new ShellCommand());
        _dispatcher.register(new SpoolCommand(this));
        _dispatcher.register(_settingStore);

        PropertyCommand propertyCommand;
        propertyCommand = new PropertyCommand(this, _henplusProperties);
        _dispatcher.register(propertyCommand);
        _dispatcher.register(new SessionPropertyCommand(this));

        _dispatcher.register(new SystemInfoCommand());

        _dispatcher.register(new ReadlineCommands());

        pluginCommand.load();
        aliasCommand.load();
        propertyCommand.load();

        Readline.setCompleter(_dispatcher);

        /* FIXME: do this platform independently */
        Runtime.getRuntime().addShutdownHook(new Thread() {

            @Override
            public void run() {
                shutdown();
            }
        });
        /*
         * if your compiler/system/whatever does not support the sun.misc.
         * classes, then just disable this call and the SigIntHandler class.
         */
        try {
            SigIntHandler.install();
        } catch (final Throwable t) {
            // ignore.
        }

        /*
         * TESTING for ^Z support in the shell. sun.misc.SignalHandler stoptest
         * = new sun.misc.SignalHandler () { public void handle(sun.misc.Signal
         * sig) { System.out.println("caught: " + sig); } }; try {
         * sun.misc.Signal.handle(new sun.misc.Signal("TSTP"), stoptest); }
         * catch (Exception e) { // ignore. }
         * 
         * end testing
         */
    }

    /**
     * @param argv
     */
    private void readCommandLineOptions(final String[] argv) {
        final Options availableOptions = getMainOptions();
        registerCommandOptions(availableOptions);
        final CommandLineParser parser = new PosixParser();
        CommandLine line = null;
        try {
            line = parser.parse(availableOptions, argv);
            if (line.hasOption('h')) {
                usageAndExit(availableOptions, 0);
            }
            if (line.hasOption('s')) {
                _quiet = true;
            }
            if (line.hasOption('v')) {
                _verbose = true;
            }
            handleCommandOptions(line);
        } catch (final Exception e) {
            Logger.error("Error handling command line arguments", e);
            usageAndExit(availableOptions, 1);
        }
    }

    /**
     * @param availableOptions
     */
    private void usageAndExit(final Options availableOptions, final int returnCode) {
        final HelpFormatter formatter = new HelpFormatter();
        formatter.printHelp("henplus", availableOptions);
        System.exit(returnCode);
    }

    /**
     * @param line
     */
    private void handleCommandOptions(final CommandLine line) {
        for (final Iterator<Command> it = _dispatcher.getRegisteredCommands(); it.hasNext();) {
            final Command element = it.next();
            element.handleCommandline(line);
        }
    }

    /**
     * @param availableOptions
     */
    private void registerCommandOptions(final Options availableOptions) {
        for (final Iterator<Command> it = _dispatcher.getRegisteredCommands(); it.hasNext();) {
            final Command element = it.next();
            try {
                for (final Option option : element.getHandledCommandLineOptions()) {
                    availableOptions.addOption(option);
                }
            } catch (final Throwable e) {
                Logger.error("while registering %s", e, element);
                e.printStackTrace();
            }
        }
    }

    /**
     * @return
     */
    private Options getMainOptions() {
        final Options availableOptions = new Options();
        availableOptions.addOption(new Option("h", "help", false, "print this message"));
        availableOptions.addOption(new Option("s", "silent", false, "suppress all output except query results"));
        availableOptions.addOption(new Option("v", "verbose", false, "print debug output"));
        return availableOptions;
    }

    /**
     * push the current state of the command execution buffer, e.g. to parse a new file.
     */
    public void pushBuffer() {
        _commandSeparator.push();
    }

    /**
     * pop the command execution buffer.
     */
    public void popBuffer() {
        _commandSeparator.pop();
    }

    public String readlineFromFile() throws IOException {
        if (_fileReader == null) {
            _fileReader = new BufferedReader(new InputStreamReader(System.in));
        }
        final String line = _fileReader.readLine();
        if (line == null) {
            throw new EOFException("EOF");
        }
        return line.length() == 0 ? null : line;
    }

    private void storeLineInHistory() {
        final String line = _historyLine.toString();
        if (!"".equals(line) && !line.equals(_previousHistoryLine)) {
            Readline.addToHistory(line);
            _previousHistoryLine = line;
        }
        _historyLine.setLength(0);
    }

    /**
     * add a new line. returns one of LINE_EMPTY, LINE_INCOMPLETE or LINE_EXECUTED.
     */
    public byte executeLine(final String line) {
        byte result = LINE_EMPTY;
        /*
         * special oracle comment 'rem'ark; should be in the comment parser.
         * ONLY if it is on the beginning of the line, no whitespace.
         */
        final int startWhite = 0;
        /*
         * while (startWhite < line.length() &&
         * Character.isWhitespace(line.charAt(startWhite))) { ++startWhite; }
         */
        if (line.length() >= 3 + startWhite && line.substring(startWhite, startWhite + 3).toUpperCase().equals("REM")
                && (line.length() == 3 || Character.isWhitespace(line.charAt(3)))) {
            return LINE_EMPTY;
        }

        final StringBuilder lineBuf = new StringBuilder(line);
        lineBuf.append('\n');
        _commandSeparator.append(lineBuf.toString());
        result = LINE_INCOMPLETE;
        while (_commandSeparator.hasNext()) {
            String completeCommand = _commandSeparator.next();
            completeCommand = varsubst(completeCommand, _settingStore.getVariableMap());
            final Command c = _dispatcher.getCommandFrom(completeCommand);
            if (c == null) {
                _commandSeparator.consumed();
                /*
                 * do not shadow successful executions with the 'line-empty'
                 * message. Background is: when we consumed a command, that is
                 * complete with a trailing ';', then the following newline
                 * would be considered as empty command. So return only the
                 * LINE_EMPTY, if we haven't got a succesfully executed line.
                 */
                if (result != LINE_EXECUTED) {
                    result = LINE_EMPTY;
                }
            } else if (!c.isComplete(completeCommand)) {
                _commandSeparator.cont();
                result = LINE_INCOMPLETE;
            } else {
                _dispatcher.execute(_sessionManager.getCurrentSession(), completeCommand);
                _commandSeparator.consumed();
                result = LINE_EXECUTED;
            }
        }
        return result;
    }

    public String getPartialLine() {
        return _historyLine.toString() + Readline.getLineBuffer();
    }

    public void run() {
        String cmdLine = null;
        String displayPrompt = _prompt;
        while (!_terminated) {
            _interrupted = false;
            /*
             * a CTRL-C will not interrupt the current reading thus it does not
             * make much sense here to interrupt. WORKAROUND: Print message in
             * the interrupt() method. TODO: find out, if we can do something
             * that behaves like a shell. This requires, that CTRL-C makes
             * Readline.readline() return..
             */
            SigIntHandler.getInstance().pushInterruptable(this);

            try {
                cmdLine = _fromTerminal ? Readline.readline(displayPrompt, false) : readlineFromFile();
            } catch (final EOFException e) {
                // EOF on CTRL-D
                if (_sessionManager.getCurrentSession() != null) {
                    _dispatcher.execute(_sessionManager.getCurrentSession(), "disconnect");
                    displayPrompt = _prompt;
                    continue;
                } else {
                    break; // last session closed -> exit.
                }
            } catch (final Exception e) {
                if (_verbose) {
                    e.printStackTrace();
                }
            }

            SigIntHandler.getInstance().reset();

            // anyone pressed CTRL-C
            if (_interrupted) {
                _historyLine.setLength(0);
                _commandSeparator.discard();
                displayPrompt = _prompt;
                continue;
            }

            if (cmdLine == null) {
                continue;
            }

            /*
             * if there is already some line in the history, then add newline.
             * But if the only thing we added was a delimiter (';'), then this
             * would be annoying.
             */
            if (_historyLine.length() > 0 && !cmdLine.trim().equals(";")) {
                _historyLine.append("\n");
            }
            _historyLine.append(cmdLine);
            final byte lineExecState = executeLine(cmdLine);
            if (lineExecState == LINE_INCOMPLETE) {
                displayPrompt = _emptyPrompt;
            } else {
                displayPrompt = _prompt;
            }
            if (lineExecState != LINE_INCOMPLETE) {
                storeLineInHistory();
            }
        }
        SigIntHandler.getInstance().reset();
    }

    /**
     * called at the very end; on signal or called from the shutdown-hook
     */
    private void shutdown() {
        if (_alreadyShutDown) {
            return;
        }
        Logger.info("storing settings..");
        /*
         * allow hard resetting.
         */
        SigIntHandler.getInstance().reset();
        try {
            if (_dispatcher != null) {
                _dispatcher.shutdown();
            }
            if (_historyConfig != null) {
                _historyConfig.write(new ConfigurationContainer.WriteAction() {

                    @Override
                    public void writeConfiguration(final OutputStream out) throws Exception {
                        HistoryWriter.writeReadlineHistory(out);
                    }
                });
            }
            Readline.cleanup();
        } finally {
            _alreadyShutDown = true;
        }
        /*
         * some JDBC-Drivers (notably hsqldb) do some important cleanup (closing
         * open threads, for instance) in finalizers. Force them to do their
         * duty:
         */
        System.gc();
        System.gc();
    }

    public void terminate() {
        _terminated = true;
    }

    public CommandDispatcher getDispatcher() {
        return _dispatcher;
    }

    /**
     * Provides access to the session manager. He maintains the list of opened sessions with their names.
     * 
     * @return the session manager.
     */
    public SessionManager getSessionManager() {
        return _sessionManager;
    }

    /**
     * set current session. This is called from commands, that switch the sessions (i.e. the ConnectCommand.)
     */
    public void setCurrentSession(final SQLSession session) {
        getSessionManager().setCurrentSession(session);
    }

    /**
     * get current session.
     */
    public SQLSession getCurrentSession() {
        return getSessionManager().getCurrentSession();
    }

    public ListUserObjectsCommand getObjectLister() {
        return _objectLister;
    }

    public void setPrompt(final String p) {
        _prompt = p;
        final StringBuilder tmp = new StringBuilder();
        final int emptyLength = p.length();
        for (int i = emptyLength; i > 0; --i) {
            tmp.append(' ');
        }
        _emptyPrompt = tmp.toString();
        // readline won't know anything about these extra characters:
        // if (_fromTerminal) {
        // prompt = Terminal.BOLD + prompt + Terminal.NORMAL;
        // }
    }

    public void setDefaultPrompt() {
        setPrompt(_fromTerminal ? PROMPT : "");
    }

    /**
     * substitute the variables in String 'in', that are in the form $VARNAME or ${VARNAME} with the equivalent value that is found
     * in the Map. Return the varsubstituted String.
     * 
     * @param in
     *            the input string containing variables to be substituted (with leading $)
     * @param variables
     *            the Map containing the mapping from variable name to value.
     */
    public String varsubst(final String in, final Map<String,String> variables) {
        int pos = 0;
        int endpos = 0;
        int startVar = 0;
        final StringBuilder result = new StringBuilder();
        String varname;
        boolean hasBrace = false;

        if (in == null) {
            return null;
        }

        if (variables == null) {
            return in;
        }

        while ((pos = in.indexOf('$', pos)) >= 0) {
            startVar = pos;
            if (in.charAt(pos + 1) == '$') { // quoting '$'
                result.append(in.substring(endpos, pos));
                endpos = pos + 1;
                pos += 2;
                continue;
            }

            hasBrace = in.charAt(pos + 1) == '{';

            // text between last variable and here
            result.append(in.substring(endpos, pos));

            if (hasBrace) {
                pos++;
            }

            endpos = pos + 1;
            while (endpos < in.length() && (Character.isJavaIdentifierPart(in.charAt(endpos)) || in.charAt(endpos) == '.')) {
                endpos++;
            }
            varname = in.substring(pos + 1, endpos);

            if (hasBrace) {
                while (endpos < in.length() && in.charAt(endpos) != '}') {
                    ++endpos;
                }
                ++endpos;
            }
            if (endpos > in.length()) {
                if (variables.containsKey(varname)) {
                    Logger.info("warning: missing '}' for variable '%s'.", varname);
                }
                result.append(in.substring(startVar));
                break;
            }

            if (variables.containsKey(varname)) {
                result.append(variables.get(varname));
            } else {
                Logger.info("warning: variable '%s' not set.", varname);
                result.append(in.substring(startVar, endpos));
            }

            pos = endpos;
        }
        if (endpos < in.length()) {
            result.append(in.substring(endpos));
        }
        return result.toString();
    }

    // -- Interruptable interface
    @Override
    public void interrupt() {
        // watchout: Readline.getLineBuffer() will cause a segmentation fault!
        getMessageDevice().attributeBold();
        getMessageDevice().print("\n...discarded current command line; press [RETURN] to continue or [CTRL-D] to exit henplus");
        getMessageDevice().attributeReset();

        _interrupted = true;
    }

    // *****************************************************************
    public static HenPlus getInstance() {
        return instance;
    }

    public void setOutput(final OutputDevice out, final OutputDevice msg) {
        _output = out;
        _msg = msg;
    }

    public OutputDevice getOutputDevice() {
        return _output;
    }

    public OutputDevice getMessageDevice() {
        return _msg;
    }

    public static OutputDevice out() {
        return getInstance().getOutputDevice();
    }

    public static OutputDevice msg() {
        return getInstance().getMessageDevice();
    }

    public static void main(final String[] argv) throws Exception {
        instance = new HenPlus();
        instance.init(argv);
        instance.run();
        instance.shutdown();
        /*
         * hsqldb does not always stop its log-thread. So do an explicit exit()
         * here.
         */
        System.exit(0);
    }

    /**
     * returns an InputStream for a named configuration. That stream must be closed on finish.
     */
    public ConfigurationContainer createConfigurationContainer(final String configName) {
        return new ConfigurationContainer(new File(getConfigDir(), configName));
    }

    public String getConfigurationDirectoryInfo() {
        return getConfigDir().getAbsolutePath();
    }

    private File getConfigDir() {
        if (_configDir != null) {
            return _configDir;
        }
        /*
         * test local directory and superdirectories.
         */
        File dir = new File(".").getAbsoluteFile();
        while (dir != null) {
            _configDir = new File(dir, HENPLUSDIR);
            if (_configDir.exists() && _configDir.isDirectory()) {
                break;
            } else {
                _configDir = null;
            }
            dir = dir.getParentFile();
        }

        /*
         * fallback: home directory.
         */
        if (_configDir == null) {
            final String homeDir = System.getProperty("user.home", ".");
            _configDir = new File(homeDir + File.separator + HENPLUSDIR);
            if (!_configDir.exists()) {
                Logger.debug("creating henplus config dir.");
                if (!_configDir.mkdir()) {
                    Logger.error("henplus config dir at '%s' could not be created.", _configDir.getAbsolutePath());
                }
            }
            try {
                /*
                 * Make this directory accessible only for the user in question.
                 * works only on unix. Ignore Exception other OSes
                 */
                final String[] params = new String[] { "chmod", "700", _configDir.toString() };
                Runtime.getRuntime().exec(params);
            } catch (final Exception e) {
                if (_verbose) {
                    e.printStackTrace();
                }
            }
        }
        _configDir = _configDir.getAbsoluteFile();
        try {
            _configDir = _configDir.getCanonicalFile();
        } catch (final IOException ign) { /* ign */
        }

        Logger.info("henplus config at " + _configDir, false);
        return _configDir;
    }

    public boolean isQuiet() {
        return _quiet;
    }

    public boolean isVerbose() {
        return _verbose;
    }
}

/*
 * Local variables: c-basic-offset: 4 compile-command:
 * "ant -emacs -find build.xml" End:
 */
