/*
 * 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 java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.junit.AssumptionViolatedException;

/**
 * Helper and constants accessible to host and device components that enable Business Logic
 * configuration
 */
public class BusinessLogic {

    // Device location to which business logic data is pushed
    public static final String DEVICE_FILE = "/sdcard/bl";

    /* A map from testcase name to the business logic rules for the test case */
    protected Map<String, List<BusinessLogicRulesList>> mRules;
    /* Feature flag determining if device specific tests are executed. */
    public boolean mConditionalTestsEnabled;
    private AuthenticationStatusEnum mAuthenticationStatus = AuthenticationStatusEnum.UNKNOWN;

    // A Date denoting the time of request from the business logic service
    protected Date mTimestamp;

    // A list of regexes triggering log redaction
    protected List<String> mRedactionRegexes = new ArrayList<>();

    /**
     * Determines whether business logic exists for a given test name
     * @param testName the name of the test case, prefixed by fully qualified class name, then '#'.
     * For example, "com.android.foo.FooTest#testFoo"
     * @return whether business logic exists for this test for this suite
     */
    public boolean hasLogicFor(String testName) {
        List<BusinessLogicRulesList> rulesLists = mRules.get(testName);
        return rulesLists != null && !rulesLists.isEmpty();
    }

    /**
     * Return whether multiple rule lists exist in the BusinessLogic for this test name.
     */
    private boolean hasLogicsFor(String testName) {
        List<BusinessLogicRulesList> rulesLists = mRules.get(testName);
        return rulesLists != null && rulesLists.size() > 1;
    }

    /**
     * Apply business logic for the given test.
     * @param testName the name of the test case, prefixed by fully qualified class name, then '#'.
     * For example, "com.android.foo.FooTest#testFoo"
     * @param executor a {@link BusinessLogicExecutor}
     */
    public void applyLogicFor(String testName, BusinessLogicExecutor executor) {
        if (!hasLogicFor(testName)) {
            return;
        }
        if (hasLogicsFor(testName)) {
            applyLogicsFor(testName, executor); // handle this special case separately
            return;
        }
        // expecting exactly one rules list at this point
        BusinessLogicRulesList rulesList = mRules.get(testName).get(0);
        rulesList.invokeRules(executor);
    }

    /**
     * Handle special case in which multiple rule lists exist for the test name provided.
     * Execute each rule list in a sandbox and store an exception for each rule list that
     * triggers failure or skipping for the test.
     * If all rule lists trigger skipping, rethrow AssumptionViolatedException to report a 'skip'
     * for the test as a whole.
     * If one or more rule lists trigger failure, rethrow RuntimeException with a list containing
     * each failure.
     */
    private void applyLogicsFor(String testName, BusinessLogicExecutor executor) {
        Map<String, RuntimeException> failedMap = new HashMap<>();
        Map<String, RuntimeException> skippedMap = new HashMap<>();
        List<BusinessLogicRulesList> rulesLists = mRules.get(testName);
        for (int index = 0; index < rulesLists.size(); index++) {
            BusinessLogicRulesList rulesList = rulesLists.get(index);
            String description = cleanDescription(rulesList.getDescription(), index);
            try {
                rulesList.invokeRules(executor);
            } catch (RuntimeException re) {
                if (AssumptionViolatedException.class.isInstance(re)) {
                    skippedMap.put(description, re);
                    executor.logInfo("Test %s (%s) skipped for reason: %s", testName, description,
                            re.getMessage());
                } else {
                    failedMap.put(description, re);
                }
            }
        }
        if (skippedMap.size() == rulesLists.size()) {
            throwAggregatedException(skippedMap, false);
        } else if (failedMap.size() > 0) {
            throwAggregatedException(failedMap, true);
        } // else this test should be reported as a pure pass
    }

    /**
     * Helper to aggregate the messages of many {@link RuntimeException}s, and optionally their
     * stack traces, before throwing an exception.
     * @param exceptions a map from description strings to exceptions. The descriptive keySet is
     * used to differentiate which BusinessLogicRulesList caused which exception
     * @param failed whether to trigger failure. When false, throws assumption failure instead, and
     * excludes stack traces from the exception message.
     */
    private static void throwAggregatedException(Map<String, RuntimeException> exceptions,
            boolean failed) {
        Set<String> keySet = exceptions.keySet();
        String[] descriptions = keySet.toArray(new String[keySet.size()]);
        StringBuilder msg = new StringBuilder("");
        msg.append(String.format("Test %s for cases: ", (failed) ? "failed" : "skipped"));
        msg.append(Arrays.toString(descriptions));
        msg.append("\nReasons include:");
        for (String description : descriptions) {
            RuntimeException re = exceptions.get(description);
            msg.append(String.format("\nMessage [%s]: %s", description, re.getMessage()));
            if (failed) {
                StringWriter sw = new StringWriter();
                re.printStackTrace(new PrintWriter(sw));
                msg.append(String.format("\nStack Trace: %s", sw.toString()));
            }
        }
        if (failed) {
            throw new RuntimeException(msg.toString());
        } else {
            throw new AssumptionViolatedException(msg.toString());
        }
    }

    /**
     * Helper method to generate a meaningful description in case the provided description is null
     * or empty. In this case, returns a string representation of the index provided.
     */
    private String cleanDescription(String description, int index) {
        return (description == null || description.length() == 0)
                ? Integer.toString(index)
                : description;
    }

