import FuzzySearch from "fuzzy-search";
import {
  concat,
  difference,
  fill,
  filter,
  flatten,
  includes,
  map,
} from "es-toolkit/compat";
import { observer } from "mobx-react";
import { StarredIcon, DocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
import DocumentExplorerNode from "./DocumentExplorerNode";
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";

type Props = {
  /** Action taken upon submission of selected item, could be publish, move etc. */
  onSubmit: () => void;
  /** A side-effect of item selection */
  onSelect: (item: NavigationNode | null) => void;
  /** Items to be shown in explorer */
  items: NavigationNode[];
  /** Automatically expand to and select item with the given id */
  defaultValue?: string;
  /** Whether to show child documents */
  showDocuments?: boolean;
};

const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;

const innerElementType = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
  { style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
  ref
) {
  return (
    <div
      ref={ref}
      style={{
        ...style,
        height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
      }}
      {...rest}
    />
  );
});

function DocumentExplorer({
  onSubmit,
  onSelect,
  items,
  defaultValue,
  showDocuments,
}: Props) {
  const isMobile = useMobile();
  const { collections, documents } = useStores();
  const { t } = useTranslation();
  const theme = useTheme();

  const [searchTerm, setSearchTerm] = React.useState<string>();
  const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
    () => {
      if (!defaultValue) {
        return null;
      }

      // Search through all nodes in the tree, not just top-level items
      const allNodes = flatten(items.map(flattenTree));
      const node = allNodes.find((item) => item.id === defaultValue);
      return node || null;
    }
  );
  const [activeNode, setActiveNode] = React.useState<number>(0);
  const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
    if (defaultValue) {
      // Search through all nodes in the tree, not just top-level items
      const allNodes = flatten(items.map(flattenTree));
      const node = allNodes.find((item) => item.id === defaultValue);
      if (node) {
        return ancestors(node).map((ancestorNode) => ancestorNode.id);
      }
    }
    return [];
  });

  const [itemRefs, setItemRefs] = React.useState<
    React.RefObject<HTMLSpanElement>[]
  >([]);

  const inputSearchRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(
    null
  );
  const listRef = React.useRef<List<NavigationNode[]>>(null);

  const searchIndex = React.useMemo(
    () =>
      new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
        caseSensitive: false,
      }),
    [items]
  );

  React.useEffect(() => {
    if (searchTerm) {
      selectNode(null);
      setExpandedNodes([]);
    }
    setActiveNode(0);
  }, [searchTerm]);

  React.useEffect(() => {
    setItemRefs((existingItemRefs) =>
      map(
        fill(Array(items.length), 0),
        (_, i) => existingItemRefs[i] || React.createRef()
      )
    );
  }, [items.length]);

  function getNodes() {
    function includeDescendants(item: NavigationNode): NavigationNode[] {
      return expandedNodes.includes(item.id)
        ? [item, ...descendants(item, 1).flatMap(includeDescendants)]
        : [item];
    }

    return searchTerm
      ? searchIndex.search(searchTerm)
      : items.flatMap(includeDescendants);
  }

  const nodes = getNodes();

  React.useEffect(() => {
    onSelect(selectedNode);
  }, [selectedNode, onSelect]);

  React.useEffect(() => {
    if (defaultValue && selectedNode && listRef) {
      const index = nodes.findIndex((node) => node.id === selectedNode.id);
      if (index > 0) {
        setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
      }
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [defaultValue]);
  const baseDepth = nodes.reduce(
    (min, node) => (node.depth ? Math.min(min, node.depth) : min),
    Infinity
  );
  const normalizedBaseDepth =
    (baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);

  const scrollNodeIntoView = React.useCallback((node: number) => {
    listRef.current?.scrollToItem(node, "smart");
  }, []);

  const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
    setSearchTerm(ev.target.value);
  };

  const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);

  const preserveScrollOffset = (itemCount: number) => {
    if (listRef.current) {
      const { height, itemSize } = listRef.current.props;
      const { scrollOffset } = listRef.current.state as {
        scrollOffset: number;
      };
      const itemsHeight = itemCount * itemSize;
      const offset = itemsHeight < Number(height) ? 0 : scrollOffset;
      setTimeout(() => listRef.current?.scrollTo(offset), 0);
    }
  };

  const collapse = (node: number) => {
    const descendantIds = descendants(nodes[node]).map((des) => des.id);
    setExpandedNodes(
      difference(expandedNodes, [...descendantIds, nodes[node].id])
    );

    // remove children
    const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
    preserveScrollOffset(newNodes.length);
  };

  const expand = (node: number) => {
    setExpandedNodes(concat(expandedNodes, nodes[node].id));

    // add children
    const newNodes = nodes.slice();
    newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
    preserveScrollOffset(newNodes.length);
  };

  React.useEffect(() => {
    collections.orderedData
      .filter(
        (collection) => expandedNodes.includes(collection.id) || searchTerm
      )
      .forEach((collection) => {
        void collection.fetchDocuments();
      });
  }, [collections, expandedNodes, searchTerm]);

  const isSelected = (node: number) => {
    if (!selectedNode) {
      return false;
    }
    const selectedNodeId = selectedNode.id;
    const nodeId = nodes[node].id;

    return selectedNodeId === nodeId;
  };

  const hasChildren = (node: number) =>
    nodes[node].children.length > 0 ||
    (showDocuments !== false && nodes[node].type === "collection");

  const toggleCollapse = (node: number) => {
    if (!hasChildren(node)) {
      return;
    }
    if (isExpanded(node)) {
      collapse(node);
    } else {
      expand(node);
    }
  };

  const toggleSelect = (node: number) => {
    if (isSelected(node)) {
      selectNode(null);
    } else {
      selectNode(nodes[node]);
    }
  };

  const ListItem = observer(
    ({
      index,
      data,
      style,
    }: {
      index: number;
      data: NavigationNode[];
      style: React.CSSProperties;
    }) => {
      const node = data[index];
      const isCollection = node.type === "collection";
      let renderedIcon,
        title: string,
        icon: string | undefined,
        color: string | undefined,
        path;

      if (isCollection) {
        const col = collections.get(node.collectionId as string);
        renderedIcon = col && (
          <CollectionIcon collection={col} expanded={isExpanded(index)} />
        );
        title = node.title;
      } else {
        const doc = documents.get(node.id);
        icon = doc?.icon ?? node.icon ?? node.emoji;
        color = doc?.color ?? node.color;
        title = doc?.title ?? node.title;

        if (icon) {
          renderedIcon = (
            <Icon value={icon} initial={node.title} color={color} />
          );
        } else if (doc?.isStarred) {
          renderedIcon = <StarredIcon color={theme.yellow} />;
        } else {
          renderedIcon = <DocumentIcon color={theme.textSecondary} />;
        }

        path = ancestors(node)
          .map((a) => a.title)
          .join(" / ");
      }

      return searchTerm ? (
        <DocumentExplorerSearchResult
          selected={isSelected(index)}
          active={activeNode === index}
          style={{
            ...style,
            top: (style.top as number) + VERTICAL_PADDING,
            left: (style.left as number) + HORIZONTAL_PADDING,
            width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
          }}
          onPointerMove={() => setActiveNode(index)}
          onClick={() => toggleSelect(index)}
          icon={renderedIcon}
          title={title}
          path={path}
        />
      ) : (
        <DocumentExplorerNode
          style={{
            ...style,
            top: (style.top as number) + VERTICAL_PADDING,
            left: (style.left as number) + HORIZONTAL_PADDING,
            width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
          }}
          onPointerMove={() => setActiveNode(index)}
          onClick={() => toggleSelect(index)}
          onDisclosureClick={(ev) => {
            ev.stopPropagation();
            toggleCollapse(index);
          }}
          selected={isSelected(index)}
          active={activeNode === index}
          expanded={isExpanded(index)}
          icon={renderedIcon}
          title={title}
          depth={(node.depth ?? 0) - normalizedBaseDepth}
          hasChildren={hasChildren(index)}
          ref={itemRefs[index]}
        />
      );
    }
  );

  const focusSearchInput = () => {
    inputSearchRef.current?.focus();
  };

  const next = () => Math.min(activeNode + 1, nodes.length - 1);

  const prev = () => Math.max(activeNode - 1, 0);

  const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
    switch (ev.key) {
      case "ArrowDown": {
        ev.preventDefault();
        ev.stopPropagation();
        setActiveNode(next());
        scrollNodeIntoView(next());
        break;
      }
      case "ArrowUp": {
        ev.preventDefault();
        ev.stopPropagation();
        if (activeNode === 0) {
          focusSearchInput();
        } else {
          setActiveNode(prev());
          scrollNodeIntoView(prev());
        }
        break;
      }
      case "ArrowLeft": {
        if (!searchTerm && isExpanded(activeNode)) {
          toggleCollapse(activeNode);
        }
        break;
      }
      case "ArrowRight": {
        if (!searchTerm) {
          toggleCollapse(activeNode);
          // let the nodes re-render first and then scroll
          setTimeout(() => scrollNodeIntoView(activeNode), 0);
        }
        break;
      }
      case "Enter": {
        if (isModKey(ev)) {
          onSubmit();
        } else {
          toggleSelect(activeNode);
        }
        break;
      }
    }
  };

  return (
    <Container tabIndex={-1} onKeyDown={handleKeyDown}>
      <ListSearch
        ref={inputSearchRef}
        onChange={handleSearch}
        placeholder={
          showDocuments
            ? `${t("Search collections & documents")}…`
            : `${t("Search collections")}…`
        }
        autoFocus
      />
      <ListContainer>
        {nodes.length ? (
          <AutoSizer>
            {({ width, height }: { width: number; height: number }) => (
              <Flex role="listbox" column>
                <List
                  ref={listRef}
                  width={width}
                  height={height}
                  itemData={nodes}
                  itemCount={nodes.length}
                  itemSize={isMobile ? 48 : 32}
                  innerElementType={innerElementType}
                  itemKey={(index, results) => results[index].id}
                >
                  {ListItem}
                </List>
              </Flex>
            )}
          </AutoSizer>
        ) : (
          <FlexContainer>
            <Text as="p" type="secondary">
              {t("No results found")}.
            </Text>
          </FlexContainer>
        )}
      </ListContainer>
    </Container>
  );
}

const Container = styled.div``;

const FlexContainer = styled(Flex)`
  height: 100%;
  align-items: center;
  justify-content: center;
`;

const ListSearch = styled(InputSearch).attrs({ round: true })`
  margin-bottom: 4px;
  padding-left: 24px;
  padding-right: 24px;
`;

const ListContainer = styled.div`
  height: 65vh;

  ${breakpoint("tablet")`
    height: 40vh;
  `}
`;

export default observer(DocumentExplorer);
