/* -*- mode: java; c-basic-offset: 2; indent-tabs-mode: nil -*- */

/*
Part of the Processing project - http://processing.org
Copyright (c) 2012-16 The Processing Foundation

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License version 2
as published by the Free Software Foundation.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

package processing.mode.java.pdex;

import java.awt.EventQueue;
import java.awt.Point;
import java.awt.event.ComponentListener;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionListener;
import java.util.BitSet;
import java.util.Collections;
import java.util.List;

import javax.swing.DefaultListModel;
import javax.swing.SwingWorker;

import processing.app.Messages;
import processing.app.Platform;
import processing.app.syntax.PdeTextArea;
import processing.app.syntax.TextAreaDefaults;
import processing.mode.java.JavaEditor;
import processing.mode.java.JavaInputHandler;
import processing.mode.java.JavaMode;
import processing.mode.java.tweak.ColorControlBox;
import processing.mode.java.tweak.Handle;


/**
 * TextArea implementation for Java Mode. Primary differences from PdeTextArea
 * are completions, suggestions, and tweak handling.
 */
public class JavaTextArea extends PdeTextArea {
	private CompletionPanel suggestion;


	public JavaTextArea(TextAreaDefaults defaults, JavaEditor editor) {
		super(defaults, new JavaInputHandler(editor), editor);

		suggestionGenerator = new CompletionGenerator();

		tweakMode = false;
	}


	public JavaEditor getJavaEditor() {
		return (JavaEditor) editor;
	}


	@Override
	protected JavaTextAreaPainter createPainter(final TextAreaDefaults defaults) {
		return new JavaTextAreaPainter(this, defaults);
	}


	// used by Tweak Mode
	protected JavaTextAreaPainter getJavaPainter() {
		return (JavaTextAreaPainter) painter;
	}


	/**
	 * Handles KeyEvents for TextArea (code completion begins from here).
	 * TODO Needs explanation of why this implemented with an override
	 *			of processKeyEvent() instead of using listeners.
	 */
	@Override
	public void processKeyEvent(KeyEvent evt) {
		if (evt.getKeyCode() == KeyEvent.VK_ESCAPE) {
			if (suggestion != null){
				if (suggestion.isVisible()){
					Messages.log("ESC key");
					hideSuggestion();
					evt.consume();
					return;
				}
			}

		} else if (evt.getKeyCode() == KeyEvent.VK_ENTER &&
							 evt.getID() == KeyEvent.KEY_PRESSED) {
			if (suggestion != null &&
					suggestion.isVisible() &&
					suggestion.insertSelection(CompletionPanel.KEYBOARD_COMPLETION)) {
				evt.consume();
				// Still try to show suggestions after inserting if it's
				// the case of overloaded methods. See #2755
				if (suggestion.isVisible()) {
					prepareSuggestions(evt);
				}
				return;
			}
		}

		if (evt.getID() == KeyEvent.KEY_PRESSED) {
			switch (evt.getKeyCode()) {
			case KeyEvent.VK_DOWN:
				if (suggestion != null)
					if (suggestion.isVisible()) {
						//log("KeyDown");
						suggestion.moveDown();
						return;
					}
				break;
			case KeyEvent.VK_UP:
				if (suggestion != null)
					if (suggestion.isVisible()) {
						//log("KeyUp");
						suggestion.moveUp();
						return;
					}
				break;
			case KeyEvent.VK_BACK_SPACE:
				Messages.log("BK Key");
				break;
			case KeyEvent.VK_SPACE:
				if (suggestion != null) {
					if (suggestion.isVisible()) {
						Messages.log("Space bar, hide completion list");
						suggestion.setInvisible();
					}
				}
				break;
			}
		}
		super.processKeyEvent(evt);

		// code completion disabled if Java tabs present
		if (!getJavaEditor().hasJavaTabs()) {
			if (evt.getID() == KeyEvent.KEY_TYPED) {
				processCompletionKeys(evt);
			} else if (!Platform.isMacOS() && evt.getID() == KeyEvent.KEY_RELEASED) {
				processCompletionKeys(evt);
			} else if (Platform.isMacOS() && evt.getID() == KeyEvent.KEY_RELEASED) {
				processControlSpace(evt);
			}
		}
	}


	// Special case for OS X, where Ctrl-Space is not detected as KEY_TYPED
	// https://github.com/processing/processing/issues/2699
	private void processControlSpace(final KeyEvent event) {
		if (event.getKeyCode() == KeyEvent.VK_SPACE && event.isControlDown()) {
			// Provide completions only if it's enabled
			if (JavaMode.codeCompletionsEnabled) {
				Messages.log("[KeyEvent]" + KeyEvent.getKeyText(event.getKeyCode()) + "	|Prediction started");
				fetchPhrase();
			}
		}
	}


