/*
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you under the Apache License, Version 2.0 (the
 *  "License"); you may not use this file except in compliance
 *  with the License.  You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing,
 *  software distributed under the License is distributed on an
 *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 *  KIND, either express or implied.  See the License for the
 *  specific language governing permissions and limitations
 *  under the License.
 */
package groovy.console.ui.text;

import groovy.lang.Tuple2;
import org.antlr.v4.runtime.CharStream;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.ConsoleErrorListener;
import org.antlr.v4.runtime.LexerNoViableAltException;
import org.antlr.v4.runtime.Token;
import org.apache.groovy.parser.antlr4.GroovyLangLexer;
import org.apache.groovy.parser.antlr4.GroovySyntaxError;
import org.apache.groovy.parser.antlr4.util.PositionConfigureUtils;
import org.apache.groovy.parser.antlr4.util.StringUtils;
import org.apache.groovy.util.ReversedList;

import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.DefaultStyledDocument;
import javax.swing.text.DocumentFilter;
import javax.swing.text.Style;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyleContext;
import java.awt.Color;
import java.io.IOException;
import java.io.StringReader;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import groovy.console.ui.ThemeManager;

import static org.apache.groovy.parser.antlr4.GroovyLexer.ABSTRACT;
import static org.apache.groovy.parser.antlr4.GroovyLexer.AS;
import static org.apache.groovy.parser.antlr4.GroovyLexer.AT;
import static org.apache.groovy.parser.antlr4.GroovyLexer.CapitalizedIdentifier;
import static org.apache.groovy.parser.antlr4.GroovyLexer.DOT;
import static org.apache.groovy.parser.antlr4.GroovyLexer.Identifier;
import static org.apache.groovy.parser.antlr4.GroovyLexer.ASSERT;
import static org.apache.groovy.parser.antlr4.GroovyLexer.ASYNC;
import static org.apache.groovy.parser.antlr4.GroovyLexer.AWAIT;
import static org.apache.groovy.parser.antlr4.GroovyLexer.BREAK;
import static org.apache.groovy.parser.antlr4.GroovyLexer.BooleanLiteral;
import static org.apache.groovy.parser.antlr4.GroovyLexer.BuiltInPrimitiveType;
import static org.apache.groovy.parser.antlr4.GroovyLexer.CASE;
import static org.apache.groovy.parser.antlr4.GroovyLexer.CATCH;
import static org.apache.groovy.parser.antlr4.GroovyLexer.CLASS;
import static org.apache.groovy.parser.antlr4.GroovyLexer.COMMA;
import static org.apache.groovy.parser.antlr4.GroovyLexer.CONST;
import static org.apache.groovy.parser.antlr4.GroovyLexer.CONTINUE;
import static org.apache.groovy.parser.antlr4.GroovyLexer.DEF;
import static org.apache.groovy.parser.antlr4.GroovyLexer.DEFAULT;
import static org.apache.groovy.parser.antlr4.GroovyLexer.DEFER;
import static org.apache.groovy.parser.antlr4.GroovyLexer.DO;
import static org.apache.groovy.parser.antlr4.GroovyLexer.ELSE;
import static org.apache.groovy.parser.antlr4.GroovyLexer.ENUM;
import static org.apache.groovy.parser.antlr4.GroovyLexer.EOF;
import static org.apache.groovy.parser.antlr4.GroovyLexer.EXTENDS;
import static org.apache.groovy.parser.antlr4.GroovyLexer.FINAL;
import static org.apache.groovy.parser.antlr4.GroovyLexer.FINALLY;
import static org.apache.groovy.parser.antlr4.GroovyLexer.FOR;
import static org.apache.groovy.parser.antlr4.GroovyLexer.FloatingPointLiteral;
import static org.apache.groovy.parser.antlr4.GroovyLexer.GOTO;
import static org.apache.groovy.parser.antlr4.GroovyLexer.GStringBegin;
import static org.apache.groovy.parser.antlr4.GroovyLexer.GStringEnd;
import static org.apache.groovy.parser.antlr4.GroovyLexer.GStringPart;
import static org.apache.groovy.parser.antlr4.GroovyLexer.IF;
import static org.apache.groovy.parser.antlr4.GroovyLexer.IMPLEMENTS;
import static org.apache.groovy.parser.antlr4.GroovyLexer.IMPORT;
import static org.apache.groovy.parser.antlr4.GroovyLexer.IN;
import static org.apache.groovy.parser.antlr4.GroovyLexer.INSTANCEOF;
import static org.apache.groovy.parser.antlr4.GroovyLexer.INTERFACE;
import static org.apache.groovy.parser.antlr4.GroovyLexer.IntegerLiteral;
import static org.apache.groovy.parser.antlr4.GroovyLexer.NATIVE;
import static org.apache.groovy.parser.antlr4.GroovyLexer.NEW;
import static org.apache.groovy.parser.antlr4.GroovyLexer.NL;
import static org.apache.groovy.parser.antlr4.GroovyLexer.NON_SEALED;
import static org.apache.groovy.parser.antlr4.GroovyLexer.NOT_IN;
import static org.apache.groovy.parser.antlr4.GroovyLexer.NOT_INSTANCEOF;
import static org.apache.groovy.parser.antlr4.GroovyLexer.NullLiteral;
import static org.apache.groovy.parser.antlr4.GroovyLexer.PACKAGE;
import static org.apache.groovy.parser.antlr4.GroovyLexer.PERMITS;
import static org.apache.groovy.parser.antlr4.GroovyLexer.PRIVATE;
import static org.apache.groovy.parser.antlr4.GroovyLexer.PROTECTED;
import static org.apache.groovy.parser.antlr4.GroovyLexer.PUBLIC;
import static org.apache.groovy.parser.antlr4.GroovyLexer.RECORD;
import static org.apache.groovy.parser.antlr4.GroovyLexer.RETURN;
import static org.apache.groovy.parser.antlr4.GroovyLexer.SEALED;
import static org.apache.groovy.parser.antlr4.GroovyLexer.SEMI;
import static org.apache.groovy.parser.antlr4.GroovyLexer.STATIC;
import static org.apache.groovy.parser.antlr4.GroovyLexer.STRICTFP;
import static org.apache.groovy.parser.antlr4.GroovyLexer.SUPER;
import static org.apache.groovy.parser.antlr4.GroovyLexer.SWITCH;
import static org.apache.groovy.parser.antlr4.GroovyLexer.SYNCHRONIZED;
import static org.apache.groovy.parser.antlr4.GroovyLexer.StringLiteral;
import static org.apache.groovy.parser.antlr4.GroovyLexer.THIS;
import static org.apache.groovy.parser.antlr4.GroovyLexer.THREADSAFE;
import static org.apache.groovy.parser.antlr4.GroovyLexer.THROW;
import static org.apache.groovy.parser.antlr4.GroovyLexer.THROWS;
import static org.apache.groovy.parser.antlr4.GroovyLexer.TRAIT;
import static org.apache.groovy.parser.antlr4.GroovyLexer.TRANSIENT;
import static org.apache.groovy.parser.antlr4.GroovyLexer.TRY;
import static org.apache.groovy.parser.antlr4.GroovyLexer.UNEXPECTED_CHAR;
import static org.apache.groovy.parser.antlr4.GroovyLexer.VAL;
import static org.apache.groovy.parser.antlr4.GroovyLexer.VAR;
import static org.apache.groovy.parser.antlr4.GroovyLexer.VOID;
import static org.apache.groovy.parser.antlr4.GroovyLexer.VOLATILE;
import static org.apache.groovy.parser.antlr4.GroovyLexer.WHILE;
import static org.apache.groovy.parser.antlr4.GroovyLexer.YIELD;


