// Copyright 2004-present Facebook. All Rights Reserved.

package com.facebook.react.views.recyclerview;

import java.util.ArrayList;
import java.util.List;

import android.content.Context;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;

import com.facebook.infer.annotation.Assertions;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.common.SystemClock;
import com.facebook.react.common.annotations.VisibleForTesting;
import com.facebook.react.uimanager.UIManagerModule;
import com.facebook.react.uimanager.events.NativeGestureUtil;
import com.facebook.react.views.scroll.ScrollEvent;
import com.facebook.react.views.scroll.ScrollEventType;

/**
 * Wraps {@link RecyclerView} providing interface similar to `ScrollView.js` where each children
 * will be rendered as a separate {@link RecyclerView} row.
 *
 * Currently supports only vertically positioned item. Views will not be automatically recycled but
 * they will be detached from native view hierarchy when scrolled offscreen.
 *
 * It works by storing all child views in an array within adapter and binding appropriate views to
 * rows when requested.
 */
@VisibleForTesting
public class RecyclerViewBackedScrollView extends RecyclerView {

  /**
   * Simple implementation of {@link ViewHolder} as it's an abstract class. The only thing we need
   * to hold in this implementation is the reference to {@link RecyclableWrapperViewGroup} that
   * is already stored by default.
   */
  private static class ConcreteViewHolder extends ViewHolder {
    public ConcreteViewHolder(View itemView) {
      super(itemView);
    }
  }

  /**
   * View that is going to be used as a cell in {@link RecyclerView}. It's going to be reusable and
   * we will remove/attach views for a certain positions based on the {@code mViews} array stored
   * in the adapter class.
   *
   * This method overrides {@link #onMeasure} and delegates measurements to the child view that has
   * been attached to. This is because instances of {@link RecyclableWrapperViewGroup} are created
   * outside of {@link NativeViewHierarchyManager} and their layout is not managed by that manager
   * as opposed to all the other react-native views. Instead we use dimensions of the child view
   * (dimensions has been set in layouting process) so that size of this view match the size of
   * the view it wraps.
   */
  private static class RecyclableWrapperViewGroup extends ViewGroup {

    public RecyclableWrapperViewGroup(Context context) {
      super(context);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
      // This view will only have one child that is managed by the `NativeViewHierarchyManager` and
      // its position and dimensions are set separately. We don't need to handle its layouting here
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
      if (getChildCount() > 0) {
        // We override measure spec and use dimensions of the children. Children is a view added
        // from the adapter and always have a correct dimensions specified as they are calculated
        // and set with NativeViewHierarchyManager
        View child = getChildAt(0);
        setMeasuredDimension(child.getMeasuredWidth(), child.getMeasuredHeight());
      } else {
        Assertions.assertUnreachable("RecyclableWrapperView measured but no view attached");
      }
    }
  }

  /**
   * JavaScript ListView implementation rely on getting correct scroll offset. This class helps
   * with calculating that "real" offset of items in recycler view as those are not provided by
   * android widget implementation ({@link #onScrollChanged} is called with offset 0). We can't use
   * onScrolled either as we need to take into account that if height of element that is not above
   * the visible window changes the real scroll offset will change too, but onScrolled will only
   * give us scroll deltas that comes from the user interaction.
   *
   * This class helps in calculating "real" offset of row at specified index. It's used from
   * {@link #onScrollChanged} to query for the first visible index. Since while scrolling the
   * queried index will usually increment or decrement by one it's optimize to return result in
   * that common case very quickly.
   */
  private static class ScrollOffsetTracker {

    private final ReactListAdapter mReactListAdapter;

    private int mLastRequestedPosition;
    private int mOffsetForLastPosition;

    private ScrollOffsetTracker(ReactListAdapter reactListAdapter) {
      mReactListAdapter = reactListAdapter;
    }

    public void onHeightChange(int index, int oldHeight, int newHeight) {
      if (index < mLastRequestedPosition) {
        mOffsetForLastPosition = (mOffsetForLastPosition - oldHeight + newHeight);
      }
    }

