/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.io/license
 */

import {QueryList} from '@angular/core';
import {Subject} from 'rxjs/Subject';
import {Subscription} from 'rxjs/Subscription';
import {UP_ARROW, DOWN_ARROW, TAB, A, Z, ZERO, NINE} from '@angular/cdk/keycodes';
import {debounceTime} from 'rxjs/operators/debounceTime';
import {filter} from 'rxjs/operators/filter';
import {map} from 'rxjs/operators/map';
import {tap} from 'rxjs/operators/tap';

/** This interface is for items that can be passed to a ListKeyManager. */
export interface ListKeyManagerOption {
  /** Whether the option is disabled. */
  disabled?: boolean;

  /** Gets the label for this option. */
  getLabel?(): string;
}

/**
 * This class manages keyboard events for selectable lists. If you pass it a query list
 * of items, it will set the active item correctly when arrow events occur.
 */
export class ListKeyManager<T extends ListKeyManagerOption> {
  private _activeItemIndex = -1;
  private _activeItem: T;
  private _wrap = false;
  private _letterKeyStream = new Subject<string>();
  private _typeaheadSubscription = Subscription.EMPTY;

  // Buffer for the letters that the user has pressed when the typeahead option is turned on.
  private _pressedLetters: string[] = [];

  constructor(private _items: QueryList<T>) { }

  /**
   * Stream that emits any time the TAB key is pressed, so components can react
   * when focus is shifted off of the list.
   */
  tabOut: Subject<void> = new Subject<void>();

  /** Stream that emits whenever the active item of the list manager changes. */
  change = new Subject<number>();

  /**
   * Turns on wrapping mode, which ensures that the active item will wrap to
   * the other end of list when there are no more items in the given direction.
   */
  withWrap(): this {
    this._wrap = true;
    return this;
  }

  /**
   * Turns on typeahead mode which allows users to set the active item by typing.
   * @param debounceInterval Time to wait after the last keystroke before setting the active item.
   */
  withTypeAhead(debounceInterval: number = 200): this {
    if (this._items.length && this._items.some(item => typeof item.getLabel !== 'function')) {
      throw Error('ListKeyManager items in typeahead mode must implement the `getLabel` method.');
    }

    this._typeaheadSubscription.unsubscribe();

    // Debounce the presses of non-navigational keys, collect the ones that correspond to letters
    // and convert those letters back into a string. Afterwards find the first item that starts
    // with that string and select it.
    this._typeaheadSubscription = this._letterKeyStream.pipe(
      tap(keyCode => this._pressedLetters.push(keyCode)),
      debounceTime(debounceInterval),
      filter(() => this._pressedLetters.length > 0),
      map(() => this._pressedLetters.join(''))
    ).subscribe(inputString => {
      const items = this._items.toArray();

      // Start at 1 because we want to start searching at the item immediately
      // following the current active item.
      for (let i = 1; i < items.length + 1; i++) {
        const index = (this._activeItemIndex + i) % items.length;
        const item = items[index];

        if (!item.disabled && item.getLabel!().toUpperCase().trim().indexOf(inputString) === 0) {
          this.setActiveItem(index);
          break;
        }
      }

      this._pressedLetters = [];
    });

    return this;
  }

  /**
   * Sets the active item to the item at the index specified.
   * @param index The index of the item to be set as active.
   */
  setActiveItem(index: number): void {
    const previousIndex = this._activeItemIndex;

    this._activeItemIndex = index;
    this._activeItem = this._items.toArray()[index];

    if (this._activeItemIndex !== previousIndex) {
      this.change.next(index);
    }
  }

  /**
   * Sets the active item depending on the key event passed in.
   * @param event Keyboard event to be used for determining which element should be active.
   */
  onKeydown(event: KeyboardEvent): void {
    switch (event.keyCode) {
      case DOWN_ARROW: this.setNextItemActive(); break;
      case UP_ARROW: this.setPreviousItemActive(); break;
      case TAB: this.tabOut.next(); return;
      default:
        const keyCode = event.keyCode;

        // Attempt to use the `event.key` which also maps it to the user's keyboard language,
        // otherwise fall back to resolving alphanumeric characters via the keyCode.
        if (event.key && event.key.length === 1) {
          this._letterKeyStream.next(event.key.toLocaleUpperCase());
        } else if ((keyCode >= A && keyCode <= Z) || (keyCode >= ZERO && keyCode <= NINE)) {
          this._letterKeyStream.next(String.fromCharCode(keyCode));
        }

        // Note that we return here, in order to avoid preventing
        // the default action of non-navigational keys.
        return;
    }

    this._pressedLetters = [];
    event.preventDefault();
  }

  /** Index of the currently active item. */
  get activeItemIndex(): number | null {
    return this._activeItemIndex;
  }

  /** The active item. */
  get activeItem(): T | null {
    return this._activeItem;
  }

  /** Sets the active item to the first enabled item in the list. */
  setFirstItemActive(): void {
    this._setActiveItemByIndex(0, 1);
  }

  /** Sets the active item to the last enabled item in the list. */
  setLastItemActive(): void {
    this._setActiveItemByIndex(this._items.length - 1, -1);
  }

  /** Sets the active item to the next enabled item in the list. */
  setNextItemActive(): void {
    this._activeItemIndex < 0 ? this.setFirstItemActive() : this._setActiveItemByDelta(1);
  }

  /** Sets the active item to a previous enabled item in the list. */
  setPreviousItemActive(): void {
    this._activeItemIndex < 0 && this._wrap ? this.setLastItemActive()
                                            : this._setActiveItemByDelta(-1);
  }

  /**
   * Allows setting of the activeItemIndex without any other effects.
   * @param index The new activeItemIndex.
   */
  updateActiveItemIndex(index: number) {
    this._activeItemIndex = index;
  }

  /**
   * This method sets the active item, given a list of items and the delta between the
   * currently active item and the new active item. It will calculate differently
   * depending on whether wrap mode is turned on.
   */
  private _setActiveItemByDelta(delta: number, items = this._items.toArray()): void {
    this._wrap ? this._setActiveInWrapMode(delta, items)
               : this._setActiveInDefaultMode(delta, items);
  }

  /**
   * Sets the active item properly given "wrap" mode. In other words, it will continue to move
   * down the list until it finds an item that is not disabled, and it will wrap if it
   * encounters either end of the list.
   */
  private _setActiveInWrapMode(delta: number, items: T[]): void {
    // when active item would leave menu, wrap to beginning or end
    this._activeItemIndex =
      (this._activeItemIndex + delta + items.length) % items.length;

    // skip all disabled menu items recursively until an enabled one is reached
    if (items[this._activeItemIndex].disabled) {
      this._setActiveInWrapMode(delta, items);
    } else {
      this.setActiveItem(this._activeItemIndex);
    }
  }

  /**
   * Sets the active item properly given the default mode. In other words, it will
   * continue to move down the list until it finds an item that is not disabled. If
   * it encounters either end of the list, it will stop and not wrap.
   */
  private _setActiveInDefaultMode(delta: number, items: T[]): void {
    this._setActiveItemByIndex(this._activeItemIndex + delta, delta, items);
  }

  /**
   * Sets the active item to the first enabled item starting at the index specified. If the
   * item is disabled, it will move in the fallbackDelta direction until it either
   * finds an enabled item or encounters the end of the list.
   */
  private _setActiveItemByIndex(index: number, fallbackDelta: number,
                                  items = this._items.toArray()): void {
    if (!items[index]) { return; }
    while (items[index].disabled) {
      index += fallbackDelta;
      if (!items[index]) { return; }
    }
    this.setActiveItem(index);
  }
}
