/*
 * Copyright (C) 2017 The Android Open Source Project
 *
 * Licensed 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 com.android.compatibility.common.util;

import com.google.errorprone.annotations.CanIgnoreReturnValue;

import org.junit.AssumptionViolatedException;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Resolves methods provided by the BusinessLogicService and invokes them
 */
public abstract class BusinessLogicExecutor {

    protected static final String LOG_TAG = "BusinessLogicExecutor";

    /** String representations of the String class and String[] class */
    protected static final String STRING_CLASS = "java.lang.String";
    protected static final String STRING_ARRAY_CLASS = "[Ljava.lang.String;";

    private static final String REDACTED_PLACEHOLDER = "[redacted]";
    /* List of regexes indicating a method arg should be redacted in the logs */
    protected List<String> mRedactionRegexes = new ArrayList<>();

    /**
     * Execute a business logic condition.
     * @param method the name of the method to invoke. Must include fully qualified name of the
     * enclosing class, followed by '.', followed by the name of the method
     * @param args the string arguments to supply to the method
     * @return the return value of the method invoked
     * @throws RuntimeException when failing to resolve or invoke the condition method
     */
    public boolean executeCondition(String method, String... args) {
        logDebug("Executing condition: %s", formatExecutionString(method, args));
        try {
            return (Boolean) invokeMethod(method, args);
        } catch (ClassNotFoundException | IllegalAccessException | InstantiationException |
                InvocationTargetException | NoSuchMethodException e) {
            throw new RuntimeException(String.format(
                    "BusinessLogic: Failed to invoke condition method %s with args: %s", method,
                    Arrays.toString(args)), e);
        }
    }

    /**
     * Execute a business logic action.
     * @param method the name of the method to invoke. Must include fully qualified name of the
     * enclosing class, followed by '.', followed by the name of the method
     * @param args the string arguments to supply to the method
     * @throws RuntimeException when failing to resolve or invoke the action method
     */
    public void executeAction(String method, String... args) {
        logDebug("Executing action: %s", formatExecutionString(method, args));
        try {
            invokeMethod(method, args);
        } catch (ClassNotFoundException | IllegalAccessException | InstantiationException |
                NoSuchMethodException e) {
            throw new RuntimeException(String.format(
                    "BusinessLogic: Failed to invoke action method %s with args: %s", method,
                    Arrays.toString(args)), e);
        } catch (InvocationTargetException e) {
            // This action throws an exception, so throw the original exception (e.g.
            // AssertionFailedError) for a more readable stacktrace.
            Throwable t = e.getCause();
            if (AssumptionViolatedException.class.isInstance(t)) {
                // This is an assumption failure (registered as a "pass") so don't wrap this
                // throwable in a RuntimeException
                throw (AssumptionViolatedException) t;
            } else {
                RuntimeException re = new RuntimeException(t.getMessage(), t.getCause());
                re.setStackTrace(t.getStackTrace());
                throw re;
            }
        }
    }

    /**
     * Format invocation information as "method(args[0], args[1], ...)".
     */
    protected abstract String formatExecutionString(String method, String... args);

    /** Substitute sensitive information with REDACTED_PLACEHOLDER if necessary. */
    protected String[] formatArgs(String[] args) {
        List<String> formattedArgs = new ArrayList<>();
        for (String arg : args) {
            formattedArgs.add(formatArg(arg));
        }
        return formattedArgs.toArray(new String[0]);
    }

    private String formatArg(String arg) {
        for (String regex : mRedactionRegexes) {
            Pattern pattern = Pattern.compile(regex);
            Matcher matcher = pattern.matcher(arg);
            if (matcher.find()) {
                return REDACTED_PLACEHOLDER;
            }
        }
        return arg;
    }

