import { html, nothing, PropertyValues, TemplateResult } from 'lit'
import { property, query, state } from 'lit/decorators.js'
import { classMap } from 'lit/directives/class-map.js'
import { ifDefined } from 'lit/directives/if-defined.js'
import { cancelEvent } from '@api-client/core/lib/events/Utils.js'
import type { IconType } from '../../../md/icons/Icons.js'
import ApiElement from '../../ApiElement.js'

import '../../../md/icons/ui-icon.js'
import '../../../md/icon-button/ui-icon-button.js'

export interface ISelectDetail {
  /**
   * The key of the selected item
   */
  key: string
  /**
   * THe kind of the selected item
   */
  kind: string
  /**
   * Optionally, the parent odf the selected item, if any.
   */
  parent?: string
  /**
   * When applicable, the top object in the structure.
   */
  root?: string
}

export interface IListItemRenderOptions {
  draggable?: boolean
  parent?: string
  disabled?: boolean
  /**
   * Only relevant for parent list item.
   * When set it forces this icon to be rendered next to the toggle button.
   */
  parentIcon?: IconType
  /**
   * When set is add the `data-root` attribute.
   * In principle, it represents a root object in the tree, if any.
   */
  root?: string
}

/**
 * A base class for navigation elements helping creating specific implementation of the view.
 * The implementations sets their own properties with the data that are used to generate the view.
 *
 * The generated menu must have the following structure created by the implementations:
 *
 * ```html
 * <ul aria-label="..." aria-multiselectable="false">
 *  <li role="treeitem" data-key="..." data-kind="..." data-draggable="true|false">
 *    <div class="list-item-content"><span class="label" title="...">...</span></div>
 *  </li>
 *  <li role="treeitem" data-key="..." data-kind="..." data-draggable="true" aria-expanded="true" class="parent-item">
 *    <div class="list-item-content">
 *      <ui-icon icon="chevronRight" class="group-toggle-icon"></ui-icon>
 *      <span class="label" title="...">...</span>
 *    </div>
 *    <ul role="group" data-parent="..." ?hidden aria-hidden="true|false">
 *      <li role="treeitem" data-key="..." data-parent="..." data-kind="..." data-draggable="true|false">
 *        <div class="list-item-content">
 *          <ui-icon icon="..." class="item-icon"></ui-icon>
 *          <span class="label" title="...">...</span>
 *        </div>
 *      </li>
 *    </ul>
 *  </li>
 * </ul>
 * ```
 *
 * Additionally, the child element must include styles from './AppNavigation.styles.js`.
 *
 * Assumptions when running the menu:
 *
 * - all `li` items that do not have `aria-disabled` attribute are considered active tree items (learn more why not to use "disabled" attribute https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-disabled)
 * - all `li` elements have `data-key` and `data-kind` attributes set. Elements without these properties are ignored.
 * - all `ul` and `li` elements that have a parent also have `data-parent` with the value of the parent's key.
 *    When this is not set the element is considered a top level element.
 * - the implementations does not manage the `tabindex` of any item. Instead set the `_focusedItem` to
 *    the currently focused element and the base will manage the focus for you (after the update is complete).
 *
 * @fires {CustomEvent} select - Custom, non bubbling event when a selection is made.
 *        It has the `key` and `kind` properties on the detail panel with optional `parent` id.
 * @fires {Event} deselect - Custom, non bubbling event when the selection is cleared.
 */
export default class AppNavigation extends ApiElement {
  /**
   * The list of keys of opened items with children. This should be clear each time
   * when the main data property change.
   */
  protected opened = new Set<string>()

  /**
   * The key of the currently focused item.
   */
  protected focused?: string

  /**
   * The currently focused node.
   * In case there are headers in the list, this may not be a list item.
   */
  @state() accessor focusedItem: HTMLElement | undefined

  /**
   * To be set in the child class constructor to configure whether a name change is possible.
   */
  protected allowRename?: boolean

  /**
   * The currently selected navigation item.
   * @attr
   */
  @property({ type: String }) accessor selected: string | undefined

