// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import m from 'mithril';
import {classNames} from '../base/classnames';
import {FuzzySegment} from '../base/fuzzy';
import {isString} from '../base/object_utils';
import {exists} from '../base/utils';
import {raf} from '../core/raf_scheduler';
import {EmptyState} from '../widgets/empty_state';
import {KeycapGlyph} from '../widgets/hotkey_glyphs';
import {Popup} from '../widgets/popup';

interface OmniboxOptionRowAttrs {
  // Human readable display name for the option.
  // This can either be a simple string, or a list of fuzzy segments in which
  // case highlighting will be applied to the matching segments.
  displayName: FuzzySegment[] | string;

  // Highlight this option.
  highlighted: boolean;

  // Arbitrary components to put on the right hand side of the option.
  rightContent?: m.Children;

  // Some tag to place on the right (to the left of the right content).
  label?: string;

  // Additional attrs forwarded to the underlying element.
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  [htmlAttrs: string]: any;
}

class OmniboxOptionRow implements m.ClassComponent<OmniboxOptionRowAttrs> {
  private highlightedBefore = false;

  view({attrs}: m.Vnode<OmniboxOptionRowAttrs>): void | m.Children {
    const {displayName, highlighted, rightContent, label, ...htmlAttrs} = attrs;
    return m(
      'li',
      {
        class: classNames(highlighted && 'pf-highlighted'),
        ...htmlAttrs,
      },
      m('span.pf-title', this.renderTitle(displayName)),
      label && m('span.pf-tag', label),
      rightContent,
    );
  }

  private renderTitle(title: FuzzySegment[] | string): m.Children {
    if (isString(title)) {
      return title;
    } else {
      return title.map(({matching, value}) => {
        return matching ? m('b', value) : value;
      });
    }
  }

  onupdate({attrs, dom}: m.VnodeDOM<OmniboxOptionRowAttrs, this>) {
    if (this.highlightedBefore !== attrs.highlighted) {
      if (attrs.highlighted) {
        dom.scrollIntoView({block: 'nearest'});
      }
      this.highlightedBefore = attrs.highlighted;
    }
  }
}

// Omnibox option.
export interface OmniboxOption {
  // The value to place into the omnibox. This is what's returned in onSubmit.
  key: string;

  // Display name provided as a string or a list of fuzzy segments to enable
  // fuzzy match highlighting.
  displayName: FuzzySegment[] | string;

  // Some tag to place on the right (to the left of the right content).
  tag?: string;

  // Arbitrary components to put on the right hand side of the option.
  rightContent?: m.Children;
}

export interface OmniboxAttrs {
  // Current value of the omnibox input.
  value: string;

  // What to show when value is blank.
  placeholder?: string;

  // Called when the text changes.
  onInput?: (value: string, previousValue: string) => void;

  // Class or list of classes to append to the Omnibox element.
  extraClasses?: string;

  // Called on close.
  onClose?: () => void;

  // Dropdown items to show. If none are supplied, the omnibox runs in free text
  // mode, where anyt text can be input. Otherwise, onSubmit will always be
  // called with one of the options.
  // Options are provided in groups called categories. If the category has a
  // name the name will be listed at the top of the group rendered with a little
  // divider as well.
  options?: OmniboxOption[];

  // Called when the user expresses the intent to "execute" the thing.
  onSubmit?: (value: string, mod: boolean, shift: boolean) => void;

  // Called when the user hits backspace when the field is empty.
  onGoBack?: () => void;

  // When true, disable and grey-out the omnibox's input.
  readonly?: boolean;

  // Ref to use on the input - useful for extracing this element from the DOM.
  inputRef?: string;

  // Whether to close when the user presses Enter. Default = false.
  closeOnSubmit?: boolean;

  // Whether to close the omnibox (i.e. call the |onClose| handler) when we
  // click outside the omnibox or its dropdown. Default = false.
  closeOnOutsideClick?: boolean;

  // Some content to place into the right hand side of the after the input.
  rightContent?: m.Children;

  // If we have options, this value indicates the index of the option which
  // is currently highlighted.
  selectedOptionIndex?: number;

  // Callback for when the user pressed up/down, expressing a desire to change
  // the |selectedOptionIndex|.
  onSelectedOptionChanged?: (index: number) => void;
}

export class Omnibox implements m.ClassComponent<OmniboxAttrs> {
  private popupElement?: HTMLElement;
  private dom?: Element;
  private attrs?: OmniboxAttrs;

