/*******************************************************************************
 * Copyright (c) 2006, 2015 The Pampered Chef 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:
 *     The Pampered Chef - initial API and implementation
 ******************************************************************************/

package org.eclipse.jface.examples.databinding.mask;

import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;

import org.eclipse.jface.examples.databinding.mask.internal.EditMaskParser;
import org.eclipse.jface.examples.databinding.mask.internal.SWTUtil;
import org.eclipse.swt.events.DisposeListener;
import org.eclipse.swt.events.FocusAdapter;
import org.eclipse.swt.events.FocusEvent;
import org.eclipse.swt.events.FocusListener;
import org.eclipse.swt.events.VerifyListener;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Text;

/**
 * Ensures text widget has the format specified by the edit mask.  Edit masks
 * are currently defined as follows:
 *
 * The following characters are reserved words that match specific kinds of
 * characters:
 *
 * # - digits 0-9
 * A - uppercase A-Z
 * a - upper or lowercase a-z, A-Z
 * n - alphanumeric 0-9, a-z, A-Z
 *
 * All other characters are literals.  The above characters may be turned into
 * literals by preceeding them with a backslash.  Use two backslashes to
 * denote a backslash.
 *
 * Examples:
 *
 * (###) ###-####  U.S. phone number
 * ###-##-####     U.S. Social Security number
 * \\\###          A literal backslash followed by a literal pound symbol followed by two digits
 *
 * Ideas for future expansion:
 *
 * Quantifiers as postfix modifiers to a token.  ie:
 *
 * #{1, 2}/#{1,2}/####   MM/DD/YYYY date format allowing 1 or 2 digit month or day
 *
 * Literals may only be quantified as {0,1} which means that they only appear
 * if placeholders on both sides of the literal have data.  This will be used
 * along with:
 *
 * Right-to-left support for numeric entry.  When digits are being entered and
 * a decimal point is present in the mask, digits to the left of the decimal
 * are entered right-to-left but digits to the right of the decimal left-to-right.
 * This might need to be a separate type of edit mask. (NumericMask, maybe?)
 *
 * Example:
 *
 * $#{0,3},{0,1}#{0,3}.#{0,2}  ie: $123,456.12 or $12.12 or $1,234.12 or $123.12
 *
 *
 * Here's the basic idea of how the current implementation works (the actual
 * implementation is slightly more abstracted and complicated than this):
 *
 * We always let the verify event pass if the user typed a new character or selected/deleted anything.
 * During the verify event, we post an async runnable.
 * Inside that async runnable, we:
 *   - Remember the selection position
 *   - getText(), then
 *   - Strip out all literal characters from text
 *   - Truncate the resulting string to raw length of edit mask without literals
 *   - Insert literal characters back in the correct positions
 *   - setText() the resulting string
 *   - reset the selection to the correct location
 *
 * @since 3.3
 */
public class EditMask {

	public static final String FIELD_TEXT = "text";
	public static final String FIELD_RAW_TEXT = "rawText";
	public static final String FIELD_COMPLETE = "complete";
	protected Text text;
	protected EditMaskParser editMaskParser;
	private boolean fireChangeOnKeystroke = true;
	private PropertyChangeSupport propertyChangeSupport = new PropertyChangeSupport(this);

	protected String oldValidRawText = "";
	protected String oldValidText = "";

	/**
	 * Creates an instance that wraps around a text widget and manages its<br>
	 * formatting.
	 */
	public EditMask(Text text) {
		this.text = text;
	}

	/**
	 * @return the underlying Text control used by EditMask
	 */
	public Text getControl() {
		return this.text;
	}

	/**
	 * Set the edit mask string on the edit mask control.
	 *
	 * @param editMask The edit mask string
	 */
	public void setMask(String editMask) {
		editMaskParser = new EditMaskParser(editMask);
		text.addVerifyListener(verifyListener);
		text.addFocusListener(focusListener);
		text.addDisposeListener(disposeListener);
		updateTextField.run();
		oldValidText = text.getText();
		oldValidRawText = editMaskParser.getRawResult();
	}

	/**
	 * @param string Sets the text string in the receiver
	 */
	public void setText(String string) {
		String oldValue = text.getText();
		if (editMaskParser != null) {
			editMaskParser.setInput(string);
			String formattedResult = editMaskParser.getFormattedResult();
			text.setText(formattedResult);
			firePropertyChange(FIELD_TEXT, oldValue, formattedResult);
		} else {
			text.setText(string);
			firePropertyChange(FIELD_TEXT, oldValue, string);
		}
		oldValidText = text.getText();
		oldValidRawText = editMaskParser.getRawResult();
	}

