/*******************************************************************************
 * Copyright (c) 2011, 2017 IBM Corporation and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     IBM Corporation - initial API and implementation
 ******************************************************************************/

package org.eclipse.e4.ui.bindings.internal;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.TreeSet;
import org.eclipse.core.commands.ParameterizedCommand;
import org.eclipse.core.commands.common.CommandException;
import org.eclipse.core.commands.common.NotDefinedException;
import org.eclipse.e4.core.contexts.IEclipseContext;
import org.eclipse.e4.ui.bindings.EBindingService;
import org.eclipse.e4.ui.bindings.keys.KeyBindingDispatcher;
import org.eclipse.jface.bindings.Binding;
import org.eclipse.jface.dialogs.Dialog;
import org.eclipse.jface.dialogs.PopupDialog;
import org.eclipse.jface.window.Window;
import org.eclipse.swt.SWT;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.graphics.Rectangle;
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.layout.GridLayout;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Control;
import org.eclipse.swt.widgets.Event;
import org.eclipse.swt.widgets.Label;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.swt.widgets.Table;
import org.eclipse.swt.widgets.TableColumn;
import org.eclipse.swt.widgets.TableItem;

/**
 * <p>
 * A dialog displaying a list of key bindings. The dialog will execute a command if it is selected.
 * </p>
 * <p>
 * The methods on this class are not thread-safe and must be run from the UI thread.
 * </p>
 *
 * @since 3.1
 */
public class KeyAssistDialog extends PopupDialog {

	/**
	 * The data key for the binding stored on an SWT widget. The key is a fully-qualified name, but
	 * in reverse order. This is so that the equals method will detect misses faster.
	 */
	private static final String BINDING_KEY = "Binding.bindings.jface.eclipse.org"; //$NON-NLS-1$

	/**
	 * The value of <code>previousWidth</code> to set if there is no remembered width.
	 */
	private static final int NO_REMEMBERED_WIDTH = -1;

	/**
	 * The ordered list of command identifiers corresponding to the table.
	 */
	private List<Binding> bindings = new ArrayList<>();

	/**
	 * The table containing of the possible completions. This value is <code>null</code> until the
	 * dialog is created.
	 */
	private Table completionsTable = null;

	/**
	 * The width of the shell when it was previously open. This is only remembered until
	 * <code>clearRememberedState()</code> is called.
	 */
	private int previousWidth = NO_REMEMBERED_WIDTH;

	/**
	 * The key binding listener for the associated workbench.
	 */
	private KeyBindingDispatcher workbenchKeyboard;

	/**
	 * A sorted map of conflicts or partial matches to be used when the dialog pops up.
	 *
	 * @since 3.3
	 */
	private Collection<Binding> matches;

	private IEclipseContext context;

	/**
	 * Constructs a new instance of <code>KeyAssistDialog</code>. When the dialog is first
	 * constructed, it contains no widgets. The dialog is first created with no parent. If a parent
	 * is required, call <code>setParentShell()</code>. Also, between uses, it might be necessary to
	 * call <code>setParentShell()</code> as well.
	 *
	 * @param context
	 *            The context in which this dialog is created; must not be <code>null</code>.
	 * @param associatedKeyboard
	 *            The key binding listener for the workbench; must not be <code>null</code>.
	 */
	public KeyAssistDialog(IEclipseContext context, KeyBindingDispatcher associatedKeyboard) {
		super((Shell) null, PopupDialog.INFOPOPUP_SHELLSTYLE, true, false, false, false, false, null, null);
		this.context = context;
		this.workbenchKeyboard = associatedKeyboard;
	}

	/**
	 * Clears out the remembered state of the key assist dialog. This includes its width, as well as
	 * the selected binding.
	 */
	public void clearRememberedState() {
		previousWidth = NO_REMEMBERED_WIDTH;
	}

	/**
	 * Closes this shell, but first remembers some state of the dialog. This way it will have a
	 * response if asked to open the dialog again or if asked to open the keys preference page. This
	 * does not remember the internal state.
	 *
	 * @return Whether the shell was already closed.
	 */
	@Override
	public boolean close() {
		return close(false);
	}

	/**
	 * Closes this shell, but first remembers some state of the dialog. This way it will have a
	 * response if asked to open the dialog again or if asked to open the keys preference page.
	 *
	 * @param rememberState
	 *            Whether the internal state should be remembered.
	 * @return Whether the shell was already closed.
	 */
	public boolean close(boolean rememberState) {
		return close(rememberState, true);
	}

