/*
 * Copyright (C) 2019 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 android.server.wm.intent;

import static java.util.stream.Collectors.toList;

import android.content.ComponentName;
import android.content.Intent;
import android.net.Uri;
import android.server.wm.WindowManagerState;

import com.google.common.collect.Lists;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * The intent tests are generated by running a series of intents and then recording the end state
 * of the system. This class contains all the models needed to store the intents that were used to
 * create the test case and the end states so that they can be asserted on.
 *
 * All test cases are serialized to JSON and stored in a single file per testcase.
 */
public class Persistence {

    /**
     * The highest level entity in the JSON file
     */
    public static class TestCase {
        private static final String SETUP_KEY = "setup";
        private static final String INITIAL_STATE_KEY = "initialState";
        private static final String END_STATE_KEY = "endState";

        /**
         * Contains the {@link android.content.Intent}-s that will be launched in this test case.
         */
        private final Setup mSetup;

        /**
         * The state of the system after the {@link Setup#mInitialIntents} have been launched.
         */
        private final StateDump mInitialState;

        /**
         * The state of the system after the {@link Setup#mAct} have been launched
         */
        private final StateDump mEndState;

        /**
         * The name of the testCase, usually the file name it is stored in.
         * Not actually persisted to json, since it is only used for presentation purposes.
         */
        private final String mName;

        public TestCase(Setup setup, StateDump initialState,
                StateDump endState, String name) {
            mSetup = setup;
            mInitialState = initialState;
            mEndState = endState;
            mName = name;
        }

        public JSONObject toJson() throws JSONException {
            return new JSONObject()
                    .put(SETUP_KEY, mSetup.toJson())
                    .put(INITIAL_STATE_KEY, mInitialState.toJson())
                    .put(END_STATE_KEY, mEndState.toJson());
        }

        public static TestCase fromJson(JSONObject object,
                Map<String, IntentFlag> table, String name) throws JSONException {
            return new TestCase(Setup.fromJson(object.getJSONObject(SETUP_KEY), table),
                    StateDump.fromJson(object.getJSONObject(INITIAL_STATE_KEY)),
                    StateDump.fromJson(object.getJSONObject(END_STATE_KEY)), name);
        }

        public Setup getSetup() {
            return mSetup;
        }

        public StateDump getInitialState() {
            return mInitialState;
        }

        public String getName() {
            return mName;
        }

        public StateDump getEndState() {
            return mEndState;
        }
    }

    /**
     * Setup consists of two stages. Firstly a list of intents to bring the system in the state we
     * want to test something in. Secondly a list of intents to bring the system to the final state.
     */
    public static class Setup {
        private static final String INITIAL_INTENT_KEY = "initialIntents";
        private static final String ACT_KEY = "act";
        /**
         * The intent(s) used to bring the system to the initial state.
         */
        private final List<GenerationIntent> mInitialIntents;

        /**
         * The intent(s) that we actually want to test.
         */
        private final List<GenerationIntent> mAct;

        public Setup(List<GenerationIntent> initialIntents, List<GenerationIntent> act) {
            mInitialIntents = initialIntents;
            mAct = act;
        }

        public List<ComponentName> componentsInCase() {
            return Stream.concat(mInitialIntents.stream(), mAct.stream())
                    .map(GenerationIntent::getActualIntent)
                    .map(Intent::getComponent)
                    .collect(Collectors.toList());
        }

        public JSONObject toJson() throws JSONException {
            return new JSONObject()
                    .put(INITIAL_INTENT_KEY, intentsToJson(mInitialIntents))
                    .put(ACT_KEY, intentsToJson(mAct));
        }

        public static Setup fromJson(JSONObject object,
                Map<String, IntentFlag> table) throws JSONException {
            List<GenerationIntent> initialState = intentsFromJson(
                    object.getJSONArray(INITIAL_INTENT_KEY), table);
            List<GenerationIntent> act = intentsFromJson(object.getJSONArray(ACT_KEY), table);

            return new Setup(initialState, act);
        }


        public static JSONArray intentsToJson(List<GenerationIntent> intents)
                throws JSONException {

            JSONArray intentArray = new JSONArray();
            for (GenerationIntent intent : intents) {
                intentArray.put(intent.toJson());
            }
            return intentArray;
        }

        public static List<GenerationIntent> intentsFromJson(JSONArray intentArray,
                Map<String, IntentFlag> table) throws JSONException {
            List<GenerationIntent> intents = new ArrayList<>();

            for (int i = 0; i < intentArray.length(); i++) {
                JSONObject object = (JSONObject) intentArray.get(i);
                GenerationIntent intent = GenerationIntent.fromJson(object, table);

                intents.add(intent);
            }

            return intents;
        }

