/*******************************************************************************
 * Copyright (c) 2009, 2018 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:
 *     IBM Corporation - initial API and implementation
 *     Angelo Zerr <angelo.zerr@gmail.com> - adapt code org.eclipse.wst.sse.ui.internal.projection.AbstractStructuredFoldingStrategy to support generic indent folding strategy.
 *                                           [generic editor] Default Code folding for generic editor should use IndentFoldingStrategy - Bug 520659
 */
package org.eclipse.ui.internal.genericeditor.folding;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.Position;
import org.eclipse.jface.text.reconciler.DirtyRegion;
import org.eclipse.jface.text.reconciler.IReconcilingStrategy;
import org.eclipse.jface.text.reconciler.IReconcilingStrategyExtension;
import org.eclipse.jface.text.source.Annotation;
import org.eclipse.jface.text.source.projection.IProjectionListener;
import org.eclipse.jface.text.source.projection.ProjectionAnnotation;
import org.eclipse.jface.text.source.projection.ProjectionAnnotationModel;
import org.eclipse.jface.text.source.projection.ProjectionViewer;
import org.eclipse.swt.graphics.FontMetrics;
import org.eclipse.swt.graphics.GC;
import org.eclipse.swt.graphics.Rectangle;
import org.eclipse.swt.widgets.Canvas;

/**
 * Indent folding strategy to fold code by using indentation. The folding
 * strategy must be associated with a viewer for it to function.
 */
public class IndentFoldingStrategy implements IReconcilingStrategy, IReconcilingStrategyExtension, IProjectionListener {

	private IDocument document;
	private ProjectionViewer viewer;
	private ProjectionAnnotationModel projectionAnnotationModel;
	private final String lineStartsWithKeyword;
	private boolean hasExternalFoldingAnnotations = false;

	public IndentFoldingStrategy() {
		this(null);
	}

	public IndentFoldingStrategy(String lineStartsWithKeyword) {
		this.lineStartsWithKeyword = lineStartsWithKeyword;
	}

	/**
	 * A FoldingAnnotation is a {@link ProjectionAnnotation} it is folding and
	 * overriding the paint method (in a hacky type way) to prevent one line folding
	 * annotations to be drawn.
	 */
	protected class FoldingAnnotation extends ProjectionAnnotation {
		private boolean visible; /* workaround for BUG85874 */

		/**
		 * Creates a new FoldingAnnotation.
		 *
		 * @param isCollapsed true if this annotation should be collapsed, false
		 *                    otherwise
		 */
		public FoldingAnnotation(boolean isCollapsed) {
			super(isCollapsed);
			visible = false;
		}

		/**
		 * Does not paint hidden annotations. Annotations are hidden when they only span
		 * one line.
		 *
		 * @see ProjectionAnnotation#paint(org.eclipse.swt.graphics.GC,
		 *      org.eclipse.swt.widgets.Canvas, org.eclipse.swt.graphics.Rectangle)
		 */
		@Override
		public void paint(GC gc, Canvas canvas, Rectangle rectangle) {
			/* workaround for BUG85874 */
			/*
			 * only need to check annotations that are expanded because hidden annotations
			 * should never have been given the chance to collapse.
			 */
			if (!isCollapsed()) {
				// working with rectangle, so line height
				FontMetrics metrics = gc.getFontMetrics();
				if (metrics != null) {
					// do not draw annotations that only span one line and
					// mark them as not visible
					if ((rectangle.height / metrics.getHeight()) <= 1) {
						visible = false;
						return;
					}
				}
			}
			visible = true;
			super.paint(gc, canvas, rectangle);
		}

		@Override
		public void markCollapsed() {
			/* workaround for BUG85874 */
			// do not mark collapsed if annotation is not visible
			if (visible)
				super.markCollapsed();
		}
	}

	/**
	 * The folding strategy must be associated with a viewer for it to function
	 *
	 * @param viewer the viewer to associate this folding strategy with
	 */
	public void setViewer(ProjectionViewer viewer) {
		if (this.viewer != null) {
			this.viewer.removeProjectionListener(this);
		}
		this.viewer = viewer;
		this.viewer.addProjectionListener(this);
		this.projectionAnnotationModel = this.viewer.getProjectionAnnotationModel();
	}

	public void uninstall() {
		setDocument(null);

		if (viewer != null) {
			viewer.removeProjectionListener(this);
			viewer = null;
		}

		projectionDisabled();
	}

