/*******************************************************************************
 * Copyright (c) 2000, 2008 IBM Corporation and others.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *     Sergey Prigogin, Google
 *     Anton Leherbauer (Wind River Systems)
 *******************************************************************************/
package org.eclipse.cdt.internal.ui.actions;

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.IRegion;
import org.eclipse.jface.text.IRewriteTarget;
import org.eclipse.jface.text.ITextSelection;
import org.eclipse.jface.text.ITypedRegion;
import org.eclipse.jface.text.Position;
import org.eclipse.jface.text.TextSelection;
import org.eclipse.jface.text.TextUtilities;
import org.eclipse.jface.text.source.ISourceViewer;
import org.eclipse.jface.viewers.ISelection;
import org.eclipse.jface.viewers.ISelectionProvider;
import org.eclipse.swt.custom.BusyIndicator;
import org.eclipse.swt.widgets.Display;
import org.eclipse.ui.IEditorInput;
import org.eclipse.ui.texteditor.IDocumentProvider;
import org.eclipse.ui.texteditor.ITextEditor;
import org.eclipse.ui.texteditor.ITextEditorExtension3;
import org.eclipse.ui.texteditor.TextEditorAction;

import org.eclipse.cdt.core.CCorePlugin;
import org.eclipse.cdt.core.formatter.DefaultCodeFormatterConstants;
import org.eclipse.cdt.core.model.ICProject;
import org.eclipse.cdt.core.model.ITranslationUnit;
import org.eclipse.cdt.ui.CUIPlugin;
import org.eclipse.cdt.ui.text.ICPartitions;

import org.eclipse.cdt.internal.corext.util.CodeFormatterUtil;

import org.eclipse.cdt.internal.ui.editor.CEditor;
import org.eclipse.cdt.internal.ui.editor.IndentUtil;
import org.eclipse.cdt.internal.ui.text.CHeuristicScanner;
import org.eclipse.cdt.internal.ui.text.CIndenter;


/**
 * Indents a line or range of lines in a C document to its correct position. No complete
 * AST must be present, the indentation is computed using heuristics. The algorithm used is fast for
 * single lines, but does not store any information and therefore not so efficient for large line
 * ranges.
 * 
 * @see org.eclipse.cdt.internal.ui.text.CHeuristicScanner
 * @see org.eclipse.cdt.internal.ui.text.CIndenter
 */
public class IndentAction extends TextEditorAction {
	
	/** The caret offset after an indent operation. */
	private int fCaretOffset;
	
	/**
	 * Whether this is the action invoked by TAB. When <code>true</code>, indentation behaves
	 * differently to accommodate normal TAB operation.
	 */
	private final boolean fIsTabAction;
	
	/**
	 * Creates a new instance.
	 * 
	 * @param bundle the resource bundle
	 * @param prefix the prefix to use for keys in <code>bundle</code>
	 * @param editor the text editor
	 * @param isTabAction whether the action should insert tabs if over the indentation
	 */
	public IndentAction(ResourceBundle bundle, String prefix, ITextEditor editor, boolean isTabAction) {
		super(bundle, prefix, editor);
		fIsTabAction= isTabAction;
	}
	
	/*
	 * @see org.eclipse.jface.action.Action#run()
	 */
	@Override
	public void run() {
		// update has been called by the framework
		if (!isEnabled() || !validateEditorInputState())
			return;
		
		ITextSelection selection= getSelection();
		final IDocument document= getDocument();
		
		if (document != null) {
			final int offset= selection.getOffset();
			final int length= selection.getLength();
			final Position end= new Position(offset + length);
			final int firstLine, nLines;
			fCaretOffset= -1;
			
			try {
				firstLine= document.getLineOfOffset(offset);
				// check for marginal (zero-length) lines
				int minusOne= length == 0 ? 0 : 1;
				nLines= document.getLineOfOffset(offset + length - minusOne) - firstLine + 1;
				document.addPosition(end);
			} catch (BadLocationException e) {
				// will only happen on concurrent modification
				CUIPlugin.log(new Status(IStatus.ERROR, CUIPlugin.getPluginId(), IStatus.OK, "", e)); //$NON-NLS-1$
				return;
			}
			
			Runnable runnable= new Runnable() {
				public void run() {
					IRewriteTarget target= (IRewriteTarget)getTextEditor().getAdapter(IRewriteTarget.class);
					if (target != null)
						target.beginCompoundChange();
					
					try {
						CHeuristicScanner scanner= new CHeuristicScanner(document);
						CIndenter indenter= new CIndenter(document, scanner, getCProject());
						final boolean multiLine= nLines > 1;
						boolean hasChanged= false;
						for (int i= 0; i < nLines; i++) {
							hasChanged |= indentLine(document, firstLine + i, offset, indenter, scanner, multiLine);
						}
						
						// update caret position: move to new position when indenting just one line
						// keep selection when indenting multiple
						int newOffset, newLength;
						if (!fIsTabAction && multiLine) {
							newOffset= offset;
							newLength= end.getOffset() - offset;
						} else {
							newOffset= fCaretOffset;
							newLength= 0;
						}
						
						// always reset the selection if anything was replaced
						// but not when we had a single line non-tab invocation
						if (newOffset != -1 && (hasChanged || newOffset != offset || newLength != length))
							selectAndReveal(newOffset, newLength);
						
					} catch (BadLocationException e) {
						// will only happen on concurrent modification
						CUIPlugin.log(new Status(IStatus.ERROR, CUIPlugin.getPluginId(), IStatus.OK, "ConcurrentModification in IndentAction", e)); //$NON-NLS-1$
					} finally {
						document.removePosition(end);
						if (target != null)
							target.endCompoundChange();
					}
				}
			};
			
			if (nLines > 50) {
				Display display= getTextEditor().getEditorSite().getWorkbenchWindow().getShell().getDisplay();
				BusyIndicator.showWhile(display, runnable);
			} else {
				runnable.run();
			}
		}
	}
	
