/*******************************************************************************
 * Copyright (c) 2000, 2003 IBM Corporation and others.
 * All rights reserved. This program and the accompanying materials 
 * are made available under the terms of the Common Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/cpl-v10.html
 * 
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *******************************************************************************/

package net.sourceforge.phpdt.internal.ui.text.link;

import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;

import net.sourceforge.phpeclipse.PHPeclipsePlugin;

import org.eclipse.jface.text.Assert;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.BadPositionCategoryException;
import org.eclipse.jface.text.DocumentCommand;
import org.eclipse.jface.text.DocumentEvent;
import org.eclipse.jface.text.IAutoEditStrategy;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IDocumentExtension;
import org.eclipse.jface.text.IDocumentListener;
import org.eclipse.jface.text.IPositionUpdater;
import org.eclipse.jface.text.Position;
import org.eclipse.jface.text.TypedPosition;
import org.eclipse.jface.text.contentassist.ICompletionProposal;

/**
 * This class manages linked positions in a document. Positions are linked by
 * type names. If positions have the same type name, they are considered as
 * <em>linked</em>.
 * 
 * The manager remains active on a document until any of the following actions
 * occurs:
 * 
 * <ul>
 * <li>A document change is performed which would invalidate any of the above
 * constraints.</li>
 * 
 * <li>The method <code>uninstall()</code> is called.</li>
 * 
 * <li>Another instance of <code>LinkedPositionManager</code> tries to gain
 * control of the same document.
 * </ul>
 */
