// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */

import type * as Common from '../../core/common/common.js';
import * as Platform from '../../core/platform/platform.js';
import * as VisualLogging from '../visual_logging/visual_logging.js';

import * as ARIAUtils from './ARIAUtils.js';
import {Events as ListModelEvents, type ItemsReplacedEvent, type ListModel} from './ListModel.js';
import {measurePreferredSize} from './UIUtils.js';

export interface ListDelegate<T> {
  createElementForItem(item: T): Element;

  /**
   * This method is not called in NonViewport mode.
   * Return zero to make list measure the item (only works in SameHeight mode).
   */
  heightForItem(item: T): number;
  isItemSelectable(item: T): boolean;
  selectedItemChanged(from: T|null, to: T|null, fromElement: HTMLElement|null, toElement: HTMLElement|null): void;
  updateSelectedItemARIA(fromElement: Element|null, toElement: Element|null): boolean;
}

export enum ListMode {
  /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
  NonViewport = 'UI.ListMode.NonViewport',
  EqualHeightItems = 'UI.ListMode.EqualHeightItems',
  VariousHeightItems = 'UI.ListMode.VariousHeightItems',
  /* eslint-enable @typescript-eslint/naming-convention */
}

export class ListControl<T> {
  element: HTMLDivElement;
  private topElement: HTMLElement;
  private bottomElement: HTMLElement;
  private firstIndex: number;
  private lastIndex: number;
  private renderedHeight: number;
  private topHeight: number;
  private bottomHeight: number;
  private model: ListModel<T>;
  private itemToElement: Map<T, Element>;
  #selectedIndex: number;
  #selectedItem: T|null;
  private delegate: ListDelegate<T>;
  private readonly mode: ListMode;
  private fixedHeight: number;
  private variableOffsets: Int32Array;

  constructor(model: ListModel<T>, delegate: ListDelegate<T>, mode?: ListMode) {
    this.element = document.createElement('div');
    this.element.style.overflowY = 'auto';
    this.topElement = this.element.createChild('div');
    this.bottomElement = this.element.createChild('div');
    this.firstIndex = 0;
    this.lastIndex = 0;
    this.renderedHeight = 0;
    this.topHeight = 0;
    this.bottomHeight = 0;

    this.model = model;
    this.model.addEventListener(ListModelEvents.ITEMS_REPLACED, this.replacedItemsInRange, this);
    this.itemToElement = new Map();
    this.#selectedIndex = -1;
    this.#selectedItem = null;

    this.element.tabIndex = -1;
    this.element.addEventListener('click', this.onClick.bind(this), false);
    this.element.addEventListener('keydown', this.onKeyDown.bind(this), false);
    ARIAUtils.markAsListBox(this.element);

    this.delegate = delegate;
    this.mode = mode || ListMode.EqualHeightItems;
    this.fixedHeight = 0;
    this.variableOffsets = new Int32Array(0);
    this.clearContents();

    if (this.mode !== ListMode.NonViewport) {
      this.element.addEventListener('scroll', () => {
        this.updateViewport(this.element.scrollTop, this.element.offsetHeight);
      }, false);
    }
  }

  setModel(model: ListModel<T>): void {
    this.itemToElement.clear();
    const length = this.model.length;
    this.model.removeEventListener(ListModelEvents.ITEMS_REPLACED, this.replacedItemsInRange, this);
    this.model = model;
    this.model.addEventListener(ListModelEvents.ITEMS_REPLACED, this.replacedItemsInRange, this);
    this.invalidateRange(0, length);
  }

  private replacedItemsInRange(event: Common.EventTarget.EventTargetEvent<ItemsReplacedEvent<T>>): void {
    const data = event.data;
    const from = data.index;
    const to = from + data.removed.length;
    const keepSelectedIndex = data.keepSelectedIndex;

    const oldSelectedItem = this.#selectedItem;
    const oldSelectedElement = oldSelectedItem !== null ? (this.itemToElement.get(oldSelectedItem) || null) : null;
    for (let i = 0; i < data.removed.length; i++) {
      this.itemToElement.delete(data.removed[i]);
    }
    this.invalidate(from, to, data.inserted);

    if (this.#selectedIndex >= to) {
      this.#selectedIndex += data.inserted - (to - from);
      this.#selectedItem = this.model.at(this.#selectedIndex);
    } else if (this.#selectedIndex >= from) {
      const selectableIndex = keepSelectedIndex ? from : from + data.inserted;
      let index = this.findFirstSelectable(selectableIndex, +1, false);
      if (index === -1) {
        const alternativeSelectableIndex = keepSelectedIndex ? from : from - 1;
        index = this.findFirstSelectable(alternativeSelectableIndex, -1, false);
      }
      this.select(index, oldSelectedItem, oldSelectedElement);
    }
  }

