import { useLocation } from '@tanstack/react-router';
import debounce from 'lodash/debounce';
import React, { useEffect, useRef, useMemo, useCallback } from 'react';
import {
  Virtuoso,
  Components,
  VirtuosoProps,
  VirtuosoHandle,
  ListRange,
  IndexLocationWithAlign,
} from 'react-virtuoso';

import LoadMore from '@/components/load-more';
import Spinner from '@/components/ui/spinner';
import { useSettings } from '@/stores/settings';

import { EmptyMessage } from './empty-message';

/** Custom Viruoso component context. */
type Context = {
  itemClassName?: string;
  listClassName?: string;
};

/** Scroll position saved in sessionStorage. */
type SavedScrollPosition = {
  index: number;
  offset: number;
};

/** Custom Virtuoso Item component representing a single scrollable item. */
// NOTE: It's crucial to space lists with **padding** instead of margin!
// Pass an `itemClassName` like `pb-3`, NOT a `space-y-3` className
// https://virtuoso.dev/troubleshooting#list-does-not-scroll-to-the-bottom--items-jump-around
const Item: Components<React.JSX.Element, Context>['Item'] = ({ context, ...rest }) => (
  <div className={context?.itemClassName} {...rest} />
);

/** Custom Virtuoso List component for the outer container. */
// Ensure the className winds up here
const List: Components<React.JSX.Element, Context>['List'] = React.forwardRef((props, ref) => {
  const { context, ...rest } = props;
  return <div ref={ref} className={context?.listClassName} {...rest} />;
});

interface IScrollableList extends VirtuosoProps<any, any> {
  /** Unique key to preserve the scroll position when navigating back. */
  scrollKey?: string;
  /** Pagination callback when the end of the list is reached. */
  onLoadMore?: () => void;
  /** Whether the data is currently being fetched. */
  isLoading?: boolean;
  /** Whether to actually display the loading state. */
  showLoading?: boolean;
  /** Whether we expect an additional page of data. */
  hasMore?: boolean;
  /** Additional element to display at the top of the list. */
  prepend?: React.ReactNode;
  /** Message to display when the list is loaded but empty. */
  emptyMessage?: React.ReactNode;
  /** Message to display when the list is loaded but empty. */
  emptyMessageText?: React.ReactNode;
  /** Message to display next to the emptyMessage text. */
  emptyMessageIcon?: string;
  /** Scrollable content. */
  children: Iterable<React.ReactNode>;
  /** Callback when the list is scrolled to the top. */
  onScrollToTop?: () => void;
  /** Callback when the list is scrolled. */
  onScroll?: () => void;
  /** Placeholder component to render while loading. */
  placeholderComponent?: React.ComponentType | React.NamedExoticComponent;
  /** Number of placeholders to render while loading. */
  placeholderCount?: number;
  /** Extra class names on the Virtuoso element. */
  className?: string;
  /** Extra class names on the list element. */
  listClassName?: string;
  /** Class names on each item container. */
  itemClassName?: string;
  /** Extra class names on the LoadMore element */
  loadMoreClassName?: string;
  /** `id` attribute on the Virtuoso element. */
  id?: string;
  /** CSS styles on the Virtuoso element. */
  style?: React.CSSProperties;
  /** Whether to use the window to scroll the content instead of Virtuoso's container. */
  useWindowScroll?: boolean;
}

