/*******************************************************************************
 * Copyright (c) 2014, 2023 Mateusz Matela 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:
 *     Mateusz Matela <mateusz.matela@gmail.com> - [formatter] Formatter does not format Java code correctly, especially when max line width is set - https://bugs.eclipse.org/303519
 *******************************************************************************/
package org.eclipse.jdt.internal.formatter;

import static org.eclipse.jdt.internal.compiler.parser.TerminalTokens.TokenNameCOMMENT_LINE;
import static org.eclipse.jdt.internal.compiler.parser.TerminalTokens.TokenNameCOMMENT_BLOCK;
import static org.eclipse.jdt.internal.compiler.parser.TerminalTokens.TokenNameCOMMENT_JAVADOC;
import static org.eclipse.jdt.internal.compiler.parser.TerminalTokens.TokenNameNotAToken;
import static org.eclipse.jdt.internal.compiler.parser.TerminalTokens.TokenNameStringLiteral;
import static org.eclipse.jdt.internal.compiler.parser.TerminalTokens.TokenNameTextBlock;
import static org.eclipse.jdt.internal.compiler.parser.TerminalTokens.TokenNameWHITESPACE;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import org.eclipse.jdt.internal.compiler.parser.ScannerHelper;
import org.eclipse.jdt.internal.formatter.Token.WrapMode;
import org.eclipse.jdt.internal.formatter.Token.WrapPolicy;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.Region;
import org.eclipse.text.edits.ReplaceEdit;
import org.eclipse.text.edits.TextEdit;

/**
 * Creates the formatter's result TextEdit by scanning through the tokens and comparing them with the original source.
 */
public class TextEditsBuilder extends TokenTraverser {

	private final String source;
	private TokenManager tm;
	private final DefaultCodeFormatterOptions options;
	private final StringBuilder buffer;

	private final List<Token> stringLiteralsInLine = new ArrayList<Token>();
	private final List<TextEdit> edits = new ArrayList<TextEdit>();

	private final List<IRegion> regions;
	private int currentRegion = 0;

	private TextEditsBuilder childBuilder;
	private final TextEditsBuilder parent;
	private int alignChar;
	private int sourceLimit;
	private int parentTokenIndex;

	public TextEditsBuilder(String source, List<IRegion> regions, TokenManager tokenManager,
			DefaultCodeFormatterOptions options) {
		this.source = source;
		this.tm = tokenManager;
		this.options = options;
		this.regions = adaptRegions(regions);

		this.alignChar = this.options.align_with_spaces ? DefaultCodeFormatterOptions.SPACE : this.options.tab_char;
		this.sourceLimit = source.length();
		this.parent = null;

		this.buffer = new StringBuilder();
	}

	private TextEditsBuilder(TextEditsBuilder parent) {
		this.buffer = parent.buffer;
		this.parent = parent;
		this.source = parent.source;
		this.options = parent.options;
		this.regions = parent.regions;
		this.alignChar = DefaultCodeFormatterOptions.SPACE;
	}

	private List<IRegion> adaptRegions(List<IRegion> givenRegions) {
		// make sure regions don't begin or end inside multiline comments
		ArrayList<IRegion> result = new ArrayList<IRegion>();
		IRegion previous = null;
		for (IRegion region : givenRegions) {
			int start = region.getOffset();
			int end = start + region.getLength() - 1;
			int sourceStart = this.tm.get(0).originalStart;

			if (start > sourceStart) {
				Token token = this.tm.get(this.tm.findIndex(start, -1, false));
				if ((token.tokenType == TokenNameCOMMENT_BLOCK || token.tokenType == TokenNameCOMMENT_JAVADOC)
						&& start <= token.originalEnd) {
					start = token.originalStart;
				}
			}

			if (end > start && end > sourceStart) {
				Token token = this.tm.get(this.tm.findIndex(end, -1, false));
				if ((token.tokenType == TokenNameCOMMENT_BLOCK || token.tokenType == TokenNameCOMMENT_JAVADOC)
						&& end < token.originalEnd) {
					end = token.originalEnd;
				}
			}

			if (previous != null && previous.getOffset() + previous.getLength() >= start) {
				result.remove(result.size() - 1);
				start = previous.getOffset();
			}
			if (end + 1 == this.source.length())
				end++;
			IRegion adapted = new Region(start, end - start + 1);
			result.add(adapted);
			previous = adapted;
		}
		return result;
	}

