/**
 * Copyright (c) 2015 by Brainwy Software Ltda. All Rights Reserved.
 * Licensed under1 the terms of the Eclipse Public License (EPL).
 * Please see the license.txt included with this distribution for details.
 * Any modifications to this file must keep this entire header intact.
 */
package org.python.pydev.debug.newconsole.prefs;

import java.io.File;
import java.io.FileFilter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.eclipse.core.commands.AbstractHandler;
import org.eclipse.core.commands.Category;
import org.eclipse.core.commands.Command;
import org.eclipse.core.commands.ExecutionEvent;
import org.eclipse.core.commands.ExecutionException;
import org.eclipse.core.commands.ParameterizedCommand;
import org.eclipse.core.runtime.Assert;
import org.eclipse.jface.action.IAction;
import org.eclipse.jface.bindings.Binding;
import org.eclipse.jface.bindings.TriggerSequence;
import org.eclipse.jface.bindings.keys.KeySequence;
import org.eclipse.jface.bindings.keys.ParseException;
import org.eclipse.ui.IWorkbench;
import org.eclipse.ui.PlatformUI;
import org.eclipse.ui.commands.ICommandService;
import org.python.pydev.editor.PyEdit;
import org.python.pydev.editor.actions.IExecuteLineAction;
import org.python.pydev.shared_core.callbacks.CallbackWithListeners;
import org.python.pydev.shared_core.callbacks.ICallbackListener;
import org.python.pydev.shared_core.log.Log;
import org.python.pydev.shared_core.path_watch.IFilesystemChangesListener;
import org.python.pydev.shared_core.path_watch.PathWatch;
import org.python.pydev.shared_core.preferences.IScopedPreferences;
import org.python.pydev.shared_core.preferences.ScopedPreferences;
import org.python.pydev.shared_core.string.FastStringBuffer;
import org.python.pydev.shared_core.string.TextSelectionUtils;
import org.python.pydev.shared_ui.bindings.BindKeysHelper;
import org.python.pydev.shared_ui.bindings.BindKeysHelper.IFilter;
import org.python.pydev.shared_ui.bindings.KeyBindingHelper;
import org.python.pydev.shared_ui.utils.RunInUiThread;

/**
 * Used to deal with the interactive console commands defined by the user.
 *
 * These commands can be bound to a key which when triggered will send some
 * content to the console based on the current selection and defined text.
 */
public class InteractiveConsoleCommand {

    public static final class InteractiveCommandCustomHandler extends AbstractHandler {
        private final InteractiveConsoleCommand interactiveConsoleCommand;

        public InteractiveCommandCustomHandler(InteractiveConsoleCommand interactiveConsoleCommand) {
            this.interactiveConsoleCommand = interactiveConsoleCommand;
        }

        @Override
        public Object execute(ExecutionEvent event) throws ExecutionException {
            Object applicationContext = event.getApplicationContext();
            if (applicationContext instanceof org.eclipse.core.expressions.IEvaluationContext) {
                org.eclipse.core.expressions.IEvaluationContext iEvaluationContext = (org.eclipse.core.expressions.IEvaluationContext) applicationContext;
                Object activeEditor = iEvaluationContext.getVariable("activeEditor");
                if (activeEditor instanceof PyEdit) {
                    PyEdit pyEdit = (PyEdit) activeEditor;
                    execute(pyEdit);
                } else {
                    Log.log("Expected PyEdit. Found: " + activeEditor);
                }
            }
            return null;
        }

        public void execute(PyEdit pyEdit) {
            IAction action = pyEdit.getAction("org.python.pydev.editor.actions.execLineInConsole");
            if (action instanceof IExecuteLineAction) {
                IExecuteLineAction executeLineAction = (IExecuteLineAction) action;
                String commandText = this.interactiveConsoleCommand.commandText;
                TextSelectionUtils ts = pyEdit.createTextSelectionUtils();
                String selectedText = ts.getSelectedText();
                if (selectedText.length() == 0) {
                    selectedText = ts.getCursorLineContents();
                }
                executeLineAction.executeText(new FastStringBuffer(commandText, selectedText.length() * 2).replaceAll(
                        "${text}",
                        selectedText).toString());
            } else {
                Log.log("Expected: " + action + " to implement IExecuteLineAction.");
            }
        }
    }

