/*******************************************************************************
 * Copyright (c) 2000, 2015 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:
 *     Genady Beryozkin, me@genady.org - initial API and implementation
 *     Fabio Zadrozny <fabiofz at gmail dot com> - [typing] HippieCompleteAction is slow  ( Alt+/ ) - https://bugs.eclipse.org/bugs/show_bug.cgi?id=270385
 *******************************************************************************/
package org.eclipse.ui.texteditor;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.ResourceBundle;

import org.eclipse.core.runtime.Assert;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;

import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IRewriteTarget;
import org.eclipse.jface.text.ITextSelection;
import org.eclipse.jface.text.source.ISourceViewer;

import org.eclipse.ui.internal.texteditor.CompoundEditExitStrategy;
import org.eclipse.ui.internal.texteditor.HippieCompletionEngine;
import org.eclipse.ui.internal.texteditor.TextEditorPlugin;


/**
 * This class implements the emacs style completion action. Completion action is
 * a stateful action, as the user may invoke it several times in a row in order
 * to scroll the possible completions.
 *
 * TODO: Sort by editor type
 * TODO: Provide history option
 *
 * @since 3.1
 */
final class HippieCompleteAction extends TextEditorAction {

	/**
	 * This class represents the state of the last completion process. Each time
	 * the user moves to a new position and calls this action an instance of
	 * this inner class is created and saved in
	 * {@link HippieCompleteAction#fLastCompletion}.
	 */
	private static class CompletionState {

		/** The length of the last suggestion string */
		int length;

		/** The index of next suggestion (index into the suggestion array) */
		int nextSuggestion;

		/** The caret position at which we insert the suggestions */
		final int startOffset;

		/**
		 * Iterator of Strings with suggestions computed when the completion action is invoked
		 *
		 * @since 3.6
		 */
		private final Iterator<String> suggestions;

		/**
		 * List of Strings with the suggestions that are already consumed from the iterator
		 *
		 * @since 3.6
		 */
		private final List<String> consumedSuggestions;

		/**
		 * Do we have only 1 (empty) completion available?
		 *
		 * @since 3.6
		 */
		private final boolean hasOnly1EmptySuggestion;

		/**
		 * Set with the String completions found so that we can make them unique
		 *
		 * @since 3.6
		 */
		private final HashSet<String> alreadyFound;

		/**
		 * Create a new completion state object
		 *
		 * @param suggestions the iterator of Strings with possible completions
		 * @param startOffset the position in the parent document at which the completions will be
		 *            inserted.
		 */
		CompletionState(Iterator<String> suggestions, int startOffset) {
			this.suggestions= suggestions;
			this.consumedSuggestions= new ArrayList<>();
			this.alreadyFound= new HashSet<>();
			this.startOffset= startOffset;
			this.length= 0;
			this.nextSuggestion= 0;


			//Let's see if only 1 is available.
			if (this.suggestions.hasNext()) {
				addNewToken(this.suggestions.next());

				boolean hasOnly1Temp= true;
				while (this.suggestions.hasNext()) {
					Object next= this.suggestions.next();
					if (consumedSuggestions.contains(next)) {
						continue;
					}
					addNewToken((String)next);
					hasOnly1Temp= false;
					break;
				}
				this.hasOnly1EmptySuggestion= hasOnly1Temp;
			} else {
				throw new AssertionError("At least the empty completion must be available in the iterator!"); //$NON-NLS-1$
			}
		}

		/**
		 * Advances the completion state to represent the next completion (starts cycling when it
		 * gets to the end).
		 *
		 * @return The next suggestion to be shown to the user.
		 * @since 3.6
		 */
		public String next() {
			String ret= null;
			if (this.consumedSuggestions.size() > nextSuggestion) {
				//We already consumed one that we didn't return
				ret= this.consumedSuggestions.get(nextSuggestion);
				nextSuggestion++;

			}

			while (ret == null &&
					this.consumedSuggestions.size() == nextSuggestion &&
					this.suggestions.hasNext()) {
				//we're just in the place to get a new one from the iterator
				String temp= this.suggestions.next();
				if (this.alreadyFound.contains(temp)) {
					continue;//go to next iteration
				}
				addNewToken(temp);
				ret= temp;
				nextSuggestion++;

			}

			if (ret == null) {
				//we consumed all in the iterator, so, just start cycling.
				ret= this.consumedSuggestions.get(0);
				nextSuggestion= 1; //we just got the 0, so, we can already skip to 1.
			}


			length= ret.length();
			return ret;
		}

		/**
		 * Adds a new suggestion to the found and consumed suggestions
		 *
		 * @param suggestion the suggestion to be added
		 * @since 3.6
		 */
		private void addNewToken(String suggestion) {
			this.alreadyFound.add(suggestion);
			this.consumedSuggestions.add(suggestion);
		}
	}

	/**
	 * The document that will be manipulated (currently open in the editor)
	 */
	private IDocument fDocument;

	/**
	 * The completion state that is used to continue the iteration over
	 * completion suggestions
	 */
	private CompletionState fLastCompletion= null;

	/**
	 * The completion engine
	 */
	private final HippieCompletionEngine fEngine= new HippieCompletionEngine();

	/** The compound edit exit strategy. */
	private final CompoundEditExitStrategy fExitStrategy= new CompoundEditExitStrategy(ITextEditorActionDefinitionIds.HIPPIE_COMPLETION);