	private void processCompletionKeys(final KeyEvent event) {
		char keyChar = event.getKeyChar();
		int keyCode = event.getKeyCode();
		if (keyChar == KeyEvent.VK_ENTER ||
				keyChar == KeyEvent.VK_ESCAPE ||
				keyChar == KeyEvent.VK_TAB ||
				(event.getID() == KeyEvent.KEY_RELEASED &&
						keyCode != KeyEvent.VK_LEFT && keyCode != KeyEvent.VK_RIGHT)) {
			// ignore
		} else if (keyChar == ')') {
			// https://github.com/processing/processing/issues/2741
			hideSuggestion();

		} else if (keyChar == '.') {
			if (JavaMode.codeCompletionsEnabled) {
				Messages.log("[KeyEvent]" + KeyEvent.getKeyText(event.getKeyCode()) + "	|Prediction started");
				fetchPhrase();
			}
		} else if (keyChar == ' ') { // Trigger on Ctrl-Space
			if (!Platform.isMacOS() && JavaMode.codeCompletionsEnabled &&
					(event.isControlDown() || event.isMetaDown())) {
				// Provide completions only if it's enabled
				if (JavaMode.codeCompletionsEnabled) {
					// Removed for https://github.com/processing/processing/issues/3847
					//try {
					//	getDocument().remove(getCaretPosition() - 1, 1); // Remove the typed space
					Messages.log("[KeyEvent]" + event.getKeyChar() + "	|Prediction started");
					fetchPhrase();
					//} catch (BadLocationException e) {
					//	e.printStackTrace();
					//}
				}
			} else {
				hideSuggestion(); // hide on spacebar
			}
		} else {
			if (JavaMode.codeCompletionsEnabled) {
				prepareSuggestions(event);
			}
		}
	}


	/** Kickstart auto-complete suggestions */
	private void prepareSuggestions(final KeyEvent evt) {
		// Provide completions only if it's enabled
		if (JavaMode.codeCompletionsEnabled &&
				(JavaMode.ccTriggerEnabled ||
				(suggestion != null && suggestion.isVisible()))) {
			Messages.log("[KeyEvent]" + evt.getKeyChar() + "	|Prediction started");
			fetchPhrase();
		}
	}


	CompletionGenerator suggestionGenerator;

	SwingWorker<Void, Void> suggestionWorker = null;

	volatile boolean suggestionRunning = false;
	volatile boolean suggestionRequested = false;

	/**
	 * Retrieves the current word typed just before the caret.
	 * Then triggers code completion for that word.
	 * @param evt - the KeyEvent which triggered this method
	 */
	protected void fetchPhrase() {
		if (suggestionRunning) {
			suggestionRequested = true;
			return;
		}

		suggestionRunning = true;
		suggestionRequested = false;

		final String text;
		final int caretLineIndex;
		final int caretLinePosition;
		{
			// Get caret position
			int caretPosition = getCaretPosition();
			if (caretPosition < 0) {
				suggestionRunning = false;
				return;
			}

			// Get line index
			caretLineIndex = getCaretLine();
			if (caretLineIndex < 0) {
				suggestionRunning = false;
				return;
			}

			// Get text of the line
			String lineText = getLineText(caretLineIndex);
			if (lineText == null) {
				suggestionRunning = false;
				return;
			}

			// Get caret position on the line
			caretLinePosition = getCaretPosition() - getLineStartOffset(caretLineIndex);
			if (caretLinePosition <= 0) {
				suggestionRunning = false;
				return;
			}

			// Get part of the line to the left of the caret
			if (caretLinePosition > lineText.length()) {
				suggestionRunning = false;
				return;
			}
			text = lineText.substring(0, caretLinePosition);
		}

		// Adjust line number for tabbed sketches
		//int codeIndex = editor.getSketch().getCodeIndex(getJavaEditor().getCurrentTab());
		int codeIndex = editor.getSketch().getCurrentCodeIndex();
		int lineStartOffset = editor.getTextArea().getLineStartOffset(caretLineIndex);

		getJavaEditor().getPreprocessingService().whenDone(ps -> {
			int lineNumber = ps.tabOffsetToJavaLine(codeIndex, lineStartOffset);

			String phrase = null;
			DefaultListModel<CompletionCandidate> defListModel = null;

			try {
				Messages.log("phrase parse start");
				phrase = parsePhrase(text);
				Messages.log("phrase: " + phrase);
				if (phrase != null) {
					List<CompletionCandidate> candidates;

					candidates = suggestionGenerator.preparePredictions(ps, phrase, lineNumber);

					if (!suggestionRequested) {

		//				// don't show completions when the outline is visible
		//				boolean showSuggestions =
		//					astGenerator.sketchOutline == null || !astGenerator.sketchOutline.isVisible();

		//				if (showSuggestions && phrase != null &&
						if (candidates != null && !candidates.isEmpty()) {
							Collections.sort(candidates);
							defListModel = CompletionGenerator.filterPredictions(candidates);
							Messages.log("Got: " + candidates.size() + " candidates, " + defListModel.size() + " filtered");
						}
					}

				}

				final String finalPhrase = phrase;
				final DefaultListModel<CompletionCandidate> finalDefListModel = defListModel;

				EventQueue.invokeLater(() -> {

					suggestionRunning = false;
					if (suggestionRequested) {
						Messages.log("completion invalidated");
						fetchPhrase();
						return;
					}

					Messages.log("completion finishing");

					if (finalDefListModel != null) {
						showSuggestion(finalDefListModel, finalPhrase);
					} else {
						hideSuggestion();
					}
				});
			} catch (Exception e) {
				Messages.loge("error while preparing suggestions", e);
			}
		});
	}