	@Override
	protected boolean token(Token token, int index) {

		bufferWhitespaceBefore(token, index);

		List<Token> structure = token.getInternalStructure();
		if (token.tokenType == TokenNameCOMMENT_LINE) {
			handleSingleLineComment(token, index);
		} else if (structure != null && !structure.isEmpty()) {
			handleStructuredToken(token, index);
		} else {
			flushBuffer(token.originalStart);
			if (token.isToEscape()) {
				this.buffer.append(this.tm.toString(token));
				flushBuffer(token.originalEnd + 1);
			} else {
				this.counter = token.originalEnd + 1;
			}
		}

		if (token.tokenType == TokenNameStringLiteral || token.tokenType == TokenNameTextBlock)
			this.stringLiteralsInLine.add(token);

		if (getNext() == null) {
			for (int i = 0; i < token.getLineBreaksAfter(); i++)
				bufferLineSeparator(null, i + 1 == token.getLineBreaksAfter());
			char lastChar = this.source.charAt(this.sourceLimit - 1);
			if (token.getLineBreaksAfter() == 0 && (lastChar == '\r' || lastChar == '\n'))
				bufferLineSeparator(null, false);
			flushBuffer(this.sourceLimit);
		}
		return true;
	}

	private void bufferWhitespaceBefore(Token token, int index) {
		if (getLineBreaksBefore() > 0) {
			this.stringLiteralsInLine.clear();
			if (getLineBreaksBefore() > 1) {
				Token indentToken = null;
				if (this.options.indent_empty_lines && token.tokenType != TokenNameNotAToken) {
					if (index == 0) {
						indentToken = token;
					} else {
						boolean isBlockIndent = token.getWrapPolicy() != null
								&& token.getWrapPolicy().wrapMode == WrapMode.BLOCK_INDENT;
						Token previous = this.tm.get(this.tm.findFirstTokenInLine(index - 1, true, !isBlockIndent));
						indentToken = (token.getIndent() > previous.getIndent()) ? token : previous;
					}
				}
				for (int i = 1; i < getLineBreaksBefore(); i++) {
					bufferLineSeparator(token, true);
					if (indentToken != null)
						bufferIndent(indentToken, index);
				}
			}
			bufferLineSeparator(token, false);
			bufferAlign(token, index);
			bufferIndent(token, index);
		} else if (index == 0 && this.parent == null) {
			bufferIndent(token, index);
		} else {
			if (!bufferAlign(token, index) && isSpaceBefore())
				this.buffer.append(' ');
		}
	}

	private void bufferLineSeparator(Token token, boolean emptyLine) {
		if (this.parent == null) {
			this.buffer.append(this.options.line_separator);
			return;
		}

		boolean isTextBlock = token != null && token.tokenType == TokenNameTextBlock;
		this.parent.counter = this.counter;
		this.parent.bufferLineSeparator(null, false);
		if (!(isTextBlock && emptyLine && !this.options.indent_empty_lines))
			this.parent.bufferIndent(this.parent.tm.get(this.parentTokenIndex), this.parentTokenIndex);
		this.counter = this.parent.counter;

		if (isTextBlock)
			return;
		if (token != null && token.tokenType == TokenNameNotAToken)
			return; // this is an unformatted block comment, don't force asterisk
		if (getNext() == null && !emptyLine)
			return; // this is the last token of block comment, asterisk is included

		boolean asteriskFound = false;
		int searchLimit = token != null ? token.originalStart : this.sourceLimit;
		for (int i = this.counter; i < searchLimit; i++) {
			char c = this.source.charAt(i);
			if (c == '*') {
				this.buffer.append(' ');
				flushBuffer(i);
				while (i + 1 < this.sourceLimit && this.source.charAt(i + 1) == '*')
					i++;
				this.counter = i + 1;
				c = this.source.charAt(i + 1);
				if ((c != '\r' && c != '\n') || !emptyLine)
					this.buffer.append(' ');
				asteriskFound = true;
				break;
			}
			if (!ScannerHelper.isWhitespace(c))
				break;
		}
		if (!asteriskFound)
			this.buffer.append(" * "); //$NON-NLS-1$
	}

