import { forEachObjIndexed } from "ramda";
import * as React from "react";
import {
  Animated,
  ScrollView,
  View,
  ViewStyle,
  LayoutChangeEvent,
  NativeScrollEvent,
} from "react-native";

type HeaderHeight = number | (() => number);
type FooterHeight = number | (() => number);
type SectionHeight = number | ((section: number) => number);
type RowHeight = number | ((section: number, row?: number) => number);
type SectionFooterHeight = number | ((section: number) => number);

enum FastListItemType {
  SPACER = "spacer",
  HEADER = "header",
  SECTION = "section",
  ROW = "row",
  FOOTER = "footer",
  SECTION_FOOTER = "footer",
}

type FastListItem = {
  type: FastListItemType;
  key: number;
  layoutY: number;
  layoutHeight: number;
  section: number;
  row: number;
};

interface ScrollEvent {
  nativeEvent: NativeScrollEvent;
}

interface FastListComputerProps {
  headerHeight: HeaderHeight;
  footerHeight: FooterHeight;
  sectionHeight: SectionHeight;
  rowHeight: RowHeight;
  sectionFooterHeight: SectionFooterHeight;
  sections: number[];
  insetTop: number;
  insetBottom: number;
}

export class FastListComputer {
  headerHeight: HeaderHeight;
  footerHeight: FooterHeight;
  sectionHeight: SectionHeight;
  rowHeight: RowHeight;
  sectionFooterHeight: SectionFooterHeight;
  sections: number[];
  insetTop: number;
  insetBottom: number;
  uniform: boolean;

  constructor({
    headerHeight,
    footerHeight,
    sectionHeight,
    rowHeight,
    sectionFooterHeight,
    sections,
    insetTop,
    insetBottom,
  }: FastListComputerProps) {
    this.headerHeight = headerHeight;
    this.footerHeight = footerHeight;
    this.sectionHeight = sectionHeight;
    this.rowHeight = rowHeight;
    this.sectionFooterHeight = sectionFooterHeight;
    this.sections = sections;
    this.insetTop = insetTop;
    this.insetBottom = insetBottom;
    this.uniform = typeof rowHeight === "number";
  }

  getHeightForHeader(): number {
    const { headerHeight } = this;
    return typeof headerHeight === "number" ? headerHeight : headerHeight();
  }

  getHeightForFooter(): number {
    const { footerHeight } = this;
    return typeof footerHeight === "number" ? footerHeight : footerHeight();
  }

  getHeightForSection(section: number): number {
    const { sectionHeight } = this;
    return typeof sectionHeight === "number"
      ? sectionHeight
      : sectionHeight(section);
  }

  getHeightForRow(section: number, row?: number): number {
    const { rowHeight } = this;
    return typeof rowHeight === "number" ? rowHeight : rowHeight(section, row);
  }

  getHeightForSectionFooter(section: number): number {
    const { sectionFooterHeight } = this;
    return typeof sectionFooterHeight === "number"
      ? sectionFooterHeight
      : sectionFooterHeight(section);
  }