    /**
     * Execute a business logic method.
     *
     * @param method the name of the method to invoke. Must include fully qualified name of the
     *     enclosing class, followed by '.', followed by the name of the method
     * @param args the string arguments to supply to the method
     * @return the return value of the method invoked (type Boolean if method is a condition)
     * @throws RuntimeException when failing to resolve or invoke the method
     */
    @CanIgnoreReturnValue
    protected Object invokeMethod(String method, String... args)
            throws ClassNotFoundException, IllegalAccessException, InstantiationException,
                    InvocationTargetException, NoSuchMethodException {
        // Method names served by the BusinessLogic service should assume format
        // classname.methodName, but also handle format classname#methodName since test names use
        // this format
        int index = (method.indexOf('#') == -1) ? method.lastIndexOf('.') : method.indexOf('#');
        if (index == -1) {
            throw new RuntimeException(String.format("BusinessLogic: invalid method name "
                    + "\"%s\". Method string must include fully qualified class name. "
                    + "For example, \"com.android.packagename.ClassName.methodName\".", method));
        }
        String className = method.substring(0, index);
        Class cls = Class.forName(className);
        Object obj = null;
        if (getTestObject() != null && cls.isAssignableFrom(getTestObject().getClass())) {
            // The given method is a member of the test class, use the known test class instance
            obj = getTestObject();
        } else {
            // Only instantiate a new object if we don't already have one.
            // Otherwise the class could have been an interface which isn't instantiatable.
            obj = cls.getDeclaredConstructor().newInstance();
        }
        ResolvedMethod rm = getResolvedMethod(cls, method.substring(index + 1), args);
        return rm.invoke(obj);
    }

    /**
     * Log information with whichever logging mechanism is available to the instance. This varies
     * from host-side to device-side, so implementations are left to subclasses.
     * See {@link String.format(String, Object...)} for parameter information.
     */
    public abstract void logInfo(String format, Object... args);

    /**
     * Log debugging information to the host or device logs (depending on implementation).
     * See {@link String.format(String, Object...)} for parameter information.
     */
    public abstract void logDebug(String format, Object... args);

    /**
     * Get the test object. This method is left abstract, since non-abstract subclasses will set
     * the test object in the constructor.
     * @return the test case instance
     */
    protected abstract Object getTestObject();

    /**
     * Get the method and list of arguments corresponding to the class, method name, and proposed
     * argument values, in the form of a {@link ResolvedMethod} object. This object stores all
     * information required to successfully invoke the method. getResolvedMethod is left abstract,
     * since argument types differ between device-side (e.g. Context) and host-side
     * (e.g. ITestDevice) implementations of this class.
     * @param cls the Class to which the method belongs
     * @param methodName the name of the method to invoke
     * @param args the string arguments to use when invoking the method
     * @return a {@link ResolvedMethod}
     * @throws ClassNotFoundException
     */
    protected abstract ResolvedMethod getResolvedMethod(Class cls, String methodName,
            String... args) throws ClassNotFoundException;

    /**
     * Retrieve all methods within a class that match a given name
     * @param cls the class
     * @param name the method name
     * @return a list of method objects
     */
    protected List<Method> getMethodsWithName(Class cls, String name) {
        List<Method> methodList = new ArrayList<>();
        for (Method m : cls.getMethods()) {
            if (name.equals(m.getName())) {
                methodList.add(m);
            }
        }
        return methodList;
    }

    /**
     * Helper class for storing a method object, and a list of arguments to use when invoking the
     * method. The class is also equipped with an "invoke" method for convenience.
     */
    protected static class ResolvedMethod {
        private Method mMethod;
        List<Object> mArgs;

        public ResolvedMethod(Method method) {
            mMethod = method;
            mArgs = new ArrayList<>();
        }

        /** Add an argument to the argument list for this instance */
        public void addArg(Object arg) {
            mArgs.add(arg);
        }

        /** Invoke the stored method with the stored args on a given object */
        public Object invoke(Object instance) throws IllegalAccessException,
                InvocationTargetException {
            return mMethod.invoke(instance, mArgs.toArray());
        }
    }
}
