/*
 * 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
 *
 *      https://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.commons.jexl3.internal;

import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
import java.util.function.Consumer;

import org.apache.commons.jexl3.JexlArithmetic;
import org.apache.commons.jexl3.JexlCache;
import org.apache.commons.jexl3.JexlEngine;
import org.apache.commons.jexl3.JexlException;
import org.apache.commons.jexl3.JexlOperator;
import org.apache.commons.jexl3.internal.introspection.MethodExecutor;
import org.apache.commons.jexl3.internal.introspection.MethodKey;
import org.apache.commons.jexl3.introspection.JexlMethod;
import org.apache.commons.jexl3.introspection.JexlUberspect;
import org.apache.commons.jexl3.parser.JexlNode;

/**
 * Helper class to deal with operator overloading and specifics.
 *
 * @since 3.0
 */
public final class Operator implements JexlOperator.Uberspect {
    private static final String METHOD_IS_EMPTY = "isEmpty";
    private static final String METHOD_SIZE = "size";
    private static final String METHOD_CONTAINS = "contains";
    private static final String METHOD_STARTS_WITH = "startsWith";
    private static final String METHOD_ENDS_WITH = "endsWith";

    /**
     * The comparison operators.
     * <p>Used to determine if a compare method overload might be used.</p>
     */
    private static final Set<JexlOperator> CMP_OPS =
            EnumSet.of(JexlOperator.GT, JexlOperator.LT, JexlOperator.EQ, JexlOperator.GTE, JexlOperator.LTE);

    /**
     * The postfix operators.
     * <p>Used to determine the returned value in assignment.</p>
     */
    private static final Set<JexlOperator> POSTFIX_OPS =
            EnumSet.of(JexlOperator.GET_AND_INCREMENT, JexlOperator.GET_AND_DECREMENT);

    /** The uberspect. */
    private final JexlUberspect uberspect;

    /** The arithmetic instance being analyzed. */
    private final JexlArithmetic arithmetic;

    /** The set of overloaded operators. */
    private final Set<JexlOperator> overloads;

    /** The delegate if built as a 3.4 legacy. */
    private final JexlArithmetic.Uberspect delegate;

    /** Caching state: -1 unknown, 0 false, 1 true. */
    private volatile int caching = -1;

    /**
     * Creates an instance.
     * <p>Mostly used as a compatibility measure by delegating instead of extending.</p>
     *
     * @param theUberspect  the uberspect instance
     * @param theArithmetic the arithmetic instance used to delegate operator overloads
     */
    public Operator(final JexlUberspect theUberspect, final JexlArithmetic theArithmetic) {
        this.uberspect = theUberspect;
        this.arithmetic = theArithmetic;
        this.overloads = Collections.emptySet();
        this.delegate = theUberspect.getArithmetic(theArithmetic);
    }

    /**
     * Creates an instance.
     *
     * @param theUberspect the uberspect instance
     * @param theArithmetic the arithmetic instance
     * @param theOverloads  the overloaded operators
     */
    public Operator(final JexlUberspect theUberspect,
                    final JexlArithmetic theArithmetic,
                    final Set<JexlOperator> theOverloads) {
        this(theUberspect, theArithmetic, theOverloads, -1);
    }

    /**
     * Creates an instance.
     *
     * @param theUberspect the uberspect instance
     * @param theArithmetic the arithmetic instance
     * @param theOverloads  the overloaded operators
     * @param theCache the caching state
     */
    public Operator(final JexlUberspect theUberspect,
                    final JexlArithmetic theArithmetic,
                    final Set<JexlOperator> theOverloads,
                    final int theCache) {
        this.uberspect = theUberspect;
        this.arithmetic = theArithmetic;
        this.overloads = theOverloads;
        this.delegate = null;
        this.caching = theCache;
    }

    @Override
    public JexlMethod getOperator(final JexlOperator operator, final Object... args) {
        if (delegate != null) {
            return delegate.getOperator(operator, args);
        }
        if (overloads.contains(operator) && args != null && args.length == operator.getArity()) {
            return uberspectOperator(arithmetic, operator, args);
        }
        return null;
    }

    @Override
    public boolean overloads(final JexlOperator operator) {
        return delegate != null
            ? delegate.overloads(operator)
            : overloads.contains(operator);
    }

    /**
     * @return whether caching is enabled in the engine
     */
    private boolean isCaching() {
        int c = caching;
        if (c < 0) {
            synchronized(this) {
                c = caching;
                if (c < 0) {
                    final JexlEngine jexl = JexlEngine.getThreadEngine();
                    caching = c = jexl instanceof Engine && ((Engine) jexl).cache != null ? 1 : 0;
                }
            }
        }
        return c > 0;
    }