  /**
   * When set it renders a name change input for the item.
   * This is the `key` of the object.
   *
   * Note, when the user finish editing the name it calls the `_commitName(key, kind, name)` method.
   * This requires the `_allowRename` property to be set to `true` in the constructor.
   * @attr
   */
  @property({ type: String }) accessor edited: string | undefined

  /**
   * When set it prohibits the element to make visual selection (via the css) when an item is selected.
   * This is helpful when one navigation can be visually references in multiple places (like split layout).
   * @attr
   */
  @property({ type: Boolean, reflect: true }) accessor noVisualSelection: boolean | undefined

  /**
   * A reference to the currently rendered name input, if any.
   */
  @query('.name-change input')
  protected accessor _nameInput!: HTMLInputElement | null

  /**
   * When set it adds a menu entry with the navigation label.
   * @attribute
   */
  @property({ type: String }) accessor label: string | undefined

  /**
   * A reference to the currently dragged item.
   * This logic assumes that the drag and drop is local to this element.
   */
  protected draggingItem?: HTMLLIElement

  constructor() {
    super()
    this.addEventListener('click', this.handleClick.bind(this))
  }

  override connectedCallback(): void {
    super.connectedCallback()
    this.setAttribute('role', 'navigation')
    this.setAttribute('tabindex', '0')
  }

  protected override willUpdate(cp: PropertyValues<this>): void {
    super.willUpdate(cp)
    if (cp.has('selected') && this.selected) {
      this.ensureTreeVisibility(this.selected)
    }
  }

  protected override updated(cp: PropertyValues<this>): void {
    super.updated(cp)
    if (cp.has('edited') && this.edited) {
      const node = this._nameInput
      if (node) {
        node.focus()
      }
      this.ensureTreeVisibility(this.edited)
    }
    if (cp.has('focusedItem') && this.focusedItem) {
      const old = cp.get('focusedItem') as HTMLElement | undefined
      this.manageFocus(this.focusedItem, old)
    }
  }

  protected reset(): void {
    this.opened.clear()
    this.focused = undefined
    this.focusedItem = undefined
    this.edited = undefined
  }

  /**
   * This method is called when the `_focusedItem` property is updated.
   * It removes tabindex from the old element (if any) and sets tabindex and focuses on the new item (if any).
   * When there's no new item, it sets the `tabindex` on self.
   *
   * @param current The new focused element, if any
   * @param old The old focused element, if any.
   */
  protected manageFocus(current?: HTMLElement, old?: HTMLElement): void {
    if (old) {
      // don't set `-1` here as the item can have children which event with tabindex '0' won't be focusable.
      old.removeAttribute('tabindex')
    }
    if (current) {
      current.setAttribute('tabindex', '0')
      current.focus()
      // current.scrollIntoView();
      // remove tabindex from self so when the user returns to this navigation it will focus directly on
      // this item.
      if (this.hasAttribute('tabindex')) {
        this.removeAttribute('tabindex')
      }
    } else if (!this.hasAttribute('tabindex')) {
      // restore tabindex on self.
      this.setAttribute('tabindex', '0')
    }
  }

  /**
   * Makes sure the node represented by the passed key is visible in the UI.
   * This opens all parents on the way up to the root and scrolls to the node, when needed.
   * @param key The key of the object to ensure visibility for.
   */
  ensureTreeVisibility(key: string): void {
    const node = this.shadowRoot?.querySelector(`li[data-key="${key}"]`) as HTMLLIElement
    if (!node) {
      return
    }
    if (node.offsetParent !== null) {
      // node is visible
      return
    }
    const { opened: _opened } = this
    let parent = this.findParentListItem(node)
    const toOpen = new Set<string>()
    while (parent) {
      const id = parent.dataset.key as string
      if (!_opened.has(id)) {
        toOpen.add(id)
      }
      parent = this.findParentListItem(parent)
    }
    if (toOpen.size) {
      this.opened = this.opened.union(toOpen)
      this.requestUpdate()
    }
  }

  /**
   * Toggles a group of tree items.
   *
   * @param key The key of the group parent.
   */
  toggleGroup(key: string): void {
    if (this.opened.has(key)) {
      this.opened.delete(key)
    } else {
      this.opened.add(key)
    }
    this.requestUpdate()
  }

