/*
 * Copyright (c) 2015 Karl Tauber <karl at jformdesigner dot com>
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 *  o Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 *
 *  o Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package org.markdownwriterfx.editor;

import static javafx.scene.input.KeyCode.*;
import static javafx.scene.input.KeyCombination.*;
import static org.fxmisc.wellbehaved.event.EventPattern.keyPressed;
import static org.fxmisc.wellbehaved.event.InputMap.*;
import java.io.File;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.ReadOnlyStringWrapper;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.Scene;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.IndexRange;
import javafx.scene.input.ContextMenuEvent;
import javafx.scene.input.DragEvent;
import javafx.scene.input.Dragboard;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.TransferMode;
import com.vladsch.flexmark.ast.Node;
import com.vladsch.flexmark.parser.Parser;
import org.fxmisc.flowless.VirtualizedScrollPane;
import org.fxmisc.richtext.Caret.CaretVisibility;
import org.fxmisc.richtext.CaretNode;
import org.fxmisc.richtext.CharacterHit;
import org.fxmisc.undo.UndoManager;
import org.fxmisc.wellbehaved.event.Nodes;
import org.markdownwriterfx.controls.BottomSlidePane;
import org.markdownwriterfx.editor.FindReplacePane.HitsChangeListener;
import org.markdownwriterfx.editor.MarkdownSyntaxHighlighter.ExtraStyledRanges;
import org.markdownwriterfx.options.MarkdownExtensions;
import org.markdownwriterfx.options.Options;

/**
 * Markdown editor pane.
 *
 * Uses flexmark-java (https://github.com/vsch/flexmark-java) for parsing markdown.
 *
 * @author Karl Tauber
 */
public class MarkdownEditorPane
{
	private final BottomSlidePane borderPane;
	private final MarkdownTextArea textArea;
	private final ParagraphOverlayGraphicFactory overlayGraphicFactory;
	private LineNumberGutterFactory lineNumberGutterFactory;
	private WhitespaceOverlayFactory whitespaceOverlayFactory;
	private ContextMenu contextMenu;
	private CaretNode dragCaret;
	private final SmartEdit smartEdit;

	private final FindReplacePane findReplacePane;
	private final HitsChangeListener findHitsChangeListener;
	private Parser parser;
	private final InvalidationListener optionsListener;
	private String lineSeparator = getLineSeparatorOrDefault();

	public MarkdownEditorPane() {
		textArea = new MarkdownTextArea();
		textArea.setWrapText(true);
		textArea.setUseInitialStyleForInsertion(true);
		textArea.getStyleClass().add("markdown-editor");
		textArea.getStylesheets().add("org/markdownwriterfx/editor/MarkdownEditor.css");
		textArea.getStylesheets().add("org/markdownwriterfx/prism.css");

		textArea.textProperty().addListener((observable, oldText, newText) -> {
			textChanged(newText);
		});

		textArea.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, this::showContextMenu);
		textArea.addEventHandler(MouseEvent.MOUSE_PRESSED, this::hideContextMenu);
		textArea.setOnDragEntered(this::onDragEntered);
		textArea.setOnDragExited(this::onDragExited);
		textArea.setOnDragOver(this::onDragOver);
		textArea.setOnDragDropped(this::onDragDropped);

		smartEdit = new SmartEdit(this, textArea);

		Nodes.addInputMap(textArea, sequence(
			consume(keyPressed(PLUS, SHORTCUT_DOWN),	this::increaseFontSize),
			consume(keyPressed(MINUS, SHORTCUT_DOWN),	this::decreaseFontSize),
			consume(keyPressed(DIGIT0, SHORTCUT_DOWN),	this::resetFontSize),
			consume(keyPressed(W, ALT_DOWN),			this::showWhitespace),
			consume(keyPressed(I, ALT_DOWN),			this::showImagesEmbedded)
		));

		// create scroll pane
		VirtualizedScrollPane<MarkdownTextArea> scrollPane = new VirtualizedScrollPane<>(textArea);

		// create border pane
		borderPane = new BottomSlidePane(scrollPane);

		overlayGraphicFactory = new ParagraphOverlayGraphicFactory(textArea);
		textArea.setParagraphGraphicFactory(overlayGraphicFactory);
		updateFont();
		updateShowLineNo();
		updateShowWhitespace();

		// initialize properties
		markdownText.set("");
		markdownAST.set(parseMarkdown(""));

