/*
 * Copyright 2018 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 androidx.recyclerview.widget;

import static org.junit.Assert.assertEquals;

import android.graphics.Rect;
import android.os.Parcel;
import android.os.Parcelable;
import android.util.Log;

import androidx.test.filters.LargeTest;
import androidx.test.platform.app.InstrumentationRegistry;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;

@RunWith(Parameterized.class)
@LargeTest
public class LinearLayoutManagerSavedStateTest extends BaseLinearLayoutManagerTest {
    final Config mConfig;
    final boolean mWaitForLayout;
    final boolean mLoadDataAfterRestore;
    final PostLayoutRunnable mPostLayoutOperation;
    final PostRestoreRunnable mPostRestoreOperation;

    public LinearLayoutManagerSavedStateTest(Config config, boolean waitForLayout,
            boolean loadDataAfterRestore, PostLayoutRunnable postLayoutOperation,
            PostRestoreRunnable postRestoreOperation, int index) {
        mConfig = config;
        mWaitForLayout = waitForLayout;
        mLoadDataAfterRestore = loadDataAfterRestore;
        mPostLayoutOperation = postLayoutOperation;
        mPostRestoreOperation = postRestoreOperation;
        mPostLayoutOperation.mLayoutManagerDelegate = new Delegate<WrappedLinearLayoutManager>() {
            @Override
            public WrappedLinearLayoutManager get() {
                return mLayoutManager;
            }
        };
        mPostLayoutOperation.mTestAdapterDelegate = new Delegate<TestAdapter>() {
            @Override
            public TestAdapter get() {
                return mTestAdapter;
            }
        };
        mPostRestoreOperation.mLayoutManagerDelegate = new Delegate<WrappedLinearLayoutManager>() {
            @Override
            public WrappedLinearLayoutManager get() {
                return mLayoutManager;
            }
        };
        mPostRestoreOperation.mTestAdapterDelegate = new Delegate<TestAdapter>() {
            @Override
            public TestAdapter get() {
                return mTestAdapter;
            }
        };
    }

    @Parameterized.Parameters(name = "{0},waitForLayout:{1},loadDataAfterRestore:{2}"
            + ",postLayout:{3},postRestore:{4},testIndex:{5}")
    public static Iterable<Object[]> params()
            throws IllegalAccessException, CloneNotSupportedException, NoSuchFieldException {
        PostLayoutRunnable[] postLayoutOptions = new PostLayoutRunnable[]{
                new PostLayoutRunnable() {
                    @Override
                    public void run() throws Throwable {
                        // do nothing
                    }

                    @Override
                    public String describe() {
                        return "doing_nothing";
                    }
                },
                new PostLayoutRunnable() {
                    @Override
                    public void run() throws Throwable {
                        layoutManager().expectLayouts(1);
                        scrollToPosition(testAdapter().getItemCount() * 3 / 4);
                        layoutManager().waitForLayout(2);
                    }

                    @Override
                    public String describe() {
                        return "scroll_to_position";
                    }
                },
                new PostLayoutRunnable() {
                    @Override
                    public void run() throws Throwable {
                        layoutManager().expectLayouts(1);
                        scrollToPositionWithOffset(testAdapter().getItemCount() / 3,
                                50);
                        layoutManager().waitForLayout(2);
                    }

                    @Override
                    public String describe() {
                        return "scroll_to_position_with_positive_offset";
                    }
                },
                new PostLayoutRunnable() {
                    @Override
                    public void run() throws Throwable {
                        layoutManager().expectLayouts(1);
                        scrollToPositionWithOffset(testAdapter().getItemCount() * 2 / 3,
                                -10);  // Some tests break if this value is below the item height.
                        layoutManager().waitForLayout(2);
                    }

                    @Override
                    public String describe() {
                        return "scroll_to_position_with_negative_offset";
                    }
                }
        };

        PostRestoreRunnable[] postRestoreOptions = new PostRestoreRunnable[]{
                new PostRestoreRunnable() {
                    @Override
                    public String describe() {
                        return "Doing nothing";
                    }
                },
                new PostRestoreRunnable() {
                    @Override
                    void onAfterRestore(Config config) throws Throwable {
                        // update config as well so that restore assertions will work
                        config.mOrientation = 1 - config.mOrientation;
                        layoutManager().setOrientation(config.mOrientation);
                    }

                    @Override
                    boolean shouldLayoutMatch(Config config) {
                        return config.mItemCount == 0;
                    }

                    @Override
                    public String describe() {
                        return "Changing_orientation";
                    }
                },
                new PostRestoreRunnable() {
                    @Override
                    void onAfterRestore(Config config) throws Throwable {
                        config.mStackFromEnd = !config.mStackFromEnd;
                        layoutManager().setStackFromEnd(config.mStackFromEnd);
                    }

                    @Override
                    boolean shouldLayoutMatch(Config config) {
                        return true; //stack from end should not move items on change
                    }

                    @Override
                    public String describe() {
                        return "Changing_stack_from_end";
                    }
                },
                new PostRestoreRunnable() {
                    @Override
                    void onAfterRestore(Config config) throws Throwable {
                        config.mReverseLayout = !config.mReverseLayout;
                        layoutManager().setReverseLayout(config.mReverseLayout);
                    }

                    @Override
                    boolean shouldLayoutMatch(Config config) {
                        return config.mItemCount == 0;
                    }

                    @Override
                    public String describe() {
                        return "Changing_reverse_layout";
                    }
                },
                new PostRestoreRunnable() {
                    @Override
                    void onAfterRestore(Config config) throws Throwable {
                        config.mRecycleChildrenOnDetach = !config.mRecycleChildrenOnDetach;
                        layoutManager().setRecycleChildrenOnDetach(config.mRecycleChildrenOnDetach);
                    }

                    @Override
                    boolean shouldLayoutMatch(Config config) {
                        return true;
                    }

                    @Override
                    String describe() {
                        return "Change_should_recycle_children";
                    }
                },
                new PostRestoreRunnable() {
                    int position;
                    @Override
                    void onAfterRestore(Config config) throws Throwable {
                        position = testAdapter().getItemCount() / 2;
                        layoutManager().scrollToPosition(position);
                    }

                    @Override
                    boolean shouldLayoutMatch(Config config) {
                        return testAdapter().getItemCount() == 0;
                    }

                    @Override
                    String describe() {
                        return "Scroll_to_position_" + position;
                    }

                    @Override
                    void onAfterReLayout(Config config) {
                        if (testAdapter().getItemCount() > 0) {
                            assertEquals(config + ":scrolled view should be last completely visible",
                                    position,
                                    config.mStackFromEnd ?
                                            layoutManager().findLastCompletelyVisibleItemPosition()
                                            : layoutManager().findFirstCompletelyVisibleItemPosition());
                        }
                    }
                }
        };
        boolean[] waitForLayoutOptions = new boolean[]{true, false};
        boolean[] loadDataAfterRestoreOptions = new boolean[]{true, false};
        List<Config> variations = addConfigVariation(createBaseVariations(), "mItemCount", 0, 300);
        variations = addConfigVariation(variations, "mRecycleChildrenOnDetach", true);

        List<Object[]> params = new ArrayList<>();
        int index = 0;
        for (Config config : variations) {
            for (PostLayoutRunnable postLayoutRunnable : postLayoutOptions) {
                for (boolean waitForLayout : waitForLayoutOptions) {
                    for (PostRestoreRunnable postRestoreRunnable : postRestoreOptions) {
                        for (boolean loadDataAfterRestore : loadDataAfterRestoreOptions) {
                            params.add(new Object[]{
                                    config.clone(), waitForLayout,
                                    loadDataAfterRestore, postLayoutRunnable, postRestoreRunnable,
                                    index++
                            });
                        }
                    }

                }
            }
        }
        return params;
    }

    @Test
    public void savedStateTest()
            throws Throwable {
        if (DEBUG) {
            Log.d(TAG, "testing saved state with wait for layout = " + mWaitForLayout + " config " +
                    mConfig + " post layout action " + mPostLayoutOperation.describe() +
                    "post restore action " + mPostRestoreOperation.describe());
        }
        setupByConfig(mConfig, false);

        if (mWaitForLayout) {
            waitForFirstLayout();
            mPostLayoutOperation.run();
        }
        Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
        Parcelable savedState = mRecyclerView.onSaveInstanceState();
        // we append a suffix to the parcelable to test out of bounds
        String parcelSuffix = UUID.randomUUID().toString();
        Parcel parcel = Parcel.obtain();
        savedState.writeToParcel(parcel, 0);
        parcel.writeString(parcelSuffix);
        removeRecyclerView();
        // reset for reading
        parcel.setDataPosition(0);
        // re-create
        savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel);

        final int itemCount = mTestAdapter.getItemCount();
        List<Item> testItems = new ArrayList<>();
        if (mLoadDataAfterRestore) {
            // we cannot delete and re-add since new items may have different sizes. We need the
            // exact same adapter.
            testItems.addAll(mTestAdapter.mItems);
            mTestAdapter.deleteAndNotify(0, itemCount);
        }

        RecyclerView restored = new RecyclerView(getActivity());
        // this config should be no op.
        mLayoutManager = new WrappedLinearLayoutManager(getActivity(),
                mConfig.mOrientation, mConfig.mReverseLayout);
        mLayoutManager.setStackFromEnd(mConfig.mStackFromEnd);
        restored.setLayoutManager(mLayoutManager);
        // use the same adapter for Rect matching
        restored.setAdapter(mTestAdapter);
        restored.onRestoreInstanceState(savedState);

        if (mLoadDataAfterRestore) {
            // add the same items back
            mTestAdapter.resetItemsTo(testItems);
        }

        mPostRestoreOperation.onAfterRestore(mConfig);
        assertEquals("Parcel reading should not go out of bounds", parcelSuffix,
                parcel.readString());
        mLayoutManager.expectLayouts(1);
        setRecyclerView(restored);
        mLayoutManager.waitForLayout(2);
        // calculate prefix here instead of above to include post restore changes
        final String logPrefix = mConfig + "\npostLayout:" + mPostLayoutOperation.describe() +
                "\npostRestore:" + mPostRestoreOperation.describe() + "\n";
        assertEquals(logPrefix + " on saved state, reverse layout should be preserved",
                mConfig.mReverseLayout, mLayoutManager.getReverseLayout());
        assertEquals(logPrefix + " on saved state, orientation should be preserved",
                mConfig.mOrientation, mLayoutManager.getOrientation());
        assertEquals(logPrefix + " on saved state, stack from end should be preserved",
                mConfig.mStackFromEnd, mLayoutManager.getStackFromEnd());
        if (mWaitForLayout) {
            final boolean strictItemEquality = !mLoadDataAfterRestore;
            if (mPostRestoreOperation.shouldLayoutMatch(mConfig)) {
                assertRectSetsEqual(
                        logPrefix + ": on restore, previous view positions should be preserved",
                        before, mLayoutManager.collectChildCoordinates(), strictItemEquality);
            } else {
                assertRectSetsNotEqual(
                        logPrefix
                                + ": on restore with changes, previous view positions should NOT "
                                + "be preserved",
                        before, mLayoutManager.collectChildCoordinates(), strictItemEquality);
            }
            mPostRestoreOperation.onAfterReLayout(mConfig);
        }
    }

    protected static abstract class PostLayoutRunnable {
        private Delegate<WrappedLinearLayoutManager> mLayoutManagerDelegate;
        private Delegate<TestAdapter> mTestAdapterDelegate;
        protected WrappedLinearLayoutManager layoutManager() {
            return mLayoutManagerDelegate.get();
        }
        protected TestAdapter testAdapter() {
            return mTestAdapterDelegate.get();
        }

        abstract void run() throws Throwable;
        void scrollToPosition(final int position) {
            InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
                @Override
                public void run() {
                    layoutManager().scrollToPosition(position);
                }
            });
        }
        void scrollToPositionWithOffset(final int position, final int offset) {
            InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
                @Override
                public void run() {
                    layoutManager().scrollToPositionWithOffset(position, offset);
                }
            });
        }
        abstract String describe();

        @Override
        public String toString() {
            return describe();
        }
    }

    protected static abstract class PostRestoreRunnable {
        private Delegate<WrappedLinearLayoutManager> mLayoutManagerDelegate;
        private Delegate<TestAdapter> mTestAdapterDelegate;
        protected WrappedLinearLayoutManager layoutManager() {
            return mLayoutManagerDelegate.get();
        }
        protected TestAdapter testAdapter() {
            return mTestAdapterDelegate.get();
        }

        void onAfterRestore(Config config) throws Throwable {
        }

        abstract String describe();

        boolean shouldLayoutMatch(Config config) {
            return true;
        }

        void onAfterReLayout(Config config) {

        };

        @Override
        public String toString() {
            return describe();
        }
    }

    private interface Delegate<T> {
        T get();
    }
}