	/**
	 * @return the actual (formatted) text
	 */
	public String getText() {
		if (editMaskParser != null) {
			return editMaskParser.getFormattedResult();
		}
		return text.getText();
	}

	/**
	 * setRawText takes raw text as a parameter but formats it before
	 * setting the text in the Text control.
	 *
	 * @param string the raw (unformatted) text
	 */
	public void setRawText(String string)  {
		if (string == null) {
			string = "";
		}
		if (editMaskParser != null) {
			String oldValue = editMaskParser.getRawResult();
			editMaskParser.setInput(string);
			text.setText(editMaskParser.getFormattedResult());
			firePropertyChange(FIELD_RAW_TEXT, oldValue, string);
		} else {
			String oldValue = text.getText();
			text.setText(string);
			firePropertyChange(FIELD_RAW_TEXT, oldValue, string);
		}
		oldValidText = text.getText();
		oldValidRawText = editMaskParser.getRawResult();
	}

	/**
	 * @return The input text with literals removed
	 */
	public String getRawText() {
		if (editMaskParser != null) {
			return editMaskParser.getRawResult();
		}
		return text.getText();
	}

	/**
	 * @return true if the field is complete according to the mask; false otherwise
	 */
	public boolean isComplete() {
		if (editMaskParser == null) {
			return true;
		}
		return editMaskParser.isComplete();
	}

	/**
	 * Returns the placeholder character.  The placeholder
	 * character must be a different character than any character that is
	 * allowed as input anywhere in the edit mask.  For example, if the edit
	 * mask permits spaces to be used as input anywhere, the placeholder
	 * character must be something other than a space character.
	 * <p>
	 * The space character is the default placeholder character.
	 *
	 * @return the placeholder character
	 */
	public char getPlaceholder() {
		if (editMaskParser == null) {
			throw new IllegalArgumentException("Have to set an edit mask first");
		}
		return editMaskParser.getPlaceholder();
	}

	/**
	 * Sets the placeholder character for the edit mask.  The placeholder
	 * character must be a different character than any character that is
	 * allowed as input anywhere in the edit mask.  For example, if the edit
	 * mask permits spaces to be used as input anywhere, the placeholder
	 * character must be something other than a space character.
	 * <p>
	 * The space character is the default placeholder character.
	 *
	 * @param placeholder The character to use as a placeholder
	 */
	public void setPlaceholder(char placeholder) {
		if (editMaskParser == null) {
			throw new IllegalArgumentException("Have to set an edit mask first");
		}
		editMaskParser.setPlaceholder(placeholder);
		updateTextField.run();
		oldValidText = text.getText();
	}

	/**
	 * Indicates if change notifications will be fired after every keystroke
	 * that affects the value of the rawText or only when the value is either
	 * complete or empty.
	 *
	 * @return true if every change (including changes from one invalid state to
	 *         another) triggers a change event; false if only empty or valid
	 *         values trigger a change event.  Defaults to false.
	 */
	public boolean isFireChangeOnKeystroke() {
		return fireChangeOnKeystroke;
	}

	/**
	 * Sets if change notifications will be fired after every keystroke that
	 * affects the value of the rawText or only when the value is either
	 * complete or empty.
	 *
	 * @param fireChangeOnKeystroke
	 *            true if every change (including changes from one invalid state
	 *            to another) triggers a change event; false if only empty or
	 *            valid values trigger a change event.  Defaults to false.
	 */
	public void setFireChangeOnKeystroke(boolean fireChangeOnKeystroke) {
		this.fireChangeOnKeystroke = fireChangeOnKeystroke;
	}

	/**
	 * JavaBeans boilerplate code...
	 *
	 * @param listener
	 */
	public void addPropertyChangeListener(PropertyChangeListener listener) {
		propertyChangeSupport.addPropertyChangeListener(listener);
	}

	/**
	 * JavaBeans boilerplate code...
	 *
	 * @param propertyName
	 * @param listener
	 */
	public void addPropertyChangeListener(String propertyName,
			PropertyChangeListener listener) {
		propertyChangeSupport.addPropertyChangeListener(propertyName, listener);
	}

	/**
	 * JavaBeans boilerplate code...
	 *
	 * @param listener
	 */
	public void removePropertyChangeListener(PropertyChangeListener listener) {
		propertyChangeSupport.removePropertyChangeListener(listener);
	}

	/**
	 * JavaBeans boilerplate code...
	 *
	 * @param propertyName
	 * @param listener
	 */
	public void removePropertyChangeListener(String propertyName,
			PropertyChangeListener listener) {
		propertyChangeSupport.removePropertyChangeListener(propertyName,
				listener);
	}