    /**
     * Tidy arguments based on operator arity.
     * <p>The interpreter may add a null to the arguments of operator expecting only one parameter.</p>
     *
     * @param operator the operator
     * @param args the arguments (as seen by the interpreter)
     * @return the tidied arguments
     */
    private Object[] arguments(final JexlOperator operator, final Object...args) {
        return operator.getArity() == 1 && args.length > 1 ? new Object[]{args[0]} : args;
    }

    /**
     * Attempts finding a method in left and eventually narrowing right.
     *
     * @param methodName the method name
     * @param right the left argument in the operator
     * @param left the right argument in the operator
     * @return a boolean is call was possible, null otherwise
     * @throws Exception if invocation fails
     */
    private Boolean booleanDuckCall(final String methodName, final Object left, final Object right) throws Exception {
        JexlMethod vm = uberspect.getMethod(left, methodName, right);
        if (returnsBoolean(vm)) {
            return (Boolean) vm.invoke(left, right);
        }
        final Object[] argv = { right };
        if (arithmetic.narrowArguments(argv)) {
            vm = uberspect.getMethod(left, methodName, argv);
            if (returnsBoolean(vm)) {
                return (Boolean) vm.invoke(left, argv);
            }
        }
        return null;
    }

    /**
     * Throw a NPE if operator is strict and one of the arguments is null.
     *
     * @param arithmetic the JEXL arithmetic instance
     * @param operator the operator to check
     * @param args the operands
     * @throws JexlArithmetic.NullOperand if operator is strict and an operand is null
     */
     private void controlNullOperands(final JexlArithmetic arithmetic, final JexlOperator operator, final Object...args) {
        for (final Object arg : args) {
            // only check operator if necessary
            if (arg == null) {
                // check operator only once if it is not strict
                if (arithmetic.isStrict(operator)) {
                    throw new JexlArithmetic.NullOperand();
                }
                break;
            }
        }
    }

    /**
     * Triggered when an operator fails.
     *
     * @param ref     the node where the error originated from
     * @param operator the operator symbol
     * @param cause    the cause of error (if any)
     * @param alt      what to return if not strict
     * @param <T>      the return type
     * @return throws JexlException if strict and not silent, null otherwise
     */
    private <T> T operatorError(final JexlCache.Reference ref, final JexlOperator operator, final Throwable cause, final T alt) {
        final JexlNode node = ref instanceof JexlNode ? (JexlNode) ref : null;
        final Engine engine = (Engine) JexlEngine.getThreadEngine();
        if (engine == null || engine.isStrict()) {
            throw new JexlException.Operator(node, operator.getOperatorSymbol(), cause);
        }
        if (engine.logger.isDebugEnabled()) {
            engine.logger.debug(JexlException.operatorError(node, operator.getOperatorSymbol()), cause);
        }
        return alt;
    }

    /**
     * Seeks an implementation of an operator method in an arithmetic instance.
     * <p>Method must <em><>not/em belong to JexlArithmetic</p>
     *
     * @param arithmetic the arithmetic instance
     * @param operator the operator
     * @param args the arguments
     * @return a JexlMethod instance or null
     */
    private JexlMethod uberspectOperator(final JexlArithmetic arithmetic,
                                       final JexlOperator operator,
                                       final Object... args) {
        final JexlMethod me = uberspect.getMethod(arithmetic, operator.getMethodName(), args);
        if (!(me instanceof MethodExecutor) ||
            !JexlArithmetic.class.equals(((MethodExecutor) me).getMethod().getDeclaringClass())) {
            return me;
        }
        return null;
    }

    /**
     * Checks whether a method returns a boolean or a Boolean.
     *
     * @param vm the JexlMethod (can be null)
     * @return true of false
     */
    private boolean returnsBoolean(final JexlMethod vm) {
        if (vm != null) {
            final Class<?> rc = vm.getReturnType();
            return Boolean.TYPE.equals(rc) || Boolean.class.equals(rc);
        }
        return false;
    }

    /**
     * Checks whether a method returns an int or an Integer.
     *
     * @param vm the JexlMethod (can be null)
     * @return true of false
     */
    private boolean returnsInteger(final JexlMethod vm) {
        if (vm != null) {
            final Class<?> rc = vm.getReturnType();
            return Integer.TYPE.equals(rc) || Integer.class.equals(rc);
        }
        return false;
    }