	@Override
	public void setDocument(IDocument document) {
		this.document = document;
	}

	@Override
	public void projectionDisabled() {
		projectionAnnotationModel = null;
	}

	@Override
	public void projectionEnabled() {
		if (viewer != null) {
			projectionAnnotationModel = viewer.getProjectionAnnotationModel();
			if (projectionAnnotationModel != null) {
				projectionAnnotationModel.addAnnotationModelListener(model -> {
					IndentFoldingStrategy.this.hasExternalFoldingAnnotations = containsExternalFoldingAnnotations();
					if (hasExternalFoldingAnnotations) {
						removeCurrentFoldingAnnotations();
					} else {
						initialReconcile();
					}
				});
			}
		}
	}

	private static class LineIndent {
		public int line;
		public final int indent;

		public LineIndent(int line, int indent) {
			this.line = line;
			this.indent = indent;
		}
	}

	private boolean containsExternalFoldingAnnotations() {
		Iterator<Annotation> iter = getAnnotationIterator(null);
		boolean hasExternalFoldingAnnotation = false;
		if (iter != null) {
			while (iter.hasNext()) {
				Annotation anno = iter.next();
				if (!(anno instanceof FoldingAnnotation)) {
					hasExternalFoldingAnnotation = true;
					break;
				}
			}
		}
		return hasExternalFoldingAnnotation;
	}

	private void removeCurrentFoldingAnnotations() {
		List<Annotation> modifications = new ArrayList<>();
		List<FoldingAnnotation> deletions = new ArrayList<>();
		Map<Annotation, Position> additions = new HashMap<>();
		Iterator<Annotation> iter = getAnnotationIterator(null);
		deletions = getAllAnnotationsForDeletion(iter);
		projectionAnnotationModel.modifyAnnotations(deletions.toArray(new Annotation[1]), additions,
				modifications.toArray(new Annotation[0]));
	}

	@Override
	public void reconcile(DirtyRegion dirtyRegion, IRegion subRegion) {
		if (projectionAnnotationModel != null && !hasExternalFoldingAnnotations) {

			// these are what are passed off to the annotation model to
			// actually create and maintain the annotations
			List<Annotation> modifications = new ArrayList<>();
			List<FoldingAnnotation> deletions = new ArrayList<>();
			List<FoldingAnnotation> existing = new ArrayList<>();
			Map<Annotation, Position> additions = new HashMap<>();

			// find and mark all folding annotations with length 0 for deletion
			markInvalidAnnotationsForDeletion(dirtyRegion, deletions, existing);

			List<LineIndent> previousRegions = new ArrayList<>();

			int tabSize = 1;
			int minimumRangeSize = 1;
			try {

				// Today we recompute annotation from the whole document each
				// time.
				// performance s good even with large document, but it should be
				// better to loop for only DirtyRegion (and before/after)
				// int offset = dirtyRegion.getOffset();
				// int length = dirtyRegion.getLength();
				// int startLine = 0; //document.getLineOfOffset(offset);
				int endLine = document.getNumberOfLines() - 1; // startLine +
																// document.getNumberOfLines(offset,
																// length) - 1;

				// sentinel, to make sure there's at least one entry
				previousRegions.add(new LineIndent(endLine + 1, -1));

				int lastLineWhichIsNotEmpty = 0;
				int lineEmptyCount = 0;
				Integer lastLineForKeyword = null;
				int line = endLine;
				for (line = endLine; line >= 0; line--) {
					int lineOffset = document.getLineOffset(line);
					String delim = document.getLineDelimiter(line);
					int lineLength = document.getLineLength(line) - (delim != null ? delim.length() : 0);
					String lineContent = document.get(lineOffset, lineLength);

					LineState state = getLineState(lineContent, lastLineForKeyword);
					switch (state) {
					case StartWithKeyWord:
						lineEmptyCount = 0;
						lastLineWhichIsNotEmpty = line;
						if (lastLineForKeyword == null) {
							lastLineForKeyword = line;
						}
						break;
					case EmptyLine:
						lineEmptyCount++;
						break;
					default:
						addAnnotationForKeyword(modifications, deletions, existing, additions,
								line + 1 + lineEmptyCount, lastLineForKeyword);
						lastLineForKeyword = null;
						lineEmptyCount = 0;
						lastLineWhichIsNotEmpty = line;
						int indent = computeIndentLevel(lineContent, tabSize);
						if (indent == -1) {
							continue; // only whitespace
						}

						LineIndent previous = previousRegions.get(previousRegions.size() - 1);
						if (previous.indent > indent) {
							// discard all regions with larger indent
							do {
								previousRegions.remove(previousRegions.size() - 1);
								previous = previousRegions.get(previousRegions.size() - 1);
							} while (previous.indent > indent);

							// new folding range
							int endLineNumber = previous.line - 1;
							if (endLineNumber - line >= minimumRangeSize) {
								updateAnnotation(modifications, deletions, existing, additions, line, endLineNumber);
							}
						}
						if (previous.indent == indent) {
							previous.line = line;
						} else { // previous.indent < indent
							// new region with a bigger indent
							previousRegions.add(new LineIndent(line, indent));
						}
					}
				}
				addAnnotationForKeyword(modifications, deletions, existing, additions, lastLineWhichIsNotEmpty,
						lastLineForKeyword);
			} catch (BadLocationException e) {
				// should never done
				e.printStackTrace();
			}

			// be sure projection has not been disabled
			if (projectionAnnotationModel != null) {
				if (!existing.isEmpty()) {
					deletions.addAll(existing);
				}
				// send the calculated updates to the annotations to the
				// annotation model
				projectionAnnotationModel.modifyAnnotations(deletions.toArray(new Annotation[1]), additions,
						modifications.toArray(new Annotation[0]));
			}
		}
	}

