/*******************************************************************************
 * Copyright (c) 2006, 2012 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:
 *     Christian Plesner Hansen (plesner@quenta.org) - initial API and implementation
 *******************************************************************************/
package org.eclipse.jface.text.source;
import org.eclipse.core.runtime.Assert;

import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IDocumentExtension3;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.ITypedRegion;
import org.eclipse.jface.text.Region;
import org.eclipse.jface.text.TextUtilities;

/**
 * A character pair matcher that matches a specified set of character
 * pairs against each other.  Only characters that occur in the same
 * partitioning are matched.
 *
 * @since 3.3
 */
public class DefaultCharacterPairMatcher implements ICharacterPairMatcher, ICharacterPairMatcherExtension {

	private int fAnchor= -1;
	private final CharPairs fPairs;
	private final String fPartitioning;
	private final boolean fCaretEitherSideOfBracket;

	/**
	 * Creates a new character pair matcher that matches the specified characters within the
	 * specified partitioning. The specified list of characters must have the form <blockquote>{
	 * <i>start</i>, <i>end</i>, <i>start</i>, <i>end</i>, ..., <i>start</i>, <i>end</i>
	 * }</blockquote> For instance:
	 *
	 * <pre>
	 * char[] chars = new char[] {'(', ')', '{', '}', '[', ']'};
	 * new DefaultCharacterPairMatcher(chars, ...);
	 * </pre>
	 *
	 * @param chars a list of characters
	 * @param partitioning the partitioning to match within
	 */
	public DefaultCharacterPairMatcher(char[] chars, String partitioning) {
		this(chars, partitioning, false);
	}

	/**
	 * Creates a new character pair matcher that matches the specified characters within the
	 * specified partitioning. The specified list of characters must have the form <blockquote>{
	 * <i>start</i>, <i>end</i>, <i>start</i>, <i>end</i>, ..., <i>start</i>, <i>end</i>
	 * }</blockquote> For instance:
	 *
	 * <pre>
	 * char[] chars = new char[] {'(', ')', '{', '}', '[', ']'};
	 * new DefaultCharacterPairMatcher(chars, ...);
	 * </pre>
	 *
	 * @param chars a list of characters
	 * @param partitioning the partitioning to match within
	 * @param caretEitherSideOfBracket controls the matching behavior. When <code>true</code>, the
	 *            matching peer will be found when the caret is placed either before or after a
	 *            character. When <code>false</code>, the matching peer will be found only when the
	 *            caret is placed after a character.
	 * @since 3.8
	 */
	public DefaultCharacterPairMatcher(char[] chars, String partitioning, boolean caretEitherSideOfBracket) {
		Assert.isLegal(chars.length % 2 == 0);
		Assert.isNotNull(partitioning);
		fPairs= new CharPairs(chars);
		fPartitioning= partitioning;
		fCaretEitherSideOfBracket= caretEitherSideOfBracket;
	}

	/**
	 * Creates a new character pair matcher that matches characters within the default partitioning.
	 * The specified list of characters must have the form <blockquote>{ <i>start</i>, <i>end</i>,
	 * <i>start</i>, <i>end</i>, ..., <i>start</i>, <i>end</i> }</blockquote> For instance:
	 *
	 * <pre>
	 * char[] chars= new char[] { '(', ')', '{', '}', '[', ']' };
	 * new DefaultCharacterPairMatcher(chars);
	 * </pre>
	 *
	 * @param chars a list of characters
	 */
	public DefaultCharacterPairMatcher(char[] chars) {
		this(chars, IDocumentExtension3.DEFAULT_PARTITIONING);
	}

	@Override
	public IRegion match(IDocument doc, int offset) {
		if (doc == null || offset < 0 || offset > doc.getLength()) return null;
		try {
			return performMatch(doc, offset);
		} catch (BadLocationException ble) {
			return null;
		}
	}

	/**
	 * @see org.eclipse.jface.text.source.ICharacterPairMatcherExtension#match(org.eclipse.jface.text.IDocument,
	 *      int, int)
	 * @since 3.8
	 */
	@Override
	public IRegion match(IDocument document, int offset, int length) {
		if (document == null || offset < 0 || offset > document.getLength() || Math.abs(length) > 1)
			return null;

		try {
			int sourceCaretOffset= offset + length;
			if (Math.abs(length) == 1) {
				char ch= length > 0 ? document.getChar(offset) : document.getChar(sourceCaretOffset);
				if (!fPairs.contains(ch))
					return null;
			}
			int adjustment= getOffsetAdjustment(document, sourceCaretOffset, length);
			sourceCaretOffset+= adjustment;
			return match(document, sourceCaretOffset);
		} catch (BadLocationException e) {
			return null;
		}
	}