	private void bufferIndent(Token token, int index) {
		int indent = token.getIndent();
		if (getCurrent() != null && getCurrent() != token)
			indent += getCurrent().getEmptyLineIndentAdjustment();
		int spaces = 0;
		if (this.options.use_tabs_only_for_leading_indentations
				&& this.options.tab_char != DefaultCodeFormatterOptions.SPACE) {
			WrapPolicy wrapPolicy = token.getWrapPolicy();
			boolean isWrappedBlockComment = this.childBuilder != null && this.childBuilder.parentTokenIndex == index;
			if (isWrappedBlockComment) {
				Token lineStart = this.tm.get(this.tm.findFirstTokenInLine(index));
				spaces = token.getIndent() - lineStart.getIndent();
				token = lineStart;
				wrapPolicy = token.getWrapPolicy();
			}
			while (wrapPolicy != null) {
				Token parentLineStart = this.tm.get(this.tm.findFirstTokenInLine(wrapPolicy.wrapParentIndex));
				if (wrapPolicy.wrapMode != WrapMode.BLOCK_INDENT)
					spaces += token.getIndent() - parentLineStart.getIndent();
				token = parentLineStart;
				if (wrapPolicy == token.getWrapPolicy()) {
					assert wrapPolicy == WrapPolicy.FORCE_FIRST_COLUMN || wrapPolicy == WrapPolicy.DISABLE_WRAP;
					break;
				}
				wrapPolicy = token.getWrapPolicy();
			}
		}
		appendIndentationString(this.buffer, this.options.tab_char, this.options.tab_size, indent - spaces, spaces);
	}

	public static void appendIndentationString(StringBuilder target, int tabChar, int tabSize, int indent,
			int additionalSpaces) {
		int spacesCount = additionalSpaces;
		int tabsCount = 0;
		switch (tabChar) {
			case DefaultCodeFormatterOptions.SPACE:
				spacesCount += indent;
				break;
			case DefaultCodeFormatterOptions.TAB:
				if (tabSize > 0) {
					tabsCount += indent / tabSize;
					if (indent % tabSize > 0)
						tabsCount++;
				}
				break;
			case DefaultCodeFormatterOptions.MIXED:
				if (tabSize > 0) {
					tabsCount += indent / tabSize;
					spacesCount += indent % tabSize;
				} else {
					spacesCount += indent;
				}
				break;
			default:
				throw new IllegalStateException("Unrecognized tab char: " + tabChar); //$NON-NLS-1$
		}

		char[] indentChars = new char[tabsCount + spacesCount];
		Arrays.fill(indentChars, 0, tabsCount, '\t');
		Arrays.fill(indentChars, tabsCount, indentChars.length, ' ');
		target.append(indentChars);
	}

	private boolean bufferAlign(Token token, int index) {
		int align = token.getAlign();
		int alignmentChar = this.alignChar;
		if (align == 0 && getLineBreaksBefore() == 0 && this.parent != null) {
			align = token.getIndent();
			token.setAlign(align);
			alignmentChar = DefaultCodeFormatterOptions.SPACE;
		}
		if (align == 0)
			return false;

		int currentPositionInLine = 0;
		if (getLineBreaksBefore() > 0) {
			if (this.parent == null)
				currentPositionInLine = this.tm.toIndent(token.getIndent(), token.getWrapPolicy() != null);
		} else {
			currentPositionInLine = this.tm.getPositionInLine(index - 1);
			currentPositionInLine += this.tm.getLength(this.tm.get(index - 1), currentPositionInLine);
		}
		if (currentPositionInLine >= align)
			return false;

		final int tabSize = this.options.tab_size;
		switch (alignmentChar) {
			case DefaultCodeFormatterOptions.SPACE:
				while (currentPositionInLine++ < align) {
					this.buffer.append(' ');
				}
				break;
			case DefaultCodeFormatterOptions.TAB:
				while (currentPositionInLine < align && tabSize > 0) {
					this.buffer.append('\t');
					currentPositionInLine += tabSize - currentPositionInLine % tabSize;
				}
				break;
			case DefaultCodeFormatterOptions.MIXED:
				while (tabSize > 0 && currentPositionInLine + tabSize - currentPositionInLine % tabSize <= align) {
					this.buffer.append('\t');
					currentPositionInLine += tabSize - currentPositionInLine % tabSize;
				}
				while (currentPositionInLine++ < align) {
					this.buffer.append(' ');
				}
				break;
			default:
				throw new IllegalStateException("Unrecognized align char: " + alignmentChar); //$NON-NLS-1$
		}
		return true;
	}