  compute(
    top: number,
    bottom: number,
    prevItems: FastListItem[]
  ): {
    height: number;
    items: FastListItem[];
  } {
    const { sections } = this;

    let height = this.insetTop;
    let spacerHeight = height;
    let items = [] as FastListItem[];

    const recycler = new FastListItemRecycler(prevItems);

    function isVisible(itemHeight: number): boolean {
      const prevHeight = height;
      height += itemHeight;
      if (height < top || prevHeight > bottom) {
        spacerHeight += itemHeight;
        return false;
      } else {
        return true;
      }
    }

    function isBelowVisibility(itemHeight: number): boolean {
      if (height > bottom) {
        spacerHeight += itemHeight;
        return false;
      } else {
        return true;
      }
    }

    function push(item: FastListItem) {
      if (spacerHeight > 0) {
        items.push(
          recycler.get(
            FastListItemType.SPACER,
            item.layoutY - spacerHeight,
            spacerHeight,
            item.section,
            item.row
          )
        );
        spacerHeight = 0;
      }

      items.push(item);
    }

    let layoutY;

    const headerHeight = this.getHeightForHeader();
    if (headerHeight > 0) {
      layoutY = height;
      if (isVisible(headerHeight)) {
        push(recycler.get(FastListItemType.HEADER, layoutY, headerHeight));
      }
    }

    for (let section = 0; section < sections.length; section++) {
      const rows = sections[section];

      if (rows === 0) {
        continue;
      }

      const sectionHeight = this.getHeightForSection(section);
      layoutY = height;
      height += sectionHeight;

      // Replace previous spacers and sections, so we only render section headers
      // whose children are visible + previous section (required for sticky header animation).
      if (
        section > 1 &&
        items.length > 0 &&
        items[items.length - 1].type === FastListItemType.SECTION
      ) {
        const spacerLayoutHeight = items.reduce((totalHeight, item, i) => {
          if (i !== items.length - 1) {
            return totalHeight + item.layoutHeight;
          }
          return totalHeight;
        }, 0);
        const prevSection = items[items.length - 1];
        const spacer = recycler.get(
          FastListItemType.SPACER,
          0,
          spacerLayoutHeight,
          prevSection.section,
          0
        );
        items = [spacer, prevSection];
      }

      if (isBelowVisibility(sectionHeight)) {
        push(
          recycler.get(
            FastListItemType.SECTION,
            layoutY,
            sectionHeight,
            section
          )
        );
      }

      if (this.uniform) {
        const rowHeight = this.getHeightForRow(section);
        for (let row = 0; row < rows; row++) {
          layoutY = height;
          if (isVisible(rowHeight)) {
            push(
              recycler.get(
                FastListItemType.ROW,
                layoutY,
                rowHeight,
                section,
                row
              )
            );
          }
        }
      } else {
        for (let row = 0; row < rows; row++) {
          const rowHeight = this.getHeightForRow(section, row);
          layoutY = height;
          if (isVisible(rowHeight)) {
            push(
              recycler.get(
                FastListItemType.ROW,
                layoutY,
                rowHeight,
                section,
                row
              )
            );
          }
        }
      }

      const sectionFooterHeight = this.getHeightForSectionFooter(section);
      if (sectionFooterHeight > 0) {
        layoutY = height;
        if (isVisible(sectionFooterHeight)) {
          push(
            recycler.get(
              FastListItemType.SECTION_FOOTER,
              layoutY,
              sectionFooterHeight,
              section
            )
          );
        }
      }
    }

    const footerHeight = this.getHeightForFooter();
    if (footerHeight > 0) {
      layoutY = height;
      if (isVisible(footerHeight)) {
        push(recycler.get(FastListItemType.FOOTER, layoutY, footerHeight));
      }
    }

    height += this.insetBottom;
    spacerHeight += this.insetBottom;

    if (spacerHeight > 0) {
      items.push(
        recycler.get(
          FastListItemType.SPACER,
          height - spacerHeight,
          spacerHeight,
          sections.length
        )
      );
    }

    recycler.fill();

    return {
      height,
      items,
    };
  }

  computeScrollPosition(
    targetSection: number,
    targetRow: number
  ): {
    scrollTop: number;
    sectionHeight: number;
  } {
    const { sections, insetTop } = this;
    let scrollTop = insetTop + this.getHeightForHeader();
    let section = 0;
    let foundRow = false;

    while (section <= targetSection) {
      const rows = sections[section];
      if (rows === 0) {
        section += 1;
        continue;
      }
      scrollTop += this.getHeightForSection(section);
      if (this.uniform) {
        const uniformHeight = this.getHeightForRow(section);
        if (section === targetSection) {
          scrollTop += uniformHeight * targetRow;
          foundRow = true;
        } else {
          scrollTop += uniformHeight * rows;
        }
      } else {
        for (let row = 0; row < rows; row++) {
          if (
            section < targetSection ||
            (section === targetSection && row < targetRow)
          ) {
            scrollTop += this.getHeightForRow(section, row);
          } else if (section === targetSection && row === targetRow) {
            foundRow = true;
            break;
          }
        }
      }
      if (!foundRow) {
        scrollTop += this.getHeightForSectionFooter(section);
      }
      section += 1;
    }

    return {
      scrollTop,
      sectionHeight: this.getHeightForSection(targetSection),
    };
  }
}