    // The name is always the USER_COMMAND_PREFIX + int (saying which command is bound).
    public static final String USER_COMMAND_PREFIX = "org.python.pydev.custom.interactive_console.user_command.InteractiveConsoleUserCommand";

    /**
     * The name for the command (caption for the user/keybindings).
     */
    public final String name;

    public String keybinding = "";

    public String commandText = "";

    private static final CallbackWithListeners<Object> interactiveConsoleCommandsChanged = new CallbackWithListeners<>();
    private static final IFilesystemChangesListener pathChangesListener = new IFilesystemChangesListener() {

        @Override
        public void removed(File file) {
            interactiveConsoleCommandsChanged.call(null);
        }

        @Override
        public void added(File file) {
            interactiveConsoleCommandsChanged.call(null);

        }
    };

    /**
     * @param name the name of the command.
     * Note that other fields must be properly filled later on.
     */
    public InteractiveConsoleCommand(String name) {
        Assert.isNotNull(name);
        Assert.isTrue(name.length() > 0);
        this.name = name;
    }

    /**
     * @return a map (which we can persist and use later to create a new command).
     */
    public Map<String, Object> asMap() {
        HashMap<String, Object> map = new HashMap<>();
        map.put("name", name);
        map.put("keybinding", keybinding);
        map.put("commandText", commandText);
        return map;
    }

    /**
     * Creates the command from a map. May return null if the contents are not valid.
     */
    public static InteractiveConsoleCommand createFromMap(Map<String, Object> map) {
        String name = (String) map.get("name");
        if (name == null || name.length() == 0) {
            return null;
        }
        String keybinding = (String) map.get("keybinding");
        if (keybinding == null) {
            return null;
        }
        String commandText = (String) map.get("commandText");
        if (commandText == null) {
            return null;
        }
        InteractiveConsoleCommand ret = new InteractiveConsoleCommand(name);
        ret.keybinding = keybinding;
        ret.commandText = commandText;
        return ret;
    }

    /**
     * Loads the commands that the user created previously in the preferences.
     */
    @SuppressWarnings({ "rawtypes", "unchecked" })
    public static List<InteractiveConsoleCommand> loadExistingCommands() {
        List<InteractiveConsoleCommand> ret = new ArrayList<InteractiveConsoleCommand>();
        try {
            IScopedPreferences scopedPreferences = getScopedPreferences();
            File workspaceSettingsLocation = scopedPreferences.getWorkspaceSettingsLocation();
            Map<String, Object> contentsAsMap = scopedPreferences.getYamlFileContents(workspaceSettingsLocation);
            if (contentsAsMap != null) {
                Object commands = contentsAsMap.get("commands");
                if (commands instanceof Collection) {
                    Collection collection = (Collection) commands;
                    for (Iterator iterator = collection.iterator(); iterator.hasNext();) {
                        Object object = iterator.next();
                        if (object instanceof Map) {
                            Map<String, Object> map = (Map<String, Object>) object;
                            InteractiveConsoleCommand created = InteractiveConsoleCommand.createFromMap(map);
                            if (created != null) {
                                ret.add(created);
                            }

                        }
                    }
                }
            }
        } catch (Exception e) {
            Log.log(e);
        }
        return ret;
    }

    /**
     * @return the preferences we'll use to save the commands.
     */
    public static IScopedPreferences getScopedPreferences() {
        return ScopedPreferences.get("org.python.pydev.interactive_console.commands");
    }

    /**
     * Helper to track changes to the preferences.
     */
    private static PathWatch watcher = null;

    /**
     * Helper for locking.
     */
    private static final Object lock = new Object();