	/**
	 * @see org.eclipse.jface.text.source.ICharacterPairMatcherExtension#findEnclosingPeerCharacters(org.eclipse.jface.text.IDocument,
	 *      int, int)
	 * @since 3.8
	 */
	@Override
	public IRegion findEnclosingPeerCharacters(IDocument document, int offset, int length) {
		if (document == null || offset < 0 || offset > document.getLength())
			return null;

		//maybe a bracket is selected
		IRegion region= match(document, offset, length);
		fAnchor= ICharacterPairMatcher.LEFT; //always set the anchor to LEFT
		if (region != null) {
			return region;
		}

		//bracket is not selected
		try {
			final String partition= TextUtilities.getContentType(document, fPartitioning, offset, false);
			DocumentPartitionAccessor partDoc= new DocumentPartitionAccessor(document, fPartitioning, partition);
			IRegion enclosingPeers= findEnclosingPeers(document, partDoc, offset, length, 0, document.getLength());
			if (enclosingPeers != null)
				return enclosingPeers;
			partDoc= new DocumentPartitionAccessor(document, fPartitioning, IDocument.DEFAULT_CONTENT_TYPE);
			return findEnclosingPeers(document, partDoc, offset, length, 0, document.getLength());
		} catch (BadLocationException ble) {
			fAnchor= -1;
			return null;
		}
	}

	/**
	 * @see org.eclipse.jface.text.source.ICharacterPairMatcherExtension#isMatchedChar(char)
	 * @since 3.8
	 */
	@Override
	public boolean isMatchedChar(char ch) {
		return fPairs.contains(ch);
	}

	/**
	 * @see org.eclipse.jface.text.source.ICharacterPairMatcherExtension#isMatchedChar(char,
	 *      org.eclipse.jface.text.IDocument, int)
	 * @since 3.8
	 */
	@Override
	public boolean isMatchedChar(char ch, IDocument document, int offset) {
		return isMatchedChar(ch);
	}

	/**
	 * @see org.eclipse.jface.text.source.ICharacterPairMatcherExtension#isRecomputationOfEnclosingPairRequired(org.eclipse.jface.text.IDocument,
	 *      org.eclipse.jface.text.IRegion, org.eclipse.jface.text.IRegion)
	 * @since 3.8
	 */
	@Override
	public boolean isRecomputationOfEnclosingPairRequired(IDocument document, IRegion currentSelection, IRegion previousSelection) {
		int previousStartOffset= previousSelection.getOffset();
		int currentStartOffset= currentSelection.getOffset();
		int previousEndOffset= previousStartOffset + previousSelection.getLength();
		int currentEndOffset= currentStartOffset + currentSelection.getLength();

		try {
			String prevEndContentType= TextUtilities.getContentType(document, fPartitioning, previousEndOffset, false);
			String currEndContentType= TextUtilities.getContentType(document, fPartitioning, currentEndOffset, false);
			if (!prevEndContentType.equals(currEndContentType))
				return true;

			String prevStartContentType= TextUtilities.getContentType(document, fPartitioning, previousStartOffset, true);
			String currStartContentType= TextUtilities.getContentType(document, fPartitioning, currentStartOffset, true);
			if (!prevStartContentType.equals(currStartContentType))
				return true;

			int start;
			int end;
			if (currentEndOffset > previousEndOffset) {
				start= previousEndOffset;
				end= currentEndOffset;
			} else {
				start= currentEndOffset;
				end= previousEndOffset;
			}
			for (int i= Math.max(start - 1, 0); i <= end; i++) {
				if (isMatchedChar(document.getChar(i))) {
					return true;
				}
			}

			if (currentStartOffset > previousStartOffset) {
				start= previousStartOffset;
				end= currentStartOffset;
			} else {
				start= currentStartOffset;
				end= previousStartOffset;
			}
			for (int i= Math.max(start - 1, 0); i <= end; i++) {
				if (isMatchedChar(document.getChar(i))) {
					return true;
				}
			}
		} catch (BadLocationException e) {
			//do nothing
		}
		return false;
	}

