/*******************************************************************************
 * Copyright (c) 2009, 2018 Red Hat Inc. and others.
 *
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     Jeff Briggs, Henry Hughes, Ryan Morse, Roland Grunberg, Anithra P J
 *******************************************************************************/

package org.eclipse.linuxtools.internal.systemtap.ui.ide.handlers;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.net.URI;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;

import org.eclipse.core.commands.AbstractHandler;
import org.eclipse.core.commands.ExecutionEvent;
import org.eclipse.core.commands.ExecutionException;
import org.eclipse.core.filesystem.URIUtil;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.Path;
import org.eclipse.jface.dialogs.MessageDialog;
import org.eclipse.jface.preference.IPreferenceStore;
import org.eclipse.jface.window.Window;
import org.eclipse.jface.wizard.ProgressMonitorPart;
import org.eclipse.linuxtools.internal.systemtap.ui.ide.IDEPlugin;
import org.eclipse.linuxtools.internal.systemtap.ui.ide.Localization;
import org.eclipse.linuxtools.internal.systemtap.ui.ide.editors.stp.PathEditorInput;
import org.eclipse.linuxtools.internal.systemtap.ui.ide.launcher.SystemTapScriptLaunch;
import org.eclipse.linuxtools.internal.systemtap.ui.ide.preferences.EnvironmentVariablesPreferencePage;
import org.eclipse.linuxtools.internal.systemtap.ui.ide.preferences.IDEPreferenceConstants;
import org.eclipse.linuxtools.internal.systemtap.ui.ide.structures.StapErrorParser;
import org.eclipse.linuxtools.systemtap.graphing.ui.widgets.ExceptionErrorDialog;
import org.eclipse.linuxtools.systemtap.ui.consolelog.ScpClient;
import org.eclipse.linuxtools.systemtap.ui.consolelog.structures.RemoteScriptOptions;
import org.eclipse.linuxtools.systemtap.ui.consolelog.structures.ScriptConsole;
import org.eclipse.swt.layout.FillLayout;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.ui.IEditorInput;
import org.eclipse.ui.IEditorPart;
import org.eclipse.ui.IEditorReference;
import org.eclipse.ui.IPathEditorInput;
import org.eclipse.ui.IWorkbenchPage;
import org.eclipse.ui.IWorkbenchWindow;
import org.eclipse.ui.PartInitException;
import org.eclipse.ui.PlatformUI;
import org.eclipse.ui.handlers.HandlerUtil;
import org.eclipse.ui.ide.ResourceUtil;

import com.jcraft.jsch.JSchException;

/**
 * This <code>Action</code> is used to run a SystemTap script that is currently
 * open in the editor.
 *
 * @since 2.0
 */

public class RunScriptHandler extends AbstractHandler {

    /**
     * @since 2.0
     */
    private RemoteScriptOptions remoteOptions = null;
    private IEditorPart targetEditor = null;
    private String fileName = null;
    private String tmpfileName = null;
    private IPath path = null;
    private IProject project = null;
    private SystemTapScriptLaunch launch = null;
    private final List<String> cmdList = new ArrayList<>();

    /**
     * @since 2.0
     */
    public void setPath(IPath path) {
        this.path = path;
        URI uri = URIUtil.toURI(path);
        IFile[] files = ResourcesPlugin.getWorkspace().getRoot().findFilesForLocationURI(uri);
        if (files.length > 0) {
            this.project = files[0].getProject();
        }
    }

    /**
     * @since 2.3
     */
    public void setLaunch(SystemTapScriptLaunch launch) {
        this.launch = launch;
    }

    /**
     * Adds the given String to the list of commands to be
     * passed to systemtap when running the command
     * @param option
     */
    public void addComandLineOptions(String option) {
        cmdList.add(option);
    }

    /**
     * Set the options for running the script remotely. If the script is to be run locally,
     * pass <code>null</code> as the only parameter.
     * @param remoteOptions The remote options of the script run, or <code>null</code> if the script
     * is to be run locally.
     * @since 3.0
     */
    public void setRemoteScriptOptions(RemoteScriptOptions remoteOptions) {
        this.remoteOptions = remoteOptions;
    }

    public boolean getRunLocal() {
        return remoteOptions == null;
    }

    /**
     * @since 2.1
     */
    public IProject getProject() {
        return project;
    }

    /**
     * The main body of this event. Starts by making sure the current editor is valid to run,
     * then builds the command line arguments for stap and retrieves the environment variables.
     * Finally, it gets an instance of <code>ScriptConsole</code> to run the script.
     */
    @Override
    public Object execute(ExecutionEvent event) throws ExecutionException {
        try {
            executeAction(event);
        } catch (ExecutionException e) {
            // If the event isn't null, an error dialog must be displayed now.
            if (event != null) {
                ExceptionErrorDialog.openError(
                        Localization.getString("RunScriptHandler.Error"), //$NON-NLS-1$
                        Localization.getString("RunScriptHandler.ErrorMessage"), e); //$NON-NLS-1$
            }
            throw e;
        }
        return null;
    }

