/*
 * Copyright (C) 2021 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.inputmethod.stresstest;

import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
import static android.view.WindowInsetsAnimation.Callback.DISPATCH_MODE_STOP;

import static com.android.compatibility.common.util.SystemUtil.eventually;

import static com.google.common.truth.Truth.assertWithMessage;

import android.app.Activity;
import android.app.Instrumentation;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.WindowInsets;
import android.view.WindowInsetsAnimation;
import android.view.WindowInsetsController;
import android.view.WindowManager;
import android.view.WindowManager.LayoutParams;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.LinearLayout;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.test.platform.app.InstrumentationRegistry;

import com.android.compatibility.common.util.ThrowingRunnable;

import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

/** Utility methods for IME stress test. */
public final class ImeStressTestUtil {

    private static final long TIMEOUT = TimeUnit.SECONDS.toMillis(3);

    private ImeStressTestUtil() {}

    private static final int[] WINDOW_FOCUS_FLAGS =
            new int[] {
                LayoutParams.FLAG_NOT_FOCUSABLE,
                LayoutParams.FLAG_ALT_FOCUSABLE_IM,
                LayoutParams.FLAG_NOT_FOCUSABLE | LayoutParams.FLAG_ALT_FOCUSABLE_IM,
                LayoutParams.FLAG_LOCAL_FOCUS_MODE
            };

    private static final int[] SOFT_INPUT_VISIBILITY_FLAGS =
            new int[] {
                LayoutParams.SOFT_INPUT_STATE_UNSPECIFIED,
                LayoutParams.SOFT_INPUT_STATE_UNCHANGED,
                LayoutParams.SOFT_INPUT_STATE_HIDDEN,
                LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN,
                LayoutParams.SOFT_INPUT_STATE_VISIBLE,
                LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE,
            };

    private static final int[] SOFT_INPUT_ADJUST_FLAGS =
            new int[] {
                LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED,
                LayoutParams.SOFT_INPUT_ADJUST_RESIZE,
                LayoutParams.SOFT_INPUT_ADJUST_PAN,
                LayoutParams.SOFT_INPUT_ADJUST_NOTHING
            };

    public static final String SOFT_INPUT_FLAGS = "soft_input_flags";
    public static final String WINDOW_FLAGS = "window_flags";
    public static final String UNFOCUSABLE_VIEW = "unfocusable_view";
    public static final String REQUEST_FOCUS_ON_CREATE = "request_focus_on_create";
    public static final String INPUT_METHOD_MANAGER_SHOW_ON_CREATE =
            "input_method_manager_show_on_create";
    public static final String INPUT_METHOD_MANAGER_HIDE_ON_CREATE =
            "input_method_manager_hide_on_create";
    public static final String WINDOW_INSETS_CONTROLLER_SHOW_ON_CREATE =
            "window_insets_controller_show_on_create";
    public static final String WINDOW_INSETS_CONTROLLER_HIDE_ON_CREATE =
            "window_insets_controller_hide_on_create";

    /** Parameters for show/hide ime parameterized tests. */
    public static ArrayList<Object[]> getWindowAndSoftInputFlagParameters() {
        ArrayList<Object[]> params = new ArrayList<>();

        // Set different window focus flags and keep soft input flags as default values (4 cases)
        for (int windowFocusFlags : WINDOW_FOCUS_FLAGS) {
            params.add(
                    new Object[] {
                        windowFocusFlags,
                        LayoutParams.SOFT_INPUT_STATE_UNSPECIFIED,
                        LayoutParams.SOFT_INPUT_ADJUST_RESIZE
                    });
        }
        // Set the combinations of different softInputVisibility, softInputAdjustment flags,
        // keep the window focus flag as default value ( 6 * 4 = 24 cases)
        for (int softInputVisibility : SOFT_INPUT_VISIBILITY_FLAGS) {
            for (int softInputAdjust : SOFT_INPUT_ADJUST_FLAGS) {
                params.add(
                        new Object[] {
                            0x0 /* No window focus flags */, softInputVisibility, softInputAdjust
                        });
            }
        }
        return params;
    }

    /** Checks if the IME is shown on the window that the given view belongs to. */
    public static boolean isImeShown(View view) {
        WindowInsets insets = view.getRootWindowInsets();
        if (insets == null) {
            return false;
        }
        return insets.isVisible(WindowInsets.Type.ime());
    }

