// Credits go to Blueprintjs for API structure
// https://github.com/palantir/blueprint/blob/develop/packages/select/src/components/query-list/queryList.tsx

import m from 'mithril';
import classnames from 'classnames';
import debounce from 'lodash.debounce';
import { Classes, isFunction, Keys, safeCall, IAttrs, getClosest } from '../../_shared';
import { AbstractComponent } from '../abstract-component';
import { Icon, Icons } from '../icon';
import { List, IListAttrs, IListItemAttrs, ListItem } from '../list';
import { Input, IInputAttrs } from '../input';
import { ControlGroup, IControlGroupAttrs } from '../control-group';

type Direction = 'up' | 'down';

export interface IQueryListEvents {
  handleKeyDown: (e: KeyboardEvent) => void;
}

export interface IFilterableAttrs {
  /**
   * Attrs passed through to the ControlGroup component
   * @default {}
   */
  controlGroupAttrs?: IControlGroupAttrs;

  /** Right-justified content in relation to Input component  */
  contentRight?: m.Children;

  /** Left-justified content in relation to Input component  */
  contentLeft?: m.Children;

  /** Initial query value (uncontrolled mode) */
  defaultQuery?: string;

  /**
   * Toggles search input
   * @default true
   */
  filterable?: boolean;

  /** Callback invoked on input query change; only called when `query` is defined */
  onQueryChange?: (query: string) => void;

  /** Input query value (controlled mode) */
  query?: string;
}

export interface IQueryableAttrs<T> extends IAttrs {
  /** Current index position (controlled mode)  */
  activeIndex?: number;

  /**
   * When true, items will be "cached" when a query is specified.
   * When false, every redraw will call itemPredicate or itemListPredicate if a query is specified
   * @default true
   */
  cacheItems?: boolean;

  /**
   * Wether to show a checkmark for selected item(s)
   * @default true
   */
  checkmark?: boolean;

  /** Initial active index (uncontrolled mode)  */
  defaultActiveIndex?: number;

  /** Disables arrow key navigation and prevents highlighting of active item */
  disableArrowKeys?: boolean;

  /**
   * Content rendered when input query is empty. If defined, items will only be rendered
   * when a search query is provided.
   */
  initialContent?: m.Children;

  /**
   * Attrs passed through to Input component.
   * @default {}
   */
  inputAttrs?: IInputAttrs;

  /**
   * Custom render function for the entire list. If undefined, returns a List
   * component that calls `itemRender` for each item.
   */

  itemListRender?: (items: T[]) => m.Vnode;

  /**
   * Predicate function used to filter all items.
   * Takes predecent over `itemPredicate`
   */
  itemListPredicate?: (query: string, items: T[]) => T[];

  /** Render function applied to each item  */
  itemRender: (item: T, index: number) => m.Vnode<any, any>;

  /** Predicate function applied to filter individual items  */
  itemPredicate?: (query: string, item: T, index: number) => boolean;

  /** Array of T items */
  items: T[];

  /**
   * Element(s) shown when input query returns empty
   * @default 'No items available'
   */
  emptyContent?: m.Children;

  /** Callback invoked on active item change; only called when `activeIndex` is defined */
  onActiveItemChange?: (activeItem: T, index: number) => void;

  /** Callback invoked when child item is clicked */
  onSelect?: (item: T, e: Event, index: number) => void;

  /**
   * Attrs passed through to List component
   * @default {}
   */
  listAttrs?: IListAttrs;

  eventCallbacks?: (events: IQueryListEvents) => void;
}

export interface IQueryListAttrs<T> extends IQueryableAttrs<T>, IFilterableAttrs { }

export class QueryList<T> extends AbstractComponent<IQueryListAttrs<T>> {
  private filteredItems: T[] = [];
  private activeIndex: number;
  private itemNodes: m.Vnode<IListItemAttrs>[];
  private inputEl: HTMLElement;
  private query: string;
  private listEl: HTMLElement;

  public static ofType<T>() {
    return QueryList as new () => QueryList<T>;
  }

  public getDefaultAttrs() {
    return {
      cacheItems: true,
      checkmark: true,
      inputAttrs: {},
      listAttrs: {},
      filterable: true,
      controlGroupAttrs: {},
      emptyContent: 'No items available.'
    } as IQueryListAttrs<T>;
  }

  public oninit(vnode: m.Vnode<IQueryListAttrs<T>>) {
    super.oninit(vnode);

    this.query = this.attrs.defaultQuery || '';
    this.activeIndex = this.attrs.defaultActiveIndex || 0;
    this.setControlledAttrs();

    this.filteredItems = this.getFilteredItems();
  }

