/*
 * 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 org.apache.nifi.attribute.expression.language;

import org.antlr.runtime.tree.Tree;
import org.apache.nifi.attribute.expression.language.compile.ExpressionCompiler;
import org.apache.nifi.attribute.expression.language.evaluation.Evaluator;
import org.apache.nifi.attribute.expression.language.evaluation.QueryResult;
import org.apache.nifi.attribute.expression.language.evaluation.selection.AttributeEvaluator;
import org.apache.nifi.attribute.expression.language.exception.AttributeExpressionLanguageParsingException;
import org.apache.nifi.expression.AttributeExpression.ResultType;
import org.apache.nifi.expression.AttributeValueDecorator;
import org.apache.nifi.parameter.ExpressionLanguageAwareParameterParser;
import org.apache.nifi.parameter.ParameterLookup;
import org.apache.nifi.parameter.ParameterParser;
import org.apache.nifi.parameter.ParameterReference;
import org.apache.nifi.parameter.ParameterToken;
import org.apache.nifi.parameter.ParameterTokenList;
import org.apache.nifi.processor.exception.ProcessException;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * Class used for creating and evaluating NiFi Expression Language. Once a Query
 * has been created, it may be evaluated using the evaluate methods exactly
 * once.
 */
public class Query {

    private final String query;
    private final Tree tree;
    private final Evaluator<?> evaluator;
    private final AtomicBoolean evaluated = new AtomicBoolean(false);

    private Query(final String query, final Tree tree, final Evaluator<?> evaluator) {
        this.query = query;
        this.tree = tree;
        this.evaluator = evaluator;
    }

    public static boolean isValidExpression(final String value) {
        try {
            validateExpression(value, false);
            return true;
        } catch (final AttributeExpressionLanguageParsingException | ProcessException e) {
            return false;
        }
    }

    public static ResultType getResultType(final String value) throws AttributeExpressionLanguageParsingException {
        return Query.compile(value).getResultType();
    }

    public static List<ResultType> extractResultTypes(final String value) throws AttributeExpressionLanguageParsingException {
        final List<ResultType> types = new ArrayList<>();

        for (final Range range : extractExpressionRanges(value)) {
            final String text = value.substring(range.getStart(), range.getEnd() + 1);
            types.add(getResultType(text));
        }

        return types;
    }

    public static List<String> extractExpressions(final String value) throws AttributeExpressionLanguageParsingException {
        final List<String> expressions = new ArrayList<>();

        for (final Range range : extractExpressionRanges(value)) {
            expressions.add(value.substring(range.getStart(), range.getEnd() + 1));
        }

        return expressions;
    }


    public static List<Range> extractExpressionRanges(final String value) throws AttributeExpressionLanguageParsingException {
        return extractExpressionRanges(value, false);
    }

    public static List<Range> extractEscapedRanges(final String value) throws AttributeExpressionLanguageParsingException {
        return extractExpressionRanges(value, true);
    }

    private static List<Range> extractExpressionRanges(final String value, final boolean extractEscapeSequences) throws AttributeExpressionLanguageParsingException {
        final List<Range> ranges = new ArrayList<>();
        char lastChar = 0;
        int embeddedCount = 0;
        int expressionStart = -1;
        int dollarCount = 0;
        int backslashCount = 0;

        charLoop:
            for (int i = 0; i < value.length(); i++) {
                final char c = value.charAt(i);

                if (expressionStart > -1 && (c == '\'' || c == '"') && (lastChar != '\\' || backslashCount % 2 == 0)) {
                    final int endQuoteIndex = findEndQuoteChar(value, i);
                    if (endQuoteIndex < 0) {
                        break charLoop;
                    }

                    i = endQuoteIndex;
                    continue;
                }

                if (c == '{') {
                    final boolean evenDollarCount = dollarCount % 2 == 0;
                    if ((evenDollarCount == extractEscapeSequences) && lastChar == '$') {
                        if (embeddedCount == 0) {
                            expressionStart = i - (extractEscapeSequences ? dollarCount : 1);
                        }
                    }

                    // Keep track of the number of opening curly braces that we are embedded within,
                    // if we are within an Expression. If we are outside of an Expression, we can just ignore
                    // curly braces. This allows us to ignore the first character if the value is something
                    // like: { ${abc} }
                    // However, we will count the curly braces if we have something like: ${ $${abc} }
                    if (expressionStart > -1) {
                        embeddedCount++;
                    }
                } else if (c == '}') {
                    if (embeddedCount <= 0) {
                        continue;
                    }

                    if (--embeddedCount == 0) {
                        if (expressionStart > -1) {
                            // ended expression. Add a new range.
                            final Range range = new Range(expressionStart, i);
                            ranges.add(range);
                        }

                        expressionStart = -1;
                    }
                } else if (c == '$') {
                    dollarCount++;
                } else if (c == '\\') {
                    backslashCount++;
                } else {
                    dollarCount = 0;
                }

                lastChar = c;
            }

        return ranges;
    }