		// find/replace
		findReplacePane = new FindReplacePane(textArea);
		findHitsChangeListener = this::findHitsChanged;
		findReplacePane.addListener(findHitsChangeListener);
		findReplacePane.visibleProperty().addListener((ov, oldVisible, newVisible) -> {
			if (!newVisible)
				borderPane.setBottom(null);
		});

		// listen to option changes
		optionsListener = e -> {
			if (textArea.getScene() == null)
				return; // editor closed but not yet GCed

			if (e == Options.fontFamilyProperty() || e == Options.fontSizeProperty())
				updateFont();
			else if (e == Options.showLineNoProperty())
				updateShowLineNo();
			else if (e == Options.showWhitespaceProperty())
				updateShowWhitespace();
			else if (e == Options.showImagesEmbeddedProperty())
				updateShowImagesEmbedded();
			else if (e == Options.markdownRendererProperty() || e == Options.markdownExtensionsProperty()) {
				// re-process markdown if markdown extensions option changes
				parser = null;
				textChanged(textArea.getText());
			}
		};
		WeakInvalidationListener weakOptionsListener = new WeakInvalidationListener(optionsListener);
		Options.fontFamilyProperty().addListener(weakOptionsListener);
		Options.fontSizeProperty().addListener(weakOptionsListener);
		Options.markdownRendererProperty().addListener(weakOptionsListener);
		Options.markdownExtensionsProperty().addListener(weakOptionsListener);
		Options.showLineNoProperty().addListener(weakOptionsListener);
		Options.showWhitespaceProperty().addListener(weakOptionsListener);
		Options.showImagesEmbeddedProperty().addListener(weakOptionsListener);

