// 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 {findRef} from '../base/dom_utils';
import {
  FuzzyFinder,
  type FuzzySegment,
  computeHighlightSegments,
} from '../base/fuzzy';
import {assertExists, assertUnreachable} from '../base/assert';
import {isString} from '../base/object_utils';
import {exists} from '../base/utils';
import {AppImpl} from '../core/app_impl';
import {OmniboxMode} from '../core/omnibox_manager';
import {raf} from '../core/raf_scheduler';
import type {TraceImpl} from '../core/trace_impl';
import {Button} from '../widgets/button';
import {Chip} from '../widgets/chip';
import {type HTMLAttrs, Intent} from '../widgets/common';
import {EmptyState} from '../widgets/empty_state';
import {HotkeyGlyphs, KeycapGlyph} from '../widgets/hotkey_glyphs';
import {Popup} from '../widgets/popup';
import {Spinner} from '../widgets/spinner';
import type {Command} from '../public/commands';

const OMNIBOX_INPUT_REF = 'omnibox';
const RECENT_COMMANDS_LIMIT = 6;

// Omnibox attrs - simplified to just what's needed from outside
export interface OmniboxAttrs {
  readonly trace?: TraceImpl;
}

interface CommandWithMatchInfo extends Command {
  readonly segments: FuzzySegment[];
}

// Omnibox: Smart component that contains all omnibox business logic
// and wraps the presentational OmniboxWidget
export class Omnibox implements m.ClassComponent<OmniboxAttrs> {
  private omniboxInputEl?: HTMLInputElement;
  private recentCommands: ReadonlyArray<string> = [];

  view({attrs}: m.Vnode<OmniboxAttrs>): m.Children {
    const {trace} = attrs;
    const app = AppImpl.instance;
    const omnibox = app.omnibox;
    const omniboxMode = omnibox.mode;
    const statusMessage = omnibox.statusMessage;
    if (statusMessage !== undefined) {
      return m(
        `.pf-omnibox.pf-omnibox--message-mode`,
        m(`input[readonly][disabled][ref=omnibox]`, {
          value: '',
          placeholder: statusMessage,
        }),
      );
    } else if (omniboxMode === OmniboxMode.Command) {
      return this.renderCommandOmnibox();
    } else if (omniboxMode === OmniboxMode.Prompt) {
      return this.renderPromptOmnibox();
    } else if (omniboxMode === OmniboxMode.Search) {
      return this.renderSearchOmnibox(trace);
    } else if (omniboxMode === OmniboxMode.RegisteredMode) {
      return this.renderRegisteredMode();
    } else {
      assertUnreachable(omniboxMode);
    }
  }

  private renderPromptOmnibox(): m.Children {
    const omnibox = AppImpl.instance.omnibox;
    const prompt = assertExists(omnibox.pendingPrompt);

    let options: OmniboxOption[] | undefined = undefined;

    if (prompt.options) {
      const fuzzy = new FuzzyFinder(
        prompt.options,
        ({displayName}) => displayName,
      );
      const result = fuzzy.find(omnibox.text);
      options = result.map((result) => {
        return {
          key: result.item.key,
          displayName: result.segments,
        };
      });
    }

    return m(OmniboxWidget, {
      value: omnibox.text,
      placeholder: prompt.text,
      inputRef: OMNIBOX_INPUT_REF,
      className: 'pf-omnibox--prompt-mode',
      closeOnOutsideClick: true,
      options,
      selectedOptionIndex: omnibox.selectionIndex,
      onSelectedOptionChanged: (index) => {
        omnibox.setSelectionIndex(index);
      },
      onInput: (value) => {
        omnibox.setText(value);
        omnibox.setSelectionIndex(0);
      },
      onSubmit: (value, _alt) => {
        omnibox.resolvePrompt(value);
      },
      onClose: () => {
        omnibox.rejectPrompt();
      },
    });
  }

  private fuzzyFilterCommands(searchTerm: string): CommandWithMatchInfo[] {
    const app = AppImpl.instance;
    const allCommands = app.commands.getCommands();
    // Index name + source so users can filter by either. Highlight segments
    // are recomputed per-field at render time so each piece (title chip /
    // source chip) gets its own match highlighting.
    const finder = new FuzzyFinder(allCommands, ({name, source}) =>
      source ? `${source} ${name}` : name,
    );
    return finder.find(searchTerm).map((result) => {
      return {
        segments: computeHighlightSegments(searchTerm, result.item.name),
        ...result.item,
      };
    });
  }