/**
 * FastListItemRecycler is used to recycle FastListItem objects between
 * recomputations of the list. By doing this we ensure that components
 * maintain their keys and avoid reallocations.
 */
export class FastListItemRecycler {
  static _LAST_KEY: number = 0;

  items: Partial<
    {
      [key in FastListItemType]: Partial<{
        [key: string]: FastListItem;
      }>;
    }
  > = {};
  pendingItems: Partial<{
    [key: string]: FastListItem[];
  }> = {};

  constructor(items: FastListItem[]) {
    items.forEach((item) => {
      const { type, section, row } = item;
      const [itemsForType] = this.itemsForType(type);
      itemsForType[`${type}:${section}:${row}`] = item;
    });
  }

  itemsForType(
    type: FastListItemType
  ): [
    {
      [key: string]: FastListItem;
    },
    FastListItem[]
  ] {
    return [
      this.items[type] || (this.items[type] = {}),
      this.pendingItems[type] || (this.pendingItems[type] = []),
    ];
  }

  get(
    type: FastListItemType,
    layoutY: number,
    layoutHeight: number,
    section: number = 0,
    row: number = 0
  ): FastListItem {
    const [items, pendingItems] = this.itemsForType(type);
    return this._get(
      type,
      layoutY,
      layoutHeight,
      section,
      row,
      items,
      pendingItems
    );
  }

  _get(
    type: FastListItemType,
    layoutY: number,
    layoutHeight: number,
    section: number,
    row: number,
    items: {
      [key: string]: FastListItem;
    },
    pendingItems: FastListItem[]
  ) {
    const itemKey = `${type}:${section}:${row}`;
    let item = items[itemKey];
    if (item == null) {
      item = { type, key: -1, layoutY, layoutHeight, section, row };
      pendingItems.push(item);
    } else {
      item.layoutY = layoutY;
      item.layoutHeight = layoutHeight;
      delete items[itemKey];
    }
    return item;
  }

  fill() {
    forEachObjIndexed((type) => {
      const [items, pendingItems] = this.itemsForType(type);
      this._fill(items, pendingItems);
    }, FastListItemType);
  }

  _fill(
    items: {
      [key: string]: FastListItem;
    },
    pendingItems: FastListItem[]
  ) {
    let index = 0;

    forEachObjIndexed(({ key }) => {
      const item = pendingItems[index];
      if (item == null) {
        return false;
      }
      item.key = key;
      index++;
    }, items);

    for (; index < pendingItems.length; index++) {
      pendingItems[index].key = ++FastListItemRecycler._LAST_KEY;
    }

    pendingItems.length = 0;
  }
}

const FastListSectionRenderer = ({
  layoutY,
  layoutHeight,
  nextSectionLayoutY,
  scrollTopValue,
  children,
}: {
  layoutY: number;
  layoutHeight: number;
  nextSectionLayoutY?: number;
  scrollTopValue: Animated.Value;
  children: React.ReactElement<{ style?: ViewStyle }>;
}) => {
  const inputRange: number[] = [-1, 0];
  const outputRange: number[] = [0, 0];

  inputRange.push(layoutY);
  outputRange.push(0);
  const collisionPoint = (nextSectionLayoutY || 0) - layoutHeight;
  if (collisionPoint >= layoutY) {
    inputRange.push(collisionPoint, collisionPoint + 1);
    outputRange.push(collisionPoint - layoutY, collisionPoint - layoutY);
  } else {
    inputRange.push(layoutY + 1);
    outputRange.push(1);
  }

  const translateY = scrollTopValue.interpolate({
    inputRange,
    outputRange,
  });

  const child = React.Children.only(children);

  return (
    <Animated.View
      style={[
        React.isValidElement(child) && child.props.style
          ? child.props.style
          : undefined,
        {
          zIndex: 10,
          height: layoutHeight,
          transform: [{ translateY }],
        },
      ]}
    >
      {React.isValidElement(child) &&
        React.cloneElement(child, {
          style: { flex: 1 },
        })}
    </Animated.View>
  );
};