    /**
     * @param value expression to validate
     * @param allowSurroundingCharacters whether to allow surrounding chars
     * @throws AttributeExpressionLanguageParsingException if problems parsing given expression
     */
    public static void validateExpression(final String value, final boolean allowSurroundingCharacters) throws AttributeExpressionLanguageParsingException {
        if (!allowSurroundingCharacters) {
            final List<Range> ranges = extractExpressionRanges(value);
            if (ranges.size() > 1) {
                throw new AttributeExpressionLanguageParsingException("Found multiple Expressions but expected only 1");
            }

            if (ranges.isEmpty()) {
                throw new AttributeExpressionLanguageParsingException("No Expressions found");
            }

            final Range range = ranges.get(0);
            final String expression = value.substring(range.getStart(), range.getEnd() + 1);
            Query.compile(expression);

            if (range.getStart() > 0 || range.getEnd() < value.length() - 1) {
                throw new AttributeExpressionLanguageParsingException("Found characters outside of Expression");
            }
        } else {
            for (final Range range : extractExpressionRanges(value)) {
                final String expression = value.substring(range.getStart(), range.getEnd() + 1);
                Query.compile(expression);
            }
        }
    }

    static int findEndQuoteChar(final String value, final int quoteStart) {
        final char quoteChar = value.charAt(quoteStart);

        int backslashCount = 0;
        char lastChar = 0;
        for (int i = quoteStart + 1; i < value.length(); i++) {
            final char c = value.charAt(i);

            if (c == '\\') {
                backslashCount++;
            } else if (c == quoteChar && (backslashCount % 2 == 0 || lastChar != '\\')) {
                return i;
            }

            lastChar = c;
        }

        return -1;
    }

    static String evaluateExpression(final Tree tree, final Evaluator<?> rootEvaluator, final String queryText, final EvaluationContext evaluationContext, final AttributeValueDecorator decorator)
                throws ProcessException {

        Query query = new Query(queryText, tree, rootEvaluator);
        final Object evaluated = query.evaluate(evaluationContext).getValue();
        if (evaluated == null) {
            return null;
        }

        final String value = evaluated.toString();
        return decorator == null ? value : decorator.decorate(value);
    }

    static String evaluateExpressions(final String rawValue, Map<String, String> expressionMap, final AttributeValueDecorator decorator, final Map<String, String> stateVariables,
                                      final ParameterLookup parameterLookup) throws ProcessException {
        return Query.prepare(rawValue).evaluateExpressions(new StandardEvaluationContext(expressionMap, stateVariables, parameterLookup), decorator);
    }

    static String evaluateExpressions(final String rawValue, final Map<String, String> valueLookup, final ParameterLookup parameterLookup) throws ProcessException {
        return evaluateExpressions(rawValue, valueLookup, null, parameterLookup);
    }

    static String evaluateExpressions(final String rawValue, final Map<String, String> valueLookup, final AttributeValueDecorator decorator, final ParameterLookup parameterLookup)
            throws ProcessException {
        return Query.prepare(rawValue).evaluateExpressions(new StandardEvaluationContext(valueLookup, Collections.emptyMap(), parameterLookup), decorator);
    }