    /**
     * Whenever the preferences related to the command change the passed callback will be called.
     */
    public static void registerOnCommandsChangedCallback(ICallbackListener<Object> iCallbackListener) {
        final File workspaceSettingsLocation = getScopedPreferences().getWorkspaceSettingsLocation();
        File folderToTrack = workspaceSettingsLocation.getParentFile();
        if (folderToTrack.exists()) {
            if (!folderToTrack.isDirectory()) {
                folderToTrack.delete();
                folderToTrack.mkdirs();
            }
        } else {
            // It doesn't exist: create it.
            folderToTrack.mkdirs();
        }

        interactiveConsoleCommandsChanged.registerListener(iCallbackListener);

        // Make sure that we're really tracking the needed folder
        synchronized (lock) {
            if (watcher != null) {
                if (!watcher.hasTracker(folderToTrack, pathChangesListener)) {
                    watcher.dispose();
                    watcher = null;
                }
            }
            if (watcher == null) {
                watcher = new PathWatch();
                watcher.setDirectoryFileFilter(new FileFilter() {

                    @Override
                    public boolean accept(File pathname) {
                        return pathname.equals(workspaceSettingsLocation);
                    }
                }, new FileFilter() {

                    @Override
                    public boolean accept(File pathname) {
                        return false;
                    }
                });
                watcher.track(folderToTrack, pathChangesListener);
            }
        }
    }

    public static void unregisterOnCommandsChangedCallback(ICallbackListener<Object> iCallbackListener) {
        interactiveConsoleCommandsChanged.unregisterListener(iCallbackListener);
    }

    /**
     * Helper which when called will synchronize the interactive console commands to the
     * actual commands/bindings in the eclipse preferences.
     *
     * Note that it may be called even if nothing changed...
     */
    private static ICallbackListener<Object> syncCommands = new ICallbackListener<Object>() {

        @Override
        public Object call(Object obj) {
            Runnable r = new Runnable() {

                @Override
                public void run() {
                    syncCommands();
                }
            };
            RunInUiThread.async(r, false);
            return null;
        }
    };

    private static boolean alreadyListening = false;

    /**
     * Makes sure that whenever the user commands are changed the keybindings/commands are kept up to date.
     */
    public static void keepBindingsUpdated() {
        if (alreadyListening) {
            return;
        }
        alreadyListening = true;
        try {
            registerOnCommandsChangedCallback(syncCommands);
            //On the first call make sure that we have the initial state synchronized.
            syncCommands.call(null);
        } catch (Exception e) {
            Log.log(e);
        }
    }

    /**
     * Creates a handler for the given command.
     */
    protected static InteractiveCommandCustomHandler createHandler(
            final InteractiveConsoleCommand interactiveConsoleCommand) {
        return new InteractiveCommandCustomHandler(interactiveConsoleCommand);
    }