const FastListItemRenderer = ({
  layoutHeight: height,
  children,
}: {
  layoutHeight: number;
  children?: React.ReactNode;
}) => <View style={{ height }}>{children}</View>;

export interface FastListProps {
  renderActionSheetScrollViewWrapper?: (
    wrapper: React.ReactNode
  ) => React.ReactNode;
  actionSheetScrollRef?: { current: React.ReactNode | null | undefined };
  onScroll?: (event: ScrollEvent) => any;
  onScrollEnd?: (event: ScrollEvent) => any;
  onLayout?: (event: LayoutChangeEvent) => any;
  renderHeader: () => React.ReactElement<any> | null | undefined;
  renderFooter: () => React.ReactElement<any> | null | undefined;
  renderSection: (
    section: number
  ) => React.ReactElement<any> | null | undefined;
  renderRow: (
    section: number,
    row: number
  ) => React.ReactElement<any> | null | undefined;
  renderSectionFooter: (
    section: number
  ) => React.ReactElement<any> | null | undefined;
  renderAccessory?: (list: FastList) => React.ReactNode;
  renderEmpty?: () => React.ReactElement<any> | null | undefined;
  headerHeight: HeaderHeight;
  footerHeight: FooterHeight;
  sectionHeight: SectionHeight;
  sectionFooterHeight: SectionFooterHeight;
  rowHeight: RowHeight;
  sections: number[];
  insetTop: number;
  insetBottom: number;
  scrollTopValue?: Animated.Value;
  contentInset: {
    top?: number;
    left?: number;
    right?: number;
    bottom?: number;
  };
}

interface FastListState {
  batchSize: number;
  blockStart: number;
  blockEnd: number;
  height?: number;
  items?: FastListItem[];
}

const computeBlock = (
  containerHeight: number,
  scrollTop: number
): FastListState => {
  if (containerHeight === 0) {
    return {
      batchSize: 0,
      blockStart: 0,
      blockEnd: 0,
    };
  }
  const batchSize = Math.ceil(containerHeight / 2);
  const blockNumber = Math.ceil(scrollTop / batchSize);
  const blockStart = batchSize * blockNumber;
  const blockEnd = blockStart + batchSize;
  return { batchSize, blockStart, blockEnd };
};

function getFastListState(
  {
    headerHeight,
    footerHeight,
    sectionHeight,
    rowHeight,
    sectionFooterHeight,
    sections,
    insetTop,
    insetBottom,
  }: FastListProps,
  { batchSize, blockStart, blockEnd, items: prevItems }: FastListState
): FastListState {
  if (batchSize === 0) {
    return {
      batchSize,
      blockStart,
      blockEnd,
      height: insetTop + insetBottom,
      items: [],
    };
  }

  const computer = new FastListComputer({
    headerHeight,
    footerHeight,
    sectionHeight,
    rowHeight,
    sectionFooterHeight,
    sections,
    insetTop,
    insetBottom,
  });
  return {
    batchSize,
    blockStart,
    blockEnd,
    ...computer.compute(
      blockStart - batchSize,
      blockEnd + batchSize,
      prevItems || []
    ),
  };
}

export class FastList extends React.PureComponent<
  FastListProps,
  FastListState