    @Override
    public Object empty(final JexlCache.Reference node, final Object object) {
        if (object == null) {
            return true;
        }
        Object result = overloads(JexlOperator.EMPTY)
                ? tryOverload(node, JexlOperator.EMPTY, object)
                : JexlEngine.TRY_FAILED;
        if (result == JexlEngine.TRY_FAILED) {
            result = arithmetic.isEmpty(object, null);
            if (result == null) {
                result = false;
                // check if there is an isEmpty method on the object that returns a
                // boolean and if so, just use it
                final JexlMethod vm = uberspect.getMethod(object, METHOD_IS_EMPTY, InterpreterBase.EMPTY_PARAMS);
                if (returnsBoolean(vm)) {
                    try {
                        result = vm.invoke(object, InterpreterBase.EMPTY_PARAMS);
                    } catch (final Exception any) {
                        return operatorError(node, JexlOperator.EMPTY, any, false);
                    }
                }
            }
        }
        return result;
    }

    @Override
    public Object size(final JexlCache.Reference node, final Object object) {
        if (object == null) {
            return 0;
        }
        Object result = overloads(JexlOperator.SIZE)
                ? tryOverload(node, JexlOperator.SIZE, object)
                : JexlEngine.TRY_FAILED;
        if (result == JexlEngine.TRY_FAILED) {
            result = arithmetic.size(object, null);
            if (result == null) {
                // check if there is a size method on the object that returns an
                // integer and if so, just use it
                final JexlMethod vm = uberspect.getMethod(object, METHOD_SIZE, InterpreterBase.EMPTY_PARAMS);
                if (returnsInteger(vm)) {
                    try {
                        result = vm.invoke(object, InterpreterBase.EMPTY_PARAMS);
                    } catch (final Exception any) {
                        return operatorError(node, JexlOperator.SIZE, any, 0);
                    }
                }
            }
        }
        return result instanceof Number ? ((Number) result).intValue() : 0;
    }

    @Override
    public boolean contains(final JexlCache.Reference node, final JexlOperator operator, final Object left, final Object right) {
        final boolean contained;
        try {
            // try operator overload
            final Object result = overloads(JexlOperator.CONTAINS)
                    ? tryOverload(node, JexlOperator.CONTAINS, left, right)
                    : null;
            if (result instanceof Boolean) {
                contained = (Boolean) result;
            } else {
                // use arithmetic / pattern matching ?
                final Boolean matched = arithmetic.contains(left, right);
                if (matched != null) {
                    contained = matched;
                } else {
                    // try a left.contains(right) method
                    final Boolean duck = booleanDuckCall(METHOD_CONTAINS, left, right);
                    if (duck != null) {
                        contained = duck;
                    } else {
                        // defaults to equal
                        contained = arithmetic.equals(left, right);
                    }
                }
            }
            // not-contains is !contains
            return JexlOperator.CONTAINS == operator == contained;
        } catch (final Exception any) {
            return operatorError(node, operator, any, false);
        }
    }

    @Override
    public boolean startsWith(final JexlCache.Reference node, final JexlOperator operator, final Object left, final Object right) {
        final boolean starts;
        try {
            // try operator overload
            final Object result = overloads(JexlOperator.STARTSWITH)
                    ? tryOverload(node, JexlOperator.STARTSWITH, left, right)
                    : null;
            if (result instanceof Boolean) {
                starts = (Boolean) result;
            } else {
                // use arithmetic / pattern matching ?
                final Boolean matched = arithmetic.startsWith(left, right);
                if (matched != null) {
                    starts = matched;
                } else {
                    // try a left.startsWith(right) method
                    final Boolean duck = booleanDuckCall(METHOD_STARTS_WITH, left, right);
                    if (duck != null) {
                        starts = duck;
                    } else {
                        // defaults to equal
                        starts = arithmetic.equals(left, right);
                    }
                }
            }
            // not-startswith is !starts-with
            return JexlOperator.STARTSWITH == operator == starts;
        } catch (final Exception any) {
            return operatorError(node, operator, any, false);
        }
    }