	private void flushBuffer(int currentPosition) {
		String buffered = this.buffer.toString();
		boolean sourceMatch = this.source.startsWith(buffered, this.counter)
				&& this.counter + buffered.length() == currentPosition;
		while (!sourceMatch && this.currentRegion < this.regions.size()) {
			IRegion region = this.regions.get(this.currentRegion);
			if (currentPosition < region.getOffset())
				break;
			int regionEnd = region.getOffset() + region.getLength();
			if (this.counter >= regionEnd) {
				this.currentRegion++;
				continue;
			}
			if (this.currentRegion == this.regions.size() - 1
					|| this.regions.get(this.currentRegion + 1).getOffset() > currentPosition) {
				this.edits.add(getReplaceEdit(this.counter, currentPosition, buffered, region));
				break;
			}

			// this edit will span more than one region, split it
			IRegion nextRegion = this.regions.get(this.currentRegion + 1);
			int bestSplit = 0;
			int bestSplitScore = Integer.MAX_VALUE;
			for (int i = 0; i < buffered.length(); i++) {
				ReplaceEdit edit1 = getReplaceEdit(this.counter, regionEnd, buffered.substring(0, i), region);
				ReplaceEdit edit2 = getReplaceEdit(regionEnd, currentPosition, buffered.substring(i), nextRegion);
				int score = edit1.getLength() + edit1.getText().length() + edit2.getLength() + edit2.getText().length();
				if (score < bestSplitScore) {
					bestSplit = i;
					bestSplitScore = score;
				}
			}
			this.edits.add(getReplaceEdit(this.counter, regionEnd, buffered.substring(0, bestSplit), region));
			buffered = buffered.substring(bestSplit);
			this.counter = regionEnd;
		}
		this.buffer.setLength(0);
		this.counter = currentPosition;
	}

	private ReplaceEdit getReplaceEdit(int editStart, int editEnd, String text, IRegion region) {
		int regionEnd = region.getOffset() + region.getLength();
		if (editStart < region.getOffset() && regionEnd < editEnd) {
			int breaksInReplacement = this.tm.countLineBreaksBetween(text, 0, text.length());
			int breaksBeforeRegion = this.tm.countLineBreaksBetween(this.source, editStart, region.getOffset());
			int breaksAfterRegion = this.tm.countLineBreaksBetween(this.source, regionEnd, editEnd);
			if (breaksBeforeRegion + breaksAfterRegion > breaksInReplacement) {
				text = ""; //$NON-NLS-1$
				editStart = region.getOffset();
				editEnd = regionEnd;
			}
		}
		if (region.getOffset() > editStart && isOnlyWhitespace(text)) {
			int breaksInReplacement = this.tm.countLineBreaksBetween(text, 0, text.length());
			int breaksOutsideRegion = this.tm.countLineBreaksBetween(this.source, editStart, region.getOffset());
			int breaksToPreserve = breaksInReplacement - breaksOutsideRegion;
			text = adaptReplaceText(text, breaksToPreserve, false, region.getOffset() - 1);
			editStart = region.getOffset();
		}
		if (regionEnd < editEnd && isOnlyWhitespace(text)) {
			int breaksInReplacement = this.tm.countLineBreaksBetween(text, 0, text.length());
			int breaksOutsideRegion = this.tm.countLineBreaksBetween(this.source, regionEnd, editEnd);
			int breaksToPreserve = breaksInReplacement - breaksOutsideRegion;
			text = adaptReplaceText(text, breaksToPreserve, true, regionEnd);
			editEnd = regionEnd;
		}
		return new ReplaceEdit(editStart, editEnd - editStart, text);
	}

	private boolean isOnlyWhitespace(String text) {
		for (int i = 0; i < text.length(); i++)
			if (!ScannerHelper.isWhitespace(text.charAt(i)))
				return false;
		return true;
	}