	/**
	 * Computes the adjustment in the start offset for the purpose of finding a matching peer. This
	 * is required as the direction of selection can be right-to-left or left-to-right.
	 *
	 * @param document the document to work on
	 * @param offset the start offset
	 * @param length the selection length
	 * @return the start offset adjustment which can be -1, 0 or +1
	 * @since 3.8
	 */
	private int getOffsetAdjustment(IDocument document, int offset, int length) {
		if (length == 0 || Math.abs(length) > 1 || offset >= document.getLength())
			return 0;
		try {
			if (length < 0) {
				if (fPairs.isStartCharacter(document.getChar(offset))) {
					return 1;
				}
			} else {
				if (fCaretEitherSideOfBracket && fPairs.isEndCharacter(document.getChar(offset - 1))) {
					return -1;
				}
			}
		} catch (BadLocationException e) {
			//do nothing
		}
		return 0;
	}

	/*
	 * Performs the actual work of matching for #match(IDocument, int).
	 */
	private IRegion performMatch(IDocument doc, int caretOffset) throws BadLocationException {
		char prevChar= (caretOffset - 1 >= 0) ? doc.getChar(caretOffset - 1) : Character.MIN_VALUE;
		boolean isForward;
		final char ch;
		if (fCaretEitherSideOfBracket) {
			char currChar= (caretOffset != doc.getLength()) ? doc.getChar(caretOffset) : Character.MIN_VALUE;
			if (fPairs.isEndCharacter(prevChar) && !fPairs.isEndCharacter(currChar)) { //https://bugs.eclipse.org/bugs/show_bug.cgi?id=372516
				caretOffset--;
				currChar= prevChar;
				prevChar= doc.getChar(Math.max(caretOffset - 1, 0));
			} else if (fPairs.isStartCharacter(currChar) && !fPairs.contains(prevChar)) {
				caretOffset++;
				prevChar= currChar;
				currChar= doc.getChar(caretOffset);
			}

			isForward= fPairs.contains(prevChar) && fPairs.isStartCharacter(prevChar);
			boolean isBackward= fPairs.contains(currChar) && !fPairs.isStartCharacter(currChar);
			if (!isForward && !isBackward) {
				return null;
			}
			ch= isForward ? prevChar : currChar;
		} else {
			if (!fPairs.contains(prevChar))
				return null;
			isForward= fPairs.isStartCharacter(prevChar);
			ch= prevChar;
		}

		fAnchor= isForward ? ICharacterPairMatcher.LEFT : ICharacterPairMatcher.RIGHT;
		final int searchStartPosition= isForward ? caretOffset : (fCaretEitherSideOfBracket ? caretOffset - 1 : caretOffset - 2);
		final int adjustedOffset= isForward ? caretOffset - 1 : (fCaretEitherSideOfBracket ? caretOffset + 1 : caretOffset);
		final String partition= TextUtilities.getContentType(doc, fPartitioning, ((!isForward && fCaretEitherSideOfBracket) ? caretOffset : Math.max(caretOffset - 1, 0)), false);
		final DocumentPartitionAccessor partDoc= new DocumentPartitionAccessor(doc, fPartitioning, partition);
		int endOffset= findMatchingPeer(partDoc, ch, fPairs.getMatching(ch),
				isForward, isForward ? doc.getLength() : -1, searchStartPosition);
		if (endOffset == -1)
			return null;
		final int adjustedEndOffset= isForward ? endOffset + 1 : endOffset;
		if (adjustedEndOffset == adjustedOffset)
			return null;
		return new Region(Math.min(adjustedOffset, adjustedEndOffset),
				Math.abs(adjustedEndOffset - adjustedOffset));
	}

	/**
	 * Searches <code>doc</code> for the specified end character, <code>end</code>.
	 *
	 * @param doc the document to search
	 * @param start the opening matching character
	 * @param end the end character to search for
	 * @param searchForward search forwards or backwards?
	 * @param boundary a boundary at which the search should stop
	 * @param startPos the start offset
	 * @return the index of the end character if it was found, otherwise -1
	 * @throws BadLocationException if the document is accessed with invalid offset or line
	 */
	private int findMatchingPeer(DocumentPartitionAccessor doc, char start, char end, boolean searchForward, int boundary, int startPos) throws BadLocationException {
		int pos= startPos;
		int nestingLevel= 0;
		while (pos != boundary) {
			final char c= doc.getChar(pos);
			if (c == end && doc.inPartition(pos)) {
				if (nestingLevel == 0)
					return pos;
				nestingLevel--;
			} else if (c == start && doc.inPartition(pos)) {
				nestingLevel++;
			}
			pos= doc.getNextPosition(pos, searchForward);
		}
		return -1;
	}