    /** Calls the callable on the main thread and returns the result. */
    public static <V> V callOnMainSync(Callable<V> callable) {
        AtomicReference<V> result = new AtomicReference<>();
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            try {
                                result.set(callable.call());
                            } catch (Exception e) {
                                throw new RuntimeException("Exception was thrown", e);
                            }
                        });
        return result.get();
    }

    /**
     * Requests EditText view focus on the main thread, and assert this returns {@code true}.
     */
    public static void requestFocusAndVerify(TestActivity activity) {
        boolean result = callOnMainSync(activity::requestFocus);
        assertWithMessage("View focus request should have succeeded").that(result).isTrue();
    }

    /**
     * Waits until {@code pred} returns true, or throws on timeout.
     *
     * <p>The given {@code pred} will be called on the main thread.
     */
    public static void waitOnMainUntil(String message, Callable<Boolean> pred) {
        eventually(() -> assertWithMessage(message).that(callOnMainSync(pred)).isTrue(), TIMEOUT);
    }

    /** Waits until IME is shown, or throws on timeout. */
    public static void waitOnMainUntilImeIsShown(View view) {
        eventually(
                () ->
                        assertWithMessage("IME should have been shown")
                                .that(callOnMainSync(() -> isImeShown(view)))
                                .isTrue(),
                TIMEOUT);
    }

    /** Waits until IME is hidden, or throws on timeout. */
    public static void waitOnMainUntilImeIsHidden(View view) {
        eventually(
                () ->
                        assertWithMessage("IME should have been hidden")
                                .that(callOnMainSync(() -> isImeShown(view)))
                                .isFalse(),
                TIMEOUT);
    }

    /** Waits until window gains focus, or throws on timeout. */
    public static void waitOnMainUntilWindowGainsFocus(View view) {
        eventually(
                () ->
                        assertWithMessage(
                                "Window should have gained focus; value of hasWindowFocus:")
                                .that(callOnMainSync(view::hasWindowFocus))
                                .isTrue(),
                TIMEOUT);
    }

    /** Waits until view gains focus, or throws on timeout. */
    public static void waitOnMainUntilViewGainsFocus(View view) {
        eventually(
                () ->
                        assertWithMessage("View should have gained focus; value of hasFocus:")
                                .that(callOnMainSync(view::hasFocus))
                                .isTrue(),
                TIMEOUT);
    }

    /** Verify IME is always hidden within the given time duration. */
    public static void verifyImeIsAlwaysHidden(View view) {
        always(
                () ->
                        assertWithMessage("IME should have been hidden")
                                .that(callOnMainSync(() -> isImeShown(view)))
                                .isFalse(),
                TIMEOUT);
    }

    /** Verify the window never gains focus within the given time duration. */
    public static void verifyWindowNeverGainsFocus(View view) {
        always(
                () ->
                        assertWithMessage(
                                "Window should not have gained focus; value of hasWindowFocus:")
                                .that(callOnMainSync(view::hasWindowFocus))
                                .isFalse(),
                TIMEOUT);
    }

    /** Verify the view never gains focus within the given time duration. */
    public static void verifyViewNeverGainsFocus(View view) {
        always(
                () ->
                        assertWithMessage("View should not have gained focus; value of hasFocus:")
                                .that(callOnMainSync(view::hasFocus))
                                .isFalse(),
                TIMEOUT);
    }

    /**
     * Make sure that a {@link Runnable} always finishes without throwing a {@link Exception} in the
     * given duration
     *
     * @param r The {@link Runnable} to run.
     * @param timeoutMillis The number of milliseconds to wait for {@code r} to not throw
     */
    public static void always(ThrowingRunnable r, long timeoutMillis) {
        long start = System.currentTimeMillis();

        while (true) {
            try {
                r.run();
                if (System.currentTimeMillis() - start >= timeoutMillis) {
                    return;
                }
                try {
                    Thread.sleep(100);
                } catch (InterruptedException ignored) {
                    // Do nothing
                }
            } catch (Throwable e) {
                throw new RuntimeException(e);
            }
        }
    }

    /**
     * Returns {@code true} if the activity can't receive IME focus, based on its window flags,
     * and {@code false} otherwise.
     *
     * @param activity the activity to check.
     */
    public static boolean hasUnfocusableWindowFlags(Activity activity) {
        return hasUnfocusableWindowFlags(activity.getWindow().getAttributes().flags);
    }

    /**
     * Returns {@code true} if the activity can't receive IME focus, based on its window flags,
     * and {@code false} otherwise.
     *
     * @param windowFlags the window flags to check.
     */
    public static boolean hasUnfocusableWindowFlags(int windowFlags) {
        return (windowFlags & LayoutParams.FLAG_NOT_FOCUSABLE) != 0
                || (windowFlags & LayoutParams.FLAG_ALT_FOCUSABLE_IM) != 0
                || (windowFlags & LayoutParams.FLAG_LOCAL_FOCUS_MODE) != 0;
    }

    public static void verifyWindowAndViewFocus(
            View view, boolean expectWindowFocus, boolean expectViewFocus) {
        if (expectWindowFocus) {
            waitOnMainUntilWindowGainsFocus(view);
        } else {
            verifyWindowNeverGainsFocus(view);
        }
        if (expectViewFocus) {
            waitOnMainUntilViewGainsFocus(view);
        } else {
            verifyViewNeverGainsFocus(view);
        }
    }

    public static void verifyImeAlwaysHiddenWithWindowFlagSet(TestActivity activity) {
        int windowFlags = activity.getWindow().getAttributes().flags;
        View view = activity.getEditText();
        if ((windowFlags & WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) != 0) {
            // When FLAG_NOT_FOCUSABLE is set true, the view will never gain window focus. The IME
            // will always be hidden even though the view can get focus itself.
            verifyWindowAndViewFocus(view, /*expectWindowFocus*/ false, /*expectViewFocus*/ true);
            verifyImeIsAlwaysHidden(view);
        } else if ((windowFlags & WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) != 0
                || (windowFlags & WindowManager.LayoutParams.FLAG_LOCAL_FOCUS_MODE) != 0) {
            // When FLAG_ALT_FOCUSABLE_IM or FLAG_LOCAL_FOCUS_MODE is set, the view can gain both
            // window focus and view focus but not IME focus. The IME will always be hidden.
            verifyWindowAndViewFocus(view, /*expectWindowFocus*/ true, /*expectViewFocus*/ true);
            verifyImeIsAlwaysHidden(view);
        }
    }

    /** Activity to help test show/hide behavior of IME. */
    public static class TestActivity extends Activity {
        private static final String TAG = "ImeStressTestUtil.TestActivity";
        private EditText mEditText;
        private boolean mIsAnimating;
        private static WeakReference<TestActivity> sLastCreatedInstance =
                new WeakReference<>(null);

        private final WindowInsetsAnimation.Callback mWindowInsetsAnimationCallback =
                new WindowInsetsAnimation.Callback(DISPATCH_MODE_STOP) {
                    @NonNull
                    @Override
                    public WindowInsetsAnimation.Bounds onStart(
                            @NonNull WindowInsetsAnimation animation,
                            @NonNull WindowInsetsAnimation.Bounds bounds) {
                        mIsAnimating = true;
                        return super.onStart(animation, bounds);
                    }

                    @Override
                    public void onEnd(@NonNull WindowInsetsAnimation animation) {
                        super.onEnd(animation);
                        mIsAnimating = false;
                    }

                    @NonNull
                    @Override
                    public WindowInsets onProgress(
                            @NonNull WindowInsets insets,
                            @NonNull List<WindowInsetsAnimation> runningAnimations) {
                        return insets;
                    }
                };

        /** Create intent with extras. */
        public static Intent createIntent(
                int windowFlags, int softInputFlags, List<String> extras) {
            Intent intent =
                    new Intent()
                            .putExtra(WINDOW_FLAGS, windowFlags)
                            .putExtra(SOFT_INPUT_FLAGS, softInputFlags);
            for (String extra : extras) {
                intent.putExtra(extra, true);
            }
            return intent;
        }

        @Override
        protected void onCreate(@Nullable Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            Log.i(TAG, "onCreate()");
            sLastCreatedInstance = new WeakReference<>(this);
            boolean isUnfocusableView = getIntent().getBooleanExtra(UNFOCUSABLE_VIEW, false);
            boolean requestFocus = getIntent().getBooleanExtra(REQUEST_FOCUS_ON_CREATE, false);
            int softInputFlags = getIntent().getIntExtra(SOFT_INPUT_FLAGS, 0);
            int windowFlags = getIntent().getIntExtra(WINDOW_FLAGS, 0);
            boolean showWithInputMethodManagerOnCreate =
                    getIntent().getBooleanExtra(INPUT_METHOD_MANAGER_SHOW_ON_CREATE, false);
            boolean hideWithInputMethodManagerOnCreate =
                    getIntent().getBooleanExtra(INPUT_METHOD_MANAGER_HIDE_ON_CREATE, false);
            boolean showWithWindowInsetsControllerOnCreate =
                    getIntent().getBooleanExtra(WINDOW_INSETS_CONTROLLER_SHOW_ON_CREATE, false);
            boolean hideWithWindowInsetsControllerOnCreate =
                    getIntent().getBooleanExtra(WINDOW_INSETS_CONTROLLER_HIDE_ON_CREATE, false);

            getWindow().addFlags(windowFlags);
            getWindow().setSoftInputMode(softInputFlags);

            LinearLayout rootView = new LinearLayout(this);
            rootView.setOrientation(LinearLayout.VERTICAL);
            mEditText = new EditText(this);
            if (isUnfocusableView) {
                mEditText.setFocusableInTouchMode(false);
            }
            rootView.addView(mEditText, new LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT));
            rootView.setFitsSystemWindows(true);
            setContentView(rootView);

            if (requestFocus) {
                requestFocus();
            }
            if (showWithInputMethodManagerOnCreate) {
                showImeWithInputMethodManager();
            }
            if (hideWithInputMethodManagerOnCreate) {
                hideImeWithInputMethodManager();
            }
            if (showWithWindowInsetsControllerOnCreate) {
                showImeWithWindowInsetsController();
            }
            if (hideWithWindowInsetsControllerOnCreate) {
                hideImeWithWindowInsetsController();
            }
        }

        /** Get the last created TestActivity instance. */
        @Nullable
        public static TestActivity getLastCreatedInstance() {
            return sLastCreatedInstance.get();
        }

        /** Show IME with InputMethodManager. */
        public boolean showImeWithInputMethodManager() {
            boolean showResult =
                    getInputMethodManager()
                            .showSoftInput(mEditText, 0 /* flags */);
            if (showResult) {
                Log.i(TAG, "IMM#showSoftInput succeeded");
            } else {
                Log.i(TAG, "IMM#showSoftInput failed");
            }
            return showResult;
        }

        /** Hide IME with InputMethodManager. */
        public boolean hideImeWithInputMethodManager() {
            boolean hideResult =
                    getInputMethodManager()
                            .hideSoftInputFromWindow(mEditText.getWindowToken(), 0 /* flags */);
            if (hideResult) {
                Log.i(TAG, "IMM#hideSoftInput succeeded");
            } else {
                Log.i(TAG, "IMM#hideSoftInput failed");
            }
            return hideResult;
        }

        /** Show IME with WindowInsetsController */
        public void showImeWithWindowInsetsController() {
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
                return;
            }
            Log.i(TAG, "showImeWithWIC()");
            WindowInsetsController windowInsetsController = mEditText.getWindowInsetsController();
            assertWithMessage("WindowInsetsController")
                    .that(windowInsetsController)
                    .isNotNull();
            windowInsetsController.show(WindowInsets.Type.ime());
        }

        /** Hide IME with WindowInsetsController. */
        public void hideImeWithWindowInsetsController() {
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
                return;
            }
            Log.i(TAG, "hideImeWithWIC()");
            WindowInsetsController windowInsetsController = mEditText.getWindowInsetsController();
            assertWithMessage("WindowInsetsController")
                    .that(windowInsetsController)
                    .isNotNull();
            windowInsetsController.hide(WindowInsets.Type.ime());
        }

        private InputMethodManager getInputMethodManager() {
            return getSystemService(InputMethodManager.class);
        }

        public EditText getEditText() {
            return mEditText;
        }

        /** Start TestActivity with intent. */
        public static TestActivity start(Intent intent) {
            Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
            intent.setAction(Intent.ACTION_MAIN)
                    .setClass(instrumentation.getContext(), TestActivity.class)
                    .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                    .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
            return (TestActivity) instrumentation.startActivitySync(intent);
        }

        /** Start the second TestActivity with intent. */
        public TestActivity startSecondTestActivity(Intent intent) {
            Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
            intent.setClass(TestActivity.this, TestActivity.class);
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            return (TestActivity) instrumentation.startActivitySync(intent);
        }

        public void enableAnimationMonitoring() {
            // Enable WindowInsetsAnimation.
            // Note that this has a side effect of disabling InsetsAnimationThreadControlRunner.
            InstrumentationRegistry.getInstrumentation()
                    .runOnMainSync(
                            () -> {
                                getWindow().setDecorFitsSystemWindows(false);
                                mEditText.setWindowInsetsAnimationCallback(
                                        mWindowInsetsAnimationCallback);
                            });
        }

        public boolean isAnimating() {
            return mIsAnimating;
        }

        public boolean requestFocus() {
            boolean requestFocusResult = mEditText.requestFocus();
            if (requestFocusResult) {
                Log.i(TAG, "View#requestFocus succeeded");
            } else {
                Log.i(TAG, "View#requestFocus failed");
            }
            return requestFocusResult;
        }
    }
}