  refreshItem(item: T): void {
    const index = this.model.indexOf(item);
    if (index === -1) {
      console.error('Item to refresh is not present');
      return;
    }
    this.refreshItemByIndex(index);
  }

  refreshItemByIndex(index: number): void {
    const item = this.model.at(index);
    this.itemToElement.delete(item);
    this.invalidateRange(index, index + 1);
    if (this.#selectedIndex !== -1) {
      this.select(this.#selectedIndex, null, null);
    }
  }

  refreshAllItems(): void {
    this.itemToElement.clear();
    this.invalidateRange(0, this.model.length);
    if (this.#selectedIndex !== -1) {
      this.select(this.#selectedIndex, null, null);
    }
  }

  invalidateRange(from: number, to: number): void {
    this.invalidate(from, to, to - from);
  }

  viewportResized(): void {
    if (this.mode === ListMode.NonViewport) {
      return;
    }
    // TODO(dgozman): try to keep visible scrollTop the same.
    const scrollTop = this.element.scrollTop;
    const viewportHeight = this.element.offsetHeight;
    this.clearViewport();
    this.updateViewport(
        Platform.NumberUtilities.clamp(scrollTop, 0, this.totalHeight() - viewportHeight), viewportHeight);
  }

  invalidateItemHeight(): void {
    if (this.mode !== ListMode.EqualHeightItems) {
      console.error('Only supported in equal height items mode');
      return;
    }
    this.fixedHeight = 0;
    if (this.model.length) {
      this.itemToElement.clear();
      this.invalidate(0, this.model.length, this.model.length);
    }
  }

  itemForNode(node: Node|null): T|null {
    while (node && node.parentNodeOrShadowHost() !== this.element) {
      node = node.parentNodeOrShadowHost();
    }
    if (!node) {
      return null;
    }
    const element = (node as Element);
    const index = this.model.findIndex(item => this.itemToElement.get(item) === element);
    return index !== -1 ? this.model.at(index) : null;
  }

  scrollItemIntoView(item: T, center?: boolean): void {
    const index = this.model.indexOf(item);
    if (index === -1) {
      console.error('Attempt to scroll onto missing item');
      return;
    }
    this.scrollIntoView(index, center);
  }

  selectedItem(): T|null {
    return this.#selectedItem;
  }

  selectedIndex(): number {
    return this.#selectedIndex;
  }

  selectItem(item: T|null, center?: boolean, dontScroll?: boolean): void {
    let index = -1;
    if (item !== null) {
      index = this.model.indexOf(item);
      if (index === -1) {
        console.error('Attempt to select missing item');
        return;
      }
      if (!this.delegate.isItemSelectable(item)) {
        console.error('Attempt to select non-selectable item');
        return;
      }
    }
    // Scrolling the item before selection ensures it is in the DOM.
    if (index !== -1 && !dontScroll) {
      this.scrollIntoView(index, center);
    }
    if (this.#selectedIndex !== index) {
      this.select(index);
    }
  }

  selectPreviousItem(canWrap?: boolean, center?: boolean): boolean {
    if (this.#selectedIndex === -1 && !canWrap) {
      return false;
    }
    let index: number = this.#selectedIndex === -1 ? this.model.length - 1 : this.#selectedIndex - 1;
    index = this.findFirstSelectable(index, -1, Boolean(canWrap));
    if (index !== -1) {
      this.scrollIntoView(index, center);
      this.select(index);
      return true;
    }
    return false;
  }

  selectNextItem(canWrap?: boolean, center?: boolean): boolean {
    if (this.#selectedIndex === -1 && !canWrap) {
      return false;
    }
    let index: number = this.#selectedIndex === -1 ? 0 : this.#selectedIndex + 1;
    index = this.findFirstSelectable(index, +1, Boolean(canWrap));
    if (index !== -1) {
      this.scrollIntoView(index, center);
      this.select(index);
      return true;
    }
    return false;
  }

  selectItemPreviousPage(center?: boolean): boolean {
    if (this.mode === ListMode.NonViewport) {
      return false;
    }
    let index: number = this.#selectedIndex === -1 ? this.model.length - 1 : this.#selectedIndex;
    index = this.findPageSelectable(index, -1);
    if (index !== -1) {
      this.scrollIntoView(index, center);
      this.select(index);
      return true;
    }
    return false;
  }

  selectItemNextPage(center?: boolean): boolean {
    if (this.mode === ListMode.NonViewport) {
      return false;
    }
    let index: number = this.#selectedIndex === -1 ? 0 : this.#selectedIndex;
    index = this.findPageSelectable(index, +1);
    if (index !== -1) {
      this.scrollIntoView(index, center);
      this.select(index);
      return true;
    }
    return false;
  }

  selectFirstItem(center?: boolean): boolean {
    const index = this.findFirstSelectable(0, +1, false);
    if (index !== -1) {
      this.scrollIntoView(index, center);
      this.select(index);
      return true;
    }
    return false;
  }

  selectLastItem(center?: boolean): boolean {
    const index = this.findFirstSelectable(this.model.length - 1, -1, false);
    if (index !== -1) {
      this.scrollIntoView(index, center);
      this.select(index);
      return true;
    }
    return false;
  }

  private scrollIntoView(index: number, center?: boolean): void {
    if (this.mode === ListMode.NonViewport) {
      this.elementAtIndex(index).scrollIntoViewIfNeeded(Boolean(center));
      return;
    }

    const top = this.offsetAtIndex(index);
    const bottom = this.offsetAtIndex(index + 1);
    const viewportHeight = this.element.offsetHeight;
    if (center) {
      const scrollTo = (top + bottom) / 2 - viewportHeight / 2;
      this.updateViewport(
          Platform.NumberUtilities.clamp(scrollTo, 0, this.totalHeight() - viewportHeight), viewportHeight);
      return;
    }

    const scrollTop = this.element.scrollTop;
    if (top < scrollTop) {
      this.updateViewport(top, viewportHeight);
    } else if (bottom > scrollTop + viewportHeight) {
      this.updateViewport(bottom - viewportHeight, viewportHeight);
    }
  }

  private onClick(event: Event): void {
    const item = this.itemForNode((event.target as Node | null));
    if (item !== null && this.delegate.isItemSelectable(item)) {
      this.selectItem(item);
    }
  }

  private onKeyDown(event: KeyboardEvent): void {
    let selected = false;
    switch (event.key) {
      case 'ArrowUp':
        selected = this.selectPreviousItem(true, false);
        break;
      case 'ArrowDown':
        selected = this.selectNextItem(true, false);
        break;
      case 'PageUp':
        selected = this.selectItemPreviousPage(false);
        break;
      case 'PageDown':
        selected = this.selectItemNextPage(false);
        break;
      case 'Home':
        selected = this.selectFirstItem();
        break;
      case 'End':
        selected = this.selectLastItem();
        break;
    }
    if (selected) {
      event.consume(true);
    }
  }

  private totalHeight(): number {
    return this.offsetAtIndex(this.model.length);
  }

  private indexAtOffset(offset: number): number {
    if (this.mode === ListMode.NonViewport) {
      throw new Error('There should be no offset conversions in non-viewport mode');
    }
    if (!this.model.length || offset < 0) {
      return 0;
    }
    if (this.mode === ListMode.VariousHeightItems) {
      return Math.min(
          this.model.length - 1,
          Platform.ArrayUtilities.lowerBound(
              this.variableOffsets, offset, Platform.ArrayUtilities.DEFAULT_COMPARATOR, 0, this.model.length));
    }
    if (!this.fixedHeight) {
      this.measureHeight();
    }
    return Math.min(this.model.length - 1, Math.floor(offset / this.fixedHeight));
  }

  elementAtIndex(index: number): Element {
    const item = this.model.at(index);
    let element = this.itemToElement.get(item);
    if (!element) {
      element = this.delegate.createElementForItem(item);
      if (!element.hasAttribute('jslog')) {
        element.setAttribute('jslog', `${VisualLogging.item().track({
                               click: true,
                               resize: true,
                               keydown: 'ArrowUp|ArrowDown|PageUp|PageDown|Home|End'
                             })}`);
      }
      this.itemToElement.set(item, element);
      this.updateElementARIA(element, index);
    }
    return element;
  }

  private refreshARIA(): void {
    for (let index = this.firstIndex; index <= this.lastIndex; index++) {
      const item = this.model.at(index);
      const element = this.itemToElement.get(item);
      if (element) {
        this.updateElementARIA(element, index);
      }
    }
  }

  private updateElementARIA(element: Element, index: number): void {
    if (!ARIAUtils.hasRole(element)) {
      ARIAUtils.markAsOption(element);
    }
    ARIAUtils.setSetSize(element, this.model.length);
    ARIAUtils.setPositionInSet(element, index + 1);
  }

  private offsetAtIndex(index: number): number {
    if (this.mode === ListMode.NonViewport) {
      throw new Error('There should be no offset conversions in non-viewport mode');
    }
    if (!this.model.length) {
      return 0;
    }
    if (this.mode === ListMode.VariousHeightItems) {
      return this.variableOffsets[index];
    }
    if (!this.fixedHeight) {
      this.measureHeight();
    }
    return index * this.fixedHeight;
  }

  private measureHeight(): void {
    this.fixedHeight = this.delegate.heightForItem(this.model.at(0));
    if (!this.fixedHeight) {
      this.fixedHeight = measurePreferredSize(this.elementAtIndex(0), this.element).height;
    }
  }

  private select(index: number, oldItem?: T|null, oldElement?: Element|null): void {
    if (oldItem === undefined) {
      oldItem = this.#selectedItem;
    }
    if (oldElement === undefined) {
      oldElement = this.itemToElement.get((oldItem as T)) || null;
    }
    this.#selectedIndex = index;
    this.#selectedItem = index === -1 ? null : this.model.at(index);
    const newItem = this.#selectedItem;
    const newElement = this.#selectedIndex !== -1 ? this.elementAtIndex(index) : null;
    this.delegate.selectedItemChanged(
        oldItem, newItem, (oldElement as HTMLElement | null), (newElement as HTMLElement | null));
    if (!this.delegate.updateSelectedItemARIA((oldElement), newElement)) {
      if (oldElement) {
        ARIAUtils.setSelected(oldElement, false);
      }
      if (newElement) {
        ARIAUtils.setSelected(newElement, true);
      }
      ARIAUtils.setActiveDescendant(this.element, newElement);
    }
  }

  private findFirstSelectable(index: number, direction: number, canWrap: boolean): number {
    const length = this.model.length;
    if (!length) {
      return -1;
    }
    for (let step = 0; step <= length; step++) {
      if (index < 0 || index >= length) {
        if (!canWrap) {
          return -1;
        }
        index = (index + length) % length;
      }
      if (this.delegate.isItemSelectable(this.model.at(index))) {
        return index;
      }
      index += direction;
    }
    return -1;
  }

  private findPageSelectable(index: number, direction: number): number {
    let lastSelectable = -1;
    const startOffset = this.offsetAtIndex(index);
    // Compensate for zoom rounding errors with -1.
    const viewportHeight = this.element.offsetHeight - 1;
    while (index >= 0 && index < this.model.length) {
      if (this.delegate.isItemSelectable(this.model.at(index))) {
        if (Math.abs(this.offsetAtIndex(index) - startOffset) >= viewportHeight) {
          return index;
        }
        lastSelectable = index;
      }
      index += direction;
    }
    return lastSelectable;
  }

  private reallocateVariableOffsets(length: number, copyTo: number): void {
    if (this.variableOffsets.length < length) {
      const variableOffsets = new Int32Array(Math.max(length, this.variableOffsets.length * 2));
      variableOffsets.set(this.variableOffsets.slice(0, copyTo), 0);
      this.variableOffsets = variableOffsets;
    } else if (this.variableOffsets.length >= 2 * length) {
      const variableOffsets = new Int32Array(length);
      variableOffsets.set(this.variableOffsets.slice(0, copyTo), 0);
      this.variableOffsets = variableOffsets;
    }
  }

  private invalidate(from: number, to: number, inserted: number): void {
    if (this.mode === ListMode.NonViewport) {
      this.invalidateNonViewportMode(from, to - from, inserted);
      return;
    }

    if (this.mode === ListMode.VariousHeightItems) {
      this.reallocateVariableOffsets(this.model.length + 1, from + 1);
      for (let i = from + 1; i <= this.model.length; i++) {
        this.variableOffsets[i] = this.variableOffsets[i - 1] + this.delegate.heightForItem(this.model.at(i - 1));
      }
    }

    const viewportHeight = this.element.offsetHeight;
    const totalHeight = this.totalHeight();
    const scrollTop = this.element.scrollTop;

    if (this.renderedHeight < viewportHeight || totalHeight < viewportHeight) {
      this.clearViewport();
      this.updateViewport(Platform.NumberUtilities.clamp(scrollTop, 0, totalHeight - viewportHeight), viewportHeight);
      return;
    }

    const heightDelta = totalHeight - this.renderedHeight;
    if (to <= this.firstIndex) {
      const topHeight = this.topHeight + heightDelta;
      this.topElement.style.height = topHeight + 'px';
      this.element.scrollTop = scrollTop + heightDelta;
      this.topHeight = topHeight;
      this.renderedHeight = totalHeight;
      const indexDelta = inserted - (to - from);
      this.firstIndex += indexDelta;
      this.lastIndex += indexDelta;
      return;
    }

    if (from >= this.lastIndex) {
      const bottomHeight = this.bottomHeight + heightDelta;
      this.bottomElement.style.height = bottomHeight + 'px';
      this.bottomHeight = bottomHeight;
      this.renderedHeight = totalHeight;
      return;
    }

    // TODO(dgozman): try to keep visible scrollTop the same
    // when invalidating after firstIndex but before first visible element.
    this.clearViewport();
    this.updateViewport(Platform.NumberUtilities.clamp(scrollTop, 0, totalHeight - viewportHeight), viewportHeight);
    this.refreshARIA();
  }

  private invalidateNonViewportMode(start: number, remove: number, add: number): void {
    let startElement: HTMLElement = this.topElement;
    for (let index = 0; index < start; index++) {
      startElement = (startElement.nextElementSibling as HTMLElement);
    }
    while (remove--) {
      (startElement.nextElementSibling as HTMLElement).remove();
    }
    while (add--) {
      this.element.insertBefore(this.elementAtIndex(start + add), startElement.nextElementSibling);
    }
  }

  private clearViewport(): void {
    if (this.mode === ListMode.NonViewport) {
      console.error('There should be no viewport updates in non-viewport mode');
      return;
    }
    this.firstIndex = 0;
    this.lastIndex = 0;
    this.renderedHeight = 0;
    this.topHeight = 0;
    this.bottomHeight = 0;
    this.clearContents();
  }

  private clearContents(): void {
    // Note: this method should not force layout. Be careful.
    this.topElement.style.height = '0';
    this.bottomElement.style.height = '0';
    this.element.removeChildren();
    this.element.appendChild(this.topElement);
    this.element.appendChild(this.bottomElement);
  }

  private updateViewport(scrollTop: number, viewportHeight: number): void {
    // Note: this method should not force layout. Be careful.
    if (this.mode === ListMode.NonViewport) {
      console.error('There should be no viewport updates in non-viewport mode');
      return;
    }
    const totalHeight = this.totalHeight();
    if (!totalHeight) {
      this.firstIndex = 0;
      this.lastIndex = 0;
      this.topHeight = 0;
      this.bottomHeight = 0;
      this.renderedHeight = 0;
      this.topElement.style.height = '0';
      this.bottomElement.style.height = '0';
      return;
    }

    const firstIndex = this.indexAtOffset(scrollTop - viewportHeight);
    const lastIndex = this.indexAtOffset(scrollTop + 2 * viewportHeight) + 1;

    while (this.firstIndex < Math.min(firstIndex, this.lastIndex)) {
      this.elementAtIndex(this.firstIndex).remove();
      this.firstIndex++;
    }
    while (this.lastIndex > Math.max(lastIndex, this.firstIndex)) {
      this.elementAtIndex(this.lastIndex - 1).remove();
      this.lastIndex--;
    }

    this.firstIndex = Math.min(this.firstIndex, lastIndex);
    this.lastIndex = Math.max(this.lastIndex, firstIndex);
    for (let index = this.firstIndex - 1; index >= firstIndex; index--) {
      const element = this.elementAtIndex(index);
      this.element.insertBefore(element, this.topElement.nextSibling);
    }
    for (let index = this.lastIndex; index < lastIndex; index++) {
      const element = this.elementAtIndex(index);
      this.element.insertBefore(element, this.bottomElement);
    }

    this.firstIndex = firstIndex;
    this.lastIndex = lastIndex;
    this.topHeight = this.offsetAtIndex(firstIndex);
    this.topElement.style.height = this.topHeight + 'px';
    this.bottomHeight = totalHeight - this.offsetAtIndex(lastIndex);
    this.bottomElement.style.height = this.bottomHeight + 'px';
    this.renderedHeight = totalHeight;
    this.element.scrollTop = scrollTop;
  }
}
