/*
 * 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.support.test.uiautomator.UiObject;
import android.support.test.uiautomator.UiObjectNotFoundException;
import android.support.test.uiautomator.UiScrollable;
import android.support.test.uiautomator.UiSelector;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;

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


/**
 * For parsing strings that create UiScrollable objects into UiScrollable objects
 */
public class UiScrollableParser {

    private final static Method[] methods = UiScrollable.class.getDeclaredMethods();
    private static String[] prefixes = {"new UiScrollable", "UiScrollable"};
    private String text;
    private UiScrollable scrollable;
    private UiObject uiObject;
    private boolean returnedUiObject;

    /*
     * Returns whether or not the input string is trying to instantiate a UiScrollable, and use its methods
     */
    public static boolean isUiScrollable(String textToParse) {
        for (String prefix : prefixes) {
            if (textToParse.startsWith(prefix)) {
                return true;
            }
        }
        return false;
    }

    /*
     * Parse a string into a UiSelector, but use UiScrollable class and methods
     */
    public UiSelector parse(String textToParse) throws UiSelectorSyntaxException {
        text = textToParse.trim();
        returnedUiObject = false;

        consumePrefix();
        consumeConstructor();

        while (text.length() > 0) {
            consumePeriod();
            consumeFunctionCall();
        }

        if (!returnedUiObject) {
            throw new UiSelectorSyntaxException("Last method called on a UiScrollable object must return a UiObject object");
        }

        return uiObject.getSelector();
    }

    private void consumePeriod() throws UiSelectorSyntaxException {
        if (text.startsWith(".")) {
            text = text.substring(1);
        } else {
            throw new UiSelectorSyntaxException("Expected \".\" but saw \"" + text.charAt(0) + "\"");
        }
    }

    /*
     * You can start a UiScrollable like: "new UiScrollable(UiSelector).somemethod()" or "Uiscrollable(UiSelector).somemethod()"
     */
    private void consumePrefix() throws UiSelectorSyntaxException {
        boolean removedPrefix = false;
        for (String prefix : prefixes) {
            if (text.startsWith(prefix)) {
                text = text.substring(prefix.length());
                removedPrefix = true;
                break;
            }
        }
        if (!removedPrefix) {
            throw new UiSelectorSyntaxException("Was trying to parse as UiScrollable, but didn't start with an acceptable prefix. Acceptable prefixes are: 'new UiScrollable' or 'UiScrollable'. Saw: " + text);
        }
    }

    /*
     * consume UiScrollable constructor argument: parens surrounding a uiSelector. eg - "(new UiSelector().scrollable(true))"
     * initialize the UiScrollable object for this parser
     */
    private void consumeConstructor() throws UiSelectorSyntaxException {
        if (text.charAt(0) != '(') {
            throw new UiSelectorSyntaxException("Was expecting \"" + ")" + "\" but instead saw \"" + text.charAt(0) + "\"");
        }
        StringBuilder argument = new StringBuilder();

        int index = 1;
        int parenCount = 1;
        while (parenCount > 0) {
            try {
                switch (text.charAt(index)) {
                    case ')':
                        parenCount--;
                        if (parenCount > 0) {
                            argument.append(text.charAt(index));
                        }
                        break;
                    case '(':
                        parenCount++;
                        argument.append(text.charAt(index));
                        break;
                    default:
                        argument.append(text.charAt(index));
                }
            } catch (StringIndexOutOfBoundsException e) {
                throw new UiSelectorSyntaxException("unclosed paren in expression");
            }
            index++;
        }
        if (argument.length() < 1) {
            throw new UiSelectorSyntaxException("UiScrollable constructor expects an argument");
        }

        UiSelector selector = new UiSelectorParser().parse(argument.toString());
        scrollable = new UiScrollable(selector);

        // add two for parentheses surrounding arg
        text = text.substring(argument.length() + 2);
    }