    /**
     * Makes sure that the commands are always updated (to be called in the UI-thread). Not thread safe.
     */
    private static void syncCommands() {
        IWorkbench workbench;
        try {
            workbench = PlatformUI.getWorkbench();
        } catch (Throwable e) {
            // It's already disposed or not even created -- and the class may be unavailable on unit-tests.
            // Log.log(e); -- don't even log (if we're in a state we can't use it, there's no point in doing anything).
            return;
        }
        ICommandService commandService = (ICommandService) workbench.getService(ICommandService.class);

        //Note: hardcoding that we want to deal with the PyDev editor category.
        Category pydevCommandsCategory = commandService.getCategory("org.python.pydev.ui.category.source");
        if (!pydevCommandsCategory.isDefined()) {
            Log.log("Expecting org.python.pydev.ui.category.source to be a a defined commands category.");
            return;
        }

        //First we have to remove bindings which would conflict.
        final Set<KeySequence> removeKeySequences = new HashSet<>();
        List<InteractiveConsoleCommand> existingCommands = InteractiveConsoleCommand.loadExistingCommands();
        for (InteractiveConsoleCommand interactiveConsoleCommand : existingCommands) {
            try {
                removeKeySequences.add(KeyBindingHelper.getKeySequence(interactiveConsoleCommand.keybinding));
            } catch (Exception e) {
                Log.log("Error resolving: " + interactiveConsoleCommand.keybinding, e);
            }
        }

        BindKeysHelper bindKeysHelper = new BindKeysHelper(PyEdit.PYDEV_EDITOR_KEYBINDINGS_CONTEXT_ID);
        bindKeysHelper.removeUserBindingsWithFilter(new IFilter() {

            @Override
            public boolean removeBinding(Binding binding) {
                TriggerSequence triggerSequence = binding.getTriggerSequence();

                if (removeKeySequences.contains(triggerSequence)) {
                    return true;
                }
                ParameterizedCommand command = binding.getParameterizedCommand();
                if (command == null) {
                    return false;
                }
                String id = command.getId();
                if (id.startsWith(USER_COMMAND_PREFIX)) {
                    return true;
                }
                return false;
            }
        });

        Map<String, InteractiveCommandCustomHandler> commandIdToHandler = new HashMap<>();

        // Now, define the commands and the bindings for the user-commands.
        int i = 0;
        for (InteractiveConsoleCommand interactiveConsoleCommand : existingCommands) {
            try {
                String commandId = USER_COMMAND_PREFIX + i;
                Command cmd = commandService.getCommand(commandId);
                if (!cmd.isDefined()) {
                    cmd.define(interactiveConsoleCommand.name, interactiveConsoleCommand.name,
                            pydevCommandsCategory);
                }
                InteractiveCommandCustomHandler handler = createHandler(interactiveConsoleCommand);
                commandIdToHandler.put(commandId, handler);
                cmd.setHandler(handler);
                KeySequence keySequence;
                try {
                    if (interactiveConsoleCommand.keybinding == null
                            || interactiveConsoleCommand.keybinding.length() == 0) {
                        continue;
                    }
                    keySequence = KeyBindingHelper.getKeySequence(interactiveConsoleCommand.keybinding);
                } catch (IllegalArgumentException | ParseException e) {
                    Log.log("Error resolving: " + interactiveConsoleCommand.keybinding, e);
                    continue;
                }
                bindKeysHelper.addUserBindings(keySequence, new ParameterizedCommand(cmd, null));
            } catch (Exception e) {
                Log.log(e);
            }
            i++;
        }

        // Unbind any command we may have previously created.
        for (; i < 100; i++) {
            Command cmd = commandService
                    .getCommand(USER_COMMAND_PREFIX + i);
            if (cmd.isDefined()) {
                cmd.undefine();
            }
        }
        bindKeysHelper.saveIfChanged();
        setCommandIdToHandler(commandIdToHandler);
    }

    /**
     * API to know that the list of commands pointing from command id to the handler changed.
     */
    public static final CallbackWithListeners<Object> onCommandIdToHandlerChanged = new CallbackWithListeners<>();
    private static Map<String, InteractiveCommandCustomHandler> commandIdToHandler = new HashMap<>();

    private static void setCommandIdToHandler(Map<String, InteractiveCommandCustomHandler> commandIdToHandler0) {
        commandIdToHandler = commandIdToHandler0;
        onCommandIdToHandlerChanged.call(commandIdToHandler0);
    }

    public static Map<String, InteractiveCommandCustomHandler> getCommandIdToHandler() {
        return commandIdToHandler;
    }

    public boolean isValid() {
        if (this.name != null && this.name.trim().length() > 0 && this.keybinding != null
                && this.keybinding.trim().length() > 0 && this.commandText != null) {
            //it may be valid, let's check if the keybinding actually resolves.
            try {
                KeyBindingHelper.getKeySequence(keybinding);
            } catch (IllegalArgumentException | ParseException e) {
                return false;
            }
            return true;
        }
        return false;
    }
}