    public void setAuthenticationStatus(String authenticationStatus) {
        try {
            mAuthenticationStatus = Enum.valueOf(AuthenticationStatusEnum.class,
                    authenticationStatus);
        } catch (IllegalArgumentException e) {
            // Invalid value, set to unknown
            mAuthenticationStatus = AuthenticationStatusEnum.UNKNOWN;
        }
    }

    public boolean isAuthorized() {
        return AuthenticationStatusEnum.AUTHORIZED.equals(mAuthenticationStatus);
    }

    public Date getTimestamp() {
        return mTimestamp;
    }

    public List<String> getRedactionRegexes() {
        return new ArrayList<String>(mRedactionRegexes);
    }

    /**
     * Builds a user readable string tha explains the authentication status and the effect on tests
     * which require authentication to execute.
     */
    public String getAuthenticationStatusMessage() {
        switch (mAuthenticationStatus) {
            case AUTHORIZED:
                return "Authorized";
            case NOT_AUTHENTICATED:
                return "authorization failed, please ensure the service account key is "
                        + "properly installed.";
            case NOT_AUTHORIZED:
                return "service account is not authorized to access information for this device. "
                        + "Please verify device properties are set correctly and account "
                        + "permissions are configured to the Business Logic Api.";
            case NO_DEVICE_INFO:
                return "unable to read device info files. Retry without --skip-device-info flag.";
            default:
                return "something went wrong, please try again.";
        }
    }

    /**
     * A list of BusinessLogicRules, wrapped with an optional description to differentiate rule
     * lists that apply to the same test.
     */
    protected static class BusinessLogicRulesList {

        /* Stored description and rules */
        protected List<BusinessLogicRule> mRulesList;
        protected String mDescription;

        public BusinessLogicRulesList(List<BusinessLogicRule> rulesList) {
            mRulesList = rulesList;
        }

        public BusinessLogicRulesList(List<BusinessLogicRule> rulesList, String description) {
            mRulesList = rulesList;
            mDescription = description;
        }

        public String getDescription() {
            return mDescription;
        }

        public List<BusinessLogicRule> getRules() {
            return mRulesList;
        }

        public void invokeRules(BusinessLogicExecutor executor) {
            for (BusinessLogicRule rule : mRulesList) {
                // Check conditions
                if (rule.invokeConditions(executor)) {
                    rule.invokeActions(executor);
                }
            }
        }
    }

    /**
     * Nested class representing an Business Logic Rule. Stores a collection of conditions
     * and actions for later invocation.
     */
    protected static class BusinessLogicRule {

        /* Stored conditions and actions */
        protected List<BusinessLogicRuleCondition> mConditions;
        protected List<BusinessLogicRuleAction> mActions;

        public BusinessLogicRule(List<BusinessLogicRuleCondition> conditions,
                List<BusinessLogicRuleAction> actions) {
            mConditions = conditions;
            mActions = actions;
        }

        /**
         * Method that invokes all Business Logic conditions for this rule, and returns true
         * if all conditions evaluate to true.
         */
        public boolean invokeConditions(BusinessLogicExecutor executor) {
            for (BusinessLogicRuleCondition condition : mConditions) {
                if (!condition.invoke(executor)) {
                    return false;
                }
            }
            return true;
        }

        /**
         * Method that invokes all Business Logic actions for this rule
         */
        public void invokeActions(BusinessLogicExecutor executor) {
            for (BusinessLogicRuleAction action : mActions) {
                action.invoke(executor);
            }
        }
    }

    /**
     * Nested class representing an Business Logic Rule Condition. Stores the name of a method
     * to invoke, as well as String args to use during invocation.
     */
    protected static class BusinessLogicRuleCondition {

        /* Stored method name and String args */
        protected String mMethodName;
        protected List<String> mMethodArgs;
        /* Whether or not the boolean result of this condition should be reversed */
        protected boolean mNegated;


        public BusinessLogicRuleCondition(String methodName, List<String> methodArgs,
                boolean negated) {
            mMethodName = methodName;
            mMethodArgs = methodArgs;
            mNegated = negated;
        }

        /**
         * Invoke this Business Logic condition with an executor.
         */
        public boolean invoke(BusinessLogicExecutor executor) {
            // XOR the negated boolean with the return value of the method
            return (mNegated != executor.executeCondition(mMethodName,
                    mMethodArgs.toArray(new String[mMethodArgs.size()])));
        }
    }

    /**
     * Nested class representing an Business Logic Rule Action. Stores the name of a method
     * to invoke, as well as String args to use during invocation.
     */
    protected static class BusinessLogicRuleAction {

        /* Stored method name and String args */
        protected String mMethodName;
        protected List<String> mMethodArgs;

        public BusinessLogicRuleAction(String methodName, List<String> methodArgs) {
            mMethodName = methodName;
            mMethodArgs = methodArgs;
        }

        /**
         * Invoke this Business Logic action with an executor.
         */
        public void invoke(BusinessLogicExecutor executor) {
            executor.executeAction(mMethodName,
                    mMethodArgs.toArray(new String[mMethodArgs.size()]));
        }
    }

    /**
     * Nested enum of the possible authentication statuses.
     */
    protected enum AuthenticationStatusEnum {
        UNKNOWN,
        NOT_AUTHENTICATED,
        NOT_AUTHORIZED,
        AUTHORIZED,
        NO_DEVICE_INFO
    }

}