	/*
	 * Performs the actual work of finding enclosing peer characters for #findEnclosingPeerCharacters(IDocument, int, int).
	 */
	private IRegion findEnclosingPeers(IDocument document, DocumentPartitionAccessor doc, int offset, int length, int lowerBoundary, int upperBoundary) throws BadLocationException {
		char[] pairs= fPairs.fPairs;

		int start;
		int end;
		if (length >= 0) {
			start= offset;
			end= offset + length;
		} else {
			end= offset;
			start= offset + length;
		}

		boolean lowerFound= false;
		boolean upperFound= false;
		int[][] counts= new int[pairs.length][2];
		char currChar= (start != document.getLength()) ? doc.getChar(start) : Character.MIN_VALUE;
		int pos1;
		int pos2;
		if (fPairs.isEndCharacter(currChar)) {
			pos1= doc.getNextPosition(start, false);
			pos2= start;
		} else {
			pos1= start;
			pos2= doc.getNextPosition(start, true);
		}

		while ((pos1 >= lowerBoundary && !lowerFound) || (pos2 < upperBoundary && !upperFound)) {
			for (int[] count : counts) {
				count[0] = count[1] = 0;
			}

			outer1: while (pos1 >= lowerBoundary && !lowerFound) {
				final char c= doc.getChar(pos1);
				int i= getCharacterIndex(c, document, pos1);
				if (i != -1 && doc.inPartition(pos1)) {
					if (i % 2 == 0) {
						counts[i / 2][0]--; //start
					} else {
						counts[i / 2][0]++; //end
					}
					for (int[] count : counts) {
						if (count[0] == -1) {
							lowerFound= true;
							break outer1;
						}
					}
				}
				pos1= doc.getNextPosition(pos1, false);
			}

			outer2: while (pos2 < upperBoundary && !upperFound) {
				final char c= doc.getChar(pos2);
				int i= getCharacterIndex(c, document, pos2);
				if (i != -1 && doc.inPartition(pos2)) {
					if (i % 2 == 0) {
						counts[i / 2][1]++; //start
					} else {
						counts[i / 2][1]--; //end
					}
					for (int[] count : counts) {
						if (count[1] == -1 && count[0] == -1) {
							upperFound= true;
							break outer2;
						}
					}
				}
				pos2= doc.getNextPosition(pos2, true);
			}

			if (pos1 > start || pos2 < end - 1) {
				//match inside selection => discard
				pos1= doc.getNextPosition(pos1, false);
				pos2= doc.getNextPosition(pos2, true);
				lowerFound= false;
				upperFound= false;
			}
		}
		pos2++;
		if (pos1 < lowerBoundary || pos2 > upperBoundary)
			return null;
		return new Region(pos1, pos2 - pos1);
	}

	/**
	 * Determines the index of the character in the char array passed to the constructor of the pair
	 * matcher.
	 *
	 * @param ch the character
	 * @param document the document
	 * @param offset the offset in document
	 * @return the index of the character in the char array passed to the constructor of the pair
	 *         matcher, and -1 if the character is not one of the matched characters
	 * @since 3.8
	 */
	private int getCharacterIndex(char ch, IDocument document, int offset) {
		char[] pairs= fPairs.fPairs;
		for (int i= 0; i < pairs.length; i++) {
			if (pairs[i] == ch && isMatchedChar(ch, document, offset)) {
				return i;
			}
		}
		return -1;
	}

	@Override
	public int getAnchor() {
		return fAnchor;
	}

	@Override
	public void dispose() { }

	@Override
	public void clear() {
		fAnchor= -1;
	}

	/**
	 * Utility class that wraps a document and gives access to
	 * partitioning information.  A document is tied to a particular
	 * partition and, when considering whether or not a position is a
	 * valid match, only considers position within its partition.
	 */
	private static class DocumentPartitionAccessor {

		private final IDocument fDocument;
		private final String fPartitioning, fPartition;
		private ITypedRegion fCachedPartition;
		private int fLength;

		/**
		 * Creates a new partitioned document for the specified document.
		 *
		 * @param doc the document to wrap
		 * @param partitioning the partitioning used
		 * @param partition the partition managed by this document
		 */
		public DocumentPartitionAccessor(IDocument doc, String partitioning,
				String partition) {
			fDocument= doc;
			fPartitioning= partitioning;
			fPartition= partition;
			fLength= doc.getLength();
		}