    /**
     * Un-escapes ${...} patterns that were escaped
     *
     * @param value to un-escape
     * @return un-escaped value
     */
    public static String unescape(final String value) {
        return value.replaceAll("\\$\\$(?=\\$*\\{.*?\\})", "\\$");
    }

    public static Query fromTree(final Tree tree, final String text) {
        final ExpressionCompiler compiler = new ExpressionCompiler();
        return new Query(text, tree, compiler.buildEvaluator(tree));
    }

    private static String unescapeLeadingDollarSigns(final String value) {
        final int index = value.indexOf("{");
        if (index < 0) {
            return value.replace("$$", "$");
        } else {
            final String prefix = value.substring(0, index);
            return prefix.replace("$$", "$") + value.substring(index);
        }
    }

    private static String unescapeTrailingDollarSigns(final String value, final boolean escapeIfAllDollars) {
        if (!value.endsWith("$")) {
            return value;
        }

        // count number of $$ at end of string
        int dollars = 0;
        for (int i=value.length()-1; i >= 0; i--) {
            final char c = value.charAt(i);
            if (c == '$') {
                dollars++;
            } else {
                break;
            }
        }

        // If the given argument consists solely of $ characters, then we
        if (dollars == value.length() && !escapeIfAllDollars) {
            return value;
        }

        final int charsToRemove = dollars / 2;
        final int newLength = value.length() - charsToRemove;
        return value.substring(0, newLength);
    }


    public static PreparedQuery prepareWithParametersPreEvaluated(final String query) throws AttributeExpressionLanguageParsingException {
        return prepare(query, true);
    }

    public static PreparedQuery prepare(final String query) throws AttributeExpressionLanguageParsingException {
        return prepare(query, false);
    }

    private static PreparedQuery prepare(final String rawQuery, final boolean escapeParameterReferences) throws AttributeExpressionLanguageParsingException {
        if (rawQuery == null) {
            return new EmptyPreparedQuery(null);
        }

        final ParameterParser parameterParser = new ExpressionLanguageAwareParameterParser();

        final String query;
        if (escapeParameterReferences) {
            query = parameterParser.parseTokens(rawQuery).escape();
        } else {
            query = rawQuery;
        }

        final List<Range> ranges = extractExpressionRanges(query);

        if (ranges.isEmpty()) {
            final List<Expression> expressions = new ArrayList<>();

            final List<Range> escapedRanges = extractEscapedRanges(query);
            int lastIndex = 0;
            for (final Range range : escapedRanges) {
                final String treeText = unescapeLeadingDollarSigns(query.substring(range.getStart(), range.getEnd() + 1));

                if (range.getStart() > lastIndex) {
                    String substring = unescapeLeadingDollarSigns(query.substring(lastIndex, range.getStart()));
                    addLiteralsAndParameters(parameterParser, substring, expressions, true);
                }

                addLiteralsAndParameters(parameterParser, treeText, expressions, true);
                lastIndex = range.getEnd() + 1;
            }

            if (escapedRanges.isEmpty()) {
                addLiteralsAndParameters(parameterParser, query, expressions, true);
            } else {
                final Range lastRange = escapedRanges.get(escapedRanges.size() - 1);
                if (lastRange.getEnd() + 1 < query.length()) {
                    final String treeText = unescapeLeadingDollarSigns(query.substring(lastRange.getEnd() + 1));
                    addLiteralsAndParameters(parameterParser, treeText, expressions, true);
                }
            }

            if (expressions.isEmpty()) {
                return new EmptyPreparedQuery(query);
            }

            return new StandardPreparedQuery(expressions);
        }

        final ExpressionCompiler compiler = new ExpressionCompiler();

        try {
            final List<Expression> expressions = new ArrayList<>();

            int lastIndex = 0;
            for (final Range range : ranges) {
                final String treeText = unescapeLeadingDollarSigns(query.substring(range.getStart(), range.getEnd() + 1));
                final CompiledExpression compiledExpression = compiler.compile(treeText);

                if (range.getStart() > lastIndex) {
                    String substring = unescapeLeadingDollarSigns(query.substring(lastIndex, range.getStart()));

                    // If this string literal evaluator immediately precedes an Attribute Reference, then we need to consider the String Literal to be
                    // Escaping if it ends with $$'s, otherwise not. We also want to avoid un-escaping if the expression consists solely of $$, because
                    // those would have been addressed by the previous #unescapeLeadingDollarSigns() call already.
                    if (compiledExpression.getRootEvaluator() instanceof AttributeEvaluator) {
                        substring = unescapeTrailingDollarSigns(substring, false);
                    }

                    // Do not allow sensitive parameters to be referenced because this is within an actual Expression.
                    // For example, ${#{sensitiveParam}} is not allowed. However, we do support referencing sensitive parameters
                    // for the use case of simply #{sensitiveParam} outside of an Expression. In such a case, the PreparedQuery will
                    // still be used to evaluate this, since all Property Values are evaluated through PreparedQueries.
                    addLiteralsAndParameters(parameterParser, substring, expressions, false);
                }

                expressions.add(compiledExpression);

                lastIndex = range.getEnd() + 1;
            }

            final Range lastRange = ranges.get(ranges.size() - 1);
            if (lastRange.getEnd() + 1 < query.length()) {
                final String treeText = unescapeLeadingDollarSigns(query.substring(lastRange.getEnd() + 1));
                addLiteralsAndParameters(parameterParser, treeText, expressions, false);
            }

            return new StandardPreparedQuery(expressions);
        } catch (final AttributeExpressionLanguageParsingException e) {
            return new InvalidPreparedQuery(query, e.getMessage());
        }
    }