    @Override
    public boolean endsWith(final JexlCache.Reference node, final JexlOperator operator, final Object left, final Object right) {
        try {
            final boolean ends;
            // try operator overload
            final Object result = overloads(JexlOperator.ENDSWITH)
                ? tryOverload(node, JexlOperator.ENDSWITH, left, right)
                : null;
            if (result instanceof Boolean) {
                ends = (Boolean) result;
            } else {
                // use arithmetic / pattern matching ?
                final Boolean matched = arithmetic.endsWith(left, right);
                if (matched != null) {
                    ends = matched;
                } else {
                    // try a left.endsWith(right) method
                    final Boolean duck = booleanDuckCall(METHOD_ENDS_WITH, left, right);
                    if (duck != null) {
                        ends = duck;
                    } else {
                        // defaults to equal
                        ends = arithmetic.equals(left, right);
                    }
                }
            }
            // not-endswith is !ends-with
            return JexlOperator.ENDSWITH == operator == ends;
        } catch (final Exception any) {
            return operatorError(node, operator, any, false);
        }
    }

    @Override
    public Object tryAssignOverload(final JexlCache.Reference node,
                             final JexlOperator operator,
                             final Consumer<Object> assignFun,
                             final Object... args) {
        if (args.length < operator.getArity()) {
            return JexlEngine.TRY_FAILED;
        }
        Object result = JexlEngine.TRY_FAILED;
        try {
            // if the operator is strict, the left-hand side can not be null
            controlNullOperands(arithmetic, operator, args[0]);
            // attempt assignment operator overload
            if (overloads(operator)) {
                result = tryOverload(node, operator, arguments(operator, args));
                if (result != JexlEngine.TRY_FAILED) {
                    return result;
                }
            }
            // let's attempt base operator overload
            final JexlOperator base = operator.getBaseOperator();
            if (base != null && overloads(base)) {
                result = tryOverload(node, base, arguments(base, args));
            }
            // no overload or overload failed, use base operator
            if (result == JexlEngine.TRY_FAILED) {
                result = performBaseOperation(operator, args);
            }
            // on success, assign value
            if (result != JexlEngine.TRY_FAILED) {
                assignFun.accept(result);
                // postfix implies return initial argument value
                if (POSTFIX_OPS.contains(operator)) {
                    result = args[0];
                }
            }
            return result;
        } catch (final Exception any) {
            return operatorError(node, operator, any, JexlEngine.TRY_FAILED);
        }
    }

    /**
     * Performs the base operation of an assignment.
     *
     * @param operator the operator
     * @param args the arguments
     * @return the result
     */
    private Object performBaseOperation(final JexlOperator operator, final Object... args) {
        switch (operator) {
            case SELF_ADD: return arithmetic.add(args[0], args[1]);
            case SELF_SUBTRACT: return arithmetic.subtract(args[0], args[1]);
            case SELF_MULTIPLY: return arithmetic.multiply(args[0], args[1]);
            case SELF_DIVIDE: return arithmetic.divide(args[0], args[1]);
            case SELF_MOD: return arithmetic.mod(args[0], args[1]);
            case SELF_AND: return arithmetic.and(args[0], args[1]);
            case SELF_OR: return arithmetic.or(args[0], args[1]);
            case SELF_XOR: return arithmetic.xor(args[0], args[1]);
            case SELF_SHIFTLEFT: return arithmetic.shiftLeft(args[0], args[1]);
            case SELF_SHIFTRIGHT: return arithmetic.shiftRight(args[0], args[1]);
            case SELF_SHIFTRIGHTU: return arithmetic.shiftRightUnsigned(args[0], args[1]);
            case INCREMENT_AND_GET:
            case GET_AND_INCREMENT:
                return arithmetic.increment(args[0]);
            case DECREMENT_AND_GET:
            case GET_AND_DECREMENT:
                return arithmetic.decrement(args[0]);
            default:
                throw new UnsupportedOperationException(operator.getOperatorSymbol());
        }
    }

    @Override
    public Object tryOverload(final JexlCache.Reference node, final JexlOperator operator, final Object... args) {
        controlNullOperands(arithmetic, operator, args);
        try {
            return tryEval(isCaching() ? node : null, operator, args);
        } catch (final Exception any) {
            // ignore return if lenient, will return try_failed
            return operatorError(node, operator, any, JexlEngine.TRY_FAILED);
        }
    }