    public int getTopOffsetForItem(int index) {
      // This method is frequently called from the "onScroll" handler of the "RecyclerView" with an
      // index of first visible item of the view. Implementation of this method takes advantage of
      // that fact by caching the value for the last index that this method has been called with.
      //
      // There are a 2 cases that we optimize for:
      // 1) The visible item doesn't change between subsequent "onScroll" calls, in that case we
      //    don't need to calculate anything, just return the cached value
      // 2) The next visible item will be the one that is adjacent to the item that we store the
      //    cached value for: index + 1 when scrolling down or index - 1 when scrolling up. Then it
      //    is sufficient to add/subtract height of item at the "last index"
      //
      // The implementation accounts for the cases when next index is not necessarily a subsequent
      // number of the cached one. In which case we try to minimize the number of rows we will loop
      // through.
      if (mLastRequestedPosition != index) {
        int sum;

        if (mLastRequestedPosition < index) {
          // This can either happen when we're scrolling down or if the cached value has never been
          // calculated
          int startIndex;

          if (mLastRequestedPosition != -1) {
            // We already have the value cached, let's use it and only add heights of the items
            // starting at the index we have the cached value for
            sum = mOffsetForLastPosition;
            startIndex = mLastRequestedPosition;
          } else {
            sum = 0;
            startIndex = 0;
          }

          for (int i = startIndex; i < index; i++) {
            sum += mReactListAdapter.mViews.get(i).getMeasuredHeight();
          }
        } else {
          // We are scrolling up, we can either use cached value and subtract heights of rows
          // between mLastRequestPosition and index, or we can calculate the height starting from 0
          // (this can be quite a frequent case as well, when the list implements "jump to the top"
          // action). We just go for the option that require less calculations
          if (index < (mLastRequestedPosition - index)) {
            // index is relatively small, it's faster to calculate the sum starting from 0
            sum = 0;
            for (int i = 0; i < index; i++) {
              sum += mReactListAdapter.mViews.get(i).getMeasuredHeight();
            }
          } else {
            // index is "closer" to the last cached index than it is to 0. We can reuse cached sum
            // and calculate the new sum by subtracting heights of the elements between
            // "mLastRequestPosition" and "index"
            sum = mOffsetForLastPosition;
            for (int i = mLastRequestedPosition - 1; i >= index; i--) {
              sum -= mReactListAdapter.mViews.get(i).getMeasuredHeight();
            }
          }
        }
        mLastRequestedPosition = index;
        mOffsetForLastPosition = sum;
      }
      return mOffsetForLastPosition;
    }

  }

  /*package*/ static class ReactListAdapter extends Adapter<ConcreteViewHolder> {

    private final List<View> mViews = new ArrayList<>();
    private final ScrollOffsetTracker mScrollOffsetTracker;
    private final RecyclerViewBackedScrollView mScrollView;
    private int mTotalChildrenHeight = 0;

    // The following `OnLayoutChangeListsner` is attached to the views stored in the adapter
    // `mViews` array. It's used to get layout information passed to that view from css-layout
    // and to update its layout to be enclosed in the wrapper view group.
    private final View.OnLayoutChangeListener
        mChildLayoutChangeListener = new View.OnLayoutChangeListener() {

      @Override
      public void onLayoutChange(
          View v,
          int left,
          int top,
          int right,
          int bottom,
          int oldLeft,
          int oldTop,
          int oldRight,
          int oldBottom) {
        // We need to get layout information from css-layout to set the size of the rows correctly.

        int oldHeight = (oldBottom - oldTop);
        int newHeight = (bottom - top);

        if (oldHeight != newHeight) {
          updateTotalChildrenHeight(newHeight - oldHeight);
          mScrollOffsetTracker.onHeightChange(mViews.indexOf(v), oldHeight, newHeight);

          // Since "wrapper" view position +dimensions are not managed by NativeViewHierarchyManager
          // we need to ensure that the wrapper view is properly layed out as it dimension should
          // be updated if the wrapped view dimensions are changed.
          // To achieve that we call `forceLayout()` on the view modified and on `RecyclerView`
          // instance (which is accessible with `v.getParent().getParent()` if the view is
          // attached). We rely on NativeViewHierarchyManager to call `layout` on `RecyclerView`
          // then, which will happen once all the children of `RecyclerView` have their layout
          // updated. This will trigger `layout` call on attached wrapper nodes and will let us
          // update dimensions of them through overridden onMeasure method.
          // We don't care about calling this is the view is not currently attached as it would be
          // laid out once added to the recycler.
          if (v.getParent() != null
              && v.getParent().getParent() != null) {
            View wrapper = (View) v.getParent(); // native view that wraps view added to adapter
            wrapper.forceLayout();
            // wrapper.getParent() points to the recycler if the view is currently attached (it
            // could be in "scrape" state when it is attached to recyclable wrapper but not to
            // the recycler)
            ((View) wrapper.getParent()).forceLayout();
          }
        }
      }
    };

    public ReactListAdapter(RecyclerViewBackedScrollView scrollView) {
      mScrollView = scrollView;
      mScrollOffsetTracker = new ScrollOffsetTracker(this);
      setHasStableIds(true);
    }

    public void addView(View child, int index) {
      mViews.add(index, child);

      updateTotalChildrenHeight(child.getMeasuredHeight());
      child.addOnLayoutChangeListener(mChildLayoutChangeListener);

      notifyItemInserted(index);
    }

    public void removeViewAt(int index) {
      View child = mViews.get(index);
      if (child != null) {
        mViews.remove(index);
        child.removeOnLayoutChangeListener(mChildLayoutChangeListener);
        updateTotalChildrenHeight(-child.getMeasuredHeight());

        notifyItemRemoved(index);
      }
    }

    private void updateTotalChildrenHeight(int delta) {
      if (delta != 0) {
        mTotalChildrenHeight += delta;
        mScrollView.onTotalChildrenHeightChange(mTotalChildrenHeight);
      }
    }

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

    @Override
    public void onBindViewHolder(ConcreteViewHolder holder, int position) {
      RecyclableWrapperViewGroup vg = (RecyclableWrapperViewGroup) holder.itemView;
      View row = mViews.get(position);
      if (row.getParent() != vg) {
        vg.addView(row, 0);
      }
    }

    @Override
    public void onViewRecycled(ConcreteViewHolder holder) {
      super.onViewRecycled(holder);
      ((RecyclableWrapperViewGroup) holder.itemView).removeAllViews();
    }

    @Override
    public int getItemCount() {
      return mViews.size();
    }

    @Override
    public long getItemId(int position) {
      return mViews.get(position).getId();
    }

    public View getView(int index) {
      return mViews.get(index);
    }

    public int getTotalChildrenHeight() {
      return mTotalChildrenHeight;
    }

    public int getTopOffsetForItem(int index) {
      return mScrollOffsetTracker.getTopOffsetForItem(index);
    }
  }