	/**
	 * Closes this shell, but first remembers some state of the dialog. This way it will have a
	 * response if asked to open the dialog again or if asked to open the keys preference page.
	 *
	 * @param rememberState
	 *            Whether the internal state should be remembered.
	 * @param resetState
	 *            Whether the state should be reset.
	 * @return Whether the shell was already closed.
	 */
	private boolean close(boolean rememberState, boolean resetState) {
		Shell shell = getShell();
		if (rememberState) {

			// Remember the previous width.
			int widthToRemember;
			if ((shell != null) && (!shell.isDisposed())) {
				widthToRemember = getShell().getSize().x;
			} else {
				widthToRemember = NO_REMEMBERED_WIDTH;
			}

			this.previousWidth = widthToRemember;
			completionsTable = null;
		}
		matches = null;
		boolean popupClosed = super.close();
		if (resetState) {
			workbenchKeyboard.resetState();
		}
		return popupClosed;
	}

	/**
	 * Sets the position for the dialog based on the position of the workbench window. The dialog is
	 * flush with the bottom right corner of the workbench window. However, the dialog will not
	 * appear outside of the display's client area.
	 *
	 * @param size
	 *            The final size of the dialog; must not be <code>null</code>.
	 */
	private void configureLocation(Point size) {
		Shell shell = getShell();

		Shell workbenchWindowShell = (Shell) shell.getParent();
		int xCoord;
		int yCoord;
		if (workbenchWindowShell != null) {
			/*
			 * Position the shell at the bottom right corner of the workbench window
			 */
			Rectangle workbenchWindowBounds = workbenchWindowShell.getBounds();
			xCoord = workbenchWindowBounds.x + workbenchWindowBounds.width - size.x - 10;
			yCoord = workbenchWindowBounds.y + workbenchWindowBounds.height - size.y - 10;

		} else {
			xCoord = 0;
			yCoord = 0;

		}
		Rectangle bounds = new Rectangle(xCoord, yCoord, size.x, size.y);
		shell.setBounds(getConstrainedShellBounds(bounds));
	}

	/**
	 * Sets the size for the dialog based on its previous size. The width of the dialog is its
	 * previous width, if it exists. Otherwise, it is simply the packed width of the dialog. The
	 * maximum width is 40% of the workbench window's width. The dialog's height is the packed
	 * height of the dialog to a maximum of half the height of the workbench window.
	 *
	 * @return The size of the dialog
	 */
	private Point configureSize() {
		Shell shell = getShell();

		// Get the packed size of the shell.
		shell.pack();
		Point size = shell.getSize();

		// Use the previous width if appropriate.
		if ((previousWidth != NO_REMEMBERED_WIDTH) && (previousWidth > size.x)) {
			size.x = previousWidth;
		}

		// Enforce maximum sizing.
		Shell workbenchWindowShell = (Shell) shell.getParent();
		if (workbenchWindowShell != null) {
			Point workbenchWindowSize = workbenchWindowShell.getSize();
			int maxWidth = workbenchWindowSize.x * 2 / 5;
			int maxHeight = workbenchWindowSize.y / 2;
			if (size.x > maxWidth) {
				size.x = maxWidth;
			}
			if (size.y > maxHeight) {
				size.y = maxHeight;
			}
		}

		// Set the size for the shell.
		shell.setSize(size);
		return size;
	}

	/**
	 * Creates the content area for the key assistant. This creates a table and places it inside the
	 * composite. The composite will contain a list of all the key bindings.
	 *
	 * @param parent
	 *            The parent composite to contain the dialog area; must not be <code>null</code>.
	 */
	@Override
	protected Control createDialogArea(Composite parent) {

		// Create a composite for the dialog area.
		Composite composite = new Composite(parent, SWT.NONE);
		GridLayout compositeLayout = new GridLayout();
		compositeLayout.marginHeight = 0;
		compositeLayout.marginWidth = 0;
		composite.setLayout(compositeLayout);
		composite.setLayoutData(new GridData(GridData.FILL_BOTH));
		composite.setBackground(parent.getBackground());

		// Layout the partial matches.
		Collection<Binding> bindings;
		// if we're going to display a list of conflicts or partial matches...
		if (matches != null) {
			bindings = matches;
		}
		// else just grab the entire list of active bindings
		else {
			bindings = getActiveBindings();
		}

		if (bindings == null || bindings.isEmpty()) {
			createEmptyDialogArea(composite);
		} else {
			createTableDialogArea(composite, bindings);
		}
		return composite;
	}