        public List<GenerationIntent> getInitialIntents() {
            return mInitialIntents;
        }

        public List<GenerationIntent> getAct() {
            return mAct;
        }
    }

    /**
     * An representation of an {@link android.content.Intent} that can be (de)serialized to / from
     * JSON. It abstracts whether the context it should be started from is implicitly or explicitly
     * specified.
     */
    interface GenerationIntent {
        Intent getActualIntent();

        JSONObject toJson() throws JSONException;

        int getLaunchFromIndex(int currentPosition);

        boolean startForResult();

        static GenerationIntent fromJson(JSONObject object, Map<String, IntentFlag> table)
                throws JSONException {
            if (object.has(LaunchFromIntent.LAUNCH_FROM_KEY)) {
                return LaunchFromIntent.fromJson(object, table);
            } else {
                return LaunchIntent.fromJson(object, table);
            }
        }
    }

    /**
     * Representation of {@link android.content.Intent} used by the {@link LaunchSequence} api.
     * It be can used to normally start activities, to start activities for result and Intent Flags
     * can be added using {@link LaunchIntent#withFlags(IntentFlag...)}
     */
    static class LaunchIntent implements GenerationIntent {
        private static final String FLAGS_KEY = "flags";
        private static final String PACKAGE_KEY = "package";
        private static final String CLASS_KEY = "class";
        private static final String DATA_KEY = "data";
        private static final String START_FOR_RESULT_KEY = "startForResult";

        private final List<IntentFlag> mIntentFlags;
        private final ComponentName mComponentName;
        private final String mData;
        private final boolean mStartForResult;

        public LaunchIntent(List<IntentFlag> intentFlags, ComponentName componentName, String data,
                boolean startForResult) {
            mIntentFlags = intentFlags;
            mComponentName = componentName;
            mData = data;
            mStartForResult = startForResult;
        }

        @Override
        public Intent getActualIntent() {
            final Intent intent = new Intent().setComponent(mComponentName).setFlags(buildFlag());
            if (mData != null && !mData.isEmpty()) {
                intent.setData(Uri.parse(mData));
            }
            return intent;
        }

        @Override
        public int getLaunchFromIndex(int currentPosition) {
            return currentPosition - 1;
        }

        @Override
        public boolean startForResult() {
            return mStartForResult;
        }

        public int buildFlag() {
            int flag = 0;
            for (IntentFlag intentFlag : mIntentFlags) {
                flag |= intentFlag.flag;
            }

            return flag;
        }

        public String humanReadableFlags() {
            return mIntentFlags.stream().map(IntentFlag::toString).collect(
                    Collectors.joining(" | "));
        }

        public static LaunchIntent fromJson(JSONObject fakeIntent, Map<String, IntentFlag> table)
                throws JSONException {
            List<IntentFlag> flags = IntentFlag.parse(table, fakeIntent.getString(FLAGS_KEY));

            boolean startForResult = fakeIntent.optBoolean(START_FOR_RESULT_KEY, false);
            String uri = fakeIntent.optString(DATA_KEY);
            return new LaunchIntent(flags,
                    new ComponentName(
                            fakeIntent.getString(PACKAGE_KEY),
                            fakeIntent.getString(CLASS_KEY)),
                            uri,
                            startForResult);
        }

        @Override
        public JSONObject toJson() throws JSONException {
            return new JSONObject().put(FLAGS_KEY, this.humanReadableFlags())
                    .put(CLASS_KEY, this.mComponentName.getClassName())
                    .put(PACKAGE_KEY, this.mComponentName.getPackageName())
                    .put(START_FOR_RESULT_KEY, mStartForResult);
        }

        public LaunchIntent withFlags(IntentFlag... flags) {
            List<IntentFlag> intentFlags = Lists.newArrayList(mIntentFlags);
            Collections.addAll(intentFlags, flags);
            return new LaunchIntent(intentFlags, mComponentName, mData, mStartForResult);
        }

        public List<IntentFlag> getIntentFlags() {
            return mIntentFlags;
        }

        public ComponentName getComponentName() {
            return mComponentName;
        }
    }