    /*
     * 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
     *
     */
    private void consumeFunctionCall() throws UiSelectorSyntaxException {
        String methodName;
        StringBuilder argument = new StringBuilder();

        int parenIndex = text.indexOf('(');
        methodName = text.substring(0, parenIndex);

        int index = parenIndex + 1;
        int parenCount = 1;
        while (parenCount > 0) {
            try {
                switch (text.charAt(index)) {
                    case ')':
                        parenCount--;
                        if (parenCount > 0) {
                            argument.append(text.charAt(index));
                        }
                        break;
                    case '(':
                        parenCount++;
                        argument.append(text.charAt(index));
                        break;
                    default:
                        argument.append(text.charAt(index));
                }
            } catch (StringIndexOutOfBoundsException e) {
                throw new UiSelectorSyntaxException("unclosed paren in expression");
            }
            index++;
        }

        ArrayList<String> args = splitArgs(argument.toString());

        Method method = getUiScrollableMethod(methodName, args);

        applyArgsToMethod(method, args);

        // add two for parentheses surrounding arg
        text = text.substring(methodName.length() + argument.length() + 2);
    }

    private Method getUiScrollableMethod(String methodName, Collection<String> args) throws UiSelectorSyntaxException {
        for (Method method : methods) {
            if (method.getName().equals(methodName) && method.getGenericParameterTypes().length == args.size()) {
                return method;
            }
        }
        throw new UiSelectorSyntaxException("UiScrollable has no \"" + methodName + "\" method that takes " + args.size() + " arguments");
    }

    private void applyArgsToMethod(Method method, ArrayList<String> arguments) throws UiSelectorSyntaxException {
        StringBuilder sb = new StringBuilder();
        for (String arg : arguments) {
            sb.append(arg + ", ");
        }
        Logger.debug("UiScrollable invoking method: " + method + " args: " + sb.toString());

        if (method.getGenericReturnType() == UiScrollable.class && returnedUiObject) {
            throw new UiSelectorSyntaxException("Cannot call UiScrollable method \"" + method.getName() + "\" on a UiObject instance");
        }

        if (method.getGenericParameterTypes().length == 0) {
            try {
                scrollable = (UiScrollable) method.invoke(scrollable);
            } catch (IllegalAccessException e) {
                e.printStackTrace();
                throw new UiSelectorSyntaxException("problem using reflection to call this method");
            } catch (InvocationTargetException e) {
                e.printStackTrace();
                throw new UiSelectorSyntaxException("problem using reflection to call this method");
            } catch (ClassCastException e) {
                throw new UiSelectorSyntaxException("methods must return UiScrollable or UiObject instances");
            }
        } else {
            ArrayList<Object> convertedArgs = new ArrayList<Object>();
            Type[] parameterTypes = method.getGenericParameterTypes();
            for (int i = 0; i < parameterTypes.length; i++) {
                convertedArgs.add(coerceArgToType(parameterTypes[i], arguments.get(i)));
            }

            String methodName = method.getName();
            Logger.debug("Method name: " + methodName);
            boolean scrollIntoView = methodName.contentEquals("scrollIntoView");

            if (method.getGenericReturnType() == UiScrollable.class || scrollIntoView) {
                if (convertedArgs.size() > 1) {
                    throw new UiSelectorSyntaxException("No UiScrollable method that returns type UiScrollable takes more than 1 argument");
                }
                try {
                    if (scrollIntoView) {
                        Logger.debug("Setting uiObject for scrollIntoView");
                        UiSelector arg = (UiSelector) convertedArgs.get(0);
                        returnedUiObject = true;
                        uiObject = new UiObject(arg);

                        if (!scrollable.exists()) {
                            Logger.debug("Not scrolling because scrollable does not exist: " + scrollable.getSelector());
                            return;
                        }

                        // scrollIntoView must return the object if it's already in view.
                        // without the exists check, the parser will error because there's no scrollable.
                        if (uiObject.exists()) {
                            return;
                        }
                        Logger.debug("Invoking method: " + method + " with: " + uiObject);
                        method.invoke(scrollable, uiObject);
                        Logger.debug("Invoke complete.");
                    } else {
                        scrollable = (UiScrollable) method.invoke(scrollable, convertedArgs.get(0));
                    }
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                    throw new UiSelectorSyntaxException("problem using reflection to call this method");
                } catch (InvocationTargetException e) {
                    // Ignoring UiObjectNotFoundException as this handled during actual find.
                    if (e.getCause() instanceof UiObjectNotFoundException) {
                        Logger.debug("Ignoring UiObjectNotFoundException when using reflection to invoke method.");
                        return;
                    }
                    Logger.error(e.getCause().toString()); // we're only interested in the cause. InvocationTarget wraps the underlying problem.
                    throw new UiSelectorSyntaxException("problem using reflection to call this method");
                }
            } else if (method.getGenericReturnType() == UiObject.class) {
                returnedUiObject = true;

                if (convertedArgs.size() == 2) {
                    try {
                        uiObject = (UiObject) method.invoke(scrollable, convertedArgs.get(0), convertedArgs.get(1));
                    } catch (IllegalAccessException e) {
                        e.printStackTrace();
                        throw new UiSelectorSyntaxException("problem using reflection to call this method");
                    } catch (InvocationTargetException e) {
                        e.printStackTrace();
                        throw new UiSelectorSyntaxException("problem using reflection to call this method");
                    }
                } else if (convertedArgs.size() == 3) {
                    try {
                        uiObject = (UiObject) method.invoke(scrollable, convertedArgs.get(0), convertedArgs.get(1), convertedArgs.get(2));
                    } catch (IllegalAccessException e) {
                        e.printStackTrace();
                        throw new UiSelectorSyntaxException("problem using reflection to call this method");
                    } catch (InvocationTargetException e) {
                        e.printStackTrace();
                        throw new UiSelectorSyntaxException("problem using reflection to call this method");
                    }
                } else {
                    throw new UiSelectorSyntaxException("UiScrollable methods which return a UiObject have 2-3 args");
                }
            } else {
                throw new UiSelectorSyntaxException("Must only call the 'scrollIntoView' method OR methods on UiScrollable which return UiScrollable or UiObject objects");
            }
        }
    }