	/**
	 * Selects the given range on the editor.
	 * 
	 * @param newOffset the selection offset
	 * @param newLength the selection range
	 */
	private void selectAndReveal(int newOffset, int newLength) {
		Assert.isTrue(newOffset >= 0);
		Assert.isTrue(newLength >= 0);
		ITextEditor editor= getTextEditor();
		if (editor instanceof CEditor) {
			ISourceViewer viewer= ((CEditor)editor).getViewer();
			if (viewer != null)
				viewer.setSelectedRange(newOffset, newLength);
		} else {
			// this is too intrusive, but will never get called anyway
			getTextEditor().selectAndReveal(newOffset, newLength);
		}
	}

	/**
	 * Indents a single line using the heuristic scanner. Multiline comments are
	 * indented as specified by the <code>CCommentAutoIndentStrategy</code>.
	 * 
	 * @param document the document
	 * @param line the line to be indented
	 * @param caret the caret position
	 * @param indenter the indenter
	 * @param scanner the heuristic scanner
	 * @param multiLine <code>true</code> if more than one line is being indented
	 * @return <code>true</code> if <code>document</code> was modified, <code>false</code> otherwise
	 * @throws BadLocationException if the document got changed concurrently
	 */
	private boolean indentLine(IDocument document, int line, int caret, CIndenter indenter, CHeuristicScanner scanner, boolean multiLine) throws BadLocationException {
		IRegion currentLine= document.getLineInformation(line);
		int offset= currentLine.getOffset();
		int wsStart= offset; // where we start searching for non-WS; after the "//" in single line comments
		
		String indent= null;
		if (offset < document.getLength()) {
			ITypedRegion partition= TextUtilities.getPartition(document, ICPartitions.C_PARTITIONING, offset, true);
			ITypedRegion startingPartition= TextUtilities.getPartition(document, ICPartitions.C_PARTITIONING, offset, false);
			String type= partition.getType();
			if (type.equals(ICPartitions.C_MULTI_LINE_COMMENT)) {
				indent= computeCommentIndent(document, line, scanner, startingPartition);
			} else if (startingPartition.getType().equals(ICPartitions.C_PREPROCESSOR)) {
				indent= computePreprocessorIndent(document, line, startingPartition);
			} else if (!fIsTabAction && startingPartition.getOffset() == offset && startingPartition.getType().equals(ICPartitions.C_SINGLE_LINE_COMMENT)) {
				// line comment starting at position 0 -> indent inside
				if (indentInsideLineComments()) {
					int max= document.getLength() - offset;
					int slashes= 2;
					while (slashes < max - 1 && document.get(offset + slashes, 2).equals("//")) //$NON-NLS-1$
						slashes+= 2;
					
					wsStart= offset + slashes;
					
					StringBuilder computed= indenter.computeIndentation(offset);
					if (computed == null)
						computed= new StringBuilder(0);
					int tabSize= getTabSize();
					while (slashes > 0 && computed.length() > 0) {
						char c= computed.charAt(0);
						if (c == '\t') {
							if (slashes > tabSize)
								slashes-= tabSize;
							else
								break;
						} else if (c == ' ') {
							slashes--;
						} else {
							break;
						}
						
						computed.deleteCharAt(0);
					}
					
					indent= document.get(offset, wsStart - offset) + computed;
				}
			}
		}
		
		// standard C code indentation
		if (indent == null) {
			StringBuilder computed= indenter.computeIndentation(offset);
			if (computed != null)
				indent= computed.toString();
			else
				indent= ""; //$NON-NLS-1$
		}
		
		// change document:
		// get current white space
		int lineLength= currentLine.getLength();
		int end= scanner.findNonWhitespaceForwardInAnyPartition(wsStart, offset + lineLength);
		if (end == CHeuristicScanner.NOT_FOUND) {
			// an empty line
			end= offset + lineLength;
			if (multiLine && !indentEmptyLines())
				indent= ""; //$NON-NLS-1$
		}
		int length= end - offset;
		String currentIndent= document.get(offset, length);
		
		// if we are right before the text start / line end, and already after the insertion point
		// then just shift to the right
		if (fIsTabAction && caret == end && whiteSpaceLength(currentIndent) >= whiteSpaceLength(indent)) {
			int indentWidth= whiteSpaceLength(currentIndent) + getIndentSize();
			String replacement= IndentUtil.changePrefix(currentIndent.trim(), indentWidth, getTabSize(), useSpaces());
			document.replace(offset, length, replacement);
			fCaretOffset= offset + replacement.length();
			return true;
		}
		
		// set the caret offset so it can be used when setting the selection
		if (caret >= offset && caret <= end)
			fCaretOffset= offset + indent.length();
		else
			fCaretOffset= -1;
		
		// only change the document if it is a real change
		if (!indent.equals(currentIndent)) {
			document.replace(offset, length, indent);
			return true;
		}
		return false;
	}