	protected static String parsePhrase(final String lineText) {
		boolean overloading = false;

		{ // Check if we can provide suggestions for this phrase ending
			String trimmedLineText = lineText.trim();
			if (trimmedLineText.length() == 0) return null;

			char lastChar = trimmedLineText.charAt(trimmedLineText.length() - 1);
			if (lastChar == '.') {
				trimmedLineText = trimmedLineText.substring(0, trimmedLineText.length() - 1).trim();
				if (trimmedLineText.length() == 0) return null;
				lastChar = trimmedLineText.charAt(trimmedLineText.length() - 1);
				switch (lastChar) {
					case ')':
					case ']':
					case '"':
						break; // We can suggest for these
					default:
						if (!Character.isJavaIdentifierPart(lastChar)) {
							return null; // Not something we can suggest
						}
						break;
				}
			} else if (lastChar == '(') {
				overloading = true; // We can suggest overloaded methods
			} else if (!Character.isJavaIdentifierPart(lastChar)) {
				return null; // Not something we can suggest
			}
		}

		final int currentCharIndex = lineText.length() - 1;

		{ // Check if the caret is in the comment
			int commentStart = lineText.indexOf("//", 0);
			if (commentStart >= 0 && currentCharIndex > commentStart) {
				return null;
			}
		}

		// Index the line
		BitSet isInLiteral = new BitSet(lineText.length());
		BitSet isInBrackets = new BitSet(lineText.length());

		{ // Mark parts in literals
			boolean inString = false;
			boolean inChar = false;
			boolean inEscaped = false;

			for (int i = 0; i < lineText.length(); i++) {
				if (!inEscaped) {
					switch (lineText.charAt(i)) {
						case '\"':
							if (!inChar) inString = !inString;
							break;
						case '\'':
							if (!inString) inChar = !inChar;
							break;
						case '\\':
							if (inString || inChar) {
								inEscaped = true;
							}
							break;
					}
				} else {
					inEscaped = false;
				}
				isInLiteral.set(i, inString || inChar);
			}
		}

		if (isInLiteral.get(currentCharIndex)) return null;

		{ // Mark parts in top level brackets
			int depth = overloading ? 1 : 0;
			int bracketStart = overloading ? lineText.length() : 0;
			int squareDepth = 0;
			int squareBracketStart = 0;

			bracketLoop: for (int i = lineText.length() - 1; i >= 0; i--) {
				if (!isInLiteral.get(i)) {
					switch (lineText.charAt(i)) {
						case ')':
							if (depth == 0) bracketStart = i;
							depth++;
							break;
						case '(':
							depth--;
							if (depth == 0) {
								isInBrackets.set(i, bracketStart);
							} else if (depth < 0) {
								break bracketLoop;
							}
							break;
						case ']':
							if (squareDepth == 0) squareBracketStart = i;
							squareDepth++;
							break;
						case '[':
							squareDepth--;
							if (squareDepth == 0) {
								isInBrackets.set(i, squareBracketStart);
							} else if (squareDepth < 0) {
								break bracketLoop;
							}
							break;
					}
				}
			}

			if (depth > 0) isInBrackets.set(0, bracketStart);
			if (squareDepth > 0) isInBrackets.set(0, squareBracketStart);
		}

		// Walk the line from the end while it makes sense
		int position = currentCharIndex;
		parseLoop: while (position >= 0) {
			int currChar = lineText.charAt(position);
			switch (currChar) {
				case '.': // Grab it
					position--;
					break;
				case '[':
					break parseLoop; // End of scope
				case ']': // Grab the whole region in square brackets
					position = isInBrackets.previousClearBit(position-1);
					break;
				case '(':
					if (isInBrackets.get(position)) {
						position--; // This checks for first bracket while overloading
						break;
					}
					break parseLoop; // End of scope
				case ')': // Grab the whole region in brackets
					position = isInBrackets.previousClearBit(position-1);
					break;
				case '"': // Grab the whole literal and quit
					position = isInLiteral.previousClearBit(position - 1);
					break parseLoop;
				default:
					if (Character.isJavaIdentifierPart(currChar)) {
						position--; // Grab the identifier
					} else if (Character.isWhitespace(currChar)) {
						position--; // Grab whitespace too
					} else {
						break parseLoop; // Got a char ending the phrase
					}
					break;
			}
		}

		position++;

		// Extract phrase
		String phrase = lineText.substring(position, lineText.length()).trim();
		Messages.log(phrase);

		if (phrase.length() == 0 || Character.isDigit(phrase.charAt(0))) {
			return null; // Can't suggest for numbers or empty phrases
		}
		return phrase;
	}