public class LinkedPositionManager implements IDocumentListener,
		IPositionUpdater, IAutoEditStrategy {

	// This class still exists to properly handle code assist.
	// This is due to the fact that it cannot be distinguished betweeen document
	// changes which are
	// issued by code assist and document changes which origin from another text
	// viewer.
	// There is a conflict in interest since in the latter case the linked mode
	// should be left, but in the former case
	// the linked mode should remain.
	// To support content assist, document changes have to be propagated to
	// connected positions
	// by registering replace commands using IDocumentExtension.
	// if it wasn't for the support of content assist, the documentChanged()
	// method could be reduced to
	// a simple call to leave(true)
	private class Replace implements IDocumentExtension.IReplace {

		private Position fReplacePosition;

		private int fReplaceDeltaOffset;

		private int fReplaceLength;

		private String fReplaceText;

		public Replace(Position position, int deltaOffset, int length,
				String text) {
			fReplacePosition = position;
			fReplaceDeltaOffset = deltaOffset;
			fReplaceLength = length;
			fReplaceText = text;
		}

		public void perform(IDocument document, IDocumentListener owner) {
			document.removeDocumentListener(owner);
			try {
				document.replace(fReplacePosition.getOffset()
						+ fReplaceDeltaOffset, fReplaceLength, fReplaceText);
			} catch (BadLocationException e) {
				PHPeclipsePlugin.log(e);
				// TBD
			}
			document.addDocumentListener(owner);
		}
	}

	private static class PositionComparator implements Comparator {
		/*
		 * @see Comparator#compare(Object, Object)
		 */
		public int compare(Object object0, Object object1) {
			Position position0 = (Position) object0;
			Position position1 = (Position) object1;

			return position0.getOffset() - position1.getOffset();
		}
	}

	private static final String LINKED_POSITION_PREFIX = "LinkedPositionManager.linked.position"; //$NON-NLS-1$

	private static final Comparator fgPositionComparator = new PositionComparator();

	private static final Map fgActiveManagers = new HashMap();

	private static int fgCounter = 0;

	private IDocument fDocument;

	private ILinkedPositionListener fListener;

	private String fPositionCategoryName;

	private boolean fMustLeave;

	/**
	 * Flag that records the state of this manager. As there are many different
	 * entities that may call leave or exit, these cannot always be sure whether
	 * the linked position infrastructure is still active. This is especially
	 * true for multithreaded situations.
	 */
	private boolean fIsActive = false;

	/**
	 * Creates a <code>LinkedPositionManager</code> for a
	 * <code>IDocument</code>.
	 * 
	 * @param document
	 *            the document to use with linked positions.
	 * @param canCoexist
	 *            <code>true</code> if this manager can coexist with an
	 *            already existing one
	 */
	public LinkedPositionManager(IDocument document, boolean canCoexist) {
		Assert.isNotNull(document);
		fDocument = document;
		fPositionCategoryName = LINKED_POSITION_PREFIX + (fgCounter++);
		install(canCoexist);
	}

	/**
	 * Creates a <code>LinkedPositionManager</code> for a
	 * <code>IDocument</code>.
	 * 
	 * @param document
	 *            the document to use with linked positions.
	 */
	public LinkedPositionManager(IDocument document) {
		this(document, false);
	}

	/**
	 * Sets a listener to notify changes of current linked position.
	 */
	public void setLinkedPositionListener(ILinkedPositionListener listener) {
		fListener = listener;
	}

	/**
	 * Adds a linked position to the manager with the type being the content of
	 * the document at the specified range. There are the following constraints
	 * for linked positions:
	 * 
	 * <ul>
	 * <li>Any two positions have spacing of at least one character. This
	 * implies that two positions must not overlap.</li>
	 * 
	 * <li>The string at any position must not contain line delimiters.</li>
	 * </ul>
	 * 
	 * @param offset
	 *            the offset of the position.
	 * @param length
	 *            the length of the position.
	 */
	public void addPosition(int offset, int length) throws BadLocationException {
		String type = fDocument.get(offset, length);
		addPosition(offset, length, type);
	}

	/**
	 * Adds a linked position of the specified position type to the manager.
	 * There are the following constraints for linked positions:
	 * 
	 * <ul>
	 * <li>Any two positions have spacing of at least one character. This
	 * implies that two positions must not overlap.</li>
	 * 
	 * <li>The string at any position must not contain line delimiters.</li>
	 * </ul>
	 * 
	 * @param offset
	 *            the offset of the position.
	 * @param length
	 *            the length of the position.
	 * @param type
	 *            the position type name - any positions with the same type are
	 *            linked.
	 */
	public void addPosition(int offset, int length, String type)
			throws BadLocationException {
		Position[] positions = getPositions(fDocument);

		if (positions != null) {
			for (int i = 0; i < positions.length; i++)
				if (collides(positions[i], offset, length))
					throw new BadLocationException(
							LinkedPositionMessages
									.getString(("LinkedPositionManager.error.position.collision"))); //$NON-NLS-1$
		}

		String content = fDocument.get(offset, length);

		if (containsLineDelimiters(content))
			throw new BadLocationException(
					LinkedPositionMessages
							.getString(("LinkedPositionManager.error.contains.line.delimiters"))); //$NON-NLS-1$

		try {
			fDocument.addPosition(fPositionCategoryName, new TypedPosition(
					offset, length, type));
		} catch (BadPositionCategoryException e) {
			PHPeclipsePlugin.log(e);
			Assert.isTrue(false);
		}
	}

	/**
	 * Adds a linked position to the manager. The current document content at
	 * the specified range is taken as the position type.
	 * <p>
	 * There are the following constraints for linked positions:
	 * 
	 * <ul>
	 * <li>Any two positions have spacing of at least one character. This
	 * implies that two positions must not overlap.</li>
	 * 
	 * <li>The string at any position must not contain line delimiters.</li>
	 * </ul>
	 * 
	 * It is usually best to set the first item in
	 * <code>additionalChoices</code> to be equal with the text inserted at
	 * the current position.
	 * </p>
	 * 
	 * @param offset
	 *            the offset of the position.
	 * @param length
	 *            the length of the position.
	 * @param additionalChoices
	 *            a number of additional choices to be displayed when selecting
	 *            a position of this <code>type</code>.
	 */
	public void addPosition(int offset, int length,
			ICompletionProposal[] additionalChoices)
			throws BadLocationException {
		String type = fDocument.get(offset, length);
		addPosition(offset, length, type, additionalChoices);
	}

	/**
	 * Adds a linked position of the specified position type to the manager.
	 * There are the following constraints for linked positions:
	 * 
	 * <ul>
	 * <li>Any two positions have spacing of at least one character. This
	 * implies that two positions must not overlap.</li>
	 * 
	 * <li>The string at any position must not contain line delimiters.</li>
	 * </ul>
	 * 
	 * It is usually best to set the first item in
	 * <code>additionalChoices</code> to be equal with the text inserted at
	 * the current position.
	 * 
	 * @param offset
	 *            the offset of the position.
	 * @param length
	 *            the length of the position.
	 * @param type
	 *            the position type name - any positions with the same type are
	 *            linked.
	 * @param additionalChoices
	 *            a number of additional choices to be displayed when selecting
	 *            a position of this <code>type</code>.
	 */
	public void addPosition(int offset, int length, String type,
			ICompletionProposal[] additionalChoices)
			throws BadLocationException {
		Position[] positions = getPositions(fDocument);

		if (positions != null) {
			for (int i = 0; i < positions.length; i++)
				if (collides(positions[i], offset, length))
					throw new BadLocationException(
							LinkedPositionMessages
									.getString(("LinkedPositionManager.error.position.collision"))); //$NON-NLS-1$
		}

		String content = fDocument.get(offset, length);

		if (containsLineDelimiters(content))
			throw new BadLocationException(
					LinkedPositionMessages
							.getString(("LinkedPositionManager.error.contains.line.delimiters"))); //$NON-NLS-1$

		try {
			fDocument.addPosition(fPositionCategoryName, new ProposalPosition(
					offset, length, type, additionalChoices));
		} catch (BadPositionCategoryException e) {
			PHPeclipsePlugin.log(e);
			Assert.isTrue(false);
		}
	}

	/**
	 * Tests if a manager is already active for a document.
	 */
	public static boolean hasActiveManager(IDocument document) {
		return fgActiveManagers.get(document) != null;
	}

	private void install(boolean canCoexist) {

		if (fIsActive)
			;// JavaPlugin.log(new Status(IStatus.WARNING,
				// JavaPlugin.getPluginId(), IStatus.OK, "LinkedPositionManager
				// is already active: "+fPositionCategoryName, new
				// IllegalStateException())); //$NON-NLS-1$
		else {
			fIsActive = true;
			// JavaPlugin.log(new Status(IStatus.INFO, JavaPlugin.getPluginId(),
			// IStatus.OK, "LinkedPositionManager activated:
			// "+fPositionCategoryName, new Exception())); //$NON-NLS-1$
		}

		if (!canCoexist) {
			LinkedPositionManager manager = (LinkedPositionManager) fgActiveManagers
					.get(fDocument);
			if (manager != null)
				manager.leave(true);
		}

		fgActiveManagers.put(fDocument, this);
		fDocument.addPositionCategory(fPositionCategoryName);
		fDocument.addPositionUpdater(this);
		fDocument.addDocumentListener(this);

		fMustLeave = false;
	}

	/**
	 * Leaves the linked mode. If unsuccessful, the linked positions are
	 * restored to the values at the time they were added.
	 */
	public void uninstall(boolean success) {

		if (!fIsActive)
			// we migth also just return
			;// JavaPlugin(new Status(IStatus.WARNING,
				// JavaPlugin.getPluginId(), IStatus.OK, "LinkedPositionManager
				// activated: "+fPositionCategoryName, new
				// IllegalStateException())); //$NON-NLS-1$
		else {
			fDocument.removeDocumentListener(this);

			try {
				Position[] positions = getPositions(fDocument);
				if ((!success) && (positions != null)) {
					// restore
					for (int i = 0; i != positions.length; i++) {
						TypedPosition position = (TypedPosition) positions[i];
						fDocument.replace(position.getOffset(), position
								.getLength(), position.getType());
					}
				}

				fDocument.removePositionCategory(fPositionCategoryName);

				fIsActive = false;
				// JavaPlugin.log(new Status(IStatus.INFO,
				// JavaPlugin.getPluginId(), IStatus.OK, "LinkedPositionManager
				// deactivated: "+fPositionCategoryName, new Exception()));
				// //$NON-NLS-1$

			} catch (BadLocationException e) {
				PHPeclipsePlugin.log(e);
				Assert.isTrue(false);

			} catch (BadPositionCategoryException e) {
				PHPeclipsePlugin.log(e);
				Assert.isTrue(false);

			} finally {
				fDocument.removePositionUpdater(this);
				fgActiveManagers.remove(fDocument);
			}
		}

	}

	/**
	 * Returns the position at the given offset, <code>null</code> if there is
	 * no position.
	 * 
	 * @since 2.1
	 */
	public Position getPosition(int offset) {
		Position[] positions = getPositions(fDocument);
		if (positions == null)
			return null;

		for (int i = positions.length - 1; i >= 0; i--) {
			Position position = positions[i];
			if (offset >= position.getOffset()
					&& offset <= position.getOffset() + position.getLength())
				return positions[i];
		}

		return null;
	}

	/**
	 * Returns the first linked position.
	 * 
	 * @return returns <code>null</code> if no linked position exist.
	 */
	public Position getFirstPosition() {
		return getNextPosition(-1);
	}

	public Position getLastPosition() {
		Position[] positions = getPositions(fDocument);
		for (int i = positions.length - 1; i >= 0; i--) {
			String type = ((TypedPosition) positions[i]).getType();
			int j;
			for (j = 0; j != i; j++)
				if (((TypedPosition) positions[j]).getType().equals(type))
					break;

			if (j == i)
				return positions[i];
		}

		return null;
	}

	/**
	 * Returns the next linked position with an offset greater than
	 * <code>offset</code>. If another position with the same type and offset
	 * lower than <code>offset</code> exists, the position is skipped.
	 * 
	 * @return returns <code>null</code> if no linked position exist.
	 */
	public Position getNextPosition(int offset) {
		Position[] positions = getPositions(fDocument);
		return findNextPosition(positions, offset);
	}

	private static Position findNextPosition(Position[] positions, int offset) {
		// skip already visited types
		for (int i = 0; i != positions.length; i++) {
			if (positions[i].getOffset() > offset) {
				String type = ((TypedPosition) positions[i]).getType();
				int j;
				for (j = 0; j != i; j++)
					if (((TypedPosition) positions[j]).getType().equals(type))
						break;

				if (j == i)
					return positions[i];
			}
		}

		return null;
	}

	/**
	 * Returns the position with the greatest offset smaller than
	 * <code>offset</code>.
	 * 
	 * @return returns <code>null</code> if no linked position exist.
	 */
	public Position getPreviousPosition(int offset) {
		Position[] positions = getPositions(fDocument);
		if (positions == null)
			return null;

		TypedPosition currentPosition = (TypedPosition) findCurrentPosition(
				positions, offset);
		String currentType = currentPosition == null ? null : currentPosition
				.getType();

		Position lastPosition = null;
		Position position = getFirstPosition();

		while (position != null && position.getOffset() < offset) {
			if (!((TypedPosition) position).getType().equals(currentType))
				lastPosition = position;
			position = findNextPosition(positions, position.getOffset());
		}

		return lastPosition;
	}

	private Position[] getPositions(IDocument document) {

		if (!fIsActive)
			// we migth also just return an empty array
			;// JavaPlugin(new Status(IStatus.WARNING,
				// JavaPlugin.getPluginId(), IStatus.OK, "LinkedPositionManager
				// is not active: "+fPositionCategoryName, new
				// IllegalStateException())); //$NON-NLS-1$

		try {
			Position[] positions = document.getPositions(fPositionCategoryName);
			Arrays.sort(positions, fgPositionComparator);
			return positions;

		} catch (BadPositionCategoryException e) {
			PHPeclipsePlugin.log(e);
			Assert.isTrue(false);
		}

		return null;
	}

	public static boolean includes(Position position, int offset, int length) {
		return (offset >= position.getOffset())
				&& (offset + length <= position.getOffset()
						+ position.getLength());
	}

	public static boolean excludes(Position position, int offset, int length) {
		return (offset + length <= position.getOffset())
				|| (position.getOffset() + position.getLength() <= offset);
	}

	/*
	 * Collides if spacing if positions intersect each other or are adjacent.
	 */
	private static boolean collides(Position position, int offset, int length) {
		return (offset <= position.getOffset() + position.getLength())
				&& (position.getOffset() <= offset + length);
	}

	private void leave(boolean success) {
		try {
			uninstall(success);

			if (fListener != null)
				fListener.exit((success ? LinkedPositionUI.COMMIT : 0)
						| LinkedPositionUI.UPDATE_CARET);
		} finally {
			fMustLeave = false;
		}
	}

	private void abort() {
		uninstall(true); // don't revert anything

		if (fListener != null)
			fListener.exit(LinkedPositionUI.COMMIT); // don't let the UI
														// restore anything

		// don't set fMustLeave, as we will get re-registered by a document
		// event
	}

	/*
	 * @see IDocumentListener#documentAboutToBeChanged(DocumentEvent)
	 */
	public void documentAboutToBeChanged(DocumentEvent event) {

		if (fMustLeave) {
			event.getDocument().removeDocumentListener(this);
			return;
		}

		IDocument document = event.getDocument();

		Position[] positions = getPositions(document);
		Position position = findCurrentPosition(positions, event.getOffset());

		// modification outside editable position
		if (position == null) {
			// check for destruction of constraints (spacing of at least 1)
			if ((event.getText() == null || event.getText().length() == 0)
					&& (findCurrentPosition(positions, event.getOffset()) != null)
					&& // will never become true, see condition above
					(findCurrentPosition(positions, event.getOffset()
							+ event.getLength()) != null)) {
				leave(true);
			}

			// modification intersects editable position
		} else {
			// modificaction inside editable position
			if (includes(position, event.getOffset(), event.getLength())) {
				if (containsLineDelimiters(event.getText()))
					leave(true);

				// modificaction exceeds editable position
			} else {
				leave(true);
			}
		}
	}

	/*
	 * @see IDocumentListener#documentChanged(DocumentEvent)
	 */
	public void documentChanged(DocumentEvent event) {

		// have to handle code assist, so can't just leave the linked mode
		// leave(true);

		IDocument document = event.getDocument();

		Position[] positions = getPositions(document);
		TypedPosition currentPosition = (TypedPosition) findCurrentPosition(
				positions, event.getOffset());

		// ignore document changes (assume it won't invalidate constraints)
		if (currentPosition == null)
			return;

		int deltaOffset = event.getOffset() - currentPosition.getOffset();

		if (fListener != null) {
			int length = event.getText() == null ? 0 : event.getText().length();
			fListener.setCurrentPosition(currentPosition, deltaOffset + length);
		}

		for (int i = 0; i != positions.length; i++) {
			TypedPosition p = (TypedPosition) positions[i];

			if (p.getType().equals(currentPosition.getType())
					&& !p.equals(currentPosition)) {
				Replace replace = new Replace(p, deltaOffset,
						event.getLength(), event.getText());
				((IDocumentExtension) document)
						.registerPostNotificationReplace(this, replace);
			}
		}
	}

	/*
	 * @see IPositionUpdater#update(DocumentEvent)
	 */
	public void update(DocumentEvent event) {

		int eventOffset = event.getOffset();
		int eventOldLength = event.getLength();
		int eventNewLength = event.getText() == null ? 0 : event.getText()
				.length();
		int deltaLength = eventNewLength - eventOldLength;

		Position[] positions = getPositions(event.getDocument());

		for (int i = 0; i != positions.length; i++) {

			Position position = positions[i];

			if (position.isDeleted())
				continue;

			int offset = position.getOffset();
			int length = position.getLength();
			int end = offset + length;

			if (offset > eventOffset + eventOldLength) // position comes way
														// after change - shift
				position.setOffset(offset + deltaLength);
			else if (end < eventOffset) // position comes way before change -
										// leave alone
				;
			else if (offset <= eventOffset
					&& end >= eventOffset + eventOldLength) {
				// event completely internal to the position - adjust length
				position.setLength(length + deltaLength);
			} else if (offset < eventOffset) {
				// event extends over end of position - adjust length
				int newEnd = eventOffset + eventNewLength;
				position.setLength(newEnd - offset);
			} else if (end > eventOffset + eventOldLength) {
				// event extends from before position into it - adjust offset
				// and length
				// offset becomes end of event, length ajusted acordingly
				// we want to recycle the overlapping part
				int newOffset = eventOffset + eventNewLength;
				position.setOffset(newOffset);
				position.setLength(length + deltaLength);
			} else {
				// event consumes the position - delete it
				position.delete();
				// JavaPlugin.log(new Status(IStatus.INFO,
				// JavaPlugin.getPluginId(), IStatus.OK, "linked position
				// deleted -> must leave: "+fPositionCategoryName, null));
				// //$NON-NLS-1$
				fMustLeave = true;
			}
		}

		if (fMustLeave)
			abort();
	}

	private static Position findCurrentPosition(Position[] positions, int offset) {
		for (int i = 0; i != positions.length; i++)
			if (includes(positions[i], offset, 0))
				return positions[i];

		return null;
	}

	private boolean containsLineDelimiters(String string) {

		if (string == null)
			return false;

		String[] delimiters = fDocument.getLegalLineDelimiters();

		for (int i = 0; i != delimiters.length; i++)
			if (string.indexOf(delimiters[i]) != -1)
				return true;

		return false;
	}

	/**
	 * Test if ok to modify through UI.
	 */
	public boolean anyPositionIncludes(int offset, int length) {
		Position[] positions = getPositions(fDocument);

		Position position = findCurrentPosition(positions, offset);
		if (position == null)
			return false;

		return includes(position, offset, length);
	}

	/**
	 * Returns the position that includes the given range.
	 * 
	 * @param offset
	 * @param length
	 * @return position that includes the given range
	 */
	public Position getEmbracingPosition(int offset, int length) {
		Position[] positions = getPositions(fDocument);

		Position position = findCurrentPosition(positions, offset);
		if (position != null && includes(position, offset, length))
			return position;

		return null;
	}

	/*
	 * @see org.eclipse.jface.text.IAutoIndentStrategy#customizeDocumentCommand(org.eclipse.jface.text.IDocument,
	 *      org.eclipse.jface.text.DocumentCommand)
	 */
	public void customizeDocumentCommand(IDocument document,
			DocumentCommand command) {

		if (fMustLeave) {
			leave(true);
			return;
		}

		// don't interfere with preceding auto edit strategies
		if (command.getCommandCount() != 1) {
			leave(true);
			return;
		}

		Position[] positions = getPositions(document);
		TypedPosition currentPosition = (TypedPosition) findCurrentPosition(
				positions, command.offset);

		// handle edits outside of a position
		if (currentPosition == null) {
			leave(true);
			return;
		}

		if (!command.doit)
			return;

		command.doit = false;
		command.owner = this;
		command.caretOffset = command.offset + command.length;

		int deltaOffset = command.offset - currentPosition.getOffset();

		if (fListener != null)
			fListener.setCurrentPosition(currentPosition, deltaOffset
					+ command.text.length());

		for (int i = 0; i != positions.length; i++) {
			TypedPosition position = (TypedPosition) positions[i];

			try {
				if (position.getType().equals(currentPosition.getType())
						&& !position.equals(currentPosition))
					command.addCommand(position.getOffset() + deltaOffset,
							command.length, command.text, this);
			} catch (BadLocationException e) {
				PHPeclipsePlugin.log(e);
			}
		}
	}

}