    private void executeAction(ExecutionEvent event) throws ExecutionException {
        final boolean local = getRunLocal();
        findTargetEditor(event);
        findFilePath();
        tryEditorSave(event);
        if (!local) {
            prepareNonLocalScript();
        }
        final String[] script = buildStandardScript();
        final String[] envVars = EnvironmentVariablesPreferencePage.getEnvironmentVariables();
        Display.getDefault().asyncExec(() -> {
		    String name = getConsoleName();
		    if (ScriptConsole.instanceIsRunning(name)) {
		        MessageDialog dialog = new MessageDialog(
		                PlatformUI.getWorkbench().getActiveWorkbenchWindow().getShell(),
		                Messages.RunScriptHandler_AlreadyRunningDialogTitle, null,
		                MessageFormat.format(Messages.RunScriptHandler_AlreadyRunningDialogMessage, fileName),
		                MessageDialog.QUESTION, new String[]{"Yes", "No"}, 0); //$NON-NLS-1$ //$NON-NLS-2$
		        if (dialog.open() != Window.OK) {
		            if (launch != null) {
		                launch.forceRemove();
		            }
		            return;
		        }
		    }
		    final ScriptConsole console = ScriptConsole.getInstance(name);
		    if (!local) {
		        console.run(script, envVars, remoteOptions, new StapErrorParser());
		    } else {
		        console.runLocally(script, envVars, new StapErrorParser(), getProject());
		    }
		    scriptConsoleInitialized(console);
		});
    }

    private String getConsoleName() {
        return getRunLocal() ? fileName :
            MessageFormat.format(Messages.RunScriptHandler_NonLocalTitle,
                    fileName, remoteOptions.userName, remoteOptions.hostName);
    }

    /**
     * Once a console for running the script has been created this
     * function is called so that observers can be added for example
     * @param console
     * @since 2.0
     */
    protected void scriptConsoleInitialized(ScriptConsole console) {
        if (launch != null && path != null) {
            launch.setConsole(console);
        }
    }

    private void findTargetEditor(ExecutionEvent event) {
        if (event != null) {
            targetEditor = HandlerUtil.getActiveEditor(event);
        } else {
            for (IWorkbenchWindow window : PlatformUI.getWorkbench().getWorkbenchWindows()) {
                IWorkbenchPage activePage = window.getActivePage();
                IEditorPart edTest = activePage.getActiveEditor();
                if (edTest != null && editorMatchesPath(edTest.getEditorInput())) {
                    targetEditor = edTest;
                } else {
                    for (IEditorReference ref : activePage.getEditorReferences()) {
                        try {
                            if (editorMatchesPath(ref.getEditorInput())) {
                                targetEditor = ref.getEditor(false);
                                break;
                            }
                        } catch (PartInitException e) {
                            continue;
                        }
                    }
                }
            }
        }
    }

    private boolean editorMatchesPath(IEditorInput input) {
        return input instanceof IPathEditorInput && ((IPathEditorInput) (input)).getPath().equals(path);
    }

    /**
     * Checks whether the directory to which the given file
     * belongs is a valid directory. Currently this function just
     * checks if the given file does not belong to the tapset
     * directory.
     * @param fileName
     * @return true if the given path is valid false otherwise.
     * @since 1.2
     */
    private void findFilePath() throws ExecutionException {
        if (path != null) {
            fileName = path.toOSString();
        } else if (targetEditor == null) {
            // Cannot have neither a path nor an editor.
            throw new ExecutionException(Localization.getString("RunScriptHandler.noScriptFile"));  //$NON-NLS-1$
        } else if (targetEditor.getEditorInput() instanceof PathEditorInput) {
            fileName = ((PathEditorInput) targetEditor.getEditorInput()).getPath().toString();
        } else {
            fileName = ResourceUtil.getFile(targetEditor.getEditorInput()).getLocation().toString();
        }
    }

    /**
     * If an editor containing the file to be run is open & dirty, save it, if appropriate.
     * @param event
     */
    private void tryEditorSave(final ExecutionEvent event) {
        // No need to save if the script will already be saved with its project when launched.
        if (project != null) {
            return;
        }

        if (targetEditor != null && targetEditor.isDirty()) {
            Display.getDefault().syncExec(() -> {
			    Shell shell = event != null ? HandlerUtil.getActiveShell(event)
			            : PlatformUI.getWorkbench().getActiveWorkbenchWindow().getShell();
			    targetEditor.doSave(new ProgressMonitorPart(shell, new FillLayout()));
			});
        }
    }