  /**
   * Allows to programmatically open a folder.
   * It opens all parents if they are not opened.
   * @param key The key of the folder to open.
   */
  openGroup(key: string): void {
    const node = this.shadowRoot?.querySelector(`li[data-key="${key}"]`) as HTMLLIElement
    if (!node) {
      return
    }
    if (this.opened.has(key)) {
      return
    }
    this.opened.add(key)

    // open all parents
    let parent = this.findParentListItem(node)
    while (parent) {
      const parentKey = parent.dataset.key as string
      this.opened.add(parentKey)
      parent = this.findParentListItem(parent)
    }
    this.requestUpdate()
  }

  /**
   * Allows to programmatically close a folder.
   * @param key The key of the folder to open.
   */
  closeGroup(key: string): void {
    if (this.opened.has(key)) {
      this.opened.delete(key)
      this.requestUpdate()
    }
  }

  /**
   * A function to determine whether the passed Element is an active list item.
   *
   * @param node The Element to test
   * @returns true when the passed element is a `li` element and is not disabled.
   */
  protected isValidListItem(node: Element): boolean {
    return (
      node &&
      node.localName === 'li' &&
      node.getAttribute('aria-disabled') !== 'true' &&
      node.hasAttribute('data-key') &&
      node.hasAttribute('data-kind')
    )
  }

  /**
   * Marks the item as currently focused.
   *
   * Note, focused item is not the same as selected item. Focus is reflected in the UI without changing the selection.
   *
   * Also note, this only sets the `_focused` and `_focusedItem` properties. After the element renders
   * the implementation should change the `tabindex` of the focused item to `0`.
   */
  protected focusListItem(node: HTMLElement): void {
    this.focused = node.dataset.key
    this.focusedItem = node
  }

  /**
   * Finds a valid list item that is a parent to the current item.
   *
   * @param node The current node located somewhere in the tree.
   * @returns The nearest parent list item or undefined when not found.
   */
  protected findParentListItem(node: HTMLElement): HTMLLIElement | undefined {
    let parent = node.parentElement as HTMLElement
    while (parent) {
      if (this.isValidListItem(parent)) {
        return parent as HTMLLIElement
      }
      // when the `parentElement` is ShadowRoot then it's parent is `null` so the loop breaks.
      parent = parent.parentElement as HTMLElement
    }
    return undefined
  }

  /**
   * From a `<li>` it finds the last `<li>` descendant in the tree.
   *
   * ```html
   * <ul>
   *  <li> <-- this is the current node
   *    <div>...</div>
   *    <ul>
   *       <li>...</li>
   *       <li>  <-- this is the last list item.
   *          <div>...</div>
   *          <ul hidden> <- this is ignored.
   *            <li></li>
   *            <li></li>  <-- this is not the last list item because the parent list is hidden.
   *          </ul>
   *      </li>
   *    </ul>
   *  </li>
   * </ul>
   * ```
   *
   * @param node The current list item to find the last descendant for.
   * @returns The last list item or undefined.
   */
  protected findLastDescendant(node: HTMLElement): HTMLElement | undefined {
    const list = node.querySelector('ul:not([hidden])')
    if (!list) {
      return undefined
    }
    const li = list.querySelector('li[data-key][data-kind]:last-of-type:not([aria-disabled="true"])')
    if (!li) {
      return undefined
    }
    const result = this.findLastDescendant(li as HTMLElement)
    return result || (li as HTMLElement)
  }

  /**
   * Focuses on a list descendant of a folder item.
   * It does nothing when the `node` is not a folder item or the folder has no selectable children.
   *
   * @param node The node to use as a reference. The current focused node by default.
   * @returns true when focused on any of the descendants
   */
  protected focusFirstDescendant(node = this.focusedItem): boolean {
    if (!node) {
      return false
    }
    const list = node.querySelector('ul:not([aria-hidden="true"])')
    if (!list) {
      return false
    }
    const child = list.querySelector('li[data-key][data-kind]:not([aria-disabled="true"])')
    if (!child) {
      return false
    }
    this.focusListItem(child as HTMLLIElement)
    return true
  }

