// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Platform from '../../platform/platform.js';
import * as LitHtml from '../../third_party/lit-html/lit-html.js';

interface BaseTreeNode {
  key: string;
  renderer?: (node: TreeNode, state: {isExpanded: boolean}) => LitHtml.TemplateResult;
}

export interface TreeNodeWithChildren extends BaseTreeNode {
  children: () => Promise<TreeNode[]>;
}
interface LeafNode extends BaseTreeNode {
  children?: never;
}

export type TreeNode = TreeNodeWithChildren|LeafNode;

export function isExpandableNode(node: TreeNode): node is TreeNodeWithChildren {
  return 'children' in node;
}

/**
 * This is a custom lit-html directive that lets us track the DOM nodes that Lit
 * creates and maps them to the tree node that was given to us. This means we
 * can navigate between real DOM node and structural tree node easily in code.
 */
export const trackDOMNodeToTreeNode =
    LitHtml.directive((weakMap: WeakMap<HTMLLIElement, TreeNode>, treeNode: TreeNode) => {
      return (part: LitHtml.Part): void => {
        if (!(part instanceof LitHtml.AttributePart)) {
          throw new Error('Ref directive must be used as an attribute.');
        }

        const elem = part.committer.element;
        if (!(elem instanceof HTMLLIElement)) {
          throw new Error('trackTreeNodeToDOMNode must be used on <li> elements.');
        }
        weakMap.set(elem, treeNode);
      };
    });


/**
 * Finds the next sibling of the node's parent, recursing up the tree if
 * required.
 * Given:
 * A
 *   * B
 *     * C
 * D
 * If called on B, this will return D. If called on C, this will also return D.
 */
const findNextParentSibling = (currentDOMNode: HTMLLIElement): HTMLLIElement|null => {
  // We go up two parents here because the structure is:
  // <li treeitem> => <ul group> => <li treeitem>
  // So if we are on the last treeitem (furthest to the right), we need to find its parent tree item, which is two parents up.
  const currentDOMNodeParentListItem = currentDOMNode.parentElement?.parentElement;

  if (currentDOMNodeParentListItem && currentDOMNodeParentListItem instanceof HTMLLIElement) {
    const parentNodeSibling = currentDOMNodeParentListItem.nextElementSibling;
    // If this parent doesn't have a sibling, recurse up the tree to look for
    // the nearest parent that does have a sibling.
    if (parentNodeSibling && parentNodeSibling instanceof HTMLLIElement) {
      return parentNodeSibling;
    }
    return findNextParentSibling(currentDOMNodeParentListItem);
  }
  return null;
};

const getFirstChildOfExpandedTreeNode = (currentDOMNode: HTMLLIElement): HTMLLIElement => {
  const firstChild =
      currentDOMNode.querySelector<HTMLLIElement>(':scope > [role="group"] > [role="treeitem"]:first-child');
  if (!firstChild) {
    throw new Error('Could not find child of expanded node.');
  }
  return firstChild;
};

const domNodeIsExpandable = (domNode: HTMLLIElement): boolean => {
  // Nodes with no children are not given the aria-expanded attributes.
  // Nodes with children are given aria-expanded = true/false.
  return domNode.getAttribute('aria-expanded') !== null;
};

const domNodeIsLeafNode = (domNode: HTMLLIElement): boolean => {
  return !domNodeIsExpandable(domNode);
};

const domNodeIsExpanded = (domNode: HTMLLIElement): boolean => {
  // Nodes with no children are not given the aria-expanded attributes.
  // Nodes with children are given aria-expanded = true/false.
  return domNodeIsExpandable(domNode) && domNode.getAttribute('aria-expanded') === 'true';
};

const getDeepLastChildOfExpandedTreeNode = (currentDOMNode: HTMLLIElement): HTMLLIElement => {
  const lastChild =
      currentDOMNode.querySelector<HTMLLIElement>(':scope > [role="group"] > [role="treeitem"]:last-child');
  if (!lastChild) {
    throw new Error('Could not find child of expanded node.');
  }

  if (domNodeIsExpanded(lastChild)) {
    return getDeepLastChildOfExpandedTreeNode(lastChild);
  }
  return lastChild;
};

const getNextSiblingOfCurrentDOMNode = (currentDOMNode: HTMLLIElement): HTMLLIElement|null => {
  const currentNodeSibling = currentDOMNode.nextElementSibling;
  if (currentNodeSibling && currentNodeSibling instanceof HTMLLIElement) {
    return currentNodeSibling;
  }
  return null;
};