  public oncreate({ dom }: m.VnodeDOM<IQueryListAttrs<T>>) {
    this.listEl = dom.querySelector(`.${Classes.LIST}`) as HTMLElement;
    this.inputEl = dom.querySelector(`.${Classes.INPUT}`) as HTMLElement;

    this.scrollToActiveItem();
  }

  public onbeforeupdate(vnode: m.Vnode<IQueryListAttrs<T>>, old: m.VnodeDOM<IQueryListAttrs<T>>) {
    super.onbeforeupdate(vnode, old);
    this.setControlledAttrs();

    if (vnode.attrs.items !== old.attrs.items ||
      vnode.attrs.query !== old.attrs.query ||
      vnode.attrs.activeIndex !== old.attrs.activeIndex ||
      !vnode.attrs.cacheItems
    ) {
      this.filteredItems = this.getFilteredItems();
      this.scrollToActiveItem();
    }
  }

  public view() {
    const {
      activeIndex,
      cacheItems,
      checkmark,
      class: className,
      controlGroupAttrs,
      contentLeft,
      contentRight,
      defaultActiveIndex,
      defaultQuery,
      emptyContent,
      eventCallbacks,
      filterable,
      initialContent,
      inputAttrs,
      itemPredicate,
      itemListPredicate,
      itemListRender,
      itemRender,
      items,
      listAttrs,
      onActiveItemChange,
      onSelect,
      query,
      onQueryChange,
      ...htmlAttrs
    } = this.attrs;

    const classes = classnames(
      Classes.QUERY_LIST,
      checkmark && Classes.QUERY_LIST_CHECKMARK,
      className
    );

    safeCall(eventCallbacks, { handleKeyDown: this.handleKeyDown });

    const innerContent = [
      filterable && this.renderControlGroup(),
      this.renderList()
    ];

    return m('', {
      ...htmlAttrs,
      class: classes,
      onkeydown: this.handleKeyDown,
      tabindex: 0
    }, innerContent);
  }

  private renderControlGroup() {
    const {
      inputAttrs,
      controlGroupAttrs,
      contentLeft,
      contentRight
    } = this.attrs;

    return m(ControlGroup, {
      ...this.attrs.controlGroupAttrs,
      class: classnames(Classes.FLUID, controlGroupAttrs!.class)
    }, [
      contentLeft,

      m(Input, {
        placeholder: 'Search items...',
        ...inputAttrs,
        oninput: this.handleInput,
        contentRight: (this.query.length !== 0)
          ? m(Icon, {
            name: Icons.X,
            onclick: this.handleInputClear
          })
          : inputAttrs!.contentRight,
        value: this.query
      }),

      contentRight
    ]);
  }

  private renderList() {
    const { listAttrs, emptyContent, initialContent } = this.attrs;
    this.itemNodes = this.filteredItems.map(this.renderItem);
    const isEmpty = this.filteredItems.length === 0;
    const hasInitialContent = initialContent && this.query === '';

    const classes = classnames(
      isEmpty && Classes.QUERY_LIST_EMPTY,
      hasInitialContent && Classes.QUERY_LIST_INITIAL,
      listAttrs!.class
    );

    const emptyOrInitialContent = m(`.${Classes.QUERY_LIST_MESSAGE}`,
      (hasInitialContent && !isEmpty) && initialContent,
      isEmpty && emptyContent
    );

    const content = (hasInitialContent || isEmpty)
      ? emptyOrInitialContent
      : this.itemNodes;

    return m(List, {
      ...listAttrs,
      class: classes
    }, content);
  }

  private renderItem = (item: T, index: number) => {
    const { itemRender, disableArrowKeys, checkmark, listAttrs } = this.attrs;
    const listItem = itemRender(item, index) as m.Vnode<IListItemAttrs>;

    listItem.attrs = listItem.attrs || {};
    listItem.attrs.onclick = (e) => this.handleSelect(index, listItem.attrs.disabled!, e);

    if (!disableArrowKeys && this.activeIndex === index) {
      listItem.attrs.class = classnames(
        listItem.attrs.className,
        listItem.attrs.class,
        Classes.ACTIVE
      );
    }

    if (listItem.tag === ListItem) {
      if (listItem.attrs.selected && checkmark) {
        listItem.attrs.contentLeft = m(Icon, {
          name: Icons.CHECK,
          size: listAttrs!.size
        });
      }
    }

    return listItem;
  }

  private setControlledAttrs() {
    const { activeIndex, query } = this.attrs;

    if (query != null) {
      this.query = query;
    }

    if (activeIndex != null) {
      this.activeIndex = activeIndex === -1 ? 0 : activeIndex;
    }
  }