const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(
  (
    {
      scrollKey,
      prepend = null,
      children,
      isLoading,
      emptyMessage,
      emptyMessageText,
      emptyMessageIcon,
      showLoading,
      onScroll,
      onScrollToTop,
      onLoadMore,
      className,
      listClassName,
      itemClassName,
      loadMoreClassName,
      hasMore,
      placeholderComponent: Placeholder,
      placeholderCount = 0,
      initialTopMostItemIndex = 0,
      useWindowScroll = true,
      ...params
    },
    ref,
  ) => {
    const { autoloadMore } = useSettings();
    const { state: locationState } = useLocation();

    // Preserve scroll position
    const scrollDataKey = `plfe:scrollData:${scrollKey}:${locationState.key}`;
    const scrollData: SavedScrollPosition | null = useMemo(
      () => JSON.parse(sessionStorage.getItem(scrollDataKey)!),
      [scrollDataKey],
    );
    const topIndex = useRef<number>(scrollData ? scrollData.index : 0);
    const topOffset = useRef<number>(scrollData ? scrollData.offset : 0);

    /** Normalized children. */
    const elements = Array.from(children || []);

    const showPlaceholder = showLoading && Placeholder && placeholderCount > 0;

    // NOTE: We are doing some trickery to load a feed of placeholders
    // Virtuoso's `EmptyPlaceholder` unfortunately doesn't work for our use-case
    const data = showPlaceholder ? Array(placeholderCount).fill('') : elements;

    // Add a placeholder at the bottom for loading
    // (Don't use Virtuoso's `Footer` component because it doesn't preserve its height)
    if (hasMore && (autoloadMore || isLoading) && Placeholder) {
      data.push(<Placeholder />);
    } else if (hasMore && (autoloadMore || isLoading)) {
      data.push(<Spinner />);
    }

    const handleScroll = useCallback(
      debounce(
        () => {
          // HACK: Virtuoso has no better way to get this...
          const node = document.querySelector(
            `[data-virtuoso-scroller] [data-item-index="${topIndex.current}"]`,
          );
          if (node) {
            topOffset.current = node.getBoundingClientRect().top * -1;
          } else {
            topOffset.current = 0;
          }
        },
        150,
        { trailing: true },
      ),
      [],
    );

    useEffect(() => {
      document.addEventListener('scroll', handleScroll);
      sessionStorage.removeItem(scrollDataKey);

      return () => {
        if (scrollKey) {
          const data: SavedScrollPosition = { index: topIndex.current, offset: topOffset.current };
          sessionStorage.setItem(scrollDataKey, JSON.stringify(data));
        }
        document.removeEventListener('scroll', handleScroll);
      };
    }, []);

    /* Render an empty state instead of the scrollable list. */
    const renderEmpty = (): React.JSX.Element => {
      return isLoading ? (
        <Spinner />
      ) : emptyMessageText ? (
        <EmptyMessage text={emptyMessageText} icon={emptyMessageIcon} />
      ) : (
        <>{emptyMessage}</>
      );
    };

    /** Render a single item. */
    const renderItem = (_i: number, element: React.JSX.Element): React.JSX.Element => {
      if (showPlaceholder) {
        return <Placeholder />;
      } else {
        return element;
      }
    };

    const handleEndReached = () => {
      if (autoloadMore && hasMore && onLoadMore) {
        onLoadMore();
      }
    };

    const loadMore = () => {
      if (autoloadMore || !hasMore || !onLoadMore) {
        return null;
      } else {
        const button = <LoadMore visible={!isLoading} onClick={onLoadMore} />;

        if (loadMoreClassName) return <div className={loadMoreClassName}>{button}</div>;

        return button;
      }
    };

    const handleRangeChange = (range: ListRange) => {
      // HACK: using the first index can be buggy.
      // Track the second item instead, unless the endIndex comes before it (eg one 1 item in view).
      topIndex.current = Math.min(range.startIndex + 1, range.endIndex);
      handleScroll();
    };

    /** Figure out the initial index to scroll to. */
    const initialIndex = useMemo<number | IndexLocationWithAlign>(() => {
      if (showLoading) return 0;

      if (initialTopMostItemIndex) {
        if (typeof initialTopMostItemIndex === 'number') {
          return {
            align: 'start',
            index: initialTopMostItemIndex,
            offset: 60,
          };
        }
        return initialTopMostItemIndex;
      }

      if (scrollData) {
        return {
          align: 'start',
          index: scrollData.index,
          offset: scrollData.offset,
        };
      }

      return 0;
    }, [showLoading, initialTopMostItemIndex]);

    return (
      <Virtuoso
        useWindowScroll={useWindowScroll}
        {...params}
        overscan={window.innerHeight * 1.5}
        ref={ref}
        data={data}
        totalCount={data.length}
        startReached={onScrollToTop}
        endReached={handleEndReached}
        isScrolling={(isScrolling) => isScrolling && onScroll && onScroll()}
        itemContent={renderItem}
        initialTopMostItemIndex={initialIndex}
        rangeChanged={handleRangeChange}
        context={{
          listClassName,
          itemClassName,
        }}
        components={{
          Header: prepend ? () => <>{prepend}</> : undefined,
          ScrollSeekPlaceholder: Placeholder as any,
          EmptyPlaceholder: renderEmpty,
          List,
          Item,
          Footer: loadMore,
        }}
      />
    );
  },
);

export { type IScrollableList, ScrollableList as default };