  /**
   * Focuses on the next node relative to the passed node.
   *
   * @param node The node to use as a starting point. When not set it uses the currently focused node.
   * @returns true when focused on any of the next siblings
   */
  focusNextSibling(node = this.focusedItem): boolean {
    if (!node) {
      return false
    }
    let current = node.nextElementSibling as HTMLElement
    while (current) {
      if (this.isValidListItem(current)) {
        this.focusListItem(current)
        return true
      }
      current = current.nextElementSibling as HTMLElement
    }
    return false
  }

  /**
   * Focuses on the previous node relative to the passed node.
   *
   * @param node The node to use as a starting point. When not set it uses the currently focused node.
   * @returns true when focused on any of the previous siblings
   */
  focusPreviousSibling(node = this.focusedItem): boolean {
    if (!node) {
      return false
    }
    let current = node.previousElementSibling as HTMLElement
    while (current) {
      if (this.isValidListItem(current)) {
        const last = this.findLastDescendant(current)
        if (last) {
          this.focusListItem(last)
          return true
        }
        this.focusListItem(current)
        return true
      }
      current = current.previousElementSibling as HTMLElement
    }
    return false
  }

  /**
   * Focuses on a first list item that accepts focus relative to the given node.
   *
   * @param node The node to use as a reference. The current focused node by default.
   * @returns true when focused on any of the parents
   */
  protected focusFirstParent(node: HTMLElement): boolean {
    if (!node) {
      return false
    }
    const parent = this.findParentListItem(node)
    if (parent) {
      this.focusListItem(parent)
      return true
    }
    return false
  }

  protected findClosestListItem(e: Event): HTMLLIElement | undefined {
    const target = e.composedPath()[0] as HTMLElement
    return (target.closest('li[data-key][data-kind]:not([aria-disabled="true"])') as HTMLLIElement) || undefined
    // const path = e.composedPath()
    // while (path.length) {
    //   const target = path.shift() as Node
    //   if (!target) {
    //     break
    //   }
    //   if (target.nodeType !== Node.ELEMENT_NODE) {
    //     continue
    //   }
    //   if (!this._isValidListItem(target as Element)) {
    //     continue
    //   }
    //   return target as HTMLLIElement
    // }
    // return undefined
  }

  /**
   * Dispatched the `select` custom event with the `key` and `kind` on the detail.
   *
   * @param key The key of the selected item.
   * @param kind The kind of the selected item
   * @param parent Optional parent item.
   */
  protected notifySelection(key: string, kind: string, parent?: string, root?: string): void {
    this.dispatchEvent(
      new CustomEvent<ISelectDetail>('select', {
        detail: { key, kind, parent, root },
      })
    )
  }

  /**
   * A handler for the `dragstart` event dispatched when the item is draggable.
   * Calls the `setupDataTransfer()` if has valid target.
   */
  protected handleItemDragStart(e: DragEvent): void {
    if (e.defaultPrevented) {
      return
    }
    const node = this.findClosestListItem(e)
    const dt = e.dataTransfer
    if (!dt || !node) {
      return
    }
    this.draggingItem = node
    // do not cancel this event
    this.setupDataTransfer(node, dt)
  }

  /**
   * A handler for the `dragend` event dispatched when the item is draggable.
   * @param e The dragend event.
   */
  protected handleItemDragEnd(e: DragEvent): void {
    if (e.defaultPrevented) {
      return
    }
    const dt = e.dataTransfer
    if (!dt || !this.draggingItem) {
      return
    }
    this.unloadDataTransfer(this.draggingItem, dt)
    this.draggingItem = undefined
    // const node = this.findClosestListItem(e)
    // const dt = e.dataTransfer
    // if (!dt || !node) {
    //   return
    // }
    // e.preventDefault()
    // this.unloadDataTransfer(node, dt)
  }