	/**
	 * Calculates location of caret and displays the suggestion pop-up.
	 */
	protected void showSuggestion(DefaultListModel<CompletionCandidate> listModel, String subWord) {
		// TODO can this be ListModel instead? why is size() in DefaultListModel
		// different from getSize() in ListModel (or are they, really?)
		hideSuggestion();

		if (listModel.size() != 0) {
			int position = getCaretPosition();
			try {
				Point location =
					new Point(offsetToX(getCaretLine(),
															position - getLineStartOffset(getCaretLine())),
										lineToY(getCaretLine()) + getPainter().getLineHeight());
				suggestion = new CompletionPanel(this, position, subWord,
																				 listModel, location, getJavaEditor());
				requestFocusInWindow();

			} catch (Exception e) {
				e.printStackTrace();
			}
		} else {
			Messages.log("TextArea: No suggestions to show.");
		}
	}


	/** Hides suggestion popup */
	public void hideSuggestion() {
		if (suggestion != null) {
			suggestion.setInvisible();
			//log("Suggestion hidden.");
			suggestion = null; // TODO: check if we dispose the window properly
		}
	}


	// . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .

	// TWEAK MODE


	// save input listeners to stop/start text edit
	protected ComponentListener[] baseCompListeners;
	protected MouseListener[] baseMouseListeners;
	protected MouseMotionListener[] baseMotionListeners;
	protected KeyListener[] baseKeyListeners;
	protected boolean tweakMode;


	/* remove all standard interaction listeners */
	public void tweakRemoveListeners() {
		if (baseCompListeners == null) {
			// First time in tweak mode, grab the default listeners. Moved from the
			// constructor since not all listeners may have been added at that point.
			baseCompListeners = painter.getComponentListeners();
			baseMouseListeners = painter.getMouseListeners();
			baseMotionListeners = painter.getMouseMotionListeners();
			baseKeyListeners = editor.getKeyListeners();
		}
		ComponentListener[] componentListeners = painter.getComponentListeners();
		MouseListener[] mouseListeners = painter.getMouseListeners();
		MouseMotionListener[] mouseMotionListeners = painter.getMouseMotionListeners();
		KeyListener[] keyListeners = editor.getKeyListeners();

		for (ComponentListener cl : componentListeners) {
			painter.removeComponentListener(cl);
		}
		for (MouseListener ml : mouseListeners) {
			painter.removeMouseListener(ml);
		}
		for (MouseMotionListener mml : mouseMotionListeners) {
			painter.removeMouseMotionListener(mml);
		}
		for (KeyListener kl : keyListeners) {
			editor.removeKeyListener(kl);
		}
	}


	public void startTweakMode() {
		// ignore if we are already in interactiveMode
		if (!tweakMode) {
			tweakRemoveListeners();
			getJavaPainter().startTweakMode();
			this.editable = false;
			this.caretBlinks = false;
			this.setCaretVisible(false);
			tweakMode = true;
		}
	}


	public void stopTweakMode() {
		// ignore if we are not in interactive mode
		if (tweakMode) {
			tweakRemoveListeners();
			tweakRestoreBaseListeners();
			getJavaPainter().stopTweakMode();
			editable = true;
			caretBlinks = true;
			setCaretVisible(true);
			tweakMode = false;
		}
	}


	private void tweakRestoreBaseListeners() {
		// add the original text-edit listeners
		for (ComponentListener cl : baseCompListeners) {
			painter.addComponentListener(cl);
		}
		for (MouseListener ml : baseMouseListeners) {
			painter.addMouseListener(ml);
		}
		for (MouseMotionListener mml : baseMotionListeners) {
			painter.addMouseMotionListener(mml);
		}
		for (KeyListener kl : baseKeyListeners) {
			editor.addKeyListener(kl);
		}
	}


	public void updateInterface(List<List<Handle>> handles,
															List<List<ColorControlBox>> colorBoxes) {
		getJavaPainter().updateTweakInterface(handles, colorBoxes);
	}
}
