/*
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 * 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 io.appium.uiautomator2.utils;

import android.util.Pair;

import androidx.test.uiautomator.UiObjectNotFoundException;
import androidx.test.uiautomator.UiSelector;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import java.util.Stack;

import io.appium.uiautomator2.common.exceptions.UiAutomator2Exception;
import io.appium.uiautomator2.common.exceptions.UiSelectorSyntaxException;

abstract class UiExpressionParser<T, U> {
    protected final Class<T> clazz;
    protected final StringBuilderWrapper expression;
    private int currentIndex;
    private T target;

    UiExpressionParser(Class<T> clazz, String expression) {
        this.clazz = clazz;
        this.expression = new StringBuilderWrapper(expression);
        prepareForParsing();
    }

    protected String getConstructorExpression() {
        return "new " + clazz.getSimpleName();
    }

    public abstract U parse() throws UiSelectorSyntaxException, UiObjectNotFoundException;

    // prepares text for the main parsing loop
    protected void prepareForParsing() {
        if (expression.startsWith(clazz.getSimpleName())) {
            expression.getStringBuilder().insert(0, "new ");
        }
    }

    @SuppressWarnings("unchecked")
    protected T consumeConstructor() throws UiSelectorSyntaxException,
            UiObjectNotFoundException {
        skipLeadingSpaces();
        final String constructorExpression = getConstructorExpression();
        if (!expression.startsWith(constructorExpression, currentIndex)) {
            throw new UiSelectorSyntaxException(expression.toString(), String.format(
                    "Was trying to parse as %1$s, but didn't start with an acceptable prefix. " +
                            "Acceptable prefixes are: `new %1$s` or `%1$s`",
                    clazz.getSimpleName()));
        }
        currentIndex += constructorExpression.length();
        final List<String> params = consumeMethodParameters();
        final Pair<Constructor, List<Object>> constructor = findConstructor(params);
        try {
            target = (T) constructor.first.newInstance(constructor.second.toArray());
        } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
            throw new UiAutomator2Exception("Can not create instance of " +
                    clazz.getSimpleName(), e);
        }
        return target;
    }

    protected void consumePeriod() throws UiSelectorSyntaxException {
        skipLeadingSpaces();
        if (hasMoreDataToParse() && expression.getStringBuilder().charAt(currentIndex) == '.') {
            currentIndex++;
        } else {
            throw new UiSelectorSyntaxException(expression.toString(), "Expected \".\"",
                    currentIndex);
        }
    }

    protected String consumeMethodName() throws UiSelectorSyntaxException {
        skipLeadingSpaces();
        final int firstParenIndex = expression.getStringBuilder().indexOf("(", currentIndex);
        if (firstParenIndex < 0) {
            throw new UiSelectorSyntaxException(expression.toString(),
                    "No opening parenthesis after method name", currentIndex);
        }
        final String methodName = expression.getStringBuilder()
                .substring(currentIndex, firstParenIndex).trim();
        if (methodName.isEmpty()) {
            throw new UiSelectorSyntaxException(expression.toString(),
                    "Missing method name", currentIndex);
        }
        currentIndex = firstParenIndex;
        return methodName;
    }

    protected List<String> consumeMethodParameters() throws UiSelectorSyntaxException {
        skipLeadingSpaces();
        final List<String> arguments = new ArrayList<>();
        final Stack<Character> parenthesesStack = new Stack<>();
        int startIndex = currentIndex;
        boolean isInsideStringLiteral = false;
        do {
            final char currentChar = expression.getStringBuilder().charAt(currentIndex);

            if (currentChar == '"') {
                /* Skip escaped quotes */
                isInsideStringLiteral = !(isInsideStringLiteral && currentIndex > 0
                        && expression.getStringBuilder().charAt(currentIndex - 1) != '\\');
            }

            if (!isInsideStringLiteral) {
                switch (currentChar) {
                    case ')':
                        if (parenthesesStack.peek() == '(') {
                            parenthesesStack.pop();
                        } else {
                            parenthesesStack.push(currentChar);
                        }
                        break;
                    case '(':
                        parenthesesStack.push(currentChar);
                        break;
                    case ',':
                        final String argument = expression.getStringBuilder()
                                .substring(startIndex + 1, currentIndex).trim();
                        if (argument.isEmpty()) {
                            throw new UiSelectorSyntaxException(expression.toString(),
                                    "Missing argument", startIndex);
                        }
                        arguments.add(argument);
                        startIndex = currentIndex;
                        break;
                    default:
                        break;
                }
            }
            currentIndex++;
        } while (!parenthesesStack.empty() && hasMoreDataToParse());

        if (!parenthesesStack.isEmpty()) {
            throw new UiSelectorSyntaxException(expression.toString(),
                    "Unclosed paren in expression");
        }

        final String argument = expression.getStringBuilder()
                .substring(startIndex + 1, currentIndex - 1).trim();

        if (!argument.isEmpty()) {
            arguments.add(argument);
        } else if (!arguments.isEmpty()) {
            /* Throw exception if the last argument is missing */
            throw new UiSelectorSyntaxException(expression.toString(), "Missing argument",
                    startIndex);
        }

        return arguments;
    }

    /**
     * consume [a-z]* then an open paren, this is our methodName
     * consume .* and count open/close parens until the original open paren is close, this is
     * our
     * argument
     */
    protected <V> V consumeMethodCall() throws UiSelectorSyntaxException,
            UiObjectNotFoundException {
        final String methodName = consumeMethodName();
        final List<String> arguments = consumeMethodParameters();
        final Pair<Method, List<Object>> methodWithArgument = findMethod(methodName, arguments);
        return invokeMethod(target, methodWithArgument.first, methodWithArgument.second);
    }

    protected Pair<Method, List<Object>> findMethod(String methodName, List<String> arguments)
            throws UiSelectorSyntaxException, UiObjectNotFoundException {
        final List<Method> candidates = new ArrayList<>();
        for (final Method method : clazz.getDeclaredMethods()) {
            if (method.getName().equals(methodName)) {
                candidates.add(method);
            }
        }

        if (candidates.isEmpty()) {
            throw new UiSelectorSyntaxException(expression.toString(),
                    String.format("%s has no `%s` method", getTarget().getClass().getSimpleName(),
                            methodName));
        }

        UiSelectorSyntaxException exThrown = null;
        for (final Method method : candidates) {
            try {
                final Type[] parameterTypes = method.getGenericParameterTypes();
                final List<Object> args = coerceArgsToTypes(parameterTypes, arguments);
                return new Pair<>(method, args);
            } catch (UiSelectorSyntaxException e) {
                exThrown = e;
            }
        }

        final String errorMsg = "`%s` doesn't have suitable method `%s` with arguments %s" +
                (exThrown != null ? ": " + exThrown.getMessage() : "");

        throw new UiSelectorSyntaxException(expression.toString(),
                String.format(errorMsg, clazz.getSimpleName(), methodName, arguments), exThrown);
    }

    private Pair<Constructor, List<Object>> findConstructor(List<String> arguments) throws
            UiSelectorSyntaxException, UiObjectNotFoundException {
        UiSelectorSyntaxException exThrown = null;
        for (final Constructor constructor : clazz.getConstructors()) {
            try {
                final Type[] parameterTypes = constructor.getGenericParameterTypes();
                final List<Object> args = coerceArgsToTypes(parameterTypes, arguments);
                return new Pair<>(constructor, args);
            } catch (UiSelectorSyntaxException e) {
                exThrown = e;
            }
        }

        throw new UiSelectorSyntaxException(expression.toString(),
                String.format("%s has no suitable constructor with arguments %s",
                        clazz.getSimpleName(), arguments), exThrown);
    }

    @SuppressWarnings("unchecked")
    protected <V> V invokeMethod(Object receiver, Method method, List<Object> arguments) throws
            UiSelectorSyntaxException, UiObjectNotFoundException {
        try {
            return (V) method.invoke(receiver, arguments.toArray());
        } catch (IllegalAccessException e) {
            e.printStackTrace();
            throw new UiSelectorSyntaxException(expression.toString(),
                    String.format("Problem using reflection to call `%s` method",
                            method.getName()), e);
        } catch (InvocationTargetException e) {
            Throwable targetException = e.getTargetException();
            if (targetException instanceof UiObjectNotFoundException) {
                throw (UiObjectNotFoundException) targetException;
            }
            throw new UiAutomator2Exception(targetException);
        }
    }

    private List<Object> coerceArgsToTypes(Type[] types, List<String> arguments) throws
            UiSelectorSyntaxException, UiObjectNotFoundException {
        if (types.length != arguments.size()) {
            throw new UiSelectorSyntaxException(expression.toString(),
                    String.format("Invalid arguments count. Actual: %s. Expected: %s.",
                            arguments.size(), types.length));
        }
        List<Object> result = new ArrayList<>();
        for (int i = 0; i < types.length; i++) {
            result.add(coerceArgToType(types[i], arguments.get(i)));
        }
        return result;
    }

    private Object coerceArgToType(Type type, String argument) throws UiSelectorSyntaxException,
            UiObjectNotFoundException {
        Logger.debug(String.format("UiSelector coerce type:%s arg:%s", type, argument));
        if (type == boolean.class) {
            if (argument.matches("^(true|false)$")) {
                return Boolean.valueOf(argument);
            }
            throw new UiSelectorSyntaxException(expression.toString(),
                    argument + " is not a boolean");
        }

        if (type == String.class) {
            if (argument.matches("^\"[\\s\\S]*\"$")) {
                return argument.substring(1, argument.length() - 1).replaceAll("\\\\\"", "\"");
            }
            throw new UiSelectorSyntaxException(expression.toString(),
                    argument + " is not a string");
        }

        if (type == int.class) {
            try {
                return Integer.parseInt(argument);
            } catch (NumberFormatException e) {
                throw new UiSelectorSyntaxException(expression.toString(),
                        argument + " is not a integer");
            }
        }

        if (type == double.class) {
          return Double.parseDouble(argument);
        }

        if ("java.lang.Class<T>".equals(type.toString())) {
            try {
                return Class.forName(argument);
            } catch (ClassNotFoundException e) {
                throw new UiSelectorSyntaxException(expression.toString(),
                        argument + " class could not be found");
            }
        }

        if (type == UiSelector.class) {
            UiSelectorParser parser = new UiSelectorParser(argument);
            return parser.parse();
        }

        throw new UiSelectorSyntaxException(expression.toString(),
                String.format("Type `%s` is not supported.", type));
    }


    protected T getTarget() {
        return target;
    }

    protected void setTarget(T target) {
        this.target = target;
    }

    protected void skipLeadingSpaces() {
        while (hasMoreDataToParse() && expression.getStringBuilder().charAt(currentIndex) == ' ') {
            currentIndex++;
        }
    }

    protected void resetCurrentIndex() {
        currentIndex = 0;
    }

    protected boolean hasMoreDataToParse() {
        return currentIndex < expression.getStringBuilder().length();
    }

    class StringBuilderWrapper {

        private final StringBuilder sb;

        public StringBuilderWrapper(String string) {
            sb = new StringBuilder(string.trim());
        }

        public boolean startsWith(String str, int index) {
            return sb.indexOf(str, index) == index;
        }

        public boolean startsWith(String str) {
            return startsWith(str, 0);
        }

        public StringBuilder getStringBuilder() {
            return sb;
        }

        @Override
        public String toString() {
            return sb.toString();
        }
    }
}