> {
  static defaultProps = {
    isFastList: true,
    renderHeader: () => null,
    renderFooter: () => null,
    renderSection: () => null,
    renderSectionFooter: () => null,
    headerHeight: 0,
    footerHeight: 0,
    sectionHeight: 0,
    sectionFooterHeight: 0,
    insetTop: 0,
    insetBottom: 0,
    contentInset: { top: 0, right: 0, left: 0, bottom: 0 },
  };

  containerHeight: number = 0;
  scrollTop: number = 0;
  scrollTopValue: Animated.Value =
    this.props.scrollTopValue || new Animated.Value(0);
  scrollTopValueAttachment: { detach: () => void } | null | undefined;
  scrollView: { current: ScrollView | null | undefined } = React.createRef();

  state = getFastListState(
    this.props,
    computeBlock(this.containerHeight, this.scrollTop)
  );

  static getDerivedStateFromProps(props: FastListProps, state: FastListState) {
    return getFastListState(props, state);
  }

  getItems(): FastListItem[] {
    return this.state.items || [];
  }

  isVisible = (layoutY: number): boolean => {
    return (
      layoutY >= this.scrollTop &&
      layoutY <= this.scrollTop + this.containerHeight
    );
  };

  scrollToLocation = (
    section: number,
    row: number,
    animated: boolean = true
  ) => {
    const scrollView = this.scrollView.current;
    if (scrollView != null) {
      const {
        headerHeight,
        footerHeight,
        sectionHeight,
        rowHeight,
        sectionFooterHeight,
        sections,
        insetTop,
        insetBottom,
      } = this.props;
      const computer = new FastListComputer({
        headerHeight,
        footerHeight,
        sectionHeight,
        sectionFooterHeight,
        rowHeight,
        sections,
        insetTop,
        insetBottom,
      });
      const {
        scrollTop: layoutY,
        sectionHeight: layoutHeight,
      } = computer.computeScrollPosition(section, row);
      scrollView.scrollTo({
        x: 0,
        y: Math.max(0, layoutY - layoutHeight),
        animated,
      });
    }
  };

  handleScroll = (event: ScrollEvent) => {
    const { nativeEvent } = event;
    const { contentInset } = this.props;

    this.containerHeight =
      nativeEvent.layoutMeasurement.height -
      (contentInset.top || 0) -
      (contentInset.bottom || 0);
    this.scrollTop = Math.min(
      Math.max(0, nativeEvent.contentOffset.y),
      nativeEvent.contentSize.height - this.containerHeight
    );

    const nextState = computeBlock(this.containerHeight, this.scrollTop);
    if (
      nextState.batchSize !== this.state.batchSize ||
      nextState.blockStart !== this.state.blockStart ||
      nextState.blockEnd !== this.state.blockEnd
    ) {
      this.setState(nextState);
    }

    const { onScroll } = this.props;
    if (onScroll != null) {
      onScroll(event);
    }
  };

  handleLayout = (event: LayoutChangeEvent) => {
    const { nativeEvent } = event;
    const { contentInset } = this.props;

    this.containerHeight =
      nativeEvent.layout.height -
      (contentInset.top || 0) -
      (contentInset.bottom || 0);

    const nextState = computeBlock(this.containerHeight, this.scrollTop);
    if (
      nextState.batchSize !== this.state.batchSize ||
      nextState.blockStart !== this.state.blockStart ||
      nextState.blockEnd !== this.state.blockEnd
    ) {
      this.setState(nextState);
    }

    const { onLayout } = this.props;
    if (onLayout != null) {
      onLayout(event);
    }
  };

  /**
   * FastList only re-renders when items change which which does not happen with
   * every scroll event. Since an accessory might depend on scroll position this
   * ensures the accessory at least re-renders when scrolling ends
   */
  handleScrollEnd = (event: ScrollEvent) => {
    const { renderAccessory, onScrollEnd } = this.props;
    if (renderAccessory != null) {
      this.forceUpdate();
    }
    if (onScrollEnd) {
      onScrollEnd(event);
    }
  };

  renderItems() {
    const {
      renderHeader,
      renderFooter,
      renderSection,
      renderRow,
      renderSectionFooter,
      renderEmpty,
    } = this.props;

    const { items = [] } = this.state;

    if (renderEmpty != null && this.isEmpty()) {
      return renderEmpty();
    }

    const sectionLayoutYs = [] as number[];
    items.forEach(({ type, layoutY }) => {
      if (type === FastListItemType.SECTION) {
        sectionLayoutYs.push(layoutY);
      }
    });

    const children = [] as JSX.Element[];
    items.forEach(({ type, key, layoutY, layoutHeight, section, row }) => {
      switch (type) {
        case FastListItemType.SPACER: {
          const child = (
            <FastListItemRenderer key={key} layoutHeight={layoutHeight} />
          );
          children.push(child);
          break;
        }
        case FastListItemType.HEADER: {
          const child = renderHeader();
          if (child != null) {
            children.push(
              <FastListItemRenderer key={key} layoutHeight={layoutHeight}>
                {child}
              </FastListItemRenderer>
            );
          }
          break;
        }
        case FastListItemType.FOOTER: {
          const child = renderFooter();
          if (child != null) {
            children.push(
              <FastListItemRenderer key={key} layoutHeight={layoutHeight}>
                {child}
              </FastListItemRenderer>
            );
          }
          break;
        }
        case FastListItemType.SECTION: {
          sectionLayoutYs.shift();
          const child = renderSection(section);
          if (child != null) {
            children.push(
              <FastListSectionRenderer
                key={key}
                layoutY={layoutY}
                layoutHeight={layoutHeight}
                nextSectionLayoutY={sectionLayoutYs[0]}
                scrollTopValue={this.scrollTopValue}
              >
                {child}
              </FastListSectionRenderer>
            );
          }
          break;
        }
        case FastListItemType.ROW: {
          const child = renderRow(section, row);
          if (child != null) {
            children.push(
              <FastListItemRenderer key={key} layoutHeight={layoutHeight}>
                {child}
              </FastListItemRenderer>
            );
          }
          break;
        }
        case FastListItemType.SECTION_FOOTER: {
          const child = renderSectionFooter(section);
          if (child != null) {
            children.push(
              <FastListItemRenderer key={key} layoutHeight={layoutHeight}>
                {child}
              </FastListItemRenderer>
            );
          }
          break;
        }
      }
    });

    return children;
  }

  componentDidMount() {
    if (this.scrollView.current != null) {
      // @ts-ignore: Types for React Native doesn't include attachNativeEvent
      this.scrollTopValueAttachment = Animated.attachNativeEvent(
        this.scrollView.current,
        "onScroll",
        [{ nativeEvent: { contentOffset: { y: this.scrollTopValue } } }]
      );
    }
  }

  componentDidUpdate(prevProps: FastListProps) {
    if (prevProps.scrollTopValue !== this.props.scrollTopValue) {
      throw new Error("scrollTopValue cannot changed after mounting");
    }
  }

  componentWillUnmount() {
    if (this.scrollTopValueAttachment != null) {
      this.scrollTopValueAttachment.detach();
    }
  }

  isEmpty = () => {
    const { sections } = this.props;
    const length = sections.reduce((total, rowLength) => {
      return total + rowLength;
    }, 0);
    return length === 0;
  };

  render() {
    const {
      /* eslint-disable no-unused-vars */
      renderSection,
      renderRow,
      renderAccessory,
      sectionHeight,
      rowHeight,
      sections,
      insetTop,
      insetBottom,
      actionSheetScrollRef,
      renderActionSheetScrollViewWrapper,
      renderEmpty,

      /* eslint-enable no-unused-vars */
      ...props
    } = this.props;
    // what is this??
    // well! in order to support continuous scrolling of a scrollview/list/whatever in an action sheet, we need
    // to wrap the scrollview in a NativeViewGestureHandler. This wrapper does that thing that need do
    const wrapper = renderActionSheetScrollViewWrapper || ((val) => val);
    const scrollView = wrapper(
      <ScrollView
        {...props}
        ref={(ref) => {
          this.scrollView.current = ref;
          if (actionSheetScrollRef) {
            actionSheetScrollRef.current = ref;
          }
        }}
        removeClippedSubviews={false}
        scrollEventThrottle={16}
        onScroll={this.handleScroll}
        onLayout={this.handleLayout}
        onMomentumScrollEnd={this.handleScrollEnd}
        onScrollEndDrag={this.handleScrollEnd}
      >
        {this.renderItems()}
      </ScrollView>
    );
    return (
      <React.Fragment>
        {scrollView}
        {renderAccessory != null ? renderAccessory(this) : null}
      </React.Fragment>
    );
  }
}