    private Object coerceArgToType(Type type, String argument) throws UiSelectorSyntaxException {
        Logger.debug("UiScrollable coerce type: " + type + " arg: " + argument);
        if (type == boolean.class) {
            if (argument.equals("true")) {
                return true;
            }
            if (argument.equals("false")) {
                return false;
            }
            throw new UiSelectorSyntaxException(argument + " is not a boolean");
        }

        if (type == String.class) {
            if (argument.charAt(0) != '"' || argument.charAt(argument.length() - 1) != '"') {
                throw new UiSelectorSyntaxException(argument + " is not a string");
            }
            return argument.substring(1, argument.length() - 1);
        }

        if (type == int.class) {
            return Integer.parseInt(argument);
        }

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

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

        throw new UiSelectorSyntaxException("Could not coerce " + argument + " to any sort of Type");
    }

    private ArrayList<String> splitArgs(String argumentString) throws UiSelectorSyntaxException {
        ArrayList<String> args = new ArrayList<String>();
        if (argumentString.isEmpty()) {
            return args;
        }
        if (argumentString.charAt(0) == ',' || argumentString.charAt(argumentString.length() - 1) == ',') {
            throw new UiSelectorSyntaxException("Missing argument. Trying to parse: " + argumentString);
        }

        int prevIndex = 0;
        int index = 1;
        boolean inQuotes = false;
        while (index < argumentString.length()) {
            switch (argumentString.charAt(index)) {
                case ',':
                    if (!inQuotes) {
                        if (prevIndex == index) {
                            throw new UiSelectorSyntaxException("Missing argument. Trying to parse: " + argumentString);
                        }
                        args.add(argumentString.substring(prevIndex, index).trim());
                        prevIndex = index + 1;
                    }
                case '"':
                    inQuotes = !inQuotes;
                    break;
            }
            index++;
        }
        args.add(argumentString.substring(prevIndex, index).trim());

        return args;
    }
}