	/**
	 * Creates an empty dialog area with a simple message saying there were no matches. This is used
	 * if no partial matches could be found. This should not really ever happen, but might be
	 * possible if the commands are changing while waiting for this dialog to open.
	 *
	 * @param parent
	 *            The parent composite for the dialog area; must not be <code>null</code>.
	 */
	private void createEmptyDialogArea(Composite parent) {
		Label noMatchesLabel = new Label(parent, SWT.NULL);
		noMatchesLabel.setText("No matches"); //$NON-NLS-1$
		noMatchesLabel.setLayoutData(new GridData(GridData.FILL_BOTH));
		noMatchesLabel.setBackground(parent.getBackground());
	}

	/**
	 * Creates a dialog area with a table of the partial matches for the current key binding state.
	 * The table will be either the minimum width, or <code>previousWidth</code> if it is not
	 * <code>NO_REMEMBERED_WIDTH</code>.
	 *
	 * @param parent
	 *            The parent composite for the dialog area; must not be <code>null</code>.
	 * @param partialMatches
	 *            The lexicographically sorted map of partial matches for the current state; must
	 *            not be <code>null</code> or empty.
	 */
	private void createTableDialogArea(Composite parent, Collection<Binding> partialMatches) {
		// Layout the table.
		completionsTable = new Table(parent, SWT.FULL_SELECTION | SWT.SINGLE);
		GridData gridData = new GridData(GridData.FILL_BOTH);
		completionsTable.setLayoutData(gridData);
		completionsTable.setBackground(parent.getBackground());
		completionsTable.setLinesVisible(true);

		// Initialize the columns and rows.
		bindings.clear();
		TableColumn columnCommandName = new TableColumn(completionsTable, SWT.LEFT, 0);
		TableColumn columnKeySequence = new TableColumn(completionsTable, SWT.LEFT, 1);
		Iterator<Binding> itemsItr = partialMatches.iterator();
		while (itemsItr.hasNext()) {
			Binding binding = itemsItr.next();
			String sequence = binding.getTriggerSequence().format();
			ParameterizedCommand command = binding.getParameterizedCommand();
			try {
				String[] text = { command.getName(), sequence };
				TableItem item = new TableItem(completionsTable, SWT.NULL);
				item.setText(text);
				item.setData(BINDING_KEY, binding);
				bindings.add(binding);
			} catch (NotDefinedException e) {
				// Not much to do, but this shouldn't really happen.
			}
		}

		Dialog.applyDialogFont(parent);
		columnKeySequence.pack();
		if (previousWidth != NO_REMEMBERED_WIDTH) {
			columnKeySequence.setWidth(previousWidth);
		}
		columnCommandName.pack();
		if (completionsTable.getItems().length > 0) {
			completionsTable.setSelection(0);
		}

		/*
		 * If you double-click on the table, it should execute the selected command.
		 */
		completionsTable.addListener(SWT.DefaultSelection, this::executeKeyBinding);
	}

	/**
	 * Handles the default selection event on the table of possible completions. This attempts to
	 * execute the given command.
	 */
	private void executeKeyBinding(Event trigger) {
		int selectionIndex = completionsTable.getSelectionIndex();
		// Try to execute the corresponding command.
		if (selectionIndex >= 0) {
			close();
			Binding binding = bindings.get(selectionIndex);
			try {
				// workbenchKeyboard.updateShellKludge(null);
				workbenchKeyboard.executeCommand(binding.getParameterizedCommand(), trigger);
			} catch (CommandException e) {
				// WorkbenchPlugin.log(binding.getParameterizedCommand().toString(), e);
				// TODO we probably need to log something here.
				System.err.println(binding.getParameterizedCommand() + " : " + e); //$NON-NLS-1$
			}
		}
	}