		// workaround a problem with wrong selection after undo:
		//   after undo the selection is 0-0, anchor is 0, but caret position is correct
		//   --> set selection to caret position
		textArea.selectionProperty().addListener((observable,oldSelection,newSelection) -> {
			// use runLater because the wrong selection temporary occurs while edition
			Platform.runLater(() -> {
				IndexRange selection = textArea.getSelection();
				int caretPosition = textArea.getCaretPosition();
				if (selection.getStart() == 0 && selection.getEnd() == 0 && textArea.getAnchor() == 0 && caretPosition > 0)
					textArea.selectRange(caretPosition, caretPosition);
			});
		});
	}

	private void updateFont() {
		textArea.setStyle("-fx-font-family: '" + Options.getFontFamily()
				+ "'; -fx-font-size: " + Options.getFontSize() );
	}

	public javafx.scene.Node getNode() {
		return borderPane;
	}

	public boolean isReadOnly() {
		return textArea.isDisable();
	}

	public void setReadOnly(boolean readOnly) {
		textArea.setDisable(readOnly);
	}

	public BooleanProperty readOnlyProperty() {
		return textArea.disableProperty();
	}

	public UndoManager<?> getUndoManager() {
		return textArea.getUndoManager();
	}

	public SmartEdit getSmartEdit() {
		return smartEdit;
	}

	public void requestFocus() {
		Platform.runLater(() -> {
			if (textArea.getScene() != null)
				textArea.requestFocus();
			else {
				// text area still does not have a scene
				// --> use listener on scene to make sure that text area receives focus
				ChangeListener<Scene> l = new ChangeListener<Scene>() {
					@Override
					public void changed(ObservableValue<? extends Scene> observable, Scene oldValue, Scene newValue) {
						textArea.sceneProperty().removeListener(this);
						textArea.requestFocus();
					}
				};
				textArea.sceneProperty().addListener(l);
			}
		});
	}

	private String getLineSeparatorOrDefault() {
		String lineSeparator = Options.getLineSeparator();
		return (lineSeparator != null) ? lineSeparator : System.getProperty( "line.separator", "\n" );
	}

	private String determineLineSeparator(String str) {
		int strLength = str.length();
		for (int i = 0; i < strLength; i++) {
			char ch = str.charAt(i);
			if (ch == '\n')
				return (i > 0 && str.charAt(i - 1) == '\r') ? "\r\n" : "\n";
		}
		return getLineSeparatorOrDefault();
	}

	// 'markdown' property
	public String getMarkdown() {
		String markdown = textArea.getText();
		if (!lineSeparator.equals("\n"))
			markdown = markdown.replace("\n", lineSeparator);
		return markdown;
	}
	public void setMarkdown(String markdown) {
		// remember old selection range
		IndexRange oldSelection = textArea.getSelection();

		// replace text
		lineSeparator = determineLineSeparator(markdown);
		textArea.replaceText(markdown);

		// restore old selection range
        int newLength = textArea.getLength();
        textArea.selectRange(Math.min(oldSelection.getStart(), newLength), Math.min(oldSelection.getEnd(), newLength));
	}
	public ObservableValue<String> markdownProperty() { return textArea.textProperty(); }

	// 'markdownText' property
	private final ReadOnlyStringWrapper markdownText = new ReadOnlyStringWrapper();
	public String getMarkdownText() { return markdownText.get(); }
	public ReadOnlyStringProperty markdownTextProperty() { return markdownText.getReadOnlyProperty(); }

	// 'markdownAST' property
	private final ReadOnlyObjectWrapper<Node> markdownAST = new ReadOnlyObjectWrapper<>();
	public Node getMarkdownAST() { return markdownAST.get(); }
	public ReadOnlyObjectProperty<Node> markdownASTProperty() { return markdownAST.getReadOnlyProperty(); }

	// 'selection' property
	public ObservableValue<IndexRange> selectionProperty() { return textArea.selectionProperty(); }

	// 'scrollY' property
	public double getScrollY() { return textArea.scrollY.getValue(); }
	public ObservableValue<Double> scrollYProperty() { return textArea.scrollY; }

	// 'path' property
	private final ObjectProperty<Path> path = new SimpleObjectProperty<>();
	public Path getPath() { return path.get(); }
	public void setPath(Path path) { this.path.set(path); }
	public ObjectProperty<Path> pathProperty() { return path; }

	Path getParentPath() {
		Path path = getPath();
		return (path != null) ? path.getParent() : null;
	}

	private void textChanged(String newText) {
		if (borderPane.getBottom() != null) {
			findReplacePane.removeListener(findHitsChangeListener);
			findReplacePane.textChanged();
			findReplacePane.addListener(findHitsChangeListener);
		}

		if (isReadOnly())
			newText = "";

		Node astRoot = parseMarkdown(newText);

		if (Options.isShowImagesEmbedded())
			EmbeddedImage.replaceImageSegments(textArea, astRoot, getParentPath());

		applyHighlighting(astRoot);

		markdownText.set(newText);
		markdownAST.set(astRoot);
	}

	private void findHitsChanged() {
		applyHighlighting(markdownAST.get());
	}

	Node parseMarkdown(String text) {
		if (parser == null) {
			parser = Parser.builder()
				.extensions(MarkdownExtensions.getFlexmarkExtensions(Options.getMarkdownRenderer()))
				.build();
		}
		return parser.parse(text);
	}

	private void applyHighlighting(Node astRoot) {
		List<ExtraStyledRanges> extraStyledRanges = findReplacePane.hasHits()
			? Arrays.asList(
				new ExtraStyledRanges("hit", findReplacePane.getHits()),
				new ExtraStyledRanges("hit-active", Arrays.asList(findReplacePane.getActiveHit())))
			: null;

		MarkdownSyntaxHighlighter.highlight(textArea, astRoot, extraStyledRanges);
	}

	private void increaseFontSize(KeyEvent e) {
		Options.setFontSize(Options.getFontSize() + 1);
	}

	private void decreaseFontSize(KeyEvent e) {
		Options.setFontSize(Options.getFontSize() - 1);
	}

	private void resetFontSize(KeyEvent e) {
		Options.setFontSize(Options.DEF_FONT_SIZE);
	}

	private void showWhitespace(KeyEvent e) {
		Options.setShowWhitespace(!Options.isShowWhitespace());
	}

	private void showImagesEmbedded(KeyEvent e) {
		Options.setShowImagesEmbedded(!Options.isShowImagesEmbedded());
	}

	private void updateShowLineNo() {
		boolean showLineNo = Options.isShowLineNo();
		if (showLineNo && lineNumberGutterFactory == null) {
			lineNumberGutterFactory = new LineNumberGutterFactory(textArea);
			overlayGraphicFactory.addGutterFactory(lineNumberGutterFactory);
		} else if (!showLineNo && lineNumberGutterFactory != null) {
			overlayGraphicFactory.removeGutterFactory(lineNumberGutterFactory);
			lineNumberGutterFactory = null;
		}
	}

	private void updateShowWhitespace() {
		boolean showWhitespace = Options.isShowWhitespace();
		if (showWhitespace && whitespaceOverlayFactory == null) {
			whitespaceOverlayFactory = new WhitespaceOverlayFactory();
			overlayGraphicFactory.addOverlayFactory(whitespaceOverlayFactory);
		} else if (!showWhitespace && whitespaceOverlayFactory != null) {
			overlayGraphicFactory.removeOverlayFactory(whitespaceOverlayFactory);
			whitespaceOverlayFactory = null;
		}
	}

	private void updateShowImagesEmbedded() {
		if (Options.isShowImagesEmbedded())
			EmbeddedImage.replaceImageSegments(textArea, getMarkdownAST(), getParentPath());
		else
			EmbeddedImage.removeAllImageSegments(textArea);
	}

	public void undo() {
		textArea.getUndoManager().undo();
	}

	public void redo() {
		textArea.getUndoManager().redo();
	}

	public void cut() {
		textArea.cut();
	}

	public void copy() {
		textArea.copy();
	}

	public void paste() {
		textArea.paste();
	}

	public void selectAll() {
		textArea.selectAll();
	}

	public void selectRange(int anchor, int caretPosition) {
		SmartEdit.selectRange(textArea, anchor, caretPosition);
	}

	//---- context menu -------------------------------------------------------

	private void showContextMenu(ContextMenuEvent e) {
		if (e.isConsumed())
			return;

		// create context menu
		if (contextMenu == null) {
			contextMenu = new ContextMenu();
			initContextMenu();
		}

		// update context menu
		CharacterHit hit = textArea.hit(e.getX(), e.getY());
		updateContextMenu(hit.getCharacterIndex().orElse(-1), hit.getInsertionIndex());

		if (contextMenu.getItems().isEmpty())
			return;

		// show context menu
		contextMenu.show(textArea, e.getScreenX(), e.getScreenY());
		e.consume();
	}

	private void hideContextMenu(MouseEvent e) {
		if (contextMenu != null)
			contextMenu.hide();
	}

	private void initContextMenu() {
		SmartEditActions.initContextMenu(this, contextMenu);
	}

	private void updateContextMenu(int characterIndex, int insertionIndex) {
		SmartEditActions.updateContextMenu(this, contextMenu, characterIndex);
	}

	//---- find/replace -------------------------------------------------------

	public void find(boolean replace) {
		if (borderPane.getBottom() == null)
			borderPane.setBottom(findReplacePane.getNode());

		findReplacePane.show(replace, true);
	}

	public void findNextPrevious(boolean next) {
		if (borderPane.getBottom() == null) {
			// show pane
			find(false);
			return;
		}

		if (next)
			findReplacePane.findNext();
		else
			findReplacePane.findPrevious();
	}

	//---- drag & drop --------------------------------------------------------

	private void onDragEntered(DragEvent event) {
		// create drag caret
		if (dragCaret == null) {
			dragCaret = new CaretNode("mwfx-drag-caret", textArea);
			dragCaret.getStyleClass().add("drag-caret");
			textArea.addCaret(dragCaret);
		}

		// show drag caret
        dragCaret.setShowCaret(CaretVisibility.ON);
	}

	private void onDragExited(DragEvent event) {
		// hide drag caret
		dragCaret.setShowCaret(CaretVisibility.OFF);
	}

	private void onDragOver(DragEvent event) {
		// check whether we can accept a drop
		Dragboard db = event.getDragboard();
		if (db.hasString() || db.hasFiles())
			event.acceptTransferModes(TransferMode.COPY);

		// move drag caret to mouse location
		if (event.isAccepted()) {
			CharacterHit hit = textArea.hit(event.getX(), event.getY());
			dragCaret.moveTo(hit.getInsertionIndex());
		}

		event.consume();
	}

	private void onDragDropped(DragEvent event) {
		Dragboard db = event.getDragboard();
		if (db.hasFiles()) {
			// drop files (e.g. from project file tree)
			List<File> files = db.getFiles();
			if (!files.isEmpty())
				smartEdit.insertLinkOrImage(dragCaret.getPosition(), files.get(0).toPath());
		} else if (db.hasString()) {
			// drop text
			String newText = db.getString();
			int insertPosition = dragCaret.getPosition();
			SmartEdit.insertText(textArea, insertPosition, newText);
			SmartEdit.selectRange(textArea, insertPosition, insertPosition + newText.length());
		}

		textArea.requestFocus();

		event.setDropCompleted(true);
		event.consume();
	}
}