  private renderCommandOmnibox(): m.Children {
    // Fuzzy-filter commands by the filter string.
    const {commands, omnibox} = AppImpl.instance;
    const filteredCmds = this.fuzzyFilterCommands(omnibox.text);

    // Create an array of commands with attached heuristics from the recent
    // command register.
    const commandsWithHeuristics = filteredCmds.map((cmd) => {
      return {
        recentsIndex: this.recentCommands.findIndex((id) => id === cmd.id),
        cmd,
      };
    });

    // Sort recentsIndex first
    const sorted = commandsWithHeuristics.sort((a, b) => {
      if (b.recentsIndex === a.recentsIndex) {
        // If recentsIndex is the same, retain original sort order
        return 0;
      } else {
        return b.recentsIndex - a.recentsIndex;
      }
    });

    const options = sorted.map(({recentsIndex, cmd}): OmniboxOption => {
      const {segments, id, defaultHotkey, source} = cmd;
      return {
        key: id,
        displayName: segments,
        tag: recentsIndex !== -1 ? 'recently used' : undefined,
        source:
          source !== undefined
            ? computeHighlightSegments(omnibox.text, source)
            : undefined,
        rightContent: defaultHotkey && m(HotkeyGlyphs, {hotkey: defaultHotkey}),
      };
    });

    return m(OmniboxWidget, {
      value: omnibox.text,
      placeholder: 'Filter commands...',
      inputRef: OMNIBOX_INPUT_REF,
      className: 'pf-omnibox--command-mode',
      options,
      closeOnSubmit: true,
      closeOnOutsideClick: true,
      selectedOptionIndex: omnibox.selectionIndex,
      onSelectedOptionChanged: (index) => {
        omnibox.setSelectionIndex(index);
      },
      onInput: (value) => {
        omnibox.setText(value);
        omnibox.setSelectionIndex(0);
      },
      onClose: () => {
        if (this.omniboxInputEl) {
          this.omniboxInputEl.blur();
        }
        omnibox.reset();
      },
      onSubmit: (key: string) => {
        this.addRecentCommand(key);
        commands.runCommand(key);
      },
      onGoBack: () => {
        omnibox.reset();
      },
    });
  }

  private addRecentCommand(id: string): void {
    this.recentCommands = this.recentCommands
      .filter((x) => x !== id) // Remove duplicates
      .concat(id) // Add to the end
      .splice(-RECENT_COMMANDS_LIMIT); // Limit items
  }

  private renderRegisteredMode(): m.Children {
    const omnibox = AppImpl.instance.omnibox;
    const desc = assertExists(omnibox.activeRegisteredMode);
    return m(OmniboxWidget, {
      value: omnibox.text,
      placeholder: desc.placeholder,
      inputRef: OMNIBOX_INPUT_REF,
      className: desc.className,
      onInput: (value) => {
        if (desc.onInput) {
          desc.onInput(value);
        } else {
          omnibox.setText(value);
        }
      },
      onSubmit: (value, alt) => {
        desc.onSubmit(value, alt);
      },
      onClose: () => {
        if (desc.onClose) {
          desc.onClose();
        } else {
          omnibox.setText('');
          if (this.omniboxInputEl) {
            this.omniboxInputEl.blur();
          }
          omnibox.reset();
        }
      },
      onGoBack: () => {
        if (desc.onGoBack) {
          desc.onGoBack();
        } else {
          omnibox.reset();
        }
      },
    });
  }

  private renderSearchOmnibox(trace: TraceImpl | undefined): m.Children {
    const omnibox = AppImpl.instance.omnibox;
    const hints = ["'>' for commands"];
    for (const desc of omnibox.registeredModes.values()) {
      if (desc.hint) hints.push(desc.hint);
    }
    return m(OmniboxWidget, {
      value: omnibox.text,
      placeholder: `Search or type ${hints.join(', ')}`,
      inputRef: OMNIBOX_INPUT_REF,
      onInput: (value, _prev) => {
        if (value === '>') {
          omnibox.setMode(OmniboxMode.Command);
          return;
        }
        // Check registered mode triggers.
        if (value.length === 1 && omnibox.registeredModes.has(value)) {
          omnibox.activateRegisteredMode(value);
          return;
        }
        omnibox.setText(value);
        if (trace === undefined) return; // No trace loaded.
        if (value.length >= 4) {
          trace.search.search(value);
        } else {
          trace.search.reset();
        }
      },
      onClose: () => {
        if (this.omniboxInputEl) {
          this.omniboxInputEl.blur();
        }
      },
      onSubmit: (value, _mod, shift) => {
        if (trace === undefined) return; // No trace loaded.
        trace.search.search(value);
        if (shift) {
          trace.search.stepBackwards();
        } else {
          trace.search.stepForward();
        }
        if (this.omniboxInputEl) {
          this.omniboxInputEl.blur();
        }
      },
      rightContent: trace && this.renderStepThrough(trace),
    });
  }

  private renderStepThrough(trace: TraceImpl) {
    const children = [];
    const results = trace.search.searchResults;
    if (trace?.search.searchInProgress) {
      children.push(m('.pf-omnibox__stepthrough-current', m(Spinner)));
    } else if (results !== undefined) {
      const searchMgr = trace.search;
      const index = searchMgr.resultIndex;
      const total = results.totalResults ?? 0;
      children.push(
        m(
          '.pf-omnibox__stepthrough-current',
          `${total === 0 ? '0 / 0' : `${index + 1} / ${total}`}`,
        ),
        m(Button, {
          onclick: () => searchMgr.stepBackwards(),
          icon: 'keyboard_arrow_left',
        }),
        m(Button, {
          onclick: () => searchMgr.stepForward(),
          icon: 'keyboard_arrow_right',
        }),
      );
    }
    return m('.pf-omnibox__stepthrough', children);
  }