  protected handleItemDragOver(e: DragEvent): void {
    if (e.defaultPrevented) {
      return
    }
    const node = this.findClosestListItem(e)
    if (!node) {
      return
    }
    const dt = e.dataTransfer
    if (!dt) {
      return
    }
    // This is required to enable drop event.
    e.preventDefault()
    const validTarget = this.isValidDropTarget(node, dt)
    if (validTarget) {
      node.classList.add('drop-target')
    }
    if (node.classList.contains('parent-item')) {
      this.scheduleGroupOpen(node.dataset.key as string, node)
    }
    this.processDragOver(node, dt, validTarget)
  }

  protected handleItemDragLeave(e: DragEvent): void {
    if (e.defaultPrevented) {
      return
    }
    // const node = e.composedPath()[0] as HTMLElement
    // if (node.localName !== 'li') {
    //   // We get a lot of false positive here when the user drags over the list item.
    //   // which interfeers with the dragleave event.
    //   // Because of that, we onlt react when the leave hapens on a list item only.
    //   return
    // }
    const node = this.findClosestListItem(e)
    if (!node) {
      return
    }
    const dt = e.dataTransfer
    if (!dt) {
      return
    }
    node.classList.remove('drop-target')
    if (node.classList.contains('parent-item')) {
      this.cancelGroupOpen(node.dataset.key as string)
    }
    this.processDragLeave(node as HTMLLIElement, dt)
  }

  protected handleItemDrop(e: DragEvent): void {
    if (e.defaultPrevented) {
      return
    }
    const node = this.findClosestListItem(e)
    if (!node) {
      return
    }
    const dt = e.dataTransfer
    if (!dt) {
      return
    }
    // This is required to prohibit the default action.
    e.preventDefault()
    node.classList.remove('drop-target')
    this.unloadDataTransfer(node, dt)
    this.processDrop(node, dt)
  }