	private void addAnnotationForKeyword(List<Annotation> modifications, List<FoldingAnnotation> deletions,
			List<FoldingAnnotation> existing, Map<Annotation, Position> additions, int startLine,
			Integer lastLineForKeyword) throws BadLocationException {
		if (lastLineForKeyword != null) {
			updateAnnotation(modifications, deletions, existing, additions, startLine, lastLineForKeyword);
		}
	}

	private enum LineState {
		StartWithKeyWord, DontStartWithKeyWord, EmptyLine
	}

	/**
	 * Returns the line state for line which starts with a given keyword.
	 *
	 * @param lineContent        line content.
	 * @param lastLineForKeyword last line for the given keyword.
	 * @return
	 */
	private LineState getLineState(String lineContent, Integer lastLineForKeyword) {
		if (lineStartsWithKeyword == null) {
			// none keyword defined.
			return LineState.DontStartWithKeyWord;
		}
		if (lineContent != null && lineContent.trim().startsWith(lineStartsWithKeyword)) {
			// The line starts with the given keyword (ex: starts with "import")
			return LineState.StartWithKeyWord;
		}
		if (lastLineForKeyword != null && (lineContent == null || lineContent.trim().isEmpty())) {
			// a last line for keyword was defined, line is empty
			return LineState.EmptyLine;
		}
		return LineState.DontStartWithKeyWord;
	}

	/**
	 * Compute indentation level of the given line by using the given tab size.
	 *
	 * @param line    the line text.
	 * @param tabSize the tab size.
	 * @return the indentation level of the given line by using the given tab size.
	 */
	private static int computeIndentLevel(String line, int tabSize) {
		int i = 0;
		int indent = 0;
		while (i < line.length()) {
			char ch = line.charAt(i);
			if (ch == ' ') {
				indent++;
			} else if (ch == '\t') {
				indent = indent - indent % tabSize + tabSize;
			} else {
				break;
			}
			i++;
		}
		if (i == line.length()) {
			return -1; // line only consists of whitespace
		}
		return indent;
	}

	/**
	 * Given a {@link DirtyRegion} returns an {@link Iterator} of the already
	 * existing annotations in that region.
	 *
	 * @param dirtyRegion the {@link DirtyRegion} to check for existing annotations
	 *                    in
	 *
	 * @return an {@link Iterator} over the annotations in the given
	 *         {@link DirtyRegion}. The iterator could have no annotations in it. Or
	 *         <code>null</code> if projection has been disabled.
	 */
	private Iterator<Annotation> getAnnotationIterator(DirtyRegion dirtyRegion) {
		Iterator<Annotation> annoIter = null;
		// be sure project has not been disabled
		if (projectionAnnotationModel != null) {
			// workaround for Platform Bug 299416
			annoIter = projectionAnnotationModel.getAnnotationIterator(0, document.getLength(), false, false);
		}
		return annoIter;
	}