  oncreate({dom}: m.VnodeDOM<OmniboxAttrs>) {
    this.updateOmniboxInputRef(dom);
    this.maybeFocusOmnibar();
  }

  onupdate({dom}: m.VnodeDOM<OmniboxAttrs>) {
    this.updateOmniboxInputRef(dom);
    this.maybeFocusOmnibar();
  }

  private updateOmniboxInputRef(dom: Element): void {
    const el = findRef(dom, OMNIBOX_INPUT_REF);
    if (el && el instanceof HTMLInputElement) {
      this.omniboxInputEl = el;
    }
  }

  private maybeFocusOmnibar() {
    if (AppImpl.instance.omnibox.focusOmniboxNextRender) {
      const omniboxEl = this.omniboxInputEl;
      if (omniboxEl) {
        omniboxEl.focus();
        if (AppImpl.instance.omnibox.pendingCursorPlacement === undefined) {
          omniboxEl.select();
        } else {
          omniboxEl.setSelectionRange(
            AppImpl.instance.omnibox.pendingCursorPlacement,
            AppImpl.instance.omnibox.pendingCursorPlacement,
          );
        }
      }
      AppImpl.instance.omnibox.clearFocusFlag();
    }
  }
}

interface OmniboxOptionRowAttrs extends HTMLAttrs {
  // 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.
  readonly displayName: FuzzySegment[] | string;

  // Highlight this option.
  readonly highlighted: boolean;

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

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

  // Source label to show as a left-side chip (e.g. extension module name).
  // Either a plain string or fuzzy segments to enable match highlighting.
  readonly source?: FuzzySegment[] | string;
}

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

  view({attrs}: m.Vnode<OmniboxOptionRowAttrs>): void | m.Children {
    const {
      displayName,
      highlighted,
      rightContent,
      label,
      source,
      ...htmlAttrs
    } = attrs;
    return m(
      'li',
      {
        class: classNames(highlighted && 'pf-highlighted'),
        ...htmlAttrs,
      },
      exists(source) &&
        m(Chip, {
          className: 'pf-omnibox__source',
          label: this.renderSegments(source),
          rounded: true,
          compact: true,
          intent: Intent.Primary,
        }),
      m('span.pf-title', this.renderSegments(displayName)),
      label && m(Chip, {className: 'pf-omnibox__tag', label, rounded: true}),
      rightContent,
    );
  }

  private renderSegments(text: FuzzySegment[] | string): m.Children {
    if (isString(text)) {
      return text;
    } else {
      return text.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.
interface OmniboxOption {
  // The value to place into the omnibox. This is what's returned in onSubmit.
  readonly key: string;

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

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

  // Source label to show as a left-side chip (e.g. extension module name).
  // Either a plain string or fuzzy segments to enable match highlighting.
  readonly source?: FuzzySegment[] | string;

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

interface OmniboxWidgetAttrs extends HTMLAttrs {
  // Current value of the omnibox input.
  readonly value: string;

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

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

  // Called on close.
  readonly 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.
  readonly options?: OmniboxOption[];

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

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

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

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

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

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

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

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

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

class OmniboxWidget implements m.ClassComponent<OmniboxWidgetAttrs> {
  private popupElement?: HTMLElement;
  private dom?: Element;
  private attrs?: OmniboxWidgetAttrs;

  view({attrs}: m.Vnode<OmniboxWidgetAttrs>): m.Children {
    const {
      value,
      placeholder,
      onInput = () => {},
      onSubmit = () => {},
      onGoBack = () => {},
      inputRef = 'omnibox',
      options,
      closeOnSubmit = false,
      rightContent,
      selectedOptionIndex = 0,
      ...htmlAttrs
    } = 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(
          '.pf-omnibox',
          htmlAttrs,
          m('input', {
            spellcheck: false,
            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: OmniboxWidgetAttrs): 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: OmniboxWidgetAttrs,
    options: OmniboxOption[],
  ): m.Children {
    const {
      onClose = () => {},
      onSubmit = () => {},
      closeOnSubmit = false,
      selectedOptionIndex,
    } = attrs;

    const opts = options.map(
      ({displayName, key, rightContent, tag, source}, index) => {
        return m(OmniboxOptionRow, {
          key,
          label: tag,
          source,
          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<OmniboxWidgetAttrs>) {
    this.attrs = attrs;
    this.dom = dom;
    const {closeOnOutsideClick} = attrs;
    if (closeOnOutsideClick) {
      document.addEventListener('mousedown', this.onMouseDown);
    }
  }

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

  onremove() {
    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();

    // 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: OmniboxWidgetAttrs): void {
    const {onClose = () => {}} = attrs;
    onClose();
  }

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

    if (options.length === 0) return;

    onSelectedOptionChanged(
      (selectedOptionIndex - 1 + options.length) % options.length,
    );
  }

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

    if (options.length === 0) return;

    onSelectedOptionChanged((selectedOptionIndex + 1) % options.length);
  }
}