  view({attrs}: m.Vnode<OmniboxAttrs>): m.Children {
    const {
      value,
      placeholder,
      extraClasses,
      onInput = () => {},
      onSubmit = () => {},
      onGoBack = () => {},
      inputRef = 'omnibox',
      options,
      closeOnSubmit = false,
      rightContent,
      selectedOptionIndex = 0,
    } = attrs;

    return m(
      Popup,
      {
        onPopupMount: (dom: HTMLElement) => (this.popupElement = dom),
        onPopupUnMount: (_dom: HTMLElement) => (this.popupElement = undefined),
        isOpen: exists(options),
        showArrow: false,
        matchWidth: true,
        offset: 2,
        trigger: m(
          '.omnibox',
          {
            class: extraClasses,
          },
          m('input', {
            ref: inputRef,
            value,
            placeholder,
            oninput: (e: Event) => {
              onInput((e.target as HTMLInputElement).value, value);
            },
            onkeydown: (e: KeyboardEvent) => {
              if (e.key === 'Backspace' && value === '') {
                onGoBack();
              } else if (e.key === 'Escape') {
                e.preventDefault();
                this.close(attrs);
              }

              if (options) {
                if (e.key === 'ArrowUp') {
                  e.preventDefault();
                  this.highlightPreviousOption(attrs);
                } else if (e.key === 'ArrowDown') {
                  e.preventDefault();
                  this.highlightNextOption(attrs);
                } else if (e.key === 'Enter') {
                  e.preventDefault();

                  const option = options[selectedOptionIndex];
                  // Return values from indexing arrays can be undefined.
                  // We should enable noUncheckedIndexedAccess in
                  // tsconfig.json.
                  /* eslint-disable
                      @typescript-eslint/strict-boolean-expressions */
                  if (option) {
                    /* eslint-enable */
                    closeOnSubmit && this.close(attrs);

                    const mod = e.metaKey || e.ctrlKey;
                    const shift = e.shiftKey;
                    onSubmit(option.key, mod, shift);
                  }
                }
              } else {
                if (e.key === 'Enter') {
                  e.preventDefault();

                  closeOnSubmit && this.close(attrs);

                  const mod = e.metaKey || e.ctrlKey;
                  const shift = e.shiftKey;
                  onSubmit(value, mod, shift);
                }
              }
            },
          }),
          rightContent,
        ),
      },
      options && this.renderDropdown(attrs),
    );
  }

  private renderDropdown(attrs: OmniboxAttrs): m.Children {
    const {options} = attrs;

    if (!options) return null;

    if (options.length === 0) {
      return m(EmptyState, {title: 'No matching options...'});
    } else {
      return m(
        '.pf-omnibox-dropdown',
        this.renderOptionsContainer(attrs, options),
        this.renderFooter(),
      );
    }
  }

  private renderFooter() {
    return m(
      '.pf-omnibox-dropdown-footer',
      m(
        'section',
        m(KeycapGlyph, {keyValue: 'ArrowUp'}),
        m(KeycapGlyph, {keyValue: 'ArrowDown'}),
        'to navigate',
      ),
      m('section', m(KeycapGlyph, {keyValue: 'Enter'}), 'to use'),
      m('section', m(KeycapGlyph, {keyValue: 'Escape'}), 'to dismiss'),
    );
  }

  private renderOptionsContainer(
    attrs: OmniboxAttrs,
    options: OmniboxOption[],
  ): m.Children {
    const {
      onClose = () => {},
      onSubmit = () => {},
      closeOnSubmit = false,
      selectedOptionIndex,
    } = attrs;

    const opts = options.map(({displayName, key, rightContent, tag}, index) => {
      return m(OmniboxOptionRow, {
        key,
        label: tag,
        displayName: displayName,
        highlighted: index === selectedOptionIndex,
        onclick: () => {
          closeOnSubmit && onClose();
          onSubmit(key, false, false);
        },
        rightContent,
      });
    });

    return m('ul.pf-omnibox-options-container', opts);
  }

  oncreate({attrs, dom}: m.VnodeDOM<OmniboxAttrs, this>) {
    this.attrs = attrs;
    this.dom = dom;
    const {closeOnOutsideClick} = attrs;
    if (closeOnOutsideClick) {
      document.addEventListener('mousedown', this.onMouseDown);
    }
  }

  onupdate({attrs, dom}: m.VnodeDOM<OmniboxAttrs, this>) {
    this.attrs = attrs;
    this.dom = dom;
    const {closeOnOutsideClick} = attrs;
    if (closeOnOutsideClick) {
      document.addEventListener('mousedown', this.onMouseDown);
    } else {
      document.removeEventListener('mousedown', this.onMouseDown);
    }
  }

  onremove(_: m.VnodeDOM<OmniboxAttrs, this>) {
    this.attrs = undefined;
    this.dom = undefined;
    document.removeEventListener('mousedown', this.onMouseDown);
  }

  // This is defined as an arrow function to have a single handler that can be
  // added/remove while keeping `this` bound.
  private onMouseDown = (e: Event) => {
    // We need to schedule a redraw manually as this event handler was added
    // manually to the DOM and doesn't use Mithril's auto-redraw system.
    raf.scheduleFullRedraw('force');

    // Don't close if the click was within ourselves or our popup.
    if (e.target instanceof Node) {
      if (this.popupElement && this.popupElement.contains(e.target)) {
        return;
      }
      if (this.dom && this.dom.contains(e.target)) return;
    }
    if (this.attrs) {
      this.close(this.attrs);
    }
  };

  private close(attrs: OmniboxAttrs): void {
    const {onClose = () => {}} = attrs;
    raf.scheduleFullRedraw();
    onClose();
  }

  private highlightPreviousOption(attrs: OmniboxAttrs) {
    const {selectedOptionIndex = 0, onSelectedOptionChanged = () => {}} = attrs;

    onSelectedOptionChanged(Math.max(0, selectedOptionIndex - 1));
  }

  private highlightNextOption(attrs: OmniboxAttrs) {
    const {
      selectedOptionIndex = 0,
      onSelectedOptionChanged = () => {},
      options = [],
    } = attrs;

    const max = options.length - 1;
    onSelectedOptionChanged(Math.min(max, selectedOptionIndex + 1));
  }
}