	/**
	 * Computes and returns the indentation for a block comment line.
	 * 
	 * @param document the document
	 * @param line the line in document
	 * @param scanner the scanner
	 * @param partition the comment partition
	 * @return the indent, or <code>null</code> if not computable
	 * @throws BadLocationException
	 */
	private String computeCommentIndent(IDocument document, int line, CHeuristicScanner scanner, ITypedRegion partition) throws BadLocationException {
		return IndentUtil.computeCommentIndent(document, line, scanner, partition);
	}
	
	/**
	 * Computes and returns the indentation for a preprocessor line.
	 * 
	 * @param document the document
	 * @param line the line in document
	 * @param partition the comment partition
	 * @return the indent, or <code>null</code> if not computable
	 * @throws BadLocationException
	 */
	private String computePreprocessorIndent(IDocument document, int line, ITypedRegion partition) throws BadLocationException {
		return IndentUtil.computePreprocessorIndent(document, line, partition);
	}
	
	/**
	 * Returns the size in characters of a string. All characters count one, tabs count the editor's
	 * preference for the tab display
	 * 
	 * @param indent the string to be measured.
	 * @return the size in characters of a string
	 */
	private int whiteSpaceLength(String indent) {
		if (indent == null)
			return 0;
		return IndentUtil.computeVisualLength(indent, getTabSize());
	}

	/**
	 * Returns whether spaces should be used exclusively for indentation, depending on the editor and
	 * formatter preferences.
	 * 
	 * @return <code>true</code> if only spaces should be used
	 */
	private boolean useSpaces() {
		return CCorePlugin.SPACE.equals(getCoreFormatterOption(DefaultCodeFormatterConstants.FORMATTER_TAB_CHAR));
	}
	
	/**
	 * Returns the tab size used by the editor, which is deduced from the
	 * formatter preferences.
	 * 
	 * @return the tab size as defined in the current formatter preferences
	 */
	private int getTabSize() {
		return getCoreFormatterOption(DefaultCodeFormatterConstants.FORMATTER_TAB_SIZE, 4);
	}

	/**
	 * Returns the indent size used by the editor, which is deduced from the
	 * formatter preferences.
	 * 
	 * @return the indent size as defined in the current formatter preferences
	 */
	private int getIndentSize() {
		return CodeFormatterUtil.getIndentWidth(getCProject());
	}

	/**
	 * Returns <code>true</code> if empty lines should be indented, <code>false</code> otherwise.
	 * 
	 * @return <code>true</code> if empty lines should be indented, <code>false</code> otherwise
	 */
	private boolean indentEmptyLines() {
		return DefaultCodeFormatterConstants.TRUE.equals(getCoreFormatterOption(DefaultCodeFormatterConstants.FORMATTER_INDENT_EMPTY_LINES));
	}
	
	/**
	 * Returns <code>true</code> if line comments at column 0 should be indented inside, <code>false</code> otherwise.
	 * 
	 * @return <code>true</code> if line comments at column 0 should be indented inside, <code>false</code> otherwise.
	 */
	private boolean indentInsideLineComments() {
		return DefaultCodeFormatterConstants.TRUE.equals(getCoreFormatterOption(DefaultCodeFormatterConstants.FORMATTER_INDENT_INSIDE_LINE_COMMENTS));
	}
	