    /**
     * Attempts to set up a channel for a script that runs on a non-local host or user.
     * @return <code>true</code> on success, <code>false</code> on failure.
     * @throws ExecutionException If failure occurs during a (non-simple) launch,
     * this will throw an exception instead of returning <code>false</code>.
     */
    private void prepareNonLocalScript() throws ExecutionException {
        try {
            ScpClient scpclient = new ScpClient(remoteOptions);
            tmpfileName = new Path("/tmp").append(getFileName(fileName)).toOSString(); //$NON-NLS-1$
            scpclient.transfer(fileName, tmpfileName);
        } catch (final JSchException | IOException e) {
            String message = e instanceof JSchException
                    ? Localization.getString("RunScriptHandler.checkCredentials") //$NON-NLS-1$
                    : Localization.getString("RunScriptHandler.ioError"); //$NON-NLS-1$
            throw new ExecutionException(message, e);
        }
    }

    private String getFileName(String fileName) {
        return new File(fileName).getName();
    }

    /**
     * The command line argument generation method used by <code>RunScriptAction</code>. This generates
     * a stap command line that includes the tapsets specified in user preferences, a guru mode flag
     * if necessary, and the path to the script on disk.
     * @return The command to invoke to start the script running in stap.
     * @since 2.0
     */
    private String[] buildStandardScript() throws ExecutionException {
        getImportedTapsets();

        if (isGuru()) {
            cmdList.add("-g"); //$NON-NLS-1$
        }

        return finalizeScript();
    }

    /**
     * Adds the tapsets that the user has added in preferences to the input <code>ArrayList</code>
     * @param cmdList The list to add the user-specified tapset locations to.
     * @since 2.0
     */

    private void getImportedTapsets() {
        IPreferenceStore preferenceStore = IDEPlugin.getDefault().getPreferenceStore();
        String[] tapsets = preferenceStore.getString(IDEPreferenceConstants.P_TAPSETS).split(File.pathSeparator);

        //Get all imported tapsets
        if (tapsets.length > 0 && tapsets[0].trim().length() > 0) {
            for (int i = 0; i < tapsets.length; i++) {
                cmdList.add("-I"); //$NON-NLS-1$
                cmdList.add(tapsets[i]);
            }
        }
    }

    /**
     * Checks the current script to determine if guru mode is required in order to run. This is determined
     * by the presence of embedded C.
     * @return True if the script contains embedded C code.
     */
    private boolean isGuru() throws ExecutionException {
        File f = new File(fileName);
        try (FileReader fr = new FileReader(f)) {
            int curr = 0;
            int prev = 0;
            boolean front = false;
            boolean embedded = false;
            boolean inLineComment = false;
            boolean inBlockComment = false;
            while (-1 != (curr = fr.read())) {
                if (!inLineComment && !inBlockComment && prev == '%' && curr == '{') {
                    front = true;
                } else if (!inLineComment && !inBlockComment && prev == '%' && curr == '}' && front) {
                    embedded = true;
                    break;
                } else if (!inBlockComment && ((prev == '/' && curr == '/') || curr == '#')) {
                    inLineComment = true;
                } else if (!inLineComment && prev == '/' && curr == '*') {
                    inBlockComment = true;
                } else if (curr == '\n') {
                    inLineComment = false;
                } else if (prev == '*' && curr == '/') {
                    inBlockComment = false;
                }
                prev = curr;
            }
            if (embedded) {
                return true;
            }
        } catch (FileNotFoundException fnfe) {
            throw new ExecutionException(Localization.getString("RunScriptHandler.couldNotOpenScriptFile"), fnfe); //$NON-NLS-1$
        } catch (IOException ie) {
            throw new ExecutionException(Localization.getString("RunScriptHandler.fileIOError"), ie); //$NON-NLS-1$
        }
        return false;
    }

    /**
     * Produces a <code>String[]</code> from the <code>ArrayList</code> passed in with stap inserted
     * as the first entry, and the filename as the last entry. Used to convert the arguments generated
     * earlier in <code>buildStandardScript</code> such as tapset locations and guru mode into an actual
     * command line argument array that can be passed to <code>Runtime.exec</code>.
     * @return An array suitable to pass to <code>Runtime.exec</code> to start stap on this file.
     * @since 2.0
     */
    private String[] finalizeScript() throws ExecutionException {
        // Make sure script name only contains underscores and/or alphanumeric characters.
        if (!Pattern.matches("^[a-z0-9_A-Z]+$", //$NON-NLS-1$
                getFileNameWithoutExtension(getFileName(fileName)))) {
            throw new ExecutionException(Messages.RunScriptHandler_InvalidScriptMessage);
        }

        String[] script = new String[cmdList.size() + 2];
        script[0] = "stap"; //$NON-NLS-1$
        script[script.length - 1] = !getRunLocal() ? tmpfileName : fileName;

        for (int i = 0; i < cmdList.size(); i++) {
            script[i + 1] = cmdList.get(i);
        }
        return script;
    }

    private String getFileNameWithoutExtension(String fileName) {
        int dotIndex = fileName.lastIndexOf('.');
        return dotIndex != -1 ? fileName.substring(0, dotIndex) : fileName;
    }

}
