// 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|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;

  // 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 = () => {},
      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: classNames(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 === '') {
                    this.close(attrs);
                  } 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, {header: '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);
  }

  private onMouseDown = (e: Event) => {
    // 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));
  }
}