	/**
	 * Update annotations.
	 *
	 * @param modifications the folding annotations to update.
	 * @param deletions     the folding annotations to delete.
	 * @param existing      the existing folding annotations.
	 * @param additions     annoation to add
	 * @param line          the line index
	 * @param endLineNumber the end line number
	 * @throws BadLocationException
	 */
	private void updateAnnotation(List<Annotation> modifications, List<FoldingAnnotation> deletions,
			List<FoldingAnnotation> existing, Map<Annotation, Position> additions, int line, Integer endLineNumber)
			throws BadLocationException {
		int startOffset = document.getLineOffset(line);
		int endOffset = document.getLineOffset(endLineNumber) + document.getLineLength(endLineNumber);
		Position newPos = new Position(startOffset, endOffset - startOffset);
		if (!existing.isEmpty()) {
			FoldingAnnotation existingAnnotation = existing.remove(existing.size() - 1);
			updateAnnotations(existingAnnotation, newPos, modifications, deletions);
		} else {
			additions.put(new FoldingAnnotation(false), newPos);
		}
	}

	/**
	 * Update annotations.
	 *
	 * @param existingAnnotation the existing annotations that need to be updated
	 *                           based on the given dirtied IndexRegion
	 * @param newPos             the new position that caused the annotations need
	 *                           for updating and null otherwise.
	 * @param modifications      the list of annotations to be modified
	 * @param deletions          the list of annotations to be deleted
	 */
	protected void updateAnnotations(Annotation existingAnnotation, Position newPos, List<Annotation> modifications,
			List<FoldingAnnotation> deletions) {
		if (existingAnnotation instanceof FoldingAnnotation) {
			FoldingAnnotation foldingAnnotation = (FoldingAnnotation) existingAnnotation;

			// if a new position can be calculated then update the position of
			// the annotation,
			// else the annotation needs to be deleted
			if (newPos != null && newPos.length > 0 && projectionAnnotationModel != null) {
				Position oldPos = projectionAnnotationModel.getPosition(foldingAnnotation);
				// only update the position if we have to
				if (!newPos.equals(oldPos)) {
					oldPos.setOffset(newPos.offset);
					oldPos.setLength(newPos.length);
					modifications.add(foldingAnnotation);
				}
			} else {
				deletions.add(foldingAnnotation);
			}
		}
	}

	/**
	 * <p>
	 * Searches the given {@link DirtyRegion} for annotations that now have a length
	 * of 0. This is caused when something that was being folded has been deleted.
	 * These {@link FoldingAnnotation}s are then added to the {@link List} of
	 * {@link FoldingAnnotation}s to be deleted
	 * </p>
	 *
	 * @param dirtyRegion find the now invalid {@link FoldingAnnotation}s in this
	 *                    {@link DirtyRegion}
	 * @param deletions   the current list of {@link FoldingAnnotation}s marked for
	 *                    deletion that the newly found invalid
	 *                    {@link FoldingAnnotation}s will be added to
	 */
	protected void markInvalidAnnotationsForDeletion(DirtyRegion dirtyRegion, List<FoldingAnnotation> deletions,
			List<FoldingAnnotation> existing) {
		Iterator<Annotation> iter = getAnnotationIterator(dirtyRegion);
		if (iter != null) {
			while (iter.hasNext()) {
				Annotation anno = iter.next();
				if (anno instanceof FoldingAnnotation) {
					FoldingAnnotation folding = (FoldingAnnotation) anno;
					Position pos = projectionAnnotationModel.getPosition(anno);
					if (pos.length == 0) {
						deletions.add(folding);
					} else {
						existing.add(folding);
					}
				}
			}
		}
	}

	protected List<FoldingAnnotation> getAllAnnotationsForDeletion(Iterator<Annotation> iter) {
		List<FoldingAnnotation> deletions = new ArrayList<>();
		if (iter != null) {
			while (iter.hasNext()) {
				Annotation anno = iter.next();
				if (anno instanceof FoldingAnnotation) {
					deletions.add((FoldingAnnotation) anno);
				}
			}
		}
		return deletions;
	}

	@Override
	public void reconcile(IRegion partition) {
		// not used, we use:
		// reconcile(DirtyRegion dirtyRegion, IRegion subRegion)
	}

	@Override
	public void setProgressMonitor(IProgressMonitor monitor) {
		// Do nothing
	}

	@Override
	public void initialReconcile() {
		reconcile(new DirtyRegion(0, document.getLength(), DirtyRegion.INSERT, document.get()), null);
	}
}