	private String adaptReplaceText(String text, int breaksToPreserve, boolean isRegionEnd, int regionEdge) {
		int i = isRegionEnd ? 0 : text.length() - 1;
		int direction = isRegionEnd ? 1 : -1;
		int preservedBreaks = 0;
		for (; i >= 0 && i < text.length(); i += direction) {
			assert ScannerHelper.isWhitespace(text.charAt(i));
			char c1 = text.charAt(i);
			if (c1 == '\r' || c1 == '\n') {
				if (preservedBreaks >= breaksToPreserve)
					break;
				preservedBreaks++;
				int i2 = i + direction;
				if (i2 >= 0 && i2 < text.length()) {
					char c2 = text.charAt(i2);
					if ((c2 == '\r' || c2 == '\n') && c2 != c1)
						i = i2;
				}
			}
		}
		text = isRegionEnd ? text.substring(0, i) : text.substring(i + 1);

		// cut out text if the source outside region is a matching whitespace
		int textPos = isRegionEnd ? text.length() - 1 : 0;
		int sourcePos = regionEdge;
		theLoop: while (textPos >= 0 && textPos < text.length() && sourcePos >= 0 && sourcePos < this.source.length()) {
			char c1 = text.charAt(textPos);
			char c2 = this.source.charAt(sourcePos);
			if (c1 == c2 && (c1 == ' ' || c1 == '\t')) {
				textPos -= direction;
				sourcePos += direction;
			} else if (c1 == '\t' && c2 == ' ') {
				for (i = 0; i < this.options.tab_size; i++) {
					sourcePos += direction;
					if (i < this.options.tab_size - 1 && (sourcePos < 0 || sourcePos >= this.source.length()
							|| this.source.charAt(sourcePos) != ' '))
						continue theLoop;
				}
				textPos -= direction;
			} else if (c2 == '\t' && c1 == ' ') {
				for (i = 0; i < this.options.tab_size; i++) {
					textPos -= direction;
					if (i < this.options.tab_size - 1
							&& (textPos < 0 || textPos >= text.length() || text.charAt(textPos) != ' '))
						continue theLoop;
				}
				sourcePos += direction;
			} else {
				break;
			}
		}
		if (isRegionEnd) {
			text = text.substring(0, textPos + 1);
		} else {
			text = text.substring(textPos);
		}

		return text;
	}

	private void handleSingleLineComment(Token lineComment, int index) {
		List<Token> structure = lineComment.getInternalStructure();
		if (structure == null) {
			flushBuffer(lineComment.originalStart);
			this.counter = lineComment.originalEnd + 1;
			return;
		}
		if (structure.get(0).tokenType == TokenNameWHITESPACE) {
			flushBuffer(structure.get(0).originalStart);
		} else {
			flushBuffer(lineComment.originalStart);
		}

		for (int i = 0; i < structure.size(); i++) {
			Token fragment = structure.get(i);

			if (fragment.getLineBreaksBefore() > 0) {
				bufferLineSeparator(fragment, false);
				if (this.parent != null)
					bufferAlign(lineComment, index);
				bufferIndent(fragment, index);
			} else if (fragment.isSpaceBefore() && i > 0) {
				this.buffer.append(' ');
			}

			if (fragment.hasNLSTag()) {
				int tagNumber = this.stringLiteralsInLine.indexOf(fragment.getNLSTag());
				assert tagNumber >= 0;
				this.buffer.append("//$NON-NLS-").append(tagNumber + 1).append("$"); //$NON-NLS-1$ //$NON-NLS-2$
			} else if (fragment.originalStart < this.counter) {
				// Comment line prefix may be a copy of earlier code
				this.buffer.append(this.tm.toString(fragment));
			} else {
				flushBuffer(fragment.originalStart);
				this.counter = fragment.originalEnd + 1;
			}
		}

		if (lineComment.originalEnd > lineComment.originalStart) // otherwise it's a forged comment
			flushBuffer(lineComment.originalEnd + 1);
	}

	private void handleStructuredToken(Token comment, int index) {
		flushBuffer(comment.originalStart);
		if (this.childBuilder == null) {
			this.childBuilder = new TextEditsBuilder(this);
		}
		this.childBuilder.traverseInternalStructure(comment, index);
		this.edits.addAll(this.childBuilder.edits);
		this.childBuilder.edits.clear();
		this.counter = this.childBuilder.sourceLimit;
	}

	private void traverseInternalStructure(Token token, int index) {
		List<Token> structure = token.getInternalStructure();
		this.tm = new TokenManager(structure, this.parent.tm);
		this.counter = token.originalStart;
		this.sourceLimit = token.originalEnd + 1;

		this.parentTokenIndex = index;

		traverse(structure, 0);
	}

	public void processComment(Token commentToken) {
		assert commentToken.isComment();
		if (commentToken.tokenType == TokenNameCOMMENT_LINE) {
			handleSingleLineComment(commentToken, this.tm.indexOf(commentToken));
		} else {
			handleStructuredToken(commentToken, this.tm.indexOf(commentToken));
		}
	}

	public List<TextEdit> getEdits() {
		return this.edits;
	}

	public void setAlignChar(int alignChar) {
		this.alignChar = alignChar;
	}
}