    /**
     * Representation of {@link android.content.Intent} used by the {@link LaunchSequence} api.
     * It can used to normally start activities, to start activities for result and Intent Flags
     * can
     * be added using {@link LaunchIntent#withFlags(IntentFlag...)} just like {@link LaunchIntent}
     *
     * However {@link LaunchFromIntent}  also supports launching from a activity earlier in the
     * launch sequence. This can be done using {@link LaunchSequence#act} and related methods.
     */
    static class LaunchFromIntent implements GenerationIntent {
        static final String LAUNCH_FROM_KEY = "launchFrom";

        /**
         * The underlying {@link LaunchIntent} that we are wrapping with the launch point behaviour.
         */
        private final LaunchIntent mLaunchIntent;

        /**
         * The index in the activityLog maintained by {@link LaunchRunner}, used to retrieve the
         * activity from the log to start this {@link LaunchIntent} from.
         */
        private final int mLaunchFrom;

        LaunchFromIntent(LaunchIntent fakeIntent, int launchFrom) {
            mLaunchIntent = fakeIntent;
            mLaunchFrom = launchFrom;
        }


        @Override
        public Intent getActualIntent() {
            return mLaunchIntent.getActualIntent();
        }

        @Override
        public int getLaunchFromIndex(int currentPosition) {
            return mLaunchFrom;
        }

        @Override
        public boolean startForResult() {
            return mLaunchIntent.mStartForResult;
        }

        @Override
        public JSONObject toJson() throws JSONException {
            return mLaunchIntent.toJson()
                    .put(LAUNCH_FROM_KEY, mLaunchFrom);
        }

        public static LaunchFromIntent fromJson(JSONObject object, Map<String, IntentFlag> table)
                throws JSONException {
            LaunchIntent fakeIntent = LaunchIntent.fromJson(object, table);
            int launchFrom = object.optInt(LAUNCH_FROM_KEY, -1);

            return new LaunchFromIntent(fakeIntent, launchFrom);
        }

        static List<GenerationIntent> prepareSerialisation(List<LaunchFromIntent> intents) {
            return prepareSerialisation(intents, 0);
        }

        // In serialized form we only want to store the launch from index if it deviates from the
        // default, the default being the previous activity.
        static List<GenerationIntent> prepareSerialisation(List<LaunchFromIntent> intents,
                int base) {
            List<GenerationIntent> serializeIntents = Lists.newArrayList();
            for (int i = 0; i < intents.size(); i++) {
                LaunchFromIntent launchFromIntent = intents.get(i);
                serializeIntents.add(launchFromIntent.forget(base + i));
            }

            return serializeIntents;
        }

        public GenerationIntent forget(int currentIndex) {
            if (mLaunchFrom == currentIndex - 1) {
                return this.mLaunchIntent;
            } else {
                return this;
            }
        }

        public int getLaunchFrom() {
            return mLaunchFrom;
        }
    }

    /**
     * An intent flag that also stores the name of the flag.
     * It is used to be able to put the flags in human readable form in the JSON file.
     */
    static class IntentFlag {
        /**
         * The underlying flag, should be a value from Intent.FLAG_ACTIVITY_*.
         */
        public final int flag;
        /**
         * The name of the flag.
         */
        public final String name;

        public IntentFlag(int flag, String name) {
            this.flag = flag;
            this.name = name;
        }

        public int getFlag() {
            return flag;
        }

        public String getName() {
            return name;
        }

        public int combine(IntentFlag other) {
            return other.flag | flag;
        }

        public static List<IntentFlag> parse(Map<String, IntentFlag> names, String flagsToParse) {
            String[] split = flagsToParse.replaceAll("\\s", "").split("\\|");
            return Arrays.stream(split).map(names::get).collect(toList());
        }

        public String toString() {
            return name;
        }
    }

    static IntentFlag flag(int flag, String name) {
        return new IntentFlag(flag, name);
    }

    public static class StateDump {
        private static final String TASKS_KEY = "tasks";

        /**
         * The Tasks in this stack ordered from most recent to least recent.
         */
        private final List<TaskState> mTasks;

        public static StateDump fromTasks(List<WindowManagerState.Task> activityTasks,
                List<WindowManagerState.Task> baseStacks) {
            List<TaskState> tasks = new ArrayList<>();
            for (WindowManagerState.Task task : trimTasks(activityTasks, baseStacks)) {
                tasks.add(new TaskState(task));
            }
            return new StateDump(tasks);
        }

        private StateDump(List<TaskState> tasks) {
            mTasks = tasks;
        }

        JSONObject toJson() throws JSONException {
            JSONArray tasks = new JSONArray();
            for (TaskState task : mTasks) {
                tasks.put(task.toJson());
            }

            return new JSONObject().put(TASKS_KEY, tasks);
        }