    /**
     * Tries operator evaluation, handles method resolution caching.
     *
     * @param node the node
     * @param operator the operator
     * @param args the operator arguments
     * @return the operator evaluation result or TRY_FAILED
     */
    private Object tryEval(final JexlCache.Reference node, final JexlOperator operator, final Object...args) {
        if (node != null) {
            final Object cached = node.getCache();
            if (cached instanceof JexlMethod) {
                // we found a method on previous call; try and reuse it (*1)
                final JexlMethod me = (JexlMethod) cached;
                final Object eval = me.tryInvoke(operator.getMethodName(), arithmetic, args);
                if (!me.tryFailed(eval)) {
                    return eval;
                }
            } else if (cached instanceof MethodKey) {
                // check for a fail-fast, we tried to find an overload before but could not (*2)
                final MethodKey cachedKey = (MethodKey) cached;
                final MethodKey key = new MethodKey(operator.getMethodName(), args);
                if (key.equals(cachedKey)) {
                    return JexlEngine.TRY_FAILED;
                }
            }
        }
        // trying to find an operator overload
        JexlMethod vm = getOperator(operator, args);
        // no direct overload, any special case ?
        if (vm == null) {
            vm = getAlternateOverload(operator, args);
        }
        // *1: found a method, try it and cache it if successful
        if (vm != null) {
            final Object result = vm.tryInvoke(operator.getMethodName(), arithmetic, args);
            if (node != null && !vm.tryFailed(result)) {
                node.setCache(vm);
            }
            return result;
        }
        if (node != null) {
            // *2: could not find an overload for this operator and arguments, keep track of the fail
            final MethodKey key = new MethodKey(operator.getMethodName(), args);
            node.setCache(key);
        }
        return JexlEngine.TRY_FAILED;
    }

    /**
     * Special handling of overloads where another attempt at finding a method may be attempted.
     * <p>As of 3.5.0, only the comparison operators attempting to use compare() are handled.</p>
     *
     * @param operator the operator
     * @param args the arguments
     * @return an instance or null
     */
    private JexlMethod getAlternateOverload(final JexlOperator operator, final Object... args) {
        // comparison operators may use the compare overload in derived arithmetic
        if (CMP_OPS.contains(operator) && args.length == 2) {
            JexlMethod cmp = getOperator(JexlOperator.COMPARE, args);
            if (cmp != null) {
                return new Operator.CompareMethod(operator, cmp);
            }
            cmp = getOperator(JexlOperator.COMPARE, args[1], args[0]);
            if (cmp != null) {
                return new Operator.AntiCompareMethod(operator, cmp);
            }
        }
        return null;
    }

    /**
     * Delegates a comparison operator to a compare method.
     * The expected signature of the derived JexlArithmetic method is:
     * int compare(L left, R right);
     */
    private static class CompareMethod implements JexlMethod {
        protected final JexlOperator operator;
        protected final JexlMethod compare;

        CompareMethod(final JexlOperator op, final JexlMethod m) {
            operator = op;
            compare = m;
        }

        @Override
        public Class<?> getReturnType() {
            return Boolean.TYPE;
        }

        @Override
        public Object invoke(final Object arithmetic, final Object... params) throws Exception {
            return operate((int) compare.invoke(arithmetic, params));
        }

        @Override
        public boolean isCacheable() {
            return true;
        }

        @Override
        public boolean tryFailed(final Object rval) {
            return rval == JexlEngine.TRY_FAILED;
        }

        @Override
        public Object tryInvoke(final String name, final Object arithmetic, final Object... params) throws JexlException.TryFailed {
            final Object cmp = compare.tryInvoke(JexlOperator.COMPARE.getMethodName(), arithmetic, params);
            return cmp instanceof Integer? operate((int) cmp) : JexlEngine.TRY_FAILED;
        }

        /**
         * From compare() int result to operator boolean result.
         *
         * @param cmp the compare result
         * @return the operator applied
         */
        protected boolean operate(final int cmp) {
            switch(operator) {
                case EQ: return cmp == 0;
                case LT: return cmp < 0;
                case LTE: return cmp <= 0;
                case GT: return cmp > 0;
                case GTE: return cmp >= 0;
                default:
                    throw new ArithmeticException("unexpected operator " + operator);
            }
        }
    }

    /**
     * The reverse compare swaps left and right arguments and exploits the symmetry
     * of compare as in: compare(a, b) &lt=&gt -compare(b, a)
     */
    private static class AntiCompareMethod extends CompareMethod {
        AntiCompareMethod(final JexlOperator op, final JexlMethod m) {
            super(op, m);
        }

        @Override
        public Object invoke(final Object arithmetic, final Object... params) throws Exception {
            return operate(-(int) compare.invoke(arithmetic, params[1], params[0]));
        }

        @Override
        public Object tryInvoke(final String name, final Object arithmetic, final Object... params) throws JexlException.TryFailed {
            final Object cmp = compare.tryInvoke(JexlOperator.COMPARE.getMethodName(), arithmetic, params[1], params[0]);
            return cmp instanceof Integer? operate(-Integer.signum((Integer) cmp)) : JexlEngine.TRY_FAILED;
        }
    }
}