	/**
	 * Creates a new action.
	 *
	 * @param bundle the resource bundle
	 * @param prefix a prefix to be prepended to the various resource keys
	 *        (described in <code>ResourceAction</code> constructor), or
	 *        <code>null</code> if none
	 * @param editor the text editor
	 */
	HippieCompleteAction(ResourceBundle bundle, String prefix, ITextEditor editor) {
		super(bundle, prefix, editor);
		fExitStrategy.addCompoundListener(this::clearState);
	}

	/**
	 * Invalidates the cached completions, removes all registered listeners and
	 * sets the cached document to <code>null</code>.
	 */
	private void clearState() {
		fLastCompletion= null;

		ITextEditor editor= getTextEditor();

		if (editor != null) {
			IRewriteTarget target= editor.getAdapter(IRewriteTarget.class);
			if (target != null) {
				fExitStrategy.disarm();
				target.endCompoundChange();
			}
		}

		fDocument= null;
	}

	/**
	 * Perform the next completion.
	 */
	private void completeNext() {
		try {
			fDocument.replace(fLastCompletion.startOffset, fLastCompletion.length, fLastCompletion.next()); //next() will already advance
		} catch (BadLocationException e) {
			// we should never get here. different from other places to notify the user.
			log(e);
			clearState();
			return;
		}

		// move the caret to the insertion point
		ISourceViewer sourceViewer= ((AbstractTextEditor) getTextEditor()).getSourceViewer();
		sourceViewer.setSelectedRange(fLastCompletion.startOffset + fLastCompletion.length, 0);
		sourceViewer.revealRange(fLastCompletion.startOffset, fLastCompletion.length);

		fExitStrategy.arm(((AbstractTextEditor) getTextEditor()).getSourceViewer());
	}

	/**
	 * Returns the document currently displayed in the editor, or
	 * <code>null</code>
	 *
	 * @return the document currently displayed in the editor, or
	 *         <code>null</code>
	 */
	private IDocument getCurrentDocument() {
		ITextEditor editor= getTextEditor();
		if (editor == null)
			return null;
		IDocumentProvider provider= editor.getDocumentProvider();
		if (provider == null)
			return null;

		IDocument document= provider.getDocument(editor.getEditorInput());
		return document;
	}

	/**
	 * Return the part of a word before the caret. If the caret is not at a
	 * middle/end of a word, returns null.
	 *
	 * @return the prefix at the current cursor position that will be used in
	 *         the search for possible completions
	 * @throws BadLocationException if accessing the document fails
	 */
	private String getCurrentPrefix() throws BadLocationException {
		ITextSelection selection= (ITextSelection) getTextEditor().getSelectionProvider().getSelection();
		if (selection.getLength() > 0) {
			return null;
		}
		return fEngine.getPrefixString(fDocument, selection.getOffset());
	}

	/**
	 * Returns the current selection (or caret) offset.
	 *
	 * @return the current selection (or caret) offset
	 */
	private int getSelectionOffset() {
		return ((ITextSelection) getTextEditor().getSelectionProvider().getSelection()).getOffset();
	}

	/**
	 * Returns <code>true</code> if the current completion state is still
	 * valid given the current document and selection.
	 *
	 * @return <code>true</code> if the cached state is valid,
	 *         <code>false</code> otherwise
	 */
	private boolean isStateValid() {
		return fDocument != null
				&& fDocument.equals(getCurrentDocument())
				&& fLastCompletion != null
				&& fLastCompletion.startOffset + fLastCompletion.length == getSelectionOffset();
	}

	/**
	 * Notifies the user that there are no suggestions.
	 */
	private void notifyUser() {
		// TODO notify via status line?
		getTextEditor().getSite().getShell().getDisplay().beep();
	}

	@Override
	public void run() {
		if (!validateEditorInputState())
			return;

		if (!isStateValid())
			updateState();

		if (isStateValid())
			completeNext();
	}

	@Override
	public boolean isEnabled() {
		return canModifyEditor();
	}

	@Override
	public void setEditor(ITextEditor editor) {
		clearState(); // make sure to remove listers before the editor changes!
		super.setEditor(editor);
	}

	/**
	 * Update the completion state. The completion cache is updated with the
	 * completions based on the currently displayed document and the current
	 * selection. To track the validity of the cached state, listeners are
	 * registered with the editor and document, and the current document is
	 * cached.
	 */
	private void updateState() {
		Assert.isNotNull(getTextEditor());

		clearState();

		List<IDocument> documents= HippieCompletionEngine.computeDocuments(getTextEditor());

		if (!documents.isEmpty()) {
			fDocument= documents.remove(0);

			Iterator<String> suggestions;
			try {
				String prefix= getCurrentPrefix();
				if (prefix == null) {
					notifyUser();
					return;
				}
				suggestions= fEngine.getMultipleDocumentsIterator(
						fDocument, documents, prefix, getSelectionOffset());
			} catch (BadLocationException e) {
				log(e);
				return;
			}

			CompletionState completionState= new CompletionState(
					suggestions, getSelectionOffset());

			// if it is single empty suggestion
			if (completionState.hasOnly1EmptySuggestion) {
				notifyUser();
				return;
			}

			IRewriteTarget target= getTextEditor().getAdapter(IRewriteTarget.class);
			if (target != null)
				target.beginCompoundChange();

			fLastCompletion= completionState;
		}
	}

	/**
	 * Logs the exception.
	 *
	 * @param e the exception
	 */
	private void log(BadLocationException e) {
		String msg= e.getLocalizedMessage();
		if (msg == null)
			msg= "unable to access the document"; //$NON-NLS-1$
		TextEditorPlugin.getDefault().getLog().log(new Status(IStatus.ERROR, TextEditorPlugin.PLUGIN_ID, IStatus.OK, msg, e));
	}
}