	private Collection<Binding> getActiveBindings() {

		EBindingService bindingService = context.getActiveLeaf().get(EBindingService.class);

		Iterator<Binding> iter, matchesIter;
		Binding binding, bindingToAdd;
		Collection<Binding> matchesForCommand;
		Collection<Binding> activeBindings = bindingService.getActiveBindings();
		Collection<Binding> conflictBindings = bindingService.getAllConflicts();
		Collection<Binding> sortedMatches = new TreeSet<>((binding1, binding2) -> {
			ParameterizedCommand cmdA = binding1.getParameterizedCommand();
			ParameterizedCommand cmdB = binding2.getParameterizedCommand();
			int result = 0;
			try {
				result = cmdA.getName().compareTo(cmdB.getName());
			} catch (NotDefinedException e) {
				// whaaa?
			}
			return result;
		});

		// if the active scheme is not the default scheme then we should clean up the active
		// bindings list... if we find multiple bindings for the same command and they are for
		// different schemes, then we need to handle which one should be displayed in the dialog
		if (activeBindings != null) {
			iter = activeBindings.iterator();
			while (iter.hasNext()) {
				binding = iter.next();
				matchesForCommand = bindingService
						.getBindingsFor(binding.getParameterizedCommand());
				// if there is more than one match, then look for a binding that does not belong to
				// the default scheme. If they all belong to the default scheme or they all do NOT
				// belong to the default scheme, then arbitrarily choose one
				if (matchesForCommand != null && matchesForCommand.size() > 1) {
					bindingToAdd = null;

					matchesIter = matchesForCommand.iterator();
					while (matchesIter.hasNext()) {
						bindingToAdd = matchesIter.next();
						if (!bindingToAdd.getSchemeId().equals(EBindingService.DEFAULT_SCHEME_ID)) {
							sortedMatches.add(bindingToAdd);
							break;
						}
					}
					// if they're all the same, arbitrarily choose one
					if (bindingToAdd != null) {
						sortedMatches.add(bindingToAdd);
					}
				}
				// if there is only one match, then just add it
				else if (matchesForCommand != null && matchesForCommand.size() == 1) {
					sortedMatches.addAll(matchesForCommand);
				}
			}
		}
		if (conflictBindings != null) {
			iter = conflictBindings.iterator();
			while (iter.hasNext()) {
				binding = iter.next();
				sortedMatches.add(binding);
			}
		}
		return sortedMatches;
	}

	/**
	 * Opens this dialog. This method can be called multiple times on the same dialog. This only
	 * opens the dialog if there is no remembered state; if there is remembered state, then it tries
	 * to open the preference page instead.
	 *
	 * @return The return code from this dialog.
	 */
	@Override
	public int open() {
		// If the dialog is already open, dispose the shell and recreate it.
		Shell shell = getShell();
		if (shell != null) {
			close(false, false);
			return Window.OK;
		}
		create();
		// Bug 412001. Stop ShellActivationListener from creating a context for this.
		getShell().setData("org.eclipse.e4.ui.ignoreDialog", Boolean.TRUE); //$NON-NLS-1$

		// Configure the size and location.
		Point size = configureSize();
		configureLocation(size);

		// Call the super method.
		return super.open();
	}

	/**
	 * Opens this dialog with the list of bindings for the user to select from.
	 *
	 * @return The return code from this dialog.
	 * @since 3.3
	 */
	public int open(Collection<Binding> bindings) {
		matches = new TreeSet<>((a, b) -> {
			Binding bindingA = a;
			Binding bindingB = b;
			ParameterizedCommand commandA = bindingA.getParameterizedCommand();
			ParameterizedCommand commandB = bindingB.getParameterizedCommand();
			try {
				return commandA.getName().compareTo(commandB.getName());
			} catch (NotDefinedException e) {
				// should not happen
				return 0;
			}
		});
		matches.addAll(bindings);

		// If the dialog is already open, dispose the shell and recreate it.
		Shell shell = getShell();
		if (shell != null) {
			close(false, false);
			return Window.OK;
		}
		create();
		// Bug 369860. Stop ShellActivationListener from creating a context for this.
		getShell().setData("org.eclipse.e4.ui.ignoreDialog", Boolean.TRUE); //$NON-NLS-1$
		// Configure the size and location.
		Point size = configureSize();
		configureLocation(size);

		// Call the super method.
		return super.open();
	}

	/**
	 * Exposing this within the keys package.
	 *
	 * @param newParentShell
	 *            The new parent shell; this value may be <code>null</code> if there is to be no
	 *            parent.
	 */
	@Override
	public void setParentShell(Shell newParentShell) {
		super.setParentShell(newParentShell);
	}

	/**
	 * Tests whether the key assist dialog is currently showing a set of bindings.
	 *
	 * @param bindings A set of conflicts or partial matches.
	 * @return Whether the key assist dialog shows the bindings.
	 */
	public boolean isShowingBindings(Collection<Binding> bindings) {
		if (matches == null || !matches.equals(new HashSet<>(bindings))) {
			return false;
		}

		Shell shell = getShell();
		return shell != null && !shell.isDisposed() && shell.isVisible();
	}

	/**
	 * Returns the currently selected binding from the table if the table is not disposed
	 *
	 * @return the currently selected binding or <code>null</code>
	 */
	protected Binding getSelectedBinding() {
		if ((completionsTable != null) && (!completionsTable.isDisposed())) {
			int selectedIndex = completionsTable.getSelectionIndex();
			if (selectedIndex != -1) {
				TableItem selectedItem = completionsTable.getItem(selectedIndex);
				return (Binding) selectedItem.getData(BINDING_KEY);
			}
		}
		return null;
	}
}