const getPreviousSiblingOfCurrentDOMNode = (currentDOMNode: HTMLLIElement): HTMLLIElement|null => {
  const currentNodeSibling = currentDOMNode.previousElementSibling;
  if (currentNodeSibling && currentNodeSibling instanceof HTMLLIElement) {
    return currentNodeSibling;
  }
  return null;
};

const getParentListItemForDOMNode = (currentDOMNode: HTMLLIElement): HTMLLIElement|null => {
  let parentNode = currentDOMNode.parentElement;
  if (!parentNode) {
    return null;
  }
  while (parentNode && parentNode.getAttribute('role') !== 'treeitem' &&
         (parentNode instanceof HTMLLIElement) === false) {
    parentNode = parentNode.parentElement;
  }
  return parentNode as HTMLLIElement;
};

interface KeyboardNavigationOptions {
  currentDOMNode: HTMLLIElement;
  currentTreeNode: TreeNode;
  direction: Platform.KeyboardUtilities.ArrowKey;
  setNodeExpandedState: (treeNode: TreeNode, expanded: boolean) => void;
}

export const findNextNodeForTreeOutlineKeyboardNavigation = (options: KeyboardNavigationOptions): HTMLLIElement => {
  const {
    currentDOMNode,
    currentTreeNode,
    direction,
    setNodeExpandedState,
  } = options;
  if (!currentTreeNode) {
    return currentDOMNode;
  }

  if (direction === Platform.KeyboardUtilities.ArrowKey.DOWN) {
    // If the node has expanded children, down takes you into that list.
    if (domNodeIsExpanded(currentDOMNode)) {
      return getFirstChildOfExpandedTreeNode(currentDOMNode);
    }
    // If the node has a sibling, we go to that.
    const currentNodeSibling = getNextSiblingOfCurrentDOMNode(currentDOMNode);
    if (currentNodeSibling) {
      return currentNodeSibling;
    }

    // If the Node's parent has a sibling then we go to that.
    const parentSibling = findNextParentSibling(currentDOMNode);
    if (parentSibling) {
      return parentSibling;
    }
  } else if (direction === Platform.KeyboardUtilities.ArrowKey.RIGHT) {
    if (domNodeIsLeafNode(currentDOMNode)) {
      // If the node cannot be expanded, we have nothing to do and we leave everything as is.
      return currentDOMNode;
    }

    // If the current node is expanded, move and focus into the first child
    if (domNodeIsExpanded(currentDOMNode)) {
      return getFirstChildOfExpandedTreeNode(currentDOMNode);
    }
    // Else, we expand the Node (but leave focus where it is)
    setNodeExpandedState(currentTreeNode, true);
    return currentDOMNode;
  } else if (direction === Platform.KeyboardUtilities.ArrowKey.UP) {
    // First see if there is a previous sibling
    const currentNodePreviousSibling = getPreviousSiblingOfCurrentDOMNode(currentDOMNode);
    if (currentNodePreviousSibling) {
      // We now find the nested node within our previous sibling; if it has
      // children that are expanded, we want to find the last child and
      // highlight that, else we'll highlight our sibling directly.
      if (domNodeIsExpanded(currentNodePreviousSibling)) {
        return getDeepLastChildOfExpandedTreeNode(currentNodePreviousSibling);
      }
      // Otherwise, if we have a previous sibling with no children, focus it.
      return currentNodePreviousSibling;
    }

    // Otherwise, let's go to the direct parent if there is one.
    const parentNode = getParentListItemForDOMNode(currentDOMNode);
    if (parentNode && parentNode instanceof HTMLLIElement) {
      return parentNode;
    }
  } else if (direction === Platform.KeyboardUtilities.ArrowKey.LEFT) {
    // If the node is expanded, we close it.
    if (domNodeIsExpanded(currentDOMNode)) {
      setNodeExpandedState(currentTreeNode, false);
      return currentDOMNode;
    }

    // Otherwise, let's go to the parent if there is one.
    const parentNode = getParentListItemForDOMNode(currentDOMNode);
    if (parentNode && parentNode instanceof HTMLLIElement) {
      return parentNode;
    }
  }

  // If we got here, there's no other option than to stay put.
  return currentDOMNode;
};