	private boolean isEitherValueNotNull(Object oldValue, Object newValue) {
		return oldValue != null || newValue != null;
	}

	private void firePropertyChange(String propertyName, Object oldValue,
			Object newValue) {
		if (isEitherValueNotNull(oldValue, newValue)) {
			propertyChangeSupport.firePropertyChange(propertyName,
					oldValue, newValue);
		}
	}

	protected boolean updating = false;

	protected int oldSelection = 0;
	protected int selection = 0;
	protected String oldRawText = "";
	protected boolean replacedSelectedText = false;

	private VerifyListener verifyListener = e -> {
		// If the edit mask is already full, don't let the user type
		// any new characters
		if (editMaskParser.isComplete() && // should eventually be .isFull() to account for optional characters
			e.start == e.end &&
			e.text.length() > 0)
		{
			e.doit=false;
			return;
		}

		oldSelection = selection;
		Point selectionRange = text.getSelection();
		selection = selectionRange.x;

		if (!updating) {
			replacedSelectedText = false;
			if (selectionRange.y - selectionRange.x > 0 && e.text.length() > 0) {
				replacedSelectedText = true;
			}
			// If the machine is loaded down (ie: spyware, malware), we might
			// get another keystroke before asyncExec can process, so we use
			// greedyExec instead.
			SWTUtil.greedyExec(Display.getCurrent(), this.updateTextField);
//				Display.getCurrent().asyncExec(updateTextField);
		}
	};

	private Runnable updateTextField = new Runnable() {
		@Override
		public void run() {
			updating = true;
			try {
				if (!text.isDisposed()) {
					Boolean oldIsComplete = Boolean.valueOf(editMaskParser.isComplete());

					editMaskParser.setInput(text.getText());
					text.setText(editMaskParser.getFormattedResult());
					String newRawText = editMaskParser.getRawResult();

					updateSelectionPosition(newRawText);
					firePropertyChangeEvents(oldIsComplete, newRawText);
				}
			} finally {
				updating = false;
			}
		}

		private void updateSelectionPosition(String newRawText) {

			// Adjust the selection
			if (isInsertingNewCharacter(newRawText) || replacedSelectedText) {
				// Find the position after where the new character was actually inserted
				int selectionDelta =
					editMaskParser.getNextInputPosition(oldSelection)
					- oldSelection;
				if (selectionDelta == 0) {
					selectionDelta = editMaskParser.getNextInputPosition(selection)
					- selection;
				}
				selection += selectionDelta;
			}

			// Did we just type something that was accepted by the mask?
			if (!newRawText.equals(oldRawText)) { // yep

			// If the user hits <end>, bounce them back to the end of their actual input
				int firstIncompletePosition = editMaskParser.getFirstIncompleteInputPosition();
				if (firstIncompletePosition > 0 && selection > firstIncompletePosition)
					selection = firstIncompletePosition;
				text.setSelection(new Point(selection, selection));

			} else // Either we backspaced over a literal or we typed an illegal character
			if (selection > oldSelection) { // typed an illegal character; backup
				text.setSelection(new Point(selection-1, selection-1));
			} else { // backspaced over a literal; don't interfere with selection position
				text.setSelection(new Point(selection, selection));
			}
			oldRawText = newRawText;
		}

		private boolean isInsertingNewCharacter(String newRawText) {
			return newRawText.length() > oldRawText.length();
		}

		private void firePropertyChangeEvents(Boolean oldIsComplete, String newRawText) {
			Boolean newIsComplete = Boolean.valueOf(editMaskParser.isComplete());
			if (!oldIsComplete.equals(newIsComplete)) {
				firePropertyChange(FIELD_COMPLETE, oldIsComplete, newIsComplete);
			}
			if (!newRawText.equals(oldValidRawText)) {
				if ( newIsComplete.booleanValue() || "".equals(newRawText) || fireChangeOnKeystroke) {
					firePropertyChange(FIELD_RAW_TEXT, oldValidRawText, newRawText);
					firePropertyChange(FIELD_TEXT, oldValidText, text.getText());
					oldValidText = text.getText();
					oldValidRawText = newRawText;
				}
			}
		}
	};

	private FocusListener focusListener = new FocusAdapter() {
		@Override
		public void focusGained(FocusEvent e) {
			selection = editMaskParser.getFirstIncompleteInputPosition();
			text.setSelection(selection, selection);
		}
	};

	private DisposeListener disposeListener = e -> {
		text.removeVerifyListener(verifyListener);
		text.removeFocusListener(focusListener);
		text.removeDisposeListener(this.disposeListener);
	};

}