  /**
   * Sets the common properties on the `DataTransfer`. Implementations can override this method to add
   * own properties.
   */
  protected setupDataTransfer(item: HTMLLIElement, dt: DataTransfer): void {
    const kind = item.dataset.kind
    const key = item.dataset.key
    if (!key || !kind) {
      return
    }
    dt.setData('text/kind', kind)
    dt.setData(kind, '')
    dt.setData('text/key', key)
    dt.setData('text/plain', key)
    dt.setData('text/source', this.localName)
    dt.effectAllowed = 'copyMove'

    // Setup the image
    const clone = item.cloneNode(true) as HTMLLIElement
    // removes children + toggle buttons
    clone.querySelectorAll('ul,.group-toggle-icon').forEach((i) => {
      i.remove()
    })
    // Setup the dragged width.
    const content = item.querySelector('.list-item-content') || item
    clone.style.width = `${content.clientWidth}px`
    // Marks the clone as a drag image and selected
    clone.classList.add('drag-image', 'selected')
    const root = this.shadowRoot as ShadowRoot
    root.appendChild(clone)
    dt.setDragImage(clone, 0, 0)
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  protected unloadDataTransfer(item: HTMLLIElement, dt: DataTransfer): void {
    const root = this.shadowRoot as ShadowRoot
    root.querySelectorAll('.drag-image').forEach((i) => {
      i.remove()
    })
  }

  protected handleNameInputKeydown(e: KeyboardEvent): void {
    e.stopPropagation()
    if (e.key === 'Escape') {
      this.edited = undefined
    } else if (e.key === 'Enter') {
      const input = e.target as HTMLInputElement
      const { key, kind } = input.dataset
      if (!key || !kind) {
        return
      }
      this.commitName(key, kind, input.value.trim())
    }
  }

  /**
   * Handles an event when something is dragged over another item.
   * Mind that it can be anything (files, text, etc.) so the implementation should
   * check the type of the data transfer.
   *
   * @param item The item that an object is dragged overt that item.
   * @param dt Data transfer instance.
   * @param validTarget True when the target is a valid drop target.
   */
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  protected processDragOver(item: HTMLLIElement, dt: DataTransfer, validTarget: boolean): void | Promise<void> {
    // ...
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  protected processDragLeave(item: HTMLLIElement, dt: DataTransfer): void | Promise<void> {
    // ...
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  protected processDrop(item: HTMLLIElement, dt: DataTransfer): void | Promise<void> {
    // ...
  }

  /**
   * To be implemented by the child class.
   * Checks whether the target is a valid drop target.
   * When used, it simplifies the sub-class logic by using this classes drag and drop handling.
   * @param target The target item that the object is dragged over.
   * @param dt The data transfer instance.
   * @returns true when the target is a valid drop target.
   */
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  protected isValidDropTarget(target: HTMLElement, dt: DataTransfer): boolean {
    return false
  }

  /**
   * A map of timeouts for opening groups.
   */
  private groupOpenTimeouts = new Map<string, number>()

  /**
   * Schedules opening a group after a delay.
   * @param key The key of the group to open.
   * @param item The list item element.
   */
  private scheduleGroupOpen(key: string, item: HTMLLIElement): void {
    // console.log(Date.now(), 'SchemaDesignNavigation.scheduleGroupOpen', key)
    if (item.ariaExpanded === 'true' || this.groupOpenTimeouts.has(key)) {
      // console.log(Date.now(), 'NOT NEEDED', key, item.ariaExpanded === 'true', this.groupOpenTimeouts.has(key))
      return
    }
    const timeoutId = window.setTimeout(() => {
      this.openGroup(key)
      this.groupOpenTimeouts.delete(key)
    }, 700)
    this.groupOpenTimeouts.set(key, timeoutId)
  }

  /**
   * Cancels a scheduled group open.
   * @param key The key of the group to cancel.
   */
  private cancelGroupOpen(key: string): void {
    // console.log(Date.now(), 'SchemaDesignNavigation.cancelGroupOpen', key)
    const timeoutId = this.groupOpenTimeouts.get(key)
    if (timeoutId !== undefined) {
      window.clearTimeout(timeoutId)
      this.groupOpenTimeouts.delete(key)
    }
  }

  protected handleNameCommit(e: Event): void {
    e.preventDefault()
    const input = (e.currentTarget as HTMLElement).previousElementSibling as HTMLInputElement
    if (!input || input.localName !== 'input') {
      return
    }
    const { key, kind } = input.dataset
    if (!key || !kind) {
      return
    }
    this.commitName(key, kind, input.value.trim())
  }

  /**
   * This should be overwritten by the implementation that support name change.
   * Method called when the user request to change a name of an item.
   *
   * @param key The key of the item.
   * @param kind The kind of the item.
   * @param name THe new name.
   */
  protected commitName(key: string, kind: string, name: string): void | Promise<void> {
    // eslint-disable-next-line no-console
    console.warn(`The commit name method is not set in the child class for ${key}, ${kind}, and ${name}.`)
  }

  protected handleDoubleClick(e: Event): void {
    if (e.defaultPrevented) {
      return
    }
    const node = this.findClosestListItem(e)
    if (!node) {
      return
    }
    this.handleEnterAction(node)
  }

  protected handleClick(e: Event): void {
    if (e.defaultPrevented) {
      return
    }
    const node = this.findClosestListItem(e)
    if (!node) {
      if (e.target === this) {
        this.deselect()
      }
      return
    }
    this.handleEnterAction(node)
    this.focusListItem(node)
    this.requestUpdate()
  }

  protected handleKeydown(e: KeyboardEvent): void {
    const node = this.findClosestListItem(e)
    if (!node) {
      return
    }
    switch (e.key) {
      case 'ArrowRight':
        this.handleRightAction(node)
        e.preventDefault()
        break
      case 'ArrowLeft':
        this.handleLeftAction(node)
        e.preventDefault()
        break
      case 'ArrowDown':
        this.handleDownAction(node)
        e.preventDefault()
        break
      case 'ArrowUp':
        this.handleUpAction(node)
        e.preventDefault()
        break
      case 'Home':
        this.handleHomeAction()
        e.preventDefault()
        break
      case 'End':
        this.handleEndAction()
        e.preventDefault()
        break
      case 'Enter':
        this.handleEnterAction(node)
        e.preventDefault()
        break
      default:
    }
  }

  protected renderLabel(): TemplateResult | typeof nothing {
    const { label } = this
    if (!label) {
      return nothing
    }
    return html` <div class="navigation-title">${label}</div> `
  }

  protected deselect(): void {
    this.dispatchEvent(new Event('deselect'))
    this.selected = undefined
    this.focused = undefined
    this.focusedItem = undefined
  }

  /**
   * Performs the default action of the currently focused node.
   * For parent nodes, it opens or closes the node.
   * In single-select trees, if the node has no children, selects the current node
   * if not already selected (which is the default action).
   */
  protected handleEnterAction(node: HTMLElement): void {
    const key = node.dataset.key as string
    const isFolder = node.classList.contains('parent-item')
    if (isFolder) {
      this.toggleGroup(key)
    }
    // don't guard this with checking whether the selection changed.
    // In a tabbed / split layout the item can be closed while the navigation item still can
    // be selected.
    this.selected = key
    this.notifySelection(key, node.dataset.kind as string, node.dataset.parent, node.dataset.root)
  }

  /**
   * 1. When focus is on a closed node, opens the node; focus does not move.
   * 2. When focus is on a open node, moves focus to the first child node.
   * 3. When focus is on an end node (a tree item with no children), does nothing.
   */
  protected handleRightAction(node: HTMLElement): void {
    const isFolder = node.classList.contains('parent-item')
    if (!isFolder) {
      return
    }
    const key = node.dataset.key as string
    if (!this.opened.has(key)) {
      // #1
      this.opened.add(key)
      this.requestUpdate()
      return
    }
    const size = Number(node.dataset.size)
    if (Number.isNaN(size) || !size) {
      // #3
      return
    }
    // #2
    this.focusFirstDescendant(node)
  }

  /**
   * 1. When focus is on an open node, closes the node.
   * 2. When focus is on a child node that is also either an end node or a closed node, moves focus to its parent node.
   * 3. When focus is on a closed `tree`, does nothing.
   */
  protected handleLeftAction(node: HTMLElement): void {
    const key = node.dataset.key as string
    const isFolder = node.classList.contains('parent-item')
    if (isFolder) {
      if (this.opened.has(key)) {
        // #1
        this.opened.delete(key)
        this.requestUpdate()
        return
      }
    }
    // #2
    this.focusFirstParent(node)
    // note, #3 won't happen here as the outer tree is always opened.
  }

  /**
   * Moves focus to the next node that is focusable without opening or closing a node.
   */
  protected handleDownAction(node: HTMLElement): void {
    const isFolder = node.classList.contains('parent-item')
    // try focusing on any child
    if (isFolder && this.focusFirstDescendant(node)) {
      return
    }
    // try focus on any next item.
    if (this.focusNextSibling(node)) {
      return
    }
    let parent = this.findParentListItem(node)
    while (parent) {
      if (this.focusNextSibling(parent)) {
        return
      }
      parent = this.findParentListItem(parent)
    }
  }

  /**
   * Moves focus to the previous node that is focusable without opening or closing a node.
   */
  protected handleUpAction(node: HTMLElement): void {
    if (this.focusPreviousSibling(node)) {
      return
    }
    this.focusFirstParent(node)
  }

  protected handleHomeAction(): void {
    const first = (this.shadowRoot as ShadowRoot).querySelector('ul')
    if (!first) {
      return
    }
    const children = Array.from(first.children) as HTMLElement[]
    for (const node of children) {
      if (this.isValidListItem(node)) {
        this.focusListItem(node)
        break
      }
    }
  }

  protected handleEndAction(): void {
    const first = (this.shadowRoot as ShadowRoot).querySelector('ul')
    if (!first) {
      return
    }
    const children = Array.from(first.children).reverse() as HTMLElement[]
    for (const node of children) {
      if (this.isValidListItem(node)) {
        this.focusListItem(node)
        break
      }
    }
  }

  /**
   *
   * @param contents The children contents.
   * @returns A predefined template for the outer list element.
   */
  protected renderOuterList(contents: TemplateResult | TemplateResult[] | string): TemplateResult {
    return html`
      <ul
        role="tree"
        aria-multiselectable="false"
        aria-labelledby="${this.label ? 'navigation-title' : nothing}"
        @dblclick="${this.handleDoubleClick}"
        @keydown="${this.handleKeydown}"
        @dragstart="${this.handleItemDragStart}"
        @dragend="${this.handleItemDragEnd}"
        @dragover="${this.handleItemDragOver}"
        @dragleave="${this.handleItemDragLeave}"
        @drop="${this.handleItemDrop}"
        class="root"
      >
        ${contents}
      </ul>
    `
  }

  protected renderParentListItem(
    key: string,
    kind: string,
    label: string,
    children: TemplateResult | TemplateResult[] | string,
    opts: IListItemRenderOptions = {}
  ): TemplateResult {
    const { selected, focused: _focused, edited } = this
    const opened = this.opened.has(key)
    const classes = {
      'parent-item': true,
      'selected': selected === key,
      'focused': _focused === key,
      opened,
    }
    return html`
      <li
        class="${classMap(classes)}"
        role="treeitem"
        aria-selected="false"
        aria-expanded="${opened ? 'true' : 'false'}"
        data-parent="${ifDefined(opts.parent)}"
        data-root="${ifDefined(opts.root)}"
        data-key="${key}"
        data-kind="${kind}"
        draggable="${opts.draggable ? 'true' : 'false'}"
        aria-disabled="${opts.disabled ? 'true' : 'false'}"
      >
        ${edited === key ? this.renderNameInput(key, kind, label) : this.renderParentListItemContent(label, opts)}
        <ul
          role="group"
          id="group${key}"
          data-parent="${ifDefined(opts.parent)}"
          ?hidden="${!opened}"
          aria-hidden="${opened ? 'false' : 'true'}"
        >
          ${children}
        </ul>
      </li>
    `
  }

  protected renderParentListItemContent(label: string, opts: IListItemRenderOptions = {}): TemplateResult {
    return html`
      <div class="list-item-content parent-content">
        <ui-icon icon="chevronRight" class="group-toggle-icon"></ui-icon>
        ${opts.parentIcon ? html`<ui-icon icon="${opts.parentIcon}" class="object-icon"></ui-icon>` : ''}
        <span class="label" title="${label}">${label}</span>
      </div>
    `
  }

  protected renderListItem(
    key: string,
    kind: string,
    label: string,
    content: TemplateResult | TemplateResult[] | string,
    opts: IListItemRenderOptions = {}
  ): TemplateResult {
    const { selected, focused, edited } = this
    if (edited === key) {
      const input = this.renderNameInput(key, kind, label)
      return html`
        <li
          role="treeitem"
          aria-selected="false"
          class="name-change-item"
          data-parent="${ifDefined(opts.parent)}"
          data-root="${ifDefined(opts.root)}"
          data-key="${key}"
          data-kind="${kind}"
        >
          ${input}
        </li>
      `
    }
    const classes = {
      selected: selected === key,
      focused: focused === key,
    }
    return html`
      <li
        class="${classMap(classes)}"
        role="treeitem"
        aria-selected="false"
        data-parent="${ifDefined(opts.parent)}"
        data-root="${ifDefined(opts.root)}"
        data-key="${key}"
        data-kind="${kind}"
        draggable="${opts.draggable ? 'true' : 'false'}"
        aria-disabled="${opts.disabled ? 'true' : 'false'}"
      >
        <div class="list-item-content">${content}</div>
      </li>
    `
  }

  protected renderNameInput(key: string, kind: string, oldName?: string): TemplateResult {
    return html`
      <div class="name-change" @click="${cancelEvent}" @keydown="${cancelEvent}" @dblclick="${cancelEvent}">
        <input
          type="text"
          .value=${oldName || ''}
          @keydown="${this.handleNameInputKeydown}"
          data-key="${key}"
          data-kind="${kind}"
        />
        <ui-icon-button @click="${this.handleNameCommit}" aria-label="Confirm name change" class="name-commit-button">
          <ui-icon icon="check"></ui-icon>
        </ui-icon-button>
      </div>
    `
  }

  protected renderItemContent(icon: IconType, label: string): TemplateResult {
    return html`
      <ui-icon icon="${icon}" class="object-icon"></ui-icon>
      <span class="label" title="${label}">${label}</span>
    `
  }
}