    private static void addLiteralsAndParameters(final ParameterParser parser, final String input, final List<Expression> expressions, final boolean allowSensitiveParameterReference) {
        final ParameterTokenList references = parser.parseTokens(input);
        int index = 0;

        ParameterToken lastReference = null;
        for (final ParameterToken token : references) {
            if (token.isEscapeSequence()) {
                expressions.add(new StringLiteralExpression(token.getValue(ParameterLookup.EMPTY)));
                index = token.getEndOffset() + 1;
                lastReference = token;
                continue;
            }

            final int start = token.getStartOffset();

            if (start > index) {
                expressions.add(new StringLiteralExpression(input.substring(index, start)));
            }

            if (token.isParameterReference()) {
                final ParameterReference parameterReference = (ParameterReference) token;
                expressions.add(new ParameterExpression(parameterReference.getParameterName(), allowSensitiveParameterReference));
            } else {
                expressions.add(new StringLiteralExpression(token.getValue(ParameterLookup.EMPTY)));
            }

            index = token.getEndOffset() + 1;
            lastReference = token;
        }

        if (lastReference == null) {
            expressions.add(new StringLiteralExpression(input));
        } else if (input.length() > lastReference.getEndOffset() + 1) {
            expressions.add(new StringLiteralExpression(input.substring(lastReference.getEndOffset() + 1)));
        }
    }

    public static Query compile(final String query) throws AttributeExpressionLanguageParsingException {
        try {
            final ExpressionCompiler compiler = new ExpressionCompiler();
            final CompiledExpression compiledExpression = compiler.compile(query);

            return new Query(compiledExpression.getExpression(), compiledExpression.getTree(), compiledExpression.getRootEvaluator());
        } catch (final AttributeExpressionLanguageParsingException e) {
            throw e;
        } catch (final Exception e) {
            throw new AttributeExpressionLanguageParsingException(e);
        }
    }

    public ResultType getResultType() {
        return evaluator.getResultType();
    }

    QueryResult<?> evaluate(final EvaluationContext evaluationContext) {
        if (evaluated.getAndSet(true)) {
            throw new IllegalStateException("A Query cannot be evaluated more than once");
        }

        return evaluator.evaluate(evaluationContext);
    }


    Tree getTree() {
        return this.tree;
    }

    @Override
    public String toString() {
        return "Query [" + query + "]";
    }



    public static class Range {

        private final int start;
        private final int end;

        public Range(final int start, final int end) {
            this.start = start;
            this.end = end;
        }

        public int getStart() {
            return start;
        }

        public int getEnd() {
            return end;
        }

        @Override
        public String toString() {
            return start + " - " + end;
        }
    }
}