/**
 * The document filter based on Parrot's lexer is for highlighting the content of text editor
 *
 * @since 3.0.0
 */
public class SmartDocumentFilter extends DocumentFilter {
    /**
     * Token types rendered with the reserved-word style.
     */
    public static final List<Integer> HIGHLIGHTED_TOKEN_TYPE_LIST =
        Arrays.asList(AS, ASYNC, AWAIT, DEF, DEFER, IN, TRAIT, THREADSAFE,
            VAL, VAR, BuiltInPrimitiveType, ABSTRACT, ASSERT, BREAK, CASE, CATCH, CLASS, CONST, CONTINUE, DEFAULT, DO,
            ELSE, ENUM, EXTENDS, FINAL, FINALLY, FOR, IF, GOTO, IMPLEMENTS, IMPORT, INSTANCEOF, INTERFACE,
            NATIVE, NEW, NON_SEALED, NOT_IN, NOT_INSTANCEOF, PACKAGE, PERMITS, PRIVATE, PROTECTED, PUBLIC,
            RECORD, RETURN, SEALED, STATIC, STRICTFP, SUPER, SWITCH, SYNCHRONIZED,
            THIS, THROW, THROWS, TRANSIENT, TRY, VOID, VOLATILE, WHILE, YIELD, NullLiteral, BooleanLiteral);
    private static final String MONOSPACED = "Monospaced";
    private final DefaultStyledDocument styledDocument;
    private final StyleContext styleContext;
    private final Style defaultStyle;