		/**
		 * Returns the character at the specified position in this document.
		 *
		 * @param pos an offset within this document
		 * @return the character at the offset
		 * @throws BadLocationException if the offset is invalid in this document
		 */
		public char getChar(int pos) throws BadLocationException {
			return fDocument.getChar(pos);
		}

		/**
		 * Returns true if the specified offset is within the partition
		 * managed by this document.
		 *
		 * @param pos an offset within this document
		 * @return true if the offset is within this document's partition
		 */
		public boolean inPartition(int pos) {
			final ITypedRegion partition= getPartition(pos);
			return partition != null && partition.getType().equals(fPartition);
		}

		/**
		 * Returns the next position to query in the search. The position
		 * is not guaranteed to be in this document's partition.
		 *
		 * @param pos an offset within the document
		 * @param searchForward the direction of the search
		 * @return the next position to query
		 */
		public int getNextPosition(int pos, boolean searchForward) {
			final ITypedRegion partition= getPartition(pos);
			if (partition == null || fPartition.equals(partition.getType()))
				return simpleIncrement(pos, searchForward);
			if (searchForward) {
				int end= partition.getOffset() + partition.getLength();
				if (pos < end)
					return end;
			} else {
				int offset= partition.getOffset();
				if (pos > offset)
					return offset - 1;
			}
			return simpleIncrement(pos, searchForward);
		}

		private int simpleIncrement(int pos, boolean searchForward) {
			return pos + (searchForward ? 1 : -1);
		}

		/**
		 * Returns partition information about the region containing the
		 * specified position.
		 *
		 * @param pos a position within this document.
		 * @return positioning information about the region containing the
		 *   position
		 */
		private ITypedRegion getPartition(int pos) {
			if (fCachedPartition == null || !contains(fCachedPartition, pos)) {
				Assert.isTrue(pos >= 0 && pos <= fLength);
				try {
					fCachedPartition= TextUtilities.getPartition(fDocument, fPartitioning, pos, false);
				} catch (BadLocationException e) {
					fCachedPartition= null;
				}
			}
			return fCachedPartition;
		}

		private static boolean contains(IRegion region, int pos) {
			int offset= region.getOffset();
			return offset <= pos && pos < offset + region.getLength();
		}

	}

	/**
	 * Utility class that encapsulates access to matching character pairs.
	 */
	private static class CharPairs {

		private final char[] fPairs;

		public CharPairs(char[] pairs) {
			fPairs= pairs;
		}

		/**
		 * Returns true if the specified character occurs in one of the character pairs.
		 *
		 * @param c a character
		 * @return true exactly if the character occurs in one of the pairs
		 */
		public boolean contains(char c) {
			char[] pairs= fPairs;
			for (char pair : pairs) {
				if (c == pair)
					return true;
			}
			return false;
		}

		/**
		 * Returns true if the specified character opens a character pair
		 * when scanning in the specified direction.
		 *
		 * @param c a character
		 * @param searchForward the direction of the search
		 * @return whether or not the character opens a character pair
		 */
		public boolean isOpeningCharacter(char c, boolean searchForward) {
			for (int i= 0; i < fPairs.length; i += 2) {
				if (searchForward && getStartChar(i) == c) return true;
				else if (!searchForward && getEndChar(i) == c) return true;
			}
			return false;
		}

		/**
		 * Returns true if the specified character is a start character.
		 *
		 * @param c a character
		 * @return true exactly if the character is a start character
		 */
		public boolean isStartCharacter(char c) {
			return this.isOpeningCharacter(c, true);
		}

		/**
		 * Returns true if the specified character is an end character.
		 *
		 * @param c a character
		 * @return true exactly if the character is an end character
		 * @since 3.8
		 */
		public boolean isEndCharacter(char c) {
			return this.isOpeningCharacter(c, false);
		}

		/**
		 * Returns the matching character for the specified character.
		 *
		 * @param c a character occurring in a character pair
		 * @return the matching character
		 */
		public char getMatching(char c) {
			for (int i= 0; i < fPairs.length; i += 2) {
				if (getStartChar(i) == c) return getEndChar(i);
				else if (getEndChar(i) == c) return getStartChar(i);
			}
			Assert.isTrue(false);
			return '\0';
		}

		private char getStartChar(int i) {
			return fPairs[i];
		}

		private char getEndChar(int i) {
			return fPairs[i + 1];
		}

	}

}