	/**
	 * Returns the possibly project-specific core preference defined under <code>key</code>.
	 * 
	 * @param key the key of the preference
	 * @return the value of the preference
	 */
	private String getCoreFormatterOption(String key) {
		ICProject project= getCProject();
		if (project == null)
			return CCorePlugin.getOption(key);
		return project.getOption(key, true);
	}

	/**
	 * Returns the possibly project-specific core preference defined under <code>key</code>, or
	 * <code>def</code> if the value is not a integer.
	 * 
	 * @param key the key of the preference
	 * @param def the default value
	 * @return the value of the preference
	 */
	private int getCoreFormatterOption(String key, int def) {
		try {
			return Integer.parseInt(getCoreFormatterOption(key));
		} catch (NumberFormatException e) {
			return def;
		}
	}

	/**
	 * Returns the <code>ICProject</code> of the current editor input, or
	 * <code>null</code> if it cannot be found.
	 * 
	 * @return the <code>ICProject</code> of the current editor input, or
	 *         <code>null</code> if it cannot be found
	 */
	private ICProject getCProject() {
		ITextEditor editor= getTextEditor();
		if (editor == null)
			return null;
		
		ITranslationUnit cu= CUIPlugin.getDefault().getWorkingCopyManager().getWorkingCopy(editor.getEditorInput());
		if (cu == null)
			return null;
		return cu.getCProject();
	}

	/**
	 * Returns the editor's selection provider.
	 * 
	 * @return the editor's selection provider or <code>null</code>
	 */
	private ISelectionProvider getSelectionProvider() {
		ITextEditor editor= getTextEditor();
		if (editor != null) {
			return editor.getSelectionProvider();
		}
		return null;
	}
	
	/*
	 * @see org.eclipse.ui.texteditor.IUpdate#update()
	 */
	@Override
	public void update() {
		super.update();
		
		if (isEnabled()) {
			if (fIsTabAction)
				setEnabled(canModifyEditor() && isSmartMode() && isValidSelection());
			else
				setEnabled(canModifyEditor() && !getSelection().isEmpty());
		}
	}
	
	/**
	 * Returns if the current selection is valid, i.e. whether it is empty and the caret in the
	 * whitespace at the start of a line, or covers multiple lines.
	 * 
	 * @return <code>true</code> if the selection is valid for an indent operation
	 */
	private boolean isValidSelection() {
		ITextSelection selection= getSelection();
		if (selection.isEmpty())
			return false;
		
		int offset= selection.getOffset();
		int length= selection.getLength();
		
		IDocument document= getDocument();
		if (document == null)
			return false;
		
		try {
			IRegion firstLine= document.getLineInformationOfOffset(offset);
			int lineOffset= firstLine.getOffset();
			
			// either the selection has to be empty and the caret in the WS at the line start
			// or the selection has to extend over multiple lines
			if (length == 0) {
				return document.get(lineOffset, offset - lineOffset).trim().length() == 0;
			}
//			return lineOffset + firstLine.getLength() < offset + length;
			return false; // only enable for empty selections for now
		} catch (BadLocationException e) {
		}
		
		return false;
	}
	
	/**
	 * Returns the smart preference state.
	 * 
	 * @return <code>true</code> if smart mode is on, <code>false</code> otherwise
	 */
	private boolean isSmartMode() {
		ITextEditor editor= getTextEditor();
		
		if (editor instanceof ITextEditorExtension3)
			return ((ITextEditorExtension3) editor).getInsertMode() == ITextEditorExtension3.SMART_INSERT;
		
		return false;
	}
	
	/**
	 * Returns the document currently displayed in the editor, or <code>null</code> if none can be
	 * obtained.
	 * 
	 * @return the current document or <code>null</code>
	 */
	private IDocument getDocument() {
		
		ITextEditor editor= getTextEditor();
		if (editor != null) {
			
			IDocumentProvider provider= editor.getDocumentProvider();
			IEditorInput input= editor.getEditorInput();
			if (provider != null && input != null)
				return provider.getDocument(input);
			
		}
		return null;
	}
	
	/**
	 * Returns the selection on the editor or an invalid selection if none can be obtained. Returns
	 * never <code>null</code>.
	 * 
	 * @return the current selection, never <code>null</code>
	 */
	private ITextSelection getSelection() {
		ISelectionProvider provider= getSelectionProvider();
		if (provider != null) {
			
			ISelection selection= provider.getSelection();
			if (selection instanceof ITextSelection)
				return (ITextSelection) selection;
		}
		
		// null object
		return TextSelection.emptySelection();
	}
}