    /**
     * Creates a filter that styles the supplied document.
     *
     * @param styledDocument the document to tokenize and highlight
     */
    public SmartDocumentFilter(DefaultStyledDocument styledDocument) {
        this.styledDocument = styledDocument;

        this.styleContext = StyleContext.getDefaultStyleContext();
        this.defaultStyle = this.styleContext.getStyle(StyleContext.DEFAULT_STYLE);
        StyleConstants.setFontFamily(this.defaultStyle, MONOSPACED);

        initStyles();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void insertString(DocumentFilter.FilterBypass fb, int offset,
                             String text, AttributeSet attrs) throws BadLocationException {
        // remove problem meta characters returns
        text = replaceMetaCharacters(text);

        fb.insertString(offset, text, attrs);
        parseDocument();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void remove(DocumentFilter.FilterBypass fb, int offset, int length)
            throws BadLocationException {

        fb.remove(offset, length);
        parseDocument();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void replace(DocumentFilter.FilterBypass fb, int offset,
                        int length, String text, AttributeSet attrs)
            throws BadLocationException {

        // text might be null and indicates no replacement text
        if (text == null) text = "";

        // remove problem meta characters returns
        text = replaceMetaCharacters(text);

        fb.replace(offset, length, text, attrs);

        parseDocument();
    }

    private String replaceMetaCharacters(String string) {
        // just in case remove carriage returns
        string = string.replace("\r\n", "\n");
        return string;
    }

    private void parseDocument() throws BadLocationException {
        GroovyLangLexer lexer;
        try {
            lexer = createLexer(styledDocument.getText(0, styledDocument.getLength()));
        } catch (IOException e) {
            e.printStackTrace();
            this.latest = false;
            return;
        }

        CommonTokenStream tokenStream = new CommonTokenStream(lexer);

        try {
            tokenStream.fill();
        } catch (LexerNoViableAltException | GroovySyntaxError e) {
            // ignore
            this.latest = false;
            return;
        } catch (Exception e) {
            e.printStackTrace();
            this.latest = false;
            return;
        }

        List<Token> tokenList = tokenStream.getTokens();
        List<Token> tokenListToRender;
        try {
            tokenListToRender = findTokensToRender(tokenList);
        } finally {
            this.setRenderRange(null);
        }

        boolean inAnnotation = false;
        for (Token token : tokenListToRender) {
            int tokenType = token.getType();

            if (EOF == tokenType) {
                continue;
            }

            int tokenStartIndex = token.getStartIndex();
            int tokenStopIndex = token.getStopIndex();
            int tokenLength = tokenStopIndex - tokenStartIndex + 1;

            // color annotation names (identifier/dot tokens after @) with annotation style
            // handles @ToString, @groovy.transform.CompileStatic, etc.
            boolean isAnnotationPart = inAnnotation
                    && (tokenType == Identifier || tokenType == CapitalizedIdentifier || tokenType == DOT);
            if (tokenType == AT) {
                inAnnotation = true;
            } else if (isAnnotationPart) {
                // stay in annotation for dotted names
                inAnnotation = (tokenType == DOT);
            } else {
                inAnnotation = false;
            }

            styledDocument.setCharacterAttributes(tokenStartIndex,
                    tokenLength,
                    isAnnotationPart ? findStyleByTokenType(AT) : findStyleByTokenType(tokenType),
                    true);

            if (GStringBegin == tokenType || GStringPart == tokenType) {
                styledDocument.setCharacterAttributes(
                        tokenStartIndex + tokenLength - 1,
                        1,
                        defaultStyle,
                        true);
            }
        }

        this.latestTokenList = tokenList;
        this.latest = true;
    }

    private List<Token> findTokensToRender(List<Token> tokenList) {
        final Tuple2<Integer, Integer> renderRange = getRenderRange();
        if (null != renderRange) {
            long startLine = lineNumber(renderRange.getV1());
            long stopLine = lineNumber(renderRange.getV2());

            if (startLine < 0 || stopLine < 0) return tokenList; // should never happen

            return tokenList.stream()
                    .filter(e -> e.getLine() >= startLine && PositionConfigureUtils.endPosition(e).getV1() <= stopLine)
                    .collect(Collectors.toList());
        }

        List<Token> tmpLatestTokenList = filterNewlines(this.latestTokenList);
        int latestTokenListSize = tmpLatestTokenList.size();
        if (0 == latestTokenListSize) {
            return tokenList;
        }

        List<Token> tmpTokenList = filterNewlines(tokenList);
        int tmpTokenListSize = tmpTokenList.size();
        if (0 == tmpTokenListSize) {
            return tokenList;
        }

        int startTokenIndex = 0;
        int minSize = Math.min(tmpTokenListSize, latestTokenListSize);
        for (int i = 0; i < minSize; i++) {
            Token token = tmpTokenList.get(i);
            Token latestToken = tmpLatestTokenList.get(i);

            if (token.getType() == latestToken.getType()
                    && token.getStartIndex() == latestToken.getStartIndex()
                    && token.getStopIndex() == latestToken.getStopIndex()) {
                continue;
            }

            startTokenIndex = i;
            break;
        }

        List<Token> newTokenList = new ReversedList<>(tmpTokenList);
        List<Token> newLatestTokenList = new ReversedList<>(tmpLatestTokenList);

        int stopTokenIndex = tmpTokenListSize;

        Token lastToken = newTokenList.get(0);
        Token lastLatestToken = newLatestTokenList.get(0);

        for (int i = 0; i < minSize; i++) {
            Token token = newTokenList.get(i);
            Token latestToken = newLatestTokenList.get(i);

            if ((token.getType() == latestToken.getType())
                    && (token.getStartIndex() - lastToken.getStartIndex()) == (latestToken.getStartIndex() - lastLatestToken.getStartIndex())
                    && ((token.getStopIndex() - lastToken.getStopIndex()) == (latestToken.getStopIndex() - lastLatestToken.getStopIndex()))) {
                continue;
            }

            stopTokenIndex = tmpTokenListSize - i;
            break;
        }

        if (startTokenIndex == stopTokenIndex) {
            return tmpTokenListSize != latestTokenListSize ? tokenList : Collections.emptyList();
        } else if (startTokenIndex < stopTokenIndex) {
            return tmpTokenList.subList(startTokenIndex, stopTokenIndex);
        }

        return tokenList; // should never reach here. If unexpected error occurred, it's better to render all tokens
    }

    /**
     * Returns line number starting with 1
     */
    private long lineNumber(int position) {
        long result = -1;

        if (position < 0) return result;

        try {
            String text = this.styledDocument.getText(0, position + 1);
            result = StringUtils.countChar(text, '\n') + 1;
        } catch (BadLocationException e) {
            e.printStackTrace(System.err);
        }

        return result;
    }

    private List<Token> filterNewlines(List<Token> tokenList) {
        return tokenList.stream()
                .filter(e -> !(NL == e.getType() && e.getText().trim().isEmpty()))
                .collect(Collectors.toList());
    }

    private Style findStyleByTokenType(int tokenType) {
        Style style = styleContext.getStyle(String.valueOf(tokenType));

        return null == style ? defaultStyle : style;
    }

    private Style createDefaultStyleByTokenType(int tokenType) {
        return styleContext.addStyle(String.valueOf(tokenType), defaultStyle);
    }

    private GroovyLangLexer createLexer(String text) throws IOException {
        CharStream charStream = CharStreams.fromReader(new StringReader(text));
        GroovyLangLexer lexer = new GroovyLangLexer(charStream);

        lexer.setErrorIgnored(true);
        lexer.removeErrorListener(ConsoleErrorListener.INSTANCE);

        return lexer;
    }

    private void initStyles() {
        // create styles (ensures they exist in the StyleContext)
        createDefaultStyleByTokenType(NL);
        for (int t : Arrays.asList(GStringBegin, GStringPart, GStringEnd)) {
            createDefaultStyleByTokenType(t);
        }
        createDefaultStyleByTokenType(StringLiteral);
        for (int t : Arrays.asList(IntegerLiteral, FloatingPointLiteral)) {
            createDefaultStyleByTokenType(t);
        }
        for (int t : HIGHLIGHTED_TOKEN_TYPE_LIST) {
            createDefaultStyleByTokenType(t);
        }
        for (int t : Arrays.asList(COMMA, SEMI)) {
            createDefaultStyleByTokenType(t);
        }
        createDefaultStyleByTokenType(AT);
        createDefaultStyleByTokenType(UNEXPECTED_CHAR);

        // apply theme colors
        updateStyles();
    }

    /**
     * Forces a full re-parse and re-render of the document with current styles.
     * Clears the incremental-render cache first so every token is restyled,
     * not just the ones that differ from the last parse.
     *
     * @since 6.0.0
     */
    public void reparseDocument() {
        this.latestTokenList = Collections.emptyList();
        try {
            parseDocument();
        } catch (BadLocationException e) {
            // ignore
        }
    }

    /**
     * Updates all SmartDocumentFilter styles with current theme colors.
     * Attribute values are sourced from ThemeManager so custom .theme files
     * feed both this (ANTLR-token-driven) path and the regex-driven GroovyFilter
     * path from one place. Called at init time and on theme switch.
     *
     * @since 6.0.0
     */
    public static void updateStyles() {
        StyleContext sc = StyleContext.getDefaultStyleContext();

        applyThemeStyle(sc.getStyle(String.valueOf(NL)), "comment");

        for (int t : Arrays.asList(GStringBegin, GStringPart, GStringEnd)) {
            applyThemeStyle(sc.getStyle(String.valueOf(t)), "quotes");
        }
        applyThemeStyle(sc.getStyle(String.valueOf(StringLiteral)), "single_quotes");

        for (int t : Arrays.asList(IntegerLiteral, FloatingPointLiteral)) {
            applyThemeStyle(sc.getStyle(String.valueOf(t)), "digit");
        }

        for (int t : HIGHLIGHTED_TOKEN_TYPE_LIST) {
            applyThemeStyle(sc.getStyle(String.valueOf(t)), "reserved_word");
        }

        // commas and semicolons reuse the "operation" theme key — closest semantic match
        for (int t : Arrays.asList(COMMA, SEMI)) {
            applyThemeStyle(sc.getStyle(String.valueOf(t)), "operation");
        }

        applyThemeStyle(sc.getStyle(String.valueOf(AT)), "annotation");

        // unexpected-char is a lexer-error indicator, not semantic syntax — stay hardcoded
        Style unexpectedChar = sc.getStyle(String.valueOf(UNEXPECTED_CHAR));
        if (unexpectedChar != null) {
            StyleConstants.setForeground(unexpectedChar,
                    ThemeManager.isDark() ? Color.CYAN : Color.CYAN.darker());
        }
    }

    @SuppressWarnings("rawtypes")
    private static void applyThemeStyle(Style style, String themeKey) {
        if (style == null) return;
        // clear managed attributes so the previous theme's values don't linger
        style.removeAttribute(StyleConstants.Foreground);
        style.removeAttribute(StyleConstants.Background);
        style.removeAttribute(StyleConstants.Bold);
        style.removeAttribute(StyleConstants.Italic);
        style.removeAttribute(StyleConstants.Underline);
        Map attrs = ThemeManager.getStyleAttrs(themeKey);
        Object fg = attrs.get("foreground");
        if (fg instanceof Color)  StyleConstants.setForeground(style, (Color) fg);
        Object bg = attrs.get("background");
        if (bg instanceof Color)  StyleConstants.setBackground(style, (Color) bg);
        if (Boolean.TRUE.equals(attrs.get("bold")))      StyleConstants.setBold(style, true);
        if (Boolean.TRUE.equals(attrs.get("italic")))    StyleConstants.setItalic(style, true);
        if (Boolean.TRUE.equals(attrs.get("underline"))) StyleConstants.setUnderline(style, true);
    }

    private volatile boolean latest = false;
    private volatile List<Token> latestTokenList = Collections.emptyList();
    private volatile Tuple2<Integer, Integer> renderRange;

    /**
     * Indicates whether the cached token list reflects the current document text.
     *
     * @return {@code true} if the latest parse completed successfully
     */
    public boolean isLatest() {
        return latest;
    }

    /**
     * Returns the tokens produced by the most recent successful parse.
     *
     * @return the latest token list
     */
    public List<Token> getLatestTokenList() {
        return latestTokenList;
    }

    /**
     * Limits rendering to the supplied character range for the next parse.
     *
     * @param renderRange the inclusive start and stop offsets to re-render, or {@code null} for all text
     */
    public void setRenderRange(Tuple2<Integer, Integer> renderRange) {
        this.renderRange = renderRange;
    }

    /**
     * Returns the character range scheduled for selective re-rendering.
     *
     * @return the current render range, or {@code null} if the whole document will be rendered
     */
    public Tuple2<Integer, Integer> getRenderRange() {
        return renderRange;
    }
}