        static StateDump fromJson(JSONObject object) throws JSONException {
            JSONArray jsonTasks = object.getJSONArray(TASKS_KEY);
            List<TaskState> tasks = new ArrayList<>();

            for (int i = 0; i < jsonTasks.length(); i++) {
                tasks.add(TaskState.fromJson((JSONObject) jsonTasks.get(i)));
            }

            return new StateDump(tasks);
        }

        /**
         * To make the state dump non device specific we remove every task that was present
         * in the system before recording, by their ID. For example a task containing the launcher
         * activity.
         */
        public static List<WindowManagerState.Task> trimTasks(
                List<WindowManagerState.Task> toTrim,
                List<WindowManagerState.Task> trimFrom) {

            for (WindowManagerState.Task task : trimFrom) {
                toTrim.removeIf(t -> t.getRootTaskId() == task.getRootTaskId());
            }

            return toTrim;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            StateDump stateDump = (StateDump) o;
            return Objects.equals(mTasks, stateDump.mTasks);
        }

        @Override
        public int hashCode() {
            return Objects.hash(mTasks);
        }
    }

    public static class TaskState {

        private static final String STATE_RESUMED = "RESUMED";
        private static final String ACTIVITIES_KEY = "activities";

        /**
         * The component name of the resumedActivity in this state, empty string if there is none.
         */
        private final String mResumedActivity;

        /**
         * The activities in this task ordered from most recent to least recent.
         */
        private final List<ActivityState> mActivities = new ArrayList<>();

        private TaskState(JSONArray jsonActivities) throws JSONException {
            String resumedActivity = "";
            for (int i = 0; i < jsonActivities.length(); i++) {
                final ActivityState activity =
                        ActivityState.fromJson((JSONObject) jsonActivities.get(i));
                // The json file shouldn't define multiple resumed activities, but it is fine that
                // the test will fail when comparing to the real state.
                if (STATE_RESUMED.equals(activity.getState())) {
                    resumedActivity = activity.getName();
                }
                mActivities.add(activity);
            }

            mResumedActivity = resumedActivity;
        }

        public TaskState(WindowManagerState.Task state) {
            final String resumedActivity = state.getResumedActivity();
            mResumedActivity = resumedActivity != null ? resumedActivity : "";
            for (WindowManagerState.Activity activity : state.getActivities()) {
                this.mActivities.add(new ActivityState(activity));
            }
        }

        JSONObject toJson() throws JSONException {
            JSONArray jsonActivities = new JSONArray();

            for (ActivityState activity : mActivities) {
                jsonActivities.put(activity.toJson());
            }

            return new JSONObject()
                    .put(ACTIVITIES_KEY, jsonActivities);
        }

        static TaskState fromJson(JSONObject object) throws JSONException {
            return new TaskState(object.getJSONArray(ACTIVITIES_KEY));
        }

        public List<ActivityState> getActivities() {
            return mActivities;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            TaskState task = (TaskState) o;
            return Objects.equals(mResumedActivity, task.mResumedActivity)
                    && Objects.equals(mActivities, task.mActivities);
        }

        @Override
        public int hashCode() {
            return Objects.hash(mResumedActivity, mActivities);
        }
    }

    public static class ActivityState {
        private static final String NAME_KEY = "name";
        private static final String STATE_KEY = "state";
        /**
         * The componentName of this activity.
         */
        private final String mComponentName;

        /**
         * The lifecycle state this activity is in.
         */
        private final String mLifeCycleState;

        public ActivityState(String name, String state) {
            mComponentName = name;
            mLifeCycleState = state;
        }

        public ActivityState(WindowManagerState.Activity activity) {
            mComponentName = activity.getName();
            mLifeCycleState = activity.getState();
        }


        JSONObject toJson() throws JSONException {
            return new JSONObject().put(NAME_KEY, mComponentName).put(STATE_KEY, mLifeCycleState);
        }

        static ActivityState fromJson(JSONObject object) throws JSONException {
            return new ActivityState(object.getString(NAME_KEY), object.getString(STATE_KEY));
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            ActivityState activity = (ActivityState) o;
            return Objects.equals(mComponentName, activity.mComponentName) &&
                    Objects.equals(mLifeCycleState, activity.mLifeCycleState);
        }

        @Override
        public int hashCode() {
            return Objects.hash(mComponentName, mLifeCycleState);
        }

        public String getName() {
            return mComponentName;
        }

        public String getState() {
            return mLifeCycleState;
        }
    }
}