  public scrollToActiveItem() {
    const { listEl, activeIndex } = this;

    if (listEl && activeIndex >= 0) {
      const activeEl = listEl.children[activeIndex] as HTMLElement;

      if (!activeEl) return;

      const { offsetTop: activeTop, offsetHeight: activeHeight } = activeEl;

      const { scrollTop: listScrollTop, clientHeight: listHeight } = listEl;
      const activeBottomEdge = activeTop + activeHeight;
      const activeTopEdge = activeTop;

      if (activeBottomEdge >= listScrollTop + listHeight) {
        listEl.scrollTop = activeBottomEdge + activeHeight - listHeight;
      } else if (activeTopEdge <= listScrollTop) {
        listEl.scrollTop = activeTopEdge - activeHeight;
      }
    }
  }

  private get activeItem() {
    return this.filteredItems[this.activeIndex];
  }

  private updateQuery(text: string) {
    const { query, onQueryChange } = this.attrs;

    if (query != null) {
      safeCall(onQueryChange, text);
    } else this.query = text;
  }

  private handleInput = (e: Event) => {
    this.handleSearchDebounce(e);
    (e as any).redraw = false;
  }

  private handleSearchDebounce = debounce((e: Event) => {
    const value = (e.target as HTMLInputElement).value;

    this.updateQuery(value);
    this.filteredItems = this.getFilteredItems();
    this.updateActiveIndex(0);
    m.redraw();
  }, 200);

  private handleInputClear = () => {
    this.updateQuery('');
    this.updateActiveIndex(0);
    this.filteredItems = this.getFilteredItems();
    this.scrollToActiveItem();

    if (this.inputEl) {
      this.inputEl.focus();
    }
  }

  private handleSelect = (index: number, isDisabled: boolean, e: Event) => {
    const { onSelect } = this.attrs;
    const target = e.target as HTMLElement;
    const selectedItem = this.filteredItems[index];
    const actionsEl = getClosest(target, `.${Classes.LIST_ITEM_CONTENT_RIGHT}`);

    if (selectedItem != null && !actionsEl && !isDisabled) {
      this.updateActiveIndex(index);
      safeCall(onSelect, selectedItem, e, index);
    } else (e as any).redraw = false;
  }

  private handleKeyDown = (e: KeyboardEvent) => {
    const key = e.which;

    switch (key) {
      case Keys.ARROW_UP:
      case Keys.ARROW_DOWN:
        if (!this.attrs.disableArrowKeys) {
          e.preventDefault();
          this.moveActiveIndex(key === Keys.ARROW_UP ? 'up' : 'down');
          m.redraw();
        }
        break;
      case Keys.ESCAPE:
        if (this.query) {
          this.handleInputClear();
          m.redraw();
        }
        break;
      case Keys.ENTER:
        this.handleEnterKey(e);
        m.redraw();
        break;
      default:
        break;
    }

    (e as any).redraw = false;
  }

  private moveActiveIndex(direction: Direction) {
    const { activeIndex } = this;
    const index = getNextIndex(activeIndex, this.itemNodes, direction);

    this.updateActiveIndex(index);
    this.scrollToActiveItem();
  }

  private updateActiveIndex(index: number) {
    const { activeIndex, onActiveItemChange } = this.attrs;
    const currentIndex = index > this.filteredItems.length ? 0 : index;

    if (activeIndex != null) {
      safeCall(onActiveItemChange, this.activeItem, currentIndex);
    } else this.activeIndex = currentIndex;
  }

  private handleEnterKey(e: KeyboardEvent) {
    const item = this.activeItem;

    if (item != null) {
      safeCall(this.attrs.onSelect, item, e);
    }
  }

  private getFilteredItems() {
    const { items, itemPredicate, itemListPredicate } = this.attrs;

    if (isFunction(itemListPredicate)) {
      return itemListPredicate(this.query, items);
    }

    if (isFunction(itemPredicate)) {
      return items.filter((item, index) => itemPredicate(this.query, item, index));
    }

    return items;
  }
}

function getNextIndex(currentIndex: number, vnodes: m.Vnode<IListItemAttrs>[], direction: Direction) {
  const maxIndex = vnodes.length - 1;
  let index = currentIndex;
  let flag = true;

  if (index < 0 || maxIndex <= 0) {
    return 0;
  }

  while (flag) {
    index = direction === 'up'
      ? index === 0 ? maxIndex : index - 1
      : index === maxIndex ? 0 : index + 1;

    const vnode = vnodes[index];
    const attrs = vnode && vnode.attrs;

    if (attrs && !attrs.disabled) {
      flag = false;
    }
  }

  return index;
}