  private boolean mSendContentSizeChangeEvents;

  public void setSendContentSizeChangeEvents(boolean sendContentSizeChangeEvents) {
    mSendContentSizeChangeEvents = sendContentSizeChangeEvents;
  }

  private int calculateAbsoluteOffset() {
    int offsetY = 0;
    if (getChildCount() > 0) {
      View recyclerViewChild = getChildAt(0);
      int childPosition = getChildViewHolder(recyclerViewChild).getLayoutPosition();
      offsetY = ((ReactListAdapter) getAdapter()).getTopOffsetForItem(childPosition) -
          recyclerViewChild.getTop();
    }
    return offsetY;
  }

  /*package*/ void scrollTo(int scrollX, int scrollY, boolean animated) {
    int deltaY = scrollY - calculateAbsoluteOffset();
    if (animated) {
      smoothScrollBy(0, deltaY);
    } else {
      scrollBy(0, deltaY);
    }
  }

  @Override
  protected void onScrollChanged(int l, int t, int oldl, int oldt) {
    super.onScrollChanged(l, t, oldl, oldt);

    ((ReactContext) getContext()).getNativeModule(UIManagerModule.class).getEventDispatcher()
        .dispatchEvent(ScrollEvent.obtain(
                getId(),
                SystemClock.nanoTime(),
                ScrollEventType.SCROLL,
                0, /* offsetX = 0, horizontal scrolling only */
                calculateAbsoluteOffset(),
                getWidth(),
                ((ReactListAdapter) getAdapter()).getTotalChildrenHeight(),
                getWidth(),
                getHeight()));
  }

  private void onTotalChildrenHeightChange(int newTotalChildrenHeight) {
    if (mSendContentSizeChangeEvents) {
      ((ReactContext) getContext()).getNativeModule(UIManagerModule.class).getEventDispatcher()
          .dispatchEvent(new ContentSizeChangeEvent(
                  getId(),
                  SystemClock.nanoTime(),
                  getWidth(),
                  newTotalChildrenHeight));
    }
  }

  public RecyclerViewBackedScrollView(Context context) {
    super(context);
    setHasFixedSize(true);
    setItemAnimator(new NotAnimatedItemAnimator());
    setLayoutManager(new LinearLayoutManager(context));
    setAdapter(new ReactListAdapter(this));
  }

  /*package*/ void addViewToAdapter(View child, int index) {
    ((ReactListAdapter) getAdapter()).addView(child, index);
  }

  /*package*/ void removeViewFromAdapter(int index) {
    ((ReactListAdapter) getAdapter()).removeViewAt(index);
  }

  /*package*/ View getChildAtFromAdapter(int index) {
    return ((ReactListAdapter) getAdapter()).getView(index);
  }

  /*package*/ int getChildCountFromAdapter() {
    return getAdapter().getItemCount();
  }

  @Override
  public boolean onInterceptTouchEvent(MotionEvent ev) {
    if (super.onInterceptTouchEvent(ev)) {
      NativeGestureUtil.notifyNativeGestureStarted(this, ev);
      return true;
    }
    return false;
  }
}
