/*
 * 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 static org.junit.Assert.assertTrue;

import static java.util.concurrent.TimeUnit.SECONDS;

import android.content.Context;
import android.support.test.filters.MediumTest;
import android.support.test.filters.Suppress;
import android.support.test.runner.AndroidJUnit4;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import androidx.annotation.NonNull;

import org.hamcrest.CoreMatchers;
import org.hamcrest.MatcherAssert;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.util.BitSet;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

@MediumTest
@RunWith(AndroidJUnit4.class)
public class AsyncListUtilLayoutTest extends BaseRecyclerViewInstrumentationTest {

    private static final boolean DEBUG = false;

    private static final String TAG = "AsyncListUtilLayoutTest";

    private static final int ITEM_COUNT = 1000;
    private static final int TILE_SIZE = 5;

    AsyncTestAdapter mAdapter;

    WrappedLinearLayoutManager mLayoutManager;

    private TestDataCallback mDataCallback;
    private TestViewCallback mViewCallback;
    private AsyncListUtil<String> mAsyncListUtil;

    public int mStartPrefetch = 0;
    public int mEndPrefetch = 0;

    // Test is disabled as it is flaky.
    @Suppress
    @Test
    public void asyncListUtil() throws Throwable {
        mRecyclerView = inflateWrappedRV();
        mRecyclerView.setHasFixedSize(true);

        mAdapter = new AsyncTestAdapter();
        mRecyclerView.setAdapter(mAdapter);

        mLayoutManager = new WrappedLinearLayoutManager(
                getActivity(), LinearLayoutManager.VERTICAL, false);
        mRecyclerView.setLayoutManager(mLayoutManager);

        mLayoutManager.expectLayouts(1);
        setRecyclerView(mRecyclerView);
        mLayoutManager.waitForLayout(2);

        int rangeStart = 0;
        assertEquals(rangeStart, mLayoutManager.findFirstVisibleItemPosition());

        final int rangeSize = mLayoutManager.findLastVisibleItemPosition() + 1;
        assertTrue("No visible items", rangeSize > 0);

        assertEquals("All visible items must be empty at first",
                rangeSize, getEmptyVisibleChildCount());

        mDataCallback = new TestDataCallback();
        mViewCallback = new TestViewCallback();

        mDataCallback.expectTilesInRange(rangeStart, rangeSize);
        mAdapter.expectItemsInRange(rangeStart, rangeSize);

        mActivityRule.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                mAsyncListUtil = new AsyncListUtil<>(
                        String.class, TILE_SIZE, mDataCallback, mViewCallback);
            }
        });

        mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
            @Override
            public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                mAsyncListUtil.onRangeChanged();
            }
        });
        assertAllLoaded("First load");

        rangeStart = roundUp(rangeSize);
        scrollAndAssert("Scroll with no prefetch", rangeStart, rangeSize);

        rangeStart = roundUp(rangeStart + rangeSize);
        mEndPrefetch = TILE_SIZE * 2;
        scrollAndAssert("Scroll with prefetch", rangeStart, rangeSize);

        rangeStart += mEndPrefetch;
        mEndPrefetch = 0;
        scrollAndAssert("Scroll a little down, no prefetch", rangeStart, 0);

        rangeStart = ITEM_COUNT / 2;
        mStartPrefetch = TILE_SIZE * 2;
        mEndPrefetch = TILE_SIZE * 3;
        scrollAndAssert("Scroll to middle, prefetch", rangeStart, rangeSize);

        rangeStart -= mStartPrefetch;
        mStartPrefetch = 0;
        mEndPrefetch = 0;
        scrollAndAssert("Scroll a little up, no prefetch", rangeStart, 0);

        Thread.sleep(500);  // Wait for possible spurious messages.
    }

    private void assertAllLoaded(String context) throws InterruptedException {
        assertTrue(context + ", timed out while waiting for items", mAdapter.waitForItems(10));
        assertTrue(context + ", timed out while waiting for tiles", mDataCallback.waitForTiles(10));
        assertEquals(context + ", empty child found", 0, getEmptyVisibleChildCount());
    }

    private void scrollAndAssert(String context, int rangeStart, int rangeSize) throws Throwable {
        if (rangeSize > 0) {
            mDataCallback.expectTilesInRange(rangeStart, rangeSize);
        } else {
            mDataCallback.expectNoNewTilesLoaded();
        }
        mAdapter.expectItemsInRange(rangeStart, rangeSize);
        mLayoutManager.expectLayouts(1);
        scrollToPositionWithOffset(rangeStart, 0);
        mLayoutManager.waitForLayout(1);
        assertAllLoaded(context);
    }

    void scrollToPositionWithOffset(final int position, final int offset) throws Throwable {
        mActivityRule.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                mLayoutManager.scrollToPositionWithOffset(position, offset);
            }
        });
    }

    private int roundUp(int value) {
        return value - value % TILE_SIZE + TILE_SIZE;
    }

    private int getTileCount(int start, int size) {
        return ((start + size - 1) / TILE_SIZE) - (start / TILE_SIZE) + 1;
    }

    private int getEmptyVisibleChildCount() {
        int emptyChildCount = 0;
        int firstVisible = mLayoutManager.findFirstVisibleItemPosition();
        int lastVisible = mLayoutManager.findLastVisibleItemPosition();
        for (int i = firstVisible; i <= lastVisible; i++) {
            View child = mLayoutManager.findViewByPosition(i);
            assertTrue(child instanceof TextView);
            if (((TextView) child).getText() == "") {
                emptyChildCount++;
            }
        }
        return emptyChildCount;
    }

    private class TestDataCallback extends AsyncListUtil.DataCallback<String> {

        private CountDownLatch mTilesLatch;

        @Override
        public void fillData(String[] data, int startPosition, int itemCount) {
            assertTrue("Unexpected tile load @" + startPosition, mTilesLatch.getCount() > 0);
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
            }
            for (int i = 0; i < itemCount; i++) {
                data[i] = "Item #" + (startPosition + i);
            }
            mTilesLatch.countDown();
        }

        @Override
        public int refreshData() {
            return ITEM_COUNT;
        }

        private void expectTiles(int count) {
            mTilesLatch = new CountDownLatch(count);
        }

        public void expectTilesInRange(int rangeStart, int rangeSize) {
            expectTiles(getTileCount(rangeStart - mStartPrefetch,
                    rangeSize + mStartPrefetch + mEndPrefetch));
        }

        public void expectNoNewTilesLoaded() {
            expectTiles(0);
        }

        public boolean waitForTiles(long timeoutInSeconds) throws InterruptedException {
            return mTilesLatch.await(timeoutInSeconds, TimeUnit.SECONDS);
        }
    }

    private class TestViewCallback extends AsyncListUtil.ViewCallback {
        @Override
        public void getItemRangeInto(int[] outRange) {
            outRange[0] = mLayoutManager.findFirstVisibleItemPosition();
            outRange[1] = mLayoutManager.findLastVisibleItemPosition();
        }

        @Override
        public void extendRangeInto(int[] range, int[] outRange, int scrollHint) {
            outRange[0] = range[0] - mStartPrefetch;
            outRange[1] = range[1] + mEndPrefetch;
        }

        @Override
        public void onDataRefresh() {
            mRecyclerView.getAdapter().notifyDataSetChanged();
        }

        @Override
        public void onItemLoaded(int position) {
            mRecyclerView.getAdapter().notifyItemChanged(position);
        }
    }

    private static class SimpleViewHolder extends RecyclerView.ViewHolder {

        public SimpleViewHolder(Context context) {
            super(new TextView(context));
        }
    }

    private class AsyncTestAdapter extends RecyclerView.Adapter<SimpleViewHolder> {

        private BitSet mLoadedPositions;
        private BitSet mExpectedPositions;

        private CountDownLatch mItemsLatch;
        public AsyncTestAdapter() {
            mLoadedPositions = new BitSet(ITEM_COUNT);
        }

        @Override
        public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
            return new SimpleViewHolder(parent.getContext());
        }

        @Override
        public void onBindViewHolder(@NonNull SimpleViewHolder holder, int position) {
            final String item = mAsyncListUtil == null ? null : mAsyncListUtil.getItem(position);
            ((TextView) (holder.itemView)).setText(item == null ? "" : item);

            if (item != null) {
                mLoadedPositions.set(position);
                if (mExpectedPositions.get(position)) {
                    mExpectedPositions.clear(position);
                    if (mExpectedPositions.cardinality() == 0) {
                        mItemsLatch.countDown();
                    }
                }
            }
        }

        @Override
        public int getItemCount() {
            return ITEM_COUNT;
        }

        private void expectItemsInRange(int rangeStart, int rangeSize) {
            mExpectedPositions = new BitSet(rangeStart + rangeSize);
            for (int i = 0; i < rangeSize; i++) {
                if (!mLoadedPositions.get(rangeStart + i)) {
                    mExpectedPositions.set(rangeStart + i);
                }
            }
            mItemsLatch = new CountDownLatch(1);
            if (mExpectedPositions.cardinality() == 0) {
                mItemsLatch.countDown();
            }
        }

        public boolean waitForItems(long timeoutInSeconds) throws InterruptedException {
            return mItemsLatch.await(timeoutInSeconds, TimeUnit.SECONDS);
        }
    }

    class WrappedLinearLayoutManager extends LinearLayoutManager {

        CountDownLatch mLayoutLatch;

        public WrappedLinearLayoutManager(Context context, int orientation, boolean reverseLayout) {
            super(context, orientation, reverseLayout);
        }

        public void expectLayouts(int count) {
            mLayoutLatch = new CountDownLatch(count);
        }

        public void waitForLayout(int seconds) throws Throwable {
            mLayoutLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS);
            checkForMainThreadException();
            MatcherAssert.assertThat("all layouts should complete on time",
                    mLayoutLatch.getCount(), CoreMatchers.is(0L));
            // use a runnable to ensure RV layout is finished
            getInstrumentation().runOnMainSync(new Runnable() {
                @Override
                public void run() {
                }
            });
        }

        @Override
        public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
            try {
                super.onLayoutChildren(recycler, state);
            } catch (Throwable t) {
                postExceptionToInstrumentation(t);
            }
            mLayoutLatch.countDown();
        }
    }
}
